Cheaper, faster preparation without a weaker review
The inbox prepares every review-requested pull request with one Claude Code run. Measured over the
22 runs that did real work (session logs under ~/.claude/projects/-home-fiddur--diffity-inbox-worktrees-*,
bundles and store under ~/.diffity/inbox):
| Measure |
Value |
| Wall time per prepare |
avg 8.7 min, max 25 |
| Tool calls per prepare |
avg 37 |
| Time split |
80% model (thinking, writing), 20% tools |
| Output tokens per prepare |
avg 27k (8k–61k) |
| Cache reads per prepare |
avg 1.1M; ~76k context per turn; 27k on the first turn |
Local pnpm typecheck / pnpm test |
20 of 22 runs, 13 min in total, 191 s in one run |
| Findings in 18 bundles |
P1 in 6, P1 or P2 in 12, P3-only or none in 6 |
| Wasted attempts |
3 PRs × 3 attempts ended on "You've hit your session limit · resets 2pm" and sit failed |
Two of the six P1s came from tiny diffs (#14550 at +11/−11, a stale snapshot baseline; #13444 at +5/−1),
so nothing below picks a cheaper path by diff size.
The logs also show two things the daemon should not be doing:
- The agent gets the reviewer's MCP servers. In #14188 it called gcloud (read-only, harmless) with the
reviewer's credentials inside a checkout the author controls; the prod datastore, Slack, Atlassian and
Mixpanel servers were in its tool list. The daemon strips GitHub tokens from the environment, but MCP
servers come from the reviewer's Claude settings.
- The agent runs the repository's toolchain (
pnpm typecheck, pnpm test) in nearly every run. CI
has already done that, and the result is one field away on a gh call the daemon already makes.
It also loads the reviewer's own ~/.claude/CLAUDE.md, memory index and skills into every run; in #14532
it wrote a memory file.
Verified on this machine before planning: claude -p --output-format json returns total_cost_usd,
duration_ms, num_turns, usage and per-model modelUsage; --setting-sources "" loads no user
settings, no MCP servers, no user skills and no user CLAUDE.md; --setting-sources user keeps MCP servers
(including the claude.ai connectors); a PreToolUse hook passed through --settings '<json>' with matcher
mcp__.* blocks a tool by exiting 2 even under --dangerously-skip-permissions (gcloud denied, Atlassian
allowed in the test); --append-system-prompt takes the 20 KB review skill body; --tools restricts
built-ins only and does not touch MCP; --strict-mcp-config removes the claude.ai connectors too, so it is
not the way to keep a few of them.
Wave 1
1. The daemon builds the agent command; agent replaces prepare
Config gains an agent block and refuses the old prepare key with a message naming the replacement
(prepare was the raw argv; it cannot carry the safety flags below, and a copied default would silently
keep the old behaviour):
agent-argv.ts builds, for the drafting pass:
claude -p --dangerously-skip-permissions --output-format json
--setting-sources "" # mcpAllow empty: no user settings, memory, skills or MCP
--setting-sources user # mcpAllow set: MCP servers come from user settings
--settings '{"hooks":{"PreToolUse":[{"matcher":"mcp__.*","hooks":[{"type":"command","command":"<node> <entry> inbox mcp-gate"}]}]}}'
# mcpAllow set only; the gate reads DIFFITY_MCP_ALLOW from its env
--append-system-prompt "<diffity-review SKILL.md body, frontmatter stripped, from <dist>/skills>"
--disallowedTools 'Bash(gh pr review:*)' 'Bash(gh pr comment:*)' 'Bash(gh pr merge:*)' 'Bash(gh api:*)'
'Bash(pnpm:*)' 'Bash(npm:*)' 'Bash(npx:*)' 'Bash(yarn:*)' 'Bash(bun:*)' 'Bash(make:*)'
[--model X] [--effort Y] [--max-budget-usd Z] [extraArgs...]
inbox mcp-gate is a hidden subcommand: reads the hook JSON on stdin, exits 0 for a tool named in
DIFFITY_MCP_ALLOW (comma-separated, set by agentEnv), otherwise prints "the review agent may only use
" to stderr and exits 2. Unit-tested on stdin fixtures.
- Shipping the skill in the system prompt removes the dependency on the reviewer's
~/.claude/skills. The
prompt's "follow the diffity-review skill" becomes "follow the review instructions in your system prompt".
- With
mcpAllow set the prompt adds: "You may use these tools to read material the pull request refers to
(a ticket, a document, a thread): . Nothing else outside this checkout." Reading is the intent;
the allowlist is exact tool names, so a write tool is only there if the reviewer put it there.
Suggested starting set for NC: mcp__claude_ai_Atlassian__getJiraIssue,
mcp__claude_ai_Atlassian__getConfluencePage, mcp__claude_ai_Slack__slack_read_thread,
mcp__claude_ai_Google_Drive__read_file_content.
runAgent output goes through parseAgentOutput(stdout) → { text, stats | null }: an object with
type: "result" yields text = result and stats from total_cost_usd, duration_ms, num_turns,
usage, modelUsage, is_error, subtype; anything else is text as today. verdictOf runs on text.
The log file keeps the raw stdout and appends the extracted text so it stays readable.
subtype: error_max_budget_usd is a failed attempt with that reason.
- README: the
agent block, what each built flag closes off, and the prepare migration note.
- Settings panel:
model, effort, mcpAllow (one per line), maxBudgetUsd.
2. A log of every agent run, and a pause on the session limit
- Table
inbox_runs: id, pr_id, head_sha, phase ('prepare' | 'validate' | 'answer'), model, started_at, ended_at, duration_ms, turns, cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, outcome ('prepared' | 'skipped' | 'validated' | 'answered' | 'failed' | 'timeout' | 'rate-limited'), note. Same migration style as the inbox_prs columns. store.recordRun,
store.runs({ since?, prId? }), store.runTotals(since).
preparePr returns the runs it made (runs: RunRecord[] on every result kind); tick records them.
Attendant answers are recorded through a recordRun callback handed to realAttendantDeps.
diffity inbox runs [--json] [--since <days>]: a table of when, PR, phase, model, turns, minutes, cost,
tokens, outcome, with totals; the log the reviewer keeps.
- Page: a prepared card's meta line gets "· 8 min · $1.20" (prepare plus validate; hover shows each
phase, model, turns and tokens); the footer gets "agent runs today: 5 · 41 min · $6.30 · 7 days: …".
/api/inbox carries per-row cost and the totals; inbox status --json too.
- Rate limit:
parseAgentOutput flags is_error, or text matching
/hit your (session|usage) limit/i, as rateLimited with a best-effort resetsAt parsed from
"resets 2pm (Europe/Stockholm)" (fallback now + 30 min). The row goes back to queued with reason
"waiting: Claude session limit until 14:00", no attempt is counted, the run is recorded as
rate-limited, and runDaemon sets pausedUntil: polling and reconciling continue, preparing does not.
DaemonStatus//api/inbox expose pausedUntil; the footer says "preparing paused until 14:00 — Claude
session limit"; ↑ after the reset time clears it.
3. CI's verdict in the prompt, and an optional hold until it is green
PrSnapshot gains checks: { name, status }[] with status normalised to success | failure | pending | skipped | neutral from statusCheckRollup (check runs: status + conclusion; status contexts:
state), duplicates collapsed by name keeping the worst, and files: { path, additions, deletions }[]
capped at 300 — both from the gh pr view --json call already made per PR (fields work on gh 2.46).
ciState(checks) → passing | failing | running | none. Store persists ci_state.
- Prompt, after the size line:
CI at this head: check-job SUCCESS · pr-ecosystem-test (admin3) SUCCESS · integration-test-job SKIPPED · 11 more skipped
Do not install dependencies, build, typecheck, lint or run tests: CI has done that, and this checkout is
the author's code. Reason from the source. If a check failed or is still running, say so in the summary.
or "CI has not reported for this head." The deny list in (1) enforces it.
waitForCi (default false; Settings panel checkbox): in reconcile, before the normal queueing and
only when the row is not bumped — running → queued with reason "waiting: CI running (3 checks)";
failing → skipped with reason "CI failed: check-job, pr-mgmt-job". Both are re-evaluated every tick
from the fresh snapshot (a "CI failed:" skip returns to the queue when the checks pass, unlike a
filter skip at the same head). none counts as passing. ↑ prepares regardless.
alertPaths: string[] (globs via path.matchesGlob): any changed file matching → the row is alerted,
OR'd with the agent's ALERT line. "changes to datastore models" becomes
["packages/shared/src/model/**", "**/dbref/**"] and cannot be misjudged. Settings panel textarea.
- Card: a CI dot (green / red / grey) from
ci_state.
Wave 2
4. validate: a second pass checks the P1 and P2 drafts
- In
preparePr, after PREPARED and before the bundle export: agent list --json on the session; keep
open threads whose severityOf is P1 or P2. None → no validation (the "simple" case is decided by what
was found, not by size). Otherwise run the validating agent — same argv builder with validate.model,
same gate and deny list, no review skill in the system prompt — with composeValidatePrompt:
A first pass drafted this review. Check its P1 and P2 findings against the code before the reviewer sees them.
For each finding below, read the lines it points at and what they depend on (callers, callees, tests).
- Correct and well put: leave it.
- Correct but the text or severity is off: diffity --repo <wt> agent amend <comment-id> --body-file - <<'EOF' … EOF
- Wrong: diffity --repo <wt> agent dismiss <thread-id> --reason "<why>"
Add a finding only where checking one of these reveals another; this pass does not re-review the diff.
If the general summary's verdict or counts no longer hold, amend it too.
Nothing you do may reach GitHub. Print exactly one final line and stop: VALIDATED
followed by the findings as data: thread id, comment id, file, lines, body (indented), one block each.
VALIDATED → export the bundle, recompute the summary, record the run as validated. Timeout or no
verdict → still export the draft, suffix the summary "· unchecked", record timeout/failed; the row is
prepared either way.
- Live answers use
validate.model when set, else agent.model: a question is about a finding, the
stronger model's job.
- Summary hover shows "draft $x · check $y" from the run rows.
5. Measure before switching the default
scripts/inbox-compare.ts <owner/repo#n> [--head <sha>] [--model <m>] [--effort <e>]: fetches
refs/pull/n/head into the clone if needed, cuts a scratch worktree and data dir, runs preparePr with
the candidate settings, and prints two columns of findings (severity, file, line range, first sentence)
against the newest existing bundle for that head, matching on file plus overlapping lines, with each
side's cost and time from the run log.
- Run it over the NCBackend3/admin3 bundles with
--model opus, and with --effort medium on the
default model. Paste the table here.
- Gate: a cheaper drafter is adopted as
agent.model only if it reproduces every P1 in the baseline and at
least four of five P2s. Otherwise the default model keeps drafting and validate stays off.
Delivery
Stacked PRs in this order, each with a patch version bump, a self-review on its own build, squash merge,
and a rebuild of the global:
- (1) agent block, argv builder, skill in the system prompt, MCP gate, deny list, JSON output parsing.
- (2) run log,
inbox runs, cost on the page, rate-limit pause.
- (3)
checks/files on the snapshot, CI in the prompt, waitForCi, alertPaths, CI dot.
- (4)
validate pass.
- (5) compare script, the comparison run, the table posted here.
After (1) lands: restart diffity inbox, delete the prepare key from ~/.diffity/inbox/config.json
(the built default applies), set agent.mcpAllow if wanted, and ↑ the three rows that failed on the
session limit (#14378, #13713, admin3 #605).
Later, if the run log says the prompt is where the tokens go: a per-file digest marking generated and
lockfile paths, and CI annotations on failing checks so lint and type errors are not re-found.
Cheaper, faster preparation without a weaker review
The inbox prepares every review-requested pull request with one Claude Code run. Measured over the
22 runs that did real work (session logs under
~/.claude/projects/-home-fiddur--diffity-inbox-worktrees-*,bundles and store under
~/.diffity/inbox):pnpm typecheck/pnpm testfailedTwo of the six P1s came from tiny diffs (#14550 at +11/−11, a stale snapshot baseline; #13444 at +5/−1),
so nothing below picks a cheaper path by diff size.
The logs also show two things the daemon should not be doing:
reviewer's credentials inside a checkout the author controls; the prod datastore, Slack, Atlassian and
Mixpanel servers were in its tool list. The daemon strips GitHub tokens from the environment, but MCP
servers come from the reviewer's Claude settings.
pnpm typecheck,pnpm test) in nearly every run. CIhas already done that, and the result is one field away on a
ghcall the daemon already makes.It also loads the reviewer's own
~/.claude/CLAUDE.md, memory index and skills into every run; in #14532it wrote a memory file.
Verified on this machine before planning:
claude -p --output-format jsonreturnstotal_cost_usd,duration_ms,num_turns,usageand per-modelmodelUsage;--setting-sources ""loads no usersettings, no MCP servers, no user skills and no user CLAUDE.md;
--setting-sources userkeeps MCP servers(including the claude.ai connectors); a
PreToolUsehook passed through--settings '<json>'with matchermcp__.*blocks a tool by exiting 2 even under--dangerously-skip-permissions(gcloud denied, Atlassianallowed in the test);
--append-system-prompttakes the 20 KB review skill body;--toolsrestrictsbuilt-ins only and does not touch MCP;
--strict-mcp-configremoves the claude.ai connectors too, so it isnot the way to keep a few of them.
Wave 1
1. The daemon builds the agent command;
agentreplacesprepareConfig gains an
agentblock and refuses the oldpreparekey with a message naming the replacement(
preparewas the raw argv; it cannot carry the safety flags below, and a copied default would silentlykeep the old behaviour):
agent-argv.tsbuilds, for the drafting pass:inbox mcp-gateis a hidden subcommand: reads the hook JSON on stdin, exits 0 for a tool named inDIFFITY_MCP_ALLOW(comma-separated, set byagentEnv), otherwise prints "the review agent may only use" to stderr and exits 2. Unit-tested on stdin fixtures.
~/.claude/skills. Theprompt's "follow the diffity-review skill" becomes "follow the review instructions in your system prompt".
mcpAllowset the prompt adds: "You may use these tools to read material the pull request refers to(a ticket, a document, a thread): . Nothing else outside this checkout." Reading is the intent;
the allowlist is exact tool names, so a write tool is only there if the reviewer put it there.
Suggested starting set for NC:
mcp__claude_ai_Atlassian__getJiraIssue,mcp__claude_ai_Atlassian__getConfluencePage,mcp__claude_ai_Slack__slack_read_thread,mcp__claude_ai_Google_Drive__read_file_content.runAgentoutput goes throughparseAgentOutput(stdout)→{ text, stats | null }: an object withtype: "result"yieldstext = resultand stats fromtotal_cost_usd,duration_ms,num_turns,usage,modelUsage,is_error,subtype; anything else is text as today.verdictOfruns ontext.The log file keeps the raw stdout and appends the extracted text so it stays readable.
subtype: error_max_budget_usdis a failed attempt with that reason.agentblock, what each built flag closes off, and thepreparemigration note.model,effort,mcpAllow(one per line),maxBudgetUsd.2. A log of every agent run, and a pause on the session limit
inbox_runs:id, pr_id, head_sha, phase ('prepare' | 'validate' | 'answer'), model, started_at, ended_at, duration_ms, turns, cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, outcome ('prepared' | 'skipped' | 'validated' | 'answered' | 'failed' | 'timeout' | 'rate-limited'), note. Same migration style as theinbox_prscolumns.store.recordRun,store.runs({ since?, prId? }),store.runTotals(since).preparePrreturns the runs it made (runs: RunRecord[]on every result kind);tickrecords them.Attendant answers are recorded through a
recordRuncallback handed torealAttendantDeps.diffity inbox runs [--json] [--since <days>]: a table of when, PR, phase, model, turns, minutes, cost,tokens, outcome, with totals; the log the reviewer keeps.
phase, model, turns and tokens); the footer gets "agent runs today: 5 · 41 min · $6.30 · 7 days: …".
/api/inboxcarries per-rowcostand the totals;inbox status --jsontoo.parseAgentOutputflagsis_error, or text matching/hit your (session|usage) limit/i, asrateLimitedwith a best-effortresetsAtparsed from"resets 2pm (Europe/Stockholm)" (fallback now + 30 min). The row goes back to
queuedwith reason"waiting: Claude session limit until 14:00", no attempt is counted, the run is recorded as
rate-limited, andrunDaemonsetspausedUntil: polling and reconciling continue, preparing does not.DaemonStatus//api/inboxexposepausedUntil; the footer says "preparing paused until 14:00 — Claudesession limit"; ↑ after the reset time clears it.
3. CI's verdict in the prompt, and an optional hold until it is green
PrSnapshotgainschecks: { name, status }[]with status normalised tosuccess | failure | pending | skipped | neutralfromstatusCheckRollup(check runs:status+conclusion; status contexts:state), duplicates collapsed by name keeping the worst, andfiles: { path, additions, deletions }[]capped at 300 — both from the
gh pr view --jsoncall already made per PR (fields work on gh 2.46).ciState(checks)→passing | failing | running | none. Store persistsci_state.waitForCi(default false; Settings panel checkbox): inreconcile, before the normal queueing andonly when the row is not bumped —
running→queuedwith reason "waiting: CI running (3 checks)";failing→skippedwith reason "CI failed: check-job, pr-mgmt-job". Both are re-evaluated every tickfrom the fresh snapshot (a "CI failed:" skip returns to the queue when the checks pass, unlike a
filter skip at the same head).
nonecounts as passing. ↑ prepares regardless.alertPaths: string[](globs viapath.matchesGlob): any changed file matching → the row is alerted,OR'd with the agent's ALERT line. "changes to datastore models" becomes
["packages/shared/src/model/**", "**/dbref/**"]and cannot be misjudged. Settings panel textarea.ci_state.Wave 2
4.
validate: a second pass checks the P1 and P2 draftspreparePr, after PREPARED and before the bundle export:agent list --jsonon the session; keepopen threads whose
severityOfis P1 or P2. None → no validation (the "simple" case is decided by whatwas found, not by size). Otherwise run the validating agent — same argv builder with
validate.model,same gate and deny list, no review skill in the system prompt — with
composeValidatePrompt:VALIDATED→ export the bundle, recompute the summary, record the run asvalidated. Timeout or noverdict → still export the draft, suffix the summary "· unchecked", record
timeout/failed; the row ispreparedeither way.validate.modelwhen set, elseagent.model: a question is about a finding, thestronger model's job.
5. Measure before switching the default
scripts/inbox-compare.ts <owner/repo#n> [--head <sha>] [--model <m>] [--effort <e>]: fetchesrefs/pull/n/headinto the clone if needed, cuts a scratch worktree and data dir, runspreparePrwiththe candidate settings, and prints two columns of findings (severity, file, line range, first sentence)
against the newest existing bundle for that head, matching on file plus overlapping lines, with each
side's cost and time from the run log.
--model opus, and with--effort mediumon thedefault model. Paste the table here.
agent.modelonly if it reproduces every P1 in the baseline and atleast four of five P2s. Otherwise the default model keeps drafting and
validatestays off.Delivery
Stacked PRs in this order, each with a patch version bump, a self-review on its own build, squash merge,
and a rebuild of the global:
inbox runs, cost on the page, rate-limit pause.checks/fileson the snapshot, CI in the prompt,waitForCi,alertPaths, CI dot.validatepass.After (1) lands: restart
diffity inbox, delete thepreparekey from~/.diffity/inbox/config.json(the built default applies), set
agent.mcpAllowif wanted, and ↑ the three rows that failed on thesession limit (#14378, #13713, admin3 #605).
Later, if the run log says the prompt is where the tokens go: a per-file digest marking generated and
lockfile paths, and CI annotations on failing checks so lint and type errors are not re-found.