diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 679997c..c509b6e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pm", - "version": "0.23.1", + "version": "0.24.0", "description": "A lightweight project-management layer that sits above OpenSpec and Superpowers. Tracks active proposals as epics, maintains an explicit detour stack, and enforces a reconcile gate so nothing is lost when development pivots or context is compacted.", "author": { "name": "Rob Sherman", diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md index 232e60c..2b0a212 100644 --- a/.claude/skills/pr-workflow/SKILL.md +++ b/.claude/skills/pr-workflow/SKILL.md @@ -19,7 +19,7 @@ existed and had to be untangled after the fact. If you're on `main` with uncommitted work, `git checkout dev` first — working-tree changes carry over cleanly since `dev` and `main` share history between releases. -2. **Tests green before committing.** `node --test scripts/conductor.test.mjs` — the +2. **Tests green before committing.** `node --test scripts/test/*.test.mjs` — the `.githooks/pre-commit` hook re-runs this on every commit and blocks on failure, but don't rely on the hook alone catching a break you already know about. @@ -53,7 +53,7 @@ existed and had to be untangled after the fact. (confirms the squash commit itself is sound, not just the pre-merge state): ```bash git checkout main && git fetch origin && git reset --hard origin/main - node --test scripts/conductor.test.mjs + node --test scripts/test/*.test.mjs git checkout dev && git reset --hard main && git push origin dev --force-with-lease ``` `git reset --hard main` (not a plain `git merge`/`--ff-only`) is required here — after a diff --git a/.claude/skills/release-checklist/SKILL.md b/.claude/skills/release-checklist/SKILL.md index 8c574b0..ecd527b 100644 --- a/.claude/skills/release-checklist/SKILL.md +++ b/.claude/skills/release-checklist/SKILL.md @@ -22,7 +22,7 @@ skip straight to the branch dance at the bottom. ## The checklist -1. **Engine + tests.** `node --test scripts/conductor.test.mjs` green, including any new tests +1. **Engine + tests.** `node --test scripts/test/*.test.mjs` green, including any new tests for the change. No `--no-verify`, ever — the pre-commit hook already enforces this, but re-run explicitly before touching version/changelog files so a failure is caught here, not mid-release. @@ -52,7 +52,7 @@ skip straight to the branch dance at the bottom. every release by recomputing, never estimating: ```bash grep -c '^## \[' CHANGELOG.md # releases shipped - node --test scripts/conductor.test.mjs 2>&1 | grep '^ℹ tests' # tests in the engine + node --test scripts/test/*.test.mjs 2>&1 | grep '^ℹ tests' # tests in the engine wc -l scripts/conductor.mjs # engine LOC # external dependencies is always 0 — enforced by the zero-dependency hard constraint ``` diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index a04eba1..8d438f8 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-28T19:33:54.281Z", - "stateMtimeMs": 1785267234261.489 + "renderedAt": "2026-07-30T15:40:51.660Z", + "stateMtimeMs": 1785426051639.6658 } diff --git a/.conductor/state.json b/.conductor/state.json index a51f03b..c151ad0 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1136,12 +1136,14 @@ "id": "rules-block-hardcodes-claude-slash-commands", "title": "rulesBlock() hardcodes Claude Code slash-command syntax (/pm:status, /pm:detour --minimal, /pm:resume) throughout the emitted CLAUDE.md rules text, plus the 'Manage with /pm:status · /pm:next · ...' footer. On any platform without slash commands, that text is actively misleading -- it instructs the agent to run commands that don't exist there. Needs a command-style notion so the block can emit the direct CLI form (node scripts/conductor.mjs render) instead. rulesBlock() already takes (tracker, reviewMode, secondaryTrackers) so an added parameter fits the existing signature. Prerequisite for any multi-platform support; found during the multi-platform-agent-support brainstorm.", "priority": "P1", - "status": "queued", + "status": "archived", "role": "epic", "lane": "claude-code", "links": [], "reconcileNeeded": false, - "parent": "multi-platform-agent-support" + "parent": "multi-platform-agent-support", + "startedAt": "2026-07-29T02:28:12.027Z", + "completedAt": "2026-07-30T05:54:27.332Z" }, { "id": "platform-parity-mechanism", @@ -1222,12 +1224,19 @@ "lane": "claude-code", "links": [ { - "type": "blocks", - "epic": "hermes-platform-support", - "reason": "observe.py would report rules_block_present false for any AGENTS.md platform -- a confident, wrong parity failure on the first Hermes run" + "type": "depends-on", + "epic": "rules-block-hardcodes-claude-slash-commands", + "reason": "observe.py correctly observes what the engine WRITES; the engine hardcodes CLAUDE.md (constants.mjs:14) and writes only there, so parameterizing observe first would abstract over a filename nothing produces" } ], - "reconcileNeeded": false + "reconcileNeeded": false, + "startedAt": "2026-07-29T02:27:31.264Z", + "stories": [ + { + "title": "fold into the rules-block epic: once the engine writes a per-platform rules-block target, update observe() to match and prove both with one eval", + "done": false + } + ] }, { "id": "edd-user-memory-confound-control-run", @@ -1292,12 +1301,290 @@ "lane": "claude-code", "links": [], "reconcileNeeded": false + }, + { + "id": "init-detects-foreign-agent-instruction-files", + "title": "pm init should detect a pre-existing AGENTS.md / sol.md from a prior agent and read it before initializing", + "priority": "P3", + "status": "planned", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "if CLAUDE.md is absent but AGENTS.md (or sol.md) exists, read its contents -- it may dictate what belongs in CLAUDE.md, or at minimum hint how to initialize correctly in a project another agent already used", + "done": false + } + ] + }, + { + "id": "rules-block-cli-fallback", + "title": "rules block: CLI fallback for any platform lacking slash commands or hitting a namespace collision", + "priority": "P3", + "status": "planned", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "deferred deliberately: a CLI fallback changes WHO the instruction addresses (slash command = the user, node scripts/conductor.mjs = the agent), which is a design fork, not just longer text", + "done": false + } + ] + }, + { + "id": "gh-64-sync-duplicate-shipped-plan", + "title": "sync registers a duplicate untriaged epic for a plan whose feature already shipped", + "priority": "P2", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "externalId": "64", + "externalUrl": "https://github.com/cfdude/pm/issues/64", + "stories": [ + { + "title": "CONFIRMED remove-epic is NOT durable: identical 3 phantoms returned on the next sync ~1h later. Another session saw the same across a 2-day gap in agent-dm.", + "done": false + }, + { + "title": "WORKAROUND THAT STICKS (no code change): set the phantom to --status archived instead of remove-epic. The id still exists so sync's own 'id already exists' guard fires. Verified: repeated syncs add 0.", + "done": false + }, + { + "title": "prefer the tombstone/syncIgnore fix over plan-field matching for the near term: matching is blocked by #66 (no update-epic --plan) while the tombstone has no dependency; archived already IS a working tombstone", + "done": false + } + ] + }, + { + "id": "gh-65-autodetour-false-positives", + "title": "auto-detour writes false-positive entries: no success check + no repo-scope check", + "priority": "P1", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "externalId": "65", + "externalUrl": "https://github.com/cfdude/pm/issues/65", + "stories": [ + { + "title": "bug 1: guard the write path on HEAD having advanced -- covers BOTH a pre-commit-rejected commit (#65) and a backgrounded commit not yet landed (#68). An exit-code check would NOT cover the latter.", + "done": true + }, + { + "title": "bug 2: repo-scope check -- only fire when the commit's repo root matches the pm-managed repo (paired repo / submodule / git -C elsewhere)", + "done": false + }, + { + "title": "closes #68 as duplicate; reporter hit it across job-search-agent + a paired repo, so it is not machine-specific", + "done": true + }, + { + "title": "bug 2 is functionally covered by the SAME guard (a commit landing elsewhere leaves our HEAD subject unmatched) -- but NO explicit repo-scope check was added; decide whether belt-and-braces is wanted", + "done": false + } + ], + "startedAt": "2026-07-30T02:48:10.981Z" + }, + { + "id": "gh-66-update-epic-missing-flags", + "title": "update-epic: no --lane, no --plan, undocumented valueless --link for clearing", + "priority": "P3", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [ + { + "type": "blocks", + "epic": "gh-69-sync-no-done-signal-for-plans", + "reason": "sync's fix wants to read each epic's plan field, but update-epic has no --plan so that field cannot be populated for existing epics" + } + ], + "reconcileNeeded": false, + "externalId": "66", + "externalUrl": "https://github.com/cfdude/pm/issues/66" + }, + { + "id": "gh-69-sync-no-done-signal-for-plans", + "title": "sync re-registers COMPLETED Superpowers plans as untriaged epics", + "priority": "P2", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [ + { + "type": "relates-to", + "epic": "gh-64-sync-duplicate-shipped-plan", + "reason": "same root cause -- dedup keys on the plan's filename-derived id instead of a plan<->epic association, so any epic whose plan is named differently is re-registered forever" + } + ], + "reconcileNeeded": false, + "externalId": "69", + "externalUrl": "https://github.com/cfdude/pm/issues/69" + }, + { + "id": "gh-70-link-validation-gap", + "title": "add-epic --link silently accepts malformed format; no update-epic setter to fix it", + "priority": "P2", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "externalId": "70", + "externalUrl": "https://github.com/cfdude/pm/issues/70" + }, + { + "id": "test-suite-perf-and-scaffolding", + "title": "Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding", + "priority": "P1", + "status": "archived", + "role": "epic", + "lane": "superpowers", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "MEASURED: 73ms per engine spawn x 676 run() call sites = ~49s of pure node startup, in ONE file, on 16 cores. node parallelizes across FILES only (proven: 4 files x 3s = 3.3s wall).", + "done": false + }, + { + "title": "split conductor.test.mjs (3202 lines, 255 tests) into ~8-10 files along the 29 existing section markers; the 8 exported helpers already exist and nothing imports them -- the file was built to be split", + "done": true + }, + { + "title": "do NOT convert to in-process imports to skip node startup: faster, but stops testing the real CLI contract, which is the point of these tests", + "done": true + }, + { + "title": "SILENT-PASS HOLE: detourLog() returns '' for a missing file, so assert.doesNotMatch(detourLog(cwd),...) passes even if commit-nudge never ran -- and it exits 0 on every early return, so run() will not throw. Assert the hook EMITTED its JSON first.", + "done": true + }, + { + "title": "keep the :996 self-heal test repo-less (it is the only coverage of the unverifiable branch and it caught the two-state guard bug); rename it to say so and ADD a git-repo twin for the verified path", + "done": true + }, + { + "title": "audit every doesNotMatch/empty-string assertion in the suite for the same vacuous-pass shape, not just the ones added for gh#65", + "done": true + }, + { + "title": "MEASURED RESULT: 118s serial -> ~46s parallel (2.6x) across 11 files. 18 files was WORSE (46s) than 11 -- runner startup overhead. Floor is the slowest file (21s); the 46-21 gap is tmpdir/spawn contention, not concurrency (--test-concurrency=16 changed nothing).", + "done": false + }, + { + "title": "GLOB LANDMINE: node --test scripts/test (a DIRECTORY) does not work on Node 26 -- it treats the arg as a module, dies MODULE_NOT_FOUND, reports '1 test'. Only the shell glob works. Documented in the hook so nobody 'simplifies' it.", + "done": false + }, + { + "title": "MUTATION-TESTED the guard tests: disabling the guard initially left 4 of 5 still PASSING, because gitRepo()'s baseline commit touches 4 files and looksLikeUnloggedMinimalDetour rejects >3 -- they were passing on the file-count rule, not the guard. Fixed; now 4/5 fail on mutation and the 5th is the positive control that should pass either way.", + "done": false + }, + { + "title": "AUDIT RESULT: projectMd/claudeMd THROW on a missing file so they cannot be vacuous. detourLog returns '' -- 4 pre-existing bare assertions in conductor-08 hardened via nudgeAndReadLog(). REMAINING (low): 3 doesNotMatch(parseBrief(...)) can be vacuous since parseBrief returns '' on empty brief output.", + "done": false + } + ], + "startedAt": "2026-07-30T04:28:32.303Z", + "completedAt": "2026-07-30T05:02:31.485Z" + }, + { + "id": "2026-07-21-conductor-mjs-module-split", + "title": "conductor.mjs Module Split Implementation Plan", + "priority": "P?", + "status": "archived", + "role": "epic", + "lane": "superpowers", + "planPath": "docs/superpowers/plans/2026-07-21-conductor-mjs-module-split.md", + "links": [], + "reconcileNeeded": false, + "completedAt": "2026-07-30T04:00:55.770Z" + }, + { + "id": "2026-07-26-edd-harness-agent-behavior-testing", + "title": "EDD Harness for Agent Behavior Testing — Implementation Plan", + "priority": "P?", + "status": "archived", + "role": "epic", + "lane": "superpowers", + "planPath": "docs/superpowers/plans/2026-07-26-edd-harness-agent-behavior-testing.md", + "links": [], + "reconcileNeeded": false, + "completedAt": "2026-07-30T04:00:55.969Z" + }, + { + "id": "2026-07-29-platform-aware-rules-block", + "title": "Platform-Aware Rules Block Implementation Plan", + "priority": "P?", + "status": "archived", + "role": "epic", + "lane": "superpowers", + "planPath": "docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md", + "links": [], + "reconcileNeeded": false, + "completedAt": "2026-07-30T04:00:56.179Z" + }, + { + "id": "platform-switch-orphans-old-rules-block", + "title": "a platform switch leaves the previous platform's rules block live but unmanaged", + "priority": "P2", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [ + { + "type": "blocks", + "epic": "hermes-platform-support", + "reason": "a switched repo would leave stale conductor instructions in the file the previous platform read" + } + ], + "reconcileNeeded": false, + "stories": [ + { + "title": "writeRules() writes only the newly-resolved target, so switching claude-code -> codex creates AGENTS.md and leaves a frozen CLAUDE.md block an agent may still read -- a STALE-INSTRUCTIONS hazard distinct from 'recreate the artifact tree'", + "done": false + }, + { + "title": "unreachable today (no Hermes/Codex hooks ship, needs a hand-passed --platform), but must be resolved before either port", + "done": false + } + ] + }, + { + "id": "autodetour-parser-misses-am-and-f", + "title": "commit-nudge's -m parser misses -am / -F / editor commits, so #65 is only closed for -m", + "priority": "P2", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "git commit -am \"fix: x\" parses to subject='' which short-circuits the guard; a REJECTED -am commit still writes a false DETOUR-COMMIT line while a detour is active -- the original #65 symptom", + "done": false + }, + { + "title": "b2e472f's message claims 'closes #65, #68' -- true for -m only. Correct the claim or close the gap BEFORE those issues are closed upstream.", + "done": false + }, + { + "title": "ALSO: an escaped quote inside a double-quoted -m truncates the capture (regex is \"([^\"]*)\"), so a genuinely landed commit is CONTRADICTED and the hook silently suppressed. Fix shape: backslash-aware capture \"((?:[^\"\\\\]|\\\\.)*)\", or treat a value ending in a backslash as unverifiable.", + "done": false + } + ] } ], "detourStack": [], - "pmVersion": "0.23.1", + "pmVersion": "0.24.0", "tracker": { "system": "github-issues", "repo": "cfdude/pm" - } + }, + "platform": "claude-code" } diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 52de5d9..997d4bc 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -20,12 +20,33 @@ cd "$(git rev-parse --show-toplevel)" tmpfile=$(mktemp) trap 'rm -f "$tmpfile"' EXIT -if node --test scripts/conductor.test.mjs >"$tmpfile" 2>&1; then +# The suite is a GLOB of scripts/test/*.test.mjs, not one file. Two notes on that: +# * `node --test scripts/test` does NOT work -- on Node 26 a directory argument is treated as +# a module to execute, so it dies with MODULE_NOT_FOUND and reports "1 test". It exits +# nonzero so it can't pass silently, but don't "simplify" the glob into a directory. +# * The glob introduces a NEW silent-loss risk the single-file suite never had: if a file +# stops matching (renamed, moved, a typo in the pattern), the suite still passes -- just +# with fewer tests. So below we cross-check the runner's reported count against the number +# of test() declarations actually present in the source. A drop fails the commit. +if node --test scripts/test/*.test.mjs >"$tmpfile" 2>&1; then # node --test's default reporter (and therefore this summary line's leading marker) differs # by Node version: newer Node uses the "spec" reporter (ℹ) even when output isn't a TTY, # older Node (e.g. the CI runner's Node 18) defaults to the "tap" reporter (#). Match both. total=$(grep -m1 -E '^(ℹ|#) tests ' "$tmpfile" | awk '{print $3}') passed=$(grep -m1 -E '^(ℹ|#) pass ' "$tmpfile" | awk '{print $3}') + + # Self-maintaining floor: count top-level test( declarations in the files themselves. No + # constant to bump as tests are added -- it only fires when the runner ran FEWER than exist. + # -H forces the filename: prefix even when the glob matches ONE file. Plain `grep -c` omits + # it for a single file, so awk -F: summed field 2 of a bare number and got 0 -- the guard + # went blind in exactly the situation it exists to catch (a glob collapsed to one file). + declared=$(grep -Hc '^test(' scripts/test/*.test.mjs | awk -F: '{s+=$2} END {print s}') + if [ -n "$total" ] && [ -n "$declared" ] && [ "$total" -lt "$declared" ]; then + echo "pre-commit: ABORT -- ran ${total} tests but ${declared} are declared in scripts/test/." >&2 + echo "pre-commit: a test file is not being picked up by the glob; the suite is passing on a subset." >&2 + exit 1 + fi + if [ -n "$total" ] && [ -n "$passed" ]; then echo "pre-commit: ${passed}/${total} passing" else diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d7688d..7a74698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Syntax check run: | node -c scripts/conductor.mjs - node -c scripts/conductor.test.mjs + for f in scripts/lib/*.mjs scripts/test/*.mjs; do node -c "$f"; done - name: Run test suite - run: node --test scripts/conductor.test.mjs + run: node --test scripts/test/*.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0138c38..a1378cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,85 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.24.0] — 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 ` in the hook command pm + authors for that platform, and the active platform is recorded in `.conductor/state.json`. +- Per-platform slash-command form: `/pm:status` on Claude Code and Hermes, `/pm-status` on Codex. + The namespace is retained wherever supported because Hermes silently skips a plugin command + that collides with a built-in, and it ships a built-in `status`. + +### Changed + +- **The test suite is now `scripts/test/*.test.mjs` (11 files) instead of one + `scripts/conductor.test.mjs`, cutting a full run from ~118s to ~46s.** 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". It exits non-zero + so it cannot pass silently, but use the glob. + + Rejected: converting to in-process testing to skip `node` startup entirely. It would be faster + still, but would stop exercising the real CLI contract — flag parsing, exit codes, stderr text, + the dispatch table — which is where the bugs this suite catches actually live. + +- **`.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 -c '^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` the block was silently invisible — no error, just an agent + running without the conductor's instructions. + +- **The auto-detour hook no longer writes a `detours.log` entry for a commit that did not land + in this repo** (closes [#65](https://github.com/cfdude/pm/issues/65) and + [#68](https://github.com/cfdude/pm/issues/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 (the documented + way to avoid an agent-harness tool timeout) and was still running; or the commit landed in a + *different* repo — a paired repo, a submodule, `git -C elsewhere` — leaving our HEAD untouched + while `gitShortSha()` and `headChangedFiles()`, which both read the pm repo, attributed it here. + + 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. + + All three reduce to one question: does HEAD hold the commit whose subject we just parsed? + Comparing SHAs would need a stored baseline; the subject is already in hand. Note an + **exit-code check would not have been sufficient** — a backgrounded commit has no exit code + yet — which is why the two independently-reported issues share one fix. + + The guard is deliberately three-state. Only *contradicted* (a subject was parsed, git works, + and HEAD disagrees) suppresses the entry. *Unverifiable* — no `-m` to parse, or git unusable + in this directory — keeps the previous behavior, because the archived-epic self-heal must still + run in a repo with no git at all. + + A local `detours.log` that confidently points at the wrong commit is worse than a missing + line, since anything reconstructing "what happened around commit X" gets a confidently wrong + answer. Note the log is untracked under a common global `*.log` ignore pattern, so the damage + was confined to a working copy rather than repo history. + +### 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. + ## [0.23.1] — 2026-07-23 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index da5391c..5b3f46d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ `node:path`, `node:os`, `node:child_process`, `node:url`). **Never** add an npm package or a `package.json` dependency. If a format needs parsing, prefer JSON (native) over pulling a parser. -- **Tests:** `node --test scripts/conductor.test.mjs`. All tests pass before any commit — no +- **Tests:** `node --test scripts/test/*.test.mjs`. All tests pass before any commit — no exceptions, no `--no-verify`. - **Architectural law — `pm` is an INSTRUCTION layer, never an INTEGRATION layer.** It emits instructions for the interactive Claude agent to act on (the managed `CLAUDE.md` rules block, @@ -44,7 +44,7 @@ the plugin ships to users. Follow it every time `plugin.json`'s version bumps; don't re-derive the checklist from memory. - Engine subcommands are dispatched at the bottom of `conductor.mjs`; every new subcommand needs - a matching command doc under `commands/` and coverage in `conductor.test.mjs`. + a matching command doc under `commands/` and coverage in `scripts/test/*.test.mjs`. - **State-transition flags are not pure functions of current state.** `reconcileNeeded` in particular is set at detour-POP time and must survive until reconciliation completes — POP protocol removes the detour-stack frame *before* reconciliation runs, so deriving the flag @@ -65,7 +65,7 @@ branch cleanup). A change that is genuinely internal (a test, an engine-internal process-only doc fix) does not need either — but say so explicitly rather than silently skipping the check. - + ## PM Conductor — operating rules This repo is managed by the `pm` plugin. The conductor sits ABOVE OpenSpec and Superpowers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e22b01..60d9c07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ - No direct pushes to `main` — all changes land via pull request. - Required status check: the `test` job in `.github/workflows/ci.yml` - (`node --test scripts/conductor.test.mjs` plus a syntax check). + (`node --test scripts/test/*.test.mjs` plus a syntax check). - 0 required approving reviews — this is a solo-maintainer repo, so PRs merge once CI is green, without waiting on a second reviewer. - Merge method is squash-only (`allow_squash_merge: true`, `allow_merge_commit: false`, @@ -35,7 +35,7 @@ git config core.hooksPath .githooks ``` After that, `git commit` runs `.githooks/pre-commit` automatically, which runs -`node --test scripts/conductor.test.mjs` and blocks the commit on any failure. +`node --test scripts/test/*.test.mjs` and blocks the commit on any failure. ## If main moves out from under your PR @@ -63,7 +63,7 @@ conflicts, resolve them the normal way (`git status` shows the conflicting files ## Running the EDD evaluation corpus (optional) -pm's engine is covered by `node --test scripts/conductor.test.mjs`. That suite cannot cover +pm's engine is covered by `node --test scripts/test/*.test.mjs`. That suite cannot cover pm's *agent-facing* artifacts — command docs, skills, the rules block, hooks — because their correctness is a non-deterministic judgment made by an agent, not an assertable return value. Those are covered by an evaluation corpus under `evals/`, built on diff --git a/PROJECT.md b/PROJECT.md index 861bfa0..e57ef82 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,7 +3,7 @@ > GENERATED by the `pm` plugin — do not hand-edit. Source of truth is > `.conductor/state.json` (ordering, detours, links) + OpenSpec `tasks.md` (stories). > Regenerate: `/pm:status` (or `node scripts/conductor.mjs render`). -> Last rendered: 2026-07-28T19:38:19.560Z +> Last rendered: 2026-07-30T15:40:51.653Z ## Now @@ -17,17 +17,20 @@ _Empty — no work is paused._ | Priority | Epic | Lane | Role | Status | Progress | Links | |----------|------|------|------|--------|----------|-------| +| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | archived | 5/10 stories | - | | P1 | `df-changelog-fragment-conflicts` | claude-code | epic | archived 🤖 | — | - | | P1 | `df-conflict-resolution-verification-step` | claude-code | epic | archived 🤖 | — | - | | P1 | `df-hierarchy-executor-must-check-readme-for-user-facing-changes` | claude-code | epic | archived | — | - | | P1 | `df-live-infra-epic-session-continuity-warning` | claude-code | epic | archived 🤖 | — | - | | P1 | `df-readme-stale-since-gate-guard` | claude-code | epic | archived | — | - | -| P1 | `edd-observe-hardcodes-claude-md` | claude-code | epic | queued | — | blocks→hermes-platform-support | +| P1 | `edd-observe-hardcodes-claude-md` | claude-code | epic | queued | 0/1 stories | depends-on→rules-block-hardcodes-claude-slash-commands | | P1 | `edd-user-memory-confound-control-run` | claude-code | epic | queued | 0/5 stories | blocks→hermes-platform-support | +| P1 | `gh-65-autodetour-false-positives` | claude-code | epic | queued | 2/4 stories | - | | P1 | `pm-fix-link-flag-validation-and-update` | claude-code | epic | archived | — | - | | P2 | `conductor-mjs-module-split` | openspec | epic | archived | — | - | | P2 | `multi-tracker-primary-secondary-support` | openspec | epic | archived | — | - | | P2 | `ai-feedback-loop-github-issues` | claude-code | epic | archived 🤖 | — | - | +| P2 | `autodetour-parser-misses-am-and-f` | claude-code | epic | queued | 0/3 stories | - | | P2 | `cfdude-pm-repo-governance` | claude-code | epic | archived | 3/3 children archived | - | | P2 | └─ `branch-protection-and-pr-workflow` | claude-code | epic | archived 🤖 | — | - | | P2 | └─ `ci-test-lint-workflow` | claude-code | epic | archived 🤖 | — | - | @@ -46,7 +49,11 @@ _Empty — no work is paused._ | P2 | `docs-site-cleanup` | claude-code | epic | archived | 3/9 stories | - | | P2 | `edd-adapter-discards-agent-stdout` | claude-code | epic | queued | — | - | | P2 | `edd-measures-installed-plugin-not-worktree` | claude-code | epic | queued | — | blocks→hermes-platform-support | +| P2 | `gh-64-sync-duplicate-shipped-plan` | claude-code | epic | queued | 0/3 stories | - | +| P2 | `gh-69-sync-no-done-signal-for-plans` | claude-code | epic | queued | — | relates-to→gh-64-sync-duplicate-shipped-plan | +| P2 | `gh-70-link-validation-gap` | claude-code | epic | queued | — | - | | P2 | `github-issue-tracker-sync` | claude-code | epic | archived 🤖 | — | relates-to→ai-feedback-loop-github-issues | +| P2 | `platform-switch-orphans-old-rules-block` | claude-code | epic | queued | 0/2 stories | blocks→hermes-platform-support | | P2 | `pm-plugin-improvements-2026-07-14` | claude-code | epic | archived | 19/19 children archived | - | | P0 | └─ `df-plan-hierarchy-includes-archived-children` | claude-code | epic | archived | — | - | | P0 | └─ `df-stale-cache-silent-fallback` | claude-code | epic | archived 🤖 | — | - | @@ -70,20 +77,25 @@ _Empty — no work is paused._ | P2 | `epic-hierarchy-orchestration` | decision | epic | archived | — | - | | P2 | `infra-runbook-preflight` | decision | epic | archived | — | - | | P3 | `df-contributing-doc-missing-direct-push-recovery` | claude-code | epic | archived | — | - | -| P3 | `multi-platform-agent-support` | claude-code | epic | queued | 1/5 children archived | - | +| P3 | `gh-66-update-epic-missing-flags` | claude-code | epic | queued | — | blocks→gh-69-sync-no-done-signal-for-plans | +| P3 | `init-detects-foreign-agent-instruction-files` | claude-code | epic | planned | 0/1 stories | - | +| P3 | `multi-platform-agent-support` | claude-code | epic | queued | 2/5 children archived | - | | P1 | └─ `platform-parity-mechanism` | openspec | epic | queued ⚠ no change on disk | — | - | -| P1 | └─ `rules-block-hardcodes-claude-slash-commands` | claude-code | epic | queued | — | - | +| P1 | └─ `rules-block-hardcodes-claude-slash-commands` | claude-code | epic | archived | — | - | | P1 | └─ `edd-harness-agent-behavior-testing` | decision | epic | archived | — | - | | P2 | └─ `hermes-platform-support` | openspec | epic | queued ⚠ no change on disk | — | depends-on→platform-parity-mechanism; depends-on→edd-harness-agent-behavior-testing; depends-on→rules-block-hardcodes-claude-slash-commands | | P3 | └─ `codex-platform-support` | openspec | epic | queued ⚠ no change on disk | — | depends-on→platform-parity-mechanism; depends-on→edd-harness-agent-behavior-testing; depends-on→rules-block-hardcodes-claude-slash-commands; depends-on→hermes-platform-support | +| P3 | `rules-block-cli-fallback` | claude-code | epic | planned | 0/1 stories | - | | P3 | `portfolio-architecture-consistency-scan` | decision | epic | archived | — | depends-on→epic-hierarchy-orchestration | | P? | `2026-07-14-epic-hierarchy-orchestration` | superpowers | epic | archived | 0/17 tasks | - | +| P? | `2026-07-21-conductor-mjs-module-split` | superpowers | epic | archived | 0/99 tasks | - | +| P? | `2026-07-26-edd-harness-agent-behavior-testing` | superpowers | epic | archived | 0/37 tasks | - | +| P? | `2026-07-29-platform-aware-rules-block` | superpowers | epic | archived | 0/34 tasks | - | ## Recent detours | When | SHA | Kind | Epic | Note | |------|-----|------|------|------| -| 2026-07-16T23:30:51.367Z | `b8bd6da` | MINIMAL | `-` | Added record-gate-review to README.md's Commands section — a real doc-currency gap the user caught, since my own dispatch instructions for that epic only required updating SKILL.md | | 2026-07-16T23:31:35.148Z | `3f57167` | AUTO-DETOUR | `-` | chore(pm): register df-hierarchy-executor-must-check-readme-for-user-facing-changes (P1) Elevated to P1 since it's a standing-instruction gap, not a one-off -- the same class of drift df-skill-commands-line-drift already fixed for SKILL.md, but README.md has no equivalent enforcement yet and just proved it can slip through a real release. | | 2026-07-16T23:34:02.176Z | `23151af` | AUTO-DETOUR | `-` | chore(pm): re-render PROJECT.md (stale after merge) | | 2026-07-17T07:06:31.412Z | `e4771e5` | AUTO-DETOUR | `-` | chore(pm): release 0.17.0 Ships the README.md drift test (mirrors SKILL.md's) and the standing requirement for hierarchy children to update README.md, not just SKILL.md, for user-facing changes. | @@ -91,6 +103,7 @@ _Empty — no work is paused._ | 2026-07-21T02:57:55.060Z | `255d258` | MINIMAL | `-` | Pinned security.yml's reusable workflow refs to a commit SHA instead of @main, per background security review finding | | 2026-07-21T05:38:54.894Z | `0b29db2` | MINIMAL | `-` | Documented fork inheritance of dogfooding state (.conductor/state.json) and tracker config in CONTRIBUTING.md, per user's fork-hygiene concern about the newly-added github-issues tracker | | 2026-07-24T00:56:47.456Z | `4df5e7a` | MINIMAL | `-` | Fixed /pm:upgrade's own command text to defend against the local-command-caveat misread -- self-inflicted by the command's own reload-then-upgrade recommendation, hit live this session when I incorrectly treated /pm:upgrade as passive output | +| 2026-07-29T05:03:16.107Z | `0b18af2` | AUTO-DETOUR | `rules-block-hardcodes-claude-slash-commands` | fix(evals): disable the honcho plugin in spawned agent sessions Every headless \`claude\` session the harness spawns inherited the user's environment, so the user-scope honcho memory plugin loaded and wrote machine-generated eval text into a personal memory workspace -- content with no value to a human reader. HONCHO_ENABLED=false is that plugin's hard off switch (config.ts sets enabled=false on exactly this string, and saveConfig explicitly preserves the on-disk value while the override is active, so it is never persisted). Passed explicitly rather than inherited. Set in one _child_env() helper rather than at the call site: runners.py's contract is that adding a platform means adding a function plus a RUNNERS entry, and the hermes/codex runners are queued -- this way they inherit it by construction. Nested processes inherit it normally. Two guards: one asserts PATH survives (passing env= replaces the environment wholesale, so a bare dict would break \`claude\`), and one iterates the whole RUNNERS registry so a runner added later cannot silently reintroduce the leak. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC | ## Briefing (what a fresh session sees) @@ -100,30 +113,35 @@ CONDUCTOR STATE — where we are and what's next NOW: (no active epic set) NEXT UP (by priority, then lane): - • `edd-observe-hardcodes-claude-md` (P1, claude-code, queued) — — + • `edd-observe-hardcodes-claude-md` (P1, claude-code, queued) — 0/1 stories • `edd-user-memory-confound-control-run` (P1, claude-code, queued) — 0/5 stories - • `rules-block-hardcodes-claude-slash-commands` (P1, claude-code, queued, parent: `multi-platform-agent-support`) — — + • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 2/4 stories + • `autodetour-parser-misses-am-and-f` (P2, claude-code, queued) — 0/3 stories • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — - • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — - (+1 more — see PROJECT.md) - lanes: openspec 2 · superpowers 1 · claude-code 48 · decision 7 + (+7 more — see PROJECT.md) + lanes: openspec 2 · superpowers 5 · claude-code 55 · decision 7 + +planned: 2 — see PROJECT.md EPIC LINKS: - • `edd-observe-hardcodes-claude-md` blocks `hermes-platform-support` — observe.py would report rules_block_present false for any AGENTS.md platform -- a confident, wrong parity failure on the first Hermes run + • `edd-observe-hardcodes-claude-md` depends-on `rules-block-hardcodes-claude-slash-commands` — observe.py correctly observes what the engine WRITES; the engine hardcodes CLAUDE.md (constants.mjs:14) and writes only there, so parameterizing observe first would abstract over a filename nothing produces • `edd-user-memory-confound-control-run` blocks `hermes-platform-support` — the Claude Code baseline cannot be trusted as a comparison target until a control run shows pm's own artifacts carry the behavior • `hermes-platform-support` depends-on `platform-parity-mechanism` — the structural parity gate and ledger must exist before the first second-platform port • `hermes-platform-support` depends-on `edd-harness-agent-behavior-testing` — semantic parity is unprovable without a blessed Claude Code baseline to compare a Hermes run against • `hermes-platform-support` depends-on `rules-block-hardcodes-claude-slash-commands` — the rules block must stop hardcoding Claude Code slash-command syntax before pm can claim Hermes support • `edd-measures-installed-plugin-not-worktree` blocks `hermes-platform-support` — editing an evaluated artifact must be measurable before the port, and the baseline must record which plugin version it describes + • `gh-69-sync-no-done-signal-for-plans` relates-to `gh-64-sync-duplicate-shipped-plan` — same root cause -- dedup keys on the plan's filename-derived id instead of a plan<->epic association, so any epic whose plan is named differently is re-registered forever • `github-issue-tracker-sync` relates-to `ai-feedback-loop-github-issues` — issues created by the feedback loop are exactly what this syncs back in + • `platform-switch-orphans-old-rules-block` blocks `hermes-platform-support` — a switched repo would leave stale conductor instructions in the file the previous platform read • `codex-platform-support` depends-on `platform-parity-mechanism` — the structural parity gate and ledger must exist before any second-platform port • `codex-platform-support` depends-on `edd-harness-agent-behavior-testing` — semantic parity needs a blessed Claude Code baseline to compare against • `codex-platform-support` depends-on `rules-block-hardcodes-claude-slash-commands` — the rules block must stop hardcoding Claude Code slash-command syntax • `codex-platform-support` depends-on `hermes-platform-support` — Hermes goes first; the second port should reuse whatever the first one teaches about per-platform artifact layout + • `gh-66-update-epic-missing-flags` blocks `gh-69-sync-no-done-signal-for-plans` — sync's fix wants to read each epic's plan field, but update-epic has no --plan so that field cannot be populated for existing epics • `portfolio-architecture-consistency-scan` depends-on `epic-hierarchy-orchestration` — C presumes B's hierarchy execution exists to propagate cross-cutting decisions into TRACKER SYNC (github-issues): - ⚠ not yet in github-issues — create issues + record keys (update-epic): `edd-observe-hardcodes-claude-md`, `edd-user-memory-confound-control-run`, `rules-block-hardcodes-claude-slash-commands`, `edd-adapter-discards-agent-stdout`, `edd-measures-installed-plugin-not-worktree`, `multi-platform-agent-support` + ⚠ not yet in github-issues — create issues + record keys (update-epic): `edd-observe-hardcodes-claude-md`, `edd-user-memory-confound-control-run`, `autodetour-parser-misses-am-and-f`, `edd-adapter-discards-agent-stdout`, `edd-measures-installed-plugin-not-worktree`, `platform-switch-orphans-old-rules-block`, `multi-platform-agent-support` 💡 1 tracker configured (github-issues) — consider `/pm:sync` this session to pull in any new issues. diff --git a/README.md b/README.md index 5e00652..6607b88 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ Not a benchmark — real numbers pulled straight from this repo's own history, v - Every multi-agent hierarchy dispatch runs **worktree-isolated**, unattended, converging back through sequential merge with **zero data loss** — every conflict seen so far has been mechanical (a shared CHANGELOG header, a usage string), never a real logic collision. -- **37 releases** shipped end-to-end (spec → build → test → changelog → version bump → release) +- **38 releases** shipped end-to-end (spec → build → test → changelog → version bump → release) with the plugin managing its own backlog the entire time. -- **250 tests**, **0 dependencies** — the entire engine is Node 18+ built-ins only, ~2,700 lines - across a 121-line `scripts/conductor.mjs` entry point and 25 `scripts/lib/*.mjs` modules, - nothing to `npm install`. +- **282 tests**, **0 dependencies** — the entire engine is Node 18+ built-ins only, ~2,900 lines + across a 130-line `scripts/conductor.mjs` entry point and 26 `scripts/lib/*.mjs` modules, + nothing to `npm install`. The suite itself is split across 11 files so it runs in parallel. - Caught its own bugs mid-flight, live: a stale-cache silent fallback, an archived-child leak in hierarchy planning, a false-positive auto-detour heuristic — each found by using the tool on itself, logged as a `DF-` finding, and fixed in the same session it was discovered. @@ -177,7 +177,8 @@ cd your-project ``` `/pm:init` scaffolds `.conductor/state.json`, registers any existing OpenSpec proposals and -Superpowers plans as epics, writes the managed rules block into your project's `CLAUDE.md`, +Superpowers plans as epics, writes the managed rules block into your project's `CLAUDE.md` +(or the equivalent file for a declared non-Claude-Code platform — see Supported Platforms), and renders `PROJECT.md`. From there: ```bash @@ -187,10 +188,25 @@ and renders `PROJECT.md`. From there: ## Supported Platforms +The host declares which platform it is via `--platform ` in the hook +command pm authors for that platform (unrecognized values are rejected, not silently defaulted). +The declared platform is recorded in `.conductor/state.json` by `/pm:init` and `write-rules` — +the commands that accept the flag and (re)write the block. The read-only hooks pass it through +without persisting, and `/pm:upgrade` does not read it either (its `0.24.0` migration stamps +`claude-code` on repos that predate the field, rather than adopting a declared value). The +recorded platform shapes two things: the command +form written into the managed rules block (`/pm:status` on Claude Code and Hermes, `/pm-status` +on Codex — Hermes keeps the `pm:` namespace because it silently skips a plugin command that +collides with one of its built-ins, and it ships a built-in `status`), and which file the block +is written to, first-match-wins over that platform's own project-context precedence chain +(`CLAUDE.md` for Claude Code; `HERMES.md` > `AGENTS.md` > `CLAUDE.md` for Hermes; `AGENTS.md` for +Codex, which cannot read `CLAUDE.md` at all). + | Platform | Status | Notes | |----------|--------|-------| -| Claude Code | ✅ Supported | The only platform PM runs on today — plugin commands, hooks, and skills all target it directly. | -| Codex | 🗺️ Planned | Tracked under `multi-platform-agent-support`. | +| Claude Code | ✅ Supported | The only platform PM actually *runs* on today — plugin commands, hooks, and skills all target it directly. | +| Hermes | 🗺️ Rules block only | pm renders a correctly-targeted, correctly-worded rules block (`--platform hermes`) but ships no Hermes commands/hooks yet. Tracked under `multi-platform-agent-support`. | +| Codex | 🗺️ Rules block only | Same — `--platform codex` writes `AGENTS.md` with the flat `/pm-status` command form, but no Codex prompt files ship yet. Tracked under `multi-platform-agent-support`. | | Gemini CLI | 🗺️ Planned | Tracked under `multi-platform-agent-support`. | | Grok Build (xAI) | 🗺️ Planned | Tracked under `multi-platform-agent-support`. | | `AGENTS.md`-based platforms (generic) | 🗺️ Planned | Most non-Claude-Code tools use `AGENTS.md` instead of `CLAUDE.md` for project instructions — supporting that format is the shared unlock for all of the above. | @@ -286,7 +302,8 @@ this is a non-blocking reminder only, never an automatic sync. /pm:init — Initialize the PM conductor in this repo Scaffolds `.conductor/state.json`, registers any existing OpenSpec proposals and Superpowers -plans as epics, writes the managed rules block into `CLAUDE.md`, and renders `PROJECT.md`. +plans as epics, writes the managed rules block into `CLAUDE.md` (or the file the declared +`--platform` actually reads — see Supported Platforms), and renders `PROJECT.md`. Safe to run once per repo; re-running is a no-op if already initialized. @@ -465,10 +482,11 @@ version.
/pm:upgrade — Upgrade this repo's conductor state/rules -Refreshes the `CLAUDE.md` rules block (`write-rules`), runs any pending migrations, -re-renders `PROJECT.md`, and stamps the new `pmVersion`. Idempotent — safe to run more than -once. Requires `/reload-plugins` first if you just updated the plugin (the SessionStart -briefing tells you when). +Refreshes the managed rules block (`write-rules`) in whichever file the recorded platform +reads, runs any pending migrations — including stamping `platform: "claude-code"` on a +pre-0.24.0 state file that predates the field — re-renders `PROJECT.md`, and stamps the new +`pmVersion`. Idempotent — safe to run more than once. Requires `/reload-plugins` first if you +just updated the plugin (the SessionStart briefing tells you when). After showing the changelog delta ("What's new in pm"), the agent reviews each `Added` headline and recommends adopting any opt-in capability that's relevant to this repo's current @@ -569,6 +587,9 @@ your-project/ │ ├── detours.log # append-only trail: timestamp · SHA · kind · epic · note │ └── honcho-memories.log # ready-to-copy Honcho memory lines, timestamped ├── CLAUDE.md # managed rules block (idempotent; delete to opt out) +│ # — AGENTS.md instead, on a platform that reads that file (see +│ # Supported Platforms below); pm targets whichever file the +│ # declared platform actually resolves first └── PROJECT.md # generated view — never hand-edited pm/ (this repo) diff --git a/agents/hierarchy-child-executor.md b/agents/hierarchy-child-executor.md index 09e5169..7aed062 100644 --- a/agents/hierarchy-child-executor.md +++ b/agents/hierarchy-child-executor.md @@ -59,7 +59,7 @@ too, in the same commit. This is not optional or a "nice to have": `record-gate- in 0.16.0 as a genuine new subcommand with zero README mention, because a prior dispatch's instructions only required updating `SKILL.md`. `SKILL.md` and `README.md` drift from the real dispatch table independently and are checked by two separate tests -(`scripts/conductor.test.mjs`) — passing one does not mean the other is current. If your change +(`scripts/test/*.test.mjs`) — passing one does not mean the other is current. If your change is purely internal (no user-facing surface — a test, an engine-internal refactor, a process-only doc fix), say so explicitly in DECISIONS rather than silently skipping the check. diff --git a/commands/upgrade.md b/commands/upgrade.md index 6c97f40..8db56b3 100644 --- a/commands/upgrade.md +++ b/commands/upgrade.md @@ -20,8 +20,13 @@ allowed-tools: Bash, Read Bring this repository in line with the currently-installed `pm` plugin version. Safe to run anytime; idempotent. Use it when the briefing shows a "pm " upgrade nudge. -1. Run the engine's upgrade (applies any pending migrations, refreshes the CLAUDE.md rules - block, re-renders PROJECT.md, and re-stamps the recorded version): +1. Run the engine's upgrade (applies any pending migrations, refreshes the managed rules block + in whichever file the recorded platform reads — `CLAUDE.md` for Claude Code, `AGENTS.md` for + Codex, first-match-wins over `HERMES.md` > `AGENTS.md` > `CLAUDE.md` for Hermes — re-renders + PROJECT.md, and re-stamps the recorded version). If this repo's `.conductor/state.json` predates + `0.24.0` and has no `platform` field, the migration stamps `platform: "claude-code"` — the + platform this repo was already running on — so the rules block keeps landing in the same file + it always has: ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs" upgrade diff --git a/docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md b/docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md new file mode 100644 index 0000000..8454469 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md @@ -0,0 +1,806 @@ +# Platform-Aware Rules Block Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make pm's managed rules block platform-aware — the host agent declares which platform it is, and pm writes the right file with the right command syntax for that platform. + +**Architecture:** pm never runs on its own; a host agent always triggers it, through a hook whose command string pm itself authored. So the platform is passed explicitly as `--platform ` rather than detected. It is recorded in `state.json` (written and read once per session start), and resolves through a chain that ends in a `claude-code` default so it can never silently resolve to nothing. Two things vary per platform: the target filename (respecting each platform's instruction-file precedence chain) and the slash-command form. The block body stays a single platform-neutral source of text. + +**Tech Stack:** Node 18+ built-ins only (`node:fs`, `node:path`). Tests: `node --test scripts/test/*.test.mjs`. + +**Spec:** `docs/superpowers/specs/2026-07-29-platform-aware-rules-block-design.md` + +## Global Constraints + +- **`scripts/conductor.mjs` and `scripts/lib/*.mjs` are ZERO-DEPENDENCY.** Node 18+ built-ins only (`node:fs`, `node:path`, `node:os`, `node:child_process`, `node:url`). **Never** add an npm package or a `package.json` dependency. +- **`node --test scripts/test/*.test.mjs` must pass — currently 257 tests, 0 failing.** `.githooks/pre-commit` runs it and blocks failing commits. **NEVER** use `git commit --no-verify`. +- **pm is an INSTRUCTION layer, never an INTEGRATION layer.** No code path may open a network connection or call an external system (Jira, GitHub, Linear). This plan adds no exceptions. +- **Backward compatibility is mandatory.** A `state.json` written by 0.23.1 must still load. Every existing caller of `rulesBlock()` / `writeRules()` must keep working unchanged — the new parameter is optional with a `claude-code` default. +- **Release discipline:** a feature bumps `.claude-plugin/plugin.json` `version`, adds a `CHANGELOG.md` entry, and — because this changes the `state.json` schema — adds a `MIGRATIONS` entry keyed to the new release (additive, idempotent). Current version is `0.23.1`; this ships as **`0.24.0`**. +- Conventional commits (`feat|fix|docs|style|refactor|test|chore|perf`). +- **Do not push and do not open a pull request.** Commits stay local; the branch is finished separately. + +### Verified platform facts — use these exact values, do not re-derive + +| Platform id | Instruction-file chain (first existing wins) | Command form | +|---|---|---| +| `claude-code` | `CLAUDE.md` | `/pm:status` | +| `hermes` | `HERMES.md` → `AGENTS.md` → `CLAUDE.md` | `/pm:status` | +| `codex` | `AGENTS.md` | `/pm-status` | + +Hermes preserves `:` in plugin command names (`name.lower().strip().lstrip("/").replace(" ", "-")` — never strips `:`) and looks commands up by whole key. Codex derives command names from `~/.codex/prompts/*.md` filename stems, so it is flat and hyphenated. + +## File Structure + +- **`scripts/lib/constants.mjs`** (modify) — add `KNOWN_PLATFORMS`, `PLATFORM_RULES_CHAIN`, `PLATFORM_COMMAND_PREFIX`. Pure data; no logic. +- **`scripts/lib/platform.mjs`** (create) — platform resolution and the target-file chain walk. One responsibility: answering "which platform, and which file". Kept out of `rules.mjs` so `rules.mjs` stays about *content*. +- **`scripts/lib/rules.mjs`** (modify) — `rulesBlock()` takes a platform and emits per-platform command strings; `writeRules()` writes to the resolved target. +- **`scripts/lib/migrations.mjs`** (modify) — one additive `0.24.0` entry stamping `state.platform`. +- **`scripts/conductor.mjs`** (modify) — pass the resolved platform into the `rules` subcommand. +- **`hooks/hooks.json`** (modify) — every pm-authored Claude Code hook command declares `--platform claude-code`. +- **`scripts/test/platform.test.mjs`** (create) — all tests for this feature. The suite lives in + `scripts/test/*.test.mjs` (11 files, run in parallel); a new capability gets its own file rather + than being appended to an existing one, which also keeps the split balanced. + +**Out of scope for this plan:** `evals/observe.py` (owned by the separate `edd-observe-hardcodes-claude-md` epic, which depends on this one), and the Hermes/Codex artifact trees (owned by the port epics). This plan makes the engine platform-aware; it does not add a second platform's files. + +--- + +### Task 1: Platform constants and resolution + +**Files:** +- Modify: `scripts/lib/constants.mjs:18` (after `KNOWN_LANES`) +- Create: `scripts/lib/platform.mjs` +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) + +**Interfaces:** +- Consumes: `loadState()` / `saveState(state)` from `scripts/lib/state.mjs:25,33`; `ROOT` from `scripts/lib/constants.mjs`. +- Produces: + - `KNOWN_PLATFORMS: string[]` — `["claude-code", "hermes", "codex"]` + - `PLATFORM_RULES_CHAIN: Record` + - `PLATFORM_COMMAND_PREFIX: Record` + - `platformFlag(argv: string[]) -> string` — `""` when absent + - `resolvePlatform(flags: object, state?: object|null) -> string` — never throws, never returns falsy + - `assertKnownPlatform(platform: string) -> void` — `process.exit(1)` on an unknown value + - `rulesTarget(platform: string, root: string) -> string` — absolute path + - `recordPlatform(state: object, platform: string) -> boolean` — true if it changed + +**Critical test-isolation hazard:** `run()` in `scripts/test/helpers.mjs` spreads `...process.env` into the child, so `CLAUDECODE=1` is present whenever the suite runs inside a Claude Code session. Any test asserting the *default* rung must pass `env: { CLAUDECODE: "" }` explicitly, or it will pass via the `CLAUDECODE` rung and prove nothing. + +- [ ] **Step 1: Write the failing tests** + +Create `scripts/test/platform.test.mjs` with the standard header, then add: + +```javascript +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { tmpRepo, run, ENGINE, EMPTY_CACHE } from "./helpers.mjs"; +``` + +Then the tests: + +```javascript +// ────────────── platform resolution + rules target ────────────── + +test("resolvePlatform prefers an explicit --platform flag over everything", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + const out = run(["rules", "--platform", "codex"], { cwd }); + assert.match(out, /\/pm-status/, "codex form should appear when --platform codex is passed"); + assert.doesNotMatch(out, /\/pm:status/, "the claude-code form must not leak through"); +}); + +test("resolvePlatform rejects an unknown --platform instead of silently defaulting", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + const r = spawnSync("node", [ENGINE, "rules", "--platform", "nope"], { + cwd, + env: { ...process.env, CLAUDE_PROJECT_DIR: cwd, PM_CACHE_ROOT: EMPTY_CACHE }, + encoding: "utf8", + }); + assert.equal(r.status, 1); + assert.match(r.stderr, /--platform must be one of claude-code\|hermes\|codex/); +}); + +test("resolvePlatform falls back to claude-code when nothing declares a platform", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + // CLAUDECODE is blanked so this exercises the terminal default, not the env rung. + const out = run(["rules"], { cwd, env: { CLAUDECODE: "" } }); + assert.match(out, /\/pm:status/); +}); + +test("rulesTarget returns CLAUDE.md for claude-code regardless of a stray AGENTS.md", () => { + const cwd = tmpRepo(); + fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# from some other agent\n"); + run(["init"], { cwd }); + assert.ok(fs.existsSync(path.join(cwd, "CLAUDE.md")), "claude-code has no chain; CLAUDE.md is written"); + const agents = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"); + assert.doesNotMatch(agents, /BEGIN pm-conductor rules/, "the stray file must be left alone"); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail` is non-zero — the first two error on the unknown `--platform` flag being accepted, or on `/pm-status` never appearing. + +- [ ] **Step 3: Add the platform constants** + +In `scripts/lib/constants.mjs`, immediately after the `KNOWN_LANES` line: + +```javascript +export const KNOWN_PLATFORMS = ["claude-code", "hermes", "codex"]; + +// Each platform's project-instruction file, in the order that platform resolves them. +// First EXISTING file wins (that is the one the platform will actually read); if none +// exist, the LAST entry is created -- it is the most broadly-compatible choice, e.g. a +// fresh Hermes repo gets CLAUDE.md (which Hermes reads as its third fallback and Claude +// Code reads natively) rather than a Hermes-exclusive HERMES.md. +export const PLATFORM_RULES_CHAIN = { + "claude-code": ["CLAUDE.md"], + hermes: ["HERMES.md", "AGENTS.md", "CLAUDE.md"], + codex: ["AGENTS.md"], +}; + +// Slash-command form per platform. Hermes preserves ':' in plugin command names and +// looks them up by whole key, so the namespace survives -- and the namespace matters: +// Hermes SILENTLY skips a plugin command colliding with a built-in, and it ships a +// built-in `status`. Codex derives command names from prompt-file stems, so it is flat. +export const PLATFORM_COMMAND_PREFIX = { + "claude-code": "/pm:", + hermes: "/pm:", + codex: "/pm-", +}; +``` + +- [ ] **Step 4: Create the resolution module** + +Create `scripts/lib/platform.mjs`: + +```javascript +// Which host agent is driving this invocation, and which file its rules block belongs in. +// +// pm never runs on its own: every engine invocation is triggered by a host agent, through a +// hook whose command string pm itself authored. So the platform is DECLARED, not detected -- +// each platform's hook config carries `--platform `. No markers, no filesystem +// archaeology, no precedence heuristic to get wrong. +import fs from "node:fs"; +import path from "node:path"; + +import { KNOWN_PLATFORMS, PLATFORM_RULES_CHAIN } from "./constants.mjs"; + +/** Extract just `--platform ` from an argv slice. + * + * Deliberately NOT lib/add-epic.mjs's parseFlags(): importing that would pull platform.mjs + * into add-epic.mjs -> render.mjs -> briefing.mjs -> active-pointer.mjs, the known circular + * cluster documented in CLAUDE.md. Since rules.mjs imports platform.mjs, any future edge + * from that cluster back to rules.mjs would close a loop around the rules writer itself. + * platform.mjs stays a LEAF -- constants.mjs and state.mjs only. Scanning for one flag is + * three lines; the coupling is not worth saving them. */ +export function platformFlag(argv) { + const i = argv.indexOf("--platform"); + if (i === -1) return ""; + const v = argv[i + 1]; + return (typeof v === "string" && !v.startsWith("--")) ? v.trim() : ""; +} + +/** Resolve the active platform. Never throws and never returns falsy: an unresolved + * platform that wrote NO rules block would be a silent no-op -- pm appearing installed + * while contributing nothing -- so the chain ends in a hard default. + * + * Order: explicit flag > recorded in state > CLAUDECODE env > claude-code. + * An unknown explicit value is rejected by the caller (see assertKnownPlatform), not + * silently ignored, because it means a hand-authored hook has a typo. */ +export function resolvePlatform(flags = {}, state = null) { + const flag = typeof flags.platform === "string" ? flags.platform.trim() : ""; + if (flag) return flag; + const recorded = state && typeof state.platform === "string" ? state.platform.trim() : ""; + if (recorded && KNOWN_PLATFORMS.includes(recorded)) return recorded; + if (process.env.CLAUDECODE) return "claude-code"; + return "claude-code"; +} + +/** Exit(1) with a legible message on an unknown platform, mirroring how add-epic + * treats an unknown --lane. Called only for an EXPLICIT flag value. */ +export function assertKnownPlatform(platform) { + if (!KNOWN_PLATFORMS.includes(platform)) { + process.stderr.write(`conductor: --platform must be one of ${KNOWN_PLATFORMS.join("|")}\n`); + process.exit(1); + } +} + +/** Absolute path of the file this platform's rules block belongs in: the first file in + * its chain that already EXISTS (that is the one the platform will actually read), else + * the chain's last entry. */ +export function rulesTarget(platform, root) { + const chain = PLATFORM_RULES_CHAIN[platform] || PLATFORM_RULES_CHAIN["claude-code"]; + for (const name of chain) { + const candidate = path.join(root, name); + if (fs.existsSync(candidate)) return candidate; + } + return path.join(root, chain[chain.length - 1]); +} + +/** Stamp the active platform onto state. Returns true when it changed, so a caller can + * detect a platform SWITCH (the project changed hands) and report it. */ +export function recordPlatform(state, platform) { + if (state.platform === platform) return false; + state.platform = platform; + return true; +} +``` + +- [ ] **Step 5: Wire `--platform` into the `rules` subcommand** + +`rules` (`scripts/conductor.mjs:112-116`) is already a closure that parses flags, so it is the one entry point that can honour `--platform` with a local change. It currently reads: + +```javascript + process.stdout.write(rulesBlock(currentTracker(), currentReviewMode(epicId), currentSecondaryTrackers())); +``` + +`rules` is read-only — it prints the block and must **not** persist anything — so resolve without recording: + +```javascript + const declared = platformFlag(process.argv.slice(3)); + if (declared) assertKnownPlatform(declared); + const rulesPlatform = resolvePlatform({ platform: declared }, loadState()); + process.stdout.write(rulesBlock(currentTracker(), currentReviewMode(epicId), currentSecondaryTrackers(), rulesPlatform)); +``` + +Keep the existing `parseFlags` line above it — `--epic` still comes from it. + +Imports to add at the top, alongside the other `./lib/*.mjs` imports: `import { resolvePlatform, assertKnownPlatform, platformFlag } from "./lib/platform.mjs";`. Check what is already imported with `rg -n "loadState" scripts/conductor.mjs` and add only what is missing. + +**Note:** `platformFlag` and `assertKnownPlatform` are defined in Task 1 Step 4; `resolveAndRecordPlatform` (Task 4) is not needed here precisely because `rules` must not write. + +- [ ] **Step 6: Run the tests** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. Tests 1, 2 and 4 pass. **Test 3 and the `/pm-status` assertion in test 1 will still fail** — `rulesBlock()` does not yet accept or use a platform. That is expected; Task 2 closes it. Confirm the *only* remaining failures are those command-form assertions. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/lib/constants.mjs scripts/lib/platform.mjs scripts/conductor.mjs scripts/test/platform.test.mjs +git commit -m "feat(engine): platform constants and declared-platform resolution + +pm never runs on its own -- a host agent always triggers it through a hook whose +command string pm authored -- so the platform is declared via --platform rather +than detected. Resolution ends in a hard claude-code default because resolving to +nothing would write no rules block at all: pm appearing installed while +contributing nothing." +``` + +--- + +### Task 2: Per-platform command strings in the rules block + +**Files:** +- Modify: `scripts/lib/rules.mjs:82` (the `rulesBlock` signature) and its 14 hardcoded `/pm:` sites +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) + +**Interfaces:** +- Consumes: `PLATFORM_COMMAND_PREFIX` from `scripts/lib/constants.mjs` (Task 1). +- Produces: `rulesBlock(tracker, reviewMode, secondaryTrackers = [], platform = "claude-code") -> string` — the platform parameter is **fourth and optional**, so all six existing callers keep working untouched. +- Produces: `pmCmd(platform: string, name: string) -> string` exported from `scripts/lib/rules.mjs`. + +There are exactly **14** lines in `scripts/lib/rules.mjs` containing `/pm:`. Find them with `rg -n '/pm:' scripts/lib/rules.mjs`. Every one becomes a template string using `pmCmd(platform, "")`. Do not miss the `Manage with …` footer. + +- [ ] **Step 1: Write the failing test** + +```javascript +test("rules block uses the platform's command form, and the body stays identical otherwise", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + + const cc = run(["rules", "--platform", "claude-code"], { cwd }); + const hermes = run(["rules", "--platform", "hermes"], { cwd }); + const codex = run(["rules", "--platform", "codex"], { cwd }); + + assert.match(cc, /\/pm:status/); + assert.match(hermes, /\/pm:status/, "Hermes preserves ':' in plugin command names"); + assert.match(codex, /\/pm-status/, "Codex command names come from prompt-file stems: flat"); + assert.doesNotMatch(codex, /\/pm:/, "no namespaced form may leak into the codex block"); + + // The BODY is platform-neutral: normalising the command form makes the blocks equal. + const norm = (s) => s.replace(/\/pm[-:]/g, "/pm§"); + assert.equal(norm(codex), norm(cc), "only command strings may differ between platforms"); + assert.equal(norm(hermes), norm(cc)); +}); + +test("rulesBlock defaults to the claude-code command form when no platform is given", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + const out = run(["rules"], { cwd, env: { CLAUDECODE: "" } }); + assert.match(out, /\/pm:status/); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: FAIL — `codex` output still contains `/pm:status`. + +- [ ] **Step 3: Add `pmCmd` and thread the platform through** + +In `scripts/lib/rules.mjs`, add `PLATFORM_COMMAND_PREFIX` to the existing `constants.mjs` import, then add above `rulesBlock`: + +```javascript +/** The platform's invocation form for a pm command. `pmCmd("codex", "status")` -> "/pm-status". + * An unrecognised platform falls back to the claude-code form rather than emitting a broken + * string -- the rules block must always name SOMETHING invocable. */ +export function pmCmd(platform, name) { + const prefix = PLATFORM_COMMAND_PREFIX[platform] || PLATFORM_COMMAND_PREFIX["claude-code"]; + return `${prefix}${name}`; +} +``` + +Change the signature at `scripts/lib/rules.mjs:82`: + +```javascript +export function rulesBlock(tracker, reviewMode, secondaryTrackers = [], platform = "claude-code") { +``` + +Then convert each of the 14 sites. Two worked examples — apply the same transformation to all of them: + +```javascript +// before + " then run `/pm:detour --minimal \"\"` so it is recorded in `.conductor/detours.log`.", +// after + ` then run \`${pmCmd(platform, "detour")} --minimal ""\` so it is recorded in \`.conductor/detours.log\`.`, +``` + +```javascript +// before + " priority, or the detour stack, re-render with `/pm:status`. Never hand-edit `PROJECT.md`.", +// after + ` priority, or the detour stack, re-render with \`${pmCmd(platform, "status")}\`. Never hand-edit \`PROJECT.md\`.`, +``` + +- [ ] **Step 4: Verify no hardcoded form survives** + +Run: `rg -n '/pm:' scripts/lib/rules.mjs` +Expected: **no output.** Any remaining hit is a site you missed. (`/pm-` should also produce no output — every form now comes from `pmCmd`.) + +- [ ] **Step 5: Run the tests** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/lib/rules.mjs scripts/test/platform.test.mjs +git commit -m "feat(engine): per-platform command form in the rules block + +Body stays a single platform-neutral source of text; only the slash-command form +varies. The pm: namespace is retained wherever supported because Hermes SILENTLY +skips a plugin command that collides with a built-in, and it ships a built-in +\`status\` -- so a bare /status would be dropped with no signal." +``` + +--- + +### Task 3: Write to the file the platform will actually read + +**Files:** +- Modify: `scripts/lib/rules.mjs:291-310` (`writeRules`) +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) + +**Interfaces:** +- Consumes: `rulesTarget(platform, root)` and `resolvePlatform(flags, state)` from `scripts/lib/platform.mjs` (Task 1); `ROOT` from `scripts/lib/constants.mjs`. +- Produces: `writeRules(platform = "claude-code") -> string` — **now returns the absolute path it wrote**, so callers can report it. All six existing callers ignore the return value and keep working. + +`writeRules()` currently hardcodes `CLAUDE_MD`. It must use the resolved target instead. Note the existing function reads, patches and writes in three branches (refresh in place / append / create) — preserve all three; only the path and the reported filename change. + +- [ ] **Step 1: Write the failing tests** + +```javascript +test("hermes writes into a pre-existing AGENTS.md, which outranks CLAUDE.md in its chain", () => { + const cwd = tmpRepo(); + fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# pre-existing, from a prior Codex attempt\n"); + run(["init", "--platform", "hermes"], { cwd }); + + const agents = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"); + assert.match(agents, /BEGIN pm-conductor rules/, + "Hermes resolves AGENTS.md before CLAUDE.md, so the block must land there"); + assert.match(agents, /pre-existing, from a prior Codex attempt/, "existing content is preserved"); + + // Writing CLAUDE.md here would be the silent-invisibility bug: Hermes would never read it. + if (fs.existsSync(path.join(cwd, "CLAUDE.md"))) { + const claude = fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf8"); + assert.doesNotMatch(claude, /BEGIN pm-conductor rules/, + "the block must not go to a file Hermes will not read"); + } +}); + +test("hermes with a clean repo writes CLAUDE.md, the most compatible entry in its chain", () => { + const cwd = tmpRepo(); + run(["init", "--platform", "hermes"], { cwd }); + assert.ok(fs.existsSync(path.join(cwd, "CLAUDE.md"))); + assert.ok(!fs.existsSync(path.join(cwd, "HERMES.md")), "no Hermes-exclusive file is invented"); +}); + +test("codex writes AGENTS.md and never CLAUDE.md, which it cannot read", () => { + const cwd = tmpRepo(); + run(["init", "--platform", "codex"], { cwd }); + const agents = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"); + assert.match(agents, /BEGIN pm-conductor rules/); + assert.ok(!fs.existsSync(path.join(cwd, "CLAUDE.md")), "Codex does not read CLAUDE.md"); +}); + +test("the rules block refreshes in place rather than duplicating on a second write", () => { + const cwd = tmpRepo(); + run(["init", "--platform", "codex"], { cwd }); + run(["write-rules", "--platform", "codex"], { cwd }); + const agents = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"); + assert.equal(agents.match(/BEGIN pm-conductor rules/g).length, 1); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: FAIL — the block lands in `CLAUDE.md` for every platform. + +- [ ] **Step 3: Make `writeRules` platform-aware** + +Replace `writeRules()` in `scripts/lib/rules.mjs` with: + +```javascript +export function writeRules(platform = "claude-code") { + const target = rulesTarget(platform, ROOT); + const name = path.basename(target); + + let existing = ""; + try { existing = fs.readFileSync(target, "utf8"); } catch { /* target does not exist yet */ } + + const block = rulesBlock(currentTracker(), currentReviewMode(), currentSecondaryTrackers(), platform); + let next; + if (existing.includes(RULES_BEGIN) && existing.includes(RULES_END)) { + // refresh in place + const re = new RegExp(`${RULES_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${RULES_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`); + next = existing.replace(re, block); + process.stderr.write(`conductor: refreshed rules block in ${name} (platform: ${platform})\n`); + } else if (existing.trim()) { + next = existing.replace(/\n*$/, "\n\n") + block; + process.stderr.write(`conductor: appended rules block to ${name} (platform: ${platform})\n`); + } else { + next = `# ${name}\n\n` + block; + process.stderr.write(`conductor: created ${name} with rules block (platform: ${platform})\n`); + } + fs.writeFileSync(target, next); + return target; +} +``` + +Add `path` and the platform helper to the imports at the top of `scripts/lib/rules.mjs`: + +```javascript +import path from "node:path"; +import { rulesTarget } from "./platform.mjs"; +``` + +Add `ROOT` to the existing `constants.mjs` import. `CLAUDE_MD` is no longer used by this function — leave the constant exported (other code and tests may reference it) but confirm with `rg -n "CLAUDE_MD" scripts/` that nothing now depends on `writeRules` writing it. + +- [ ] **Step 4: Run the tests** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. If an older test asserted on the literal string `CLAUDE.md` in stderr, update it to accept the new `(platform: …)` suffix — the message changed deliberately. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/lib/rules.mjs scripts/test/platform.test.mjs +git commit -m "feat(engine): write the rules block where the platform will read it + +Hermes resolves project context first-match-wins over HERMES.md > AGENTS.md > +CLAUDE.md. Writing CLAUDE.md in a repo that already has an AGENTS.md means the +block is SILENTLY invisible -- no error, just an agent with no conductor rules. +writeRules now targets the first existing file in the platform's chain, and +reports which file and platform it chose." +``` + +--- + +### Task 4: `--platform` wiring, state recording, and the migration + +**Files:** +- Modify: `scripts/lib/subcommands.mjs:28` (the `writeRules()` caller) +- Modify: `scripts/lib/migrations.mjs:17` (add a `0.24.0` entry) and `:60` +- Modify: `scripts/lib/tracker.mjs:42,54,81` and `scripts/lib/review-mode.mjs:27` — pass the recorded platform +- Modify: `.claude-plugin/plugin.json` (version → `0.24.0`), `CHANGELOG.md` +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) + +**Interfaces:** +- Consumes: `resolvePlatform`, `assertKnownPlatform`, `recordPlatform` from `scripts/lib/platform.mjs`; `writeRules(platform)` from `scripts/lib/rules.mjs`. +- Produces: `state.platform: string` in `.conductor/state.json`. + +The six `writeRules()` callers must pass the platform the session declared. The cheapest correct approach: each caller resolves from `loadState()` (the recorded value), because only `init`/`upgrade`/`write-rules`/`rules` receive an explicit flag — the tracker and review-mode refreshes are downstream of a session whose platform is already recorded. + +- [ ] **Step 1: Write the failing tests** + +```javascript +test("init records the declared platform in state.json", () => { + const cwd = tmpRepo(); + run(["init", "--platform", "codex"], { cwd }); + const state = JSON.parse(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8")); + assert.equal(state.platform, "codex"); +}); + +test("a later invocation with no flag reuses the recorded platform", () => { + const cwd = tmpRepo(); + run(["init", "--platform", "codex"], { cwd }); + // No --platform, and CLAUDECODE blanked: the recorded value must win over the default. + const out = run(["rules"], { cwd, env: { CLAUDECODE: "" } }); + assert.match(out, /\/pm-status/, "the recorded codex platform should still apply"); +}); + +test("a platform switch is recorded and reported, not silently ignored", () => { + const cwd = tmpRepo(); + run(["init", "--platform", "claude-code"], { cwd }); + const out = runCombined(["write-rules", "--platform", "hermes"], { cwd }); + const state = JSON.parse(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8")); + assert.equal(state.platform, "hermes"); + assert.match(out, /platform: hermes/); +}); + +test("upgrade stamps platform on a state file written before the field existed", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + const p = path.join(cwd, ".conductor", "state.json"); + const state = JSON.parse(fs.readFileSync(p, "utf8")); + delete state.platform; // simulate a 0.23.1 state file + state.pmVersion = "0.23.1"; + fs.writeFileSync(p, JSON.stringify(state, null, 2)); + + run(["upgrade"], { cwd }); + + const after = JSON.parse(fs.readFileSync(p, "utf8")); + assert.equal(after.platform, "claude-code", "the migration must be additive and default to the base platform"); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: FAIL — `state.platform` is `undefined`. + +- [ ] **Step 3: Add the migration entry** + +In `scripts/lib/migrations.mjs`, append to the `MIGRATIONS` array (keeping existing entries untouched): + +```javascript + { + release: "0.24.0", + note: "stamp the active host platform (claude-code for every pre-existing repo)", + apply(state) { + if (!state.platform) state.platform = "claude-code"; + }, + }, +``` + +- [ ] **Step 4a: Understand the dispatch trap before writing code** + +`scripts/conductor.mjs`'s dispatch is `{ …table… }[cmd] || (usage)()` — **every subcommand function is invoked with NO arguments.** And `init()`, `upgrade()` and `writeRules()` do not read `process.argv` at all: + +- `"write-rules": writeRules` (`scripts/conductor.mjs:117`) — a bare function reference. +- `init()` (`scripts/lib/subcommands.mjs:19`) — no parameters. +- `upgrade()` (`scripts/lib/migrations.mjs:36`) — no parameters. + +So passing `--platform` to any of them is currently a **silent no-op**: it would default to `claude-code` and this task's tests would fail in a confusing direction (the flag "works" but does nothing). `rules` is the only entry point that already parses flags, because it is a closure. + +Each entry point that must honour `--platform` therefore needs to parse `process.argv.slice(3)` itself. `upgrade` does **not** need the flag — the `0.24.0` MIGRATIONS entry stamps the default. + +- [ ] **Step 4b: Add a shared resolve-and-record helper** + +Add to `scripts/lib/platform.mjs`: + +`platformFlag` and `assertKnownPlatform` already exist from Task 1 Step 4. Add only this: + +```javascript +/** Resolve the platform for a top-level subcommand invocation and persist it. + * Centralised because the dispatch table calls every subcommand with NO arguments, so + * each entry point must read argv itself -- duplicating that logic is exactly how one of + * them silently stops honouring the flag. Returns { platform, switched }. */ +export function resolveAndRecordPlatform() { + const declared = platformFlag(process.argv.slice(3)); + if (declared) assertKnownPlatform(declared); + const state = loadState(); + const platform = resolvePlatform({ platform: declared }, state); + const switched = recordPlatform(state, platform); + if (switched) saveState(state); + return { platform, switched }; +} +``` + +Add this one import to `scripts/lib/platform.mjs` (alongside the existing `constants.mjs` import from Task 1): + +```javascript +import { loadState, saveState } from "./state.mjs"; +``` + +Verify the leaf property holds before committing: `rg -n "^import" scripts/lib/platform.mjs` must show only `node:fs`, `node:path`, `./constants.mjs`, and `./state.mjs`. + +- [ ] **Step 4c: Convert the entry points** + +In `scripts/conductor.mjs`, replace the bare reference: + +```javascript + "write-rules": () => { + const { platform } = resolveAndRecordPlatform(); + writeRules(platform); + }, +``` + +In `scripts/lib/subcommands.mjs`, `init()` — resolve and record before the `writeRules()` call at line 28: + +```javascript + const { platform } = resolveAndRecordPlatform(); + writeRules(platform); +``` + +In `scripts/lib/migrations.mjs:60`, `scripts/lib/tracker.mjs:42,54,81` and `scripts/lib/review-mode.mjs:27` — these are downstream refreshes with no flag of their own, so read the recorded value without re-parsing argv: + +```javascript + writeRules(resolvePlatform({}, loadState())); +``` + +Add the needed imports to each file. Note `subcommands.mjs` already imports `loadState`/`saveState`; verify per file with `rg -n "^import" scripts/lib/.mjs` rather than assuming. + +- [ ] **Step 4d: Verify no bare call site survives** + +Run: `rg -n "^\s*writeRules\(\);" scripts/` +Expected: **no output.** + +Use this statement-anchored pattern, **not** a bare `rg "writeRules\(\)"` — the looser pattern also matches two explanatory comments (`scripts/lib/tracker.mjs:3`, `scripts/lib/review-mode.mjs:3`) and so can never return empty, which would make the check meaningless. + +- [ ] **Step 5: Bump the version and add the changelog entry** + +In `.claude-plugin/plugin.json`, set `"version": "0.24.0"`. + +Prepend to `CHANGELOG.md` (match the file's existing heading style exactly — check with `head -20 CHANGELOG.md`): + +```markdown +## 0.24.0 + +### Added +- **Platform-aware rules block.** The managed rules block is no longer Claude-Code-only. The + host agent declares itself via `--platform ` in the hook command pm + authors for that platform, and the active platform is recorded in `.conductor/state.json`. +- Per-platform slash-command form: `/pm:status` on Claude Code and Hermes, `/pm-status` on Codex. + The namespace is retained wherever supported because Hermes silently skips a plugin command + that collides with a built-in, and it ships a built-in `status`. + +### 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` the block was silently invisible — no error, just an agent + running without the conductor's instructions. + +### 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. +``` + +- [ ] **Step 6: Run the tests** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/lib/subcommands.mjs scripts/lib/migrations.mjs scripts/lib/tracker.mjs \ + scripts/lib/review-mode.mjs .claude-plugin/plugin.json CHANGELOG.md scripts/test/platform.test.mjs +git commit -m "feat(engine): record the active platform in state.json (0.24.0) + +Written and read once per session start, so the choice is auditable in the state +of record and a platform SWITCH is detectable -- the project changed hands and the +new host's artifacts may need recreating. Additive 0.24.0 migration defaults +existing repos to claude-code." +``` + +--- + +### Task 5: Claude Code's hooks declare their platform, and the docs follow + +**Files:** +- Modify: `hooks/hooks.json` (4 command strings) +- Modify: `README.md`, `commands/upgrade.md`, `skills/conductor/SKILL.md` +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) + +**Interfaces:** +- Consumes: the `--platform` flag from Tasks 1 and 4. +- Produces: nothing new in code; this closes the loop so the declared-platform mechanism is actually exercised in the shipping product. + +Without this task the mechanism exists but nothing uses it — every real session would fall through to the `CLAUDECODE`/default rung. There are exactly 4 hook commands (`brief`, `snapshot`, `commit-nudge`, `gate-guard`) at `hooks/hooks.json:10,22,34,46`. + +- [ ] **Step 1: Write the failing test** + +```javascript +test("every pm-authored Claude Code hook command declares its platform", () => { + const hooks = JSON.parse(fs.readFileSync(new URL("../hooks/hooks.json", import.meta.url), "utf8")); + const commands = JSON.stringify(hooks).match(/conductor\.mjs" [a-z-]+[^"]*/g) || []; + assert.ok(commands.length >= 4, `expected at least 4 hook commands, found ${commands.length}`); + for (const c of commands) { + assert.match(c, /--platform claude-code/, `hook command does not declare its platform: ${c}`); + } +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: FAIL — no hook command carries `--platform`. + +- [ ] **Step 3: Add the flag to each hook command** + +In `hooks/hooks.json`, append ` --platform claude-code` to all four commands. Example for the first: + +```json +"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" brief --platform claude-code" +``` + +Apply the same to `snapshot`, `commit-nudge`, and `gate-guard`. + +- [ ] **Step 4: Run the tests** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. + +- [ ] **Step 5: Update the user-facing docs** + +`CLAUDE.md`'s documentation-currency rule requires README.md and the Mintlify site to reflect any user-facing change in the same PR cycle. This change is user-facing (a new flag, a new state field, a different file possibly being written). + +- `README.md` — document `--platform`, the three supported ids, and that the rules block now targets the file the host platform reads. Read the surrounding section first and match its voice. +- `commands/upgrade.md` — note that `upgrade` stamps `platform` on pre-0.24.0 state. +- `skills/conductor/SKILL.md` — if it describes the rules block as living in `CLAUDE.md`, correct it. + +Find every place that needs it rather than guessing: `rg -n "CLAUDE\.md" README.md commands/ skills/`. + +**The Mintlify site is a separate repo and a separate procedure** — follow the `mintlify-doc-sync` skill; do **not** hand-edit or guess at it here. If you cannot complete it, say so explicitly in your report rather than silently skipping it. + +- [ ] **Step 6: Verify the whole suite and commit** + +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. + +```bash +git add hooks/hooks.json README.md commands/upgrade.md skills/conductor/SKILL.md scripts/test/platform.test.mjs +git commit -m "feat(hooks): Claude Code hooks declare --platform claude-code + +Closes the loop: without this the declared-platform mechanism exists but nothing +exercises it, and every real session would fall through to the env/default rung." +``` + +--- + +## Self-Review + +**1. Spec coverage** + +| Spec section | Task | +|---|---| +| §1 host declares itself via `--platform` | 1 (resolution), 5 (hooks actually declare it) | +| §2 resolution order + non-silent default | 1 (chain, reject-unknown), 4 (recorded rung) | +| §3 record in `state.json`, session start, MIGRATIONS | 4 | +| §4 write the file that wins the chain | 3 | +| §5 neutral body, only commands vary | 2 (asserted by the normalising equality test) | +| §6 per-platform command form | 2 | +| Verified platform facts table | 1 (constants) | +| Report the chosen platform/file | 3 (stderr), 4 (switch test) | +| Deferred: CLI fallback, foreign-file adoption, `SOUL.md` | none — correctly out of scope, filed as planned epics | +| Out of scope: `observe.py`, port artifact trees | none — stated in File Structure | + +Open question 2 from the spec (reject vs fall through on an unknown `--platform`) is **resolved in Task 1**: reject with a message, mirroring `add-epic`'s `--lane` handling. Open question 1 (platform-switch artifact recreation) is deliberately left to the port epics; Task 4 implements only the detection-and-report half, which is this epic's share. + +**2. Placeholder scan** — no TBD/TODO; every code step carries real code; every command has an exact expected result. Task 5 Step 5 intentionally instructs *finding* the doc sites via `rg` rather than listing line numbers, because doc line numbers drift and a stale number is worse than a search. + +**3. Type consistency** — `resolvePlatform(flags, state)`, `assertKnownPlatform(platform)`, `rulesTarget(platform, root)`, `recordPlatform(state, platform)`, `pmCmd(platform, name)`, `writeRules(platform)`, `rulesBlock(tracker, reviewMode, secondaryTrackers, platform)` are used with these exact names and argument orders in every task that references them. `writeRules` gains a return value (the target path) that no existing caller reads. + +**One risk worth flagging to the human before execution:** Task 3 changes `writeRules`'s stderr messages, and Task 4 touches six call sites. Existing tests that assert on the old `"refreshed rules block in CLAUDE.md"` string will fail. That is intended — the message changed on purpose — but the implementer must update those assertions rather than weaken them, and a reviewer should confirm the updates preserve the original intent. diff --git a/docs/superpowers/specs/2026-07-29-platform-aware-rules-block-design.md b/docs/superpowers/specs/2026-07-29-platform-aware-rules-block-design.md new file mode 100644 index 0000000..374682a --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-platform-aware-rules-block-design.md @@ -0,0 +1,225 @@ +# Design: platform-aware rules block + +**Epic:** `rules-block-hardcodes-claude-slash-commands` — child of `multi-platform-agent-support` +**Date:** 2026-07-29 +**Blocks:** `hermes-platform-support`, `codex-platform-support`, `edd-observe-hardcodes-claude-md` + +## Problem + +`rulesBlock()` hardcodes two Claude-Code-specific things, and the second is worse than the +epic title suggests: + +1. **Command syntax** — `/pm:status`, `/pm:detour --minimal`, and the + `Manage with /pm:status · /pm:next · …` footer. +2. **The target filename** — `constants.mjs:14` defines `CLAUDE_MD` and `rules.mjs:309` is the + only writer. pm can only ever write `CLAUDE.md`. + +Claude Code is the permanent base platform; every other platform is held to parity with it. +Neither hardcode survives contact with a second platform. + +## The finding that reshaped this design + +Hermes does not simply "read `AGENTS.md`". It resolves project context through a +**first-match-wins precedence chain** (`agent/prompt_builder.py:2073-2077`): + +``` +HERMES.md → AGENTS.md → CLAUDE.md → .cursorrules +``` + +Two consequences, both load-bearing: + +- Hermes **does** read `CLAUDE.md` — so in a clean repo, pm's existing rules block already + reaches a Hermes session unchanged. Hermes support is far cheaper than assumed. +- But if the repo already contains `AGENTS.md` or `HERMES.md` — e.g. from a prior Codex or + Hermes attempt — Hermes reads **that** and never looks at `CLAUDE.md`. pm's rules block + becomes **silently invisible**: no error, no warning, just an agent operating without the + conductor's instructions. + +**Writing to a file the platform is capable of reading is not sufficient. The file must win +that platform's precedence chain.** This is the single most important requirement here, and it +is why "detect the platform, write one file" is necessary but not sufficient on its own. + +Codex, by contrast, reads `AGENTS.md` and does not read `CLAUDE.md` at all. + +## Verified platform facts + +Established against the installed CLIs (per the standing platform-verification procedure), not +from documentation or search: + +| | Claude Code | Hermes v0.19.0 | Codex 0.145.0 | +|---|---|---|---| +| Instruction file | `CLAUDE.md` | chain above | `AGENTS.md` | +| Reads `CLAUDE.md`? | yes | **only if `HERMES.md` + `AGENTS.md` absent** | no | +| Namespaced slash cmd | `/pm:status` | **`/pm:status` — `:` survives** | **no — `/pm-status`** | + +**Hermes command evidence.** Plugins register in-session commands via `register_command()` +(`hermes_cli/plugins.py:548`). Normalization is +`name.lower().strip().lstrip("/").replace(" ", "-")` — it never strips `:` — and +`resolve_command()` (`hermes_cli/commands.py:299`) is a whole-key dict lookup that does not +split on `:`. So `/pm:status` works end to end. + +Critically, **a name that collides with a built-in is silently skipped** — `logger.warning` +then `return` (`plugins.py:583-590`). Hermes ships a built-in `status`. A bare `/status` from +pm would therefore be **dropped with no user-visible signal**. The `pm:` namespace is not +cosmetic; it is what prevents silent loss. + +**Codex command evidence.** Custom commands are files in `~/.codex/prompts/*.md`, named from +the filename stem. The maintainer's own directory is the proof: `opsx-apply.md`, +`opsx-archive.md`, `opsx-propose.md` — the same OpenSpec commands invoked as `/opsx:apply` +under Claude Code. That is a direct A/B on one plugin, and it settles the Codex design's open +question #3: Codex is flat and hyphenated. + +## Design + +### 1. The host declares itself — pm does not sniff for it + +The framing that unlocked this: **pm never runs on its own.** Every engine invocation is +triggered by a host agent, either through a hook that agent fired or a command that agent ran. +So the question is not "what files suggest a platform" but "which agent am I running inside +right now" — and the host can simply be made to say. + +pm already ships a **separate hook configuration per platform**, because each platform's format +differs (`hooks/hooks.json` for Claude Code, an `(event, matcher, command)` triple for Hermes, +`.codex/hooks.json` for Codex). In all three, **the command string is authored by pm**. So the +platform is known at packaging time, not runtime: + +| Platform | pm-authored hook command | +|---|---| +| claude-code | `node "${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs" brief --platform claude-code` | +| hermes | `node ".../scripts/conductor.mjs" brief --platform hermes` | +| codex | `node "${PLUGIN_ROOT}/scripts/conductor.mjs" brief --platform codex` | + +This is **deterministic — no heuristics, no filesystem archaeology, no marker precedence to get +wrong.** It also scales: adding a platform means authoring its hook file with its own +`--platform` value, which the port epic is already doing. + +Rejected — env-var sniffing. Claude Code does set a reliable marker (`CLAUDECODE=1`, verified in +a live session), but Hermes does not: its shell-hook runner calls `subprocess.run` with **no +`env=`** (`agent/shell_hooks.py:463`), so hooks inherit whatever launched Hermes and carry no +guaranteed `HERMES_*` marker. Sniffing could therefore detect Claude Code but could not +distinguish Hermes from Codex. It survives only as a fallback rung below. + +Rejected — writing every target unconditionally. Claude Code does not read `AGENTS.md` and Codex +does not read `CLAUDE.md`, so writing both leaves a dead file in most repos, and a project is +normally driven by one agent. + +### 2. Resolution order, with a default that can never be silent + +`--platform` covers every hook-driven invocation. A human running the engine directly from a +terminal is the remaining case, so resolution is a chain: + +1. explicit `--platform ` (every pm-authored hook path) +2. the value recorded in `state.json` (see §3) +3. `CLAUDECODE=1` in the environment → `claude-code` +4. **default `claude-code`** — the base platform and today's behavior + +Detection's dangerous failure is resolving to nothing and writing nothing: pm would appear +installed while contributing no rules block, failing silently — the same class of bug as the +precedence trap above. Hence a terminal default rather than an error, and `init`/`upgrade` +**reports which platform and which file it chose**. A visible wrong guess is recoverable; a +silent no-op is not. + +### 3. Record the active platform in `state.json`; refresh it at session start + +`state.json` is already the state of record, so the active platform belongs there: **written +once at session start, read once at session start.** That makes the choice auditable in the same +file everything else lives in, and it survives between invocations. + +It also enables the case that matters most: **platform switching.** If the recorded platform is +`claude-code` and the current session declares `hermes`, pm knows the project changed hands. It +can then look at the artifacts the previous platform left behind and faithfully recreate the +equivalents the new platform supports, rather than silently leaving a repo half-configured for +an agent that is no longer driving it. + +Because this adds a field existing state files lack, it needs a `MIGRATIONS` entry keyed to the +release — additive and idempotent, with a state file written by the prior version still loading. + +### 4. Guarantee the written file wins the platform's precedence chain + +For the detected platform, pm must write to the file that platform will actually consult given +what is already on disk — not merely a file it could consult. Concretely, for Hermes, writing +`CLAUDE.md` is correct only when no `HERMES.md` or `AGENTS.md` exists; otherwise the block must +go to the file at the head of the chain. + +**Decision: pm writes to the head of the detected platform's chain, and reports which file it +chose and why.** Refusing to write was considered and rejected — it would leave pm +non-functional in the repo, trading a silent failure for a loud one rather than fixing it. + +Note this is per-platform, not global. Claude Code has no chain: it reads `CLAUDE.md` +regardless of what else is present, so a stray `AGENTS.md` is simply irrelevant there. The rule +only bites for platforms that resolve by precedence, which today means Hermes. + +### 5. The block body stays platform-neutral; only command strings vary + +The rules-block body — detour classification, the autonomy decision rule, tracker instructions, +the review-mode table — is platform-neutral and stays a single source of text. Nothing in it is +Claude-Code-specific once the command strings are parameterized, and forking the body per +platform would create exactly the drift the parity mechanism exists to prevent. + +**Only the command strings are per-platform.** That keeps the substitution surface as small as +it can be: one command-form table, not N copies of the instructions. + +### 6. Command syntax: namespaced slash command, per-platform form + +The namespace is retained on every platform that supports one, because it is what prevents the +silent collision documented above. + +| Platform | Form | +|---|---| +| claude-code | `/pm:status` | +| hermes | `/pm:status` | +| codex | `/pm-status` | + +This is a per-platform command *form*, not a prefix substitution — Codex changes the separator, +not just the prefix. + +## Explicitly deferred + +- **CLI fallback** for a platform with no slash commands or an unavoidable collision — filed as + `rules-block-cli-fallback` (P3, planned). Deferred deliberately: a CLI fallback changes *who + the instruction addresses* (a slash command is for the user; `node scripts/conductor.mjs` is + realistically for the agent), which is a design fork rather than longer text. Registered as a + planned epic rather than a code `TODO`, because a comment resurfaces to nobody while a planned + epic appears in `PROJECT.md` and the briefing. +- **Adopting a pre-existing foreign instruction file** (reading an `AGENTS.md` left by a prior + agent to inform initialization) — filed as `init-detects-foreign-agent-instruction-files` + (P3, planned). Note this is *distinct* from §3: §3 is a correctness requirement that must ship + now; the P3 item is the richer "read it and learn from it" behavior. +- **`SOUL.md`** — loaded by Hermes from `HERMES_HOME` only, not from the project directory, so + it is not a project-instruction target and is out of scope here. +- **`.cursorrules`** — last in Hermes' chain; no pm platform targets it. + +## Out of scope + +- The Codex and Hermes ports themselves. This epic only makes the rules block platform-aware; + the ports consume it. +- Per-platform artifact trees (`codex/`, `.codex-plugin/`) — owned by the port epics. + +## Consequences for `edd-observe-hardcodes-claude-md` + +That epic is re-sequenced as `depends-on` this one. `evals/observe.py` hardcodes `CLAUDE.md`, +which is **correct today** because the engine only writes `CLAUDE.md`; parameterizing the +observer first would abstract over a filename nothing produces. Once the engine writes a +per-platform target, `observe()` follows, and one eval proves both halves together. + +## Resolved during design + +The three questions that were open when this spec was first drafted are now settled and folded +into the sections above: + +1. **How the platform is identified** → §1. The host declares itself via a `--platform` flag in + the hook command pm authors for that platform. No marker detection. +2. **Where the choice lives** → §3. `state.json`, written and read once per session start, with + a `MIGRATIONS` entry. Enables the platform-switch case. +3. **Per-platform body?** → §5. No. Body stays neutral; only command strings vary. + +## Open questions for the plan + +1. What does pm do concretely on a detected platform *switch* (§3)? Recreating the new + platform's artifacts is the intent, but the boundary between "pm refreshes its own managed + block" and "pm regenerates a whole artifact tree" belongs to the port epics, not here. This + spec should settle only the rules-block half and state where the line falls. +2. Does `--platform` validate against a known list and reject an unknown value, or fall through + to the default? Rejecting is more honest for a typo in a hand-authored hook; falling through + is more forgiving. Leaning reject-with-message, consistent with how `add-epic` treats an + unknown `--lane`. diff --git a/evals/runners.py b/evals/runners.py index cc9dc35..7325637 100644 --- a/evals/runners.py +++ b/evals/runners.py @@ -8,10 +8,30 @@ from __future__ import annotations import json +import os import subprocess from pathlib import Path +def _child_env() -> dict[str, str]: + """Environment for every spawned agent session. + + HONCHO_ENABLED=false: honcho is a *user-scope* Claude Code plugin, so each headless + session this harness spawns would otherwise write machine-generated eval text into the + maintainer's personal memory workspace -- content with no value to a human reader, and + the overwhelming majority of sessions on the machine. `false` is the plugin's hard off + switch (isPluginEnabled() returns false and every hook exits before doing any work). + + It is a per-invocation runtime override that the plugin deliberately never persists to + its config file, so interactive work in this same repo keeps honcho ON. + + Set here rather than at the call site so that a platform runner added later (hermes, + codex) inherits it by construction instead of having to remember. Nested processes -- + a session that shells out, a worktree child -- inherit it the normal way. + """ + return {**os.environ, "HONCHO_ENABLED": "false"} + + def _parse_result(stdout: str) -> dict: """Pull run metrics out of --output-format json, tolerating non-JSON output.""" try: @@ -39,6 +59,7 @@ def run_claude_code( "--output-format", "json", ], cwd=str(cwd), + env=_child_env(), capture_output=True, text=True, timeout=timeout, diff --git a/evals/tests/test_runners.py b/evals/tests/test_runners.py index bead7ac..2864870 100644 --- a/evals/tests/test_runners.py +++ b/evals/tests/test_runners.py @@ -28,3 +28,40 @@ def test_parse_result_survives_non_json_output(): assert parsed["duration_ms"] is None assert parsed["num_turns"] is None assert parsed["total_cost_usd"] is None + + +def test_child_env_disables_honcho_without_dropping_the_environment(): + """honcho is a user-scope plugin; spawned eval sessions must not write to the + maintainer's personal memory. Passing env= replaces the environment wholesale, so + assert PATH survived too -- a bare {"HONCHO_ENABLED": "false"} would break `claude`.""" + env = runners._child_env() + + assert env["HONCHO_ENABLED"] == "false" + assert env.get("PATH"), "env= replaces the environment; PATH must be carried through" + + +def test_every_runner_passes_the_disabling_env_to_the_subprocess(monkeypatch): + """Guards the whole RUNNERS registry, not just claude-code, so a platform runner + added later cannot silently reintroduce memory pollution.""" + seen = {} + + def fake_run(argv, **kwargs): + seen[argv[0]] = kwargs.get("env") + + class _Proc: + returncode = 0 + stdout = "{}" + + return _Proc() + + monkeypatch.setattr(runners.subprocess, "run", fake_run) + + for platform, runner in runners.RUNNERS.items(): + seen.clear() + runner("noop prompt", cwd=".") + assert seen, f"{platform} runner spawned nothing observable" + for binary, env in seen.items(): + assert env is not None, f"{platform} inherits the environment instead of setting it" + assert env.get("HONCHO_ENABLED") == "false", ( + f"{platform} spawns {binary} without HONCHO_ENABLED=false" + ) diff --git a/hooks/hooks.json b/hooks/hooks.json index 81fe965..ce545aa 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" brief" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" brief --platform claude-code" } ] } @@ -19,7 +19,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" snapshot" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" snapshot --platform claude-code" } ] } @@ -31,7 +31,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" commit-nudge" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" commit-nudge --platform claude-code" } ] } @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" gate-guard" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/conductor.mjs\" gate-guard --platform claude-code" } ] } diff --git a/scripts/conductor.mjs b/scripts/conductor.mjs index 7cc42f9..4415bd9 100644 --- a/scripts/conductor.mjs +++ b/scripts/conductor.mjs @@ -45,6 +45,8 @@ import { pluginVersion } from "./lib/plugin-meta.mjs"; import { currentTracker, currentSecondaryTrackers, currentReviewMode, rulesBlock, writeRules, } from "./lib/rules.mjs"; +import { resolvePlatform, assertKnownPlatform, platformFlag, resolveAndRecordPlatform } from "./lib/platform.mjs"; +import { loadState } from "./lib/state.mjs"; import { setActive, clearActive } from "./lib/active-pointer.mjs"; import { setAutonomy } from "./lib/autonomy.mjs"; import { parseFlags, planHierarchy, addEpic } from "./lib/add-epic.mjs"; @@ -112,9 +114,16 @@ if (showEngineBanner) { rules: () => { const f = parseFlags(process.argv.slice(3)); const epicId = typeof f.epic === "string" ? f.epic : undefined; - process.stdout.write(rulesBlock(currentTracker(), currentReviewMode(epicId), currentSecondaryTrackers())); + const declared = platformFlag(process.argv.slice(3)); + if (declared) assertKnownPlatform(declared); + const rulesPlatform = resolvePlatform({ platform: declared }, loadState()); + process.stdout.write(rulesBlock(currentTracker(), currentReviewMode(epicId), currentSecondaryTrackers(), rulesPlatform)); + }, + "write-rules": () => { + const { platform, switched } = resolveAndRecordPlatform(); + writeRules(platform); + if (switched) process.stderr.write(`conductor: platform: ${platform}\n`); }, - "write-rules": writeRules, }[cmd] || (() => { process.stderr.write("usage: conductor.mjs init|render|brief|snapshot|commit-nudge|sync|log-detour|honcho-memory|add-epic|add-many|update-epic|remove-epic|set-active|clear-active|set-tracker|set-lane-routing|suggest-lane|set-autonomy|record-reconcile|record-gate-review|set-review-mode|set-gate-guard|gate-guard|plan-hierarchy|verify-worktrees|verify-state|changesets|upgrade|changelog|rules|write-rules\n"); process.exit(1); diff --git a/scripts/conductor.test.mjs b/scripts/conductor.test.mjs deleted file mode 100644 index edbae45..0000000 --- a/scripts/conductor.test.mjs +++ /dev/null @@ -1,3132 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const ENGINE = path.join(path.dirname(fileURLToPath(import.meta.url)), "conductor.mjs"); -const EMPTY_CACHE = fs.mkdtempSync(path.join(os.tmpdir(), "pm-empty-cache-")); - -export function tmpRepo() { - return fs.mkdtempSync(path.join(os.tmpdir(), "pm-test-")); -} -export function run(args, { cwd, env = {}, input } = {}) { - return execFileSync("node", [ENGINE, ...args], { - cwd, - env: { ...process.env, CLAUDE_PROJECT_DIR: cwd, PM_CACHE_ROOT: EMPTY_CACHE, ...env }, - encoding: "utf8", - input, - }); -} -/** Like run(), but returns stdout+stderr combined — for commands whose confirmation - * message (e.g. remove-epic's "stripped dangling link" warning) is on stderr. */ -export function runCombined(args, { cwd, env = {}, input } = {}) { - const r = spawnSync("node", [ENGINE, ...args], { - cwd, - env: { ...process.env, CLAUDE_PROJECT_DIR: cwd, PM_CACHE_ROOT: EMPTY_CACHE, ...env }, - encoding: "utf8", - input, - }); - return (r.stdout || "") + (r.stderr || ""); -} -export function readState(cwd) { - return JSON.parse(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8")); -} -export function writeState(cwd, obj) { - fs.mkdirSync(path.join(cwd, ".conductor"), { recursive: true }); - fs.writeFileSync(path.join(cwd, ".conductor", "state.json"), JSON.stringify(obj, null, 2) + "\n"); -} -export function projectMd(cwd) { - return fs.readFileSync(path.join(cwd, "PROJECT.md"), "utf8"); -} -export function claudeMd(cwd) { - return fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf8"); -} -export function parseBrief(cwd) { - const out = run(["brief"], { cwd }); - return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : ""; -} - -test("epic without lane reads as openspec (back-compat) and shows a Lane column", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { - version: 1, active: null, detourStack: [], - epics: [{ id: "legacy", title: "Legacy epic", priority: "P1", status: "queued", role: "epic", links: [], reconcileNeeded: false }], - }); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /\| Lane \|/); // Lane column header exists - assert.match(md, /`legacy`/); - assert.match(md, /\| openspec \|/); // legacy epic defaulted to openspec -}); - -test("epics sort by priority then lane rank deterministically", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { - version: 1, active: null, detourStack: [], - epics: [ - { id: "b-sp", title: "b", priority: "P1", status: "queued", role: "epic", lane: "superpowers", links: [] }, - { id: "a-os", title: "a", priority: "P1", status: "queued", role: "epic", lane: "openspec", links: [] }, - { id: "c-cc", title: "c", priority: "P0", status: "queued", role: "epic", lane: "claude-code", links: [] }, - ], - }); - run(["render"], { cwd }); - const md = projectMd(cwd); - // P0 claude-code first, then P1 openspec before P1 superpowers - const order = ["c-cc", "a-os", "b-sp"].map(id => md.indexOf(`\`${id}\``)); - assert.ok(order[0] < order[1] && order[1] < order[2], `bad order: ${order}`); -}); - -test("init scaffolds state.json, PROJECT.md, and CLAUDE.md rules block", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const state = readState(cwd); - assert.equal(state.version, 1); - assert.deepEqual(state.epics, []); - assert.deepEqual(state.detourStack, []); - assert.match(projectMd(cwd), /PROJECT — Conductor Index/); - assert.match(claudeMd(cwd), /BEGIN pm-conductor rules/); -}); - -test("state.json writes leave no stray tmp file behind after tmp+rename", () => { - // This is a hygiene/regression check for the success path, not a fault-injection proof - // of atomicity — the CLI is exercised via execFileSync (a child process), so this test - // harness can't inject a crash mid-write to directly observe the failure-path guarantee - // (a crash leaves a truncated .tmp-* file, never a truncated state.json, because rename(2) - // is atomic on the same filesystem). What IS verified here: repeated writes never leave a - // leftover tmp file next to state.json, and the final file is always valid, complete JSON. - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--title", "Renamed"], { cwd }); - const entries = fs.readdirSync(path.join(cwd, ".conductor")); - assert.deepEqual(entries.sort(), ["render-stamp.json", "state.json"]); - const parsed = JSON.parse(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8")); - assert.equal(parsed.epics.find(e => e.id === "a").title, "Renamed"); -}); - -test("render() does not rewrite render-stamp.json when state.json is unchanged", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const stampPath = path.join(cwd, ".conductor", "render-stamp.json"); - const before = fs.readFileSync(stampPath, "utf8"); - const beforeMtime = fs.statSync(stampPath).mtimeMs; - // Render again with no state.json change in between — render-stamp.json's stateMtimeMs - // is already correct, so the file's content (and mtime) should be left untouched. - run(["render"], { cwd }); - const after = fs.readFileSync(stampPath, "utf8"); - const afterMtime = fs.statSync(stampPath).mtimeMs; - assert.equal(after, before, "render-stamp.json content should be byte-identical when state.json didn't change"); - assert.equal(afterMtime, beforeMtime, "render-stamp.json should not be rewritten (mtime unchanged) when state.json didn't change"); -}); - -test("progress precedence: manual stories win", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "m", title: "m", priority: "P1", status: "queued", role: "epic", lane: "claude-code", - stories: [{ title: "a", done: true }, { title: "b", done: false }], links: [] }, - ]}); - run(["render"], { cwd }); - assert.match(projectMd(cwd), /1\/2 stories/); -}); - -test("progress precedence: planPath checkboxes when no stories", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - fs.mkdirSync(path.join(cwd, "docs", "superpowers", "plans"), { recursive: true }); - fs.writeFileSync(path.join(cwd, "docs", "superpowers", "plans", "p.md"), - "# Plan\n- [x] one\n- [ ] two\n- [ ] three\n"); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "sp", title: "sp", priority: "P1", status: "queued", role: "epic", lane: "superpowers", - planPath: "docs/superpowers/plans/p.md", links: [] }, - ]}); - run(["render"], { cwd }); - assert.match(projectMd(cwd), /1\/3 tasks/); -}); - -test("dangling planPath renders a warning, not a count", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "sp", title: "sp", priority: "P1", status: "queued", role: "epic", lane: "superpowers", - planPath: "docs/superpowers/plans/missing.md", links: [] }, - ]}); - run(["render"], { cwd }); - assert.match(projectMd(cwd), /⚠ planPath missing/); -}); - -test("decision lane with no source renders an em dash", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "d", title: "d", priority: "P2", status: "queued", role: "epic", lane: "decision", links: [] }, - ]}); - run(["render"], { cwd }); - assert.match(projectMd(cwd), /`d` \| decision \| epic \| queued \| — \|/); -}); - -test("openspec lane still reads tasks.md by id", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const ch = path.join(cwd, "openspec", "changes", "feat-x"); - fs.mkdirSync(ch, { recursive: true }); - fs.writeFileSync(path.join(ch, "tasks.md"), "- [x] a\n- [x] b\n- [ ] c\n"); - run(["sync"], { cwd }); - run(["render"], { cwd }); - assert.match(projectMd(cwd), /2\/3 stories/); -}); - -test("non-openspec epic appears in NEXT UP", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "sp1", title: "sp1", priority: "P1", status: "queued", role: "epic", lane: "superpowers", - stories: [{ title: "x", done: false }], links: [] }, - ]}); - const brief = parseBrief(cwd); - assert.match(brief, /NEXT UP/); - assert.match(brief, /`sp1` \(P1, superpowers, queued\)/); -}); - -test("missing openspec change is marked and excluded from NEXT UP", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "ghost", title: "ghost", priority: "P1", status: "queued", role: "epic", lane: "openspec", links: [] }, - ]}); - run(["render"], { cwd }); - assert.match(projectMd(cwd), /no change on disk/); - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /`ghost`/); -}); - -test("an archived openspec epic is never flagged as missing its change, even if its change dir is gone", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "shipped", title: "shipped", priority: "P1", status: "archived", role: "epic", lane: "openspec", links: [] }, - ]}); - run(["render"], { cwd }); - assert.doesNotMatch(projectMd(cwd), /no change on disk/); -}); - -function manyEpics(n) { - return Array.from({ length: n }, (_, i) => ({ - id: `e${String(i).padStart(2, "0")}`, title: `e${i}`, priority: "P1", - status: "queued", role: "epic", lane: "superpowers", - stories: [{ title: "x", done: false }], links: [], - })); -} - -test("brief caps NEXT UP at 5 and reports the remainder", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: manyEpics(8) }); - const brief = parseBrief(cwd); - const shown = (brief.match(/^ {2}• /gm) || []).length; - assert.equal(shown, 5); - assert.match(brief, /\(\+3 more — see PROJECT\.md\)/); - assert.match(brief, /lanes: superpowers 8/); -}); - -test("active epic is shown even when NEXT UP is capped", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const epics = manyEpics(8); - epics.push({ id: "live", title: "live", priority: "P0", status: "active", role: "epic", lane: "openspec", links: [] }); - writeState(cwd, { version: 1, active: "live", detourStack: [], epics }); - const brief = parseBrief(cwd); - assert.match(brief, /NOW: `live`/); -}); - -function expectFail(fn) { - try { fn(); return null; } catch (e) { return e; } -} - -test("epic with no autonomy field defaults to level off via render/brief (no crash, no marker)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code", "--status", "active"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /`a`/); - assert.doesNotMatch(md, /🤖/); // no autonomy marker for a plain epic - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /🤖/); -}); - -test("add-epic inserts a lane-tagged epic with defaults", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "refactor-auth", "--title", "Refactor auth", "--lane", "superpowers", "--priority", "P1"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "refactor-auth"); - assert.equal(e.lane, "superpowers"); - assert.equal(e.priority, "P1"); - assert.equal(e.status, "queued"); - assert.equal(e.role, "epic"); -}); - -test("add-epic rejects a duplicate id", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "dup", "--lane", "claude-code"], { cwd }); - const err = expectFail(() => run(["add-epic", "--id", "dup", "--lane", "claude-code"], { cwd })); - assert.ok(err, "expected non-zero exit on duplicate"); -}); - -test("add-epic rejects a bad id and an unknown lane", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.ok(expectFail(() => run(["add-epic", "--id", "Bad ID", "--lane", "claude-code"], { cwd }))); - assert.ok(expectFail(() => run(["add-epic", "--id", "ok", "--lane", "nope"], { cwd }))); -}); - -test("add-epic stores planPath and links", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "y", "--lane", "claude-code"], { cwd }); // link target must exist - run(["add-epic", "--id", "x", "--lane", "superpowers", "--plan", "docs/superpowers/plans/x.md", - "--link", "blocks:y:needs token"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "x"); - assert.equal(e.planPath, "docs/superpowers/plans/x.md"); - assert.deepEqual(e.links, [{ type: "blocks", epic: "y", reason: "needs token" }]); -}); - -test("add-epic rejects a --link whose epic id doesn't exist, instead of silently storing garbage", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - // the reported real-world typo: "type:related:epic:..." — split(":") yields - // type="type", epic="related", and "related" is not a real epic id. - const err = expectFail(() => run(["add-epic", "--id", "x", "--lane", "claude-code", - "--link", "type:related:epic:some reason"], { cwd })); - assert.ok(err, "expected rejection"); - assert.match(String(err.stderr || err.message), /not a known epic/); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); - assert.equal(readState(cwd).epics.length, 0); // epic itself was not created either -}); - -test("add-epic rejects a --link with fewer than two segments", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.ok(expectFail(() => run(["add-epic", "--id", "x", "--lane", "claude-code", - "--link", "justoneword"], { cwd }))); -}); - -test("update-epic --link replaces the epic's links wholesale, validated the same way as add-epic", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "y", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "z", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "x", "--lane", "claude-code", "--link", "blocks:y:old reason"], { cwd }); - run(["update-epic", "x", "--link", "relates-to:z:new reason"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "x"); - assert.deepEqual(e.links, [{ type: "relates-to", epic: "z", reason: "new reason" }]); // replaced, not appended - - // fixing a malformed link works the same way: an invalid --link is rejected and - // writes nothing, leaving the last-good links array intact. - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - const err = expectFail(() => run(["update-epic", "x", "--link", "type:ghost-epic:bad"], { cwd })); - assert.ok(err, "expected rejection"); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("sync imports superpowers plans as lane-tagged epics", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - fs.mkdirSync(path.join(cwd, "docs", "superpowers", "plans"), { recursive: true }); - fs.writeFileSync(path.join(cwd, "docs", "superpowers", "plans", "big-refactor.md"), "# Big Refactor\n- [ ] a\n"); - run(["sync"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "big-refactor"); - assert.equal(e.lane, "superpowers"); - assert.equal(e.title, "Big Refactor"); - assert.equal(e.planPath, "docs/superpowers/plans/big-refactor.md"); -}); - -test("sync tolerates a missing plans dir", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); // no docs/ dir at all - run(["sync"], { cwd }); // must not throw - assert.ok(Array.isArray(readState(cwd).epics)); -}); - -test("sync skips a plan whose id collides with an existing epic", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "auth", "--lane", "openspec"], { cwd }); - fs.mkdirSync(path.join(cwd, "docs", "superpowers", "plans"), { recursive: true }); - fs.writeFileSync(path.join(cwd, "docs", "superpowers", "plans", "auth.md"), "# Auth\n- [ ] a\n"); - run(["sync"], { cwd }); - const matches = readState(cwd).epics.filter(x => x.id === "auth"); - assert.equal(matches.length, 1); - assert.equal(matches[0].lane, "openspec"); // original kept; plan skipped -}); - -test("sync: openspec change discovered in same run prevents same-id plan from being added", () => { - // This test guards the known.add(id) call inside the openspec loop of sync. - // Without that call, a plan with the same id as a freshly-discovered openspec - // change would be pushed as a second epic with lane "superpowers". - const cwd = tmpRepo(); - run(["init"], { cwd }); - // On-disk OpenSpec change directory with tasks.md (no pre-existing epic in state) - const chDir = path.join(cwd, "openspec", "changes", "auth"); - fs.mkdirSync(chDir, { recursive: true }); - fs.writeFileSync(path.join(chDir, "tasks.md"), "- [ ] a\n"); - // Superpowers plan with the same id - fs.mkdirSync(path.join(cwd, "docs", "superpowers", "plans"), { recursive: true }); - fs.writeFileSync(path.join(cwd, "docs", "superpowers", "plans", "auth.md"), "# Auth\n- [ ] a\n"); - // Both are discovered in the same sync run - run(["sync"], { cwd }); - const matches = readState(cwd).epics.filter(x => x.id === "auth"); - assert.equal(matches.length, 1, "expected exactly one 'auth' epic"); - assert.equal(matches[0].lane, "openspec", "openspec change should win over same-run plan"); -}); - -function fixtureCache(versions) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "pm-cache-")); - for (const v of versions) { - const dir = path.join(root, "mp", "pm", v, ".claude-plugin"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "plugin.json"), JSON.stringify({ name: "pm", version: v }) + "\n"); - } - return root; -} - -function fixturePluginRoot(version, changelog) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-plugin-")); - fs.mkdirSync(path.join(dir, ".claude-plugin"), { recursive: true }); - fs.writeFileSync(path.join(dir, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "pm", version }) + "\n"); - if (changelog) fs.writeFileSync(path.join(dir, "CHANGELOG.md"), changelog); - return dir; -} - -const FIXTURE_CHANGELOG = `# Changelog - -## [0.6.0] — 2026-06-25 -### Added -- Feature F6 lands here. - ---- - -## [0.5.0] — 2026-06-24 -### Added -- Feature F5 lands here. - ---- - -## [0.4.0] — 2026-06-23 -### Added -- Feature F4 lands here. -`; - -test("init stamps pmVersion from the running plugin", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.3.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.equal(readState(cwd).pmVersion, "0.3.0"); -}); - -test("brief nudges when stamped pmVersion is older than running (semver-aware)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - // simulate an old repo: stamp 0.9.0, run as 0.10.0 (string compare would get this wrong) - const s = readState(cwd); s.pmVersion = "0.9.0"; writeState(cwd, s); - const root = fixturePluginRoot("0.10.0"); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } })).hookSpecificOutput.additionalContext; - assert.match(out, /pm 0\.9\.0 → 0\.10\.0 since this repo was set up/); - assert.match(out, /\/pm:upgrade/); -}); - -test("no nudge when stamped equals running", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.3.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } })).hookSpecificOutput.additionalContext; - assert.doesNotMatch(out, /since this repo was set up/); -}); - -test("upgrade on a never-stamped repo runs migrations, stamps lanes + pmVersion", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - // simulate a pre-0.3.0 repo: remove pmVersion, add an epic with no lane - const s = readState(cwd); delete s.pmVersion; - s.epics.push({ id: "legacy", title: "legacy", priority: "P1", status: "queued", role: "epic", links: [] }); - writeState(cwd, s); - const root = fixturePluginRoot("0.3.0"); - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const after = readState(cwd); - assert.equal(after.pmVersion, "0.3.0"); - assert.equal(after.epics.find(e => e.id === "legacy").lane, "openspec"); -}); - -test("upgrade from several versions behind applies ALL intermediate migrations", () => { - // A repo two minor versions behind (0.2.0) must replay BOTH the 0.3.0 (lane) and - // 0.5.0 (link-normalize) migrations — not just the most recent one. - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.5.1"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - writeState(cwd, { version: 1, active: null, detourStack: [], pmVersion: "0.2.0", - epics: [{ id: "old", title: "old", priority: "P1", status: "queued", role: "epic", - links: ["blocks:other:was flaky", ""] }] }); - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const after = readState(cwd); - assert.equal(after.pmVersion, "0.5.1"); - const e = after.epics.find(x => x.id === "old"); - assert.equal(e.lane, "openspec"); // 0.3.0 migration fired - assert.deepEqual(e.links, [{ type: "blocks", epic: "other", reason: "was flaky" }]); // 0.5.0 migration fired -}); - -test("upgrade is idempotent on a second run", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const s = readState(cwd); delete s.pmVersion; writeState(cwd, s); - const root = fixturePluginRoot("0.3.0"); - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const first = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const second = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.equal(first, second); -}); - -test("rules block is lane-agnostic, not openspec-only", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = run(["rules"], { cwd }); - assert.match(out, /lane-agnostic/i); - assert.match(out, /openspec \| superpowers \| claude-code/); - assert.doesNotMatch(out, /becomes its own OpenSpec proposal/); -}); - -test("rules block always includes the epic-level autonomy section, with the five-criteria decision rule", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = run(["rules"], { cwd }); - assert.match(out, /## Epic-level autonomy/); - assert.match(out, /set-autonomy/); - assert.match(out, /No backup\/restore path exists\? → STOP/); - assert.match(out, /Destructive but restorable.*→ WARN/); - assert.match(out, /irreversible EXTERNAL side/i); // scope boundary called out explicitly -}); - -test("render is a no-op when content is unchanged (no timestamp churn)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["render"], { cwd }); - const first = fs.readFileSync(path.join(cwd, "PROJECT.md"), "utf8"); - run(["render"], { cwd }); - const second = fs.readFileSync(path.join(cwd, "PROJECT.md"), "utf8"); - assert.equal(first, second); // byte-identical, including the Last rendered line -}); - -test("render rewrites with a fresh stamp when content changes", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["render"], { cwd }); - const before = fs.readFileSync(path.join(cwd, "PROJECT.md"), "utf8"); - const s = readState(cwd); - s.epics.push({ id: "x", title: "x", priority: "P1", status: "queued", role: "epic", lane: "claude-code", links: [] }); - writeState(cwd, s); - run(["render"], { cwd }); - const after = fs.readFileSync(path.join(cwd, "PROJECT.md"), "utf8"); - assert.notEqual(before, after); - assert.match(after, /`x`/); -}); - -test("add-epic accepts --status planned", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "road-1", "--title", "Road 1", "--lane", "openspec", "--status", "planned"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "road-1").status, "planned"); -}); - -test("add-epic and update-epic accept --status later and --status blocked (documented in README, previously rejected)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "deferred", "--lane", "claude-code", "--status", "later"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "deferred").status, "later"); - run(["add-epic", "--id", "stuck", "--lane", "claude-code", "--status", "blocked"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "stuck").status, "blocked"); - run(["update-epic", "deferred", "--status", "blocked"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "deferred").status, "blocked"); -}); - -test("later/blocked epics are excluded from NEXT UP but still appear in the lanes rollup (unlike planned, which is excluded from both)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "ready", title: "ready", priority: "P1", status: "queued", role: "epic", lane: "claude-code", links: [] }, - { id: "deferred", title: "deferred", priority: "P0", status: "later", role: "epic", lane: "claude-code", links: [] }, - { id: "stuck", title: "stuck", priority: "P0", status: "blocked", role: "epic", lane: "claude-code", links: [] }, - ]}); - const brief = parseBrief(cwd); - assert.match(brief, /`ready`/); - assert.doesNotMatch(brief, /`deferred`/); - assert.doesNotMatch(brief, /`stuck`/); - assert.match(brief, /lanes: claude-code 3/); // rollup counts all three, unlike planned -}); - -test("add-epic rejects an unknown --status", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - assert.ok(expectFail(() => run(["add-epic", "--id", "x", "--lane", "openspec", "--status", "bogus"], { cwd }))); -}); - -test("add-epic rejects a valueless --id and writes nothing", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - assert.ok(expectFail(() => run(["add-epic", "--lane", "openspec", "--id"], { cwd }))); - assert.equal(readState(cwd).epics.length, 0); -}); - -test("add-epic tolerates a valueless --link without crashing", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "y", "--lane", "claude-code", "--link"], { cwd }); // must not throw - assert.deepEqual(readState(cwd).epics.find(e => e.id === "y").links, []); -}); - -test("ACCEPTANCE: 30 lane-tagged epics, zero OpenSpec changes", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const lanes = ["superpowers", "claude-code", "decision"]; - for (let i = 0; i < 30; i++) { - const lane = lanes[i % lanes.length]; - const pr = `P${i % 4}`; - run(["add-epic", "--id", `item-${String(i).padStart(2, "0")}`, "--title", `Item ${i}`, - "--lane", lane, "--priority", pr], { cwd }); - } - // mark one superpowers epic active with manual progress - const s = readState(cwd); - const target = s.epics.find(e => e.lane === "superpowers"); - target.status = "active"; - target.stories = [{ title: "a", done: true }, { title: "b", done: false }]; - s.active = target.id; - writeState(cwd, s); - run(["render"], { cwd }); - - // all 30 registered, none from OpenSpec - assert.equal(readState(cwd).epics.length, 30); - assert.equal(fs.existsSync(path.join(cwd, "openspec")), false); - - // PROJECT.md shows them with lanes and the active one's progress - const md = projectMd(cwd); - for (let i = 0; i < 30; i++) assert.match(md, new RegExp(`item-${String(i).padStart(2, "0")}`)); - assert.match(md, /1\/2 stories/); // active epic's manual progress rendered - assert.match(md, new RegExp(`\`${target.id}\``)); - - // brief is bounded and shows lane counts - const brief = parseBrief(cwd); - assert.match(brief, /NOW: `/); - assert.match(brief, /lanes: /); - assert.match(brief, /\(\+\d+ more — see PROJECT\.md\)/); -}); - -test("planned openspec epic: not missing, not in NEXT UP, counted, in table", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "4c", title: "4c", priority: "P0", status: "planned", role: "epic", lane: "openspec", links: [] }, - ]}); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.doesNotMatch(md, /no change on disk/); // not flagged missing - assert.match(md, /`4c` \| openspec \| epic \| planned/); // shown in Epics table - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /NEXT UP/); // not actionable - assert.match(brief, /planned: 1 — see PROJECT\.md/); -}); - -test("planned epics do not inflate the brief lanes: rollup", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "q1", title: "q1", priority: "P1", status: "queued", role: "epic", lane: "superpowers", stories: [{ title: "a", done: false }], links: [] }, - { id: "p1", title: "p1", priority: "P0", status: "planned", role: "epic", lane: "openspec", links: [] }, - ]}); - const brief = parseBrief(cwd); - assert.match(brief, /lanes: superpowers 1/); - assert.doesNotMatch(brief, /openspec 1/); // planned openspec excluded from lanes rollup - assert.match(brief, /planned: 1/); -}); - -test("sync auto-transitions a planned openspec epic to untriaged once its change dir exists", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "feat-z", "--lane", "openspec", "--priority", "P1", "--status", "planned"], { cwd }); - assert.doesNotMatch(parseBrief(cwd), /`feat-z`/); // planned → not in NEXT UP yet - fs.mkdirSync(path.join(cwd, "openspec", "changes", "feat-z"), { recursive: true }); - fs.writeFileSync(path.join(cwd, "openspec", "changes", "feat-z", "tasks.md"), "- [ ] a\n"); - run(["sync"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "feat-z").status, "untriaged"); - assert.match(parseBrief(cwd), /`feat-z`/); // now actionable -}); - -test("sync does not transition a non-openspec planned epic (lane guard)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "dual", "--lane", "claude-code", "--status", "planned"], { cwd }); - fs.mkdirSync(path.join(cwd, "openspec", "changes", "dual"), { recursive: true }); - run(["sync"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "dual"); - assert.equal(e.lane, "claude-code"); - assert.equal(e.status, "planned"); // lane guard: not flipped despite a matching change dir -}); - -test("rules block mentions planned status and the roadmap on-ramp", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - const out = run(["rules"], { cwd }); - assert.match(out, /planned/); - assert.match(out, /roadmap/i); -}); - -test("upgrade refuses when a newer pm is installed than the running engine", () => { - const cwd = tmpRepo(); - const root03 = fixturePluginRoot("0.4.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root03 } }); - const stampedBefore = readState(cwd).pmVersion; // 0.4.0 - const cache = fixtureCache(["0.4.0", "0.4.1"]); - const err = expectFail(() => run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root03, PM_CACHE_ROOT: cache } })); - assert.ok(err, "expected non-zero exit when stale"); - assert.match(String(err.stderr || err.message), /0\.4\.0.*0\.4\.1|0\.4\.1.*installed/); - assert.equal(readState(cwd).pmVersion, stampedBefore); // unchanged — no mutation -}); - -test("upgrade proceeds when the running engine is the newest installed", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.4.1"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const cache = fixtureCache(["0.4.0", "0.4.1"]); - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } }); - assert.equal(readState(cwd).pmVersion, "0.4.1"); -}); - -test("upgrade proceeds when the cache cannot be read (newest null)", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.4.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - // PM_CACHE_ROOT defaults to the empty cache → newest is null → guard no-op - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.equal(readState(cwd).pmVersion, "0.4.0"); -}); - -test("newest-version semver: 0.10.0 beats 0.9.0 (guard fires)", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.9.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const cache = fixtureCache(["0.9.0", "0.10.0"]); - const err = expectFail(() => run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } })); - assert.ok(err, "0.10.0 must be treated as newer than 0.9.0"); -}); - -test("nudge fires from newest-installed even when the running engine is old", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.3.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); // stamps 0.3.0 - const cache = fixtureCache(["0.3.0", "0.4.1"]); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } })) - .hookSpecificOutput.additionalContext; - assert.match(out, /pm 0\.3\.0 → 0\.4\.1 available/); - assert.match(out, /\/reload-plugins/); - assert.match(out, /\/pm:upgrade/); -}); - -test("nudge inlines top Added-bullet headlines from versions between stamped and newest", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.4.0", FIXTURE_CHANGELOG); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); // stamps 0.4.0 - const cache = fixtureCache(["0.4.0", "0.6.0"]); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } })) - .hookSpecificOutput.additionalContext; - assert.match(out, /pm 0\.4\.0 → 0\.6\.0 available/); - assert.match(out, /Feature F6 lands here\./); - assert.match(out, /Feature F5 lands here\./); - assert.doesNotMatch(out, /Feature F4 lands here\./); // at/below stamped version, excluded -}); - -test("nudge headlines are capped at 3 even across many in-between versions", () => { - const cwd = tmpRepo(); - const changelog = `# Changelog - -## [0.4.0] — 2026-06-26 -### Added -- Feature FA lands here. - ---- - -## [0.3.0] — 2026-06-25 -### Added -- Feature FB lands here. - ---- - -## [0.2.0] — 2026-06-24 -### Added -- Feature FC lands here. - ---- - -## [0.1.0] — 2026-06-23 -### Added -- Feature FD lands here. -`; - const root = fixturePluginRoot("0.1.0", changelog); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); // stamps 0.1.0 - const cache = fixtureCache(["0.1.0", "0.4.0"]); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } })) - .hookSpecificOutput.additionalContext; - assert.match(out, /Feature FA lands here\./); - assert.match(out, /Feature FB lands here\./); - assert.match(out, /Feature FC lands here\./); - assert.doesNotMatch(out, /Feature FD lands here\./); // 4th headline, over the cap of 3 -}); - -test("nudge has no headlines section when the plugin ships no CHANGELOG", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.3.0"); // no changelog file - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const cache = fixtureCache(["0.3.0", "0.4.1"]); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } })) - .hookSpecificOutput.additionalContext; - assert.match(out, /pm 0\.3\.0 → 0\.4\.1 available/); -}); - -test("no nudge when stamped equals newest installed", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.4.1"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const cache = fixtureCache(["0.4.1"]); - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root, PM_CACHE_ROOT: cache } })) - .hookSpecificOutput.additionalContext; - assert.doesNotMatch(out, /available —/); - assert.doesNotMatch(out, /since this repo was set up/); -}); - -// ─────────────── 0.7.0: set-active / clear-active + active↔status ─────────────── - -test("set-active sets the .active pointer and the epic's status, demoting a prior active", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "b", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - let s = readState(cwd); - assert.equal(s.active, "a"); - assert.equal(s.epics.find(e => e.id === "a").status, "active"); - run(["set-active", "b"], { cwd }); - s = readState(cwd); - assert.equal(s.active, "b"); - assert.equal(s.epics.find(e => e.id === "b").status, "active"); - assert.equal(s.epics.find(e => e.id === "a").status, "queued"); // prior active demoted -}); - -test("set-active rejects an unknown or archived id and writes nothing", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "real", "--lane", "claude-code"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run(["set-active", "ghost"], { cwd })), "unknown id rejected"); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); - // archived id - fs.mkdirSync(path.join(cwd, "openspec", "changes", "archive", "2026-07-08-done"), { recursive: true }); - run(["add-epic", "--id", "done", "--lane", "openspec"], { cwd }); - assert.ok(expectFail(() => run(["set-active", "done"], { cwd })), "archived id rejected"); -}); - -test("clear-active nulls the pointer and demotes the active epic", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - run(["clear-active"], { cwd }); - const s = readState(cwd); - assert.equal(s.active, null); - assert.equal(s.epics.find(e => e.id === "a").status, "queued"); -}); - -test("update-epic --status active also sets the .active pointer (no desync)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--status", "active"], { cwd }); - const s = readState(cwd); - assert.equal(s.active, "a"); // the reported footgun, fixed - assert.equal(s.epics.find(e => e.id === "a").status, "active"); - assert.match(parseBrief(cwd), /NOW: `a`/); -}); - -test("update-epic moving the active epic off active clears the pointer", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - run(["update-epic", "a", "--status", "queued"], { cwd }); - const s = readState(cwd); - assert.equal(s.active, null); - assert.equal(s.epics.find(e => e.id === "a").status, "queued"); -}); - -test("add-epic --status active sets the .active pointer too", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code", "--status", "active"], { cwd }); - assert.equal(readState(cwd).active, "a"); -}); - -// ──────────────── epic-level autonomy: set-autonomy ──────────────── - -test("set-autonomy sets level and rejects an unknown level", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-autonomy", "a", "--level", "autonomous"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "a").autonomy.level, "autonomous"); - assert.ok(expectFail(() => run(["set-autonomy", "a", "--level", "bogus"], { cwd })), "bad level rejected"); -}); - -test("set-autonomy records preauthorize/context/notify entries, repeatable and merged across calls", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-autonomy", "a", - "--preauthorize", "drop-scratch-table:reviewed, safe to drop", - "--preauthorize", "rename-field:no external readers", - "--context", "staging DB only, no prod access", - ], { cwd }); - let a = readState(cwd).epics.find(e => e.id === "a").autonomy; - assert.equal(a.preAuthorized.length, 2); - assert.deepEqual( - { action: a.preAuthorized[0].action, reason: a.preAuthorized[0].reason }, - { action: "drop-scratch-table", reason: "reviewed, safe to drop" }, - ); - assert.ok(a.preAuthorized[0].grantedAt); // timestamp present - assert.deepEqual(a.context, ["staging DB only, no prod access"]); - - // a second call APPENDS, does not clobber - run(["set-autonomy", "a", "--notify", "ran a schema migration"], { cwd }); - a = readState(cwd).epics.find(e => e.id === "a").autonomy; - assert.equal(a.preAuthorized.length, 2); // unchanged by the second call - assert.equal(a.notifications.length, 1); - assert.equal(a.notifications[0].what, "ran a schema migration"); - assert.ok(a.notifications[0].when); -}); - -test("set-autonomy supports a category-based --preauthorize shorthand distinct from exact-action grants", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-autonomy", "a", - "--preauthorize", "category:filesystem:routine scratch-file cleanup", - "--preauthorize", "delete-legacy-config:reviewed, one-off", - "--preauthorize", "category:network:internal health checks only", - ], { cwd }); - const a = readState(cwd).epics.find(e => e.id === "a").autonomy; - assert.equal(a.preAuthorized.length, 3); - - const catEntry = a.preAuthorized.find(e => e.category === "filesystem"); - assert.ok(catEntry, "filesystem category entry recorded"); - assert.equal(catEntry.action, undefined); // category entries carry no `action` - assert.equal(catEntry.reason, "routine scratch-file cleanup"); - assert.ok(catEntry.grantedAt); - - const actionEntry = a.preAuthorized.find(e => e.action === "delete-legacy-config"); - assert.ok(actionEntry, "exact-action entry still recorded unchanged"); - assert.equal(actionEntry.category, undefined); // exact-action entries carry no `category` - assert.equal(actionEntry.reason, "reviewed, one-off"); - - const netEntry = a.preAuthorized.find(e => e.category === "network"); - assert.ok(netEntry); - assert.equal(netEntry.reason, "internal health checks only"); -}); - -test("set-autonomy rejects an unknown --preauthorize category", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - assert.ok(expectFail(() => run(["set-autonomy", "a", - "--preauthorize", "category:bogus-category:whatever", - ], { cwd })), "unknown category rejected"); -}); - -test("set-autonomy on an unknown id exits non-zero and writes nothing", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run(["set-autonomy", "ghost", "--level", "autonomous"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("render marks an autonomous epic with 🤖 in its Status cell; a plain epic gets no marker", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "auto", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "plain", "--lane", "claude-code"], { cwd }); - run(["set-autonomy", "auto", "--level", "autonomous"], { cwd }); - const md = projectMd(cwd); - const autoLine = md.split("\n").find(l => l.includes("`auto`")); - const plainLine = md.split("\n").find(l => l.includes("`plain`")); - assert.match(autoLine, /🤖/); - assert.doesNotMatch(plainLine, /🤖/); -}); - -test("brief NOW line shows 🤖 autonomous only when the active epic is autonomous", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code", "--status", "active"], { cwd }); - assert.doesNotMatch(parseBrief(cwd), /🤖/); - run(["set-autonomy", "a", "--level", "autonomous"], { cwd }); - assert.match(parseBrief(cwd), /NOW: `a`.*🤖 autonomous/); -}); - -// ──────────────── 0.6.1: date-prefixed archive detection ──────────────── - -function withArchivedChange(cwd, id) { - fs.mkdirSync(path.join(cwd, "openspec", "changes", "archive", `2026-06-25-${id}`), { recursive: true }); - writeState(cwd, { version: 1, active: id, detourStack: [], epics: [ - { id, title: id, priority: "P0", status: "active", role: "epic", lane: "openspec", links: [] }] }); -} - -test("isArchived recognizes a date-prefixed openspec archive dir (status flips, no ghost)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - withArchivedChange(cwd, "feat-x"); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /`feat-x` \| openspec \| epic \| archived/); // derived status = archived - assert.doesNotMatch(md, /no change on disk/); // not a false ghost -}); - -test("brief does not show an archived epic as active, and stays read-only", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - withArchivedChange(cwd, "feat-x"); - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /NOW: `feat-x`/); // not presented as active - assert.match(brief, /was archived/); // honest note instead - assert.equal(readState(cwd).active, "feat-x"); // brief did NOT mutate state (read path) -}); - -test("sync clears an archived active pointer and stamps archived status", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - withArchivedChange(cwd, "feat-x"); - run(["sync"], { cwd }); - const s = readState(cwd); - assert.equal(s.active, null); - assert.equal(s.epics.find(e => e.id === "feat-x").status, "archived"); -}); - -test("commit-nudge self-heals an archived active pointer after a git commit", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - withArchivedChange(cwd, "feat-x"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "archive feat-x"' } }) }); - const s = readState(cwd); - assert.equal(s.active, null); - assert.equal(s.epics.find(e => e.id === "feat-x").status, "archived"); -}); - -// ───────── recompute-don't-remember: active validity + reconcileNeeded self-heal ───────── - -test("render clears a dangling active pointer that references a completely missing epic (not just an archived one)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: "ghost-id", detourStack: [], epics: [ - { id: "real", title: "real", priority: "P1", status: "queued", role: "epic", lane: "claude-code", links: [] }, - ]}); - run(["render"], { cwd }); - assert.equal(readState(cwd).active, null); -}); - -test("render recomputes reconcileNeeded from the detour stack rather than trusting a stored flag", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { - version: 1, active: "paused-a", detourStack: [ - { pausedEpic: "paused-a", pausedAt: "2026-07-14T00:00:00Z", reason: "x", spawnedDetour: "d1", reconcileOnResume: true }, - ], - epics: [ - // stale true with no matching frame → should be healed to false - { id: "stale-true", title: "stale-true", priority: "P1", status: "queued", role: "epic", lane: "claude-code", links: [], reconcileNeeded: true }, - // missing/false but IS the pausedEpic of a reconcileOnResume frame → should be healed to true - { id: "paused-a", title: "paused-a", priority: "P1", status: "paused", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, - ], - }); - run(["render"], { cwd }); - const s = readState(cwd); - assert.equal(s.epics.find(e => e.id === "stale-true").reconcileNeeded, false); - assert.equal(s.epics.find(e => e.id === "paused-a").reconcileNeeded, true); -}); - -test("render NEVER clears reconcileNeeded on the currently active epic, even with no live detour frame (the legitimate just-popped, pre-reconcile window)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - // POP protocol removes the detour-stack frame BEFORE reconciliation runs, so an active - // epic can legitimately still owe reconcile with an EMPTY detourStack. A naive recompute - // that derives reconcileNeeded purely from live-frame presence would wipe this out at - // exactly the moment it matters most — this regression test locks that in. - writeState(cwd, { - version: 1, active: "just-resumed", detourStack: [], - epics: [ - { id: "just-resumed", title: "just-resumed", priority: "P1", status: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: true }, - ], - }); - run(["render"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "just-resumed").reconcileNeeded, true); -}); - -test("brief displays the recomputed truth but stays read-only, even for the new active/reconcile checks", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: "ghost-id", detourStack: [], epics: [ - { id: "real", title: "real", priority: "P1", status: "queued", role: "epic", lane: "claude-code", links: [] }, - ]}); - const brief = parseBrief(cwd); - assert.match(brief, /NOW: \(no active epic set\)/); - assert.equal(readState(cwd).active, "ghost-id"); // brief did NOT mutate state (read path) -}); - -// ───────────────────── 0.6.0: changelog surfacing ───────────────────── - -test("changelog --since lists only entries newer than the given version", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.6.0", FIXTURE_CHANGELOG); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const out = run(["changelog", "--since", "0.4.0"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.match(out, /Feature F6/); - assert.match(out, /Feature F5/); - assert.doesNotMatch(out, /Feature F4/); // 0.4.0 is the floor, excluded -}); - -test("changelog defaults --since to the version stamped in this repo", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.6.0", FIXTURE_CHANGELOG); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const s = readState(cwd); s.pmVersion = "0.5.0"; writeState(cwd, s); - const out = run(["changelog"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.match(out, /Feature F6/); - assert.doesNotMatch(out, /Feature F5/); // 0.5.0 not newer than stamped 0.5.0 -}); - -test("changelog is graceful when the plugin ships no CHANGELOG", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.6.0"); // no changelog file - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const out = run(["changelog", "--since", "0.1.0"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.match(out, /no CHANGELOG/i); -}); - -test("upgrade prints the changelog delta for the versions it crossed", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.6.0", FIXTURE_CHANGELOG); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const s = readState(cwd); s.pmVersion = "0.4.0"; writeState(cwd, s); - const out = run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.match(out, /What's new/i); - assert.match(out, /Feature F6/); - assert.match(out, /Feature F5/); - assert.doesNotMatch(out, /Feature F4/); // from-version excluded -}); - -test("upgrade prints no changelog delta on an idempotent re-run", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.6.0", FIXTURE_CHANGELOG); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); // stamps 0.6.0 == running - const out = run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.doesNotMatch(out, /Feature F6/); -}); - -test("nudge falls back to running-version comparison when cache is unreadable", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.3.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const newer = fixturePluginRoot("0.4.1"); - // default PM_CACHE_ROOT (empty) → newest null → fallback compares stamped(0.3.0) vs running(0.4.1) - const out = JSON.parse(run(["brief"], { cwd, env: { CLAUDE_PLUGIN_ROOT: newer } })) - .hookSpecificOutput.additionalContext; - assert.match(out, /since this repo was set up/); -}); - -// ───────────────────────── 0.5.0: epic hierarchy ───────────────────────── - -test("0.4.1-shaped state (no parent/externalId/tracker) loads and renders unchanged", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - // A state exactly as v0.4.1 would write it — no new fields anywhere. - writeState(cwd, { - version: 1, active: "live", detourStack: [], pmVersion: "0.4.1", - epics: [ - { id: "live", title: "Live one", priority: "P0", status: "active", role: "epic", lane: "openspec", links: [], reconcileNeeded: false }, - { id: "q", title: "Queued", priority: "P1", status: "queued", role: "epic", lane: "superpowers", stories: [{ title: "a", done: false }], links: [] }, - ], - }); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /`live`/); - assert.match(md, /`q`/); - assert.doesNotMatch(md, /undefined/); - const brief = parseBrief(cwd); - assert.match(brief, /NOW: `live`/); - assert.doesNotMatch(brief, /undefined/); -}); - -test("add-epic --parent sets parent when the parent exists", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "sprint", "--lane", "external", "--priority", "P0"], { cwd }); - run(["add-epic", "--id", "child-1", "--lane", "external", "--parent", "sprint"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "child-1").parent, "sprint"); -}); - -test("add-epic --parent rejects a non-existent parent and writes nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const before = readState(cwd).epics.length; - const err = expectFail(() => run(["add-epic", "--id", "orphan", "--lane", "external", "--parent", "nope"], { cwd })); - assert.ok(err, "expected non-zero exit for missing parent"); - assert.match(String(err.stderr || err.message), /parent/i); - assert.equal(readState(cwd).epics.length, before); -}); - -test("render groups children under their parent with indent, rollup, and sorted siblings", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "sprint", title: "Sprint", priority: "P0", status: "queued", role: "epic", lane: "external", links: [] }, - { id: "c-b", title: "cb", priority: "P1", status: "queued", role: "epic", lane: "external", parent: "sprint", links: [] }, - { id: "c-a", title: "ca", priority: "P0", status: "archived", role: "epic", lane: "external", parent: "sprint", links: [] }, - ]}); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /└─ `c-a`/); // children indented - assert.match(md, /└─ `c-b`/); - assert.match(md, /1\/2 children archived/); // rollup on the parent row - assert.ok(md.indexOf("`sprint`") < md.indexOf("`c-a`"), "parent renders before its children"); - assert.ok(md.indexOf("`c-a`") < md.indexOf("`c-b`"), "siblings sorted by priority (P0 before P1)"); -}); - -test("render indents grandchildren one level deeper", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "p", title: "p", priority: "P0", status: "queued", role: "epic", lane: "external", links: [] }, - { id: "c", title: "c", priority: "P0", status: "queued", role: "epic", lane: "external", parent: "p", links: [] }, - { id: "gc", title: "gc", priority: "P0", status: "queued", role: "epic", lane: "external", parent: "c", links: [] }, - ]}); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /└─ `c`/); - assert.match(md, /└─ └─ `gc`/); -}); - -test("brief keeps a child's priority slot in NEXT UP and annotates its parent", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "par", title: "par", priority: "P2", status: "queued", role: "epic", lane: "external", links: [] }, - { id: "kid", title: "kid", priority: "P0", status: "queued", role: "epic", lane: "external", parent: "par", links: [] }, - ]}); - const brief = parseBrief(cwd); - assert.ok(brief.indexOf("`kid`") < brief.indexOf("`par`"), "P0 child outranks its P2 parent in NEXT UP"); - assert.match(brief, /`kid`[^\n]*parent: `par`/); // child annotated with its parent -}); - -// ───────────────────────── 0.5.0: defensive render ───────────────────────── - -test("malformed links never render as undefined, valid links still show", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "a", title: "a", priority: "P1", status: "queued", role: "epic", lane: "external", - links: [{ reason: "broken — no type/epic" }, { type: "blocks", epic: "b" }] }, - { id: "b", title: "b", priority: "P1", status: "queued", role: "epic", lane: "external", links: [] }, - ]}); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.doesNotMatch(md, /undefined/); - assert.match(md, /blocks→b/); // valid link still rendered in the table - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /undefined/); - assert.match(brief, /`a` blocks `b`/); // valid link still rendered in EPIC LINKS -}); - -// ─────────────────── 0.5.0: external-tracker awareness ─────────────────── - -test("set-tracker writes a tracker block with a multi-entry statusIntent map", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--instance", "onvex", "--project", "JOB", - "--mechanism", "mcp", "--intent", "active:in-progress", "--intent", "paused:todo", - "--intent", "archived:done"], { cwd }); - const t = readState(cwd).tracker; - assert.equal(t.system, "jira"); - assert.equal(t.instance, "onvex"); - assert.equal(t.projectKey, "JOB"); - assert.equal(t.mechanism, "mcp"); - assert.deepEqual(t.statusIntent, { active: "in-progress", paused: "todo", archived: "done" }); -}); - -test("add-epic stores externalId/externalUrl", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "job-506", "--lane", "external", - "--external-id", "JOB-506", "--external-url", "https://onvex.example/JOB-506"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "job-506"); - assert.equal(e.externalId, "JOB-506"); - assert.equal(e.externalUrl, "https://onvex.example/JOB-506"); -}); - -test("update-epic records external id/url onto an existing epic (write-back)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "job-507", "--lane", "external"], { cwd }); - run(["update-epic", "job-507", "--external-id", "JOB-507", "--external-url", "https://onvex.example/JOB-507"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "job-507"); - assert.equal(e.externalId, "JOB-507"); - assert.equal(e.externalUrl, "https://onvex.example/JOB-507"); -}); - -// ────────────── github-issues tracker: inward pull (issues → untriaged epics) ────────────── - -test("set-tracker --system github-issues --repo stores the repo alongside system", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "github-issues", "--repo", "cfdude/pm"], { cwd }); - const t = readState(cwd).tracker; - assert.equal(t.system, "github-issues"); - assert.equal(t.repo, "cfdude/pm"); -}); - -test("rules block gains a GitHub issue sync section (gh issue list -> add-epic) only for a github-issues tracker", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.doesNotMatch(claudeMd(cwd), /GitHub issue sync/); - run(["set-tracker", "--system", "github-issues", "--repo", "cfdude/pm"], { cwd }); - const md = claudeMd(cwd); - assert.match(md, /GitHub issue sync/); - assert.match(md, /gh issue list --repo cfdude\/pm --state open/); - assert.match(md, /externalId/); - assert.match(md, /add-epic --status untriaged/); - assert.match(md, /--lane claude-code/); - assert.match(md, /--priority P2/); -}); - -test("a jira tracker does not get the GitHub issue sync section", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - assert.doesNotMatch(claudeMd(cwd), /GitHub issue sync/); -}); - -test("a github-issues tracker suppresses the outward External tracker sync section — inward-only by design", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "github-issues", "--repo", "cfdude/pm"], { cwd }); - const md = claudeMd(cwd); - assert.doesNotMatch(md, /External tracker sync/); - assert.doesNotMatch(md, /has no `externalId` → create the/); - assert.match(md, /GitHub issue sync/); -}); - -test("a jira tracker keeps the outward External tracker sync section fully intact — bidirectional", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - const md = claudeMd(cwd); - assert.match(md, /External tracker sync/); - assert.match(md, /has no `externalId` → create the/); -}); - -test("add-epic rejects a duplicate --external-id, leaving state unchanged (dedup by externalId)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "gh-42", "--lane", "claude-code", "--status", "untriaged", - "--external-id", "42", "--external-url", "https://github.com/cfdude/pm/issues/42"], { cwd }); - const before = readState(cwd).epics.length; - const err = expectFail(() => run(["add-epic", "--id", "gh-42-dup", "--lane", "claude-code", - "--status", "untriaged", "--external-id", "42", - "--external-url", "https://github.com/cfdude/pm/issues/42"], { cwd })); - assert.match(String(err.stderr || err.message), /external-id '42' already/); - const after = readState(cwd); - assert.equal(after.epics.length, before); - assert.ok(!after.epics.some(e => e.id === "gh-42-dup")); -}); - -test("update-epic's own --external-id write is unaffected by the add-epic dedup guard", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "gh-43", "--lane", "claude-code"], { cwd }); - run(["update-epic", "gh-43", "--external-id", "43", "--external-url", "https://github.com/cfdude/pm/issues/43"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "gh-43"); - assert.equal(e.externalId, "43"); -}); - -test("update-epic re-status/re-priority works; self-parent and cycle are rejected", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "external"], { cwd }); - run(["add-epic", "--id", "b", "--lane", "external", "--parent", "a"], { cwd }); // b under a - run(["update-epic", "a", "--status", "active", "--priority", "P0"], { cwd }); - const e = readState(cwd).epics.find(x => x.id === "a"); - assert.equal(e.status, "active"); - assert.equal(e.priority, "P0"); - assert.ok(expectFail(() => run(["update-epic", "a", "--parent", "a"], { cwd })), "self-parent rejected"); - assert.ok(expectFail(() => run(["update-epic", "a", "--parent", "b"], { cwd })), "cycle rejected"); - assert.equal(readState(cwd).epics.find(x => x.id === "a").parent, undefined); -}); - -test("update-epic on an unknown id exits non-zero and writes nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "real", "--lane", "external"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - const err = expectFail(() => run(["update-epic", "ghost", "--status", "active"], { cwd })); - assert.ok(err, "expected non-zero exit for unknown id"); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("update-epic --title updates an existing epic's title, mirroring add-epic", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--title", "Old title", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--title", "New, corrected title"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "a").title, "New, corrected title"); -}); - -// ─────────────── update-epic --add-story / --story --done (df-update-epic-no-story-toggle-verb) ─────────────── - -test("update-epic --add-story appends { title, done: false } to a fresh stories[] array", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--add-story", "First story"], { cwd }); - const epic = readState(cwd).epics.find(e => e.id === "a"); - assert.deepEqual(epic.stories, [{ title: "First story", done: false }]); -}); - -test("update-epic --add-story appends to an existing stories[] array without disturbing earlier entries", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--add-story", "First story"], { cwd }); - run(["update-epic", "a", "--add-story", "Second story"], { cwd }); - const epic = readState(cwd).epics.find(e => e.id === "a"); - assert.deepEqual(epic.stories, [ - { title: "First story", done: false }, - { title: "Second story", done: false }, - ]); -}); - -test("update-epic --add-story rejects an empty/blank title and writes nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - const err = expectFail(() => run(["update-epic", "a", "--add-story", " "], { cwd })); - assert.ok(err); - assert.match(String(err.stderr || err.message), /non-empty title/); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("update-epic --story --done marks the n-th (1-indexed) story done, leaving others untouched", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--add-story", "First story"], { cwd }); - run(["update-epic", "a", "--add-story", "Second story"], { cwd }); - run(["update-epic", "a", "--story", "2", "--done"], { cwd }); - const epic = readState(cwd).epics.find(e => e.id === "a"); - assert.deepEqual(epic.stories, [ - { title: "First story", done: false }, - { title: "Second story", done: true }, - ]); -}); - -test("update-epic --story out of range (including 0, and beyond the array length) is rejected and writes nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--add-story", "Only story"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - for (const bad of ["0", "2", "-1"]) { - const err = expectFail(() => run(["update-epic", "a", "--story", bad, "--done"], { cwd })); - assert.ok(err, `expected --story ${bad} to be rejected`); - assert.match(String(err.stderr || err.message), /out of range/); - } - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("update-epic --story without --done is rejected (only supported story mutation today), and --done without --story is rejected", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["update-epic", "a", "--add-story", "Only story"], { cwd }); - const err1 = expectFail(() => run(["update-epic", "a", "--story", "1"], { cwd })); - assert.ok(err1); - assert.match(String(err1.stderr || err1.message), /requires --done/); - const err2 = expectFail(() => run(["update-epic", "a", "--done"], { cwd })); - assert.ok(err2); - assert.match(String(err2.stderr || err2.message), /requires --story/); -}); - -test("update-epic rejects an unrecognized flag instead of silently no-op'ing, and writes nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--title", "Original", "--lane", "claude-code"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - const err = expectFail(() => run(["update-epic", "a", "--titel", "Typo'd flag name"], { cwd })); - assert.ok(err, "expected non-zero exit for an unknown flag"); - assert.match(String(err.stderr || err.message), /unknown flag/); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); - assert.equal(readState(cwd).epics.find(e => e.id === "a").title, "Original"); -}); - -test("rules block always includes the Review mode section, defaulting to standard when never set", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = run(["rules"], { cwd }); - assert.match(out, /## Review mode/); - assert.match(out, /set-review-mode/); - assert.match(out, /\| `off` \|/); - assert.match(out, /\| `standard` \|/); - assert.match(out, /\| `thorough` \|/); - assert.match(out, /Current mode: \*\*standard\*\*/); -}); - -test("rules block always includes the Feedback section encouraging /pm:feedback adoption", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = run(["rules"], { cwd }); - assert.match(out, /## Feedback/); - assert.match(out, /\/pm:feedback \[bug\|feature\]/); - assert.match(out, /want me to file this as feedback/i); -}); - -test("set-review-mode sets the active mode and rejects an unknown mode", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["set-review-mode", "--mode", "thorough"], { cwd }); - assert.equal(readState(cwd).reviewMode, "thorough"); - assert.match(run(["rules"], { cwd }), /Current mode: \*\*thorough\*\*/); - assert.ok(expectFail(() => run(["set-review-mode", "--mode", "bogus"], { cwd })), "bad mode rejected"); - assert.ok(expectFail(() => run(["set-review-mode"], { cwd })), "missing --mode rejected"); -}); - -test("update-epic --review-mode escalates above the repo-global dial but never de-escalates below it", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--title", "Security-sensitive epic", "--lane", "claude-code"], { cwd }); - - // Repo dial defaults to "standard". Escalating a single epic to "thorough" is allowed. - run(["update-epic", "a", "--review-mode", "thorough"], { cwd }); - assert.equal(readState(cwd).epics.find(e => e.id === "a").reviewMode, "thorough"); - - // Now raise the repo dial to "thorough" and try to set the epic override to "standard" — - // that would de-escalate below the (now higher) global dial, so it must be rejected. - run(["set-review-mode", "--mode", "thorough"], { cwd }); - const err = expectFail(() => run(["update-epic", "a", "--review-mode", "standard"], { cwd })); - assert.ok(err, "expected rejection of a de-escalating override"); - assert.match(String(err.stderr || err.message), /de-escalate|below/); - // State must be unchanged by the rejected attempt. - assert.equal(readState(cwd).epics.find(e => e.id === "a").reviewMode, "thorough"); - - // An unknown mode is still rejected outright. - assert.ok(expectFail(() => run(["update-epic", "a", "--review-mode", "bogus"], { cwd })), "bad mode rejected"); -}); - -test("currentReviewMode(epicId) returns the higher of the repo-global dial and the epic's override", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--title", "Epic A", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "b", "--title", "Epic B", "--lane", "claude-code"], { cwd }); - - // Global standard, no override on either epic -> both effectively standard. - assert.match(run(["rules", "--epic", "a"], { cwd }), /Current mode: \*\*standard\*\*/); - - // Escalate epic 'a' to thorough; epic 'b' stays at the global standard dial. - run(["update-epic", "a", "--review-mode", "thorough"], { cwd }); - assert.match(run(["rules", "--epic", "a"], { cwd }), /Current mode: \*\*thorough\*\*/); - assert.match(run(["rules", "--epic", "b"], { cwd }), /Current mode: \*\*standard\*\*/); - - // Raising the global dial past an epic's override makes the global dial win again. - run(["set-review-mode", "--mode", "thorough"], { cwd }); - run(["set-review-mode", "--mode", "off"], { cwd }); - assert.match(run(["rules", "--epic", "b"], { cwd }), /Current mode: \*\*off\*\*/); -}); - -test("set-gate-guard toggles the opt-in flag and rejects an invalid value", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - assert.equal(readState(cwd).gateGuard, undefined); // off by default, never written until set - run(["set-gate-guard", "on"], { cwd }); - assert.equal(readState(cwd).gateGuard, true); - run(["set-gate-guard", "off"], { cwd }); - assert.equal(readState(cwd).gateGuard, false); - assert.ok(expectFail(() => run(["set-gate-guard", "bogus"], { cwd })), "invalid value rejected"); -}); - -test("gate-guard blocks by default (no set-gate-guard needed) when the active epic owes a reconcile", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: "a", detourStack: [], epics: [ - { id: "a", title: "a", priority: "P1", status: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: true }, - ]}); - const err = expectFail(() => run(["gate-guard"], { cwd, input: "{}" })); - assert.ok(err, "expected a block"); - assert.match(String(err.stderr || err.message), /still owes a reconcile/); -}); - -test("gate-guard blocks (exit non-zero, reason on stderr) when enabled and the active epic owes a reconcile", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["set-gate-guard", "on"], { cwd }); - writeState(cwd, { version: 1, active: "a", detourStack: [], gateGuard: true, epics: [ - { id: "a", title: "a", priority: "P1", status: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: true }, - ]}); - const err = expectFail(() => run(["gate-guard"], { cwd, input: "{}" })); - assert.ok(err, "expected a block"); - assert.match(String(err.stderr || err.message), /still owes a reconcile/); -}); - -test("gate-guard does not block when enabled but the active epic does not owe a reconcile", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: "a", detourStack: [], gateGuard: true, epics: [ - { id: "a", title: "a", priority: "P1", status: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, - ]}); - run(["gate-guard"], { cwd, input: "{}" }); // does not throw -}); - -test("gate-guard does not block when explicitly off and the active epic does not owe a reconcile", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["set-gate-guard", "off"], { cwd }); - writeState(cwd, { version: 1, active: "a", detourStack: [], gateGuard: false, epics: [ - { id: "a", title: "a", priority: "P1", status: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, - ]}); - run(["gate-guard"], { cwd, input: "{}" }); // does not throw -}); - -test("gate-guard still blocks on reconcileNeeded even when explicitly set off (reconcile safety overrides the opt-out)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["set-gate-guard", "off"], { cwd }); - writeState(cwd, { version: 1, active: "a", detourStack: [], gateGuard: false, epics: [ - { id: "a", title: "a", priority: "P1", status: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: true }, - ]}); - const err = expectFail(() => run(["gate-guard"], { cwd, input: "{}" })); - assert.ok(err, "expected a block even with gateGuard explicitly off"); - assert.match(String(err.stderr || err.message), /still owes a reconcile/); -}); - -test("rules block gains an External tracker sync section only when a tracker is configured", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.doesNotMatch(claudeMd(cwd), /External tracker sync/); // none after a plain init - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - assert.match(claudeMd(cwd), /External tracker sync/); - assert.match(claudeMd(cwd), /jira/); -}); - -test("tracker-linked autonomy addendum appears only when a tracker is configured", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const noTracker = run(["rules"], { cwd }); - assert.doesNotMatch(noTracker, /Epic-level autonomy on tracker-linked epics/); - - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - const withTracker = run(["rules"], { cwd }); - assert.match(withTracker, /Epic-level autonomy on tracker-linked epics/); - assert.match(withTracker, /mid-run drift/i); - assert.match(withTracker, /non-authoritative/i); -}); - -test("brief surfaces create-issue drift only for unmirrored active-work epics", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], - tracker: { system: "jira", projectKey: "JOB", statusIntent: {} }, - epics: [ - { id: "m1", title: "m1", priority: "P1", status: "queued", role: "epic", lane: "external", links: [] }, // unmirrored → listed - { id: "m2", title: "m2", priority: "P1", status: "active", role: "epic", lane: "external", externalId: "JOB-2", links: [] }, // mirrored → excluded - { id: "done", title: "done", priority: "P1", status: "archived", role: "epic", lane: "external", links: [] }, // archived → excluded - { id: "later", title: "later", priority: "P1", status: "planned", role: "epic", lane: "external", links: [] }, // planned → excluded - { id: "ghost", title: "ghost", priority: "P1", status: "queued", role: "epic", lane: "openspec", links: [] }, // missing() openspec → excluded - ]}); - const brief = parseBrief(cwd); - assert.match(brief, /TRACKER SYNC \(jira · JOB\)/); - const syncLine = brief.split("\n").find(l => /not yet in jira/.test(l)) || ""; - assert.match(syncLine, /`m1`/); - for (const id of ["m2", "done", "later", "ghost"]) assert.doesNotMatch(syncLine, new RegExp(`\`${id}\``)); -}); - -test("no tracker block → no TRACKER SYNC in the brief", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [ - { id: "x", title: "x", priority: "P1", status: "queued", role: "epic", lane: "external", links: [] }]}); - assert.doesNotMatch(parseBrief(cwd), /TRACKER SYNC/); -}); - -test("brief invents no transition drift when all active epics are mirrored", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { version: 1, active: null, detourStack: [], - tracker: { system: "jira", projectKey: "JOB", statusIntent: { archived: "done" } }, - epics: [{ id: "m", title: "m", priority: "P1", status: "active", role: "epic", lane: "external", externalId: "JOB-1", links: [] }]}); - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /not yet in jira/); // nothing to create - // Scope to the TRACKER SYNC block specifically — the brief's SessionStart upgrade nudge - // (added in 0.13.0) can legitimately inline CHANGELOG bullet text containing words like - // "drift" for unrelated reasons (e.g. a changelog entry about doc-drift detection), so a - // whole-brief search for these words is too broad and produces false positives. - const trackerBlock = brief.split(/\n\n/).find(b => b.startsWith("TRACKER SYNC")) || ""; - assert.doesNotMatch(trackerBlock, /transition pending|out of sync|drift/i); // no fabricated transition drift -}); - -// ───────────────────────── 0.5.0: bulk creation ───────────────────────── - -function writeBatch(cwd, obj) { - const p = path.join(cwd, "batch.json"); - fs.writeFileSync(p, JSON.stringify(obj)); - return p; -} - -test("add-many creates a parent + children atomically; children inherit the parent id", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const batch = writeBatch(cwd, { - parent: { id: "sprint", title: "Sprint", lane: "external", priority: "P0", status: "queued" }, - epics: [ - { id: "job-1", title: "one", lane: "external", priority: "P0", externalId: "JOB-1" }, - { id: "job-2", title: "two", lane: "external", priority: "P1" }, - ], - }); - run(["add-many", "--from", batch], { cwd }); - const s = readState(cwd); - assert.ok(s.epics.find(e => e.id === "sprint")); - assert.equal(s.epics.find(e => e.id === "job-1").parent, "sprint"); - assert.equal(s.epics.find(e => e.id === "job-2").parent, "sprint"); - assert.equal(s.epics.find(e => e.id === "job-1").externalId, "JOB-1"); -}); - -test("add-many children-only batch leaves parent unset", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const batch = writeBatch(cwd, { epics: [ - { id: "x", lane: "external", priority: "P1" }, { id: "y", lane: "external", priority: "P1" }] }); - run(["add-many", "--from", batch], { cwd }); - const s = readState(cwd); - assert.ok(s.epics.find(e => e.id === "x") && s.epics.find(e => e.id === "y")); - assert.equal(s.epics.find(e => e.id === "x").parent, undefined); -}); - -test("add-many aborts the whole batch on one invalid entry, writing nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const before = readState(cwd).epics.length; - const batch = writeBatch(cwd, { epics: [ - { id: "good", lane: "external", priority: "P1" }, - { id: "Bad ID", lane: "external" }, // malformed id - ]}); - assert.ok(expectFail(() => run(["add-many", "--from", batch], { cwd })), "expected non-zero exit"); - assert.equal(readState(cwd).epics.length, before); // nothing written — not even 'good' -}); - -test("add-many rejects a duplicate id within the batch", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const batch = writeBatch(cwd, { epics: [{ id: "dup", lane: "external" }, { id: "dup", lane: "external" }] }); - assert.ok(expectFail(() => run(["add-many", "--from", batch], { cwd }))); - assert.equal(readState(cwd).epics.length, 0); -}); - -test("add-many rejects a duplicate against an existing epic", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "exists", "--lane", "external"], { cwd }); - const batch = writeBatch(cwd, { epics: [{ id: "exists", lane: "external" }] }); - assert.ok(expectFail(() => run(["add-many", "--from", batch], { cwd }))); - assert.equal(readState(cwd).epics.filter(e => e.id === "exists").length, 1); -}); - -test("add-many reads a batch from stdin (--from -)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const batch = JSON.stringify({ epics: [{ id: "s1", lane: "external", priority: "P1" }] }); - run(["add-many", "--from", "-"], { cwd, input: batch }); - assert.ok(readState(cwd).epics.find(e => e.id === "s1")); -}); - -test("add-many rejects an intra-batch parent cycle", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const batch = writeBatch(cwd, { epics: [ - { id: "x", lane: "external", parent: "y" }, { id: "y", lane: "external", parent: "x" }] }); - assert.ok(expectFail(() => run(["add-many", "--from", batch], { cwd }))); - assert.equal(readState(cwd).epics.length, 0); -}); - -// ───────────────────────── 0.5.0: link migration ───────────────────────── - -test("0.5.0 migration repairs colon-string links, drops unrecoverable, is idempotent", () => { - const cwd = tmpRepo(); - const root = fixturePluginRoot("0.5.0"); - run(["init"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const s = readState(cwd); - s.pmVersion = "0.4.1"; - s.epics.push({ id: "a", title: "a", priority: "P1", status: "queued", role: "epic", lane: "openspec", - links: ["blocks:other:was flaky", { type: "related", epic: "z" }, "", {}] }); - writeState(cwd, s); - - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - const after = readState(cwd); - assert.equal(after.pmVersion, "0.5.0"); - const links = after.epics.find(e => e.id === "a").links; - assert.deepEqual(links.find(l => l.type === "blocks"), { type: "blocks", epic: "other", reason: "was flaky" }); - assert.ok(links.find(l => l.type === "related" && l.epic === "z")); // valid object preserved - assert.equal(links.length, 2); // "" and {} dropped - - // idempotent on a second run - const first = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - run(["upgrade"], { cwd, env: { CLAUDE_PLUGIN_ROOT: root } }); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), first); -}); - -// ──────────────── epic-hierarchy orchestration: plan-hierarchy ──────────────── - -function setupHierarchy(cwd, childOverrides = {}) { - run(["init"], { cwd }); - run(["add-epic", "--id", "sprint", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "child-a", "--lane", "claude-code", "--parent", "sprint", "--priority", "P1"], { cwd }); - run(["add-epic", "--id", "child-b", "--lane", "claude-code", "--parent", "sprint", "--priority", "P0"], { cwd }); - run(["add-epic", "--id", "child-c", "--lane", "claude-code", "--parent", "sprint", "--priority", "P2"], { cwd }); - if (childOverrides.applyLinks) childOverrides.applyLinks(cwd); -} - -test("plan-hierarchy batches independent children together, ordered by priority within a batch", () => { - const cwd = tmpRepo(); - setupHierarchy(cwd); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "sprint"], { cwd })); - assert.equal(out.parent, "sprint"); - assert.equal(out.batches.length, 1); - assert.deepEqual(out.batches[0].epics.map(e => e.id), ["child-b", "child-a", "child-c"]); // P0, P1, P2 - for (const e of out.batches[0].epics) assert.deepEqual(e.dependsOn, []); -}); - -test("plan-hierarchy sequences a depends-on chain into separate batches", () => { - const cwd = tmpRepo(); - setupHierarchy(cwd); - run(["update-epic", "child-b", "--link", "depends-on:child-a:needs a's output"], { cwd }); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "sprint"], { cwd })); - assert.equal(out.batches.length, 2); - assert.deepEqual(out.batches[0].epics.map(e => e.id), ["child-a", "child-c"]); // no unresolved deps - assert.deepEqual(out.batches[1].epics.map(e => e.id), ["child-b"]); // waits on child-a - const childA = out.batches[0].epics.find(e => e.id === "child-a"); - assert.deepEqual(childA.dependsOn, []); - const childB = out.batches[1].epics.find(e => e.id === "child-b"); - assert.deepEqual(childB.dependsOn, ["child-a"]); -}); - -test("plan-hierarchy ignores a depends-on link to an epic outside the hierarchy", () => { - const cwd = tmpRepo(); - setupHierarchy(cwd); - run(["add-epic", "--id", "outsider", "--lane", "claude-code"], { cwd }); - run(["update-epic", "child-a", "--link", "depends-on:outsider:unrelated"], { cwd }); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "sprint"], { cwd })); - assert.equal(out.batches.length, 1); // outsider isn't a sibling, so it doesn't force a second batch -}); - -test("plan-hierarchy detects and rejects a dependency cycle among children, naming the cycle path", () => { - const cwd = tmpRepo(); - setupHierarchy(cwd); - run(["update-epic", "child-a", "--link", "depends-on:child-b:x"], { cwd }); - run(["update-epic", "child-b", "--link", "depends-on:child-a:y"], { cwd }); - const err = expectFail(() => run(["plan-hierarchy", "--parent", "sprint"], { cwd })); - assert.ok(err, "expected a cycle rejection"); - const msg = String(err.stderr || err.message); - assert.match(msg, /dependency cycle/); - assert.match(msg, /child-a/); - assert.match(msg, /child-b/); -}); - -test("plan-hierarchy annotates each child's autonomy status", () => { - const cwd = tmpRepo(); - setupHierarchy(cwd); - run(["set-autonomy", "child-a", "--level", "autonomous"], { cwd }); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "sprint"], { cwd })); - const byId = Object.fromEntries(out.batches[0].epics.map(e => [e.id, e.autonomous])); - assert.equal(byId["child-a"], true); - assert.equal(byId["child-b"], false); - assert.equal(byId["child-c"], false); -}); - -test("plan-hierarchy on a parent with no children returns an empty batches array", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "lonely-parent", "--lane", "claude-code"], { cwd }); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "lonely-parent"], { cwd })); - assert.deepEqual(out.batches, []); -}); - -test("plan-hierarchy rejects an unknown parent id", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.ok(expectFail(() => run(["plan-hierarchy", "--parent", "ghost"], { cwd }))); -}); - -test("plan-hierarchy requires --parent", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.ok(expectFail(() => run(["plan-hierarchy"], { cwd }))); -}); - -// ──────── top-level queue: dependency-aware ordering (dependency-aware-standalone-ordering) ──────── - -test("NEXT UP does not starve a top-level epic's unresolved depends-on dependency, even when the dependent outranks it on priority", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "low-dep", "--lane", "claude-code", "--priority", "P3"], { cwd }); - run(["add-epic", "--id", "high-blocked", "--lane", "claude-code", "--priority", "P0", - "--link", "depends-on:low-dep:needs low-dep shipped first"], { cwd }); - const brief = parseBrief(cwd); - assert.ok(brief.indexOf("`low-dep`") < brief.indexOf("`high-blocked`"), - "unresolved dependency must be listed ahead of the higher-priority epic waiting on it"); -}); - -test("brief prints a one-line note naming the blocking epic when priority order is overridden by an unresolved depends-on", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "low-dep", "--lane", "claude-code", "--priority", "P3"], { cwd }); - run(["add-epic", "--id", "high-blocked", "--lane", "claude-code", "--priority", "P0", - "--link", "depends-on:low-dep:needs low-dep shipped first"], { cwd }); - const brief = parseBrief(cwd); - assert.match(brief, /`high-blocked` ready but waiting on `low-dep`/); -}); - -test("top-level dependency ordering applies across unrelated epics, not just siblings under one parent", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - // No parent/child relationship at all — both are top-level, unrelated epics. - run(["add-epic", "--id", "infra", "--lane", "claude-code", "--priority", "P2"], { cwd }); - run(["add-epic", "--id", "feature", "--lane", "claude-code", "--priority", "P0", - "--link", "depends-on:infra:needs infra"], { cwd }); - const brief = parseBrief(cwd); - assert.ok(brief.indexOf("`infra`") < brief.indexOf("`feature`"), - "top-level depends-on ordering must not be limited to plan-hierarchy's parent/child scope"); - assert.match(brief, /`feature` ready but waiting on `infra`/); -}); - -test("a resolved depends-on (dependency archived) does not starve the dependent — no reordering, no note", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "done-dep", "--lane", "claude-code", "--priority", "P3"], { cwd }); - run(["add-epic", "--id", "dependent", "--lane", "claude-code", "--priority", "P0", - "--link", "depends-on:done-dep:needs done-dep"], { cwd }); - run(["update-epic", "done-dep", "--status", "archived"], { cwd }); - const brief = parseBrief(cwd); - assert.doesNotMatch(brief, /ready but waiting on/); - // dependent is now the only queued epic left (done-dep archived, excluded from NEXT UP). - assert.match(brief, /`dependent`/); -}); - -test("no unresolved depends-on among queued epics leaves plain priority order untouched (no notes)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code", "--priority", "P0"], { cwd }); - run(["add-epic", "--id", "b", "--lane", "claude-code", "--priority", "P1"], { cwd }); - const brief = parseBrief(cwd); - assert.ok(brief.indexOf("`a`") < brief.indexOf("`b`")); - assert.doesNotMatch(brief, /ready but waiting on/); -}); - -test("a dependency cycle among top-level queued epics does not crash the brief — falls back gracefully", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code", "--priority", "P1"], { cwd }); - run(["add-epic", "--id", "b", "--lane", "claude-code", "--priority", "P1"], { cwd }); - run(["update-epic", "a", "--link", "depends-on:b:cyclic"], { cwd }); - run(["update-epic", "b", "--link", "depends-on:a:cyclic"], { cwd }); - const brief = parseBrief(cwd); - assert.match(brief, /NEXT UP/); - assert.match(brief, /`a`/); - assert.match(brief, /`b`/); -}); - -// ---------- remove-epic ---------- - -test("remove-epic hard-deletes a childless, unreferenced epic", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["remove-epic", "a"], { cwd }); - const state = readState(cwd); - assert.ok(!state.epics.some(e => e.id === "a")); -}); - -test("remove-epic clears the active pointer when the removed epic was active", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code", "--status", "active"], { cwd }); - run(["remove-epic", "a"], { cwd }); - const state = readState(cwd); - assert.equal(state.active, null); -}); - -test("remove-epic strips dangling links[] from other epics and warns", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "b", "--lane", "claude-code", "--link", "depends-on:a"], { cwd }); - const out = runCombined(["remove-epic", "a"], { cwd }); - const state = readState(cwd); - const b = state.epics.find(e => e.id === "b"); - assert.deepEqual(b.links, []); - assert.match(out, /stripped dangling link/); - assert.match(out, /\bb\b/); -}); - -test("remove-epic blocks removal of an epic with children by default, printing a table", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "parent", "--lane", "claude-code", "--title", "Parent epic"], { cwd }); - run(["add-epic", "--id", "child1", "--lane", "claude-code", "--parent", "parent", "--title", "Child one"], { cwd }); - run(["add-epic", "--id", "child2", "--lane", "claude-code", "--parent", "parent", "--title", "Child two"], { cwd }); - const err = expectFail(() => run(["remove-epic", "parent"], { cwd })); - assert.ok(err); - const out = String(err.stdout || "") + String(err.stderr || ""); - assert.match(out, /child1/); - assert.match(out, /child2/); - assert.match(out, /--cascade/); - const state = readState(cwd); - assert.ok(state.epics.some(e => e.id === "parent")); - assert.ok(state.epics.some(e => e.id === "child1")); -}); - -test("remove-epic blocked-removal preview includes grandchildren, not just direct children", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "parent", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "child", "--lane", "claude-code", "--parent", "parent"], { cwd }); - run(["add-epic", "--id", "grandchild", "--lane", "claude-code", "--parent", "child"], { cwd }); - const err = expectFail(() => run(["remove-epic", "parent"], { cwd })); - assert.ok(err); - const out = String(err.stdout || "") + String(err.stderr || ""); - assert.match(out, /grandchild/); - assert.match(out, /2 descendant\(s\) total/); -}); - -test("remove-epic --cascade removes a parent and all its descendants", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "parent", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "child1", "--lane", "claude-code", "--parent", "parent"], { cwd }); - run(["add-epic", "--id", "grandchild", "--lane", "claude-code", "--parent", "child1"], { cwd }); - run(["remove-epic", "parent", "--cascade"], { cwd }); - const state = readState(cwd); - assert.equal(state.epics.length, 0); -}); - -test("remove-epic rejects an unknown id", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.ok(expectFail(() => run(["remove-epic", "ghost"], { cwd }))); -}); - -test("remove-epic requires a positional id", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.ok(expectFail(() => run(["remove-epic"], { cwd }))); -}); - -// ──────────────── verify-worktrees ──────────────── - -function gitInitWithCommit(cwd) { - execFileSync("git", ["init", "-q"], { cwd }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd }); - execFileSync("git", ["config", "user.name", "Test"], { cwd }); - fs.writeFileSync(path.join(cwd, "README.md"), "# test\n"); - execFileSync("git", ["add", "README.md"], { cwd }); - execFileSync("git", ["commit", "-q", "-m", "init"], { cwd }); -} - -function addHierarchyWorktree(cwd, epicId) { - const branch = `hierarchy-child/${epicId}`; - const wtPath = fs.mkdtempSync(path.join(os.tmpdir(), "pm-wt-")); - fs.rmdirSync(wtPath); // git worktree add requires the target not exist yet - execFileSync("git", ["worktree", "add", "-b", branch, wtPath], { cwd }); - return wtPath; -} - -test("verify-worktrees reports no orphans when there are no hierarchy-child worktrees", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - gitInitWithCommit(cwd); - const out = JSON.parse(run(["verify-worktrees"], { cwd })); - assert.deepEqual(out.orphaned, []); -}); - -test("verify-worktrees flags a hierarchy-child worktree whose epic is already archived", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - gitInitWithCommit(cwd); - run(["add-epic", "--id", "done-child", "--lane", "claude-code", "--status", "archived"], { cwd }); - const wtPath = addHierarchyWorktree(cwd, "done-child"); - const out = JSON.parse(run(["verify-worktrees"], { cwd })); - assert.equal(out.orphaned.length, 1); - assert.equal(out.orphaned[0].epicId, "done-child"); - assert.equal(out.orphaned[0].branch, "hierarchy-child/done-child"); - assert.equal(fs.realpathSync(out.orphaned[0].path), fs.realpathSync(wtPath)); - execFileSync("git", ["worktree", "remove", "--force", wtPath], { cwd }); -}); - -test("verify-worktrees does not flag a hierarchy-child worktree whose epic is still in flight and whose branch has unmerged work", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - gitInitWithCommit(cwd); - run(["add-epic", "--id", "in-flight-child", "--lane", "claude-code", "--status", "active"], { cwd }); - const wtPath = addHierarchyWorktree(cwd, "in-flight-child"); - // Simulate real in-flight work: a commit on the child branch not yet merged into HEAD, so - // its tip is genuinely NOT an ancestor of the current branch (unlike a freshly-created - // worktree, whose tip trivially equals HEAD at creation time). - fs.writeFileSync(path.join(wtPath, "wip.txt"), "wip\n"); - execFileSync("git", ["add", "wip.txt"], { cwd: wtPath }); - execFileSync("git", ["commit", "-q", "-m", "wip"], { cwd: wtPath }); - const out = JSON.parse(run(["verify-worktrees"], { cwd })); - assert.deepEqual(out.orphaned, []); - execFileSync("git", ["worktree", "remove", "--force", wtPath], { cwd }); -}); - -test("verify-worktrees flags a hierarchy-child worktree whose branch is already merged into HEAD, even when the epic's status is not archived", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - gitInitWithCommit(cwd); - run(["add-epic", "--id", "merged-child", "--lane", "claude-code", "--status", "active"], { cwd }); - const wtPath = addHierarchyWorktree(cwd, "merged-child"); - // Branch tip is created directly from HEAD (no divergent commits), so it's trivially an - // ancestor of HEAD — mirrors the real "git branch -d failed, used by worktree" scenario - // where the merge already landed but the worktree/epic bookkeeping wasn't cleaned up. - const out = JSON.parse(run(["verify-worktrees"], { cwd })); - assert.equal(out.orphaned.length, 1); - assert.equal(out.orphaned[0].epicId, "merged-child"); - assert.deepEqual(out.orphaned[0].reasons, ["branch-merged"]); - execFileSync("git", ["worktree", "remove", "--force", wtPath], { cwd }); -}); - -test("verify-worktrees returns an empty orphaned list gracefully when the cwd isn't a git repo at all", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = JSON.parse(run(["verify-worktrees"], { cwd })); - assert.deepEqual(out.orphaned, []); -}); - -// ──────────────── changesets ──────────────── - -test("changesets returns an empty list when .changesets doesn't exist", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = JSON.parse(run(["changesets"], { cwd })); - assert.deepEqual(out.changesets, []); -}); - -test("changesets lists fragment files sorted by epic id, with body content", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const dir = path.join(cwd, ".changesets"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "zeta-epic.md"), "- **Zeta thing.** Did the zeta.\n"); - fs.writeFileSync(path.join(dir, "alpha-epic.md"), "- **Alpha thing.** Did the alpha.\n"); - const out = JSON.parse(run(["changesets"], { cwd })); - assert.equal(out.changesets.length, 2); - assert.equal(out.changesets[0].id, "alpha-epic"); - assert.equal(out.changesets[1].id, "zeta-epic"); - assert.match(out.changesets[0].body, /Did the alpha/); -}); - -test("changesets ignores non-markdown files in .changesets", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const dir = path.join(cwd, ".changesets"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "epic-a.md"), "- **A thing.**\n"); - fs.writeFileSync(path.join(dir, ".gitkeep"), ""); - const out = JSON.parse(run(["changesets"], { cwd })); - assert.equal(out.changesets.length, 1); - assert.equal(out.changesets[0].id, "epic-a"); -}); - -// ──────────────── render --diff-summary ──────────────── - -test("render --diff-summary reports epic-relevant: yes on the very first render (no baseline to compare against)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); // init() itself renders once, establishing a PROJECT.md baseline - fs.rmSync(path.join(cwd, "PROJECT.md")); // remove it to simulate a genuine "no prior render" - const out = runCombined(["render", "--diff-summary"], { cwd }); - assert.match(out, /epic-relevant: yes/); -}); - -test("render --diff-summary reports epic-relevant: no when the only diff is the 'Last rendered' timestamp", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = runCombined(["render", "--diff-summary"], { cwd }); - assert.match(out, /epic-relevant: no/); -}); - -test("render --diff-summary reports epic-relevant: no when the only diff is Recent-detours table rotation", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - // Simulate detour-log rotation directly (log-detour requires a real git SHA context this - // fixture doesn't have) -- append rows to .conductor/detours.log, the file render() reads - // to build the "Recent detours" table, then re-render. - const logPath = path.join(cwd, ".conductor", "detours.log"); - fs.writeFileSync(logPath, "2026-07-01T00:00:00Z\tabc1234\tminimal\ta\tfirst rotation entry\n"); - const out1 = runCombined(["render", "--diff-summary"], { cwd }); - assert.match(out1, /epic-relevant: no/, "first rotation: no other epic-relevant content changed"); - - fs.appendFileSync(logPath, "2026-07-02T00:00:00Z\tdef5678\tminimal\ta\tsecond rotation entry\n"); - const out2 = runCombined(["render", "--diff-summary"], { cwd }); - assert.match(out2, /epic-relevant: no/, "further rotation: still no other epic-relevant content changed"); -}); - -test("render --diff-summary reports epic-relevant: yes when the on-disk PROJECT.md is stale relative to current state", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - // add-epic/update-epic/etc already auto-re-render, so PROJECT.md on disk always reflects - // current state.json by the time this call returns. Capture that as a "stale baseline" (as - // if it were the last commit), then make a real epic-relevant change, then put the stale - // baseline back on disk -- simulating a PROJECT.md that hasn't been re-rendered since a real - // state change landed, which is exactly the case `--diff-summary` needs to catch. - const staleBaseline = projectMd(cwd); - run(["add-epic", "--id", "b", "--lane", "claude-code"], { cwd }); // a real, epic-relevant change - fs.writeFileSync(path.join(cwd, "PROJECT.md"), staleBaseline); - const out = runCombined(["render", "--diff-summary"], { cwd }); - assert.match(out, /epic-relevant: yes/); -}); - -// ──────────────── verify-state ──────────────── - -test("verify-state succeeds right after init/render (stamp matches state.json)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = runCombined(["verify-state"], { cwd }); - assert.match(out, /conductor: state.json matches the last render/); -}); - -test("verify-state succeeds after render is re-run following a legitimate state change", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["render"], { cwd }); - const out = runCombined(["verify-state"], { cwd }); - assert.match(out, /conductor: state.json matches the last render/); -}); - -test("verify-state fails loudly when state.json is hand-edited after the last render", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const state = readState(cwd); - state.epics.push({ id: "hand-edited", title: "Hand edited", priority: "P2", status: "queued", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }); - // Force the on-disk mtime forward so it's unambiguously newer than the render stamp, - // even on filesystems with coarse mtime resolution. - const statePath = path.join(cwd, ".conductor", "state.json"); - fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n"); - const future = new Date(Date.now() + 60_000); - fs.utimesSync(statePath, future, future); - const err = expectFail(() => run(["verify-state"], { cwd })); - assert.ok(err); - const out = runCombined(["verify-state"], { cwd }); - assert.match(out, /hand-edit|re-render|\/pm:status/i); -}); - -test("verify-state fails loudly when never rendered (no stamp) but state.json exists", () => { - const cwd = tmpRepo(); - fs.mkdirSync(path.join(cwd, ".conductor"), { recursive: true }); - writeState(cwd, { version: 1, active: null, detourStack: [], epics: [] }); - const err = expectFail(() => run(["verify-state"], { cwd })); - assert.ok(err); -}); - -test("plan-hierarchy excludes already-archived children from the plan", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "sprint", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "done-child", "--lane", "claude-code", "--parent", "sprint", "--status", "archived"], { cwd }); - run(["add-epic", "--id", "pending-child", "--lane", "claude-code", "--parent", "sprint"], { cwd }); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "sprint"], { cwd })); - const allIds = out.batches.flatMap(b => b.epics.map(e => e.id)); - assert.ok(!allIds.includes("done-child"), "archived child should not appear in the plan"); - assert.ok(allIds.includes("pending-child"), "non-archived child should still appear"); -}); - -test("plan-hierarchy on a parent whose only children are all archived returns an empty batches array", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "sprint2", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "done-only", "--lane", "claude-code", "--parent", "sprint2", "--status", "archived"], { cwd }); - const out = JSON.parse(run(["plan-hierarchy", "--parent", "sprint2"], { cwd })); - assert.deepEqual(out.batches, []); -}); - -test("the engine banner is suppressed by default in a dev/self-hosting context (CLAUDE_PROJECT_DIR set), so it's not noise on every invocation", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - // run()/runCombined() always set CLAUDE_PROJECT_DIR=cwd (matching real self-hosting - // usage), so the default here is already the suppressed case -- see the next test for the - // opt-in override, and the one after for the banner's un-suppressed default elsewhere. - const r = runCombined(["render"], { cwd }); - assert.doesNotMatch(r, /conductor: engine/); -}); - -test("PM_VERBOSE_ENGINE_BANNER=1 forces the engine banner back on even when CLAUDE_PROJECT_DIR is set", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const r = runCombined(["render"], { cwd, env: { PM_VERBOSE_ENGINE_BANNER: "1" } }); - assert.match(r, /conductor: engine \S+ @ .*scripts/); -}); - -test("the engine banner is shown by default when CLAUDE_PROJECT_DIR is NOT set (outside a dev/self-hosting context), so a stale cached engine is still visible there", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const env = { ...process.env, PM_CACHE_ROOT: EMPTY_CACHE }; - delete env.CLAUDE_PROJECT_DIR; - const r = spawnSync("node", [ENGINE, "render"], { cwd, env, encoding: "utf8" }); - const combined = (r.stdout || "") + (r.stderr || ""); - assert.match(combined, /conductor: engine \S+ @ .*scripts/); -}); - -test("the engine banner stays suppressed when both CLAUDE_PROJECT_DIR and PM_QUIET_ENGINE_BANNER are set (explicit suppress, back-compat with the pre-fix default-on behavior)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const r = runCombined(["render"], { cwd, env: { PM_QUIET_ENGINE_BANNER: "1" } }); - assert.doesNotMatch(r, /conductor: engine/); -}); - -// ─────────────── timestamps + staleness ─────────────── - -test("set-active stamps startedAt (ISO string) on first activation, and does not reset it on re-activation", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "b", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - const s1 = readState(cwd); - const a1 = s1.epics.find(e => e.id === "a"); - assert.ok(a1.startedAt, "startedAt stamped"); - assert.ok(!Number.isNaN(Date.parse(a1.startedAt)), "startedAt is a valid ISO string"); - - run(["set-active", "b"], { cwd }); // demotes a - run(["set-active", "a"], { cwd }); // re-activate a - const s2 = readState(cwd); - const a2 = s2.epics.find(e => e.id === "a"); - assert.equal(a2.startedAt, a1.startedAt, "re-activation does not reset startedAt"); -}); - -test("update-epic --status archived stamps completedAt", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - run(["update-epic", "a", "--status", "archived"], { cwd }); - const s = readState(cwd); - const a = s.epics.find(e => e.id === "a"); - assert.ok(a.completedAt, "completedAt stamped"); - assert.ok(!Number.isNaN(Date.parse(a.completedAt)), "completedAt is a valid ISO string"); -}); - -test("update-epic --status queued (not archived) does not stamp completedAt", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - run(["update-epic", "a", "--status", "queued"], { cwd }); - const s = readState(cwd); - assert.equal(s.epics.find(e => e.id === "a").completedAt, undefined); -}); - -test("PROJECT.md and the brief flag a stale epic (startedAt > 14 days ago, no completedAt)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - const s = readState(cwd); - const staleDate = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(); - s.epics.find(e => e.id === "a").startedAt = staleDate; - writeState(cwd, s); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /⚠ stale, 20d active/); - const brief = parseBrief(cwd); - assert.match(brief, /⚠ stale, 20d active/); -}); - -test("an epic active fewer than 14 days is not flagged stale", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.doesNotMatch(md, /stale/); -}); - -test("a completed epic is never flagged stale, even if startedAt is old", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "a", "--lane", "claude-code"], { cwd }); - run(["set-active", "a"], { cwd }); - run(["update-epic", "a", "--status", "archived"], { cwd }); - const s = readState(cwd); - const a = s.epics.find(e => e.id === "a"); - a.startedAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - writeState(cwd, s); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.doesNotMatch(md, /stale/); -}); - -// ─────────────── honcho-memory: push/pop ready-to-copy line ─────────────── - -test("honcho-memory push prints the exact ready-to-copy line and appends it to .conductor/honcho-memories.log", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = run(["honcho-memory", "push", "parent-epic", "blocking bug in shared lib"], { cwd }); - assert.equal(out.trim(), "paused parent-epic for blocking bug in shared lib"); - - const logPath = path.join(cwd, ".conductor", "honcho-memories.log"); - const logged = fs.readFileSync(logPath, "utf8").trim().split("\n"); - assert.equal(logged.length, 1); - const [ts, line] = logged[0].split("\t"); - assert.ok(!Number.isNaN(Date.parse(ts)), "first field should be an ISO timestamp"); - assert.equal(line, "paused parent-epic for blocking bug in shared lib"); -}); - -test("honcho-memory pop prints the resume line, formatted differently from push", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = run(["honcho-memory", "pop", "parent-epic", "detour-fix-shared-lib"], { cwd }); - assert.equal(out.trim(), "resumed parent-epic, reconciled vs detour-fix-shared-lib"); -}); - -test("honcho-memory appends multiple emissions to the same log file, one line each", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["honcho-memory", "push", "epic-a", "reason one"], { cwd }); - run(["honcho-memory", "pop", "epic-a", "detour-a"], { cwd }); - const logPath = path.join(cwd, ".conductor", "honcho-memories.log"); - const lines = fs.readFileSync(logPath, "utf8").trim().split("\n"); - assert.equal(lines.length, 2); - assert.match(lines[0], /paused epic-a for reason one$/); - assert.match(lines[1], /resumed epic-a, reconciled vs detour-a$/); -}); - -test("honcho-memory rejects an unknown action", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.throws(() => run(["honcho-memory", "sideways", "epic-a", "reason"], { cwd })); -}); - -// ───────── auto-detect an unlogged minimal detour from commit/diff shape ───────── - -function gitRepo(cwd) { - execFileSync("git", ["init", "-q"], { cwd }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd }); - execFileSync("git", ["config", "user.name", "Test"], { cwd }); - // Baseline commit for whatever /pm:init already scaffolded (CLAUDE.md, .conductor/state.json, - // PROJECT.md), so the commit under test only reflects the files it actually touches. - execFileSync("git", ["add", "-A"], { cwd }); - execFileSync("git", ["commit", "-q", "-m", "chore: baseline"], { cwd }); -} -function commitFiles(cwd, files, message) { - for (const [name, content] of Object.entries(files)) { - fs.writeFileSync(path.join(cwd, name), content); - } - execFileSync("git", ["add", "-A"], { cwd }); - execFileSync("git", ["commit", "-q", "-m", message], { cwd }); -} -function detourLog(cwd) { - try { return fs.readFileSync(path.join(cwd, ".conductor", "detours.log"), "utf8"); } - catch { return ""; } -} - -test("commit-nudge auto-logs a minimal detour for a small fix commit with no active detour", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - commitFiles(cwd, { "a.txt": "1" }, "fix: correct off-by-one in renderer"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "fix: correct off-by-one in renderer"' } }) }); - const log = detourLog(cwd); - assert.match(log, /AUTO-DETOUR/); - assert.match(log, /correct off-by-one in renderer/); -}); - -test("commit-nudge does not auto-log a large commit (more than 3 files)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - commitFiles(cwd, { "a.txt": "1", "b.txt": "1", "c.txt": "1", "d.txt": "1" }, "fix: sweeping cleanup"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "fix: sweeping cleanup"' } }) }); - assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); -}); - -test("commit-nudge does not auto-log a commit without a fix/chore conventional-commit prefix", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - commitFiles(cwd, { "a.txt": "1" }, "feat: add new widget"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "feat: add new widget"' } }) }); - assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); -}); - -test("commit-nudge does not auto-log a commit that names the active epic (treated as the epic's own work)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { version: 1, active: "feat-x", detourStack: [], epics: [ - { id: "feat-x", title: "feat-x", priority: "P1", status: "in-progress", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, - ]}); - gitRepo(cwd); - commitFiles(cwd, { "a.txt": "1" }, "fix(feat-x): tighten validation"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "fix(feat-x): tighten validation"' } }) }); - assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); -}); - -test("commit-nudge does not auto-log a commit already inside a detour (existing DETOUR-COMMIT path wins)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - writeState(cwd, { - version: 1, active: "paused-a", detourStack: [ - { pausedEpic: "paused-a", pausedAt: "2026-07-15T00:00:00Z", reason: "x", spawnedDetour: "detour-1", reconcileOnResume: false }, - ], - epics: [ - { id: "paused-a", title: "paused-a", priority: "P1", status: "paused", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, - { id: "detour-1", title: "detour-1", priority: "P1", status: "in-progress", role: "detour", lane: "claude-code", links: [], reconcileNeeded: false }, - ], - }); - gitRepo(cwd); - commitFiles(cwd, { "a.txt": "1" }, "fix: patch the thing"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "fix: patch the thing"' } }) }); - const log = detourLog(cwd); - assert.match(log, /DETOUR-COMMIT/); - assert.doesNotMatch(log, /AUTO-DETOUR/); -}); - -test("commit-nudge does not auto-log a routine conductor-bookkeeping commit touching only its own state-output files", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - commitFiles(cwd, { - ".conductor/state.json": '{"version":1,"active":null,"detourStack":[],"epics":[]}', - "PROJECT.md": "# updated\n", - }, "chore(pm): register 3 new epics"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "chore(pm): register 3 new epics"' } }) }); - assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); -}); - -test("commit-nudge still auto-logs a chore commit that touches a real source file alongside state.json", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - commitFiles(cwd, { - ".conductor/state.json": '{"version":1,"active":null,"detourStack":[],"epics":[]}', - "some-real-file.mjs": "// fix\n", - }, "chore: tidy up a helper"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: 'git commit -m "chore: tidy up a helper"' } }) }); - assert.match(detourLog(cwd), /AUTO-DETOUR/); -}); - -// ─────────────────── lane-routing overrides ─────────────────── - -test("set-lane-routing --add writes a laneRouting.overrides block", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-lane-routing", "--add", "billing-*:openspec", "--add", "hotfix:claude-code"], { cwd }); - const lr = readState(cwd).laneRouting; - assert.deepEqual(lr.overrides, [ - { match: "billing-*", lane: "openspec" }, - { match: "hotfix", lane: "claude-code" }, - ]); -}); - -test("set-lane-routing rejects an override naming an unknown lane", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.throws(() => run(["set-lane-routing", "--add", "foo:not-a-lane"], { cwd })); -}); - -test("set-lane-routing rejects a malformed override (missing ':lane')", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - assert.throws(() => run(["set-lane-routing", "--add", "no-colon-here"], { cwd })); -}); - -test("set-lane-routing --remove drops a single override by its match string", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-lane-routing", "--add", "billing-*:openspec", "--add", "hotfix:claude-code"], { cwd }); - run(["set-lane-routing", "--remove", "hotfix"], { cwd }); - const lr = readState(cwd).laneRouting; - assert.deepEqual(lr.overrides, [{ match: "billing-*", lane: "openspec" }]); -}); - -test("set-lane-routing --clear empties the overrides list", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-lane-routing", "--add", "billing-*:openspec"], { cwd }); - run(["set-lane-routing", "--clear"], { cwd }); - const lr = readState(cwd).laneRouting; - assert.deepEqual(lr.overrides, []); -}); - -test("suggest-lane matches an exact keyword override before falling back", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-lane-routing", "--add", "hotfix:claude-code"], { cwd }); - const out = run(["suggest-lane", "urgent hotfix for prod"], { cwd }).trim(); - const parsed = JSON.parse(out); - assert.equal(parsed.lane, "claude-code"); - assert.equal(parsed.matched, "hotfix"); -}); - -test("suggest-lane matches a glob-style override (billing-*)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-lane-routing", "--add", "billing-*:openspec"], { cwd }); - const out = JSON.parse(run(["suggest-lane", "billing-refund-flow"], { cwd }).trim()); - assert.equal(out.lane, "openspec"); - assert.equal(out.matched, "billing-*"); -}); - -test("suggest-lane with no matching override reports no override so the generic heuristic applies", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-lane-routing", "--add", "hotfix:claude-code"], { cwd }); - const out = JSON.parse(run(["suggest-lane", "brand new capability"], { cwd }).trim()); - assert.equal(out.lane, null); - assert.equal(out.matched, null); -}); - -test("suggest-lane with no laneRouting configured at all reports no override", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const out = JSON.parse(run(["suggest-lane", "anything"], { cwd }).trim()); - assert.equal(out.lane, null); -}); - -// ──────────────── reconciler structured writeback: record-reconcile ──────────────── - -test("record-reconcile writes a structured verdict onto the paused epic's link to the detour, and clears reconcileNeeded", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "paused-epic", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "detour-epic", "--lane", "claude-code"], { cwd }); - run(["update-epic", "paused-epic", "--link", "may-invalidate:detour-epic"], { cwd }); - let s = readState(cwd); - s.epics.find(e => e.id === "paused-epic").reconcileNeeded = true; - writeState(cwd, s); - - run(["record-reconcile", "paused-epic", "--detour", "detour-epic", - "--verdict", "invalidated", "--amendments", "rewrite story 2;drop story 4"], { cwd }); - - const epic = readState(cwd).epics.find(e => e.id === "paused-epic"); - assert.equal(epic.reconcileNeeded, false); - const link = epic.links.find(l => l.epic === "detour-epic"); - assert.ok(link, "link to the detour should still exist"); - assert.equal(link.reconciled.verdict, "invalidated"); - assert.deepEqual(link.reconciled.amendments, ["rewrite story 2", "drop story 4"]); - assert.ok(link.reconciled.reconciledAt); - assert.match(link.reconciled.reconciledAt, /^\d{4}-\d{2}-\d{2}T/); -}); - -test("record-reconcile creates the link to the detour if one doesn't already exist", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "paused-epic", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "detour-epic", "--lane", "claude-code"], { cwd }); - - run(["record-reconcile", "paused-epic", "--detour", "detour-epic", "--verdict", "valid"], { cwd }); - - const epic = readState(cwd).epics.find(e => e.id === "paused-epic"); - const link = epic.links.find(l => l.epic === "detour-epic"); - assert.ok(link, "link should be created"); - assert.equal(link.type, "may-invalidate"); - assert.equal(link.reconciled.verdict, "valid"); - assert.deepEqual(link.reconciled.amendments, []); -}); - -test("record-reconcile rejects an unknown verdict", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "paused-epic", "--lane", "claude-code"], { cwd }); - run(["add-epic", "--id", "detour-epic", "--lane", "claude-code"], { cwd }); - assert.ok(expectFail(() => run( - ["record-reconcile", "paused-epic", "--detour", "detour-epic", "--verdict", "maybe"], { cwd }))); -}); - -test("record-reconcile on an unknown epic id exits non-zero and writes nothing", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "detour-epic", "--lane", "claude-code"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run( - ["record-reconcile", "ghost", "--detour", "detour-epic", "--verdict", "valid"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("record-reconcile on an unknown detour id exits non-zero and writes nothing", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "paused-epic", "--lane", "claude-code"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run( - ["record-reconcile", "paused-epic", "--detour", "ghost-detour", "--verdict", "valid"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -// ---------- doc drift: SKILL.md "Commands" vs the real dispatch table ---------- - -test("every dispatch-table subcommand is mentioned somewhere in skills/conductor/SKILL.md", () => { - const engineSrc = fs.readFileSync(ENGINE, "utf8"); - const dispatchMatch = engineSrc.match(/^\(\{\n([\s\S]*?)\n\}\[cmd\]/m); - assert.ok(dispatchMatch, "could not locate the dispatch table object in conductor.mjs — " + - "has the dispatch section been restructured? update this test's extraction regex"); - const dispatchBody = dispatchMatch[1]; - - // Each dispatch entry key is either a bare identifier (`init,`) or a quoted string - // (`"set-active": setActive,`). Extract both forms. - const keys = new Set(); - for (const m of dispatchBody.matchAll(/^\s*"([a-z-]+)"\s*:/gm)) keys.add(m[1]); - for (const m of dispatchBody.matchAll(/^\s*([a-zA-Z][\w-]*)\s*:/gm)) keys.add(m[1]); - for (const m of dispatchBody.matchAll(/^\s*([a-zA-Z][\w-]*),?\s*$/gm)) keys.add(m[1]); - assert.ok(keys.size > 10, `expected many dispatch keys, only extracted ${keys.size}: ${[...keys]}`); - - // No entries are excluded: `snapshot` and `write-rules` are hook/init-only invocations - // (not run directly by a user/agent) but are still real, documentable subcommands, so - // they are asserted like everything else rather than excluded. - const UNDOCUMENTED_INTERNAL = new Set([ - // (currently empty — every dispatch subcommand is expected to be mentioned in SKILL.md) - ]); - - const skillPath = path.join(path.dirname(ENGINE), "..", "skills", "conductor", "SKILL.md"); - const skillText = fs.readFileSync(skillPath, "utf8"); - - const missing = []; - for (const key of keys) { - if (UNDOCUMENTED_INTERNAL.has(key)) continue; - if (!skillText.includes(key)) missing.push(key); - } - assert.deepEqual(missing, [], - `SKILL.md's Commands section (or elsewhere in the doc) is missing a mention of: ${missing.join(", ")}`); -}); - -// ──────────────── openspec gate enforcement: record-gate-review ──────────────── - -test("record-gate-review writes a structured verdict for the given gate onto an openspec-lane epic", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - - run(["record-gate-review", "spec-epic", "--gate", "1", "--verdict", "pass", "--reviewer", "fresh-context review of proposal.md"], { cwd }); - - const epic = readState(cwd).epics.find(e => e.id === "spec-epic"); - assert.ok(epic.gateReview); - assert.equal(epic.gateReview.gate1.verdict, "pass"); - assert.equal(epic.gateReview.gate1.note, "fresh-context review of proposal.md"); - assert.ok(epic.gateReview.gate1.reviewedAt); - assert.match(epic.gateReview.gate1.reviewedAt, /^\d{4}-\d{2}-\d{2}T/); -}); - -test("record-gate-review supports gate 2 independently of gate 1", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - - run(["record-gate-review", "spec-epic", "--gate", "2", "--verdict", "pass"], { cwd }); - - const epic = readState(cwd).epics.find(e => e.id === "spec-epic"); - assert.equal(epic.gateReview.gate2.verdict, "pass"); - assert.equal(epic.gateReview.gate1, undefined); -}); - -test("record-gate-review rejects a non-openspec-lane epic", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "cc-epic", "--lane", "claude-code"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run( - ["record-gate-review", "cc-epic", "--gate", "1", "--verdict", "pass"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("record-gate-review rejects an unknown epic id", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run( - ["record-gate-review", "ghost", "--gate", "1", "--verdict", "pass"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("record-gate-review rejects an invalid gate number", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run( - ["record-gate-review", "spec-epic", "--gate", "3", "--verdict", "pass"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("record-gate-review rejects an invalid verdict", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run( - ["record-gate-review", "spec-epic", "--gate", "1", "--verdict", "maybe"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("update-epic blocks archiving an openspec-lane epic without a passing gate2 review", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"); - assert.ok(expectFail(() => run(["update-epic", "spec-epic", "--status", "archived"], { cwd }))); - assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before); -}); - -test("update-epic blocks archiving an openspec-lane epic with a gate2 fail verdict", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - run(["record-gate-review", "spec-epic", "--gate", "2", "--verdict", "fail"], { cwd }); - assert.ok(expectFail(() => run(["update-epic", "spec-epic", "--status", "archived"], { cwd }))); -}); - -test("update-epic allows archiving an openspec-lane epic once gate2 has a passing verdict", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "spec-epic", "--lane", "openspec"], { cwd }); - run(["record-gate-review", "spec-epic", "--gate", "1", "--verdict", "pass"], { cwd }); - run(["record-gate-review", "spec-epic", "--gate", "2", "--verdict", "pass"], { cwd }); - - run(["update-epic", "spec-epic", "--status", "archived"], { cwd }); - - const epic = readState(cwd).epics.find(e => e.id === "spec-epic"); - assert.equal(epic.status, "archived"); -}); - -test("update-epic archiving a non-openspec-lane epic is unaffected by gate-review enforcement", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); - run(["add-epic", "--id", "cc-epic", "--lane", "claude-code"], { cwd }); - - run(["update-epic", "cc-epic", "--status", "archived"], { cwd }); - - const epic = readState(cwd).epics.find(e => e.id === "cc-epic"); - assert.equal(epic.status, "archived"); -}); - -// ---------- doc drift: README.md "Commands" vs the real dispatch table ---------- - -test("every dispatch-table subcommand is mentioned somewhere in README.md", () => { - const engineSrc = fs.readFileSync(ENGINE, "utf8"); - const dispatchMatch = engineSrc.match(/^\(\{\n([\s\S]*?)\n\}\[cmd\]/m); - assert.ok(dispatchMatch, "could not locate the dispatch table object in conductor.mjs — " + - "has the dispatch section been restructured? update this test's extraction regex"); - const dispatchBody = dispatchMatch[1]; - - const keys = new Set(); - for (const m of dispatchBody.matchAll(/^\s*"([a-z-]+)"\s*:/gm)) keys.add(m[1]); - for (const m of dispatchBody.matchAll(/^\s*([a-zA-Z][\w-]*)\s*:/gm)) keys.add(m[1]); - for (const m of dispatchBody.matchAll(/^\s*([a-zA-Z][\w-]*),?\s*$/gm)) keys.add(m[1]); - assert.ok(keys.size > 10, `expected many dispatch keys, only extracted ${keys.size}: ${[...keys]}`); - - // No entries are excluded — same precedent as the SKILL.md drift test: hook/init-only - // subcommands (commit-nudge, snapshot, write-rules) are still real and documentable, so - // they're asserted like everything else rather than silently excluded. - const UNDOCUMENTED_INTERNAL = new Set([ - // (currently empty — every dispatch subcommand is expected to be mentioned in README.md) - ]); - - const readmePath = path.join(path.dirname(ENGINE), "..", "README.md"); - const readmeText = fs.readFileSync(readmePath, "utf8"); - - const missing = []; - for (const key of keys) { - if (UNDOCUMENTED_INTERNAL.has(key)) continue; - if (!readmeText.includes(key)) missing.push(key); - } - assert.deepEqual(missing, [], - `README.md's Commands section (or elsewhere in the doc) is missing a mention of: ${missing.join(", ")}`); -}); - -// ---------- pre-commit hook: mechanical test-before-commit safeguard ---------- - -test(".githooks/pre-commit exists, is executable, and runs the full test suite", () => { - const hookPath = path.join(path.dirname(ENGINE), "..", ".githooks", "pre-commit"); - assert.ok(fs.existsSync(hookPath), ".githooks/pre-commit is missing"); - const stat = fs.statSync(hookPath); - assert.ok(stat.mode & 0o111, ".githooks/pre-commit is not executable"); - const hookText = fs.readFileSync(hookPath, "utf8"); - assert.match(hookText, /node --test scripts\/conductor\.test\.mjs/, - ".githooks/pre-commit does not run the full test suite"); - assert.match(hookText, /set -e/, ".githooks/pre-commit does not fail the commit on a non-zero exit"); -}); - -/** Copies the real .githooks/pre-commit into a fresh throwaway git repo with a stand-in - * scripts/conductor.test.mjs (either passing or failing), so the hook's actual noise-control - * logic (capture-to-tempfile, exit-code check, cat-only-on-failure) is exercised against the - * shipped file — not a re-implementation of it — without paying the ~30s cost of the real - * 236-test suite for both the success and failure cases. */ -function runHookAgainstFixture(testFileBody) { - const cwd = tmpRepo(); - execFileSync("git", ["init", "-q"], { cwd }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd }); - execFileSync("git", ["config", "user.name", "Test"], { cwd }); - fs.mkdirSync(path.join(cwd, "scripts"), { recursive: true }); - fs.writeFileSync(path.join(cwd, "scripts", "conductor.test.mjs"), testFileBody); - fs.mkdirSync(path.join(cwd, ".githooks"), { recursive: true }); - const realHookPath = path.join(path.dirname(ENGINE), "..", ".githooks", "pre-commit"); - const hookDestPath = path.join(cwd, ".githooks", "pre-commit"); - fs.copyFileSync(realHookPath, hookDestPath); - fs.chmodSync(hookDestPath, 0o755); - // Strip NODE_TEST_CONTEXT/NODE_TEST_WORKER_ID: node --test sets these on itself, and if - // inherited by the hook's own nested `node --test` invocation, node treats it as an - // already-child test-runner worker and short-circuits rather than actually running the - // fixture suite — a real hook invocation via `git commit` never has these set. - const env = { ...process.env }; - delete env.NODE_TEST_CONTEXT; - delete env.NODE_TEST_WORKER_ID; - return spawnSync("sh", [hookDestPath], { cwd, encoding: "utf8", env }); -} - -test(".githooks/pre-commit is quiet on success -- one summary line, no per-test noise, no engine banner", () => { - const passingTests = ` - import { test } from "node:test"; - import assert from "node:assert/strict"; - test("a passing test", () => { assert.ok(true); }); - test("another passing test", () => { assert.ok(true); }); - `; - const r = runHookAgainstFixture(passingTests); - assert.equal(r.status, 0, `expected the hook to exit 0 on a passing suite: ${r.stdout}${r.stderr}`); - const combined = (r.stdout || "") + (r.stderr || ""); - assert.match(combined, /pre-commit: 2\/2 passing/, "expected a one-line N/N passing summary"); - assert.doesNotMatch(combined, /✔/, "should not dump individual per-test pass lines on success"); -}); - -test(".githooks/pre-commit dumps full node --test output and fails the commit when a test actually fails", () => { - const failingTests = ` - import { test } from "node:test"; - import assert from "node:assert/strict"; - test("a passing test", () => { assert.ok(true); }); - test("a FAILING test", () => { assert.ok(false, "boom"); }); - `; - const r = runHookAgainstFixture(failingTests); - assert.notEqual(r.status, 0, "expected the hook to exit non-zero on a failing suite"); - const combined = (r.stdout || "") + (r.stderr || ""); - assert.match(combined, /a FAILING test/, "full test output (including the failure) must be dumped on failure"); - assert.doesNotMatch(combined, /^pre-commit: \d+\/\d+ passing/m, "must not print the success summary on failure"); -}); - -// ────────────── multi-tracker-primary-secondary-support: secondaryTrackers[] ────────────── - -test("set-tracker --role secondary adds a new entry to state.secondaryTrackers, tracker untouched", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - const state = readState(cwd); - assert.equal(state.tracker.system, "jira"); - assert.equal(state.tracker.projectKey, "JOB"); - assert.equal(state.secondaryTrackers.length, 1); - assert.equal(state.secondaryTrackers[0].system, "github-issues"); - assert.equal(state.secondaryTrackers[0].repo, "acme/market-intelligence"); - assert.equal(state.secondaryTrackers[0].role, "secondary"); -}); - -test("re-running set-tracker --role secondary with the same system+repo merges in place, not a duplicate", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence", "--instance", "ghe"], { cwd }); - const secondary = readState(cwd).secondaryTrackers; - assert.equal(secondary.length, 1); - assert.equal(secondary[0].instance, "ghe"); -}); - -test("two secondary trackers with different repos coexist independently", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/risk-engine"], { cwd }); - const secondary = readState(cwd).secondaryTrackers; - assert.equal(secondary.length, 2); - const repos = secondary.map(s => s.repo).sort(); - assert.deepEqual(repos, ["acme/market-intelligence", "acme/risk-engine"]); -}); - -test("a repo-keyed and a projectKey-keyed secondary entry with the same string value do not collide", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "jira", "--project", "ABC"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "jira", "--repo", "ABC"], { cwd }); - const secondary = readState(cwd).secondaryTrackers; - assert.equal(secondary.length, 2, "namespace-prefixed keys must not collide across repo/projectKey"); -}); - -test("set-tracker with no --role (and --role primary) behaves exactly as before this change", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--instance", "onvex", "--project", "JOB", - "--mechanism", "mcp", "--intent", "active:in-progress"], { cwd }); - let state = readState(cwd); - assert.equal(state.tracker.system, "jira"); - assert.equal(state.secondaryTrackers, undefined); - - run(["set-tracker", "--role", "primary", "--intent", "paused:todo"], { cwd }); - state = readState(cwd); - assert.deepEqual(state.tracker.statusIntent, { active: "in-progress", paused: "todo" }); - assert.equal(state.secondaryTrackers, undefined); -}); - -test("set-tracker --role secondary --remove deletes the matching entry", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/decommissioned-repo"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/still-active"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/decommissioned-repo", "--remove"], { cwd }); - const secondary = readState(cwd).secondaryTrackers; - assert.equal(secondary.length, 1); - assert.equal(secondary[0].repo, "acme/still-active"); -}); - -test("set-tracker --role secondary --remove against a non-existent entry exits non-zero and changes nothing", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", "--repo", "acme/kept"], { cwd }); - assert.throws(() => run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/never-registered", "--remove"], { cwd })); - const secondary = readState(cwd).secondaryTrackers; - assert.equal(secondary.length, 1); - assert.equal(secondary[0].repo, "acme/kept"); -}); - -test("state.json without secondaryTrackers loads and renders exactly as before this change", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - writeState(cwd, { - version: 1, active: null, detourStack: [], - tracker: { system: "jira", projectKey: "JOB" }, - epics: [{ id: "a", title: "a", priority: "P1", status: "queued", role: "epic", lane: "claude-code", links: [] }], - }); - run(["render"], { cwd }); - const md = projectMd(cwd); - assert.match(md, /`a`/); - const rules = run(["rules"], { cwd }); - assert.doesNotMatch(rules, /market-intelligence/); -}); - -// ────────────── externalUrl-first dedup (cross-tracker externalId collision fix) ────────────── - -test("add-epic dedups by externalUrl when both incoming and an existing epic have one, even if externalId matches", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "mi-42", "--lane", "claude-code", "--external-id", "42", - "--external-url", "https://github.com/acme/market-intelligence/issues/42"], { cwd }); - // Same bare externalId "42", but a DIFFERENT repo's issue — must NOT be treated as a duplicate. - run(["add-epic", "--id", "risk-42", "--lane", "claude-code", "--external-id", "42", - "--external-url", "https://github.com/acme/risk-engine/issues/42"], { cwd }); - const ids = readState(cwd).epics.map(e => e.id).sort(); - assert.deepEqual(ids, ["mi-42", "risk-42"]); -}); - -test("add-epic still rejects a true duplicate externalUrl", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "mi-42", "--lane", "claude-code", "--external-id", "42", - "--external-url", "https://github.com/acme/market-intelligence/issues/42"], { cwd }); - assert.throws(() => run(["add-epic", "--id", "mi-42-dup", "--lane", "claude-code", "--external-id", "42", - "--external-url", "https://github.com/acme/market-intelligence/issues/42"], { cwd })); - const ids = readState(cwd).epics.map(e => e.id); - assert.deepEqual(ids, ["mi-42"]); -}); - -test("add-epic falls back to bare externalId dedup when neither side has a URL", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["add-epic", "--id", "job-1", "--lane", "claude-code", "--external-id", "JOB-1"], { cwd }); - assert.throws(() => run(["add-epic", "--id", "job-1-dup", "--lane", "claude-code", "--external-id", "JOB-1"], { cwd })); - const ids = readState(cwd).epics.map(e => e.id); - assert.deepEqual(ids, ["job-1"]); -}); - -test("a URL-less legacy epic never falsely blocks a genuinely distinct, URL-bearing epic sharing the same bare externalId (Gate 2 finding)", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - // Legacy epic registered with only a bare externalId, no URL. - run(["add-epic", "--id", "legacy-42", "--lane", "claude-code", "--external-id", "42"], { cwd }); - // A genuinely distinct epic happens to share the bare id "42" but DOES carry a URL — must - // NOT be treated as a duplicate of the URL-less legacy entry. - run(["add-epic", "--id", "risk-42", "--lane", "claude-code", "--external-id", "42", - "--external-url", "https://github.com/acme/risk-engine/issues/42"], { cwd }); - const ids = readState(cwd).epics.map(e => e.id).sort(); - assert.deepEqual(ids, ["legacy-42", "risk-42"]); -}); - -// ────────────── rulesBlock(): secondary-tracker inward pull + status writeback ────────────── - -test("rulesBlock emits an inward-pull + status-writeback section per secondary tracker", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.match(rules, /acme\/market-intelligence/); - assert.match(rules, /externalUrl/); - assert.match(rules, /add-epic --status untriaged/); - assert.match(rules, /archived/); - assert.match(rules, /close/i); -}); - -test("rulesBlock emits one section per secondary tracker when multiple are configured", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/risk-engine"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.match(rules, /acme\/market-intelligence/); - assert.match(rules, /acme\/risk-engine/); -}); - -test("secondary trackers never get an outward issue-creation instruction", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - const rules = run(["rules"], { cwd }); - // The primary-only outward phrase must not appear anywhere in a secondary tracker's section - assert.doesNotMatch(rules, /create the .*acme\/market-intelligence.* issue/i); -}); - -test("primary tracker rules-block output (including github-issues-as-primary suppression) is unchanged when secondaryTrackers is empty", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "github-issues", "--repo", "cfdude/pm"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.match(rules, /GitHub issue sync/); - assert.doesNotMatch(rules, /External tracker sync/); -}); - -test("jira primary + github-issues secondary coexist: primary gets bidirectional sync, secondary gets inward+writeback only", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--project", "JOB", - "--intent", "active:in-progress", "--intent", "archived:done"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.match(rules, /External tracker sync \(jira/); - assert.match(rules, /acme\/market-intelligence/); -}); - -// ────────────── completion-time resync instruction + session-start sync nudge ────────────── - -test("rulesBlock adds a resync-after-completion instruction when a secondary tracker is configured", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.match(rules, /Sync after completing tracker-linked work/); - assert.match(rules, /\/pm:sync/); -}); - -test("rulesBlock adds a resync-after-completion instruction when the primary tracker is github-issues", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "github-issues", "--repo", "cfdude/pm"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.match(rules, /Sync after completing tracker-linked work/); -}); - -test("rulesBlock omits the resync instruction when the only tracker is a non-github-issues primary with no secondaries", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.doesNotMatch(rules, /Sync after completing tracker-linked work/); -}); - -test("rulesBlock omits the resync instruction when no tracker is configured at all", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const rules = run(["rules"], { cwd }); - assert.doesNotMatch(rules, /Sync after completing tracker-linked work/); -}); - -test("SessionStart brief nudges toward /pm:sync when trackers are configured, singular phrasing for one", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "github-issues", "--repo", "cfdude/pm"], { cwd }); - const brief = run(["brief"], { cwd }); - assert.match(brief, /1 tracker configured \(github-issues\) — consider `\/pm:sync`/); -}); - -test("SessionStart brief pluralizes and lists every system when primary + secondary trackers are configured", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - run(["set-tracker", "--system", "jira", "--project", "JOB"], { cwd }); - run(["set-tracker", "--role", "secondary", "--system", "github-issues", - "--repo", "acme/market-intelligence"], { cwd }); - const brief = run(["brief"], { cwd }); - assert.match(brief, /2 trackers configured \(jira, github-issues\) — consider `\/pm:sync`/); -}); - -test("SessionStart brief has no sync nudge when no tracker is configured", () => { - const cwd = tmpRepo(); - run(["init"], { cwd }); - const brief = run(["brief"], { cwd }); - assert.doesNotMatch(brief, /consider `\/pm:sync` this session/); -}); diff --git a/scripts/lib/constants.mjs b/scripts/lib/constants.mjs index 42d4285..95cb3a8 100644 --- a/scripts/lib/constants.mjs +++ b/scripts/lib/constants.mjs @@ -16,6 +16,28 @@ export const CHANGES_DIR = path.join(ROOT, "openspec", "changes"); export const ARCHIVE_DIR = path.join(CHANGES_DIR, "archive"); export const PLANS_DIR = path.join(ROOT, "docs", "superpowers", "plans"); export const KNOWN_LANES = ["openspec", "superpowers", "claude-code", "decision", "external"]; +export const KNOWN_PLATFORMS = ["claude-code", "hermes", "codex"]; + +// Each platform's project-instruction file, in the order that platform resolves them. +// First EXISTING file wins (that is the one the platform will actually read); if none +// exist, the LAST entry is created -- it is the most broadly-compatible choice, e.g. a +// fresh Hermes repo gets CLAUDE.md (which Hermes reads as its third fallback and Claude +// Code reads natively) rather than a Hermes-exclusive HERMES.md. +export const PLATFORM_RULES_CHAIN = { + "claude-code": ["CLAUDE.md"], + hermes: ["HERMES.md", "AGENTS.md", "CLAUDE.md"], + codex: ["AGENTS.md"], +}; + +// Slash-command form per platform. Hermes preserves ':' in plugin command names and +// looks them up by whole key, so the namespace survives -- and the namespace matters: +// Hermes SILENTLY skips a plugin command colliding with a built-in, and it ships a +// built-in `status`. Codex derives command names from prompt-file stems, so it is flat. +export const PLATFORM_COMMAND_PREFIX = { + "claude-code": "/pm:", + hermes: "/pm:", + codex: "/pm-", +}; export const KNOWN_STATUSES = ["untriaged", "queued", "active", "paused", "later", "blocked", "planned", "archived"]; export const KNOWN_AUTONOMY_LEVELS = ["off", "autonomous"]; // Default category taxonomy for the `--preauthorize "category::"` shorthand — @@ -30,5 +52,23 @@ export const REVIEW_MODE_RANK = { off: 0, standard: 1, thorough: 2 }; export const LANE_RANK = { openspec: 0, superpowers: 1, "claude-code": 2, decision: 3, external: 4 }; export const laneRank = (l) => (l in LANE_RANK ? LANE_RANK[l] : 9); -export const RULES_BEGIN = ""; +// Platform-neutral by design: this is the literal ANCHOR writeRules() detects/replaces the +// block by (existing.includes(RULES_BEGIN), and the regex built from it). It must stay a +// single invariant string across all platforms, so it deliberately does not reference any +// platform's command form -- that lives in the block body instead, via pmCmd(). +export const RULES_BEGIN = ""; export const RULES_END = ""; + +// DETECTION keys on this stable prefix, never on the full decorated RULES_BEGIN above. +// +// Learned the hard way: the parenthetical used to read "(managed by /pm:init …)" and changing +// it to the platform-neutral "(managed by pm …)" meant `existing.includes(RULES_BEGIN)` no +// longer matched a block written by any earlier version -- so writeRules() fell through to its +// APPEND branch and produced a SECOND rules block in every existing repo on the next upgrade. +// Verified live before the fix: 1 block in, 2 blocks out. +// +// Keying on the prefix makes the decoration free to change: an old block is found, replaced in +// place, and silently upgraded to the current wording. Never tighten this back to the full +// string, and never make it platform-dependent -- a per-platform anchor would mean a block +// written under one platform is invisible to another, reintroducing the same duplication. +export const RULES_BEGIN_PREFIX = "