Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pm",
"version": "0.24.0",
"version": "0.25.0",
"description": "A lightweight project-management layer that sits above OpenSpec and Superpowers. Tracks active proposals as epics, maintains an explicit detour stack, and enforces a reconcile gate so nothing is lost when development pivots or context is compacted.",
"author": {
"name": "Rob Sherman",
Expand Down
4 changes: 2 additions & 2 deletions .conductor/render-stamp.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"renderedAt": "2026-07-30T15:40:51.660Z",
"stateMtimeMs": 1785426051639.6658
"renderedAt": "2026-07-30T21:21:55.794Z",
"stateMtimeMs": 1785446515777.2825
}
7 changes: 4 additions & 3 deletions .conductor/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -1219,7 +1219,7 @@
"id": "edd-observe-hardcodes-claude-md",
"title": "observe.py hardcodes CLAUDE.md -- a second, undeclared platform seam",
"priority": "P1",
"status": "queued",
"status": "archived",
"role": "epic",
"lane": "claude-code",
"links": [
Expand All @@ -1234,9 +1234,10 @@
"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
"done": true
}
]
],
"completedAt": "2026-07-30T21:21:55.677Z"
},
{
"id": "edd-user-memory-confound-control-run",
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ This project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.25.0] — 2026-07-30

### Added

- **`rules-target` — a read-only query printing the absolute path of the file the resolved
platform's rules block belongs in**, walking that platform's first-match-wins chain. It
deliberately does **not** record a platform: a query must not mutate state the way
`write-rules` does.

It exists so tooling around pm never has to mirror `PLATFORM_RULES_CHAIN`. `evals/observe.py`
hardcoded `CLAUDE.md`, which 0.24.0 quietly turned into a *second* platform seam — once the
engine began writing a per-platform target, the observer reported `rules_block_present: false`
for any platform whose block lands elsewhere. That is a confident wrong answer, and it would
have surfaced on the first Hermes or Codex run as a parity failure that is not real. Copying
the chain into Python was rejected: a second copy of platform knowledge only *moves* the
drift instead of removing it.

## [0.24.0] — 2026-07-30

### Added
Expand Down
10 changes: 5 additions & 5 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:40:51.653Z
> Last rendered: 2026-07-30T21:21:55.691Z

## Now

Expand All @@ -23,7 +23,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 | 0/1 stories | depends-on→rules-block-hardcodes-claude-slash-commands |
| P1 | `edd-observe-hardcodes-claude-md` | claude-code | epic | archived | 1/1 stories | depends-on→rules-block-hardcodes-claude-slash-commands |
| P1 | `edd-user-memory-confound-control-run` | claude-code | epic | queued | 0/5 stories | blocks→hermes-platform-support |
| P1 | `gh-65-autodetour-false-positives` | claude-code | epic | queued | 2/4 stories | - |
| P1 | `pm-fix-link-flag-validation-and-update` | claude-code | epic | archived | — | - |
Expand Down Expand Up @@ -113,12 +113,12 @@ CONDUCTOR STATE — where we are and what's next
NOW: (no active epic set)

NEXT UP (by priority, then lane):
• `edd-observe-hardcodes-claude-md` (P1, claude-code, queued) — 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/3 stories
• `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — —
(+7 more — see PROJECT.md)
• `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — —
(+6 more — see PROJECT.md)
lanes: openspec 2 · superpowers 5 · claude-code 55 · decision 7

planned: 2 — see PROJECT.md
Expand All @@ -141,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`, `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`
⚠ not yet in github-issues — create issues + record keys (update-epic): `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.

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ Not a benchmark — real numbers pulled straight from this repo's own history, v
- Every multi-agent hierarchy dispatch runs **worktree-isolated**, unattended, converging back
through sequential merge with **zero data loss** — every conflict seen so far has been
mechanical (a shared CHANGELOG header, a usage string), never a real logic collision.
- **38 releases** shipped end-to-end (spec → build → test → changelog → version bump → release)
- **39 releases** shipped end-to-end (spec → build → test → changelog → version bump → release)
with the plugin managing its own backlog the entire time.
- **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,
- **287 tests**, **0 dependencies** — the entire engine is Node 18+ built-ins only, ~2,900 lines
across a 140-line `scripts/conductor.mjs` entry point and 26 `scripts/lib/*.mjs` modules,
nothing to `npm install`. The suite itself is split across 11 files so it runs in parallel.
- Caught its own bugs mid-flight, live: a stale-cache silent fallback, an archived-child leak in
hierarchy planning, a false-positive auto-detour heuristic — each found by using the tool on
Expand Down Expand Up @@ -488,6 +488,11 @@ pre-0.24.0 state file that predates the field — re-renders `PROJECT.md`, and s
`pmVersion`. Idempotent — safe to run more than once. Requires `/reload-plugins` first if you
just updated the plugin (the SessionStart briefing tells you when).

