diff --git a/.canopy/agent.json b/.canopy/agent.json index 4fb07c7e..0e2de4a2 100644 --- a/.canopy/agent.json +++ b/.canopy/agent.json @@ -14,6 +14,7 @@ ], "model": "claude-opus-4-8", "source": "builtin", + "runtime": "pi", "provider": "anthropic" } } diff --git a/.pi/sessions/2026-06-14T10-48-42-610Z_019ec5bf-8172-76ae-b749-53f2f5300bb2.jsonl b/.pi/sessions/2026-06-14T10-48-42-610Z_019ec5bf-8172-76ae-b749-53f2f5300bb2.jsonl new file mode 100644 index 00000000..8c36eefc --- /dev/null +++ b/.pi/sessions/2026-06-14T10-48-42-610Z_019ec5bf-8172-76ae-b749-53f2f5300bb2.jsonl @@ -0,0 +1,55 @@ +{"type":"session","version":3,"id":"019ec5bf-8172-76ae-b749-53f2f5300bb2","timestamp":"2026-06-14T10:48:42.610Z","cwd":"/workspace"} +{"type":"model_change","id":"bde967cc","parentId":null,"timestamp":"2026-06-14T10:48:42.641Z","provider":"anthropic","modelId":"claude-opus-4-8"} +{"type":"thinking_level_change","id":"510b2836","parentId":"bde967cc","timestamp":"2026-06-14T10:48:42.642Z","thinkingLevel":"medium"} +{"type":"message","id":"f27e0469","parentId":"510b2836","timestamp":"2026-06-14T10:48:42.680Z","message":{"role":"user","content":[{"type":"text","text":"You are a helpful coding assistant. Be concise.\n\nWorkspace map:\n- The project repo is mounted at the burrow workspace root.\n- /workspace/.canopy/agent.json is the rendered agent definition (warren seeded it).\n- /workspace/.mulch/expertise/.jsonl holds the project's expertise records.\n- /workspace/.seeds/issues.jsonl holds the project's issue queue.\n\nOperating contract:\n- Edit files in place. Run tests when relevant.\n- Quality gates are terminal, not advisory. You are NOT done until the gate exits zero. Resolve the command in this order: `$WARREN_QUALITY_GATE` if set, otherwise the command documented in CLAUDE.md / AGENTS.md, otherwise fall back to `bun run check:all` or `npm run lint && npm run typecheck && npm test`. Run it before committing and again before reporting completion. Do not declare the task complete, hand off, or end the session with a red gate — fix failures (including lint warnings, which CI treats as errors) until it is green. If the gate is genuinely unfixable in this run, say so explicitly and leave the work open rather than claiming success.\n- Use git as you normally would. Commit your changes; warren reaps the branch and pushes upstream.\n- Committing is mandatory, not the same as staging. `git add` ALONE IS NOT ENOUGH — you must run `git commit` so the work lands as a real commit. A run that ends with staged-but-uncommitted changes is treated as a FAILURE (`dropped_commit`), not a success. Before you report completion, run `git status`/`git log` and confirm your changes are in a commit, not just staged. The only exception is when you have genuinely made no file changes at all.\n- Do not run `git push` yourself — warren handles the push host-side after the run terminates.\n\n---\n\nwork on sd warren-db9a"}],"timestamp":1781434122677}} +{"type":"message","id":"6d74258f","parentId":"f27e0469","timestamp":"2026-06-14T10:48:44.959Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Looking at the issue now.","thinkingSignature":"Et4BCmMIDhgCKkBP8GZk38nkjpLR3J3bVTe3WjUFGfUjnCiIwYUyp6JXewIXikqsvsStMDi97kV0W1eqkfMgKoVr20ba5RgNpOVkMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDMF88IT8IrDs20crIBoMyKllKnvanbrdj0rRIjAfEvmryBteb11RyP3jIkWKfSPVtMnhZvem+jCBtCv0UPM/+1bENbAWEa+sQOpyFTsqKZ2oqU1lA1u2ohC2eJilCan9inhYnz2oTYridbp08kCXK3NB5oP+Njz6GAE="},{"type":"toolCall","id":"toolu_01DiVp2MCB8DUrTByU56arQD","name":"bash","arguments":{"command":"cd /workspace && sd show warren-db9a 2>/dev/null || grep -i \"db9a\" .seeds/issues.jsonl"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":103,"cacheRead":8942,"cacheWrite":696,"totalTokens":9743,"cost":{"input":0.00001,"output":0.002575,"cacheRead":0.004471,"cacheWrite":0.004350000000000001,"total":0.011406}},"stopReason":"toolUse","timestamp":1781434122702,"responseId":"msg_01GU28t4SRtPEE51PdfFiuNN"}} +{"type":"message","id":"a7358737","parentId":"6d74258f","timestamp":"2026-06-14T10:48:45.116Z","message":{"role":"toolResult","toolCallId":"toolu_01DiVp2MCB8DUrTByU56arQD","toolName":"bash","content":[{"type":"text","text":"warren-db9a open\nTitle: Create src/runs/pr-checks.ts and move the PR-merge/URL-parse group into it. Move checkPullRequestMerged, mergePullRequest, parsePullRequestUrl, parsePullRequestRef, isRateLimited and their associated exported types/regexes (CheckPullRequestMergedInput, CheckPrMergedResult, MergePullRequestInput, MergePullRequestResult, PR_URL_RE, PR_SHORT_RE) out of src/runs/pr.ts into the new src/runs/pr-checks.ts, carrying any private helpers they need (buildHeaders/readJson/readText/truncate may need to be shared — if so, keep one copy and import it, do NOT duplicate, or jscpd check:dups will flag it). Re-export every moved PUBLIC symbol from src/runs/pr.ts (export { ... } from \"./pr-checks.ts\") so the path src/runs/pr.ts still resolves all of them. Verify: `wc -l src/runs/pr.ts` shows < 500 AND `bun run typecheck` is clean AND `bun test src/runs/pr.test.ts` is green with the same test count as before the split.\nType: task Priority: Medium\nLabels: ratchetwatch\n\n\nStep 1 of plan pl-88bb.\n\nParent seed: warren-60ee — ratchetwatch tightening: 2026-06-14\nPlan template: refactor\nPlan approach: Split along the existing seam: move the PR-merge / URL-parsing group (checkPullRequestMerged, parsePullRequestUrl, parsePullRequestRef, mergePullRequest, isRateLimited, plus their…\n\nRun `sd plan show pl-88bb` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n\nBlocks: warren-60ee\nCreated: 2026-06-14T10:46:18.235Z\nUpdated: 2026-06-14T10:46:18.235Z\n\n\nPlan: pl-88bb [approved]\nPlan steps (2):\n warren-db9a [open] Create src/runs/pr-checks.ts and move the PR-merge/URL-parse group into it. Move checkPullRequestMerged, mergePullRequest, parsePullRequestUrl, parsePullRequestRef, isRateLimited and their associated exported types/regexes (CheckPullRequestMergedInput, CheckPrMergedResult, MergePullRequestInput, MergePullRequestResult, PR_URL_RE, PR_SHORT_RE) out of src/runs/pr.ts into the new src/runs/pr-checks.ts, carrying any private helpers they need (buildHeaders/readJson/readText/truncate may need to be shared — if so, keep one copy and import it, do NOT duplicate, or jscpd check:dups will flag it). Re-export every moved PUBLIC symbol from src/runs/pr.ts (export { ... } from \"./pr-checks.ts\") so the path src/runs/pr.ts still resolves all of them. Verify: `wc -l src/runs/pr.ts` shows < 500 AND `bun run typecheck` is clean AND `bun test src/runs/pr.test.ts` is green with the same test count as before the split.\n warren-70d7 [open] Remove the \"src/runs/pr.ts\": 659 entry from scripts/file-size-budgets.json (delete that one line). Do NOT add a budget entry for src/runs/pr-checks.ts — it must default-pass under the 500 threshold (confirm `wc -l src/runs/pr-checks.ts` < 500). Per Constitution Article VI, before declaring done run a repo-wide search for any reference to the old single-file assumption and the new path across ALL file types — `rg -n \"runs/pr\\\\.ts|runs/pr-checks\" --hidden -g '!node_modules'` plus an explicit sweep of Dockerfile, docker-compose.yml, .github/workflows/*.yml, src/supervisor/ spawn/config strings, and docs/ — and fix any stale reference (file moves have broken production here before; encode the check, do not assume it). Verify: `bun run check:size` exits 0 AND `bun run check:dups` exits 0 AND `bun run check:all` is fully green (every gate stays green, the raised-tightness budget still passes against current actuals).\n"}],"isError":false,"timestamp":1781434125116}} +{"type":"message","id":"6e1d7ae4","parentId":"a7358737","timestamp":"2026-06-14T10:48:49.011Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01Hebon6FDCuSfnX5ZQM1xEf","name":"bash","arguments":{"command":"cd /workspace && wc -l src/runs/pr.ts && echo \"---\" && cat src/runs/pr.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":85,"cacheRead":9638,"cacheWrite":1623,"totalTokens":11348,"cost":{"input":0.00001,"output":0.002125,"cacheRead":0.0048189999999999995,"cacheWrite":0.01014375,"total":0.017097750000000002}},"stopReason":"toolUse","timestamp":1781434125117,"responseId":"msg_016kiHe9RDSQfMQ7BKYM2CWW"}} +{"type":"message","id":"ca17d61f","parentId":"6e1d7ae4","timestamp":"2026-06-14T10:48:49.017Z","message":{"role":"toolResult","toolCallId":"toolu_01Hebon6FDCuSfnX5ZQM1xEf","toolName":"bash","content":[{"type":"text","text":"659 src/runs/pr.ts\n---\nimport {\n\tcomposeBody,\n\tcomposeTitle,\n\ttype PrFragmentContext,\n\ttype PrTemplateOverrides,\n} from \"./pr-template.ts\";\n\n/**\n * `openPullRequest` — open a GitHub PR for a branch reap just pushed\n * (warren-f6af). Fourth best-effort sub-step of `reapRun`, gated by\n * `WARREN_AUTO_OPEN_PR` (default on).\n *\n * The call hits the GitHub REST API directly (`POST /repos/:owner/:repo/pulls`)\n * via an injected `fetch` seam — no shell-out to `gh`, no extra runtime\n * dependency. Auth is `GITHUB_TOKEN`, the same token the supervisor wires\n * into git's `insteadOf` rule at boot (warren-dcf3).\n *\n * Failure shapes (callers translate into `reap_failed` events; reap never\n * crashes the run because the PR step blew up):\n * - `missing_token` — `GITHUB_TOKEN` unset/empty. Skip cleanly.\n * - `pr_exists` — GitHub returns 422 because a PR already covers the\n * same head→base. Treated as success: re-fetch the\n * existing PR's `html_url` so the caller still gets\n * a link to surface (idempotency for re-runs and\n * restart-recovery sweeps).\n * - `network` — fetch threw or non-2xx response that isn't a\n * known idempotent shape.\n *\n * The body and title format is template-driven (warren-bd49): the body\n * comes from the named-fragment registry in `src/runs/pr-template.ts`,\n * and projects override individual fragments via\n * `.warren/pr-template.md`. Title follows the same registry: a project\n * `title` override wins; otherwise the seed/commit/prompt precedence\n * chain applies.\n */\n\nexport interface OpenPullRequestInput {\n\treadonly owner: string;\n\treadonly repo: string;\n\treadonly head: string;\n\treadonly base: string;\n\treadonly title: string;\n\treadonly body: string;\n\treadonly token: string;\n}\n\nexport type OpenPullRequestResult =\n\t| { readonly ok: true; readonly url: string; readonly mode: \"created\" | \"exists\" }\n\t| {\n\t\t\treadonly ok: false;\n\t\t\treadonly reason: \"missing_token\" | \"network\" | \"http_error\";\n\t\t\treadonly message: string;\n\t };\n\nexport interface PrFetcher {\n\treadonly fetch: typeof fetch;\n}\n\nconst GITHUB_API_BASE = \"https://api.github.com\";\nconst USER_AGENT = \"warren-reap-pr-open\";\n\n/**\n * Acceptance test seam (warren-ae00 / scenario 26). When\n * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits\n * to a canned positive response — `openPullRequest` returns a synthetic\n * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately.\n * Lets the in-proc plan-run roundtrip exercise reap's PR open + the\n * coordinator's pr_open → merged transition without standing up a real\n * GitHub fixture. Unset in production deployments.\n */\nconst GH_FETCH_OVERRIDE_ENV = \"WARREN_GH_FETCH_OVERRIDE\";\n\nfunction readGhFetchOverride(): \"merged\" | null {\n\tconst v = process.env[GH_FETCH_OVERRIDE_ENV];\n\tif (typeof v !== \"string\") return null;\n\treturn v.trim() === \"merged\" ? \"merged\" : null;\n}\n\nexport async function openPullRequest(\n\tinput: OpenPullRequestInput,\n\tdeps: PrFetcher = { fetch: globalThis.fetch },\n): Promise {\n\tif (readGhFetchOverride() === \"merged\") {\n\t\treturn {\n\t\t\tok: true,\n\t\t\turl: `https://github.com/${input.owner}/${input.repo}/pull/1`,\n\t\t\tmode: \"created\",\n\t\t};\n\t}\n\tif (input.token === \"\") {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"missing_token\",\n\t\t\tmessage: \"GITHUB_TOKEN unset; cannot open pull request\",\n\t\t};\n\t}\n\n\tconst url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls`;\n\tconst headers = buildHeaders(input.token);\n\n\tlet res: Response;\n\ttry {\n\t\tres = await deps.fetch(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders,\n\t\t\tbody: JSON.stringify({\n\t\t\t\ttitle: input.title,\n\t\t\t\tbody: input.body,\n\t\t\t\thead: input.head,\n\t\t\t\tbase: input.base,\n\t\t\t}),\n\t\t});\n\t} catch (err) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"network\",\n\t\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\t};\n\t}\n\n\tif (res.status === 201) {\n\t\tconst created = (await readJson(res)) as { html_url?: unknown } | null;\n\t\tconst link = typeof created?.html_url === \"string\" ? created.html_url : null;\n\t\tif (link === null) {\n\t\t\treturn { ok: false, reason: \"http_error\", message: \"POST /pulls returned no html_url\" };\n\t\t}\n\t\treturn { ok: true, url: link, mode: \"created\" };\n\t}\n\n\tif (res.status === 422) {\n\t\t// 422 covers both \"PR already exists\" and \"no commits between head and\n\t\t// base\". The first is idempotent — fetch the existing PR and return\n\t\t// its url. The second is a no-op shape callers are expected to skip\n\t\t// upstream (commitsAhead === 0), but if it slips through we surface\n\t\t// the message so the operator sees why.\n\t\tconst body = (await readJson(res)) as { errors?: unknown; message?: unknown } | null;\n\t\tconst message = typeof body?.message === \"string\" ? body.message : \"422 from POST /pulls\";\n\t\tconst errorsBlob = JSON.stringify(body?.errors ?? []);\n\t\tif (/already exists|pull request already exists/i.test(errorsBlob + message)) {\n\t\t\tconst existing = await findExistingPr(input, deps);\n\t\t\tif (existing !== null) {\n\t\t\t\treturn { ok: true, url: existing, mode: \"exists\" };\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tok: false,\n\t\t\t\treason: \"http_error\",\n\t\t\t\tmessage: \"PR already exists but lookup did not return a url\",\n\t\t\t};\n\t\t}\n\t\treturn { ok: false, reason: \"http_error\", message: `${message} errors=${errorsBlob}` };\n\t}\n\n\tconst text = await readText(res);\n\treturn {\n\t\tok: false,\n\t\treason: \"http_error\",\n\t\tmessage: `POST /pulls returned ${res.status}: ${truncate(text, 500)}`,\n\t};\n}\n\nasync function findExistingPr(\n\tinput: OpenPullRequestInput,\n\tdeps: PrFetcher,\n): Promise {\n\tconst params = new URLSearchParams({\n\t\thead: `${input.owner}:${input.head}`,\n\t\tbase: input.base,\n\t\tstate: \"open\",\n\t\tper_page: \"1\",\n\t});\n\tconst url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls?${params.toString()}`;\n\tlet res: Response;\n\ttry {\n\t\tres = await deps.fetch(url, { method: \"GET\", headers: buildHeaders(input.token) });\n\t} catch {\n\t\treturn null;\n\t}\n\tif (!res.ok) return null;\n\tconst list = (await readJson(res)) as Array<{ html_url?: unknown }> | null;\n\tif (!Array.isArray(list) || list.length === 0) return null;\n\tconst first = list[0];\n\treturn typeof first?.html_url === \"string\" ? first.html_url : null;\n}\n\nfunction buildHeaders(token: string): Record {\n\treturn {\n\t\taccept: \"application/vnd.github+json\",\n\t\tauthorization: `Bearer ${token}`,\n\t\t\"content-type\": \"application/json\",\n\t\t\"user-agent\": USER_AGENT,\n\t\t\"x-github-api-version\": \"2022-11-28\",\n\t};\n}\n\nasync function readJson(res: Response): Promise {\n\ttry {\n\t\treturn await res.json();\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nasync function readText(res: Response): Promise {\n\ttry {\n\t\treturn await res.text();\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n\nfunction truncate(input: string, max: number): string {\n\treturn input.length <= max ? input : `${input.slice(0, max)}…`;\n}\n\n/* ----------------------------------------------------------------------- */\n/* Title + body formatting */\n/* ----------------------------------------------------------------------- */\n\nconst TITLE_MAX_LENGTH = 72;\n\nexport interface PrCommit {\n\treadonly sha: string;\n\treadonly subject: string;\n}\n\nexport interface PrSeed {\n\treadonly id: string;\n\treadonly title: string;\n}\n\nexport interface BuildPrContentInput {\n\treadonly prompt: string;\n\treadonly runId: string;\n\treadonly agentName: string;\n\t/** Optional warren UI base URL (e.g. `https://warren.example.com`). */\n\treadonly warrenBaseUrl?: string;\n\t/** Commits ahead of the base branch, oldest-first. */\n\treadonly commits?: readonly PrCommit[];\n\t/** Raw `git diff --stat ..HEAD` output (multi-line). */\n\treadonly diffStat?: string;\n\t/** Resolved seed referenced by the prompt (best-effort `sd show`). */\n\treadonly seed?: PrSeed;\n\treadonly startedAt?: string;\n\treadonly endedAt?: string;\n\treadonly costUsd?: number;\n\treadonly tokensInput?: number;\n\treadonly tokensOutput?: number;\n\treadonly tokensCacheRead?: number;\n\t/**\n\t * Project opted into per-run preview environments (R-19 / SPEC §11.L).\n\t * When true, the body includes a `preview_url_or_placeholder` fragment\n\t * (a `## Preview` section bracketed by ``\n\t * and ``) so reap's `pr_annotate_preview`\n\t * sub-step can patch in the live URL once the sidecar is ready. False /\n\t * absent renders no fragment — non-opted-in projects don't see preview\n\t * scaffolding in their PR body.\n\t */\n\treadonly previewOptedIn?: boolean;\n\t/**\n\t * Per-project PR-template overrides (warren-bd49). Loaded from\n\t * `.warren/pr-template.md` by `loadPrTemplate` and threaded through\n\t * `reapRun`. Each key is a fragment name from `PR_FRAGMENT_NAMES`\n\t * (`src/runs/pr-template.ts`); the value replaces the default body\n\t * for that fragment. Whitespace-only values remove the fragment\n\t * from the output. Omitted / undefined → defaults apply.\n\t */\n\treadonly templateOverrides?: PrTemplateOverrides;\n}\n\n/**\n * Markers `pr_annotate_preview` looks for when patching the live URL or\n * failure tail into the PR body (warren-f156). Exported so the annotator\n * can match without duplicating the literal — drift between the two\n * sides would silently break idempotency.\n */\nexport const PREVIEW_FRAGMENT_START = \"\" as const;\nexport const PREVIEW_FRAGMENT_END = \"\" as const;\n\nexport interface PrContent {\n\treadonly title: string;\n\treadonly body: string;\n}\n\n/**\n * Build the PR title and body (warren-9ee3).\n *\n * Body is composed from named fragments via the registry in\n * `src/runs/pr-template.ts` (warren-bd49); project overrides from\n * `.warren/pr-template.md` thread through `input.templateOverrides`\n * and replace individual fragments by name.\n *\n * Title precedence — first non-empty wins:\n * 1. Project `title` override (when supplied via templateOverrides).\n * 2. Resolved seed title (`seed.title`) when the prompt referenced a seed.\n * 3. First commit subject on the branch.\n * 4. First non-empty line of the prompt.\n * 5. `warren run ()` fallback for empty prompts.\n *\n * Body fragments (in order, omitted when their data is absent and\n * the project hasn't overridden):\n * - summary, run, seeds, preview_url_or_placeholder, commits,\n * files_changed, prompt, trailer\n */\nexport function buildPrContent(input: BuildPrContentInput): PrContent {\n\tconst ctx: PrFragmentContext = buildContext(input);\n\tconst overrides = input.templateOverrides ?? {};\n\treturn {\n\t\ttitle: composeTitle(ctx, overrides, TITLE_MAX_LENGTH),\n\t\tbody: composeBody(ctx, overrides),\n\t};\n}\n\nfunction buildContext(input: BuildPrContentInput): PrFragmentContext {\n\tconst ctx: {\n\t\tprompt: string;\n\t\trunId: string;\n\t\tagentName: string;\n\t\twarrenBaseUrl?: string;\n\t\tcommits?: readonly PrCommit[];\n\t\tdiffStat?: string;\n\t\tseed?: PrSeed;\n\t\tstartedAt?: string;\n\t\tendedAt?: string;\n\t\tcostUsd?: number;\n\t\ttokensInput?: number;\n\t\ttokensOutput?: number;\n\t\ttokensCacheRead?: number;\n\t\tpreviewOptedIn?: boolean;\n\t} = {\n\t\tprompt: input.prompt,\n\t\trunId: input.runId,\n\t\tagentName: input.agentName,\n\t};\n\tif (input.warrenBaseUrl !== undefined) ctx.warrenBaseUrl = input.warrenBaseUrl;\n\tif (input.commits !== undefined) ctx.commits = input.commits;\n\tif (input.diffStat !== undefined) ctx.diffStat = input.diffStat;\n\tif (input.seed !== undefined) ctx.seed = input.seed;\n\tif (input.startedAt !== undefined) ctx.startedAt = input.startedAt;\n\tif (input.endedAt !== undefined) ctx.endedAt = input.endedAt;\n\tif (input.costUsd !== undefined) ctx.costUsd = input.costUsd;\n\tif (input.tokensInput !== undefined) ctx.tokensInput = input.tokensInput;\n\tif (input.tokensOutput !== undefined) ctx.tokensOutput = input.tokensOutput;\n\tif (input.tokensCacheRead !== undefined) ctx.tokensCacheRead = input.tokensCacheRead;\n\tif (input.previewOptedIn !== undefined) ctx.previewOptedIn = input.previewOptedIn;\n\treturn ctx;\n}\n\n/* ----------------------------------------------------------------------- */\n/* Config */\n/* ----------------------------------------------------------------------- */\n\nexport interface AutoOpenPrConfig {\n\treadonly enabled: boolean;\n\treadonly token: string;\n\treadonly warrenBaseUrl: string | null;\n}\n\nexport type AutoOpenEnvLike = Readonly>;\n\n/**\n * Resolve the auto-open config (warren-f6af). `WARREN_AUTO_OPEN_PR` defaults\n * to enabled — the seed's whole point is \"agent run → reviewable change with\n * no manual hop\". Anything that isn't a recognized falsy value (`0`, `false`,\n * `no`, `off`, case-insensitive, with whitespace tolerated) leaves it on, so\n * an operator can disable globally with `WARREN_AUTO_OPEN_PR=false` without\n * tripping a stricter parser.\n */\nexport function loadAutoOpenPrConfigFromEnv(env: AutoOpenEnvLike = process.env): AutoOpenPrConfig {\n\tconst raw = env.WARREN_AUTO_OPEN_PR;\n\tconst enabled = raw === undefined ? true : !isFalsy(raw);\n\tconst fetchOverride = env[GH_FETCH_OVERRIDE_ENV];\n\tconst overrideActive = typeof fetchOverride === \"string\" && fetchOverride.trim() === \"merged\";\n\t// Acceptance seam (warren-ae00): WARREN_GH_FETCH_OVERRIDE=merged synthesizes\n\t// a stub token so reap's empty-token gate + tryOpenPr's empty-token gate\n\t// don't skip pr_open. The actual HTTP call short-circuits inside\n\t// openPullRequest before the token is read.\n\tconst token = env.GITHUB_TOKEN ?? (overrideActive ? \"stub-token\" : \"\");\n\treturn {\n\t\tenabled,\n\t\ttoken,\n\t\twarrenBaseUrl: env.WARREN_BASE_URL ?? null,\n\t};\n}\n\nfunction isFalsy(raw: string): boolean {\n\tconst v = raw.trim().toLowerCase();\n\treturn v === \"0\" || v === \"false\" || v === \"no\" || v === \"off\" || v === \"\";\n}\n\n/* ----------------------------------------------------------------------- */\n/* PR-merge polling */\n/* ----------------------------------------------------------------------- */\n\n/**\n * `checkPullRequestMerged` — poll a GitHub PR's merge state for the PlanRun\n * coordinator (warren-9e4c). Pure helper: the caller decides what each\n * non-merged shape means (`open` = wait, `closed_unmerged` = fail the plan).\n *\n * Mirrors `openPullRequest`'s posture: direct REST call against\n * `GET /repos/:owner/:repo/pulls/:number`, `Authorization: Bearer `\n * from `GITHUB_TOKEN`, fetch injected as a seam.\n */\nexport interface CheckPullRequestMergedInput {\n\treadonly owner: string;\n\treadonly repo: string;\n\treadonly number: number;\n\treadonly token: string;\n\treadonly fetch?: typeof fetch;\n}\n\nexport type CheckPrMergedResult =\n\t| { readonly kind: \"merged\"; readonly mergedAt: string }\n\t| { readonly kind: \"open\" }\n\t| { readonly kind: \"closed_unmerged\" }\n\t| { readonly kind: \"missing_token\"; readonly message: string }\n\t| { readonly kind: \"http_error\"; readonly status: number; readonly message: string };\n\nexport async function checkPullRequestMerged(\n\tinput: CheckPullRequestMergedInput,\n): Promise {\n\tif (readGhFetchOverride() === \"merged\") {\n\t\treturn { kind: \"merged\", mergedAt: new Date().toISOString() };\n\t}\n\tif (input.token === \"\") {\n\t\treturn {\n\t\t\tkind: \"missing_token\",\n\t\t\tmessage: \"GITHUB_TOKEN unset; cannot check pull request merge state\",\n\t\t};\n\t}\n\n\tconst fetchImpl = input.fetch ?? globalThis.fetch;\n\tconst url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}`;\n\n\tlet res: Response;\n\ttry {\n\t\tres = await fetchImpl(url, { method: \"GET\", headers: buildHeaders(input.token) });\n\t} catch (err) {\n\t\treturn {\n\t\t\tkind: \"http_error\",\n\t\t\tstatus: 0,\n\t\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\t};\n\t}\n\n\tif (res.status !== 200) {\n\t\tconst text = await readText(res);\n\t\treturn {\n\t\t\tkind: \"http_error\",\n\t\t\tstatus: res.status,\n\t\t\tmessage: `GET /pulls/${input.number} returned ${res.status}: ${truncate(text, 500)}`,\n\t\t};\n\t}\n\n\tconst body = (await readJson(res)) as { merged_at?: unknown; state?: unknown } | null;\n\tconst mergedAt = typeof body?.merged_at === \"string\" ? body.merged_at : null;\n\tif (mergedAt !== null) {\n\t\treturn { kind: \"merged\", mergedAt };\n\t}\n\tconst state = typeof body?.state === \"string\" ? body.state : \"\";\n\tif (state === \"closed\") {\n\t\treturn { kind: \"closed_unmerged\" };\n\t}\n\treturn { kind: \"open\" };\n}\n\n/**\n * `parsePullRequestUrl` — regex-parse `https://github.com///pull/`.\n * Returns `null` on mismatch (e.g. GHE-hosted shapes) so the coordinator\n * treats them as \"cannot verify merge\" rather than \"merged\".\n */\nconst PR_URL_RE = /^https:\\/\\/github\\.com\\/([^/\\s]+)\\/([^/\\s]+)\\/pull\\/(\\d+)(?:[/?#].*)?$/;\n\nexport function parsePullRequestUrl(\n\tprUrl: string,\n): { owner: string; repo: string; number: number } | null {\n\tconst m = PR_URL_RE.exec(prUrl.trim());\n\tif (m === null) return null;\n\tconst [, owner, repo, num] = m;\n\tif (owner === undefined || repo === undefined || num === undefined) return null;\n\tconst n = Number.parseInt(num, 10);\n\tif (!Number.isFinite(n) || n <= 0) return null;\n\treturn { owner, repo, number: n };\n}\n\n/**\n * `parsePullRequestRef` — accept either a full GitHub PR URL\n * (`https://github.com///pull/`) or the conventional\n * shorthand `/#` shape that Plot `gh_pr` attachments\n * use (see `refPlaceholder('gh_pr')` in src/ui/src/pages/PlotDetail.tsx).\n * Returns `null` on mismatch so the merge handler treats it as\n * \"cannot resolve\" rather than \"merged\" (warren-8e39 / pl-0344 step 14).\n */\nconst PR_SHORT_RE = /^([^/\\s]+)\\/([^/\\s#]+)#(\\d+)$/;\n\nexport function parsePullRequestRef(\n\tref: string,\n): { owner: string; repo: string; number: number } | null {\n\tconst trimmed = ref.trim();\n\tconst viaUrl = parsePullRequestUrl(trimmed);\n\tif (viaUrl !== null) return viaUrl;\n\tconst m = PR_SHORT_RE.exec(trimmed);\n\tif (m === null) return null;\n\tconst [, owner, repo, num] = m;\n\tif (owner === undefined || repo === undefined || num === undefined) return null;\n\tconst n = Number.parseInt(num, 10);\n\tif (!Number.isFinite(n) || n <= 0) return null;\n\treturn { owner, repo, number: n };\n}\n\n/* ----------------------------------------------------------------------- */\n/* PR merge (warren-8e39 / pl-0344 step 14) */\n/* ----------------------------------------------------------------------- */\n\n/**\n * `mergePullRequest` — click-to-merge a GitHub PR attached to a Plot\n * (warren-8e39 / pl-0344 step 14). Mirrors `openPullRequest`'s posture:\n * direct `PUT /repos/:owner/:repo/pulls/:number/merge`, fetch injected\n * as a seam, `GITHUB_TOKEN` auth.\n *\n * The result variants are tuned for what the PlotDetail merge UI cares\n * about:\n * - `merged` — 200 with `merged: true`. Caller schedules\n * a follow-up `refreshProjectClone` so the\n * local clone picks up the new merge commit.\n * - `already_merged` — 405/409 with \"already merged\" body. Same\n * net effect as `merged`; caller still\n * schedules the refresh (idempotent).\n * - `not_mergeable` — 405 (conflict / required reviews missing /\n * checks failing) or 409 (base SHA mismatch).\n * - `missing_token` — `GITHUB_TOKEN` unset on the server.\n * - `rate_limited` — 403 with `x-ratelimit-remaining: 0` or 429.\n * Surfaces the reset timestamp so the UI can\n * show a countdown.\n * - `not_found` — 404 (wrong owner/repo/number).\n * - `network` — fetch threw / non-HTTP failure.\n * - `http_error` — anything else (5xx etc.).\n */\nexport interface MergePullRequestInput {\n\treadonly owner: string;\n\treadonly repo: string;\n\treadonly number: number;\n\treadonly token: string;\n\t/** Optional commit message override; GitHub defaults are fine for most cases. */\n\treadonly commitMessage?: string;\n\t/** Merge method. Defaults to `merge` (matches GitHub's UI default). */\n\treadonly mergeMethod?: \"merge\" | \"squash\" | \"rebase\";\n\treadonly fetch?: typeof fetch;\n}\n\nexport type MergePullRequestResult =\n\t| { readonly kind: \"merged\"; readonly sha: string }\n\t| { readonly kind: \"already_merged\" }\n\t| { readonly kind: \"not_mergeable\"; readonly message: string }\n\t| { readonly kind: \"not_found\"; readonly message: string }\n\t| { readonly kind: \"missing_token\"; readonly message: string }\n\t| {\n\t\t\treadonly kind: \"rate_limited\";\n\t\t\treadonly message: string;\n\t\t\treadonly resetAt: string | null;\n\t }\n\t| { readonly kind: \"network\"; readonly message: string }\n\t| { readonly kind: \"http_error\"; readonly status: number; readonly message: string };\n\nexport async function mergePullRequest(\n\tinput: MergePullRequestInput,\n): Promise {\n\tif (readGhFetchOverride() === \"merged\") {\n\t\treturn { kind: \"merged\", sha: \"0\".repeat(40) };\n\t}\n\tif (input.token === \"\") {\n\t\treturn {\n\t\t\tkind: \"missing_token\",\n\t\t\tmessage: \"GITHUB_TOKEN unset; cannot merge pull request\",\n\t\t};\n\t}\n\n\tconst fetchImpl = input.fetch ?? globalThis.fetch;\n\tconst url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}/merge`;\n\tconst body: Record = {\n\t\tmerge_method: input.mergeMethod ?? \"merge\",\n\t};\n\tif (input.commitMessage !== undefined) {\n\t\tbody.commit_message = input.commitMessage;\n\t}\n\n\tlet res: Response;\n\ttry {\n\t\tres = await fetchImpl(url, {\n\t\t\tmethod: \"PUT\",\n\t\t\theaders: buildHeaders(input.token),\n\t\t\tbody: JSON.stringify(body),\n\t\t});\n\t} catch (err) {\n\t\treturn {\n\t\t\tkind: \"network\",\n\t\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\t};\n\t}\n\n\tif (res.status === 200) {\n\t\tconst payload = (await readJson(res)) as {\n\t\t\tmerged?: unknown;\n\t\t\tsha?: unknown;\n\t\t\tmessage?: unknown;\n\t\t} | null;\n\t\tconst merged = payload?.merged === true;\n\t\tconst sha = typeof payload?.sha === \"string\" ? payload.sha : \"\";\n\t\tif (merged) {\n\t\t\treturn { kind: \"merged\", sha };\n\t\t}\n\t\tconst message = typeof payload?.message === \"string\" ? payload.message : \"PR not merged\";\n\t\treturn { kind: \"not_mergeable\", message };\n\t}\n\n\t// Rate limit: GitHub surfaces 403 with x-ratelimit-remaining: 0, or 429.\n\tif (res.status === 429 || (res.status === 403 && isRateLimited(res))) {\n\t\tconst reset = res.headers.get(\"x-ratelimit-reset\");\n\t\tconst resetAt =\n\t\t\treset !== null && /^\\d+$/.test(reset)\n\t\t\t\t? new Date(Number.parseInt(reset, 10) * 1000).toISOString()\n\t\t\t\t: null;\n\t\tconst text = await readText(res);\n\t\treturn {\n\t\t\tkind: \"rate_limited\",\n\t\t\tmessage: truncate(text, 500),\n\t\t\tresetAt,\n\t\t};\n\t}\n\n\tif (res.status === 404) {\n\t\tconst text = await readText(res);\n\t\treturn { kind: \"not_found\", message: truncate(text, 500) };\n\t}\n\n\t// 405 \"Method Not Allowed\" with `message: \"Pull Request is not mergeable\"`\n\t// is the standard \"can't merge right now\" shape. 409 is base SHA mismatch.\n\t// Both also cover \"already merged\" — distinguish via the body message.\n\tif (res.status === 405 || res.status === 409) {\n\t\tconst payload = (await readJson(res)) as { message?: unknown } | null;\n\t\tconst message =\n\t\t\ttypeof payload?.message === \"string\" ? payload.message : `${res.status} from PUT /merge`;\n\t\tif (/already merged/i.test(message)) {\n\t\t\treturn { kind: \"already_merged\" };\n\t\t}\n\t\treturn { kind: \"not_mergeable\", message };\n\t}\n\n\tconst text = await readText(res);\n\treturn {\n\t\tkind: \"http_error\",\n\t\tstatus: res.status,\n\t\tmessage: `PUT /merge returned ${res.status}: ${truncate(text, 500)}`,\n\t};\n}\n\nfunction isRateLimited(res: Response): boolean {\n\tconst remaining = res.headers.get(\"x-ratelimit-remaining\");\n\treturn remaining === \"0\";\n}\n"}],"isError":false,"timestamp":1781434129017}} +{"type":"message","id":"c1be99f7","parentId":"ca17d61f","timestamp":"2026-06-14T10:49:03.761Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I'm identifying which functions and types need to be moved to the pr-checks file, including the pull request validation and merge operations along with their associated type definitions and regex patterns.\n\nFor the shared helpers like buildHeaders, readJson, readText, and truncate that both files need, I should consolidate them in pr-checks.ts and have pr.ts import them from there. This avoids duplication and circular dependencies since pr-checks won't import from pr.ts.","thinkingSignature":"EqkVCmMIDhgCKkCj6BXBzFVfhVVrXbrKtliCbYHDDFn34+PUi0RyFeG4whZxbxY3UZog4QaY407Vu9yfpVAJ5HRkfFlayWuQUmAnMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDC2L1Js3qiVZ7droehoM8Om076Z9VE5ALTtPIjDNhJjxsueQGcvGVt9Sic6u12KHP4tGZbn6r7FKdbH52OwkNDjLNO4LNRqg1fD4DNsq8xOZZ1/onkcm63zHgeR+1ZOQXZZ/sbk1XBQ3uLrVYCo26UlKJsxMr84hIaoad6dIvDQDa6STTO/ZyzVSx9PoxxNiEh2pvhV7cXi9r3wF1hExpjzdGNhTWkvWoAHmcG+8d/lydwQSkjLMBiIB1xd7DjBVtBGg8jdNf6UQg8slyWMeNb9csuM3cVuhlZJ6moK5NGLvrt7NwklSquqJCiRUP01f7gAY1j0hVx8Efu5GsONA1c3Who6BWaYm3KBc1GOifglphQGgsO6ALNp56/N8SM5gBDBDv7/+AL84E7KLnoEeQ7BkG796kI6jJl5f+N5fBFpk2rsPImYNx7QRLzIK7lUzqY3XAAQy7G5ifTNQsBysSxm19tilLxbnmFBwJkiJSlQTBg2hnNZiknsr8leGzQGf9yHyWZKRgVC0oFebB4NiIFML0i+Pn7+Ir740aTomcW7XMFjaU/A1DchDOs+5M8aOTcl4uZSRy0ww+fUWU71LlaRwgYNYn/J+vJ+kWwKWwYenlb21Qx00eu1Am0RKcK7CKJTGxd8EI1IrWQ3k6c99A6MF5TX6WWMqCOVp/+BtIfve/SuyLVM2Y+NgB0e3la+ACccNj2ijCSxKJqHl0iYp9GR1j1WKBSm9n7FSRzdJC/I0SnQ8x5hfKbQR5ATmmuAgML1zBCYwg/psauAMNkCxQQqxnMbOMKj5HcSR8w7OHM57VAP09EugN3NAQS2Sj+wSSfxnK/NV/aWMJcSLXeACk0N2CRoQ+kQ9db8m53ONcI4HUJsS+OT97kuTFkZ57oKQjmhdhOidoHjGfopW89PnkRZas2KS7QgI7x74FC+JMSEmx3JgUV+iSFEfl4Xnhx3nsMuh/B1alivNElozBvZ3T/EACTXsNoKXtZ78VYMAmX0788WblYaHx0FR1Na6gwMnLsTFhxG7zh5r8cKAvrNMnLmgL3qz74kHRXdFZI9Buko6DUGtZHqqZWqpdH5sHOgIZJYre5kHVFZlxTBjHjSS6Hh2PiGV+CDYQs2JgbStBTqvGgEG3vs2EOLHwSeqttj4m9uJfazDwM8KTy336YM5/BpAO2CLZLfKLGXuKROmxaBGZPiOgaCiyTH7qzFkq9d5FL3C8lrkALgZlwYcsV38Zs/dc67TTEO+FvcC3i9L6VDdDlC3gNLmd4W60agIW3Iwqv8UXyXAtAp3PZLZFZZkmDAYdvyRfNt2iPyhayQPTdBS1ejaa9uxgh22BRAJ01l4CmBqbx49pX/39NrVbTv+7Bu4olOWlQNZC+ezoSz+0I7W/ZXpjjg9f4POekCSZE6AT5A10iydDAhldofbEW93mDQXTTVyJwin175H1ueiq/t8vVCYR/smPMfgwvgnRrxswupUCSueGRRMUWyunKYS1oTfERHl6ojQzzyfG+cONTQfyUKR8Aj7Us77ePiHEniBd6xqLlpONZmeSHdr5RUy/AZvYByClJFH52kM1wpNS6MjzVIXZWB7nQl5lwdTZe8mbmMlwi0Fgd4XLchL1WwZctN3/rlD/eppS8/tAT13btMu+rcdEmkllGenSPQMhd3xlcAsPqxunlzz1hWL6Re8YTtJqrLcTr1oLTSXqxiN435l9WkNqWXJfttYJkmowMi6GOLiWaMLiaRK827vvT791yWv9Y9SvoKvAw6YzMMxZYYjftb/Cl3gTyd3KaI+jFsahpydDXiIjb67fPo+ZecXc7SM+yu0PgarhylQ+yRsI5dRaBhjE1hFbSWM3oaSD5pzPBHu4iWQvX1QEPmwflHgV+LqSMwTsDiGmx3ZZ8zCejwM8241k++/hX2DqyP1+W5RbaXT92yjtdWUVY379zFUDYpetkxXXYi2XvKBIcbh3fdK5xMiR0YhPXyEAMZGmDZYbNYdV/8TW3HqJcICIHe1beERFnB+ivfxlLCo6L5Cfjne6XrgCfH7nInW+4s8D08tFtiZShYZtcMn7PFxPKJp2S1hOAsJKzxAfNjiQ37o6pWVee+F5+DKM108CQCB/snBblh3FFdTRwRevEq4mCav/BS02Zdbpy2WXXCzipASCALNuI02zUbBVc8/mU2dlVwnQGT7Vp3nb4inYjKedQB8pAzHXvV4CnyNIC/s/KPPnXSxI2n44tjrHknTejSWdmBs+MeophYQthFxUp8FWksX0CHYfcyiSTCTlIrJYI3SY/hcwm6RsaMJ9kGaeEyoMDqwU9pXztg6qZ3kJgpEF32KC1nIJ297draZ/gaho4FYV9Htgq8pUJXQXy/veCnSM0fSQhrryuO2gmj5r1P5VdKL2O4hmTTplkOxE/8p8aYIeq6WPm1BVYYngp3zoL8+kqIFm7Hww4Ch60ZYk9M6Fu8lvC5zG+hXZmEDGM2LgRmz7B+Ft2gp+g5FcjK1kNMxnAdlHYDgJuucwP2zOJjXmS4e1VBDeOsbQB3oYvp1fD2unGetbkQmSqT4fbZzclnG7rsLv7MNk1JLyMbwu5Y7gA0TmvHuhEVmDA8V4OX5pjVqB4w+iKkwcSdWeKtiSloxv2g6ZS0BfDIfAGttSV2AwddQZ2/kuWlUok+xLzodl8S+Rfk/tkv5XTafMOBz00akVoStiA7M6WH3vr2F+OoXhebSESR79VGLilXv403CO7bZXxbu1R3SgRAK/KFCMLUIS7XSa4inv+99MLVDkl32DtZs6BXTWsDeI2TS8rE9ybZWa9E0dBgZf4WmMJmkV966IZXwFo0monZ/fyF4yCH+MuhFhtbYS9HmHQ3kxUU4FeEPw+rF+U7fE5O134aShjOmhIL4vdj6g4+q4djuxpOGAc1Rcw13r97gt3hLVJWC1Fq+MxcsX6DnCc59wa4/bi4Fx84mT2cGj/3LKhkPOHMOIr8Xm72hvaAcGctPrV2/YT8n++8kXGLSL97PAN/uXmWbwBmevvwFqQN0B/2vUurTIuO4v7FiupQVmHLnXYXtOOdsV5rGXgR3Kj253tdcMQBCXx3S6nlyN7x53yg6+eYD3mcax3vpbC+IeUSf4n9+Gln+Qh7jfHAATVh9JwKDvtozodOaFA820GA7WP+JiBjAShgq8z0fxiPl2SAU4whEDukrjhM9mREWtKenQlVwKALeetkI+0HyVqvy7ejaTPJZNcy63hGCwCE5xEfAg3gUcySpRMf1/o2AC3u5sKozgC+BOinTOCE4ioH/mQE3jgTzaX1vBUTp12wjOAsJSNP97oe5XTU61XY1gGMCUELD00d4WuIUX0+/Kn4wRMP/ATk12R3BlQ6IaT2LC+5x4hxeNdTTBITcvIw3yTEDNUHlRqAm8hHJCsOuPVrI6FDRe6xxw9uUAOuDWM9bUNVIL76dxf8lk6wlGffim9YsvBQ0rIMu3T4C89Tzh9r0ZrYpG4g6r8ZLA3PwkdG2funiwL9U4Cie0kh6XjoYAQ=="},{"type":"toolCall","id":"toolu_01VHfsSBf3uW1FztxAz5f1Eq","name":"bash","arguments":{"command":"cd /workspace && grep -n \"from.*pr\\.ts\\|from.*\\\"\\.\\/pr\\\"\\|import\" src/runs/pr.test.ts | head -40; echo \"===\"; grep -rn \"runs/pr\\.ts\\|runs/pr'\" --include=*.ts src/ | grep -v \"pr.test\\|pr-template\\|pr-checks\" | head"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":1238,"cacheRead":11261,"cacheWrite":10046,"totalTokens":22547,"cost":{"input":0.00001,"output":0.030950000000000002,"cacheRead":0.0056305,"cacheWrite":0.0627875,"total":0.099378}},"stopReason":"toolUse","timestamp":1781434129018,"responseId":"msg_01TQ9wgEakaBj6cokU68z2L9"}} +{"type":"message","id":"1ea8b394","parentId":"c1be99f7","timestamp":"2026-06-14T10:49:03.784Z","message":{"role":"toolResult","toolCallId":"toolu_01VHfsSBf3uW1FztxAz5f1Eq","toolName":"bash","content":[{"type":"text","text":"1:import { describe, expect, test } from \"bun:test\";\n2:import {\n11:} from \"./pr.ts\";\n===\nsrc/server/main/index.ts:64:import { buildPrContent, openPullRequest } from \"../../runs/pr.ts\";\nsrc/server/types.ts:23:import type { AutoOpenPrConfig } from \"../runs/pr.ts\";\nsrc/plots/sync.ts:7:import { mergePullRequest, openPullRequest, parsePullRequestRef } from \"../runs/pr.ts\";\nsrc/plots/pr-merger.ts:24:import { type MergePullRequestResult, mergePullRequest, parsePullRequestRef } from \"../runs/pr.ts\";\nsrc/plan-runs/pr-merge.ts:2: * Retry-aware wrapper around `checkPullRequestMerged` (src/runs/pr.ts,\nsrc/plan-runs/pr-merge.ts:26:} from \"../runs/pr.ts\";\nsrc/plan-runs/pr-merge.test.ts:2:import type { CheckPrMergedResult } from \"../runs/pr.ts\";\nsrc/ci-fixer/check-runs.ts:5: * the aggregate state. Mirrors `src/runs/pr.ts`'s posture: a direct REST\n"}],"isError":false,"timestamp":1781434143784}} +{"type":"message","id":"f06ad1e9","parentId":"1ea8b394","timestamp":"2026-06-14T10:49:49.352Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I'm setting up a new pr-checks.ts file to house the shared helper functions, then re-exporting them from pr.ts so existing consumers don't break.","thinkingSignature":"EoYDCmMIDhgCKkAWUSxxgWvRPYixkJRB/aBomONZBODZFoCmEZmfCUcC9jspG9/FrWb0h4TqA6msDKjyS7sM9pGuYACRf6XkThtVMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDKjQB5V+BUk9Wop0XxoMOYzVZjtU+MZCpKYvIjAWO3TBFDeg2RHuRHzEfz209jd+aRYG+TPLtPxGJySAHTnSNZRPh3kBc+B/SptuuqEq0AFsiy2kc+ZMbmhBKPtox2ISIQ+3tfJ8CN20XL1bI42bn1x9xapyS3dMUEitm8GhJNdT2pEQTlOj/JblaCr5eB4/D42pxVg8jnj4prJNsxdzK3SAb38dWdHRmD0tU4JlFTuxvg/dyzyi97AlZ3reek4msw+oBp4bEbnO4iz1RZiW7uje7hkIQULPkDMaBuiT3SKkCotyjFwrAkpSOU3P77rZwDaBw0I2HslsKHt3DaTo3bMybTmKFqvp4KJQHxEozXLhVpB4ucdslAQ9u31XmrUzGAE="},{"type":"text","text":"Now I'll create `pr-checks.ts` with the moved group plus shared helpers, and import them back into `pr.ts`."},{"type":"toolCall","id":"toolu_01ViRJmjLDuMa7FRHBvouwVh","name":"write","arguments":{"path":"/workspace/src/runs/pr-checks.ts","content":"/**\n * `src/runs/pr-checks.ts` — the PR-merge / URL-parse group split out of\n * `src/runs/pr.ts` (warren-db9a / pl-88bb step 1) to keep both files under\n * the per-file line budget. Houses `checkPullRequestMerged`,\n * `mergePullRequest`, `parsePullRequestUrl`, `parsePullRequestRef`,\n * `isRateLimited`, and the shared GitHub REST helpers\n * (`buildHeaders`/`readJson`/`readText`/`truncate`) that both this module\n * and `pr.ts` use. `pr.ts` re-exports the public symbols so existing\n * `../runs/pr.ts` import paths keep resolving.\n */\n\nexport const GITHUB_API_BASE = \"https://api.github.com\";\nconst USER_AGENT = \"warren-reap-pr-open\";\n\n/**\n * Acceptance test seam (warren-ae00 / scenario 26). When\n * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits\n * to a canned positive response — `openPullRequest` returns a synthetic\n * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately.\n * Lets the in-proc plan-run roundtrip exercise reap's PR open + the\n * coordinator's pr_open → merged transition without standing up a real\n * GitHub fixture. Unset in production deployments.\n */\nexport const GH_FETCH_OVERRIDE_ENV = \"WARREN_GH_FETCH_OVERRIDE\";\n\nexport function readGhFetchOverride(): \"merged\" | null {\n\tconst v = process.env[GH_FETCH_OVERRIDE_ENV];\n\tif (typeof v !== \"string\") return null;\n\treturn v.trim() === \"merged\" ? \"merged\" : null;\n}\n\nexport function buildHeaders(token: string): Record {\n\treturn {\n\t\taccept: \"application/vnd.github+json\",\n\t\tauthorization: `Bearer ${token}`,\n\t\t\"content-type\": \"application/json\",\n\t\t\"user-agent\": USER_AGENT,\n\t\t\"x-github-api-version\": \"2022-11-28\",\n\t};\n}\n\nexport async function readJson(res: Response): Promise {\n\ttry {\n\t\treturn await res.json();\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport async function readText(res: Response): Promise {\n\ttry {\n\t\treturn await res.text();\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n\nexport function truncate(input: string, max: number): string {\n\treturn input.length <= max ? input : `${input.slice(0, max)}…`;\n}\n\n/* ----------------------------------------------------------------------- */\n/* PR-merge polling */\n/* ----------------------------------------------------------------------- */\n\n/**\n * `checkPullRequestMerged` — poll a GitHub PR's merge state for the PlanRun\n * coordinator (warren-9e4c). Pure helper: the caller decides what each\n * non-merged shape means (`open` = wait, `closed_unmerged` = fail the plan).\n *\n * Mirrors `openPullRequest`'s posture: direct REST call against\n * `GET /repos/:owner/:repo/pulls/:number`, `Authorization: Bearer `\n * from `GITHUB_TOKEN`, fetch injected as a seam.\n */\nexport interface CheckPullRequestMergedInput {\n\treadonly owner: string;\n\treadonly repo: string;\n\treadonly number: number;\n\treadonly token: string;\n\treadonly fetch?: typeof fetch;\n}\n\nexport type CheckPrMergedResult =\n\t| { readonly kind: \"merged\"; readonly mergedAt: string }\n\t| { readonly kind: \"open\" }\n\t| { readonly kind: \"closed_unmerged\" }\n\t| { readonly kind: \"missing_token\"; readonly message: string }\n\t| { readonly kind: \"http_error\"; readonly status: number; readonly message: string };\n\nexport async function checkPullRequestMerged(\n\tinput: CheckPullRequestMergedInput,\n): Promise {\n\tif (readGhFetchOverride() === \"merged\") {\n\t\treturn { kind: \"merged\", mergedAt: new Date().toISOString() };\n\t}\n\tif (input.token === \"\") {\n\t\treturn {\n\t\t\tkind: \"missing_token\",\n\t\t\tmessage: \"GITHUB_TOKEN unset; cannot check pull request merge state\",\n\t\t};\n\t}\n\n\tconst fetchImpl = input.fetch ?? globalThis.fetch;\n\tconst url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}`;\n\n\tlet res: Response;\n\ttry {\n\t\tres = await fetchImpl(url, { method: \"GET\", headers: buildHeaders(input.token) });\n\t} catch (err) {\n\t\treturn {\n\t\t\tkind: \"http_error\",\n\t\t\tstatus: 0,\n\t\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\t};\n\t}\n\n\tif (res.status !== 200) {\n\t\tconst text = await readText(res);\n\t\treturn {\n\t\t\tkind: \"http_error\",\n\t\t\tstatus: res.status,\n\t\t\tmessage: `GET /pulls/${input.number} returned ${res.status}: ${truncate(text, 500)}`,\n\t\t};\n\t}\n\n\tconst body = (await readJson(res)) as { merged_at?: unknown; state?: unknown } | null;\n\tconst mergedAt = typeof body?.merged_at === \"string\" ? body.merged_at : null;\n\tif (mergedAt !== null) {\n\t\treturn { kind: \"merged\", mergedAt };\n\t}\n\tconst state = typeof body?.state === \"string\" ? body.state : \"\";\n\tif (state === \"closed\") {\n\t\treturn { kind: \"closed_unmerged\" };\n\t}\n\treturn { kind: \"open\" };\n}\n\n/**\n * `parsePullRequestUrl` — regex-parse `https://github.com///pull/`.\n * Returns `null` on mismatch (e.g. GHE-hosted shapes) so the coordinator\n * treats them as \"cannot verify merge\" rather than \"merged\".\n */\nexport const PR_URL_RE = /^https:\\/\\/github\\.com\\/([^/\\s]+)\\/([^/\\s]+)\\/pull\\/(\\d+)(?:[/?#].*)?$/;\n\nexport function parsePullRequestUrl(\n\tprUrl: string,\n): { owner: string; repo: string; number: number } | null {\n\tconst m = PR_URL_RE.exec(prUrl.trim());\n\tif (m === null) return null;\n\tconst [, owner, repo, num] = m;\n\tif (owner === undefined || repo === undefined || num === undefined) return null;\n\tconst n = Number.parseInt(num, 10);\n\tif (!Number.isFinite(n) || n <= 0) return null;\n\treturn { owner, repo, number: n };\n}\n\n/**\n * `parsePullRequestRef` — accept either a full GitHub PR URL\n * (`https://github.com///pull/`) or the conventional\n * shorthand `/#` shape that Plot `gh_pr` attachments\n * use (see `refPlaceholder('gh_pr')` in src/ui/src/pages/PlotDetail.tsx).\n * Returns `null` on mismatch so the merge handler treats it as\n * \"cannot resolve\" rather than \"merged\" (warren-8e39 / pl-0344 step 14).\n */\nexport const PR_SHORT_RE = /^([^/\\s]+)\\/([^/\\s#]+)#(\\d+)$/;\n\nexport function parsePullRequestRef(\n\tref: string,\n): { owner: string; repo: string; number: number } | null {\n\tconst trimmed = ref.trim();\n\tconst viaUrl = parsePullRequestUrl(trimmed);\n\tif (viaUrl !== null) return viaUrl;\n\tconst m = PR_SHORT_RE.exec(trimmed);\n\tif (m === null) return null;\n\tconst [, owner, repo, num] = m;\n\tif (owner === undefined || repo === undefined || num === undefined) return null;\n\tconst n = Number.parseInt(num, 10);\n\tif (!Number.isFinite(n) || n <= 0) return null;\n\treturn { owner, repo, number: n };\n}\n\n/* ----------------------------------------------------------------------- */\n/* PR merge (warren-8e39 / pl-0344 step 14) */\n/* ----------------------------------------------------------------------- */\n\n/**\n * `mergePullRequest` — click-to-merge a GitHub PR attached to a Plot\n * (warren-8e39 / pl-0344 step 14). Mirrors `openPullRequest`'s posture:\n * direct `PUT /repos/:owner/:repo/pulls/:number/merge`, fetch injected\n * as a seam, `GITHUB_TOKEN` auth.\n *\n * The result variants are tuned for what the PlotDetail merge UI cares\n * about:\n * - `merged` — 200 with `merged: true`. Caller schedules\n * a follow-up `refreshProjectClone` so the\n * local clone picks up the new merge commit.\n * - `already_merged` — 405/409 with \"already merged\" body. Same\n * net effect as `merged`; caller still\n * schedules the refresh (idempotent).\n * - `not_mergeable` — 405 (conflict / required reviews missing /\n * checks failing) or 409 (base SHA mismatch).\n * - `missing_token` — `GITHUB_TOKEN` unset on the server.\n * - `rate_limited` — 403 with `x-ratelimit-remaining: 0` or 429.\n * Surfaces the reset timestamp so the UI can\n * show a countdown.\n * - `not_found` — 404 (wrong owner/repo/number).\n * - `network` — fetch threw / non-HTTP failure.\n * - `http_error` — anything else (5xx etc.).\n */\nexport interface MergePullRequestInput {\n\treadonly owner: string;\n\treadonly repo: string;\n\treadonly number: number;\n\treadonly token: string;\n\t/** Optional commit message override; GitHub defaults are fine for most cases. */\n\treadonly commitMessage?: string;\n\t/** Merge method. Defaults to `merge` (matches GitHub's UI default). */\n\treadonly mergeMethod?: \"merge\" | \"squash\" | \"rebase\";\n\treadonly fetch?: typeof fetch;\n}\n\nexport type MergePullRequestResult =\n\t| { readonly kind: \"merged\"; readonly sha: string }\n\t| { readonly kind: \"already_merged\" }\n\t| { readonly kind: \"not_mergeable\"; readonly message: string }\n\t| { readonly kind: \"not_found\"; readonly message: string }\n\t| { readonly kind: \"missing_token\"; readonly message: string }\n\t| {\n\t\t\treadonly kind: \"rate_limited\";\n\t\t\treadonly message: string;\n\t\t\treadonly resetAt: string | null;\n\t }\n\t| { readonly kind: \"network\"; readonly message: string }\n\t| { readonly kind: \"http_error\"; readonly status: number; readonly message: string };\n\nexport async function mergePullRequest(\n\tinput: MergePullRequestInput,\n): Promise {\n\tif (readGhFetchOverride() === \"merged\") {\n\t\treturn { kind: \"merged\", sha: \"0\".repeat(40) };\n\t}\n\tif (input.token === \"\") {\n\t\treturn {\n\t\t\tkind: \"missing_token\",\n\t\t\tmessage: \"GITHUB_TOKEN unset; cannot merge pull request\",\n\t\t};\n\t}\n\n\tconst fetchImpl = input.fetch ?? globalThis.fetch;\n\tconst url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}/merge`;\n\tconst body: Record = {\n\t\tmerge_method: input.mergeMethod ?? \"merge\",\n\t};\n\tif (input.commitMessage !== undefined) {\n\t\tbody.commit_message = input.commitMessage;\n\t}\n\n\tlet res: Response;\n\ttry {\n\t\tres = await fetchImpl(url, {\n\t\t\tmethod: \"PUT\",\n\t\t\theaders: buildHeaders(input.token),\n\t\t\tbody: JSON.stringify(body),\n\t\t});\n\t} catch (err) {\n\t\treturn {\n\t\t\tkind: \"network\",\n\t\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\t};\n\t}\n\n\tif (res.status === 200) {\n\t\tconst payload = (await readJson(res)) as {\n\t\t\tmerged?: unknown;\n\t\t\tsha?: unknown;\n\t\t\tmessage?: unknown;\n\t\t} | null;\n\t\tconst merged = payload?.merged === true;\n\t\tconst sha = typeof payload?.sha === \"string\" ? payload.sha : \"\";\n\t\tif (merged) {\n\t\t\treturn { kind: \"merged\", sha };\n\t\t}\n\t\tconst message = typeof payload?.message === \"string\" ? payload.message : \"PR not merged\";\n\t\treturn { kind: \"not_mergeable\", message };\n\t}\n\n\t// Rate limit: GitHub surfaces 403 with x-ratelimit-remaining: 0, or 429.\n\tif (res.status === 429 || (res.status === 403 && isRateLimited(res))) {\n\t\tconst reset = res.headers.get(\"x-ratelimit-reset\");\n\t\tconst resetAt =\n\t\t\treset !== null && /^\\d+$/.test(reset)\n\t\t\t\t? new Date(Number.parseInt(reset, 10) * 1000).toISOString()\n\t\t\t\t: null;\n\t\tconst text = await readText(res);\n\t\treturn {\n\t\t\tkind: \"rate_limited\",\n\t\t\tmessage: truncate(text, 500),\n\t\t\tresetAt,\n\t\t};\n\t}\n\n\tif (res.status === 404) {\n\t\tconst text = await readText(res);\n\t\treturn { kind: \"not_found\", message: truncate(text, 500) };\n\t}\n\n\t// 405 \"Method Not Allowed\" with `message: \"Pull Request is not mergeable\"`\n\t// is the standard \"can't merge right now\" shape. 409 is base SHA mismatch.\n\t// Both also cover \"already merged\" — distinguish via the body message.\n\tif (res.status === 405 || res.status === 409) {\n\t\tconst payload = (await readJson(res)) as { message?: unknown } | null;\n\t\tconst message =\n\t\t\ttypeof payload?.message === \"string\" ? payload.message : `${res.status} from PUT /merge`;\n\t\tif (/already merged/i.test(message)) {\n\t\t\treturn { kind: \"already_merged\" };\n\t\t}\n\t\treturn { kind: \"not_mergeable\", message };\n\t}\n\n\tconst text = await readText(res);\n\treturn {\n\t\tkind: \"http_error\",\n\t\tstatus: res.status,\n\t\tmessage: `PUT /merge returned ${res.status}: ${truncate(text, 500)}`,\n\t};\n}\n\nexport function isRateLimited(res: Response): boolean {\n\tconst remaining = res.headers.get(\"x-ratelimit-remaining\");\n\treturn remaining === \"0\";\n}\n"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":5241,"cacheRead":21307,"cacheWrite":1670,"totalTokens":28220,"cost":{"input":0.00001,"output":0.131025,"cacheRead":0.0106535,"cacheWrite":0.0104375,"total":0.152126}},"stopReason":"toolUse","timestamp":1781434143785,"responseId":"msg_011do361PsTiVE7u5Bb9WgCD"}} +{"type":"message","id":"0cc4556a","parentId":"f06ad1e9","timestamp":"2026-06-14T10:49:49.359Z","message":{"role":"toolResult","toolCallId":"toolu_01ViRJmjLDuMa7FRHBvouwVh","toolName":"write","content":[{"type":"text","text":"Successfully wrote 11816 bytes to /workspace/src/runs/pr-checks.ts"}],"isError":false,"timestamp":1781434189359}} +{"type":"message","id":"e6f70d8c","parentId":"0cc4556a","timestamp":"2026-06-14T10:50:03.870Z","message":{"role":"assistant","content":[{"type":"text","text":"Now update `pr.ts` to remove the moved code, import shared helpers, and re-export public symbols."},{"type":"toolCall","id":"toolu_01PKjd6gZFQ8UjQ9qT97u69E","name":"edit","arguments":{"path":"/workspace/src/runs/pr.ts","edits":[{"oldText":"import {\n\tcomposeBody,\n\tcomposeTitle,\n\ttype PrFragmentContext,\n\ttype PrTemplateOverrides,\n} from \"./pr-template.ts\";","newText":"import {\n\tbuildHeaders,\n\tGH_FETCH_OVERRIDE_ENV,\n\tGITHUB_API_BASE,\n\treadGhFetchOverride,\n\treadJson,\n\treadText,\n\ttruncate,\n} from \"./pr-checks.ts\";\nimport {\n\tcomposeBody,\n\tcomposeTitle,\n\ttype PrFragmentContext,\n\ttype PrTemplateOverrides,\n} from \"./pr-template.ts\";\n\nexport {\n\ttype CheckPrMergedResult,\n\tcheckPullRequestMerged,\n\ttype CheckPullRequestMergedInput,\n\tisRateLimited,\n\tmergePullRequest,\n\ttype MergePullRequestInput,\n\ttype MergePullRequestResult,\n\tparsePullRequestRef,\n\tparsePullRequestUrl,\n\tPR_SHORT_RE,\n\tPR_URL_RE,\n} from \"./pr-checks.ts\";"},{"oldText":"const GITHUB_API_BASE = \"https://api.github.com\";\nconst USER_AGENT = \"warren-reap-pr-open\";\n\n/**\n * Acceptance test seam (warren-ae00 / scenario 26). When\n * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits\n * to a canned positive response — `openPullRequest` returns a synthetic\n * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately.\n * Lets the in-proc plan-run roundtrip exercise reap's PR open + the\n * coordinator's pr_open → merged transition without standing up a real\n * GitHub fixture. Unset in production deployments.\n */\nconst GH_FETCH_OVERRIDE_ENV = \"WARREN_GH_FETCH_OVERRIDE\";\n\nfunction readGhFetchOverride(): \"merged\" | null {\n\tconst v = process.env[GH_FETCH_OVERRIDE_ENV];\n\tif (typeof v !== \"string\") return null;\n\treturn v.trim() === \"merged\" ? \"merged\" : null;\n}\n\nexport async function openPullRequest(","newText":"export async function openPullRequest("},{"oldText":"function buildHeaders(token: string): Record {\n\treturn {\n\t\taccept: \"application/vnd.github+json\",\n\t\tauthorization: `Bearer ${token}`,\n\t\t\"content-type\": \"application/json\",\n\t\t\"user-agent\": USER_AGENT,\n\t\t\"x-github-api-version\": \"2022-11-28\",\n\t};\n}\n\nasync function readJson(res: Response): Promise {\n\ttry {\n\t\treturn await res.json();\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nasync function readText(res: Response): Promise {\n\ttry {\n\t\treturn await res.text();\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n\nfunction truncate(input: string, max: number): string {\n\treturn input.length <= max ? input : `${input.slice(0, max)}…`;\n}\n\n/* -----","newText":"/* -----"}]}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":1407,"cacheRead":22977,"cacheWrite":5279,"totalTokens":29665,"cost":{"input":0.00001,"output":0.035175000000000005,"cacheRead":0.011488499999999999,"cacheWrite":0.03299375,"total":0.07966725000000001}},"stopReason":"toolUse","timestamp":1781434189360,"responseId":"msg_012bsm9fTgTitEWbtRgXCPpH"}} +{"type":"message","id":"38ddeee3","parentId":"e6f70d8c","timestamp":"2026-06-14T10:50:03.890Z","message":{"role":"toolResult","toolCallId":"toolu_01PKjd6gZFQ8UjQ9qT97u69E","toolName":"edit","content":[{"type":"text","text":"Successfully replaced 3 block(s) in /workspace/src/runs/pr.ts."}],"details":{"diff":" 1 import {\n+ 2 \tbuildHeaders,\n+ 3 \tGH_FETCH_OVERRIDE_ENV,\n+ 4 \tGITHUB_API_BASE,\n+ 5 \treadGhFetchOverride,\n+ 6 \treadJson,\n+ 7 \treadText,\n+ 8 \ttruncate,\n+ 9 } from \"./pr-checks.ts\";\n+ 10 import {\n 2 \tcomposeBody,\n 3 \tcomposeTitle,\n 4 \ttype PrFragmentContext,\n 5 \ttype PrTemplateOverrides,\n 6 } from \"./pr-template.ts\";\n 7 \n+ 17 export {\n+ 18 \ttype CheckPrMergedResult,\n+ 19 \tcheckPullRequestMerged,\n+ 20 \ttype CheckPullRequestMergedInput,\n+ 21 \tisRateLimited,\n+ 22 \tmergePullRequest,\n+ 23 \ttype MergePullRequestInput,\n+ 24 \ttype MergePullRequestResult,\n+ 25 \tparsePullRequestRef,\n+ 26 \tparsePullRequestUrl,\n+ 27 \tPR_SHORT_RE,\n+ 28 \tPR_URL_RE,\n+ 29 } from \"./pr-checks.ts\";\n+ 30 \n 8 /**\n 9 * `openPullRequest` — open a GitHub PR for a branch reap just pushed\n 10 * (warren-f6af). Fourth best-effort sub-step of `reapRun`, gated by\n 11 * `WARREN_AUTO_OPEN_PR` (default on).\n ...\n 55 export interface PrFetcher {\n 56 \treadonly fetch: typeof fetch;\n 57 }\n 58 \n- 59 const GITHUB_API_BASE = \"https://api.github.com\";\n- 60 const USER_AGENT = \"warren-reap-pr-open\";\n- 61 \n- 62 /**\n- 63 * Acceptance test seam (warren-ae00 / scenario 26). When\n- 64 * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits\n- 65 * to a canned positive response — `openPullRequest` returns a synthetic\n- 66 * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately.\n- 67 * Lets the in-proc plan-run roundtrip exercise reap's PR open + the\n- 68 * coordinator's pr_open → merged transition without standing up a real\n- 69 * GitHub fixture. Unset in production deployments.\n- 70 */\n- 71 const GH_FETCH_OVERRIDE_ENV = \"WARREN_GH_FETCH_OVERRIDE\";\n- 72 \n- 73 function readGhFetchOverride(): \"merged\" | null {\n- 74 \tconst v = process.env[GH_FETCH_OVERRIDE_ENV];\n- 75 \tif (typeof v !== \"string\") return null;\n- 76 \treturn v.trim() === \"merged\" ? \"merged\" : null;\n- 77 }\n- 78 \n 79 export async function openPullRequest(\n 80 \tinput: OpenPullRequestInput,\n 81 \tdeps: PrFetcher = { fetch: globalThis.fetch },\n 82 ): Promise {\n ...\n 181 \tconst first = list[0];\n 182 \treturn typeof first?.html_url === \"string\" ? first.html_url : null;\n 183 }\n 184 \n-185 function buildHeaders(token: string): Record {\n-186 \treturn {\n-187 \t\taccept: \"application/vnd.github+json\",\n-188 \t\tauthorization: `Bearer ${token}`,\n-189 \t\t\"content-type\": \"application/json\",\n-190 \t\t\"user-agent\": USER_AGENT,\n-191 \t\t\"x-github-api-version\": \"2022-11-28\",\n-192 \t};\n-193 }\n-194 \n-195 async function readJson(res: Response): Promise {\n-196 \ttry {\n-197 \t\treturn await res.json();\n-198 \t} catch {\n-199 \t\treturn null;\n-200 \t}\n-201 }\n-202 \n-203 async function readText(res: Response): Promise {\n-204 \ttry {\n-205 \t\treturn await res.text();\n-206 \t} catch {\n-207 \t\treturn \"\";\n-208 \t}\n-209 }\n-210 \n-211 function truncate(input: string, max: number): string {\n-212 \treturn input.length <= max ? input : `${input.slice(0, max)}…`;\n-213 }\n-214 \n 215 /* ----------------------------------------------------------------------- */\n 216 /* Title + body formatting */\n 217 /* ----------------------------------------------------------------------- */\n 218 \n ...","patch":"--- /workspace/src/runs/pr.ts\n+++ /workspace/src/runs/pr.ts\n@@ -1,11 +1,34 @@\n import {\n+\tbuildHeaders,\n+\tGH_FETCH_OVERRIDE_ENV,\n+\tGITHUB_API_BASE,\n+\treadGhFetchOverride,\n+\treadJson,\n+\treadText,\n+\ttruncate,\n+} from \"./pr-checks.ts\";\n+import {\n \tcomposeBody,\n \tcomposeTitle,\n \ttype PrFragmentContext,\n \ttype PrTemplateOverrides,\n } from \"./pr-template.ts\";\n \n+export {\n+\ttype CheckPrMergedResult,\n+\tcheckPullRequestMerged,\n+\ttype CheckPullRequestMergedInput,\n+\tisRateLimited,\n+\tmergePullRequest,\n+\ttype MergePullRequestInput,\n+\ttype MergePullRequestResult,\n+\tparsePullRequestRef,\n+\tparsePullRequestUrl,\n+\tPR_SHORT_RE,\n+\tPR_URL_RE,\n+} from \"./pr-checks.ts\";\n+\n /**\n * `openPullRequest` — open a GitHub PR for a branch reap just pushed\n * (warren-f6af). Fourth best-effort sub-step of `reapRun`, gated by\n * `WARREN_AUTO_OPEN_PR` (default on).\n@@ -55,28 +78,8 @@\n export interface PrFetcher {\n \treadonly fetch: typeof fetch;\n }\n \n-const GITHUB_API_BASE = \"https://api.github.com\";\n-const USER_AGENT = \"warren-reap-pr-open\";\n-\n-/**\n- * Acceptance test seam (warren-ae00 / scenario 26). When\n- * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits\n- * to a canned positive response — `openPullRequest` returns a synthetic\n- * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately.\n- * Lets the in-proc plan-run roundtrip exercise reap's PR open + the\n- * coordinator's pr_open → merged transition without standing up a real\n- * GitHub fixture. Unset in production deployments.\n- */\n-const GH_FETCH_OVERRIDE_ENV = \"WARREN_GH_FETCH_OVERRIDE\";\n-\n-function readGhFetchOverride(): \"merged\" | null {\n-\tconst v = process.env[GH_FETCH_OVERRIDE_ENV];\n-\tif (typeof v !== \"string\") return null;\n-\treturn v.trim() === \"merged\" ? \"merged\" : null;\n-}\n-\n export async function openPullRequest(\n \tinput: OpenPullRequestInput,\n \tdeps: PrFetcher = { fetch: globalThis.fetch },\n ): Promise {\n@@ -181,38 +184,8 @@\n \tconst first = list[0];\n \treturn typeof first?.html_url === \"string\" ? first.html_url : null;\n }\n \n-function buildHeaders(token: string): Record {\n-\treturn {\n-\t\taccept: \"application/vnd.github+json\",\n-\t\tauthorization: `Bearer ${token}`,\n-\t\t\"content-type\": \"application/json\",\n-\t\t\"user-agent\": USER_AGENT,\n-\t\t\"x-github-api-version\": \"2022-11-28\",\n-\t};\n-}\n-\n-async function readJson(res: Response): Promise {\n-\ttry {\n-\t\treturn await res.json();\n-\t} catch {\n-\t\treturn null;\n-\t}\n-}\n-\n-async function readText(res: Response): Promise {\n-\ttry {\n-\t\treturn await res.text();\n-\t} catch {\n-\t\treturn \"\";\n-\t}\n-}\n-\n-function truncate(input: string, max: number): string {\n-\treturn input.length <= max ? input : `${input.slice(0, max)}…`;\n-}\n-\n /* ----------------------------------------------------------------------- */\n /* Title + body formatting */\n /* ----------------------------------------------------------------------- */\n \n","firstChangedLine":2},"isError":false,"timestamp":1781434203889}} +{"type":"message","id":"fd5e14ae","parentId":"38ddeee3","timestamp":"2026-06-14T10:50:06.714Z","message":{"role":"assistant","content":[{"type":"text","text":"Now remove the merge-polling/parse block that remains at the end of pr.ts:"},{"type":"toolCall","id":"toolu_013b9HoAKJHkv4kDYDCVqdiE","name":"bash","arguments":{"command":"cd /workspace && grep -n \"PR-merge polling\\|checkPullRequestMerged\\|isRateLimited\\|parsePullRequest\\|mergePullRequest\\|PR merge (warren-8e39\" src/runs/pr.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":154,"cacheRead":28256,"cacheWrite":1459,"totalTokens":29871,"cost":{"input":0.00001,"output":0.00385,"cacheRead":0.014128,"cacheWrite":0.00911875,"total":0.02710675}},"stopReason":"toolUse","timestamp":1781434203891,"responseId":"msg_01PdQ7HccUEPeXJkC6UU9Gig"}} +{"type":"message","id":"9e2f8481","parentId":"fd5e14ae","timestamp":"2026-06-14T10:50:06.722Z","message":{"role":"toolResult","toolCallId":"toolu_013b9HoAKJHkv4kDYDCVqdiE","toolName":"bash","content":[{"type":"text","text":"19:\tcheckPullRequestMerged,\n21:\tisRateLimited,\n22:\tmergePullRequest,\n25:\tparsePullRequestRef,\n26:\tparsePullRequestUrl,\n364:/* PR-merge polling */\n368: * `checkPullRequestMerged` — poll a GitHub PR's merge state for the PlanRun\n391:export async function checkPullRequestMerged(\n440: * `parsePullRequestUrl` — regex-parse `https://github.com///pull/`.\n446:export function parsePullRequestUrl(\n459: * `parsePullRequestRef` — accept either a full GitHub PR URL\n468:export function parsePullRequestRef(\n472:\tconst viaUrl = parsePullRequestUrl(trimmed);\n484:/* PR merge (warren-8e39 / pl-0344 step 14) */\n488: * `mergePullRequest` — click-to-merge a GitHub PR attached to a Plot\n537:export async function mergePullRequest(\n589:\tif (res.status === 429 || (res.status === 403 && isRateLimited(res))) {\n629:function isRateLimited(res: Response): boolean {\n"}],"isError":false,"timestamp":1781434206721}} +{"type":"message","id":"b6b7c3fe","parentId":"9e2f8481","timestamp":"2026-06-14T10:50:08.255Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01KQqmTxzsG3qFjchC32Cvd4","name":"bash","arguments":{"command":"cd /workspace && sed -n '358,364p' src/runs/pr.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":72,"cacheRead":29715,"cacheWrite":565,"totalTokens":30354,"cost":{"input":0.00001,"output":0.0018000000000000002,"cacheRead":0.0148575,"cacheWrite":0.00353125,"total":0.020198749999999998}},"stopReason":"toolUse","timestamp":1781434206722,"responseId":"msg_013CrFFKNmEH5UgHPY6jYR9H"}} +{"type":"message","id":"06aea737","parentId":"b6b7c3fe","timestamp":"2026-06-14T10:50:08.260Z","message":{"role":"toolResult","toolCallId":"toolu_01KQqmTxzsG3qFjchC32Cvd4","toolName":"bash","content":[{"type":"text","text":"function isFalsy(raw: string): boolean {\n\tconst v = raw.trim().toLowerCase();\n\treturn v === \"0\" || v === \"false\" || v === \"no\" || v === \"off\" || v === \"\";\n}\n\n/* ----------------------------------------------------------------------- */\n/* PR-merge polling */\n"}],"isError":false,"timestamp":1781434208260}} +{"type":"message","id":"5b41c764","parentId":"06aea737","timestamp":"2026-06-14T10:50:10.311Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01SfviM8oxvkrFmva4G5roEF","name":"bash","arguments":{"command":"cd /workspace && head -361 src/runs/pr.ts > /tmp/pr.ts && mv /tmp/pr.ts src/runs/pr.ts && wc -l src/runs/pr.ts src/runs/pr-checks.ts && tail -5 src/runs/pr.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":144,"cacheRead":30280,"cacheWrite":178,"totalTokens":30604,"cost":{"input":0.00001,"output":0.0036000000000000003,"cacheRead":0.015139999999999999,"cacheWrite":0.0011125,"total":0.019862499999999998}},"stopReason":"toolUse","timestamp":1781434208261,"responseId":"msg_014xUu9GyD6u3ULJ755LD8r8"}} +{"type":"message","id":"390675b5","parentId":"5b41c764","timestamp":"2026-06-14T10:50:10.321Z","message":{"role":"toolResult","toolCallId":"toolu_01SfviM8oxvkrFmva4G5roEF","toolName":"bash","content":[{"type":"text","text":" 361 src/runs/pr.ts\n 331 src/runs/pr-checks.ts\n 692 total\n\nfunction isFalsy(raw: string): boolean {\n\tconst v = raw.trim().toLowerCase();\n\treturn v === \"0\" || v === \"false\" || v === \"no\" || v === \"off\" || v === \"\";\n}\n"}],"isError":false,"timestamp":1781434210321}} +{"type":"message","id":"0101979d","parentId":"390675b5","timestamp":"2026-06-14T10:50:11.906Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01MryfWqBh6wxcneA3aVQvVc","name":"bash","arguments":{"command":"cd /workspace && bun run typecheck 2>&1 | tail -20"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":70,"cacheRead":30458,"cacheWrite":253,"totalTokens":30783,"cost":{"input":0.00001,"output":0.00175,"cacheRead":0.015229,"cacheWrite":0.00158125,"total":0.01857025}},"stopReason":"toolUse","timestamp":1781434210322,"responseId":"msg_017PmFuV1s4iNE4CjfBrqTqR"}} +{"type":"message","id":"d15ffb8a","parentId":"0101979d","timestamp":"2026-06-14T10:50:11.915Z","message":{"role":"toolResult","toolCallId":"toolu_01MryfWqBh6wxcneA3aVQvVc","toolName":"bash","content":[{"type":"text","text":"$ tsc --noEmit\n/usr/bin/bash: line 1: tsc: command not found\nerror: script \"typecheck\" exited with code 127\n"}],"isError":false,"timestamp":1781434211915}} +{"type":"message","id":"932145b6","parentId":"d15ffb8a","timestamp":"2026-06-14T10:50:13.524Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01LYba3SrLiXrCLs9xyFxbsM","name":"bash","arguments":{"command":"cd /workspace && bunx tsc --noEmit 2>&1 | tail -20; echo \"EXIT $?\""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":85,"cacheRead":30711,"cacheWrite":124,"totalTokens":30922,"cost":{"input":0.00001,"output":0.002125,"cacheRead":0.0153555,"cacheWrite":0.0007750000000000001,"total":0.0182655}},"stopReason":"toolUse","timestamp":1781434211916,"responseId":"msg_01GDC2KnFXzC2VGTYw8BaRYs"}} +{"type":"message","id":"790fd43b","parentId":"932145b6","timestamp":"2026-06-14T10:50:15.946Z","message":{"role":"toolResult","toolCallId":"toolu_01LYba3SrLiXrCLs9xyFxbsM","toolName":"bash","content":[{"type":"text","text":"Resolving dependencies\nResolved, downloaded and extracted [4]\nSaved lockfile\nerror TS2688: Cannot find type definition file for 'bun-types'.\n The file is in the program because:\n Entry point of type library 'bun-types' specified in compilerOptions\nEXIT 0\n"}],"isError":false,"timestamp":1781434215946}} +{"type":"message","id":"95d031c5","parentId":"790fd43b","timestamp":"2026-06-14T10:50:18.665Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Checking if the dependencies are already installed before proceeding.","thinkingSignature":"EoACCmMIDhgCKkCYoIapmLpsK3tyY2tMIF7M/YJglxlc0gQLc3CmG0zyDmG1T0MTgYYBenhstQuaKPXX01SpGn+JHUaEGpSvbuYVMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDCO4RjgDMfBLv+mrlhoM2oVvL4CPiw3PJX+8IjBj3C272ucSfSqn3atVtjuLQgEVP8i5N6hQ6Z7+ynlPndu6bLjN3abNCiJaHdhXPV8qSxjzH2TXbWBCxDrCBUCUIID6gRFknJrY3jStf0IHYqiiZWBrah2K82v4cXr4HfELBH4HqHZTyMzrcH6NwNDwM+BGe4L0Aqmi3VALQhgB"},{"type":"toolCall","id":"toolu_01H4m5fXaHY5xRFeE9kWoUZn","name":"bash","arguments":{"command":"cd /workspace && ls node_modules/.bin/tsc 2>/dev/null; bun install 2>&1 | tail -3"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":110,"cacheRead":30835,"cacheWrite":191,"totalTokens":31138,"cost":{"input":0.00001,"output":0.0027500000000000003,"cacheRead":0.015417499999999999,"cacheWrite":0.0011937500000000001,"total":0.01937125}},"stopReason":"toolUse","timestamp":1781434215948,"responseId":"msg_015pU2Xd4DPxXvaXEoWXn6ue"}} +{"type":"message","id":"7c8aca53","parentId":"95d031c5","timestamp":"2026-06-14T10:50:19.756Z","message":{"role":"toolResult","toolCallId":"toolu_01H4m5fXaHY5xRFeE9kWoUZn","toolName":"bash","content":[{"type":"text","text":"+ zod@4.4.3\n\n209 packages installed [1059.00ms]\n"}],"isError":false,"timestamp":1781434219756}} +{"type":"message","id":"97b48bef","parentId":"7c8aca53","timestamp":"2026-06-14T10:50:21.760Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01FrjtxW4M1mQhC6A6yNPysm","name":"bash","arguments":{"command":"cd /workspace && bun run typecheck 2>&1 | tail -20; echo \"EXIT ${PIPESTATUS[0]}\""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":93,"cacheRead":31026,"cacheWrite":141,"totalTokens":31262,"cost":{"input":0.00001,"output":0.0023250000000000002,"cacheRead":0.015512999999999999,"cacheWrite":0.0008812500000000001,"total":0.01872925}},"stopReason":"toolUse","timestamp":1781434219757,"responseId":"msg_012fgY9tT3udYyeSzyNn4nLG"}} +{"type":"message","id":"633ad534","parentId":"97b48bef","timestamp":"2026-06-14T10:50:34.980Z","message":{"role":"toolResult","toolCallId":"toolu_01FrjtxW4M1mQhC6A6yNPysm","toolName":"bash","content":[{"type":"text","text":"$ tsc --noEmit\nEXIT 0\n"}],"isError":false,"timestamp":1781434234980}} +{"type":"message","id":"6e45aa0a","parentId":"633ad534","timestamp":"2026-06-14T10:50:38.638Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01LqywxapvVh7ToT2iZZ82k7","name":"bash","arguments":{"command":"cd /workspace && bun test src/runs/pr.test.ts 2>&1 | tail -15"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":78,"cacheRead":31167,"cacheWrite":116,"totalTokens":31363,"cost":{"input":0.00001,"output":0.0019500000000000001,"cacheRead":0.0155835,"cacheWrite":0.0007250000000000001,"total":0.0182685}},"stopReason":"toolUse","timestamp":1781434234981,"responseId":"msg_01TD5ycwoK58nhM6giTW8m2D"}} +{"type":"message","id":"326ac836","parentId":"6e45aa0a","timestamp":"2026-06-14T10:50:38.705Z","message":{"role":"toolResult","toolCallId":"toolu_01LqywxapvVh7ToT2iZZ82k7","toolName":"bash","content":[{"type":"text","text":"(pass) parsePullRequestRef > rejects unrecognized shapes [0.03ms]\n(pass) mergePullRequest > returns merged on 200 with merged=true [0.61ms]\n(pass) mergePullRequest > forwards merge_method and commit_message [0.18ms]\n(pass) mergePullRequest > detects already_merged via 405 body [0.16ms]\n(pass) mergePullRequest > returns not_mergeable on 405 with non-merged message [0.16ms]\n(pass) mergePullRequest > returns rate_limited on 403 with x-ratelimit-remaining=0 [0.25ms]\n(pass) mergePullRequest > returns rate_limited on 429 [0.20ms]\n(pass) mergePullRequest > returns not_found on 404 [0.12ms]\n(pass) mergePullRequest > missing token short-circuits [0.08ms]\n(pass) mergePullRequest > network failure surfaces [0.27ms]\n\n 49 pass\n 0 fail\n 138 expect() calls\nRan 49 tests across 1 file. [58.00ms]\n"}],"isError":false,"timestamp":1781434238705}} +{"type":"message","id":"4bd6036e","parentId":"326ac836","timestamp":"2026-06-14T10:50:42.022Z","message":{"role":"assistant","content":[{"type":"text","text":"All 49 tests pass, typecheck clean, pr.ts is 361 lines. Now the full gate:"},{"type":"toolCall","id":"toolu_01GsTcdSoJy6bNa5DPYmP3qz","name":"bash","arguments":{"command":"cd /workspace && bun run check:dups 2>&1 | tail -15; echo \"DUPS EXIT ${PIPESTATUS[0]}\""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":133,"cacheRead":31283,"cacheWrite":454,"totalTokens":31872,"cost":{"input":0.00001,"output":0.0033250000000000003,"cacheRead":0.0156415,"cacheWrite":0.0028375,"total":0.021814}},"stopReason":"toolUse","timestamp":1781434238706,"responseId":"msg_018MhKcCyPMGTq4SKHtTSpW4"}} +{"type":"message","id":"36c15a0b","parentId":"4bd6036e","timestamp":"2026-06-14T10:50:47.198Z","message":{"role":"toolResult","toolCallId":"toolu_01GsTcdSoJy6bNa5DPYmP3qz","toolName":"bash","content":[{"type":"text","text":" 162 │ 221 │ \t} catch { \n 163 │ 222 │ \t\treturn \"\"; \n 164 │ 223 │ \t} \n 165 │ 224 │ } \n 166 │ 225 │ \n 167 │ 226 │ function truncate(input: string, max: number): string { \n 168 │ 227 │ \treturn input.length <= max ? input : `${input.slice(0, max)}…`; \n 169 │ 228 │ } \n\nFound 28 clones.\ntime: 4.661s\n\n💡 Auto-refactor with AI: npx skills add kucherenko/jscpd\n🎩 New: Gangsta Agents — discipline your AI coding → gangsta.page\n💖 Support jscpd project → https://opencollective.com/jscpd\nDUPS EXIT 0\n"}],"isError":false,"timestamp":1781434247198}} +{"type":"message","id":"490a62e9","parentId":"36c15a0b","timestamp":"2026-06-14T10:51:02.630Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I'm seeing that the clone detection found 28 instances but exited with code 0, which means it's under the threshold. Let me verify the output is actually checking my files and then run the full size check to confirm pr.ts still passes the budget, since step 2 handles the budgets.json removal separately.","thinkingSignature":"EuwHCmMIDhgCKkC4POpay1YQwb0LJbmbgIctO3PyKEQmdgLa928iJRM26ZWH9JIx+2eSMFwFnZwBkZR4BfLD3gjlfNPoy3zy1ASvMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDFXCDsrjonX9H5wAHBoMHhbHF1z7Ii4pjgRhIjAl2KYkekX5u4AEse5UE2dLu9RAVOIHBoolzXrFxOPPc1nUIoqW+Ts1NRef+Kdg63oqtgYxWYsBYObwlVXOJRx23uobnTBnOtWoOtgpxOc0ZEKET7YQICreEF1nQyJSwoyLSmUwzguL6l4lAQptbv91n1pQTFGyCY09uNLYiebS32v1mf9oVdyAgWFahS7BIJaqXPntZYb6HCg7b8E1ovUYTd2dgR4Cmti7+uKQMyP0GQ8J+nqo8uZBs+j6UI20cHRFUjyld1EfMGxI03dOTDcDu6LeM1ItpLm7DSoZncy2OnhY+ftv12JuoR/GO5o2IejCMkx8R8aGHeAnFpFe1wkwiAip2koVbktWMqz+dHrL2HDXe4SCvYAxvHl3O9nARRRZJAA0jyDJiAirIhwpG0A9lAWYVQk2Q/Br4dAEthWT6ettXPEl9dBjRzZP7nTV4HS4QA3NKzBR8N+o2uqNoZzGmyQBxN9dRfRXwMZNjCu//HpvSNC1nGm98h0CJtR9WSeCf8DCtvJPhs8ZOzKC5acpQoF7JipmggFo/RISOJ5cq9oSmqecArmHJS4bPXoTdctuKanNKKHZ0zWTGTq2PeID5V8/AK9/RKZb+UR0U34aMdzXjZd4i7BupQTEhQR/Zcy3vxxWYRtuh4SBhgNS7ejZOIBCmDT3g+xcYVDfKBCFwr5M3dCgCeXTC0OZOQL5PoyAFLZJhwo7ueKGcZvRczWJD1lG3ohMW9ccilQzC+HuOJI6fUl2NeGJSgot4GkVncSlFlo1GuiE9A52t7hAsbkT0yHKHfLkBHnzpGu321mMiciolAroG8btdKDOkMkL/X7V1BIL/8PHEn0FdDH7CcluKUUbZuRaxaJtjnRtqE5z+PAa8Vc5ZsIjNgFn3orAgzLItnbU+cpgOdB5/jayrFaQSHr1fGBo1RC5ipjqMxgxjtz6oAg/HS0RdpFph80IwAloK9R0hyl4MmdHXatiD2MvHkTrLrl5moy+n6INq0v5arLt9obhUcYamD4gddUlfPM1AEWATX1ICav82spAEe2oaILtwtSwfewR3LH/buqQAUhkf4EIQw554NxXyMB1zlyfgaMdNMrinfug5Sjwjt0ZemHYlARnvpRpHHX+JWAoRMv+P/oyGWRKKNartpx5aPSeKEJyNyUbo4IYAQ=="},{"type":"toolCall","id":"toolu_01PZuPgpKAkDHqsaGs3mfqaQ","name":"bash","arguments":{"command":"cd /workspace && bun run check:dups 2>&1 | grep -i \"pr-checks\\|runs/pr\" | head"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":372,"cacheRead":31737,"cacheWrite":425,"totalTokens":32536,"cost":{"input":0.00001,"output":0.009300000000000001,"cacheRead":0.0158685,"cacheWrite":0.00265625,"total":0.02783475}},"stopReason":"toolUse","timestamp":1781434247199,"responseId":"msg_01HeeVjAoaKAe2brExdxStCa"}} +{"type":"message","id":"8c3e50f5","parentId":"490a62e9","timestamp":"2026-06-14T10:51:07.829Z","message":{"role":"toolResult","toolCallId":"toolu_01PZuPgpKAkDHqsaGs3mfqaQ","toolName":"bash","content":[{"type":"text","text":" src/runs/pr-annotate.ts [204:37 - 228:2]\n src/runs/pr-annotate.ts [204:37 - 228:2]\n"}],"isError":false,"timestamp":1781434267829}} +{"type":"message","id":"ed6f1a68","parentId":"8c3e50f5","timestamp":"2026-06-14T10:51:14.574Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The duplication check passed without introducing new clones since I'm importing shared helpers rather than duplicating code. Now I'm running the size check to verify the refactor didn't impact the bundle.","thinkingSignature":"Eq4DCmMIDhgCKkDBVFmokEaCx9hFC9lUV3I9oFVZsVGTHM5AeMyw8BLsmLBlV8JrFXtIScIyY3C2Hoc6a17G7NrORS/m6m+Fa2sNMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDGmH2RdJYtTmxYghKhoM1fcoOxyQPTYV+6D+IjC/D9/T09EG4o6PrxF4h9pQyNj5ss+CXZjoe8SR9qtnZOxxaJAWWn1X8ozyEreg86Eq+AFGFShtDe7xzKA7U5oeVD1Na/VXdUXL+KSnUJwrfivQGHYc1I0jXOoNVZkvAi6qbiEi2vX/DGLLE+uubApZKwqNkGACtlD6WO3Zn3Bccy9zy2KQOD5QwB/G0wtP0FdYbFaawC6g59LHwz37PqdKsnBtVgPUw1IfANpfA/22XJ5cF6sk1qnFU8H9714WD7Onsm8XdB/I4aoJnCNA7rBocEIX6dPwY0Ipfvp4NRk0gVCJp/KGXTjoUCv4b3ITVp36qCKdiJv/7PMBECUdYt7pMIFiSpUo5o6U8LZnygNF6PiKtBUewQxTK+VXbqzPWeH6xSBmaLITnxUleRgB"},{"type":"text","text":"No new clones from my split (the helpers are imported, not duplicated). Let me run the size check and commit:"},{"type":"toolCall","id":"toolu_012GouyCf8AxeUepA7kBqJQH","name":"bash","arguments":{"command":"cd /workspace && bun run check:size 2>&1 | tail -8; echo \"SIZE EXIT ${PIPESTATUS[0]}\""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":214,"cacheRead":32162,"cacheWrite":431,"totalTokens":32809,"cost":{"input":0.00001,"output":0.005350000000000001,"cacheRead":0.016080999999999998,"cacheWrite":0.00269375,"total":0.024134749999999996}},"stopReason":"toolUse","timestamp":1781434267831,"responseId":"msg_01HZ13QPHBMNLDBSUpEXtHDz"}} +{"type":"message","id":"93527958","parentId":"ed6f1a68","timestamp":"2026-06-14T10:51:14.648Z","message":{"role":"toolResult","toolCallId":"toolu_012GouyCf8AxeUepA7kBqJQH","toolName":"bash","content":[{"type":"text","text":"$ bun run scripts/check-file-sizes.ts\nFile-size guard ok.\nSIZE EXIT 0\n"}],"isError":false,"timestamp":1781434274648}} +{"type":"message","id":"36c539fa","parentId":"93527958","timestamp":"2026-06-14T10:51:16.471Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_018niWfaEdgdz5r2oeMMrajU","name":"bash","arguments":{"command":"cd /workspace && bun run lint 2>&1 | tail -8; echo \"LINT EXIT ${PIPESTATUS[0]}\""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":94,"cacheRead":32593,"cacheWrite":259,"totalTokens":32948,"cost":{"input":0.00001,"output":0.00235,"cacheRead":0.0162965,"cacheWrite":0.0016187500000000002,"total":0.020275249999999998}},"stopReason":"toolUse","timestamp":1781434274649,"responseId":"msg_0149fYPs77FyDABXjWy1wUyK"}} +{"type":"message","id":"295a95e9","parentId":"36c539fa","timestamp":"2026-06-14T10:51:17.846Z","message":{"role":"toolResult","toolCallId":"toolu_018niWfaEdgdz5r2oeMMrajU","toolName":"bash","content":[{"type":"text","text":"Checked 681 files in 1278ms. No fixes applied.\nFound 2 errors.\ncheck ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n × Some errors were emitted while running checks.\n \n\nerror: script \"lint\" exited with code 1\nLINT EXIT 1\n"}],"isError":false,"timestamp":1781434277846}} +{"type":"message","id":"445e7551","parentId":"295a95e9","timestamp":"2026-06-14T10:51:20.873Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_0186Z7quGbHPUB9ffv6K22X7","name":"bash","arguments":{"command":"cd /workspace && bun run lint 2>&1 | grep -A6 \"×\\|pr-checks\\|pr.ts\" | head -40"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":91,"cacheRead":32852,"cacheWrite":269,"totalTokens":33214,"cost":{"input":0.00001,"output":0.002275,"cacheRead":0.016426,"cacheWrite":0.00168125,"total":0.020392249999999997}},"stopReason":"toolUse","timestamp":1781434277847,"responseId":"msg_015cQrFW1CFT7FG1wraXfHWp"}} +{"type":"message","id":"7549529d","parentId":"445e7551","timestamp":"2026-06-14T10:51:22.278Z","message":{"role":"toolResult","toolCallId":"toolu_0186Z7quGbHPUB9ffv6K22X7","toolName":"bash","content":[{"type":"text","text":"src/runs/pr-checks.ts:236:23 lint/complexity/noExcessiveCognitiveComplexity ━━━━━━━━━━━━━━━━━━━━━━━━\n\n × Excessive complexity of 28 detected (max: 15).\n \n 234 │ \t| { readonly kind: \"http_error\"; readonly status: number; readonly message: string };\n 235 │ \n > 236 │ export async function mergePullRequest(\n │ ^^^^^^^^^^^^^^^^\n 237 │ \tinput: MergePullRequestInput,\n--\nsrc/runs/pr.ts:1:1 assist/source/organizeImports FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n × The imports and exports are not sorted.\n \n > 1 │ import {\n │ ^^^^^^^^\n > 2 │ \tbuildHeaders,\n > 3 │ \tGH_FETCH_OVERRIDE_ENV,\n > 4 │ \tGITHUB_API_BASE,\n--\n > 9 │ } from \"./pr-checks.ts\";\n │ ^^^^^^^^^^^^^^^^^^^^^^^^\n 10 │ import {\n 11 │ \tcomposeBody,\n \n i Safe fix: Organize Imports (Biome)\n \n--\n 29 29 │ } from \"./pr-checks.ts\";\n 30 30 │ \n \n\nChecked 681 files in 1282ms. No fixes applied.\nFound 2 errors.\ncheck ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n--\n × Some errors were emitted while running checks.\n \n\nerror: script \"lint\" exited with code 1\n"}],"isError":false,"timestamp":1781434282277}} +{"type":"message","id":"3154cf77","parentId":"7549529d","timestamp":"2026-06-14T10:51:28.432Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I need to fix two issues: the `mergePullRequest` function in pr-checks.ts is now flagging a complexity of 28, so I need to update the biome.json override that was previously grandfathering it in pr.ts to point to the new location. I also need to run biome to fix the organizeImports issues.","thinkingSignature":"EqwECmMIDhgCKkDQHPS1k+g52Bx1iiQZihtTMdlJNs1ICZA+CPCESqhT2Ujg3KbC5kEZekWL+UQeguX+1qRDHXyJCqY1X7lPXC7hMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDJ/DeKDCx+pjI5lk2BoMimWwS3OqsdJyZN8/IjA+quOi2xp5u63TqKfwRu6KgLs+2OEAAD1SwWH6EWe8ezm7Sqk/dJ0ZYEqjBaqBphcq9gJ+I/3KJk6WC0CBrOMZG3DlbzeQtC9xNU528dx52Ea1xgK6Yzk0i5mGUaNnUulcrIhutelWb/GGQAOj1gjZ6sF1G8XyY95algPKIs7UXHefYDEaIgfu9AzagdX2nBBrsDKyVh2sHhEchXQ4+C4S+dPXoSUTM8FhjVeULwmVYDgBW7iMmDLJVlsbstypcKqgT8VT11XBjCcKwkT7eGWRhbrA1KEFgGiwOsg6DoouzVHhfcavJHatHgtZSCT6/X4wr2MLUIIMRyE337/fQCViu/4Ab6xIctiLvxmJD4CQ5WuQb6a8q6MenyJuGWcwTghmpSOE6QDd/N7a7iW1ULJgr62FWiDEsyo+xjXoN9MY4A4ebOG1lR9zky8KcJw03tadLkyYn4IVaA0sCegYKZ0IzVQpmQceSjPMrSlaNajRjrp4fTH6bPQmXSlG2WD54AajwB8DHGyceTMneG7rS4LwSAPsvSHGl0IPfCSr59Rirsu8vbIyED6bRRgB"},{"type":"toolCall","id":"toolu_01B8DaexeoqinJPj5MGM5tS4","name":"bash","arguments":{"command":"cd /workspace && grep -n \"pr.ts\\|pr-checks\\|mergePullRequest\\|noExcessiveCognitiveComplexity\" biome.json"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":238,"cacheRead":33121,"cacheWrite":769,"totalTokens":34130,"cost":{"input":0.00001,"output":0.00595,"cacheRead":0.0165605,"cacheWrite":0.004806250000000001,"total":0.02732675}},"stopReason":"toolUse","timestamp":1781434282279,"responseId":"msg_01AAfk7UJEHcjj6kKY1rGDE3"}} +{"type":"message","id":"2c6d6c3c","parentId":"3154cf77","timestamp":"2026-06-14T10:51:28.437Z","message":{"role":"toolResult","toolCallId":"toolu_01B8DaexeoqinJPj5MGM5tS4","toolName":"bash","content":[{"type":"text","text":"49:\t\t\t\t\"noExcessiveCognitiveComplexity\": {\n98:\t\t\t\t\"src/runs/pr.ts\",\n123:\t\t\t\t\t\t\"noExcessiveCognitiveComplexity\": \"off\"\n"}],"isError":false,"timestamp":1781434288437}} +{"type":"message","id":"060c2578","parentId":"2c6d6c3c","timestamp":"2026-06-14T10:51:30.243Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01AEuTs3waoLYUTwjZFhW9ey","name":"read","arguments":{"path":"/workspace/biome.json","offset":90,"limit":40}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":83,"cacheRead":33890,"cacheWrite":305,"totalTokens":34280,"cost":{"input":0.00001,"output":0.002075,"cacheRead":0.016944999999999998,"cacheWrite":0.0019062500000000002,"total":0.02093625}},"stopReason":"toolUse","timestamp":1781434288438,"responseId":"msg_01RGGLaoYEfCpPFPMmKsZ66W"}} +{"type":"message","id":"edececfa","parentId":"060c2578","timestamp":"2026-06-14T10:51:30.251Z","message":{"role":"toolResult","toolCallId":"toolu_01AEuTs3waoLYUTwjZFhW9ey","toolName":"read","content":[{"type":"text","text":"\t\t\t\t\"src/preview/proxy/rewrite.ts\",\n\t\t\t\t\"src/projects/refresh.ts\",\n\t\t\t\t\"src/runs/burrow-config.ts\",\n\t\t\t\t\"src/runs/cancel.ts\",\n\t\t\t\t\"src/runs/cost-analytics.ts\",\n\t\t\t\t\"src/runs/interactive.ts\",\n\t\t\t\t\"src/runs/pause.ts\",\n\t\t\t\t\"src/runs/pr-template.ts\",\n\t\t\t\t\"src/runs/pr.ts\",\n\t\t\t\t\"src/runs/reap/mulch.ts\",\n\t\t\t\t\"src/runs/reap/plot-merge.ts\",\n\t\t\t\t\"src/runs/reap/run.ts\",\n\t\t\t\t\"src/runs/reap/seeds.ts\",\n\t\t\t\t\"src/runs/seed.ts\",\n\t\t\t\t\"src/runs/spawn/dispatch.ts\",\n\t\t\t\t\"src/runs/stream/bridge.ts\",\n\t\t\t\t\"src/runs/usage-aggregate.ts\",\n\t\t\t\t\"src/server-config/workers.ts\",\n\t\t\t\t\"src/server/bridge-reconnect.ts\",\n\t\t\t\t\"src/server/bridges.ts\",\n\t\t\t\t\"src/server/errors.ts\",\n\t\t\t\t\"src/server/handlers/plan-runs.ts\",\n\t\t\t\t\"src/server/handlers/plot-plan-runs.ts\",\n\t\t\t\t\"src/server/handlers/projects.ts\",\n\t\t\t\t\"src/server/handlers/runs.ts\",\n\t\t\t\t\"src/server/integration.multi-worker.test.ts\",\n\t\t\t\t\"src/server/main/index.ts\",\n\t\t\t\t\"src/server/server.ts\",\n\t\t\t\t\"src/ui-tests/plot-detail-run-plan-dialog.test.ts\"\n\t\t\t],\n\t\t\t\"linter\": {\n\t\t\t\t\"rules\": {\n\t\t\t\t\t\"complexity\": {\n\t\t\t\t\t\t\"noExcessiveCognitiveComplexity\": \"off\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"includes\": [\"scripts/check-all.ts\", \"scripts/check-ci-parity.ts\"],\n\n[7 more lines in file. Use offset=130 to continue.]"}],"isError":false,"timestamp":1781434290251}} +{"type":"message","id":"1a902b1b","parentId":"edececfa","timestamp":"2026-06-14T10:51:37.156Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The complexity ratchet only allows metrics to decrease, but since I'm moving `mergePullRequest` to a new file, I need to add `pr-checks.ts` to the grandfathered list to keep the build passing—this is a legitimate exception since the function was already grandfathered in its original location.","thinkingSignature":"EsUICmMIDhgCKkBD7rq2RIQpuxqL4Fng9SobfXe+glA6ipl07vc4OMwd6JT8Ejq2WpzI3eBPcw1qO6r1UqvBmkoSbtFV0Kqk9ljMMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDL+qxRhwpwaJ+RbzVxoMgA60iVLnJpiPtqBeIjDmS6VZofDI+xJizkbWsaz3JRqIJhI2QGJhaKJnZCGU7pOr5KvodrNKBTVJco3sP7QqjwdIrMq+MpZuJSH+sGv6e0Xhta7fm/3mh49bHvPbTt4i+L9ISOkjv5u5nuNLk/yQvbwKbyKPJCEiNWNaKQDa/4uxwEuW3uF70bsVgnWPsswLcWszTS2VHHQzxncaU1P/zdaz733wKBGjglqSNvKrw2YYvEAlgNHfqsV4bX15uFxvsuCXRSyGsUtCLspAKhCwxgtzkW2axjK8IJA5q/n7e7XbS3iI/b3aYDoR9spy7a+yWpQQ88oIwn5rpQpEqGx4LqgHf5J3u4VXHw0xgP/rnAlEwx39Ngmmsb0NiqNPab1T/fJOHQ0A1wM880hcS4EXLMNUwnPfGepyB3DjrQ7/Oy6cEHxwxlNdN1BLrzZR5ZABHvMaaBSTIDQHa6kv3rI1z3/sg6AK6betHksnaBdaxIHNttMnlLDe4kDffPrdL2YXDvPWQ5A2gPUSpYXShvXCKdTM5ZDZBevlL8/N+LOjeAtIaxOsqNHvC3OfOhVYrHlc4ZAHCO5Y8HzS0HrrnkFHt8bIUB5puFdrQmoIOU2ubhassMuFeB/ZolbfO0pPQ7iRh9YbkTfmhlIoLTHuS61ELMz7ZBm9NayU2agdVSNx7UYvMFGYgpREHWywREu82ffPjjBroWESLiSIQIXPauyHXAPopFo5WNGe3cCXN8E2XlDZC0MJU1v3dPysG68CWq2mg5SSiCqZp0PuNjkZ1qK5X53eSGIEvBq9I0VDEPVG6nBx9QqolmNQlqKDnXlEj9qfkRFVlhZoEUKMwTbPCistoUXXSWG47zXnwTGGsJnQH96FqNa2WFY4XAQl0MogGDynD9F6WVIANb4O1/4AC2+eCvby5eatE9v72P/troPWB/G5HgdT021Y7kl4+VZn3pmnju+GXyFzzTa/vZ5VxxFNOqEPDMaHuQel+r+7jhWEQBLfInlEitlokTgWxj3T/lIgxkGfvmQ9oIGVyK/Ow7JPDdcEkqDhnFM0fE3niKPr5gEn7GzUSLjrrzCJbBVhKOiCXKHAuJOBAxC0AoiUHLQYpyY7EMD+zM6hiVgmyL/zReTHLy41e7TXDgGdXWMJSV6fPDF18tM1URYYNWA2WEwBPUjdFxnVyZn8ImLqjwZizC/C8rTm5L9Ghcg4W6BsnMIvsQ1x/XTmPgBXwq7UhzUWcZVfxqSKYe5ccvNRMNG989x0CyuIY16OArU3fi844QnvzUJ8btpZFuOXulQH0EHrrxgB"},{"type":"toolCall","id":"toolu_01TkUM7tub3ReuW5CPu3scTK","name":"bash","arguments":{"command":"cd /workspace && bunx biome check --write src/runs/pr.ts 2>&1 | tail -3"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":407,"cacheRead":34195,"cacheWrite":724,"totalTokens":35328,"cost":{"input":0.00001,"output":0.010175,"cacheRead":0.017097499999999998,"cacheWrite":0.004525,"total":0.031807499999999995}},"stopReason":"toolUse","timestamp":1781434290252,"responseId":"msg_01DXLytJbLsa9YzSNCLGTKzd"}} +{"type":"message","id":"ce859d57","parentId":"1a902b1b","timestamp":"2026-06-14T10:51:37.277Z","message":{"role":"toolResult","toolCallId":"toolu_01TkUM7tub3ReuW5CPu3scTK","toolName":"bash","content":[{"type":"text","text":"Checked 1 file in 30ms. Fixed 1 file.\n"}],"isError":false,"timestamp":1781434297277}} +{"type":"message","id":"93344e98","parentId":"ce859d57","timestamp":"2026-06-14T10:51:40.736Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01Y8BLqJh86p2Y6pvDiUc897","name":"edit","arguments":{"path":"/workspace/biome.json","edits":[{"oldText":"\t\t\t\t\"src/runs/pr-template.ts\",\n\t\t\t\t\"src/runs/pr.ts\",","newText":"\t\t\t\t\"src/runs/pr-checks.ts\",\n\t\t\t\t\"src/runs/pr-template.ts\",\n\t\t\t\t\"src/runs/pr.ts\","}]}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":215,"cacheRead":34919,"cacheWrite":435,"totalTokens":35571,"cost":{"input":0.00001,"output":0.0053750000000000004,"cacheRead":0.0174595,"cacheWrite":0.0027187500000000002,"total":0.02556325}},"stopReason":"toolUse","timestamp":1781434297278,"responseId":"msg_01XDsJ7JqpmdV5VoKvTKk2bY"}} +{"type":"message","id":"2bc261ae","parentId":"93344e98","timestamp":"2026-06-14T10:51:40.739Z","message":{"role":"toolResult","toolCallId":"toolu_01Y8BLqJh86p2Y6pvDiUc897","toolName":"edit","content":[{"type":"text","text":"Successfully replaced 1 block(s) in /workspace/biome.json."}],"details":{"diff":" ...\n 93 \t\t\t\t\"src/runs/cancel.ts\",\n 94 \t\t\t\t\"src/runs/cost-analytics.ts\",\n 95 \t\t\t\t\"src/runs/interactive.ts\",\n 96 \t\t\t\t\"src/runs/pause.ts\",\n+ 97 \t\t\t\t\"src/runs/pr-checks.ts\",\n 97 \t\t\t\t\"src/runs/pr-template.ts\",\n 98 \t\t\t\t\"src/runs/pr.ts\",\n 99 \t\t\t\t\"src/runs/reap/mulch.ts\",\n 100 \t\t\t\t\"src/runs/reap/plot-merge.ts\",\n ...","patch":"--- /workspace/biome.json\n+++ /workspace/biome.json\n@@ -93,8 +93,9 @@\n \t\t\t\t\"src/runs/cancel.ts\",\n \t\t\t\t\"src/runs/cost-analytics.ts\",\n \t\t\t\t\"src/runs/interactive.ts\",\n \t\t\t\t\"src/runs/pause.ts\",\n+\t\t\t\t\"src/runs/pr-checks.ts\",\n \t\t\t\t\"src/runs/pr-template.ts\",\n \t\t\t\t\"src/runs/pr.ts\",\n \t\t\t\t\"src/runs/reap/mulch.ts\",\n \t\t\t\t\"src/runs/reap/plot-merge.ts\",\n","firstChangedLine":97},"isError":false,"timestamp":1781434300739}} +{"type":"message","id":"9ea2ade1","parentId":"2bc261ae","timestamp":"2026-06-14T10:51:42.325Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01R3XqfavuhTsG6B4eaKJa7p","name":"bash","arguments":{"command":"cd /workspace && bun run lint 2>&1 | tail -4; echo \"LINT EXIT ${PIPESTATUS[0]}\""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":94,"cacheRead":35354,"cacheWrite":244,"totalTokens":35694,"cost":{"input":0.00001,"output":0.00235,"cacheRead":0.017677,"cacheWrite":0.001525,"total":0.021561999999999998}},"stopReason":"toolUse","timestamp":1781434300741,"responseId":"msg_01FcKSCwoWKWzFnrnFnEHeTB"}} +{"type":"message","id":"523af1e8","parentId":"9ea2ade1","timestamp":"2026-06-14T10:51:43.705Z","message":{"role":"toolResult","toolCallId":"toolu_01R3XqfavuhTsG6B4eaKJa7p","toolName":"bash","content":[{"type":"text","text":"$ biome check --error-on-warnings .\nChecked 681 files in 1277ms. No fixes applied.\nLINT EXIT 0\n"}],"isError":false,"timestamp":1781434303705}} +{"type":"message","id":"1c555020","parentId":"523af1e8","timestamp":"2026-06-14T10:51:50.138Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I should leave pr.ts in the complexity list since openPullRequest is genuinely complex, and I don't want to over-engineer this. Let me verify the requirements are met: the file is under 500 lines (361 ✓) and I need to confirm the typecheck passes after the biome reordering, then run the pr tests and check dependent files.","thinkingSignature":"EtkFCmMIDhgCKkA/2/wmxIi0b6ypo3B/tfk01hhKU7HXCp29nl7w3V/kV2uQirP/L31e8/v0mrWFudE4BYpiGVlp7Wxi2NlpmeBRMg9jbGF1ZGUtb3B1cy00LTg4AEIIdGhpbmtpbmcSDI47YtJop8+D/Rw1LxoMZKSG6uGNbuwAA+DmIjC8Qbp7ZvSegEucfX/B1Uk6xhRAyl/cSplN4laXPFOGOXC/Y+BcmudtX4BYoy8+puUqowRN7Ge5rRsaZ4L5aAtoZdxogl0vk+i3sYue8ROvDNutBe3dhNFtLBD+3WFxGsQrrT9xhIH0XNmD9lSfD5MQf5r3FCqWcFJYpLgxXX4l+S70+5VwmRxfBaBeXXkw0wTF2iAOlpwLNO2zuRSk0Gxk68DKjBuOYO46HacXanckBtbrPH8T0jnqEUVyudhP3/bzX8mBE7boGPzbJNmFSvxAWrE/KpNWa5rMfQwDkbVi70znkbShueFyMyCZ29eBn7wJP+5njZMjca7weYU7brHeda/1pkeXr3kiLXiTYiaktEsEIG8sKqBl/TRPWE3PGgzW9Oa3fZPs7onoVG677ULMIDa1s1EXhd3OgMSAhIp71L0Bwoz3QPIGBAPz3yOJCYDC5T4Fhq7/dn40dJ3xiC6r8scQ/knYpKmyxW74axw/wzF+P5CACHg4Uaxij27WBfQ5ENBgZAIhO1eI7jMdus4H+k27KqM9v/2u2YMREmPohy9u3ZkWzVUbu8jOolNbAsQrnF6T2146UJcfE6LoQiOCDus4kzdNA+hrZhb/eTOZAj9I8zGNVreH29R5WBSaEMILbGVxvABYtUw+QyUEXo5vq390oGy6+Fqcj+sD//wbjFxtyX/2hIHJfEvUtCCpKCXKJ57KJ50dfqsrOCzL6b3+ZflgP614I+E2Blme7Ovl+d1AU1NeSUqTdwoevl63R992sqzAHB2OEsOoyfhuBCUv9dZUZlqHGAE="},{"type":"toolCall","id":"toolu_01LC24gixnBZvrhAYEUYjt2s","name":"bash","arguments":{"command":"cd /workspace && bun run typecheck 2>&1 | tail -3; echo \"TC ${PIPESTATUS[0]}\"; bun test src/runs/pr.test.ts src/plots/pr-merger.test.ts src/plan-runs/pr-merge.test.ts 2>&1 | tail -4"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":342,"cacheRead":35598,"cacheWrite":146,"totalTokens":36088,"cost":{"input":0.00001,"output":0.00855,"cacheRead":0.017799,"cacheWrite":0.0009125,"total":0.0272715}},"stopReason":"toolUse","timestamp":1781434303707,"responseId":"msg_01ELFw4LS85WTPBaVcuUCAae"}} +{"type":"message","id":"40f45ac2","parentId":"1c555020","timestamp":"2026-06-14T10:52:03.239Z","message":{"role":"toolResult","toolCallId":"toolu_01LC24gixnBZvrhAYEUYjt2s","toolName":"bash","content":[{"type":"text","text":"$ tsc --noEmit\nTC 0\n 59 pass\n 0 fail\n 157 expect() calls\nRan 59 tests across 3 files. [173.00ms]\n"}],"isError":false,"timestamp":1781434323239}} +{"type":"message","id":"80a86f4e","parentId":"40f45ac2","timestamp":"2026-06-14T10:52:05.293Z","message":{"role":"assistant","content":[{"type":"text","text":"All green. Let me verify the head of pr.ts looks clean and commit:"},{"type":"toolCall","id":"toolu_01WZ3BxfA1V9VQe8xauyDwGj","name":"bash","arguments":{"command":"cd /workspace && head -30 src/runs/pr.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":91,"cacheRead":35744,"cacheWrite":406,"totalTokens":36243,"cost":{"input":0.00001,"output":0.002275,"cacheRead":0.017872,"cacheWrite":0.0025375000000000003,"total":0.0226945}},"stopReason":"toolUse","timestamp":1781434323241,"responseId":"msg_01NDjkpnhguQ1p22RKqAucsc"}} +{"type":"message","id":"f1a11ff6","parentId":"80a86f4e","timestamp":"2026-06-14T10:52:05.298Z","message":{"role":"toolResult","toolCallId":"toolu_01WZ3BxfA1V9VQe8xauyDwGj","toolName":"bash","content":[{"type":"text","text":"import {\n\tbuildHeaders,\n\tGH_FETCH_OVERRIDE_ENV,\n\tGITHUB_API_BASE,\n\treadGhFetchOverride,\n\treadJson,\n\treadText,\n\ttruncate,\n} from \"./pr-checks.ts\";\nimport {\n\tcomposeBody,\n\tcomposeTitle,\n\ttype PrFragmentContext,\n\ttype PrTemplateOverrides,\n} from \"./pr-template.ts\";\n\nexport {\n\ttype CheckPrMergedResult,\n\ttype CheckPullRequestMergedInput,\n\tcheckPullRequestMerged,\n\tisRateLimited,\n\ttype MergePullRequestInput,\n\ttype MergePullRequestResult,\n\tmergePullRequest,\n\tPR_SHORT_RE,\n\tPR_URL_RE,\n\tparsePullRequestRef,\n\tparsePullRequestUrl,\n} from \"./pr-checks.ts\";\n\n"}],"isError":false,"timestamp":1781434325298}} +{"type":"message","id":"df8ece7b","parentId":"f1a11ff6","timestamp":"2026-06-14T10:52:11.372Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01RUkpC8fELC8EfMfHF2y7ny","name":"bash","arguments":{"command":"cd /workspace && git add -A && git commit -q -m \"refactor(runs): split PR-merge/URL-parse group into pr-checks.ts\n\nMove checkPullRequestMerged, mergePullRequest, parsePullRequestUrl,\nparsePullRequestRef, isRateLimited and their exported types/regexes out\nof src/runs/pr.ts into a new src/runs/pr-checks.ts. Shared GitHub REST\nhelpers (buildHeaders/readJson/readText/truncate/readGhFetchOverride/\nGITHUB_API_BASE/GH_FETCH_OVERRIDE_ENV) live in pr-checks.ts and are\nimported by pr.ts (one copy, no duplication). pr.ts re-exports every\nmoved public symbol so the ../runs/pr.ts import path still resolves.\n\npr.ts drops from 659 to 361 lines. mergePullRequest's grandfathered\ncomplexity override follows it to pr-checks.ts in biome.json.\n\nRefs: warren-db9a (pl-88bb step 1)\" && git log --oneline -1"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":412,"cacheRead":36150,"cacheWrite":399,"totalTokens":36963,"cost":{"input":0.00001,"output":0.0103,"cacheRead":0.018075,"cacheWrite":0.0024937500000000003,"total":0.03087875}},"stopReason":"toolUse","timestamp":1781434325299,"responseId":"msg_0176Xzwqj5sNZDjMYTCDASGm"}} diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index 849b362a..e557a15c 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -727,7 +727,7 @@ {"id":"warren-5755","title":"Fix unbounded /workspaces growth: bound the conversation card's ancestor height in src/ui/src/pages/conversation-detail/conversation-surface.tsx (min-h-[60vh] → a bounded height) so Chat.tsx's existing internal flex-1 min-h-0 overflow-y-auto engages and the transcript scrolls internally instead of growing the page; no new scroll container","status":"open","type":"bug","priority":2,"plan_step_index":1,"description":"\nStep 2 of plan pl-2e59.\n\nParent seed: warren-faab — Cleanup batch → 0.9.1: logging hardening (pl-f700) + workspace chat fixes + leaked transcript removal + version bump\nPlan template: feature\nPlan approach: Walk the net-new tail serially — one seed per PR, each gated on the prior PR merging — mirroring the polish-pass convention and the Plot's stated constraint. Order: chat ordering/dedup fix → chat unbounded-growth fix → leaked-transcript…\n\nRun `sd plan show pl-2e59` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-14T05:54:42.639Z","updatedAt":"2026-06-14T05:54:42.639Z","plan_id":"pl-2e59","blockedBy":["warren-f4b8"],"blocks":["warren-4c8d","warren-faab"]} {"id":"warren-fade","title":"Bump version to 0.9.1: set package.json \"version\" and src/index.ts VERSION to 0.9.1 in sync (release workflow fails on drift) and add a matching CHANGELOG.md 0.9.1 section summarizing the logging hardening (pl-f700), workspace chat fixes, and leaked-transcript removal; land last, after every pl-f700 logging PR plus the chat + transcript PRs merge","status":"open","type":"task","priority":1,"plan_step_index":3,"description":"\nStep 4 of plan pl-2e59.\n\nParent seed: warren-faab — Cleanup batch → 0.9.1: logging hardening (pl-f700) + workspace chat fixes + leaked transcript removal + version bump\nPlan template: feature\nPlan approach: Walk the net-new tail serially — one seed per PR, each gated on the prior PR merging — mirroring the polish-pass convention and the Plot's stated constraint. Order: chat ordering/dedup fix → chat unbounded-growth fix → leaked-transcript…\n\nRun `sd plan show pl-2e59` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-14T05:54:42.639Z","updatedAt":"2026-06-14T05:54:42.639Z","plan_id":"pl-2e59","blockedBy":["warren-4c8d"],"blocks":["warren-faab"]} {"id":"warren-d426","title":"gatewatch: 4 Article IX-protected merges in window lack human sign-off record","status":"open","type":"task","priority":1,"createdAt":"2026-06-14T10:15:36.224Z","updatedAt":"2026-06-14T10:15:36.224Z","description":"Constitution Article IX requires that any merged change to docs/CONSTITUTION.md, the gatewatch/ratchetwatch/tastewatch entries in .canopy/, or audit entries in .warren/triggers.yaml carry EXPLICIT human review, recorded in the seed tracker (the warren-ea20 ratification-record pattern: 'explicitly human-reviewed and human-merged by the operator... This seed is the sign-off evidence gatewatch's Article IX check looks for'). Four PRs merged in the last 36h touch protected paths and NONE has a corresponding ratification/sign-off seed. The auto-merge.yml Article IX gate (intact, untouched in window) would have BLOCKED auto-merge on these, implying a human merge occurred — but with no recorded ratification, gatewatch cannot verify the gate was honest. Per gatewatch mandate item 6, file priority 1.\n\nEVIDENCE (each modifies its own mandate / the constitution):\n- SHA 7d3cf95e (PR #364): docs/CONSTITUTION.md — adds the 'Audit Warden boundary' section (ingestion rule, digest cadence, meta-Plot, autonomy-promotion constraints). Closes warren-05ef (a WORK seed, not a sign-off record).\n- SHA e2d88409 (PR #362): .warren/triggers.yaml — adds the 'warden-digest' cron audit entry (0 5 * * 0). The constitution explicitly names warden-digest as Article IX-protected. Closes warren-4240 (work seed).\n- SHA f8ead2ff (PR #361): .canopy/prompts.jsonl — bumps gatewatch (v2), ratchetwatch (v2), tastewatch (v3) auditor system prompts to add warden-delivery instructions; ALSO edits the three audit prompt entries in .warren/triggers.yaml. This is the audit population editing its own running mandate (including this very gatewatch prompt). Work seed warren-7f62, not a ratification record.\n- SHA 5f41fef5 (PR #338): .canopy/prompts.jsonl — repins the tastewatch agent model claude-fable-5 -> claude-opus-4-8 (tastewatch entry v1 -> v2). Modifies a protected auditor .canopy entry. No ratification record. (Same PR also adds a biome.json formatter-exempt override carrying tracker pl-cf2a — Article II satisfied there; the Article IX gap is the .canopy auditor change.)\n\nWHAT ARTICLE IX REQUIRES: a recorded human sign-off (warren-ea20-style) per protected-path merge, OR revert. The gate forcing human-merge is necessary but not sufficient — the constitution treats the recorded ratification seed as the audit evidence.\n\nREMEDIATION IS NON-MECHANICAL (no plan attached): the fix is a human decision — file a ratification record for each PR (#364, #362, #361, #338) attesting the operator reviewed and merged it, or revert. A mechanical plan-run cannot self-grant this, and any plan touching these protected paths would itself re-trigger Article IX. Routed to the warden for human triage.\n\nDedupe: not covered by warren-d5e5 (PR #270), warren-4c8d (PR #340), or warren-ea20 (PR #337 only). warden delivery: undeliverable this patrol (WARREN_API_TOKEN unset -> /conversations 401); seed is the durable record.","labels":["audit","gatewatch"]} -{"id":"warren-60ee","title":"ratchetwatch tightening: 2026-06-14","status":"open","type":"task","priority":3,"createdAt":"2026-06-14T10:45:24.898Z","updatedAt":"2026-06-14T10:46:18.235Z","description":"Mechanical ratchet tightening for the 2026-06-14 patrol (Constitution Article II — ratchets only tighten).\n\nMeasurements (clean env; node_modules repaired via bun install --frozen-lockfile):\n- Coverage: functions 88.90% vs floor 88.60% (slack 0.30pt); lines 91.65% vs floor 91.54% (slack 0.11pt). Both < 0.75pt → no floor raise this patrol.\n- File-size: no grandfathered entry has dropped below the 500-line global limit (smallest = src/plots/aggregate.ts at 506), so no satisfied entries to remove.\n- Bundle: 7-day net gzip-js -221 B (re-baseline-down in #384); raises sum ~1.9 KB << 20 KB → no finding.\n- Debt allowlist: empty → no finding.\n\nThe single mechanical tightening this patrol is the one-file-per-patrol grandfather decomposition: src/runs/pr.ts (659 lines, furthest over the 500 limit, not covered by any open seed). See the attached refactor plan. Per Article III no release step is included — this hygiene batches into the next real release.","labels":["audit","ratchetwatch"],"plan_id":"pl-88bb","blockedBy":["warren-db9a","warren-70d7"]} +{"id":"warren-60ee","title":"ratchetwatch tightening: 2026-06-14","status":"open","type":"task","priority":3,"createdAt":"2026-06-14T10:45:24.898Z","updatedAt":"2026-06-14T10:53:27.319Z","description":"Mechanical ratchet tightening for the 2026-06-14 patrol (Constitution Article II — ratchets only tighten).\n\nMeasurements (clean env; node_modules repaired via bun install --frozen-lockfile):\n- Coverage: functions 88.90% vs floor 88.60% (slack 0.30pt); lines 91.65% vs floor 91.54% (slack 0.11pt). Both < 0.75pt → no floor raise this patrol.\n- File-size: no grandfathered entry has dropped below the 500-line global limit (smallest = src/plots/aggregate.ts at 506), so no satisfied entries to remove.\n- Bundle: 7-day net gzip-js -221 B (re-baseline-down in #384); raises sum ~1.9 KB << 20 KB → no finding.\n- Debt allowlist: empty → no finding.\n\nThe single mechanical tightening this patrol is the one-file-per-patrol grandfather decomposition: src/runs/pr.ts (659 lines, furthest over the 500 limit, not covered by any open seed). See the attached refactor plan. Per Article III no release step is included — this hygiene batches into the next real release.","labels":["audit","ratchetwatch"],"plan_id":"pl-88bb","blockedBy":["warren-70d7"]} {"id":"warren-889a","title":"Grandfather-at-birth: src/runs/stream/bridge.test.ts pushed to 521 lines and exempted in be18ba73 (within 24h)","status":"open","type":"task","priority":3,"createdAt":"2026-06-14T10:45:36.305Z","updatedAt":"2026-06-14T10:45:36.305Z","description":"Constitution Article II finding (ratchets only tighten / nothing grandfathered at birth).\n\nEvidence (Article VIII):\n- File: src/runs/stream/bridge.test.ts — current 521 lines (limit 500).\n- Budget entry \"src/runs/stream/bridge.test.ts\": 521 ADDED in commit be18ba73e20fd4f3beee5cdb7e0563ef6b90cb6a (Jaymin West, 2026-06-13 11:51:08 -0700 = 18:51 UTC; tracker warren-df71, 'feat(runs): keep conversation runs alive...'), i.e. within the trailing 24h of this 2026-06-14 10:42 UTC patrol.\n- The file pre-existed (created 2026-05-27 in 22d16f5d) and was previously UNDER budget; warren-df71 grew it past 500 and added a fresh grandfather exception in the same diff rather than keeping the test under the limit (Article II: 'a new file written over the size limit is decomposed before merge, not exempted at write time' — same spirit applies to pushing a compliant file over the line).\n\nSeverity is low: only 21 lines over. Remedy is a small split/trim of bridge.test.ts back under 500 and removal of its budget entry — NOT planned by ratchetwatch this patrol because the one-decomposition-per-patrol slot went to the furthest-over file (src/runs/pr.ts, 659). Filed for human/gatewatch attention.\n\nDedupe: no existing seed matches 'bridge.test' or 'df71' (searched). Coordinate with gatewatch on be18ba73/warren-df71 rather than double-filing.","labels":["audit","ratchetwatch"]} -{"id":"warren-db9a","title":"Create src/runs/pr-checks.ts and move the PR-merge/URL-parse group into it. Move checkPullRequestMerged, mergePullRequest, parsePullRequestUrl, parsePullRequestRef, isRateLimited and their associated exported types/regexes (CheckPullRequestMergedInput, CheckPrMergedResult, MergePullRequestInput, MergePullRequestResult, PR_URL_RE, PR_SHORT_RE) out of src/runs/pr.ts into the new src/runs/pr-checks.ts, carrying any private helpers they need (buildHeaders/readJson/readText/truncate may need to be shared — if so, keep one copy and import it, do NOT duplicate, or jscpd check:dups will flag it). Re-export every moved PUBLIC symbol from src/runs/pr.ts (export { ... } from \"./pr-checks.ts\") so the path src/runs/pr.ts still resolves all of them. Verify: `wc -l src/runs/pr.ts` shows < 500 AND `bun run typecheck` is clean AND `bun test src/runs/pr.test.ts` is green with the same test count as before the split.","status":"open","type":"task","priority":2,"plan_step_index":0,"description":"\nStep 1 of plan pl-88bb.\n\nParent seed: warren-60ee — ratchetwatch tightening: 2026-06-14\nPlan template: refactor\nPlan approach: Split along the existing seam: move the PR-merge / URL-parsing group (checkPullRequestMerged, parsePullRequestUrl, parsePullRequestRef, mergePullRequest, isRateLimited, plus their…\n\nRun `sd plan show pl-88bb` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-14T10:46:18.235Z","updatedAt":"2026-06-14T10:46:18.235Z","labels":["ratchetwatch"],"plan_id":"pl-88bb","blocks":["warren-60ee"]} +{"id":"warren-db9a","title":"Create src/runs/pr-checks.ts and move the PR-merge/URL-parse group into it. Move checkPullRequestMerged, mergePullRequest, parsePullRequestUrl, parsePullRequestRef, isRateLimited and their associated exported types/regexes (CheckPullRequestMergedInput, CheckPrMergedResult, MergePullRequestInput, MergePullRequestResult, PR_URL_RE, PR_SHORT_RE) out of src/runs/pr.ts into the new src/runs/pr-checks.ts, carrying any private helpers they need (buildHeaders/readJson/readText/truncate may need to be shared — if so, keep one copy and import it, do NOT duplicate, or jscpd check:dups will flag it). Re-export every moved PUBLIC symbol from src/runs/pr.ts (export { ... } from \"./pr-checks.ts\") so the path src/runs/pr.ts still resolves all of them. Verify: `wc -l src/runs/pr.ts` shows < 500 AND `bun run typecheck` is clean AND `bun test src/runs/pr.test.ts` is green with the same test count as before the split.","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"\nStep 1 of plan pl-88bb.\n\nParent seed: warren-60ee — ratchetwatch tightening: 2026-06-14\nPlan template: refactor\nPlan approach: Split along the existing seam: move the PR-merge / URL-parsing group (checkPullRequestMerged, parsePullRequestUrl, parsePullRequestRef, mergePullRequest, isRateLimited, plus their…\n\nRun `sd plan show pl-88bb` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-14T10:46:18.235Z","updatedAt":"2026-06-14T10:53:27.319Z","labels":["ratchetwatch"],"plan_id":"pl-88bb","blocks":["warren-60ee"],"extensions":{"role":"pi","lastRunId":"run_etn8ndfywq3k","lastRunAt":"2026-06-14T10:48:41.120Z"},"closedAt":"2026-06-14T10:53:27.319Z"} {"id":"warren-70d7","title":"Remove the \"src/runs/pr.ts\": 659 entry from scripts/file-size-budgets.json (delete that one line). Do NOT add a budget entry for src/runs/pr-checks.ts — it must default-pass under the 500 threshold (confirm `wc -l src/runs/pr-checks.ts` < 500). Per Constitution Article VI, before declaring done run a repo-wide search for any reference to the old single-file assumption and the new path across ALL file types — `rg -n \"runs/pr\\\\.ts|runs/pr-checks\" --hidden -g '!node_modules'` plus an explicit sweep of Dockerfile, docker-compose.yml, .github/workflows/*.yml, src/supervisor/ spawn/config strings, and docs/ — and fix any stale reference (file moves have broken production here before; encode the check, do not assume it). Verify: `bun run check:size` exits 0 AND `bun run check:dups` exits 0 AND `bun run check:all` is fully green (every gate stays green, the raised-tightness budget still passes against current actuals).","status":"open","type":"task","priority":2,"plan_step_index":1,"description":"\nStep 2 of plan pl-88bb.\n\nParent seed: warren-60ee — ratchetwatch tightening: 2026-06-14\nPlan template: refactor\nPlan approach: Split along the existing seam: move the PR-merge / URL-parsing group (checkPullRequestMerged, parsePullRequestUrl, parsePullRequestRef, mergePullRequest, isRateLimited, plus their…\n\nRun `sd plan show pl-88bb` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-14T10:46:18.235Z","updatedAt":"2026-06-14T10:46:18.235Z","labels":["ratchetwatch"],"plan_id":"pl-88bb","blocks":["warren-60ee"]} diff --git a/biome.json b/biome.json index 4fb5748d..ca2d8a12 100644 --- a/biome.json +++ b/biome.json @@ -94,6 +94,7 @@ "src/runs/cost-analytics.ts", "src/runs/interactive.ts", "src/runs/pause.ts", + "src/runs/pr-checks.ts", "src/runs/pr-template.ts", "src/runs/pr.ts", "src/runs/reap/mulch.ts", diff --git a/src/runs/pr-checks.ts b/src/runs/pr-checks.ts new file mode 100644 index 00000000..8310db12 --- /dev/null +++ b/src/runs/pr-checks.ts @@ -0,0 +1,331 @@ +/** + * `src/runs/pr-checks.ts` — the PR-merge / URL-parse group split out of + * `src/runs/pr.ts` (warren-db9a / pl-88bb step 1) to keep both files under + * the per-file line budget. Houses `checkPullRequestMerged`, + * `mergePullRequest`, `parsePullRequestUrl`, `parsePullRequestRef`, + * `isRateLimited`, and the shared GitHub REST helpers + * (`buildHeaders`/`readJson`/`readText`/`truncate`) that both this module + * and `pr.ts` use. `pr.ts` re-exports the public symbols so existing + * `../runs/pr.ts` import paths keep resolving. + */ + +export const GITHUB_API_BASE = "https://api.github.com"; +const USER_AGENT = "warren-reap-pr-open"; + +/** + * Acceptance test seam (warren-ae00 / scenario 26). When + * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits + * to a canned positive response — `openPullRequest` returns a synthetic + * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately. + * Lets the in-proc plan-run roundtrip exercise reap's PR open + the + * coordinator's pr_open → merged transition without standing up a real + * GitHub fixture. Unset in production deployments. + */ +export const GH_FETCH_OVERRIDE_ENV = "WARREN_GH_FETCH_OVERRIDE"; + +export function readGhFetchOverride(): "merged" | null { + const v = process.env[GH_FETCH_OVERRIDE_ENV]; + if (typeof v !== "string") return null; + return v.trim() === "merged" ? "merged" : null; +} + +export function buildHeaders(token: string): Record { + return { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "user-agent": USER_AGENT, + "x-github-api-version": "2022-11-28", + }; +} + +export async function readJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return null; + } +} + +export async function readText(res: Response): Promise { + try { + return await res.text(); + } catch { + return ""; + } +} + +export function truncate(input: string, max: number): string { + return input.length <= max ? input : `${input.slice(0, max)}…`; +} + +/* ----------------------------------------------------------------------- */ +/* PR-merge polling */ +/* ----------------------------------------------------------------------- */ + +/** + * `checkPullRequestMerged` — poll a GitHub PR's merge state for the PlanRun + * coordinator (warren-9e4c). Pure helper: the caller decides what each + * non-merged shape means (`open` = wait, `closed_unmerged` = fail the plan). + * + * Mirrors `openPullRequest`'s posture: direct REST call against + * `GET /repos/:owner/:repo/pulls/:number`, `Authorization: Bearer ` + * from `GITHUB_TOKEN`, fetch injected as a seam. + */ +export interface CheckPullRequestMergedInput { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly token: string; + readonly fetch?: typeof fetch; +} + +export type CheckPrMergedResult = + | { readonly kind: "merged"; readonly mergedAt: string } + | { readonly kind: "open" } + | { readonly kind: "closed_unmerged" } + | { readonly kind: "missing_token"; readonly message: string } + | { readonly kind: "http_error"; readonly status: number; readonly message: string }; + +export async function checkPullRequestMerged( + input: CheckPullRequestMergedInput, +): Promise { + if (readGhFetchOverride() === "merged") { + return { kind: "merged", mergedAt: new Date().toISOString() }; + } + if (input.token === "") { + return { + kind: "missing_token", + message: "GITHUB_TOKEN unset; cannot check pull request merge state", + }; + } + + const fetchImpl = input.fetch ?? globalThis.fetch; + const url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}`; + + let res: Response; + try { + res = await fetchImpl(url, { method: "GET", headers: buildHeaders(input.token) }); + } catch (err) { + return { + kind: "http_error", + status: 0, + message: err instanceof Error ? err.message : String(err), + }; + } + + if (res.status !== 200) { + const text = await readText(res); + return { + kind: "http_error", + status: res.status, + message: `GET /pulls/${input.number} returned ${res.status}: ${truncate(text, 500)}`, + }; + } + + const body = (await readJson(res)) as { merged_at?: unknown; state?: unknown } | null; + const mergedAt = typeof body?.merged_at === "string" ? body.merged_at : null; + if (mergedAt !== null) { + return { kind: "merged", mergedAt }; + } + const state = typeof body?.state === "string" ? body.state : ""; + if (state === "closed") { + return { kind: "closed_unmerged" }; + } + return { kind: "open" }; +} + +/** + * `parsePullRequestUrl` — regex-parse `https://github.com///pull/`. + * Returns `null` on mismatch (e.g. GHE-hosted shapes) so the coordinator + * treats them as "cannot verify merge" rather than "merged". + */ +export const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/; + +export function parsePullRequestUrl( + prUrl: string, +): { owner: string; repo: string; number: number } | null { + const m = PR_URL_RE.exec(prUrl.trim()); + if (m === null) return null; + const [, owner, repo, num] = m; + if (owner === undefined || repo === undefined || num === undefined) return null; + const n = Number.parseInt(num, 10); + if (!Number.isFinite(n) || n <= 0) return null; + return { owner, repo, number: n }; +} + +/** + * `parsePullRequestRef` — accept either a full GitHub PR URL + * (`https://github.com///pull/`) or the conventional + * shorthand `/#` shape that Plot `gh_pr` attachments + * use (see `refPlaceholder('gh_pr')` in src/ui/src/pages/PlotDetail.tsx). + * Returns `null` on mismatch so the merge handler treats it as + * "cannot resolve" rather than "merged" (warren-8e39 / pl-0344 step 14). + */ +export const PR_SHORT_RE = /^([^/\s]+)\/([^/\s#]+)#(\d+)$/; + +export function parsePullRequestRef( + ref: string, +): { owner: string; repo: string; number: number } | null { + const trimmed = ref.trim(); + const viaUrl = parsePullRequestUrl(trimmed); + if (viaUrl !== null) return viaUrl; + const m = PR_SHORT_RE.exec(trimmed); + if (m === null) return null; + const [, owner, repo, num] = m; + if (owner === undefined || repo === undefined || num === undefined) return null; + const n = Number.parseInt(num, 10); + if (!Number.isFinite(n) || n <= 0) return null; + return { owner, repo, number: n }; +} + +/* ----------------------------------------------------------------------- */ +/* PR merge (warren-8e39 / pl-0344 step 14) */ +/* ----------------------------------------------------------------------- */ + +/** + * `mergePullRequest` — click-to-merge a GitHub PR attached to a Plot + * (warren-8e39 / pl-0344 step 14). Mirrors `openPullRequest`'s posture: + * direct `PUT /repos/:owner/:repo/pulls/:number/merge`, fetch injected + * as a seam, `GITHUB_TOKEN` auth. + * + * The result variants are tuned for what the PlotDetail merge UI cares + * about: + * - `merged` — 200 with `merged: true`. Caller schedules + * a follow-up `refreshProjectClone` so the + * local clone picks up the new merge commit. + * - `already_merged` — 405/409 with "already merged" body. Same + * net effect as `merged`; caller still + * schedules the refresh (idempotent). + * - `not_mergeable` — 405 (conflict / required reviews missing / + * checks failing) or 409 (base SHA mismatch). + * - `missing_token` — `GITHUB_TOKEN` unset on the server. + * - `rate_limited` — 403 with `x-ratelimit-remaining: 0` or 429. + * Surfaces the reset timestamp so the UI can + * show a countdown. + * - `not_found` — 404 (wrong owner/repo/number). + * - `network` — fetch threw / non-HTTP failure. + * - `http_error` — anything else (5xx etc.). + */ +export interface MergePullRequestInput { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly token: string; + /** Optional commit message override; GitHub defaults are fine for most cases. */ + readonly commitMessage?: string; + /** Merge method. Defaults to `merge` (matches GitHub's UI default). */ + readonly mergeMethod?: "merge" | "squash" | "rebase"; + readonly fetch?: typeof fetch; +} + +export type MergePullRequestResult = + | { readonly kind: "merged"; readonly sha: string } + | { readonly kind: "already_merged" } + | { readonly kind: "not_mergeable"; readonly message: string } + | { readonly kind: "not_found"; readonly message: string } + | { readonly kind: "missing_token"; readonly message: string } + | { + readonly kind: "rate_limited"; + readonly message: string; + readonly resetAt: string | null; + } + | { readonly kind: "network"; readonly message: string } + | { readonly kind: "http_error"; readonly status: number; readonly message: string }; + +export async function mergePullRequest( + input: MergePullRequestInput, +): Promise { + if (readGhFetchOverride() === "merged") { + return { kind: "merged", sha: "0".repeat(40) }; + } + if (input.token === "") { + return { + kind: "missing_token", + message: "GITHUB_TOKEN unset; cannot merge pull request", + }; + } + + const fetchImpl = input.fetch ?? globalThis.fetch; + const url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}/merge`; + const body: Record = { + merge_method: input.mergeMethod ?? "merge", + }; + if (input.commitMessage !== undefined) { + body.commit_message = input.commitMessage; + } + + let res: Response; + try { + res = await fetchImpl(url, { + method: "PUT", + headers: buildHeaders(input.token), + body: JSON.stringify(body), + }); + } catch (err) { + return { + kind: "network", + message: err instanceof Error ? err.message : String(err), + }; + } + + if (res.status === 200) { + const payload = (await readJson(res)) as { + merged?: unknown; + sha?: unknown; + message?: unknown; + } | null; + const merged = payload?.merged === true; + const sha = typeof payload?.sha === "string" ? payload.sha : ""; + if (merged) { + return { kind: "merged", sha }; + } + const message = typeof payload?.message === "string" ? payload.message : "PR not merged"; + return { kind: "not_mergeable", message }; + } + + // Rate limit: GitHub surfaces 403 with x-ratelimit-remaining: 0, or 429. + if (res.status === 429 || (res.status === 403 && isRateLimited(res))) { + const reset = res.headers.get("x-ratelimit-reset"); + const resetAt = + reset !== null && /^\d+$/.test(reset) + ? new Date(Number.parseInt(reset, 10) * 1000).toISOString() + : null; + const text = await readText(res); + return { + kind: "rate_limited", + message: truncate(text, 500), + resetAt, + }; + } + + if (res.status === 404) { + const text = await readText(res); + return { kind: "not_found", message: truncate(text, 500) }; + } + + // 405 "Method Not Allowed" with `message: "Pull Request is not mergeable"` + // is the standard "can't merge right now" shape. 409 is base SHA mismatch. + // Both also cover "already merged" — distinguish via the body message. + if (res.status === 405 || res.status === 409) { + const payload = (await readJson(res)) as { message?: unknown } | null; + const message = + typeof payload?.message === "string" ? payload.message : `${res.status} from PUT /merge`; + if (/already merged/i.test(message)) { + return { kind: "already_merged" }; + } + return { kind: "not_mergeable", message }; + } + + const text = await readText(res); + return { + kind: "http_error", + status: res.status, + message: `PUT /merge returned ${res.status}: ${truncate(text, 500)}`, + }; +} + +export function isRateLimited(res: Response): boolean { + const remaining = res.headers.get("x-ratelimit-remaining"); + return remaining === "0"; +} diff --git a/src/runs/pr.ts b/src/runs/pr.ts index 65e5841b..f6d28901 100644 --- a/src/runs/pr.ts +++ b/src/runs/pr.ts @@ -1,3 +1,12 @@ +import { + buildHeaders, + GH_FETCH_OVERRIDE_ENV, + GITHUB_API_BASE, + readGhFetchOverride, + readJson, + readText, + truncate, +} from "./pr-checks.ts"; import { composeBody, composeTitle, @@ -5,6 +14,20 @@ import { type PrTemplateOverrides, } from "./pr-template.ts"; +export { + type CheckPrMergedResult, + type CheckPullRequestMergedInput, + checkPullRequestMerged, + isRateLimited, + type MergePullRequestInput, + type MergePullRequestResult, + mergePullRequest, + PR_SHORT_RE, + PR_URL_RE, + parsePullRequestRef, + parsePullRequestUrl, +} from "./pr-checks.ts"; + /** * `openPullRequest` — open a GitHub PR for a branch reap just pushed * (warren-f6af). Fourth best-effort sub-step of `reapRun`, gated by @@ -56,26 +79,6 @@ export interface PrFetcher { readonly fetch: typeof fetch; } -const GITHUB_API_BASE = "https://api.github.com"; -const USER_AGENT = "warren-reap-pr-open"; - -/** - * Acceptance test seam (warren-ae00 / scenario 26). When - * `WARREN_GH_FETCH_OVERRIDE` is set, every GitHub REST call short-circuits - * to a canned positive response — `openPullRequest` returns a synthetic - * `pull/1` URL and `checkPullRequestMerged` returns `merged` immediately. - * Lets the in-proc plan-run roundtrip exercise reap's PR open + the - * coordinator's pr_open → merged transition without standing up a real - * GitHub fixture. Unset in production deployments. - */ -const GH_FETCH_OVERRIDE_ENV = "WARREN_GH_FETCH_OVERRIDE"; - -function readGhFetchOverride(): "merged" | null { - const v = process.env[GH_FETCH_OVERRIDE_ENV]; - if (typeof v !== "string") return null; - return v.trim() === "merged" ? "merged" : null; -} - export async function openPullRequest( input: OpenPullRequestInput, deps: PrFetcher = { fetch: globalThis.fetch }, @@ -182,36 +185,6 @@ async function findExistingPr( return typeof first?.html_url === "string" ? first.html_url : null; } -function buildHeaders(token: string): Record { - return { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - "user-agent": USER_AGENT, - "x-github-api-version": "2022-11-28", - }; -} - -async function readJson(res: Response): Promise { - try { - return await res.json(); - } catch { - return null; - } -} - -async function readText(res: Response): Promise { - try { - return await res.text(); - } catch { - return ""; - } -} - -function truncate(input: string, max: number): string { - return input.length <= max ? input : `${input.slice(0, max)}…`; -} - /* ----------------------------------------------------------------------- */ /* Title + body formatting */ /* ----------------------------------------------------------------------- */ @@ -386,274 +359,3 @@ function isFalsy(raw: string): boolean { const v = raw.trim().toLowerCase(); return v === "0" || v === "false" || v === "no" || v === "off" || v === ""; } - -/* ----------------------------------------------------------------------- */ -/* PR-merge polling */ -/* ----------------------------------------------------------------------- */ - -/** - * `checkPullRequestMerged` — poll a GitHub PR's merge state for the PlanRun - * coordinator (warren-9e4c). Pure helper: the caller decides what each - * non-merged shape means (`open` = wait, `closed_unmerged` = fail the plan). - * - * Mirrors `openPullRequest`'s posture: direct REST call against - * `GET /repos/:owner/:repo/pulls/:number`, `Authorization: Bearer ` - * from `GITHUB_TOKEN`, fetch injected as a seam. - */ -export interface CheckPullRequestMergedInput { - readonly owner: string; - readonly repo: string; - readonly number: number; - readonly token: string; - readonly fetch?: typeof fetch; -} - -export type CheckPrMergedResult = - | { readonly kind: "merged"; readonly mergedAt: string } - | { readonly kind: "open" } - | { readonly kind: "closed_unmerged" } - | { readonly kind: "missing_token"; readonly message: string } - | { readonly kind: "http_error"; readonly status: number; readonly message: string }; - -export async function checkPullRequestMerged( - input: CheckPullRequestMergedInput, -): Promise { - if (readGhFetchOverride() === "merged") { - return { kind: "merged", mergedAt: new Date().toISOString() }; - } - if (input.token === "") { - return { - kind: "missing_token", - message: "GITHUB_TOKEN unset; cannot check pull request merge state", - }; - } - - const fetchImpl = input.fetch ?? globalThis.fetch; - const url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}`; - - let res: Response; - try { - res = await fetchImpl(url, { method: "GET", headers: buildHeaders(input.token) }); - } catch (err) { - return { - kind: "http_error", - status: 0, - message: err instanceof Error ? err.message : String(err), - }; - } - - if (res.status !== 200) { - const text = await readText(res); - return { - kind: "http_error", - status: res.status, - message: `GET /pulls/${input.number} returned ${res.status}: ${truncate(text, 500)}`, - }; - } - - const body = (await readJson(res)) as { merged_at?: unknown; state?: unknown } | null; - const mergedAt = typeof body?.merged_at === "string" ? body.merged_at : null; - if (mergedAt !== null) { - return { kind: "merged", mergedAt }; - } - const state = typeof body?.state === "string" ? body.state : ""; - if (state === "closed") { - return { kind: "closed_unmerged" }; - } - return { kind: "open" }; -} - -/** - * `parsePullRequestUrl` — regex-parse `https://github.com///pull/`. - * Returns `null` on mismatch (e.g. GHE-hosted shapes) so the coordinator - * treats them as "cannot verify merge" rather than "merged". - */ -const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/; - -export function parsePullRequestUrl( - prUrl: string, -): { owner: string; repo: string; number: number } | null { - const m = PR_URL_RE.exec(prUrl.trim()); - if (m === null) return null; - const [, owner, repo, num] = m; - if (owner === undefined || repo === undefined || num === undefined) return null; - const n = Number.parseInt(num, 10); - if (!Number.isFinite(n) || n <= 0) return null; - return { owner, repo, number: n }; -} - -/** - * `parsePullRequestRef` — accept either a full GitHub PR URL - * (`https://github.com///pull/`) or the conventional - * shorthand `/#` shape that Plot `gh_pr` attachments - * use (see `refPlaceholder('gh_pr')` in src/ui/src/pages/PlotDetail.tsx). - * Returns `null` on mismatch so the merge handler treats it as - * "cannot resolve" rather than "merged" (warren-8e39 / pl-0344 step 14). - */ -const PR_SHORT_RE = /^([^/\s]+)\/([^/\s#]+)#(\d+)$/; - -export function parsePullRequestRef( - ref: string, -): { owner: string; repo: string; number: number } | null { - const trimmed = ref.trim(); - const viaUrl = parsePullRequestUrl(trimmed); - if (viaUrl !== null) return viaUrl; - const m = PR_SHORT_RE.exec(trimmed); - if (m === null) return null; - const [, owner, repo, num] = m; - if (owner === undefined || repo === undefined || num === undefined) return null; - const n = Number.parseInt(num, 10); - if (!Number.isFinite(n) || n <= 0) return null; - return { owner, repo, number: n }; -} - -/* ----------------------------------------------------------------------- */ -/* PR merge (warren-8e39 / pl-0344 step 14) */ -/* ----------------------------------------------------------------------- */ - -/** - * `mergePullRequest` — click-to-merge a GitHub PR attached to a Plot - * (warren-8e39 / pl-0344 step 14). Mirrors `openPullRequest`'s posture: - * direct `PUT /repos/:owner/:repo/pulls/:number/merge`, fetch injected - * as a seam, `GITHUB_TOKEN` auth. - * - * The result variants are tuned for what the PlotDetail merge UI cares - * about: - * - `merged` — 200 with `merged: true`. Caller schedules - * a follow-up `refreshProjectClone` so the - * local clone picks up the new merge commit. - * - `already_merged` — 405/409 with "already merged" body. Same - * net effect as `merged`; caller still - * schedules the refresh (idempotent). - * - `not_mergeable` — 405 (conflict / required reviews missing / - * checks failing) or 409 (base SHA mismatch). - * - `missing_token` — `GITHUB_TOKEN` unset on the server. - * - `rate_limited` — 403 with `x-ratelimit-remaining: 0` or 429. - * Surfaces the reset timestamp so the UI can - * show a countdown. - * - `not_found` — 404 (wrong owner/repo/number). - * - `network` — fetch threw / non-HTTP failure. - * - `http_error` — anything else (5xx etc.). - */ -export interface MergePullRequestInput { - readonly owner: string; - readonly repo: string; - readonly number: number; - readonly token: string; - /** Optional commit message override; GitHub defaults are fine for most cases. */ - readonly commitMessage?: string; - /** Merge method. Defaults to `merge` (matches GitHub's UI default). */ - readonly mergeMethod?: "merge" | "squash" | "rebase"; - readonly fetch?: typeof fetch; -} - -export type MergePullRequestResult = - | { readonly kind: "merged"; readonly sha: string } - | { readonly kind: "already_merged" } - | { readonly kind: "not_mergeable"; readonly message: string } - | { readonly kind: "not_found"; readonly message: string } - | { readonly kind: "missing_token"; readonly message: string } - | { - readonly kind: "rate_limited"; - readonly message: string; - readonly resetAt: string | null; - } - | { readonly kind: "network"; readonly message: string } - | { readonly kind: "http_error"; readonly status: number; readonly message: string }; - -export async function mergePullRequest( - input: MergePullRequestInput, -): Promise { - if (readGhFetchOverride() === "merged") { - return { kind: "merged", sha: "0".repeat(40) }; - } - if (input.token === "") { - return { - kind: "missing_token", - message: "GITHUB_TOKEN unset; cannot merge pull request", - }; - } - - const fetchImpl = input.fetch ?? globalThis.fetch; - const url = `${GITHUB_API_BASE}/repos/${input.owner}/${input.repo}/pulls/${input.number}/merge`; - const body: Record = { - merge_method: input.mergeMethod ?? "merge", - }; - if (input.commitMessage !== undefined) { - body.commit_message = input.commitMessage; - } - - let res: Response; - try { - res = await fetchImpl(url, { - method: "PUT", - headers: buildHeaders(input.token), - body: JSON.stringify(body), - }); - } catch (err) { - return { - kind: "network", - message: err instanceof Error ? err.message : String(err), - }; - } - - if (res.status === 200) { - const payload = (await readJson(res)) as { - merged?: unknown; - sha?: unknown; - message?: unknown; - } | null; - const merged = payload?.merged === true; - const sha = typeof payload?.sha === "string" ? payload.sha : ""; - if (merged) { - return { kind: "merged", sha }; - } - const message = typeof payload?.message === "string" ? payload.message : "PR not merged"; - return { kind: "not_mergeable", message }; - } - - // Rate limit: GitHub surfaces 403 with x-ratelimit-remaining: 0, or 429. - if (res.status === 429 || (res.status === 403 && isRateLimited(res))) { - const reset = res.headers.get("x-ratelimit-reset"); - const resetAt = - reset !== null && /^\d+$/.test(reset) - ? new Date(Number.parseInt(reset, 10) * 1000).toISOString() - : null; - const text = await readText(res); - return { - kind: "rate_limited", - message: truncate(text, 500), - resetAt, - }; - } - - if (res.status === 404) { - const text = await readText(res); - return { kind: "not_found", message: truncate(text, 500) }; - } - - // 405 "Method Not Allowed" with `message: "Pull Request is not mergeable"` - // is the standard "can't merge right now" shape. 409 is base SHA mismatch. - // Both also cover "already merged" — distinguish via the body message. - if (res.status === 405 || res.status === 409) { - const payload = (await readJson(res)) as { message?: unknown } | null; - const message = - typeof payload?.message === "string" ? payload.message : `${res.status} from PUT /merge`; - if (/already merged/i.test(message)) { - return { kind: "already_merged" }; - } - return { kind: "not_mergeable", message }; - } - - const text = await readText(res); - return { - kind: "http_error", - status: res.status, - message: `PUT /merge returned ${res.status}: ${truncate(text, 500)}`, - }; -} - -function isRateLimited(res: Response): boolean { - const remaining = res.headers.get("x-ratelimit-remaining"); - return remaining === "0"; -}