Skip to main content
All notable changes to the pm plugin, mirroring CHANGELOG.md in the plugin’s repo. Format follows Keep a Changelog; this project adheres to Semantic Versioning.
2026-10-21

Know where the tokens went, and keep the record honest

This release adds the read side of spend tracking (a postmortem report, a decision log, an overhead report and a developer session log, all read-only and all off or inert until you ask), lets a token ceiling count work instead of cache-read, and closes a run of places where the record could say something false: a minimal detour logged against the wrong commit, an epic with no trace of which sessions wrote it, a repeated attribution nothing reported, a rejected lesson matcher nobody was told about. 22 epics were members of this release: 20 built, this release’s own cut, and one carried forward. Resolves #204, #207, #228, #239, #242 and #251. Run /reload-plugins after updating, then /pm:upgrade in each repo: the managed rules block changes (shipped instructions no longer pin an OpenSpec change to openspec/changes/<id>/, and the ## Spend guard section now follows whether a limit is live). There is no state.json schema change, so no migration runs: the newest MIGRATIONS entry is still 0.44.0, and every field this release adds (epic.sessions, an epic’s recorded decisions, state.devLog, a spend-guard tokenBasis) is optional.

Added

  • spend-analysis [--epic <id> | --release <id>] [--json] [--no-ingest]: where the tokens went, which agents are outliers, and what to change. Reads the existing spend ledger through spend’s own ingest and attribution (it never turns tracking on, writes nothing to state.json, opens no network connection and calls no model). It keeps input, cache-write, cache-read, output, work and total apart, split by actor, model tier, role and phase; ranks sub-agents by work with total beside it; gives the unattributed reasons and ledger-wide coverage their own section; and counts tool use per tool name (messages that called the tool, never a token figure). There is no money cost. Six outlier rules each print their evidence, threshold and one rule-rendered recommendation: long-context-agent (at least 100 messages and 200,000 mean context per message), dominant-agent (at least 50% of an epic’s work, on an epic with 2 or more agents), tier-mismatch, review-loop (3 or more distinct review rounds), low-coverage (under 90%) and invalid-markers; every threshold is a named constant labelled a proposal, not a measurement. A cross-release trend gives mean work per epic and a growing, falling or flat verdict (a 20% band) only when two releases each hold at least 3 epics with attributed rows, otherwise insufficient data. See commands/spend-analysis.md.
  • record-decision <epic> --what "<decision>" --why "<why>" --since <iso> and decisions <epic> [--json] [--no-ingest]: the decisions an epic’s work reached, for a postmortem. A numbered decision (per epic, from 1, never reused) carries what, why, --alternative once per alternative, --phase, and the window of work that reached it (--since to --until, defaulting to the record time). Recording is refused while spend tracking is off, naming set-spend-tracking on and update-epic <id> --notes; an archived epic accepts one. --withdraw <n> --reason marks a decision withdrawn and keeps it. A decision’s cost is computed when read and never typed or stored: the work and total the ledger attributes to the epic inside its window, plus that window’s coverage share. It is the epic’s spend in the window, not spend the decision caused, and overlapping windows are not summed. spend-analysis --epic <id> carries the same section. See commands/record-decision.md and commands/decisions.md.
  • overhead [--all-features] [--json]: what pm injects into a session, by surface and by feature. Reports every managed rules section, the SessionStart brief, the PreCompact snapshot, each hook’s text and the shipped command, skill and agent descriptions, each with its bytes and a ~N tokens (bytes/4, estimate) figure and the one feature that emits it. --all-features computes the every-opt-in-on case in memory and writes nothing. Read-only, and it claims no downstream saving. The README gains “What each feature costs”, one row per surface with its estimated cost and the verb that switches it or no switch with the reason; a test holds the table to the same surface map overhead reports. The audit behind it (docs/measurement/2026-10-feature-switch-audit.md) found one candidate for a new switch, rules:intake, six tokens over the proposed 1,000-token line, and built none: that line is a decision for the maintainer. See commands/overhead.md.
  • set-dev-log on|off, dev-log: a developer-only session log, off by default. Needs spend tracking; on is refused while it is off and in a linked worktree or on a detached main HEAD. init and upgrade never turn it on and list devLog among the opt-ins not adopted. While both are on, spend’s ingest (and spend-guard’s) appends a composition entry per assistant message to .conductor/spend/devlog.jsonl: byte counts only (user text, tool results per tool name, attachments, an image count, the response’s text and thinking) and identifiers, never any text; tokens are joined from the ledger at report time. dev-log [--session-id <id>] [--since <iso>] [--until <iso>] [--json] [--no-ingest] reports per Claude Code session the message count, times, token classes and work, the byte composition, the features snapshot in force, and three retention warnings each printed with its evidence and a proposed threshold: stale-session (at least 7 days), large-context (a message with at least 300,000 tokens of context) and compacted. dev-log --clear [--yes] removes only devlog.jsonl, printing its plan first. off stops capture and deletes nothing. See commands/set-dev-log.md and commands/dev-log.md.
  • list [--status <status>] [--lane <lane>] [--linked-to <epicId>] [--json]: which epics exist (#204). Intake, the tracker-sync dedup and a disposition’s --deferral "<epicId>:<section>" all need that set, and the engine had no verb for it: agents were told to run an epic list that did not exist and fell back to reading .conductor/state.json. It prints one line per epic, or {count, epics} with --json; --linked-to is incoming (the epics carrying a link to that id). Read-only. See commands/list.md.
  • An epic records which named sessions wrote it, and --session works on every epic-writing verb. --session <name> (fallback PM_SESSION) was declared for claim and unclaim only. It is now accepted on every verb whose save can change an epic. When a save changes an epic and an identity resolves, the same write appends it to epic.sessions: one {session, firstAt, lastAt} entry per distinct name, in first-seen order. No identity, no field; a no-op write records nothing; a hook’s save never stamps. Behaviour change on claim and unclaim: the identity is now validated for every non-hook verb before any write (non-blank, at most 128 characters, no control character), so a control character, which claim used to store, is refused.
  • log-detour --sha <sha>, and retract-detour <sha> --minimal "<exact description>" --reason "<why>" (#239). A commit for a minimal detour blocked by a format hook left log-detour recording against the previous HEAD, and the wrong row was permanent. --sha records against the named commit (refused, writing nothing, when it names no commit here); with no --sha, when HEAD already carries a visible MINIMAL row the command says so and names the repair. The retraction appends a RETRACTED-MINIMAL row naming the one target’s timestamp, hides only that row from PROJECT.md, keeps it in .conductor/detours.log, and leaves the commit’s AUTO-DETOUR and DETOUR-COMMIT rows alone. An engine older than this one shows the RETRACTED-MINIMAL row itself in PROJECT.md. log-detour was flagless and now accepts --sha.
  • set-spend-guard --token-basis total|work, and a work total (#251). spend totals gain work (input + cacheCreation + output), the one definition every consumer reads. --token-basis records the unit a --tokens ceiling is counted in, at the project, a lane or an epic (--unset token-basis is its inverse). An absent basis reads as total, so every ceiling set on 0.52 keeps its meaning. spend-guard always prints the epic’s four token classes plus work and total beside the token signal (signals.tokens.basis and .breakdown in --json). A total-basis ceiling below 10,000,000 is recorded with a one-line note naming cache-read and --token-basis work (the floor is the issue’s suggestion, not a measurement). A tokens override records the basis its baseline was counted in and is set aside, with a notice, when the epic now counts another.
  • spend names the invalid pm-spend: markers it refused (#242). A marker whose role, phase or round is invalid surfaced only as tokens under invalid-marker. In this repository, 24.5M tokens (17% of a 141.2M ledger) of real work were dispatched with phase=spec or phase=gate-1 and went unseen until attribution read 68.1%. The text report now prints one line per distinct (field, value) with the accepted set, the agent count and the tokens, and --json carries unattributed.invalidMarkers. Validation stays strict: no alias is accepted.
  • integrity reports attributed-commits-hold-duplicates. An epic whose attributedCommits names one commit more than once, written by an engine older than 0.52.0 or by hand: this repository holds seven such epics, the worst with 58 entries for 35 distinct commits. The finding names the epic, the entry and distinct-commit counts and the repeated commits, and prints no remedy on purpose: the gates read the set, and no verb removes one entry from the append-only array. A read of state.json that repairs nothing.
  • The SessionStart briefing reports a repo’s own rejected lesson detect: (#228). The lesson-advice hook must stay silent, so a consumer repo with a broken detect: was told nothing and its lesson never fired. The briefing now carries ⚠ N lesson matcher(s) rejected — the lesson-advice hook never fires it/them; fix the detect: in docs/lessons/: followed by - <file>: <reason> for up to five, and (+N more in docs/lessons/) past that. It is printed only when N is above zero, on the SessionStart context only. The optional read-only lint verb was not added.

Changed

  • Shipped instructions no longer pin an OpenSpec change to openspec/changes/<id>/. OpenSpec resolves a change’s location itself (changeRoot in openspec status --change <id> --json). Every directive in the rules block, agents/reconciler.md, skills/conductor/SKILL.md, five command docs and the README now names the change’s directory as OpenSpec reports it, and states the stock path as a default. A new sweep over every shipped document and the rendered rules block and brief fails on any other openspec/changes/ literal: 34 lines before, 13 after (all stated defaults or the bare directory name). The engine’s own path handling is unchanged on purpose. Repositories pick up the emitted line on their next upgrade or write-rules.
  • Contributor tooling and internal tests (nothing a plugin user installs or runs changes). The pre-commit hook deletes a leftover single-file pm-suite-certification.json with an idempotent rm -f, as 0.51.0 moved the record to pm-suite-certification.d/. Test guards that read a value out of engine source now refuse an empty extraction, through one helper (scripts/test/fixtures/engine-extraction.mjs) replacing five copies of the dispatch-table reader. A flaky unitTest() failure is now retried like any other (the harness writes a [unit-test-file: <path>] line the retry reads back). The certify-index SIGTERM guard and the G2-C1/I2 lock-refusal guard no longer flake under load, and scripts/test/known-flakes.json is empty.

Fixed

  • The rules block’s ## Spend guard section follows whether a limit is live, after any verb. Whether a limit is configured depends on epic status (an epic-layer limit counts only on an epic that is not archived), but only set-spend-guard refreshed the block. Archiving or removing the only epic holding a limit left the “run spend-guard <epic> before every review or fix dispatch” instruction in CLAUDE.md, and un-archiving it left it absent. The dispatch wrapper now compares the block on disk with the live-limit state after any state-writing verb and refreshes it only when they disagree. A hook never does this work; a failed refresh is a warning naming write-rules, never a failed verb.
  • A quoted free-text value that starts with -- is no longer told to “quote the whole value” (#207). suggest-lane '--limit=5 ignored' was refused with that advice though the value WAS quoted: the shell removed the quotes before the engine saw argv. When the refused token holds whitespace, the refusal now says so and names the way out: the = form where the verb has one (suggest-lane --ask='--limit=5 ignored'), otherwise reword so the text does not start with -- (log-detour, honcho-memory, triage have no = form). An unquoted flag-shaped word keeps the quoting hint. triage still has no --ask= form.
2026-10-14

Stop token burn, cap what hooks inject, and measure spend

Safe to leave running. This release stops runaway and stuck-loop token burn, stops hooks injecting large context into every session, stops several silent wrong records, and adds a way to measure spend: opt-in, off by default, and on in pm’s own repository. A per-epic spend ledger, a review-loop guard with escalation, a six-role execution profile with built-in model defaults, a capped SessionStart brief and a Gate 1 activation guard are the headline changes. 14 epics were members of this release: 13 shipped and the 14th is this release’s own cut. Two candidates, decision-cost-log and token-spend-analysis-and-recommendations, moved to 0.53.0. Resolves #172, #176, #196, #210 and #213. Run /reload-plugins after updating, then /pm:upgrade in each repo: the managed rules block changes (the six role defaults, plus the spend and spend-guard sections while those are on) and the brief it sends is now capped. There is no state.json schema change, so no migration runs: the newest MIGRATIONS entry is still 0.44.0, and every field this release adds (state.spendTracking, epic.gateRounds, the spend-guard limits, gate1Waived, a story’s corrections[]) is optional.

Added

  • Spend tracking per epic, phase, role and model: opt-in, off by default. set-spend-tracking on|off turns on a local, read-only ledger built from Claude Code’s own transcript files. spend is the only reader: no hook reads a transcript, and a repo with tracking off pays nothing new on any hot path. The ledger stores counts and identifiers only, never prompt or response text. Sub-agents are attributed by a pm-spend: epic=<id> role=<role> phase=<phase> [round=<n>] line the rules block asks you to begin each dispatch prompt with (that section is emitted only while tracking is on); orchestrator spend by an active-epic timeline the engine records; the rest is reported as unattributed, by reason, with a coverage share, so unmeasured spend never looks like zero. spend [--epic] [--since] [--until] [--json] [--no-ingest] reports per-epic totals by actor, role, phase, gate round and model tier. It computes no cost: pm ships no price table. Every write has an inverse: spend --clear --yes, spend --rebuild --yes (keeps rows whose transcript Claude Code has pruned) and spend --clear --timeline --yes, each printing its plan without --yes. The ledger lives in the main working tree’s git-ignored .conductor/spend/. See commands/spend.md.
  • A spend and stuck-loop guard: opt-in, no limit on by default. Every accepted record-gate-review now keeps a per-gate round tally on the epic (epic.gateRounds), in the same write as the verdict and with no setting needed. set-spend-guard [--epic <id> | --lane <lane>] [--max-failed-rounds <n>] [--tokens <n>] [--warn-at <pct>] [--unset <field>] turns it into limits at the project, a lane or an epic (most specific wins). spend-guard <epic> [--json] [--no-ingest] compares the epic’s rounds and attributed tokens with those limits and exits 2 while it is stuck, printing a STOP that names the escalation target (one model tier above the resolved review or implement model, else the user) and says autonomy does not bypass it. override-spend-guard <epic> --signal rounds|tokens --escalated-to user|<model> --reason "<why>" records the decision (no autonomy grant covers it), and --revoke --reason marks it revoked without deleting it. The brief carries one STOP: spend guard — line for a stuck active epic, and the rules block a ## Spend guard section only while a limit is configured. Never wire spend-guard into a hook: exit 2 is the hook block code. See commands/spend-guard.md, commands/set-spend-guard.md and commands/override-spend-guard.md.
  • Six execution-profile job roles, each with a built-in default model. The roles grow from implement | test | review to plan | implement | test | review | mechanical | rudimentary, and each resolves to a default {model, effort} when no layer sets it: plan opus (high), implement sonnet (medium), test sonnet (low), review opus (high), mechanical sonnet (low), rudimentary haiku. A default is applied at read time and never written to state.json; profile reports it as source default, and unsetting a role falls back to it. To have a top-tier model build an epic straight off its finished plan, set update-epic <id> --model implement=fable:<effort>: an override of implement, not a seventh role. /pm:init now recommends accepting the defaults. Breaking, in emitted instructions only: a repo that never set a model used to read “no model directive”; after /pm:upgrade its rules block names a default pair for every role. Its state.json does not change.
  • rules --sections and a session-cost report. rules --sections prints the managed rules block’s per-section sizes, ranked. render (/pm:status) and upgrade now end with two lines: the rules block as it sits on disk and the SessionStart brief (bytes and estimated tokens, bytes/4). The block itself is unchanged.
  • /pm:upgrade lists the opt-ins a repo has never adopted, on every run (#210). The changelog delta only names what a release added, so an opt-in skipped at an earlier release was invisible forever. After the delta, upgrade prints one opt-ins line of what is adopted and one NOT SET line per opt-in that is not, each naming the command that adopts it (set-review-mode --mode …, set-lane-routing --add …, set-activity-log on, set-tracker --system …, set-spend-tracking on, set-spend-guard …). It reads a registry in scripts/lib/opt-ins.mjs and state.json, and enables nothing. There is no claim row: a claim is an epic’s temporary hold, not a repo setting.
  • An openspec-lane epic can no longer be made active with its change on disk and no passing Gate 1. set-active, update-epic --status active, and add-epic / add-many creating at active refuse an epic whose openspec/changes/<id>/ exists and whose Gate 1 is absent, failed or withdrawn; the refusal names record-gate-review <id> --gate 1 … and the waiver. New update-epic <id> --status active --gate1-waived "<why>" (reason required, openspec-lane only) waives it in one call; inverse --clear gate1-waived. The guard finds the change by the epic’s id or by the directory its --plan / --spec points into, and update-epic judges it against the lane the call sets. An epic with no change on disk yet, one already active, and a stale-but-passing Gate 1 are not refused; push-detour / pop-detour warn instead.
  • integrity names an epic whose Gate 1 verdict reads stale. The new gate-1-verdict-stale check reuses the artifact-digest predicate behind the ⚠ stale marking on /pm:status and the brief, prints its count line even at zero, names the changed artifact files and record-gate-review <id> --gate 1 … to re-record. A legacy verdict with no digests is ⚠ unverifiable and is not reported. Reported, never refused.
  • update-epic --story <n> --correct-disposition "<why>" reopens a story recorded against the wrong index (gh#176). The original is kept under stories[n-1].corrections[], and the call may carry --done or --wont-do to apply the right mutation at once. --help now renders --story n, 1-indexed (NOT the 0-indexed stories[] position in state.json), and a successful --story write echoes the story it landed on. The replacement guard is unchanged and its refusal names this path.

Changed

  • The SessionStart brief is capped at 16384 bytes, and the cap shortens lists instead of cutting text. The brief was uncapped and grows with a repository’s history: 78,275 bytes, about 19.5K tokens, on pm’s own repo. A brief under the cap is sent unchanged. Over it, only RELEASES (the 3 most recent), DISPOSITIONS, GATE REVIEWS, HANDOFFS and EPIC LINKS (the first 5 each) are bounded, each item line is trimmed to 240 characters and each list ends in (+N more — see PROJECT.md "<section>"); those lists move after every obligation block. Warnings and obligation blocks are never cut: if they alone exceed the cap they are sent whole and the marker says so. On pm’s own record the brief is now 8,242 bytes. The PreCompact snapshot and PROJECT.md’s “Briefing” are unchanged and uncapped.
  • The commit hook no longer logs an epic’s own small chore commits as AUTO-DETOURs (#213). A commit is left out of the detour trail when the record ties it to the active epic: already attributed to an epic, its subject names the active epic’s id, or every path it changed was already changed by a commit attributed to the active epic. That last rule needs two or more changed paths, and a commit made while a detour is open is never suppressed. A commit another epic already holds is no longer offered for attribution again, and the two messages for a commit named in both places no longer contradict each other. The hook also reports at most 50 commits per run and says how many remain, anchors only on a complete reflog line, and a retraction row names every epic it covers.
  • Hooks drain their payload. brief and snapshot now read stdin first, and gate-guard, lesson-advice and commit-nudge do the same where they used to return before reading (a repository without pm, a detached tree), so a hook payload past the pipe buffer (about 64 KiB) can no longer leave the writer holding an unread pipe and failing with EPIPE. Allow and deny decisions are unchanged.
  • upgrade’s printed opt-in commands run as printed. set-review-mode and set-tracker first shipped in the registry without the --mode / --system flags the verbs read; every printed command is now run against the engine by a test.
  • CI re-pinned to TruffleHog 3.97.0 (#172). .github/workflows/security.yml points at a cfdude/.github workflow SHA whose scanner no longer reports a test_-prefixed pytest function name as a verified Lob API key.

Fixed

  • A slow or unreadable openspec --version no longer reads as “no drift” (#196). The installed OpenSpec version is read from the @fission-ai/openspec package.json beside the resolved binary on PATH, with no spawn; only when that cannot answer does the engine fall back to openspec --version, now with a 15 s timeout (was 3 s) and one answer per process. When the CLI is present but its version still cannot be read, the brief, PROJECT.md and upgrade print one fixed OpenSpec drift UNKNOWN line instead of nothing, so the drift line stops flapping between renders of a committed PROJECT.md. A CLI absent from PATH stays silent.
2026-10-06

Test infrastructure you can trust; gate and record verbs that refuse what they should

Test infrastructure you can trust, and gate and record verbs that refuse what they should. The certification record that serialised parallel worktrees in 0.50.0 is redesigned, a guard now stops any test writing the real .conductor record, and the flaky and fragile test surfaces are fixed or quarantined (Theme A). The gate and record verbs refuse contradictory input, a Gate 1 verdict goes stale when a reviewed artifact changes, and integrity names four more kinds of drift (Theme B). A layered execution profile and a one-round release-candidate review arrive alongside them. 31 epics were members of this release: 30 shipped and one, store-owns-claude-md-managed-block, was deferred because no quiet-machine measurement was taken to justify it. Resolves #167, #198, #200, #201, #205, #206, #215, #216, #226, #227, #229, #230, #231, #232, #233 and #237. Run /reload-plugins after updating, then /pm:upgrade in each repo: the managed rules block changes (the planning-completeness paragraph, the execution profile and the release-candidate section) and .gitignore gains the session files below. There is no state.json schema change, so no migration runs: the newest MIGRATIONS entry is still 0.44.0, and every field this release adds is optional.

Added

  • An execution profile: review intensity, model and effort per job role, and report verbosity, resolved most-specific-wins across four layers. set-profile writes the project layer or, with --lane <lane>, a lane’s; an epic sets its own with --review-mode, --model <role>=<model>[:<effort>] and --verbosity on add-epic, update-epic and add-many. Each field resolves on its own: epic, then lane, then project, then the default (review: standard, verbosity: quiet, no model directive). Models are fable | opus | sonnet | haiku with an effort (haiku takes none), for the roles implement | test | review; /pm:init walks the user through it top-down and recommends opus at medium for every role. profile [--lane <lane> | --epic <id>] reads the effective values back and names the layer each came from. Every set has an inverse: set-profile --unset …, and on an epic --clear <field> or --clear-model <role>. The engine records and emits the profile into the rules block and the session brief; it never dispatches an agent or checks which model ran.
  • release show prints a derived candidate review: line, and a release-candidate skill carries the procedure. One fresh-context review round over the merged candidate, a reviewer budget that is a maximum, Critical-only reopening, and the same baseSha..headSha recorded as every member’s Gate 2. Nothing is stored: the line is derived from the candidate members’ Gate 2 records and reads converged at <sha> only when every member shares one base AND head, has a verdict, and none is a fail; otherwise it lists each range, each member with no verdict and each failed member.
  • A Gate 1 verdict goes stale when a reviewed artifact is amended (#198). record-gate-review --gate 1 --artifact now stores a sha-256 per readable artifact as artifactDigests, beside the unchanged artifacts paths. Amending an artifact reads ⚠ stale on /pm:status and the brief; an unreadable one reads ⚠ unverifiable. A verdict recorded before this release carries no digests and renders exactly as before, never stale and never unverifiable. A live change’s tasks.md is a progress ledger that /opsx:apply ticks constantly, so it stays in the artifact list but is never digested. The archive move is not an amendment, and staleness never blocks an archive.
  • sync --dry-run previews a sync, and sync --only <id> imports selectively (#167). --dry-run prints what a real run would register (changes, plans, archived changes), flip and heal, flags the one-time archive backfill when it would be one, and writes nothing: no state.json, no PROJECT.md, no backfill marker. --only <id> (repeatable) limits registration to the named change, plan or archived-change ids; an id that matches nothing is refused before anything is written, and a selective run leaves the backfill marker unstamped so the next plain sync still announces it. The two combine.
  • update-epic <id> --spec-deltas-waived "<why>" takes a change archived without applying its specs out of the spec-deltas report. The reason is the value, so it cannot be recorded blank. integrity’s delivered-epic-spec-deltas-absent and the briefing’s spec-sync block then stop naming the epic. It is refused unless the epic is in the check’s scope (a delivered, openspec-lane epic whose change is archived), and the refusal names that scope. A waiver is never silent: the briefing and render’s output print SPEC DELTAS WAIVED (N) with the epic ids. Inverse: --clear spec-deltas-waived.
  • A release can record that it delivered: release <id> --deliver, and --undeliver to take it back. integrity’s delivered-release-epic-left-open reads the marker when it is there and reports every open member not in the release’s deferred[], active or not. A release with no marker keeps the member-derived reading; its finding now says it is inferred and prints release <id> --deliver. This replaces the --status active workaround the check’s earlier wording suggested. release show renders the marker.
  • An epic can claim its OpenSpec change by name. --plan / --spec naming a file under openspec/changes/[archive/]<name>/ now holds that change for integrity’s archive-directory-has-no-epic and for sync’s archive backfill, so a change proposed under a different name than its premise epic is no longer reported as unheld, or registered a second time (#200). --clear spec / --clear plan is the inverse.
  • Four new integrity checks. tracker-item-held-by-two-epics names two epics holding one tracker item (the pre-0.50.0 duplicates), with update-epic <one> --clear external-url as the remedy (#231). archived-change-also-live names a change present both live and under archive/ with identical content, the shape upstream openspec archive leaves because it copies a tree rather than moving it (#215), with git rm -r of the live copy as the remedy. late-failing-gate-review reports a failing verdict recorded after the merge as the real retrospective review it is, and gate-recorded-as-bookkeeping no longer reports it (#201). archived-delivered-fails-delivered-obligation names an archived delivered epic that no longer meets what delivered requires by a path no gate sees: a Gate 2 flipped to fail after the archive, a stripped carriedTo, or tasks changed on disk. archive-directory-has-no-epic also reports an archive directory the 0.50.0 date rule set aside, in the same words sync uses.
  • The emitted rules direct an agent to confirm an OpenSpec epic’s planning is complete before treating it as ready to apply. One paragraph under “Re-read the source before an epic becomes the work”: every artifact the project’s OpenSpec schema requires exists, and they agree. It states the obligation and names no OpenSpec command. Run /pm:upgrade to refresh the managed block.
  • Contributor tooling: flaky-test quarantine and a lean sub-agent bootstrap. Nothing a plugin user runs changes. A test failure is now re-run once, on the failed files only. A test listed in scripts/test/known-flakes.json passes quietly and is printed as a “known flake”; a pass-on-retry that is not listed passes loudly as an “UNLISTED flake” that needs an owning epic; a test that fails twice is a real failure. Failures that cannot be tied to a file are never retried, so one file’s flake cannot mask another’s failure. Each retry is appended to the git-ignored .test-flakes.log. Vitest and node’s own --test-rerun-failures were evaluated and not adopted (the reasons are in the epic’s plan). Separately, .claude/agent-startup.md replaces “read CLAUDE.md and CONTRIBUTING.md in full” in every dispatched agent’s brief. The recorded estimate is about 35.8K tokens before and 13.7K after, computed as bytes divided by four and not measured in a live agent.

Changed

  • An epic’s --review-mode may now be BELOW the lane’s or the repo’s. The old escalate-only refusal is gone by decision; what replaces it is visibility: profile --epic <id> and rules --epic name the layer each value came from and the higher value an epic lowers. --clear review-mode falls back to the lane, then the repo dial.
  • update-epic --attribute-commit records a commit once (#237). A commit the epic already attributes, or one given twice in a call (HEAD and its full sha included), is a no-op: exit 0, a line on stderr naming it, nothing written. Retrying an attribution after a failed commit hook no longer inflates the array that Gate 2 reachability and integrity reason over.
  • The attribution array is read as a set, and nothing depends on its order (#216). After a catch-up of an earlier commit the array’s last entry is an ancestor of the rest. The stale-Gate-2 remedy no longer tells you to record --head-sha <the last attributed commit>: it names the attributed commit every other one is an ancestor of, and the parent of the earliest attributed commit. integrity’s bookkeeping arm dates “the merge commit” by the latest commit instead of the last element.
  • Attributing a commit that turns a passing Gate 2 stale now says so (#205). --attribute-commit prints that the verdict moved to stale and names both ways out: re-record Gate 2 if the commit changed the implementation, or --withdraw-commit if it was lifecycle bookkeeping (lessons routed after the gate, the task-list tick). The ordering rule is in the attribution task item: commit such work before recording Gate 2, and do not attribute it where you cannot.
  • update-epic refuses contradictory deferral assertions and a directory as a plan. --no-deferrals together with --deferral or --declined-deferral is refused by name with nothing written (#233). --plan / --spec on update-epic and add-epic refuse a trailing-slash value and a path that exists and is not a regular file; a path that does not exist yet is still accepted (#232). add-many entries’ planPath / specPath get the same refusal.
  • A printed archive invocation never carries a bare --no-deferrals. dispositionInvocation(), deliveredArchiveInvocation() and the superseded-epic remedy now print the placeholder <--no-deferrals | --deferral "<epicId>:<section>">, because the bare flag is a claim the agent has not made.
  • update-epic --status <non-archived> no longer claims a status the heal undoes. On an epic whose change directory is archived on disk, the success line says the --status was not kept instead of printing “updated” while render restores archived.
  • recover-created-at dates an epic from the AUTHOR date of its earliest introducing commit. It used to print the committer date, which a rewrite (rebase, amend, cherry-pick) moves later than the authoring: one epic was stamped 07-09 when its only introducing commit was authored 06-30. It keeps full history and takes the earliest match by date. Dates already stamped are never overwritten; clear one with update-epic <id> --clear created-at and re-run the verb.
  • The attribution nudge no longer tells an agent to attribute bookkeeping, and no longer claims the active epic owns a commit it cannot tie to it. A commit whose changed paths are only .conductor/**, PROJECT.md and/or a change moved under openspec/changes/archive/ now gets “pm bookkeeping — needs no attribution” instead of an --attribute-commit command. An archive move is recognised by pairing openspec/changes/<id>/<rest> with openspec/changes/archive/<date>-<id>/<rest>, so an edit to a change is still real work. When neither a commit’s subject nor its paths name the single candidate epic, the nudge says the engine cannot tell whether the commit is <id>’s work, instead of “record this commit against its epic”.
  • Contributor tooling: the certification record is one file per passing run, named by what it certified, so parallel worktrees certify with no lock (#226). Nothing a plugin user runs changes. certify.mjs used to rewrite one shared pm-suite-certification.json, and every commit resolved every entry against its own tree, so a peer worktree’s certification could refuse a commit it was not about, and two certifies could lose each other’s write. The record is now $(git rev-parse --git-common-dir)/pm-suite-certification.d/<bucket>/<key>.json, created and never rewritten. A commit is fresh only when one passing entry’s manifest (the mode and blob id of every path in the bucket’s subject) equals that subject in the index, and a staged deletion of a subject path demands a run too. Entries are pruned to the newest 50 per bucket. The old file is neither read nor removed; its retirement is deferred to a later release.
  • Contributor tooling: certify runs over the index, not the working tree (#230). It copies the index once, runs the bucket in a git clone --shared checked out from that copy, and records that copy’s manifest, so a partial stage is certified as its staged half and an edit made during a run of several minutes never enters the record. It never writes the working tree, the index, the stash or the worktree list, and removes its run directory on exit and on Ctrl-C.
  • Contributor tooling: the functional half is demanded by what it observes (#229). Its subject was the engine modules that call the git gateway, so a change to a module the half imports but that never reaches git (a refusal’s wording in archive-gate.mjs) skipped it. The subject is now the half’s import closure, the assertion files it executes, the files it names, the shipped roots it walks, and README.md, CLAUDE.md and docs/parity-ledger.json when named. Over the 0.50.0 build this moves the functional demand from 15 of 126 commits to 70. A run-time observer loaded into every Node process the half starts fails the certification, naming the file, when the half reads a tracked file the derivation missed or starts a Node child whose NODE_OPTIONS drops the observer.
  • Contributor tooling: diff coupling moved to a new commit-msg hook, with a declared and audited exemption (#227). A staged functional test still needs its assertion twin in the same commit, unless the message carries a Twin-Unchanged: <id> — <reason> git trailer saying the change leaves what the file tests untouched. git parses the trailer, and Gate 2 lists and judges every one in the reviewed range (a numbered step in the pr-workflow skill). The pre-commit hook now runs enrolment, twin coverage and freshness; a bare node scripts/test/drift.mjs still runs all four checks. A git commit --amend judges the staged set against HEAD’s parent, so a Twin-Unchanged declaration on a reworded message is no longer refused as “not staged”, and drift now names every stale bucket in one run, with the command for each, instead of one per attempt.
  • Contributor tooling: no test may write the developer’s real .conductor record. Every test bucket, the hook’s snapshot and certify’s clone load scripts/test/fixtures/record-isolation.mjs, which pins CLAUDE_PROJECT_DIR to an empty scratch directory and fails any test file that adds, changes or removes a file under a protected .conductor (and fingerprints PROJECT.md, CLAUDE.md and .gitignore). It never restores. A test that must read the live record names its root with withRoot(REPO). The evidence: on 2026-09-21 an alpha/from-caller epic and 554 honcho-memories.log lines leaked into the real record. The prevention was proven by mutation.
  • Contributor tooling: guards that read engine source can no longer be satisfied by a comment. A guard that matched /archiveGate\(/ against the raw source of lib/update-epic.mjs stayed green with the gate call deleted, because the file’s own prose names the function. scripts/test/fixtures/source-code.mjs (codeOnly / engineCode, built on js-lexer, failing closed) now backs every engine-source guard, all hand-rolled comment strippers were migrated onto it, a lint flags positive assertions on raw engine source, and a sweep pins the lexer’s regex-after-paren blind spot absent from engine files. The plan tabulates all 94 engine-source reads in scripts/test, each with a disposition.
  • Contributor tooling: the hook and certify fixtures derive the machinery they copy from the hooks’ own code. A fixture that copied the real pre-commit hook broke on every new hook step: adding the drift step turned three unrelated tests red, all pointing at the hook. Adding a hook step no longer needs a matching edit to a fixture builder. Adding an engine verb is also checked against every per-verb table in one run (#206): a new assertion-half test names every missing row at once (usage line, positionals, flag surface, effects row, both functional baselines), and CONTRIBUTING.md documents the tables and the baseline seed hook.

Fixed

  • .conductor/brief.txt is git-ignored, and a repo that already committed it is told how to stop. 14 of 24 pm-managed repos on one machine had committed it, and every snapshot left a changed tracked file. It is ignored now, as are .conductor/write-conflicts.log.prev and .conductor/honcho-memories.log, which init and /pm:upgrade now write into .gitignore by name instead of relying on a global *.log rule. Because an ignore line does nothing for a file already in the index, upgrade (and init) print an ⚠ UNTRACK PM'S SESSION FILES block with the exact git rm -r --cached line and a commit line when any of them is still tracked. pm never runs it; the files stay on disk. No state.json change and no migration.
  • delivered-release-epic-left-open stops being a false alarm on a release that is still in flight. Archiving the last active member of a release read as the release being delivered and its queued members as leftovers. The finding now says the reading is inferred, and release <id> --deliver (above) records the fact.
  • Contributor tooling: two flaky functional and unit tests were fixed at the root, not retried. The emitted-invocations Layer B integrity:archived-with-zero-ticked-tasks test failed with ENOENT while copying the template repo’s .git/objects (2 of 4 full runs on 2026-09-28). hermetic-git’s GIT_CONFIG_GLOBAL=/dev/null had hidden gc.autodetach=false, so every fixture commit spawned a detached git maintenance run whose lock raced the copy. The fixture environment now sets maintenance.auto=false and gc.auto=0: 1 to 7 failures in 24 to 72 runs before, 0 in 168 after. The lesson-detect budget tests failed intermittently under load because a benign regex’s own 50 ms watchdog fired while the test process was CPU-starved. matchLessons() now takes an injectable clock and regex runner, the budget tests assert per-regex grants over a fake clock, and runaway detection is checked by result. The hook still uses the real vm runner, unchanged.
2026-09-25

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 -- <path> 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, #219, #222 and #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 <conductor root> 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 <detour>=<verdict> 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 -- <path>, 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 <n> --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 "<type>:<epic>[:<reason>]" 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). 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 before it is re-run, so a flake cannot be retried away silently. The flaky test #219 reported is not identified yet; this makes its next occurrence diagnosable.

Fixed

  • cfdude/pm#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 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 — 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 <holder> --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:archived 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 '<session>' — '<id>' 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 <id> 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.
2026-09-24

pm supports the oldest non-EOL Node; CI proves every supported LTS line

Changed

  • pm states a Node support policy: it supports exactly the Node LTS lines that are not end-of-life, and the oldest of them — the support floor — is Node 22. The floor is one engine constant, NODE_FLOOR_MAJOR in the new scripts/lib/runtime-support.mjs, moved at release time by a new release-checklist step; the engine still never fetches anything. Node 18 and 20 are dropped (end-of-life 2025-04-30 and 2026-04-30); odd majors (23, 25, 27) are unsupported. The README, CONTRIBUTING.md, CLAUDE.md and the engine header now say Node 22+, and a per-commit test holds every documented copy equal to the constant.
  • CI tests every supported major, computed from Node’s release schedule — 22, 24 and 26 today. A node-majors job fetches schedule.json and keeps each entry with an lts date, start <= today < end (the filter is scripts/test/node-majors.mjs, tested clause by clause); a fetch failure falls back to the committed PM_NODE_FALLBACK with a visible warning, an unreadable schedule and a computed set that differs from the fallback both fail, and the fallback’s minimum is held equal to NODE_FLOOR_MAJOR on every commit. The legs report as test (node N) with fail-fast: false, and the required check stays the single context test, now an if: always() aggregate that fails unless the set was computed and every leg passed. Verified on Actions: computed [22,24,26], the 404 fallback, and the mismatch refusal. Each leg is faster than the old Node 18 job (4 m 18 s / 4 m 18 s / 3 m 38 s against 4 m 46 s), and the three run in parallel.
  • The assertion half runs under the test runner’s default per-file isolation; the single-process mode is retired. On this 16-CPU machine the pre-commit hook, end to end, went from 33.55 s (single-process, runner 27.1 s) to 24.0 s (runner 15.7 s), median of three each. That is still NOT under 15 s. On a 4-core CI runner per-file is the slower mode — 14.5 s against 11.3 s — and that trade is accepted for one command on every supported major and failures attributed to the file that failed. Every rung file now installs the run-time git counter itself: 13 file-rung files relied on another file’s process and would otherwise have run with the real git reachable.
  • One reporter everywhere a count is read. The hook, CI’s three bucket steps, certify.mjs and the release checklist’s Real Numbers recipe run FORCE_COLOR=0 node --test --test-reporter=spec and parse ℹ only; the ℹ/# dual parse is gone.
  • CI actions move to actions/checkout@v7 and actions/setup-node@v7 (both node24).

Added

  • The SessionStart brief warns, in ONE line, when the Node running pm is below the support floor — naming the version and the floor, saying pm still runs. It is a warning, never a refusal; it never appears in PROJECT.md or the PreCompact snapshot. The running version reaches the engine through the invocation (runtimeVersion()), so both sides are tested without an old Node.

Fixed

  • cfdude/pm#220 — a hung child no longer hangs the functional half. spawnAll bounds every child at 30 s, kills it and fails the test naming the invocation; a source scan refuses any unbounded wait on a child’s close/exit event in the suite.
  • The pre-commit hook no longer skips its count floor when it cannot read the count. A colour-forcing environment made the summary unparseable and the hook printed “tests passing” with the floor skipped; an unreadable count, and a run of zero tests, are now refusals — in the hook, in every CI bucket step and in certify.mjs.
  • The git shim and the harness no longer leak a temp directory per process. 4,043 pm-assert-no-git-* and 6,879 pm-empty-cache-* directories had accumulated in os.tmpdir(); both are now removed at process exit.

Removed

  • The pre-commit hook’s --test-isolation probe, its cached pm-isolation-flag (removed from existing clones on the next commit), and the Node-18 [ -f ] loop that resolved the rung globs.
2026-09-22

The assertion half gains a unit rung — and misses its own acceptance

Changed

  • The assertion half runs in 27 s, down from 73 s — and it MISSES its own sub-15-second acceptance. The half gained a second rung: a unit rung (scripts/test/unit/) for tests whose observable is a value the engine produced — a verb’s result, a refusal, an exit status, anything state.json holds — running over an in-memory store with no tmpdir, no fsync and no render to disk, and the file rung (scripts/test/assert/) for tests whose observable is bytes on disk. Both rungs are one Node process, and the pre-commit hook runs both in one invocation. A store seam (scripts/lib/store.mjs) is what makes the read-back a value: the engine’s record and PROJECT.md are reached through an injected store rather than through path constants, and 137 load/save sites now obtain it from the invocation. Fixture snapshots (scripts/test/fixtures/fixture-snapshot.mjs) build once per file and restore per test.
The acceptance was sub-15 seconds for the full half, and 26.50 s is a miss — reported as a miss. The cause is stated rather than rounded: the control that removes EVERY flush still runs 5.22 s, so 21.3 s of the 26.5 s is durability flushing (80% of what is left, and it was 83% before) — the file rung’s 517 tests issue all 3,430 fsyncSync calls, and the unit rung’s 752 issue none. The file rung is down to 74 files from 91, with 63 files now on the unit rung. The gap between 26.5 s and 15 s is the file-rung population held there by four seam edges, the largest of which (CLAUDE.md’s managed rules block, ~27 tests) needs a spec change this release did not make: the engine-invocation delta states in bold that the block is a repository file the store does NOT own. What IS met is the unit rung’s own bound: one engine invocation over the memory store is 0.70–0.87 ms (n = 200), and the rung’s 752 tests run in 3.67 s with zero fsyncSync — the migrated, value-observing population is well inside the 10 s it was given.The memory store ships (design Open Question 1, resolved as recommended): it lives in scripts/lib/store.mjs beside the interface it implements, so a seam whose test implementation is not next to the interface is not a seam anyone finds. It has no production caller; the disk store is still what every real invocation gets. The engine remains zero-runtime-dependency — no package.json was added, and npm i in a fresh clone still fails, deliberately.Contributor-facing: CONTRIBUTING.md gains The dev inner loop — the clone-to-test quickstart (verified by running it: 1,269 tests in 26 s with no install step), node --test --watch on the half or on a named file, which rung a new test belongs in and how to tell, and what the unit rung’s guard refuses.
2026-09-21

The test suite runs in seconds, not minutes

Changed

  • The test suite runs in seconds, not minutes. The engine is invocable in-process (main(argv, io) returns its status; every process global is per-call), all git access goes through one injected gateway, and the suite is split: the assertion half (one process, spawn-free, git faked) runs on every commit; the functional half (real git) and the sweep bucket are triggered by content-hash certification and run in CI. A drift script in pre-commit enforces the functional–assertion pairing. Pre-commit wall time drops from ~7 minutes to ~90 seconds. Dev-only dependencies are now permitted (see the amended hard constraint); the engine remains zero-runtime-dependency, and none were needed.
2026-09-18

Operations ship their inverses; the guard covers every write path

Cluster 4 of the independent 0.43.0 review: the defect class both gates structurally miss — an operation shipped without its inverse, a stored reference nobody checks, and a mechanical guard whose matcher was narrower than the behaviour it claimed.Requires /reload-plugins.

Added

  • drop-detour <pausedEpicId> --reason "<why>" — the inverse push-detour never shipped. It ends the detour-stack frame naming that epic wherever it sits in the stack (a buried frame is the case with no other exit), never resumes the epic, never moves the active pointer, never changes its status, and accepts an epic that is already archived — the state an existing jam is already in. It ends the reconcile obligation the push armed rather than answering it: no verdict is written, and the may-invalidate link is disarmed and kept, carrying a drop stamp (droppedAt + the reason), so the record distinguishes dropped from reconciled. It requires a non-empty reason and refuses, writing nothing, when no frame pauses that epic. The interactive archive verb now refuses an epic a live frame still pauses and names this verb as the remedy; the two ship together because an exit reachable only when the jam is absent is no exit. drop-detour writes no Honcho line, unlike push/pop: a drop ends an obligation rather than moving work between epics, and it already records itself durably on the disarmed link.
  • set-autonomy <id> --revoke "<action>" --revoke-reason "<why>" — the inverse --preauthorize never had. A revocation RECORDS rather than deletes: the grant stays readable carrying its revocation and its required reason, matching --withdraw-gate-review and linkOnce’s superseded. A revoked grant is never honoured and never restored. It refuses, writing nothing, for an empty action, for an action the epic does not hold, and when every match is already revoked. set-autonomy --level autonomous now names what it is re-arming — off deliberately does NOT clear grants, so re-arming reports the count and each live grant’s action or category, and says so explicitly when there are none.

Fixed

  • An operation shipped without its inverse, and a stored reference nobody validated. A disposition’s --carried-to and the epic half of each --deferral now refuse — writing nothing — an empty id, an unknown id, and the archiving epic itself; the self-reference previously satisfied the handoff obligation while recording work as owned by a record that had just ended. --preauthorize refuses an empty action. integrity grows to see three shapes already on disk that the existing dangling-epic-reference check could not: a self-referential stored id, an empty stored id, and an autonomy grant naming nothing. Its declaration of id-holding fields becomes value-agnostic so an empty id is enumerable at all, while dangling-epic-reference and remove-epic’s sweep keep their existing non-empty predicate. The emitted rules block’s present-tense claim that grants accumulate with no revoke is reworded to past-tense evidence. No migration: every field added is optional and read-time-defaulted.
  • The reconcile gate guard covers Bash write paths. The PreToolUse guard was registered for Edit|Write|NotebookEdit only, so an agent blocked on Edit wrote the same file with cat > f <<EOF, sed -i or tee and the gate it exists to protect was skipped in one hop. Its matcher is now Bash|Edit|Write|NotebookEdit, and for a Bash call it blocks only a member of a closed, documented list of write shapes (a redirection into a file, an in-place stream editor, tee, a copier, git apply, destroying the conductor record) and passes everything else. Destroying .conductor/state.json is on the list — the guard is dormant while no record exists, so deleting it turns the block off entirely — and the record match is the exact path (plus a trailing *), never a longer literal filename, so the engine’s own rm .conductor/state.json.lock remedy stays runnable. A git invocation is read through its subcommand, so git rm -f .conductor/state.json and git -C <path> apply p.patch are both covered. The block message names the matched shape with a fixed label, interpolates no text taken from the command, and states that a Bash write is forbidden whether or not the check detects it; it drops the claim that completing the reconcile gate is the only way through, which this change does not make true. Wedge-freedom under the widened matcher rests on an explicit exemption: an unreadable state.json allows every affirmed Bash call carrying a command, because one of the remedies the message prints redirects into the record itself. The check is incomplete by construction and the docs say so — two fail-open modes (an unreadable record allows every Bash call with a command; an absent record leaves the guard dormant), and every undecidable form (a variable-built path, eval, a script by name, an interpreter given inline source) stays where pm’s law puts it, in the instruction the block carries. The reconcile arm deliberately ships no inverse (set-gate-guard off still does not reach it); the tracker-refresh arm keeps its own.
2026-09-17

What the commit hook observes, what the emitted commands do, and what text can do to output

The first batch of Important findings from the independent review of 0.43.0: what the commit hook observes, what the commands pm emits actually do when they are run, and what user-supplied text can do to pm’s own output.Requires /reload-plugins. Plugin versions install side by side, so until a session reloads it keeps 0.44.0’s commit hook and engine — which still write .conductor/commit-watch.json and none of what follows.

Fixed

  • A commit followed by a checkout in the same call was never reported (commit-nudge read only the top reflog entry, so checkout: moving from tmp to main read as no commit — and the watermark had already moved past it, so no later call reported it either). The hook now keeps a reflog anchor in .conductor/commit-observe.json — the reflog’s byte size and its last line, stored as bytes (anchor.lineBase64) — and reports every commit… entry since, oldest first. cfdude/pm#184
  • Two commits in one call reported only HEAD, so the attribution hint named one and the append-only array was left short. Every commit is named, and the attribution command carries every --attribute-commit, in landing order, as one invocation.
  • commit --amend double-logged. An amend now replaces: when the replaced commit is on no branch, the engine retracts its automatic row, and for each epic that attributes it prints update-epic <id> --withdraw-commit <replaced> --withdrawal-reason "amended into <new>" before any attribution command — or, where update-epic would refuse that withdrawal for a delivered epic, says so instead of printing a command that fails. An undone amend replaces nothing.
  • The active epic’s own work was logged as a detour from it. A commit touching the active epic’s own artifacts (openspec/changes/<id>/, its plan path, its spec path) writes no AUTO-DETOUR row, and a commit confined to a paused epic’s own artifacts writes no DETOUR-COMMIT row. Decided from paths, never from a subject prefix. cfdude/pm#184
  • A nested conductor logged its own bookkeeping. Changed paths are compared from the conductor root (read with -z, so a non-ASCII root still matches), so a commit touching only projects/sub/.conductor/state.json and projects/sub/PROJECT.md is bookkeeping, while a mixed commit touching a file outside the root is still logged. cfdude/pm#195
  • During a detour the attribution hint named only the detour epic. It now lists every candidate — the detour epic and each paused epic, or the active epic — one runnable command each, states that choosing is the agent’s, and orders a candidate whose own files the commits touch first. It never adds a candidate from paths and never writes an attribution. cfdude/pm#199
  • The only correction for a wrong automatic row was a hand-edit of git-ignored detours.log, which left the false row in the tracked PROJECT.md. See retract-detour below. cfdude/pm#173
  • Commits the hook could not have attributed safely are no longer offered. A commit rewritten by pull --rebase or reset away is named as rewritten or abandoned, with no row and no --attribute-commit.
  • Overlapping hook runs cannot drop or repeat a commit. The observation runs under an O_EXCL lock with a reported-sha set; a run that cannot take the lock within 200 ms skips, and the next run reports. The lock is broken on liveness — at once for a dead holder, after 10 s where liveness cannot be confirmed, never for age while its holder is confirmed alive — and a run releases only its own lock.
  • Remedies the engine printed and then refused. integrity’s delivered-release-epic-left-open printed a delivered archive for an openspec member with no Gate 2 (exit 1), and the archive refusal’s own Gate 2 remedy lacked --base-sha/--head-sha (exit 1 again). Every gate remedy is now rendered from one declaration carrying its gate’s evidence — the sha range for Gate 2, --artifact for Gate 1 — including integrity’s malformed-value finding, which is now gate-aware. unconsidered-outcomes and integrity’s epic-in-undefined-status offer delivered only where the archive gate would accept it (gh-189: 12 of 20 entries printed a choice that exited 1). An amend that withdrew an archived delivered epic’s only attribution printed attribute-first, which is refused; every site now prints the Gate 2 re-record THEN --attribute-commit.
  • Tracker recipes that could not run as written. The secondary gh issue list line requested no updatedAt while its registration line required one, and the secondary section had no watermark step. gh issue list without --limit stops at 30 items, so later items were never registered and the closed-item step proposed archiving their epics; the step now passes --limit 1000 with a truncation stop. A jira registration line derived jira-abc-<issue-number>, refused for the key ABC-123; non-numeric keys now derive <issue-key-slug>. A github-issues --repo was spliced into a shell line unquoted (--repo 'a/b; touch pwned' was accepted) and is now refused unless it is owner/name. Item titles were wrapped in double quotes with no instruction; the recipe now says to fill item values as one single-quoted word and emits --title=, --external-url= and suggest-lane --ask=, so a title like --help or $(touch pwned) stays data. Both inward sections pointed at /pm:epic list, which does not exist; an inward-only primary’s completion-sync reminder cited “writeback steps above” that the block never emitted.
  • A primary vendor switch kept a stale scope and could silently turn on outward creation. set-tracker --system jira --project ABC kept the old repo, which headed the jira section and seeded its ids; the old repo/projectKey/instance not re-given are now dropped and each is printed. A legacy github-issues primary with no recorded direction became an outward jira tracker; the switch now records the direction the old tracker resolved to, and says so.
  • The brief claimed what it could not check. ✓ all active epics are mirrored to jira printed while the active epic was linked to a GitHub secondary; with any secondary it now says every active epic carries an external link. never re-read — run /pm:sync counted epics /pm:sync never reads; it now names record-tracker-refresh, which clears every epic it counts, and the outward record-the-key line carries --external-updated-at so an outward-created link starts with a watermark.
  • Shipped docs taught refused gate forms and hand-edits. The hierarchy child’s record-gate-review --gate 1|2 --verdict pass exited 1; SKILL and /pm:review-mode taught the sha range for Gate 1, which records the wrong kind of evidence — every form now matches its gate. /pm:review-mode said an epic override had no unset (update-epic <id> --clear review-mode clears it). /pm:upgrade named a nonexistent /pm:integrity; /pm:cross-spec-review ran a checkout-only path. init’s stderr, the commit nudge, /pm:init and SKILL told the agent to edit state.json; each now names the verb.
  • Gate 2 fixes to the above. A primary set-tracker --remove saved a refused --repo (the --remove exemption now applies to the secondary role only). The brief’s not-yet-in-the-outward-tracker remedy recorded no watermark. The repo shape refused GitHub Enterprise; HOST/owner/name is accepted. Printed invocations interpolated raw epic ids; every one now goes through printedId(), which shell-quotes an id outside the id format (a legacy My Plan). A checkbox-source release member was offered an archive the gate refuses on open tasks; the drift-heal step’s archive was refused on open work too (it now names a stories source’s --story <n> --done first, or carries --carried-to <epicId> --reason "<which tasks moved>"); update-epic’s archived-delivered regression refusal printed an invocation missing that handoff flag (it now carries it, only with delivered); and unconsidered-outcomes named a checkbox handoff blocker with an empty remedy.
  • A value the engine did not write could forge a line of its output. A newline (or CR, NEL, U+2028, U+2029) in a detour reason, disposition reason, title, description, story title, withdrawal reason, session name, plan heading, .changesets fragment, lesson frontmatter, unknown id, legacy release id or tracker value started a line the engine never wrote: a NOW: line in the brief, a ## heading in PROJECT.md, a runnable update-epic … --status archived line inside the archive refusal, a ✓ integrity: all checks pass line from integrity, a ## FORGED rule heading in the managed rules block, a forged owners row. Every governed value is now escaped on PROJECT.md, the brief, the rules block, every hook’s decoded output, integrity, release show, owners, every verb’s stdout/stderr and refusals, and .conductor/honcho-memories.log (one line per entry). A project or log directory named with a line terminator is escaped in engine messages too (render’s rendered <path>, set-activity-log on, a honcho-memory filesystem failure, the verbose engine banner). .conductor/detours.log escapes its epic and note fields at write, since the engine parses that log by line and tab.
  • Forged PROJECT.md table cells. A | in a detour reason or a minimal detour note added a cell, and a newline in a disposition reason added a row — the Dispositions table escaped | and nothing else. Every data row of every table is built by one function that escapes control characters, then \, then |, so a\|b stays one cell under GitHub-flavored-Markdown splitting.
  • JSON on stdout left DEL, C1 and U+2028/U+2029 raw — JSON.stringify escapes only C0 — so a legacy status holding U+2028 put a line start into triage’s output. Every stdout JSON document goes through one serialiser that escapes them; the parsed value is unchanged.
  • Ids holding a control character were stored. sync (active changes and plan files) and the archive backfill registered a directory or file name holding a newline as an epic id, and release "r<newline>FORGED" --intent … stored that release id. See Changed.
  • Tracker scopes holding a control character were stored. set-tracker --system, --project and --repo now refuse a control character, for both roles, before anything is read or written — including a primary --remove, which exited 0 and wrote ## FORGED into CLAUDE.md and state.json. --role secondary --remove is not refused, so a legacy secondary stays removable.
  • A printed command could carry a control-character id. Every printed command naming an epic or release id goes through printedId() — release ids included now, among them integrity’s release <id> --defer remedy — and a legacy id holding a control character prints no command: <kind> '<escaped id>' holds a control character; no verb can rename it, never 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.

Added

  • retract-detour <sha> --reason "<why>" — the inverse of the hook’s automatic logging. Appends one RETRACTED row covering every AUTO-DETOUR and DETOUR-COMMIT row of that commit (matched by prefix, whatever the row’s abbreviation length), removes nothing, and re-renders PROJECT.md without them. Refuses, naming why and writing nothing: a value that is not a hex sha (a ref such as HEAD), no matching row, a MINIMAL-only row, an already-retracted commit, a missing or empty reason, a too-short or ambiguous sha of a pruned commit, and a detached tree. cfdude/pm#173
  • deliveredBlockedBy on every unconsidered-outcomes entry — always present, [] when nothing blocks: {kind, detail, remedy} per obligation blocking delivered, in the order to run them (Gate 2 before the handoff). A checkbox source’s handoff remedy is the delivered archive carrying --carried-to <epicId> --reason "<which tasks moved>".
  • suggest-lane --ask=<text> — the same lookup for a flag-shaped text, which cannot be passed positionally. The positional form is unchanged; both together are refused.
  • The tracker-repo-not-a-github-repository integrity check — names a github-issues tracker whose recorded repo fails [HOST/]owner/name (it gets no gh step), with the re-record, and for a secondary its --remove printed shell-quoted.
  • The emitted-invocation sweep (scripts/test/emitted-invocations.test.mjs): every invocation in the rules block (every platform × tracker role/system/direction), init, the commit nudge and the shipped docs passes the pre-dispatch check; every engine-printed remedy is executed against a fixture reproducing its finding and the finding is asserted gone; Layer A also reads the whole output of every run in the file, and a source scan requires every printed-invocation template in scripts/lib to be reached by a fixture. Deliberate refused examples in docs carry {/* pm:refused <class> */}, and the argv check reports a refusal class.
  • The brief’s blocked-without-depends-on remedy is a registered brief remedy with its own fixture.
  • The output-integrity poison sweep (scripts/test/output-text-integrity.test.mjs, developer-facing): one accumulated fixture runs a poisoned value through every value-bearing flag and free-text positional (the key set is held equal to the flag and positional registries), every declared non-argv input, and legacy poison recipes written straight into state.json (ids, a release, a detour frame, trackers, a stored priority, a session, a pmVersion) — then sweeps every surface: render, write-rules, every hook verb, every read-only verb, every id-taking positional and every recipe’s own output. Each recipe declares that its value is rendered, why it is not, or which check refuses it.
  • The per-interpolation output sweep (scripts/test/output-interpolations.mjs, developer-facing): a lexer classifies every ${…} and non-literal + operand in the engine as escaped, literal, sunk at a line join, not output, or judged with a stated reason; the suite fails on an unclassified, stale or over-broad judgment. It is best-effort by design — its known limits are listed at the top of the file — and the poison sweep above is the behavioural backstop.

Changed

  • commit-nudge is also wired on PostToolUseFailure, so a commit inside a Bash call that then failed is reported; the output envelope names the event it answers. A failed Bash call now spawns one hook process (63 ms per no-op run measured, mean of 20); a successful one gains none.
  • Every report states its provenance: the commits “landed since the last observation — this call, another terminal, or a parallel call; the hook cannot tell which”. A commit made in another terminal is still reported by the next observation and can still be auto-logged against the active epic (proposal defect 3): that is a stated residual, not a fix, and retract-detour corrects the row.
  • On unreadable state.json, commit-nudge writes nothing, the observation record included, on both post-call events, so the commit is reported by the first run after the file is repaired.
  • The rules block’s tracker sections change for every tracker-configured repo — the listing step, the quoting sentence, the secondary watermark step, the non-numeric key placeholder and the reminder wording — and are re-rendered by /pm:upgrade. No state.json change and no migration. A repo whose recorded github-issues repo fails [HOST/]owner/name stops receiving a literal gh line (it gets the vendor-neutral step) until it re-runs set-tracker, and integrity names it.
  • sync skips a name that cannot be an epic id. A change directory, plan file or archive directory whose name holds a control character or whitespace is no longer registered; sync registers the rest and names it on stderr on every run, quiet included — rename it to register it. integrity’s archive-directory-has-no-epic says such a directory must be renamed. Uppercase names are unaffected, and an entry an epic already holds prints nothing.
  • release validates a new id first. A new release id 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.
  • Display wording changes only where a value holds a control character (now a visible escape) or, in a PROJECT.md table cell, a \ or | (now escaped). A description written with newlines renders as one line. No state.json schema change and no migration.
2026-09-16

The engine refuses what it cannot read or verify

Every Critical finding from the independent review of 0.43.0, fixed in three changes.A command line the engine does not read is no longer acted on. Before this, no single place decided what a verb accepts. Appending an undeclared flag to a working invocation of each of the 50 dispatched verbs, 38 exited 0 and 15 of those wrote state.json or detours.log; only 12 refused the flag by name. A stray positional was dropped the same way: add-epic … --title My Title stored My. One check now runs before dispatch for every verb, and before the banner, the activity snapshot and any file write. It reads the verb’s declared flags, how many positionals it reads, and the argv-level --force. Its population is the dispatch table, so a verb added later is bound without anyone extending a list.And a help token can no longer perform the write. 0.41.0’s fix for cfdude/pm#187 honoured --help/-h only as the first token after the verb, so one later on the line reached the verb as data. remove-epic e2 --help removed e2, set-gate-guard off --help disarmed the guard, and 14 verbs performed their write on a trailing --help.And input the engine cannot read is refused, not guessed at. Four places met a file they could not read and guessed, and each guess destroyed or hid the record it was handed, while reporting success. A conflicted state.json loaded as an empty record; 16 parallel saves reported 9 successes over 7 epics on disk; claim --ttl 1e12 wrote a claim every later reader crashed on; and write-rules deleted hand-written CLAUDE.md text and printed refreshed. Each now refuses with the file named and nothing written, or is serialised so the loss cannot happen.And two gates bind to evidence the engine checked. The reconcile gate after a detour could be cleared by a verdict against the wrong detour, erased by moving the active pointer, and overwritten by a later push. Gate 2’s staleness check read a commit value stored exactly as typed, compared only the last attributed entry, and read a head on an unrelated branch as fresh. Each let an epic past a gate that should have held it. The obligation is now recorded per detour on the link, and every commit value is resolved when it is written.

Fixed

  • cfdude/pm#187’s 0.41.0 regression. A --help or -h anywhere after the verb, outside a flag’s value, prints that verb’s help, exits 0 and writes nothing, whatever else the line carries: remove-epic e2 --bogus --help prints help rather than refusing --bogus. In a value position --help is still refused, as #187 requires (conductor: --title requires a value — '--help' arrived where that value belonged and was read as a flag, not as the value. If it IS the value, write --title=--help), and -h there is the flag’s value, as before.
  • init validates --platform before it creates anything. init --platform bogus printed created .conductor/state.json and then exited 1, ending pm’s dormancy in a repo whose init had failed, and a valueless init --platform exited 0 on the default. Both are now refused with nothing created: conductor: --platform must be one of claude-code|hermes|codex and conductor: --platform requires a value.
  • --force is reachable on add-epic, update-epic and claim. Their own allowlists refused the escape hatch the state-write guard documents. --force is now one registry row, marked argv-level, accepted on exactly the verbs VERB_EFFECTS declares mutates and refused on every read-only verb (conductor: unknown flag --force for integrity — it accepts no flags).
  • --force is never read as a positional. Each verb receives its positionals first, its flags in their original relative order, and argv-level flags last. log-detour fixed it --force logs fixed it, and set-active --force e2 activates e2. set-gate-guard --force prints the same report as bare set-gate-guard, where it printed usage; set-activity-log --force gives the same usage answer as bare set-activity-log, which has no read form.
  • update-epic’s disposition flags are refused outside an archive. update-epic e1 --outcome killed --reason no exited 0 saying every supplied value was already held, and recorded no disposition. --outcome, --reason and --carried-to now share the deferral flags’ refusal: conductor: --outcome, --reason are recorded only when an epic is ARCHIVED, and this invocation does not archive 'e1' — nothing would have been written.
  • A bare set-lane-routing refuses instead of writing an empty laneRouting block and reporting success: conductor: set-lane-routing needs an operation — --add "<match>:<lane>", --remove "<match>" or --clear. Nothing was written.
  • A shared helper no longer reads a flag on another verb’s behalf. set-active e2 --diff-summary printed epic-relevant: yes; --diff-summary is declared on render only and refused everywhere else.
  • brief --help no longer says the verb takes no flags while hooks/hooks.json passes it --platform claude-code. --platform is declared on init and all five hook verbs and validated before anything else they do.
  • An unreadable state.json is refused, never replaced — exit 11. A present file that cannot be read, does not parse, or has the wrong shape (top level not an object; epics present and not an array, or holding a non-object; detourStack present and not an array) makes every verb that reads state refuse, write nothing and exit with a new dedicated code, 11, chosen outside Node’s own exit codes. --force does not override it. On 0.43.0, add-epic over a three-epic file with a conflict marker prepended exited 0 and left only the new epic, and sync, upgrade and init wiped a truncated file the same way. The refusal names the reason and the remedies: git checkout --ours|--theirs, git show <good-rev>:.conductor/state.json > .conductor/state.json, git restore, and, for a file git has never had, mv .conductor/state.json .conductor/state.json.damaged then /pm:init. init loads an existing file before its first write, so it refuses before touching .gitignore. An ABSENT state.json stays dormancy. Exempt by name: verify-state, which never loads the file, and activity, which reports the current revision and whether the log is on as unknown.
  • Concurrent saves are serialised and flushed. The revision check and the rename were not one critical section: 16 parallel add-epic, three runs, printed added epic 9, 9 and 8 times while 7, 6 and 6 epics reached disk, and one run published the same revision from two writers. saveState() now holds an exclusive lock file, .conductor/state.json.lock, from the revision check through the rename and read-back, fsyncs the temp file before the rename, and releases the lock on every return and throw path. --force bypasses the revision comparison, never the lock. This reverses 0.26.0’s documented decision against a lock file (“a session killed mid-write leaves the lock held forever”): the stale-break rule below answers that objection, and the revision guard 0.26.0 chose instead was measured insufficient. The revision guard stays — the lock serialises the section; the revision still catches a writer whose load predates another’s save.
  • A stale lock is broken; a live one is waited for, then refused — exit 9, never a lost update. A save waits up to 2 s for a live holder, then refuses with the conflict code naming the lock path, the holder’s pid and host, the 30 s rule, and the rm .conductor/state.json.lock to run if no pm command is running. A lock is stale when its mtime is more than 30 s away from now (past or future — a backward clock step must not wedge it), or its holder is confirmed dead on this host in this pid namespace. Breakers are serialised by .conductor/state.json.lock.break and re-judge the lock by inode and nonce before removing it, so none removes a lock it did not judge. A hook’s save that times out on the lock is skipped and recorded in write-conflicts.log, as a hook’s revision conflict already was.
  • An advisory claim’s lifetime is bounded, and an unreadable expiry reads as expired. claim and claim --repo refuse a --ttl above 10080 minutes (7 days), exit 1, writing nothing: conductor: --ttl requires a positive number of minutes, at most 10080 (7 days). Nothing was written. On 0.43.0 claim --ttl 1e12 wrote the claim, crashed with RangeError: Invalid time value, and owners, integrity, another session’s claim and unclaim --steal crashed the same way from then on. A stored claim whose TTL is above the bound, whose claimedAt does not parse, or whose expiry is not a representable date now reads as expired: owners shows it STALE … expired at an unreadable time and another session takes it over without --steal. .conductor/session-claim.json is written by temp file plus rename, so a racing reader never sees a torn marker.
  • The managed rules block is located by whole-line markers and replaced only when unambiguous. Markers were matched as substrings: a prose line quoting <!-- BEGIN pm-conductor rules above a hand-written section made write-rules delete that section and print refreshed; a deleted END line appended a second block whose next refresh swallowed the text between them. A marker is now a whole line. Exactly one BEGIN line then one END line → replaced, every byte outside unchanged; none → appended; anything else → refused, exit 11, naming the file and every marker’s line number, rules file untouched. init and upgrade check before their first write, so a refused upgrade never stamps pmVersion; write-rules, set-tracker and set-review-mode refuse at the block write, after their state save, and the message says so: after fixing the markers, run write-rules and then render (or /pm:status) to complete it. A marker inside a fenced code example counts; the outcome is a refusal with line numbers, never a deletion.
  • The block is written literally. The splice was a string replacement, where $``, &` and `’are substitution patterns:set-tracker —system github-issues —repo ‘o/n$' copied the file’s own prefix into the block, so a sentinel line present once was present three times. Lines are now sliced and joined, with no String.prototype.replace on the path.
  • The block follows the rules file’s line endings. A CRLF CLAUDE.md got an LF block (3 CRLF lines of 380 afterwards). A refresh takes the BEGIN line’s terminator; an append takes the file’s first terminator, LF when it has none.
  • Three ways to clear a reconcile nobody answered. After push-detour p --detour d --reconcile and pop-detour p: record-reconcile p --detour p (or --detour <any other epic>) wrote a new may-invalidate link, cleared reconcileNeeded, and let gate-guard through; clear-active or set-active <other> cleared it through the render heal, and so did archive-then-unarchive; and a later push-detour p --detour d2 --no-reconcile overwrote it, after which the pop logged resumed p, reconciled vs d2; no reconcile was required. A verdict is now accepted only against a detour pushed --reconcile whose frame has been popped, and never creates a link: conductor: 'p' cannot answer a reconcile against itself — 'p' owes a verdict against: 'd'. Nothing was written. The heal never clears the flag because of the pointer or the status, a pointer move off an owing epic warns, push-detour ORs the flag, and pop-detour names every detour still owed.
  • The writes that destroyed an owed reconcile’s record. update-epic p --clear-links (the clear-and-re-supply repair included) and remove-epic d stripped the only link the verdict could be recorded against, leaving an obligation nothing could answer; both are refused while p owes, naming record-reconcile. A same-target --link reason correction replaced the link wholesale and dropped its verdict; it now keeps every other key. The two emitted repair messages name record-reconcile first on an owing epic.
  • Four ways a commit value let a refused archive through. --attribute-commit not-a-commit read unverifiable, which the archive gate does not refuse; --head-sha HEAD was stored as the literal HEAD, a different commit on every read; a Gate 2 whose headSha was on an unrelated branch read fresh; and attributing an ancestor after an uncovered descendant read fresh because only the last entry was compared. Each is refused now: conductor: cannot archive openspec-lane epic 'e' — its passing Gate 2 reviewed up to 87886d787d1182cec964bdddfdb21aeb58d32e13, which does not reach the commit(s) attributed to this epic: 1b27861d8a50e310ff9f8f0e82574c9d187dd4f9. Re-review the full range and record it, or correct the attribution.
  • --withdraw-commit <full sha> refused a stored short sha of the same commit as “never attributed”. It matches by commit identity, and by exact spelling where the value does not resolve, so a legacy not-a-commit stays withdrawable.
  • --amendments none was stored as the amendment ["none"], and an amendment containing ; was split into several. none now records no amendments, and the new repeatable --amendment records one verbatim amendment per occurrence.
  • Recording a reconcile verdict twice overwrote the first. The earlier verdict moves to superseded on the link; re-pushing --reconcile to an answered detour re-arms it the same way.

Changed

  • BREAKING — an undeclared flag is refused on every verb, before anything is written, naming the flag and what the verb accepts: conductor: unknown flag --levle for set-autonomy — it accepts: --level, --preauthorize, --context, --notify, --force. Cases it closes: record-reconcile … --amendmnts "a;b" recorded invalidated with amendments: []; set-autonomy e1 --levle autonomous wrote level: off; set-tracker … --drection outward stored inward; add-many --from b.json --external-id X created the batch, although batch keys are not command-line flags. --session is declared on claim and unclaim only, so it is refused elsewhere; PM_SESSION names the session for the activity log.
  • BREAKING — a surplus positional is refused, naming the first token the verb does not read. That includes an unquoted multi-word value, which now carries a hint: conductor: add-epic takes no positional arguments — 'Title' is an extra argument it does not read. Nothing was written. then If 'Title' belongs to --title's value, quote the whole value. suggest-lane fix a typo, which routed fix alone, is refused with suggest-lane reads ONE text argument — quote it.
  • BREAKING — remove-epic <id> --cascade true is refused. A valueless flag never takes a value, so true is a surplus positional (--cascade takes no value.). It was accepted because the verb compared the parsed value to "true". Write --cascade on its own.
  • BREAKING — a valueless flag written --name=value is refused, where it was accepted with the value ignored: conductor: --force takes no value — '--force=1' gives it one, and add-epic would ignore it. Write --force on its own. Nothing was written.
  • BREAKING — outside the free-text verbs, a ---leading token that is not flag-shaped is refused as an undeclared flag. claim’s own parser skipped --Steal, so claim --repo --session s --Steal recorded the repo claim. On triage, suggest-lane, log-detour and honcho-memory such a token is still text (log-detour "--no-verify was used on the hotfix" is logged).
  • --id in place of a positional epic id is diagnosed on every verb that takes one, not only update-epic, and the refusal rewrites the line you meant: remove-epic --id e2 answers remove-epic takes its epic id POSITIONALLY, not as --id and shows remove-epic e2.
  • A positional written after flags is read. triage --limit 3 "fix the render stamp" answers where it printed usage; claim --steal e1 --session s2 claims e1. triage --limit 5 with no ask still prints usage.
  • A free-text verb’s undeclared-flag refusal carries the quoting hint too: log-detour fixed --no-verify usage adds If '--no-verify' is part of the text, quote the whole value.
  • Refusals escape control characters in the tokens they echo, so a newline inside a caller’s token is shown as an escape and cannot start a line of the refusal.
  • Help is projected from the declarations the check enforces. Its first line names the positional form (conductor.mjs remove-epic <id> — 1 flag.); a mutating verb lists --force under “Accepted on every mutating verb”; a verb whose own parser reads no flags says no flags of its own; and “takes no flags” appears only where the check accepts none.
  • BREAKING — hooks never write over an unreadable state.json, and each reports it on the channel its event can act on. gate-guard (PreToolUse) blocks Edit/Write/NotebookEdit with exit 2 until the file is fixed: on 0.43.0 a conflict marker made it exit 0, silently disabling the unconditional reconcile block, and epics: {} crashed it with a TypeError (exit 1, also allow). Bash is not matched by that hook, so every remedy stays runnable. brief (SessionStart) exits 0 with the warning as the only context, in place of a briefing of the empty guess. snapshot (PreCompact) writes nothing and exits 11 — never 2, which would block compaction. commit-nudge (PostToolUse), when a commit has landed, writes nothing but its HEAD watermark and exits 2, which shows the message to the agent; on 0.43.0 it re-rendered PROJECT.md from the empty guess. The mapping is keyed on the hook marker in VERB_EFFECTS, so a refusal raised anywhere a hook calls produces that hook’s status. Two tests that asserted the old behaviour as the contract (gh-111’s owners on an unparseable file, gh#129’s commit-nudge exit 0) are rewritten to the refusal.
  • The state lock files are written in a detached checkout. The detached-HEAD table of .conductor/ write sites settles state.json.lock and .lock.break as not session bookkeeping: the save they serialise is not suppressed there, and suppressing the lock would leave it unserialised. The repo claim’s detached-tree check now asks about the tree the marker is written into rather than the engine’s import-time root.
  • BREAKING — update-epic --attribute-commit and record-gate-review --base-sha/--head-sha refuse a value that is not a commit in this clone. Each value is resolved against the local object database at write time and stored as its full object name, so HEAD, a short sha or an annotated tag records the commit it names at that moment; not-a-commit, root, or a sha the clone does not hold refuses the whole invocation, naming every such value: conductor: --attribute-commit value "not-a-commit" does not resolve to exactly one commit in this repository's object database — not a commit, ambiguous, or absent from this clone. A recorded commit is resolved when it is written and stored as its full object name, so a value nobody can check is never recorded. Nothing was written. A clone without the reviewed commits (after a squash-merge, one without the presquash/* tags) can no longer record that range: record Gate 2 from the authoring clone before the squash-merge, or fetch the tags first. --withdraw-commit resolves too, but only to match, and never refuses.
  • A Gate 2 verdict is fresh only when its headSha reaches EVERY attributed commit — equal to it or an ancestor of it — in one git rev-list per verdict. A legacy stored value that is not a hexadecimal commit name (HEAD, not-a-commit) is never resolved at read time and reads ⚠ stale; a hexadecimal value this clone does not hold still reads ⚠ unverifiable.
  • integrity’s recorded-sha-the-repository-cannot-resolve reports a recorded value that is not a commit object name at all (gate2.headSha "HEAD"), with the withdraw or re-record command that clears it. No migration rewrites stored values.
  • Every may-invalidate link carries an arming record, reconcileOnResume. Only push-detour --reconcile writes true; a --no-reconcile push and a link supplied by hand write false. record-reconcile accepts a verdict only against an armed link, and reconcileNeeded clears only when no armed detour is left unanswered.
  • The render heal’s one remaining clear is announced. An epic owing a reconcile with no frame and no armed or unmigrated may-invalidate link can never be answered, so the flag is cleared and stderr says so: conductor: cleared the reconcile obligation on 'q' — it holds no may-invalidate link a verdict could be recorded against and no detour frame pausing it, so no record-reconcile could ever be accepted and it would have blocked the epic permanently.
  • The emitted rules block and its mirrors give one record-reconcile form (--amendments none, or one --amendment per line) and replace “the LAST entry is the endpoint a recorded Gate 2 headSha is compared against” with “every attributed commit must be reached by” that head.

Notes

  • gate-guard fails open on a line it refuses. In an initialized repo, a hook line carrying an undeclared flag exits 1 after draining stdin, which Claude Code treats as a non-blocking hook error, so that tool call is not guarded. Exit 2 would block every tool call in every session until the plugin was fixed. The suite asserts every line in pm’s own hooks/hooks.json passes the check, so a mismatch needs a hand-edited hook line or an installed plugin’s hooks.json driving a checkout engine of another version under PM_ENGINE_DELEGATION. In a repo without pm the five hook verbs refuse nothing and stay silent; a help token still prints help.
  • Help wins. remove-epic e2 --help exits 0 having removed nothing, so a script reading only the exit code sees success. The output is help text, not the success line.
  • --force is accepted and does nothing on honcho-memory and purge-logs, mutating verbs whose writes never reach the guarded state write. Refusing it there would need a second list of which mutating verbs save state.
  • A missing required positional and a value’s vocabulary (--verdict maybe, set-gate-guard maybe) stay each verb’s own refusal.
  • Run /pm:upgrade in each repo. It rewrites the managed rules block, so a repo picks up corrected emitted command lines only when it runs. For this change the sweep of every invocation pm emits (command docs, skills, README, hooks/hooks.json, the rules block on all three platforms — 487 lines) found none the check refuses, so an existing rules block breaks nothing.
  • UPGRADE NOTE — a rules block that is already malformed stops /pm:upgrade. A repository whose CLAUDE.md (or AGENTS.md/HERMES.md) holds an orphan BEGIN or END marker line, or two blocks, will have /pm:upgrade refuse with exit 11, list every marker’s line number, and write nothing — pmVersion stays behind, so a fleet sweep keeps reporting the repo as not upgraded — until the stray lines are removed by hand (highest line number first; a whole duplicate block is safe to delete). 27 marker-bearing rules files counted on one machine were all well-formed; a second machine is unmeasured.
  • .gitignore gains .conductor/state.json.lock*, .conductor/state.json.tmp* and .conductor/session-claim.json* through ensureGitignore(), which init and upgrade run. The last is appended alongside the older exact .conductor/session-claim.json entry, which is left in place. A temp file is left behind only by a save killed between its write and its rename.
  • A claim taken with a TTL over 7 days by an earlier engine reads as expired after upgrading. Claims are advisory; re-claim.
  • A damaged state.json now stops pm in that repository until it is fixed, by design, and Edit/Write/NotebookEdit are blocked there meanwhile.
  • What the lock does not promise. On macOS Node exposes no F_FULLFSYNC, so the fsync orders the temp file’s data ahead of the rename but is not power-loss durability. A holder stalled past 30 s (a suspended laptop, a debugger) can be judged stale; if it resumes in the instant between its ownership check and its rename, two writers write. .conductor/ on a network filesystem is not a supported layout. A hook can wait about 4 s under sustained contention.
  • A 0.44.0 MIGRATIONS entry: the link arming stamp. It gives every may-invalidate link without a key an explicit reconcileOnResume — true where its epic owes a reconcile, the link has no verdict, and it targets another epic that exists; false otherwise. Additive and idempotent: a keyed link is never touched, and upgrade runs the same stamp on every run so a keyless link written later by an unreloaded older session is stamped too. A 0.43.0 state file loads; until /pm:upgrade runs, every record-reconcile on an epic holding a keyless link is refused naming /pm:upgrade. The stamp cannot tell an old --no-reconcile link from an old --reconcile one, so an owing epic can come out owing a verdict against both. An owing epic whose every link already carries a verdict is stamped all-false, and the heal clears its flag with the notice above.
  • The link schema is additive: reconcileOnResume and superseded on may-invalidate links. The other two changes in this release add no schema change and no migration.
  • Staying owed is deliberate. An archived or abandoned epic that still owes keeps --clear-links and remove-epic <detour> refused. End it with record-reconcile <id> --detour <d> --verdict invalidated --amendment "abandoned: <why>"; remove-epic <id> remains for an epic registered in error.
  • A git without GIT_NO_LAZY_FETCH support may still lazily fetch a missing object in a partial clone during resolution; every other clone reads only its local object database.
2026-09-14

The archive gate reads the record the call writes

update-epic ran the archive gate before the invocation’s own field writes, so the gate decided on a record the call was about to replace. Every field write came after it: --lane, --attribute-commit, --add-story, --withdraw-commit, --story <n> --done, and the --clear unsets. And an update to an epic that is already archived never passes through --status archived, so it never met the gate at all. Both halves had one cause: the gate read a record the invocation does not leave.And a gate verdict gets the inverse every other correctable record has. A verdict recorded on the wrong epic could be replaced but never taken back, so one copied onto a tracker-mirror epic while closing #175 was recoverable only because the write was still uncommitted. It can now be withdrawn, with its reason, and the withdrawal is recorded rather than erased. Withdrawing is a field write the archive gate above decides on, so it is never a route past that gate.

Added

  • update-epic <id> --withdraw-gate-review <1|2> --withdrawal-reason "<why>" — the inverse of record-gate-review, which was the one record in state.json with none (cfdude/pm#192). Re-recording can replace a verdict; nothing could say a verdict does not belong on this epic at all. Closing #175, both verdicts were copied onto a tracker-mirror epic, integrity reported them recorded 2192 ms apart, the suite went red, and the record was recoverable only because the write was still uncommitted. The whole entry, superseded included, moves into withdrawnGateReviews[] as {gate, entry, reason, withdrawnAt} — recorded, never erased — and the write is read back after render. Repeatable, so both gates go in one call. Re-recording is the way back; there is no un-withdraw.
  • Refused where it would record nothing true: --withdrawal-reason alone (it used to pass and write nothing), no reason, a gate other than 1 or 2, the same gate twice, no stored verdict, and an ungated stamp — which is the engine’s record that no review happened, cleared by recording a real verdict.
  • Withdrawn is a state, never absence. The obligation reappears, as for a gate never recorded, but every surface says what happened. The archive gate and the archived-epic regression refusal state that Gate 2 was withdrawn and quote the reason, JSON-quoted with control characters escaped; archived-openspec-epic-with-no-gate-1 names a withdrawn Gate 1; PROJECT.md and the brief render withdrawn — <reason> from one shared gate-table helper and keep an epic whose every gate is withdrawn; the activity log records gate-withdrawn on growth of withdrawnGateReviews only.
  • A new integrity check, archived-with-withdrawn-gate-2, and its own brief heading. The archive-drift heal no longer stamps ungated over a withdrawn Gate 2 — that would relabel a review taken back as one that never happened. An archived openspec-lane epic in completion scope left that way is the standing condition’s withdrawn kind, counted as archived in state or on disk, worded as withdrawn, and saying so where it was archived ungated before the withdrawn review.
  • It is a field write the archive gate decides on: a Gate 2 withdrawal in a delivered archive call, or on an archived delivered epic whose Gate 2 was met, is refused. The #175 remedy is one call that withdraws both gates and records --outcome superseded with --correct-disposition.

Fixed

  • Four one-call bypasses of the archive gate. On 0.42.0, update-epic a1 --lane openspec --status archived --outcome delivered --no-deferrals on a claude-code epic with no Gate 2 exited 0 and left a delivered openspec-lane epic with no Gate 2. Split into two calls, the same flags were refused. The same shape reproduced with an --attribute-commit the verdict does not cover, an --add-story, and a --withdraw-commit. The gate now runs after every field write and unset, just before the completion stamp and the save, so all four are refused. Leaving the openspec lane in the archiving call is accepted, because two calls already reach it.
  • A false refusal in one call. --story 1 --done --status archived --outcome delivered --no-deferrals on an epic whose only story was outstanding was refused for the story that same call marked done. It is accepted.
  • README showed --withdraw-commit <sha> --reason. The flag has been --withdrawal-reason since 0.38.0.
  • A refused call announced fields it never cleared. The sync-ignore tombstone clear, the rank clear and the --clear notes printed before the gate, so a refused call reported changes that did not happen. They are buffered and print only once the write is going to happen.

Changed

  • An update to an archived delivered epic may not break an obligation its archive met. On 0.42.0, update-epic a3 --lane openspec on an archived delivered claude-code epic exited 0, left a record the gate would refuse, and integrity named it nowhere. The engine now compares the Gate 2 demand and the handoff demand before and after the call, one at a time, and refuses where one that was met now fails. It runs when the stored outcome is delivered, the call does not archive, and the record will be archived when the call returns. That includes a non-archived --status on an epic whose change directory is archived on disk, which the call’s own render re-archives: two drafts of this check missed that route, and both Gate 1 lenses reproduced it. An obligation that already failed is no ground for refusal, so a legacy record keeps its notes, links and priority editable. Per obligation, not per record: an already-failing handoff does not mask a Gate 2 the call breaks.
  • The refusal prints one runnable invocation. It is built from the refused call’s tokens, single-quoted, minus --status and every disposition flag, plus --status archived --outcome <…> --reason "<why>". It adds --correct-disposition only for an agent-recorded disposition, and a deferral placeholder only where none is asserted, never a bare --no-deferrals. A value carrying a control character is replaced by <re-enter this value>, so no user value can forge a line of the refusal. That invocation runs the full gate on the record it leaves.
  • The Gate 2 and handoff checks live in one exported deliveredObligations(), shared by the gate and the regression check. The gate’s own messages are byte-identical.

Notes

  • What this deliberately does not bind: record-gate-review recording a fail or an uncovering pass, the archive-drift heal re-archiving an epic changed while open, remove-epic stripping disposition.carriedTo, and disk-side task edits. Refusing at any of them would falsify the record or block unrelated work. The standing condition is held by archived-delivered-gate2-regression-report.
  • It is a ratchet, deliberately. On a record whose Gate 2 already failed, --lane claude-code is accepted and --lane openspec straight after is refused, although it restores the earlier record.
  • withdrawnGateReviews is additive and absent-tolerant, so there is no migration.
2026-09-12

A deployed checkout is not a workspace

.conductor/state.json is git-tracked by design — it is the backup, and git restore is the documented undo — so a repository that deploys by checking itself out carries that file in the deployed copy, and PM’s dormancy guard read that tree as a place to work. One file was answering two different questions, and in a self-deploying repository those diverge.Measured in a real production checkout: a commit watermark, a brief snapshot and an activity log had accumulated there, all git-ignored so git status stayed clean. And a fleet upgrade then stamped a new pmVersion and rewrote the managed rules block into it — four tracked files, in a tree whose next git checkout --force discards them.

Added

  • A detached HEAD suppresses session bookkeeping. The commit watermark, the detour log, the brief snapshot, the session claim and the activity log all record work in progress, and a tree nobody is working in has none. Silent — a file that does not appear asserts nothing, where a line on every hook invocation would be noise. The set is defined by a criterion, not a list (per-checkout, engine-owned, a record of work in progress rather than of the project), and all fifteen .conductor/ write sites are settled against it: five suppressed, four deliberately still writing with their reasons recorded, the rest removals needing no guard.
  • A verb that writes says the tree is detached, naming the files and the tag when HEAD is exactly at one — detached at v2.11.0 identifies a deployment where detached alone does not. It warns and still writes: PM reports, it does not decide. Read-only verbs stay silent, on the same verb-effects gate 0.40.0 used to stop the sibling warning crying wolf on the 17 of them.

Fixed

  • --artifact evidence was invisible to gateHasEvidence, so a correctly-run Gate 1 reported as gate-recorded-as-bookkeeping. 0.41.0 added the evidence form and updated the writer and the renderer; two other readers of the same field kept the range-only definition. The predicate is not widened — two of its callers dereference entry.headSha on the next line — the two call sites asking it the wrong question are. The arm’s premise inverts for Gate 1: for Gate 2 the loop is commit then review, while a Gate 1 recorded with artifacts necessarily post-dates the commit that fixes what the review found.

Notes

  • The probe discriminates the exit status rather than testing for non-zero. git symbolic-ref --quiet HEAD exits 1 when detached, 128 when the directory is not a repository, and throws when git is absent. A blanket catch → detached would make a non-repository suppress writes — the opposite of the safe direction, since a false record is visible and removable while a false suppression silently disables the trail.
  • Every CI run is a detached tree, because actions/checkout leaves HEAD detached at the sha. Accepted rather than carved out: those trees are ephemeral, so suppressing session bookkeeping there is right rather than merely tolerable. It is also why no opt-out ships — the population that would need one wants neither the writes nor the flag.
  • Gate 2 caught a defect that could not fail locally. The suppression guard probed the frozen root while one of its write paths re-derives its root per call, so in-process the guard asked about one tree and the write landed in another. The CLI fixes one root at startup and the two can never diverge there; CI checks out detached, so PM’s own suite would have gone red on the PR.
  • Three verbs reported writes that suppression removed, fixed in the same gate: claim --repo announced a marker it never wrote — and with --steal, a takeover that never happened, printing the other session’s expiry so the message read as plausible; log-detour discarded the boolean whose own docstring says it exists to stop precisely that; snapshot said “snapshot written”.
  • What this deliberately does not do: it does not change the dormancy guard, and it does not stop a mutating verb writing. /pm:upgrade in a deployed checkout still stamps tracked files the next deploy discards. The warning is the mitigation, not a refusal.
  • No state.json schema change and no migration.
2026-09-09

Backlog health, and the papercuts 0.40.0’s own closeout surfaced

Twelve items in one release rather than twelve point releases. Shipping 13 versions in 11 days meant a 28-repo fleet pass roughly every 20 hours, and the upgrade is the expensive half.Every item here was found by using 0.40.0 — four of them by the inverse-operation rule 0.40.0 itself added, turned on surfaces that predate it.

Added

  • release show [<id>] — a release object could be written and never read back. Membership is one-way, living on the epic as epic.release, which is the right storage and exactly why a reader who opened the release object saw deferred[] populated and members absent: it read as “exclusions and no members”, the inverse of the truth. The read-back renders intent, target, derived members, deferrals and the cross-spec verdict.
  • --unmember and --undefer on release, each requiring a reason, and those reasons land in a new amendments[] the read-back renders. --member’s long-standing implicit undefer now records the same way instead of silently.
  • record-gate-review --artifact <path> (repeatable) — Gate 1 reviews spec artifacts by path, before code exists, and was required to supply a commit range for a passing verdict. The only way to satisfy it was a range of artifact commits, which every downstream consumer then reads as an implementation range. A gate-2 pass still requires the sha pair. Making the shas merely optional was rejected: a correct Gate 1 would then render ⚠ no checkable evidence, moving the wrong record one step along rather than repairing it.
  • --clear created-at — recover-created-at never overwrites a date already present, which is correct, and nothing removed one, so a date recovered wrongly was permanent and correctable only by the hand-edit this tool forbids everywhere else. Clear-then-recover is now the correction path. touchedAt stays non-clearable and its registry row says why: the engine re-stamps it on the next real write.
  • --flag=value on every flag, splitting on the first = so a value may contain one.

Changed

  • A flag value may begin with --. The inward tracker-sync line PM’s own instructions specify verbatim failed on any issue whose title starts with a flag name — and a bug report about a flag usually is titled that way. It failed in the worst available shape: the message said “unknown flag(s)” and listed the title, pointing a reader at the flag spelling rather than the value; the sync run continued; and the item was then silently absent from the backlog with no trace in the record.
  • Four raw-argv scanners stop disagreeing. Fixing the shared parser fixed 24 modules at once — and requireKnownFlags, serving five verbs, was still emitting the original bug verbatim afterwards. No existing test could see it, because every one of them puts the unknown flag last.
  • /pm:triage accepts an ask beginning with --. That call is step 1 of intake — “the ask, in its own words” — and rewording to get past the guard defeats the lexical matching triage exists to do, because the flag names are the distinctive tokens. Narrowed rather than dropped: triage --limit 5 supplies no ask, and reading --limit as one returns a confident, meaningless result.
  • --defer takes <epicId:why> inline, --deferral accepts :: as well as :, and --declined-deferral’s placeholder finally says why its separator is doubled. Nothing that worked before was removed.

Fixed

  • hooks.json validates clean. It shipped a "comment" key beside matcher and hooks in all five matcher groups, so Claude Code reported unknown keys … ignored at the top of every session, in every repository with the plugin installed. The hooks kept working, which is why it survived: nothing broke, so nothing prompted. The comments were real documentation and moved to a README rather than being deleted — “fires on EVERY Bash call by design” is what stops someone narrowing a matcher and silently killing a hook. Guarded by a closed allowlist, not a blocklist of "comment".
  • A help token in a value position no longer exits 0 having written nothing. --help anywhere in argv short-circuited pre-dispatch, so a command carrying it as a value printed help and reported success. Narrowed to the position a person types it. --title --help is still refused, correctly; what changed is that it now exits 1 and names the token.
  • The gh-137 replay test reads only the release it replays. It called itself a replay of a historical moment but computed over the whole live record, so it failed during every release closeout with a bare 21 !== 20 about an unrelated issue. Fixed by slicing the fixture rather than filtering the findings, and the count is replaced by the identity set.
  • AGENT_OUTCOMES gets an anchor. It derives from KNOWN_OUTCOMES, which is right, but both existing drift guards anchor their assertions to it too — so emitter and assertion moved together and narrowing it fired nothing. Mutation-tested: of the 124 tests in the two existing guards, exactly one caught a narrowing, and only by accident of the record happening to hold a declined epic.

Notes

  • PM’s own archive is now fully dispositioned: 66 outcome: unknown records → 0. 0.40.0 shipped unconsidered-outcomes to enumerate them; shipping the walker was not walking it. Every verdict cites a commit, a CHANGELOG version, an archived change directory or a source line: 58 delivered, 7 superseded, 1 abandoned. Integrity findings fell from 11 to 6.
  • Four tests broke because the product worked. A fixture reconstructed the pre-migration record by peeling migration stamps off the live record, so replacing those stamps with real dispositions made the reconstruction permanently impossible. A green suite had quietly come to mean “the archive is still full of undispositioned epics”.
  • No state.json schema change and no migration. amendments[] is additive and absent-tolerant.
2026-09-08

The record answers questions about itself

Five defects, one root: .conductor/state.json could not answer questions about its own contents. Measured across 27 distinct upstreams before this shipped — 310 archived epics carrying outcome: unknown, 26 more sitting in status: "done" (a value KNOWN_STATUSES does not contain, which 0.27.0’s migration never reached), and 18 of 20 open epics in PM’s own repository carrying no date of any kind.

Added

  • Epics record when they were registered and when they were last touched. createdAt binds to pushEpic() — the single sink every creation routes through — so it inherits the source scan that already forbids bypassing it, rather than an enumeration of creation surfaces that this repository already tried and already watched go stale. touchedAt is stamped inside saveState() after its no-op early return, against the disk pre-image already read there, excluding both timekeeping fields from the per-record comparison and matching records by epic id, not array position — remove-epic filters the array, so index-matching would have falsely stamped every record after a removal.
  • recover-created-at, a re-runnable verb the 0.40.0 migration invokes once. Not implemented inside MIGRATIONS: a one-shot transformation that reads disk produces a different result per checkout, which the migration framework forbids by name — and two checkouts of one remote on the development machine differ by two commits touching state.json, so a one-shot recovery would have frozen a wrong answer in one of them permanently. A shallow clone would have fabricated a date for every epic and recorded it as fact: at a graft point git diffs the boundary commit against nothing and reports every id as introduced there. Guarded, and absence stays re-attemptable — unshallow the clone, re-run, get the real dates.
  • epic-in-undefined-status, an integrity check reporting any epic whose status is outside KNOWN_STATUSES. The finding names the consequence a reader would otherwise miss: such an epic is non-terminal to every rule testing for the archived status, so it is invisible to precisely the checks that would surface it, and it permanently absorbs the effective priority of everything depending on it. Reports; never repairs.
  • unconsidered-outcomes, a read-only verb enumerating archived epics whose outcome nobody considered — engine-stamped and outcome: unknown. Both halves matter: a stamp alone sweeps in epics whose outcome a migration correctly derived from a passing Gate 2, and an unknown value alone sweeps in epics carrying no disposition at all.
  • unreconstructable, an agent-supplied outcome recording that somebody looked for the evidence of what happened and it does not exist — distinct from unknown, which says nobody looked.
  • --clear <field> on update-epic, deriving its accepted set from a new nullable: true marker on the flag registry, with setOnly: "<reason>" on the rows deliberately not clearable. Both directions are tested, so a nullable row added later with no clearing path fails the suite instead of shipping silently. Fields whose clearing costs more than the field carry a clearNote — clearing externalUrl lets the linked item be mirrored again as a new epic; clearing plan lets the next sync register that file as a fresh untriaged epic.
  • The emitted call-site sweep now obliges the inverse operation — set against unset, add against remove, append against replace, enable against disable, grant against revoke — as a numbered required task item, a declared mustSay claim, and in all three mirrored surfaces.
  • /pm:upgrade now tells you to commit what it just rewrote. A sweep of one machine found nine repositories where /pm:upgrade or openspec update had run, succeeded, and been left uncommitted: two sat six days with git recording pm 0.16.0 while disk ran 0.39.0. The failure is silent by construction — every session reads the rewritten files off disk, so nothing is broken and nothing looks wrong. The new line names the changed paths, and is conditional in both directions off a single git diff --name-only HEAD probe, so an idempotent re-run stays silent and a git-ignored path is never named.

Changed

  • --link appends instead of replacing. A link’s identity is its type and target; the reason is not part of it, so re-supplying an identity updates the reason in place rather than duplicating it or silently discarding the correction. --clear-links and --link are no longer mutually exclusive, so the documented repair stays one atomic write. All six sites documenting the old behaviour changed with it, including two the engine emits at runtime.
  • A write that changes nothing says so, across the whole write surface rather than one verb: 27 saveState call sites report through a shared reporter, four carry a declared exemption, and a per-call-site source scan fails the build on a fifth that does neither.

Fixed

  • declined had been added to the outcome enum in an earlier release, reached the engine, and reached none of five documented surfaces — including a test asserting the four-value list that passed by prefix match, so the guard for this drift was blind to it. All five repaired, and the emitted enumerations now render from AGENT_OUTCOMES.
  • Two live tests asserted that --link replaces; both would have failed CI on the change above.
  • A read-only verb no longer warns that it is WRITING a different repository. Running integrity with CLAUDE_PROJECT_DIR pointed elsewhere printed ⚠ WRITING A DIFFERENT REPOSITORY two lines above integrity’s own “nothing here writes state”. The warning is worth shouting — which is exactly why crying wolf on the 17 read-only verbs is the defect: it trains a reader to skim past it on the 33 where it is the difference between inspecting another repo and mutating it. Gated on verb-effects.mjs, which already declared every verb’s effect — no new list of verb names to go stale.
  • verb-effects.mjs under-declared what upgrade writes — it omitted PROJECT.md and .conductor/render-stamp.json, and named CLAUDE.md where the target is whatever the platform’s precedence chain resolves to.
  • The OpenSpec drift nudge no longer reads as “a human must run this”. It equated openspec update with openspec init — “a terminal command, exactly like openspec init” — and openspec init genuinely is reserved to the human in at least one estate’s global agent instructions, so the analogy transferred that reservation onto update, which has no such reservation. Measured cost: an agent read the nudge, refused to run the command, and asked its user instead — one full round trip — and the same misread had already left four repositories stale at 1.9.0. The negation now names pm’s engine as the non-actor rather than a human as the required one, and the test asserts the old wording is absent.
2026-09-05
Things that go missing without anything noticing. Four items under one class: a shipped file that lost its tail, an agent transcript thrown away, a test helper whose sibling silently went stale, and a reported stdout loss that turned out not to exist.

Added

  • A structural check over every shipped markdown file. A skill lost 78 lines in 0.31.0 and no check noticed for five releases, because every existing guard asserts things are PRESENT — and a truncation is an absence below the last thing anyone asserts. Five assertions: an unclosed code fence, unterminated frontmatter, a heading whose section is empty, a table header with no rows, an unbalanced <details>. Each was measured at zero violations across every shipped file before adoption; anything scoring above zero was rejected rather than tuned. The fence check is a tracker, not line parity, and the difference is load-bearing: a four-backtick fence holding a three-backtick example has even parity while well-formed, and truncating inside it keeps parity even — so parity would call the broken file clean. Proven against real history, which is the strongest evidence available: the test reads skills/conductor/SKILL.md out of git at three release commits and requires the intact one to pass and the two truncated ones to report unclosed-fence. It would have fired in 0.31.0’s own CI run. And here is its reach, measured rather than implied: excising blocks at every offset in real files, the detection rate on the mid-document shape the original truncation actually took is 9.3% on SKILL.md, 3.4% on commands/epic.md, 31.0% on README.md. A real guard against a real defect — not a general truncation detector.
  • A guard on write-before-exit output size. A process that writes a large payload to a pipe and then calls process.exit() truncates at the buffer, because exit skips the flush — measured at 65,536 bytes. Unreachable today (the largest such path is 1,286 bytes), which was a property of current output sizes that nothing held.

Fixed

  • The EDD harness threw away the agent’s own output. The runner captured stdout and the adapter copied four metrics out of the result and dropped the rest, so the only way to see what an agent actually said was to run it again — 90 seconds, and a different sample against a non-deterministic surface. stderr was captured nowhere. Both are carried now, whole and never excerpted: a cap is the silent-truncation shape this release’s first item is about. A timed-out run keeps its partial transcript too — TimeoutExpired carries it, and it was being discarded.
  • A duplicated test helper that two files had to keep identical, with nothing enforcing it. Hoisted to one definition — a byte-identity test between copies was rejected, because it detects divergence where one definition prevents it.
  • 0.35.0 shipped with a heading and no changelog entry at all, and nothing noticed for four releases. Found by the new check — which had been excluding CHANGELOG.md, and that exclusion is what hid it. Backfilled from the release’s own PR and commit, marked as recovered rather than passed off as original.

Notes

  • No migration. Nothing in this release changes the shape of state.json, and the emitted rules block is byte-identical across all three platforms — managed repos receive no change.
  • One reported bug was retracted rather than fixed. A claimed stdout loss under PM_ENGINE_DELEGATION could not be reproduced in 34 full-suite runs, and its stated mechanism was impossible: delegation cannot fire from the test harness at all, and the handoff hands the child the parent’s own descriptor. Both facts are now tests, so the mechanism cannot be re-derived from the issue text later.
2026-09-04
The record says what actually happened. Four defects under one invariant: a write must be readable back, and a reported write must have happened. Three were reported from the field within a day of each other; the fourth was found by hitting it while fixing the others.

Added

  • set-gate-guard with no argument READS. It wrote and confirmed the write, and nothing anywhere read it back — so “is the guard on?” was answerable only by opening state.json, which is what a read verb exists to avoid. The reader lives on the toggle, deliberately not on gate-guard: that verb is the PreToolUse hook, which blocks with stderr + exit 2 and allows by returning silently, so its stdout is protocol surface and a report printed there would corrupt it. The reporter read that silence as a broken command. Following owners, the output states the value and its limits — above all that on alone does not mean anything is currently blocked.
  • update-epic --withdraw-commit <sha> --withdrawal-reason "<why>" — the exit from an append-only array. Append-only was right and stays: order carries meaning and the last entry is the endpoint a recorded Gate 2 headSha is compared against. But “cannot be reordered” is a different claim from “can never be corrected”. A git reset is a normal operation and the gate procedure requires attributing at the moment of each commit, so an attribution can outlive its commit through no error of process — after which every escape was worse than the problem: hand-edit state.json (forbidden), tag the orphan (makes a false record permanent), or remove-epic (destroys the disposition, links and stories). The withdrawal is recorded in a sibling withdrawnCommits field rather than erased.
  • :: as the explicit separator for --declined-deferral, and placeholders so --help teaches the form rather than printing <a value>.

Fixed

  • --declined-deferral silently truncated <what> at its first colon. Measured in the wild: "Set alwaysLoad:false to reclaim RAM:declined because X" recorded what: "Set alwaysLoad" — which reads as an instruction to do the thing being declined, the opposite of the record’s meaning. Neither split is safe for two free-text halves, so it stops guessing: a single colon behaves as before, :: is explicit, and an ambiguous value is refused with the remedy in the message. --deferral is deliberately unchanged — its left half is an epic id, which cannot contain a colon.
  • A deferral flag outside an archive printed updated and wrote nothing. It now refuses and names the correction path, which existed and was merely undiscoverable.
  • A gate verdict was recordable only on an openspec-lane epic while set-review-mode is lane-agnostic and its own table names “a Superpowers task review”. Evidence for every other lane became prose that nothing compares, so a verdict could never read stale, and integrity could not see it. The archive gate is unchanged and stays openspec-only.
  • The reconcile-gate refusal told you to bypass it in a way that does not work — that branch is unconditional and set-gate-guard off does not reach it.
  • A withdrawal could walk an epic past the archive gate — caught at Gate 2, in this release’s own new verb. Withdrawing every sha moved an epic from stale to none-attributed, and an archive that had been refused succeeded. It now reads attribution-withdrawn and is refused by name. A withdrawal corrects the record; it never discharges the obligation.
  • --declined-deferral ":" recorded {what:"", reason:""} and satisfied the archive gate — an assertion asserting nothing, while the refusal beside it claimed the reason “is not optional”. Both halves must now be non-empty.

Notes

  • No migration. withdrawnCommits is additive and absent until a withdrawal happens; a state.json written by 0.37.0 loads unchanged — verified byte-identical through render, verify-state, integrity and brief on a 158-epic file.
  • The emitted rules block is byte-identical to 0.37.0’s across all three platforms. Managed repos receive no change.
  • integrity gains reach without gaining checks. Its staleness and evidence checks read gateReview with no lane filter, so they now cover the lanes record-gate-review previously refused. Expect true findings on non-openspec epics that carry a verdict and attributed no commits.
2026-08-31
Help that answers the question actually asked. --help was verb-blind and the emitted rules block carried no route to documentation at all, so an agent that needed a verb’s flags read the engine’s source — which is exactly what a session bringing another repo up to date did, and said so. Both halves ship together: a local channel that cannot lie about the installed engine, and a pointer to the docs for everything the engine does not know.

Added

  • <verb> --help — per-verb, projected from the flag registry. Naming a verb prints that verb’s own flag surface: every flag it accepts, which take no value, which repeat, and the phrase each one’s own refusal ends in. Naming none still prints the verb list. Every line is derived from the same registry rows the unknown-flag allowlists read, so a row that grows the allowlist grows the help in the same edit and help cannot advertise a flag the parser refuses. A per-verb help table was refused: that is a rule bound to a list rather than to the function that governs it, and it would be stale the first time a flag was added.
  • A closed-enum flag names its legal values. --outcome, --status, --lane, --priority, --platform, --mode, --level, --direction, --gate and --verdict all render their real value space, projected from the same constants the engine validates against. Gate 2 measured the gap this closes: of the 92 value-bearing (verb, flag) pairs, only 21 rendered anything more specific than <a value>, and not one closed enum named its values — so a reader wanting to know what --outcome accepts still had to open scripts/lib/, which is the thing this release exists to stop. Flags with no closed set still render <a value>; that remainder is visible rather than papered over.
  • A verb that takes no flags says so. 21 of the 48 legitimately take none. Printing an empty list would make “takes none” and “nobody declared this verb yet” look identical, which is the ambiguity that declaration exists to remove.
  • A “Getting help with pm” section in the emitted rules block. Every managed repo receives it on the next /pm:upgrade. Measured across all 408 emitted lines and all three platform variants before this release: 0 occurrences of the engine binary, of any URL, of MCP, of --help, of pm-plugin.dev, of SKILL.md, of README — while the block hardcodes eight flags in a single add-epic recipe. It taught flags by worked example and offered no way to enumerate them.

Fixed

  • add-epic --help advertised --clear-links, a flag add-epic refuses — caught at Gate 2, and it falsified this release’s own headline invariant. The cause was one field doing double duty: requires is the tail of a refusal message and, new here, the help signature. --link’s reads “…to empty an epic’s links, say so with --clear-links” — correct in a refusal, a ghost flag in a signature. Rows now carry a separate placeholder for the signature.
  • The guard that found it is now the test. A mechanical sweep asserts that for every one of the 48 verbs, every --token its help prints is a flag that verb accepts. The test it replaces hardcoded four names on one verb — the sibling-miss this repo’s gate procedure names as its dominant defect class, and precisely why a ghost living inside another flag’s placeholder text was invisible to it.

Changed

  • <verb> --help and <verb> -h no longer print the global usage line. That is the point of the release rather than a side effect. The short-circuit itself is unchanged and keeps its original property: a help flag still fires before dispatch, reaches no subcommand, and cannot be consumed as data — the bug that put it there was log-detour --help writing a real detour entry described as --help, into an append-only log with no verb to remove it.
  • Documentation gaps closed — --description on add-epic and add-many, --diff-summary, --platform, and the write-rules / rules-target verbs, which had no command doc at all.

Notes

  • No migration. Nothing in this release changes the shape of state.json.
  • The skew warning embeds no version number, deliberately. The block a repo reads must be identical before and after an upgrade — a migration records behavior, it does not alter what anyone reads — and the block is static while the plugin is not, so an embedded version is a snapshot that goes stale in exactly the skew case it was meant to warn about. It routes to /pm:changelog, which computes the answer.
  • Existing repos will see exactly one addition to the emitted block, the “Getting help with pm” section. Treat a second as a regression.
2026-08-29
PM stops imposing a house style — and a shipped file that had been quietly broken for four releases is whole again.

Added

  • Your output style and your project’s communication contract now outrank PM’s reporting shape. PM emitted a prescribed closeout format regardless of what you had configured, which inverted the ordinary precedence: your instructions outrank a plugin’s preference. The split is three bands, not two, and the middle one is the part that matters:
  • Delegate discovery, so exploration never fills the orchestrator’s context. Placed as an operating rule rather than a gate item, deliberately: every gate item is a per-change record obligation ticked in a task list, and a per-action habit that fires dozens of times per change would dilute a list whose whole authority is that every member is a real gate. It carries an explicit carve-out — delegating never weakens a full-read requirement, and substituting a keyword grep for a full read is forbidden whoever performs it.
  • Lane routing becomes a deliberate decision. The mechanical suggestion is demoted to an input rather than read as a verdict, the judgment half is named as absent, and the tie-break is made directional: the lightest lane leaves no spec, plan, gate or stories behind, so over-processing costs hours while under-processing costs the record permanently — an unresolved routing question therefore resolves away from the lightest lane, never into it.
    Partial, and said to be partial. The original complaint — that routing has no product or milestone context to weigh — stays open, because that layer does not exist and the engine cannot invent it without becoming an integration layer. What shipped is a better instruction, not a better algorithm.

Fixed

  • A shipped skill had been silently truncated since 0.31.0. skills/conductor/SKILL.md lost 78 lines mid-document and went out that way in five consecutive releases. The signature was an odd number of code fences — a markdown file cannot have an unbalanced fence and be intact. That is a mechanical, content-agnostic check nobody was running, and the reason is worth stating: every existing guard checks that the right things are present, and a truncation is an absence below the last thing anyone asserts. The ledger checks the file exists; the drift guards check specific claims appear; none checks the file is whole. Restored, and a structural check for every shipped markdown surface is filed as its own work.
  • The lifecycle marker’s collision with OpenSpec’s archived lint is documented where the marker itself is documented. openspec validate --archived counts raw checkboxes and fails every correctly-archived change, because the task that archives a change is unticked at archive time by construction. Anyone wiring that command into a pre-commit hook now finds the warning before the confusion.
2026-08-29
The engine is now present at the transition it was absent from, and a guard caught a defect that existed only in the combination of two branches.

Added

  • push-detour and pop-detour. The substantial-detour PUSH was a documented hand-edit of the state file — the one mechanism this project tells every agent never to use, performed at the most consequential transition it has. None of the engine’s guarantees applied to it: no validation, no conflict guard, no read-back verification, no record that it happened.
    • POP needed the same treatment, and that was checked rather than assumed — the resume instructions spelled out the identical hand-edit. Fixing PUSH alone would have left the sibling untouched.
    • One subtlety the verbs now handle that a hand-edit could not: POP removes the frame before reconciliation runs, so the frame removal, the reconcile obligation and the active-pointer move happen in one state object and one write. Split across two writes, the obligation is cleared by the very heal that should preserve it.
    • A defect a diff-scoped review could not have seen: the hand-edit set role: "detour", and add-epic has no --role — which is precisely why the hand-edit was documented. Replacing it without that would have registered every detour as a plain epic, silently costing the detour-context fallback and the role column.
  • Advisory ownership — claim, unclaim, owners — records which session is working an epic, and whether a repository’s conductor is quiescent.
    • Advisory, and exactly one surface refuses: claiming over another session’s live claim exits non-zero having written nothing. So “two sessions both claim” has no silent-corruption reading. Everything else writes to a claimed epic unchanged, because a tool that refused to work over an advisory marker would not be advisory.
    • A stated TTL rather than a heartbeat. A heartbeat nothing beats is a timestamp in costume, and it makes staleness wrong in both directions — a live session reads stale after quiet work, a crashed one reads live long after it died.
    • The override is --steal, deliberately not --force: the state writer reads --force globally off the argument list, so spelling it that way would have silently disabled optimistic concurrency as a side effect of the cooperative feature.
  • An optional activity log that ships with its reader. A log nobody reads is a data graveyard, so the reader landed in the same change. It answers time-to-pickup, detour frequency, lane distribution and re-routes, gate verdicts in sequence — and out-of-band writes, meaning state revisions no event accounts for, which is a hand-edit turned from an archaeology exercise into a query.
    • Events carry a revision range rather than a number, because an ordinary update writes twice; single-number events would report every normal write as a hand-edit.
    • Retention is bounded by design: segment rotation, a per-project cap pruned oldest-first, and a purge-logs verb that refuses without both a selector and an explicit confirmation.

Fixed

  • The flag-value rule is bound to the dispatch table, not to one registry. It previously covered only the epic write surfaces, so it silently no-opped on a dozen other verbs — and two of them had each independently hand-rolled the same check, which is the tell that a rule was bound to a list rather than to the function it governs. A verb-level guard now reads the dispatcher’s own table and fails if any dispatched verb is unclaimed by a declaration.
That guard immediately earned itself. Two branches merged cleanly and the combination failed: four verbs from one branch were undeclared under the other’s rule. It then refused a shortcut that would have declared their flags as value-bearing when they hand-parsed arguments and therefore never refused anything — a declaration that would have been false. The fix converted three verbs to the shared parser, which surfaced a further defect nothing else had: one verb had no unknown-flag allowlist at all, so a misspelled flag printed the entire report and exited 0.
Not shipped, and worth saying why. Adopting OpenSpec’s CLI as a progress source was investigated and declined on evidence. openspec validate --archived fails this repository’s own correctly-archived change — it counts raw checkboxes and knows nothing of the lifecycle marker that excludes a change’s own archive task, which is unticked at archive time by construction. The two tools compute different quantities from one file and cannot be reconciled by a precedence rule.
2026-08-29
Two practices that only ever reached one repository now ship, a link type is no longer whatever you typed, and a flag with no value stops being accepted on some surfaces and refused on others.

Added

  • The dogfooding and lessons disciplines ship as skills. Both were invented while building PM and stayed here — the dogfooding rule appeared twice in this repository’s own project file and zero times in the emitted rules block, so it reached exactly one repo, which is precisely the asymmetry it describes. Both now live in skills/, the local copies are stubs redirecting there, and one new numbered required task item carries the rule into every conductor-managed project: route what the work taught you — a practice goes to the backlog with its evidence, tooling friction goes to /pm:feedback, a process failure goes to a lesson.
    • PM ships the mechanism and never the corpus. The lesson shape — the frontmatter contract, the trigger matching, the pre-mistake advisory hook — is a capability any project can use. The lessons themselves are this repository’s own history and stay here.
  • A closed vocabulary for link types, in three bands. --link validated that the epic existed and that the value split correctly; it never checked the type, so a typo was stored silently and then read as precedent by the next agent looking at the record. The known set is now derived mechanically from what the code actually reads, not typed from memory, and a test binds it to the source in both directions — an unknown literal appearing anywhere in the engine fails, and a declared type that stops appearing fails too.
  • verify-specs --headers proposes the epics a design document already names. Backfilling specPath revealed that the documents declare their own epics in a header — and that deriving the mapping from filenames placed 8 of 10 while structurally missing exactly the many-to-one cases the field exists for. The parse is label-agnostic and bounded to the leading metadata block, chosen empirically against all ten real documents: 26 of 26 ids, zero false candidates. It proposes; you confirm. A header id naming no epic in the record is reported as a finding, from covered documents too.

Fixed

  • A flag with no value is refused on every command that accepts it. add-epic --plan with no value exited 0 and created the epic with no plan attached, while update-epic --plan refused the identical mistake a minute later. Three flags legitimately carry no value; everything else is value-bearing by default, declared once on the shared registry so six write surfaces cannot answer it three different ways.
    • The sweep found the defect at a fourth surface nobody had named: record-gate-review checked no flag for a value at all, so a valueless --reviewer recorded a gate verdict with the reviewer’s identity simply absent.
  • Serial deferrals are visible. The stack block now reads paused 11d · 3rd deferral of this epic, derived from the record rather than a stored counter, and silent on a first deferral.
    The pushback this issue asked for was declined, for a structural reason rather than a judgment call. There is no push-detour verb — the substantial-detour PUSH is a documented hand-edit of the state file, so the engine is not running at the moment a deferral is decided and a gate has nowhere to live. Visibility shipped; the gate becomes buildable only once the engine is present at that transition.
2026-08-29
Seven issues, and three of them turned out to be about the guard rather than the code.

Added

  • The commit hook names the attribution command at the moment the commit lands. The gate procedure asks for update-epic <id> --attribute-commit <sha> as each commit is made, and nothing checked until the archive gate read the array — by which time the commits exist, often across sessions, and the append-only ordering rule may already be irrecoverably broken. Since the hook now knows from the repository whether a commit actually landed, it can say so at the only moment the advice still changes the outcome.
    • It names the detour epic while a detour is live, never the paused parent the active pointer still points at — and resolves the id against the record rather than trusting it, so a placeholder can never produce a command that will not run.
    • Louder exactly once. While an epic has attributed nothing, the clause carries the catch-up-in-order rule, because that is the only state in which catching up is still possible. After the first attribution it is one line, forever. The escalation extinguishes itself off state the agent wrote — the hook keeps no bookkeeping of its own.
    • Silent wherever the engine would be guessing: no active epic, a sha that is not a sha, a commit already attributed, and every commit on the unverifiable rung.
  • A declaration of which verbs mutate the working tree, verified mechanically. render writes PROJECT.md; brief does not — and nothing said so. All 39 dispatched verbs now carry a declared effect, checked for completeness against the dispatcher itself and exercised: every read-only verb runs against a repository with a render pending, and the tree is hashed by content and mtime.

Fixed

  • PROJECT.md’s commit loop is closed. A bookkeeping commit appended a detour row, which changed PROJECT.md, which produced another commit. Two independent causes: the trail now refuses a second row for a (sha, kind) it already holds, and the bookkeeping filter that guarded one branch had never been applied to its sibling — a rule at one call site and not the identical one beside it.
    • Nothing suppresses a render and nothing freezes the timestamp. The fix is to the cause of change, not the reporting of it: a generated file that never changes is as wrong as one that always does.
  • A warning when the conductor is pointed at a different repository. CLAUDE_PROJECT_DIR silently overrides the working directory, so a stale value means every read and write lands in another repo with no signal at all. It now warns — naming both repositories and how to undo it — on three required conditions: the variable is set, it resolves through realpath to a different directory, and the directory you are standing in has a conductor of its own.
    • That last condition is the discriminator: running from a subdirectory, or targeting a project from a neutral directory, stays silent. Only when two initialised projects are in play was there a choice that could have gone the other way. It warns and never refuses — cross-repo dispatch is a legitimate pattern, and the defect was that a redirect was indistinguishable in the output, not that it happened.
  • /pm:feedback declares its gh dependency, and gains two channels that need none — a prefilled issue URL and an email address. The report is written locally first on every path, so it survives regardless of which channel is available.
  • The retry half of “retry once, then skip” is now provable. It had no test that failed when it was removed — and the claim reproduced at HEAD six releases after it was filed: deleting the retry from both call sites left the suite entirely green. One of the two sites remains unobservable end-to-end (its caller re-runs an idempotent heal two lines later, which is the cover that hid the defect); that site is bound by a source-level check rather than a fabricated injection.
2026-08-27
A record you can correct, a hook that observes instead of guessing, and the association one artifact over.
This release carries a migration. Update the plugin, /reload-plugins, then run /pm:upgrade in each repo. Until you do, an epic registered by the archive backfill renders — for its task counts and appears in the completion-shaped integrity checks. The lift is additive and idempotent.

Added

  • --correct-disposition "<why the recorded one was wrong>". A recorded disposition used to be unreplaceable, so a mistyped outcome — or one recorded without its reason — had no correction verb and forced a hand-edit of state.json. Correcting one is now deliberate, self-describing, and non-destructive:
    • Deliberate — never reachable by re-running the ordinary verb, and refused outright when there is no agent record to correct. An engine stamp is still replaced the ordinary way; an epic with no disposition has nothing to supersede.
    • Self-describing — the flag carries its own justification, so a correction cannot be recorded without saying why.
    • Non-destructive — the prior record survives verbatim under superseded, reusing the same key and one-level cap that gate verdicts already use. Every surface renders · corrected (was <prior outcome>). The engine deliberately does not gate on who: an agent’s disposition carries no identity by construction, so “only the agent who recorded it may correct it” would be unenforceable rather than strict. What it requires instead is that a correction leave evidence it happened.
  • specPath, and verify-specs to answer what it makes answerable. An epic can now record the design document it came from (--spec), and verify-specs reports which documents have no epics drawn from them. It is its own verb rather than an integrity check on purpose — an uncovered design document can legitimately be true (a note, a reference, an abandoned sketch), and filing every one as a finding is exactly the noise 0.31.0 removed from the freshness warning. It reports, never repairs, and never speaks unless asked.
    • Coverage is family-driven rather than specPath-only, so pointing it at a plans directory answers the same question about plans. Status-blind: an archived epic for a chunk is coverage of that chunk.

Fixed

  • The commit hook observes the repository instead of parsing the command. It fired on any Bash command whose text contained git commit — a grep, a heredoc, a doc edit — asserting a commit that never happened; and it logged a spurious minimal detour when no epic was active. It now reads a HEAD watermark and classifies the move through the reflog.
    • Flag-form-blind by construction. -am, -F, editor commits with no message flag, escaped quotes, and forms git has not invented yet are all covered, because the subject comes from the commit itself rather than from a pattern over the command.
    • It also closes a false negative the text check had: a commit made inside a wrapper script, whose command text never says git commit, is now noticed. A backgrounded commit is caught on the next call and attributed to its own sha.
    • Every read is three-valued. No git, not a repository, no reflog, missing or corrupt watermark — all fall back to the previous heuristic rather than erroring, which matters for a hook that fires on every Bash call in every initialised project.
  • Recording an honest disposition no longer reverts a backfilled epic’s task counts. The root cause was provenance living inside the one field whose entire contract is that an agent overwrites it: registration was recorded on the disposition, and the interactive verb replaces that wholesale. Two lifecycles sharing one host, so recording the truth destroyed the evidence. Registration now lives on the epic and no disposition write touches it.
2026-08-27
Four issues, and two of them ended somewhere other than where they started.

Added

  • Sync dedups plan files on a real epic↔plan association, not a filename. Deduplication keyed on an id derived from the plan’s filename, so an epic whose plan was renamed — or whose id was chosen differently — was re-registered forever, and a finished plan came back as a fresh untriaged epic. Sync now walks a five-rung ladder per plan: claimed by some epic’s association (status- and lane-blind, which is the done-signal, with no completion heuristic) · literal id match · a tombstone written by the removal verb so a removal survives the next sync · an id-minus-date-prefix name match, but only if that epic claims nothing · register.
    • The field family is declared once, so specPath — the same missing association one artifact over — adds a single row and inherits all five behaviours rather than growing a second shape. A test fails CI if any row is settable at creation but not on the update surfaces.
    • Verified on this repository: every plan on disk is now accounted for by a named exit, with zero re-registrations. The four live pairs that were one change registered twice under different lanes are exactly what this prevents.
  • A terminal disposition for a story that will not be done — update-epic <id> --story <n> --wont-do "<reason>". The third state a checklist needs: not open, not completed, deliberately not being done. The row and its reason always survive, because deletion would destroy the record that the work was ever projected. A disposed story leaves both sides of the ratio — 3/3 stories · 2 disposed — exactly as a lifecycle-marked task does, never 5/5 (claiming completion nobody earned) and never 3/5 (a gate that refuses forever).
    • Stories can now be created atomically — --add-story repeats, and works on add-epic and add-many, so an epic arrives with its milestones instead of acquiring them one call at a time or never.
The measurement changed the answer here, and that is worth recording. The issue asked for a gate on unticked stories. Measuring the live record first showed the gate already existed — the archive gate has refused --outcome delivered on an epic with an unticked story since dispositions shipped. What was broken was the remedy: it told you to mark the task’s line with a lifecycle marker, which an inline story has no task line to carry. So no new refusal was added; the gate got an honest key instead. Mandatory stories were declined outright on one number — 0 of 148 epics use the free-text carriers shipped in 0.27.0, so the field’s meaning is still migrating and mandating decomposition now would manufacture false positives.

Fixed

  • The never-re-read warning stops counting work that has ended. It reported every tracker-linked epic without a watermark — including archived ones, whose linked items are closed and which /pm:sync therefore cannot clear. So the warning named an action that could not clear the number it reported, and at that size it read as a chore nobody starts. On this repository: 60 → 31.
    • The engine was contradicting text it emits: the inward-sync step already says “an epic that is already archived owes nothing here — it ended, and a record that ended does not need a second ending.” A terminal watermark was weighed and rejected as a second mechanism for what the disposition already accomplishes.
  • An epic that has ended no longer blocks writes. state.active can legitimately name an archived epic for a stretch — the pointer is cleared on write paths, and the PreToolUse gate-guard hook is read-only. Of the three readers of that pointer, the briefing and PROJECT.md both filtered an archived epic and the guard did not — so a stale reconcile obligation on an ended epic could wedge Edit/Write/NotebookEdit, on a branch that set-gate-guard off deliberately cannot reach. Filtered at the point of resolution rather than inside each branch, so a future obligation inherits the rule instead of having to remember it.
2026-08-27
Two things the record and the boundary already knew and could not say.

Added

  • A release delivers and its member epics stay open. Three signals sat in state.json at once and nothing consumed them: the epic names a release, that release has a delivered member, and the epic is not in that release’s deferred[] — the object already distinguishes “cut on purpose” from “shipped”, and only the exclusion half was ever read. Measured: a release shipped, all twenty tracker items closed, all twenty epics stayed queued, and the next-work recommendation named two P0s that had shipped hours earlier.
    • A new integrity check reports it, and /pm:sync now proposes the disposition for an epic whose linked item is no longer open. Proposing, never writing — the reason is the agent’s, and a reason is what a disposition exists to preserve.
    • A batch --release form was deliberately declined. An agent-supplied disposition is currently unreplaceable, so writing twenty terminal dispositions from one invocation is an uncorrectable bulk write of the record’s most load-bearing field. The defect was never the price of twenty calls — it was that nobody knew they were owed, which the check and the sync step both address and a batch verb addresses neither.
    • A release object carries no parent pointer and no delivery marker, so “did this release deliver” is read member-derived: at least one member delivered, none still in flight. That is a stated trade — a release with any active member goes quiet for the whole release, so the check reports late rather than never. Late-and-quiet was chosen over early-and-noisy on purpose.
    • Also new: a superseded epic that never ended — epic A declares it supersedes B, and B is still queued or active.

Fixed

  • Fourteen command docs resolved the engine from the project directory. The “if the plugin cache is empty” fallback tried $CLAUDE_PROJECT_DIR/scripts/conductor.mjs first, and its only test was -f — so any project with a file at that path got it executed by node when a user ran a /pm:* command, with no check that the project was pm.
    • Deleted rather than gated, and the reasoning is the point: any gate would have to be read from the environment, and a plugin cannot set an environment variable — so it would be a switch nobody could operate. Users never benefited from that arm at all; contributors already have PM_ENGINE_DELEGATION, which is authorized by real path against something a project cannot write.
    • A fifteenth site instructed the same resolution in prose rather than a code fence, so a pattern search structurally could not see it — deleting only the fenced blocks would have left an agent to hand-roll the arm back.
    • A CI guard now walks every shipped instruction surface — commands, skills, agents, hooks, manifest — and fails on either the original shape or its hand-rolled variant. Its claim is scoped honestly to what it actually matches: an indirection through another variable would pass it.
2026-08-27
Ordering that can see what blocks it, and an intake that can see what already exists. Both are questions PM could not previously ask about its own backlog.

Added

  • Effective priority over the dependency closure. NEXT UP ordered by an epic’s own priority, so a P1 blocked by a backlog P2 was unsurfacable: the P1 showed as next while being unstartable, and the thing that would unblock it appeared nowhere. An epic now inherits the best priority of everything that transitively depends on it, computed over the whole record rather than the queued set, and renders as P2 → P1. It is never written to state.json. New DEPENDENCY WARNINGS sections name both endpoints, both statuses, and the lift.
    • reorder <id> <id> … sets manual rank for one whole priority band — the last sort key, after dependencies and priority. It refuses a partial band, naming the omissions, because one-at-a-time ranking is both tedious and racy: two agents each setting one rank produce a numbering neither chose.
    • Membership is unchanged: planned and later epics are still excluded from NEXT UP. Not scheduled is not the same as not nameable.
    • A finding from the call-site sweep, worth stating plainly: /pm:next and the briefing had drifted. /pm:next is a command document with no engine code behind it, so the dependency logic that existed in the brief never applied to it. The warnings therefore render in PROJECT.md as well — the surface the next-up decision is actually made from.
  • /pm:triage — screen an ask against the whole backlog before it becomes an epic. Dedup was identity-only (same id, same externalUrl), which stops /pm:sync mirroring one issue twice and does nothing about the same ask arriving under a different name. Measured in PM’s own repo: four live pairs are one change registered twice, under different lanes. Identity dedup found none of them.
    • The engine computes a candidate set and never a verdict — verdict is always null. Ranking is IDF over the backlog itself, so house vocabulary self-neutralizes with no curated stoplist, and every candidate carries the tokens that earned its score. Judgment ships as a numbered required task item.
    • New terminal outcome declined — an outcome rather than a status, since archived already means terminal.
  • An integrity check for a recorded sha the repository can no longer resolve. attributedCommits and gate verdicts’ baseSha/headSha are the evidence a review actually covered a range. A squash-merge orphans them, and gc deletes them roughly two weeks later — measured here at 36 recorded shas, 0 reachable, with every existing check green throughout, because a three-valued ancestry test correctly answers “unknown” rather than “false”.
    • It distinguishes orphaned (still in the object store, reachable from nothing — recoverable now, gone at the next gc, and the finding says so with the git tag remedy) from absent, and gates the absent arm on a probe so a shallow CI clone is never reported as a disaster.

Fixed

  • A state write now reads back from disk before reporting success. At two scopes deliberately: saveState() for the general path, and update-epic at the end of the command — because render() writes again after saveState() returns, so a guard inside it structurally cannot see that write. A newer revision on disk is treated as supersession, not failure.
2026-08-27
Three gaps above the change. 0.27.0 made the conductor’s record true about one change at a time. This release asks the questions that only exist above a change: do a release’s specs agree with each other, is the tooling underneath the project current, and is the engine you are running the one you are editing.

Added

  • A release-scope cross-spec review gate. pm’s gate vocabulary was per change — Gate 1 reviews one change’s artifacts, Gate 2 its implementation — so nothing ever asked whether a release’s specs agree with each other. On 0.27.0 that question returned 5 Critical and 10 Important against six specs that had each passed openspec validate --strict and would each have passed Gate 1 alone, including a flagship scenario that was unreachable and a shared flag allowlist four capabilities all needed to grow.
    • New subcommand record-cross-spec-review <releaseId> --verdict pass|fail [--reviewer "<who>"] records the verdict on the release object. The engine enumerates the release’s spec set from disk and stores a SHA-256 per file it read — an agent never supplies the list, because a spec list typed by the party being reviewed goes stale in exactly the way this gate exists to catch.
    • A spec added after the verdict, or a reviewed spec amended, marks it ⚠ stale; an unreadable spec reads ⚠ unverifiable and a pass is refused rather than recorded against evidence that does not exist. A multi-spec release with no verdict renders ⚠ no cross-spec review (N specs) — silence and “reviewed and clean” must never look the same.
    • The threshold is two or more spec files counted flat across the release’s member changes, so one change carrying six specs qualifies exactly as six changes carrying one each do.
    • New slash command /pm:cross-spec-review carries the procedure, the six questions (contradiction, double ownership, unmeetable requirements, gaps, vocabulary forks, shared chokepoints) and the BLOCKS/POLISH adjudication. The rules block emits it as a numbered required task item — a form measured at 14/14 adoption against 3/15 for the same rule as a prose bullet.
  • An OpenSpec currency check. pm and superpowers are plugins that update themselves, but OpenSpec is a CLI you upgrade by hand — and openspec update, which regenerates the per-project instruction files, slash commands and skills the whole OpenSpec lane runs on, is a separate manual per-project step nothing anywhere asked about. Measured in pm’s own repository: four minor versions stale while actively running an OpenSpec change, with two upstream slash commands its agents could not know existed. The session brief and /pm:upgrade now both report the drift from one shared emitter, so the two surfaces cannot disagree.
    • pm never runs openspec update. It emits the instruction and you run the terminal command, exactly as with openspec init — a source scan in the test suite fails the build if any engine file ever passes the openspec binary an argv other than --version.
    • It holds rather than suppressing itself mid-change, because openspec update rewrites the instruction files an in-flight change is being authored against. Suppressing outright would silence it permanently in any repo that usually has a change open — which is precisely how the drift above accumulated unseen.
    • It tells you whether a diff will exist. Where the generated files are git-tracked, git diff after the run is the review; where they are not, it says to copy them aside first, because the rewrite destroys local edits with no diff and no trace.

Fixed

  • Developing pm no longer runs a stale engine. Every entry point the plugin ships invokes the engine through CLAUDE_PLUGIN_ROOT, which resolves to the installed plugin rather than the checkout being edited — so the PostToolUse commit hook re-rendered the tracked PROJECT.md with output a release behind the working tree, on every commit. Export PM_ENGINE_DELEGATION naming your checkout and the engine hands the whole invocation off to that checkout’s engine before doing any work. This is contributor-only setup, and pm’s users never touch it. Unset — the default — no handoff is considered at all. It is opt-in and the opt-in names one absolute path, because the decision is evaluated in every project on the machine and it decides whether to execute code the project supplies: nothing readable from inside a repository can enable it. See CONTRIBUTING.md if you are contributing to pm itself.
2026-08-25
The conductor tells the truth. A retrospective delivery audit of 49 archived OpenSpec epics across 8 repositories found that the conductor’s records were wrong and its guards did not fire: 42 of 49 epics archived with gateReview: null — by following the documented /opsx:archive workflow, because Gate 2 was enforced by update-epic and bypassed by the archive hook — and sync never walked openspec/changes/archive/, so the conductor saw 49 of 87 archived changes (56%). Every number this project has published about its own effectiveness was computed from those records. This release makes them true: 20 issues, 6 specs, 59 requirements, 115 tasks.

Added

  • integrity — a read-only audit of the conductor’s own record. Ten checks over state.json, each one a shape that cannot be true: an archived epic with zero ticked tasks; a gate verdict whose note cites commits its recorded range does not contain; an epic archived with an ungated Gate 2; a delivered epic with a passing Gate 2 that attributed no commits; an archived openspec epic with no Gate 1; an archive directory the conductor holds no epic for; an epic the heal archived that reads unknown while carrying a passing Gate 2; a gate verdict recorded as bookkeeping rather than review; one change id registered under two lanes; a dangling epic reference. It reports and never repairs — a check that repaired would be a second writer racing the paths that produce the records it reads, and one that blocked would turn an audit finding into an outage. Every check that finds nothing still says it ran, because “the check measured nothing” is exactly the failure this release exists to end.
  • release — a release is a first-class object. state.releases[{id, intent, target, deferred[]}], with membership recorded one-way as epic.release so the two can never disagree. A deliberate exclusion is recorded with its required reason and the excluded epic stays in the backlog rather than being ended. PROJECT.md and the briefing render <release>: N epics, M deferred.
  • record-tracker-refresh — records the verdict that clears a tracker-linked epic’s refresh obligation and advances its freshness watermark.
  • Terminal dispositions with a required reason, at four scopes. An epic ends by recording --outcome delivered|killed|superseded|abandoned --reason "<why>", never by deletion. The same concept covers a deferral recorded in a design doc, a handoff of unfinished work (--carried-to <epicId>), and a release exclusion. A change dropped at Gate 1 for a good reason used to record byte-identically to one that shipped.
  • A deferral assertion is required at archive time — --deferral "<epicId>:<section>", --declined-deferral "<what>:<why not>", or the explicit --no-deferrals. The engine does not read your artifacts and will not guess; an absence you never looked for is indistinguishable from one you confirmed.
  • Gate verdicts carry checkable evidence. record-gate-review gains --base-sha, --head-sha and --reviewer, stored as fields. A verdict whose recorded head does not reach the commits the epic attributed to itself now reads stale and refuses the archive. gate1 acquires a reader — it was previously stored, documented, and consumed by nothing.
  • Commit attribution — update-epic <id> --attribute-commit <sha>, repeatable and append-only. The engine infers attribution from nothing: not the files a commit touches, not an epic id in a message. One exclusion: the commit that moves openspec/changes/<id>/ under archive/ must not be attributed, or the epic’s own Gate 2 goes stale at the instant the archive gate reads it.
  • Epic annotation — --description (durable rationale, replaced wholesale), --notes (append-only trail that reads as activity), and --clear-links as the named way to empty the links array.
  • sync reconciles openspec/changes/archive/, registering an unknown archived change as an epic already in archived status. Visible, announced, one-time — never a silent side effect.
  • The gate procedure PM emits gains five required task items, not prose bullets: the call-site completeness sweep, commit-based verification, the lifecycle-marker declaration, commit attribution, and ending work by recording a disposition. Measured in the audit: a rule carried by a mandatory task section reached 14/14 adoption in subsequent changes; the same rule as a prose bullet reached 3/15.
  • {/* pm:lifecycle */} — an agent-declared marker on a task line that excludes lifecycle bookkeeping from progress. A task reading “run /opsx:archive on this change” cannot be ticked before the thing that ticks it, and used to render as outstanding work forever.

Changed

  • BREAKING — tracker direction replaces direction-by-vendor. Every tracker carries inward, outward or both, set with set-tracker --direction <d> and read by every emitter. rules.mjs used to suppress the outward section with a literal sys !== "github-issues" test while briefing.mjs emitted outward drift gated only on a tracker existing, so a repo could receive a CLAUDE.md with no outward instructions and a briefing demanding outward action for 29 epics.
  • BREAKING — a NEW primary tracker with no --direction now defaults to inward, regardless of vendor. set-tracker --system jira ... used to produce the outward mirror section and now produces the inward one. Outward creation of issues in someone else’s tracker is the consequential default and must be chosen, not inherited. One-line remedy: set-tracker --system jira --direction outward. Existing repos are unaffected — the migration stamps the direction each tracker already behaves with.
  • BREAKING — the archive transition is gated on every path, not just update-epic. reconcileArchived() used to flip an epic to archived with no lane check and no gate check; the rule now binds the function wherever it is invoked — upgrade, render, the commit nudge and sync. An archive that today succeeds silently will either refuse, or record how it bypassed.
  • BREAKING — an epic that ends carries a disposition. An openspec-lane epic cannot be archived as delivered without a passing, non-stale Gate 2; killed, superseded and abandoned are exempt by design, because the code was never written or was thrown away.
  • Three deliberate changes to what PM emits for existing repos. Diffing 0.27.0 output against 0.26.0 will show exactly these three and nothing else; treat a fourth as a regression. (1) The “Sync after completing tracker-linked work” reminder leaves the rules block where no inward procedure is emitted — it used to cite “the writeback steps above” that the same block never emitted. (2) The consider /pm:sync nudge leaves the briefing for outward-only repos. (3) Progress renders · N lifecycle where tasks are lifecycle-excluded.
  • update-epic gains --lane and --plan. A mis-routed epic used to be correctable in exactly one way — remove it and register it again — which discarded its start time, gate verdicts, links and stories.
  • The emitted inward-sync recipe runs as written. It used to instruct add-epic without the required --id; two independent sessions hit it the same afternoon and each silently invented a slug. The recipe now carries a deterministic derived id, and lane at mirror time comes from suggest-lane instead of a hardcoded claude-code.
  • The write-conflict warning latches instead of sampling count === 3. A burst past the threshold between two briefings warned zero times — so the warning was least likely to fire in exactly the wedged-writer scenario it exists for.

Migration

One entry, additive, idempotent, backward-compatible; a state.json written by 0.26.0 still loads. Tracker direction is stamped with the behavior each tracker already has (github-issues primary to inward, any other primary to outward, every secondary to inward), and every archived epic gets a terminal outcome regardless of lane — delivered only where a passing Gate 2 exists, unknown everywhere else. Lane-scoping that would be wrong on measured data: of PM’s own 69 archived epics only 3 are openspec-lane, so stamping one lane would leave 66 with no outcome and the outcome invariant would fail on its own repository the instant the migration ran.

Upgrading — what to expect on the first run

Expect a burst of heal-archived-epic-passed-gate-2 findings the first time you run /pm:integrity. 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, so they miss it by one step. This is expected, not a bug. The check reports each one with the exact remedy — update-epic <id> --status archived --outcome delivered --no-deferrals — and the archive gate’s replacement rule accepts an agent correcting an engine stamp, so the fix is one command per epic and nothing is frozen at unknown.
2026-08-18

Added

  • state.json writes are now guarded against lost updates. Two processes that both read the same state and wrote it back produced a silent lost update: the second write won wholesale and the first one’s change vanished with no error. The atomic rename(2) already guaranteed the write; the unguarded thing was the read-modify-write cycle. loadState() now stamps the on-disk revision onto the object it returns and saveState() refuses a write whose revision is stale. A lockfile was rejected deliberately — a session killed mid-write leaves a lock held forever, whereas a revision comparison leaves nothing behind.
  • A no-op save is a no-op. A saveState whose content is unchanged (revision aside) neither writes nor bumps, so re-running a command that changes nothing leaves state.json byte-identical. This preserves two existing idempotence guarantees the revision bump would otherwise have broken.
  • A conflict on an interactive verb exits 9, distinct from the 1 every validation failure already uses, so an agent can tell “someone else wrote, retry” from “you passed a bad flag”.
  • --force overwrites deliberately, bypassing the revision check. Without a documented override people learn to hand-edit state.json to get around the guard, which is strictly worse than a flag that leaves a trace in the command line. A forced write’s new revision always advances strictly past whatever is currently on disk — otherwise it can land on a revision a later writer already saw, reopening the lost-update window one hop removed.
  • Hook writes degrade instead of failing. Both hook writes re-run on the next hook, so a conflict is recorded and skipped rather than surfaced as a mid-session error for a write that did not matter. Three consecutive skips warn once in the briefing — and delivering that warning consumes it, rotating the log so the count resets, otherwise the briefing re-warns on every SessionStart about a problem that resolved days ago.
  • Skips are recorded in .conductor/write-conflicts.log, which rotates at 8 KB keeping one .prev. Size-triggered rather than count-based on purpose: enforcing “keep the last N” means reading and rewriting the file, and this is the failure path of a write guard.

Fixed

  • init now git-ignores the conductor’s generated logs. .conductor/detours.log had never been ignored by anything PM ships — it was invisible on the maintainer’s machine only because their personal global gitignore carries *.log, so every other user had carried a permanently untracked file since it shipped. Both init and upgrade perform the backfill; wiring it to init alone would have fixed new repos and missed every existing one.

Compatibility

  • A state.json written by 0.25.2 has no revision; it loads unchanged and takes revision 1 on its first write. No migration is required.
2026-08-17

Fixed

  • A help flag no longer has a side effect. --help was parsed nowhere in the engine, and the usage string was only reachable from the dispatch fallback that fires for an unknown subcommand — so on a known one, --help fell through and was consumed as data. log-detour --help wrote a real row to .conductor/detours.log with --help as the detour description. That log is append-only with no verb to remove a row, and it is what a future session reads to reconstruct what happened, so the entry was a false statement in the project’s own history rather than cosmetic noise. Recovery was worse than the bug: the obvious one-liner, head -n -1, is a GNU extension that fails on macOS. Help flags are now handled before dispatch, covering every subcommand rather than the one where the damage happened to be visible — log-detour is simply the verb with a free-text positional in the slot --help lands in. A bare invocation with no subcommand also prints usage and exits 0 instead of erroring. Worth upgrading for even if you have not noticed it: the damage is silent and lands in a file people rarely open.
2026-08-17

Fixed

  • A missing progress source now warns instead of rendering an em dash. bar() rendered — for three different states — “this epic has no progress source”, “the source exists and is empty”, and “the source is missing” — so an openspec epic whose changes/<id>/tasks.md had moved or been deleted was indistinguishable from healthy work with nothing ticked yet. It now renders ⚠ tasks.md missing, mirroring the plan lane’s existing ⚠ planPath missing. Two claims made while diagnosing this were themselves wrong and are worth recording: the lane did not render 0/0 (it rendered —, because bar() returns the em dash whenever total === 0), and warn was not unconsumed (render.mjs and briefing.mjs do not reference it directly, but both render progress through bar(), which is where the consumer lives). Both were settled by rendering a real fixture rather than reading the source.
  • Archived epics no longer warn about a missing progress source. Archiving is precisely when a source legitimately goes away: OpenSpec removes changes/<id>/, and finished plans get moved out of docs/superpowers/plans/ — which is also how a repo stops sync re-registering shipped work. Measured on a 108-epic repo, 7 of the 8 epics carrying a planPath dangled, and all 7 were archived with their plan correctly moved; the single one that resolved was queued and genuinely in flight. A warning wrong seven times out of eight trains the reader to ignore the once it is right.
  • sync no longer registers a plans directory’s own index file as an epic. Every .md under docs/superpowers/plans/ was treated as a plan, so README.md — the file documenting what the directory is for — became an untriaged epic titled after its H1, observed live as “Superpowers Plans — Active”. readme.md, index.md and contributing.md are excluded by filename; deciding from file contents would make registration depend on heading conventions, which is the fragility this surface already has.
2026-07-30

Added

  • rules-target — a read-only query printing the absolute path of the file the resolved platform’s rules block belongs in, walking that platform’s first-match-wins chain. It deliberately does not record a platform: a query must not mutate state the way write-rules does. It exists so tooling around pm never has to mirror PLATFORM_RULES_CHAIN. evals/observe.py hardcoded CLAUDE.md, which 0.24.0 quietly turned into a second platform seam — once the engine began writing a per-platform target, the observer reported rules_block_present: false for any platform whose block lands elsewhere. That is a confident wrong answer, and it would have surfaced on the first Hermes or Codex run as a parity failure that is not real. Copying the chain into Python was rejected: a second copy of platform knowledge only moves the drift instead of removing it.
2026-07-30

Added

  • Platform-aware rules block. The managed rules block is no longer Claude-Code-only. The host agent declares itself via --platform <claude-code|hermes|codex> in the hook command pm authors for that platform, and the active platform is recorded in .conductor/state.json. pm never runs on its own — a host agent always triggers it, through a hook whose command string pm itself wrote — so the platform is declared, not detected: no markers, no filesystem archaeology, no precedence heuristic to get wrong.
  • Per-platform slash-command form: /pm:status on Claude Code and Hermes, /pm-status on Codex. The pm: namespace is retained wherever supported for a concrete reason, not aesthetics — Hermes silently skips a plugin command that collides with one of its built-ins, and it ships a built-in status, so a bare /status would be dropped with no signal. Codex derives command names from prompt-file stems, so it is flat and hyphenated. Every value verified against the installed CLIs rather than documentation.

Changed

  • The test suite is now scripts/test/*.test.mjs (11 files) instead of one scripts/conductor.test.mjs, so it runs in parallel. Contributor-facing: the command is node --test scripts/test/*.test.mjs. Measured rather than guessed — one engine spawn costs ~73ms and the suite makes 676 of them, so ~49s was pure node startup, serialized in a single file on a 16-core machine; node --test parallelizes across files only. The split is verbatim, verified by test-name-set equality (255 → 255). Note node --test scripts/test (a directory) does not work — Node treats the argument as a module to execute and dies with MODULE_NOT_FOUND while reporting “1 test”. How much faster depends entirely on free cores, so treat any single headline number with salt. Measured on this project: an otherwise-idle 16-core workstation went ~118s → ~46s (2.6×), but the same machine under load (~12) went ~146s → ~122s — only 1.2×, because parallel execution is exactly what suffers when cores are already contended. On CI, whole-workflow time went from ~52–58s to ~38–48s (~1.3×). On a busy laptop or a few-core runner, expect the low end.
  • .githooks/pre-commit now aborts if the suite runs fewer tests than are declared. The glob makes a partial-suite pass possible in a way the single file never did: if a file stops matching, everything still goes green, just on a subset. The hook cross-checks the runner’s count against grep -Hc '^test(' across the files — self-maintaining, with no constant to bump as tests are added.

Fixed

  • The rules block is now written to the file the host platform will actually read. Hermes resolves project context first-match-wins over HERMES.md > AGENTS.md > CLAUDE.md, so in a repo already carrying an AGENTS.md — from a prior Codex attempt, say — the block was silently invisible: no error, no warning, just an agent running without the conductor’s instructions. Writing to a file a platform is capable of reading is not enough; it must win that platform’s precedence chain.
  • The auto-detour hook no longer writes a detours.log entry for a commit that did not land in this repo (closes #65 and #68). PostToolUse fires when the Bash tool returns, which is not the same as “a commit landed here.” Three divergences were observed live, each writing a false line attributed to this repo’s stale HEAD: the commit was rejected by pre-commit so HEAD never advanced; the commit was backgrounded and still running; or the commit landed in a different repo — a paired repo, a submodule, git -C elsewhere — leaving our HEAD untouched. An exit-code check would have closed the first but not the second, since a backgrounded commit has no exit code yet; that is why two independently-reported issues share one fix.
  • Comparing subjects is subtler than it looks, and getting it wrong is worse than the original bug. git log -1 --format=%s yields only the first line, while a -m capture spans newlines and swallows the whole message — so a naive comparison suppresses every commit that has a body, silently disabling the hook for the common case. The comparison is therefore first-line-to-first-line, and a message the shell assembled (-m "$(…)", a heredoc, -m "$MSG") is treated as unverifiable rather than mismatched, because the command string holds the shell source rather than the text git received.

Migration

  • 0.24.0 stamps platform on existing state files, defaulting to claude-code. Additive and idempotent; a 0.23.1 state file still loads.
2026-07-23

Fixed

  • /pm:upgrade’s own instructions now defend against a real misread: an agent treating the command invocation itself as passive local-command output and skipping it entirely. When /reload-plugins and /pm:upgrade land in the same turn — the exact sequence this command’s own preamble recommends — the harness wraps that turn’s local command output in a caveat meant for /reload-plugins’s passive stdout, and an agent can over-apply that caveat to /pm:upgrade itself. Self-inflicted by this command’s own documented workflow — fixed by having the command’s own text state plainly: if you’re seeing these instructions, the command was invoked, execute it.
2026-07-21

Added

  • update-epic <id> --add-story "<title>" and update-epic <id> --story <n> --done. Closes a recurring hand-edit-of-state.json risk for inline stories[] — --add-story appends { title, done: false } (creating the array on its first inline story); --story <n> is 1-indexed and currently requires --done (the only supported mutation today) — both reject out-of-range/empty input and write nothing on failure.
  • render --diff-summary — prints epic-relevant: yes or epic-relevant: no after rendering. The “Last rendered” timestamp and the “Recent detours” table both change PROJECT.md on nearly every render even when nothing about the epics themselves changed, forcing manual git diff eyeballing before every “is this safe to discard” call. --diff-summary normalizes both known-trivial sources away and reports whether anything else differs, making the check mechanical.

Fixed

  • The conductor: engine <version> @ <path> banner no longer prints on every single invocation in a self-hosting/dev context. Suppressed by default whenever CLAUDE_PROJECT_DIR is set — set PM_VERBOSE_ENGINE_BANNER=1 to force it back on. PM_QUIET_ENGINE_BANNER=1 still works unchanged outside that context.
  • .githooks/pre-commit is now quiet on success — a one-line pre-commit: N/N passing summary; full output (and a non-zero exit) only surfaces when a test actually fails.
  • verify-worktrees now also flags a hierarchy-child worktree whose branch is already merged, not just ones whose epic status is archived. Each flagged worktree’s reasons array lists which trigger(s) fired.

Changed

  • scripts/conductor.mjs split into scripts/lib/*.mjs modules — no behavior change. The engine (2,537 lines, 85 functions) is now a 121-line entry point plus 20 focused scripts/lib/*.mjs modules, one per pre-existing section. Motivated by AI-agent token-efficiency, not human-readability tech debt. Purely internal — verified by all 250 pre-existing black-box tests passing unchanged throughout.
2026-07-20

Added

  • On-demand AI-agent doc reference: llms.txt/llms-full.txt. Mintlify auto-publishes both at pm-plugin.dev — llms.txt is a lightweight (~7KB) index of every doc page, llms-full.txt is the entire site as one markdown document (~200KB, tens of thousands of tokens). Referenced from the one-time orientation points that already exist (/pm:init’s step 0, the conductor skill’s new “Further reference” section) rather than the persistent CLAUDE.md rules block, with an explicit size/token warning at every reference point. Also added a docs-site pointer to README.md’s intro.
2026-07-20

Added

  • Security scanning via .github/workflows/security.yml. Calls the two reusable workflow_call workflows published in cfdude/.github — Semgrep SAST and a Trivy filesystem scan — on push/PR to main and a weekly schedule, publishing SARIF results to the repo’s Security tab. Both are non-blocking by design. Pinned to a commit SHA (not @main) to avoid a supply-chain gap where a moving branch ref could silently change what runs in CI.
  • SECURITY.md — vulnerability reporting instructions and a short architecture note.
2026-07-20

Added

  • CLAUDE.md rules block gains an unconditional “Feedback” section encouraging the agent to proactively use /pm:feedback [bug|feature] "<summary>" (or ask the user “want me to file this as feedback?”) whenever it hits a bug, a missing CLI verb, or repeated friction — instead of silently working around it. Motivated by a real gap: /pm:feedback shipped in 0.14.0 and was never used once, while the friction of hand-editing .conductor/state.json to flip a story’s done flag (no CLI verb exists for it) recurred silently across several sessions before being reported.
2026-07-20

Added

  • /pm:upgrade now recommends adopting relevant new capabilities. After printing the changelog delta, the command’s instructions tell the agent to review each Added headline, judge whether it’s an opt-in capability (a new flag/subcommand/behavior, not a bug fix or automatic change) relevant to the repo’s current .conductor/state.json, and recommend it — one line, one reason, the command to run — without enabling anything itself. Instruction-only change (commands/upgrade.md, README.md); no engine code or schema touched.
2026-07-20

Added

  • Completion-time tracker resync instruction. When an inward-pull-capable tracker is configured (a github-issues primary, or any secondary tracker), the CLAUDE.md rules block now adds a “Sync after completing tracker-linked work” section: after closing/transitioning a tracker-linked issue as part of completing an epic, re-sync with your tracker(s) (/pm:sync) right away, since you’re already doing tracker I/O for that epic. Phrased tracker-count- agnostic (“your tracker(s)”) so it reads correctly whether a repo has one tracker or several (primary + secondary).
  • Session-start sync nudge. The SessionStart brief now includes a one-line, non-blocking nudge — “N tracker(s) configured (…) — consider /pm:sync this session to pull in any new issues” — whenever any tracker (primary or secondary) is configured. This is a reminder only; the engine never calls a tracker itself, and the agent decides whether syncing is worth it. Deliberately does not track a last-synced-at timestamp — the nudge is enough without it.
2026-07-19

Added

  • Support a primary tracker plus zero or more secondary trackers. state.tracker is unchanged and is now, implicitly, the primary tracker — full existing bidirectional behavior (outward issue creation on new epics, statusIntent-driven status transitions), including the github-issues-as-primary inward-only special case, byte-for-byte unchanged. New optional state.secondaryTrackers[] lets a repo also watch additional trackers — e.g. Jira as the real dev tracker plus a GitHub repo for inbound issues from outside contributors or another internal repo publishing cross-project notifications — via set-tracker --role secondary --system <sys> --repo <repo> (or --project <key>), removable with --remove. Secondary trackers get inward pull (open issues become untriaged epics) plus a new capability that didn’t exist even for the old inward-only github-issues case: completion status writeback — when an epic sourced from a secondary tracker reaches archived, the agent closes the linked issue there too. Secondary trackers never receive outward-created issues; that stays exclusive to the primary tracker. Dedup for both inward pull and writeback now matches on externalUrl (globally unique) rather than bare externalId (only unique within one tracker/repo) — fixing a latent cross-tracker collision risk (e.g. issue #42 existing in two different secondary-tracker repos) surfaced during this change’s own Gate 1 review, before any code shipped.
2026-07-17

Added

  • Mechanical pre-commit hook: the full test suite must pass immediately before every commit, enforced, not just documented. A genuinely failing test was committed once already (0.16.0) because a prose reminder alone wasn’t enough — “run the tests one more time before committing” is exactly the kind of rule that gets skipped under momentum. .githooks/pre-commit runs node --test scripts/conductor.test.mjs and blocks the commit on any failure. One-time setup per clone: git config core.hooksPath .githooks (documented in CONTRIBUTING.md). Found and fixed a real bug on first live use: git sets GIT_DIR/GIT_INDEX_FILE/etc. for hook processes, which leaked into the test suite’s own child git processes (tmp-repo fixtures), causing them to operate against the outer repo’s locked index instead of their own tmp dirs — the hook now unsets those variables before running tests.
2026-07-17

Added

  • Added a mechanical test that catches README.md “Commands” drift from the real dispatch table, mirroring the existing SKILL.md drift test. Running it against the current docs caught 8 real gaps (commit-nudge, log-detour, honcho-memory, set-review-mode, verify-state, write-rules, snapshot, changesets) — several of them genuinely undocumented user-facing subcommands (honcho-memory, verify-state, changesets), fixed in the same pass. agents/hierarchy-child-executor.md’s standing instructions and the conductor skill’s epic-hierarchy preflight section now explicitly require a README.md update (not just SKILL.md) whenever a child epic adds/changes a user-facing command, flag, or behavior — the same class of gap that let record-gate-review ship in 0.16.0 with zero README mention.
2026-07-16

Added

  • OpenSpec’s two mandatory gates are now mechanically enforced at archive time, not just narrated. Nothing previously checked that an openspec-lane epic actually passed Gate 1 (spec review, before code) and Gate 2 (implementation review, before docs) before it was archived — an epic could go straight from apply to archive on narration alone. A new record-gate-review <epicId> --gate 1|2 --verdict pass|fail [--reviewer "<note>"] subcommand writes a fresh-context reviewer’s verdict durably onto the epic (gateReview.gate1/gate2, mirroring record-reconcile’s shape), and update-epic --status archived now REJECTS the transition for any openspec-lane epic that doesn’t already have a recorded gateReview.gate2.verdict === "pass". Scoped strictly to the openspec lane — superpowers/claude-code/decision/external epics are completely unaffected, since they have no two-gate process.
  • Added a mechanical test that catches SKILL.md “Commands” drift from the real dispatch table. conductor.test.mjs now extracts every subcommand key from conductor.mjs’s dispatch table and asserts each one is mentioned somewhere in skills/conductor/SKILL.md, failing CI the next time a new subcommand ships without a doc mention (the same bug class fixed once by hand in 0.12.0, now enforced instead of relying on someone remembering). Running it against the current docs caught two real gaps — snapshot (the PreCompact-hook-only re-render) and write-rules (the /pm:init//pm:upgrade-only CLAUDE.md rules-block refresher) were both real, invoked subcommands with no mention anywhere in SKILL.md — fixed by adding a line for each to the Commands section.

Changed

  • The github-issues tracker no longer tells the agent to auto-create a GitHub issue for every unmirrored local epic. rulesBlock() now suppresses the outward “External tracker sync” section entirely when tracker.system === "github-issues", leaving only the existing inward “GitHub issue sync” section (open issues → untriaged epics) in effect. Filing a public GitHub issue for any local claude-code epic just because a github-issues tracker is configured is a materially bigger, more consequential default than mirroring toward an internal Jira/Linear instance, so github-issues is now documented and implemented as INWARD-ONLY by design. Jira, Linear, and any other tracker --system keep the full bidirectional outward-mirror behavior unchanged.

Fixed

  • commit-nudge’s auto-detour heuristic no longer false-positives on routine conductor bookkeeping. A commit touching only pm’s own state-output files (.conductor/state.json, PROJECT.md, .conductor/render-stamp.json) is never auto-logged as a stray minimal detour, even if it matches the fix:/chore: + <=3 files shape — this fired 3 separate times in one session (registering epics, archiving epics, granting autonomy), always on commits that were routine administration, never a real detour. CLAUDE.md is deliberately excluded from this allowlist: it’s user-authored content, not purely engine-generated output, so a commit touching it could still be a genuine detour.
  • render-stamp.json no longer produces a spurious diff on every render() call when nothing meaningful changed. Root cause: writeRenderStamp() unconditionally rewrote .conductor/render-stamp.json on every render() invocation, bumping its renderedAt timestamp even when state.json (and therefore the rendered PROJECT.md content) hadn’t changed at all — producing a byte-only diff that had to be manually discarded roughly a dozen times across a single dogfooding session. verify-state (the mechanism this stamp exists for) only ever compares the recorded stateMtimeMs against state.json’s current mtime; it never reads renderedAt back for correctness. writeRenderStamp() now skips the rewrite entirely when the existing stamp’s stateMtimeMs already matches state.json’s current mtime, so the sidecar file is only ever touched when something that actually matters changed. .conductor/brief.txt was confirmed already gitignored in this repo (a prior fix); no further action was needed there.
2026-07-15

Added

  • Changesets-style fragment files replace direct CHANGELOG.md edits for hierarchy children. Every parallel hierarchy-child batch was hitting a 100% collision rate on CHANGELOG.md’s shared ## [Unreleased] header — every dispatched child edited the same section, guaranteeing a merge conflict on every multi-child batch. Children now write their changelog entry to .changesets/<epic-id>.md instead (same bullet format CHANGELOG.md already uses: a bold one-line summary, then wrapped prose). The orchestrator remains the sole writer of CHANGELOG.md — consistent with it already being the sole writer of .conductor/state.json — and consolidates all pending fragments into the real [Unreleased]/new-version section once, at release time, then deletes the consumed fragment files. A new zero-dependency changesets engine subcommand (node conductor.mjs changesets) lists .changesets/*.md fragments as { changesets: [{ id, path, body }] }, sorted by epic id, to make that consolidation step mechanical rather than a manual cat + guesswork. (First real-world test, this very release: zero CHANGELOG.md conflicts across 3 parallel children, versus a 100% collision rate before.)
  • Mandatory post-resolution verification for the epic-hierarchy merge-conflict ladder. After ANY conflict resolution (self-resolved by the orchestrator, via agents/merge-conflict-resolver, or via an escalated model/advisor() opinion), before the merge is committed: grep every touched file for leftover <<<<<<</=======/>>>>>>> markers, and run node -c on every touched .mjs/.js file. Either failure means the file is still unresolved. Closes a gap found during this repo’s own 0.14.0 dogfood run, where a resolution removed only the closing conflict markers and left the opening <<<<<<< HEAD marker in place — caught only by a manual re-grep, not by any required step.
  • Session-continuity check for live external-infra epics. The hierarchy-child-executor agent now has a required checklist item: before finalizing its report, if the epic’s work made a live change to external infrastructure the orchestrator itself depends on for the rest of the session (branch protection rules, credential/token rotation, webhook/API changes, etc.), it must explicitly answer “does this change affect how the orchestrator itself needs to operate for the rest of this session?” in CONCERNS — even an explicit “no” is required output, not silence. Fixes a real incident: branch-protection-and-pr-workflow applied live branch- protection settings to main, and the orchestrator’s very next git push origin main was rejected — discovered only empirically, not flagged by that epic’s own report.
  • README.md fully revamped. Replaces the agent-facing, all-over-the-place structure with a Comet-inspired layout: a real banner image, honest badges (CI/version/license only — no DeepWiki/CodeCov/trending until the tooling behind them actually exists), a “Why Use PM?” section built from real, verifiable repo history (not a fabricated benchmark), a genuine “From Industry-Frontier Practice” write-up of the design patterns PM’s architecture actually mirrors, a Supported Platforms table with an honest Status column (Claude Code: Supported; Codex/Gemini CLI/Grok Build/AGENTS.md format: Planned, tracked under multi-platform-agent-support), collapsible <details> command reference instead of one long flat table, and a Star History chart. Resolves df-readme-stale-since-gate-guard (README hadn’t been touched since f77d774, missing everything shipped since).
  • Branch protection + PR workflow on cfdude/pm. main now requires pull requests (no direct pushes), the test job from .github/workflows/ci.yml as a required status check, 0 required approving reviews (solo maintainer), and squash-merge-only at the repo level. Day-to-day work moves to a new dev branch (created from main’s tip); PRs merge dev → main. See CONTRIBUTING.md for the full workflow. This is a live GitHub repo settings change, not a code change — no state.json schema impact.
2026-07-15

Added

  • github-issues tracker: inward pull (open issues → new untriaged epics). set-tracker --system github-issues --repo <owner/name> records a repo alongside the tracker’s system. The rules block now gains a “GitHub issue sync” section (in addition to the existing outward “External tracker sync” mirror) telling the interactive agent — as part of /pm:sync — to gh issue list --repo <repo> --state open, skip issues already mapped to an epic via externalId, and register the rest with add-epic --status untriaged --external-id <n> --external-url <url> --lane claude-code --priority P2 (a P0/P1/P2/P3 label on the issue overrides the P2 default). The engine itself never calls gh — same instruction-layer law as every other tracker. add-epic now also rejects a duplicate --external-id outright (exits non-zero, writes nothing), so re-running sync can never create a duplicate epic for the same issue even off a stale local view. See commands/tracker.md, commands/sync.md, and the conductor skill’s “Hierarchy & external trackers” section.
  • .github/workflows/ci.yml. A GitHub Actions CI workflow on push to main and on every pull request targeting main, running Node 18.x: a node -c syntax check on scripts/conductor.mjs and scripts/conductor.test.mjs, then the full test suite via node --test scripts/conductor.test.mjs. This repo is zero-dependency, so “lint” here means the syntax check rather than a third-party linter. The job is named test (jobs.test) — a follow-up epic wires this job into required branch-protection status checks.
  • /pm:feedback [bug|feature] "<summary>". File a bug report or feature request against pm itself directly as a GitHub issue on cfdude/pm, from any session using the plugin — replacing the previous workflow of manually copy-pasting details between sessions. Pure command-doc addition: the interactive agent gathers the report, searches open issues on cfdude/pm for a near-duplicate title (commenting on a match instead of filing a new issue), and otherwise runs gh issue create --repo cfdude/pm with a bug/enhancement label, reporting back the issue URL. No engine code involved — scripts/conductor.mjs never calls GitHub itself; all gh calls are agent-invoked Bash, per the instruction-layer law. See commands/feedback.md.
2026-07-15

Added

  • record-reconcile <epicId> --detour <detourId> --verdict valid|invalidated [--amendments "<a>;<b>"]. The reconciler agent’s verdict at the POP-protocol reconcile gate previously only ever lived in the conversation transcript. This subcommand writes a structured {verdict, amendments, reconciledAt} object onto the paused epic’s link to the detour that triggered reconciliation (creating a may-invalidate link if none exists yet), and clears reconcileNeeded — so the judgment is durable in .conductor/state.json and visible in PROJECT.md, not just something Claude said once. agents/reconciler.md’s report format, commands/resume.md, and the conductor skill’s POP protocol / rules block now describe this writeback step.
  • Per-repo lane-routing overrides. New optional laneRouting.overrides config block in .conductor/state.json — keyword/glob rules ({match, lane}) checked BEFORE the generic lane heuristic when an agent decides which lane should build an epic. Set via the new set-lane-routing --add "<match>:<lane>" [--add ...] | --remove "<match>" | --clear subcommand; looked up via the new suggest-lane "<free text>" subcommand, which prints {lane, matched} JSON (lane: null means no override matched — fall back to the generic heuristic). Replaces the need for a CLAUDE.md prose carve-out when the generic heuristic is wrong for a repo (e.g. “anything touching billing always goes through openspec”). See commands/lane-routing.md and the conductor skill’s “Lane routing overrides” section. Pure local state write — the engine still never assigns a lane itself; add-epic always takes an explicit --lane.
  • Per-epic review-mode override (escalation-only). update-epic <id> --review-mode off|standard|thorough sets an epic-level override that can only ESCALATE above the repo-global set-review-mode dial — never de-escalate below it (an attempt to set a lower mode than the current global dial is rejected outright, state unchanged). currentReviewMode now accepts an optional epicId and returns the effective mode for that epic: the higher-ranked of the global dial and the epic’s override. rules --epic <id> surfaces the effective per-epic mode in the emitted “Current mode” line. Lets one security-sensitive epic force thorough review without flipping an otherwise-standard repo’s global dial.
  • Auto-detected minimal detours from commit diff shape. commit-nudge (the PostToolUse(Bash) hook that already fires after every git commit) now recognizes an UNLOGGED minimal detour by its shape — a small commit (<=3 files changed) with a fix:/chore: conventional-commit subject, made while no detour is active, and not scoped to the currently active epic (a fix(<active-epic-id>): ... subject is read as that epic’s own work, not a stray detour) — and appends an AUTO-DETOUR entry to .conductor/detours.log automatically, without waiting for /pm:detour --minimal to be run by hand. Three separate dogfooding sessions converged on “the agent forgets to log the minimal detour” as the #1 pain point; this closes that gap at the mechanism level (hook-driven, not agent-remembered) rather than relying on the agent to recall the rule. See looksLikeUnloggedMinimalDetour() / headChangedFileCount() in conductor.mjs.
  • honcho-memory <push|pop> <epicId> "<reason>" subcommand. Formats the exact ready-to-copy one-line Honcho memory string for a detour-stack PUSH/POP (per CLAUDE.md rule 4), prints it to stdout, and appends a timestamped copy to the new .conductor/honcho-memories.log. Previously the interactive agent had to compose that string itself from context on every PUSH/POP, with no engine support and no durable record of what was actually sent — easy to forget or word inconsistently. The engine still never calls Honcho itself (pure string formatting + local logging, staying inside the instruction-layer law); commands/detour.md, commands/resume.md, and the conductor skill’s PUSH/POP protocols now call it and paste its output into the actual Honcho MCP call.
  • Dependency-aware ordering for the top-level queue, not just hierarchy siblings. The brief’s NEXT UP list (and thus /pm:next’s recommendation) now applies the same depends-on topological ordering plan-hierarchy already used for one parent’s children to ALL top-level queued/untriaged epics: a higher-priority epic with an unresolved depends-on link to another still-queued epic is no longer listed (or picked) ahead of the dependency it’s waiting on, even across otherwise-unrelated epics with no shared parent. When ordering overrides plain priority this way, the brief prints a one-line note naming the blocker, e.g. ⚠ epic \high-blocked` ready but waiting on `low-dep`. Unlike plan-hierarchy`, a dependency cycle among queued epics does not error here — it’s a display/selection helper, not an execution plan, so it falls back to the original priority order for the stuck remainder.
  • SessionStart upgrade nudge now inlines top Added-bullet headlines. The pm X.Y.Z → A.B.C available nudge previously named only the old/new versions, forcing a separate /pm:changelog round trip to judge whether upgrading was worth mid-epic churn. It now inlines up to 3 “Added” bullet headlines (first line only, not the full multi-line body) drawn from every CHANGELOG.md section strictly between the stamped and newest version, so a session can judge upgrade value inline.
  • Category-based --preauthorize shorthand for epic-level autonomy. set-autonomy <id> --preauthorize "category:<filesystem|network|schema|external-api>:<reason>" grants routine actions by category instead of requiring every one enumerated individually. Stored as a distinct { category, reason?, grantedAt } grant shape alongside existing exact-action { action, reason?, grantedAt } grants in the same preAuthorized[] array — exact-action matching is unchanged. Unknown categories are rejected with a non-zero exit and no state write. The matching heuristic each category expands to at decision-rule time (approximate by design) is documented in the conductor skill’s “Epic-level autonomy — the preflight scan” section.

Changed

  • Epic-level-autonomy decision rule now says “--notify incrementally as it happens,” not “record for the end-of-epic report.” The --notify mechanism already writes durably to state.json’s notifications[] array; the prior wording implied WARN-class (c) and consequential (e) decisions were only gathered in-memory for a report assembled at the end of the epic, which loses them if the session is compacted or interrupted mid-epic. Fixed in both CLAUDE.md’s rules block and the identical generated block in scripts/conductor.mjs’s renderRulesBlock-equivalent. The end-of-epic report step now reads back notifications[] rather than being the primary record. No code change — --notify/notifications[] already worked this way; this is a wording fix so the documented process matches the existing mechanism.
  • Gate guard is now on by default whenever an epic owes a reconcile. gateGuardCheck() now blocks Edit/Write/NotebookEdit unconditionally when the active epic’s reconcileNeeded is true, regardless of the repo’s gateGuard setting in state.json — set-gate-guard off no longer bypasses this specific case. Applies retroactively to any epic that already has reconcileNeeded: true, not just future detour POPs. Reverses the original opt-in design after real-usage feedback (docs/feedback/2026-07-14-pm-plugin-improvement-feedback.md) showed the guard had never actually been turned on across several sessions where it would have caught a real skip. The repo-level gateGuard flag and set-gate-guard on|off command still exist, reserved for any future generalization of the hook to other checks. See commands/gate-guard.md and the conductor skill’s POP protocol.

Fixed

  • missing() now excludes status === "archived" epics. An already-archived openspec epic (proposed, built, and archived — its openspec/changes/<id> directory legitimately moved to openspec/specs/ by the archive process) could still render the unresolvable ”⚠ no change on disk” warning forever if its on-disk archive-dir name didn’t match isArchived()’s dated-prefix convention. Same class of bug already fixed for planHierarchy() (df-plan-hierarchy-includes-archived-children, 0.12.1), applied here to the missing-change-warning code path.
2026-07-15

Added

  • startedAt/completedAt timestamps on epics, and a staleness indicator. set-active now stamps startedAt (ISO string) the first time an epic goes active (re-activation after a demotion does not reset it); update-epic --status archived stamps completedAt. Both fields are purely additive — existing epics simply lack them until touched, so no migration is needed. PROJECT.md’s epic table, its “Now” section, and the brief’s NOW/NEXT UP lines all surface ⚠ stale, Nd active for any epic with startedAt set, no completedAt, and more than 14 days elapsed — supporting velocity tracking and the weekly Ship-Real-Software check.
  • verify-state subcommand. render() now writes .conductor/render-stamp.json (renderedAt + the state.json mtime it rendered from) every time it runs. verify-state compares state.json’s current filesystem mtime against that stamp and fails loudly (non-zero exit, clear stderr) if state.json was modified after the last recorded render — mechanical evidence of an undetected hand-edit, which CLAUDE.md explicitly forbids (state.json/PROJECT.md must only change through the engine’s subcommands). Also fails loudly if no stamp exists yet (state.json has never been rendered).
  • Engine version+source banner on every invocation. conductor.mjs now prints conductor: engine <version> @ <path> to stderr on every run (silenceable via PM_QUIET_ENGINE_BANNER=1). Discovered live while dogfooding: $ENGINE resolution had silently picked up the installed plugin cache’s 0.12.0 copy while this repo — the plugin’s own source — was already at 0.12.1, with no signal anything was stale.

Fixed

  • ENGINE-resolution snippets (skill doc + every command doc) now prefer a repo-local $CLAUDE_PROJECT_DIR/scripts/conductor.mjs before $CLAUDE_PLUGIN_ROOT and the installed-cache fallback. When the repo being worked on IS the pm plugin source (self-hosting), that copy is always the one under active development and should win over a stale cached install.
2026-07-15

Fixed

  • plan-hierarchy no longer includes already-archived children in a hierarchy plan. Children were filtered by parent only, with no status check — a done child (e.g. one already merged and archived from a prior dispatch batch) still showed up in the plan, indistinguishable from real pending work. Discovered via the first live dogfood resumption against pm-plugin-improvements-2026-07-14. Excluding status === "archived" from the children filter also correctly makes a depends-on reference to an archived sibling fall outside the hierarchy’s dependency graph — the same existing behavior as a link to any epic outside the hierarchy, since a done dependency imposes no wait.
2026-07-15

Added

  • verify-worktrees — orphaned hierarchy-dispatch worktree detection. Cross-references git worktree list against epic status: any worktree on a hierarchy-child/<epic-id> branch whose epic is already archived (successfully merged and closed out) is flagged. Bakes worktree hygiene into the plugin itself — checkable on any fresh install — rather than depending on a user’s personal CLAUDE.md discipline. Pure read, flags without deleting.
  • Worktree-isolated epic-hierarchy dispatch, replacing the original “just dispatch in parallel” instructions. Discovered via the first live dogfood attempt against a real hierarchy (every child touched scripts/conductor.mjs): concurrent children mutating shared files was a real, unaddressed race. Each child now works in its own git worktree; children never write .conductor/state.json themselves (the orchestrator is the sole writer, applied once per batch); worktree branches merge back sequentially. An ordinary merge conflict is never a hard stop — it’s resolved via a tiered ladder (normal merge → dispatch the new agents/merge-conflict-resolver → escalate to a stronger model/advisor() → commit best-effort + log a follow-up epic under the same parent) — a direct, consistent application of epic-level autonomy’s existing decision rule, since a git-tracked conflict is always recoverable via history (criterion (c), never the unconditional-stop criterion (b)).
  • agents/merge-conflict-resolver.md — a new packaged agent (mirrors reconciler.md’s shape) dispatched to resolve a worktree-merge conflict, reporting resolved/uncertain/ failed so the orchestrator knows whether to escalate further.

Fixed

  • Doc drift in the conductor skill’s Commands line: remove-epic, plan-hierarchy, and verify-worktrees were all missing despite remove-epic/plan-hierarchy already having shipped in prior releases.
2026-07-15

Added

  • remove-epic <id> [--cascade] — hard-delete an epic, replacing the raw git checkout workaround that was the only prior recovery from a mis-registered epic. Blocked by default if the epic has children: prints a concise (id, title, lane/priority/status) table of the parent plus every child and exits non-zero, so removing a parent with descendants is always a deliberate, informed choice; --cascade removes the epic and all descendants together in one atomic write. Any other epic’s links[] entries referencing a removed id are stripped automatically, with a warning naming the affected epics. Recoverable only via git history — deliberately no in-app undo, since this verb exists specifically to replace that workaround, not add a softer one next to it.
2026-07-14

Added

  • plan-hierarchy --parent <id> — batched execution plan for a parent epic’s children. Computes batches from data pm already tracks (no new persistent state): priority and depends-on links between siblings drive a topological sort — children with no dependency on each other land in the same batch (dispatchable in parallel), children in a dependency chain land in separate, ordered batches. Each child is annotated with whether it already has autonomy.level: "autonomous" (from epic-level autonomy), so a hierarchy dispatch never fires a child that hasn’t been preflighted. Each child also carries dependsOn, its sibling dependency ids within the hierarchy, so a blocked-child handler can check whether a later batch depends on it (directly or transitively) rather than guessing from batch order alone. A dependency cycle among children is rejected outright, naming the cycle path, rather than producing a bogus order.
  • agents/hierarchy-child-executor.md — a packaged subagent dispatched once per child epic in a batch: front-loaded with the epic’s full context and its autonomy grant, works the epic to completion using its lane’s normal workflow, follows epic-level autonomy’s decision rule for genuine stops, and returns a fixed report (STATUS/DONE/DECISIONS/CONCERNS).
  • The conductor skill documents the full end-to-end process: preflight every child up front (reusing epic-level autonomy’s scan, consolidated into one batch of questions) → plan-hierarchy → dispatch batch by batch (parallel within a batch, sequential across batches) → one consolidated end-of-hierarchy report flagging anything controversial.
  • Deferred to a later release: the fuller execution-strategy-selection framework (plain subagents vs. the Workflow tool vs. other execution modes) — this release covers only subagent-per-child dispatch.
2026-07-14

Fixed

  • add-epic --link accepted a malformed value silently instead of erroring. It split the string on : and stored whatever came out with no validation — a typo like type:related:epic:... parsed successfully as {type:"type", epic:"related"} since nothing checked that "related" was a real epic id. parseLinkFlags() now requires at least two segments and that <epic> references a known, existing epic id, rejecting otherwise with a clear error (shared by add-epic and update-epic).
  • update-epic had no --link flag, so a malformed link (from before this validation existed, or from a hand-edit) had no CLI path to fix — forcing a direct state.json edit, which is what caused a reported em-dash JSON-escaping corruption across unrelated epics. update-epic <id> --link "<type>:<epic>[:<reason>]" now REPLACES the epic’s links wholesale (unlike the other flags, which patch a single field) — the intended fix path.
2026-07-14

Added

  • set-gate-guard <on|off> — optional, opt-in PreToolUse guard hook. Blocks Edit/Write/NotebookEdit while the active epic still owes a reconcile after a detour POP (reconcileNeeded). Off by default and dormant until /pm:init. This is the one place pm’s law tolerates mechanical blocking over pure instruction — it protects the single highest-stakes skip (writing source before the reconcile gate runs) as a deliberate, reversible opt-in, never a silent default.

Fixed

  • POP protocol never actually told you to SET reconcileNeeded. The conductor skill documented clearing it after reconciliation, but never setting it true on the paused epic before its detour-stack frame is popped — without that, the flag (and the new gate guard) would never actually trigger. Documented as a hand-edited step, mirroring how the frame itself is already hand-edited.
  • Doc drift in the conductor skill: the Commands line and state.json reference were missing set-autonomy, set-review-mode, autonomy, reviewMode, and gateGuard — none had been added when those features shipped in 0.8.0/0.9.0.
2026-07-14

Fixed

  • Regression from 0.8.4: reconcileNeeded was cleared on an active epic with no live detour frame, defeating the post-pop reconcile gate. POP protocol removes the detour- stack frame BEFORE reconciliation runs, so deriving the flag purely from live-frame presence wiped it out at exactly the moment it needed to stay true (just-resumed, reconcile not yet done). reconcileArchived() now only recomputes what’s safely derivable from current state: an archived epic always clears it (reconcile is moot); a still-paused epic with a live reconcileOnResume frame gets it forced true; anything else stale heals to false only if it’s NOT the current active epic, since that’s exactly the legitimate post-pop-pre-reconcile window.
2026-07-14

Added

  • set-review-mode <off|standard|thorough> — a bounded, repo-level review-count dial. Incorporates Comet’s review_mode concept: a single setting (not per-epic) replacing an ad-hoc “how many reviews, when” judgment call with an explicit, dedup’d table. off = self- review only; standard (default when unset) = one fresh-context reviewer per gate; thorough = two independent reviewers per gate with disagreement adjudicated by you. Writes state.reviewMode and refreshes the CLAUDE.md rules block’s new unconditional ”## Review mode” section, which always shows the currently active mode. Pure instruction-layer — no external calls.
2026-07-14

Fixed

  • Recompute-don’t-remember: .active validity and reconcileNeeded are re-derived from disk, not trusted as stored flags. reconcileArchived() previously only cleared .active when it pointed at an archived epic — a pointer referencing an epic id missing entirely from state.epics was never healed. reconcileNeeded was pure remembered state (set/cleared only by hand-editing per the PUSH/POP protocol), with no recovery if a session lost context mid-detour. Both are now recomputed from ground truth (the epics array, the detour stack’s reconcileOnResume frames) every time render() runs — including at the end of /pm:resume — healing stale flags in either direction. brief() stays deliberately read-only, displaying the same recomputed truth in-memory without persisting.
2026-07-14

Fixed

  • state.json writes are now atomic (tmp+rename). saveState() previously wrote directly via writeFileSync; a crash or kill mid-write could leave a truncated, unparseable state.json with no recovery path. Now writes to a .tmp-<pid>-<ts> file in the same directory and rename(2)s over the real path — atomic on the same filesystem, so a crash leaves a truncated tmp file instead of corrupting the system of record.
2026-07-14

Fixed

  • KNOWN_STATUSES omitted later/blocked despite both being documented in the README’s Epic statuses table and commands/init.md — add-epic/update-epic --status later (or blocked) was rejected outright. Both statuses now validate and persist correctly; NEXT UP already excluded them (only queued/untriaged are included) with no other code change needed, and they correctly still count in the lanes rollup (only planned is excluded from both NEXT UP and the rollup, per the documented distinction).
2026-07-14

Fixed

  • update-epic silently no-op’d on an unrecognized flag. A typo’d or unwired flag would parse, run saveState/render, and print conductor: updated '<id>' even though nothing changed — the only way to catch it was cross-checking git diff. update-epic now validates its flags against a known set and exits non-zero with an “unknown flag” error instead of a false success.
  • update-epic had no --title flag. add-epic supports --title at creation, but correcting a title after an investigation changes what an epic is actually about (a common, legitimate mid-epic event) had no CLI path and required hand-editing state.json, which the tool explicitly discourages. update-epic <id> --title "..." now works.
2026-07-13

Added

  • set-autonomy <id> — per-epic autonomy contract. An epic can be granted broad execution trust (autonomy.level: "autonomous", default "off" — unchanged behavior) so it runs through phase transitions without stopping for permission each time. Autonomy is granted only after a preflight risk-scan (documented in the conductor skill) records the user’s pre-authorized actions and supplied context via --preauthorize/--context (repeatable, additive). A five-criteria execution-time decision rule (injected into the CLAUDE.md rules block) still hard-stops for anything with no backup/restore path or no context to act on — autonomy never overrides a genuine safety gate, only removes false ones. PROJECT.md and the session brief mark an autonomous epic with 🤖. Tracker-linked epics (Jira etc.) get an addendum covering lane-aware source reading, non-authoritative comment-mirroring of approvals, and mid-run drift as its own stop condition.
  • Development-time scope only — this does not cover actions with irreversible EXTERNAL side effects (sending email/Slack, deploying to production, third-party API calls, pushing to a shared branch); those remain out of scope regardless of autonomy level.
2026-07-08

Added

  • set-active <id> / clear-active — a CLI verb for the top-level active epic (closes #1). Previously .active — the pointer the briefing’s “NOW” line reads — had no CLI setter, so /pm:next’s “make it active” forced hand-editing state.json, against the “CLI is the safe interface” model. set-active <id> (positional id) sets the pointer; clear-active drops it.

Fixed

  • .active and status: "active" can no longer silently disagree. They were independent fields — update-epic --status active flipped the status but left .active null, so the brief reported “no active epic” despite an active epic. Now a single-active invariant is enforced through every CLI path: set-active, update-epic --status active, and add-epic --status active all set .active and the epic’s status together and demote any previously-active epic to queued; moving the active epic off active (or clear-active) clears the pointer. set-active rejects an unknown or archived id.

Changed

  • Skills/commands resolve the engine version-independently. The conductor skill and /pm:next now prefer $CLAUDE_PLUGIN_ROOT and fall back to the newest installed conductor.mjs (ls -t …/pm/*/… | head -1) instead of embedding a versioned cache path like …/pm/0.6.1/…, which broke on upgrade. set-active/clear-active are documented in /pm:next, /pm:epic, the skill, and the README.

Upgrade

Minor release — no schema change, no data migration. Update the plugin → /reload-plugins → /pm:upgrade.
2026-06-26

Fixed

  • Archived OpenSpec epics stayed stuck as the active epic. isArchived() only matched an archive dir named exactly <id>, but OpenSpec archives a change as openspec/changes/archive/<YYYY-MM-DD>-<id>. So the engine never detected the archive: the epic kept its active status, state.active kept pointing at it, /pm:status showed a finished epic as NOW, /pm:next wouldn’t advance, and the epic could even be mis-flagged ”⚠ no change on disk.” Fixed three ways:
    • isArchived() now matches both the exact id and OpenSpec’s date-prefixed dir.
    • Display honesty: render/brief no longer present an archived epic as the active one — they show “(no active epic — X was archived)”, so /pm:status and /pm:next are correct immediately, with no state mutation.
    • Self-heal: a new reconcileArchived() clears an active pointer aimed at an archived epic and stamps status: archived. It runs in sync, commit-nudge (so the state heals on the same commit that archives the change), init, and upgrade — no more hand-editing state.json after an archive.

Upgrade

Patch release — no schema change, no data migration. Update the plugin → /reload-plugins → /pm:upgrade.
2026-06-25

Added

  • Knowledge surfacing — the plugin now teaches the agent at the two moments that matter. Previously an upgrade exposed new commands but never explained what it brought, and a first-time install gave the agent no orientation beyond command descriptions. Closed both:
    • /pm:upgrade prints a changelog delta. After applying migrations, the engine reads its own CHANGELOG.md and prints every entry in (stamped, running] — so the agent and user see exactly what the version added, not just that it happened.
    • New changelog subcommand + /pm:changelog [--since <x.y.z>]. On-demand changelog delta; defaults its floor to the version stamped in this repo’s state.json. Zero-dependency markdown parsing (sections split on ## [x.y.z] headers); graceful when no CHANGELOG ships.
    • /pm:init orients the agent first. Init now instructs the agent to load the conductor skill (the agent-facing how-to) — and points at the shipped README.md for deeper reference — so even a cold install of a much-later version knows how to drive the plugin. Deep orientation stays a one-time/on-demand load; the persistent CLAUDE.md rules block remains the recurring anchor (no full-orientation injection every session).

Upgrade

Minor release — no schema change, no data migration. Update the plugin → /reload-plugins → /pm:upgrade; the upgrade will now print what this version (and any you skipped) brought.
2026-06-25

Fixed

  • Multi-version upgrade ordering (hardening). upgrade() already replayed every migration newer than the stamped version, so a repo several versions behind (e.g. 0.2.0 → 0.5.x) was upgraded correctly. This release makes that guarantee robust: migrations are now applied sorted by release (independent of array authoring order), the MIGRATIONS array is documented as append-only / never-reorder, and a regression test asserts a two-versions-behind repo replays both the 0.3.0 (lane) and 0.5.0 (link-normalize) migrations in order.
  • Tracker detection no longer over-triggers on Git hosting. The /pm:tracker, /pm:init, and /pm:upgrade detection guidance previously let the agent infer a tracker from the fact that a repo is hosted on GitHub. Hosting on any Git service (GitHub, GitLab, Bitbucket, …) is not a signal — they all have issues/PRs, but a remote is not evidence that work is managed there. Detection now requires a real signal (an in-use tracker MCP, issue-key conventions, or an explicit statement), frames tracker mirroring as an optional choice, and reassures that declining loses nothing — the conductor always tracks everything locally in .conductor/state.json + PROJECT.md; a tracker only adds an external mirror. Choosing a Git host as the tracker (issues + PRs) remains fully valid.

Upgrade

Patch release — no schema change, no data migration. Update the plugin → /reload-plugins → /pm:upgrade to stamp 0.5.1 and refresh the rules/command docs.
2026-06-25

Added

  • First-class epic hierarchy. Epics gain an optional parent field (single-parent tree, arbitrary depth). add-epic --parent <id> validates the reference (must exist, no self-parent, no cycle) via a shared parentError() ancestor-walk helper. PROJECT.md renders children indented beneath their parent (└─, deepened per level), groups families ordered by parent priority, and shows an X/Y children archived rollup in the parent’s Progress cell. The briefing’s NEXT UP annotates a child with its parent id. Grouping is render-only — the resolveEpics priority sort is untouched, so a P0 child of a P2 parent keeps its NEXT UP slot.
  • External-tracker awareness (instruction layer only). An optional tracker block in state.json (system, instance, projectKey, mechanism, and a semantic statusIntent map) makes the conductor aware a project mirrors epics to Jira/GitHub/Linear. The engine never calls the tracker — it only shapes the instructions it already emits:
    • the CLAUDE.md rules block gains an “External tracker sync” section assigning the interactive agent ownership (create issue + record key; transition on status change toward the semantic statusIntent; parent epic → tracker epic);
    • the briefing gains a TRACKER SYNC block listing only honestly-computable drift — active-work epics (queued/active/paused, excluding missing() ghosts) with no externalId. No transition-drift is fabricated (the engine cannot see tracker state).
    • New set-tracker subcommand (repeatable --intent <status>:<target>; parseFlags now accumulates intent like link) writes the block and refreshes the rules.
    • New per-epic externalId/externalUrl fields (on add-epic and update-epic).
    • New update-epic <id> write-back subcommand (positional id) mutates externalId/externalUrl/parent/status/priority on an existing epic under the same validation as creation — closing the sync loop after the agent creates an issue.
    • New /pm:tracker command doc; /pm:init and /pm:upgrade gain an agent-driven detection step (detect signals → confirm with the user → set-tracker; upgrade only when unset).
  • Atomic bulk creation. New add-many --from <path|-> reads a JSON { parent?, epics[] } batch. If parent is present it is created first and children default their parent to it. Every entry is validated up front (id format, uniqueness vs existing AND within the batch, lane, status, parent refs + intra-batch cycles); on any failure nothing is written and it exits non-zero. A valid batch persists in a single write — removing the race that forced chaining individual add-epic calls. JSON only (the engine stays zero-dependency).

Fixed

  • Stale-link rendering. render() and the briefing now emit a link only when both its type and epic are strings (shared validLink() helper), so malformed or older-schema link entries no longer render as undefined undefined.

Migration

  • 0.5.0 migration (repair-first). MIGRATIONS gains a 0.5.0 entry that normalizes stored links: valid {type, epic} objects pass through, the documented colon-string encoding type:epic[:reason] is repaired into an object, and unrecoverable entries are dropped. Additive and idempotent. Defensive rendering (above) is the shape-agnostic durable fix.

Compatibility

All additions are optional and backward-compatible: a state.json written by v0.4.1 loads unchanged, and a 0.5.0-written state remains loadable by the older engine (it ignores the new optional fields).

Upgrade

Existing repos: update the plugin → /reload-plugins (or restart) → /pm:upgrade per repo. The upgrade runs the additive, idempotent 0.5.0 migration, refreshes the rules, and stamps pmVersion: 0.5.0. To make a repo tracker-aware, run /pm:tracker (or answer the detection prompt during /pm:upgrade).
2026-06-22

Added

  • /pm:upgrade staleness guard. /pm:upgrade now checks whether the running engine version matches the newest installed version before proceeding. If they differ (i.e. the plugin was updated but Claude Code has not been reloaded), it refuses with a clear message — “this is pm <old> but <new> is installed; run /reload-plugins or restart Claude Code first” — instead of silently re-stamping an old version. From 0.4.1 forward every upgrade is self-guarding.
  • SessionStart nudge fires from newest installed version. The upgrade nudge in the SessionStart briefing now keys on the newest installed version (from the plugin’s plugin.json) rather than the running engine version. This means the nudge fires even before you reload Claude Code, and it names the full sequence: (1) reload/restart; (2) /pm:upgrade per repo.
  • Documented update sequence. upgrade.md and README both document the required three-step sequence: update the plugin → /reload-plugins or restart → /pm:upgrade per project. The upgrade command note now explains why the reload step is mandatory (Claude Code loads the engine at session start).

Limitation

The staleness guard ships inside 0.4.1, so the first upgrade into 0.4.1 still runs the old 0.4.0 engine until you /reload-plugins. From 0.4.1 forward every upgrade is self-guarding.

Upgrade

Existing repos: run /pm:upgrade after updating — refreshes rules, stamps 0.4.1 into state.json. Idempotent; safe to run multiple times. No data migration required. Remember to /reload-plugins first (see above).
2026-06-18

Added

  • status: planned — roadmap as ordered backlog. A new epic status for items that are known, sequenced, but not yet ready to start. planned: N appears as a brief summary line in the briefing; planned epics are excluded from NEXT UP and the lanes rollup, but are shown in the PROJECT.md epics table so the full backlog is visible.
  • sync auto-transitions proposed planned epics → untriaged (openspec lane). When sync/init discovers a new OpenSpec change on disk and an epic with the same id already exists with status: planned, it transitions that epic to untriaged automatically so it enters the normal triage flow without manual state editing.
  • PROJECT.md stamp-on-content-change only. render now compares the new output to the current file before writing; if the content is identical, the file is not touched. Prevents mtime churn and spurious git diffs when nothing meaningful changed.
  • add-epic validates --status against known statuses. Passing an unknown status to /pm:epic add is now an error rather than silently stored. A valueless-flag guard also catches --status with no argument (e.g. --status --lane) and reports a clear error instead of treating the next flag as the status value.
  • Portable ls -t glob in command docs. The find-based file listing in sync command documentation is replaced with a portable ls -t glob, removing a macOS/GNU find incompatibility.
  • --status documented in /pm:epic. The add sub-command now shows all valid status values (including planned) in its help text and the commands table.
  • Roadmap on-ramp guidance. README and SKILL document how to import an existing roadmap into the conductor without parsing: in an interactive session, read the roadmap doc and register each item via /pm:epic add … --status planned, choosing the appropriate execution lane. The conductor does not parse roadmap files automatically.

Changed

  • Rules block wording updated: documents planned status (roadmap on-ramp), auto-transition of planned epics on sync, and stamp-on-content-change behaviour.

Upgrade

Existing repos: run /pm:upgrade after updating — refreshes rules, stamps 0.4.0 into state.json. Idempotent; safe to run multiple times. No data migration required.
2026-06-18

Added

  • Lane-agnostic epics. Epics are no longer restricted to OpenSpec proposals. Every epic now carries a lane tag — openspec | superpowers | claude-code | decision | external — so the conductor tracks the full backlog regardless of how work is executed.
  • Epic schema fields.
    • lane (string, optional, backward-compatible): execution lane. Defaults to "openspec" on read so existing state.json files are unaffected.
    • planPath (string, optional): repo-relative path to a Superpowers/markdown plan file. Used as a progress source when stories[] is absent.
    • stories (array, optional): inline { title, done } story list. Highest-priority progress source.
  • Progress precedence resolver. epicProgress(epic) replaces storyProgress(id) and resolves progress in order: stories[] → planPath checkboxes → openspec/changes/<id>/tasks.md → —. A dangling planPath renders ⚠ planPath missing rather than silent 0/0.
  • Non-OpenSpec epics in the briefing. Non-OpenSpec epics now appear in NEXT UP and the Epics table. Only OpenSpec epics missing their on-disk change are flagged ⚠ no change on disk; other lanes are shown as-is.
  • Bounded briefing. NEXT UP is capped at top-5 by priority-then-lane, with a per-lane count summary (lanes: openspec 4 · superpowers 12 · claude-code 9) and a (+N more — see PROJECT.md) overflow line, so the briefing stays compact regardless of backlog size.
  • /pm:epic add. Registers a new epic directly (no state.json edit required):
    Validates id format (^[a-z0-9][a-z0-9._-]*$), lane, and uniqueness. Optional flags: --plan PATH, --status STATUS, --link "type:id:reason".
  • sync imports Superpowers plans. docs/superpowers/plans/*.md are scanned on sync/init and registered as lane-superpowers epics (id = filename without .md, planPath set, title from first # heading). Additive and id-collision-safe (colliding ids are skipped with a warning). The plans directory may be absent — the scan returns [] gracefully.
  • Version-aware upgrade subsystem.
    • init and upgrade stamp pmVersion (the running release) into state.json.
    • brief() compares the stamped version to the running release; if older, prepends a one-line upgrade nudge (re-shown every SessionStart and PreCompact until resolved).
    • /pm:upgrade runs registered migrations (those whose release is newer than the stamped version), then unconditionally refreshes the CLAUDE.md rules block, re-renders PROJECT.md, and re-stamps pmVersion. Idempotent — a second run is a no-op.
    • 0.3.0 migration: stamps an explicit lane: "openspec" on any epic lacking one, making state.json self-describing.
  • Lane-agnostic detour rules. A substantial detour becomes its own epic in the appropriate lane (not necessarily an OpenSpec proposal). The rulesBlock() wording and PUSH/POP templates are updated accordingly.

Changed

  • Epics table header changed from Epic (OpenSpec change) to Epic; a Lane column is added. Epics are sorted by priority rank then lane rank in both PROJECT.md and the brief.
  • NOW line includes the lane tag.
  • rulesBlock(): “epics = proposals” replaced with “epics are lane-agnostic; OpenSpec is one lane (openspec | superpowers | claude-code | decision | external).”

Upgrade

Existing repos: after updating the plugin, run /pm:upgrade once. It will:
  1. Refresh the CLAUDE.md rules block with lane-agnostic wording.
  2. Stamp explicit lane: "openspec" on all pre-0.3.0 epics.
  3. Record pmVersion: "0.3.0" in state.json so the upgrade nudge stops appearing.
The command is idempotent — running it more than once is safe and produces no changes on the second run. No data is lost; the migration is purely additive.
2026-06-01
Initial public release. Tracks OpenSpec proposals as epics, maintains an explicit detour stack, and enforces a reconcile gate so nothing is lost when development pivots or context is compacted.