./.graph/plans/, the repo-local tools in ./.graph/tools/, the workflow in .github/workflows/graph-checks.yaml — and every PR against graph runs them.
The finished shape is the headline. Each CI job is:
The pieces
Ephemeral storage.GRAPH_STORAGE=memory keeps runs stateless — nothing to clean up. Plans don’t need the shape cache anyway: you authored the paths.
Repo-carried setup. graph layers ./.graph/config.toml over the global config and loads ./.graph/{plans,tools}/, so the repo carries everything the checks need — config (secrets as ${ENV} references that fail loudly when missing), plans, and the rubric tool — all reviewed like any other code.
The github tool pack. [tools] packs = ["github"] enables the bundled tools the plans build on: git_changed_files, git_diff, git_file, git_grep, git_log, gh_pr_meta, gh_pr_comment, gh_pr_inline_comments, gh_pr_review_threads, gh_pr_thread_sync, gh_pr_ticket, gh_release.
docs_drift — an inferred gate as a merge check
The repo rule, from its CLAUDE.md: behavior changes in crates/ must update docs/ in the same PR. As a plan, the decision — “is this diff a behavioral change?” — is one judged yes/no call, and everything around it is deterministic and free:
- Short-circuit before you infer. E1 and E3 are
whengates over github pack tool output — a docs-only PR exits success in milliseconds with zero LLM calls. Theinfergate is the last resort, and it sees only the truncatedcrates/diff. Worst case per PR: one judge call on the cheapjudgerole. - The gate is the CI contract.
status: errormaps to process exit code 4 — distinct from exit 1 (the plan itself broke). The job needs no branching: exit 4 fails the step, and underGRAPH_EVENTS=githubgraph prints the gate’s message as a::error::annotation itself. - Every exit path emits the same shape. Both success gates and the fall-through
outputproduce{docs_required, reason}— callers parse one contract. On a non-fired infer gate,{{E5.reason}}carries the judge’s reasoning into the output for free (the judge explains why no docs are needed). - Ask the judge a falsifiable question. The prompt defines what does not count (refactors, test-only changes). The judge role answers “yes only when the data clearly supports it”, so the framing sets the false-positive rate. Live calibration: it flagged a commit adding an env var and a JSON field (“observable by users and plan authors”), and passed a dead-code removal with its reasoning in
reason.
graph_review_9000 — a composed reviewer
The reviewer is three plans and a worktree-capable tool family, split so the review engine runs anywhere and only the wrapper knows GitHub exists:
graph_review_core— the review engine, purely local (git only). Fetches the changed files, diff, and full file contents for a change set; clusters the diff into functional groups; scouts each scoutworthy group’s blast radius with boundedagentsteps; runs the eight-pass rubric; returns structured JSON (output:finish — framing, a severity-ordered pass table, findings). It takes previously known findings as a suppression list, so callers with history get only what’s new.graph_review_thread_audit— the thread lifecycle, GitHub-coupled. Given the prior findings read back from PR review threads, it verifies fixes against the file contents at head and applies the transitions (fixed → resolved with a reply, dismissed by a human reply → declined; insufficient evidence keeps a finding open).graph_review_9000— the wrapper CI runs. PR metadata, thread readback, adecidegate that calls the audit only when prior findings exist, the core review, and the posting — inline threads for new findings, a marker-keyed summary comment that refreshes in place. Open threads and human replies are never deleted.
graph plan run graph_review_9000 --input pr="$PR" — nothing else. The wrapper:
graph_review_core, the engine keeps the shape its predecessor established — complete file list (repo_changed_files), diff (repo_diff), full contents mapped 8 at a time (repo_file), an empty-diff exit, a grouping inference, a filter to scoutworthy groups, one context scout per group (an agent loop over repo_grep/repo_file, up to six in parallel), then the eight-pass rubric with the caller’s known_findings as a suppression list, finishing with an output: map.
The same engine, locally
The core’s evidence tools areuser__repo_* exec tools in ./.graph/tools/ — copies of the github pack’s git_* tools with one addition the pack versions don’t have: an empty head/ref means the working tree. repo_changed_files/repo_diff diff staged + unstaged changes against the merge-base of base and HEAD; repo_file/repo_grep read and search the checkout as it sits on disk. So the same plan CI runs over committed shas reviews uncommitted work:
jq, or actioned directly by an agent calling plan__graph_review_core. In this repo the intended rhythm is a finalize hook: an agent finishing a worktree runs the core, addresses what it finds, and only then opens the PR that triggers the wrapper.
Before trusting a plan like this with write access, step through it on a real PR in the workbench — a breakpoint on the posting steps shows exactly what would be sent:
A gated review run paused at a breakpoint on the posting step — the earlier steps executed, the debug panel showing the rendered input about to be sent.
- The rubric is the product. The
inferinstruction enumerates this repo’s invariants pass by pass and instructs “omit findings that found nothing to flag”. A focused reviewer beats a generic one precisely because it refuses to comment on everything else. Its predecessor caught real bugs in this repo’s own PRs — including one in the PR that changed the reviewer itself. - The rubric can live in the plan.
builtin__inferaccepts both the instruction and a caller-suppliedoutput_schema, so the whole rubric is one step insidegraph_review_corewith no prompt tool to keep in sync. (The retiredpr_reviewsplit them into a./.graph/tools/prompt tool — still the right shape when several plans share one rubric.) - Sub-plans return data; branches must agree on a contract. A called plan with an
output:finish hands its rendered JSON straight to the caller’s step —{{C1.findings}}is the core’s findings array, no unwrapping (composition). Thedecide’s two branches exploit the flip side: everything downstream reads{{G1.result.*}}, so the else-branchreshapeemits the audit plan’s exact output keys as empty literals — the rejoined plan can’t tell which branch ran, which is the whole point of branching. - Gate expensive history work on whether history exists. A first review has no threads to audit, but the audit’s prompt carries the full diff and file contents — review-sized money for a guaranteed-empty answer. The
decideon{{E4b.has_findings}}makes the skip structural: a first-run PR pays for the review and nothing else. - Presentation is a template, not code.
builtin__reshapeprojects each finding into its comment body and the summary renders with sections — iteration builds the pass table, inverted sections handle the clean case. Logic stays in steps; the markdown is pure presentation. - Ordering is asked for, not computed. The template dialect can’t sort, so the schema asks the model for
blocker_ordinaland a severity-orderedpassesarray, and the templates iterate in the order they arrive. Logic belongs in the steps or the schema — never the markdown. - Declare the output schema — it’s enforced. The schema pins
passesto exactly eight (minItems/maxItems) and marks every finding field required. Models occasionally omit required fields even under provider-forced structured output; graph validates the result against the declared schema and runs onerepair-role fix-up, so{{E5.overall_framing}}can never be a missing key at render time. - Review the whole diff. The diff is scoped to nothing (
paths: ".") minus generated files — an early version reviewed onlycrates/anddocs/, which blinded it to the highest-leverage changes in an infra PR: workflows, plans, and the exec tools they run. - Ground absence claims in the complete file list, never the diff. The diff is byte-capped and git orders hunks by path, so on a big PR the
docs/hunks are exactly what falls off the end — an early version reported “docs not updated” false positives on a PR whose docs edits sat past the truncation point. The fix is layered, inside the core:repo_changed_files(E1) feeds the reviewer a complete, never-truncated file list, thefilter(E3b) drops deleted entries whose contents can’t exist in the reviewed state — so themapoverrepo_file(E4) reads whole files rather than hunks without dying on a deletion, and its results stay index-aligned with{{E3b.items}}by construction —repo_diffappends a visible[diff truncated: …]marker when it cuts, and the rubric judges docs-parity and test-parity only from the complete list. Per-file truncation is the same trap one level down: the first run of the split reviewer asserted two output keys were missing from a 42KB plan file fetched under a 30KB budget — the keys sat past the cut, behind a visible[file truncated: …]marker. Hence a corollary in the finding bar (truncated evidence can never support an absence claim — ‘unverified’ at most, and the audit may not resolve or hold a finding on it either) and a per-file budget sized so the repo’s largest reviewed files arrive whole. - Ask the model to refute itself. The instruction closes with a gate: try to disprove each blocker against the diff and demote what doesn’t survive, and mark a pass
unverifiedrather thanpassfor anything you couldn’t check. A false clear costs as much as a false blocker. - Findings flow through the pipeline, enriched at each step.
reshape(E6) addspath/body/payloadto each finding without dropping the original fields;gh_pr_thread_sync(E7) passes those extras through and addsurlwhere a comment posted. So the blockers list (E8) renders from one array — severity and label from the review, the link from the post — with no join logic, which the template dialect couldn’t express anyway. Findings the model couldn’t pin to a line (or whose line the API rejects) simply come back withouturl, and the inverted section renders them as plain text. - Findings are threads with a lifecycle, not comments to repost. Each finding’s comment embeds a hidden JSON payload (its label, severity, file, line, headline);
gh_pr_review_threads(E4b) reads them back next run with thread states — open, resolved, declined (a human closed or dismissed it). Open threads and human replies are never deleted, so a conversation on a finding survives every push. - Resolution is judged against the code, not against re-detection. The audit plan transitions an open finding only when the code at head shows the fix, and treats insufficient evidence as “stays open” — if it instead resolved whatever the reviewer failed to re-find, one flaky review pass would silently close real findings. Declines come only from explicit human replies, never from silence. The core’s rubric then sees the audited findings purely as a suppression list (
known_findings): report only what’s new. - The file contents at head are the resolution evidence, not the diff. A fix that lands in a later commit of the same PR leaves no hunk in the cumulative base-to-head diff — the flagged region simply stops appearing — so the audit is told to judge from the finding’s location in the full file contents. Live calibration: the first mixed-state run kept a fixed finding open precisely because the instruction let the model wait for a diff hunk that could never exist.
- Blast radius is scouted, not guessed — one scout per functional change. The diff and changed-file contents can’t show who calls what changed. Inside the core, an inference (E4e) clusters the diff into functional groups (newline-joined
file_list/symbol_liststrings keep the per-group templates logic-less), then amap(E4f) runs anagentstep per group — a bounded loop over read-onlyrepo_grep/repo_filechasing that group’s callers, implementations, and tests in the reviewed state — up to six in parallel, each returning a dossier that echoes its group name. The chain sits after the empty-diff gate (it only earns its cost when there’s something to review), and E5 renders each dossier behind a{{#final}}guard, so a scout that exhausts its budget costs its group’s evidence rather than the run. Focused scouts dig deeper than one generalist — deep enough that the round budget, not curiosity, has to be the limit. - Scouts are dispatched only where code changed. A prose-only group — prompts, docs, configuration — has nothing a repository search can find, and a scout pointed at one will grind its whole round budget grepping fragments of English (live calibration: a prompt-only PR burned 60 rounds finding nothing before this guard existed). So the grouping call marks each group
scoutworthy, afilter(E4e2) drops the rest before the map spends a round, and E5 sees which groups went deliberately unscouted via the filter’sdroppedhalf — selection that narrows what runs next without losing what is known. The scout prompt keeps a bail-immediately instruction as the backstop for grouping misjudgments. - Thread hygiene runs before the nothing-to-review gate. The readback (E4b) and the decide-called audit plan — which applies its transitions internally — sit ahead of the wrapper’s empty-diff exit, and the sync’s two halves are split across it (the audit transitions, E7 creates). A fully reverted PR has an empty diff — exactly the case where every open finding just got fixed — so gating the lifecycle behind the exit would strand resolved-in-practice threads open forever, blocking merges on repos that require conversation resolution.
- The summary comment still refreshes in place.
gh_pr_comment(E8) takes its ownmarker, embeds it as a hidden tag, and find-and-edits the comment carrying it. Prefer a marker even for a lone plan: without one it falls back to--edit-last, which keys on the token identity, so the day a second plan comments on the same PR as the same bot the two summaries start overwriting each other. One marker per plan per comment surface. - Inline comments stay advisory — but resolution now means something. They post as plain review comments, not a
REQUEST_CHANGESreview, so they only gate merging on repos with Require conversation resolution before merging enabled — and on those repos the lifecycle pulls its weight: fixed findings resolve themselves out of the gate’s way, while a finding the author disagrees with is one human “resolve conversation” click (read back asdeclined, never re-raised).
The workflow
The full file is.github/workflows/graph-checks.yaml; the notable choices:
- The release image is the runtime.
container: ghcr.io/tylerdavis/graph:vX.Y.Zcarries graph plusgit,jq, andgh(pinned from official releases — distro packages lag the JSON fields and flags the pack tools use) and thesafe.directorygit config that Actions’ foreign-owned workspace mounts require. The image tag is the version pin;packages: readplusGITHUB_TOKENcredentials pull it while private. GRAPH_EVENTS=githubmakes a failing plan annotate the run itself — the one sanctioned case of graph writing non-deliverable output to stdout, failure-paths only (details).- Guards are cheap and layered: drafts skipped, fork PRs skipped (they have no
ANTHROPIC_API_KEY; if one slipped through, the unset${VAR}makes the provider unusable and the first model call fails naming the variable — the backstop), one concurrency group per PR with cancel-in-progress. - Cost per PR: worst case one
judge-role call (docs-drift) plus two review-sized calls — the review itself, and the transition audit only when prior findings exist (each carries full file contents alongside the diff; the audit needs the same grounding to verify a fix). A first review pays for the review alone — thedecideskips the audit outright; drafts pay nothing. - One job per plan, no
needsbetween them. The two jobs are independent and run in parallel; only docs-drift can fail the check (exit 4). The reviewer always exits 0 — its comments are the deliverable, not a gate.