Skip to content

kew epic — Epic dependency-graph build, validate, and drive

kew epic turns a set of work items into a dependency graph, then — from Phase 2a on — drives that graph: dispatching ready nodes in dependency order and mechanically merging PRs a human has already approved.

Where the epic lives depends on the configured tracker (see [tracker] configuration):

Tracker Epic membership Dependency edges
github (default) issues carrying the epic:<name> label <!-- kew:depends N M --> markers in issue bodies
linear issues in the Linear project named <name> Linear's native blocks / blocked-by relations

On Linear no kew-specific convention is needed — the blocks relations your team already maintains are the kew DAG. Auth is environment-only (LINEAR_API_KEY). The read commands (graph, validate, status) are tracker-aware; the driver commands (advance, run, retry) require the GitHub tracker — driving Linear epics is a later phase of the Linear surface spec.

All subcommands take the epic name as their first argument: the part after epic: on GitHub (an epic labeled epic:auth-rework is kew epic <cmd> auth-rework), or the project name on Linear.

kew epic plan — turn a plan into a drivable epic

Usage: kew epic plan <name> [OPTIONS]

Options:
  --from PATH           Implementation plan file to turn into issues
  --from-manifest PATH  Create from an existing manifest (skips the planning agent)
  --dry-run             Validate and preview; create nothing
  --amend               Append new nodes to an existing epic instead of refusing it
kew epic plan checkout-flow --from docs/plans/2026-07-02-checkout.md
kew epic plan checkout-flow --from docs/plans/2026-07-02-checkout.md --dry-run
kew epic plan checkout-flow --from-manifest .kew-logs/epic-plan-checkout-flow-20260702-101500.json
kew epic plan checkout-flow --from docs/plans/2026-07-02-checkout-v2.md --amend

A governed dispatch of the planning agent — the agent provides the judgment, kew provides the guardrails. The agent reads the plan, browses the codebase, and batches tasks into well-sized draft issues, written to a JSON manifest — not to GitHub. Then kew takes over:

  • the draft DAG is validated (cycles, dangling deps, plan reference in every body) before anything is created
  • kew creates the issues itself, in dependency order, applying epic:<name> plus the priority/complexity/type labels — ready is never applied (the manifest has no free-form label field)
  • <!-- kew:depends --> markers are written by kew from the manifest's depends_on, using real issue numbers
  • a mandatory validate gate re-checks the real graph after creation, then prints the Mermaid render
  • the epic is registered in the local run store the moment its issues are created, so kew epic run --all sees it before its first tick (a freshly-planned epic used to be invisible to --all until driven by hand)

Every run saves its manifest under logging.dir; --from-manifest re-uses one without re-running the agent (cheap retry after --dry-run or a mid-creation failure). Re-planning an epic that already has issues (open or closed) is refused by default — bare epic plan plans a fresh epic only. Requires the GitHub tracker, like the driver commands.

Amending a running epic. --amend appends new nodes to an existing epic through the same governed pipeline. New local_ids must not collide with the epic's existing node ids, the <=10 target_files dispatchability gate still applies, and depends_on may reference either other new nodes or the numbers of existing epic issues — kew writes <!-- kew:depends --> markers for both, then re-validates the merged graph after creation.

Commit the plan before driving. Every issue body points dispatch agents at the plan file, and dispatch worktrees are cut from origin/<base> — if the plan isn't pushed there, the agents can't read it (epic plan warns when it detects this). Review the created issues, commit/push the plan, then start the driver: kew epic run <name>.

kew epic graph and kew epic validate

Usage: kew epic graph <name> [OPTIONS]
Usage: kew epic validate <name> [OPTIONS]