The engine also exposes `rules-target`, a read-only query printing the absolute path of the file
the recorded platform's rules block belongs in (resolving that platform's first-match-wins
chain). It exists so tooling around pm never has to mirror the chain — a second copy of platform
knowledge is drift waiting to happen. Unlike `write-rules`, it records nothing.

After showing the changelog delta ("What's new in pm"), the agent reviews each `Added`
headline and recommends adopting any opt-in capability that's relevant to this repo's current
`.conductor/state.json` (e.g. secondary trackers, `thorough` review mode) — one line, one
Expand Down
1 change: 1 addition & 0 deletions evals/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def _failure(error: str) -> dict:
"epic_ids": [],
"detours": [],
"rules_block_present": False,
"rules_block_file": None,
"project_md_present": False,
"new_epics": [],
"exit_code": INFRA_FAILURE_EXIT_CODE,
Expand Down
49 changes: 47 additions & 2 deletions evals/observe.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@
from __future__ import annotations

import json
import os
import subprocess
from pathlib import Path

ENGINE = Path(__file__).resolve().parent.parent / "scripts" / "conductor.mjs"

# Only the stable PREFIX, never the full decorated marker. The engine's own detection keys on
# this same prefix precisely so the parenthetical can change without stranding older blocks --
# matching the full string here would re-create that bug on the observer side.
RULES_BEGIN = "<!-- BEGIN pm-conductor rules"


Expand All @@ -27,6 +34,37 @@ def _read_detours(path: Path) -> list[dict]:
return rows


def _rules_target(project: Path) -> Path | None:
"""Ask the ENGINE which file this project's rules block belongs in.

Deliberately a subprocess call rather than mirroring PLATFORM_RULES_CHAIN in Python. This
module used to hardcode `CLAUDE.md`, which quietly made it a SECOND platform seam: once the
engine started writing a per-platform target, the observer reported rules_block_present=False
for any platform whose block lands elsewhere -- a confident wrong answer that would surface
as a fake parity failure on the first non-Claude run. A Python copy of the chain would only
move that drift rather than remove it, so the engine stays the single source of truth.

Returns None when the engine cannot answer (not installed, not a pm project, a timeout).
None is "cannot tell", which the caller renders as absent rather than raising -- an eval
must be able to score a broken run, not crash on it.
"""
try:
proc = subprocess.run(
["node", str(ENGINE), "rules-target"],
cwd=project,
env={**os.environ, "CLAUDE_PROJECT_DIR": str(project), "PM_QUIET_ENGINE_BANNER": "1"},
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
out = proc.stdout.strip() # stdout ONLY -- stderr may still carry engine chatter
return Path(out) if out else None


def observe(project: Path) -> dict:
project = Path(project)
state_path = project / ".conductor" / "state.json"
Expand All @@ -43,13 +81,20 @@ def observe(project: Path) -> dict:
for e in state.get("epics", [])
]

claude_md = project / "CLAUDE.md"
# Where the block BELONGS for this project's recorded platform -- not a guess, and not
# "wherever a block happens to exist". Reporting the filename alongside the boolean makes a
# failure diagnosable without a re-run: "false" and "false, and we were looking at AGENTS.md"
# are very different findings.
target = _rules_target(project)

return {
"active": state.get("active"),
"epics": epics,
"epic_ids": [e["id"] for e in epics],
"detours": _read_detours(project / ".conductor" / "detours.log"),
"rules_block_present": claude_md.exists() and RULES_BEGIN in claude_md.read_text(),
"rules_block_present": bool(
target and target.exists() and RULES_BEGIN in target.read_text()
),
"rules_block_file": target.name if target else None,
"project_md_present": (project / "PROJECT.md").exists(),
}
49 changes: 49 additions & 0 deletions evals/tests/test_observe.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,52 @@ def test_observe_is_json_serializable(tmp_path: Path):

