From f022af7cbb3dfa9e7ee9dbc2e35edf509792a3c6 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 09:46:04 -0400 Subject: [PATCH 01/14] Run the review as a continuation loop on the kit's untilDone. A turn is codex's own tool loop until its final message; one that ends without review_verdict gets a continuation prompt on the same thread, up to MAX_TURNS (default 6), then the attempt fails and retries later. The hand-written second pass moves into the verdict tool: its first call is refused with that text, so the ownership challenge lands the moment the model tries to finish, inside the same turn, and can't be skipped. No fixed second turn, no turns array. Needs @bevyl-ai/agent-tools 0.14.0 (bevyl-ai/agent-tools#1) published before the lockfile can pin it. --- DEPLOY.md | 1 + package.json | 2 +- src/sweep/codex.ts | 43 ++++++++++++++++++++++++++----------------- src/sweep/config.ts | 2 ++ src/sweep/prompt.ts | 2 +- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index fe6d244..8b2ca3c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -41,6 +41,7 @@ Every knob is a line in `~/.stupify/config.env`, read fresh each sweep. A one-sh | `SCOPE` | `auto` | `auto` reviews every non-draft, non-bot PR; `label` only labelled ones | | `REVIEW_LABEL` | `codex-review` | force-include label: oversized diffs and bot PRs opt in with it | | `DIFF_LINE_CAP` | `20000` | skip bigger diffs unless labelled | +| `MAX_TURNS` | `6` | codex turns per review; past it the attempt fails and retries later | | `MAX_PRS` | `15` | reviews per sweep, counted after dedup skips | | `MAX_REVIEWS_PER_DAY` | `0` (off) | hard daily ceiling | | `FAIL_RETRY_MIN` | `60` | wait before retrying a head whose review failed | diff --git a/package.json b/package.json index 35ce19c..1f0a5da 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run build" }, "dependencies": { - "@bevyl-ai/agent-tools": "0.13.0", + "@bevyl-ai/agent-tools": "0.14.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index a77d307..ead8c8b 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -8,6 +8,7 @@ import { maybeRotateGateway, scrubSecrets, tool, + untilDone, } from '@bevyl-ai/agent-tools' import { SECOND_PASS_PROMPT } from '../hand-written-prompts' @@ -42,18 +43,21 @@ const nearest = (lines: Set, line: number): string => .join(', ') // What the schema can't say is thrown here so the MODEL corrects it, instead of the runner demoting the finding -// after the fact: the verdict is only accepted once the second pass is running (a first-turn call would skip -// the ownership challenge), an anchor must be a right-side line this diff touches (the only lines GitHub -// threads on), and a convergence verdict carries no findings (parseReview). -const verdictTool = (diff: string, secondPass: () => boolean, submit: (verdict: ReviewVerdict) => void) => { +// after the fact: the FIRST call is refused with the hand-written second pass (the ownership challenge lands +// the moment the model tries to finish, inside the same turn, and can't be skipped), an anchor must be a +// right-side line this diff touches (the only lines GitHub threads on), and a convergence verdict carries no +// findings (parseReview). +const verdictTool = (diff: string, submit: (verdict: ReviewVerdict) => void) => { const valid = diffRightLines(diff) + let challenged = false return tool( 'review_verdict', - 'Submit the review verdict. Call once, after the second pass.', + 'Finish the review with your verdict. The first call answers with a second pass to do before it accepts.', ReviewOutput, (data) => { - if (!secondPass()) { - throw new Error('not yet: finish the review, do the second pass when asked, then call review_verdict') + if (!challenged) { + challenged = true + throw new Error(`not yet. ${SECOND_PASS_PROMPT} Then call review_verdict again.`) } for (const f of data.findings) { const lines = valid.get(f.path) @@ -81,7 +85,6 @@ export async function runReview( workDir?: string, ): Promise { const got: { verdict: ReviewVerdict | null } = { verdict: null } - const turns = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] const session = new AppServerSession( { cwd: workDir ?? cfg.repoDir, @@ -95,13 +98,9 @@ export async function runReview( turnTimeoutMs: TURN_TIMEOUT_MS, }, [ - verdictTool( - diff, - () => turns.length === 0, // both prompts handed out → the second pass is the running turn - (verdict) => { - got.verdict = verdict - }, - ), + verdictTool(diff, (verdict) => { + got.verdict = verdict + }), ], (event) => { if (event.log) { @@ -126,7 +125,17 @@ export async function runReview( }, ) try { - await session.runTurns(() => turns.shift() ?? null) + // A turn is codex's own tool loop until its final message; one that ends without a verdict gets a + // continuation on the same thread, up to MAX_TURNS. + await session.runTurns( + untilDone({ + prompt: reviewPrompt(cfg, pr, priorThread, diff), + done: () => got.verdict !== null, + maxTurns: cfg.maxTurns, + continuation: (turn, max) => + `Continuation, turn ${turn} of ${max}, same thread. Resume from where you left off; finish by calling review_verdict.`, + }), + ) } catch (error) { // The kit spawns codex in start() before runTurns' own try/finally, so a failed handshake would leave the // child alive under a minute cron. Delete this once the kit's start() stops the process it spawned on failure. @@ -135,5 +144,5 @@ export async function runReview( logRaw(`${raw}\n`) return callFailed(raw) } - return got.verdict ?? { kind: 'fail', reason: 'codex finished without calling review_verdict' } + return got.verdict ?? { kind: 'fail', reason: `no verdict after ${cfg.maxTurns} turns` } } diff --git a/src/sweep/config.ts b/src/sweep/config.ts index 4ad02f0..a9b0e46 100644 --- a/src/sweep/config.ts +++ b/src/sweep/config.ts @@ -26,6 +26,7 @@ export const Config = z.object({ diffLineCap: z.number(), dryRun: z.boolean(), maxPrs: z.number(), + maxTurns: z.number(), // codex turns per review before it's a failed attempt maxReviewsPerDay: z.number(), failRetryMs: z.number(), stateDir: z.string(), @@ -115,6 +116,7 @@ export function loadConfig(): Config { diffLineCap: int('DIFF_LINE_CAP', 20_000, 1), // generous by design — only skips genuinely huge PRs; override via config.env dryRun: bool('DRY_RUN', false, true), // unset = live (cron's normal mode); garbage = preview (never post on a typo) maxPrs: int('MAX_PRS', 15, 1), + maxTurns: int('MAX_TURNS', 6, 1), maxReviewsPerDay: int('MAX_REVIEWS_PER_DAY', 0, 0), // daily cap; 0 = OFF (default). Per-head dedup + MAX_PRS/sweep + the rate-limit early-exit already bound spend; set a number for a hard daily ceiling. failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, // after a failed review, don't re-attempt that head for this long stateDir, diff --git a/src/sweep/prompt.ts b/src/sweep/prompt.ts index dd4590d..adac210 100644 --- a/src/sweep/prompt.ts +++ b/src/sweep/prompt.ts @@ -35,7 +35,7 @@ ${corpus} # This PR Review this pull request against the spec and rubric. - Catch bugs, type-lies, dead code, footguns, and slop. Reuse corpus primitives; don't add LOC. -- Submit the verdict by calling \`review_verdict\` once, after the second pass. Your text is not read. +- Finish by calling \`review_verdict\`; its first call asks for one second pass before it accepts. Your text is not read. - \`fixed\`: prior issues resolved, nothing new (runner posts \`${FIXED_NOTE}\`). - \`no_new_issues\`: clean, or prior issues still open (runner posts \`${STILL_NOTE}\` if clean). - \`findings\`: exact path/line for each inline comment, on a line this diff touches.${intent}${memory} From eb0ef514c6309bbe4841b75b7dce54aeb90d991c Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 09:49:21 -0400 Subject: [PATCH 02/14] The second pass is a turn, not a tool refusal. Turn 2 of the loop is always the hand-written second pass; the verdict tool accepts whenever and the last call wins. No hidden challenged flag, no tool that says 'not yet' to smuggle a prompt. --- src/sweep/codex.ts | 29 ++++++++++++++--------------- src/sweep/prompt.ts | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index ead8c8b..0b3784c 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -43,22 +43,15 @@ const nearest = (lines: Set, line: number): string => .join(', ') // What the schema can't say is thrown here so the MODEL corrects it, instead of the runner demoting the finding -// after the fact: the FIRST call is refused with the hand-written second pass (the ownership challenge lands -// the moment the model tries to finish, inside the same turn, and can't be skipped), an anchor must be a -// right-side line this diff touches (the only lines GitHub threads on), and a convergence verdict carries no -// findings (parseReview). +// after the fact: an anchor must be a right-side line this diff touches (the only lines GitHub threads on), and +// a convergence verdict carries no findings (parseReview). The last accepted call wins. const verdictTool = (diff: string, submit: (verdict: ReviewVerdict) => void) => { const valid = diffRightLines(diff) - let challenged = false return tool( 'review_verdict', - 'Finish the review with your verdict. The first call answers with a second pass to do before it accepts.', + 'Submit your verdict. You may call it again to revise; the last call wins.', ReviewOutput, (data) => { - if (!challenged) { - challenged = true - throw new Error(`not yet. ${SECOND_PASS_PROMPT} Then call review_verdict again.`) - } for (const f of data.findings) { const lines = valid.get(f.path) if (lines === undefined) { @@ -125,15 +118,21 @@ export async function runReview( }, ) try { - // A turn is codex's own tool loop until its final message; one that ends without a verdict gets a - // continuation on the same thread, up to MAX_TURNS. + // Turn 1 reviews, turn 2 is always the hand-written second pass, and a turn that still ends without a + // verdict gets a continuation on the same thread, up to MAX_TURNS. + let secondPass = false await session.runTurns( untilDone({ prompt: reviewPrompt(cfg, pr, priorThread, diff), - done: () => got.verdict !== null, + done: () => secondPass && got.verdict !== null, maxTurns: cfg.maxTurns, - continuation: (turn, max) => - `Continuation, turn ${turn} of ${max}, same thread. Resume from where you left off; finish by calling review_verdict.`, + continuation: (turn, max) => { + if (turn === 2) { + secondPass = true + return SECOND_PASS_PROMPT + } + return `Continuation, turn ${turn} of ${max}, same thread. Resume from where you left off; finish by calling review_verdict.` + }, }), ) } catch (error) { diff --git a/src/sweep/prompt.ts b/src/sweep/prompt.ts index adac210..7fccec1 100644 --- a/src/sweep/prompt.ts +++ b/src/sweep/prompt.ts @@ -35,7 +35,7 @@ ${corpus} # This PR Review this pull request against the spec and rubric. - Catch bugs, type-lies, dead code, footguns, and slop. Reuse corpus primitives; don't add LOC. -- Finish by calling \`review_verdict\`; its first call asks for one second pass before it accepts. Your text is not read. +- Submit the verdict by calling \`review_verdict\`; a second pass follows, and you may call it again to revise. Your text is not read. - \`fixed\`: prior issues resolved, nothing new (runner posts \`${FIXED_NOTE}\`). - \`no_new_issues\`: clean, or prior issues still open (runner posts \`${STILL_NOTE}\` if clean). - \`findings\`: exact path/line for each inline comment, on a line this diff touches.${intent}${memory} From 9157b3f29653406824613f432d30f78bcee6cfeb Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 09:53:48 -0400 Subject: [PATCH 03/14] Delete every comment; one owner for sweep state; fold the small helpers. state.ts is one factory over three JSON files with the five verbs the sweep needs, replacing four load/record pairs and the path helpers. collectCandidates reads a PR's prior reviews itself instead of taking a map the entry built for it. review-pr's three post-or-retry branches are one note(). listPrs, parseReview, and worktree lose throwaway helpers. src: 1,244 -> 986 lines. --- src/review-sweep.ts | 23 +++------ src/sweep/codex.ts | 26 ++-------- src/sweep/config.ts | 41 ++++++--------- src/sweep/diff.ts | 19 +------ src/sweep/github.ts | 30 ++--------- src/sweep/pool.ts | 19 ++----- src/sweep/prompt.ts | 3 -- src/sweep/prs.ts | 54 ++++++-------------- src/sweep/review-pr.ts | 83 +++++++++--------------------- src/sweep/state.ts | 112 +++++++++++++++-------------------------- src/sweep/sweep.ts | 68 ++++--------------------- src/sweep/verdict.ts | 45 +++-------------- src/sweep/worktree.ts | 5 +- 13 files changed, 135 insertions(+), 393 deletions(-) diff --git a/src/review-sweep.ts b/src/review-sweep.ts index 3812fbf..d5f5ab0 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -1,19 +1,15 @@ #!/usr/bin/env bun -// stupify — one review sweep, top to bottom. A cron runs this file every minute on the reviewer box, with -// config.env beside it (DEPLOY.md). Every non-draft, non-bot open PR under DIFF_LINE_CAP is reviewed against the -// target repo's .review/ (REVIEW-PROMPT.md + RUBRIC.md + CORPUS.md), once per head: a posted review carries a -// hidden `` marker, and a push moves the sha. Each review is fed the PR's existing review -// thread, so it converges instead of repeating. + import { join } from 'node:path' import { acquireLock, releaseLock } from '@bevyl-ai/agent-tools' import { loadConfig, log, refreshRepo } from './sweep/config' -import { type PriorState, prReviews } from './sweep/github' import { runCandidatePool } from './sweep/pool' import { hasMachinery } from './sweep/prompt' import { inScope, listPrs } from './sweep/prs' -import { collectCandidates, loadSweepState } from './sweep/sweep' +import { sweepState } from './sweep/state' +import { collectCandidates } from './sweep/sweep' const cfg = loadConfig() const lockPath = join(cfg.stateDir, 'sweep.lock') @@ -22,14 +18,13 @@ if (!acquireLock(lockPath)) { process.exit(0) } process.on('exit', () => { - releaseLock(lockPath) // only if still ours: a later sweep that judged us crashed and stole it now owns it + releaseLock(lockPath) }) if (!refreshRepo(cfg)) { process.exit(1) } -// The target repo's own .review/ wins; otherwise the global taste under STUPIFY_HOME/.review. Select on the full -// three-file set, so a partial repo .review/ falls back instead of dead-ending at "no machinery". + const repoReview = join(cfg.repoDir, cfg.reviewDir) cfg.reviewDir = hasMachinery(repoReview) ? repoReview : cfg.homeReviewDir if (!hasMachinery(cfg.reviewDir)) { @@ -44,11 +39,7 @@ if (prs === null) { process.exit(1) } const queue = prs.filter((pr) => inScope(pr, cfg)) -const state = loadSweepState(cfg) -const priorByPr = new Map() -for (const pr of queue) { - priorByPr.set(pr.number, prReviews(cfg, pr)) -} -const candidates = collectCandidates(cfg, queue, priorByPr, state) +const state = sweepState(cfg) +const candidates = collectCandidates(cfg, queue, state) const reviewed = await runCandidatePool(cfg, candidates, state) log(`sweep done — scope=${cfg.scope} reviewed=${reviewed}`) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index 0b3784c..f41f46d 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -1,6 +1,3 @@ -// Running Codex over one PR's diff through the kit's app-server session, and classifying the result. The verdict -// is a `review_verdict` TOOL CALL the kit validates against ReviewOutput mid-turn (a bad shape goes back to the -// model as the tool error); the model's text is never read. import { AppServerSession, isQuotaWall, @@ -18,17 +15,13 @@ import { reviewPrompt } from './prompt' import { type Pr } from './prs' import { parseReview, ReviewOutput, type ReviewVerdict } from './verdict' -/** The outcome of running Codex over one PR — classified but NOT acted on; review-pr.ts posts/converges from it. */ -export type ReviewOutcome = - | { kind: 'limit'; reason: string } // plan/credit exhaustion — the caller launches no more reviews this sweep - | { kind: 'fail'; reason: string } // Codex couldn't produce a review (down, timeout, stalled, never submitted) - | ReviewVerdict +export type ReviewOutcome = { kind: 'limit'; reason: string } | { kind: 'fail'; reason: string } | ReviewVerdict const TURN_TIMEOUT_MS = 1_200_000 function callFailed(raw: string): ReviewOutcome { const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' - // isQuotaWall covers a 502 'ChatGPT account unavailable' (dead login) — the pool must walk past it too. + if (isRateLimited(raw) || isQuotaWall(raw)) { return { kind: 'limit', reason } } @@ -42,9 +35,6 @@ const nearest = (lines: Set, line: number): string => .toSorted((a, b) => a - b) .join(', ') -// What the schema can't say is thrown here so the MODEL corrects it, instead of the runner demoting the finding -// after the fact: an anchor must be a right-side line this diff touches (the only lines GitHub threads on), and -// a convergence verdict carries no findings (parseReview). The last accepted call wins. const verdictTool = (diff: string, submit: (verdict: ReviewVerdict) => void) => { const valid = diffRightLines(diff) return tool( @@ -69,7 +59,6 @@ const verdictTool = (diff: string, submit: (verdict: ReviewVerdict) => void) => ) } -/** Run Codex over one PR's diff and classify the result. Does NO gh I/O and NO posting — the caller owns those. */ export async function runReview( cfg: Config, pr: Pr, @@ -84,8 +73,7 @@ export async function runReview( title: `#${pr.number}`, model: cfg.codexModel || undefined, effort: cfg.codexEffort, - // A reviewer reads. The per-TURN policy is what codex enforces; the kit's turn default is full access, so - // the thread-level string alone would leave both attacker-controlled turns able to write and reach the network. + threadSandbox: 'read-only', turnSandboxPolicy: { type: 'readOnly' }, turnTimeoutMs: TURN_TIMEOUT_MS, @@ -102,9 +90,7 @@ export async function runReview( }, { scrubEnv: scrubSecrets, - // Self-heal a quota wall: advance ~/.codex/config.toml to the next CODEX_GATEWAY_POOL account (the ring - // bunion and earshot rotate on too). Codex re-reads the file per session, so the next review lands on it. - // The kit walks the ring only on a real wall, never a transient 429. + onTurnError: (error) => { const rot = maybeRotateGateway({ reason: String(error), @@ -118,8 +104,6 @@ export async function runReview( }, ) try { - // Turn 1 reviews, turn 2 is always the hand-written second pass, and a turn that still ends without a - // verdict gets a continuation on the same thread, up to MAX_TURNS. let secondPass = false await session.runTurns( untilDone({ @@ -136,8 +120,6 @@ export async function runReview( }), ) } catch (error) { - // The kit spawns codex in start() before runTurns' own try/finally, so a failed handshake would leave the - // child alive under a minute cron. Delete this once the kit's start() stops the process it spawned on failure. session.stop() const raw = error instanceof Error ? error.message : String(error) logRaw(`${raw}\n`) diff --git a/src/sweep/config.ts b/src/sweep/config.ts index a9b0e46..582aa1b 100644 --- a/src/sweep/config.ts +++ b/src/sweep/config.ts @@ -1,6 +1,3 @@ -// Sweep configuration: every knob lives in config.env next to the engine bundle (read fresh each run), and a -// one-shot env override wins over the file. Also owns the sweep log, set up before knob parsing so config -// warnings reach sweep.log, not just cron.log. import { appendFileSync, existsSync, mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -8,31 +5,29 @@ import { fileURLToPath } from 'node:url' import { parseEnvFile, refreshCheckout } from '@bevyl-ai/agent-tools' import { z } from 'zod' -// The deployed engine is a single-file bun bundle, so import.meta.url collapses to the bundle's own location -// (~/.stupify) no matter which source module evaluates it — config.env sits next to the bundle. const KIT_DIR = dirname(fileURLToPath(import.meta.url)) export const Scope = z.enum(['label', 'auto']) export type Scope = z.infer export const Config = z.object({ - repoDir: z.string(), // dedicated checkout we hard-reset — never a working checkout you care about + repoDir: z.string(), slug: z.string(), defaultBranch: z.string(), - reviewDir: z.string(), // resolved later to an absolute path (repo .review/ or homeReviewDir) - homeReviewDir: z.string(), // fallback global taste under STUPIFY_HOME/.review + reviewDir: z.string(), + homeReviewDir: z.string(), scope: Scope, reviewLabel: z.string(), diffLineCap: z.number(), dryRun: z.boolean(), maxPrs: z.number(), - maxTurns: z.number(), // codex turns per review before it's a failed attempt + maxTurns: z.number(), maxReviewsPerDay: z.number(), failRetryMs: z.number(), stateDir: z.string(), codexEffort: z.string(), - codexModel: z.string(), // optional `-c model=...`; empty = codex's default - gatewayPool: z.array(z.string()), // ordered exe-llm gateway hosts to rotate through on a quota wall; empty = off + codexModel: z.string(), + gatewayPool: z.array(z.string()), rotateCooldownMs: z.number(), codexJobs: z.number(), }) @@ -48,7 +43,6 @@ export function log(message: string): void { console.log(line) } -/** Append raw text (codex transcripts, gh error excerpts) to the sweep log WITHOUT a timestamp or stdout echo. */ export function logRaw(text: string): void { if (LOG.path) { appendFileSync(LOG.path, text) @@ -57,8 +51,7 @@ export function logRaw(text: string): void { export function loadConfig(): Config { const file = parseEnvFile(join(KIT_DIR, 'config.env')) - // A one-shot env override wins over the persisted config.env, so `DRY_RUN=1 bun review-sweep.ts` actually - // previews even when the deployed file says DRY_RUN=0. Cron sets none of these keys, so it falls to the file. + const pick = (key: string, fallback: string): string => process.env[key] ?? file[key] ?? fallback const int = (key: string, fallback: number, min: number): number => { const set = process.env[key] ?? file[key] @@ -89,11 +82,10 @@ export function loadConfig(): Config { return onInvalid } - // Home is where deploy/push.sh put us (~/.stupify) — config.env, state, and the dedicated checkout all live here. const stupifyHome = pick('STUPIFY_HOME', KIT_DIR) const stateDir = join(stupifyHome, 'state') mkdirSync(stateDir, { recursive: true }) - LOG.path = join(stateDir, 'sweep.log') // set before parsing knobs so config warnings reach sweep.log, not just cron.log + LOG.path = join(stateDir, 'sweep.log') const slug = pick('REPO_SLUG', '').trim() if (!slug) { @@ -106,19 +98,19 @@ export function loadConfig(): Config { } return Config.parse({ - repoDir: join(stupifyHome, 'repo'), // HARD-PINNED under STUPIFY_HOME: refreshRepo runs `git reset --hard` here + repoDir: join(stupifyHome, 'repo'), slug, defaultBranch: pick('DEFAULT_BRANCH', 'main'), - reviewDir: pick('REVIEW_DIR', '.review'), // relative name here; main() resolves it to an absolute path (repo's or home's) + reviewDir: pick('REVIEW_DIR', '.review'), homeReviewDir: join(stupifyHome, '.review'), - scope: scopeRaw === 'label' ? 'label' : 'auto', // auto is the default; only the explicit string 'label' opts into per-PR tagging + scope: scopeRaw === 'label' ? 'label' : 'auto', reviewLabel: pick('REVIEW_LABEL', 'codex-review'), - diffLineCap: int('DIFF_LINE_CAP', 20_000, 1), // generous by design — only skips genuinely huge PRs; override via config.env - dryRun: bool('DRY_RUN', false, true), // unset = live (cron's normal mode); garbage = preview (never post on a typo) + diffLineCap: int('DIFF_LINE_CAP', 20_000, 1), + dryRun: bool('DRY_RUN', false, true), maxPrs: int('MAX_PRS', 15, 1), maxTurns: int('MAX_TURNS', 6, 1), - maxReviewsPerDay: int('MAX_REVIEWS_PER_DAY', 0, 0), // daily cap; 0 = OFF (default). Per-head dedup + MAX_PRS/sweep + the rate-limit early-exit already bound spend; set a number for a hard daily ceiling. - failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, // after a failed review, don't re-attempt that head for this long + maxReviewsPerDay: int('MAX_REVIEWS_PER_DAY', 0, 0), + failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, stateDir, codexEffort: pick('CODEX_EFFORT', 'high'), codexModel: pick('CODEX_MODEL', ''), @@ -127,7 +119,7 @@ export function loadConfig(): Config { .map((h) => h.trim()) .filter(Boolean), rotateCooldownMs: int('CODEX_ROTATE_COOLDOWN_MIN', 10, 0) * 60_000, - codexJobs: int('CODEX_JOBS', 3, 1), // a review session takes minutes; a small pool keeps a busy sweep from serializing them + codexJobs: int('CODEX_JOBS', 3, 1), }) } @@ -136,7 +128,6 @@ function logFail(message: string): false { return false } -/** Refresh the dedicated checkout to origin/main. Returns false on any git failure. */ export function refreshRepo(cfg: Config): boolean { const existed = existsSync(join(cfg.repoDir, '.git')) const ok = refreshCheckout({ repoDir: cfg.repoDir, slug: cfg.slug, defaultBranch: cfg.defaultBranch, log }) diff --git a/src/sweep/diff.ts b/src/sweep/diff.ts index 9935842..c0d3cf9 100644 --- a/src/sweep/diff.ts +++ b/src/sweep/diff.ts @@ -1,26 +1,15 @@ -// Fetching and measuring PR diffs. The RUNNER fetches the diff (not codex) so codex needs no network or gh — -// it reviews the diff straight from the prompt, sandboxed. Stacked PRs diff base..head via the compare API, not main. import { exec } from '@bevyl-ai/agent-tools' import { type Config } from './config' import { type Pr } from './prs' -// GitHub's diff endpoint 406s past EITHER of these, so a big enough PR can't even be MEASURED. They are GitHub's -// limits, not ours — DIFF_LINE_CAP can be set above the line one but never reached. export const GH_DIFF_LIMITS = '20000-line / 300-file' -/** A compare/diff failure that is GitHub's size refusal rather than a transient error — retrying can never fix it. - * Matches on gh's stable `too_large` code first: the prose differs per limit (lines vs files) and can be reworded, - * but both variants carry the code. Missing one variant is what kept #8338/#8241 looping after the first fix. */ export const isDiffTooLarge = (output: string): boolean => /PullRequest\.diff too_large|diff exceeded the maximum number of (?:lines|files)/i.test(output) -// Never treat a failed read as "0 lines" (a silent under-cap that would auto-review something it never -// measured) — and keep the two failure modes apart: 'unreadable' is transient and worth retrying, but -// 'too-large' is terminal, and conflating them is what left oversized PRs re-fetched every 60s forever. type DiffRead = { ok: true; diff: string } | { ok: false; reason: 'unreadable' | 'too-large' } -/** Diff the PR's head against its base (not defaultBranch) — correct for stacked PRs. */ export function getDiff(cfg: Config, pr: Pick): DiffRead { const r = exec('gh', [ 'api', @@ -37,17 +26,13 @@ export function getDiff(cfg: Config, pr: Pick): export const diffLineCount = (diff: string): number => diff ? diff.split('\n').length - (diff.endsWith('\n') ? 1 : 0) : 0 -// Which RIGHT-side (new-file) line numbers a unified diff actually touches, per path — the only lines GitHub lets -// you anchor an inline review comment to. Added (`+`) and context (` `) lines are anchorable; removed (`-`) lines -// are LEFT-only and don't advance the right counter. A finding on a line NOT in here can't be a thread, so the -// runner demotes it into the review body instead of 422-ing the whole review. export function diffRightLines(diff: string): Map> { const byPath = new Map>() const cur = { path: '', right: 0, inHunk: false } for (const line of diff.split('\n')) { if (line.startsWith('+++ ')) { const p = line.slice(4).trim() - cur.path = p.startsWith('b/') ? p.slice(2) : p // b/, or /dev/null for a deletion (no right lines) + cur.path = p.startsWith('b/') ? p.slice(2) : p if (!byPath.has(cur.path)) { byPath.set(cur.path, new Set()) } @@ -65,7 +50,7 @@ export function diffRightLines(diff: string): Map> { } if (line.startsWith('-') || line.startsWith('\\')) { continue - } // left-only line / "no newline" marker — right doesn't advance + } if (line.startsWith('+') || line.startsWith(' ')) { byPath.get(cur.path)?.add(cur.right) cur.right++ diff --git a/src/sweep/github.ts b/src/sweep/github.ts index 68794e8..a999998 100644 --- a/src/sweep/github.ts +++ b/src/sweep/github.ts @@ -1,5 +1,3 @@ -// Posting to GitHub and reading back what stupify has already said. Findings land as ONE COMMENT review with -// inline threads; the reviews/threads connection drives dedup, thread-resolution, and the reviewer's memory. import { exec } from '@bevyl-ai/agent-tools' import { z } from 'zod' @@ -7,16 +5,10 @@ import { type Config, logRaw } from './config' import { type Comment, type Pr, priorReviewThread } from './prs' import { markFor, type ParsedFinding } from './verdict' -// A hidden tag stamped in every inline finding comment, so a later sweep can find stupify's OWN review threads -// (to resolve them) without knowing the bot login — `gh api user` 403s for GitHub-App integrations, so we identify -// our content by marker, not author (same trick as the head marker). const STUPIFY_TAG = '' -// Non-blocking findings carry a tag that does NOT contain STUPIFY_TAG as a substring, so they never -// land in openThreadIds and don't hold the ✅. const STUPIFY_NOTE_TAG = '' -// One non-blocking COMMENT review: `comments` are inline, each anchored to a diff line (a resolvable thread). function submitReview( cfg: Config, pr: Pr, @@ -29,9 +21,6 @@ function submitReview( }) } -// Post findings as ONE COMMENT review: each finding becomes an inline comment anchored to its diff line (a -// resolvable thread); the body carries the opener + the head marker (dedup). Anchors were checked against the -// diff when the verdict was submitted (codex.ts), so every finding goes inline. export function postReview(cfg: Config, pr: Pr, opener: string, findings: ParsedFinding[]): boolean { const inline = findings.map((f) => ({ path: f.path, @@ -44,22 +33,16 @@ export function postReview(cfg: Config, pr: Pr, opener: string, findings: Parsed if (r.ok) { return true } - // GitHub rejects the WHOLE review if any single inline anchor is a line it won't accept (a diff edge - // diffRightLines didn't catch). Don't lose the findings to one bad line: retry body-only so they still land - // (visible, just not inline) instead of failing — and re-failing — every sweep. + logRaw(` postReview #${pr.number} inline rejected, body-only fallback: ${r.combined.slice(0, 200)}\n`) return submitReview(cfg, pr, [head, ...findings.map((f) => f.body), markFor(pr)].filter(Boolean).join('\n\n'), []).ok } -// A bodied-only COMMENT review (no inline comments) — for the one-time `LGTM ✅` on a clean first pass, or to carry -// a review codex wrote without parseable per-line findings. Body still ends with the head marker for dedup. export function postNote(cfg: Config, pr: Pr, note: string): boolean { return submitReview(cfg, pr, `${note}\n\n${markFor(pr)}`, []).ok } -// Resolve stupify's open threads when its findings are fixed — the native "this is handled" signal. export function resolveThreads(threadIds: string[]): boolean { - // Resolve every thread even if one fails — a partial resolve still leaves work for the next sweep. return threadIds .map( (id) => @@ -73,14 +56,11 @@ export function resolveThreads(threadIds: string[]): boolean { .every(Boolean) } -// What stupify has already said on a PR — read from the REVIEWS/THREADS connection (findings are inline threads now, -// not issue comments). Drives dedup (a review body carries the head marker), firstReview, thread-resolution, and the -// memory fed back to codex. gh's GraphQL shape is trusted; navigate leniently and default on anything missing. export interface PriorState { - memory: string // prior findings + the author's replies, fenced for codex (priorReviewThread output) - reviewedHead: boolean // a stupify review for THIS head exists — durable dedup, survives VM recreation - everReviewed: boolean // stupify has reviewed this PR at all → firstReview = !everReviewed - openThreadIds: string[] // stupify's UNRESOLVED threads — resolve these when the findings are fixed + memory: string + reviewedHead: boolean + everReviewed: boolean + openThreadIds: string[] } const GqlAuthor = z.object({ login: z.string().optional() }).nullable() const GqlComment = z.object({ diff --git a/src/sweep/pool.ts b/src/sweep/pool.ts index 86879e2..27cf2fd 100644 --- a/src/sweep/pool.ts +++ b/src/sweep/pool.ts @@ -1,12 +1,8 @@ -// The review pool: up to CODEX_JOBS candidates in flight at once. Workers share a cursor; a quota `limit` from -// any worker stops NEW launches (the rest would fail the same way) while in-flight runs drain. All the -// shared-state mutation happens between awaits on the one JS thread, so it needs no locks. import { type Config, log } from './config' import { reviewPr } from './review-pr' -import { bumpDailyCounter, dailyPath, failuresPath, recordHeadAttempt, recordReviewedHead, reviewedPath } from './state' -import { type Candidate, type SweepState } from './sweep' +import { type SweepState } from './state' +import { type Candidate } from './sweep' -/** Review the candidates; returns how many reviews were posted. */ export async function runCandidatePool(cfg: Config, candidates: Candidate[], state: SweepState): Promise { let reviewed = 0 let next = 0 @@ -18,10 +14,9 @@ export async function runCandidatePool(cfg: Config, candidates: Candidate[], sta return } // oxlint-disable-next-line no-await-in-loop -- each worker awaits serially BY DESIGN; the parallelism is across workers - const used = await reviewPr(cfg, c.pr, c.prior.memory, c.diff, c.firstReview, c.prior.openThreadIds) + const used = await reviewPr(cfg, c.pr, c.prior, c.diff) if (used === 'limit' || used === null) { - // Logged, not posted — throttle this head until the window lapses or the head moves. - recordHeadAttempt(failuresPath(cfg), state.failures, String(c.pr.number), c.pr.headRefOid) + state.failed(c.pr) if (used === 'limit') { limitHit = true log( @@ -30,11 +25,7 @@ export async function runCandidatePool(cfg: Config, candidates: Candidate[], sta } continue } - // codex reached a verdict (findings posted, or a no-op). Record this head so the next sweep doesn't re-run - // codex on it — a SUPPRESSED no-op posts no marker, so local state is what catches it. A no-op still spent - // the tokens, so it counts toward the daily ceiling either way. - recordReviewedHead(reviewedPath(cfg), state.reviewedLocal, String(c.pr.number), c.pr.headRefOid) - bumpDailyCounter(dailyPath(cfg), state.daily) + state.reviewedHead(c.pr) if (typeof used === 'object') { reviewed += 1 } diff --git a/src/sweep/prompt.ts b/src/sweep/prompt.ts index 7fccec1..b1f0b13 100644 --- a/src/sweep/prompt.ts +++ b/src/sweep/prompt.ts @@ -1,4 +1,3 @@ -// Prompt construction: point at taste files, then the per-PR tail (intent, prior thread, diff). import { existsSync } from 'node:fs' import { join } from 'node:path' @@ -44,7 +43,5 @@ Review this pull request against the spec and rubric. ${diff}` } -// Resolve a `.review/` that has the full taste set (spec + rubric + corpus). A partial dir (e.g. CORPUS without -// the spec) reads as absent so the sweep falls back cleanly. export const hasMachinery = (dir: string): boolean => existsSync(join(dir, 'CORPUS.md')) && existsSync(join(dir, 'REVIEW-PROMPT.md')) && existsSync(join(dir, 'RUBRIC.md')) diff --git a/src/sweep/prs.ts b/src/sweep/prs.ts index c3095d8..116b6d6 100644 --- a/src/sweep/prs.ts +++ b/src/sweep/prs.ts @@ -1,29 +1,21 @@ -// Listing and scoping open PRs (the gh pr list boundary), plus the per-PR MEMORY: the existing review -// conversation read back and defanged so it can be fed to codex as untrusted data. import { exec } from '@bevyl-ai/agent-tools' import { z } from 'zod' import { type Config, log } from './config' -// The gh pr list --json boundary. z.object (not strictObject) STRIPS extra keys gh adds. A non-array or -// unshaped entry throws — no silent skip. export const Pr = z.object({ number: z.number(), headRefOid: z.string(), baseRefOid: z.string(), baseRefName: z.string(), isDraft: z.boolean(), - author: z.object({ login: z.string(), is_bot: z.boolean() }).nullable(), // is_bot flags GitHub App bots (app/dependabot) the [bot] suffix misses + author: z.object({ login: z.string(), is_bot: z.boolean() }).nullable(), labels: z.array(z.object({ name: z.string() })), - title: z.string(), // title + body carry the author's STATED INTENT — fed (untrusted) into the prompt so the reviewer can weigh "I did this on purpose, here's why" instead of flagging a deliberate call as a mistake - body: z.string(), // gh returns "" for an empty description, never absent + title: z.string(), + body: z.string(), }) export type Pr = z.infer -// gh's default --limit is 30, NEWEST-first — on a repo with more open PRs than that, the older ones silently -// fall off the sweep's radar entirely: never re-reviewed, no log line, no skip status. (Observed on a 129-open-PR -// repo: a PR at list position 57 got fresh pushes for two days and the sweep never saw them.) 500 keeps the sweep -// exhaustive on any realistically-sized backlog; per-head dedup keeps the extra listings cheap. const PR_LIST_LIMIT = 500 const ListedPr = Pr.omit({ baseRefOid: true }) @@ -31,11 +23,6 @@ const RestPull = z.object({ number: z.number(), base: z.object({ sha: z.string() const REST_PAGE = 100 -// `gh pr list --json` on Ubuntu's 2.45 gh has headRefOid but not baseRefOid — unknown field → empty -// stdout, which the sweep used to log as "auth/network down". Pull base SHAs from REST instead. -// Page by hand, NOT `--paginate`: gh follows GitHub's Link header verbatim, and that header names -// api.github.com — so behind an exe.dev GH_HOST proxy, page 2 escapes the proxy, goes out unauthenticated, -// and 404s the whole call. Only a repo with >100 open PRs ever has a page 2 (bevyl, 5 days of dead sweeps). function pullBaseOids(slug: string): Map | null { const bases = new Map() for (let page = 1; ; page++) { @@ -55,7 +42,6 @@ function pullBaseOids(slug: string): Map | null { } export function listPrs(cfg: Config): Pr[] | null { - // Filter the PR list directly rather than `gh pr list --label` — that search index lags behind labelling. const fields = 'number,headRefOid,baseRefName,isDraft,author,labels,title,body' const r = exec('gh', [ 'pr', @@ -78,15 +64,13 @@ export function listPrs(cfg: Config): Pr[] | null { if (bases === null) { return null } - const out: Pr[] = [] - for (const pr of listed) { + return listed.map((pr) => { const baseRefOid = bases.get(pr.number) if (!baseRefOid) { throw new Error(`open PR #${pr.number} missing from REST pulls list`) } - out.push({ ...pr, baseRefOid }) - } - return out + return Object.assign(pr, { baseRefOid }) + }) } export function hasReviewLabel(pr: Pr, cfg: Config): boolean { @@ -97,17 +81,14 @@ export function inScope(pr: Pr, cfg: Config): boolean { if (pr.isDraft) { return false } - // Never review bot PRs, in EITHER scope — UNLESS the PR carries REVIEW_LABEL, the explicit force-include: a - // bot-authored PR you deliberately label is opted in (e.g. a factory that authors PRs as a GitHub App and wants - // them reviewed). gh's is_bot catches GitHub App bots (login `app/dependabot`) that the `[bot]` suffix misses; - // keep the suffix check as a belt-and-suspenders fallback. + if ((pr.author?.is_bot === true || (pr.author?.login ?? '').endsWith('[bot]')) && !hasReviewLabel(pr, cfg)) { return false } if (cfg.scope === 'label') { return hasReviewLabel(pr, cfg) } - return true // auto: any non-draft, non-bot PR + return true } export interface Comment { @@ -115,30 +96,23 @@ export interface Comment { body: string } -// The per-PR MEMORY: the existing review conversation — the reviewer's past reviews + the author's replies — -// fed back into the prompt so it stops re-litigating settled points and knows when to converge. The GitHub -// thread IS the durable store (survives restarts, already holds the replies); we just read it back. -const MEMORY_COMMENTS = 20 // recent thread context, bounded so the prompt can't balloon on a chatty PR -const MEMORY_BYTE_CAP = 16_000 // hard backstop: even 20 essays can't blow the prompt (and cached prefix) past this +const MEMORY_COMMENTS = 20 +const MEMORY_BYTE_CAP = 16_000 -// The thread is UNTRUSTED PR-comment content that gets inlined inside a fence. Strip hidden -// markers AND neutralize any literal fence tag in the body, so a comment can't CLOSE the fence early and smuggle -// instructions in as if they were the runner's. This is the HARD boundary; the prompt's SECURITY note is the soft -// one — relying on the model to be obedient is not a security control. export function defang(body: string): string { return body - .replaceAll(//g, '') // hidden markers (incl. our own stupify: markers) - .replaceAll(/<(?\/?)\s*(?prior_reviews|pr_description|dismissed)\s*>/gi, '‹$$›') // can't break out of any untrusted fence + .replaceAll(//g, '') + .replaceAll(/<(?\/?)\s*(?prior_reviews|pr_description|dismissed)\s*>/gi, '‹$$›') .trim() } export function priorReviewThread(comments: Comment[]): string { const thread = comments - .filter((c) => !c.login.endsWith('[bot]')) // drop CI bots; keep prior reviews + human/agent replies + .filter((c) => !c.login.endsWith('[bot]')) .slice(-MEMORY_COMMENTS) .map((c) => ({ login: c.login, body: defang(c.body) })) .filter((c) => c.body.length > 0) .map((c) => `@${c.login}:\n${c.body}`) .join('\n\n---\n\n') - return thread.length > MEMORY_BYTE_CAP ? thread.slice(-MEMORY_BYTE_CAP) : thread // keep the most recent context + return thread.length > MEMORY_BYTE_CAP ? thread.slice(-MEMORY_BYTE_CAP) : thread } diff --git a/src/sweep/review-pr.ts b/src/sweep/review-pr.ts index 2b14688..1ef1634 100644 --- a/src/sweep/review-pr.ts +++ b/src/sweep/review-pr.ts @@ -1,30 +1,13 @@ -// Acting on one sweep review: post findings as an inline-threaded COMMENT review, resolve stupify's open -// threads when its findings are fixed, post the convergence notes, or stay silent while findings stand. import { runReview } from './codex' import { type Config, log } from './config' -import { postNote, postReview, resolveThreads } from './github' +import { postNote, postReview, type PriorState, resolveThreads } from './github' import { type Pr } from './prs' import { FIXED_NOTE, STILL_NOTE } from './verdict' import { prepareHeadWorktree, removeHeadWorktree } from './worktree' -// A posted review carries its blocking-finding count — zero blocking reads as a green status. export type SweepReviewResult = { blocking: number } | 'limit' | 'clean' | 'fixed' | 'open' | null -/** Run one SWEEP review and act on it: post findings as an inline-threaded COMMENT review, RESOLVE stupify's open - * threads when its findings are fixed, post a one-time `LGTM ✅` review on a genuine first-pass clean, post a - * one-line `still ✅` on a clean head with nothing outstanding, or stay SILENT while prior findings remain open. - * Returns {tokens, blocking} on a posted review, 'clean' on a clean outcome, 'open' when prior findings remain unresolved, - * 'fixed' when it resolved prior findings, 'limit' on exhaustion, or null on a failure the caller throttles. - * Every ✅ that posts is honest: it only fires when no stupify finding is open — "nothing new while findings - * still stand" stays silent (those threads remain open); a fix resolves the threads and posts a visible note. */ -export async function reviewPr( - cfg: Config, - pr: Pr, - priorThread: string, - diff: string, - firstReview: boolean, - openThreadIds: string[], -): Promise { +export async function reviewPr(cfg: Config, pr: Pr, prior: PriorState, diff: string): Promise { log(`reviewing PR #${pr.number} @ ${pr.headRefOid.slice(0, 8)} (base ${pr.baseRefName})`) const workDir = prepareHeadWorktree(cfg.repoDir, pr) if (workDir === null) { @@ -33,7 +16,7 @@ export async function reviewPr( } let r try { - r = await runReview(cfg, pr, priorThread, diff, workDir) + r = await runReview(cfg, pr, prior.memory, diff, workDir) } finally { removeHeadWorktree(cfg.repoDir, pr) } @@ -41,49 +24,16 @@ export async function reviewPr( log(` review FAILED for #${pr.number} — ${r.reason}`) return r.kind === 'limit' ? 'limit' : null } - if (r.kind === 'no_new_issues') { - // Clean. A one-time LGTM on a PR stupify has never flagged (so "reviewed + good" is visible). On a PR it HAS - // reviewed: while its own findings are still open, a clean head stays silent (the open threads already say it - // all, and a fresh non-✅ note would fight a reasoned inline pushback) — but with NOTHING outstanding it posts - // the one-line marker-bearing re-approval, so the new head never reads as "unreviewed" to per-head consumers. - if (!firstReview) { - if (openThreadIds.length > 0) { - log(` #${pr.number} nothing new, prior findings still open — staying silent`) - return 'open' - } - if (!postNote(cfg, pr, STILL_NOTE)) { - log(` couldn't post #${pr.number} ${STILL_NOTE} (gh down?) — will retry next sweep`) - return null - } - log(` #${pr.number} nothing new — posted ${STILL_NOTE} for this head`) - return 'clean' - } - if (!postNote(cfg, pr, 'LGTM ✅')) { - log(` couldn't post #${pr.number} LGTM (gh down?) — will retry next sweep`) + const note = (text: string, why: string): SweepReviewResult => { + if (!postNote(cfg, pr, text)) { + log(` couldn't post #${pr.number} ${text} (gh down?) — will retry next sweep`) return null } - log(` #${pr.number} clean first pass — posted LGTM ✅`) + log(` #${pr.number} ${why} — posted ${text}`) return 'clean' } - // Prior findings resolved → resolve the open threads, then post the visible fixed note with the head marker. - // Keep that order: a marker before resolution could make a later sweep skip a still-open thread. Gated on - // actually having open stupify threads, so a stray fixed-signal can't manufacture approval. - if (r.kind === 'fixed') { - if (openThreadIds.length === 0) { - // Nothing left to resolve. On a PR stupify never flagged, a stray fixed-signal must stay silent — it can't - // manufacture an approval. On a PR it HAS reviewed (threads already resolved on an earlier pass), this is - // just "clean at a new head": post the marker-bearing re-approval, same as the no-op path above. - if (firstReview) { - log(` #${pr.number} fixed-signal but never flagged — staying silent`) - return 'clean' - } - if (!postNote(cfg, pr, STILL_NOTE)) { - log(` couldn't post #${pr.number} ${STILL_NOTE} (gh down?) — will retry next sweep`) - return null - } - log(` #${pr.number} prior findings already resolved — posted ${STILL_NOTE} for this head`) - return 'clean' - } + const { openThreadIds } = prior + if (r.kind === 'fixed' && openThreadIds.length > 0) { if (!resolveThreads(openThreadIds)) { log(` couldn't resolve #${pr.number} fixed thread(s) (gh down?) — will retry next sweep`) return null @@ -94,7 +44,20 @@ export async function reviewPr( log(` #${pr.number} prior findings resolved — posted ${FIXED_NOTE}; resolved ${openThreadIds.length} thread(s)`) return 'fixed' } - // A real review: post the validated findings as inline, resolvable threads. (parseReview guarantees ≥1 finding.) + if (r.kind !== 'findings') { + if (!prior.everReviewed) { + if (r.kind === 'fixed') { + log(` #${pr.number} fixed-signal but never flagged — staying silent`) + return 'clean' + } + return note('LGTM ✅', 'clean first pass') + } + if (openThreadIds.length > 0) { + log(` #${pr.number} nothing new, prior findings still open — staying silent`) + return 'open' + } + return note(STILL_NOTE, 'nothing new') + } if (!postReview(cfg, pr, r.opener, r.findings)) { log(` couldn't post #${pr.number} review (gh down?) — next sweep retries`) return null diff --git a/src/sweep/state.ts b/src/sweep/state.ts index 0c2afe5..63eae26 100644 --- a/src/sweep/state.ts +++ b/src/sweep/state.ts @@ -1,88 +1,58 @@ -// Per-box sweep state: tiny best-effort JSON files (a parse error or a fresh box just re-attempts once). -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { z } from 'zod' import { type Config } from './config' +import { type Pr } from './prs' -export const HeadAttempt = z.strictObject({ head: z.string(), at: z.number() }) -export type HeadAttempt = z.infer - -export const DailyCounter = z.strictObject({ date: z.string(), count: z.number() }) -export type DailyCounter = z.infer - -// Whole-file parse-or-{}: these are best-effort caches the sweep itself wrote, so one corrupt entry means the -// file is suspect — re-attempting everything once is the documented failure mode anyway. -const HeadAttempts = z.record(z.string(), HeadAttempt) - -export function loadHeadAttempts(path: string): Record { - try { - return HeadAttempts.parse(JSON.parse(readFileSync(path, 'utf8'))) - } catch { - return {} - } -} - -export function recordHeadAttempt( - path: string, - attempts: Record, - key: string, - head: string, - at = Date.now(), -): void { - attempts[key] = { head, at } - try { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(attempts)) - } catch { - /* best-effort */ - } -} - +const HeadAttempts = z.record(z.string(), z.strictObject({ head: z.string(), at: z.number() })) const ReviewedHeads = z.record(z.string(), z.string()) +const DailyCounter = z.strictObject({ date: z.string(), count: z.number() }) -export function loadReviewedHeads(path: string): Record { +function load(path: string, schema: z.ZodType, fallback: T): T { try { - return ReviewedHeads.parse(JSON.parse(readFileSync(path, 'utf8'))) + return schema.parse(JSON.parse(readFileSync(path, 'utf8'))) } catch { - return {} + return fallback } } -export function recordReviewedHead(path: string, reviewed: Record, key: string, head: string): void { - reviewed[key] = head - try { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(reviewed)) - } catch { - /* best-effort */ - } +export interface SweepState { + reviewsToday: () => number + isReviewed: (pr: Pr) => boolean + recentlyFailed: (pr: Pr, withinMs: number) => boolean + failed: (pr: Pr) => void + reviewedHead: (pr: Pr) => void } -export function loadDailyCounter(path: string, now = new Date()): DailyCounter { - const today = now.toISOString().slice(0, 10) - try { - const parsed = DailyCounter.parse(JSON.parse(readFileSync(path, 'utf8'))) - if (parsed.date === today) { - return parsed - } - } catch { - /* missing or corrupt — a new day */ +export function sweepState(cfg: Config): SweepState { + const today = new Date().toISOString().slice(0, 10) + const failuresPath = join(cfg.stateDir, 'failures.json') + const reviewedPath = join(cfg.stateDir, 'reviewed.json') + const dailyPath = join(cfg.stateDir, 'daily.json') + const failures = load(failuresPath, HeadAttempts, {}) + const reviewed = load(reviewedPath, ReviewedHeads, {}) + let daily = load(dailyPath, DailyCounter, { date: today, count: 0 }) + if (daily.date !== today) { + daily = { date: today, count: 0 } } - return { date: today, count: 0 } -} - -export function bumpDailyCounter(path: string, daily: DailyCounter): void { - daily.count += 1 - try { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(daily)) - } catch { - /* best-effort */ + return { + reviewsToday: () => daily.count, + isReviewed: (pr) => reviewed[String(pr.number)] === pr.headRefOid, + recentlyFailed: (pr, withinMs) => { + const f = failures[String(pr.number)] + return f !== undefined && f.head === pr.headRefOid && Date.now() - f.at < withinMs + }, + failed: (pr) => { + failures[String(pr.number)] = { head: pr.headRefOid, at: Date.now() } + writeFileSync(failuresPath, JSON.stringify(failures)) + }, + reviewedHead: (pr) => { + reviewed[String(pr.number)] = pr.headRefOid + writeFileSync(reviewedPath, JSON.stringify(reviewed)) + daily.count += 1 + writeFileSync(dailyPath, JSON.stringify(daily)) + }, } } - -export const failuresPath = (cfg: Config): string => join(cfg.stateDir, 'failures.json') -export const reviewedPath = (cfg: Config): string => join(cfg.stateDir, 'reviewed.json') -export const dailyPath = (cfg: Config): string => join(cfg.stateDir, 'daily.json') diff --git a/src/sweep/sweep.ts b/src/sweep/sweep.ts index d88b70f..782be67 100644 --- a/src/sweep/sweep.ts +++ b/src/sweep/sweep.ts @@ -1,87 +1,39 @@ -// The sweep's front half: load the per-box state, then collect the PRs that pass the cheap serial gates -// (dedup, failure throttle, daily/MAX_PRS caps, diff fetch + size cap) into review candidates. import { type Config, log } from './config' import { diffLineCount, getDiff, GH_DIFF_LIMITS } from './diff' -import { type PriorState } from './github' +import { type PriorState, prReviews } from './github' import { hasReviewLabel, type Pr } from './prs' -import { - type DailyCounter, - dailyPath, - failuresPath, - type HeadAttempt, - loadDailyCounter, - loadHeadAttempts, - loadReviewedHeads, - reviewedPath, -} from './state' +import { type SweepState } from './state' export interface Candidate { pr: Pr prior: PriorState diff: string - firstReview: boolean } -// The three per-box state files every sweep loads up front (see state.ts). -export interface SweepState { - failures: Record // PR -> failed head + when; throttles retries without a PR comment - reviewedLocal: Record // PR -> head already run; catches suppressed no-ops - daily: DailyCounter // today's review count vs MAX_REVIEWS_PER_DAY -} - -export function loadSweepState(cfg: Config): SweepState { - return { - failures: loadHeadAttempts(failuresPath(cfg)), - reviewedLocal: loadReviewedHeads(reviewedPath(cfg)), - daily: loadDailyCounter(dailyPath(cfg)), - } -} - -// Count PRs we do real (costly) work on, and cap THAT at MAX_PRS — so a backlog of already-reviewed PRs at -// the front of the list can't consume the budget and starve later ones. Candidates are collected here (all the -// cheap serial gates) and reviewed by pool.ts's CODEX_JOBS concurrent codex sessions. -export function collectCandidates( - cfg: Config, - queue: Pr[], - priorByPr: Map, - state: SweepState, -): Candidate[] { +export function collectCandidates(cfg: Config, queue: Pr[], state: SweepState): Candidate[] { let handled = 0 - // Each candidate is one review session, so the daily ceiling gates collection up front. const dailyBudget = - cfg.maxReviewsPerDay > 0 && !cfg.dryRun ? cfg.maxReviewsPerDay - state.daily.count : Number.POSITIVE_INFINITY + cfg.maxReviewsPerDay > 0 && !cfg.dryRun ? cfg.maxReviewsPerDay - state.reviewsToday() : Number.POSITIVE_INFINITY const candidates: Candidate[] = [] for (const pr of queue) { if (handled >= dailyBudget) { log(`daily cap hit (MAX_REVIEWS_PER_DAY=${cfg.maxReviewsPerDay}) — no more reviews today; resumes tomorrow`) break } - // What stupify has already said here — read from the reviews/threads connection (findings are inline threads). - const prior = priorByPr.get(pr.number) ?? null + const prior = prReviews(cfg, pr) if (prior === null) { log(`skip #${pr.number} — couldn't read its reviews from gh (failed/malformed); will retry next sweep`) continue } - const firstReview = !prior.everReviewed // stupify has never reviewed here → a clean verdict earns a one-time LGTM - // Already reviewed THIS head? A posted review's body carries the head marker (durable, survives VM recreation); - // a SUPPRESSED no-op posts nothing, so it's caught by local state instead. Either way, don't re-run codex. - const reviewedHead = prior.reviewedHead || state.reviewedLocal[String(pr.number)] === pr.headRefOid - // Failures aren't posted, so suppression is local: skip a head we already tried within the retry window. - const f = state.failures[String(pr.number)] - const recentlyFailed = f !== undefined && f.head === pr.headRefOid && Date.now() - f.at < cfg.failRetryMs - if (reviewedHead || recentlyFailed) { + if (prior.reviewedHead || state.isReviewed(pr) || state.recentlyFailed(pr, cfg.failRetryMs)) { continue } - // Past the cheap dedup skip — this PR is a real candidate. Enforce MAX_PRS here, not on the iterated list. if (handled >= cfg.maxPrs) { log(`reached MAX_PRS=${cfg.maxPrs} this sweep — deferring remaining candidates to the next sweep`) break } - - // Fetch the diff once, here in the runner — codex reviews it from the prompt with no network/gh of its own. const read = getDiff(cfg, pr) if (!read.ok) { - // too-large is terminal: gh will never hand us this diff, so there is nothing to retry and nothing to measure. log( read.reason === 'too-large' ? `skip #${pr.number} — diff over GitHub's ${GH_DIFF_LIMITS} API limit — gh can't return it, so it can't be reviewed; split the PR` @@ -89,19 +41,17 @@ export function collectCandidates( ) continue } - const { diff } = read - const lines = diffLineCount(diff) - // auto-scope only: skip oversized diffs UNLESS the PR carries the review label (the documented force-include). + const lines = diffLineCount(read.diff) if (cfg.scope === 'auto' && lines > cfg.diffLineCap && !hasReviewLabel(pr, cfg)) { log(`skip #${pr.number} — diff ${lines} lines > cap ${cfg.diffLineCap} (add '${cfg.reviewLabel}' to force)`) continue } - handled += 1 // count only PRs that pass the gates and actually get a review slot + handled += 1 if (cfg.dryRun) { log(`DRY_RUN would review #${pr.number} @ ${pr.headRefOid.slice(0, 8)} (diff ${lines} lines)`) continue } - candidates.push({ pr, prior, diff, firstReview }) + candidates.push({ pr, prior, diff: read.diff }) } return candidates } diff --git a/src/sweep/verdict.ts b/src/sweep/verdict.ts index 668a587..ea3068f 100644 --- a/src/sweep/verdict.ts +++ b/src/sweep/verdict.ts @@ -1,12 +1,7 @@ -// The review VERDICT contract: Codex submits ONE ReviewOutput through the `review_verdict` tool (codex.ts), the -// kit validates the shape mid-turn, and parseReview is the guard for what the shape can't say. Also the marker / -// convergence-note vocabulary every posted review carries. import { z } from 'zod' import { type Pr } from './prs' -// The output carries path/line (thread anchor), severity (→ blocking), and conf. The runner stamps -// emoji + conf + file pointer onto `body`. Only high/med block; low/note/praise are non-blocking. const BLOCKING = new Set(['high', 'med']) const Severity = z.enum(['high', 'med', 'low', 'note', 'praise']) const EMOJI = { high: '🔴', med: '🟠', low: '🟡', note: '🔵', praise: '🟢' } as const @@ -39,53 +34,29 @@ export type ReviewVerdict = const heading = (severity: z.infer, conf: number, path: string, line: number): string => `${EMOJI[severity]} · conf ${Number(conf.toFixed(2))} · **\`${path}:${line}\`**` -const postedBody = (head: string, body: string): string => `${head} - -${body}` - -/** Stamp headings and split verdicts. Caller already `ReviewOutput.parse`d the model JSON. */ export function parseReview(data: ReviewOutput): ReviewVerdict { if (data.verdict !== 'findings') { - // A convergence verdict that ALSO carries findings is contradictory — fail loud rather than resolve threads - // and post a ✅ while silently dropping what the model found. if (data.findings.length > 0) { throw new Error('review parsed but had no usable findings') } return { kind: data.verdict } } const findings = data.findings - .map((f): ParsedFinding | null => { - const path = f.path.trim() - const body = f.body.trim() - if (!path || !body) { - return null - } - return { - path, - line: f.line, - blocking: BLOCKING.has(f.severity), - body: postedBody(heading(f.severity, f.conf, path, f.line), body), - } - }) - .filter((f) => f !== null) + .filter((f) => f.path.trim() && f.body.trim()) + .map((f) => ({ + path: f.path.trim(), + line: f.line, + blocking: BLOCKING.has(f.severity), + body: `${heading(f.severity, f.conf, f.path.trim(), f.line)}\n\n${f.body.trim()}`, + })) if (findings.length === 0) { throw new Error('review parsed but had no usable findings') } return { kind: 'findings', opener: data.opener, findings } } -// The hidden marker stupify ends every posted review with, keyed to the head SHA — how a later sweep recognizes a -// PR it already reviewed AT THIS HEAD (durable dedup, survives VM recreation). Failures aren't posted, so there's -// no fail marker; they're throttled via local state instead. export const markFor = (pr: Pr): string => `` -// "fixed" is gated on there actually being open findings, so a stray fixed-signal on a never-flagged PR can't -// manufacture approval. Detection is strict parse-or-fail — never infer "clean" from anything looser: a reviewer -// fails toward SURFACING findings (loud, retryable), never toward hiding them behind a silent ✅. export const FIXED_NOTE = 'nice, all fixed ✅' -// The one-line re-approval a clean re-reviewed head gets when nothing is outstanding. Every posted note carries -// the `` marker, so every reviewed head keeps a durable on-PR verdict. Pure silence here made -// the latest push look unreviewed to anything that asks "does a review cover HEAD?" (merge gates, the bunion -// factory's `wait` tool — which timed out and shipped with STUPIFY_FLAKED), and to a sweep whose local -// reviewed-state was lost (VM recreation → codex re-runs on an already-clean head). + export const STILL_NOTE = 'still ✅' diff --git a/src/sweep/worktree.ts b/src/sweep/worktree.ts index 50a0c1f..fbd8f09 100644 --- a/src/sweep/worktree.ts +++ b/src/sweep/worktree.ts @@ -1,5 +1,3 @@ -// Detached worktree at a PR's head SHA so codex reads the same tree the diff describes — required for stacked -// PRs whose base is not main (the shared checkout stays on defaultBranch for refreshRepo). import { rmSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -7,11 +5,10 @@ import { exec } from '@bevyl-ai/agent-tools' import { type Pr } from './prs' -export function headWorktreePath(repoDir: string, pr: Pr): string { +function headWorktreePath(repoDir: string, pr: Pr): string { return join(dirname(repoDir), 'worktrees', `${pr.number}-${pr.headRefOid.slice(0, 8)}`) } -/** Fetch base+head and add a detached worktree at the PR head. Returns null on failure. */ export function prepareHeadWorktree(repoDir: string, pr: Pr): string | null { const dir = headWorktreePath(repoDir, pr) rmSync(dir, { recursive: true, force: true }) From fd62f3bdeb825e9bc485462e6f194166e92f16a1 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 09:57:36 -0400 Subject: [PATCH 04/14] MAX_TURNS is at least 2; state writes log instead of throwing. stupify's findings on the previous head: MAX_TURNS=1 skipped the second pass, and a state-file write error would reject a worker and abort the pool. --- src/sweep/config.ts | 2 +- src/sweep/state.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/sweep/config.ts b/src/sweep/config.ts index 582aa1b..7732743 100644 --- a/src/sweep/config.ts +++ b/src/sweep/config.ts @@ -108,7 +108,7 @@ export function loadConfig(): Config { diffLineCap: int('DIFF_LINE_CAP', 20_000, 1), dryRun: bool('DRY_RUN', false, true), maxPrs: int('MAX_PRS', 15, 1), - maxTurns: int('MAX_TURNS', 6, 1), + maxTurns: int('MAX_TURNS', 6, 2), maxReviewsPerDay: int('MAX_REVIEWS_PER_DAY', 0, 0), failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, stateDir, diff --git a/src/sweep/state.ts b/src/sweep/state.ts index 63eae26..693f07d 100644 --- a/src/sweep/state.ts +++ b/src/sweep/state.ts @@ -3,13 +3,21 @@ import { join } from 'node:path' import { z } from 'zod' -import { type Config } from './config' +import { type Config, log } from './config' import { type Pr } from './prs' const HeadAttempts = z.record(z.string(), z.strictObject({ head: z.string(), at: z.number() })) const ReviewedHeads = z.record(z.string(), z.string()) const DailyCounter = z.strictObject({ date: z.string(), count: z.number() }) +function save(path: string, value: unknown): void { + try { + writeFileSync(path, JSON.stringify(value)) + } catch (error) { + log(`couldn't write ${path} — ${error instanceof Error ? error.message : String(error)}`) + } +} + function load(path: string, schema: z.ZodType, fallback: T): T { try { return schema.parse(JSON.parse(readFileSync(path, 'utf8'))) @@ -46,13 +54,13 @@ export function sweepState(cfg: Config): SweepState { }, failed: (pr) => { failures[String(pr.number)] = { head: pr.headRefOid, at: Date.now() } - writeFileSync(failuresPath, JSON.stringify(failures)) + save(failuresPath, failures) }, reviewedHead: (pr) => { reviewed[String(pr.number)] = pr.headRefOid - writeFileSync(reviewedPath, JSON.stringify(reviewed)) + save(reviewedPath, reviewed) daily.count += 1 - writeFileSync(dailyPath, JSON.stringify(daily)) + save(dailyPath, daily) }, } } From 579a8732223bec5ef7a04fab9708ee0900cb74a9 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 09:57:46 -0400 Subject: [PATCH 05/14] DEPLOY.md: MAX_TURNS minimum. --- DEPLOY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPLOY.md b/DEPLOY.md index 8b2ca3c..7e3be51 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -41,7 +41,7 @@ Every knob is a line in `~/.stupify/config.env`, read fresh each sweep. A one-sh | `SCOPE` | `auto` | `auto` reviews every non-draft, non-bot PR; `label` only labelled ones | | `REVIEW_LABEL` | `codex-review` | force-include label: oversized diffs and bot PRs opt in with it | | `DIFF_LINE_CAP` | `20000` | skip bigger diffs unless labelled | -| `MAX_TURNS` | `6` | codex turns per review; past it the attempt fails and retries later | +| `MAX_TURNS` | `6`, min 2 | codex turns per review; past it the attempt fails and retries later | | `MAX_PRS` | `15` | reviews per sweep, counted after dedup skips | | `MAX_REVIEWS_PER_DAY` | `0` (off) | hard daily ceiling | | `FAIL_RETRY_MIN` | `60` | wait before retrying a head whose review failed | From 4678557c9d7b15919532f9d1f38051fbbf5c06ed Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 10:06:43 -0400 Subject: [PATCH 06/14] The second pass must produce its own verdict. A turn-1 verdict no longer survives a silent second pass: it is cleared when the second-pass prompt goes out, so done() needs a call from that turn onward. --- src/sweep/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index f41f46d..b716957 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -113,6 +113,7 @@ export async function runReview( continuation: (turn, max) => { if (turn === 2) { secondPass = true + got.verdict = null return SECOND_PASS_PROMPT } return `Continuation, turn ${turn} of ${max}, same thread. Resume from where you left off; finish by calling review_verdict.` From 80ffb79b1bcb6211987eb8a10e3c0a81937a2561 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 11:55:36 -0400 Subject: [PATCH 07/14] Codex through the kit's SDK thread; review_verdict is an MCP tool. codexThread() gives the read-only, network-off thread; review_verdict is a registerTool on the in-process MCP host, anchors checked in its handler. The turn loop is thread.run() with an abort signal: review, second pass (clearing any turn-1 verdict), continuations up to MAX_TURNS. Usage and tool calls go to the sweep log per turn. CODEX_EFFORT is typed; CODEX_PATH defaults to codex on PATH, which a single-file bundle needs since the SDK resolves the binary through node_modules. Kit 0.14.1. --- bun.lock | 206 +++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- src/sweep/codex.ts | 155 +++++++++++++-------------------- src/sweep/config.ts | 4 +- 4 files changed, 268 insertions(+), 99 deletions(-) diff --git a/bun.lock b/bun.lock index c7632fd..7814760 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "stupify", "dependencies": { - "@bevyl-ai/agent-tools": "0.13.0", + "@bevyl-ai/agent-tools": "0.14.1", "zod": "^4.4.3", }, "devDependencies": { @@ -18,7 +18,27 @@ }, }, "packages": { - "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.13.0", "", { "dependencies": { "zod": "^4.4.3" } }, "sha512-UfmU4uz4oAWll6yME/1Ehf+yJBM63ubC/NZGloS8+qcx2jMvsHjrOKwHutkROKwgiNSaFxRq+y9bcaES0ASFFw=="], + "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.14.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@openai/codex-sdk": "^0.153.4", "zod": "^4.4.3" } }, "sha512-UvOr8uZNlwPGKpLEdB81xMt9zf1KQdSQwoiVNLVAcbwyci6IEtqwFbL/jneXjX9ev8ojpiD/fa6pR7h0TElB/w=="], + + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + + "@openai/codex": ["@openai/codex@0.153.4", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.153.4-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.153.4-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.153.4-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.153.4-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.153.4-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.153.4-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-wbHDmit7S/YvBGVX1DQmk13xtWblZ2cApeJ/pB7xDZ10Cna+DZc5ij7f0F4OxdsXN4FW1oLT48OpogUI1+8Y2w=="], + + "@openai/codex-darwin-arm64": ["@openai/codex@0.153.4-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B1qhN3fa1ay0R0wGziXqgwSkB5icpYChNKHhtBHff/0UtSTC7z+l8aTtvMlGjH3E8HEvY3+njIJelM9CAAoVWg=="], + + "@openai/codex-darwin-x64": ["@openai/codex@0.153.4-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-vnSbbPzfoDZmmyzsxswsDDXQ06IVFBzkQU7/hroB3ji93Ok2utcsq8Psfk2tjF5r9mEx8RWFJhzuTGHG26/NDA=="], + + "@openai/codex-linux-arm64": ["@openai/codex@0.153.4-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-QKdjYLYV4hXIuUQDP3P6F4NXuWFoKo9WUoV4nAREIx55kiUyi8UsYdsVobkeXir5n/maEQgYMCKLHVma4rNPiw=="], + + "@openai/codex-linux-x64": ["@openai/codex@0.153.4-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-x1EcwBlY3AObM1VTUHNM2AzAJQsyreGdagpF+qFiYi/Oa30VBktvvG0C6tLtCzqW6hjZNWkGZQWmeVk7MuJKWg=="], + + "@openai/codex-sdk": ["@openai/codex-sdk@0.153.4", "", { "dependencies": { "@openai/codex": "0.153.4" } }, "sha512-z0rN8WMQxwEHYHpDJyKHZwNNUtM/1pdnxmKopIJ3qMCZyyDestaIhSGDZ0v0RSjm+REujcFyNd95oWs78zWgRg=="], + + "@openai/codex-win32-arm64": ["@openai/codex@0.153.4-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-/FBh42976ltF1kxDoPQBg1Q6+hwChRU5/sm5dfeC8kFVQMvOCGoGeY5d8rRZGVJE8XojlXo74VQb0sHowcfgBw=="], + + "@openai/codex-win32-x64": ["@openai/codex@0.153.4-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-lMkB43kJZH0VFr+hoXc11qqR7QtQIbkr07ALgj4urKL1osNyUyuy1iXd3Vzz2iCYvBUCSw7I0l/W1cEPGx9euQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ=="], @@ -140,18 +160,200 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.64.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.64.0", "@oxfmt/binding-android-arm64": "0.64.0", "@oxfmt/binding-darwin-arm64": "0.64.0", "@oxfmt/binding-darwin-x64": "0.64.0", "@oxfmt/binding-freebsd-x64": "0.64.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.64.0", "@oxfmt/binding-linux-arm-musleabihf": "0.64.0", "@oxfmt/binding-linux-arm64-gnu": "0.64.0", "@oxfmt/binding-linux-arm64-musl": "0.64.0", "@oxfmt/binding-linux-ppc64-gnu": "0.64.0", "@oxfmt/binding-linux-riscv64-gnu": "0.64.0", "@oxfmt/binding-linux-riscv64-musl": "0.64.0", "@oxfmt/binding-linux-s390x-gnu": "0.64.0", "@oxfmt/binding-linux-x64-gnu": "0.64.0", "@oxfmt/binding-linux-x64-musl": "0.64.0", "@oxfmt/binding-openharmony-arm64": "0.64.0", "@oxfmt/binding-win32-arm64-msvc": "0.64.0", "@oxfmt/binding-win32-ia32-msvc": "0.64.0", "@oxfmt/binding-win32-x64-msvc": "0.64.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg=="], "oxlint": ["oxlint@1.79.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.79.0", "@oxlint/binding-android-arm64": "1.79.0", "@oxlint/binding-darwin-arm64": "1.79.0", "@oxlint/binding-darwin-x64": "1.79.0", "@oxlint/binding-freebsd-x64": "1.79.0", "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", "@oxlint/binding-linux-arm-musleabihf": "1.79.0", "@oxlint/binding-linux-arm64-gnu": "1.79.0", "@oxlint/binding-linux-arm64-musl": "1.79.0", "@oxlint/binding-linux-ppc64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-musl": "1.79.0", "@oxlint/binding-linux-s390x-gnu": "1.79.0", "@oxlint/binding-linux-x64-gnu": "1.79.0", "@oxlint/binding-linux-x64-musl": "1.79.0", "@oxlint/binding-openharmony-arm64": "1.79.0", "@oxlint/binding-win32-arm64-msvc": "1.79.0", "@oxlint/binding-win32-ia32-msvc": "1.79.0", "@oxlint/binding-win32-x64-msvc": "1.79.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], } } diff --git a/package.json b/package.json index 1f0a5da..93d2f5d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run build" }, "dependencies": { - "@bevyl-ai/agent-tools": "0.14.0", + "@bevyl-ai/agent-tools": "0.14.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index b716957..c6827ab 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -1,12 +1,4 @@ -import { - AppServerSession, - isQuotaWall, - isRateLimited, - maybeRotateGateway, - scrubSecrets, - tool, - untilDone, -} from '@bevyl-ai/agent-tools' +import { codexThread, isQuotaWall, isRateLimited, maybeRotateGateway, text } from '@bevyl-ai/agent-tools' import { SECOND_PASS_PROMPT } from '../hand-written-prompts' import { type Config, log, logRaw } from './config' @@ -19,15 +11,6 @@ export type ReviewOutcome = { kind: 'limit'; reason: string } | { kind: 'fail'; const TURN_TIMEOUT_MS = 1_200_000 -function callFailed(raw: string): ReviewOutcome { - const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' - - if (isRateLimited(raw) || isQuotaWall(raw)) { - return { kind: 'limit', reason } - } - return { kind: 'fail', reason } -} - const nearest = (lines: Set, line: number): string => [...lines] .toSorted((a, b) => Math.abs(a - line) - Math.abs(b - line)) @@ -35,30 +18,6 @@ const nearest = (lines: Set, line: number): string => .toSorted((a, b) => a - b) .join(', ') -const verdictTool = (diff: string, submit: (verdict: ReviewVerdict) => void) => { - const valid = diffRightLines(diff) - return tool( - 'review_verdict', - 'Submit your verdict. You may call it again to revise; the last call wins.', - ReviewOutput, - (data) => { - for (const f of data.findings) { - const lines = valid.get(f.path) - if (lines === undefined) { - throw new Error(`${f.path} is not in this diff`) - } - if (!lines.has(f.line)) { - throw new Error( - `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, - ) - } - } - submit(parseReview(data)) - return Promise.resolve('noted') - }, - ) -} - export async function runReview( cfg: Config, pr: Pr, @@ -66,65 +25,71 @@ export async function runReview( diff: string, workDir?: string, ): Promise { + const valid = diffRightLines(diff) const got: { verdict: ReviewVerdict | null } = { verdict: null } - const session = new AppServerSession( - { - cwd: workDir ?? cfg.repoDir, - title: `#${pr.number}`, - model: cfg.codexModel || undefined, - effort: cfg.codexEffort, - - threadSandbox: 'read-only', - turnSandboxPolicy: { type: 'readOnly' }, - turnTimeoutMs: TURN_TIMEOUT_MS, - }, - [ - verdictTool(diff, (verdict) => { - got.verdict = verdict - }), - ], - (event) => { - if (event.log) { - logRaw(` codex: ${event.log}\n`) - } - }, - { - scrubEnv: scrubSecrets, - - onTurnError: (error) => { - const rot = maybeRotateGateway({ - reason: String(error), - pool: cfg.gatewayPool, - cooldownMs: cfg.rotateCooldownMs, - }) - if (rot.rotated) { - log(` codex gateway rotated: ${rot.from} → ${rot.to}`) - } - }, - }, - ) - try { - let secondPass = false - await session.runTurns( - untilDone({ - prompt: reviewPrompt(cfg, pr, priorThread, diff), - done: () => secondPass && got.verdict !== null, - maxTurns: cfg.maxTurns, - continuation: (turn, max) => { - if (turn === 2) { - secondPass = true - got.verdict = null - return SECOND_PASS_PROMPT + const { thread, close } = await codexThread({ + workingDirectory: workDir ?? cfg.repoDir, + codexPath: cfg.codexPath, + ...(cfg.codexModel ? { model: cfg.codexModel } : {}), + modelReasoningEffort: cfg.codexEffort, + tools: (server) => + server.registerTool( + 'review_verdict', + { + description: 'Submit your verdict. You may call it again to revise; the last call wins.', + inputSchema: ReviewOutput.shape, + }, + (data) => { + for (const f of data.findings) { + const lines = valid.get(f.path) + if (lines === undefined) { + throw new Error(`${f.path} is not in this diff`) + } + if (!lines.has(f.line)) { + throw new Error( + `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, + ) + } } - return `Continuation, turn ${turn} of ${max}, same thread. Resume from where you left off; finish by calling review_verdict.` + got.verdict = parseReview(data) + return Promise.resolve(text('noted')) }, - }), - ) + ), + }) + const prompts = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] + try { + for (let turn = 1; turn <= cfg.maxTurns; turn++) { + if (turn === 2) { + got.verdict = null + } + const prompt = + prompts[turn - 1] ?? + `Continuation, turn ${turn} of ${cfg.maxTurns}, same thread. Resume from where you left off; finish by calling review_verdict.` + // oxlint-disable-next-line no-await-in-loop -- turns are sequential on one thread by definition + const { items, usage } = await thread.run(prompt, { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }) + for (const item of items) { + if (item.type === 'mcp_tool_call') { + logRaw(` codex: ⚙ ${item.tool}${item.error ? ` — ${item.error.message}` : ''}\n`) + } + } + if (usage) { + logRaw(` codex: turn ${turn} — ${usage.input_tokens + usage.output_tokens} tokens\n`) + } + if (turn >= 2 && got.verdict !== null) { + break + } + } } catch (error) { - session.stop() const raw = error instanceof Error ? error.message : String(error) logRaw(`${raw}\n`) - return callFailed(raw) + const rot = maybeRotateGateway({ reason: raw, pool: cfg.gatewayPool, cooldownMs: cfg.rotateCooldownMs }) + if (rot.rotated) { + log(` codex gateway rotated: ${rot.from} → ${rot.to}`) + } + const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' + return isRateLimited(raw) || isQuotaWall(raw) ? { kind: 'limit', reason } : { kind: 'fail', reason } + } finally { + close() } return got.verdict ?? { kind: 'fail', reason: `no verdict after ${cfg.maxTurns} turns` } } diff --git a/src/sweep/config.ts b/src/sweep/config.ts index 7732743..e0b28a6 100644 --- a/src/sweep/config.ts +++ b/src/sweep/config.ts @@ -25,8 +25,9 @@ export const Config = z.object({ maxReviewsPerDay: z.number(), failRetryMs: z.number(), stateDir: z.string(), - codexEffort: z.string(), + codexEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'persistent']), codexModel: z.string(), + codexPath: z.string(), gatewayPool: z.array(z.string()), rotateCooldownMs: z.number(), codexJobs: z.number(), @@ -114,6 +115,7 @@ export function loadConfig(): Config { stateDir, codexEffort: pick('CODEX_EFFORT', 'high'), codexModel: pick('CODEX_MODEL', ''), + codexPath: pick('CODEX_PATH', Bun.which('codex') ?? 'codex'), gatewayPool: pick('CODEX_GATEWAY_POOL', '') .split(',') .map((h) => h.trim()) From 5aa4078d655a3b1c2cd6b61f462ea92a9e8e4937 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 12:15:40 -0400 Subject: [PATCH 08/14] review_verdict is read-only to codex. Without annotations codex treats an MCP tool as destructive and, in headless exec, cancels the call instead of prompting. readOnlyHint is honest here: the host records the verdict; nothing in the sandbox moves. --- src/sweep/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index c6827ab..beac5c5 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -37,6 +37,7 @@ export async function runReview( 'review_verdict', { description: 'Submit your verdict. You may call it again to revise; the last call wins.', + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, inputSchema: ReviewOutput.shape, }, (data) => { From ff02c1c7522632ea77d7d763bbd8aba7567e2152 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 12:26:44 -0400 Subject: [PATCH 09/14] Kit 0.14.2: the MCP host no longer keeps the sweep alive. --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 7814760..049a5fe 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "stupify", "dependencies": { - "@bevyl-ai/agent-tools": "0.14.1", + "@bevyl-ai/agent-tools": "0.14.2", "zod": "^4.4.3", }, "devDependencies": { @@ -18,7 +18,7 @@ }, }, "packages": { - "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.14.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@openai/codex-sdk": "^0.153.4", "zod": "^4.4.3" } }, "sha512-UvOr8uZNlwPGKpLEdB81xMt9zf1KQdSQwoiVNLVAcbwyci6IEtqwFbL/jneXjX9ev8ojpiD/fa6pR7h0TElB/w=="], + "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.14.2", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@openai/codex-sdk": "^0.153.4", "zod": "^4.4.3" } }, "sha512-lE/8XyL9LiCz2k2yBQs/zVcJYfYihFu3n7J2YnfqEi/edJgdKk5Lr4pLm1Bipm+PRlbdmV0Zd0P0wwdUXFQO2Q=="], "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], diff --git a/package.json b/package.json index 93d2f5d..0a123d5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run build" }, "dependencies": { - "@bevyl-ai/agent-tools": "0.14.1", + "@bevyl-ai/agent-tools": "0.14.2", "zod": "^4.4.3" }, "devDependencies": { From c66bb15c00380be067be07e7007e9ac9061f85a4 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 14:03:46 -0400 Subject: [PATCH 10/14] The turn-1 verdict stands; the second pass revises it only if it changes. Clearing the verdict at turn 2 fought the model: it had submitted, saw no reason to repeat itself, and burned four continuation turns to a failed review twice. The ownership challenge still runs as turn 2. --- src/sweep/codex.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index beac5c5..a1b1deb 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -57,12 +57,12 @@ export async function runReview( }, ), }) - const prompts = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] + const prompts = [ + reviewPrompt(cfg, pr, priorThread, diff), + `${SECOND_PASS_PROMPT}\n\nIf that changes your verdict, call review_verdict again. Otherwise you are done.`, + ] try { for (let turn = 1; turn <= cfg.maxTurns; turn++) { - if (turn === 2) { - got.verdict = null - } const prompt = prompts[turn - 1] ?? `Continuation, turn ${turn} of ${cfg.maxTurns}, same thread. Resume from where you left off; finish by calling review_verdict.` From 6a9cb887c1ef2fae7fe8f7406b5279fa74b7da11 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 18:32:22 -0400 Subject: [PATCH 11/14] Thread creation is inside the caught attempt; a verdict attempt supersedes the last. stupify's two findings on the previous head: a spawn failure escaped runReview and killed the pool, and a rejected second-pass revision left the turn-1 verdict standing. --- src/sweep/codex.ts | 73 ++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index a1b1deb..dc9fd81 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -27,41 +27,44 @@ export async function runReview( ): Promise { const valid = diffRightLines(diff) const got: { verdict: ReviewVerdict | null } = { verdict: null } - const { thread, close } = await codexThread({ - workingDirectory: workDir ?? cfg.repoDir, - codexPath: cfg.codexPath, - ...(cfg.codexModel ? { model: cfg.codexModel } : {}), - modelReasoningEffort: cfg.codexEffort, - tools: (server) => - server.registerTool( - 'review_verdict', - { - description: 'Submit your verdict. You may call it again to revise; the last call wins.', - annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - inputSchema: ReviewOutput.shape, - }, - (data) => { - for (const f of data.findings) { - const lines = valid.get(f.path) - if (lines === undefined) { - throw new Error(`${f.path} is not in this diff`) - } - if (!lines.has(f.line)) { - throw new Error( - `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, - ) - } - } - got.verdict = parseReview(data) - return Promise.resolve(text('noted')) - }, - ), - }) - const prompts = [ - reviewPrompt(cfg, pr, priorThread, diff), - `${SECOND_PASS_PROMPT}\n\nIf that changes your verdict, call review_verdict again. Otherwise you are done.`, - ] + let session: Awaited> | null = null try { + session = await codexThread({ + workingDirectory: workDir ?? cfg.repoDir, + codexPath: cfg.codexPath, + ...(cfg.codexModel ? { model: cfg.codexModel } : {}), + modelReasoningEffort: cfg.codexEffort, + tools: (server) => + server.registerTool( + 'review_verdict', + { + description: 'Submit your verdict. You may call it again to revise; the last call wins.', + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + inputSchema: ReviewOutput.shape, + }, + (data) => { + got.verdict = null + for (const f of data.findings) { + const lines = valid.get(f.path) + if (lines === undefined) { + throw new Error(`${f.path} is not in this diff`) + } + if (!lines.has(f.line)) { + throw new Error( + `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, + ) + } + } + got.verdict = parseReview(data) + return Promise.resolve(text('noted')) + }, + ), + }) + const { thread } = session + const prompts = [ + reviewPrompt(cfg, pr, priorThread, diff), + `${SECOND_PASS_PROMPT}\n\nIf that changes your verdict, call review_verdict again. Otherwise you are done.`, + ] for (let turn = 1; turn <= cfg.maxTurns; turn++) { const prompt = prompts[turn - 1] ?? @@ -90,7 +93,7 @@ export async function runReview( const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' return isRateLimited(raw) || isQuotaWall(raw) ? { kind: 'limit', reason } : { kind: 'fail', reason } } finally { - close() + session?.close() } return got.verdict ?? { kind: 'fail', reason: `no verdict after ${cfg.maxTurns} turns` } } From 611920f121b6534a95094fbf64cc6cb86fe80fa9 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 18:37:26 -0400 Subject: [PATCH 12/14] A review_verdict call the MCP layer rejects also drops the standing verdict. --- src/sweep/codex.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index dc9fd81..3f322f8 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -74,6 +74,9 @@ export async function runReview( for (const item of items) { if (item.type === 'mcp_tool_call') { logRaw(` codex: ⚙ ${item.tool}${item.error ? ` — ${item.error.message}` : ''}\n`) + if (item.tool === 'review_verdict' && item.error) { + got.verdict = null + } } } if (usage) { From 491acd1ac06fb51e333b9a78dce00cdb61742809 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 18:42:13 -0400 Subject: [PATCH 13/14] Only the turn's last review_verdict attempt decides the standing verdict. --- src/sweep/codex.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index 3f322f8..1641923 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -74,11 +74,12 @@ export async function runReview( for (const item of items) { if (item.type === 'mcp_tool_call') { logRaw(` codex: ⚙ ${item.tool}${item.error ? ` — ${item.error.message}` : ''}\n`) - if (item.tool === 'review_verdict' && item.error) { - got.verdict = null - } } } + const last = items.findLast((item) => item.type === 'mcp_tool_call' && item.tool === 'review_verdict') + if (last?.type === 'mcp_tool_call' && last.error) { + got.verdict = null + } if (usage) { logRaw(` codex: turn ${turn} — ${usage.input_tokens + usage.output_tokens} tokens\n`) } From 666eceb0394a1d99fef6a1a1074baf66098aff92 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Tue, 8 Sep 2026 18:48:33 -0400 Subject: [PATCH 14/14] A failed state write reports to stderr, not through the same filesystem. --- src/sweep/state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sweep/state.ts b/src/sweep/state.ts index 693f07d..0e1532a 100644 --- a/src/sweep/state.ts +++ b/src/sweep/state.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { z } from 'zod' -import { type Config, log } from './config' +import { type Config } from './config' import { type Pr } from './prs' const HeadAttempts = z.record(z.string(), z.strictObject({ head: z.string(), at: z.number() })) @@ -14,7 +14,7 @@ function save(path: string, value: unknown): void { try { writeFileSync(path, JSON.stringify(value)) } catch (error) { - log(`couldn't write ${path} — ${error instanceof Error ? error.message : String(error)}`) + console.error(`couldn't write ${path} — ${error instanceof Error ? error.message : String(error)}`) } }