How It Works¶
┌─────────────┐
│ Human adds │
│ "ready" │
│ label │
└──────┬──────┘
│
▼
┌───────────────────────┐
│ kew run │
└───────────┬───────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Select │ │ Create │ │ Build │
│ highest │ │ git │ │ prompt │
│ priority│ │ worktree│ │ from │
│ issue │ │ │ │ template │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
└────────────┼───────────┘
▼
┌───────────────────────┐
│ Claude Code │
│ │
│ Read CLAUDE.md │
│ Implement (TDD) │
│ Run CI │
│ Commit + Push │
│ Create PR │
└───────────┬───────────┘
│
┌────────────┴────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Success │ │ Failure │
│ │ │ │
│ Labels: │ │ Labels: │
│ pr-pending │ │ agent- │
│ -review │ │ failed │
│ │ │ │
│ Worktree │ │ Worktree │
│ cleaned up │ │ preserved │
└─────────────┘ └─────────────┘
The dispatch loop¶
In --loop mode with multiple workers, the full cycle runs continuously:
- Select -- query GitHub for
ready-labeled issues, ordered by priority (p0>p1>p2>p3), excluding any currently in-flight - Isolate -- create a git worktree per issue on a dedicated branch (
agent/issue-{N}) - Prompt -- build a Jinja2 prompt from the issue body, complexity labels, and your config
- Execute -- spawn Claude Code as a subprocess with
--max-budget-usd,--timeout, and the security hook. Before this, the approval gate (if configured) estimates the dispatch cost from run history and either prompts for confirmation (on a TTY) or holds the issue underneeds-approval— budget is law, approval is policy on top. - Stream -- parse Claude Code's
--output-format stream-jsonin real time, showing tool use, token counts, and progress - Retry -- if the agent exits with a retriable code, back off and retry (with
--resumeto reuse session context) - Complete -- on success, push the branch, create a PR, label it
pr-pending-review, and clean up the worktree. On failure, labelagent-failedand preserve the worktree for debugging.
Dispatch outcomes¶
Each dispatch ends in one of the following outcomes:
- Completed -- the agent called
finishand committed coherent work. kew pushes the branch, creates a PR labeledpr-pending-review, and cleans up the worktree. - No changes -- the agent ran but made no changes and called
finish. kew closes the issue without creating a PR. - Failed -- the agent crashed, hit a timeout, or exhausted its budget without finishing. kew labels the issue
agent-failedand preserves the worktree for debugging. - Rescued (incomplete run) -- the agent exhausted its step budget (
max_steps) without callingfinishbut left real worktree changes. kew commits and pushes them, opens a draft PR labeledincomplete-run, flags the issueagent-needs-help, and preserves the worktree. The issue is not closed and dependents are not unblocked — a human reviews the draft, then marks it ready and merges, or re-labelsreadyto retry from scratch. Only max-steps exhaustion triggers this; crashes, timeouts, and budget stops remain hard failures.
Label lifecycle¶
| State | Labels | Meaning |
|---|---|---|
| Queued | ready + priority |
Waiting for dispatch |
| Working | in-progress + agent-working |
Claude is implementing |
| Held | needs-approval |
Approval gate fired — agent paused until a human approves |
| Done | pr-pending-review |
PR created, needs human review |
| Failed | agent-failed |
Something went wrong (comment explains why) |
| Rescued | issue: agent-needs-help + incomplete-run; draft PR: agent-pr + incomplete-run |
Max-steps exhausted; draft PR opened, awaiting human decision |
The Held state occurs when the approval gate (if configured) estimates the dispatch cost at or above your threshold. kew removes in-progress/agent-working and adds needs-approval so the issue sits inert in the queue. Release it via kew approve --issue N, the TUI A key, a /approve GitHub comment, or the web inbox (kew serve).
Loop guardrails¶
Mid-flight budget watchdog. A delegated loop is stopped as soon as its
running cost — prior spend plus this session's incremental, usage-derived cost
(a list-price estimate) — crosses budget_usd, transitioning to budget_limited
(resume with a bumped budget via kew loop resume <id> --budget <n> --guidance "…").
The check is on that live token-derived running cost, not the reported
spent_usd aggregate — spent_usd is the persisted total and may lag the live
figure depending on the runner. Enforcement is one cost event late (post-hoc):
the loop is killed after the event that reports the overshoot, so the figure is
a near-cap, not an exact cap. Runners that emit no
incremental usage cannot be enforced mid-flight and fall back to the wall-clock
cap (the current native runners — claude-code, opencode, pi — all emit per-turn
usage).
Governance layer¶
The dispatch loop sits inside a control plane designed to keep autonomous runs observable and stoppable:
- Tamper-evident audit log — every dispatch event is written to a hash-chained log;
kew audit verifychecks integrity and exits non-zero on any gap. - Fleet kill switch —
kew stopkills all in-flight runs and halts dispatch immediately;kew resumere-enables it. Issues are reset toreadyso no work is lost. - Approval gates — configure a cost threshold and kew holds issues under
needs-approvalbefore the agent runs. Approve viakew approve, the TUIAkey, a/approveGitHub comment, or the web inbox (kew serve). - Outcome-linked spend —
kew reportcorrelates API spend to merged PRs, failed runs, and loop steps so you can see what each outcome actually cost. - First-class loops —
kew dispatch <issue> --loopruns an issue as a supervised multi-step loop (distinct fromkew run, which runs the whole queue). Pause or resume mid-flight withkew loop pause/kew loop resume.
The epic driver tick {#the-epic-driver-tick}¶
kew epic advance/run (see kew epic) drive a whole
epic:<name>-labeled dependency graph, one issue-per-node, instead of a flat
queue. Because it dispatches and merges without a human driving each step,
it owns a few primitives the flat fleet doesn't need:
- Single-driver lock + atomic reservation — the whole tick runs under a
non-blocking advisory lock (a file under
logging.dir); a secondadvance/runthat can't acquire it exits cleanly instead of racing. Before spawning any subprocess, the driver also reserves the node with a run-store write guarded by a uniqueness constraint, so the in-flight count is authoritative the instant a dispatch is decided — not moments later, once the subprocess has started. - Driver-owned status overlay — the driver computes a richer status per
node than the provider adapter alone can:
IN_PROGRESS/FAILEDare derived from the run-store (a live pending run, or the newest terminal run being a failure kind),DONErequires the issue closed asCOMPLETED(a not-planned close reads asCANCELLED, a terminal state that does not satisfy dependents), andHELDis an overlay-only pseudo-status for a node that's approved but waiting on CI (see the merge gate below). - Positive
epic:*fleet exclusion — the fleet's issue selector skips any issue carrying an epic label outright, regardless ofready, so the flat fleet can never grab a node the driver owns mid-flight; the fleet's conflict auto-resolver excludes epic PRs the same way. - Approval-gated, CI-green merge — the driver merges a node's PR only when
a verified, non-author human approval is on record and the PR's required
checks are green. See the merge gate for the
exact rule and why there is deliberately no autonomous
--auto-merge. - Failure containment — a
FAILEDnode is terminal for the tick (never auto-redispatched); itstransitive_dependentsare reported as its blast radius so an operator can see exactly what a failed, cancelled, or stuck node is holding up.kew epic retry <name> <node>is the only way back in. - Provider-aware node dispatch — each node is dispatched by a detached child
(
kew _dispatch-node) that adapts to the configured tracker. For GitHub the node id is the issue number and the classic issue → worktree → PR flow runs unchanged. For a non-GitHub tracker (Linear), the child instead fetches the item's title/description/labels from the tracker and claims it (assigns the viewer, moves it to the started state) before the agent spawns; the opaque item id (e.g.SFD-133) threads through the branch (agent/issue-sfd-133), worktree, run-store row, and PR body while the GitHub-only issue number stays a0sentinel. GitHub-issue label edits are skipped for non-GitHub trackers (their state is owned by the claim and the merge pass, not labels). A missing key, a fetch failure, or a claim failure is a loud exit that writes a terminal run record against the node's reservation rather than a silent skip, and a refusal before the agent spawns (fleet-stop, worker-cap, budget) rolls the Linear claim back so the item is never stranded In Progress with nothing running. - Reliable child resolution + dead-spawn diagnostic — the detached child is
spawned via the
kewconsole script next to the driver's own interpreter (the active venv'sbin/), preferred over an ambientPATHkewthat could be a stale or mismatched install, and the resolved path is logged at spawn. A child that crashes before writing its first run row (a bad import, a config failure) fails silently under DEVNULL stdio; once its reservation ages past the startup grace with no run row, the driver fails the node with a terminaldispatch_child_dieddiagnostic that names the spawned executable — instead of silently re-spawning the same broken child every grace window. - Empty graph is an error, not success — an epic whose tracker query
resolves to zero nodes (a typo'd
epic:<name>, or anadvance/runfired from a cwd wired to the wrong tracker) raises before any tick is journaled and exits 2 with a diagnostic naming the empty query (the GitHub label or the Linear project) — it no longer printsCOMPLETE done=0 remaining=0. A driver never declares success for work that does not exist;completestays reachable only for a non-empty graph whose nodes are all DONE.kew epic run --allsurfaces the empty epic as needing an operator (contributing exit 1 to the aggregate) instead of silently dropping it or reading it as complete. - Post-action tick summary — the merge pass runs against the snapshot taken
at tick start, so a node the tick itself merges or closes still reads
IN_REVIEWin that snapshot. Before reporting, the tick folds its own actions — dispatched, merged, and out-of-band-closed nodes — back onto the overlay and derives every printed field (waiting/done/remaining and the tri-state signal) from that post-action view. A close that unblocks the frontier this tick readsprogressing, notstalled, and stdout agrees with the journal on what the tick just did — the new state no longer only appears on the next tick. - Tracker-aware id rendering — every item id the driver prints (status
lines, waiting/
blocked onreasons, a failed node's blast radius) routes through oneitem_refrule: a GitHub numeric id renders#123(which GitHub turns into a live cross-link), while any other tracker's id renders bare (SFD-133). So a Linear node readsblocked on SFD-133while a GitHub node stays byte-identicallyblocked on #123.
Each tick reports a tri-state signal — complete/progressing/stalled —
so kew epic run knows when to stop polling instead of looping forever on a
dead graph. Full detail lives in kew epic.
Governed adversarial review¶
Every substantive defect that reached the merge surface across the
delegation-hardening waves was caught not by the implementing agent's own tests
(honest, green) but by a fresh-context adversarial reviewer run by hand:
same-context self-review structurally cannot see interactions with actors
outside its context. The governed adversarial-review stage makes that manual
loop a pipeline stage the control plane owns, between "an agent opened a PR" and
the human approval surface. It is opt-in ([review].adversarial = true) and
off by default.
Wiring status
The driver now drives the full per-round lifecycle end-to-end: on each tick a
review-enabled node due a reviewer has one dispatched against the recorded
head SHA, its host-stored sidecar is parsed, the round is recorded, and the
verdict is attested — then the node transitions (READY → in_review,
NEEDS_CHANGES → remediating, round cap / unparseable → HELD). The
reviewer launcher (the sandbox that actually runs the fresh-context
reviewer and writes the sidecar) is injected as a seam: a read-only,
non-pushing reviewer sandbox is the one remaining production piece, so until a
launcher is wired at the driver call site the drive pass logs loudly and takes
no action rather than silently clearing the gate. Do not enable
blocking = true in production until a reviewer launcher is wired — the
merge-pass invariant is live and a policy-covered node with no verdict blocks
forever by design. Advisory mode is fully operational with a launcher
injected; the out-of-band-merge race (attesting MERGED_UNREVIEWED) is live
regardless.
How the stage works. When enabled, a node that opens a PR enters
adversarial_review instead of going straight to in_review. The driver
records the PR head SHA and dispatches a fresh-context, sandboxed reviewer
against that recorded SHA — never the implementing session, and with the
head-ref auto-load files (CLAUDE.md, .claude/, .mcp.json, …) masked to
their base-ref copies so code under review can't rewrite the reviewer's own
instructions. The reviewer writes a structured verdict to a broker-collected
sidecar (stored on the host, not a deletable PR comment); kew parses only the
sidecar. A READY verdict carries executed acceptance evidence; a
NEEDS_CHANGES verdict carries evidenced file:line findings, which feed an
adapted kew review remediation pass (remediating) that loops back for
round N+1 against a new head SHA. Non-convergence within [review].max_rounds
(default 2) HELDs the node for a human — never a silent loop. Reviewer and
remediation runs draw from a separate attested budget pool
([review].max_review_usd), so review work can never starve implementation
retries or vice versa.
Advisory → blocking adoption path. The stage has two modes, and the recommended path is to adopt it advisory-first:
- Advisory (
blocking = false) — the verdict (or aDEGRADEDmarker if the reviewer crashed) is attested and the node proceeds under the pre-existing approval + CI rules. Degradation is legal here; nothing is gated on the reviewer. This is the on-ramp: turn it on, watch the verdicts against your real PRs, and tune the pinned prompt artifact before you let it gate anything. - Blocking (
blocking = true, the default onceadversarialis on) — a non-READYoutcome (reviewer crash after one retry, unparseable sidecar, review-budget refusal, a post-verdict force-push) all land in the same place: HELD: needs-human, with an attested reason. There is no advisory-degrade path inside blocking mode, so a PR-controlled reviewer crash cannot bypass the gate.
THE invariant (enforced at the merge pass, not the status label). HELD is
a live merge candidate — approval + green CI would merge it — so gating only the
status transition would leave a hole. Instead the merge pass gains one
invariant, checked at merge time for every policy-covered candidate of a
review-enabled epic:
merge only if the latest verdict is
READY∧verdict.head_sha ==the current head SHA ∧ the approval timestamp postdates the verdict.
This is what makes blocking mode real: it closes the HELD-is-mergeable hole,
enforces merged-SHA == reviewed-SHA at the merge itself (a post-verdict
force-push can no longer merge unreviewed code), and is the natural home of the
approval-postdates-verdict rule (a stale, pre-existing approval surfaces as
"re-approve" rather than auto-merging). A candidate that fails the invariant is
skipped with a loud driver line and an attested reason — never a silent skip.
Nodes already at/past in_review when the flag flips are grandfathered
(PRE_POLICY) and merge under the pre-existing rules; flipping the flag never
throws an approved PR back into review.
The GitHub-native gate (kew-review status check). The merge-pass invariant
enforces kew's own merge — it cannot reach the GitHub merge button. A human
(or an autonomous flow) can click "Merge" on GitHub before the reviewer finishes,
and the verdict then lands on an already-merged PR (incident #1013). To close that
race, kew mirrors the same verdict onto the PR head SHA as a kew-review commit
status ([review].publish_status = true, on by default): pending the moment
a reviewer round is dispatched, success on a READY bound to the current head,
failure on NEEDS_CHANGES or any crash-path HELD, and back to pending when
a post-verdict force-push moves the head (keyed to the exact reviewed SHA, mirroring
the invariant's head-sha binding). Posting is best-effort — a gh/GitHub failure is
logged and swallowed, never crashing the tick. Publishing the kew-review status alone does not block merges — the check only enforces the merge button once a branch-protection rule on main requires it. To make it block the merge button,
enable a branch-protection rule on main requiring the kew-review check; that
is the piece the code cannot enforce. Apply it once the check first publishes:
gh api -X PUT repos/OWNER/REPO/branches/main/protection/required_status_checks \
-f strict=true -f 'contexts[]=kew-review'
This is an additional GitHub-native gate that mirrors the verdict; it does not change the verdict contract or the kew-driven merge-pass invariant.
The verdict is an input to human approval — never a replacement for it.
Human review has repeatedly caught what the full AI chain missed, and nothing
here dilutes that gate: there is no auto-merge on READY. Approval stays
human; the chain-attested verdict is evidence for that decision — which exact
commit was reviewed, under which policy, at what spend — never a reason to
rubber-stamp. A reviewer can still run tests and lazily approve; that residual
is bounded by the human gate (and a v2 sampled second-reviewer audit), not
hidden.
Scope (v1). The stage proves itself on the epic path, where a human is
already watching the approval surface. The unattended delegation surface
(kew delegate watch) — the surface that arguably needs adversarial review
most — is deliberately out of scope until v1.1: extending the stage there
is mechanical once the shared reservation reconciler is proven, but no
unattended operation is advertised as review-gated until it actually is.
Sandboxed dispatch¶
When [sandbox] enabled = true, kew replaces the host-subprocess agent launch with an ephemeral container per dispatch. The flow diverges from the standard dispatch loop at step 4 (Execute):
- Standalone checkout — kew clones the repository into a temporary directory at the base commit of the issue branch. This self-contained checkout is the container's only volume mount — host
~/.claude,dispatch.db, and GitHub credentials are outside the mount and invisible to the container. - Container run — kew builds the
docker runargv with hardening flags (--rm,--read-only,--cap-drop ALL,--security-opt no-new-privileges,--pids-limit) and launches the agent image. The resolved runner's own provider credential is forwarded by name (-e ANTHROPIC_API_KEYforclaude-code,-e OPENROUTER_API_KEYfor an OpenRouter-backed runner) — never the value, and never another runner's key; see the per-runner credential allowlist. If that credential is absent from the host environment, the dispatch is refused before the container starts. No GitHub token is passed. - Agent commits — the agent works entirely within the mounted checkout, commits the branch, and exits. The container is removed (
--rm). - Host-brokered push and PR — after the container exits cleanly, kew runs
git push origin <branch>andgh pr createon the host, using the host's GitHub credentials. The agent never touches these paths.
The interface between kew and the image — mounted checkout, prompt-file→stdin handoff, "$@" extra args, one JSON event per line on stdout, and the reviewer verdict sidecar — is fixed and documented in the container entrypoint contract. Per-runner variation lives inside the image; kew's host-side contract does not change.
The security guard (.claude/hooks/security-guard.sh) is still active inside the container as best-effort layer 2 defence-in-depth. It is known-bypassable in-process (#502); the mount boundary is the primary containment. See Security and Configuration for details.
Prompt system¶
The dispatch prompt is built from a Jinja2 template with configurable sections:
- Safety rules -- branch protection, no secrets
- Execution strategy -- TDD workflow, subagent delegation for plans
- Token budget -- complexity-aware guidance (simple/medium/complex)
- Verification -- your CI and format commands
- CodeRabbit -- optional pre-PR review (2 rounds)
- Dispatch report -- structured PR summary template. If the agent finishes without posting one, kew posts a fail-soft fallback report (cost, tokens, duration, attempts) on the PR after dispatch, so the review surface stays consistent (#664)
Override the template entirely with prompt.template, or append extra instructions with prompt.append.