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 —readyis never applied (the manifest has no free-form label field) <!-- kew:depends -->markers are written by kew from the manifest'sdepends_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 --allsees it before its first tick (a freshly-planned epic used to be invisible to--alluntil 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 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:
- Acquires this epic's per-epic driver lock (see below); a concurrent
advance/runon 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 withinHEARTBEAT_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.--takeoveris 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. - Re-derives the graph and computes each node's status overlay (provider facts + run-store history + epic-level hold/reservation state).
- 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.
- 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_flightof them, through the normal budget/cost-approval/fleet-stop gates. - 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.
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), notstalled. 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 stalled — kew 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).
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 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-failedlabel. - 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 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_holdsrow for that epic/item via the store API. - On a GitHub tracker, also strips the stale
pr-pending-reviewlabel (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.
The merge gate¶
The driver merges a node's PR only when both of these hold:
- A verified human approval. GitHub's own
reviewDecision == APPROVEDcomputation (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] approversis non-empty, is in that allowlist. - 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:
— 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.
Related¶
- How It Works: the epic driver tick
- Design spec:
docs/superpowers/specs/2026-07-01-epic-dag-phase-2-driver-design.md