project = materialize("single-active-epic", tmp_path)
json.dumps(observe(project)) # must not raise


def test_observe_finds_the_block_where_a_NON_claude_platform_writes_it(tmp_path: Path):
"""The regression this epic exists for.

observe() used to hardcode CLAUDE.md. Once the engine started writing a per-platform target,
that made observe a SECOND, undeclared platform seam: a codex project's block lands in
AGENTS.md, so the observer reported rules_block_present=False -- a confident wrong answer
that would surface on the first non-Claude run as a parity failure that is not real.

This test fails under the old behavior and passes now.
"""
import subprocess
import os

project = materialize("single-active-epic", tmp_path)
engine = Path(__file__).resolve().parent.parent.parent / "scripts" / "conductor.mjs"

# Re-target this project at codex, exactly as a codex host would declare itself.
subprocess.run(
["node", str(engine), "write-rules", "--platform", "codex"],
cwd=project,
env={**os.environ, "CLAUDE_PROJECT_DIR": str(project), "PM_QUIET_ENGINE_BANNER": "1"},
capture_output=True, text=True, check=True, timeout=60,
)

assert (project / "AGENTS.md").exists(), "codex's block belongs in AGENTS.md"

out = observe(project)

assert out["rules_block_file"] == "AGENTS.md", (
"observe must follow the platform's target, not assume CLAUDE.md"
)
assert out["rules_block_present"] is True, (
"the block IS present -- reporting False here is the exact bug this epic fixed"
)


def test_observe_reports_cannot_tell_rather_than_crashing_outside_a_pm_project(tmp_path: Path):
"""A broken run must be SCOREABLE, not an exception -- same principle as the adapter's
infrastructural-failure guard. An eval that raises cannot report a verdict."""
bare = tmp_path / "not-a-pm-project"
bare.mkdir()

out = observe(bare)

assert out["rules_block_present"] is False
assert out["project_md_present"] is False
assert out["epic_ids"] == []
14 changes: 12 additions & 2 deletions scripts/conductor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ import { pluginVersion } from "./lib/plugin-meta.mjs";
import {
currentTracker, currentSecondaryTrackers, currentReviewMode, rulesBlock, writeRules,
} from "./lib/rules.mjs";
import { resolvePlatform, assertKnownPlatform, platformFlag, resolveAndRecordPlatform } from "./lib/platform.mjs";
import { resolvePlatform, assertKnownPlatform, platformFlag, resolveAndRecordPlatform, rulesTarget } from "./lib/platform.mjs";
import { loadState } from "./lib/state.mjs";
import { ROOT } from "./lib/constants.mjs";
import { setActive, clearActive } from "./lib/active-pointer.mjs";
import { setAutonomy } from "./lib/autonomy.mjs";
import { parseFlags, planHierarchy, addEpic } from "./lib/add-epic.mjs";
Expand Down Expand Up @@ -124,7 +125,16 @@ if (showEngineBanner) {
writeRules(platform);
if (switched) process.stderr.write(`conductor: platform: ${platform}\n`);
},
// Read-only query: which file does this platform's rules block belong in? Exists so a
// CONSUMER (evals/observe.py) never has to mirror PLATFORM_RULES_CHAIN -- a second copy of
// platform knowledge is exactly the drift this epic was filed to remove. Deliberately does
// NOT record the platform: a query must not mutate state the way write-rules does.
"rules-target": () => {
const declared = platformFlag(process.argv.slice(3));
if (declared) assertKnownPlatform(declared);
process.stdout.write(rulesTarget(resolvePlatform({ platform: declared }, loadState()), ROOT) + "\n");
},
}[cmd] || (() => {
process.stderr.write("usage: conductor.mjs init|render|brief|snapshot|commit-nudge|sync|log-detour|honcho-memory|add-epic|add-many|update-epic|remove-epic|set-active|clear-active|set-tracker|set-lane-routing|suggest-lane|set-autonomy|record-reconcile|record-gate-review|set-review-mode|set-gate-guard|gate-guard|plan-hierarchy|verify-worktrees|verify-state|changesets|upgrade|changelog|rules|write-rules\n");
process.stderr.write("usage: conductor.mjs init|render|brief|snapshot|commit-nudge|sync|log-detour|honcho-memory|add-epic|add-many|update-epic|remove-epic|set-active|clear-active|set-tracker|set-lane-routing|suggest-lane|set-autonomy|record-reconcile|record-gate-review|set-review-mode|set-gate-guard|gate-guard|plan-hierarchy|verify-worktrees|verify-state|changesets|upgrade|changelog|rules|write-rules|rules-target\n");
process.exit(1);
}))();
53 changes: 53 additions & 0 deletions scripts/test/platform.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,56 @@ test("every pm-authored Claude Code hook command declares its platform", () => {
assert.match(c, /--platform claude-code/, `hook command does not declare its platform: ${c}`);
}
});

