From d1c359c430f920f321395ead64d56cb2fd9708fd Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Mon, 7 Sep 2026 22:25:20 -0400 Subject: [PATCH 1/5] Drive codex through the kit's app-server session; the verdict is a tool call. codex.ts no longer runs the Codex SDK thread and parses its final message against an outputSchema. It opens a kit AppServerSession (model and effort as per-session flags, read-only sandbox, the kit's stall and turn timeouts, scrubbed env) with one tool, review_verdict, over the existing ReviewOutput schema. The kit validates the shape mid-turn and hands a bad call back to the model as the tool error; the tool's run throws for what the schema can't say (an anchor that is not a right-side line this diff touches, a convergence verdict carrying findings), so the model corrects itself instead of the runner demoting the finding after the fact. The model's text is never read. No verdict submitted is a fail, never a clean. Same two turns as before: the review prompt, then the hand-written second pass. github.ts loses the demotion path since anchors are checked at submission; the 422 body-only fallback stays. Drops @openai/codex-sdk and the failureReason regex; bumps @bevyl-ai/agent-tools 0.3.0 -> 0.13.0. --- bun.lock | 21 +------- package.json | 3 +- src/review-sweep.ts | 2 +- src/sweep/codex.ts | 111 +++++++++++++++++++++++++--------------- src/sweep/github.ts | 37 ++++---------- src/sweep/prompt.ts | 4 +- src/sweep/review-one.ts | 2 +- src/sweep/review-pr.ts | 2 +- src/sweep/verdict.ts | 10 +--- 9 files changed, 91 insertions(+), 101 deletions(-) diff --git a/bun.lock b/bun.lock index 0d7dab7..f70c07e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,8 @@ "": { "name": "stupify", "dependencies": { - "@bevyl-ai/agent-tools": "^0.3.0", + "@bevyl-ai/agent-tools": "0.13.0", "@clack/prompts": "^1.2.0", - "@openai/codex-sdk": "^0.151.0", "picocolors": "^1.1.1", "zod": "^4.4.3", }, @@ -21,28 +20,12 @@ }, }, "packages": { - "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.3.0", "", {}, "sha512-gx16OexHehbIhOHlnGD89GTiANXIV+ZoNYxr35zt8ADl9BNfdRIvyRoupC9yIRrPpCouKLQh7UuF61z/CZYP1Q=="], + "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.13.0", "", { "dependencies": { "zod": "^4.4.3" } }, "sha512-UfmU4uz4oAWll6yME/1Ehf+yJBM63ubC/NZGloS8+qcx2jMvsHjrOKwHutkROKwgiNSaFxRq+y9bcaES0ASFFw=="], "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], - "@openai/codex": ["@openai/codex@0.151.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.151.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.151.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.151.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.151.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.151.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.151.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-mhtWmOZRdmWD1jPbLDnQb59BsaVP/V+lXe/OFNR9ZcLZU0UCiBwn98Fcav1ss7sDIlHkuqj6nWd44IPeXoOhJA=="], - - "@openai/codex-darwin-arm64": ["@openai/codex@0.151.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g7YzpaCZGCw19R/gly3vRPjnLqaW7JcBAu2WQQ6e8PIlvBPmS/gMplIUURMgNO6gi8LsPzdlQtLqkwoeOOlIdg=="], - - "@openai/codex-darwin-x64": ["@openai/codex@0.151.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-0y+g8TVpP+Fn10mjoKYXER6qYjn29w7xBUsbPXJ6Accu/FoM4Qp4WbKXQPmE0G0yUACTQVZRjzTSsdWUezNgkg=="], - - "@openai/codex-linux-arm64": ["@openai/codex@0.151.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-CsLgFeX4TQ6I2Gdrxd2r5UbgIbDLCdtcLAlnMYjr06bCL057MTNGec7Ewb3+Z2DBiMuXCljdTBGqLOePkMV0sQ=="], - - "@openai/codex-linux-x64": ["@openai/codex@0.151.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-xcVyY1FtwvVYhh2JBmz8fX8CQqFAxO/lxJ2IXsh8x5uwxZVHVl5fZHFHf8JdRaOGG0vpkYmu/DKKVoLd56/DDQ=="], - - "@openai/codex-sdk": ["@openai/codex-sdk@0.151.0", "", { "dependencies": { "@openai/codex": "0.151.0" } }, "sha512-vI4gr5ipvVwH4YHW9DGUmaUI1hJzJOCO/0d5NYFnAECsQGEvBmuTocPzRP8yGzLtsYklMnrtGtm2TyBicihVxw=="], - - "@openai/codex-win32-arm64": ["@openai/codex@0.151.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-zDWzOoh9wHm+Om1Nhn7os47rAVeSGPh0SnM3YOttdq6iPJz2zn4vBnbGUZjeih1qW/3mvNF3Oyd4owlaHmphmg=="], - - "@openai/codex-win32-x64": ["@openai/codex@0.151.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-sLT7xvID3jhU6tkzcwRPnMEclKRwUPbpo0mtfxIF9KpdZH3VJV7sM2/kXWXyvUM7Zt/YeyOaeATTEysbRz8Yog=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw=="], diff --git a/package.json b/package.json index bb770e8..8506baa 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,8 @@ "cli": "bun src/cli.ts" }, "dependencies": { - "@bevyl-ai/agent-tools": "^0.3.0", + "@bevyl-ai/agent-tools": "0.13.0", "@clack/prompts": "^1.2.0", - "@openai/codex-sdk": "^0.151.0", "picocolors": "^1.1.1", "zod": "^4.4.3" }, diff --git a/src/review-sweep.ts b/src/review-sweep.ts index c5eea8d..b33d076 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -53,7 +53,7 @@ export { recordHeadAttempt, recordReviewedHead, } from './sweep/state' -export { parseReview, REVIEW_SCHEMA, STILL_NOTE } from './sweep/verdict' +export { parseReview, STILL_NOTE } from './sweep/verdict' async function main(): Promise { const cfg = loadConfig() // also mkdirs stateDir and sets LOG, so config warnings are already captured diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index a3643b4..d7fbe28 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -1,49 +1,67 @@ -import { isQuotaWall, isRateLimited } from '@bevyl-ai/agent-tools' -// Running Codex over one PR's diff and classifying the result. The SDK talks to the local `codex` CLI. -import { Codex } from '@openai/codex-sdk' +// 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, isRateLimited, scrubSecrets, tool } from '@bevyl-ai/agent-tools' import { SECOND_PASS_PROMPT } from '../hand-written-prompts' import { type Config, logRaw } from './config' +import { diffRightLines } from './diff' import { reviewPrompt } from './prompt' import { type Pr } from './prs' -import { parseReviewJson, REVIEW_SCHEMA, type ReviewVerdict } from './verdict' +import { parseReview, ReviewOutput, type ReviewVerdict } from './verdict' /** The outcome of running Codex over one PR — classified but NOT acted on. The sweep posts/converges from this; * the ad-hoc `stupify review` prints it or `--post`s it. */ export type ReviewOutcome = | { kind: 'limit'; reason: string; raw: string } // plan/credit exhaustion — caller STOPS; raw = full error for the rotation matcher - | { kind: 'fail'; reason: string } // Codex couldn't produce a review (down, timeout, wrote nothing) + | { kind: 'fail'; reason: string } // Codex couldn't produce a review (down, timeout, stalled, never submitted) | ReviewVerdict -const MODEL_TIMEOUT_MS = 1_200_000 +const TURN_TIMEOUT_MS = 1_200_000 -function failureReason(out: string): string { - const signal = /payment required|credits|quota|rate.?limit|429|5\d\d |timeout|killed|enoent|spawn|error/i - const noise = /no error|0 error/i - const hit = out - .split('\n') - .map((l) => l.trim()) - .findLast((l) => signal.test(l) && !noise.test(l)) - const cleaned = (hit ?? '').replaceAll('`', ' ').slice(0, 220).trim() - if (cleaned.length > 0) { - return cleaned - } - const short = out.replaceAll('`', ' ').trim() - if (short.length > 0 && short.length <= 220 && !short.includes('\n')) { - return short - } - return 'codex run failed (no output captured — check the sweep log)' -} - -function callFailed(out: string): ReviewOutcome { - const reason = failureReason(out) +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(out) || isQuotaWall(out)) { - return { kind: 'limit', reason, raw: out } + if (isRateLimited(raw) || isQuotaWall(raw)) { + return { kind: 'limit', reason, raw } } return { kind: 'fail', reason } } +const nearest = (lines: Set, line: number): string => + [...lines] + .toSorted((a, b) => Math.abs(a - line) - Math.abs(b - line)) + .slice(0, 8) + .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). +const verdictTool = (diff: string, submit: (verdict: ReviewVerdict) => void) => { + const valid = diffRightLines(diff) + return tool( + 'review_verdict', + 'Submit the review verdict. Call once, after the second pass.', + 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') + }, + ) +} + /** 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, @@ -52,22 +70,35 @@ export async function runReview( diff: string, workDir?: string, ): Promise { - const cwd = workDir ?? cfg.repoDir + 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', // a reviewer reads; the prompt's "don't edit code" is enforced, not requested + turnTimeoutMs: TURN_TIMEOUT_MS, + }, + [ + verdictTool(diff, (verdict) => { + got.verdict = verdict + }), + ], + (event) => { + if (event.log) { + logRaw(` codex: ${event.log}\n`) + } + }, + { scrubEnv: scrubSecrets }, + ) + const turns = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] try { - const thread = new Codex({ codexPathOverride: Bun.which('codex') ?? 'codex' }).startThread({ - workingDirectory: cwd, - }) - await thread.run(reviewPrompt(cfg, pr, priorThread, diff), { - signal: AbortSignal.timeout(MODEL_TIMEOUT_MS), - }) - const second = await thread.run(SECOND_PASS_PROMPT, { - signal: AbortSignal.timeout(MODEL_TIMEOUT_MS), - outputSchema: REVIEW_SCHEMA, - }) - return parseReviewJson(second.finalResponse) + await session.runTurns(() => turns.shift() ?? null) } catch (error) { const raw = error instanceof Error ? error.message : String(error) logRaw(`${raw}\n`) return callFailed(raw) } + return got.verdict ?? { kind: 'fail', reason: 'codex finished without calling review_verdict' } } diff --git a/src/sweep/github.ts b/src/sweep/github.ts index d13f439..68794e8 100644 --- a/src/sweep/github.ts +++ b/src/sweep/github.ts @@ -4,7 +4,6 @@ import { exec } from '@bevyl-ai/agent-tools' import { z } from 'zod' import { type Config, logRaw } from './config' -import { diffRightLines } from './diff' import { type Comment, type Pr, priorReviewThread } from './prs' import { markFor, type ParsedFinding } from './verdict' @@ -31,33 +30,17 @@ 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). Findings on a line the diff doesn't -// touch can't be anchored, so they're demoted into the body rather than 422-ing the whole review. -export function postReview(cfg: Config, pr: Pr, opener: string, findings: ParsedFinding[], diff: string): boolean { - const valid = diffRightLines(diff) - const inline: { path: string; line: number; side: 'RIGHT'; body: string }[] = [] - const demoted: string[] = [] - for (const f of findings) { - if (valid.get(f.path)?.has(f.line)) { - inline.push({ - path: f.path, - line: f.line, - side: 'RIGHT', - body: `${f.body}\n${f.blocking ? STUPIFY_TAG : STUPIFY_NOTE_TAG}`, - }) - } else { - demoted.push(f.body) - } - } +// 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, + line: f.line, + side: 'RIGHT' as const, + body: `${f.body}\n${f.blocking ? STUPIFY_TAG : STUPIFY_NOTE_TAG}`, + })) const head = opener.trim() - if (inline.length === 0) { - return submitReview(cfg, pr, [head, ...demoted, markFor(pr)].filter(Boolean).join('\n\n'), []).ok - } - const body = - demoted.length > 0 - ? [head, `couldn't anchor these to a changed line:\n\n${demoted.join('\n\n')}`, markFor(pr)].filter(Boolean) - : [head, markFor(pr)].filter(Boolean) - const r = submitReview(cfg, pr, body.join('\n\n'), inline) + const r = submitReview(cfg, pr, [head, markFor(pr)].filter(Boolean).join('\n\n'), inline) if (r.ok) { return true } diff --git a/src/sweep/prompt.ts b/src/sweep/prompt.ts index 66d0436..12bbde3 100644 --- a/src/sweep/prompt.ts +++ b/src/sweep/prompt.ts @@ -35,10 +35,10 @@ ${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. -- JSON matching the schema. +- Submit the verdict by calling \`review_verdict\` once, after the second pass. 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.${intent}${memory} + - \`findings\`: exact path/line for each inline comment, on a line this diff touches.${intent}${memory} # Diff ${diff}` diff --git a/src/sweep/review-one.ts b/src/sweep/review-one.ts index 3a1c31a..74f1c29 100644 --- a/src/sweep/review-one.ts +++ b/src/sweep/review-one.ts @@ -107,7 +107,7 @@ export async function reviewOne(cfg: Config, ref: string, post: boolean): Promis console.log([r.opener, ...r.findings.map((f) => f.body)].filter(Boolean).join('\n\n')) // default: print to stdout return } - if (!postReview(cfg, pr, r.opener, r.findings, diff)) { + if (!postReview(cfg, pr, r.opener, r.findings)) { console.error('stupify review: the review ran but posting it failed (gh).') process.exit(1) } diff --git a/src/sweep/review-pr.ts b/src/sweep/review-pr.ts index 1fdd1a5..643009a 100644 --- a/src/sweep/review-pr.ts +++ b/src/sweep/review-pr.ts @@ -135,7 +135,7 @@ export async function reviewPr( return 'fixed' } // A real review: post the validated findings as inline, resolvable threads. (parseReview guarantees ≥1 finding.) - if (!postReview(cfg, pr, r.opener, r.findings, diff)) { + 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/verdict.ts b/src/sweep/verdict.ts index b14601d..668a587 100644 --- a/src/sweep/verdict.ts +++ b/src/sweep/verdict.ts @@ -1,5 +1,5 @@ -// The review VERDICT contract: Codex returns ONE JSON object matching ReviewOutput (SDK `outputSchema` on the -// second turn), and parseReview is the boundary guard behind that enforcement. Also the marker / +// 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' @@ -24,8 +24,6 @@ export const ReviewOutput = z.strictObject({ ), }) export type ReviewOutput = z.infer -const { $schema: _schema, ...reviewSchema } = z.toJSONSchema(ReviewOutput) -export const REVIEW_SCHEMA = reviewSchema export interface ParsedFinding { path: string @@ -76,10 +74,6 @@ export function parseReview(data: ReviewOutput): ReviewVerdict { return { kind: 'findings', opener: data.opener, findings } } -export function parseReviewJson(raw: string): ReviewVerdict { - return parseReview(ReviewOutput.parse(JSON.parse(raw))) -} - // 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. From 90b5903c41ebd7a105d0ccf0ff889d985b5faea5 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Mon, 7 Sep 2026 22:38:27 -0400 Subject: [PATCH 2/5] Cut the CLI, prime hook, commit statuses, npm package, and the sweep status file. stupify is now the engine and nothing else: src/review-sweep.ts reads top to bottom as one sweep, src/sweep/* is the domain, and everything generic (codex session, exe.dev, host primitives) is @bevyl-ai/agent-tools. Gone, with their only readers: cli.ts and bin/ (provision, setup, upgrade, init, prime, status, review), prime.ts, status.ts (state/ status.json, read by `stupify status`), commit-status.ts (the GitHub App JWT minting behind one checkmark), review-one.ts (the on-demand review), the packs template, the release workflow, and the clack/picocolors deps. Rotation moves from review-pr.ts into the codex session's onTurnError hook, so the `raw` field on a limit outcome goes too. Deploy is two scripts and a runbook, earshot-style: deploy/vm-setup.sh (codex gateway config + bun, handed to `exe.dev new`) and deploy/push.sh (build, copy, first push writes config.env and installs the cron with GH_HOST on the cron line: gh reads it from the environment and a Bun process can't hand a runtime env write to a child spawn). DEPLOY.md is the runbook. README, AGENTS.md, ARCHITECTURE.md, and the landing page say the same thing. src: 2,967 lines -> 1,226. --- .github/workflows/ci.yml | 31 +- .github/workflows/release.yml | 85 --- .review/CORPUS.template.md | 77 --- AGENTS.md | 27 +- DEPLOY.md | 88 +++ README.md | 51 +- bin/stupify.js | 73 --- bun.lock | 16 - deploy/push.sh | 16 + deploy/vm-setup.sh | 29 + docs/ARCHITECTURE.md | 216 +++----- package.json | 42 +- site/src/pages/index.astro | 8 +- src/cli.ts | 976 ---------------------------------- src/prime.ts | 82 --- src/review-sweep.ts | 177 ++---- src/sweep/codex.ts | 35 +- src/sweep/commit-status.ts | 181 ------- src/sweep/config.ts | 30 +- src/sweep/pool.ts | 86 +-- src/sweep/prompt.ts | 4 +- src/sweep/review-one.ts | 115 ---- src/sweep/review-pr.ts | 42 +- src/sweep/state.ts | 6 +- src/sweep/status.ts | 144 ----- src/sweep/sweep.ts | 107 +--- 26 files changed, 359 insertions(+), 2385 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 .review/CORPUS.template.md create mode 100644 DEPLOY.md delete mode 100755 bin/stupify.js create mode 100755 deploy/push.sh create mode 100755 deploy/vm-setup.sh delete mode 100644 src/cli.ts delete mode 100644 src/prime.ts delete mode 100644 src/sweep/commit-status.ts delete mode 100644 src/sweep/review-one.ts delete mode 100644 src/sweep/status.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34b3c3a..0d82c43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,39 +40,14 @@ jobs: - name: Format run: bun run fmt:check - - name: Smoke the CLI - run: bun src/cli.ts --help - - - name: Bundle the sweep engine + - name: Build the engine run: | - bun build src/review-sweep.ts --target=bun --format=esm --outfile=/tmp/stupify-review-sweep.ts - if grep -E "from ['\"]@(stupify|bevyl-ai)/" /tmp/stupify-review-sweep.ts; then + bun run build + if grep -E "from ['\"]@(stupify|bevyl-ai)/" dist/review-sweep.ts; then echo "::error::review-sweep bundle still imports an external package" exit 1 fi - - name: Check package contents - run: | - set -euo pipefail - pack_tgz() { - dir="$1" - pattern="$2" - output="$(cd "$dir" && bun pm pack)" - printf '%s\n' "$output" >&2 - tgz="$(printf '%s\n' "$output" | grep -E "$pattern" | tail -n 1)" - if [ -z "$tgz" ]; then - echo "::error::could not find packed tarball for $dir" - exit 1 - fi - printf '%s/%s\n' "$dir" "$tgz" - } - - ROOT_TGZ="$(pack_tgz . '^stupify-cli-[0-9].*\.tgz$')" - TMP="$(mktemp -d)" - npm install --prefix "$TMP" "$ROOT_TGZ" - npm exec --prefix "$TMP" -- stupify --help - rm -rf "$TMP" "$ROOT_TGZ" - - name: Install site working-directory: site run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7694294..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Release - -# Publish `stupify` to npm when you publish a GitHub Release tagged `vX.Y.Z` (matching package.json), or on -# a manual run. A bundle step inlines the @stupify/exe-* workspace helpers so we ship ONE self-contained -# package; the npm bin is a tiny Node launcher that hands off to Bun. Auth/provenance come from the `npm` -# environment (trusted publishing + OIDC, bound to Octember/stupify). -on: - release: - types: [published] - workflow_dispatch: - -permissions: - contents: read - -jobs: - publish: - name: Publish stupify to npm - runs-on: ubuntu-latest - environment: npm - permissions: - contents: read - id-token: write # npm provenance / trusted publishing - steps: - - uses: actions/checkout@v5 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.14 - - - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Install - run: bun install --frozen-lockfile - - - name: Typecheck - run: bun run typecheck - - - name: Smoke the CLI - run: bun src/cli.ts --help - - # Bundle the CLI + engine so we publish ONE self-contained package: @bevyl-ai/agent-tools inlines at - # build time, so the published artifact has zero runtime deps beyond clack/picocolors. Runs on the - # ephemeral checkout only; the repo keeps the real dependency for development. - - name: Bundle into a single self-contained package - run: | - set -euo pipefail - bun build src/cli.ts --target=bun --format=esm --outfile=src/cli.bundled.ts - bun build src/review-sweep.ts --target=bun --format=esm --outfile=src/review-sweep.bundled.ts - mv src/cli.bundled.ts src/cli.ts - mv src/review-sweep.bundled.ts src/review-sweep.ts - if grep -E "from ['\"]@(stupify|bevyl-ai)/" src/cli.ts src/review-sweep.ts; then - echo "::error::bundle still imports an external package"; exit 1 - fi - node -e "const p=require('./package.json'); delete p.dependencies['@bevyl-ai/agent-tools']; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')" - - - name: Check package contents - run: | - set -euo pipefail - TGZ="$(npm pack --silent | tail -n 1)" - TMP="$(mktemp -d)" - npm install --prefix "$TMP" "$PWD/$TGZ" - "$TMP/node_modules/.bin/stupify" --help - rm -rf "$TMP" "$TGZ" - - - name: Guard version (tag matches package.json, not already published) - run: | - NAME="$(node -p "require('./package.json').name")" - VERSION="$(node -p "require('./package.json').version")" - TAG="${{ github.event.release.tag_name }}" - if [ -n "$TAG" ] && [ "$TAG" != "v$VERSION" ] && [ "$TAG" != "$VERSION" ]; then - echo "::error::release tag '$TAG' must be 'v$VERSION' (or '$VERSION')" - exit 1 - fi - if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then - echo "::error::$NAME@$VERSION is already on npm" - exit 1 - fi - - - name: Publish CLI - # One self-contained package, published via npm trusted publishing (OIDC) bound to Octember/stupify on - # npmjs.com (package → Settings → Trusted Publisher). No token, no helper packages. - run: npm publish --provenance --access public diff --git a/.review/CORPUS.template.md b/.review/CORPUS.template.md deleted file mode 100644 index f53a3ec..0000000 --- a/.review/CORPUS.template.md +++ /dev/null @@ -1,77 +0,0 @@ -# Good-code reference — YOUR curated exemplars (template) - -> This is a template. **Replace it with 3–6 files from your own codebase that you'd point a new hire at** — -> the code you wish all your code looked like. The reviewer treats these as the standard and measures every -> diff against them. Taste can't be auto-extracted: hand-pick these, and say _why_ each is good. A vague -> corpus produces vague reviews; a sharp one produces sharp ones. - -How to write an entry: - -- **Name the file** (a real path in this repo) and **one sentence on what makes it good** — the principle it - embodies (e.g. "complexity tamed by decomposition", "type makes illegal states unrepresentable", - "fail-fast at the boundary"). The reviewer opens the live file; the excerpt just shows the shape. -- Keep a short code excerpt that captures the pattern. The point is the _principle_, not the lines. -- Group loosely (e.g. "complex but readable", "clean service boundary") so the reviewer can cite the right one. - -Pick principles you actually care about. Common ones worth encoding: -**dependency injection** (collaborators injected, never `new`d inline; config read only at a composition root), -**type-system-first invariants** (`satisfies`, discriminated unions, schemas at boundaries — illegal states -hard to represent), **fail fast and loud** (no silent fallback), **small single-responsibility units**, -**declarative over imperative**, **readable signatures** (≤3 positional params → options object). - ---- - -## A. Complex, kept readable - -### 1. `src/path/to/your-exemplar.ts` — one line on why it's good - -`src/path/to/your-exemplar.ts` - -Say what makes it the standard — e.g. the complexity (optimistic UI, retries, sync) is tamed by decomposition: -the orchestrator only _coordinates_; every concern is a small focused unit, and every operation is the same -shape, so N of them read like one. - -```ts -// a short excerpt that shows the pattern — the shape, not the whole file -export function handle(input: Input): Result { - const state = read() - const ops = compute(state, input) // pure - return apply(ops) // effectful shell -} -``` - -### 2. `src/path/to/another.ts` — composition + named pieces - -`src/path/to/another.ts` - -e.g. pure composition — each piece a named small component, conditions become named type-guards, not inline -boolean soup. - -```ts -function hasMeasuredWidth(width: number | undefined): width is number { - return width !== undefined && width > 0 -} -``` - ---- - -## B. Clean boundary / DI - -### `src/path/to/service.ts` — injected collaborator + composition-root factory - -`src/path/to/service.ts` - -e.g. constructor injection — the collaborator is never `new`d inline; a small factory is the composition root; -the method parses input at the boundary, logs with structured context, and **fails loud** (catch → log → rethrow). - -```ts -export function createService() { - const scope = container.createChildContainer() - scope.register(CLIENT, { useValue: makeClient() }) - return scope.resolve(Service) -} -``` - ---- - -> Add a "Fine — do NOT flag" set of your own here too, if there are patterns reviewers keep wrongly dinging. diff --git a/AGENTS.md b/AGENTS.md index 82bf828..68811bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,11 @@ -# stupify — agent guide +# stupify -**stupify** is a code reviewer that runs on Codex and judges PRs against a curated "good code" corpus + an -anti-slop rubric. Read `README.md` and `docs/ARCHITECTURE.md` first. +The code is the spec; `src/review-sweep.ts` reads top to bottom as what one sweep does. -## Layout +- Codex through `@bevyl-ai/agent-tools` on the exe.dev gateway, never the Claude API. The verdict is a tool call, never parsed prose. +- One Bun file on a cron, `config.env` beside it, state in three JSON files. No server, no database, no CLI. +- Only real reviews reach a PR. Failures are logged and throttled locally, never posted. +- Every `gh --json` boundary is `Schema.parse`d; a malformed row throws, it does not skip. +- Smallest change that solves it. Before keeping anything, name its second reader or writer; otherwise delete it. -- `src/cli.ts` — the `stupify` command: a `@clack/prompts` setup wizard + `run`. The only interactive surface. -- `src/review-sweep.ts` — the engine. Bun; shells out to `git`/`gh`/`codex`. The CLI deploys - a copy to `~/.stupify/` and a cron runs it. Runs `main()` only when invoked directly (`if (import.meta.main)`), - so it stays importable for tests — but keep it standalone and spawn it from the CLI, never `import` it. -- `.review/` — the **taste templates** (`REVIEW-PROMPT.md`, `RUBRIC.md`, `CORPUS.md`). These get copied into - the _target_ repo and edited there; in this repo they're the starting point. - -## Rules - -- Smallest change that solves it; deleting/simplifying beats adding layers. Treat new code as a cost. -- `bun run typecheck` must pass (strict, `noUncheckedIndexedAccess`). No `as` assertions on external JSON — - `Schema.parse(JSON.parse(...))` at the boundary. Malformed `gh --json` throws; don't skip the row. -- The engine validates every `gh --json` boundary and fails LOUD (posts an error comment) rather than silently. - Keep that property. -- Never publish to npm or push public changes without the operator asking. +`bun run check` gates every commit: typecheck, lint, fmt, build. Deploy is `deploy/push.sh ` (DEPLOY.md). diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..fe6d244 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,88 @@ +# Deploying stupify + +stupify is one Bun file on a cron, next to a `config.env`. It rides [exe.dev](https://exe.dev): a GitHub +integration proxies `gh` for the repo and the `llm` integration fronts your ChatGPT plan for codex, so the box +holds no tokens. Onboard once with `ssh exe.dev`. + +## Provision a reviewer for a repo + +```sh +git clone https://github.com/Octember/stupify && cd stupify && bun install +ssh exe.dev integrations add github --name stupify-acme-widgets --repository acme/widgets +ssh exe.dev new --name stupify-acme-widgets --integration stupify-acme-widgets --setup-script /dev/stdin < deploy/vm-setup.sh +ssh exe.dev integrations attach llm vm:stupify-acme-widgets +deploy/push.sh stupify-acme-widgets acme/widgets stupify-acme-widgets.int.exe.xyz +``` + +`vm-setup.sh` writes the keyless codex config and installs bun. `push.sh` builds `src/review-sweep.ts` into one +file and copies it to `~/.stupify/review-sweep.ts`; a first push (three arguments) also writes `config.env` and +installs the minute cron, with `GH_HOST=.int.exe.xyz` on the cron line so `gh` talks to the +integration. The next cron tick runs it. Reviews land on open PRs within about a minute of a push. + +## Taste + +The reviewer reads `.review/` from the target repo's default branch: `REVIEW-PROMPT.md` (the spec), +`RUBRIC.md` (what counts as slop), `CORPUS.md` (the code yours should look like). Start from this repo's own +`.review/`. A repo without one falls back to `~/.stupify/.review` on the box, which you place by hand: + +```sh +scp -r .review stupify-acme-widgets.exe.xyz:.stupify/.review +``` + +## config.env + +Every knob is a line in `~/.stupify/config.env`, read fresh each sweep. A one-shot env var wins over the file. + +| key | default | meaning | +| --------------------------- | ----------------- | ---------------------------------------------------------------------- | +| `REPO_SLUG` | required | `owner/repo` | +| `DEFAULT_BRANCH` | `main` | branch the checkout tracks and `.review/` is read from | +| `REVIEW_DIR` | `.review` | taste dir inside the repo | +| `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_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 | +| `CODEX_JOBS` | `3` | concurrent codex sessions | +| `CODEX_MODEL` | codex default | `-c model=…` for the session | +| `CODEX_EFFORT` | `high` | `model_reasoning_effort` | +| `CODEX_GATEWAY_POOL` | (off) | ordered `llm` hosts to rotate through on a quota wall | +| `CODEX_ROTATE_COOLDOWN_MIN` | `10` | minimum minutes between rotations | +| `DRY_RUN` | `0` | list what would be reviewed, run no codex, post nothing | +| `STUPIFY_HOME` | beside the bundle | where `config.env`, `state/`, `repo/`, `worktrees/` live | + +## Update + +```sh +deploy/push.sh stupify-acme-widgets +``` + +## Watch it + +```sh +ssh stupify-acme-widgets.exe.xyz tail -f .stupify/state/sweep.log +``` + +- `reviewing PR #N @ sha (base main)` then `#N done (2 inline, 1 blocking)`, `#N clean first pass — posted LGTM ✅`, or `#N nothing new — posted still ✅`: working. +- `review FAILED for #N — `: codex failed; the reason is the gateway's or the kit's. A usage wall ends the sweep and rotates the gateway if `CODEX_GATEWAY_POOL` is set. +- `gh pr list failed — …` / `gh api pulls failed — …`: the GitHub integration; run the same `gh` command on the box. +- `refresh failed`: the checkout; check `DEFAULT_BRANCH` and the integration. +- `skip #N — diff … > cap`: the size cap, not a fault. + +State is three JSON files under `~/.stupify/state/`: `reviewed.json` (heads already reviewed), `failures.json` +(heads to leave alone until `FAIL_RETRY_MIN` passes), `daily.json`. Delete a PR's key from `failures.json` to +retry it on the next tick. + +## Run it anywhere else + +Any box with `bun`, `gh` (authed for the repo), and `codex` (logged in) works the same way: put the bundle and +`config.env` in a directory, run `bun review-sweep.ts` from cron. `STUPIFY_HOME` points it elsewhere. Behind an +exe.dev GitHub integration, put `GH_HOST=.int.exe.xyz` in the environment (the cron line), not +in `config.env`: `gh` reads it from the environment. + +## Tear down + +```sh +ssh exe.dev rm stupify-acme-widgets +``` diff --git a/README.md b/README.md index 9a529f9..25e2c79 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ **AI agents are rats in a maze. They reach for what they know.** And unless you show them better, what they know is slop: [most software is garbage](https://github.com/openai/codex/issues/28224), and they'll [happily](https://github.com/thesysdev/openui/issues/517) [imitate](https://github.com/RsyncProject/rsync/issues/929) [it](https://github.com/anthropics/claudes-c-compiler/issues/1). -[![npm](https://img.shields.io/npm/v/@stupify/cli?color=cb3837&label=%40stupify%2Fcli)](https://www.npmjs.com/package/@stupify/cli) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![A real stupify review with four kinds of finding in one pass: a high-confidence bug, a fail-open footgun, a reinvented SDK primitive, and a dead config seam.](docs/proof/00-slop.png) @@ -20,52 +19,26 @@ _actual issues, tells the coding agent exactly how + what to fix_ **[more catche - **On your personal Codex plan.** stupify reviews with [Codex](https://github.com/openai/codex), running on the $20-$200/month plan. API usage is roughly 50x more expensive, enjoy the subsidized tokens while they last - **Slop, named.** Code review is cheap. Taste is expensive. Codify the goodies, let the LLM pattern match -## Add the reviewer +## Run it -```bash -npx @stupify/cli -``` - -``` -┌ stupify -◇ using integration acme-widgets -◇ VM stupify-acme-widgets created -└ stupify is provisioned for acme/widgets 👀 -``` - -stupify rides on [exe.dev](https://exe.dev) with no keys or servers to run. Setup takes about two minutes and doesn't require payment. +stupify is one Bun file on a cron, next to a `config.env`. It rides [exe.dev](https://exe.dev): a GitHub +integration proxies `gh` and the `llm` integration fronts your ChatGPT plan for codex, so the box holds no tokens. ```bash -npx @stupify/cli # provision for a specific repo -npx @stupify/cli setup # run the reviewer on this machine instead of a VM -npx @stupify/cli status # show the latest sweep as a workflow -ssh exe.dev rm stupify-- # tear it down +git clone https://github.com/Octember/stupify && cd stupify && bun install +ssh exe.dev integrations add github --name stupify-acme-widgets --repository acme/widgets +ssh exe.dev new --name stupify-acme-widgets --integration stupify-acme-widgets --setup-script /dev/stdin < deploy/vm-setup.sh +ssh exe.dev integrations attach llm vm:stupify-acme-widgets +deploy/push.sh stupify-acme-widgets acme/widgets stupify-acme-widgets.int.exe.xyz ``` -Every live sweep also posts a GitHub commit status named `stupify/review` on the PR head commit: pending while -queued/running, success when reviewed or policy-skipped, failure when stupify posts findings, and error when the -reviewer itself failed. Set `GITHUB_STATUS=0` in `~/.stupify/config.env` to turn that off, or -`GITHUB_STATUS_CONTEXT=your/context` to rename it. - -If the `gh` identity the sweep runs under can't write commit statuses (e.g. a proxy integration whose token is -statuses:read-only), give stupify its own GitHub App: create an App with **Commit statuses: Read & write**, -install it on the repo, then set `GITHUB_STATUS_APP_ID=` and `GITHUB_STATUS_APP_KEY=` in `config.env`. Statuses then post via the App (short-lived installation tokens, minted and cached by the -sweep); everything else still goes through `gh`. - -### Connect your accounts - -The reviews run on Codex. On exe.dev that's a keyless **LLM integration**: it fronts your ChatGPT/Codex plan, so -the VM holds no API key and your plan is billed instead. Link one once at [exe.dev/integrations](https://exe.dev/integrations) -and provisioning attaches it for you +Open a PR and it's reviewed within a minute. Runbook, every knob, and how to read the log: [DEPLOY.md](DEPLOY.md). ## Your taste -Point stupify at the files you _wish_ all your code looked like, and it scaffolds a `.review/` in your repo: - -```bash -npx @stupify/cli init src/best.ts src/clean-service.ts # inlines them; you add one line of "why" each -``` +A `.review/` in the repo it reviews: `REVIEW-PROMPT.md` (the spec), `RUBRIC.md` (what counts as slop), and +`CORPUS.md` (the code yours should look like). Start from this repo's own, then point `CORPUS.md` at the files you +_wish_ all your code looked like, with one line each on why. ## License diff --git a/bin/stupify.js b/bin/stupify.js deleted file mode 100755 index aeb50d6..0000000 --- a/bin/stupify.js +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { createInterface } from 'node:readline/promises' -import { fileURLToPath } from 'node:url' - -const root = dirname(dirname(fileURLToPath(import.meta.url))) -const cli = join(root, 'src', 'cli.ts') -const installCommand = 'curl -fsSL https://bun.sh/install | bash' - -function run(cmd, args) { - return spawnSync(cmd, args, { stdio: 'inherit' }) -} - -function runStupify() { - return run('bun', [cli, ...process.argv.slice(2)]) -} - -async function installBun() { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - console.error('stupify runs on Bun. Install it, then re-run this command:') - console.error(` ${installCommand}`) - process.exit(127) - } - - const rl = createInterface({ input: process.stdin, output: process.stdout }) - const answer = await rl.question(`stupify runs on Bun. Install Bun now? [Y/n] `) - rl.close() - if (/^no?$/i.test(answer.trim())) { - console.error(`Install Bun later with: ${installCommand}`) - process.exit(127) - } - - const dir = mkdtempSync(join(tmpdir(), 'stupify-bun-')) - const installer = join(dir, 'install.sh') - try { - const downloaded = run('curl', ['-fsSL', 'https://bun.sh/install', '-o', installer]) - if (downloaded.error) { - console.error(downloaded.error.message) - process.exit(1) - } - if (downloaded.status !== 0) { - process.exit(downloaded.status ?? 1) - } - - const installed = run('bash', [installer]) - if (installed.error) { - console.error(installed.error.message) - process.exit(1) - } - if (installed.status !== 0) { - process.exit(installed.status ?? 1) - } - process.env.PATH = `${process.env.HOME}/.bun/bin:${process.env.PATH ?? ''}` - } finally { - rmSync(dir, { recursive: true, force: true }) - } -} - -let result = runStupify() -if (result.error?.code === 'ENOENT') { - await installBun() - result = runStupify() -} - -if (result.error) { - console.error(result.error.message) - process.exit(1) -} - -process.exit(result.status ?? 1) diff --git a/bun.lock b/bun.lock index f70c07e..c7632fd 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,6 @@ "name": "stupify", "dependencies": { "@bevyl-ai/agent-tools": "0.13.0", - "@clack/prompts": "^1.2.0", - "picocolors": "^1.1.1", "zod": "^4.4.3", }, "devDependencies": { @@ -22,10 +20,6 @@ "packages": { "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.13.0", "", { "dependencies": { "zod": "^4.4.3" } }, "sha512-UfmU4uz4oAWll6yME/1Ehf+yJBM63ubC/NZGloS8+qcx2jMvsHjrOKwHutkROKwgiNSaFxRq+y9bcaES0ASFFw=="], - "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], - - "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw=="], @@ -148,20 +142,10 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], - - "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], - "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=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "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=="], diff --git a/deploy/push.sh b/deploy/push.sh new file mode 100755 index 0000000..b3f0df8 --- /dev/null +++ b/deploy/push.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the engine and put it on a reviewer VM. The minute cron runs the new bundle on its next tick; nothing +# restarts. A first push also writes config.env and installs the cron. GH_HOST rides on the cron line, not in +# config.env: gh reads it from the environment, and a Bun process can't hand a runtime env write to a child. +# deploy/push.sh stupify-acme-widgets # update the engine +# deploy/push.sh stupify-acme-widgets acme/widgets stupify-acme-widgets.int.exe.xyz # first push +set -euo pipefail +vm="${1:?usage: deploy/push.sh [owner/repo .int.exe.xyz]}" +host="$vm.exe.xyz" +bun build src/review-sweep.ts --target=bun --format=esm --outfile=dist/review-sweep.ts +scp -q dist/review-sweep.ts "$host:/tmp/review-sweep.new.ts" +if [ $# -ge 3 ]; then + ssh "$host" "printf 'REPO_SLUG=%s\n' '$2' > \"\$HOME/.stupify/config.env\" && (crontab -l 2>/dev/null | grep -v review-sweep.ts || true; echo \"*/1 * * * * GH_HOST=$3 \$HOME/.bun/bin/bun \$HOME/.stupify/review-sweep.ts >> \$HOME/.stupify/state/cron.log 2>&1\") | crontab -" +fi +ssh "$host" 'chmod +x /tmp/review-sweep.new.ts && mv /tmp/review-sweep.new.ts "$HOME/.stupify/review-sweep.ts"' +echo "pushed $(shasum dist/review-sweep.ts | cut -c1-8) to $vm" diff --git a/deploy/vm-setup.sh b/deploy/vm-setup.sh new file mode 100755 index 0000000..3e458fd --- /dev/null +++ b/deploy/vm-setup.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# First boot of a stupify reviewer VM on exe.dev (hand it to `ssh exe.dev new --setup-script`): keyless codex via +# the exe-llm gateway, bun, and the state dir. The engine, config.env, and the cron arrive with deploy/push.sh. +set -e +export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH" +mkdir -p "$HOME/.codex" "$HOME/.stupify/state" + +# Keyless codex: the `llm` integration fronts a ChatGPT plan, so the box holds no API key. Left alone if a +# provider is already configured (codex writes its own trust entries here between runs). +if ! grep -q model_provider "$HOME/.codex/config.toml" 2>/dev/null; then + cat > "$HOME/.codex/config.toml" </dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash + +echo "stupify vm-setup done" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d51f95..1150118 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,155 +1,89 @@ # Architecture -stupify is a bundled Bun review engine (`review-sweep.ts`), a dependency-free prime hook (`prime.ts`), and a CLI -(`cli.ts`) that wires them up, all driving the same three markdown files that encode taste. This doc covers how -the pieces fit, and why. +stupify is one bundled Bun review engine (`src/review-sweep.ts` and `src/sweep/*`) on a cron, driving three +markdown files that encode taste. Codex, exe.dev, and the host primitives come from +[`@bevyl-ai/agent-tools`](https://github.com/bevyl-ai/agent-tools). This doc covers how the pieces fit, and why. ## Two halves: engine vs taste -The hard part of an AI reviewer is what it reviews against, not the loop. So the two concerns are split: the -generic engines, and the taste they read. +The hard part of an AI reviewer is what it reviews against, not the loop. So the two concerns are split. -| | Lives in | Is | -| ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| **Engines** | this repo (`review-sweep.ts`, `prime.ts`, `cli.ts`) plus `packages/exe-*` | generic infra that shells out to `git`/`gh`/`codex`, or just reads files | -| **Taste** | `.review/` (a repo's own, else `~/.stupify/.review`) | `REVIEW-PROMPT.md` (spec), `RUBRIC.md` (anti-slop), `CORPUS.md` (your good code) | +| | Lives in | Is | +| ---------- | ---------------------------------------------------- | --------------------------------------------------------------------------------- | +| **Engine** | this repo, plus the kit | generic infra that shells out to `git`/`gh` and drives a codex app-server session | +| **Taste** | `.review/` (a repo's own, else `~/.stupify/.review`) | `REVIEW-PROMPT.md` (spec), `RUBRIC.md` (anti-slop), `CORPUS.md` (your good code) | A `.review/` _inside the repo being reviewed_ is version-controlled with the code it judges, visible in code -review, and tuned through a normal PR, the same way you'd change a lint config. When a repo has none, both -engines fall back to `~/.stupify/.review`, a global taste you place by hand. The reviewer -reads it fresh from `origin/main` on every sweep, so a merged rubric change is live immediately. - -## Two ends of the loop: prevent, then detect - -The same taste drives two engines at opposite ends of the coding loop: - -- **`prime.ts` (prevention).** A Claude Code `SessionStart` hook (wired by `stupify prime --install`) runs - `bun ~/.stupify/prime.ts` at the start of every session. It resolves the taste (repo `.review/` wins, else - home), inlines the rubric + corpus index, and emits a `{hookSpecificOutput:{additionalContext}}` payload so - the agent holds your standard _before_ it writes a line. Pure file read: no model, no network, ~30ms. It - **never throws**: any miss or error emits nothing and exits 0, because a hook must not break session start. - stdout is _only_ the JSON payload (a stray byte makes Claude Code drop it). -- **`review-sweep.ts` (detection).** The cron reviewer below catches whatever drifted, against the same taste. - -Encode taste once, enforce it at both ends. The best review is the one you didn't need. - -## The sweep loop - -A cron job runs the sweep every minute (`*/1 * * * *`); the sweep self-locks so two never overlap. Each run: - -1. **Refresh** a dedicated checkout (`$STUPIFY_HOME/repo`) to `origin/` (default `main`) via - `fetch && checkout && reset --hard`. - This checkout is _hard-pinned_ and never a working tree you care about, because we destructively reset it. -2. **List** open PRs via `gh pr list --json` (with an explicit high `--limit` — gh's default of 30, newest-first, - silently drops older PRs off the sweep's radar on a busy repo). In `SCOPE=auto` (the default) it keeps all - non-draft PRs under `DIFF_LINE_CAP`, with `REVIEW_LABEL` as a force-include override for oversized ones; - `SCOPE=label` flips to opt-in (only labelled PRs). Bot and draft authors are skipped in _either_ scope (`gh`'s - `is_bot` flag) — unless the PR carries `REVIEW_LABEL`, which force-includes a bot-authored PR you deliberately - opted in. The JSON is `Pr.parse`'d at the boundary — a malformed list or entry throws rather than - skipping mid-loop. -3. **Dedup.** For each candidate it reads the PR's comments and skips if one already contains the hidden marker - `` for the _current_ head. A new push moves the SHA, the marker no longer matches, and - it re-reviews. **One review per head.** (Failures aren't posted, see _Safety_, so there's no fail marker; - failed heads are throttled in local state instead.) The marker check falls back to "any comment" when - `gh api user` is unavailable (a GitHub-App integration 403s on it), so dedup never silently re-reviews forever. -4. **Build memory** from the remaining comments (see below). -5. **Review.** The _runner_ fetches the diff via GitHub's compare API (`baseRefOid...headRefOid`), so stacked PRs - whose base is another feature branch diff against that base, not `main`. It spins a detached worktree at the PR - head SHA (`$STUPIFY_HOME/worktrees/-`) so codex reads the same tree the diff describes, then feeds the - diff to `codex exec` over **stdin**, in a `workspace-write` sandbox restricted to `/tmp` with **network off and - no `gh`**. The runner resumes that thread once to challenge duplicate ownership, then posts the final verdict. - Candidates are collected serially (all the cheap gh gates), then reviewed by a pool of up to `CODEX_JOBS` - (default 3) concurrent review sessions — a busy sweep's wall-clock is the slowest review, not the sum of them. A - quota wall from any run stops new launches while in-flight runs drain. -6. **Cap.** `MAX_PRS` limits PRs _actually reviewed_ per sweep, counted only after the cheap dedup skips, so a - backlog of already-reviewed PRs at the front of the list can't starve later ones. - -Along the way the sweep writes `state/status.json`, a best-effort workflow snapshot of the current stage and each -PR's disposition (queued, reviewing, posted, clean, skipped, deferred, failed, or dry-run). `stupify status` reads -that file and renders the latest sweep without touching GitHub or posting anything to PRs. - -Live sweeps also post a best-effort commit status on the PR head SHA (`stupify/review` by default). It is -append-only on GitHub's side, so stupify keeps `state/commit-statuses.json` as a tiny dedupe cache and only posts -when the state/description changes. Status posting is never required for review progress: if the API call fails, -the sweep logs it and keeps reviewing/commenting. `DRY_RUN` never posts GitHub statuses. +review, and tuned through a normal PR, the same way you'd change a lint config. The reviewer reads it fresh from +`origin/main` on every sweep, so a merged rubric change is live immediately. + +## The sweep + +A cron runs the sweep every minute; the sweep self-locks so two never overlap. Each run: + +1. **Refresh** a dedicated checkout (`$STUPIFY_HOME/repo`) to `origin/` with a hard reset. It is + never a working tree you care about. +2. **List** open PRs via `gh pr list --json` with a high explicit `--limit` (gh's default of 30, newest-first, + silently drops older PRs on a busy repo) and base SHAs from the REST pulls list, paged by hand: `--paginate` + follows GitHub's Link header to api.github.com verbatim and escapes an exe.dev proxy. `SCOPE=auto` keeps every + non-draft, non-bot PR under `DIFF_LINE_CAP`, with `REVIEW_LABEL` as the force-include for oversized or bot + PRs; `SCOPE=label` flips to opt-in. Every JSON boundary is `parse`d; a malformed row throws. +3. **Dedup.** A posted review carries ``. Same head, no re-run. A push moves the SHA + and re-arms it. Suppressed no-ops post nothing, so local state catches those. Failed heads are throttled in + local state too, never posted. +4. **Diff** via the compare API (`base...head`), so a stacked PR diffs against its base, not `main`. GitHub 406s + past its own size limits; that is terminal, so the PR is skipped, not retried forever. +5. **Review.** A detached worktree at the head SHA gives codex the tree the diff describes. The kit's + `AppServerSession` runs two turns in a read-only sandbox with no network and no `gh`: the review prompt (taste + paths, the PR's stated intent, its prior review thread, the diff), then the hand-written second pass. The only + structured channel is one tool, `review_verdict`. Candidates are gated serially and reviewed by up to + `CODEX_JOBS` sessions at once; a quota wall stops new launches while in-flight runs drain. +6. **Act.** Findings post as one COMMENT review with inline, resolvable threads. A clean first pass posts `LGTM ✅` + once; a clean re-review with nothing outstanding posts `still ✅`; prior findings resolved by the diff resolve + their threads and post `nice, all fixed ✅`; a clean head while findings still stand stays silent. Every ✅ is + honest: it fires only when no stupify finding is open. + +`MAX_PRS` caps PRs _actually reviewed_ per sweep, counted after the dedup skips, so a backlog of reviewed PRs at +the front of the list can't starve the rest. + +## The verdict is a tool call + +Codex never returns prose the runner parses. It calls `review_verdict` with `{ verdict, opener, findings[] }`. +The kit validates the arguments against the zod schema mid-turn and hands the error back to the model as the +tool result; the tool's own `run` throws for what a schema can't say: an anchor that isn't a right-side line the +diff touches (the only lines GitHub threads on), or a convergence verdict that carries findings. The model +corrects itself before the turn ends. No call by the end of the second turn is a failure that retries later, +never a clean. ## Per-PR memory (and why it replaced debounce) -The first version had a 5-minute **debounce**: a push started a clock, and a PR was only reviewed once its head -had been stable for 5 minutes, so a burst of commits collapsed into one review instead of one per commit. - -It worked, but it made the reviewer feel _dead_: you'd push and wait. And it was solving the wrong problem. The -real fix for "don't spam me" is **memory**, not delay: - -- Before each review, the engine collects the PR's existing comments, drops CI bots, strips the hidden markers, - and passes the recent thread (bounded to the last 20) into the prompt as _"your past reviews and the author's - replies."_ -- The prompt's **"Prior reviews on this PR"** rules tell the model: don't re-raise resolved or - reasoned-declined items, and report only what's genuinely new. When there's no new finding it emits one of two - tokens: `STUPIFY_FIXED` if the issues it raised earlier are now resolved by the diff (the runner posts a - one-time **"nice, all fixed ✅"**, gated on there having actually been open findings, so it can't repeat or fire - on a never-flagged PR), or `STUPIFY_NO_NEW_ISSUES` otherwise (clean, or prior items still open). On that second - token the runner posts a one-time **`LGTM ✅`** if it's a clean PR stupify has never flagged (so "reviewed and - good" is visible, not indistinguishable from "not run yet"), a one-line **`still ✅`** on a clean head with - nothing outstanding (so every reviewed head carries a marker-bearing verdict — pure silence made the newest - push look unreviewed to per-head merge gates), and stays silent only while its own findings remain open. Every - ✅ it posts is honest: it only fires when no stupify finding is open, and "all fixed" means actually fixed. - -The GitHub thread **is** the memory store. It survives restarts, and it already contains the author's replies -(a separate state file wouldn't). With memory, a mid-burst re-review _sees its prior reviews and converges_ -instead of repeating, which is what debounce was really for. So debounce became pure latency and was deleted. -A push now gets reviewed within ~60s, and the Nth review of a PR is short because it only covers the delta. - -The root cause was statelessness: it made the reviewer both re-litigate forever and never know when to stop. -Feed the conversation back in and both problems go away. - -## Safety & failure handling - -- **Failures stay off the PR.** If `codex` can't run (provider down, usage limit, timeout, ENOENT), the sweep - LOGS the captured cause (operator-facing) and records the failed head in local state so it doesn't re-hammer - the dead provider every minute. It does _not_ post a "couldn't review" comment, because that's noise the PR - author can't act on. **Only real reviews ever reach the PR.** `spawnSync`'s `signal`/`error` are folded into - the captured output so a timeout surfaces as "killed by SIGTERM", not "no output". -- **Config fails toward safe.** Knobs validate and warn on garbage (`MAX_PRS=15lol` → logged, default used). - `DRY_RUN` is the exception that fails _safe_: a set-but-invalid value (`DRY_RUN=ture`) falls back to preview, - never live. A typo'd safety switch must not start posting. -- **Bounded spend.** `SCOPE=label` (opt-in) + `MAX_PRS` (per sweep) + `MAX_REVIEWS_PER_DAY` (the daily ceiling) + - per-head dedup cap what gets reviewed; a usage/rate-limit ends the sweep early instead of failing every - remaining PR; `DRY_RUN` lets you see what _would_ be reviewed before spending a token. -- **Single-flight.** The sweep takes its own `state/sweep.lock` (O_EXCL create; a lock older than 30 min is - treated as stale from a crash and stolen), with no `flock` dependency, so it runs anywhere `bun` does. - -## Codex specifics - -The engine calls, in full. The prompt (rubric + corpus + the **inlined diff**) arrives on **stdin**, not argv, so -a big diff can't blow `ARG_MAX`: - -``` -gh pr diff --repo # the RUNNER fetches the diff -codex exec --json --cd /repo --sandbox workspace-write \ - -c model_reasoning_effort= \ - -c sandbox_workspace_write.network_access=false \ - -c 'sandbox_workspace_write.writable_roots=["/tmp"]' \ - - # prompt (diff inlined) on stdin -codex exec resume --json - # ownership challenge on stdin -gh pr comment --repo --body-file # the RUNNER posts -``` - -Codex runs **locked down**: no network and no `gh` of its own. The runner does all GitHub I/O and hands Codex the -diff in the prompt. The PR diff and the prior-review thread are _attacker-controlled_ (any contributor can push -code or comment), so this matters: a prompt-injected diff or comment can at worst make Codex write a junk _review -file_; it can't exfiltrate, reach the network, or touch the GitHub token. (`--cd` points it at the dedicated -checkout for read-only context; only `/tmp` is writable.) It does _not_ pin a provider or model by default; -Codex uses whatever auth you've configured. `CODEX_PROVIDER` (`-c model_provider=…`) and `CODEX_MODEL` -(`-c model=…`) let you point it at a specific gateway or model. There's no API key in stupify itself; -credentials are Codex's concern. - -If your Codex rides a pool of interchangeable gateway accounts (e.g. exe.dev `llm` integrations, each fronting -a ChatGPT plan), `CODEX_GATEWAY_POOL` (ordered comma-separated hostnames) lets the sweep self-heal a quota -wall: when a review dies rate-limited, it rewrites the gateway hostname in `~/.codex/config.toml` to the next -pool entry — Codex re-reads the file each sweep, so the next sweep runs on the fresh account. No probing (the -real failure is the signal) and at most one step per `CODEX_ROTATE_COOLDOWN_MIN` (default 10), so a fully -drained pool cycles calmly until a weekly reset rescues it. Unset = off. +The first version had a 5-minute debounce: a push started a clock, and a PR was reviewed once its head had been +stable for 5 minutes, so a burst of commits collapsed into one review. It made the reviewer feel dead, and it +solved the wrong problem. The real fix for "don't spam me" is memory, not delay. + +Before each review the engine reads the PR's existing review thread, drops CI bots, strips the hidden markers, +neutralizes any fence tags (the thread is attacker-controlled), and passes the recent thread into the prompt as +"your past reviews and the author's replies". The prompt tells the model not to re-raise resolved or +reasoned-declined items and to report only what's new. The GitHub thread _is_ the memory store: it survives +restarts and already holds the author's replies. A mid-burst re-review sees its prior reviews and converges, +which is what debounce was for, so debounce was deleted. A push is reviewed within about 60s and the Nth review +of a PR covers only the delta. + +## Safety + +- **Failures stay off the PR.** A dead gateway, a usage wall, a stall, a turn that never submits a verdict: the + sweep logs the cause for the operator and records the head locally so it doesn't re-hammer every minute. Only + real reviews ever reach a PR. +- **Codex is locked down.** Read-only sandbox, no network, no `gh`. The runner does all GitHub I/O and hands + codex the diff in the prompt. A prompt-injected diff or comment can at worst produce a junk verdict; it can't + exfiltrate, reach the network, or touch a token. Secret-looking env vars are scrubbed from the child. +- **Config fails toward safe.** Knobs validate and warn on garbage; `DRY_RUN` on a typo falls to preview, never + live. +- **Bounded spend.** `MAX_PRS` per sweep, `MAX_REVIEWS_PER_DAY`, per-head dedup, and a wall ends the sweep + early. If `CODEX_GATEWAY_POOL` names a ring of exe.dev `llm` integrations, a wall rotates `~/.codex/config.toml` + to the next one, at most once per `CODEX_ROTATE_COOLDOWN_MIN`; codex re-reads it per session. +- **Single-flight.** The sweep takes `state/sweep.lock`; a lock older than 30 minutes is treated as a crash and + stolen. ## Why curated, not inferred diff --git a/package.json b/package.json index 8506baa..35ce19c 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,25 @@ { - "name": "@stupify/cli", - "version": "0.4.7", + "name": "stupify", + "version": "0.5.0", + "private": true, "description": "A code reviewer that talks like an idiot and catches real bugs — corpus-grounded, anti-slop, runs on Codex.", - "keywords": [ - "ai", - "anti-slop", - "code-review", - "codex", - "developer-tools", - "pull-request" - ], "homepage": "https://stupif.ai", - "bugs": { - "url": "https://github.com/Octember/stupify/issues" - }, "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/Octember/stupify.git" }, - "bin": { - "stupify": "bin/stupify.js" - }, - "files": [ - "bin/stupify.js", - "src/cli.ts", - "src/prime.ts", - "src/review-sweep.ts", - "src/hand-written-prompts.ts", - "src/sweep", - ".review", - "README.md", - "LICENSE" - ], "type": "module", - "publishConfig": { - "access": "public", - "provenance": true - }, "scripts": { "typecheck": "tsc -p tsconfig.json", "lint": "oxlint", "fmt": "oxfmt", "fmt:check": "oxfmt --check", - "cli": "bun src/cli.ts" + "build": "bun build src/review-sweep.ts --target=bun --format=esm --outfile=dist/review-sweep.ts", + "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run build" }, "dependencies": { "@bevyl-ai/agent-tools": "0.13.0", - "@clack/prompts": "^1.2.0", - "picocolors": "^1.1.1", "zod": "^4.4.3" }, "devDependencies": { @@ -59,7 +30,6 @@ "typescript": "^7.0.2" }, "engines": { - "bun": ">=1.3", - "node": ">=18" + "bun": ">=1.3" } } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 53b4dfe..bce1082 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -3,8 +3,7 @@ import '../styles/global.css'; const GH = 'https://github.com/Octember/stupify'; -const NPM = 'https://www.npmjs.com/package/@stupify/cli'; -const INSTALL = 'npx @stupify/cli'; +const INSTALL = 'git clone https://github.com/Octember/stupify'; const gallery = [ { @@ -68,7 +67,6 @@ const gallery = [ 🧙 stupify @@ -156,7 +154,7 @@ const gallery = [

