/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-runprints what a real run would register (changes, plans, archived changes), flip (plannedtountriaged) and heal, flags the one-time archive backfill when it would be one, and writes nothing: nostate.json, noPROJECT.md, noarchiveBackfilledAtmarker. 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 plainsyncstill announces the backfill. Theplannedtountriagedflip and the archive-drift heal are not registrations and still run.
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.--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:- Claimed — some epic’s
planPathnames this file. Status- and lane-blind on purpose: that is the done-signal, and it needs no completion heuristic. - Literal id match — an epic already carries the plan’s derived id.
- 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. - 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.
- Otherwise, register.
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 A plan whose lowercased name is a valid id gets a runnable registration instead of the rename advice —
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.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.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 underopenspec/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:
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 itsexternalUpdatedAt 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: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.
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.