Options:
  --format TEXT      mermaid or dot (graph only; default: mermaid)
  --epic-prefix TEXT  Label prefix identifying epic membership (default: "epic:")
  • kew epic graph <name> renders the dependency DAG as a Mermaid diagram (default) or Graphviz DOT, color-coded by each node's provider-derived status (done/ready/in_progress/in_review/blocked/failed/todo).
  • kew epic validate <name> checks the graph for self-edges, dangling dependencies (an edge to an issue outside the epic that doesn't exist), cycles, and orphans, and prints a topological level count on success. Exits non-zero if the graph is not a valid DAG.

Both commands are read-only — they never dispatch, merge, or write to the run store.

kew epic status

Usage: kew epic status <name> [OPTIONS]

Options:
  --epic-prefix TEXT  Label prefix identifying epic membership (default: "epic:")

Read-only snapshot of the driver's view of the epic: per-status node counts, the dispatch frontier (nodes ready to dispatch next), in-flight nodes, held nodes (approved but waiting on CI), failed/cancelled nodes, stuck nodes with their blast radius, a done/remaining count, and the tri-state signal (complete/progressing/stalled — see below). This is the same overlay/frontier logic kew epic advance uses, but status never mutates anything — no dispatch, no merge, no run-store writes.

When this epic's per-epic driver lock is currently held, status also prints a Poller: epic=<name> pid=<pid> held-since=<ts> holds epic <name>'s driver lock line so you can see who is driving it right now, from which process, and since when (and why a concurrent run is locked out). If the recorded holder's process is no longer alive the line reads has a STALE hold on …. A stale lock — a dead-PID holder, or one whose heartbeat has not been refreshed within HEARTBEAT_STALE_SECONDS (300s) — is reclaimed automatically by the next driver that tries to acquire it (the steal is logged), so a TaskStop'd driver's orphaned lock never demotes later drivers to silent observers. --takeover (see below) is only needed for a legacy lock that predates heartbeats and whose staleness can't be proven. Because locks are keyed per epic, a different epic being driven never shows up here.

kew epic status auth-rework

kew epic advance — run one driver tick

Usage: kew epic advance <name> [OPTIONS]

Options:
  --max-parallel INTEGER   Override config.epic.max_parallel for this tick.
  --takeover               Also reclaim a legacy no-heartbeat lock (a provably-stale lock is reclaimed automatically).
  --epic-prefix TEXT       Label prefix identifying epic membership (default: "epic:")

Runs a single tick of the epic driver:

  1. Acquires this epic's per-epic driver lock (see below); a concurrent advance/run on the SAME epic that can't acquire it exits cleanly, naming the live holder (epic=<name> pid=<pid> held-since=<ts>) and its heartbeat age, plus the manual workaround: its agents keep running; tick it manually: kew epic advance <name>. A different epic driving concurrently never contends for this lock. A stale holder — a dead PID, or a heartbeat not refreshed within HEARTBEAT_STALE_SECONDS (300s, a frozen/hung driver) — is reclaimed automatically: the acquiring driver steals the lock and logs the steal, so no live epic is left permanently observing a dead driver's lock. A live holder with a fresh heartbeat is never taken over. --takeover is only for a legacy lock written before heartbeats existed, whose staleness cannot be proven (e.g. a recycled PID); it still never stomps a live, freshly-ticking driver.
  2. Re-derives the graph and computes each node's status overlay (provider facts + run-store history + epic-level hold/reservation state).
  3. Merge pass: for every node with an open PR, merges it if — and only if — the merge gate below is satisfied; otherwise leaves it open, held, or marks it stuck.
  4. Dispatch pass: computes the ready frontier (deps satisfied, not reserved/in-flight/FAILED/CANCELLED/BLOCKED/IN_REVIEW/HELD) and dispatches up to max_parallel - in_flight of them, through the normal budget/cost-approval/fleet-stop gates.
  5. Prints a tick summary and releases the lock.

If the epic's tracker query resolves to zero nodes — a typo'd epic name, or running from a cwd wired to the wrong tracker — advance errors (exit 2) naming the empty query (the GitHub label or the Linear project) instead of reporting COMPLETE done=0 remaining=0. A driver never declares success for work that does not exist; a legitimately-finished epic still reports complete via its non-empty DONE set.

If Linear is the tracker and its API is unreachable — a 502/503/504 gateway page or a reset connection — the client retries with bounded exponential backoff (~3 attempts). If the outage outlasts the retry budget, advance errors (exit 2) with a classified linear-unavailable diagnostic (a transient infrastructure blip to re-run, not a stuck epic) instead of dumping a traceback. A 4xx or a Linear GraphQL error still fails fast — retrying a client-side error only wastes time.

kew epic advance auth-rework
kew epic advance auth-rework --max-parallel 2

Sample output:

epic auth-rework: tick signal=progressing
  dispatched: ['42', '43']
  merged: ['40']
  held: ['41']
  in_flight: ['42', '43']
done=3 remaining=5

kew epic run — poll wrapper

Usage: kew epic run [name] [OPTIONS]

Options:
  --interval INTEGER       Seconds between ticks while PROGRESSING (default: 60).
  --max-parallel INTEGER   Override config.epic.max_parallel for every tick.
  --all                    Drive every known epic round-robin in one poll loop.
  --takeover               Also reclaim a legacy no-heartbeat lock (a provably-stale lock is reclaimed automatically).
  --epic-prefix TEXT       Label prefix identifying epic membership (default: "epic:")

Repeatedly calls advance every --interval seconds while the terminal signal is progressing, and stops when the epic reaches a terminal state:

  • complete — every node is DONE. Exits 0.
  • awaiting_merges — the frontier is empty and nothing is in-flight, but the only remaining work is PRs open in review awaiting a human's merge (and nothing is stuck). The benign human-gate end state: a non-alarming line lists the pending PRs and it exits 0 (clean), not stalled. A human merging those PRs resumes the epic on the next run.
  • stalled — the frontier is empty, nothing is in-flight, no hold is live, and work remains that needs an operator. Exits non-zero, with the stuck-node blast radius printed, so it never loops forever on a dead DAG.

An approved PR that's still waiting on CI reads as progressing (HELD), not stalledkew epic run keeps polling instead of giving up on work that's legitimately in flight.

Exit codes

kew epic run's exit code is API — orchestrating wrappers, cron jobs, and background-task harnesses branch on it, so the codes are stable and documented:

Code Meaning What to do
0 complete — every node is DONE — or awaiting_merges — every non-done node is a PR awaiting a human's merge (or a downstream node blocked only on one), the benign human-gate end state. Nothing broken. If awaiting_merges, merge the pending PRs (listed on stdout) and the driver resumes on the next run.
2 linear-unavailable — Linear's API stayed unreachable past the client's bounded retry. A transient blip, not operator repair. Re-run once Linear recovers.
1 stalled with a failed-terminal, cancelled, held, or otherwise operator-repair node. Repair the node(s) — e.g. kew epic retry <name> <node>, or kew epic unhold <name> <item> to release a wedged hold — then re-run.

The benign human-gated merge loop (awaiting_merges) exits 0: the driver has driven the DAG as far as it can without a human pressing merge, and there is nothing broken, so a wrapper treating any non-zero as failure sees success. 1 stays the conservative "needs repair" code, and 2 is reserved for the transient linear-unavailable blip (re-run, don't repair). With --all, a per-epic awaiting_merges still contributes 2 to the aggregate so a mixed roster surfaces "some epics await merge" distinctly (see below).

kew epic run auth-rework
kew epic run auth-rework --interval 30 --max-parallel 3

Run it under a scheduler (cron, a systemd timer, a loop of your own) to drive an epic unattended — it self-terminates instead of running forever.

Like kew epic advance, if Linear is the tracker and its API is unreachable past the client's bounded retry, kew epic run errors (exit 2) with the same classified linear-unavailable diagnostic — a transient blip to re-run, never a traceback — and never enters the poll loop. The benign awaiting-merge end state is now the distinct awaiting_merges signal (exit 0), so exit 2 on a single kew epic run unambiguously means linear-unavailable.

--all — drive every epic from one loop

kew epic run --all advances every known epic round-robin in a single poll loop: each tick advances each not-yet-terminal epic once, acquiring and releasing that epic's own per-epic lock — so two epics never lock each other out and can tick concurrently. A "known" epic is any epic recorded in the local run store — that includes freshly-planned epics (kew epic plan registers them at creation time) as well as epics with driver history. At startup --all logs the discovered roster and its discovery source (the local dispatch.db), so a missing epic is diagnosable from the output. Per-epic signals are reported per line, and the loop exits only when all epics are terminal. The aggregate exit code is the worst of the per-epic codes: 1 (any epic needs repair) beats 2 (any epic is merely awaiting merge) beats 0 (every epic complete) — see Exit codes. This replaces stacking one kew epic run <name> per epic. If an epic's lock is already held by another live driver, --all surfaces the holder and skips that epic (it's being driven elsewhere) while continuing to round-robin the rest. An epic whose query resolves empty is surfaced with its diagnostic and counted as needing an operator (contributing exit 1 to the aggregate) — it is never silently dropped or read as complete. A Linear outage that outlasts the retry budget hits every epic on the roster: each is surfaced with the classified linear-unavailable diagnostic and marked as needing an operator (exit 1), and the round-robin keeps processing the rest rather than aborting on a traceback.

--all scopes itself to the active project's tracker. An epic whose recorded tracker differs from the configured [tracker] type — inferred from the shape of its recorded work-item ids (a numeric GitHub issue number vs. an opaque Linear key like SFD-12) — belongs to a different project and cannot be driven by the active tracker's sources. --all skips it with a one-line (skipped: tracker=linear) note. A skipped epic contributes to neither the STALLED classification nor the aggregate exit code — so a store that also holds seven complete Linear epics no longer makes a healthy GitHub run exit non-zero and demand an operator. An epic with no recorded items yet (freshly planned, never driven) has no inferable tracker and is driven under the active tracker rather than guessed away. If no discovered epic belongs to the active tracker, --all reports that nothing is in scope and exits 0.

When every epic is terminal, --all prints one combined fleet summary that names every driven epic under its exit classification — complete, awaiting-merges, stalled, or locked-out (driven elsewhere). The summary is printed regardless of the aggregate exit code, so a mixed-outcome fleet stays legible: the complete and awaiting-merges epics are still named even when the worst-wins exit only reflects the stalled ones needing an operator.

kew epic run --all
kew epic run --all --interval 30

kew epic retry — re-admit a failed node

Usage: kew epic retry <name> <node> [OPTIONS]

Options:
  --epic-prefix TEXT  Label prefix identifying epic membership (default: "epic:")

<node> is the GitHub issue number. Re-admits a FAILED node to the dispatch frontier without deleting its run history:

  • Clears the node's agent-failed label.
  • Releases any stale reservation.
  • Records a non-destructive retry marker so the driver's status overlay treats the stale failed run as superseded (rather than discarding it) — the node falls through to its provider-derived status (typically back to TODO, re-entering the frontier on the next tick).

Cost and token history from the failed run(s) are preserved — kew report still shows them. A run that fails after the retry marker is a fresh failure, not the superseded stale one.

kew epic retry auth-rework 87

kew epic unhold — release a driver hold

Usage: kew epic unhold <name> <item> [OPTIONS]

Options:
  --epic-prefix TEXT  Label prefix identifying epic membership (default: "epic:")

<item> is the work-item id (a GitHub issue number, or a tracker id like SFD-12). The operator counterpart to the driver's own post-terminal hold bookkeeping: when a sticky hold (typically ci_pending) wins overlay precedence and wedges the node — the case the containment arc kept clearing by hand via raw store calls — unhold releases it:

  • Clears the epic_holds row for that epic/item via the store API.
  • On a GitHub tracker, also strips the stale pr-pending-review label (mirror of the manual unwedge playbook). A non-github tracker owns node state in the tracker itself, so no label is touched.
  • Prints the cleared reason and the hold's held-since timestamp (UTC).

Exits 2 with a clear message when <item> is not a valid item id for the configured tracker, or when no hold exists for that epic/item.

This is where a hold fits the operator loop: a sticky hold makes kew epic run exit 1 (stalled, needs repair — see Exit codes). unhold is the manual escape hatch when no automatic path clears the row — see Hold semantics for the full set/clear map.

kew epic unhold auth-rework 87
kew epic unhold auth-rework SFD-12

The merge gate

The driver merges a node's PR only when both of these hold:

  1. A verified human approval. GitHub's own reviewDecision == APPROVED computation (not the mere presence of a review — this accounts for reviews GitHub has marked stale after a new push), from a reviewer who is not the PR author and, when [epic] approvers is non-empty, is in that allowlist.
  2. CI-green. The PR's effective required status checks are green, verified via mergeStateStatus == CLEAN (read from GitHub's own effective-rules computation, which understands both classic branch protection and rulesets).

An approved PR that isn't green yet is HELD (ci_pending) — not stuck — and the driver merges it automatically the moment CI turns green, so you don't have to babysit the timing. A genuinely red or pending required check (or a stale base) is a sticky hold the operator needs to look at.

The merge itself is always:

gh pr merge <pr> --repo <repo> --squash --match-head-commit <sha>

never --admin or --force (those flags let gh merge over a red or pending required check, which would silently defeat the CI-green gate above). No label ever triggers a merge — a dispatched agent's gh token could set a label on its own PR, so a label is not a trustworthy signal.

The governed-review invariant

When the governed adversarial-review stage is enabled in blocking mode ([review].adversarial = true, [review].blocking = true), the merge pass gains one more invariant, checked at merge time for every policy-covered candidate:

merge only if the latest verdict is READY verdict.head_sha == the current head SHA the approval timestamp postdates the verdict.

This is enforced at the merge pass itself, not the status label — HELD is a live merge candidate, so an approved + green-CI HELD node with a non-READY verdict is skipped, loudly, with an attested reason. It closes three holes at once: a HELD-is-mergeable bypass, a post-verdict force-push merging unreviewed code (merged-SHA must equal reviewed-SHA), and a stale pre-existing approval auto-merging (the approval must postdate the verdict, else it surfaces as "re-approve"). Nodes already in review when the flag flipped are grandfathered (PRE_POLICY) and merge under the pre-existing approval + CI rules above.

The verdict is evidence for the human approval, never a substitute — there is still no auto-merge on READY. Each round's verdict is a chain-attested adversarial_review_verdict event (see kew audit / the audit trail) binding the PR, item, head SHA, policy in force, and review-pool spend, so "did the merged code survive review" is answerable from the chain alone.

There is no --auto-merge

Phase 2a deliberately does not offer an autonomous, no-human merge mode. An adversarial design pass found the concept unsound — there is no forgery-proof way to mark a PR "safe to merge without review" (any label a dispatched agent's own credential can set is exactly as forgeable as the signal it would replace), and gating on "required review satisfied" is self-contradictory (a PR can't be CI-green under a review-required branch until it's approved, so autonomous merge would have to fake its own approval). It's also the case kew's own thesis argues against — see Philosophy: "Offload the work. Keep the judgment." The full reasoning is recorded in the design spec (docs/superpowers/specs/2026-07-01-epic-dag-phase-2-driver-design.md, "Why autonomous --auto-merge was cut").

Hold semantics — who sets and who clears a hold

A hold is an epic_holds row keyed on the epic and the work-item id. While a hold is live it wins overlay precedence and routes the node to HELD (see The terminal signal) — the driver keeps polling that node instead of dispatching or merging it. Two subsystems set holds; several paths clear them:

Reason Set by Cleared by
ci_pending The merge pass (_classify_open, graph/driver/merge.py) when an approved PR isn't CI-green — a real non-green mergeStateStatus (BLOCKED/BEHIND/DIRTY/UNSTABLE). A transient UNKNOWN is not held; it re-polls next tick. _do_merge on a later tick, the moment CI turns green and the driver merges; the out-of-band MERGED / closed-unmerged bookkeeping (_handle_merged / _handle_closed_unmerged, #816/#818) when a terminal PR kew didn't merge itself still carries the hold; or kew epic unhold.
budget_preflight:* The dispatch budget gate (#661) when preflight refuses a node over its cost cap — the reason string carries the observed/cap numbers and the knob to raise. _reconcile_budget_holds at the start of each tick, once the cap admits the node again (raise budget.<limit>_usd, or kew dispatch <n> --force); or kew epic unhold.

kew epic unhold is the manual escape hatch for either row when a hold is stuck and no automatic path will clear it — see kew epic unhold.

The terminal signal

Every tick (advance, and the read-only status) reports one of these signals:

Signal Meaning
complete Every node is DONE.
progressing Something moved this tick (a dispatch or a live in-flight run), or at least one node is HELD on ci_pending within its stall clock — an approved PR waiting on CI is legitimate progress, not a stall.
awaiting_merges The frontier is empty and nothing is in-flight, but the only remaining work is one or more PRs open in review awaiting a human's merge (and nothing is stuck) — the normal human-gate end state, not a broken pipeline. A human merging the pending PRs resumes the epic on the next run. Only advance (which runs the merge pass) emits this; the read-only status reports the underlying stalled instead.
stalled The frontier is empty, nothing is in-flight, no hold is live, and not everything is DONE — remaining work is FAILED, CANCELLED, or stuck and needs an operator.

kew epic run uses this signal to decide whether to keep polling (complete and awaiting_merges → exit 0, stalled → exit non-zero, otherwise keep going). awaiting_merges prints a non-alarming line listing the pending PRs; a stalled tick exits 1 when a node needs operator repair — see Exit codes.

Fleet isolation

The fleet dispatcher never selects an epic-labeled issue, even if it also carries ready — epic membership is a positive exclusion in the fleet's issue selector, and it also excludes epic PRs from the auto-conflict-resolver so the fleet can't force-push a branch the driver is tracking. Epic nodes are driver-owned: only kew epic advance/run ever dispatch or merge them.

Config

See [epic] in Configuration for max_parallel, merge_strategy, stall_days, approvers, and lock_path.

Tick journal

Every kew epic advance and kew epic run tick appends a changed-only snapshot of the driver state to the epic_ticks table in dispatch.db — capturing the signal, per-node statuses, counts, edges, and frontier, plus the actions taken that tick (dispatched / merged / held / stuck) and a hash of the snapshot state. The driver also stamps the epic_driver_state heartbeat table twice per tick: once at the start of the tick (after tool preflight succeeds, inside the single-driver lock) and once at the end of the tick (still inside the single-driver lock), recording the tick's duration, its terminal signal, and a reference (last_tick_id) to the corresponding epic_ticks row.

kew epic status and the read-only commands (graph, validate) never journal — they only read the driver's current view without persisting history.

The journal feeds the web UI's /epics endpoint (Phase B/C) to display a time-lapse replay of the epic's progress across ticks. Journal rows are never pruned in v1 — the full history remains for audit and replay. See the web view: ../web/epics.md.