Stop shipping slop

-

One command. Open Claude Code primed, or open a PR and let it review.

+

Clone it, hand it a repo, open a PR.

${INSTALL} @@ -176,7 +174,7 @@ const gallery = [

diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 87e8447..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,976 +0,0 @@ -#!/usr/bin/env bun -/** - * stupify — a code reviewer that talks like an idiot and catches real bugs. - * - * `stupify` (no args) → the interactive setup wizard: checks your tools, finds your repo, asks for your - * exe.dev integration, and installs the cron sweep. On exe.dev there are no creds to - * manage (Codex → exe-llm gateway, gh → your GitHub integration). - * `stupify run [--dry]` → run one review sweep right now. - */ -import { spawnSync } from 'node:child_process' -import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - detectRepo, - exe, - exeSetupScript, - githubIntegrationFor, - installCron, - llmIntegrationFor, - normalizeRepo, - stableBun, - validHost, - validRepo, - vmNameFor as packageVmNameFor, - writeCodexGatewayConfig, -} from '@bevyl-ai/agent-tools' -import { cancel, confirm, intro, isCancel, log, note, outro, spinner, text } from '@clack/prompts' -import pc from 'picocolors' -import { z } from 'zod' - -import { SweepStatus } from './sweep/status' - -const PKG_DIR = dirname(fileURLToPath(import.meta.url)) -const PKG_ROOT = join(PKG_DIR, '..') // the published package root: holds .review/ -const VERSION = z - .object({ version: z.string() }) - .parse(JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))).version -const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify') -const STATE = join(HOME, 'state') -const REQUIRED = ['bun', 'gh', 'codex', 'git'] as const -const vmNameFor = (repo: string): string => packageVmNameFor('stupify', repo) - -function bail(value: T | symbol): asserts value is T { - if (isCancel(value)) { - cancel('aborted.') - process.exit(0) - } -} - -function die(message: string): never { - log.error(message) - process.exit(1) -} - -/** Trust an extra directory in ~/.codex/config.toml (stacked-PR worktrees live outside the main checkout). */ -function appendCodexProjectTrust(dir: string): void { - const file = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml') - if (!existsSync(file)) { - return - } - const body = readFileSync(file, 'utf8') - const key = `[projects."${dir}"]` - if (body.includes(key)) { - return - } - appendFileSync(file, `\n${key}\ntrust_level = "trusted"\n`) -} - -async function installSweepEngine(dest = join(HOME, 'review-sweep.ts')): Promise { - const built = await Bun.build({ - entrypoints: [join(PKG_DIR, 'review-sweep.ts')], - target: 'bun', - format: 'esm', - }) - if (!built.success) { - const details = built.logs - .map((l) => l.message) - .join('\n') - .trim() - throw new Error(details || 'could not bundle review-sweep.ts') - } - const [artifact] = built.outputs - if (artifact === undefined) { - throw new Error('could not bundle review-sweep.ts (no output)') - } - mkdirSync(dirname(dest), { recursive: true }) - await Bun.write(dest, artifact) -} - -// clack's spinner reads stdin and keeps the event loop alive in non-TTY contexts (CI, pipes, scripts) — the -// process never exits. Fall back to plain step logs there so non-interactive runs actually finish. -function progress(start: string): { stop: (msg: string) => void } { - if (!process.stdin.isTTY) { - log.step(start) - return { stop: (msg: string) => log.success(msg) } - } - const s = spinner() - s.start(start) - return { stop: (msg: string) => s.stop(msg) } -} - -// Fence language tag from a file extension — best-effort, blank when unknown (still renders fine). -const LANG: Record = { - ts: 'ts', - tsx: 'tsx', - js: 'js', - jsx: 'jsx', - mjs: 'js', - cjs: 'js', - py: 'python', - rb: 'ruby', - go: 'go', - rs: 'rust', - java: 'java', - kt: 'kotlin', - c: 'c', - h: 'c', - cpp: 'cpp', - cc: 'cpp', - cs: 'csharp', - zig: 'zig', - swift: 'swift', - php: 'php', - ex: 'elixir', - exs: 'elixir', - scala: 'scala', - sh: 'bash', - sql: 'sql', -} -const langOf = (p: string): string => LANG[p.split('.').pop()?.toLowerCase() ?? ''] ?? '' - -// The repo root (where the reviewer's checkout keeps .review/), so `init` from a subdir still lands at the top. -function repoRoot(): { root: string; inGit: boolean } { - const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) - const root = r.status === 0 ? (r.stdout ?? '').trim() : '' - return root ? { root, inGit: true } : { root: process.cwd(), inGit: false } -} - -const CORPUS_CAP = 150 // lines: a single exemplar past this gets truncated (a corpus is shapes, not whole files) - -// `stupify init [files…]` — scaffold a `.review/` in THIS repo from your own best files. -// Writes the rubric + review spec (defaults, kept if already present) and builds CORPUS.md by inlining -// each file you name with a one-line "why" for you to fill — the only hand-work, and the irreducible taste part. -const WHY_PLACEHOLDER = '⟨why is this good? one line, e.g. "fail-fast at the boundary"⟩' - -function init(argv: { files: string[]; force: boolean }): void { - // validate paths FIRST — before any UI or writes — so a bad path fails clean (no open frame, no partial .review/) - const missing = argv.files.filter((f) => !existsSync(f)) - if (missing.length > 0) { - die(`file(s) not found: ${missing.join(', ')} (paths are relative to your current directory)`) - } - - console.clear() - intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' · encode your own taste (.review/ in this repo)')) - const { root, inGit } = repoRoot() - const dir = join(root, '.review') - mkdirSync(dir, { recursive: true }) - - // rubric + review spec: defaults, written only if missing so we never clobber edits - for (const f of ['RUBRIC.md', 'REVIEW-PROMPT.md']) { - if (!existsSync(join(dir, f))) { - copyFileSync(join(PKG_ROOT, '.review', f), join(dir, f)) - } - } - - const corpusPath = join(dir, 'CORPUS.md') - const corpusExists = existsSync(corpusPath) - if (corpusExists && !argv.force) { - note( - argv.files.length > 0 - ? `${pc.cyan(corpusPath)} already exists. ${pc.cyan('--force')} rebuilds it from ${pc.bold(argv.files.join(', '))} ${pc.dim('· your filled-in “why” lines are kept')}.` - : `${pc.cyan(corpusPath)} already exists. name files + ${pc.cyan('--force')} to rebuild it, or edit it by hand.`, - 'corpus exists', - ) - outro(pc.dim('nothing overwritten.')) - return - } - - if (argv.files.length === 0) { - copyFileSync(join(PKG_ROOT, '.review', 'CORPUS.template.md'), corpusPath) - note( - [ - `scaffolded ${pc.cyan(`${dir}/`)} ${pc.dim('(RUBRIC + REVIEW-PROMPT + a CORPUS template)')}.`, - `fill it from your best files: ${pc.cyan('stupify init path/to/your-best.ts another.ts')}`, - `…or edit ${pc.cyan('CORPUS.md')} by hand.`, - ].join('\n'), - 'next', - ) - outro(pc.green('your .review/ is ready 🎯')) - return - } - - // preserve any "why" lines already filled in, so --force / adding a file never erases the taste work - const priorWhy = new Map() - if (corpusExists) { - for (const m of readFileSync(corpusPath, 'utf8').matchAll(/^### `(?[^`]+)` — (?.+)$/gm)) { - const path = m.groups?.path - const why = m.groups?.why - if (path && why && !why.startsWith('⟨')) { - priorWhy.set(path, why) - } - } - } - - const truncated: string[] = [] - const outside: string[] = [] - const picked = argv.files.map((f) => { - const rel = relative(root, resolve(f)) || f // repo-root-relative, so the path is correct + portable - if (rel.startsWith('..')) { - outside.push(rel) - } - const content = readFileSync(f, 'utf8').replace(/\n+$/, '') - const total = content.split('\n').length - if (total > CORPUS_CAP) { - truncated.push(rel) - } - const body = total > CORPUS_CAP ? content.split('\n').slice(0, CORPUS_CAP).join('\n') : content - const tail = - total > CORPUS_CAP ? `\n\n_(first ${CORPUS_CAP} of ${total} lines — trim to the part that matters)_` : '' - return `### \`${rel}\` — ${priorWhy.get(rel) ?? WHY_PLACEHOLDER}\n\`\`\`${langOf(f)}\n${body}\n\`\`\`${tail}` - }) - // a path outside the repo would commit a non-portable ../ reference — reject it rather than write a broken corpus - if (outside.length > 0) { - die(`outside the repo root (${root}): ${outside.join(', ')}. name files inside the repo`) - } - const header = `# Good-code reference — your corpus\n\nHand-picked from this repo: the code you wish all your code looked like. Replace each ⟨why⟩ with one line on what makes that file the standard — that one line is the taste the reviewer and prime hold every diff to.\n\n---\n\n` - writeFileSync(corpusPath, `${header}${picked.join('\n\n')}\n`) - - note( - [ - `built ${pc.cyan(corpusPath)} from ${pc.bold(String(argv.files.length))} file(s)${priorWhy.size > 0 ? pc.dim(` (kept ${priorWhy.size} of your “why” lines)`) : ''}.`, - truncated.length > 0 - ? pc.yellow(`truncated to ${CORPUS_CAP} lines: ${truncated.join(', ')}, a tighter exemplar reads better`) - : '', - ``, - `${pc.bold('1.')} edit the ${pc.cyan('⟨why⟩')} line on each block ${pc.dim('(that one line is your taste)')}`, - inGit ? `${pc.bold('2.')} commit ${pc.cyan('.review/')} ${pc.dim('· version it with your code')}` : '', - `${pc.bold(inGit ? '3.' : '2.')} ${pc.cyan('stupify prime --install')} ${pc.dim('· prime your agent against it')}`, - ] - .filter(Boolean) - .join('\n'), - 'your taste is scaffolded', - ) - outro(pc.green("fill in the whys and you're set 🎯")) -} - -async function setup(argv: { - repo?: string | undefined - host?: string | undefined - codexHost?: string | undefined - yes: boolean -}): Promise { - console.clear() - intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' · sounds dumb, reviews sharp')) - - // 1. tools - const s = progress('checking your tools') - const missing = REQUIRED.filter((b) => !Bun.which(b)) - if (missing.length > 0) { - s.stop(pc.red(`missing: ${missing.join(', ')}`)) - note( - `install them first:\n bun → ${pc.cyan('bun.sh')}\n gh → ${pc.cyan('cli.github.com')}\n codex → ${pc.cyan('github.com/openai/codex')}`, - 'missing tools', - ) - process.exit(1) - } - s.stop(pc.green('bun, gh, codex, git') + pc.dim(' · all here')) - - // 2. repo (auto-detect, else ask) - let repo = argv.repo ?? '' - if (!repo) { - const detected = detectRepo() - if (detected) { - if (argv.yes) { - repo = detected - log.success(`repo ${pc.bold(detected)} ${pc.dim('(from this checkout)')}`) - } else { - const keep = await confirm({ message: `Review ${pc.bold(detected)}? ${pc.dim('(detected from git remote)')}` }) - bail(keep) - if (keep) { - repo = detected - } - } - } - } - if (!repo) { - if (argv.yes) { - die('--yes needs a repo when none is detected: stupify setup --yes') - } - const answer = await text({ - message: 'GitHub repo to review', - placeholder: 'owner/repo', - validate: (v) => (validRepo(normalizeRepo(v ?? '')) ? undefined : 'expected owner/repo (e.g. acme/widgets)'), - }) - bail(answer) - repo = answer - } - repo = normalizeRepo(repo) - if (!validRepo(repo)) { - die(`'${repo}' is not a valid owner/repo, expected owner/repo (e.g. acme/widgets)`) - } - - // 3. integration host (exe.dev) — can't be detected - let host = argv.host ?? process.env.GH_HOST ?? '' - if (!host && !argv.yes) { - const answer = await text({ - message: 'exe.dev integration host', - placeholder: 'your-integration.int.exe.xyz', - defaultValue: '', - }) - bail(answer) - host = answer.trim() - } - if (host && !validHost(host)) { - die(`'${host}' is not a valid host, hostname characters only (e.g. acme.int.exe.xyz)`) - } - if (argv.codexHost && !validHost(argv.codexHost)) { - die(`'${argv.codexHost}' is not a valid Codex gateway host, hostname characters only`) - } - - // 4. plan + confirm - note( - [ - `${pc.dim('repo ')} ${pc.bold(repo)}`, - host - ? `${pc.dim('auth ')} exe.dev integration ${pc.bold(host)} ${pc.dim('· exe-llm gateway, no keys')}` - : `${pc.dim('auth ')} your own gh + codex ${pc.dim('(run `gh auth login` first)')}`, - `${pc.dim('cadence')} every ~60s via cron`, - `${pc.dim('home ')} ${HOME}`, - ].join('\n'), - 'plan', - ) - if (!argv.yes) { - const go = await confirm({ message: 'Set it up?' }) - bail(go) - if (!go) { - cancel('aborted.') - process.exit(0) - } - } - - // 5. install - const s2 = progress('installing') - mkdirSync(STATE, { recursive: true }) - await installSweepEngine() - const cfg = [`REPO_SLUG=${repo}`, host ? `GH_HOST=${host}` : '', '# tune anything else here, see the README'] - .filter(Boolean) - .join('\n') - writeFileSync(join(HOME, 'config.env'), `${cfg}\n`) - if (host) { - const worktrees = join(HOME, 'worktrees') - mkdirSync(worktrees, { recursive: true }) - writeCodexGatewayConfig({ - ...(argv.codexHost === undefined ? {} : { gatewayHost: argv.codexHost }), - trustDir: join(HOME, 'repo'), - }) - appendCodexProjectTrust(worktrees) - } // exe.dev VM: route Codex through the no-key exe-llm gateway - try { - installCron({ - stateDir: STATE, - engineFile: join(HOME, 'review-sweep.ts'), - ghHost: host, - removeMarker: 'review-sweep.ts', - }) - } catch (error) { - s2.stop(pc.yellow('files installed, but the cron job failed')) - die(error instanceof Error ? error.message : String(error)) // friendly: includes the reason + the exact line to add by hand - } - s2.stop(pc.green('installed') + pc.dim(` → ${HOME}`)) - - // 6. success - const preview = `${pc.dim('preview anytime:')} ${pc.cyan(`DRY_RUN=1 bun ${join(HOME, 'review-sweep.ts')}`)}` - const statusLine = `${pc.dim('status anytime: ')} ${pc.cyan('stupify status')}` - const githubLine = `${pc.dim('github status: ')} ${pc.cyan('stupify/review')} ${pc.dim('on each PR head commit')}` - note( - [ - `${pc.bold('1.')} ${pc.cyan('stupify init ')} in ${pc.bold(repo)} scaffolds a ${pc.cyan('.review/')}; point ${pc.cyan('CORPUS.md')} at YOUR best files`, - `${pc.bold('2.')} open a PR → stupify reviews it in ~60s ${pc.dim('(no labels needed)')}`, - ``, - githubLine, - preview, - statusLine, - ].join('\n'), - 'two steps to your first review', - ) - outro(pc.green('stupify is watching ') + pc.bold(repo) + pc.green(' 👀')) -} - -// --- prime: wire `stupify prime` into Claude Code as a SessionStart hook (self-contained, install ⇄ uninstall) --- -// The hook EMITTER lives in the dependency-free ./prime module (also copied to ~/.stupify/prime.ts on install, -// so the hook runs with no global install / node_modules). Everything below only manages the wiring. - -const PRIME_ENGINE = join(HOME, 'prime.ts') // the dep-free copy the hook actually runs; also our marker in settings.json - -/** Claude Code's user settings file. CLAUDE_CONFIG_DIR overrides ~/.claude (and makes this testable). */ -const claudeSettingsPath = (): string => - join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json') - -/** Codex's user hooks file. Same JSON shape as Claude's settings.json (hooks.SessionStart[]). CODEX_HOME - * overrides ~/.codex. We use hooks.json (not config.toml) so we never touch the user's main Codex config. */ -const codexHooksPath = (): string => join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'hooks.json') - -const HookCommand = z.object({ type: z.string().optional(), command: z.string().optional() }).passthrough() -const HookEntry = z.object({ matcher: z.string().optional(), hooks: z.array(HookCommand).optional() }).passthrough() -type HookEntry = z.infer -const AgentSettings = z - .object({ - hooks: z - .object({ SessionStart: z.array(HookEntry).optional() }) - .passthrough() - .optional(), - }) - .passthrough() -type AgentSettings = z.infer - -/** Read settings.json (or {} if absent). Throws on malformed JSON so callers refuse to clobber a broken file. */ -function readSettings(path: string): AgentSettings { - if (!existsSync(path)) { - return {} - } - return AgentSettings.parse(JSON.parse(readFileSync(path, 'utf8'))) -} - -const isOurHook = (e: HookEntry): boolean => (e.hooks ?? []).some((h) => (h.command ?? '').includes(PRIME_ENGINE)) - -// Every agent stupify primes reads a SessionStart command hook in the SAME JSON shape -// ({ hooks: { SessionStart: [{ matcher, hooks: [{ type:'command', command }] }] } }) and the SAME prime.ts -// payload ({ hookSpecificOutput: { hookEventName:'SessionStart', additionalContext } }). So adding an agent is -// just another target here — the merge/emit logic is shared. -interface PrimeTarget { - id: string - label: string - file: () => string - matcher: string - installed: () => boolean - trust?: string // post-install step unique to this agent (Codex makes you trust a hook before it runs) -} -const PRIME_TARGETS: PrimeTarget[] = [ - { - id: 'claude', - label: 'Claude Code', - file: claudeSettingsPath, - matcher: 'startup', - installed: () => - Bun.which('claude') !== null || existsSync(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')), - }, - { - id: 'codex', - label: 'Codex', - file: codexHooksPath, - matcher: 'startup|resume', - installed: () => Bun.which('codex') !== null || existsSync(process.env.CODEX_HOME ?? join(homedir(), '.codex')), - trust: `run ${pc.cyan('/hooks')} in Codex once to trust it. Codex won't run an untrusted hook`, - }, -] - -/** Which agents to prime: an explicit --agent list, else every one we detect installed (fall back to Claude Code - * so `prime --install` on a bare machine still wires something). */ -function selectTargets(agentArg?: string): PrimeTarget[] { - if (agentArg !== undefined) { - const ids = agentArg - .toLowerCase() - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - const unknown = ids.filter((id) => !PRIME_TARGETS.some((t) => t.id === id)) - if (unknown.length > 0) { - die(`unknown --agent: ${unknown.join(', ')} (known: ${PRIME_TARGETS.map((t) => t.id).join(', ')})`) - } - return PRIME_TARGETS.filter((t) => ids.includes(t.id)) - } - const detected = PRIME_TARGETS.filter((t) => t.installed()) - return detected.length > 0 ? detected : PRIME_TARGETS.slice(0, 1) // fall back to Claude Code -} - -/** Merge our SessionStart command hook into one agent's hooks file. Never clobbers the user's other hooks or - * settings; refreshes our command if already present (the resolved bun path can move); never duplicates. */ -function mergeHook(file: string, matcher: string, command: string): { already: boolean } { - let settings: AgentSettings - try { - settings = readSettings(file) - } catch { - die(`couldn't parse ${file}, fix or remove it, then retry (left it untouched)`) - } - settings.hooks ??= {} - const { hooks } = settings - hooks.SessionStart ??= [] - const sessionStart = hooks.SessionStart - const existing = sessionStart.find((e) => isOurHook(e)) - if (existing) { - existing.hooks = [{ type: 'command', command }] - } else { - sessionStart.push({ matcher, hooks: [{ type: 'command', command }] }) - } - mkdirSync(dirname(file), { recursive: true }) - writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`) - return { already: existing !== undefined } -} - -/** Remove our SessionStart hook from one agent's hooks file, leaving the user's other hooks + settings intact. */ -function removeHook(file: string): { removed: boolean } { - if (!existsSync(file)) { - return { removed: false } - } - let settings: AgentSettings - try { - settings = readSettings(file) - } catch { - die(`couldn't parse ${file}, fix or remove it, then retry (left it untouched)`) - } - const { hooks } = settings - if (!hooks?.SessionStart) { - return { removed: false } - } - const kept = hooks.SessionStart.filter((e) => !isOurHook(e)) - const removed = kept.length !== hooks.SessionStart.length - if (kept.length > 0) { - hooks.SessionStart = kept - } else { - delete hooks.SessionStart - } - if (Object.keys(hooks).length === 0) { - delete settings.hooks - } - writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`) - return { removed } -} - -const hasTaste = (d: string): boolean => existsSync(join(d, 'RUBRIC.md')) && existsSync(join(d, 'CORPUS.md')) - -function installPrimeHook(argv: { agent?: string | undefined }): void { - console.clear() - const targets = selectTargets(argv.agent) - intro( - pc.bgMagenta(pc.black(' stupify ')) + pc.dim(` · prime ${targets.map((t) => t.label).join(' + ')} with your taste`), - ) - - // 0. the hook runs in EVERY repo: the repo's own .review/ wins, ~/.stupify/.review is the fallback. Without - // either it no-ops, so say so up front. - const primed = hasTaste(join(HOME, '.review')) || hasTaste(join(repoRoot().root, '.review')) - if (!primed) { - log.warn( - `no taste yet. the hook will no-op until this repo has a ${pc.cyan('.review/')} (${pc.cyan('stupify init')})`, - ) - } - - // 1. drop the dep-free emitter where the hook can run it fast, no global install needed - mkdirSync(HOME, { recursive: true }) - copyFileSync(join(PKG_DIR, 'prime.ts'), PRIME_ENGINE) - const command = `${stableBun()} ${PRIME_ENGINE}` - - // 2. wire the SessionStart hook into each target. The command carries the resolved bun path (which can move — - // a new bun install, a Homebrew relocation), so mergeHook refreshes it on re-install instead of going stale. - const wired = targets.map((t) => ({ t, already: mergeHook(t.file(), t.matcher, command).already })) - - note( - [ - ...wired.map( - ({ t, already }) => - `${already ? pc.dim('refreshed') : pc.green('wired ')} ${pc.bold(t.label)} ${pc.dim(`→ ${t.file()}`)}${t.trust ? `\n ${pc.yellow(`↳ ${t.trust}`)}` : ''}`, - ), - ``, - primed - ? `every new session now opens primed with your taste ${pc.dim('(~30ms, pure file read)')}.` - : `wired but ${pc.bold('dormant')}. it activates in any repo with a ${pc.cyan('.review/')} (${pc.cyan('stupify init')}).`, - ``, - `${pc.dim('undo:')} ${pc.cyan('stupify prime --uninstall')}`, - ].join('\n'), - primed ? "you're primed" : 'hooks wired (no taste yet)', - ) - outro( - primed - ? pc.green('your agents will write to your taste from the first line 🧠') - : pc.yellow('add taste to bring it to life ↑'), - ) -} - -function uninstallPrimeHook(): void { - console.clear() - intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' · remove the prime hooks')) - // Sweep every known target, not just the ones currently installed — so an uninstall after the agent is gone - // (or after switching machines) still cleans up any hook we left behind. - const removedFrom = PRIME_TARGETS.filter((t) => removeHook(t.file()).removed).map((t) => t.label) - rmSync(PRIME_ENGINE, { force: true }) // drop the copied engine too - - note( - removedFrom.length > 0 - ? `removed the stupify SessionStart hook from ${pc.bold(removedFrom.join(' + '))}. your other hooks + settings are untouched.` - : `no stupify prime hook found ${pc.dim('(nothing to remove)')}.`, - 'done', - ) - outro(pc.green('unprimed.')) -} - -function run(dry: boolean): void { - const sweep = join(HOME, 'review-sweep.ts') - if (!existsSync(sweep)) { - log.error( - `not set up yet. run ${pc.cyan('stupify setup')} to install on this machine, or ${pc.cyan('stupify')} to provision an exe.dev VM`, - ) - process.exit(1) - } - const env = { ...process.env, ...(dry ? { DRY_RUN: '1' } : {}) } - const r = spawnSync(stableBun(), [sweep], { stdio: 'inherit', env }) // same bun the cron uses, not ambient PATH - process.exit(r.status ?? 1) -} - -function formatWhen(iso: string): string { - const time = Date.parse(iso) - if (!Number.isFinite(time)) { - return iso - } - const seconds = Math.max(0, Math.round((Date.now() - time) / 1000)) - if (seconds < 60) { - return `${seconds}s ago` - } - const minutes = Math.round(seconds / 60) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.round(minutes / 60) - if (hours < 48) { - return `${hours}h ago` - } - return new Date(time).toLocaleString() -} - -function statusMarker(state: SweepStatus['prs'][number]['state']): string { - if (state === 'reviewing') { - return pc.cyan('[>]') - } - if (state === 'posted' || state === 'clean' || state === 'dry_run') { - return pc.green('[x]') - } - if (state === 'failed') { - return pc.red('[!]') - } - if (state === 'skipped' || state === 'deferred') { - return pc.yellow('[-]') - } - return pc.dim('[ ]') -} - -function renderStatus(status: SweepStatus): string { - const running = status.finishedAt === undefined && status.stage !== 'done' && status.stage !== 'blocked' - const mode = status.dryRun ? 'dry-run' : 'live' - let sweepLabel = pc.green('(last sweep)') - if (running) { - sweepLabel = pc.cyan('(running)') - } else if (status.stage === 'blocked') { - sweepLabel = pc.red('(blocked)') - } - const header = [ - `${pc.bold('stupify status')} ${sweepLabel}`, - `${pc.dim('repo ')} ${pc.bold(status.repo)}`, - `${pc.dim('stage ')} ${status.stage} ${pc.dim(`- ${status.message}`)}`, - `${pc.dim('scope ')} ${status.scope} ${pc.dim(`· ${mode}`)}`, - `${pc.dim('time ')} started ${formatWhen(status.startedAt)} · updated ${formatWhen(status.updatedAt)}`, - `${pc.dim('totals ')} open ${status.totals.openPrs} · in scope ${status.totals.inScope} · handled ${status.totals.handled}/${status.totals.maxPrs} · posted ${status.totals.reviewed} · skipped ${status.totals.skipped} · tokens~${status.totals.tokens}`, - ] - if (status.prs.length === 0) { - return [...header, '', pc.dim('no PRs in scope')].join('\n') - } - const rows = status.prs.map((pr) => { - const title = pr.title.trim() || '(untitled)' - const head = pr.head ? ` @ ${pr.head.slice(0, 8)}` : '' - const lines = pr.lines === undefined ? '' : pc.dim(` · ${pr.lines} lines`) - return `${statusMarker(pr.state)} #${pr.number} ${title}${pc.dim(head)} - ${pr.detail}${lines}` - }) - return [...header, '', ...rows].join('\n') -} - -function cmdStatus(): void { - const file = join(STATE, 'status.json') - if (!existsSync(file)) { - log.warn(`no sweep status yet at ${pc.cyan(file)}. Run ${pc.cyan('stupify run --dry')} or wait for the cron sweep.`) - return - } - console.log(renderStatus(SweepStatus.parse(JSON.parse(readFileSync(file, 'utf8'))))) -} - -// `stupify review ` — review ONE PR on demand via the bundled engine (no `setup` -// needed, just taste). Prints the review to stdout; `--post` comments it on the PR. A bare `#123` resolves against -// the repo you're standing in. -function cmdReview(ref: string | undefined, post: boolean): void { - if (!ref) { - die('usage: stupify review [--post]') - } - let target = ref - if (/^#?\d+$/.test(ref)) { - const repo = detectRepo() - if (!repo) { - die(`'${ref}' is just a number — run this inside the target repo, or pass owner/repo#${ref.replace(/^#/, '')}`) - } - target = `${repo}#${ref.replace(/^#/, '')}` - } - const engine = join(PKG_DIR, 'review-sweep.ts') // the bundled engine, run directly — review needs no install - // STUPIFY_HOME points the engine at ~/.stupify (assembled home taste + logs), not the package's own src/ dir. - const env = { ...process.env, STUPIFY_HOME: HOME, REVIEW_PR: target, ...(post ? { REVIEW_POST: '1' } : {}) } - const r = spawnSync(stableBun(), [engine], { stdio: 'inherit', env }) - process.exit(r.status ?? 1) -} - -// --- provision: spin up an exe.dev VM that runs stupify, from your laptop --- - -async function provision(argv: { repo?: string | undefined; yes: boolean }): Promise { - console.clear() - intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' · provision a reviewer on exe.dev')) - - // 1. onboarded to exe.dev? - const s = progress('checking exe.dev') - const who = exe(['whoami']) - if (!who.ok) { - s.stop(pc.red('not connected to exe.dev')) - note( - `onboarding is one step. run this once, then re-run stupify:\n\n ${pc.cyan('ssh exe.dev')}`, - 'connect exe.dev', - ) - process.exit(1) - } - s.stop(pc.green('exe.dev ready') + pc.dim(` · ${(who.out.match(/[\w.+-]+@[\w.-]+/) ?? [''])[0]}`)) - - // 2. repo (auto-detect, else ask) - let repo = argv.repo ?? '' - if (!repo) { - const detected = detectRepo() - if (detected) { - if (argv.yes) { - repo = detected - } else { - const keep = await confirm({ message: `Review ${pc.bold(detected)}? ${pc.dim('(detected from git remote)')}` }) - bail(keep) - if (keep) { - repo = detected - } - } - } - } - if (!repo) { - if (argv.yes) { - die('--yes needs a repo when none is detected: stupify --yes') - } - const answer = await text({ - message: 'GitHub repo to review', - placeholder: 'owner/repo', - validate: (v) => (validRepo(normalizeRepo(v ?? '')) ? undefined : 'expected owner/repo (e.g. acme/widgets)'), - }) - bail(answer) - repo = answer - } - repo = normalizeRepo(repo) - if (!validRepo(repo)) { - die(`'${repo}' is not a valid owner/repo, expected owner/repo (e.g. acme/widgets)`) - } - - // 3. GitHub integration — reuse an existing one, else create it (needs your GitHub linked once, on the web) - const s2 = progress('finding your GitHub integration') - let integration = githubIntegrationFor(repo) - if (integration) { - s2.stop(pc.green(`using integration ${pc.bold(integration)}`)) - } else { - const name = vmNameFor(repo) - const add = exe(['integrations', 'add', 'github', '--name', name, '--repository', repo]) - if (add.ok) { - integration = name - s2.stop(pc.green(`created integration ${pc.bold(name)}`)) - } else { - s2.stop(pc.red(`no GitHub integration for ${repo}`)) - note( - `link your GitHub account once (web), then re-run stupify:\n\n ${pc.cyan('https://exe.dev/integrations')}\n\n${pc.dim(add.out.trim().slice(0, 200))}`, - 'connect GitHub', - ) - process.exit(1) - } - } - // The integration name comes back from the exe.dev API and gets baked into the VM's first-boot setup SCRIPT - // (which runs in a shell) and into the host. Refuse anything with shell metacharacters before interpolating it. - if (!integration || !validHost(integration)) { - die( - `exe.dev returned an unexpected integration name${integration ? ` (${integration})` : ''}. refusing to build a setup script with it`, - ) - } - const host = `${integration}.int.exe.xyz` - // Codex on the VM runs on the no-key exe-llm gateway, fronted by the `llm` integration (auto-installed for most - // exe.dev users). We point Codex at it in setup and attach it to the VM below; without it every review 401s. - const llm = llmIntegrationFor() - if (llm && !validHost(llm)) { - die(`exe.dev returned an unexpected exe-llm integration name (${llm}). refusing to use it`) - } - // 4. plan + confirm - note( - [ - `${pc.dim('repo ')} ${pc.bold(repo)}`, - `${pc.dim('vm ')} a small always-on exe.dev VM on your account`, - `${pc.dim('auth ')} integration ${pc.bold(integration)} ${pc.dim('· no keys, no tokens')}`, - ].join('\n'), - 'plan', - ) - if (!argv.yes) { - const go = await confirm({ message: 'Provision it?' }) - bail(go) - if (!go) { - cancel('aborted.') - process.exit(0) - } - } - - // 5. create the VM with a first-boot setup-script that installs stupify - const s3 = progress('provisioning VM + installing stupify') - const vm = vmNameFor(repo) - const setupCommand = `exec bunx @stupify/cli@${VERSION} setup ${repo} --host ${host} --yes` - const script = exeSetupScript(setupCommand, llm ? `${llm}.int.exe.xyz` : undefined) - const created = exe( - ['new', '--name', vm, '--integration', integration, '--json', '--setup-script', '/dev/stdin'], - script, - ) - if (!created.ok) { - s3.stop(pc.red('provision failed')) - log.error(created.out.trim().slice(0, 400)) - process.exit(1) - } - const dest = z.object({ ssh_dest: z.string() }).parse(JSON.parse(created.out)).ssh_dest - s3.stop(pc.green(`VM ${pc.bold(vm)} created`) + pc.dim(` (${dest})`)) - - // 5.5 attach the exe-llm gateway so Codex can review (creating with --integration drops the auto:all llm) - if (llm) { - exe(['integrations', 'attach', llm, `vm:${vm}`]) - } else { - note( - [ - `Codex reviews need the exe-llm gateway and I couldn't find it on your account.`, - `link your ChatGPT/Codex plan once (web): ${pc.cyan('https://exe.dev/integrations')}`, - `then attach it: ${pc.cyan(`ssh exe.dev integrations attach llm vm:${vm}`)}`, - ].join('\n'), - 'connect Codex', - ) - } - - // 6. success - const firstReview = [ - `reviewing ${pc.bold(repo)} against its own ${pc.cyan('.review/')}. ${pc.dim('no labels, no setup.')}`, - `no ${pc.cyan('.review/')} there yet? ${pc.cyan('stupify init ')} scaffolds one; the reviewer no-ops every sweep until it lands.`, - ] - note( - [ - `${pc.bold(vm)} is booting and installing stupify ${pc.dim('(~15s)')}.`, - ``, - ...firstReview, - ``, - `${pc.dim('github status:')} ${pc.cyan('stupify/review')} ${pc.dim('on each PR head commit')}`, - `${pc.dim('status:')} ${pc.cyan(`ssh ${dest} 'bunx @stupify/cli@${VERSION} status'`)}`, - `${pc.dim('watch:')} ${pc.cyan(`ssh ${dest} 'tail -f ~/.stupify/state/sweep.log'`)}`, - `${pc.dim('stop: ')} ${pc.cyan(`ssh exe.dev rm ${vm}`)}`, - ].join('\n'), - 'done', - ) - outro(pc.green('stupify is provisioned for ') + pc.bold(repo) + pc.green(' 👀')) -} - -function help(): void { - console.log(`${pc.bold('stupify')}: a code reviewer that talks like an idiot and catches real bugs - -${pc.dim('Usage')} ${pc.dim('(run from your laptop)')} - stupify provision an exe.dev VM that reviews your repo ${pc.dim('(the magic)')} - stupify provision for a specific repo - stupify setup [repo] install on THIS machine instead of provisioning a VM - stupify run [--dry] run one review sweep now (where stupify is installed) - stupify status show the latest sweep as a workflow - stupify upgrade [repo] move a running reviewer to the latest engine, in place ${pc.dim('(a VM if repo given, else this box)')} - stupify review [--post] review ONE pull request on demand (a URL or owner/repo#123); prints it, --post comments it - stupify init [files…] encode YOUR OWN taste: scaffold .review/ from your best files in this repo - stupify prime --install prime Claude Code + Codex with your taste every session (SessionStart hook) - stupify prime --uninstall remove those hooks - stupify --help - -${pc.dim('Flags')} - --host GitHub integration host (for 'setup') - --codex-host exe-llm gateway host (for 'setup'; default llm.int.exe.xyz) - --agent ('prime') which agents to wire: ${PRIME_TARGETS.map((t) => t.id).join(', ')} (default: detected) - --force ('init') rebuild CORPUS.md even if it exists (your filled-in "why" lines are kept) - --yes, -y accept detected defaults, no prompts (for CI / scripts) - -${pc.dim("Provisioning rides exe.dev. Onboard once with 'ssh exe.dev', then one command does the rest.")} https://stupif.ai`) -} - -// `stupify upgrade [repo]` — move a box to the latest published engine, IN PLACE. Narrow on purpose: it only -// re-bundles the engine (review-sweep.ts, plus prime.ts where the hook is installed). It never rewrites config.env -// (your tuning), the assembled .review/ taste, or the cron line. The minute cron runs the new file on its next -// tick, so nothing restarts. The engine is otherwise PINNED at provision time — this is the supported way forward. -// no repo → upgrade THIS machine (run it on the VM itself, or a local `stupify setup` box) from this package. -// → from your laptop: ssh that repo's exe.dev VM and run `bunx @stupify/cli@latest upgrade` there. -async function upgrade(repoArg?: string): Promise { - if (repoArg) { - const repo = normalizeRepo(repoArg) - if (!validRepo(repo)) { - die(`'${repo}' is not a valid owner/repo (e.g. acme/widgets)`) - } - const dest = `${vmNameFor(repo)}.exe.xyz` - const s = progress(`upgrading ${dest}`) - // Run the LATEST published CLI's own `upgrade` on the box — it pulls newest from npm and copies its engine in. - // An exe.dev VM installs bun to ~/.bun/bin but leaves it off the (non-login) ssh PATH — the cron survives by - // calling bun via its absolute path — so prepend the standard bun bindir or `bunx` is "command not found". - const remote = 'PATH="$HOME/.bun/bin:$PATH" bunx @stupify/cli@latest upgrade' - const r = spawnSync('ssh', ['-o', 'ConnectTimeout=25', dest, remote], { encoding: 'utf8', timeout: 180_000 }) - if (r.status !== 0) { - s.stop(pc.red(`couldn't upgrade ${repo}`)) - die( - ((r.stderr ?? '') + (r.stdout ?? '')).trim().slice(0, 300) || - r.error?.message || - `ssh ${dest} exited ${r.status ?? '?'}`, - ) - } - s.stop(pc.green(`${repo}'s reviewer is on the latest engine`) + pc.dim(' · the cron picks it up within ~60s')) - return - } - // local: refresh THIS box's engine from this package; leave config.env, taste, and the cron line untouched. - if (!existsSync(join(HOME, 'review-sweep.ts'))) { - die( - `nothing installed at ${HOME} to upgrade — run ${pc.cyan('stupify setup')} (this machine) or ${pc.cyan('stupify ')} (a VM) first`, - ) - } - await installSweepEngine() - if (existsSync(PRIME_ENGINE)) { - copyFileSync(join(PKG_DIR, 'prime.ts'), PRIME_ENGINE) - } // only where `prime --install` put it - log.success(`engine refreshed to ${VERSION} → ${HOME} ${pc.dim('· cron runs it within ~60s')}`) -} - -// --- routing --- -const args = process.argv.slice(2) -const yes = args.includes('--yes') || args.includes('-y') -const valueFlag = (name: string) => { - const i = args.indexOf(name) - return i !== -1 ? args[i + 1] : undefined -} -const host = valueFlag('--host') -const codexHost = valueFlag('--codex-host') -const agent = valueFlag('--agent') -const positional = args.filter( - (a, i) => - !a.startsWith('-') && args[i - 1] !== '--host' && args[i - 1] !== '--codex-host' && args[i - 1] !== '--agent', -) -const [cmd] = positional - -if (args.includes('-h') || args.includes('--help') || cmd === 'help') { - help() -} else if (cmd === 'init') { - await init({ files: positional.slice(1), force: args.includes('--force') }) -} else if (cmd === 'prime') { - if (args.includes('--install')) { - installPrimeHook({ agent }) - } else if (args.includes('--uninstall')) { - uninstallPrimeHook() - } else { - die( - '`stupify prime --install` to wire it up (or `--uninstall`). The hook itself runs ~/.stupify/prime.ts directly.', - ) - } -} else if (cmd === 'run') { - run(args.includes('--dry')) -} else if (cmd === 'status') { - cmdStatus() -} else if (cmd === 'review') { - cmdReview(positional[1], args.includes('--post')) -} else if (cmd === 'setup') { - await setup({ repo: positional[1], host, codexHost, yes }) -} else if (cmd === 'upgrade') { - await upgrade(positional[1]) -} else { - // default (and explicit `provision`): provision an exe.dev VM - await provision({ repo: cmd === 'provision' ? positional[1] : cmd, yes }) -} diff --git a/src/prime.ts b/src/prime.ts deleted file mode 100644 index 855aa00..0000000 --- a/src/prime.ts +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bun -/** - * stupify prime — emit the pre-decided taste (rubric + corpus index) as a Claude Code SessionStart hook - * payload, so a coding session opens already holding your standard instead of only catching slop in review. - * - * Dependency-free (node builtins only) ON PURPOSE: `stupify prime --install` drops a copy of THIS file at - * ~/.stupify/prime.ts and points the hook at it, so the hook runs fast with no global install and no - * node_modules. Pure file read — no model, no network. It must NEVER break session start: any miss or error - * emits nothing and exits 0. stdout is ONLY the JSON payload (a stray byte makes Claude Code drop it). - */ -import { spawnSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' - -const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify') -const BUDGET = 9000 // max bytes of injected additionalContext — measured: SessionStart silently truncates above ~10KB - -/** Resolve taste like the reviewer does (the repo you're coding in wins, else the global one under ~/.stupify/.review) - * and build the SessionStart payload. Returns null when no taste is set up — caller emits nothing. */ -export function primePayload(cwd: string = process.cwd(), home: string = HOME): string | null { - // A repo's .review/ lives at its git ROOT — so a session opened in a subdir still finds it (cwd → root → home). - const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }) - const root = r.status === 0 ? (r.stdout ?? '').trim() : '' - const candidates = [ - join(cwd, '.review'), - ...(root && root !== cwd ? [join(root, '.review')] : []), - join(home, '.review'), - ] - const dir = candidates.find((d) => existsSync(join(d, 'RUBRIC.md')) && existsSync(join(d, 'CORPUS.md'))) - if (dir === undefined) { - return null - } - const rubric = readFileSync(join(dir, 'RUBRIC.md'), 'utf8').trim() - let corpus = readFileSync(join(dir, 'CORPUS.md'), 'utf8').trim() - const head = `# Your taste, loaded by stupify — write to this standard - -You're about to write or change code in this repo. Hold every edit to the standard below BEFORE you write it — -it's the same taste stupify reviews against, so matching it now is a clean review later. - -## What counts as slop here — don't ship it (RUBRIC) -${rubric} - -## The code yours should look like — match it (CORPUS) -` - // A SessionStart hook's additionalContext is silently truncated above the cap, and the corpus lands LAST. A - // multi-section corpus easily exceeds the room — and a naive trim would keep only the FIRST section and silently - // drop the rest. Instead, give every section a FAIR SHARE of the room so each is represented (the - // reviewer reads the full CORPUS.md from disk and is unaffected). CORPUS.md is `intro --- section1 --- section2 …`; - // a single-section corpus (e.g. from `stupify init`) degrades to a plain trim of that one section. - const room = BUDGET - head.length - if (corpus.length > room) { - const [intro = '', ...sections] = corpus.split('\n\n---\n\n') - const trimNote = '\n\n_(trimmed per section to fit the session-start budget — full corpus in .review/CORPUS.md)_' - const per = Math.max(400, (room - intro.length - trimNote.length) / Math.max(1, sections.length)) - const trimSection = (p: string) => { - if (p.length <= per) { - return p - } - const cut = Math.max(p.lastIndexOf('\n### ', per), p.lastIndexOf('\n```\n', per)) // whole exemplars only - return cut > 0 ? p.slice(0, cut) : p.slice(0, per) - } - corpus = `${[intro, ...sections.map((p) => trimSection(p))].join('\n\n---\n\n')}${trimNote}` - } - return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: head + corpus } }) -} - -/** Write the payload to stdout, or nothing. Swallows every error: a hook must never disrupt session start. */ -export function emitPrime(): void { - try { - const payload = primePayload() - if (payload !== null) { - process.stdout.write(payload) - } - } catch { - /* never break session start */ - } -} - -if (import.meta.main) { - emitPrime() -} // run directly (the installed hook calls `bun ~/.stupify/prime.ts`) diff --git a/src/review-sweep.ts b/src/review-sweep.ts index b33d076..3812fbf 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -1,155 +1,54 @@ #!/usr/bin/env bun -/** - * stupify (review sweep) — auto-review open GitHub PRs with Codex against a corpus of code YOU picked. - * The engine the `stupify` CLI deploys to ~/.stupify and runs on a cron (or `stupify run`); config.env sits - * next to it. - * - * Reviews every PR by default (SCOPE=auto): every non-draft, non-bot PR under DIFF_LINE_CAP, no label needed. - * REVIEW_LABEL is just a force-include override for an oversized diff. Want manual control instead? SCOPE=label - * flips it to opt-in: only PRs you tag REVIEW_LABEL are reviewed, so spend tracks exactly what you tag. - * The "taste" — REVIEW-PROMPT.md, RUBRIC.md, CORPUS.md — lives in the TARGET repo under REVIEW_DIR (default - * `.review/`), so it's version-controlled with the code it judges and edited via a normal PR. - * Idempotent: skips a PR already reviewed — or already reported as failed — at its current head SHA, via a - * hidden marker comment. A new push moves the SHA, clears the markers, and re-arms the review. - * Per-PR memory: each review is fed the PR's existing review thread. The runner posts a one-line `still ✅` - * when nothing is outstanding (so every reviewed head carries a marker-bearing verdict), and stays silent - * while its own findings remain open. - * - * Single-flight: the sweep takes its own lockfile (state/sweep.lock) so two cron ticks never overlap — no - * `flock` dependency. Every knob lives in config.env next to this file (read fresh each run). Run: `bun review-sweep.ts`. - * - * Layout: this file is the entry (main + the public surface tests import). The engine proper is split into - * src/sweep/* — config, prs, diff, verdict, github, state, status, commit-status, prompt, codex, review-pr, - * review-one, sweep (candidate collection), pool (the concurrent review workers). The CLI bundles this entry - * into one file at install time, so the split costs the deployed artifact nothing. - */ +// 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 { setCommitStatus } from './sweep/commit-status' 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 { reviewOne } from './sweep/review-one' -import { initialStatus, isoNow, seedStatusPrs, setStatusStage, writeStatus } from './sweep/status' import { collectCandidates, loadSweepState } from './sweep/sweep' -export { isRateLimited, pidAlive } from '@bevyl-ai/agent-tools' -export { appJwt, commitStatusDescription } from './sweep/commit-status' -export type { Config } from './sweep/config' -export { diffRightLines, isDiffTooLarge } from './sweep/diff' -export { reviewPrompt } from './sweep/prompt' -export { type Pr, priorReviewThread } from './sweep/prs' -export { commitStatusForSweepResult } from './sweep/review-pr' -export { - bumpDailyCounter, - DailyCounter, - loadDailyCounter, - loadHeadAttempts, - loadReviewedHeads, - recordHeadAttempt, - recordReviewedHead, -} from './sweep/state' -export { parseReview, STILL_NOTE } from './sweep/verdict' - -async function main(): Promise { - const cfg = loadConfig() // also mkdirs stateDir and sets LOG, so config warnings are already captured - const ref = process.env.REVIEW_PR - if (ref) { - return reviewOne(cfg, ref, process.env.REVIEW_POST === '1') - } // `stupify review ` — one-shot, no sweep/lock/checkout - - const lockPath = join(cfg.stateDir, 'sweep.lock') - if (!acquireLock(lockPath)) { - log('another sweep already running — skip') - return - } - const status = initialStatus(cfg) - writeStatus(cfg, status) - process.on('exit', () => { - // Only clear the lock if we still hold it. If a later sweep judged us crashed and stole it, deleting it here - // would free a lock that another run now owns — letting a third sweep overlap it. - releaseLock(lockPath) - }) - - setStatusStage(cfg, status, 'refreshing', `refreshing ${cfg.defaultBranch}`) - if (!refreshRepo(cfg)) { - setStatusStage(cfg, status, 'blocked', 'checkout refresh failed') - status.finishedAt = isoNow() - writeStatus(cfg, status) - process.exit(1) - } - // Resolve the taste: the target repo's own .review/ wins (a repo can override); otherwise fall back to the - // global taste under ~/.stupify/.review. Either way cfg.reviewDir becomes ABSOLUTE. - // Select on the FULL 3-file set, not just CORPUS.md — a partial repo .review/ (e.g. CORPUS without the spec) - // then gracefully falls back to the home taste instead of being picked and dead-ending at "no machinery". - setStatusStage(cfg, status, 'loading_taste', 'loading review taste') - const repoReview = join(cfg.repoDir, cfg.reviewDir) - cfg.reviewDir = hasMachinery(repoReview) ? repoReview : cfg.homeReviewDir - if (!hasMachinery(cfg.reviewDir)) { - log( - `no review machinery at ${cfg.reviewDir}/ (need REVIEW-PROMPT.md + RUBRIC.md + CORPUS.md) — no-op. Add a .review/ to ${cfg.slug} (\`stupify init\`).`, - ) - status.stage = 'done' - status.message = 'no review machinery found' - status.finishedAt = isoNow() - writeStatus(cfg, status) - return - } +const cfg = loadConfig() +const lockPath = join(cfg.stateDir, 'sweep.lock') +if (!acquireLock(lockPath)) { + log('another sweep already running — skip') + 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 +}) - setStatusStage(cfg, status, 'listing_prs', 'listing open pull requests') - const prs = listPrs(cfg) - if (prs === null) { - setStatusStage(cfg, status, 'blocked', 'could not list pull requests') - status.finishedAt = isoNow() - writeStatus(cfg, status) - process.exit(1) - } - const queue = prs.filter((pr) => inScope(pr, cfg)) // MAX_PRS is applied to PRs actually HANDLED, not iterated (collectCandidates) - status.totals.openPrs = prs.length - seedStatusPrs(cfg, status, queue) - setStatusStage( - cfg, - status, - 'reviewing', - queue.length === 0 ? 'no PRs in scope' : `reviewing ${queue.length} PR(s) in scope`, +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)) { + log( + `no review machinery at ${cfg.reviewDir}/ (need REVIEW-PROMPT.md + RUBRIC.md + CORPUS.md) — no-op. Add a .review/ to ${cfg.slug}.`, ) - - const state = loadSweepState(cfg) - const priorByPr = new Map() - for (const pr of queue) { - const prior = prReviews(cfg, pr) - priorByPr.set(pr.number, prior) - if (prior === null) { - continue - } - const reviewedHead = prior.reviewedHead || state.reviewedLocal[String(pr.number)] === pr.headRefOid - const f = state.failures[String(pr.number)] - const recentlyFailed = f !== undefined && f.head === pr.headRefOid && Date.now() - f.at < cfg.failRetryMs - const dailyBlocked = cfg.maxReviewsPerDay > 0 && !cfg.dryRun && state.daily.count >= cfg.maxReviewsPerDay - if (!reviewedHead && !recentlyFailed && !dailyBlocked) { - setCommitStatus(cfg, state.commitStatuses, pr, 'pending', 'queued for stupify review') - } - } - - const { candidates, handled } = collectCandidates(cfg, status, queue, priorByPr, state) - const { reviewed, tokens } = await runCandidatePool(cfg, status, candidates, state) - - log(`sweep done — scope=${cfg.scope} reviewed=${reviewed} tokens~${tokens}`) - if (status.stage !== 'blocked') { - status.stage = 'done' - status.message = `sweep done — scope=${cfg.scope} reviewed=${reviewed} tokens~${tokens}` - } - status.totals.handled = handled - status.totals.reviewed = reviewed - status.totals.tokens = tokens - status.finishedAt = isoNow() - writeStatus(cfg, status) + process.exit(0) } -if (import.meta.main) { - await main() -} // run only when invoked directly (cron / `stupify run`); stays importable for tests +const prs = listPrs(cfg) +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 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 d7fbe28..05112b0 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -1,19 +1,25 @@ // 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, isRateLimited, scrubSecrets, tool } from '@bevyl-ai/agent-tools' +import { + AppServerSession, + isQuotaWall, + isRateLimited, + maybeRotateGateway, + scrubSecrets, + tool, +} from '@bevyl-ai/agent-tools' import { SECOND_PASS_PROMPT } from '../hand-written-prompts' -import { type Config, logRaw } from './config' +import { type Config, log, logRaw } from './config' import { diffRightLines } from './diff' 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. The sweep posts/converges from this; - * the ad-hoc `stupify review` prints it or `--post`s it. */ +/** 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; raw: string } // plan/credit exhaustion — caller STOPS; raw = full error for the rotation matcher + | { 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 @@ -23,7 +29,7 @@ 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, raw } + return { kind: 'limit', reason } } return { kind: 'fail', reason } } @@ -90,7 +96,22 @@ export async function runReview( logRaw(` codex: ${event.log}\n`) } }, - { scrubEnv: scrubSecrets }, + { + 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), + pool: cfg.gatewayPool, + cooldownMs: cfg.rotateCooldownMs, + }) + if (rot.rotated) { + log(` codex gateway rotated: ${rot.from} → ${rot.to}`) + } + }, + }, ) const turns = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] try { diff --git a/src/sweep/commit-status.ts b/src/sweep/commit-status.ts deleted file mode 100644 index c3326ad..0000000 --- a/src/sweep/commit-status.ts +++ /dev/null @@ -1,181 +0,0 @@ -// GitHub commit statuses (`stupify/review`) for PR-head workflow visibility. Posted under our own GitHub App -// when configured (the exe.dev integration's gh token is statuses:read-only); gh is the fallback. -import { createSign } from 'node:crypto' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { exec } from '@bevyl-ai/agent-tools' -import { z } from 'zod' - -import { type Config, log } from './config' -import { type Pr } from './prs' -import { commitStatusPath } from './state' - -const CommitStatusState = z.enum(['pending', 'success', 'failure', 'error']) -export type CommitStatusState = z.infer - -const PostedCommitStatus = z.strictObject({ state: CommitStatusState, description: z.string() }) -export type PostedCommitStatus = z.infer - -const PostedCommitStatuses = z.record(z.string(), PostedCommitStatus) - -export function loadCommitStatuses(path: string): Record { - try { - return PostedCommitStatuses.parse(JSON.parse(readFileSync(path, 'utf8'))) - } catch { - return {} - } -} - -function writeCommitStatuses(path: string, statuses: Record): void { - try { - writeFileSync(path, JSON.stringify(statuses)) - } catch { - /* best-effort */ - } -} - -export const commitStatusDescription = (description: string): string => - description.length <= 140 ? description : `${description.slice(0, 137)}...` - -const enc = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64url') - -// The short-lived JWT that authenticates US as our GitHub App (not yet as an installation). iat is backdated 60s -// for clock skew, exp stays under GitHub's 10-minute cap. -export function appJwt(appId: string, privateKeyPem: string, nowSec: number): string { - const signed = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iat: nowSec - 60, exp: nowSec + 540, iss: appId })}` - return `${signed}.${createSign('RSA-SHA256').update(signed).sign(privateKeyPem, 'base64url')}` -} - -// curl (not gh) for App-authenticated calls: gh on the VMs is wired to the exe.dev proxy via GH_HOST, and these -// calls must hit api.github.com with OUR credentials. The bearer token goes through curl's stdin config, never argv. -function ghAppApi(method: 'GET' | 'POST', path: string, bearer: string, body?: string): { ok: boolean; raw: string } { - const args = [ - '-sS', - '--fail-with-body', - '--max-time', - '30', - '-X', - method, - '--config', - '-', - `https://api.github.com${path}`, - ] - if (body !== undefined) { - args.push('-d', body) - } - const r = exec('curl', args, { - input: `header = "Authorization: Bearer ${bearer}"\nheader = "Accept: application/vnd.github+json"\n`, - }) - return { ok: r.ok, raw: r.combined } -} - -const CachedAppToken = z.strictObject({ token: z.string(), expiresAtMs: z.number() }) -const Installation = z.object({ id: z.number() }) -const AccessToken = z.object({ token: z.string() }) - -const appTokenPath = (cfg: Config): string => join(cfg.stateDir, 'gh-app-token.json') - -/** Mint (or reuse) an installation token for our commit-status App. Cached on disk so the every-minute cron mints - * roughly once an hour, not once a sweep. Returns null (with a log) on any failure — the caller skips the status, - * same degraded state as a gh outage. */ -function appStatusToken(cfg: Config): string | null { - try { - const cached = CachedAppToken.parse(JSON.parse(readFileSync(appTokenPath(cfg), 'utf8'))) - if (cached.expiresAtMs - Date.now() > 5 * 60_000) { - return cached.token - } - } catch { - /* no usable cache — mint below */ - } - const pem = (() => { - try { - return readFileSync(cfg.statusAppKeyPath, 'utf8') - } catch { - return null - } - })() - if (pem === null) { - log(` couldn't read GITHUB_STATUS_APP_KEY at ${cfg.statusAppKeyPath} — skipping commit status`) - return null - } - const jwt = appJwt(cfg.statusAppId, pem, Math.floor(Date.now() / 1000)) - const inst = ghAppApi('GET', `/repos/${cfg.slug}/installation`, jwt) - if (!inst.ok) { - log( - ` status App isn't installed on ${cfg.slug} (or the key/app id is wrong) — ${inst.raw.slice(0, 180).replaceAll(/\s+/g, ' ').trim()}`, - ) - return null - } - const installation = Installation.parse(JSON.parse(inst.raw)) - const minted = ghAppApi( - 'POST', - `/app/installations/${installation.id}/access_tokens`, - jwt, - JSON.stringify({ permissions: { statuses: 'write' } }), - ) - if (!minted.ok) { - log(` couldn't mint status App token — ${minted.raw.slice(0, 180).replaceAll(/\s+/g, ' ').trim()}`) - return null - } - const mintedToken = AccessToken.parse(JSON.parse(minted.raw)) - // GitHub installation tokens live 1h; we cache 55min (the 5-min freshness floor above trims the rest). - const cache = { token: mintedToken.token, expiresAtMs: Date.now() + 55 * 60_000 } - try { - writeFileSync(appTokenPath(cfg), JSON.stringify(cache)) - } catch { - /* best-effort — re-minting next sweep is just one extra round-trip */ - } - return mintedToken.token -} - -export function setCommitStatus( - cfg: Config, - posted: Record, - pr: Pr, - state: CommitStatusState, - description: string, -): void { - if (!cfg.githubStatus || cfg.dryRun) { - return - } - const safeDescription = commitStatusDescription(description) - const key = `${pr.headRefOid}:${cfg.githubStatusContext}` - const previous = posted[key] - if (previous?.state === state && previous.description === safeDescription) { - return - } - - const payload = { - state, - context: cfg.githubStatusContext, - description: safeDescription, - target_url: `https://github.com/${cfg.slug}/pull/${pr.number}`, - } - // Our own App (when configured) posts the status so it carries our bot identity and statuses:write; the exe.dev - // integration's gh token is statuses:read-only. gh remains the fallback for setups without an App. - const r = ((): { ok: boolean; combined: string } | null => { - if (cfg.statusAppId && cfg.statusAppKeyPath) { - const token = appStatusToken(cfg) - if (token === null) { - return null - } // already logged - const post = ghAppApi('POST', `/repos/${cfg.slug}/statuses/${pr.headRefOid}`, token, JSON.stringify(payload)) - return { ok: post.ok, combined: post.raw } - } - return exec('gh', ['api', `repos/${cfg.slug}/statuses/${pr.headRefOid}`, '--method', 'POST', '--input', '-'], { - input: JSON.stringify(payload), - }) - })() - if (r === null) { - return - } - if (!r.ok) { - log( - ` couldn't post GitHub status for #${pr.number} (${state}) — ${r.combined.slice(0, 180).replaceAll(/\s+/g, ' ').trim()}`, - ) - return - } - posted[key] = { state, description: safeDescription } - writeCommitStatuses(commitStatusPath(cfg), posted) -} diff --git a/src/sweep/config.ts b/src/sweep/config.ts index b8e4e16..4ad02f0 100644 --- a/src/sweep/config.ts +++ b/src/sweep/config.ts @@ -1,6 +1,6 @@ -// Sweep configuration: every knob lives in config.env next to the deployed engine (read fresh each run), and a -// one-shot env override wins over the persisted file. Also owns the sweep log, set up before knob parsing so -// config warnings reach sweep.log, not just cron.log. +// 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' @@ -30,13 +30,8 @@ export const Config = z.object({ failRetryMs: z.number(), stateDir: z.string(), codexEffort: z.string(), - codexProvider: z.string(), // optional `-c model_provider=...`; empty = codex's default codexModel: z.string(), // optional `-c model=...`; empty = codex's default - githubStatus: z.boolean(), - statusAppId: z.string(), - statusAppKeyPath: z.string(), - githubStatusContext: z.string(), - gatewayPool: z.string(), + gatewayPool: z.array(z.string()), // ordered exe-llm gateway hosts to rotate through on a quota wall; empty = off rotateCooldownMs: z.number(), codexJobs: z.number(), }) @@ -93,16 +88,15 @@ export function loadConfig(): Config { return onInvalid } - // Home is where the CLI deployed us (~/.stupify) — config.env, state, and the dedicated checkout all live here. + // 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 const slug = pick('REPO_SLUG', '').trim() - if (!slug && !process.env.REVIEW_PR) { - // `stupify review ` carries the repo in the PR ref, so it doesn't need a configured REPO_SLUG; the sweep does. - log('config: REPO_SLUG is required (owner/repo) — aborting. Run `stupify setup` to install locally.') + if (!slug) { + log('config: REPO_SLUG is required (owner/repo) — aborting. See DEPLOY.md.') process.exit(1) } const scopeRaw = pick('SCOPE', 'auto').trim().toLowerCase() @@ -125,13 +119,11 @@ export function loadConfig(): Config { failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, // after a failed review, don't re-attempt that head for this long stateDir, codexEffort: pick('CODEX_EFFORT', 'high'), - codexProvider: pick('CODEX_PROVIDER', ''), codexModel: pick('CODEX_MODEL', ''), - githubStatus: bool('GITHUB_STATUS', true, false), // default visible in GitHub; typo disables instead of surprise-posting - githubStatusContext: pick('GITHUB_STATUS_CONTEXT', 'stupify/review').trim() || 'stupify/review', - statusAppId: pick('GITHUB_STATUS_APP_ID', '').trim(), - statusAppKeyPath: pick('GITHUB_STATUS_APP_KEY', '').trim(), - gatewayPool: pick('CODEX_GATEWAY_POOL', ''), + gatewayPool: pick('CODEX_GATEWAY_POOL', '') + .split(',') + .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 }) diff --git a/src/sweep/pool.ts b/src/sweep/pool.ts index 172a987..86879e2 100644 --- a/src/sweep/pool.ts +++ b/src/sweep/pool.ts @@ -1,22 +1,14 @@ // 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 (counters, status, throttle files) happens between awaits on the one JS thread, so it -// needs no locks. -import { setCommitStatus } from './commit-status' +// shared-state mutation happens between awaits on the one JS thread, so it needs no locks. import { type Config, log } from './config' -import { commitStatusForSweepResult, reviewPr } from './review-pr' +import { reviewPr } from './review-pr' import { bumpDailyCounter, dailyPath, failuresPath, recordHeadAttempt, recordReviewedHead, reviewedPath } from './state' -import { setStatusPr, setStatusStage, skipStatusPr, type SweepStatus } from './status' import { type Candidate, type SweepState } from './sweep' -export async function runCandidatePool( - cfg: Config, - status: SweepStatus, - candidates: Candidate[], - state: SweepState, -): Promise<{ reviewed: number; tokens: number }> { +/** Review the candidates; returns how many reviews were posted. */ +export async function runCandidatePool(cfg: Config, candidates: Candidate[], state: SweepState): Promise { let reviewed = 0 - const tokens = 0 let next = 0 let limitHit = false const worker = async (): Promise => { @@ -25,69 +17,29 @@ export async function runCandidatePool( if (c === undefined) { return } - const { pr, prior, diff, lines } = c - setStatusPr(cfg, status, pr, 'reviewing', `running codex over ${lines} diff lines`, lines) - setCommitStatus(cfg, state.commitStatuses, pr, 'pending', `stupify is reviewing ${lines} diff lines`) // oxlint-disable-next-line no-await-in-loop -- each worker awaits serially BY DESIGN; the parallelism is across workers - const used = await reviewPr(cfg, pr, prior.memory, diff, c.firstReview, prior.openThreadIds) - if (used === 'limit') { - limitHit = true - log( - 'codex plan is rate-limited — no new reviews this sweep (the rest would fail the same way); retries next sweep', - ) - setStatusPr(cfg, status, pr, 'failed', 'codex plan is rate-limited; ending sweep early', lines) - setStatusStage(cfg, status, 'blocked', 'codex plan is rate-limited') - setCommitStatus(cfg, state.commitStatuses, pr, 'error', 'codex plan is rate-limited; retrying later') - recordHeadAttempt(failuresPath(cfg), state.failures, String(pr.number), pr.headRefOid) // throttle this head too so the next sweep doesn't immediately re-hit the wall + const used = await reviewPr(cfg, c.pr, c.prior.memory, c.diff, c.firstReview, c.prior.openThreadIds) + 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) + if (used === 'limit') { + limitHit = true + log( + 'codex plan is rate-limited — no new reviews this sweep (the rest would fail the same way); retries next sweep', + ) + } continue } - if (used === null) { - recordHeadAttempt(failuresPath(cfg), state.failures, String(pr.number), pr.headRefOid) // logged, not posted — throttle re-attempt until the window lapses or the head moves - setStatusPr(cfg, status, pr, 'failed', 'review failed; retry will wait for the failure window', lines) - setCommitStatus(cfg, state.commitStatuses, pr, 'error', 'stupify review failed; retrying later') - continue - } - // codex ran and reached a verdict (findings posted, or a no-op). Record this head so the next sweep doesn't - // re-run codex on it — without this a SUPPRESSED no-op (no thread marker) would re-run every minute and drain - // the plan. Count the run toward the daily spend ceiling either way: a no-op still spent the tokens. - recordReviewedHead(reviewedPath(cfg), state.reviewedLocal, String(pr.number), pr.headRefOid) + // 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) if (typeof used === 'object') { reviewed += 1 - setStatusPr( - cfg, - status, - pr, - 'posted', - `posted review${used.blocking === 0 ? ' (non-blocking only)' : ''}`, - lines, - ) - } else if (used === 'open') { - setStatusPr(cfg, status, pr, 'skipped', 'prior findings still open; no new review posted', lines) - } else if (used === 'fixed') { - setStatusPr(cfg, status, pr, 'clean', 'prior findings resolved', lines) - } else { - setStatusPr(cfg, status, pr, 'clean', 'no new review needed', lines) - } - // A notes-only review must not green a PR whose PRIOR blocking threads are still open — 'open' outranks it. - let result: number | 'clean' | 'fixed' | 'open' - if (typeof used === 'object') { - result = used.blocking === 0 && c.prior.openThreadIds.length > 0 ? 'open' : used.blocking - } else { - result = used } - const finalStatus = commitStatusForSweepResult(result) - setCommitStatus(cfg, state.commitStatuses, pr, finalStatus.state, finalStatus.description) - status.totals.reviewed = reviewed - status.totals.tokens = tokens } } await Promise.all(Array.from({ length: Math.min(cfg.codexJobs, candidates.length) }, () => worker())) - if (limitHit) { - for (const c of candidates.slice(next)) { - skipStatusPr(cfg, status, c.pr, 'deferred', 'codex plan is rate-limited; deferred to next sweep') - setCommitStatus(cfg, state.commitStatuses, c.pr, 'error', 'codex plan is rate-limited; retrying later') - } - } - return { reviewed, tokens } + return reviewed } diff --git a/src/sweep/prompt.ts b/src/sweep/prompt.ts index 12bbde3..dd4590d 100644 --- a/src/sweep/prompt.ts +++ b/src/sweep/prompt.ts @@ -44,7 +44,7 @@ Review this pull request against the spec and rubric. ${diff}` } -// Resolve a `.review/` that has the full taste set (spec + rubric + corpus). Both the sweep and `stupify review` -// gate on it; a partial dir (e.g. CORPUS without the spec) reads as absent so the caller falls back cleanly. +// 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/review-one.ts b/src/sweep/review-one.ts deleted file mode 100644 index 74f1c29..0000000 --- a/src/sweep/review-one.ts +++ /dev/null @@ -1,115 +0,0 @@ -// `stupify review ` — review ONE pull request on demand (no cron, no checkout, no lock) and print it, -// or `--post` it. Always a FRESH perspective: no prior-review memory, so you get the full take. -import { existsSync } from 'node:fs' -import { join } from 'node:path' - -import { detectRepo, exec } from '@bevyl-ai/agent-tools' -import { z } from 'zod' - -import { runReview } from './codex' -import { type Config } from './config' -import { getDiff, GH_DIFF_LIMITS } from './diff' -import { postReview } from './github' -import { hasMachinery } from './prompt' -import { Pr } from './prs' -import { prepareHeadWorktree, removeHeadWorktree } from './worktree' - -/** Accepts a PR URL or `owner/repo#123` (the CLI resolves a bare `#123` against the cwd repo before calling here). */ -export async function reviewOne(cfg: Config, ref: string, post: boolean): Promise { - const url = ref.match(/github\.com\/(?[^/\s]+\/[^/\s]+)\/(?:pull|issues)\/(?\d+)/i) - const short = ref.match(/^(?[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)[#/](?\d+)$/) - const slug = url?.groups?.slug ?? short?.groups?.slug ?? '' - const number = Number(url?.groups?.number ?? short?.groups?.number ?? 0) - if (!slug || !number) { - console.error(`stupify review: couldn't parse '${ref}'. Pass a PR URL or owner/repo#123.`) - process.exit(1) - } - cfg.slug = slug - // Taste: this repo's own .review/ if you're standing in it, else the global one under ~/.stupify/.review. - const cwdReview = join(process.cwd(), '.review') - cfg.reviewDir = hasMachinery(cwdReview) ? cwdReview : cfg.homeReviewDir - if (!hasMachinery(cfg.reviewDir)) { - console.error('stupify review: no taste found. Run `stupify taste` (or add a .review/ to this repo) first.') - process.exit(1) - } - // Ad-hoc review runs in cwd. When cwd is the target repo we spin a head worktree for file context; otherwise codex - // reviews from the inlined diff alone (cross-repo refs can't fetch into a foreign checkout). - cfg.repoDir = process.cwd() - // REST — `gh pr view --json baseRefOid` is missing on Ubuntu 2.45 gh. - const head = exec('gh', ['api', `repos/${slug}/pulls/${number}`]) - if (!head.ok) { - console.error(`stupify review: couldn't read ${slug}#${number} via gh (auth? does it exist?).`) - process.exit(1) - } - const raw = z - .object({ - head: z.object({ sha: z.string() }), - base: z.object({ sha: z.string(), ref: z.string() }), - title: z.string(), - body: z.string().nullable(), - }) - .parse(JSON.parse(head.stdout)) - const meta = Pr.pick({ headRefOid: true, baseRefOid: true, baseRefName: true, title: true, body: true }).parse({ - headRefOid: raw.head.sha, - baseRefOid: raw.base.sha, - baseRefName: raw.base.ref, - title: raw.title, - body: raw.body ?? '', - }) - const pr = { - number, - ...meta, - isDraft: false, - author: { login: '', is_bot: false }, - labels: [], - } - const read = getDiff(cfg, pr) - if (!read.ok) { - console.error( - read.reason === 'too-large' - ? `stupify review: ${slug}#${number} is over GitHub's ${GH_DIFF_LIMITS} diff API limit, so gh can't return it and there's nothing to review. Split the PR.` - : `stupify review: couldn't fetch the diff for ${slug}#${number}.`, - ) - process.exit(1) - } - const { diff } = read - const localRepo = detectRepo() - const canWorktree = - localRepo !== null && localRepo.toLowerCase() === slug.toLowerCase() && existsSync(join(cfg.repoDir, '.git')) - console.error(`reviewing ${slug}#${number} (base ${pr.baseRefName}) …`) // progress on stderr; stdout stays just the review - let workDir: string | undefined - if (canWorktree) { - workDir = prepareHeadWorktree(cfg.repoDir, pr) ?? undefined - if (workDir === undefined) { - console.error(`stupify review: couldn't checkout head for ${slug}#${number} (git fetch/worktree failed).`) - process.exit(1) - } - } - let r - try { - r = await runReview(cfg, pr, '', diff, workDir) - } finally { - if (canWorktree && workDir !== undefined) { - removeHeadWorktree(cfg.repoDir, pr) - } - } - if (r.kind === 'limit' || r.kind === 'fail') { - console.error( - `stupify review: ${r.kind === 'limit' ? 'codex is out of credits / rate-limited' : "codex couldn't produce a review"} — ${r.reason}`, - ) - process.exit(1) - } - if (r.kind === 'no_new_issues' || r.kind === 'fixed') { - console.log('LGTM ✅ (no blocking issues)') // a one-shot manual review has no prior findings to "fix" — both read as clean - return - } - if (!post) { - console.log([r.opener, ...r.findings.map((f) => f.body)].filter(Boolean).join('\n\n')) // default: print to stdout - return - } - if (!postReview(cfg, pr, r.opener, r.findings)) { - console.error('stupify review: the review ran but posting it failed (gh).') - process.exit(1) - } - console.log(`posted to ${slug}#${number} ✅ (${r.findings.length} inline)`) -} diff --git a/src/sweep/review-pr.ts b/src/sweep/review-pr.ts index 643009a..2b14688 100644 --- a/src/sweep/review-pr.ts +++ b/src/sweep/review-pr.ts @@ -1,9 +1,6 @@ // 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 { maybeRotateGateway } from '@bevyl-ai/agent-tools' - import { runReview } from './codex' -import { type CommitStatusState } from './commit-status' import { type Config, log } from './config' import { postNote, postReview, resolveThreads } from './github' import { type Pr } from './prs' @@ -13,25 +10,6 @@ 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 -export function commitStatusForSweepResult(result: number | 'clean' | 'fixed' | 'open'): { - state: CommitStatusState - description: string -} { - if (typeof result === 'number') { - if (result > 0) { - return { state: 'failure', description: 'stupify found issues; see review' } - } - return { state: 'success', description: 'no blocking issues; stupify left notes' } - } - if (result === 'open') { - return { state: 'failure', description: 'prior stupify findings are still open' } - } - if (result === 'fixed') { - return { state: 'success', description: 'prior stupify findings resolved' } - } - return { state: 'success', description: 'stupify review complete; no new issues' } -} - /** 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. @@ -61,25 +39,7 @@ export async function reviewPr( } if (r.kind === 'limit' || r.kind === 'fail') { log(` review FAILED for #${pr.number} — ${r.reason}`) - if (r.kind === 'limit') { - // Self-heal: advance ~/.codex/config.toml to the next CODEX_GATEWAY_POOL account (the shared ring — - // same kit + env contract bunion/earshot rotate on). Codex re-reads the file each sweep, so the next - // sweep lands on the fresh account. The kit's signature match is tighter than isRateLimited by design: - // a transient 429 ends this sweep but doesn't walk the ring. - const rot = maybeRotateGateway({ - reason: r.raw, - pool: cfg.gatewayPool - .split(',') - .map((h) => h.trim()) - .filter(Boolean), - cooldownMs: cfg.rotateCooldownMs, - }) - if (rot.rotated) { - log(` codex gateway rotated: ${rot.from} → ${rot.to}`) - } - return 'limit' - } - return null + 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 diff --git a/src/sweep/state.ts b/src/sweep/state.ts index 93448dd..0c2afe5 100644 --- a/src/sweep/state.ts +++ b/src/sweep/state.ts @@ -1,6 +1,4 @@ -// --- Per-VM sweep state: tiny best-effort JSON files (a parse error or fresh VM just re-attempts once). --- -// These lived in @stupify/exe-host, but they are review-sweep domain vocabulary (heads, reviews/day) with -// exactly one consumer, so they live here rather than in the shared kit. +// 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' @@ -88,5 +86,3 @@ export function bumpDailyCounter(path: string, daily: DailyCounter): void { 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') -export const statusPath = (cfg: Config): string => join(cfg.stateDir, 'status.json') -export const commitStatusPath = (cfg: Config): string => join(cfg.stateDir, 'commit-statuses.json') diff --git a/src/sweep/status.ts b/src/sweep/status.ts deleted file mode 100644 index b792df5..0000000 --- a/src/sweep/status.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The sweep's status file (state/status.json) — what `stupify status` renders as a workflow. Best-effort: -// writing it must never break the reviewer. -import { mkdirSync, renameSync, writeFileSync } from 'node:fs' - -import { z } from 'zod' - -import { type Config, Scope } from './config' -import { type Pr } from './prs' -import { statusPath } from './state' - -const PrStatusState = z.enum(['queued', 'reviewing', 'posted', 'clean', 'dry_run', 'skipped', 'deferred', 'failed']) -type PrStatusState = z.infer - -export const SweepStatus = z.object({ - version: z.literal(1), - repo: z.string(), - scope: Scope, - dryRun: z.boolean(), - stage: z.enum(['starting', 'refreshing', 'loading_taste', 'listing_prs', 'reviewing', 'done', 'blocked']), - startedAt: z.string(), - updatedAt: z.string(), - finishedAt: z.string().optional(), - message: z.string(), - totals: z.object({ - openPrs: z.number(), - inScope: z.number(), - handled: z.number(), - reviewed: z.number(), - skipped: z.number(), - tokens: z.number(), - maxPrs: z.number(), - }), - prs: z.array( - z.object({ - number: z.number(), - title: z.string(), - head: z.string(), - state: PrStatusState, - detail: z.string(), - lines: z.number().optional(), - updatedAt: z.string(), - }), - ), -}) -export type SweepStatus = z.infer - -export const isoNow = (): string => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') - -export function initialStatus(cfg: Config): SweepStatus { - const now = isoNow() - return { - version: 1, - repo: cfg.slug, - scope: cfg.scope, - dryRun: cfg.dryRun, - stage: 'starting', - startedAt: now, - updatedAt: now, - message: 'starting sweep', - totals: { openPrs: 0, inScope: 0, handled: 0, reviewed: 0, skipped: 0, tokens: 0, maxPrs: cfg.maxPrs }, - prs: [], - } -} - -export function writeStatus(cfg: Config, status: SweepStatus): void { - status.updatedAt = isoNow() - try { - mkdirSync(cfg.stateDir, { recursive: true }) - const path = statusPath(cfg) - const tmp = `${path}.tmp` - writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`) - renameSync(tmp, path) - } catch { - /* best-effort: status must never break the reviewer */ - } -} - -export function setStatusStage(cfg: Config, status: SweepStatus, stage: SweepStatus['stage'], message: string): void { - status.stage = stage - status.message = message - writeStatus(cfg, status) -} - -export function seedStatusPrs(cfg: Config, status: SweepStatus, prs: Pr[]): void { - status.prs = prs.map((pr) => ({ - number: pr.number, - title: pr.title, - head: pr.headRefOid, - state: 'queued', - detail: 'waiting for review slot', - updatedAt: isoNow(), - })) - status.totals.inScope = prs.length - writeStatus(cfg, status) -} - -export function setStatusPr( - cfg: Config, - status: SweepStatus, - pr: Pr, - state: PrStatusState, - detail: string, - lines?: number, -): void { - const next: SweepStatus['prs'][number] = { - number: pr.number, - title: pr.title, - head: pr.headRefOid, - state, - detail, - updatedAt: isoNow(), - } - if (lines !== undefined) { - next.lines = lines - } - const i = status.prs.findIndex((p) => p.number === pr.number) - if (i !== -1) { - status.prs[i] = next - } else { - status.prs.push(next) - } - writeStatus(cfg, status) -} - -export function skipStatusPr( - cfg: Config, - status: SweepStatus, - pr: Pr, - state: 'skipped' | 'deferred', - detail: string, - lines?: number, -): void { - status.totals.skipped += 1 - setStatusPr(cfg, status, pr, state, detail, lines) -} - -export function deferQueuedStatusPrs(cfg: Config, status: SweepStatus, prs: Pr[], start: number, detail: string): void { - for (const pr of prs.slice(start)) { - const existing = status.prs.find((p) => p.number === pr.number) - if (existing?.state === 'queued') { - skipStatusPr(cfg, status, pr, 'deferred', detail) - } - } -} diff --git a/src/sweep/sweep.ts b/src/sweep/sweep.ts index fa810b7..d88b70f 100644 --- a/src/sweep/sweep.ts +++ b/src/sweep/sweep.ts @@ -1,13 +1,10 @@ -// The sweep's front half: load the per-VM state, then collect the PRs that pass the cheap serial gates +// 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 { loadCommitStatuses, type PostedCommitStatus, setCommitStatus } from './commit-status' import { type Config, log } from './config' import { diffLineCount, getDiff, GH_DIFF_LIMITS } from './diff' import { type PriorState } from './github' import { hasReviewLabel, type Pr } from './prs' -import { commitStatusForSweepResult } from './review-pr' import { - commitStatusPath, type DailyCounter, dailyPath, failuresPath, @@ -17,22 +14,19 @@ import { loadReviewedHeads, reviewedPath, } from './state' -import { deferQueuedStatusPrs, setStatusPr, skipStatusPr, type SweepStatus } from './status' export interface Candidate { pr: Pr prior: PriorState diff: string - lines: number firstReview: boolean } -// The four per-VM state files every sweep loads up front (see state.ts). +// 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 - commitStatuses: Record // head+context -> last posted payload; avoids append-only status spam } export function loadSweepState(cfg: Config): SweepState { @@ -40,137 +34,74 @@ export function loadSweepState(cfg: Config): SweepState { failures: loadHeadAttempts(failuresPath(cfg)), reviewedLocal: loadReviewedHeads(reviewedPath(cfg)), daily: loadDailyCounter(dailyPath(cfg)), - commitStatuses: loadCommitStatuses(commitStatusPath(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 — the sweep's wall-clock -// was dominated by running those multi-minute reviews strictly one after another. +// cheap serial gates) and reviewed by pool.ts's CODEX_JOBS concurrent codex sessions. export function collectCandidates( cfg: Config, - status: SweepStatus, queue: Pr[], priorByPr: Map, state: SweepState, -): { candidates: Candidate[]; handled: number } { +): 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 const candidates: Candidate[] = [] - for (let i = 0; i < queue.length; i++) { - const pr = queue[i] - if (pr === undefined) { - continue - } + for (const pr of queue) { if (handled >= dailyBudget) { log(`daily cap hit (MAX_REVIEWS_PER_DAY=${cfg.maxReviewsPerDay}) — no more reviews today; resumes tomorrow`) - deferQueuedStatusPrs( - cfg, - status, - queue, - i, - `daily cap hit (MAX_REVIEWS_PER_DAY=${cfg.maxReviewsPerDay}); resumes tomorrow`, - ) break } - // What stupify has already said here — read from the reviews/threads connection (findings are inline threads now). + // What stupify has already said here — read from the reviews/threads connection (findings are inline threads). const prior = priorByPr.get(pr.number) ?? null if (prior === null) { log(`skip #${pr.number} — couldn't read its reviews from gh (failed/malformed); will retry next sweep`) - skipStatusPr(cfg, status, pr, 'skipped', "couldn't read reviews from gh; will retry next sweep") - setCommitStatus(cfg, state.commitStatuses, pr, 'error', "couldn't read PR review state; retrying 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 skip — don't re-run codex. + // 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 PR we already tried at THIS head within the retry - // window (so a persistently-failing PR isn't re-run every sweep, but a transient failure retries once it lapses). + // 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) { - skipStatusPr(cfg, status, pr, 'skipped', 'already reviewed this head') - const reviewedStatus = commitStatusForSweepResult(prior.openThreadIds.length > 0 ? 'open' : 'clean') - setCommitStatus(cfg, state.commitStatuses, pr, reviewedStatus.state, reviewedStatus.description) + if (reviewedHead || recentlyFailed) { continue } - if (recentlyFailed) { - skipStatusPr(cfg, status, pr, 'skipped', 'recently failed; retry window has not elapsed') - continue - } - // Past the cheap dedup skip — this PR is a real candidate. Enforce MAX_PRS here, not on the - // iterated list, and defer the rest to the next sweep. + // 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`) - deferQueuedStatusPrs(cfg, status, queue, i, `reached MAX_PRS=${cfg.maxPrs}; deferring to next sweep`) - setCommitStatus( - cfg, - state.commitStatuses, - pr, - 'pending', - `reached MAX_PRS=${cfg.maxPrs}; deferring to 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 && read.reason === 'too-large') { - // Terminal: gh will never hand us this diff, so there is nothing to retry and nothing to measure. Say so - // plainly — the old wording promised a retry that could not possibly succeed. - const why = `diff over GitHub's ${GH_DIFF_LIMITS} API limit — gh can't return it, so it can't be reviewed; split the PR` - log(`skip #${pr.number} — ${why}`) - skipStatusPr(cfg, status, pr, 'skipped', why) - setCommitStatus( - cfg, - state.commitStatuses, - pr, - 'success', - `diff over GitHub's ${GH_DIFF_LIMITS} API limit; split the PR to get a review`, - ) - continue - } if (!read.ok) { - log(`skip #${pr.number} — couldn't read its diff from gh; will retry next sweep`) - skipStatusPr(cfg, status, pr, 'skipped', "couldn't read diff from gh; will retry next sweep") - setCommitStatus(cfg, state.commitStatuses, pr, 'error', "couldn't read PR diff; retrying next sweep") + // 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` + : `skip #${pr.number} — couldn't read its diff from gh; will retry next sweep`, + ) 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). - // (label-scope means you already opted in, so size never gates there.) 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)`) - skipStatusPr( - cfg, - status, - pr, - 'skipped', - `diff ${lines} lines > cap ${cfg.diffLineCap} (add '${cfg.reviewLabel}' to force)`, - lines, - ) - setCommitStatus( - cfg, - state.commitStatuses, - pr, - 'success', - `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 — size/read skips above don't burn it - status.totals.handled = handled + handled += 1 // count only PRs that pass the gates and actually get a review slot if (cfg.dryRun) { log(`DRY_RUN would review #${pr.number} @ ${pr.headRefOid.slice(0, 8)} (diff ${lines} lines)`) - setStatusPr(cfg, status, pr, 'dry_run', `would review ${lines} diff lines`, lines) continue } - candidates.push({ pr, prior, diff, lines, firstReview }) + candidates.push({ pr, prior, diff, firstReview }) } - return { candidates, handled } + return candidates } From 5f588265f3c0499a05f17379ba6e15dfd73299ca Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Mon, 7 Sep 2026 22:46:18 -0400 Subject: [PATCH 3/5] Accept the verdict only during the second pass. stupify's own review of the first commit: review_verdict was live for the whole session, so a first-turn call could fill the verdict and be returned after runTurns without the ownership challenge ever running. The tool now refuses until the second-pass prompt is the running turn, with an error that tells the model when to call it. --- src/sweep/codex.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index 05112b0..2f46284 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -42,15 +42,19 @@ 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: 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) => { +// 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) => { const valid = diffRightLines(diff) return tool( 'review_verdict', 'Submit the review verdict. Call once, after the second pass.', ReviewOutput, (data) => { + if (!secondPass()) { + throw new Error('not yet: finish the review, do the second pass when asked, then call review_verdict') + } for (const f of data.findings) { const lines = valid.get(f.path) if (lines === undefined) { @@ -77,6 +81,7 @@ 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, @@ -87,9 +92,13 @@ export async function runReview( turnTimeoutMs: TURN_TIMEOUT_MS, }, [ - verdictTool(diff, (verdict) => { - got.verdict = verdict - }), + verdictTool( + diff, + () => turns.length === 0, // both prompts handed out → the second pass is the running turn + (verdict) => { + got.verdict = verdict + }, + ), ], (event) => { if (event.log) { @@ -113,7 +122,6 @@ export async function runReview( }, }, ) - const turns = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] try { await session.runTurns(() => turns.shift() ?? null) } catch (error) { From ca4a10657022d568be08a0c4735ca6bcd5bc0af4 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Mon, 7 Sep 2026 22:53:02 -0400 Subject: [PATCH 4/5] Set the per-turn sandbox policy to read-only. stupify's second finding on this PR: the kit's runTurns passes no per-turn sandbox override and its turn default is dangerFullAccess, so the thread-level 'read-only' string owned nothing. turnSandboxPolicy { type: 'readOnly' } is what codex enforces per turn. --- src/sweep/codex.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index 2f46284..a2f6e4a 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -88,7 +88,10 @@ export async function runReview( title: `#${pr.number}`, model: cfg.codexModel || undefined, effort: cfg.codexEffort, - threadSandbox: 'read-only', // a reviewer reads; the prompt's "don't edit code" is enforced, not requested + // 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, }, [ From 5926fc0c53ab3f071d57787c5976f3ff3dcd7ee2 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Mon, 7 Sep 2026 23:01:10 -0400 Subject: [PATCH 5/5] Stop the codex child when a session fails before its own cleanup runs. stupify's third finding: the kit's start() spawns codex and rethrows a failed handshake without stop(), and runTurns only enters its try/finally after start() returns. Under a minute cron that is an orphan per failed sweep. One stop() in our catch until the kit's start() owns it. --- src/sweep/codex.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index a2f6e4a..a77d307 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -128,6 +128,9 @@ export async function runReview( try { await session.runTurns(() => turns.shift() ?? null) } 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`) return callFailed(raw)