--priority \
@@ -219,9 +228,11 @@ The reason is required, and a recorded disposition is never silently replaced. *
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 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.
+ **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 --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.
@@ -356,7 +367,7 @@ conductor.mjs update-epic --clear parent --clear external-url
| --- | --- |
| `parent` · `description` · `review-mode` · `external-updated-at` | None beyond the field going absent. |
| `created-at` **(0.41.0)** | Returns the registration date to **absent**, which means UNKNOWN. The next `recover-created-at` will attempt it again from history — so clear-then-recover is the correction path for a date recovered wrongly. |
-| `external-url` | This is the **dedup key** the inward sync procedure matches on. Clear it and the linked item is mirrored again as a **new** epic on the next sync. |
+| `external-url` | This is the **dedup key** the inward sync procedure matches on. Clear it and the linked item is mirrored again as a **new** epic on the next sync. It is also how a genuinely **reopened** tracker item is freed from the archived epic that still holds it (0.50.0), since one item maps to one epic on every write path. |
| `external-id` | The epic stops being tracker-linked; `record-tracker-refresh` refuses it by name. |
| `plan` · `spec` | The epic un-claims a source artifact on disk. The next `sync` finds that file orphaned and registers it as a fresh untriaged epic. |
diff --git a/commands/gate-guard.mdx b/commands/gate-guard.mdx
index 90aa8c8..8ddc4d1 100644
--- a/commands/gate-guard.mdx
+++ b/commands/gate-guard.mdx
@@ -55,10 +55,11 @@ The list is **closed**, and each row matches a FIXED label the block message pri
**Segments are split on newline, `;`, `&&`, `||` and `|`** (except the `>|` no-clobber operator), and the scan runs per segment, so a write shape after a `&&` is caught. The command word is taken after its last `/`, and a leading `VAR=value`, `sudo`, `env` or `command` is skipped.
-### The two fail-open modes, stated plainly
+### The three fail-open modes, stated plainly
- **An unreadable record allows every Bash call CARRYING A COMMAND.** This is a carve-out, not a gap: one remedy the unreadable-state message prints redirects into the record itself and would otherwise match the shape list.
- **An absent record leaves the guard dormant.** With no `.conductor/state.json` the hook exits 0 silently for every tool. That is the plugin's standing dormancy contract, and it is exactly why destroying the record is a shape.
+- **A hook line the engine refuses fails open too** — a hand-edited `hooks.json` line carrying a flag `gate-guard` does not declare exits 1, and Claude Code runs the tool call unguarded (see "A refused hook line fails OPEN, deliberately" above).
### What it CANNOT see
@@ -162,5 +163,5 @@ Do not allow an Edit/Write aimed at `state.json` itself as a way out — that wa
- The gate guard hook is dormant until `/pm:init` has been run in a project. It activates only in projects that have been initialized and have a `.conductor/state.json` present — and an absent or unreadable record is one of the two fail-open modes documented above.
+ The gate guard hook is dormant until `/pm:init` has been run in a project. It activates only in projects that have been initialized and have a `.conductor/state.json` present — and an absent or unreadable record is one of the fail-open modes documented above.
diff --git a/commands/hierarchy.mdx b/commands/hierarchy.mdx
index f265977..92d5fdb 100644
--- a/commands/hierarchy.mdx
+++ b/commands/hierarchy.mdx
@@ -91,3 +91,5 @@ conductor.mjs verify-worktrees
```
This cross-references `git worktree list` against epic status and flags any `hierarchy-child/*` worktree whose epic is already archived but was not cleaned up. Once a child's branch has merged, remove its worktree and delete its branch immediately — never leave them dangling.
+
+It reads `git worktree list --porcelain -z`, the NUL-terminated listing, which **needs git 2.36 or later** (0.50.0), so a worktree path holding a line feed is reported whole instead of truncated at the break. Outside a git repository it prints `"orphaned": []`; any other failure to list worktrees — an older git, dubious ownership, a corrupt repository, git missing from PATH — is refused with git's own message instead of the empty list a clean repository prints.
diff --git a/commands/review-mode.mdx b/commands/review-mode.mdx
index e7f1951..e4ceeb5 100644
--- a/commands/review-mode.mdx
+++ b/commands/review-mode.mdx
@@ -45,7 +45,7 @@ conductor: refused to write the pm rules block into CLAUDE.md — its marker lin
The rules file, and every write this command makes after it, were NOT made. After fixing the markers, run `write-rules` and then `render` (or /pm:status) to complete it.
```
-Unlike `init` and `upgrade`, this verb refuses AT the block write, after its state save. So `state.reviewMode` already reads `thorough`, while `CLAUDE.md` and `PROJECT.md` were not written — and `verify-state` reports a hand-edit until they are. That is why the message says to run `write-rules` then `render` rather than to re-run the verb. Delete the stray marker lines (here, the whole second block), then `write-rules` and `render` (or `/pm:status`); after that `CLAUDE.md` carries `Current mode: **thorough**.` and `verify-state` reports no hand-edit.
+Unlike `init` and `upgrade`, this verb refuses AT the block write, after its state save. So `state.reviewMode` already reads `thorough`, while `CLAUDE.md` and `PROJECT.md` were not written — and `verify-state` reports PROJECT.md as stale until they are. That is why the message says to run `write-rules` then `render` rather than to re-run the verb. Delete the stray marker lines (here, the whole second block), then `write-rules` and `render` (or `/pm:status`); after that `CLAUDE.md` carries `Current mode: **thorough**.` and `verify-state` reports that the last render is current.
An unreadable `.conductor/state.json` is refused before anything is written, exit 11, with the git remedies (see `/pm:gate-guard`).
diff --git a/commands/status.mdx b/commands/status.mdx
index 03f2e67..1ceb466 100644
--- a/commands/status.mdx
+++ b/commands/status.mdx
@@ -4,13 +4,17 @@ sidebarTitle: "Status"
description: "Re-render PROJECT.md from state.json and display the full briefing: active epic, detour stack, next-up queue, lane counts, and tracker sync status."
---
-`/pm:status` runs the conductor engine's `render` command, then reads `PROJECT.md` and summarizes the project's position: what's actively being built, what's paused and why, what's queued next, and how many epics exist per lane. It's the fastest way to re-orient yourself at the start of a session or after any state-changing operation.
+`/pm:status` runs the conductor engine's `render` command, then reads **both** the output `render` just printed and `PROJECT.md`, and summarizes the project's position: what's actively being built, what's paused and why, what's queued next, and how many epics exist per lane. It's the fastest way to re-orient yourself at the start of a session or after any state-changing operation.
+
+
+ **Read the `render` output, not only `PROJECT.md` (0.50.0).** The `render` output can carry a block that `PROJECT.md` never does — **SPEC DELTAS ABSENT FROM THE MAIN SPECS**, below. It depends on git's index rather than on the record, so it is printed, not written into a tracked file. Reading only `PROJECT.md` misses it.
+
## What the briefing shows
- The active epic with its lane icon, priority badge, and live story progress derived from checkbox counts in the epic's `tasks.md` file. If no epic is active, this section prompts you to triage.
+ The active epic with its lane icon, priority badge, and live progress: its inline stories and its checkbox source (plan file or `tasks.md`) counted together. If no epic is active, this section prompts you to triage.
@@ -41,6 +45,10 @@ description: "Re-render PROJECT.md from state.json and display the full briefing
The same standing condition's other kind (0.43.0): archived openspec-lane epics whose Gate 2 verdict was withdrawn (`update-epic --withdraw-gate-review`) and not recorded again. Its own heading, never under UNGATED ARCHIVES, because "no review" is false of a review that was recorded and taken back. Each entry quotes the withdrawal reason, and says so where the epic was archived ungated before the withdrawn review. Cleared the same way: record a real verdict.
+
+ New in 0.50.0, and read from the `render` **output**, never from `PROJECT.md`: a `delivered` openspec epic whose archived change's spec deltas the main specs in git's index do not hold (an ADDED/MODIFIED/RENAMED-`TO` header missing, or a REMOVED/RENAMED-`FROM` header still there). The same set `integrity`'s `delivered-epic-spec-deltas-absent` reports, and the SessionStart briefing carries it under the same heading. A standing condition: it clears only when the index holds what the deltas require. Run `integrity` for each finding's remedy.
+
+
Work carried out of an archived epic, shown from **both** ends — the epic that carried it out and the epic that inherited it. A relationship visible from one side only is how a remainder disappears.
@@ -54,6 +62,16 @@ description: "Re-render PROJECT.md from state.json and display the full briefing
+## Progress counts stories and tasks together (0.50.0)
+
+**Progress is the UNION of two parts, and an archived change is still read.** An epic's count sums its inline **stories** (a story recorded `--wont-do` leaves both sides of the ratio) and its **checkbox source** — its plan file, or for an openspec-lane epic the change's `tasks.md`. Neither hides the other: adding a story to an epic that has a `tasks.md` no longer makes the tasks unread, and the ratio then counts `items` (`N/M items`).
+
+Once `/opsx:archive` has moved `openspec/changes//`, the checkbox source is the ARCHIVED `tasks.md` (`openspec/changes/archive/-/`, the latest date winning where one id was archived twice) — for every epic, not only a backfilled one — so an archived epic renders its real counts instead of `0/0`, and the archive gate's handoff demand reads the same count. A missing `tasks.md` or plan still warns while the epic is not archived, whether or not it has stories.
+
+
+ **An unrelated old archive directory does not end a live epic (0.50.0).** A name is not an identity. An archive directory dated (`YYYY-MM-DD-`) 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. A live epic with no `createdAt` is never ended by a bare name match (`recover-created-at` dates it). An epic that is already archived still finds its archive by name. `sync` names each directory it set aside — see [`/pm:sync`](/commands/sync).
+
+
## Progress excludes lifecycle bookkeeping
A task that is bookkeeping about a change's own lifecycle — above all the task that archives the change itself — carries the literal marker `{/* pm:lifecycle */}` on its own task line, and is excluded from progress. It renders as `· N lifecycle` beside the ratio, or `0/0 · N lifecycle` where every task is excluded.
@@ -62,7 +80,7 @@ The engine infers this from nothing else: not the wording, not the commands the
## Auditing the record itself — `integrity`
-`integrity` is a **read-only** audit of the conductor's own record: shapes that cannot be true. An archived epic whose task source exists with nothing ticked. One change registered under two lanes. A recorded commit sha the repository can no longer resolve — orphaned by a squash-merge and due for deletion at the next `gc`, or already gone. An epic left open in a release that already delivered, and not among the epics that release cut on purpose. An epic something else declares it supersedes, still sitting queued or active. A gate verdict whose recorded range does not reach the commits its own note cites. A gate recorded as bookkeeping rather than review. A `delivered` epic that attributed no commits. An archived openspec-lane epic with a passing Gate 2 and no (or a withdrawn) Gate 1. An epic archived with an `ungated` Gate 2. An archived openspec-lane epic whose Gate 2 was withdrawn and not recorded again (`archived-with-withdrawn-gate-2`, 0.43.0). An epic the archive-drift heal flipped that reads `outcome: unknown` while carrying a passing Gate 2. A dangling epic reference. An archive directory no epic corresponds to. An epic in a status the engine does not define. A `github-issues` tracker whose recorded repo is not `owner/name` or `HOST/owner/name` (`tracker-repo-not-a-github-repository`, 0.45.0) — such a repo gets no literal `gh` step until it is re-recorded, and the finding carries the re-record command, plus, for a secondary, its `--remove` printed shell-quoted.
+`integrity` is a **read-only** audit of the conductor's own record: shapes that cannot be true. An archived epic whose task source exists with nothing ticked. One change registered under two lanes. A recorded commit sha the repository can no longer resolve — orphaned by a squash-merge and due for deletion at the next `gc`, or already gone. An epic left open in a release that already delivered, and not among the epics that release cut on purpose. An epic something else declares it supersedes, still sitting queued or active. A gate verdict whose recorded range does not reach the commits its own note cites. A gate recorded as bookkeeping rather than review. A `delivered` epic that attributed no commits. An archived openspec-lane epic with a passing Gate 2 and no (or a withdrawn) Gate 1. An epic archived with an `ungated` Gate 2. An archived openspec-lane epic whose Gate 2 was withdrawn and not recorded again (`archived-with-withdrawn-gate-2`, 0.43.0). An epic the archive-drift heal flipped that reads `outcome: unknown` while carrying a passing Gate 2. A dangling epic reference. An archive directory no epic corresponds to. An epic in a status the engine does not define. A `github-issues` tracker whose recorded repo is not `owner/name` or `HOST/owner/name` (`tracker-repo-not-a-github-repository`, 0.45.0) — such a repo gets no literal `gh` step until it is re-recorded, and the finding carries the re-record command, plus, for a secondary, its `--remove` printed shell-quoted. A `delivered` openspec epic whose archived spec deltas never reached the main specs (`delivered-epic-spec-deltas-absent`, 0.50.0, below).
```bash
conductor.mjs integrity
@@ -74,6 +92,24 @@ Every check is reported with its count **including the ones that found nothing**
**On the first run after upgrading to 0.27.0, expect a burst of `heal-archived-epic-passed-gate-2`.** Every repo that followed the documented `/opsx:archive` then heal flow lands on `outcome: unknown` rather than `delivered`: the migration only stamps epics already `archived` in state, and the heal flips the rest afterwards. Expected, not a bug — each finding carries the exact remedy.
+### `delivered-epic-spec-deltas-absent` — the check that reads git's index (0.50.0)
+
+For every `delivered` openspec-lane epic whose change sits under `openspec/changes/archive/`, it reads each delta `specs//spec.md` there and checks the main spec `openspec/specs//spec.md`: every ADDED and MODIFIED requirement header, and each RENAMED `TO`, must be under its `## Requirements`; every REMOVED header, and each RENAMED `FROM`, must not be. **Headers only, never bodies** — a later legitimate amendment changes a body, and a check that fired on routine work would be ignored. A later archived change that touched the same header the other way discharges it. An unpaired RENAMED line in an archived delta is reported too, since nobody can check it. Each finding names the epic, the change, the capability and each header.
+
+The main spec is read from the **index**, not the working tree and not `HEAD`, in ONE `git cat-file --batch`, relative to the conductor root. The working tree would pass the exact loss this exists for — 0.48.0's archive rewrote two main specs, the commit staged only `openspec/changes`, and a later hard reset discarded four ADDED requirements for two days.
+
+
+ **The stated cost: from `openspec archive` until `git add`, the index still holds the old main specs, so a CORRECT archive is reported in that window too** — stage `openspec/` whole and it clears. That window is why the condition is never written into `PROJECT.md`: `integrity`, the SessionStart briefing and the `render` verb's printed output carry it. It is a standing condition, **never a refusal** — no archive path is refused on it.
+
+
+Where git cannot answer at all (no repository), no presence or absence finding is made, and each surface prints one line instead, the same way on all three:
+
+```text
+spec-sync check unavailable:
+```
+
+The remedy it prints runs against a change that is already archived (neither `openspec archive` nor `/opsx:sync` acts on one): edit the main spec's `## Requirements` so it holds what the archived delta requires — copy each block reported absent from that delta, delete each block reported present, rename a RENAMED `FROM` header to its `TO` — then `git -C add openspec/`.
+
### `epic-in-undefined-status` — the record no terminal rule can reach
**New in 0.40.0.** An epic whose `status` is outside the set the engine defines is now a reported finding, mirroring the existing `link-of-unknown-type` check. The finding names the consequence a reader would otherwise have to work out:
@@ -120,7 +156,7 @@ Every write now reports through a shared reporter across the **whole** write sur
## Design-document coverage — `verify-specs`
-Once epics can name the design document they came from (`--spec`), "which design documents produced no work?" becomes mechanically answerable.
+The command has its own page as of 0.50.0: [`/pm:verify-specs`](/commands/verify-specs). Once epics can name the design document they came from (`--spec`), "which design documents produced no work?" becomes mechanically answerable.
```bash
conductor.mjs verify-specs [--root ]
@@ -189,9 +225,10 @@ conductor.mjs release show 0.27.0
- **Intent prose is required to create one** — an id with no statement of what it is for is unreadable later, which is the failure this records against.
- **A new release id is validated first (0.45.0)** — it must match `^[a-z0-9][a-z0-9._-]*$`, checked before the missing-intent refusal (which prints the id inside a command) and before any write. An existing release with a legacy id is still updated and shown.
-- **Membership is one-way.** It lives on the epic as `epic.release`, at most one, and the release object carries no member list to fall out of step with it.
+- **Membership is one-way.** It lives on the epic as `epic.release`, at most one, and the release object carries no member list to fall out of step with it. Re-associating an epic MOVES it — and since 0.50.0 the move is RECORDED on the release it leaves: `release r2 --member e1`, with `e1` in `r1`, appends `{op: "unmember", epic: "e1", via: "member", to: "r2"}` to `r1`'s `amendments[]` and says so on stderr, so `release show r1` reads "moved to `r2`" rather than silently holding one epic fewer. Re-adding an epic to the release it is already in writes nothing.
- **The engine proposes nothing.** No epic is auto-assigned; adding, re-prioritizing, or archiving epics changes membership for none of them. Grouping is a scope judgment, and the scope judgment is the thing being preserved.
- **An exclusion is not an ending.** A deferred epic keeps its status and gets no disposition of its own — it stays in the backlog because it is still work someone may do. What it loses is membership of that release.
+- **Re-deferring with a NEW reason keeps the old one (0.50.0).** `deferred[]` holds the current reason, and `{op: "redefer", reason, was, wasRecordedAt}` goes to the release's `amendments[]`, where `release show` renders both. The same reason again writes nothing.
### Reading one back, and taking it back — `release show`, `--unmember`, `--undefer`
@@ -218,7 +255,7 @@ Each requires a reason, and those reasons **land in the record** — a new `amen
Run `/pm:status` at the **start of every session** to confirm where you left off, after `/pm:resume` to verify the detour stack contracted correctly, after `/pm:sync` to see newly registered epics, and any time you want to re-orient mid-session. The `SessionStart` hook fires it automatically when Claude Code opens your project — running it manually just forces a fresh render at any point you choose.
-Story progress counts come from each epic's `openspec/changes//tasks.md` checkboxes in real time. If the counts look stale, the `tasks.md` file is the source of truth — `/pm:status` will recount on the next render.
+Story counts are derived live from each change's `tasks.md` — `openspec/changes//tasks.md` while the change is in flight, and its archived copy under `openspec/changes/archive/` once `/opsx:archive` has moved it — counted together with any inline stories. If the counts look stale, the `tasks.md` checkboxes are the source of truth — `/pm:status` will recount on the next render.
## Sample PROJECT.md output
diff --git a/commands/sync.mdx b/commands/sync.mdx
index 6f02539..eee6c7e 100644
--- a/commands/sync.mdx
+++ b/commands/sync.mdx
@@ -45,11 +45,33 @@ description: "Scan for new OpenSpec proposals, Superpowers plans, or GitHub Issu
- **A name that cannot be an epic id is skipped rather than registered (0.45.0).** A change directory, plan file or archive directory whose name holds a control character or whitespace is no longer registered — such a name used to be stored as an epic id, from which the engine would print commands that could not run. `sync` registers everything else and names the skipped entry on stderr on **every** run, quiet included; rename it to register it, and `integrity`'s `archive-directory-has-no-epic` says so too. Uppercase names are unaffected, and an entry an epic already holds prints nothing.
+ **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.
+
+ ```
+ conductor: sync skipped '' — its name is not a valid epic id (format ^[a-z0-9][a-z0-9._-]*$: lowercase letters, digits, `.`, `_`, `-`); rename it to register it
+ ```
+
+ 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 (`--`), 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`](/commands/tracker#inward-pull-worked-through-on-github-issues).
+
+## 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-`) 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:
+
+```
+conductor: sync set aside archive directory '2025-01-01-add-auth' — it predates epic 'add-auth' (registered 2026-09-25), so it is not that epic's archive and did not end it; rename the directory if it is unrelated work. If it IS this epic's archive (registered after the change was archived), end the epic: `update-epic add-auth --status archived --outcome --reason "" --no-deferrals`
+```
+
+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.
@@ -65,6 +87,8 @@ Registration is only half of an inward sync. For every epic **already** linked t
conductor.mjs update-epic --external-updated-at
```
+`` 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 --external-updated-at `.
+
## 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.
diff --git a/commands/tracker.mdx b/commands/tracker.mdx
index 64570c8..e9328fd 100644
--- a/commands/tracker.mdx
+++ b/commands/tracker.mdx
@@ -42,9 +42,11 @@ conductor.mjs set-tracker \
| `--intent` | Repeatable. Maps a PM lifecycle status to a semantic tracker target (see below). |
| `--direction` | `inward`, `outward`, or `both` — which way work flows. Explicit configuration, never inferred from the vendor. A **new** primary defaults to `inward`. |
+**`--intent` is validated (0.50.0).** Each `:` adds one entry to the map, and `` must be one of pm's statuses (`untriaged|queued|active|paused|later|blocked|planned|archived`); a value with no `:`, an empty half or an unknown status is refused by name and nothing is written. It used to be dropped without a word — the command exited 0 with "tracker set" and the intent recorded nowhere. `--intent` is for the PRIMARY tracker only — it maps statuses onto an outward mirror's transitions, and a secondary tracker is inward-only, so `--role secondary … --intent` is refused where it used to be dropped silently.
+
Re-running `set-tracker` merges — only the flags you pass change. The `CLAUDE.md` rules block is refreshed automatically.
-**If the rules block cannot be located, `set-tracker` exits 11 after saving the tracker (0.44.0).** The block is found by whole marker lines; an orphan BEGIN or END line, or two blocks, is refused with every marker's line number, and the rules file is not touched. The refusal comes at the block write, so the tracker change is already in `state.json` while `CLAUDE.md` and `PROJECT.md` are not written, and `verify-state` reports a hand-edit until they are. Delete the stray marker lines from the shell, then run `write-rules` and `render` (or `/pm:status`) — the refusal says exactly that. Do not simply re-run `set-tracker`: `set-tracker --role secondary --remove …` run a second time finds no matching tracker and exits 1 before its block write, so the block would keep the removed tracker. The full refusal is shown in `/pm:review-mode`. An unreadable `.conductor/state.json` is refused before anything is written, exit 11, with the git remedies (see `/pm:gate-guard`).
+**If the rules block cannot be located, `set-tracker` exits 11 after saving the tracker (0.44.0).** The block is found by whole marker lines; an orphan BEGIN or END line, or two blocks, is refused with every marker's line number, and the rules file is not touched. The refusal comes at the block write, so the tracker change is already in `state.json` while `CLAUDE.md` and `PROJECT.md` are not written, and `verify-state` reports PROJECT.md as stale until they are. Delete the stray marker lines from the shell, then run `write-rules` and `render` (or `/pm:status`) — the refusal says exactly that. Do not simply re-run `set-tracker`: `set-tracker --role secondary --remove …` run a second time finds no matching tracker and exits 1 before its block write, so the block would keep the removed tracker. The full refusal is shown in `/pm:review-mode`. An unreadable `.conductor/state.json` is refused before anything is written, exit 11, with the git remedies (see `/pm:gate-guard`).
A value is written into the block literally: a `--repo` containing `$`` or `$&` lands verbatim. Before this, the block was spliced with a string replacement that treats those as substitution patterns, and `--repo 'o/n$`'` copied the file's own prefix into the block.
@@ -125,6 +127,8 @@ conductor: --repo "a/b; x" is not a GitHub repository — a github-issues tracke
**The `--remove` exemption applies to `--role secondary` only.** A legacy secondary whose stored repo predates the check stays removable; a primary `--remove` carrying a refused `--repo` used to save it anyway. `--system`, `--project` and `--repo` also refuse a control character on both roles — a primary `--remove` carrying one exited 0 and wrote a forged `## FORGED` heading into `CLAUDE.md` and `state.json`. `--role secondary --remove` is exempt there too, for the same reason.
+**`--remove` on the primary tracker is refused (0.50.0).** The primary has no remove handler, so a primary `--remove` is refused outright and writes nothing — with a malformed `--repo` on that repo's shape first, and otherwise because there is no primary removal. It used to fall through to the merge: bare, it exited 0 having removed nothing, and with a valid `--repo` it REPLACED the recorded one. Change the primary with `set-tracker --system …` instead; `--role secondary … --remove` is unchanged.
+
A repo whose recorded `github-issues` repo fails that shape stops receiving a literal `gh` line — it gets the vendor-neutral "list open items with your own tooling" step instead — until it re-runs `set-tracker`, and `integrity`'s new `tracker-repo-not-a-github-repository` check names it with the re-record command (and, for a secondary, its `--remove` printed shell-quoted).
@@ -146,7 +150,7 @@ During `/pm:sync`, your agent:
--priority P2
```
**Fill every item value as ONE single-quoted word** (write an embedded `'` as `'\''`, never double quotes), and note that `--title=` and `--external-url=` stay **attached** to their values. That is not cosmetic: the engine classifies a token, not a position, so a detached `--title '--help'` is refused as an unknown flag even quoted, while `--title='--limit=5 ignored'` is one word and is read as data. Item titles used to be wrapped in double quotes with no instruction at all, which let a title like `--help` or `$(touch pwned)` act rather than register. If the issue carries a `P0`/`P1`/`P2`/`P3` label, use that label's priority instead of the `P2` default.
-4. `add-epic` itself rejects a duplicate (exits non-zero, writes nothing) as a second line of defense against a stale local view producing one.
+4. `add-epic` itself rejects a duplicate (exits non-zero, writes nothing) as a second line of defense against a stale local view producing one — and so do `update-epic` (when it sets `--external-url` or `--external-id`) and `add-many` (0.50.0). The refusal names the epic already holding the item and its status (an archived epic still holds it). If that item was REOPENED, do not register a second epic: propose `update-epic --status untriaged` to bring the archived holder back. Only when the holder is genuinely no longer mirrored, free the URL with `update-epic --clear external-url`.
Three things about that recipe are deliberate:
diff --git a/commands/triage.mdx b/commands/triage.mdx
index 2afa354..8dc682a 100644
--- a/commands/triage.mdx
+++ b/commands/triage.mdx
@@ -9,7 +9,7 @@ Run this **before `add-epic`**, every time an ask arrives — an issue you are a
## The gap it closes
-The conductor has always **accepted** work. It did not **triage** it. `add-epic` validates the id, the lane and the priority, refuses a duplicate `externalId`, and appends.
+The conductor has always **accepted** work. It did not **triage** it. `add-epic` validates the id, the lane and the priority, refuses a tracker item another epic already holds (matched on `externalUrl` first, bare `externalId` only when neither side has a URL — at every writer, not only `add-epic`), and appends.
The only dedup that existed was **identity-based** — same id, or same `externalUrl`. That correctly stops `/pm:sync` from mirroring one issue twice, and it does nothing at all about _the same ask arriving under a different name_. That failure has exactly one symptom: **the backlog only ever grows, and every entry looks equally legitimate.**
@@ -45,6 +45,8 @@ PM is an instruction layer. An engine that decided two asks were "the same" woul
Scoring is **IDF over the backlog itself** — `ln((N+1)/df)`. A term's weight comes from how rare it is _in your own epics_, so house vocabulary self-neutralizes: "Implementation Plan" in a title does not drag half the index in, and nobody maintains a curated stopword list that goes stale.
+**The tokenizer reads any script, not only ASCII (0.50.0).** A word is a run of letters, marks and digits in any script, of three or more characters, and text is NFC-normalised first so a composed and a decomposed spelling of one word match. Before, every letter outside `a-z` was a separator: an epic titled in Cyrillic and the identical ask both reduced to no words, and triage returned no candidates — the same answer as "nothing overlaps". Chinese, Japanese and Korean text, which puts no spaces between words, is split into overlapping two-character tokens instead, so a reworded ask still shares tokens with the epic it matches. Because a long CJK ask carries one such token per character, an epic that shares ONLY those needs at least two of them, and at least one for every ten in the ask — so common words such as "new" do not fill the list.
+
Every candidate carries **the tokens that earned its score**. That is what makes a lexical surface auditable rather than an oracle — you can see at a glance that a match is real, or that it rode in on one shared word.
diff --git a/commands/verify-specs.mdx b/commands/verify-specs.mdx
new file mode 100644
index 0000000..c47a3e4
--- /dev/null
+++ b/commands/verify-specs.mdx
@@ -0,0 +1,31 @@
+---
+title: "/pm:verify-specs — Which Design Documents Have Epics Drawn From Them"
+sidebarTitle: "Verify Specs"
+description: "An inventory of design documents and the epics that claim them, plus epics whose specPath names a document that is not on disk. Always exits 0."
+icon: "file-magnifying-glass"
+---
+
+An epic can record the design document its work was drawn from (`--spec ` on `add-epic` or `update-epic`, or `specPath` in an `add-many` batch), and one document may back many epics. `verify-specs` is the set difference: for every `.md` file under a root, how many epics claim it and which — then the epics whose `specPath` names a document that is not on disk.
+
+**New as a slash command in 0.50.0.** The verb existed before, but had no command doc, so it was reachable only by calling the engine directly.
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs" verify-specs [--root ] [--headers]
+```
+
+If `${CLAUDE_PLUGIN_ROOT}` is empty:
+
+```bash
+ENGINE="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/conductor.mjs}"; [ -f "$ENGINE" ] || ENGINE=$(ls -t ~/.claude/plugins/cache/*/pm/*/scripts/conductor.mjs 2>/dev/null | head -1); node "$ENGINE" verify-specs [--root ] [--headers]
+```
+
+## Flags
+
+- **`--root `** — the directory to inventory (recursively, minus `README`/`INDEX`/`CONTRIBUTING`). Point it at a plans directory to ask the same question about plans.
+- **`--headers`** — for every UNCOVERED document, read its leading metadata block (`**Epic:** ` and the like) and print the epic ids it names with the `update-epic --spec ` line that would attach each. It proposes and never applies; a header id that names no epic is reported.
+
+## An inventory, not an audit
+
+Coverage is status-blind (an archived epic is coverage), it always exits 0, and a document with no epic is often fine — a note, a reference, a sketch. An absent root says **no spec root** rather than reporting zero uncovered. Deciding what a design implies stays with you; `add-many` registers several chunks naming one `specPath` in one write.
+
+The rationale and the measured `--headers` parse are on [`/pm:status`](/commands/status), under Design-document coverage.
diff --git a/commands/verify-state.mdx b/commands/verify-state.mdx
new file mode 100644
index 0000000..6470302
--- /dev/null
+++ b/commands/verify-state.mdx
@@ -0,0 +1,47 @@
+---
+title: "/pm:verify-state — Check Whether state.json Was Hand-Edited"
+sidebarTitle: "Verify State"
+description: "A read-only check that nothing wrote to state.json behind the engine's back, baselined on the engine's last recorded write since 0.50.0."
+icon: "shield-check"
+---
+
+`state.json` is the state of record and changes only through the engine's verbs; `PROJECT.md` is rendered from it. `verify-state` is the mechanical check that nothing wrote to `state.json` behind the engine's back. It is read-only: it never modifies `state.json` or `PROJECT.md`.
+
+**New as a slash command in 0.50.0.** The verb existed before, but had no command doc, so it was reachable only by calling the engine directly.
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs" verify-state
+```
+
+If `${CLAUDE_PLUGIN_ROOT}` is empty:
+
+```bash
+ENGINE="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/conductor.mjs}"; [ -f "$ENGINE" ] || ENGINE=$(ls -t ~/.claude/plugins/cache/*/pm/*/scripts/conductor.mjs 2>/dev/null | head -1); node "$ENGINE" verify-state
+```
+
+## What it compares
+
+Every render records a stamp (`.conductor/render-stamp.json`) holding the record's `revision` and `state.json`'s mtime at that moment, and every engine save adds its own `lastSave` (revision and mtime) to the same stamp — including the saves of verbs that do not re-render, such as a claim or `set-activity-log`. A hand-edit advances neither. The check compares the file against the engine's LAST recorded write, whichever of the two is later.
+
+| What it finds | Exit | Meaning |
+| --- | --- | --- |
+| same revision as the last engine write, file unchanged, nothing saved since the render | 0 | `state.json matches the last render — no hand-edit detected` |
+| same revision as the last engine SAVE, file unchanged, saves after the render | 0 | no hand-edit, but `PROJECT.md` may be stale: run `/pm:status` |
+| same revision as the last engine write, file changed after it | 1 | bytes moved with no engine save — an undetected hand-edit |
+| revision AHEAD of the last recorded engine write | 1 | cannot rule out a hand-edit (or a save by an older pm that did not stamp it) |
+| revision BEHIND the last engine write | 1 | the file was rewound or hand-edited; the engine only ever advances it |
+| no stamp at all | 1 | never rendered, so a hand-edit cannot be ruled out — run `/pm:status` for a baseline |
+
+On exit 1, run `/pm:status` to re-render, review the diff of `state.json`, and reconcile before trusting `PROJECT.md` again. Never "fix" a finding by hand-editing the file back.
+
+
+ **Before 0.50.0 it compared only `state.json`'s mtime to the last render**, so any verb that saves without re-rendering (a claim, `set-activity-log`, platform recording) made it exit 1 claiming an undetected hand-edit. A stamp written by an older pm has no `lastSave`, so the first check after upgrading may report "cannot rule out a hand-edit" until the next save or `/pm:status`.
+
+
+## What it cannot see
+
+- A hand-edit followed by an engine save before `verify-state` runs: the save stamps the edited file as its own, so from then on it reads as the engine's.
+- A hand-edit within the filesystem's mtime resolution of the last engine save, at the same revision.
+- A merge, rebase or checkout that brings in `state.json` AND `render-stamp.json` together from another clone: the pair is internally consistent, so it reads as that clone's engine writes. One that brings in `state.json` alone is caught (its revision or mtime no longer matches the stamp).
+
+Run it right after anything that touched the file outside the engine.
diff --git a/commands/verify-worktrees.mdx b/commands/verify-worktrees.mdx
new file mode 100644
index 0000000..787902e
--- /dev/null
+++ b/commands/verify-worktrees.mdx
@@ -0,0 +1,47 @@
+---
+title: "/pm:verify-worktrees — Flag Hierarchy Worktrees Left Behind"
+sidebarTitle: "Verify Worktrees"
+description: "A read-only check that lists hierarchy-dispatch worktrees whose work has ended. It flags and never deletes. Needs git 2.36 or later."
+icon: "folder-tree"
+---
+
+Epic-hierarchy orchestration runs each child epic in its own git worktree on a branch named `hierarchy-child/`. A worktree left behind after its work landed keeps its branch checked out and its directory on disk. `verify-worktrees` lists the ones whose work has ended. It is read-only: it flags and never deletes, because a worktree could still hold work the bookkeeping has not caught up with.
+
+**New as a slash command in 0.50.0.** The verb existed before, but had no command doc, so it was reachable only by calling the engine directly.
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs" verify-worktrees
+```
+
+If `${CLAUDE_PLUGIN_ROOT}` is empty:
+
+```bash
+ENGINE="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/conductor.mjs}"; [ -f "$ENGINE" ] || ENGINE=$(ls -t ~/.claude/plugins/cache/*/pm/*/scripts/conductor.mjs 2>/dev/null | head -1); node "$ENGINE" verify-worktrees
+```
+
+## What it reports
+
+It prints one JSON document, `{ "orphaned": [{ "path", "branch", "epicId", "reasons" }] }`. A worktree is flagged for either reason, and `reasons` names which:
+
+- `epic-archived` — the epic the branch is named for is archived;
+- `branch-merged` — the branch tip is already an ancestor of the current `HEAD`, whatever the epic's status says (the case where `git branch -d` failed with "used by worktree" after the merge).
+
+Only `hierarchy-child/*` branches are considered; any other worktree is yours and is never listed.
+
+## When git cannot list worktrees
+
+Outside a git repository — when git itself answers "not a git repository" — it prints `{ "orphaned": [] }` rather than failing: there are no worktrees there. Any other failure to list them (an older git, dubious ownership, a corrupt repository, git missing from PATH) is refused with git's own message instead of an empty list.
+
+
+ **Requires git 2.36 or later (0.50.0).** It reads the NUL-terminated listing, `git worktree list --porcelain -z`, so a worktree whose path holds a line feed is reported whole rather than truncated at the break. An older git is refused with git's own message.
+
+
+## Cleaning up a flagged worktree
+
+```bash
+git worktree remove
+git worktree prune
+git branch -d hierarchy-child/
+```
+
+See [Multi-agent hierarchy](/guides/multi-agent-hierarchy) for how these worktrees are created.
diff --git a/concepts/state-and-project.mdx b/concepts/state-and-project.mdx
index fd073b2..c119279 100644
--- a/concepts/state-and-project.mdx
+++ b/concepts/state-and-project.mdx
@@ -169,5 +169,5 @@ The rules block is:
- `conductor.mjs verify-state` fails loudly if `state.json` has been modified more recently than the last render stamp — a mechanical check for accidental hand-edits. Run it any time you suspect the file has drifted, or wire it into your own pre-commit checks. A clean output means the state PM is working from matches what was last rendered.
+ [`conductor.mjs verify-state`](/commands/verify-state) compares `state.json`'s revision and mtime against the engine's **last recorded write** — the stamp the last render wrote, or the `lastSave` every engine save adds to it, whichever is later (0.50.0). A hand-edit advances neither, so it fails loudly when the file changed at the same revision as that write, when the revision went backwards, or when it moved past any revision the engine recorded — a mechanical check for accidental hand-edits. Saves by verbs that do not re-render (a claim, `set-activity-log`) exit 0 with a note that `PROJECT.md` may be stale. It cannot see a hand-edit followed by an engine save, which re-baselines over it. Run it any time you suspect the file has drifted, or wire it into your own pre-commit checks.
diff --git a/docs.json b/docs.json
index 2223290..a22d99b 100644
--- a/docs.json
+++ b/docs.json
@@ -64,6 +64,9 @@
"pages": [
"commands/init",
"commands/status",
+ "commands/verify-state",
+ "commands/verify-worktrees",
+ "commands/verify-specs",
"commands/next",
"commands/sync",
"commands/triage",
diff --git a/guides/daily-workflow.mdx b/guides/daily-workflow.mdx
index b28da7b..4ff3e1f 100644
--- a/guides/daily-workflow.mdx
+++ b/guides/daily-workflow.mdx
@@ -45,7 +45,7 @@ A typical working day with PM follows a clear rhythm:
`/pm:status` re-renders the briefing on demand. What you see:
-- **Active epic** — the currently in-flight epic, its lane (e.g. `openspec`, `superpowers`, `claude-code`), and live story progress read directly from checkbox state in `tasks.md` or your plan file
+- **Active epic** — the currently in-flight epic, its lane (e.g. `openspec`, `superpowers`, `claude-code`), and live progress: its inline stories and the checkbox state in `tasks.md` or your plan file, counted together (0.50.0) — an archived change is read from its archived `tasks.md`
- **Detour stack** — every paused frame in order, with a `⚠ reconcile-on-resume` warning on any frame whose detour touched code the paused epic depends on
- **Next-up queue** — the highest-priority `queued` epics sorted P0 → P3, with `depends-on` blockers named inline
- **Per-lane counts** — how many epics are active, queued, or paused in each lane
diff --git a/guides/external-trackers.mdx b/guides/external-trackers.mdx
index da5d590..057a1d3 100644
--- a/guides/external-trackers.mdx
+++ b/guides/external-trackers.mdx
@@ -121,6 +121,12 @@ Once set, the CLAUDE.md rules block gains a "GitHub issue sync" section. During
**That recipe runs as written.** Its `--id` is derived (`--`), so the same item yields the same epic id in every repo and every session and a re-run is refused as a duplicate rather than landing under a slug the agent invented from the title; a non-numeric item key (`ABC-123`) derives a slug rather than an id the format refuses. Its `` comes from lane routing (`suggest-lane --ask=`) rather than a hardcoded `claude-code` — the lane decides whether the work leaves any spec, plan, or gate record, so hardcoding it decided that silently for every mirrored item.
+### One tracker item, one epic — and a reopened item (0.50.0)
+
+The engine enforces the dedup rule itself, on every write path. `add-epic` (with `--external-url` or `--external-id`), `update-epic` when it sets either key, and `add-many` all refuse a tracker item another epic already holds, exit non-zero and write nothing; two entries of one `add-many` batch cannot claim the same item either. The refusal names the holding epic, its status, and the key that actually matched — it used to say `external-id` even when the URL was the match. Before 0.50.0 the check ran only when `add-epic` was given `--external-id`, so the other paths could each register a second epic for an item already mirrored, and every one of them exited 0.
+
+**An archived epic still holds its item.** If the item was genuinely REOPENED, do not register a second epic for it: propose `update-epic --status untriaged` to bring the archived holder back. Only when the holder is genuinely no longer mirrored, free the URL with `update-epic --clear external-url`. A `state.json` that already holds a duplicate still loads, and a write to either epic that does not set the key still goes through.
+
**0.45.0 fixed four ways this recipe could not run as written.** `gh issue list` stops at 30 items without `--limit`, so later items were never registered **and** the closed-item step read their absence as "closed" and proposed archiving their epics — the step now passes `--limit 1000` and stops on truncation rather than treating one page as the whole tracker. The secondary listing line requested no `updatedAt` while its own registration line required one, and the secondary section had no watermark step at all. Item values are now filled as ONE single-quoted word and emitted attached (`--title=`, `--external-url=`, `suggest-lane --ask=`), so a title like `--help` or `$(touch pwned)` stays data rather than acting. And both inward sections pointed at a `/pm:epic list` that does not exist — read `.conductor/state.json` or use `/pm:status`.
diff --git a/guides/multi-agent-hierarchy.mdx b/guides/multi-agent-hierarchy.mdx
index 4e7c752..096e7e4 100644
--- a/guides/multi-agent-hierarchy.mdx
+++ b/guides/multi-agent-hierarchy.mdx
@@ -101,6 +101,8 @@ node "$ENGINE" verify-worktrees
This cross-references `git worktree list` against epic status and flags any `hierarchy-child/*` worktree whose epic is already archived but was not cleaned up. Clean worktrees immediately after each child merges — never leave them dangling.
+It reads `git worktree list --porcelain -z`, the NUL-terminated listing, which **needs git 2.36 or later** (0.50.0), so a worktree path holding a line feed is reported whole instead of truncated at the break. Outside a git repository it prints `"orphaned": []`; any other failure to list worktrees — an older git, dubious ownership, a corrupt repository, git missing from PATH — is refused with git's own message instead of the empty list a clean repository prints.
+
`plan-hierarchy` is a pure read — it recomputes the execution plan fresh from `parent`, `priority`, `links[]`, and `autonomy` every time you run it. There is no "hierarchy in progress" flag that can get out of sync. Re-running it at any point reflects current reality.
diff --git a/installation.mdx b/installation.mdx
index 6025caf..ba8d55f 100644
--- a/installation.mdx
+++ b/installation.mdx
@@ -4,7 +4,7 @@ sidebarTitle: "Installation"
description: "Install the PM project-management plugin from the cfdude-plugins marketplace. Requires Node 22+ and Claude Code. No npm install needed."
---
-PM is distributed via the `cfdude-plugins` marketplace and requires no `npm install` — the engine is zero-dependency by hard rule. The whole engine (`scripts/conductor.mjs` plus `scripts/lib/*.mjs`) is 19,715 lines of Node 22\+ built-ins, so there is nothing to install beyond the plugin itself. Add the marketplace, install the plugin, and you're ready to initialize your first project.
+PM is distributed via the `cfdude-plugins` marketplace and requires no `npm install` — the engine is zero-dependency by hard rule. The whole engine (`scripts/conductor.mjs` plus `scripts/lib/*.mjs`) is 21,357 lines of Node 22\+ built-ins, so there is nothing to install beyond the plugin itself. Add the marketplace, install the plugin, and you're ready to initialize your first project.
## Prerequisites
diff --git a/introduction.mdx b/introduction.mdx
index 8af8326..5546a1f 100644
--- a/introduction.mdx
+++ b/introduction.mdx
@@ -55,9 +55,9 @@ These aren't benchmarks — they're real figures pulled mechanically from PM's o
| Metric | Value |
| --- | --- |
-| Releases shipped end-to-end (spec → build → test → changelog → release) | **65** |
-| Tests in the engine | **2,519** |
-| Lines of engine code (`scripts/conductor.mjs` + `scripts/lib/*.mjs`) | **19,715** |
+| Releases shipped end-to-end (spec → build → test → changelog → release) | **66** |
+| Tests in the engine | **2,775** |
+| Lines of engine code (`scripts/conductor.mjs` + `scripts/lib/*.mjs`) | **21,357** |
| External dependencies | **0** |
Every multi-agent hierarchy dispatch runs worktree-isolated and converges back through sequential merge — every conflict seen so far has been mechanical (a shared CHANGELOG header, a usage string), never a real logic collision. The engine is Node 22\+ built-ins only.