Skip to main content
/pm:sync scans your project for work that should be tracked as epics but isn’t yet. It picks up new OpenSpec proposals and Superpowers plans found on disk, reconciles openspec/changes/archive/, and — where a tracker’s direction includes inward and it names a scope to read — pulls open items in as untriaged epics. After sync completes, PM prompts you to triage any newly registered epics: assign priorities, set statuses, and add any dependency links.

Previewing and selecting — --dry-run and --only

New in 0.51.0 (#167). sync can register a lot at once: the first run after an upgrade backfills every archived change on disk. Two flags make that reviewable.
  • sync --dry-run prints what a real run would register (changes, plans, archived changes), flip (planned to untriaged) and heal, flags the one-time archive backfill when it would be one, and writes nothing: no state.json, no PROJECT.md, no archiveBackfilledAt marker. Exit 0. It still refuses a bad --only. It is a read, so it has no inverse to undo.
  • sync --only <id> [--only <id> …] limits registration to the named change, plan or archived-change ids (an archived change is named by its id without the date prefix). An id that is already an epic is accepted and does nothing; an id that is neither a candidate nor an epic is refused before anything is written. A selective run does not stamp the backfill marker, so the next plain sync still announces the backfill. The planned to untriaged flip and the archive-drift heal are not registrations and still run.
The two combine: sync --dry-run --only <id>.

The archive backfill

New in 0.27.0. sync also walks openspec/changes/archive/. An archived change the conductor holds no epic for is registered as an epic already in archived status, so the record covers what a repository actually shipped rather than only what it happened to register while the work was in flight. The audit behind 0.27.0 measured the gap: the conductor saw 49 of 87 archived changes across 8 repositories.The backfill is visible, one-time, and announced — never a silent side effect. A state-level archiveBackfilledAt marker records that it happened. A backfilled epic carries recordedBy: "archive-backfill" on its disposition and no gate verdict at all: it never passed through the conductor while in flight, so writing one would assert a permanent, unclearable condition against every change archived before the conductor existed.
An epic can claim its change by name (0.51.0, #200). An epic whose --plan or --spec names a file under openspec/changes/[archive/]<name>/ holds that change for the backfill, so a change proposed under a different name than its premise epic is no longer registered a second time. See /pm:epic.

What it registers

OpenSpec proposals

Any openspec/changes/<id>/ directory not yet tracked becomes an openspec-lane epic registered with status: untriaged and priority: P?.

Superpowers plans

Any Superpowers plan file not yet tracked becomes a superpowers-lane epic registered with status: untriaged and priority: P?.

Tracker items (inward)

Where a tracker’s direction includes inward and it names a scope, open items without a matching externalUrl become epics whose lane comes from lane routing. Default priority is P2; a P0, P1, P2, or P3 label overrides it.

Deduplication

Changed in 0.31.0 for plan files. Deduplication used to key on an id derived from the plan’s filename, so an epic whose plan was renamed — or whose id was simply chosen differently — was re-registered forever, and a finished plan came back as a fresh untriaged epic. Sync now keys on a real epic↔plan association (planPath), and walks a five-rung ladder per plan file:
  1. Claimed — some epic’s planPath names this file. Status- and lane-blind on purpose: that is the done-signal, and it needs no completion heuristic.
  2. Literal id match — an epic already carries the plan’s derived id.
  3. Tombstone — the removal verb recorded that this artifact must not be re-registered, so a removal survives the next sync. Attaching the plan to an epic (update-epic <id> --plan <path>) clears it.
  4. Name match, unclaimed only — the epic id equals the plan id minus its date prefix and that epic claims no plan already. Sync skips and prints both exits rather than silently repointing an epic’s progress source.
  5. Otherwise, register.
A plan whose id no normalisation can recover — renamed to something genuinely different — is handled by attaching it once: update-epic <id> --plan <path>, after which rung 1 does the rest forever.
A name that cannot be an epic id is skipped, and named every run (0.45.0, widened in 0.50.0). An epic id is pasted into every command the engine prints and into PROJECT.md’s tables, so sync registers an entry only under an id add-epic itself would accept: ^[a-z0-9][a-z0-9._-]*$ (lowercase letters, digits, ., _, -). Every registration path — change directories, plan files, the archive backfill — asks the same validator add-epic and add-many do. A change directory, plan file or archive directory whose name fails it (x|y, .hidden, My Plan.md, MASTER-plan.md, a name holding a newline) is not registered: sync registers everything else in the same run and prints one stderr line naming the entry, with its control characters escaped. Through 0.49.0 only a control character or whitespace was skipped, so x|y, .hidden and an uppercase plan were still registered under ids add-epic refuses.
A plan whose lowercased name is a valid id gets a runnable registration instead of the rename advice — add-epic --id master-plan --lane superpowers --plan docs/superpowers/plans/MASTER-plan.md for MASTER-plan.md — and add-epic --plan claims the file, so the next sync answers “already claimed”. The run’s final line counts what was skipped instead of a bare “synced”: conductor: synced (1 new epic(s) added as untriaged; 2 skipped — each named above, none registered).The line is printed on every run while the entry exists, quiet included, because a skipped change has no other reported condition. Rename it to register it (or, for a plan, run the add-epic --plan line it names); integrity’s archive-directory-has-no-epic says so too. An entry an epic already holds prints nothing — an epic stored under a legacy id (MASTER-…, My Plan) keeps loading, rendering and updating; only a NEW registration is refused.
For OpenSpec sources, deduplication is by the epic’s id — if an epic with that id already exists in state.json, the candidate is skipped silently. For tracker items, deduplication is by externalUrl, which is globally unique; a bare externalId is only unique within one tracker/repo, so two trackers can each hold an issue numbered #42 without colliding. The emitted registration recipe also supplies a derived epic id (<system>-<scope>-<number>), so the same item yields the same id in every repo and session and a re-run is refused as a duplicate rather than landing under an invented slug. Re-running /pm:sync as many times as you like is always safe. One tracker item maps to one epic on every write path (0.50.0). The duplicate check used to run only when add-epic was given --external-id, so add-epic --external-url alone, update-epic --external-url and an add-many entry carrying externalUrl could each register a second epic for an item already mirrored, all exiting 0. All of them, and the inward sync through its add-epic line, now refuse a tracker item another epic already holds, naming that epic, its status and the key that actually matched. An archived epic still holds its item; for a genuinely reopened item, see /pm:tracker.

An archive older than the epic is not its archive

New in 0.50.0. The drift heal archives an epic whose change sits under openspec/changes/archive/ — but a name is not an identity. An archive directory dated (YYYY-MM-DD-<id>) more than a day before a LIVE epic’s createdAt is some older, unrelated change that happens to share the name, so it neither ends the epic nor clears its active pointer, and set-active still accepts the epic. (The day of slack covers openspec’s local date against createdAt’s UTC.) A live epic with no createdAt at all is never ended by a bare name match. The rule decides only whether LIVE work is ended: an epic that is already archived finds its archive by name, because createdAt is not proof of order for it — pm’s own date recovery can date an epic after its archive. Every surface asks the same resolver, so none of them can disagree. sync names each directory it set aside for a live epic, every run, and counts them in its final line:
A set-aside directory is not registered as a new epic either: its name is held. An UNDATED directory (a hand-made move — openspec archive always writes a date) still matches a live epic by name, since its name is the only evidence about it. For an epic with no registration date the line says so and names recover-created-at, which dates it from git history; the next sync then decides by the rule. An epic registered BY the archive backfill is exempt — it was built from that very directory.

When to run it

Run /pm:sync after pulling changes from another branch that may have introduced new OpenSpec proposals, after generating new proposals in the current session, at the start of a session when you know new work was filed in your issue tracker since you last worked, or any time you suspect the epic index is out of date relative to disk.

Freshness: register what is new, re-read what moved

Registration is only half of an inward sync. For every epic already linked to a tracker item, compare its externalUpdatedAt watermark against the item’s tracker-side updated timestamp and read the ones that moved. Seeing an item in a list response is not reading it — listing must never advance a watermark, or sync erases the very drift it exists to find. The warning counts only work that can still happen (0.31.0). An epic that has ended owes no refresh — its item is closed, and /pm:sync could never clear it, so counting it named an action that could not reduce the number it reported. On PM’s own record that was 60 counted where 31 were real. The archive disposition discharges the obligation; there is no separate terminal watermark, because a record that ended does not need a second ending.
<iso> is the tracker’s own updated timestamp, never a local clock reading — an ISO-8601 date-time with a zone (2026-09-25T12:00:00Z, …000Z, or Jira’s …000+0000); a non-date or an impossible date such as February 30 is refused (0.50.0). A tracker’s updated time only moves forward, so record-tracker-refresh refuses a watermark OLDER than the one already recorded (compared as instants, so …Z and …+02:00 spellings compare correctly) and writes nothing; if the recorded one is the wrong one, correct it with update-epic <id> --external-updated-at <iso>.

The other direction: an item that is no longer open

New in 0.30.0. Sync reads tracker state anyway, so it now also looks the other way: for an epic whose linked item the tracker reports closed while the epic itself is still non-terminal, sync proposes the disposition. Proposing, never writing. The engine cannot know why work ended, and a reason is precisely what a disposition exists to preserve — so you get the exact command with the reason left to you:
This closes the gap where a release ships, every tracker item closes, and every epic stays queued — after which the next-work recommendation confidently names work that shipped hours ago. An epic already archived is excluded by name, so this never adds noise to a backlog that is already correct.
Absence from an open-item list is not proof the item closed — read the item first. It can also have been deleted, transferred, or moved out of scope. Through 0.44.0 the emitted gh issue list line carried no --limit, so gh stopped at 30 items and this step proposed archiving the epics of every item past the first page. 0.45.0 passes --limit 1000 and stops on truncation instead of reading one page as the whole tracker — and the delivered choice is now offered only where the archive gate would actually accept it.
The engine itself only scans local files and never calls an external system directly. What you do externally is decided by each tracker’s direction, and the rules block in your project-instruction file already carries the exact steps for whichever branch applies. An outward-only tracker (or one that names no scope) means sync reads nothing external — it registers local OpenSpec/Superpowers sources and stops, and its confirmation line says so.