// ── rules-target: expose the resolved target so consumers stop guessing it ──
// evals/observe.py hardcoded CLAUDE.md, which made it a SECOND platform seam: the engine now
// writes a per-platform target, so the observer reported rules_block_present=false for any
// platform whose block lands elsewhere -- a confident wrong answer that would surface as a fake
// parity failure on the first non-Claude run. Rather than mirror PLATFORM_RULES_CHAIN into
// Python (a second copy that can drift, which is the same class of bug), the engine answers.

test("rules-target prints the absolute path for the default platform", () => {
const cwd = tmpRepo();
run(["init"], { cwd });
const out = run(["rules-target"], { cwd, env: { CLAUDECODE: "" } }).trim();
assert.equal(out, path.join(cwd, "CLAUDE.md"));
assert.ok(path.isAbsolute(out), "must be absolute so a consumer can use it directly");
});

test("rules-target honours --platform and the per-platform chain", () => {
const cwd = tmpRepo();
run(["init"], { cwd });
assert.equal(run(["rules-target", "--platform", "codex"], { cwd }).trim(),
path.join(cwd, "AGENTS.md"), "codex reads AGENTS.md and never CLAUDE.md");
// Clean repo: hermes gets the chain's LAST entry (most broadly compatible), not HERMES.md.
assert.equal(run(["rules-target", "--platform", "hermes"], { cwd }).trim(),
path.join(cwd, "CLAUDE.md"));
});

test("rules-target resolves first-existing-wins, so it tracks what the platform will READ", () => {
const cwd = tmpRepo();
run(["init"], { cwd });
fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# left by a prior agent\n");
assert.equal(run(["rules-target", "--platform", "hermes"], { cwd }).trim(),
path.join(cwd, "AGENTS.md"),
"AGENTS.md outranks CLAUDE.md in hermes' chain, so that is what it will read");
});

test("rules-target is READ-ONLY -- it must not record a platform or create state", () => {
const cwd = tmpRepo();
run(["init"], { cwd });
const before = fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8");
run(["rules-target", "--platform", "codex"], { cwd });
assert.equal(fs.readFileSync(path.join(cwd, ".conductor", "state.json"), "utf8"), before,
"a query must not persist a platform switch -- only init/write-rules do that");
});

test("rules-target rejects an unknown --platform rather than defaulting", () => {
const cwd = tmpRepo();
run(["init"], { cwd });
const r = spawnSync("node", [ENGINE, "rules-target", "--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/);
});
4 changes: 4 additions & 0 deletions skills/conductor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Below, `$ENGINE` means the path resolved this way.
right before the context window collapses, so nothing is lost to compaction) ·
`write-rules` (invoked by `/pm:init`/`/pm:upgrade` — refreshes the managed rules block in
CLAUDE.md, or the recorded `--platform`'s equivalent file; not meant to be run standalone) ·
`rules-target` (read-only query — prints the absolute path of the file the recorded platform's
rules block belongs in, resolving that platform's first-match-wins chain. Exists so a consumer
never has to mirror the chain: a second copy of platform knowledge is drift waiting to happen.
Does not record a platform, unlike `write-rules`) ·
`/pm:resume` resume + reconcile (writes the reconciler's verdict back durably via
`record-reconcile`) · `record-gate-review <id> --gate 1|2 --verdict pass|fail` records an
openspec-lane epic's Gate 1/Gate 2 review verdict (see "OpenSpec build" below); `update-epic
Expand Down
Loading