/pm:epic is PM’s primary interface for registering and modifying epics. Use it to add new work items in any lane, update their status and links, manage parent–child hierarchy, and grant execution autonomy for unattended agents. Every write operation is validated up front — unknown ids, malformed links, and cycle-forming parent assignments are all rejected with a clear error rather than silently stored.
Adding an epic
Register a new epic with theadd subcommand. Only --id, --title, --lane, and --priority are required; all other flags are optional.
--id— Unique identifier in kebab-case (e.g.auth-service-refresh). Must be unique across all epics in the project.--title— Human-readable name for the epic (quoted string).--lane— One ofopenspec | superpowers | claude-code | decision | external. Routes the epic to the correct workflow.--priority— One ofP0(critical),P1(high),P2(medium),P3(low), orP?(untriaged). Determines ordering in the NEXT UP queue.--status— Defaults toqueued. Useplannedfor roadmap items you intend to do but haven’t proposed or scaffolded yet —plannedepics appear inPROJECT.mdbut are excluded from NEXT UP.--parent— Nests this epic under an existing parent. The parent must already exist, an epic may not be its own parent, and the link may not form a cycle.--external-id— Links the epic to an issue key in a configured external tracker (e.g.JOB-506). See/pm:tracker.--external-url— Full URL to the corresponding issue in the external tracker.--plan— Repo-relative path to a markdown plan file. Used as the progress source forsuperpowers-lane epics, and the associationsyncdedups on.--spec— Repo-relative path to the design document this epic came from (0.32.0). Provenance, not a progress source: nothing is counted from it. Many epics may name one document — that is the point, since a design usually implies more than one piece of work — andverify-specsreports which documents have no epics drawn from them.--link— Validated, not just parsed: the referenced epic id must already exist, the value must split into at leasttypeandepic, and the type must be one of the known set (0.34.0). The flag is repeatable.
Link types are a closed vocabulary — and only two of them do anything
A typo’d type used to be stored silently and then read as precedent by the next agent looking atstate.json. The known set is now derived mechanically from what the engine actually reads, and a test binds it to the source in both directions.
Three bands rather than two, because known and does something are different claims.
Links already stored under some other type still load and still render — validation is on write only — and
integrity’s link-of-unknown-type check reports each one rather than guessing a repair.
A flag with no value is refused
--clear-links, --no-deferrals and --done are the three flags that legitimately carry no value. Every other flag exits non-zero when its value is missing or blank, rather than writing the epic with the field quietly absent.
The rule lives on the shared flag registry rather than in each command, so add-epic, update-epic, add-many, record-gate-review, record-cross-spec-review and release cannot answer it differently — which is exactly what they used to do: add-epic --plan with no value exited 0 and created the epic with no plan attached, while update-epic --plan refused the identical mistake a minute later.
Bulk create with add-many
To register a parent epic and its children in one atomic operation — for example, a sprint and its child tickets — author a JSON batch file and pass it with--from:
parent key is present, it is created first and each entry in epics[] defaults its parent field to it. The operation is atomic: every entry is validated up front — id format, uniqueness against existing epics and within the batch itself, lane, status, and parent refs/cycles. On any failure, nothing is written and the command exits non-zero naming the offender. A valid batch is persisted in a single write, so there is no partial-state race. Pass --from - to read the batch from stdin. The parent key is optional; a bare { "epics": [...] } batch also works.
Updating an epic
Change any field on an existing epic withupdate-epic. The id is positional.
add-epic apply: no self-parent, no cycles, known status values, and referenced epic ids must exist. On an unknown id or any invalid flag value, the command exits non-zero and writes nothing — including an unrecognized flag name, which used to silently no-op and print a false “updated” success.
The whole flag surface. update-epic accepts 24 flags, declared once in EPIC_FLAGS — the same declaration add-epic’s and add-many’s surfaces are projected from, so the three cannot drift apart. Beyond the shape above:
--lane and --plan were settable only at creation, so the sole correction for a mis-routed epic was to remove it and register it again — discarding its start time, its gate verdicts, its links and its stories. Both are now in-place field writes.
--description and --notes are distinct and neither substitutes for the other: a description says why the epic exists and what would make it worth revisiting; notes are a trail of what happened. Stories used to be the only free-text carrier an epic had, which is why four epics in the 0.27.0 audit archived with “incomplete” stories that were actually completion notes.
Attributing commits
headSha is compared against — so a late-inserted ancestor reads as a stale verdict and refuses the archive.
Stories, and the third state a checklist needs
Stories are inline progress on an epic — the highest-priority progress source, ahead of a plan ortasks.md. As of 0.31.0 they can be created atomically, so an epic arrives with its milestones instead of acquiring them one call at a time:
--add-story repeats and works on add-epic, add-many (batch entries take a stories array), and update-epic.
Work that will not be done
A checklist needs three states, not two: open, completed, and deliberately not being done.3/3 stories · 2 disposed — the same shape a lifecycle-marked task produces. Never 5/5, which would claim completion nobody earned; and never 3/5, which would leave the archive gate refusing forever.
The archive gate already refused an epic with an unticked story — that has been true since dispositions shipped, because outstanding work is read from stories first. What was missing was a way out for work that was genuinely dropped: the refusal pointed at a lifecycle marker that lives on a task’s line, and an inline story has no task line to carry one.
--wont-do is that key. No new refusal was added in 0.31.0.Ending an epic — the disposition
An epic, a story, a deferral, or a release exclusion ends by recording a terminal disposition carrying its required reason — never by removing the record. Deletion removes the record of projected work, which is precisely what a disposition exists to preserve. The archive verb takes two halves in one invocation, because the gate refuses either half alone:--no-deferrals is the explicit “there are none” and is a claim, not a default — swap it for --deferral "<epicId>:<section>" where work is now held by a registered epic, or --declined-deferral "<what>:<why not>" where you are deliberately not doing it. The engine does not read your artifacts and will not guess.
The gate also refuses to archive an openspec-lane epic as delivered without a passing, non-stale Gate 2, and demands --carried-to <epicId> --reason "<which tasks moved>" where outstanding work remains. killed, superseded, and abandoned are exempt from both demands by design: the code was never written or was thrown away, and the required reason already answers where the work went.
An engine-written disposition — the migration’s stamp, the archive-drift heal’s — may be replaced by an agent recording a real one. Another agent’s recorded judgment may not, by the ordinary route.
Correcting one — --correct-disposition
A mistyped outcome, or one recorded without its reason, used to have no correction verb at all and forced a hand-edit of state.json. As of 0.32.0:
- Deliberate. It is never reachable by re-running the ordinary verb, which still refuses — it now names this route instead of dead-ending. It is refused outright when there is nothing to correct: an engine stamp is replaced the ordinary way, and an epic with no disposition has nothing to supersede. That is what stops it becoming “any agent may overwrite any judgment.”
- Self-describing. The flag carries its own justification, so a correction cannot be recorded without saying why.
- Non-destructive. The prior record survives verbatim under
disposition.superseded— one level deep, the same shape and caprecord-gate-reviewalready uses. Every surface renders· corrected (was <prior outcome>), so a correction is visible where people read rather than only in the JSON.
The engine deliberately does not gate on who. An agent’s disposition carries no identity by construction — the absence of
recordedBy is precisely what marks a record as an agent’s — so “only the agent who recorded it may correct it” would be unenforceable rather than strict. What the engine can require, and does, is that a correction leave evidence that a correction happened.A time window was considered and rejected: a wrong delivered noticed next week is exactly as false as one noticed in the same minute.remove-epic stays available and ungated for what it is actually for: an epic registered in error, a duplicate, a mistake made a minute ago — where there is no disposition to record because there was no work.
--link replaces the epic’s links wholesale — unlike the other flags, which patch a single field, passing --link discards all existing links and stores only the ones you provide in this call. This is the intended path for fixing a malformed link without touching state.json directly. Pass every link you want the epic to have; omitting --link entirely leaves existing links untouched.
--review-mode can escalate a single epic above the repo’s bounded review dial (off → standard → thorough), but never de-escalate below it.
Removing an epic
Hard-delete an epic withremove-epic. The only prior recovery path was a raw git checkout on state.json; this command replaces that workaround.
- Blocked by default if the epic has any descendants. The command prints a
(id, title, lane, priority, status)table of the parent and every descendant at any depth, then exits non-zero. Reassign or remove the descendants first, or use--cascade. --cascaderemoves the epic and all its descendants together in one atomic write. The preview table from the blocked attempt and the--cascadeblast radius always agree — confirming from that table is confirming the real deletion set.- Dangling links are cleaned up automatically. Any other epic’s
links[]entries that reference the removed id are stripped, and the command reports which epics were affected. - If the removed epic was the active epic, the
.activepointer is cleared automatically. - A removal now survives the next sync (0.31.0). Removing an epic that claimed a plan writes a tombstone recording that the artifact must not be re-registered — otherwise the next
/pm:syncwould find the orphaned plan on disk and register it right back. Attaching that plan to an epic (update-epic <id> --plan <path>) clears the tombstone.
Setting the active epic
The top-level.active pointer (what the briefing’s “NOW” line reads) has dedicated verbs. Never hand-edit state.json for this.
set-active sets .active = <id> and the epic’s status: "active" together, demoting any previously-active epic to queued — so the pointer and status can never disagree. It rejects an unknown or archived id. update-epic <id> --status active keeps them in sync too, and moving the active epic off active clears the pointer automatically.
Granting autonomy
Before an epic can run unattended through phase transitions and destructive actions, it needs a preflight scan and your recorded answers. Useset-autonomy to capture the results.
--level autonomous— Enables unattended execution. The default isoff(today’s behavior, unchanged). Pass this only after all preflight questions are answered and recorded.--preauthorize— Pre-approves a specific action (e.g."drop-scratch-table:reason") or an entire category ("category:<name>:<reason>"). Valid category names arefilesystem,network,schema, andexternal-api. Any category name outside this list is rejected, so a typo fails loudly. The flag is repeatable.--context— Records a piece of background supplied during the preflight scan. Repeatable.--notify— Records a WARN-class decision durably alongside the autonomy grant. Repeatable.
--preauthorize, --context, and --notify are additive — re-running set-autonomy on the same epic appends to the existing entries and never clobbers them. Only --level replaces.
Recording a gate verdict
--base-sha/--head-sha record the commit range the review actually covered, and --reviewer records who performed it — as fields, not as prose in a note. A review of a..b on an epic that later shipped b..c used to be byte-identical in state.json to one that covered everything; recorded as fields, the range is what later tells a covering verdict from a stale one.
Verdicts recorded before these fields existed carry a free-text note instead. They load unchanged and are reported as carrying no checkable evidence — never deleted, never rewritten, never mined for a range by parsing that note.
An archive that reached archived with no review at all records verdict: "ungated" rather than nothing. That is a standing condition, surfaced by the briefing and by integrity until a real passing verdict supersedes it — and the superseded entry is kept, not destroyed.
update-epic --status archived --outcome delivered on an openspec-lane epic requires a passing, non-stale Gate 2 verdict already recorded via record-gate-review. If it is missing, fail, ungated, or stale, the transition is rejected with a clear error naming what’s missing. As of 0.27.0 this gate binds every path that can reach archived — including the archive-drift heal reached from upgrade, render, the commit nudge, and sync — not just this verb. Non-openspec epics are unaffected.
