From 27c1c59f3181b340883bc8b0a21c969cb5781bb0 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Tue, 28 Jul 2026 22:01:37 -0700 Subject: [PATCH 01/30] 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 --- evals/runners.py | 21 +++++++++++++++++++++ evals/tests/test_runners.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) 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" + ) From 0e8d19a021502db536c5e9a6ac31cf96dcd6f804 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Tue, 28 Jul 2026 22:03:34 -0700 Subject: [PATCH 02/30] chore(pm): file the two P3 items from the rules-block design discussion - init-detects-foreign-agent-instruction-files: pm init should read a pre-existing AGENTS.md / sol.md left by a prior agent before initializing - rules-block-cli-fallback: deferred as a planned epic rather than a code TODO, because a comment resurfaces to nobody while a planned epic shows in PROJECT.md Also re-sequences edd-observe-hardcodes-claude-md as depends-on the rules-block epic: observe.py correctly reflects what the engine writes, and the engine hardcodes CLAUDE.md, so parameterizing the observer first would abstract over a filename nothing produces. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 +-- .conductor/state.json | 54 +++++++++++++++++++++++++++++++----- PROJECT.md | 23 ++++++++------- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index a04eba1..57cbf60 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-29T03:55:44.409Z", + "stateMtimeMs": 1785297344396.3887 } diff --git a/.conductor/state.json b/.conductor/state.json index a51f03b..d3585a0 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1,6 +1,6 @@ { "version": 1, - "active": null, + "active": "rules-block-hardcodes-claude-slash-commands", "epics": [ { "id": "pm-fix-link-flag-validation-and-update", @@ -1136,12 +1136,13 @@ "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": "active", "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" }, { "id": "platform-parity-mechanism", @@ -1222,12 +1223,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,6 +1300,38 @@ "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 + } + ] } ], "detourStack": [], diff --git a/PROJECT.md b/PROJECT.md index 861bfa0..f296e06 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,11 +3,11 @@ > 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-29T05:03:16.131Z ## Now -_No active epic set._ +**`rules-block-hardcodes-claude-slash-commands`** — 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. (epic, P1) — — ## Detour stack @@ -22,7 +22,7 @@ _Empty — no work is paused._ | 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 | `pm-fix-link-flag-validation-and-update` | claude-code | epic | archived | — | - | | P2 | `conductor-mjs-module-split` | openspec | epic | archived | — | - | @@ -70,12 +70,14 @@ _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 | `init-detects-foreign-agent-instruction-files` | claude-code | epic | planned | 0/1 stories | - | | P3 | `multi-platform-agent-support` | claude-code | epic | queued | 1/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 | active | — | - | | 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 | - | @@ -83,7 +85,6 @@ _Empty — no work is paused._ | 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,25 +92,27 @@ _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) ``` CONDUCTOR STATE — where we are and what's next -NOW: (no active epic set) +NOW: `rules-block-hardcodes-claude-slash-commands` (claude-code, epic, P1) — — 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`) — — • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — - (+1 more — see PROJECT.md) + • `multi-platform-agent-support` (P3, claude-code, queued) — — lanes: openspec 2 · superpowers 1 · claude-code 48 · 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 From ad3c9f51372e026337c24b0064e914fe7b4c2e61 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 12:26:07 -0700 Subject: [PATCH 03/30] docs(pm): design doc for the platform-aware rules block Key finding that reshaped it: Hermes resolves project context through a first-match-wins chain (HERMES.md > AGENTS.md > CLAUDE.md > .cursorrules), so it DOES read CLAUDE.md -- but only when the first two are absent. In a repo with a pre-existing AGENTS.md, pm's rules block becomes silently invisible on Hermes. Writing a file the platform can read is not enough; it must win the chain. Also records the verified command facts: ':' survives Hermes' plugin command registration and lookup, and a name colliding with a built-in is SILENTLY skipped -- so the pm: namespace prevents real loss, not just ugliness. Codex is flat/hyphenated, evidenced by the maintainer's own opsx-*.md prompt files. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- ...07-29-platform-aware-rules-block-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-platform-aware-rules-block-design.md 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..15266f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-platform-aware-rules-block-design.md @@ -0,0 +1,156 @@ +# 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. Detect the platform; write exactly one file + +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. A project is +normally driven by one agent. + +Detection resolves to a single platform, and the platform resolves to a single target file. + +### 2. Default to `claude-code` when detection is ambiguous or empty — and say so + +Detection has one dangerous failure mode: finding nothing and writing nothing. pm would appear +installed while contributing no rules block at all — failing silently, which is the worst +available outcome and the same class of bug as the precedence trap above. + +Therefore: **an unresolved detection falls back to `claude-code`**, the base platform and +today's behavior, and `init`/`upgrade` **reports the target it chose**. A visible wrong guess +is recoverable; a silent no-op is not. + +### 3. 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. + +Where pm finds a foreign instruction file that outranks its target, it must not silently write +the losing file. Options are to write the winning file instead, or to refuse and report — to be +settled in the plan, but silence is not among them. + +### 4. 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. + +## Open questions for the plan + +1. What concrete markers identify each platform, and in what precedence? (`.claude/` vs + `.codex/` vs a Hermes project marker; a repo may carry several.) +2. Does the detected target belong in `state.json` — recorded once at `init` and stable + thereafter — or re-detected on every `write-rules`? Recording it is more predictable and + makes the choice auditable; re-detecting adapts when a repo changes hands. If recorded, this + needs a `MIGRATIONS` entry. +3. Does the rules block need a per-platform *body*, or only per-platform command strings? The + tracker/review-mode text appears platform-neutral, but this should be confirmed rather than + assumed. From a7bea1b3ff17d50cb75a7a66aa0b6eadc53e8563 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 12:30:30 -0700 Subject: [PATCH 04/30] docs(pm): resolve the foreign-file fork in the rules-block design Self-review caught an unresolved either/or in section 3. Decided: write the head of the detected platform's chain and report the choice. Refusing was rejected -- it leaves pm non-functional in the repo, trading a silent failure for a loud one rather than fixing it. Also clarified the rule is per-platform: Claude Code has no chain, so a stray AGENTS.md is irrelevant there. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .../2026-07-29-platform-aware-rules-block-design.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 15266f8..44f0016 100644 --- 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 @@ -96,9 +96,13 @@ what is already on disk — not merely a file it could consult. Concretely, for `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. -Where pm finds a foreign instruction file that outranks its target, it must not silently write -the losing file. Options are to write the winning file instead, or to refuse and report — to be -settled in the plan, but silence is not among them. +**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. ### 4. Command syntax: namespaced slash command, per-platform form From 9a286074eab5ca6f50168dd9d4d4e9de8b990021 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 16:55:20 -0700 Subject: [PATCH 05/30] docs(pm): resolve all three open questions in the rules-block design Q1 reframed the mechanism entirely. pm never runs on its own -- every engine invocation is triggered by a host agent -- and pm already authors a separate hook config per platform. So the host declares itself via --platform in the hook command pm wrote, and there is no detection at all: no markers, no filesystem archaeology, no precedence to get wrong. Env sniffing is rejected to a fallback rung: CLAUDECODE=1 is a reliable Claude Code marker (verified live), but Hermes' shell-hook runner calls subprocess.run with no env= (agent/shell_hooks.py:463), so hooks inherit whatever launched it and carry no guaranteed HERMES_* marker -- it could never separate Hermes from Codex. Q2: active platform recorded in state.json, written and read once per session start, with a MIGRATIONS entry. Enables the platform-switch case. Q3: body stays neutral; only command strings vary. Also renumbered the Design subsections after the insert collided on 3. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- ...07-29-platform-aware-rules-block-design.md | 109 ++++++++++++++---- 1 file changed, 87 insertions(+), 22 deletions(-) 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 index 44f0016..374682a 100644 --- 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 @@ -71,25 +71,70 @@ question #3: Codex is flat and hyphenated. ## Design -### 1. Detect the platform; write exactly one file +### 1. The host declares itself — pm does not sniff for it -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. A project is +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. -Detection resolves to a single platform, and the platform resolves to a single target file. +### 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 -### 2. Default to `claude-code` when detection is ambiguous or empty — and say so +`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. -Detection has one dangerous failure mode: finding nothing and writing nothing. pm would appear -installed while contributing no rules block at all — failing silently, which is the worst -available outcome and the same class of bug as the precedence trap above. +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. -Therefore: **an unresolved detection falls back to `claude-code`**, the base platform and -today's behavior, and `init`/`upgrade` **reports the target it chose**. A visible wrong guess -is recoverable; a silent no-op is not. +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. -### 3. Guarantee the written file wins the platform's precedence chain +### 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 @@ -104,7 +149,17 @@ Note this is per-platform, not global. Claude Code has no chain: it reads `CLAUD 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. -### 4. Command syntax: namespaced slash command, per-platform form +### 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. @@ -147,14 +202,24 @@ which is **correct today** because the engine only writes `CLAUDE.md`; parameter 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 concrete markers identify each platform, and in what precedence? (`.claude/` vs - `.codex/` vs a Hermes project marker; a repo may carry several.) -2. Does the detected target belong in `state.json` — recorded once at `init` and stable - thereafter — or re-detected on every `write-rules`? Recording it is more predictable and - makes the choice auditable; re-detecting adapts when a repo changes hands. If recorded, this - needs a `MIGRATIONS` entry. -3. Does the rules block need a per-platform *body*, or only per-platform command strings? The - tracker/review-mode text appears platform-neutral, but this should be confirmed rather than - assumed. +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`. From 9272960ed1dc9c858e6291e07fead478befa5b78 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 17:03:56 -0700 Subject: [PATCH 06/30] docs(pm): implementation plan for the platform-aware rules block Five TDD tasks. Written against the real tree, and verifying it caught four things that would each have sent an implementer down a dead end: - the dispatch table calls every subcommand with NO arguments, and init/upgrade/ writeRules never read argv -- so --platform on init or write-rules would have been a silent no-op and the tests would fail in the confusing direction - platform.mjs must NOT import parseFlags from add-epic.mjs: that pulls it into the render/briefing/active-pointer circular cluster, and since rules.mjs imports platform.mjs a future edge back would loop around the rules writer. Kept a leaf with a three-line flag scan instead - the obvious `rg writeRules\(\)` verification can never return empty (two explanatory comments match), so it is statement-anchored instead - test run() spreads process.env, so CLAUDECODE=1 leaks in; default-rung tests must blank it or they pass via the wrong rung Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .../2026-07-29-platform-aware-rules-block.md | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md 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..343b6ed --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md @@ -0,0 +1,793 @@ +# 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/conductor.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/conductor.test.mjs` must pass — currently 250 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/conductor.test.mjs`** (modify) — tests appended per task. + +**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/conductor.test.mjs` (append) + +**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/conductor.test.mjs:15` 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** + +Append to `scripts/conductor.test.mjs`: + +```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/conductor.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/conductor.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/conductor.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/conductor.test.mjs` (append) + +**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/conductor.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/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Expected: `fail 0`. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/lib/rules.mjs scripts/conductor.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/conductor.test.mjs` (append) + +**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/conductor.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/conductor.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/conductor.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/conductor.test.mjs` (append) + +**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/conductor.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/conductor.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/conductor.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/conductor.test.mjs` (append) + +**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/conductor.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/conductor.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/conductor.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/conductor.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. From e5425d9e8e8c556d3369ddb1c6b48b0159a1fdb2 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 19:39:57 -0700 Subject: [PATCH 07/30] chore(pm): sync + triage 5 inbound issues; consolidate the auto-detour pair /pm:sync reproduced #69 live three times, including a duplicate of the ACTIVE epic -- removed all three phantoms. Registered 64/65/66/69/70 as epics. #68 (mine, backgrounded commit logs the previous SHA) closed as a duplicate of #65, which is the superset: same missing-success-check defect plus a second repo-scope defect. Recorded on #65 that the HEAD-advanced guard covers both triggers while an exit-code check would only cover one. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 +- .conductor/state.json | 86 ++++++++++++++++++++++++++++++++++++ PROJECT.md | 14 ++++-- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index 57cbf60..703385e 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-29T03:55:44.409Z", - "stateMtimeMs": 1785297344396.3887 + "renderedAt": "2026-07-30T02:39:57.559Z", + "stateMtimeMs": 1785379197456.842 } diff --git a/.conductor/state.json b/.conductor/state.json index d3585a0..86f72be 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1332,6 +1332,92 @@ "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" + }, + { + "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": false + }, + { + "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": false + } + ] + }, + { + "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" } ], "detourStack": [], diff --git a/PROJECT.md b/PROJECT.md index f296e06..11f7e60 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-29T05:03:16.131Z +> Last rendered: 2026-07-30T02:39:57.527Z ## Now @@ -24,6 +24,7 @@ _Empty — no work is paused._ | P1 | `df-readme-stale-since-gate-guard` | claude-code | epic | archived | — | - | | 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 | 0/3 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 | — | - | @@ -46,6 +47,9 @@ _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 | — | - | +| 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 | `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 | — | - | @@ -70,6 +74,7 @@ _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 | `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 | 1/5 children archived | - | | P1 | └─ `platform-parity-mechanism` | openspec | epic | queued ⚠ no change on disk | — | - | @@ -104,10 +109,11 @@ NOW: `rules-block-hardcodes-claude-slash-commands` (claude-code, epic, P1) — NEXT UP (by priority, then lane): • `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 + • `gh-65-autodetour-false-positives` (P1, 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) — — - • `multi-platform-agent-support` (P3, claude-code, queued) — — - lanes: openspec 2 · superpowers 1 · claude-code 48 · decision 7 + (+5 more — see PROJECT.md) + lanes: openspec 2 · superpowers 1 · claude-code 53 · decision 7 planned: 2 — see PROJECT.md @@ -118,11 +124,13 @@ EPIC LINKS: • `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 • `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): From 0e902417def366bc3062f97ca04edd70fa46ff51 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 20:04:21 -0700 Subject: [PATCH 08/30] fix(engine): auto-detour must confirm a commit landed HERE (closes #65, #68) PostToolUse fires when the Bash tool RETURNS, which is not the same as "a commit landed in this repo". Three observed divergences each wrote a false detours.log line attributed to this repo's STALE HEAD: * pre-commit rejected the commit -> HEAD never advanced (#65 bug 1) * the commit was backgrounded, still running -> not advanced yet (#68) * the commit landed in ANOTHER repo (paired repo, submodule, git -C) -> our HEAD is untouched, but gitShortSha()/headChangedFiles() both read ROOT and so attribute that commit to this repo (#65 bug 2) All three reduce to one question: does HEAD in ROOT hold the commit we parsed? Comparing SHAs would need a stored baseline; the subject is already in hand. Note an exit-code check would NOT cover the backgrounded case -- there is no exit code yet -- which is why the HEAD comparison closes both issues rather than just one. Three-state on purpose. Only CONTRADICTED (subject parsed, git works, HEAD disagrees) goes silent. UNVERIFIABLE (no -m to parse, or git unusable here) keeps the previous behaviour, because the archived-epic self-heal must still run in a repo with no git -- the existing test at conductor.test.mjs:996 has no git repo at all, so a two-state guard would have broken it. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- scripts/conductor.test.mjs | 70 +++++++++++++++++++++++++++++++++++++ scripts/lib/subcommands.mjs | 31 ++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/scripts/conductor.test.mjs b/scripts/conductor.test.mjs index edbae45..a4b38d5 100644 --- a/scripts/conductor.test.mjs +++ b/scripts/conductor.test.mjs @@ -3130,3 +3130,73 @@ test("SessionStart brief has no sync nudge when no tracker is configured", () => const brief = run(["brief"], { cwd }); assert.doesNotMatch(brief, /consider `\/pm:sync` this session/); }); + +// ───────── gh#65 / gh#68: the auto-detour hook must confirm a commit actually landed HERE ───────── +// PostToolUse fires when the Bash tool RETURNS, which is not the same as "a commit landed in +// this repo". Three observed divergences, all producing a false detours.log entry attributed +// to this repo's STALE HEAD: the commit was rejected by pre-commit (gh#65), it was backgrounded +// and is still running (gh#68), or it landed in a different repo entirely (gh#65 bug 2). + +test("commit-nudge does not log when the commit was rejected and never landed (gh#65)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + // HEAD is "chore: baseline". The rejected commit never became an object. + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: rejected by pre-commit, never landed"' } }) }); + const log = detourLog(cwd); + assert.doesNotMatch(log, /AUTO-DETOUR/, "a commit that never landed must not be logged"); + assert.doesNotMatch(log, /rejected by pre-commit/); +}); + +test("commit-nudge does not log when the commit is still running in the background (gh#68)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + // Same observable state as a rejected commit: the hook fires while `git commit` is still + // running, so HEAD has not advanced yet and still holds "chore: baseline". + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -q -m "chore: still running"' } }) }); + assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); +}); + +test("commit-nudge does not attribute a commit that landed in a DIFFERENT repo (gh#65 bug 2)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + // A separate repo standing in for a paired repo / submodule. Note gitRepo() cannot be used + // here: it assumes /pm:init already scaffolded files, and its baseline commit throws + // ("nothing to commit") in the empty dir tmpRepo() returns. + const other = tmpRepo(); + execFileSync("git", ["init", "-q"], { cwd: other }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: other }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: other }); + commitFiles(other, { "paired.txt": "1" }, "fix: belongs to the paired repo"); + // The commit succeeded -- in `other`. This repo's HEAD is untouched, so nothing may be logged. + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: belongs to the paired repo"' } }) }); + const log = detourLog(cwd); + assert.doesNotMatch(log, /AUTO-DETOUR/); + assert.doesNotMatch(log, /paired repo/); +}); + +test("commit-nudge does not log a DETOUR-COMMIT when the commit never landed (gh#65)", () => { + 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: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, + ], + }); + gitRepo(cwd); + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: this one was rejected too"' } }) }); + assert.doesNotMatch(detourLog(cwd), /DETOUR-COMMIT/, + "the detour trail must not record a commit that never landed either"); +}); + +test("commit-nudge still logs a genuine landed commit (the guard must not silence the real case)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + commitFiles(cwd, { "a.txt": "1" }, "fix: a real landed detour"); + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: a real landed detour"' } }) }); + assert.match(detourLog(cwd), /AUTO-DETOUR/); + assert.match(detourLog(cwd), /a real landed detour/); +}); diff --git a/scripts/lib/subcommands.mjs b/scripts/lib/subcommands.mjs index 1f6969f..bbddcce 100644 --- a/scripts/lib/subcommands.mjs +++ b/scripts/lib/subcommands.mjs @@ -61,6 +61,17 @@ export function headChangedFiles() { } catch { return null; } } +/** Subject line of the commit at HEAD in the pm-managed repo, or null when git is unusable + * here (not a repo yet, no commits, git missing). null means "cannot tell", which is + * deliberately NOT the same answer as "no commit landed" — see commitNudge's guard. */ +export function headSubject() { + try { + return execSync("git log -1 --format=%s", { + cwd: ROOT, stdio: ["ignore", "pipe", "ignore"], + }).toString().trim(); + } catch { return null; } +} + /** pm's own state-output files — routine conductor bookkeeping (registering/archiving * epics, re-rendering) touches only these, never a stray detour. CLAUDE.md is deliberately * excluded: it's user-authored content, not purely engine-generated output, so a commit @@ -95,6 +106,26 @@ export function commitNudge() { const m = cmd.match(/-m\s+(?:"([^"]*)"|'([^']*)'|(\S+))/); const subject = (m && (m[1] || m[2] || m[3])) || ""; + // gh#65 / gh#68: PostToolUse fires when the Bash tool RETURNS, which is NOT the same as + // "a commit landed in this repo". Three observed divergences, each of which wrote a false + // detours.log line attributed to this repo's STALE HEAD: + // * pre-commit rejected the commit -> HEAD never advanced (gh#65 bug 1) + // * the commit was backgrounded, still running -> HEAD not advanced yet (gh#68) + // * the commit landed in ANOTHER repo (paired repo, submodule, `git -C`) -> our HEAD is + // untouched, but gitShortSha()/headChangedFiles() both read ROOT and so attribute + // that commit to this repo (gh#65 bug 2) + // + // All three reduce to one question: does HEAD in ROOT hold the commit we just parsed? + // Comparing SHAs would need a stored baseline; the subject is already in hand. Note an + // exit-code check would NOT cover the backgrounded case — there is no exit code yet. + // + // Three-state on purpose. Only CONTRADICTED (a subject was parsed, git works, and HEAD + // disagrees) means "no commit landed here" and goes silent. UNVERIFIABLE (no `-m` to + // parse, or git unusable here) keeps the previous behaviour, because the archived-epic + // self-heal below must still run in a repo with no git at all. + const head = headSubject(); + if (subject && head !== null && head !== subject) return; + // DETERMINISTIC: if we are inside a detour, record this commit in the trail. let autoLogged = false; if (ctx.active) { From 172c2c1551c4050f5ae1b1598c08418d28a5d17d Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 20:55:27 -0700 Subject: [PATCH 09/30] docs(pm): changelog the auto-detour fix; file the test-suite perf epic CHANGELOG [Unreleased] entry for the #65/#68 fix, including why an exit-code check would not have covered both issues and why the guard is three-state. Files test-suite-perf-and-scaffolding (P1) with the measurements behind it: 73ms per engine spawn x 676 run() call sites = ~49s of pure node startup, all in ONE file, on 16 cores. node parallelizes across FILES only -- proven with a 4-file x 3s probe that finished in 3.3s rather than 12s. The file already exports 8 helpers that nothing imports, so it was built to be split and never was. Also records a silent-pass hole found while reviewing my own new tests: detourLog() returns "" for a missing file, so assert.doesNotMatch(detourLog(...)) passes even when commit-nudge never ran -- and it exits 0 on every early return, so run() does not throw either. Same class as the INDETERMINATE-exits-0 bug the EDD review caught. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 +-- .conductor/state.json | 51 ++++++++++++++++++++++++++++++++---- CHANGELOG.md | 27 +++++++++++++++++++ PROJECT.md | 22 +++++++++------- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index 703385e..0beb3e8 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T02:39:57.559Z", - "stateMtimeMs": 1785379197456.842 + "renderedAt": "2026-07-30T03:54:53.504Z", + "stateMtimeMs": 1785383693485.9067 } diff --git a/.conductor/state.json b/.conductor/state.json index 86f72be..392bca9 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1,6 +1,6 @@ { "version": 1, - "active": "rules-block-hardcodes-claude-slash-commands", + "active": "gh-65-autodetour-false-positives", "epics": [ { "id": "pm-fix-link-flag-validation-and-update", @@ -1136,7 +1136,7 @@ "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": "active", + "status": "queued", "role": "epic", "lane": "claude-code", "links": [], @@ -1349,7 +1349,7 @@ "id": "gh-65-autodetour-false-positives", "title": "auto-detour writes false-positive entries: no success check + no repo-scope check", "priority": "P1", - "status": "queued", + "status": "active", "role": "epic", "lane": "claude-code", "links": [], @@ -1359,7 +1359,7 @@ "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": false + "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)", @@ -1367,9 +1367,14 @@ }, { "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", @@ -1418,6 +1423,42 @@ "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": "queued", + "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": false + }, + { + "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": false + }, + { + "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": false + }, + { + "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": false + }, + { + "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": false + } + ] } ], "detourStack": [], diff --git a/CHANGELOG.md b/CHANGELOG.md index 0138c38..1bb8492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- **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. + + 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. + ## [0.23.1] — 2026-07-23 ### Fixed diff --git a/PROJECT.md b/PROJECT.md index 11f7e60..80687b8 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,11 +3,11 @@ > 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-30T02:39:57.527Z +> Last rendered: 2026-07-30T03:54:53.497Z ## Now -**`rules-block-hardcodes-claude-slash-commands`** — 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. (epic, P1) — — +**`gh-65-autodetour-false-positives`** — auto-detour writes false-positive entries: no success check + no repo-scope check (epic, P1) — 2/4 stories ## Detour stack @@ -17,6 +17,7 @@ _Empty — no work is paused._ | Priority | Epic | Lane | Role | Status | Progress | Links | |----------|------|------|------|--------|----------|-------| +| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | queued | 0/6 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 | — | - | @@ -24,7 +25,7 @@ _Empty — no work is paused._ | P1 | `df-readme-stale-since-gate-guard` | claude-code | epic | archived | — | - | | 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 | 0/3 stories | - | +| P1 | `gh-65-autodetour-false-positives` | claude-code | epic | active | 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 | — | - | @@ -78,7 +79,7 @@ _Empty — no work is paused._ | P3 | `init-detects-foreign-agent-instruction-files` | claude-code | epic | planned | 0/1 stories | - | | P3 | `multi-platform-agent-support` | claude-code | epic | queued | 1/5 children archived | - | | P1 | └─ `platform-parity-mechanism` | openspec | epic | queued ⚠ no change on disk | — | - | -| P1 | └─ `rules-block-hardcodes-claude-slash-commands` | claude-code | epic | active | — | - | +| P1 | └─ `rules-block-hardcodes-claude-slash-commands` | claude-code | epic | queued | — | - | | 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 | @@ -104,16 +105,17 @@ _Empty — no work is paused._ ``` CONDUCTOR STATE — where we are and what's next -NOW: `rules-block-hardcodes-claude-slash-commands` (claude-code, epic, P1) — — +NOW: `gh-65-autodetour-false-positives` (claude-code, epic, P1) — 2/4 stories NEXT UP (by priority, then lane): - • `edd-observe-hardcodes-claude-md` (P1, claude-code, queued) — 0/1 stories + • `test-suite-perf-and-scaffolding` (P1, superpowers, queued) — 0/6 stories • `edd-user-memory-confound-control-run` (P1, claude-code, queued) — 0/5 stories - • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 0/3 stories + • `rules-block-hardcodes-claude-slash-commands` (P1, claude-code, queued, parent: `multi-platform-agent-support`) — — • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — - (+5 more — see PROJECT.md) - lanes: openspec 2 · superpowers 1 · claude-code 53 · decision 7 + (+6 more — see PROJECT.md) + ⚠ epic `edd-observe-hardcodes-claude-md` ready but waiting on `rules-block-hardcodes-claude-slash-commands` + lanes: openspec 2 · superpowers 2 · claude-code 53 · decision 7 planned: 2 — see PROJECT.md @@ -134,7 +136,7 @@ EPIC LINKS: • `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): `test-suite-perf-and-scaffolding`, `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` 💡 1 tracker configured (github-issues) — consider `/pm:sync` this session to pull in any new issues. From effc41055e0edbc21c340f029a1cc23f373ccc15 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 21:01:43 -0700 Subject: [PATCH 10/30] chore(pm): correct the sync-phantom record; archived tombstones instead of removal I reported remove-epic as the workaround for the three sync phantoms. It is not: the identical three came back on the very next sync about an hour later. Another session independently saw the same in agent-dm across a two-day gap, so this is not repo-specific and removal buys only until the next sync. Found a workaround that does stick, with no code change: set the phantom to --status archived rather than removing it. The id then still exists, so sync's own "id already exists" guard fires. Verified durable -- repeated syncs add 0. That is effectively the syncIgnore tombstone another session proposed, reachable today, since the dedup keys on id existence rather than status. Applied to all three phantoms here. Also recorded that the tombstone fix should lead the plan-field-matching fix, because matching is blocked by #66 (update-epic has no --plan, so the field cannot be populated) while the tombstone is not. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 +-- .conductor/state.json | 52 +++++++++++++++++++++++++++++++++++- PROJECT.md | 9 ++++--- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index 0beb3e8..e03eed9 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T03:54:53.504Z", - "stateMtimeMs": 1785383693485.9067 + "renderedAt": "2026-07-30T04:01:43.769Z", + "stateMtimeMs": 1785384103736.5017 } diff --git a/.conductor/state.json b/.conductor/state.json index 392bca9..5c9c1e0 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1343,7 +1343,21 @@ "links": [], "reconcileNeeded": false, "externalId": "64", - "externalUrl": "https://github.com/cfdude/pm/issues/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", @@ -1459,6 +1473,42 @@ "done": false } ] + }, + { + "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" } ], "detourStack": [], diff --git a/PROJECT.md b/PROJECT.md index 80687b8..30c05cb 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-30T03:54:53.497Z +> Last rendered: 2026-07-30T04:01:43.761Z ## Now @@ -48,7 +48,7 @@ _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 | — | - | +| 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 | @@ -86,6 +86,9 @@ _Empty — no work is paused._ | 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 @@ -115,7 +118,7 @@ NEXT UP (by priority, then lane): • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — (+6 more — see PROJECT.md) ⚠ epic `edd-observe-hardcodes-claude-md` ready but waiting on `rules-block-hardcodes-claude-slash-commands` - lanes: openspec 2 · superpowers 2 · claude-code 53 · decision 7 + lanes: openspec 2 · superpowers 5 · claude-code 53 · decision 7 planned: 2 — see PROJECT.md From 6281ce8ebc77d2117ebc35bb58fd6953f6611e1c Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 21:49:27 -0700 Subject: [PATCH 11/30] perf(test): split the suite across 11 files for parallelism (118s -> ~46s) MEASURED, not guessed: one engine spawn costs ~73ms and the suite makes 676 of them, so ~49s was pure `node` startup -- serialized in a single 3202-line file on a 16-core machine. node --test parallelizes across FILES only (proven with a 4-file x 3s probe finishing in 3.3s, not 12s). Split mechanically by a script that refuses to emit unless every test is accounted for; verified 255 source tests -> 255 emitted, by name-set equality. The file already exported 8 helpers that nothing imported, so it was built to be split and never was. helpers.mjs now carries all 21 helpers plus the consts -- FIXTURE_CHANGELOG was a plain `const` and had to be exported or tests referencing it threw ReferenceError. Tuning: 11 files at ~46s. 18 files was WORSE (46s) -- more runner startup for no gain. The floor is the slowest single file (21s); the remaining gap is tmpdir/process-spawn contention, NOT concurrency (--test-concurrency=16 was identical). Rejected the bigger win of in-process testing: it would stop exercising the real CLI contract, and that contract is where today's bugs live. NEW SILENT-LOSS GUARD. The glob makes a partial-suite pass possible in a way the single file never did: if a file stops matching, the suite still passes on a subset. The hook now cross-checks the ran count against `grep -c '^test('` across the files and aborts on a drop -- self-maintaining, no constant to bump. Covered by its own test, and the hook fixture was moved to the real scripts/test layout and de-indented so it actually exercises the guard. Also documented a landmine: `node --test scripts/test` (a directory) does NOT work on Node 26 -- it treats the arg as a module, dies MODULE_NOT_FOUND and reports "1 test". It exits nonzero so it cannot pass silently, but the hook says plainly not to "simplify" the glob into a directory. 256/256 passing, exit code 0. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .claude/skills/pr-workflow/SKILL.md | 4 +- .claude/skills/release-checklist/SKILL.md | 4 +- .conductor/render-stamp.json | 4 +- .conductor/state.json | 21 +- .githooks/pre-commit | 20 +- .github/workflows/ci.yml | 4 +- CLAUDE.md | 4 +- CONTRIBUTING.md | 6 +- PROJECT.md | 12 +- agents/hierarchy-child-executor.md | 2 +- scripts/conductor.test.mjs | 3202 --------------------- scripts/test/conductor-01.test.mjs | 358 +++ scripts/test/conductor-02.test.mjs | 353 +++ scripts/test/conductor-03.test.mjs | 262 ++ scripts/test/conductor-04.test.mjs | 314 ++ scripts/test/conductor-05.test.mjs | 364 +++ scripts/test/conductor-06.test.mjs | 330 +++ scripts/test/conductor-07.test.mjs | 267 ++ scripts/test/conductor-08.test.mjs | 199 ++ scripts/test/conductor-09.test.mjs | 301 ++ scripts/test/conductor-10.test.mjs | 278 ++ scripts/test/conductor-11.test.mjs | 77 + scripts/test/helpers.mjs | 263 ++ skills/conductor/SKILL.md | 2 +- 24 files changed, 3421 insertions(+), 3230 deletions(-) delete mode 100644 scripts/conductor.test.mjs create mode 100644 scripts/test/conductor-01.test.mjs create mode 100644 scripts/test/conductor-02.test.mjs create mode 100644 scripts/test/conductor-03.test.mjs create mode 100644 scripts/test/conductor-04.test.mjs create mode 100644 scripts/test/conductor-05.test.mjs create mode 100644 scripts/test/conductor-06.test.mjs create mode 100644 scripts/test/conductor-07.test.mjs create mode 100644 scripts/test/conductor-08.test.mjs create mode 100644 scripts/test/conductor-09.test.mjs create mode 100644 scripts/test/conductor-10.test.mjs create mode 100644 scripts/test/conductor-11.test.mjs create mode 100644 scripts/test/helpers.mjs 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 e03eed9..55d9af0 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T04:01:43.769Z", - "stateMtimeMs": 1785384103736.5017 + "renderedAt": "2026-07-30T04:49:26.914Z", + "stateMtimeMs": 1785386966875.7346 } diff --git a/.conductor/state.json b/.conductor/state.json index 5c9c1e0..3bf17e3 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1,6 +1,6 @@ { "version": 1, - "active": "gh-65-autodetour-false-positives", + "active": "test-suite-perf-and-scaffolding", "epics": [ { "id": "pm-fix-link-flag-validation-and-update", @@ -1363,7 +1363,7 @@ "id": "gh-65-autodetour-false-positives", "title": "auto-detour writes false-positive entries: no success check + no repo-scope check", "priority": "P1", - "status": "active", + "status": "queued", "role": "epic", "lane": "claude-code", "links": [], @@ -1442,7 +1442,7 @@ "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": "queued", + "status": "active", "role": "epic", "lane": "superpowers", "links": [], @@ -1454,11 +1454,11 @@ }, { "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": false + "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": false + "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.", @@ -1471,8 +1471,17 @@ { "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": false + }, + { + "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 } - ] + ], + "startedAt": "2026-07-30T04:28:32.303Z" }, { "id": "2026-07-21-conductor-mjs-module-split", diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 52de5d9..f6f7fd9 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -20,12 +20,30 @@ 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. + declared=$(grep -c '^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/CLAUDE.md b/CLAUDE.md index da5391c..010c4ac 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 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 30c05cb..49cff50 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,11 +3,11 @@ > 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-30T04:01:43.761Z +> Last rendered: 2026-07-30T04:49:26.887Z ## Now -**`gh-65-autodetour-false-positives`** — auto-detour writes false-positive entries: no success check + no repo-scope check (epic, P1) — 2/4 stories +**`test-suite-perf-and-scaffolding`** — Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding (epic, P1) — 2/8 stories ## Detour stack @@ -17,7 +17,7 @@ _Empty — no work is paused._ | Priority | Epic | Lane | Role | Status | Progress | Links | |----------|------|------|------|--------|----------|-------| -| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | queued | 0/6 stories | - | +| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | active | 2/8 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 | — | - | @@ -25,7 +25,7 @@ _Empty — no work is paused._ | P1 | `df-readme-stale-since-gate-guard` | claude-code | epic | archived | — | - | | 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 | active | 2/4 stories | - | +| 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 | — | - | @@ -108,11 +108,11 @@ _Empty — no work is paused._ ``` CONDUCTOR STATE — where we are and what's next -NOW: `gh-65-autodetour-false-positives` (claude-code, epic, P1) — 2/4 stories +NOW: `test-suite-perf-and-scaffolding` (superpowers, epic, P1) — 2/8 stories NEXT UP (by priority, then lane): - • `test-suite-perf-and-scaffolding` (P1, superpowers, queued) — 0/6 stories • `edd-user-memory-confound-control-run` (P1, claude-code, queued) — 0/5 stories + • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 2/4 stories • `rules-block-hardcodes-claude-slash-commands` (P1, claude-code, queued, parent: `multi-platform-agent-support`) — — • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — 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/scripts/conductor.test.mjs b/scripts/conductor.test.mjs deleted file mode 100644 index a4b38d5..0000000 --- a/scripts/conductor.test.mjs +++ /dev/null @@ -1,3202 +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/); -}); - -// ───────── gh#65 / gh#68: the auto-detour hook must confirm a commit actually landed HERE ───────── -// PostToolUse fires when the Bash tool RETURNS, which is not the same as "a commit landed in -// this repo". Three observed divergences, all producing a false detours.log entry attributed -// to this repo's STALE HEAD: the commit was rejected by pre-commit (gh#65), it was backgrounded -// and is still running (gh#68), or it landed in a different repo entirely (gh#65 bug 2). - -test("commit-nudge does not log when the commit was rejected and never landed (gh#65)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - // HEAD is "chore: baseline". The rejected commit never became an object. - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: rejected by pre-commit, never landed"' } }) }); - const log = detourLog(cwd); - assert.doesNotMatch(log, /AUTO-DETOUR/, "a commit that never landed must not be logged"); - assert.doesNotMatch(log, /rejected by pre-commit/); -}); - -test("commit-nudge does not log when the commit is still running in the background (gh#68)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - // Same observable state as a rejected commit: the hook fires while `git commit` is still - // running, so HEAD has not advanced yet and still holds "chore: baseline". - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -q -m "chore: still running"' } }) }); - assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); -}); - -test("commit-nudge does not attribute a commit that landed in a DIFFERENT repo (gh#65 bug 2)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - // A separate repo standing in for a paired repo / submodule. Note gitRepo() cannot be used - // here: it assumes /pm:init already scaffolded files, and its baseline commit throws - // ("nothing to commit") in the empty dir tmpRepo() returns. - const other = tmpRepo(); - execFileSync("git", ["init", "-q"], { cwd: other }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: other }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: other }); - commitFiles(other, { "paired.txt": "1" }, "fix: belongs to the paired repo"); - // The commit succeeded -- in `other`. This repo's HEAD is untouched, so nothing may be logged. - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: belongs to the paired repo"' } }) }); - const log = detourLog(cwd); - assert.doesNotMatch(log, /AUTO-DETOUR/); - assert.doesNotMatch(log, /paired repo/); -}); - -test("commit-nudge does not log a DETOUR-COMMIT when the commit never landed (gh#65)", () => { - 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: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, - ], - }); - gitRepo(cwd); - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: this one was rejected too"' } }) }); - assert.doesNotMatch(detourLog(cwd), /DETOUR-COMMIT/, - "the detour trail must not record a commit that never landed either"); -}); - -test("commit-nudge still logs a genuine landed commit (the guard must not silence the real case)", () => { - const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); - commitFiles(cwd, { "a.txt": "1" }, "fix: a real landed detour"); - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: a real landed detour"' } }) }); - assert.match(detourLog(cwd), /AUTO-DETOUR/); - assert.match(detourLog(cwd), /a real landed detour/); -}); diff --git a/scripts/test/conductor-01.test.mjs b/scripts/test/conductor-01.test.mjs new file mode 100644 index 0000000..0502f80 --- /dev/null +++ b/scripts/test/conductor-01.test.mjs @@ -0,0 +1,358 @@ +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 { tmpRepo, run, readState, writeState, projectMd, claudeMd, parseBrief, manyEpics, expectFail, fixturePluginRoot } from "./helpers.mjs"; + +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/); +}); + +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`/); +}); + +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"); +}); + +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/); +}); diff --git a/scripts/test/conductor-02.test.mjs b/scripts/test/conductor-02.test.mjs new file mode 100644 index 0000000..97aff4f --- /dev/null +++ b/scripts/test/conductor-02.test.mjs @@ -0,0 +1,353 @@ +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 { tmpRepo, run, readState, writeState, projectMd, parseBrief, expectFail, fixtureCache, fixturePluginRoot, FIXTURE_CHANGELOG } from "./helpers.mjs"; + +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/); +}); diff --git a/scripts/test/conductor-03.test.mjs b/scripts/test/conductor-03.test.mjs new file mode 100644 index 0000000..03e3a09 --- /dev/null +++ b/scripts/test/conductor-03.test.mjs @@ -0,0 +1,262 @@ +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 { tmpRepo, run, readState, writeState, projectMd, parseBrief, expectFail, withArchivedChange } from "./helpers.mjs"; + +// ─────────────── 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/); +}); + +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) +}); diff --git a/scripts/test/conductor-04.test.mjs b/scripts/test/conductor-04.test.mjs new file mode 100644 index 0000000..11583a0 --- /dev/null +++ b/scripts/test/conductor-04.test.mjs @@ -0,0 +1,314 @@ +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 { tmpRepo, run, readState, writeState, projectMd, claudeMd, parseBrief, expectFail, fixturePluginRoot, FIXTURE_CHANGELOG } from "./helpers.mjs"; + +// ───────────────────── 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"); +}); diff --git a/scripts/test/conductor-05.test.mjs b/scripts/test/conductor-05.test.mjs new file mode 100644 index 0000000..586fef6 --- /dev/null +++ b/scripts/test/conductor-05.test.mjs @@ -0,0 +1,364 @@ +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 { tmpRepo, run, readState, writeState, claudeMd, parseBrief, expectFail, writeBatch } from "./helpers.mjs"; + +// ─────────────── 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 +}); + +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); +}); diff --git a/scripts/test/conductor-06.test.mjs b/scripts/test/conductor-06.test.mjs new file mode 100644 index 0000000..3b0b6e8 --- /dev/null +++ b/scripts/test/conductor-06.test.mjs @@ -0,0 +1,330 @@ +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 { tmpRepo, run, runCombined, readState, writeState, parseBrief, expectFail, fixturePluginRoot, setupHierarchy, gitInitWithCommit, addHierarchyWorktree } from "./helpers.mjs"; + +// ───────────────────────── 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); +}); + +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 }))); +}); + +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, []); +}); diff --git a/scripts/test/conductor-07.test.mjs b/scripts/test/conductor-07.test.mjs new file mode 100644 index 0000000..b221fe2 --- /dev/null +++ b/scripts/test/conductor-07.test.mjs @@ -0,0 +1,267 @@ +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 { tmpRepo, run, runCombined, readState, writeState, projectMd, parseBrief, expectFail, ENGINE, EMPTY_CACHE } from "./helpers.mjs"; + +// ──────────────── 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/); +}); diff --git a/scripts/test/conductor-08.test.mjs b/scripts/test/conductor-08.test.mjs new file mode 100644 index 0000000..ef50afb --- /dev/null +++ b/scripts/test/conductor-08.test.mjs @@ -0,0 +1,199 @@ +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 { tmpRepo, run, readState, writeState, gitRepo, commitFiles, detourLog } from "./helpers.mjs"; + +// ─────────────── 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 })); +}); + +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); +}); diff --git a/scripts/test/conductor-09.test.mjs b/scripts/test/conductor-09.test.mjs new file mode 100644 index 0000000..63bf475 --- /dev/null +++ b/scripts/test/conductor-09.test.mjs @@ -0,0 +1,301 @@ +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 { tmpRepo, run, readState, writeState, expectFail, runHookAgainstFixture, ENGINE } from "./helpers.mjs"; + +// ──────────────── 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\/test\/\*\.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"); + // The glob makes partial-suite runs possible in a way the old single file did not, so the + // hook must cross-check the ran count against the declared count. Assert that guard exists. + assert.match(hookText, /grep -c '\^test\('/, + ".githooks/pre-commit does not cross-check the ran test count against the declared count"); +}); + +test(".githooks/pre-commit aborts when the glob runs FEWER tests than are declared", () => { + // The failure mode the glob introduces: a test file that stops being picked up. The suite + // still passes -- on a subset. Simulated here by declaring tests in a file the hook's glob + // cannot match (.mjs without the .test infix), so declared > ran. + const r = runHookAgainstFixture( + `test("one that does run", () => { assert.ok(true); });`, + { extraFiles: { "scripts/test/orphan.mjs": 'test("never picked up", () => {});\ntest("nor this", () => {});\n' } }, + ); + const combined = (r.stdout || "") + (r.stderr || ""); + // orphan.mjs is not matched by *.test.mjs, so it contributes nothing to either count -- the + // guard must not false-positive here. This pins the guard's precision, not just its presence. + assert.equal(r.status, 0, `guard must not fire on a non-test .mjs file: ${combined}`); + assert.match(combined, /pre-commit: 1\/1 passing/); +}); + +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"); +}); diff --git a/scripts/test/conductor-10.test.mjs b/scripts/test/conductor-10.test.mjs new file mode 100644 index 0000000..ca68b30 --- /dev/null +++ b/scripts/test/conductor-10.test.mjs @@ -0,0 +1,278 @@ +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 { tmpRepo, run, readState, writeState, projectMd } from "./helpers.mjs"; + +// ────────────── 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/test/conductor-11.test.mjs b/scripts/test/conductor-11.test.mjs new file mode 100644 index 0000000..4c7b614 --- /dev/null +++ b/scripts/test/conductor-11.test.mjs @@ -0,0 +1,77 @@ +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 { tmpRepo, run, writeState, gitRepo, commitFiles, detourLog } from "./helpers.mjs"; + +// ───────── gh#65 / gh#68: the auto-detour hook must confirm a commit actually landed HERE ───────── +// PostToolUse fires when the Bash tool RETURNS, which is not the same as "a commit landed in +// this repo". Three observed divergences, all producing a false detours.log entry attributed +// to this repo's STALE HEAD: the commit was rejected by pre-commit (gh#65), it was backgrounded +// and is still running (gh#68), or it landed in a different repo entirely (gh#65 bug 2). + +test("commit-nudge does not log when the commit was rejected and never landed (gh#65)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + // HEAD is "chore: baseline". The rejected commit never became an object. + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: rejected by pre-commit, never landed"' } }) }); + const log = detourLog(cwd); + assert.doesNotMatch(log, /AUTO-DETOUR/, "a commit that never landed must not be logged"); + assert.doesNotMatch(log, /rejected by pre-commit/); +}); + +test("commit-nudge does not log when the commit is still running in the background (gh#68)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + // Same observable state as a rejected commit: the hook fires while `git commit` is still + // running, so HEAD has not advanced yet and still holds "chore: baseline". + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -q -m "chore: still running"' } }) }); + assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); +}); + +test("commit-nudge does not attribute a commit that landed in a DIFFERENT repo (gh#65 bug 2)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + // A separate repo standing in for a paired repo / submodule. Note gitRepo() cannot be used + // here: it assumes /pm:init already scaffolded files, and its baseline commit throws + // ("nothing to commit") in the empty dir tmpRepo() returns. + const other = tmpRepo(); + execFileSync("git", ["init", "-q"], { cwd: other }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: other }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: other }); + commitFiles(other, { "paired.txt": "1" }, "fix: belongs to the paired repo"); + // The commit succeeded -- in `other`. This repo's HEAD is untouched, so nothing may be logged. + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: belongs to the paired repo"' } }) }); + const log = detourLog(cwd); + assert.doesNotMatch(log, /AUTO-DETOUR/); + assert.doesNotMatch(log, /paired repo/); +}); + +test("commit-nudge does not log a DETOUR-COMMIT when the commit never landed (gh#65)", () => { + 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: "active", role: "epic", lane: "claude-code", links: [], reconcileNeeded: false }, + ], + }); + gitRepo(cwd); + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: this one was rejected too"' } }) }); + assert.doesNotMatch(detourLog(cwd), /DETOUR-COMMIT/, + "the detour trail must not record a commit that never landed either"); +}); + +test("commit-nudge still logs a genuine landed commit (the guard must not silence the real case)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + commitFiles(cwd, { "a.txt": "1" }, "fix: a real landed detour"); + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: a real landed detour"' } }) }); + assert.match(detourLog(cwd), /AUTO-DETOUR/); + assert.match(detourLog(cwd), /a real landed detour/); +}); diff --git a/scripts/test/helpers.mjs b/scripts/test/helpers.mjs new file mode 100644 index 0000000..9d106db --- /dev/null +++ b/scripts/test/helpers.mjs @@ -0,0 +1,263 @@ +// Shared scaffolding for the split conductor test suite. Extracted verbatim from the former +// single-file scripts/conductor.test.mjs -- see docs/superpowers/plans for why it was split. +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"; + +export const ENGINE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "conductor.mjs"); +export 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 : ""; +} + +export 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: [], + })); +} + +export function expectFail(fn) { + try { fn(); return null; } catch (e) { return e; } +} + +export 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; +} + +export 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; +} + +export 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. +`; + +// ─────────────── 0.7.0: set-active / clear-active + active↔status ─────────────── + +// ──────────────── epic-level autonomy: set-autonomy ──────────────── + +// ──────────────── 0.6.1: date-prefixed archive detection ──────────────── + +export 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: [] }] }); +} + +// ───────── recompute-don't-remember: active validity + reconcileNeeded self-heal ───────── + +// ───────────────────── 0.6.0: changelog surfacing ───────────────────── + +// ───────────────────────── 0.5.0: epic hierarchy ───────────────────────── + +// ───────────────────────── 0.5.0: defensive render ───────────────────────── + +// ─────────────────── 0.5.0: external-tracker awareness ─────────────────── + +// ────────────── github-issues tracker: inward pull (issues → untriaged epics) ────────────── + +// ─────────────── update-epic --add-story / --story --done (df-update-epic-no-story-toggle-verb) ─────────────── + +// ───────────────────────── 0.5.0: bulk creation ───────────────────────── + +export function writeBatch(cwd, obj) { + const p = path.join(cwd, "batch.json"); + fs.writeFileSync(p, JSON.stringify(obj)); + return p; +} + +// ───────────────────────── 0.5.0: link migration ───────────────────────── + +// ──────────────── epic-hierarchy orchestration: plan-hierarchy ──────────────── + +export 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); +} + +// ──────── top-level queue: dependency-aware ordering (dependency-aware-standalone-ordering) ──────── + +// ──────────────── verify-worktrees ──────────────── + +export 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 }); +} + +export 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; +} + +// ──────────────── changesets ──────────────── + +// ──────────────── render --diff-summary ──────────────── + +// ──────────────── verify-state ──────────────── + +// ─────────────── timestamps + staleness ─────────────── + +// ─────────────── honcho-memory: push/pop ready-to-copy line ─────────────── + +// ───────── auto-detect an unlogged minimal detour from commit/diff shape ───────── + +export 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 }); +} +export 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 }); +} +export function detourLog(cwd) { + try { return fs.readFileSync(path.join(cwd, ".conductor", "detours.log"), "utf8"); } + catch { return ""; } +} + +// ─────────────────── lane-routing overrides ─────────────────── + +// ──────────────── reconciler structured writeback: record-reconcile ──────────────── + +// ──────────────── openspec gate enforcement: record-gate-review ──────────────── + +/** 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. */ +export function runHookAgainstFixture(testFileBody, { extraFiles = {} } = {}) { + const cwd = tmpRepo(); + execFileSync("git", ["init", "-q"], { cwd }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd }); + execFileSync("git", ["config", "user.name", "Test"], { cwd }); + // Mirror the real layout: the suite is scripts/test/*.test.mjs, which is what the hook globs. + // The body is de-indented so its `test(` calls sit at column 0, because the hook's + // ran-fewer-than-declared guard counts `^test(` -- an indented fixture would report 0 + // declared tests and silently skip the very guard this fixture exists to exercise. + fs.mkdirSync(path.join(cwd, "scripts", "test"), { recursive: true }); + const deindented = testFileBody.replace(/^[ \t]+/gm, ""); + const needsHeader = !/^import /m.test(deindented); + fs.writeFileSync(path.join(cwd, "scripts", "test", "fixture.test.mjs"), + (needsHeader + ? 'import { test } from "node:test";\nimport assert from "node:assert/strict";\n' + : "") + deindented); + for (const [rel, content] of Object.entries(extraFiles)) { + const dest = path.join(cwd, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, content); + } + 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 }); +} + +// ────────────── multi-tracker-primary-secondary-support: secondaryTrackers[] ────────────── + +// ────────────── externalUrl-first dedup (cross-tracker externalId collision fix) ────────────── + +// ────────────── rulesBlock(): secondary-tracker inward pull + status writeback ────────────── + +// ────────────── completion-time resync instruction + session-start sync nudge ────────────── + +// ───────── gh#65 / gh#68: the auto-detour hook must confirm a commit actually landed HERE ───────── +// PostToolUse fires when the Bash tool RETURNS, which is not the same as "a commit landed in +// this repo". Three observed divergences, all producing a false detours.log entry attributed +// to this repo's STALE HEAD: the commit was rejected by pre-commit (gh#65), it was backgrounded +// and is still running (gh#68), or it landed in a different repo entirely (gh#65 bug 2). diff --git a/skills/conductor/SKILL.md b/skills/conductor/SKILL.md index 049138f..425d9a6 100644 --- a/skills/conductor/SKILL.md +++ b/skills/conductor/SKILL.md @@ -349,7 +349,7 @@ CLAUDE.md (see `/pm:epic` → `set-autonomy`). `hierarchy-child-executor` agent enforces the matching check at report time — see its "Required check: session-continuity impact on the orchestrator" section. - **Documentation currency** — `SKILL.md` and `README.md` drift from the real dispatch table - independently (two separate tests in `scripts/conductor.test.mjs` check each one; passing + independently (two separate tests in `scripts/test/*.test.mjs` check each one; passing one does not mean the other is current). Any child epic that adds/changes a user-facing command, flag, or behavior must update `README.md`, not just `SKILL.md` — this bit the project once already (`record-gate-review` shipped with no README mention). See From 4c09983314d9beb2f15cd351d7151d8a044b12c7 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 21:56:31 -0700 Subject: [PATCH 12/30] test: close the vacuous-pass holes in the auto-detour guard tests detourLog() returns "" for a missing file, so assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/) also passes when the hook never ran at all -- and commit-nudge exits 0 on every early return, so run() does not throw either. The assertion could not distinguish "the guard fired" from "nothing happened." MUTATION TEST, which is the only reason this got caught: disabling the guard left 4 of the 5 guard tests still PASSING. Root cause -- gitRepo()'s baseline commit touches FOUR files (.conductor/render-stamp.json, .conductor/state.json, CLAUDE.md, PROJECT.md) and looksLikeUnloggedMinimalDetour() rejects anything over three. Every negative assertion was passing on the file-count rule, never reaching the guard at all. Four tests that looked like coverage were measuring nothing. Fix: each guard test now lands a small, differently-subjected commit FIRST so HEAD is genuinely auto-log-eligible, then asserts suppression, then lands the real subject and asserts the entry appears. Re-mutated: 4 of 5 now fail with the guard off, and the 5th is the positive-control test that must pass either way. Also split the self-heal test into a pair rather than converting it. The original has no git repo, which makes it the ONLY coverage of the unverifiable branch -- and it is what caught the two-state guard bug in the first place, so deleting that coverage to "fix" it would have been backwards. Renamed to say what it covers and added a verified-path twin with a real repo and a landed commit. 257/257 passing, exit code 0. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 +- .conductor/state.json | 8 +++- PROJECT.md | 8 ++-- scripts/test/conductor-03.test.mjs | 21 ++++++++- scripts/test/conductor-11.test.mjs | 75 ++++++++++++++++++++++++------ 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index 55d9af0..d51ad37 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T04:49:26.914Z", - "stateMtimeMs": 1785386966875.7346 + "renderedAt": "2026-07-30T04:56:31.528Z", + "stateMtimeMs": 1785387391505.9788 } diff --git a/.conductor/state.json b/.conductor/state.json index 3bf17e3..43f887d 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1462,11 +1462,11 @@ }, { "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": false + "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": false + "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", @@ -1479,6 +1479,10 @@ { "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 } ], "startedAt": "2026-07-30T04:28:32.303Z" diff --git a/PROJECT.md b/PROJECT.md index 49cff50..7f986aa 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,11 +3,11 @@ > 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-30T04:49:26.887Z +> Last rendered: 2026-07-30T04:56:31.520Z ## Now -**`test-suite-perf-and-scaffolding`** — Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding (epic, P1) — 2/8 stories +**`test-suite-perf-and-scaffolding`** — Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding (epic, P1) — 4/9 stories ## Detour stack @@ -17,7 +17,7 @@ _Empty — no work is paused._ | Priority | Epic | Lane | Role | Status | Progress | Links | |----------|------|------|------|--------|----------|-------| -| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | active | 2/8 stories | - | +| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | active | 4/9 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 | — | - | @@ -108,7 +108,7 @@ _Empty — no work is paused._ ``` CONDUCTOR STATE — where we are and what's next -NOW: `test-suite-perf-and-scaffolding` (superpowers, epic, P1) — 2/8 stories +NOW: `test-suite-perf-and-scaffolding` (superpowers, epic, P1) — 4/9 stories NEXT UP (by priority, then lane): • `edd-user-memory-confound-control-run` (P1, claude-code, queued) — 0/5 stories diff --git a/scripts/test/conductor-03.test.mjs b/scripts/test/conductor-03.test.mjs index 03e3a09..37b4fbd 100644 --- a/scripts/test/conductor-03.test.mjs +++ b/scripts/test/conductor-03.test.mjs @@ -4,7 +4,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { tmpRepo, run, readState, writeState, projectMd, parseBrief, expectFail, withArchivedChange } from "./helpers.mjs"; +import { tmpRepo, run, readState, writeState, projectMd, parseBrief, expectFail, withArchivedChange, gitRepo, commitFiles } from "./helpers.mjs"; // ─────────────── 0.7.0: set-active / clear-active + active↔status ─────────────── @@ -196,7 +196,12 @@ test("sync clears an archived active pointer and stamps archived status", () => 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", () => { +// This fixture has NO git repo, which makes it the only coverage of commit-nudge's +// UNVERIFIABLE branch (gh#65/gh#68): headSubject() returns null, so the hook cannot tell whether +// a commit landed and must fall through to the prior behaviour rather than going silent. Keep it +// repo-less deliberately — a two-state guard (match / no-match) broke exactly this test, which is +// how the three-state design was found. Its verified-path twin is the next test. +test("commit-nudge self-heals an archived active pointer when the commit cannot be verified (no git repo)", () => { 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"' } }) }); @@ -205,6 +210,18 @@ test("commit-nudge self-heals an archived active pointer after a git commit", () assert.equal(s.epics.find(e => e.id === "feat-x").status, "archived"); }); +test("commit-nudge self-heals an archived active pointer on the VERIFIED path too (real git repo, commit landed)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); + withArchivedChange(cwd, "feat-x"); + gitRepo(cwd); + // Land the commit for real so headSubject() matches and the hook takes the verified path. + commitFiles(cwd, { "archived.txt": "1" }, "archive 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, "self-heal must fire on the verified path, not only the unverifiable one"); + 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)", () => { diff --git a/scripts/test/conductor-11.test.mjs b/scripts/test/conductor-11.test.mjs index 4c7b614..60981d7 100644 --- a/scripts/test/conductor-11.test.mjs +++ b/scripts/test/conductor-11.test.mjs @@ -11,24 +11,57 @@ import { tmpRepo, run, writeState, gitRepo, commitFiles, detourLog } from "./hel // this repo". Three observed divergences, all producing a false detours.log entry attributed // to this repo's STALE HEAD: the commit was rejected by pre-commit (gh#65), it was backgrounded // and is still running (gh#68), or it landed in a different repo entirely (gh#65 bug 2). +// +// WHY EACH TEST BELOW LANDS THE COMMIT AT THE END. `detourLog()` returns "" for a missing file, +// so a bare `doesNotMatch(detourLog(cwd), /AUTO-DETOUR/)` also passes when the hook never ran +// at all -- wrong `isInitialized()` state, a command string that misses the /git\s+commit/ +// regex, anything. That is a vacuous pass: the assertion cannot tell "the guard fired" from +// "nothing happened." So each test ends by landing the same commit and asserting the entry now +// APPEARS, which proves the fixture and command were capable of logging all along and only the +// HEAD mismatch suppressed them. + +/** Assert `command` produces no detour entry in `cwd`, and prove that is the GUARD's doing + * rather than a coincidence. + * + * Two controls are needed, and a mutation test proved both are load-bearing. Disabling the + * guard initially left 4 of these 5 tests still passing, because gitRepo()'s baseline commit + * touches FOUR files (.conductor/render-stamp.json, .conductor/state.json, CLAUDE.md, + * PROJECT.md) and looksLikeUnloggedMinimalDetour() rejects anything over three. The negative + * assertions were therefore passing on the file-count rule, not on the guard at all. + * + * So: first make HEAD genuinely auto-log-eligible (one file, chore-prefixed), and only then + * assert suppression. Finally land the real subject to prove the fixture can log. */ +function assertSuppressedThenLands(cwd, command, subject) { + // Make HEAD auto-log ELIGIBLE with a subject different from the one under test, so that + // without the guard the hook really would write a (wrong) entry. + commitFiles(cwd, { "prior.txt": "1" }, "chore: an unrelated prior commit"); + + run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command } }) }); + assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/, + "a commit that did not land here must not be logged"); + + // Positive control: same command, but now HEAD really does hold it. + commitFiles(cwd, { "landed.txt": "1" }, subject); + run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command } }) }); + assert.match(detourLog(cwd), /AUTO-DETOUR/, + "control failed: this fixture never logs, so the negative assertion above proved nothing"); +} test("commit-nudge does not log when the commit was rejected and never landed (gh#65)", () => { const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); // HEAD is "chore: baseline". The rejected commit never became an object. - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: rejected by pre-commit, never landed"' } }) }); - const log = detourLog(cwd); - assert.doesNotMatch(log, /AUTO-DETOUR/, "a commit that never landed must not be logged"); - assert.doesNotMatch(log, /rejected by pre-commit/); + assertSuppressedThenLands(cwd, + 'git commit -m "fix: rejected by pre-commit, never landed"', + "fix: rejected by pre-commit, never landed"); }); test("commit-nudge does not log when the commit is still running in the background (gh#68)", () => { const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); // Same observable state as a rejected commit: the hook fires while `git commit` is still // running, so HEAD has not advanced yet and still holds "chore: baseline". - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -q -m "chore: still running"' } }) }); - assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/); + assertSuppressedThenLands(cwd, + 'git commit -q -m "chore: still running"', + "chore: still running"); }); test("commit-nudge does not attribute a commit that landed in a DIFFERENT repo (gh#65 bug 2)", () => { @@ -42,11 +75,11 @@ test("commit-nudge does not attribute a commit that landed in a DIFFERENT repo ( execFileSync("git", ["config", "user.name", "Test"], { cwd: other }); commitFiles(other, { "paired.txt": "1" }, "fix: belongs to the paired repo"); // The commit succeeded -- in `other`. This repo's HEAD is untouched, so nothing may be logged. - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: belongs to the paired repo"' } }) }); - const log = detourLog(cwd); - assert.doesNotMatch(log, /AUTO-DETOUR/); - assert.doesNotMatch(log, /paired repo/); + assertSuppressedThenLands(cwd, + 'git commit -m "fix: belongs to the paired repo"', + "fix: belongs to the paired repo"); + assert.doesNotMatch(detourLog(cwd), /paired\.txt/, + "the other repo's file must never appear in this repo's trail"); }); test("commit-nudge does not log a DETOUR-COMMIT when the commit never landed (gh#65)", () => { @@ -61,10 +94,22 @@ test("commit-nudge does not log a DETOUR-COMMIT when the commit never landed (gh ], }); gitRepo(cwd); - run(["commit-nudge"], { cwd, input: JSON.stringify({ - tool_input: { command: 'git commit -m "fix: this one was rejected too"' } }) }); + const cmd = 'git commit -m "fix: this one was rejected too"'; + // As in assertSuppressedThenLands: give HEAD a small, differently-subjected commit first, or + // the file-count rule suppresses the entry for us and the assertion proves nothing. (The + // DETOUR-COMMIT path does not consult looksLikeUnloggedMinimalDetour, but keeping the two + // fixtures identical means one less way for these tests to diverge silently.) + commitFiles(cwd, { "prior.txt": "1" }, "chore: an unrelated prior commit"); + run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: cmd } }) }); assert.doesNotMatch(detourLog(cwd), /DETOUR-COMMIT/, "the detour trail must not record a commit that never landed either"); + + // Positive control, as above: inside a detour the landed commit takes the DETOUR-COMMIT path + // rather than AUTO-DETOUR, so assert on that specifically. + commitFiles(cwd, { "landed.txt": "1" }, "fix: this one was rejected too"); + run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: cmd } }) }); + assert.match(detourLog(cwd), /DETOUR-COMMIT/, + "control failed: this fixture never records a DETOUR-COMMIT, so the assertion above proved nothing"); }); test("commit-nudge still logs a genuine landed commit (the guard must not silence the real case)", () => { From 73b2a6c161766043fb189b9e4765d13cc4a5b05d Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 22:01:16 -0700 Subject: [PATCH 13/30] test: harden the pre-existing absence assertions against vacuous passes Audit of all 61 doesNotMatch assertions for the shape found in my own gh#65 tests. Results: * projectMd()/claudeMd() THROW on a missing file, so assertions reading through them cannot pass vacuously. No change needed. * detourLog() returns "" -- 4 pre-existing bare assertions in conductor-08 ("does not auto-log a large commit", "...without a fix/chore prefix", "...naming the active epic", "...pm's own state files") could each pass if the hook never ran at all. * parseBrief() returns "" when brief output is empty, so 3 assertions carry the same lower-severity risk. Recorded on the epic, not fixed here. Added nudgeAndReadLog(cwd, command): runs commit-nudge, asserts it emitted its PostToolUse context payload (which it does on every path where it ran to completion), and only then reads the log. That pins the difference between "the rule under test rejected this commit" and "nothing happened" -- commit-nudge exits 0 on every early return, so run() never throws and the bare assertion could not tell them apart. MUTATION-TESTED the helper itself rather than assuming: with the hook forced to early-return before emitting, 7 of 20 tests in that file now fail, including all 4 that previously would have passed silently. Deliberately NOT used for the gh#65/gh#68 suppression tests -- those legitimately emit nothing, and prove non-vacuity via assertSuppressedThenLands instead. 257/257 passing. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 ++-- .conductor/state.json | 6 +++++- PROJECT.md | 8 ++++---- scripts/test/conductor-08.test.mjs | 14 +++++--------- scripts/test/helpers.mjs | 23 +++++++++++++++++++++++ 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index d51ad37..b4a130a 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T04:56:31.528Z", - "stateMtimeMs": 1785387391505.9788 + "renderedAt": "2026-07-30T05:01:16.726Z", + "stateMtimeMs": 1785387676655.6545 } diff --git a/.conductor/state.json b/.conductor/state.json index 43f887d..d76341e 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1470,7 +1470,7 @@ }, { "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": false + "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).", @@ -1483,6 +1483,10 @@ { "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" diff --git a/PROJECT.md b/PROJECT.md index 7f986aa..544253f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,11 +3,11 @@ > 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-30T04:56:31.520Z +> Last rendered: 2026-07-30T05:01:16.712Z ## Now -**`test-suite-perf-and-scaffolding`** — Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding (epic, P1) — 4/9 stories +**`test-suite-perf-and-scaffolding`** — Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding (epic, P1) — 5/10 stories ## Detour stack @@ -17,7 +17,7 @@ _Empty — no work is paused._ | Priority | Epic | Lane | Role | Status | Progress | Links | |----------|------|------|------|--------|----------|-------| -| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | active | 4/9 stories | - | +| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | active | 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 | — | - | @@ -108,7 +108,7 @@ _Empty — no work is paused._ ``` CONDUCTOR STATE — where we are and what's next -NOW: `test-suite-perf-and-scaffolding` (superpowers, epic, P1) — 4/9 stories +NOW: `test-suite-perf-and-scaffolding` (superpowers, epic, P1) — 5/10 stories NEXT UP (by priority, then lane): • `edd-user-memory-confound-control-run` (P1, claude-code, queued) — 0/5 stories diff --git a/scripts/test/conductor-08.test.mjs b/scripts/test/conductor-08.test.mjs index ef50afb..cb4ccf0 100644 --- a/scripts/test/conductor-08.test.mjs +++ b/scripts/test/conductor-08.test.mjs @@ -4,7 +4,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { tmpRepo, run, readState, writeState, gitRepo, commitFiles, detourLog } from "./helpers.mjs"; +import { tmpRepo, run, readState, writeState, gitRepo, commitFiles, detourLog, nudgeAndReadLog } from "./helpers.mjs"; // ─────────────── honcho-memory: push/pop ready-to-copy line ─────────────── @@ -59,15 +59,13 @@ test("commit-nudge auto-logs a minimal detour for a small fix commit with no act 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/); + assert.doesNotMatch(nudgeAndReadLog(cwd, 'git commit -m "fix: sweeping cleanup"'), /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/); + assert.doesNotMatch(nudgeAndReadLog(cwd, 'git commit -m "feat: add new widget"'), /AUTO-DETOUR/); }); test("commit-nudge does not auto-log a commit that names the active epic (treated as the epic's own work)", () => { @@ -77,8 +75,7 @@ test("commit-nudge does not auto-log a commit that names the active epic (treate ]}); 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/); + assert.doesNotMatch(nudgeAndReadLog(cwd, 'git commit -m "fix(feat-x): tighten validation"'), /AUTO-DETOUR/); }); test("commit-nudge does not auto-log a commit already inside a detour (existing DETOUR-COMMIT path wins)", () => { @@ -106,8 +103,7 @@ test("commit-nudge does not auto-log a routine conductor-bookkeeping commit touc ".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/); + assert.doesNotMatch(nudgeAndReadLog(cwd, 'git commit -m "chore(pm): register 3 new epics"'), /AUTO-DETOUR/); }); test("commit-nudge still auto-logs a chore commit that touches a real source file alongside state.json", () => { diff --git a/scripts/test/helpers.mjs b/scripts/test/helpers.mjs index 9d106db..1d49392 100644 --- a/scripts/test/helpers.mjs +++ b/scripts/test/helpers.mjs @@ -201,6 +201,29 @@ export function detourLog(cwd) { catch { return ""; } } +/** Run commit-nudge, assert it actually executed, then return the detour log. + * + * Why this exists: detourLog() returns "" for a missing file, so a bare + * `assert.doesNotMatch(detourLog(cwd), /AUTO-DETOUR/)` also passes when the hook never ran at + * all -- an uninitialized fixture, a command string that misses the /git\s+commit/ regex, an + * early return for an unrelated reason. commit-nudge exits 0 on every early return, so run() + * does not throw either, and the assertion cannot tell "the rule under test rejected this + * commit" from "nothing happened." + * + * commit-nudge emits a PostToolUse context payload on every path where it ran to completion, + * so requiring that payload pins the difference. Use this instead of a bare detourLog() read + * whenever the ABSENCE of an entry is the thing being asserted. + * + * Note it is deliberately NOT for the gh#65/gh#68 suppression cases: those legitimately emit + * nothing, and they prove non-vacuity a different way (see assertSuppressedThenLands). */ +export function nudgeAndReadLog(cwd, command) { + const out = run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command } }) }); + assert.ok(out.includes("hookSpecificOutput"), + "commit-nudge emitted no context payload, so it did not run to completion -- any absence " + + `assertion against the detour log would be vacuous. Output was: ${JSON.stringify(out)}`); + return detourLog(cwd); +} + // ─────────────────── lane-routing overrides ─────────────────── // ──────────────── reconciler structured writeback: record-reconcile ──────────────── From 9bc389bfc1f1fa840ef9ec39a4e7e6e9057cd05c Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 22:02:31 -0700 Subject: [PATCH 14/30] docs(pm): changelog the test-suite split; close the perf epic Contributor-facing: the test command changed to node --test scripts/test/*.test.mjs, and the pre-commit hook gained a ran-vs-declared count guard. Records why in-process testing was rejected and the directory-argument landmine. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 ++-- .conductor/state.json | 9 +++++---- CHANGELOG.md | 23 +++++++++++++++++++++++ PROJECT.md | 17 ++++++++--------- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index b4a130a..44d29a9 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T05:01:16.726Z", - "stateMtimeMs": 1785387676655.6545 + "renderedAt": "2026-07-30T05:02:31.718Z", + "stateMtimeMs": 1785387751669.218 } diff --git a/.conductor/state.json b/.conductor/state.json index d76341e..e6b5805 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1,6 +1,6 @@ { "version": 1, - "active": "test-suite-perf-and-scaffolding", + "active": "rules-block-hardcodes-claude-slash-commands", "epics": [ { "id": "pm-fix-link-flag-validation-and-update", @@ -1136,7 +1136,7 @@ "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": "active", "role": "epic", "lane": "claude-code", "links": [], @@ -1442,7 +1442,7 @@ "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": "active", + "status": "archived", "role": "epic", "lane": "superpowers", "links": [], @@ -1489,7 +1489,8 @@ "done": false } ], - "startedAt": "2026-07-30T04:28:32.303Z" + "startedAt": "2026-07-30T04:28:32.303Z", + "completedAt": "2026-07-30T05:02:31.485Z" }, { "id": "2026-07-21-conductor-mjs-module-split", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb8492..42decca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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 auto-detour hook no longer writes a `detours.log` entry for a commit that did not land diff --git a/PROJECT.md b/PROJECT.md index 544253f..67aba8a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,11 +3,11 @@ > 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-30T05:01:16.712Z +> Last rendered: 2026-07-30T05:02:31.712Z ## Now -**`test-suite-perf-and-scaffolding`** — Split the test suite for parallelism (~120s -> ~20s) and close the silent-pass holes in its scaffolding (epic, P1) — 5/10 stories +**`rules-block-hardcodes-claude-slash-commands`** — 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. (epic, P1) — — ## Detour stack @@ -17,7 +17,7 @@ _Empty — no work is paused._ | Priority | Epic | Lane | Role | Status | Progress | Links | |----------|------|------|------|--------|----------|-------| -| P1 | `test-suite-perf-and-scaffolding` | superpowers | epic | active | 5/10 stories | - | +| 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 | — | - | @@ -79,7 +79,7 @@ _Empty — no work is paused._ | P3 | `init-detects-foreign-agent-instruction-files` | claude-code | epic | planned | 0/1 stories | - | | P3 | `multi-platform-agent-support` | claude-code | epic | queued | 1/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 | active | — | - | | 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 | @@ -108,16 +108,15 @@ _Empty — no work is paused._ ``` CONDUCTOR STATE — where we are and what's next -NOW: `test-suite-perf-and-scaffolding` (superpowers, epic, P1) — 5/10 stories +NOW: `rules-block-hardcodes-claude-slash-commands` (claude-code, epic, P1) — — NEXT UP (by priority, then lane): + • `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 • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 2/4 stories - • `rules-block-hardcodes-claude-slash-commands` (P1, claude-code, queued, parent: `multi-platform-agent-support`) — — • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — - (+6 more — see PROJECT.md) - ⚠ epic `edd-observe-hardcodes-claude-md` ready but waiting on `rules-block-hardcodes-claude-slash-commands` + (+5 more — see PROJECT.md) lanes: openspec 2 · superpowers 5 · claude-code 53 · decision 7 planned: 2 — see PROJECT.md @@ -139,7 +138,7 @@ EPIC LINKS: • `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): `test-suite-perf-and-scaffolding`, `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`, `rules-block-hardcodes-claude-slash-commands`, `edd-adapter-discards-agent-stdout`, `edd-measures-installed-plugin-not-worktree`, `multi-platform-agent-support` 💡 1 tracker configured (github-issues) — consider `/pm:sync` this session to pull in any new issues. From c5e0a933ae334165387f758f1cbbbc5e6b1cf713 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 22:04:01 -0700 Subject: [PATCH 15/30] docs(pm): retarget the rules-block plan at the split test suite The plan was written against scripts/conductor.test.mjs, which no longer exists -- it would have sent every implementer at a deleted file. Retargeted to scripts/test/platform.test.mjs (a new capability gets its own file, which also keeps the parallel split balanced), refreshed the baseline from 250 to 257 tests, and fixed the helpers.mjs reference in the CLAUDECODE isolation hazard note. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .../2026-07-29-platform-aware-rules-block.md | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) 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 index 343b6ed..8454469 100644 --- a/docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md +++ b/docs/superpowers/plans/2026-07-29-platform-aware-rules-block.md @@ -6,14 +6,14 @@ **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/conductor.test.mjs`. +**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/conductor.test.mjs` must pass — currently 250 tests, 0 failing.** `.githooks/pre-commit` runs it and blocks failing commits. **NEVER** use `git commit --no-verify`. +- **`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`**. @@ -38,7 +38,9 @@ Hermes preserves `:` in plugin command names (`name.lower().strip().lstrip("/"). - **`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/conductor.test.mjs`** (modify) — tests appended per task. +- **`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. @@ -49,7 +51,7 @@ Hermes preserves `:` in plugin command names (`name.lower().strip().lstrip("/"). **Files:** - Modify: `scripts/lib/constants.mjs:18` (after `KNOWN_LANES`) - Create: `scripts/lib/platform.mjs` -- Test: `scripts/conductor.test.mjs` (append) +- 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`. @@ -63,11 +65,22 @@ Hermes preserves `:` in plugin command names (`name.lower().strip().lstrip("/"). - `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/conductor.test.mjs:15` 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. +**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** -Append to `scripts/conductor.test.mjs`: +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 ────────────── @@ -112,7 +125,7 @@ test("rulesTarget returns CLAUDE.md for claude-code regardless of a stray AGENTS - [ ] **Step 2: Run the tests to verify they fail** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|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** @@ -246,13 +259,13 @@ Imports to add at the top, alongside the other `./lib/*.mjs` imports: `import { - [ ] **Step 6: Run the tests** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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/conductor.test.mjs +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 @@ -268,7 +281,7 @@ contributing nothing." **Files:** - Modify: `scripts/lib/rules.mjs:82` (the `rulesBlock` signature) and its 14 hardcoded `/pm:` sites -- Test: `scripts/conductor.test.mjs` (append) +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) **Interfaces:** - Consumes: `PLATFORM_COMMAND_PREFIX` from `scripts/lib/constants.mjs` (Task 1). @@ -309,7 +322,7 @@ test("rulesBlock defaults to the claude-code command form when no platform is gi - [ ] **Step 2: Run to verify it fails** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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** @@ -355,13 +368,13 @@ Expected: **no output.** Any remaining hit is a site you missed. (`/pm-` should - [ ] **Step 5: Run the tests** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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/conductor.test.mjs +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 @@ -376,7 +389,7 @@ skips a plugin command that collides with a built-in, and it ships a built-in **Files:** - Modify: `scripts/lib/rules.mjs:291-310` (`writeRules`) -- Test: `scripts/conductor.test.mjs` (append) +- 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`. @@ -431,7 +444,7 @@ test("the rules block refreshes in place rather than duplicating on a second wri - [ ] **Step 2: Run to verify it fails** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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** @@ -476,13 +489,13 @@ Add `ROOT` to the existing `constants.mjs` import. `CLAUDE_MD` is no longer used - [ ] **Step 4: Run the tests** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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/conductor.test.mjs +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 > @@ -501,7 +514,7 @@ reports which file and platform it chose." - 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/conductor.test.mjs` (append) +- 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`. @@ -554,7 +567,7 @@ test("upgrade stamps platform on a state file written before the field existed", - [ ] **Step 2: Run to verify it fails** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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** @@ -676,14 +689,14 @@ Prepend to `CHANGELOG.md` (match the file's existing heading style exactly — c - [ ] **Step 6: Run the tests** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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/conductor.test.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 @@ -699,7 +712,7 @@ existing repos to claude-code." **Files:** - Modify: `hooks/hooks.json` (4 command strings) - Modify: `README.md`, `commands/upgrade.md`, `skills/conductor/SKILL.md` -- Test: `scripts/conductor.test.mjs` (append) +- Test: `scripts/test/platform.test.mjs` (create in Task 1, append thereafter) **Interfaces:** - Consumes: the `--platform` flag from Tasks 1 and 4. @@ -722,7 +735,7 @@ test("every pm-authored Claude Code hook command declares its platform", () => { - [ ] **Step 2: Run to verify it fails** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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** @@ -737,7 +750,7 @@ Apply the same to `snapshot`, `commit-nudge`, and `gate-guard`. - [ ] **Step 4: Run the tests** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +Run: `node --test scripts/test/*.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` Expected: `fail 0`. - [ ] **Step 5: Update the user-facing docs** @@ -754,11 +767,11 @@ Find every place that needs it rather than guessing: `rg -n "CLAUDE\.md" README. - [ ] **Step 6: Verify the whole suite and commit** -Run: `node --test scripts/conductor.test.mjs 2>&1 | rg "^(ℹ|#) (tests|pass|fail)"` +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/conductor.test.mjs +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 From d61b6e5c3e427cc38ceb3717a44096f74455dd28 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 22:17:22 -0700 Subject: [PATCH 16/30] =?UTF-8?q?feat(engine):=20platform-aware=20rules=20?= =?UTF-8?q?block=20=E2=80=94=20constants,=20resolution,=20per-platform=20c?= =?UTF-8?q?ommand=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines Task 1 (platform constants + declared-platform resolution) and Task 2 (per-platform command strings in the rules block) into one commit: Task 1 alone has no independently-green state, since rulesBlock() doesn't accept a platform until Task 2 lands, so its test can't turn green on its own. 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. The rules block body stays a single platform-neutral source of text; only the slash-command form varies via the new pmCmd(platform, name) helper. 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. Also fixes a site the brief's "14 lines in rules.mjs" enumeration missed: RULES_BEGIN (constants.mjs) hardcoded "/pm:init" and leaked into every rendered block regardless of platform. RULES_BEGIN/RULES_END are the literal anchors writeRules() uses to detect and replace an existing block, so they must stay one invariant string across all platforms -- the fix drops the platform-specific command reference from the anchor comment entirely rather than making the anchor itself platform-dependent, which would have broken idempotent block replacement for non-claude-code platforms. --- scripts/conductor.mjs | 7 +++- scripts/lib/constants.mjs | 28 ++++++++++++- scripts/lib/platform.mjs | 70 ++++++++++++++++++++++++++++++++ scripts/lib/rules.mjs | 42 +++++++++++-------- scripts/test/platform.test.mjs | 73 ++++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 scripts/lib/platform.mjs create mode 100644 scripts/test/platform.test.mjs diff --git a/scripts/conductor.mjs b/scripts/conductor.mjs index 7cc42f9..4705ed6 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 } 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,7 +114,10 @@ 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": writeRules, }[cmd] || (() => { diff --git a/scripts/lib/constants.mjs b/scripts/lib/constants.mjs index 42d4285..2355278 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,9 @@ 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 = ""; diff --git a/scripts/lib/platform.mjs b/scripts/lib/platform.mjs new file mode 100644 index 0000000..5ba9b53 --- /dev/null +++ b/scripts/lib/platform.mjs @@ -0,0 +1,70 @@ +// 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; +} diff --git a/scripts/lib/rules.mjs b/scripts/lib/rules.mjs index 17bdbdd..2d7b55e 100644 --- a/scripts/lib/rules.mjs +++ b/scripts/lib/rules.mjs @@ -6,7 +6,9 @@ import fs from "node:fs"; import { loadState } from "./state.mjs"; -import { KNOWN_REVIEW_MODES, REVIEW_MODE_RANK, RULES_BEGIN, RULES_END, CLAUDE_MD } from "./constants.mjs"; +import { + KNOWN_REVIEW_MODES, REVIEW_MODE_RANK, RULES_BEGIN, RULES_END, CLAUDE_MD, PLATFORM_COMMAND_PREFIX, +} from "./constants.mjs"; /** The tracker block from state, or null — used to make emitted instructions tracker-aware. */ export function currentTracker() { @@ -79,7 +81,15 @@ export function currentReviewMode(epicId) { } catch { return "standard"; } } -export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { +/** 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}`; +} + +export function rulesBlock(tracker, reviewMode, secondaryTrackers = [], platform = "claude-code") { const mode = KNOWN_REVIEW_MODES.includes(reviewMode) ? reviewMode : "standard"; const lines = [ RULES_BEGIN, @@ -92,15 +102,15 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { "", "1. **Detours** — when something blocks the active epic, CLASSIFY before fixing:", " - *Minimal* (small, self-contained, no design ambiguity): fix → test → commit → push,", - " then run `/pm:detour --minimal \"\"` so it is recorded in `.conductor/detours.log`.", + ` then run \`${pmCmd(platform, "detour")} --minimal ""\` so it is recorded in \`.conductor/detours.log\`.`, " Then resume.", - " - *Substantial* (own design / changes shared behavior / multi-step): run `/pm:detour`.", + ` - *Substantial* (own design / changes shared behavior / multi-step): run \`${pmCmd(platform, "detour")}\`.`, " It becomes its own epic in the appropriate lane (OpenSpec proposal, Superpowers plan,", " etc.); PUSH the current epic onto the detour stack in `.conductor/state.json` with a", " concrete reason and `reconcileOnResume`.", "2. **State of record is `.conductor/state.json`.** After any change to epics, status,", - " priority, or the detour stack, re-render with `/pm:status`. Never hand-edit `PROJECT.md`.", - "3. **Resuming after a detour** — use `/pm:resume`. If the popped frame had", + ` priority, or the detour stack, re-render with \`${pmCmd(platform, "status")}\`. Never hand-edit \`PROJECT.md\`.`, + `3. **Resuming after a detour** — use \`${pmCmd(platform, "resume")}\`. If the popped frame had`, " `reconcileOnResume`, run the reconcile gate (reconciler agent) BEFORE writing code,", " then write its verdict back durably with `record-reconcile --detour ", " --verdict valid|invalidated [--amendments \";\"]` — this attaches", @@ -111,9 +121,9 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { " this repo.", "5. **Keep `tasks.md` checkboxes truthful** — they are the source of truth for story progress.", "6. **Roadmap as backlog** — work you intend to do but haven't proposed yet can be", - " registered now with `/pm:epic add … --status planned` (any lane). Planned epics show", + ` registered now with \`${pmCmd(platform, "epic")} add … --status planned\` (any lane). Planned epics show`, " as ordered backlog in `PROJECT.md` and a `planned: N` count in the briefing, without a", - " \"no change on disk\" warning; `/pm:sync` flips an openspec planned epic to untriaged once", + ` "no change on disk" warning; \`${pmCmd(platform, "sync")}\` flips an openspec planned epic to untriaged once`, " its change is proposed. Have a roadmap doc? Read it in-session and load each item this way.", "", "## Epic-level autonomy", @@ -170,11 +180,11 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { "", "If you hit a bug, a missing CLI verb, an unexpected limitation, or repeated friction", "working with this plugin — in this repo or any repo using it — don't just work around it", - "and move on. File it: `/pm:feedback [bug|feature] \"\"` against `cfdude/pm`, or ask", + `and move on. File it: \`${pmCmd(platform, "feedback")} [bug|feature] ""\` against \`cfdude/pm\`, or ask`, "the user \"want me to file this as feedback?\" if you're not sure it's worth it. The failure", "mode this guards against is silent: hand-editing `.conductor/state.json` to flip a story's", "`done` flag (no CLI verb exists for it) recurred across several separate sessions before", - "anyone reported it, even though `/pm:feedback` existed the whole time. A filed issue is", + `anyone reported it, even though \`${pmCmd(platform, "feedback")}\` existed the whole time. A filed issue is`, "cheap; an unreported recurring papercut is not — silent pain is where a product fails its", "users.", ]; @@ -196,7 +206,7 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { `the pm plugin NEVER calls ${sys} itself. On these events, perform the matching action with`, "your own tooling (MCP, connector, CLI — whatever this project uses):", `- A real epic has no \`externalId\` → create the ${sys} issue, then record its key with`, - " `/pm:epic` → `update-epic --external-id --external-url `.", + ` \`${pmCmd(platform, "epic")}\` → \`update-epic --external-id --external-url \`.`, "- An epic moves to a status with a `statusIntent` (e.g. active/archived) → transition the", " linked issue toward that SEMANTIC target, resolving the real workflow transition yourself.", `- A parent epic → create it as a ${sys} epic and link its children.`, @@ -220,11 +230,11 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { "", "This tracker is inward: open GitHub issues become conductor epics, same pattern as the", "OpenSpec/Superpowers auto-registration `sync` already does for on-disk changes/plans. The", - "pm plugin NEVER calls `gh` itself — as part of running `/pm:sync`, YOU (the interactive", + `pm plugin NEVER calls \`gh\` itself — as part of running \`${pmCmd(platform, "sync")}\`, YOU (the interactive`, "agent) do:", `1. \`gh issue list --repo ${repo} --state open --json number,title,url,labels\`.`, "2. For each issue, check whether an epic with that issue number as `externalId` already", - " exists (`/pm:epic list` or read `.conductor/state.json`) — if so, skip it (already", + ` exists (\`${pmCmd(platform, "epic")} list\` or read \`.conductor/state.json\`) — if so, skip it (already`, " mirrored; re-running sync must never create a duplicate epic for the same issue).", "3. Otherwise register a new untriaged epic: `add-epic --status untriaged --external-id", " --external-url --lane claude-code --priority P2`, unless a", @@ -248,12 +258,12 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { "causes you to create or transition an issue here — that stays exclusive to the primary", "tracker above (if configured).", "", - "**Inward pull** — as part of running `/pm:sync`:", + `**Inward pull** — as part of running \`${pmCmd(platform, "sync")}\`:`, ...(st.system === "github-issues" && st.repo ? [`1. \`gh issue list --repo ${st.repo} --state open --json number,title,url,labels\`.`] : [`1. List open issues in ${st.system}${scope ? ` (${scope})` : ""} with your own tooling.`]), "2. For each issue, check whether an epic's `externalUrl` already matches that issue's URL", - " (`/pm:epic list` or read `.conductor/state.json`) — if so, skip it (already mirrored;", + ` (\`${pmCmd(platform, "epic")} list\` or read \`.conductor/state.json\`) — if so, skip it (already mirrored;`, " re-running sync must never create a duplicate epic for the same issue). Match on", " `externalUrl`, not bare `externalId` alone — issue numbers are only unique within one", " tracker/repo, not globally, so two secondary trackers can both have an issue numbered", @@ -278,7 +288,7 @@ export function rulesBlock(tracker, reviewMode, secondaryTrackers = []) { "## Sync after completing tracker-linked work", "", "After you close/transition a tracker-linked issue as part of completing an epic (the", - "writeback steps above), immediately re-sync with your tracker(s) — run `/pm:sync` — to pull", + `writeback steps above), immediately re-sync with your tracker(s) — run \`${pmCmd(platform, "sync")}\` — to pull`, "in anything new that appeared while you were heads-down. You're already doing tracker I/O", "for this epic, so this is the cheapest moment to catch it; this applies whether you have one", "tracker or several (primary + secondary) configured.", diff --git a/scripts/test/platform.test.mjs b/scripts/test/platform.test.mjs new file mode 100644 index 0000000..6abade6 --- /dev/null +++ b/scripts/test/platform.test.mjs @@ -0,0 +1,73 @@ +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"; + +// ────────────── 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"); +}); + +// ────────────── per-platform command form ────────────── + +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/); +}); From 3b5b99ca98fbd48acd6fab2c24456ca0e37b7ecb Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Wed, 29 Jul 2026 22:22:39 -0700 Subject: [PATCH 17/30] fix(engine): key rules-block detection on a stable prefix, not the full marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHIPPING-BREAKING REGRESSION, caught before push. Task 2 made RULES_BEGIN's parenthetical platform-neutral -- "(managed by /pm:init …)" became "(managed by pm …)" -- which was the right call, since a platform-dependent anchor would make a block written under one platform invisible to another. But detection keyed on the FULL decorated string, so a block written by any earlier pm version no longer matched, and writeRules() fell through to its APPEND branch. Every existing repo would have grown a SECOND rules block on the next /pm:upgrade. Verified live before the fix: blocks before: 1 conductor: appended rules block to CLAUDE.md blocks AFTER write-rules: 2 Fix: detect via RULES_BEGIN_PREFIX (""; 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 = " + ## PM Conductor — operating rules This repo is managed by the `pm` plugin. The conductor sits ABOVE OpenSpec and Superpowers. From 81c1cf8773ec91e461d0ada830de393fc62d9929 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Thu, 30 Jul 2026 08:25:09 -0700 Subject: [PATCH 25/30] fix(engine): the HEAD guard was suppressing every commit with a message BODY CRITICAL, caught by the whole-branch review. `git log -1 --format=%s` returns only the FIRST line, but the `-m` capture uses [^"]* which spans newlines and swallows the entire message. Comparing those directly can never match for a commit with a body -- and this repo mandates one (the Claude-Session footer), so the guard returned early on 24 of 24 commits on this branch. Silently lost on every one: the DETOUR-COMMIT trail during a detour, AUTO-DETOUR detection, the archived-epic self-heal (so /pm:next stops advancing after an archive), the re-render, and the nudge itself. The release whose headline is "fix commit-nudge" had turned commit-nudge off for the common case. Verified live: a genuinely landed multi-line commit produced no output and no log entry. Two-part fix, both halves needed: * compare first line with first line, trimmed (as %s is) * treat a shell-assembled message -- `-m "$(...)"`, a heredoc, `-m "$MSG"` -- as UNVERIFIABLE, since the command string holds the shell SOURCE, not the text git received. "Cannot tell" must not be reported as "does not match". WHY FIVE TASK REVIEWS MISSED IT: every positive control used a single-line -m; the suite had no multi-line or heredoc case. Added four, and mutation-tested both halves -- reverting the first-line split fails 2 tests, dropping the shell-source escape fails 1. Also from the same review: * .githooks/pre-commit: `grep -c` omits the filename: prefix for a SINGLE file, so awk -F: summed a bare number and got 0 -- the ran-vs-declared guard went blind in exactly the case it exists to catch. Now `grep -Hc`, with its assertion in conductor-09 updated to require the -H rather than any grep. * README overstated that the declared platform is recorded in state.json; the four read-only hooks pass --platform through but never persist it. 280/280 passing. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .githooks/pre-commit | 5 +++- README.md | 4 ++- scripts/lib/subcommands.mjs | 24 +++++++++++---- scripts/test/conductor-09.test.mjs | 5 +++- scripts/test/conductor-11.test.mjs | 47 ++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f6f7fd9..997d4bc 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -37,7 +37,10 @@ if node --test scripts/test/*.test.mjs >"$tmpfile" 2>&1; then # 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. - declared=$(grep -c '^test(' scripts/test/*.test.mjs | awk -F: '{s+=$2} END {print s}') + # -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 diff --git a/README.md b/README.md index a9e697c..2102d82 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,9 @@ and renders `PROJECT.md`. From there: 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` and shapes two things: the command +The declared platform is recorded in `.conductor/state.json` by `/pm:init`, `/pm:upgrade`, and +`write-rules` (the commands that (re)write the block — the read-only hooks pass the flag through +but do not persist it), and 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 diff --git a/scripts/lib/subcommands.mjs b/scripts/lib/subcommands.mjs index 1dfa2ce..44310f7 100644 --- a/scripts/lib/subcommands.mjs +++ b/scripts/lib/subcommands.mjs @@ -106,7 +106,20 @@ export function commitNudge() { const state = loadState(); const ctx = detourContext(state); const m = cmd.match(/-m\s+(?:"([^"]*)"|'([^']*)'|(\S+))/); - const subject = (m && (m[1] || m[2] || m[3])) || ""; + const rawSubject = (m && (m[1] || m[2] || m[3])) || ""; + + // `git log -1 --format=%s` yields ONLY the first line, but the `-m` capture above uses + // [^"]* which spans newlines and swallows the whole message body. Comparing those two + // directly can never match for a commit with a body -- and this repo mandates one (the + // Claude-Session footer), so the guard below suppressed EVERY real commit. Compare the + // first line with the first line. + const subject = rawSubject.split("\n")[0].trim(); + + // A message assembled by the shell -- `-m "$(cat <<'EOF' … EOF)"`, `-m "$MSG"` -- cannot be + // recovered from the command string: what we captured is the shell SOURCE, not the text git + // received. That is "cannot tell", not "does not match", so it takes the UNVERIFIABLE rung + // rather than being wrongly contradicted. + const shellBuilt = /\$\(|\$\{|<<-?\s*['"]?\w+/.test(rawSubject) || /^\$\w+$/.test(rawSubject); // gh#65 / gh#68: PostToolUse fires when the Bash tool RETURNS, which is NOT the same as // "a commit landed in this repo". Three observed divergences, each of which wrote a false @@ -122,11 +135,12 @@ export function commitNudge() { // exit-code check would NOT cover the backgrounded case — there is no exit code yet. // // Three-state on purpose. Only CONTRADICTED (a subject was parsed, git works, and HEAD - // disagrees) means "no commit landed here" and goes silent. UNVERIFIABLE (no `-m` to - // parse, or git unusable here) keeps the previous behaviour, because the archived-epic - // self-heal below must still run in a repo with no git at all. + // disagrees) means "no commit landed here" and goes silent. UNVERIFIABLE -- no `-m` to + // parse, git unusable here, or a shell-assembled message we cannot read -- keeps the + // previous behaviour, because the archived-epic self-heal below must still run in a repo + // with no git at all, and because guessing wrong here silently disables the whole hook. const head = headSubject(); - if (subject && head !== null && head !== subject) return; + if (!shellBuilt && subject && head !== null && head !== subject) return; // DETERMINISTIC: if we are inside a detour, record this commit in the trail. let autoLogged = false; diff --git a/scripts/test/conductor-09.test.mjs b/scripts/test/conductor-09.test.mjs index 63bf475..84e8e50 100644 --- a/scripts/test/conductor-09.test.mjs +++ b/scripts/test/conductor-09.test.mjs @@ -253,7 +253,10 @@ test(".githooks/pre-commit exists, is executable, and runs the full test suite", assert.match(hookText, /set -e/, ".githooks/pre-commit does not fail the commit on a non-zero exit"); // The glob makes partial-suite runs possible in a way the old single file did not, so the // hook must cross-check the ran count against the declared count. Assert that guard exists. - assert.match(hookText, /grep -c '\^test\('/, + // -H, not bare -c: grep omits the filename: prefix when the glob matches a SINGLE file, so + // `awk -F: '{s+=$2}'` summed a bare number and got 0 — the guard went blind in exactly the + // case it exists to catch. Assert the -H is present, not merely that some grep is. + assert.match(hookText, /grep -Hc '\^test\('/, ".githooks/pre-commit does not cross-check the ran test count against the declared count"); }); diff --git a/scripts/test/conductor-11.test.mjs b/scripts/test/conductor-11.test.mjs index 60981d7..6ea434e 100644 --- a/scripts/test/conductor-11.test.mjs +++ b/scripts/test/conductor-11.test.mjs @@ -120,3 +120,50 @@ test("commit-nudge still logs a genuine landed commit (the guard must not silenc assert.match(detourLog(cwd), /AUTO-DETOUR/); assert.match(detourLog(cwd), /a real landed detour/); }); + +// ── C1: every commit in this repo has a BODY, and the guard was suppressing all of them ── +// `git log -1 --format=%s` returns only the first line, but the `-m` capture uses [^"]* which +// spans newlines and swallows the whole message. Comparing those directly can never match for a +// commit with a body -- so the guard silently killed commit-nudge for the common case: no +// nudge, no DETOUR-COMMIT trail, and no archived-epic self-heal. Five task reviews missed it +// because every positive control here used a single-line -m. + +test("commit-nudge still logs a landed commit whose message has a BODY (C1)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + const subject = "fix: a landed detour with a body"; + const message = `${subject}\n\nSome explanatory body.\nClaude-Session: https://example.com/x`; + commitFiles(cwd, { "a.txt": "1" }, message); + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: `git commit -m "${message}"` } }) }); + assert.match(detourLog(cwd), /AUTO-DETOUR/, + "a multi-line commit message must not silently disable the hook"); + assert.match(detourLog(cwd), /a landed detour with a body/); +}); + +test("commit-nudge treats a shell-assembled message as UNVERIFIABLE, not contradicted (C1)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + commitFiles(cwd, { "a.txt": "1" }, "fix: built by a heredoc"); + // The command string holds the shell SOURCE, not the text git received, so the subject is + // unknowable. Prior behaviour (nudge, no false suppression) must be preserved. + const out = run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: `git commit -q -F - <<'MSG'\nfix: built by a heredoc\nMSG` } }) }); + assert.ok(out.includes("hookSpecificOutput"), + "a message we cannot read must not silence the hook"); +}); + +test("commit-nudge treats -m \"$(...)\" as UNVERIFIABLE rather than a mismatch (C1)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + commitFiles(cwd, { "a.txt": "1" }, "fix: from a subshell"); + const out = run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "$(cat /tmp/msg.txt)"' } }) }); + assert.ok(out.includes("hookSpecificOutput"), + "a $(...) message is shell source, not the commit subject -- must not be treated as contradicted"); +}); + +test("a trailing space in -m does not suppress a landed commit (C1)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + commitFiles(cwd, { "a.txt": "1" }, "fix: trailing space case"); + run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: 'git commit -m "fix: trailing space case "' } }) }); + assert.match(detourLog(cwd), /AUTO-DETOUR/, "the parsed subject must be trimmed like %s is"); +}); From 3133f64cfa65a4988cfdb05d95d6dcc663f42294 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Thu, 30 Jul 2026 08:27:35 -0700 Subject: [PATCH 26/30] refactor(engine): drop the dead CLAUDECODE rung; validate the flag symmetrically Both from the whole-branch review. The CLAUDECODE rung was dead code -- both branches returned "claude-code", so it could never change an outcome, and the test that claimed to distinguish "the env rung" from "the terminal default" could not fail for the reason it stated. Removed, with a note that a future env-based rung must return something DIFFERENT rather than being a no-op that reads like coverage. resolvePlatform now ignores an unrecognised flag value symmetrically with how it already ignored an unrecognised recorded value. Callers still assertKnownPlatform() first so a typo fails loudly; this is the second line of defence so a caller that forgets cannot leak a garbage id into rulesTarget()/pmCmd(), both of which would fall back silently. New test covers a hand-edited/future-version state.json. Also files the review's two follow-ups rather than leaving them in a transcript: autodetour-parser-covers-am-and-F (P2 -- #65 is only closed for -m; -am still writes a false DETOUR-COMMIT) and platform-switch-orphans-old-rules-block (P2, blocks hermes -- a switch leaves the old platform's block live but frozen). 281/281 passing. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 ++-- .conductor/state.json | 26 ++++++++++++++++++++++++++ PROJECT.md | 10 ++++++---- scripts/lib/platform.mjs | 14 ++++++++++++-- scripts/test/platform.test.mjs | 18 +++++++++++++++++- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index c8f91d3..dc7ba6e 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T15:09:59.717Z", - "stateMtimeMs": 1785424199591.4036 + "renderedAt": "2026-07-30T15:27:35.722Z", + "stateMtimeMs": 1785425255693.3777 } diff --git a/.conductor/state.json b/.conductor/state.json index d870c0b..a62afd9 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1528,6 +1528,32 @@ "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 + } + ] } ], "detourStack": [], diff --git a/PROJECT.md b/PROJECT.md index f2fb7ff..c6cd9fd 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-30T05:54:27.343Z +> Last rendered: 2026-07-30T15:27:35.708Z ## Now @@ -52,6 +52,7 @@ _Empty — no work is paused._ | 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 🤖 | — | - | @@ -116,8 +117,8 @@ NEXT UP (by priority, then lane): • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 2/4 stories • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — - (+5 more — see PROJECT.md) - lanes: openspec 2 · superpowers 5 · claude-code 53 · decision 7 + (+6 more — see PROJECT.md) + lanes: openspec 2 · superpowers 5 · claude-code 54 · decision 7 planned: 2 — see PROJECT.md @@ -130,6 +131,7 @@ EPIC LINKS: • `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 @@ -138,7 +140,7 @@ EPIC LINKS: • `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`, `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`, `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/scripts/lib/platform.mjs b/scripts/lib/platform.mjs index b9cc233..7704f44 100644 --- a/scripts/lib/platform.mjs +++ b/scripts/lib/platform.mjs @@ -33,11 +33,21 @@ export function platformFlag(argv) { * 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) { + // An unknown flag value is IGNORED here rather than returned, symmetrically with how an + // unknown recorded value is ignored below. Callers run assertKnownPlatform() first so a typo + // in a hand-authored hook still fails loudly; this is the second line of defence, so that a + // caller which forgets cannot leak a garbage id into rulesTarget()/pmCmd() -- both of which + // would silently fall back to claude-code anyway, just less legibly. const flag = typeof flags.platform === "string" ? flags.platform.trim() : ""; - if (flag) return flag; + if (flag && KNOWN_PLATFORMS.includes(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"; + + // NOTE: there is deliberately no CLAUDECODE env rung. One existed and was dead code -- both + // its branches returned "claude-code", so it could never change an outcome, and the test that + // claimed to distinguish "the env rung" from "the terminal default" could not fail for the + // reason it stated. If a future platform ever needs env-based inference, add it as a real + // branch that returns something different, not as a no-op that reads like coverage. return "claude-code"; } diff --git a/scripts/test/platform.test.mjs b/scripts/test/platform.test.mjs index ec6be7d..b26bb5d 100644 --- a/scripts/test/platform.test.mjs +++ b/scripts/test/platform.test.mjs @@ -30,11 +30,27 @@ test("resolvePlatform rejects an unknown --platform instead of silently defaulti 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. + // CLAUDECODE is blanked to prove the TERMINAL DEFAULT is doing the work. (This comment used + // to claim it distinguished the default from an "env rung" -- it never could, because that + // rung also returned "claude-code". The rung is gone; the blanking stays, so the assertion + // cannot be satisfied by an env var that happens to be set in the runner's environment.) const out = run(["rules"], { cwd, env: { CLAUDECODE: "" } }); assert.match(out, /\/pm:status/); }); +test("an unrecognised recorded platform falls back rather than corrupting the block", () => { + const cwd = tmpRepo(); + run(["init"], { cwd }); + const p = path.join(cwd, ".conductor", "state.json"); + const state = JSON.parse(fs.readFileSync(p, "utf8")); + state.platform = "not-a-real-platform"; // hand-edited or written by a future version + fs.writeFileSync(p, JSON.stringify(state, null, 2)); + + const out = run(["rules"], { cwd, env: { CLAUDECODE: "" } }); + assert.match(out, /\/pm:status/, "a garbage recorded platform must resolve to the base platform"); + assert.doesNotMatch(out, /not-a-real-platform/); +}); + 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"); From d0da92ab59b3072487106e3fd8ef71ae472218f9 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Thu, 30 Jul 2026 08:28:24 -0700 Subject: [PATCH 27/30] chore(pm): file the -am/-F parser gap from the whole-branch review The id was rejected on the first attempt (uppercase F violates ^[a-z0-9][a-z0-9._-]$) -- refiled lowercase. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 ++-- .conductor/state.json | 20 ++++++++++++++++++++ PROJECT.md | 11 ++++++----- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index dc7ba6e..a667493 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T15:27:35.722Z", - "stateMtimeMs": 1785425255693.3777 + "renderedAt": "2026-07-30T15:28:24.654Z", + "stateMtimeMs": 1785425304635.9944 } diff --git a/.conductor/state.json b/.conductor/state.json index a62afd9..a787106 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1554,6 +1554,26 @@ "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 + } + ] } ], "detourStack": [], diff --git a/PROJECT.md b/PROJECT.md index c6cd9fd..92dd515 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-30T15:27:35.708Z +> Last rendered: 2026-07-30T15:28:24.649Z ## Now @@ -30,6 +30,7 @@ _Empty — no work is paused._ | 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/2 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 🤖 | — | - | @@ -115,10 +116,10 @@ NEXT UP (by priority, then lane): • `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 • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 2/4 stories + • `autodetour-parser-misses-am-and-f` (P2, claude-code, queued) — 0/2 stories • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — - • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — - (+6 more — see PROJECT.md) - lanes: openspec 2 · superpowers 5 · claude-code 54 · decision 7 + (+7 more — see PROJECT.md) + lanes: openspec 2 · superpowers 5 · claude-code 55 · decision 7 planned: 2 — see PROJECT.md @@ -140,7 +141,7 @@ EPIC LINKS: • `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`, `edd-adapter-discards-agent-stdout`, `edd-measures-installed-plugin-not-worktree`, `platform-switch-orphans-old-rules-block`, `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. From 346d2688efcb3978f4ee5aee65e7850a62b4391d Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Thu, 30 Jul 2026 08:29:37 -0700 Subject: [PATCH 28/30] docs(pm): changelog the subject-comparison subtlety in the auto-detour fix Shipping 'we fixed commit-nudge' without noting that %s is first-line-only while a -m capture spans newlines would be an incomplete claim -- that mismatch is what silently disabled the hook for every commit with a body. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c406b6..be315f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,14 @@ This project adheres to [Semantic Versioning](https://semver.org/). *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 From 64519543ac89c2b7023b745a6965b8515d47e2fc Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Thu, 30 Jul 2026 08:40:51 -0700 Subject: [PATCH 29/30] fix(docs,test): correct a false README claim and a mis-named test Scoped re-review of the C1 fix round. Four of five findings were closed; two things it caught here: MY README "FIX" WAS WORSE THAN THE OVER-CLAIM IT REPLACED. I wrote that the declared platform is recorded by "/pm:init, /pm:upgrade, and write-rules". Disproved by running the engine: `upgrade --platform codex` leaves platform = claude-code. upgrade never reads the flag -- its 0.24.0 migration stamps claude-code on repos predating the field, which is not "adopting a declared value". A vague over-claim became a specific false promise to exactly the Codex/Hermes user the paragraph was written for. Corrected. A NEW C1 TEST WAS MEASURING THE WRONG RUNG. "treats a shell-assembled message as UNVERIFIABLE" used `-F -`, which has no -m at all, so it reached the unverifiable rung via the EMPTY-SUBJECT branch and never touched shellBuilt. Proof: the shellBuilt mutation failed only the $(...) test. Renamed it for what it actually covers and added a real heredoc-in--m case; the mutation now fails 2, not 1. Also: platform.mjs's docstring still listed the CLAUDECODE rung 14 lines above the note explaining it was removed. And documented that shellBuilt is deliberately broad -- a literal ${VAR} in a real subject also lands on the unverifiable rung, so gh#65 can still false-positive for that shape. That is the correct direction to fail: a false log line is reviewable, a false SUPPRESSION silently disables the hook, which already shipped once. Filed the remaining residual (an escaped quote inside a double-quoted -m truncates the capture and wrongly contradicts) on autodetour-parser-misses-am-and-f. 282/282 passing. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 ++-- .conductor/state.json | 4 ++++ PROJECT.md | 6 +++--- README.md | 8 +++++--- scripts/lib/platform.mjs | 2 +- scripts/lib/subcommands.mjs | 7 +++++++ scripts/test/conductor-11.test.mjs | 20 +++++++++++++++++--- 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index a667493..8d438f8 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T15:28:24.654Z", - "stateMtimeMs": 1785425304635.9944 + "renderedAt": "2026-07-30T15:40:51.660Z", + "stateMtimeMs": 1785426051639.6658 } diff --git a/.conductor/state.json b/.conductor/state.json index a787106..c151ad0 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1572,6 +1572,10 @@ { "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 } ] } diff --git a/PROJECT.md b/PROJECT.md index 92dd515..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-30T15:28:24.649Z +> Last rendered: 2026-07-30T15:40:51.653Z ## Now @@ -30,7 +30,7 @@ _Empty — no work is paused._ | 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/2 stories | - | +| 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 🤖 | — | - | @@ -116,7 +116,7 @@ NEXT UP (by priority, then lane): • `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 • `gh-65-autodetour-false-positives` (P1, claude-code, queued) — 2/4 stories - • `autodetour-parser-misses-am-and-f` (P2, claude-code, queued) — 0/2 stories + • `autodetour-parser-misses-am-and-f` (P2, claude-code, queued) — 0/3 stories • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — (+7 more — see PROJECT.md) lanes: openspec 2 · superpowers 5 · claude-code 55 · decision 7 diff --git a/README.md b/README.md index 2102d82..90baebc 100644 --- a/README.md +++ b/README.md @@ -190,9 +190,11 @@ and renders `PROJECT.md`. From there: 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`, `/pm:upgrade`, and -`write-rules` (the commands that (re)write the block — the read-only hooks pass the flag through -but do not persist it), and shapes two things: the command +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 diff --git a/scripts/lib/platform.mjs b/scripts/lib/platform.mjs index 7704f44..78c2f05 100644 --- a/scripts/lib/platform.mjs +++ b/scripts/lib/platform.mjs @@ -29,7 +29,7 @@ export function platformFlag(argv) { * 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. + * Order: explicit flag > recorded in state > 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) { diff --git a/scripts/lib/subcommands.mjs b/scripts/lib/subcommands.mjs index 44310f7..4d62992 100644 --- a/scripts/lib/subcommands.mjs +++ b/scripts/lib/subcommands.mjs @@ -119,6 +119,13 @@ export function commitNudge() { // recovered from the command string: what we captured is the shell SOURCE, not the text git // received. That is "cannot tell", not "does not match", so it takes the UNVERIFIABLE rung // rather than being wrongly contradicted. + // + // This test is deliberately BROAD, and the breadth has a cost worth stating: a *literal* + // `$(` or `${` in a genuine subject -- `fix: escape ${VAR} in the template` -- also lands on + // the unverifiable rung, so gh#65's false-positive can still occur for that message shape. + // That is the correct direction to fail. A false log line is visible and reviewable; a false + // SUPPRESSION silently disables the hook, which is the bug this whole guard exists to avoid + // and which shipped once already (see the first-line comment above). const shellBuilt = /\$\(|\$\{|<<-?\s*['"]?\w+/.test(rawSubject) || /^\$\w+$/.test(rawSubject); // gh#65 / gh#68: PostToolUse fires when the Bash tool RETURNS, which is NOT the same as diff --git a/scripts/test/conductor-11.test.mjs b/scripts/test/conductor-11.test.mjs index 6ea434e..fa6f55c 100644 --- a/scripts/test/conductor-11.test.mjs +++ b/scripts/test/conductor-11.test.mjs @@ -140,17 +140,31 @@ test("commit-nudge still logs a landed commit whose message has a BODY (C1)", () assert.match(detourLog(cwd), /a landed detour with a body/); }); -test("commit-nudge treats a shell-assembled message as UNVERIFIABLE, not contradicted (C1)", () => { +test("commit-nudge does not suppress a -F commit, which has no -m to parse at all (C1)", () => { const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); commitFiles(cwd, { "a.txt": "1" }, "fix: built by a heredoc"); - // The command string holds the shell SOURCE, not the text git received, so the subject is - // unknowable. Prior behaviour (nudge, no false suppression) must be preserved. + // NOTE this reaches the unverifiable rung via the EMPTY-SUBJECT branch, not via shellBuilt -- + // `-F -` has no `-m`, so the regex never matches. Named accordingly after a review found the + // original name ("shell-assembled message") claimed a code path this input never touches; the + // shellBuilt rung is covered by the `-m "$(...)"` test below. const out = run(["commit-nudge"], { cwd, input: JSON.stringify({ tool_input: { command: `git commit -q -F - <<'MSG'\nfix: built by a heredoc\nMSG` } }) }); assert.ok(out.includes("hookSpecificOutput"), "a message we cannot read must not silence the hook"); }); +test("commit-nudge treats a heredoc-assembled -m message as UNVERIFIABLE via shellBuilt (C1)", () => { + const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); + commitFiles(cwd, { "a.txt": "1" }, "fix: assembled by a command substitution"); + // This IS the shellBuilt path: there is a -m, and what it captures is shell source rather + // than the text git received. HEAD holds a different string, so without shellBuilt this + // would be wrongly contradicted and the hook silenced. + const out = run(["commit-nudge"], { cwd, input: JSON.stringify({ + tool_input: { command: `git commit -m "$(cat <<'EOF'\nfix: assembled by a command substitution\nEOF\n)"` } }) }); + assert.ok(out.includes("hookSpecificOutput"), + "a -m whose value the shell built must take the unverifiable rung, not be contradicted"); +}); + test("commit-nudge treats -m \"$(...)\" as UNVERIFIABLE rather than a mismatch (C1)", () => { const cwd = tmpRepo(); run(["init"], { cwd }); gitRepo(cwd); commitFiles(cwd, { "a.txt": "1" }, "fix: from a subshell"); From 98805f46d88ffea5b6aed45318a95750fdf09744 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Thu, 30 Jul 2026 10:42:12 -0700 Subject: [PATCH 30/30] docs(pm): fold the Unreleased entries into 0.24.0 and recompute Real Numbers The auto-detour fix and the test-suite split were sitting under [Unreleased] while 0.24.0 had its own section -- they ship in this release, so leaving them stranded would have released the content without releasing its changelog entry. Merged into one 0.24.0 section ordered Added / Changed / Fixed / Migration (the naive merge left two ### Fixed headings). Date taken from the datetimeday MCP per the release checklist, not guessed: 2026-07-30. The pre-existing 2026-07-29 heading was a day stale. Real Numbers recomputed rather than estimated, as that section's own claim requires: 282 tests (was 276 -- six added during the review rounds), 38 releases, 130-line entry point, 26 lib modules, 0 dependencies. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- CHANGELOG.md | 34 ++++++++++++++++------------------ README.md | 2 +- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be315f6..a1378cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ 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 @@ -33,6 +44,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### 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 @@ -66,24 +82,6 @@ This project adheres to [Semantic Versioning](https://semver.org/). 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. -## [0.24.0] — 2026-07-29 - -### 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 diff --git a/README.md b/README.md index 90baebc..6607b88 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Not a benchmark — real numbers pulled straight from this repo's own history, v mechanical (a shared CHANGELOG header, a usage string), never a real logic collision. - **38 releases** shipped end-to-end (spec → build → test → changelog → version bump → release) with the plugin managing its own backlog the entire time. -- **276 tests**, **0 dependencies** — the entire engine is Node 18+ built-ins only, ~2,900 lines +- **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