diff --git a/.github/workflows/agent-review.yml b/.github/workflows/agent-review.yml index 96c58366c32..b5b9afe6d35 100644 --- a/.github/workflows/agent-review.yml +++ b/.github/workflows/agent-review.yml @@ -3,20 +3,25 @@ # WHAT THIS DOES (and does NOT do): # Fires when a human applies the `agent:review` label to a PULL REQUEST. It # runs the single-pass reviewer (`pnpm sandcastle:review`) against the PR's -# head branch, pushing any clarity/standards refinements back onto the PR. It -# NEVER merges the PR and NEVER closes anything — a human still merges. This is -# the single-pass replacement for the old 4-round `review-round:*` loop. +# head branch, pushing any clarity/standards refinements back onto the PR, +# and requires a structured verdict (toon-meta#275): the reviewer resolves the +# PR's target issue from its body's `Closes #n`, reviews against the issue's +# acceptance criteria, and must emit {"verdict":"clean"|"blocking", +# ...} — a malformed verdict FAILS the job. On "blocking" the runner +# posts the findings as a PR review and applies `needs:human` (both via the +# App token below — this workflow's own GITHUB_TOKEN stays read-only). It +# NEVER merges the PR and NEVER closes anything — a human still merges. This +# is the single-pass replacement for the old 4-round `review-round:*` loop. # # Committing this file triggers nothing: pull_request-`labeled` workflows fire -# only from the default branch and only when someone applies the label. +# only when someone applies the label. # -# VERIFY ON FIRST RUN — the standalone-review path: -# Sandcastle 0.12.0 only exercises the reviewer inside its parallel loop, on a -# fresh branch it just created. Running it standalone against an existing PR -# head branch is our interpretation (see .sandcastle/agent-review-pr.ts). On -# the first live run confirm: (1) the sandbox checks out the existing PR head -# rather than erroring on the ref, and (2) review-prompt.md's built-in -# {{TARGET_BRANCH}} resolves to `main` so the diff is non-empty. +# STANDALONE-REVIEW MECHANICS (proven live on connector#634's first run): +# sandcastle checks the PR head branch out in its OWN worktree under +# .sandcastle/worktrees/, and git refuses the same branch in two worktrees — +# so the checkout below pins `ref: main`, never the PR head. The runner then +# materialises the PR head as a local branch itself (see +# .sandcastle/agent-review-pr.ts). # # Note: label events on PRs opened from FORKS run without secrets (GitHub # security), so this runner only works on same-repo PRs. That is expected for @@ -83,8 +88,14 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - # Check out the PR head so the reviewer operates on the right branch. - ref: ${{ github.event.pull_request.head.ref }} + # Check out MAIN, not the PR head: sandcastle's branch strategy checks + # the PR head out in its own worktree under .sandcastle/worktrees/, + # and git refuses to check out one branch in two worktrees at once + # (WorktreeError "already checked out" — connector#634's first live + # run). The runner fetches the PR head into a local branch itself, and + # review-prompt.md's {{TARGET_BRANCH}} resolves to this checkout + # (main), giving the right diff base. + ref: main - uses: actions/setup-node@v4 with: diff --git a/.sandcastle/agent-implement-issue.ts b/.sandcastle/agent-implement-issue.ts index 06049df1ea5..89cfbec0446 100644 --- a/.sandcastle/agent-implement-issue.ts +++ b/.sandcastle/agent-implement-issue.ts @@ -41,6 +41,10 @@ import { execFileSync } from "node:child_process"; import * as sandcastle from "@ai-hero/sandcastle"; import { docker } from "@ai-hero/sandcastle/sandboxes/docker"; +import { + postBlockingVerdict, + runReviewerWithVerdict, +} from "./review-verdict.ts"; import { sandboxSecrets } from "./sandbox-secrets.ts"; // --------------------------------------------------------------------------- @@ -163,18 +167,30 @@ try { process.exit(0); } - // Review (opus, 1 iteration) on the SAME branch. The engine supplies the - // built-in {{TARGET_BRANCH}} used inside review-prompt.md, so we pass only - // BRANCH (mirrors main.ts). - await sandbox.run({ - name: "reviewer", - maxIterations: 1, - agent: sandcastle.claudeCode("claude-opus-5"), - promptFile: "./.sandcastle/review-prompt.md", - promptArgs: { BRANCH: branch }, + // Review (opus, 1 iteration) on the SAME branch, with the structured + // verdict REQUIRED (toon-meta#275): the reviewer receives the issue via + // promptArgs (Spec axis — it reviews against the issue's acceptance + // criteria, not just the diff) and must emit + // {"verdict":"clean"|"blocking","blockingFindings":[...]}. + // A malformed verdict fails the run (one engine-style resume retry, then + // non-zero exit) — see ./review-verdict.ts. The engine supplies the + // built-in {{TARGET_BRANCH}} used inside review-prompt.md. + const review = await runReviewerWithVerdict(sandbox, { + branch, + issue: { number: issueNumber, title: issueTitle }, }); + const blocking = review.verdict.verdict === "blocking"; - if (autoMerge) { + if (autoMerge && blocking) { + // A blocking verdict must never be auto-merged: fall through to PR mode + // so the findings land on a PR for a human instead (toon-meta#275). + console.log( + "\nAuto-merge requested, but the reviewer verdict is BLOCKING — " + + "falling back to PR mode so a human decides.", + ); + } + + if (autoMerge && !blocking) { // RE-ENABLE path: merge this one branch into the checked-out base and close // the issue, using the stock merge prompt scoped to the single branch. console.log("\nAuto-merge enabled — merging branch and closing issue."); @@ -222,6 +238,14 @@ try { if (openPrs.length > 0) { const pr = openPrs[0]!; console.log(`\nVerified: PR #${pr.number} is open — ${pr.url}`); + // A blocking verdict lands on the PR now that it exists: findings as a + // PR review, plus the `needs:human` label (toon-meta#275). + if (blocking) { + postBlockingVerdict(String(pr.number), review.verdict, { + number: issueNumber, + title: issueTitle, + }); + } console.log("Awaiting human review."); } else { // No open PR. Gather diagnostics (all via the authenticated host `gh`). diff --git a/.sandcastle/agent-review-pr.ts b/.sandcastle/agent-review-pr.ts index 7626f6a31b6..4a1fb7407ce 100644 --- a/.sandcastle/agent-review-pr.ts +++ b/.sandcastle/agent-review-pr.ts @@ -3,29 +3,36 @@ // applied to ONE pull request. // // This is the single-pass replacement for the old 4-round `review-round:*` -// reviewer loop. It runs the reviewer role (review-prompt.md — refactor for -// clarity while preserving behavior, enforce CODING_STANDARDS.md) against the -// PR's head branch, and pushes any refinement commits back to the PR. It NEVER -// merges the PR and NEVER closes anything — a human still merges. +// reviewer loop. It runs the reviewer role (review-prompt.md — two axes: +// Standards refinement + Spec review against the PR's target issue) against +// the PR's head branch, pushes any refinement commits back to the PR, and +// REQUIRES a structured verdict (toon-meta#275): +// - the reviewer must emit {"verdict":"clean"|"blocking", +// "blockingFindings":[{file,line,summary,why}]}; a malformed +// verdict fails the run (one engine-style resume retry, then non-zero exit) +// - on "blocking", the findings are posted as a PR review and the +// `needs:human` label is applied +// It NEVER merges the PR and NEVER closes anything — a human still merges. // -// STANDALONE-REVIEW CAVEAT (verify on first run) -// ---------------------------------------------- -// Sandcastle 0.12.0 exercises the reviewer only INSIDE the parallel loop's -// Phase 2, on a fresh `sandcastle/issue-*` branch it just created. Driving the -// same reviewer standalone against an already-existing PR head branch is our -// interpretation, not a documented engine feature. Two things to confirm on the -// first live run: -// 1. createSandbox({ branch: }) checks out the EXISTING -// branch (rather than failing because the ref already exists / creating a -// divergent one). The workflow checks out the PR head first to help this. -// 2. The built-in {{TARGET_BRANCH}} inside review-prompt.md resolves to `main` -// for a standalone sandbox. If the diff comes back empty, the base may be -// resolving wrong — check the reviewer's logged `git diff` command. +// STANDALONE-REVIEW MECHANICS (proven live on connector#634's first run): +// Sandcastle checks the PR head branch out in its OWN worktree under +// .sandcastle/worktrees/, and git refuses one branch in two worktrees — so +// the workflow checks out MAIN, never the PR head. Because the local clone +// is then on main, this runner materialises the PR head as a LOCAL branch +// (git fetch origin +head:head) before createSandbox(): without it the +// engine's `worktree add` falls back to `-b HEAD`, silently +// reviewing an EMPTY diff off main. review-prompt.md's {{TARGET_BRANCH}} +// resolves to the checked-out branch (main), so the diff base is right. +// +// The target issue for the Spec axis is resolved from the PR body's +// `Closes #n` (the implement runner writes one into every factory PR body). +// PRs without a closing reference get a Standards-only review. // // Required env: // SANDCASTLE_PR_NUMBER the PR to review (github.event.pull_request.number) // CLAUDE_CODE_OAUTH_TOKEN Claude Max-plan credential (org secret) -// GH_TOKEN token with contents:write + pull-requests:write +// GH_TOKEN token with contents:write + pull-requests:write + +// issues:write (labels) // // Usage: // SANDCASTLE_PR_NUMBER=42 npx tsx .sandcastle/agent-review-pr.ts @@ -34,6 +41,12 @@ import { execFileSync } from "node:child_process"; import * as sandcastle from "@ai-hero/sandcastle"; import { docker } from "@ai-hero/sandcastle/sandboxes/docker"; +import { + postBlockingVerdict, + resolveIssueFromPrBody, + type ReviewVerdict, + runReviewerWithVerdict, +} from "./review-verdict.ts"; import { sandboxSecrets } from "./sandbox-secrets.ts"; const prNumber = process.env.SANDCASTLE_PR_NUMBER?.trim(); @@ -55,6 +68,21 @@ if (!headRef) { throw new Error(`Could not resolve head branch for PR #${prNumber}.`); } +// Materialise the PR head as a local branch at origin's tip (the host clone is +// on main — see the standalone-review mechanics note above). Forced so a +// re-labeled PR re-reviews the CURRENT head even after a force-push. +execFileSync("git", ["fetch", "origin", `+${headRef}:${headRef}`], { + stdio: "inherit", +}); + +// Resolve the Spec-axis target issue from the PR body's `Closes #n`. +const targetIssue = resolveIssueFromPrBody(prNumber); +console.log( + targetIssue + ? `Spec axis target: issue #${targetIssue.number} — ${targetIssue.title}` + : "No `Closes #n` in the PR body — Standards-only review.", +); + const hooks = { sandbox: { onSandboxReady: [ @@ -92,19 +120,19 @@ const sandbox = await sandcastle.createSandbox({ branch: headRef, // Forward CLAUDE_CODE_OAUTH_TOKEN + GH_TOKEN into the container (the engine's // env resolver does not — see ./sandbox-secrets.ts). GH_TOKEN is what the - // review-push step's in-sandbox `git push` to the PR branch authenticates with. + // review-push step's in-sandbox `git push` to the PR branch authenticates + // with, and what the reviewer's in-sandbox `gh issue view` (Spec axis) reads. sandbox: docker({ env: sandboxSecrets() }), hooks, }); +let verdict: ReviewVerdict; try { - const review = await sandbox.run({ - name: "reviewer", - maxIterations: 1, - agent: sandcastle.claudeCode("claude-opus-5"), - promptFile: "./.sandcastle/review-prompt.md", - promptArgs: { BRANCH: headRef }, + const review = await runReviewerWithVerdict(sandbox, { + branch: headRef, + issue: targetIssue, }); + verdict = review.verdict; if (review.commits.length > 0) { // Push the reviewer's refinement commits back onto the PR branch. No merge, @@ -171,14 +199,21 @@ try { `deliberately so this is not mistaken for success.`; } } else { - console.log( - "\nReviewer made no changes — the code was already clean. Nothing to push.", - ); + console.log("\nReviewer made no changes — nothing to push."); } } finally { await sandbox.close(); } +// The verdict's side effects run AFTER the sandbox is closed, from the +// authenticated host: findings must land on the PR even if the push +// verification below is about to fail the job. +if (verdict.verdict === "blocking") { + postBlockingVerdict(prNumber, verdict, targetIssue); +} else { + console.log("\nVerdict clean — no blocking findings."); +} + // Fail loud AFTER the sandbox is closed: a silently-failed review push must turn // the Actions job red, never green. if (reviewPushError) { diff --git a/.sandcastle/main.ts b/.sandcastle/main.ts index 311feef29e4..239f14551db 100644 --- a/.sandcastle/main.ts +++ b/.sandcastle/main.ts @@ -164,6 +164,13 @@ for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) { // Only review if the implementer produced commits if (implement.commits.length > 0) { + // review-prompt.md now requires ISSUE_NUMBER/ISSUE_TITLE (the Spec + // axis, toon-meta#275) — an unresolved {{...}} placeholder fails the + // run, so pass them here too. This reserved autonomous loop does not + // yet CONSUME the reviewer's verdict; the label runners + // (agent-implement-issue.ts / agent-review-pr.ts) enforce it via + // ./review-verdict.ts, and wiring it into this merge phase is part + // of the auto-merge work (toon-meta#270). const review = await sandbox.run({ name: "reviewer", maxIterations: 1, @@ -171,6 +178,8 @@ for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) { promptFile: "./.sandcastle/review-prompt.md", promptArgs: { BRANCH: issue.branch, + ISSUE_NUMBER: issue.id, + ISSUE_TITLE: issue.title, }, }); diff --git a/.sandcastle/review-prompt.md b/.sandcastle/review-prompt.md index f209983dbde..1ca51a179aa 100644 --- a/.sandcastle/review-prompt.md +++ b/.sandcastle/review-prompt.md @@ -1,9 +1,29 @@ # TASK -Review the code changes on branch `{{BRANCH}}` and improve code clarity, consistency, and maintainability while preserving exact functionality. +Review the code changes on branch `{{BRANCH}}` along two axes, then deliver a +structured verdict: + +1. **Standards** — improve code clarity, consistency, and maintainability while + preserving exact functionality. +2. **Spec** — does the change actually satisfy the issue it targets and its + acceptance criteria? # CONTEXT +## Target issue + +Issue: #{{ISSUE_NUMBER}} — {{ISSUE_TITLE}} + +If the issue number above is `none`, no target issue could be resolved for this +branch; skip the Spec axis and review the Standards axis only. + +Otherwise, BEFORE reading the diff, run: + + gh issue view {{ISSUE_NUMBER}} + +and read the issue body — especially its acceptance criteria. The Spec axis +reviews the diff AGAINST THOSE CRITERIA, not against the diff itself. + ## Branch diff !`git diff {{TARGET_BRANCH}}...{{BRANCH}}` @@ -14,9 +34,15 @@ Review the code changes on branch `{{BRANCH}}` and improve code clarity, consist # REVIEW PROCESS -1. **Understand the change**: Read the diff and commits above to understand the intent. +1. **Understand the change**: Read the issue (above) and the diff and commits + to understand the intent. + +2. **Spec axis**: Check the diff against the target issue: + - Does the change actually resolve what the issue asked for? + - Is every acceptance criterion met by the diff (not merely claimed)? + - Does anything in the diff contradict the issue? -2. **Analyze for improvements**: Look for opportunities to: +3. **Analyze for improvements** (Standards axis): Look for opportunities to: - Reduce unnecessary complexity and nesting - Eliminate redundant code and abstractions - Improve readability through clear variable and function names @@ -25,34 +51,74 @@ Review the code changes on branch `{{BRANCH}}` and improve code clarity, consist - Avoid nested ternary operators - prefer switch statements or if/else chains - Choose clarity over brevity - explicit code is often better than overly compact code -3. **Check correctness**: - - Does the implementation match the intent? Are edge cases handled? +4. **Check correctness**: + - Are edge cases handled? - Are new/changed behaviours covered by tests? - Are there unsafe casts, `any` types, unchecked `unwrap()`/`expect()` on fallible paths, or unchecked assumptions? - Does the change introduce injection vulnerabilities, credential leaks, or other security issues? -4. **Maintain balance**: Avoid over-simplification that could: +5. **Maintain balance**: Avoid over-simplification that could: - Reduce code clarity or maintainability - Create overly clever solutions that are hard to understand - Combine too many concerns into single functions or components - Remove helpful abstractions that improve code organization - Make the code harder to debug or extend -5. **Apply project standards**: Follow the coding standards defined in @.sandcastle/CODING_STANDARDS.md +6. **Apply project standards**: Follow the coding standards defined in @.sandcastle/CODING_STANDARDS.md + +7. **Preserve functionality**: Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact. + +# WHAT YOU FIX vs WHAT IS BLOCKING -6. **Preserve functionality**: Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact. +Fix yourself (Standards axis): presentation-level issues — naming, structure, +readability, redundant code, formatting, inconsistency with neighbouring code — +that preserve exact behaviour. + +BLOCKING (report in the verdict, do NOT rewrite): a defect that makes the +change wrong to merge — + +- the diff fails or contradicts the target issue's acceptance criteria +- the diff introduces a functional defect or breaks the gate +- the diff asserts something factually wrong (a comment, doc, or test that + misstates the actual behaviour) that you cannot verify a fix for + +Never rewrite the substance of the change to force a pass. Substance defects +are the author's to fix; your channel for them is the verdict below. # EXECUTION -If you find improvements to make: +If you find Standards improvements to make: 1. Make the changes directly on this branch 2. Run buzz's gate for the side(s) the diff touches to ensure nothing is broken — Rust: `just fmt-check`, `just desktop-tauri-fmt-check`, `just clippy`, `just test-unit`; JS: `just desktop-check`, `just desktop-test`, `just desktop-build`, `just web-check`, `just web-build`. (Flutter/mobile, `desktop/src-tauri` compile checks, e2e, and infra-backed integration tests are out of scope in the sandbox — upstream `ci.yml` covers them on the PR.) 3. Commit describing the refinements -If the code is already clean and well-structured, do nothing. +If the code is already clean and well-structured, make no commits. + +# REQUIRED VERDICT (structured output) + +You MUST end your final message with exactly one verdict block. The block is +machine-parsed; a missing or malformed block FAILS the run. Emit it even when +you made no changes. + +Format — JSON only inside the tag, no comments, no trailing commas, no code +fences: + + +{"verdict":"clean","blockingFindings":[]} + + +Rules: + +- `verdict` is `"clean"` or `"blocking"` — nothing else. +- `blockingFindings` MUST be empty for `clean` and non-empty for `blocking`. +- Each finding is + `{"file":"","line":<1-based number or null>,"summary":"","why":""}` + — `line` is `null` for file-level findings. +- Standards fixes you already made are NOT findings; findings are only the + blocking defects defined above. -Once complete, output COMPLETE. +Once complete, output the verdict block and then COMPLETE. ## Context budget diff --git a/.sandcastle/review-verdict.ts b/.sandcastle/review-verdict.ts new file mode 100644 index 00000000000..04aae079c84 --- /dev/null +++ b/.sandcastle/review-verdict.ts @@ -0,0 +1,374 @@ +// Reviewer verdict channel (toon-meta#275) — shared by the implement runner's +// review phase (agent-implement-issue.ts) and the standalone review runner +// (agent-review-pr.ts). +// +// WHY THIS MODULE DOES ITS OWN EXTRACTION (verified against the published +// @ai-hero/sandcastle@0.12.0 dist): +// - The engine's structured-output surface — `Output.object({ tag, schema })`, +// fence-aware tag extraction, `StructuredOutputError`, `maxRetries` via +// session resume — exists ONLY on the top-level `run()` entry point +// (dist/index.js `run()`; see engine ADR 0010). The long-lived +// `sandbox.run()` the runners use accepts NO `output` option: the runtime +// never reads it and the option would be silently ignored. +// - We therefore declare the exact `Output.object` definition the ticket +// specifies (`reviewOutput` below) and consume its tag + schema OURSELVES, +// mirroring the engine's extraction semantics precisely: the LAST +// `` block in the run's stdout wins, ```json fences are +// unwrapped, the contents are JSON-parsed and schema-validated. +// - The retry mirrors the engine's `maxRetries` design: on a malformed +// verdict, `SandboxRunResult.resume()` continues the reviewer's captured +// session for exactly one iteration with a token-efficient description of +// the parse/validation error (the engine's own structured-output retry +// mechanism). ONE retry, then FAIL: a malformed verdict must fail the job, +// never pass silently (toon-meta#275 acceptance criterion). +// - `resume()` gotcha: it spreads the ORIGINAL runOptions (including +// promptArgs) into an inline-prompt run, and the engine rejects non-empty +// promptArgs on inline prompts — so the retry passes `promptArgs: {}` +// explicitly. + +import { execFileSync } from "node:child_process"; +import * as sandcastle from "@ai-hero/sandcastle"; +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Schema + Output declaration +// --------------------------------------------------------------------------- + +export const reviewVerdictSchema = z + .object({ + verdict: z.enum(["clean", "blocking"]), + blockingFindings: z.array( + z.object({ + /** Repo-relative path of the file the finding is about. */ + file: z.string().min(1), + /** 1-based line number, or null for a file-level finding. */ + line: z.number().int().min(1).nullable(), + /** One-line description of the defect. */ + summary: z.string().min(1), + /** Why this blocks the merge (which criterion/behaviour it violates). */ + why: z.string().min(1), + }), + ), + }) + .superRefine((value, ctx) => { + if (value.verdict === "blocking" && value.blockingFindings.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "a blocking verdict requires at least one blockingFinding", + }); + } + if (value.verdict === "clean" && value.blockingFindings.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "a clean verdict must carry zero blockingFindings", + }); + } + }); + +export type ReviewVerdict = z.infer; + +/** + * The structured-output declaration toon-meta#275 specifies. `sandbox.run()` + * cannot consume it (see module header), so `extractReviewVerdict()` consumes + * its tag + schema instead. Keeping the declaration in the engine's own shape + * means the runners can hand it straight to top-level `run({ output })` if the + * engine ever exposes structured output on the sandbox surface. + */ +export const reviewOutput = sandcastle.Output.object({ + tag: "review", + schema: reviewVerdictSchema, +}); + +// --------------------------------------------------------------------------- +// Extraction — mirrors the engine's extractStructuredOutput() semantics +// --------------------------------------------------------------------------- + +/** Last `` content in `text`, engine-identical scan. */ +function findLastTagContent(text: string, tag: string): string | undefined { + const openTag = `<${tag}>`; + const closeTag = ``; + let lastContent: string | undefined; + let searchFrom = 0; + for (;;) { + const openIdx = text.indexOf(openTag, searchFrom); + if (openIdx === -1) break; + const contentStart = openIdx + openTag.length; + const closeIdx = text.indexOf(closeTag, contentStart); + if (closeIdx === -1) break; + lastContent = text.slice(contentStart, closeIdx); + searchFrom = closeIdx + closeTag.length; + } + return lastContent; +} + +/** Engine-identical ```/```json fence unwrap. */ +function unwrapFences(text: string): string { + const fenceMatch = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n\s*```\s*$/); + return fenceMatch ? fenceMatch[1]!.trim() : text; +} + +export type VerdictAttempt = + | { ok: true; verdict: ReviewVerdict } + | { ok: false; error: string }; + +/** Extract + validate the `` verdict from a reviewer run's stdout. */ +export function extractReviewVerdict(stdout: string): VerdictAttempt { + const raw = findLastTagContent(stdout, reviewOutput.tag); + if (raw === undefined) { + return { + ok: false, + error: `structured output tag <${reviewOutput.tag}> not found in the reviewer's output`, + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(unwrapFences(raw.trim())); + } catch (cause) { + return { + ok: false, + error: + `tag <${reviewOutput.tag}> contains invalid JSON ` + + `(${cause instanceof Error ? cause.message : String(cause)}). ` + + `Matched content: ${raw.trim().slice(0, 500)}`, + }; + } + const result = reviewVerdictSchema.safeParse(parsed); + if (!result.success) { + return { + ok: false, + error: + `tag <${reviewOutput.tag}> failed schema validation: ` + + result.error.issues + .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) + .join("; "), + }; + } + return { ok: true, verdict: result.data }; +} + +// --------------------------------------------------------------------------- +// Reviewer run with enforced verdict +// --------------------------------------------------------------------------- + +export interface TargetIssue { + /** Issue number as a string, e.g. "275". */ + number: string; + title: string; +} + +/** Mirrors the engine's buildStructuredOutputRetryFeedback() wording. */ +function buildRetryFeedback(error: string): string { + return ( + `Your previous response did not produce a valid structured review verdict.\n\n` + + `Retries remaining after this attempt: 0.\n\n` + + `Problem:\n${error}\n\n` + + `Emit only a corrected block — JSON matching\n` + + `{"verdict":"clean"|"blocking","blockingFindings":[{"file":string,` + + `"line":number|null,"summary":string,"why":string}]}\n` + + `— and nothing else. Do not change files or run commands.` + ); +} + +/** + * Run the reviewer (opus, single iteration) on `branch` and REQUIRE the + * structured verdict. On a malformed verdict, retries exactly once by resuming + * the reviewer's session (the engine's structured-output retry mechanism); if + * the verdict is still malformed, THROWS so the job fails rather than passing + * silently. + */ +export async function runReviewerWithVerdict( + sandbox: sandcastle.Sandbox, + options: { + branch: string; + /** null → no target issue; the prompt skips the Spec axis. */ + issue: TargetIssue | null; + promptFile?: string; + }, +): Promise<{ verdict: ReviewVerdict; commits: { sha: string }[] }> { + const first = await sandbox.run({ + name: "reviewer", + maxIterations: 1, + agent: sandcastle.claudeCode("claude-opus-5"), + promptFile: options.promptFile ?? "./.sandcastle/review-prompt.md", + promptArgs: { + BRANCH: options.branch, + ISSUE_NUMBER: options.issue?.number ?? "none", + ISSUE_TITLE: options.issue?.title ?? "(no target issue resolved)", + }, + }); + + const commits = [...first.commits]; + let attempt = extractReviewVerdict(first.stdout); + + if (!attempt.ok) { + console.warn( + `\nReviewer verdict malformed (${attempt.error}) — retrying once via session resume.`, + ); + if (!first.resume) { + throw new Error( + `Reviewer emitted a malformed verdict and the session cannot ` + + `be resumed (no captured session id) — failing the run. ` + + `Detail: ${attempt.error}`, + ); + } + // promptArgs MUST be reset: resume() re-spreads the original run options, + // and inline prompts reject non-empty promptArgs (see module header). + const retry = await first.resume(buildRetryFeedback(attempt.error), { + name: "reviewer-verdict-retry", + promptArgs: {}, + }); + commits.push(...retry.commits); + attempt = extractReviewVerdict(retry.stdout); + if (!attempt.ok) { + throw new Error( + `Reviewer verdict still malformed after one resume retry — failing ` + + `the run so a missing verdict is never mistaken for a clean one. ` + + `Detail: ${attempt.error}`, + ); + } + } + + const { verdict } = attempt; + console.log( + `\nReviewer verdict: ${verdict.verdict.toUpperCase()}` + + (verdict.blockingFindings.length > 0 + ? ` (${verdict.blockingFindings.length} blocking finding(s))` + : ""), + ); + return { verdict, commits }; +} + +// --------------------------------------------------------------------------- +// Host-side helpers (authenticated `gh` via GH_TOKEN) +// --------------------------------------------------------------------------- + +function gh(args: string[]): string { + return execFileSync("gh", args, { encoding: "utf8" }); +} + +function repoNwo(): string { + return gh([ + "repo", + "view", + "--json", + "nameWithOwner", + "--jq", + ".nameWithOwner", + ]).trim(); +} + +/** + * Resolve the target issue for a PR from its body's closing keyword + * (`Closes #n` / `Fixes #n` / `Resolves #n`, same-repo only — the convention + * the implement runner writes into every factory PR body). Returns null when + * no closing reference exists or the referenced issue cannot be read; the + * reviewer then skips the Spec axis rather than failing. + */ +export function resolveIssueFromPrBody(prNumber: string): TargetIssue | null { + const body = gh(["pr", "view", prNumber, "--json", "body", "--jq", ".body"]); + const match = body.match( + /\b(?:clos(?:e|es|ed)|fix(?:es|ed)?|resolv(?:e|es|ed))[ \t]*:?[ \t]+#(\d+)/i, + ); + if (!match) return null; + const number = match[1]!; + try { + const title = gh([ + "issue", + "view", + number, + "--json", + "title", + "--jq", + ".title", + ]).trim(); + return { number, title }; + } catch { + console.warn( + `PR body references #${number} but the issue could not be read — ` + + `skipping the Spec axis.`, + ); + return null; + } +} + +const NEEDS_HUMAN_LABEL = "needs:human"; + +/** + * Post a blocking verdict's findings as a PR review (event COMMENT) and apply + * the `needs:human` label. Pure REST via `gh api` — porcelain `gh pr edit` is + * broken in repos with a classic Project attached (projectCards GraphQL + * deprecation), so label writes must not go through it. + */ +export function postBlockingVerdict( + prNumber: string, + verdict: ReviewVerdict, + issue: TargetIssue | null, +): void { + const nwo = repoNwo(); + + const findings = verdict.blockingFindings + .map( + (f, i) => + `${i + 1}. \`${f.file}${f.line === null ? "" : `:${f.line}`}\` — ${f.summary}\n` + + ` ${f.why}`, + ) + .join("\n"); + + const body = + `## Reviewer verdict: BLOCKING\n\n` + + (issue + ? `Reviewed against issue #${issue.number} ("${issue.title}") and its acceptance criteria.\n\n` + : `No target issue could be resolved from the PR body (no \`Closes #n\`); Standards review only.\n\n`) + + `The sandcastle reviewer found ${verdict.blockingFindings.length} blocking finding(s):\n\n` + + `${findings}\n\n` + + `Applied \`${NEEDS_HUMAN_LABEL}\` — a human must resolve these findings before merge. ` + + `(Structured reviewer verdict, toon-protocol/toon-meta#275.)`; + + execFileSync( + "gh", + [ + "api", + `repos/${nwo}/pulls/${prNumber}/reviews`, + "-f", + "event=COMMENT", + "-f", + `body=${body}`, + ], + { stdio: ["ignore", "ignore", "inherit"] }, + ); + + // Ensure the label exists (ignore "already exists"), then add it. Both are + // plain REST so a pre-existing label or a re-run stays idempotent. + try { + execFileSync( + "gh", + [ + "api", + `repos/${nwo}/labels`, + "-f", + `name=${NEEDS_HUMAN_LABEL}`, + "-f", + "color=B60205", + "-f", + "description=Factory reviewer found blocking defects - a human must decide", + ], + { stdio: "pipe" }, + ); + } catch { + // Label already exists — the normal case. + } + execFileSync( + "gh", + [ + "api", + `repos/${nwo}/issues/${prNumber}/labels`, + "-f", + `labels[]=${NEEDS_HUMAN_LABEL}`, + ], + { stdio: ["ignore", "ignore", "inherit"] }, + ); + + console.log( + `Posted blocking findings as a PR review and applied '${NEEDS_HUMAN_LABEL}' on PR #${prNumber}.`, + ); +}