From 4aae4ba05c80d3b0f37767c3caa5aa490c8d6dce Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Fri, 25 Sep 2026 22:44:57 -0700 Subject: [PATCH] =?UTF-8?q?docs:=20sync=20pm=200.50.0=20=E2=80=94=20change?= =?UTF-8?q?log=20entry,=20Real=20Numbers,=20verify-*=20pages,=20command=20?= =?UTF-8?q?pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors pm 0.50.0 (cfdude/pm#234, 32ccf8b): changelog entry; Real Numbers 66 releases / 2,775 tests / 21,357 engine LOC / 0 deps; new verify-state, verify-worktrees and verify-specs pages with nav entries; status, epic, sync, tracker, triage, changelog, activity, gate-guard, review-mode, hierarchy command pages; state-and-project, daily-workflow, external-trackers and multi-agent-hierarchy. Claude-Session: https://claude.ai/code/session_01BoqzgrFwRC6QTUKuBkw8kM --- changelog.mdx | 48 +++++++++++++++++++++++++++++++ commands/activity.mdx | 15 ++++++++++ commands/changelog.mdx | 2 +- commands/epic.mdx | 25 +++++++++++----- commands/gate-guard.mdx | 5 ++-- commands/hierarchy.mdx | 2 ++ commands/review-mode.mdx | 2 +- commands/status.mdx | 49 ++++++++++++++++++++++++++++---- commands/sync.mdx | 26 ++++++++++++++++- commands/tracker.mdx | 8 ++++-- commands/triage.mdx | 4 ++- commands/verify-specs.mdx | 31 ++++++++++++++++++++ commands/verify-state.mdx | 47 ++++++++++++++++++++++++++++++ commands/verify-worktrees.mdx | 47 ++++++++++++++++++++++++++++++ concepts/state-and-project.mdx | 2 +- docs.json | 3 ++ guides/daily-workflow.mdx | 2 +- guides/external-trackers.mdx | 6 ++++ guides/multi-agent-hierarchy.mdx | 2 ++ installation.mdx | 2 +- introduction.mdx | 6 ++-- 21 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 commands/verify-specs.mdx create mode 100644 commands/verify-state.mdx create mode 100644 commands/verify-worktrees.mdx diff --git a/changelog.mdx b/changelog.mdx index 812628e..387c13e 100644 --- a/changelog.mdx +++ b/changelog.mdx @@ -8,6 +8,54 @@ All notable changes to the `pm` plugin, mirroring [CHANGELOG.md](https://github. --- + + ## The 0.43.0 review closed out; the archive gate reads archived work; the commit gate tests the index + + The per-commit gate now tests the commit itself: the index git hands it, `git commit -a` and `git commit -- ` included, rather than the working tree, and a pre-commit run leaves 0 `pm-*` temp directories where it used to leave 436. A new `delivered-epic-spec-deltas-absent` integrity check would have caught 0.48.0's lost main-spec sync, and it flagged this release's own archive until `openspec/` was staged. Closes [#194](https://github.com/cfdude/pm/issues/194), [#219](https://github.com/cfdude/pm/issues/219), [#222](https://github.com/cfdude/pm/issues/222) and [#224](https://github.com/cfdude/pm/issues/224). The assertion half passes 1523/1523 and the functional half 1227/1227. Run `/reload-plugins` after updating. There is no `state.json` schema change, so no migration runs. + + ### Added + + - **`integrity` and the briefing now report a delivered change whose spec deltas never reached `openspec/specs/`.** The new `delivered-epic-spec-deltas-absent` check compares each `delivered` epic's archived delta specs with the main specs held in git's index. It compares headers only, and a later archived change touching the same header discharges it. It names the epic, the change, the capability and each header. It never blocks an archive. It would have caught 0.48.0's lost main-spec sync. Between `openspec archive` and `git add` a correct archive is reported too, and this release's own archive was flagged until `openspec/` was staged; stage `openspec/` and it clears. It is printed by `integrity`, the SessionStart briefing and `render`'s own output (so `/pm:status` shows it), and is never written into `PROJECT.md`. Its remedy: make the main spec hold what the archived delta requires, then `git -C add openspec/`. + - **Reconcile verdicts, autonomy changes, priority changes and review-mode changes are named activity-log events.** Before, each was logged as a bare `state-write` with no epic. `record-reconcile` now logs `reconcile-recorded`, which appears in the GATES sequence as `reconcile vs =` and is marked `(correction)` when it replaces an earlier verdict. `set-autonomy` logs `epic-autonomy`, carrying the level change and counts of grants added, grants revoked and notifications. A priority change logs `epic-priority`. `set-review-mode` and `update-epic --review-mode` log `review-mode`. The report lists the last three in a new SETTINGS section, and `activity --epic` shows the epic-scoped ones. + - **`/pm:verify-state`, `/pm:verify-worktrees` and `/pm:verify-specs` are slash commands.** The three read-only `verify-*` verbs had no command doc, so they were reachable only by calling the engine directly. Each now has one, saying what it compares, what each exit means and, for `verify-state`, what it cannot see. + + ### Changed + + - **The pre-commit hook tests what you are committing, not your working tree.** It exports the index with `git checkout-index -a` into a private temp directory and runs the assertion half there. A failing test that is staged can no longer commit green because a passing copy sits unstaged beside it; an untracked test file neither runs nor counts; a partially staged file is tested, and its tests counted by the floor, as its staged half. The hook honours the index git hands it, so `git commit -a` and `git commit -- `, whose index is not `.git/index`, are tested, drift-checked and counted as what they commit. The drift script that runs first is the commit's own copy, and it reads every set it judges (certified engine modules, test ids, conformance rows) from that index rather than from disk, so an engine module staged and then deleted from the working tree can no longer slip past the certification check. The hook never writes the working tree or the index. A `git stash` approach was measured and rejected: it rewrites the tree, fails before a repository's first commit, and shares one stash stack across every worktree. The snapshot and the suite lock are removed on exit, including on Ctrl-C. + - **Archived tasks now count.** Once `/opsx:archive` moves a change under `openspec/changes/archive/`, its epic's progress is read from the archived `tasks.md`, for every epic and not only one the archive backfill registered. An archived epic renders its real counts instead of `0/0`, and archiving as `delivered` with tasks still open is refused on the documented path (archive, heal, then `update-epic --status archived --outcome delivered`) instead of being accepted. Where one change id was archived twice, the latest date's directory is the one read, and the same directory decides whether the change is archived at all. Expect some archived epics in your record to show open work they already had: they are reported, not changed. + - **Stories no longer hide tasks.** An epic's progress is its inline stories and its task source (plan file or `tasks.md`) counted together. Adding one story to an epic with a `tasks.md` used to make the tasks unread, so it could be archived `delivered` at `1/1` with tasks open. Where both hold open work, the archive refusal names each part's remedy and says both must be done (or `--carried-to` for the whole remainder), and `integrity` and `unconsidered-outcomes` print `--story --done` first and the `--carried-to` archive for what remains. A missing `tasks.md` or plan now warns even when the epic has stories. + - **`/pm:sync` registers only ids `add-epic` itself would accept.** Since 0.45.0 sync and the archive backfill skipped a name holding whitespace or a control character, but still registered a change directory `x|y` (whose pipe splits PROJECT.md's Epics table), `.hidden`, or an uppercase plan file under ids `add-epic` refuses. Every registration path now applies the same id rule (`^[a-z0-9][a-z0-9._-]*$`). A skipped entry is named on stderr every run, sync's final line counts the skips instead of a bare "synced", and an uppercase plan such as `MASTER-plan.md` is given a runnable `add-epic --id master-plan --lane superpowers --plan …` that registers and claims it. Epics already stored under a legacy id keep loading, rendering and updating. + - **`add-many` saves what it accepts or refuses it by name.** Four kinds of input used to vanish with exit 0: a string link such as `"depends-on:base"` was dropped; a link to an epic that does not exist was stored, pointing at nothing; a link with no target was stored and could never render; and a misspelled top-level key, such as `"epic"` for `"epics"`, created the parent and silently skipped every child. The batch must now be a JSON object whose only keys are `parent` and `epics`. Each `links` element is either a `":[:]"` string, the same grammar as `--link`, or a `{type, epic, reason}` object. It must name an epic that is in the record or anywhere in the batch, and use a known link type. Any other input refuses the whole batch, and nothing is created. + - **Moving an epic between releases now leaves a record on the release it left.** `release r2 --member e1`, with `e1` in `r1`, still moves it, but `r1` now gets an `unmember` amendment marked `via: "member"` and `to: "r2"`, and stderr names both releases. `release show r1` shows "moved to `r2`"; before, it showed no members, no amendment, and nothing on stderr. Re-adding an epic to the release it is already in writes nothing. + - **Re-deferring an epic with a new reason keeps the old reason.** `deferred[]` holds the current reason, and the replaced one goes into the release's `amendments[]` as a `redefer` entry with `was` and `wasRecordedAt`, so `release show` renders both. Re-running `--defer` with the same reason writes nothing, and no longer refreshes `recordedAt`. A `state.json` written by 0.49.0 loads unchanged. + - **`triage` finds candidates in any script, not only ASCII.** Its tokenizer treated every letter outside `a-z` as a separator, so an epic titled in Cyrillic and the identical ask both reduced to no words at all and `triage` returned `candidates: []`, the same answer as "nothing overlaps", while an umlaut cut a German word in two. Letters, combining marks and digits of every script are now word characters, and text is NFC-normalised first so a composed and a decomposed spelling of one word match. Chinese, Japanese and Korean text, which puts no spaces between words, is split into overlapping two-character tokens, so a reworded ask still finds the epic whose words it shares. An epic matched only by those tokens needs a share of them that grows with the ask, so common words such as "new" and "feature" do not fill the candidate list. + - **The "tests in the engine" number on pm-plugin.dev comes only from a clean run, and the run is kept ([#219](https://github.com/cfdude/pm/issues/219)).** The release checklist's Real Numbers recipe used to pipe the combined test run straight into a count, so a run that also had a failing test still produced a number to publish, and the output that could have identified the failure was thrown away. The recipe now saves the whole run to a UTC-dated file under the git common dir (`pm-real-numbers/`), names that file, and publishes nothing unless the runner exited 0 with `fail 0`, `cancelled 0`, more than zero tests and every counted test passed (a skipped or todo test is not a pass). The log name carries the PID, so two runs in one second keep two logs. A failed run is recorded against [#219](https://github.com/cfdude/pm/issues/219) before it is re-run, so a flake cannot be retried away silently. The flaky test [#219](https://github.com/cfdude/pm/issues/219) reported is not identified yet; this makes its next occurrence diagnosable. + + ### Fixed + + - **[cfdude/pm#194](https://github.com/cfdude/pm/issues/194) — a malformed lesson `detect:` is rejected with a reason instead of being discarded silently.** The engine now tells three cases apart: a lesson with no matcher, which is retrieval-only by design; one whose matcher works; and one whose matcher cannot work. A matcher in the last group is rejected and named with its reason, for example not JSON, an unknown key, a regex that does not compile, or a JSON `\b` that decoded to a backspace. The hook stays silent about rejects. For now only pm's own repository reports them, through a per-commit test that fails naming each one; a consumer repository gets no report yet, and [cfdude/pm#228](https://github.com/cfdude/pm/issues/228) tracks that. pm's own six inert matchers were fixed: four now fire, and two that matched file content no matcher can see are now retrieval-only. + - **A lesson's `detect:` regex can no longer stall a tool call.** The `lesson-advice` hook runs before every Bash, Edit, Write and NotebookEdit call, and a catastrophic matcher such as `^(a+)+$` used to hold each call until Claude Code's 60 s hook timeout. Now each regex gets its own 50 ms budget, and one hook run spends at most 1 s on regexes in total. Each regex sees at most the first 4096 characters of the command's first line. A regex that runs out of time counts as not matched, so a runaway lesson loses only its own advice; the lessons after it still fire. The unambiguous catastrophic shape, a repeated group whose whole body is one repeated atom such as `(a+)+`, is rejected before it reaches the hook. Delimited repetitions such as `(\S+\s+)*` are accepted. + - **A typo'd `detect:` key no longer matches every tool call.** An unknown key such as `commandMatch` used to apply no predicate, so the lesson fired on `ls`. `detect:` now accepts only `tool`, `pathEndsWith`, `commandMatches` and `commandLacks`, and rejects any other key. It also rejects a matcher that could never fire or would always fire: a tool the hook is never sent, a `tool` array, no positive predicate, a command predicate on a non-Bash tool, or a `pathEndsWith` paired with a command predicate. + - **Lessons saved with CRLF line endings now fire**, and a `pathEndsWith` matcher on `NotebookEdit` now reads the path that tool actually sends (`notebook_path`). + - **[cfdude/pm#224](https://github.com/cfdude/pm/issues/224) — running pm's own test suite no longer fills your temp directory.** The scratch repositories and fixture directories the suite creates are now removed when the test process exits. Before this, one run of the per-commit tests left 436 directories in the OS temp dir and one run of the functional tests left 1,517 more, so a contributor's machine accumulated tens of thousands of `pm-test-*`, `pm-plugin-*` and `pm-cache-*` directories over months, and slow runs were blamed on the suite. A pre-commit run now leaves 0. A per-commit test refuses any new temp-directory site that is not scheduled for removal, and a functional test runs the fixture helpers in an isolated temp dir and fails if anything survives. This affects only people developing pm itself; the plugin you install is unchanged. + - **One tracker item maps to one epic on every write path.** The duplicate check ran 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 issue already mirrored, and every one of those commands exited 0. All of them, and the inward sync through its `add-epic` line, now refuse a tracker item another epic already holds. The refusal names that epic and its status, and names the key that actually matched; it used to say `external-id` even when the URL was the match. An archived epic still holds its item. For an item that was genuinely reopened, `update-epic --clear external-url` frees it. Two entries of one `add-many` batch cannot claim the same item either. 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. + - **An unrelated old archive directory no longer ends a live epic.** An active epic `add-auth` was archived by the drift heal, with outcome `unknown` and its active pointer cleared, because an unrelated `openspec/changes/archive/2025-01-01-add-auth` shared its name. An archive directory dated more than a day before a LIVE epic was registered (`createdAt`) no longer ends it, clears its active pointer or blocks `set-active`. A live epic with no registration date 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: its task counts, spec-sync and cross-spec scope are unchanged, even where pm's own date recovery dated it after its archive. `sync` names each directory it set aside for a live epic, every run, and counts them in its final line; where that directory is really the epic's own archive (the epic was registered late), the same line prints the `update-epic … --status archived --outcome … --no-deferrals` invocation that ends it. + - **An epic archived by `/opsx:archive` no longer keeps its advisory claim.** `update-epic --status archived` already cleared the claim of an epic that ended, but the archive-drift heal, the path an `/opsx:archive`d change usually takes to `archived`, did not, and `integrity` then reported the leftover claim as one that "predates that rule or was hand-edited". Both paths now clear it the same way and say so: `cleared the advisory claim held by '' — '' has ended`. + - **The activity log now names the epic on every detour event, and tells a resumed pause from an ended one.** Detour events used to record `epic: null`, because the log read field names the detour stack never writes. As a result the report's per-epic DETOURS list was always empty and `activity --epic ` left out every detour. Each detour event now carries the paused `epic` and its `detour`, and `--epic` finds the event under either one. The per-epic figure counts interruptions: one per `push-detour`, where the pop used to be counted as a second. `drop-detour` is logged as `detour-drop` rather than as a pop, and it names the frame that was actually dropped even when that frame was buried under another. The DETOURS line reads `N push(es), N pop(s), N drop(s)`. + - **`verify-state` no longer accuses the engine of a hand-edit, and still catches the ones that happen.** 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. Every engine save now records its own revision and mtime on the render stamp (`lastSave`), and the check compares against the engine's last recorded write: saves since the render exit 0 with a note that PROJECT.md may be stale; bytes changed at that revision, a revision that went backwards, or one past any the engine recorded exit 1. 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`. + - **pm's hooks are silent in a repository pm never initialised, as documented.** The root-divergence ("WRITING A DIFFERENT REPOSITORY") and detached-checkout warnings ran before the engine checked for `/pm:init`, so `commit-nudge` warned on every Bash call when `CLAUDE_PROJECT_DIR` pointed at a non-pm repository, and `snapshot` printed a four-line "about to write .conductor/brief.txt" in a detached non-pm checkout, both for a write the dormant hook never makes. Both warnings now fire only once pm is initialised there, except for `init` itself, which is the one verb that writes into an uninitialised repository and still warns before it scaffolds the wrong one. A non-hook verb refused there for want of `/pm:init` still prints the wrong-repository warning first, naming both repositories, so following its advice does not initialise the wrong one. + - **A priority outside `P0`–`P3` and `P?` is refused.** `add-epic`, `update-epic` and `add-many` stored any value: `--priority banana` became a band of its own that ranked last and rendered as if it were real. All three now refuse it by name and write nothing; a record an older engine wrote with another value still loads. + - **An `--external-updated-at` watermark must be a timestamp.** The tracker watermark was stored whatever it held, so a non-date compared against nothing and the "item changed since you read it" check could never fire. `add-epic`, `update-epic`, `add-many` (`externalUpdatedAt`) and `record-tracker-refresh` now require an ISO-8601 date-time with a zone in the shapes trackers report (`2026-09-25T12:00:00Z`, `…000Z`, or Jira's `…000+0000`) and refuse impossible dates such as February 30. `record-tracker-refresh` also refuses a watermark older than the one already recorded (a tracker's updated time only moves forward) and names `update-epic --external-updated-at` as the correction path when the recorded one is wrong. + - **`changelog --since` refuses a value that is not a version.** Any non-version read as `0.0.0`, so `--since garbage` printed the entire changelog, about 3,400 lines, instead of saying the value was wrong. It now takes only `x.y.z`. + - **`set-tracker --intent` refuses a malformed pair.** A value with no `:` or an empty half was dropped without a word: the command exited 0 with "tracker set" and the intent recorded nowhere. It is now refused by name and nothing is written. The part before `:` must be one of pm's statuses, and `--intent` is refused with `--role secondary` (a secondary tracker is inward-only), where it used to be dropped silently. + - **`set-tracker --remove` on the primary tracker is refused.** The primary has no removal, so the flag fell through to the merge: bare, it exited 0 having removed nothing, and with a valid `--repo` it replaced the recorded repo. It now refuses and writes nothing; `--role secondary … --remove` is unchanged. + - **`verify-worktrees` reports the whole path of a worktree whose name holds a line feed.** It read `git worktree list` line by line, so such a path arrived truncated at the break, naming a directory that does not exist. It now reads the NUL-terminated listing (`--porcelain -z`, git 2.36 or later). Where git cannot list worktrees for any reason but "not a git repository" (an older git, dubious ownership, a corrupt repository, git missing) it now refuses with git's own message instead of printing the empty list a clean repository prints. + - **A write conflict says how to get past it.** When another write lands first, the refusal now adds "re-run the command to apply yours on top of it, or pass --force only if you mean to overwrite that newer revision". `--force` is accepted on every mutating verb, and the one message it answers never mentioned it. + - **Two refusals read correctly.** The archive gate's open-work refusal said "2 of 1/3 task(s) outstanding"; it now says "2 task(s) outstanding (1/3 done)". A state lock that is a FIFO, socket or device is described as a special file rather than "a other". + - **`drift.mjs`, `certify.mjs` and `node-majors.mjs` no longer do nothing when run through a symlinked path.** Each compared its own path against the command line without resolving symlinks, so run from under macOS's `$TMPDIR` (`/var` → `/private/var`) it exited 0 without running; they now compare real paths. + + + ## pm supports the oldest non-EOL Node; CI proves every supported LTS line diff --git a/commands/activity.mdx b/commands/activity.mdx index 9ff2c4f..ad9a147 100644 --- a/commands/activity.mdx +++ b/commands/activity.mdx @@ -11,6 +11,8 @@ conductor.mjs activity [--since ] [--epic ] [--json] conductor.mjs purge-logs --kind [--keep N] [--over ] [--older-than ] [--dry-run] --yes ``` +`--epic ` keeps the events whose `epic` is `` **or whose `detour` is ``** (0.50.0). A detour event is about two epics, the one paused (`epic`) and the one it was paused for (`detour`), so `--epic ` shows the push that started that detour's work. Before 0.50.0 every detour event recorded `epic: null`, so the per-epic DETOURS list was always empty and `--epic` left out every detour. + ## Why it ships with a reader, and would not ship without one A log nobody reads is a data graveyard — and that argument has been used in this project to **decline** things, so it could not be waved away here. The reader landed in the same change as the writer. @@ -23,6 +25,7 @@ It answers questions that were previously archaeology: | How often is work detoured, and off which epics? | count rows in a log by hand | | How is work distributed across lanes, and what got re-routed? | not answerable | | In what order did gate verdicts actually land — or get withdrawn (`gate-withdrawn`, 0.43.0)? | reconstruct from timestamps | +| What was re-prioritised, granted or revoked autonomy, or given a different review mode — and when? (**SETTINGS**, 0.50.0) | not answerable — each was a bare `state-write` | | **Were there writes the conductor did not make?** | **a `jq` sweep across every repo** | That last row is the one that earns its cost. A **hand-edit** of the state file shows up as a revision no recorded event accounts for — the failure mode this project has repeatedly found and repeatedly had to dig for. @@ -31,6 +34,18 @@ That last row is the one that earns its cost. A **hand-edit** of the state file **The detail that makes that section signal rather than noise:** events carry a revision _range_, not a single number. An ordinary update writes twice — once for the change, once for the re-render — so single-number events would flag every normal write as an out-of-band one, and the section would be pure noise on day one. +**Named events (0.50.0).** Reconcile verdicts, autonomy changes, priority changes and review-mode changes used to be logged as a bare `state-write` with no epic. Each is now a named event: + +| Event | Written by | Where the report shows it | +| --- | --- | --- | +| `reconcile-recorded` | `record-reconcile` | **GATES**, as `reconcile vs =`, marked `(correction)` when it replaces an earlier verdict | +| `epic-autonomy` | `set-autonomy` — the level change and counts of grants added, grants revoked and notifications | **SETTINGS** | +| `epic-priority` | a priority change | **SETTINGS** | +| `review-mode` | `set-review-mode` and `update-epic --review-mode` | **SETTINGS** | +| `detour-drop` | `drop-detour` — no longer logged as a pop; names the frame actually dropped, even one buried under another | **DETOURS** | + +The DETOURS line reads `N push(es), N pop(s), N drop(s)`, and the per-epic figure counts interruptions: one per `push-detour`, where the pop used to be counted as a second. TIME TO PICKUP starts at an epic's first work event; a detour, reconcile, priority, autonomy or review-mode event alone does not start it. `activity --epic` shows the epic-scoped settings events too. + ## Bounded by design A log that grows without limit in every repository is a papercut every user inherits, so the limits are part of the feature rather than a later addition: diff --git a/commands/changelog.mdx b/commands/changelog.mdx index 4f5263f..dccecaf 100644 --- a/commands/changelog.mdx +++ b/commands/changelog.mdx @@ -15,7 +15,7 @@ description: "Show pm's own CHANGELOG entries newer than a given version — wha | Flag | Description | | --- | --- | -| `--since ` | Optional. Sets the floor version explicitly instead of using the repo's stamped `pmVersion`. | +| `--since ` | Optional. Sets the floor version explicitly instead of using the repo's stamped `pmVersion`. It takes only a version in `x.y.z` form — anything else (`v0.3.0`, `0.3`, a typo) is refused rather than read as "since the beginning" (0.50.0), which used to print the entire changelog. | ## Relationship to /pm:upgrade diff --git a/commands/epic.mdx b/commands/epic.mdx index bc383f1..e7898be 100644 --- a/commands/epic.mdx +++ b/commands/epic.mdx @@ -23,11 +23,11 @@ conductor.mjs add-epic \ - **`--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. +- **`--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. +- **`--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. - **`--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. @@ -85,7 +85,7 @@ Every engine verb on this page is held to one pre-dispatch check, which runs bef - **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 — ` '' 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. +- **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 — ` '' 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" --help` lists it under "Accepted on every mutating verb". ## Bulk create with add-many @@ -114,6 +114,13 @@ conductor.mjs add-many --from /path/to/batch.json 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 `":[:]"` 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 @@ -140,7 +147,9 @@ All validations from `add-epic` apply: no self-parent, no cycles, known status v | `--clear-links` | `links` | The named way to empty the array. **Combines with `--link` as of 0.40.0**, so the documented repair is one atomic write. **Refused** (0.44.0) on an epic that owes a reconcile and holds an armed (or pre-0.44.0) `may-invalidate` link — the clear-and-re-supply repair included — until `record-reconcile` answers it. | | `--description` | `description` | Durable rationale — why this epic exists. Replaced wholesale on each set. | | `--notes` | `notes` | Append-only trail of `{at, actor, text}`. Reads as activity. | -| `--external-updated-at` | `externalUpdatedAt` | The **tracker's own** timestamp, never a local clock reading. | +| `--external-updated-at` | `externalUpdatedAt` | The **tracker's own** timestamp, never a local clock reading — an ISO-8601 date-time **with a zone** (`…Z`, `…+00:00`, or Jira's `…+0000`); a bare date, a zoneless time or an impossible date such as February 30 is refused (0.50.0). | +| `--external-url` | `externalUrl` | The globally unique dedup key — one tracker item, one epic: setting a URL another epic holds (archived included) is refused naming it; `--clear external-url` on the holder frees it (0.50.0). Compared exactly after trimming surrounding whitespace — a trailing `/`, the host's case or a query string make a different URL, so record the tracker's canonical URL. | +| `--priority` | `priority` | One of `P0\|P1\|P2\|P3\|P?` (`P?` = not yet triaged); anything else is refused and nothing is written (0.50.0). | | `--attribute-commit` | `attributedCommits` | **Repeatable**, append-only, in landing order. Resolved at write time and stored as the FULL object name (`HEAD`, a short sha or an annotated tag records the commit it names now); a value that is not a commit in this clone refuses the whole invocation (0.44.0). | | `--outcome` | `disposition` | `delivered`, `killed`, `superseded`, `abandoned`, `declined`, or `unreconstructable`. Rendered from `AGENT_OUTCOMES`, so this list cannot drift from what the engine accepts. | | `--clear` | any nullable field | **Repeatable** — the uniform unset path. See "Clearing a nullable field" below. | @@ -197,7 +206,7 @@ conductor: --attribute-commit value "not-a-commit" does not resolve to exactly o ## Stories, and the third state a checklist needs -Stories are inline progress on an epic — the highest-priority progress source, ahead of a plan or `tasks.md`. As of 0.31.0 they can be created **atomically**, so an epic arrives with its milestones instead of acquiring them one call at a time: +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: ```bash conductor.mjs add-epic --id --title "" --lane --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.