Skip to main content
/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 the add subcommand. Only --id, --title, --lane, and --priority are required; all other flags are optional.
Flag reference:
  • --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 of openspec | superpowers | claude-code | decision | external. Routes the epic to the correct workflow.
  • --priority — One of P0 (critical), P1 (high), P2 (medium), P3 (low), or P? (untriaged). Determines ordering in the NEXT UP queue. Anything else is refused by name and nothing is written (0.50.0) — --priority banana used to be stored as a band of its own that ranked last. A record an older engine wrote with another value still loads.
  • --status — Defaults to queued. Use planned for roadmap items you intend to do but haven’t proposed or scaffolded yet — planned epics appear in PROJECT.md but 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. One tracker item maps to one epic (0.50.0): a URL another epic already holds — archived ones included — is refused, naming that epic and its status. With no URL on either side, the bare --external-id is the key. The URL is compared exactly after trimming surrounding whitespace, so record the tracker’s canonical URL.
  • --plan — Repo-relative path to a markdown plan file. Used as the progress source for superpowers-lane epics, and the association sync dedups 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 — and verify-specs reports which documents have no epics drawn from them. A path inside an OpenSpec change (openspec/changes/<name>/… or openspec/changes/archive/<dir>/…) also records which change the epic owns, whatever the epic is called (0.51.0, #200): integrity’s archive-directory-has-no-epic and sync’s archive backfill both read it, so a change proposed under a different name than its premise epic is held by that epic instead of reported as unheld or registered a second time. --clear spec / --clear plan is the inverse. --plan and --spec also refuse a trailing-slash value and a path that exists and is a directory.
  • --link — Validated, not just parsed: the referenced epic id must already exist, the value must split into at least type and epic, and the type must be one of the known set (0.34.0). The flag is repeatable.
A typo’d type used to be stored silently and then read as precedent by the next agent looking at state.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.
resolves-blocker-for is not a synonym for depends-on. The detour protocol puts it on the detour naming the parent, so the equivalent depends-on edge points the other way. Rewriting one into the other would invert an edge the queue ordering acts on.
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 value may begin with two dashes (0.41.0)

Until 0.41.0 no flag could take a value starting with --, and quoting did not help — the shell strips the quotes and the parser saw a token beginning with --. That landed squarely on the inward tracker-sync line PM’s own instructions specify verbatim, add-epic --id gh-<n> --title "<issue-title>" …, because issue titles routinely begin with a flag name — a bug report about a flag usually is titled that way. And it failed in the worst available shape: the message said “unknown flag(s)” and listed the title, pointing a reader at the flag spelling rather than the value; the sync run continued; and the item was silently absent from the backlog with no trace in the record. Two forms now work:
A token shaped exactly like a flag is still a flag, wherever it sits. --title --help leaves --title with no value and is refused — correctly, since a value-bearing flag without a value must never write the field quietly absent. What changed in 0.41.0 is that it now exits non-zero and names the token, instead of printing help and exiting 0 having written nothing. Use the = form for that case.

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.

The command line — help, quoting, --force (0.44.0)

Every engine verb on this page is held to one pre-dispatch check, which runs before anything is written:
  • --help or -h anywhere after the verb prints that verb’s help, exits 0 and writes nothing — remove-epic e2 --help removes nothing. In a flag’s value position it is refused instead, because there it is data: conductor: --title requires a value — '--help' arrived where that value belonged and was read as a flag, not as the value. If it IS the value, write --title=--help
  • An undeclared flag is refused by name, with the flags the verb does accept.
  • Quote every multi-word value. A token no flag consumed is a positional, and one beyond what the verb reads is refused before anything is written: conductor: add-epic takes no positional arguments — 'Title' is an extra argument it does not read. Nothing was written. followed by If 'Title' belongs to --title's value, quote the whole value.
  • An epic id is positional wherever a verb takes one. remove-epic --id e2, set-active --id e2 and the rest are diagnosed with the line you meant, not only on update-epic.
  • An id the engine prints is shell-quoted, and a broken one yields no command (0.45.0). Every invocation the engine prints that names an epic or release id goes through printedId(): an id outside the id format (a legacy My Plan) is shell-quoted so the printed line runs as written, and an id holding a control character prints no command at all — <kind> '<escaped id>' holds a control character; no verb can rename it — rather than a hand-edit instruction. A non-id value in a printed command (a session name, a spec path, a plan path) takes a placeholder when it holds one. Relatedly, sync no longer registers a change directory, plan file or archive directory whose name holds a control character or whitespace as an epic id: it registers the rest and names the skipped one on stderr on every run, quiet included. Rename it to register it. As of 0.50.0 every registration path applies the same id rule add-epic does — an epic id must match ^[a-z0-9][a-z0-9._-]*$ — so sync and the archive backfill skip a name that fails it (x|y, .hidden, an uppercase plan file) and say so (see /pm:sync).
  • --force is accepted on every mutating verb here — add-epic, add-many, update-epic, reorder, remove-epic, set-active, clear-active, set-autonomy and record-gate-review — and refused on read-only verbs. It belongs to the guarded write of .conductor/state.json, not to any verb’s parser, so it may stand anywhere on the line and is never read as an id. add-epic, update-epic and claim used to refuse it outright. node "$ENGINE" <verb> --help lists it under “Accepted on every mutating verb”.

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:
When a 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. add-many saves what it accepts or refuses it by name (0.50.0). Four kinds of input used to vanish with exit 0; each now refuses the whole batch, and nothing is created:
  • The document itself is checked. It must be a JSON object whose only keys are parent (one entry object) and epics (an array). A misspelled "epic": [...] used to create the parent alone and exit 0.
  • links is an array, and each element is either a "<type>:<epic>[:<reason>]" string (the --link grammar) or a {"type": …, "epic": …, "reason": …} object — nothing else. The target must be an epic already in the record or an entry of the same batch (earlier or later), and the type must be a known link type. A string link used to be dropped, a link to a missing epic stored dangling and a target-less link stored unrenderable.
  • A tracker item maps to one epic. An entry whose externalUrl (or, with no URL on either side, externalId) is already held by an epic in the record — archived ones included — or by an earlier entry of the same batch is refused, naming the holder.
  • externalUpdatedAt and priority follow the same rules as the flags below: an ISO-8601 date-time with a zone, and one of P0–P3 or P?.
A batch key is not a command-line flag. add-many --from b.json --external-id X is refused before the batch is read (unknown flag --external-id for add-many — it accepts: --from, --force); it used to create the batch and drop the flag.

Updating an epic

Change any field on an existing epic with update-epic. The id is positional.
All validations from 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, and an unquoted multi-word value: update-epic e1 --title My Title stored My and now refuses 'Title' as an extra argument, with a hint to quote the whole value. Both refusals happen before dispatch and apply to every verb on this page — see “The command line” above. The whole flag surface. update-epic accepts 35 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

The engine infers attribution from nothing — not the files a commit touches, not an epic id in a message — so an unattributed commit is one the epic’s Gate 2 cannot be checked against. The flag repeats because a non-repeatable flag is overwritten on each occurrence, which would silently turn two hashes into one and destroy the order that gives the array its meaning.

Withdrawing one — when the commit it named is gone

Append-only is right and stays: order carries meaning. But cannot be reordered is a different claim from can never be corrected. A git reset is an ordinary operation, and attributing at the moment of each commit means an attribution can outlive its commit through no error of process — after which integrity reports a sha the repository cannot resolve, forever. The withdrawal is recorded, not erased: withdrawnCommits keeps {sha, reason, withdrawnAt} beside the array rather than inside it, where sha is the stored entry removed. It removes one occurrence per request, because the array does not de-duplicate. As of 0.44.0 a value that resolves matches by commit identity — a full sha withdraws a legacy short entry of the same commit — and one that does not still withdraws an entry spelled exactly so, because the records that most need withdrawing are the legacy ones that no longer resolve.
Withdrawing every attribution does not clear a Gate 2 obligation. An epic that attributed, then withdrew, and now attributes nothing reads attribution-withdrawn — not none-attributed — and the archive gate refuses it by name. A withdrawal says a sha was wrong; it never says the work was never done.
Catch up on work already in flight in landing order, then attribute forward. The array is append-only, and every attributed commit must be reached by a recorded Gate 2 headSha — equal to that head or an ancestor of it — whatever its position; one the head does not reach reads as a stale verdict and refuses the archive. Through 0.43.0 only the LAST entry was compared, so an ancestor attributed after an uncovered descendant read fresh. The array is a set read against the head, not a sequence (0.51.0, #216). A catch-up of an earlier commit appends an ancestor after its descendants, so the last entry is not the tip, and nothing the engine reads depends on position. The stale-Gate-2 remedy no longer tells you to record --head-sha <the last attributed commit>: it names the attributed commit every other one is an ancestor of, and the parent of the earliest attributed commit as the base. integrity’s bookkeeping arm dates “the merge commit” by the latest commit instead of the last element. Re-attributing the head to “repair” the order is unnecessary, and is now a no-op. Commits made after a recorded Gate 2 (0.51.0, #205). A lifecycle commit a required task makes after the gate (lessons routed by item 7, the task-list tick) has two wrong records if attributed blindly: it stales the verdict, or the array omits a commit its own task list claims. The rule is ordering. Commit such work before recording Gate 2 where you can; where you cannot, do not attribute it. When an --attribute-commit flips a passing Gate 2 from fresh to stale, the engine says so on stderr and names both ways out: re-record Gate 2 if the commit changed the implementation, or --withdraw-commit <sha> --withdrawal-reason "<why>" if it was bookkeeping.

Every commit value is resolved when it is written (0.44.0)

--attribute-commit, --withdraw-commit and record-gate-review’s two range bounds (below) resolve the typed value against this clone’s object database and store the full object name — so HEAD, a short sha or an annotated tag records the commit it names at that moment. A value that does not resolve to exactly one commit refuses the invocation and names every such value:
--withdraw-commit is the exception that does not refuse: it resolves only to match by identity, and falls back to exact spelling. Stored values are never rewritten by a migration.
Never attribute the commit that moves openspec/changes/<id>/ under archive/, or any commit that only relocates or deletes a change’s artifacts. That move lands after the reviewed range by construction, so attributing it makes the epic’s own Gate 2 stale at the instant the archive gate reads it.

Stories, and the third state a checklist needs

Stories are inline progress on an epic. As of 0.50.0 they count together with the epic’s task source (a plan file, or an openspec change’s tasks.md) — neither hides the other. 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.

Correcting a story recorded against the wrong index (0.52.0, gh#176)

--story <n> is 1-indexed, while stories[] in state.json is a 0-indexed array, so reading a position off the file and passing it unchanged lands one story early.
This reopens the story and keeps the original under stories[n-1].corrections[]; the call may carry --done or --wont-do to apply the right mutation at once. --help now renders --story n, 1-indexed (NOT the 0-indexed stories[] position in state.json), and a successful --story write echoes the story it landed on. The replacement guard is unchanged, and its refusal names this path.

Work that will not be done

A checklist needs three states, not two: open, completed, and deliberately not being done.
The reason is required, and a recorded disposition is never silently replaced. The row and its reason always survive — deleting the story would destroy the record that the work was ever projected, which is exactly what a disposition exists to preserve. A disposed story leaves both sides of the ratio, rendering 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 inline stories are counted as outstanding work. 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.
Stories and a task source count TOGETHER (0.50.0). An epic’s progress is the sum of its inline stories and its checkbox source (a plan file, or an openspec change’s tasks.md — the ARCHIVED one once /opsx:archive has moved it); neither hides the other, so adding a story to an epic that has a tasks.md leaves every task counted, and the ratio then reads N/M items. Before, one story made the tasks unread, so an epic could be archived delivered at 1/1 with tasks open. Where both parts hold open work, the archive refusal names each part’s remedy and says BOTH must be done — --story <n> --done / --wont-do for the stories, ticking or the lifecycle declaration for the tasks — or --carried-to for the whole remainder. A missing tasks.md or plan now warns even when the epic has stories.

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. A reference a disposition stores must name a real, OTHER epic (0.46.0). --carried-to and the EPIC half of each --deferral are validated BEFORE the archive gate decides, and each refuses — writing nothing, and naming which id — when it is:
  • empty — an assertion that claims a reference and then declines to say what it points at is not the sayable form of “there are none”;
  • unknown — no epic in the record carries that id, so the work would be recorded as handed to nothing;
  • the archiving epic itself — an epic cannot hand work to itself. The self-reference previously SATISFIED the handoff obligation while conveying nothing, which is exactly what that gate exists to stop.
It binds wherever the reference is SUPPLIED, not only where the handoff is demanded: a receiver named alongside --outcome killed is still a claim about where work went, and a false one is no less false for the company it keeps. The --deferral ARTIFACT-SECTION half may be empty and stays that way; --declined-deferral’s <what> is free text and never an epic id, so none of this reaches it. integrity reports the two shapes a write-time refusal cannot reach — a self-reference and an empty id already on disk. The disposition flags are recorded only inside an archive (0.44.0). --outcome, --reason and --carried-to supplied without --status archived were dropped while the command reported that every supplied value was already held — false, and the epic carried no disposition. They now share the deferral flags’ refusal, before anything is loaded or written; --correct-disposition keeps its own. Real output, on an unarchived epic:
Two of those six keywords are easy to confuse and answer different questions:
  • declined — somebody considered this work and decided not to do it. The reason says why not. It has been accepted by the engine since it shipped while reaching none of five documented surfaces, including a test that asserted the four-value list and passed by prefix match — so the guard for exactly this drift was blind to it. Repaired in 0.40.0, and every enumeration now renders from AGENT_OUTCOMES.
  • unreconstructable (0.40.0) — somebody looked for the evidence of what happened and it does not exist. Distinct from unknown, which the engine stamps and which says nobody looked.
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.

The gate decides on the record the call writes (0.43.0)

The gate decides on the record the call writes, not the one it started from. It runs after every field write and --clear unset in the same invocation, so --lane, --attribute-commit, --add-story, --withdraw-commit and --story <n> --done all count. Through 0.42.0 it ran first, and one call could get past it: --lane openspec --status archived --outcome delivered --no-deferrals on a claude-code epic with no Gate 2 archived it as a delivered openspec-lane epic, while the same flags split into two calls were refused. The reverse held too: --story 1 --done --status archived … was refused for the outstanding story that same call marked done. It is now accepted. Leaving the openspec lane in the archiving call (--lane claude-code --status archived …) is accepted as well, because two calls already got there. A refused call also announces nothing. The sync-ignore tombstone clear, the rank clear and the --clear notes print only once the write is going to happen.

An archived delivered epic may not regress (0.43.0)

An update to an archived delivered epic may not break an obligation its archive met. An update that does not archive never reaches the gate, so on 0.42.0 update-epic <id> --lane openspec on an archived delivered claude-code epic left a delivered openspec-lane record with no Gate 2, and integrity named it nowhere. The engine now checks the Gate 2 demand and the handoff demand before and after the call, one obligation at a time, and refuses where one that was met now fails. The check runs when all three hold:
  • the stored outcome is delivered, the only outcome with obligations;
  • the call carries no --status archived (that call goes through the gate instead);
  • the record will still be archived when the call returns. Either the stored status is archived and the call carries no --status, or the change directory is archived on disk. In the second case a non-archived --status queued or --status active does not help, because the render at the end of the call re-archives the epic whatever status it wrote.
An obligation that already failed is no ground for refusal, so --notes, --link and --priority stay editable on a legacy record that never met it. It cannot hide a different one, though. On an epic whose handoff already fails, withdrawing its only attribution is still refused for the Gate 2 demand it breaks. A genuine unarchive, with nothing archived on disk to undo it, is not refused. Real output, on an archived delivered claude-code epic whose disposition an agent recorded:
The message is its own, never the gate’s cannot archive …, and the one runnable invocation is the only line beginning update-epic. It is built from the refused call’s own tokens, single-quoted for a POSIX shell:
  • --status and every disposition flag (--outcome, --reason, --carried-to, --correct-disposition, the deferral flags) are dropped with their values. A dropped non-archived --status is said out loud, with the reason.
  • It adds --status archived --outcome <…> --reason "<why>".
  • It adds --correct-disposition only when the recorded disposition is an agent’s. An engine stamp (the migration’s, the heal’s) is replaced the ordinary way, and the correction flag is refused against one.
  • It adds <--no-deferrals | --deferral "<epicId>:<section>"> only when the epic carries no deferral assertion. It never prints a bare --no-deferrals, which is a claim and not a default.
  • A value carrying a newline or another control character is not echoed. Its place shows <re-enter this value>, the refusal names the flag, and the invocation stays one line. User-supplied values on the broken: line, such as story titles, are JSON-quoted.
Filled in and run, that invocation goes through the full archive gate on the record it leaves, so it cannot archive anything the gate refuses. Recording a passing Gate 2 before the lane switch leaves the obligation met, and then no disposition is needed. The check compares against the record just before the call, so it is a ratchet. On a record whose Gate 2 already failed, --lane claude-code is accepted, and --lane openspec straight after is refused, even though that restores the earlier record. What this deliberately does not bind. Four paths can still leave an archived delivered record failing an obligation with no gate involved, and refusing at any of them would falsify the record or block unrelated work. The standing condition is held by the registered epic archived-delivered-gate2-regression-report:
  • record-gate-review recording a fail, or a pass whose headSha does not cover the attributions. A verdict is evidence.
  • The archive-drift heal re-archiving an epic that genuinely left the archive and changed while open. The heal reflects disk.
  • remove-epic <receiver> stripping disposition.carriedTo from an epic with outstanding work. The pointer would dangle otherwise.
  • Unticking a task on disk, which is not a verb.
Also unchanged: update-epic <id> --status queued on an epic whose change is archived on disk still prints updated while the heal leaves it archived (held by status-write-undone-by-heal-reports-updated), and add-epic/add-many at archived stay stamped, not gated. 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:
Three properties, each doing a specific job:
  • 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 cap record-gate-review already 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 adds to the epic’s links rather than replacing them. Until 0.40.0 it discarded every existing link and stored only the ones supplied in that call — so adding a second link meant re-passing the first, and forgetting to was a silent loss of an edge nothing reported. A link’s identity is its type and target; the reason is not part of it. So re-supplying an identity that is already stored updates its reason in place rather than duplicating the edge or silently discarding your correction, and a wholly identical link is left byte-for-byte untouched.
--clear-links and --link are no longer mutually exclusive. The documented repair for a malformed link — empty the array, then write the correct set — used to require two invocations, leaving a window in which the epic had no links at all. It is now one write.

Clearing a nullable field — --clear <field> (0.40.0)

Eight fields could be set and never unset. --clear is the uniform unset path, and it is repeatable:
The accepted set is derived from a nullable: true marker on the flag registry, not typed into the verb — so a nullable field added later with no clearing path fails the suite rather than shipping silently. Rows deliberately not clearable carry a setOnly: "<reason>" marker and are refused by name; links is one of them, because --clear-links already owns that job.

Registration and last-touched dates (0.40.0)

No flag sets either. Every epic registered from 0.40.0 on carries createdAt, stamped by pushEpic() — the single sink every creation surface routes through, so a new surface inherits the stamp instead of having to remember it. touchedAt advances inside saveState() on any write that genuinely changes the epic’s stored content, and on no write that changes nothing.
Absence means UNKNOWN — never today’s date, and never another field’s value. An epic registered before 0.40.0 has no registration date. recover-created-at (see /pm:upgrade) backfills it from the commit that first introduced that id into .conductor/state.json, and leaves it absent where this checkout holds no such evidence — no git, an untracked state file, a shallow graft, or an id older than the history fetched. The verb is re-runnable precisely so a checkout that later fetches more history recovers what it could not see before.
As of 0.41.0 a recovered date can be corrected. The recovery deliberately never overwrites a date already present — so a re-run after fetching more history cannot clobber a good answer — and until 0.41.0 nothing removed one either. Together that made a wrong date permanent, correctable only by the hand-edit this tool forbids everywhere else. And it can be wrong: a reused id dates to the first registration, and a wholesale state.json rewrite or a history rewrite dates every epic to that event.
touchedAt is deliberately not clearable, and its registry row says why: the engine re-stamps it on the next write that changes the record, so clearing it would be undone immediately and answers no question. --review-mode sets a single epic’s own review value, above or below the lane’s and the repo’s (0.51.0): the effective value is resolved most-specific-wins, epic then lane then repo dial, and is not a maximum. The old escalate-only refusal is gone by decision; what replaces it is visibility — profile --epic <id> and rules --epic <id> name the layer each value came from and the higher value an epic lowers. See /pm:profile and /pm:review-mode.

Removing an epic

Hard-delete an epic with remove-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.
  • --cascade removes the epic and all its descendants together in one atomic write. The preview table from the blocked attempt and the --cascade blast radius always agree — confirming from that table is confirming the real deletion set.
  • --cascade takes no value, in either spelling (0.44.0). remove-epic p --cascade true used to be accepted; now true is an extra argument (conductor: remove-epic takes <id> — 'true' is an extra argument it does not read. Nothing was written. then --cascade takes no value.), and --cascade=true is refused naming --cascade. Write --cascade on its own.
  • A detour a paused epic still owes a reconcile against cannot be removed (0.44.0) — removing it would strip the link the verdict must name. Answer it with record-reconcile first; see /pm:resume.
  • 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 .active pointer 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:sync would 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.
remove-epic is a hard delete — recoverable only via git history. Before using --cascade, review the blast-radius table that the blocked attempt prints and confirm explicitly. The engine has no interactive prompt; that confirmation step is your responsibility.

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. The Gate 1 activation guard (0.52.0). An openspec-lane epic may not be moved INTO active while its change is on disk (openspec/changes/<id>/) and it holds no passing Gate 1 (spec review) verdict: absent, failed or withdrawn. Doors: set-active, update-epic --status active, and add-epic / add-many creating at active; the refusal names record-gate-review <id> --gate 1 … and the deliberate way past it:
The reason is required, it is openspec-lane only, and one call waives and activates. Inverse: --clear gate1-waived. The guard finds the change by the epic’s id or by the directory its --plan / --spec points into, and update-epic judges it against the lane the call sets. Not refused: an epic with no change on disk yet (activating it is how the change gets written), an epic already active, and a stale-but-passing Gate 1. push-detour and pop-detour warn instead of refusing. A stale Gate 1 is reported by integrity as gate-1-verdict-stale: it reuses the artifact-digest check behind the ⚠ stale marking, names the changed artifact files and record-gate-review <id> --gate 1 … to re-record, prints its count line even at zero, and never refuses. A legacy verdict with no digests reads ⚠ unverifiable and is not reported. Moving the pointer off an epic that owes a reconcile warns and keeps the obligation (0.44.0). Every verb that moves .active — set-active, clear-active, update-epic, add-epic/add-many creating an active epic, and pop-detour (off the detour) — prints this after its save; push-detour parking the epic is the one exemption. The guard blocks again as soon as the epic is active:
Through 0.43.0, clear-active or set-active <other> silently erased the obligation.

Granting autonomy

Before an epic can run unattended through phase transitions and destructive actions, it needs a preflight scan and your recorded answers. Use set-autonomy to capture the results.
Flag reference:
  • --level autonomous — Enables unattended execution. The default is off (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 are filesystem, network, schema, and external-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.
All of --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.

Taking a grant back — --revoke (0.46.0)

--preauthorize shipped with no inverse: grants accumulated, and turning autonomy off left every one intact so that turning it back on silently restored them all. --revoke is that inverse.
  • --revoke "<action>" / --revoke "category:<name>" — Takes a grant back. The inverse --preauthorize never had. Repeatable.
  • --revoke-reason "<why>" — Required by --revoke; refused on its own.
A revoke RECORDS rather than deletes. The grant stays readable carrying its revocation and its required reason, matching --withdraw-gate-review and linkOnce’s superseded — so “authorised and then taken back” is distinguishable from “never authorised”. A revoked grant is never honoured and never restored. --revoke names the STORED value after the same first-colon split --preauthorize applies, so whatever a grant stored is exactly what revokes it; it refuses, writing nothing, for an empty action, for an action the epic does not hold, and when every matching grant is already revoked. A revoke marks only the UNREVOKED matches — re-granting is the documented un-revoke, so an epic can legitimately hold a revoked and a live entry for the same action at once. --level off does not clear grants, and that is deliberate: deletion is not the inverse of granting, and an epic taken off autonomy for an afternoon has not withdrawn anybody’s judgment about which actions were safe. The measured harm was never that grants survive — it was that re-arming restored them SILENTLY. So --level autonomous now reports the count and identity of the live grants it arms, and says so explicitly when there are none. A revoked grant is not restored by re-arming and does not appear in that report — which is why the hierarchy executor’s decision rule reads “already covered by a live pre-authorized grant” as satisfied by no revoked grant and by no grant that names nothing.

Recording a gate verdict

One form per gate (0.45.0). A --gate 1|2 --verdict pass with no evidence exits 1, and a sha range recorded against Gate 1 records the wrong kind of evidence entirely. Gate 1’s form is below; every doc that taught a single combined form was corrected.
Gate 1 records artifact paths, not a commit range (0.41.0). A passing verdict used to require --base-sha/--head-sha on either gate — but Gate 1 reviews spec artifacts by path, before code exists, so the only way to satisfy it was a range of artifact commits, which every downstream consumer then reads as an implementation range.
--artifact repeats. A gate-2 pass still requires the sha pair, unchanged. Making the shas merely optional for Gate 1 was considered and rejected: a correct Gate 1 would then render ⚠ no checkable evidence, which moves the wrong record one step along rather than repairing it. Paths are recorded as given. As of 0.51.0 (#198) a Gate 1 verdict also records a sha-256 of each artifact it could read, as artifactDigests beside the unchanged artifacts list (Gate 1 only; Gate 2’s evidence is its commit range). The engine computes it; no flag accepts one. Amend a reviewed artifact afterwards and the rendered Gate 1 cell reads ⚠ stale; an artifact unreadable at record time gets no digest (said on stderr) and reads ⚠ unverifiable; a verdict recorded before digests existed renders as it always did, never stale and never unverifiable. A live change’s tasks.md is a progress ledger: it stays in the artifact list but is never digested, so ticking a checkbox does not stale the verdict. /opsx:archive moving the change under archive/ is not an amendment, and Gate 1 staleness never blocks an archive (only Gate 2 does).
--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. Both bounds are resolved when recorded (0.44.0). --head-sha HEAD stores the commit HEAD was at that moment, in full. A bound that is not a commit in THIS clone refuses the whole invocation — root, or a sha from a clone that does not hold the reviewed range:
So a clone that does not hold the reviewed commits cannot record a range over them — after a squash-merge that is any clone without the presquash/* tags (one cloned without tags, or with a single branch only, for instance). Record Gate 2 from the authoring clone before the squash-merge, or fetch the presquash/* tags first. Fresh means the head reaches EVERY attributed commit. A verdict reads ⚠ stale on every surface, and refuses a delivered archive, when any attributed commit is not equal to or an ancestor of headSha — including a head on an unrelated branch, which reaches none:
A legacy value stored before write-time resolution that is not a hexadecimal commit name (HEAD, not-a-commit) is never resolved at read time and always reads stale; integrity’s recorded-sha-the-repository-cannot-resolve check names it. A hexadecimal value this clone does not hold reads unverifiable, as before. 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.

Withdrawing a verdict (0.43.0)

Re-recording can replace a verdict, but only this says it was never this epic’s. Repeatable, so both gates go in one call under the one reason. The whole entry, superseded included, moves into withdrawnGateReviews — recorded, never erased — and re-recording is the way back; there is no un-withdraw. Refused without a reason, for a gate other than 1 or 2, for the same gate twice, where no verdict is stored, and against an ungated stamp (cleared by recording a real verdict); --withdrawal-reason alone is refused too. A withdrawn Gate 2 is withdrawn, never absent. The obligation reappears exactly as for a gate never recorded — a withdrawal discharges nothing — but every surface says what happened: the archive gate and the regression refusal state that Gate 2 was withdrawn and quote the reason, JSON-quoted with control characters escaped, and PROJECT.md and the brief render the cell withdrawn — <reason> and keep an epic whose every gate is withdrawn in their gate tables. The archive-drift heal never stamps ungated over a withdrawn Gate 2 — that would relabel a review taken back as one that never happened; such an epic is reported under its own heading and its own integrity check, archived-with-withdrawn-gate-2. Recording a real verdict ends the state.
Withdrawing is a field write the archive gate decides on, never a route past it. A Gate 2 withdrawal in a delivered archive call, or on an archived delivered epic whose Gate 2 was met, is refused and prints the one call that withdraws AND records the right disposition. For a verdict copied onto the wrong epic, that is one call: withdraw both gates with --status archived --outcome superseded --reason "…" --correct-disposition "…" --no-deferrals.
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.As of 0.38.0 a verdict records on any lane. set-review-mode is lane-agnostic and names a Superpowers task review in its own table, so pm told every lane to run reviews and could record the verdict for one of them — leaving every other lane’s evidence as prose nothing compares, which meant it could never read stale and integrity could not see it. The archive gate above is unchanged and stays openspec-only: recording evidence where a review happened must not create an obligation where none existed. Note that a pass requires both shas; a fail may omit them.