From 5c8f91cbafc8193060902d0fcef063a582bb199f Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Fri, 31 Jul 2026 10:28:28 -0700 Subject: [PATCH 1/7] docs(pm): design doc for the platform parity mechanism (structural gate) Scoped deliberately small per the user's steer: scaffolding, not a system. The hard part of multi-platform support is the Hermes port, not this ledger, and a rich gate built against zero second platforms would be inventing structure for a problem not yet met. Ships one JSON ledger plus one test with two assertions -- every artifact claimed by exactly one capability, and every claimed path exists. Both can fail today, with one platform. That is the bar. A capability, not a file, is the unit: platforms implement the same capability differently (Codex uses filename-derived prompt files, Hermes registers commands in plugin code), so file-for-file would force a correspondence that cannot exist. Unported platforms are ABSENT from platforms[] rather than present-with-nulls, so there is no half-truth to maintain. DROPPED an exemption assertion that an earlier draft had. With zero exemptions it could never fail -- the vacuous-coverage pattern that appeared four separate times building the EDD harness. Exemptions are filed instead, to be designed against a real cannot-support case with a test that can actually fail. Four follow-ups filed so the deferred work is durable rather than conversational, including the user's specific concern that the harness EXISTING is not the same as EDD being DONE (the corpus covers one scenario against 22 artifacts) and that parity must be REASSESSED on every platform added, not once. Claude-Session: https://claude.ai/code/session_01WBqyLnBbYEi5hp2noc3cMC --- .conductor/render-stamp.json | 4 +- .conductor/state.json | 89 ++++++++++++- PROJECT.md | 26 ++-- ...-07-31-platform-parity-mechanism-design.md | 118 ++++++++++++++++++ 4 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md diff --git a/.conductor/render-stamp.json b/.conductor/render-stamp.json index 4e125ad..73c5ba5 100644 --- a/.conductor/render-stamp.json +++ b/.conductor/render-stamp.json @@ -1,4 +1,4 @@ { - "renderedAt": "2026-07-30T23:25:25.028Z", - "stateMtimeMs": 1785453924990.2876 + "renderedAt": "2026-07-31T17:27:24.546Z", + "stateMtimeMs": 1785518844528.5571 } diff --git a/.conductor/state.json b/.conductor/state.json index 587b42b..c273a89 100644 --- a/.conductor/state.json +++ b/.conductor/state.json @@ -1,6 +1,6 @@ { "version": 1, - "active": null, + "active": "platform-parity-mechanism", "epics": [ { "id": "pm-fix-link-flag-validation-and-update", @@ -1149,12 +1149,13 @@ "id": "platform-parity-mechanism", "title": "Build the mechanism that keeps every supported platform at parity with the Claude Code base, BEFORE any second platform exists. Three layers, mirroring how this repo already enforces documentation currency: (1) MECHANICAL -- a machine-parseable parity ledger (one markdown file with a table, OpenSpec supported-tools.md style, parsed directly by a CI test so exemptions are real config not prose) plus a conductor.test.mjs gate that enumerates Claude Code artifacts (commands/*.md, hook events in hooks/hooks.json, skills/, agents/) and FAILS CI when any lacks either a per-platform counterpart or an explicit exemption row with a reason; generic over whatever platforms the ledger lists, so it passes trivially at zero platforms and starts enforcing the moment codex is added. (2) PROCEDURAL -- a project-local skill that walks each platform directory in order, reads what changed in the Claude Code base, determines the per-platform delta, applies it, and updates the ledger; same shape as release-checklist/mintlify-doc-sync. (3) Semantic parity is explicitly NOT this epic's job -- a structural test can prove a counterpart file exists but never that it behaves the same; that gap is closed by the edd-harness sibling epic. Deliberately sequenced before codex-platform-support so the gate never has a window where drift could start unobserved.", "priority": "P1", - "status": "queued", + "status": "active", "role": "epic", "lane": "openspec", "links": [], "reconcileNeeded": false, - "parent": "multi-platform-agent-support" + "parent": "multi-platform-agent-support", + "startedAt": "2026-07-31T03:07:34.379Z" }, { "id": "codex-platform-support", @@ -1608,6 +1609,88 @@ "done": false } ] + }, + { + "id": "edd-corpus-expansion-nondeterministic-surfaces", + "title": "EDD corpus covers ONE scenario; expand to the judgment-dependent surfaces where drift actually hides", + "priority": "P2", + "status": "queued", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "the harness existing is NOT the same as EDD being done -- corpus has 1 scenario (lane routing) against ~21 artifacts", + "done": false + }, + { + "title": "inclusion criterion from the EDD design: an artifact earns a scenario when correct handling depends on agent JUDGMENT, not mechanical execution. Candidates: detour minimal-vs-substantial classification, the autonomy decision rule, the reconcile gate, hierarchy orchestration", + "done": false + }, + { + "title": "each scenario must be mutation-tested -- this session found 4 of 5 guard tests passing on an unrelated rule, so a scenario that cannot fail is worse than none", + "done": false + } + ] + }, + { + "id": "parity-reassessment-per-platform", + "title": "RECURRING: on every new platform, re-run the corpus, reassess ledger exemptions, and compare drift", + "priority": "P2", + "status": "planned", + "role": "epic", + "lane": "claude-code", + "links": [ + { + "type": "blocks", + "epic": "hermes-platform-support", + "reason": "Hermes is the first platform that must go through this reassessment, under the ledger gate" + } + ], + "reconcileNeeded": false, + "stories": [ + { + "title": "not a one-time task -- re-open this on EACH platform added. Add the platform to parity-ledger.json platforms[], fill its column, run the corpus against the blessed baseline, and record what differed", + "done": false + }, + { + "title": "the ledger catches FORGOTTEN capabilities; EDD catches capabilities that exist but BEHAVE differently. Both must run per platform -- neither substitutes for the other", + "done": false + } + ] + }, + { + "id": "parity-propagation-skill", + "title": "Procedural half of the parity model: the propagation skill, written against a real port", + "priority": "P3", + "status": "planned", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "DEFERRED from platform-parity-mechanism on purpose: writing the procedure before any port has happened would infer steps from design docs rather than from what a port actually required", + "done": false + } + ] + }, + { + "id": "parity-ledger-exemptions", + "title": "Ledger exemptions (capability genuinely unsupportable on a platform), designed against a real case", + "priority": "P3", + "status": "planned", + "role": "epic", + "lane": "claude-code", + "links": [], + "reconcileNeeded": false, + "stories": [ + { + "title": "DROPPED from the initial ledger deliberately: with zero exemptions the 'every exemption has a reason' assertion could never fail -- the vacuous-coverage pattern that bit this session four times. Design it when Hermes hits a real cannot-support case.", + "done": false + } + ] } ], "detourStack": [], diff --git a/PROJECT.md b/PROJECT.md index afebfbc..0562215 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-30T23:25:25.015Z +> Last rendered: 2026-07-31T17:27:24.539Z ## Now -_No active epic set._ +**`platform-parity-mechanism`** — Build the mechanism that keeps every supported platform at parity with the Claude Code base, BEFORE any second platform exists. Three layers, mirroring how this repo already enforces documentation currency: (1) MECHANICAL -- a machine-parseable parity ledger (one markdown file with a table, OpenSpec supported-tools.md style, parsed directly by a CI test so exemptions are real config not prose) plus a conductor.test.mjs gate that enumerates Claude Code artifacts (commands/*.md, hook events in hooks/hooks.json, skills/, agents/) and FAILS CI when any lacks either a per-platform counterpart or an explicit exemption row with a reason; generic over whatever platforms the ledger lists, so it passes trivially at zero platforms and starts enforcing the moment codex is added. (2) PROCEDURAL -- a project-local skill that walks each platform directory in order, reads what changed in the Claude Code base, determines the per-platform delta, applies it, and updates the ledger; same shape as release-checklist/mintlify-doc-sync. (3) Semantic parity is explicitly NOT this epic's job -- a structural test can prove a counterpart file exists but never that it behaves the same; that gap is closed by the edd-harness sibling epic. Deliberately sequenced before codex-platform-support so the gate never has a window where drift could start unobserved. (epic, P1) — — ## Detour stack @@ -48,11 +48,13 @@ _Empty — no work is paused._ | P2 | `df-verify-worktrees-merged-not-just-archived` | claude-code | epic | archived 🤖 | — | - | | P2 | `docs-site-cleanup` | claude-code | epic | archived | 3/9 stories | - | | P2 | `edd-adapter-discards-agent-stdout` | claude-code | epic | queued | — | - | +| P2 | `edd-corpus-expansion-nondeterministic-surfaces` | claude-code | epic | queued | 0/3 stories | - | | P2 | `edd-measures-installed-plugin-not-worktree` | claude-code | epic | queued | — | blocks→hermes-platform-support | | P2 | `gh-64-sync-duplicate-shipped-plan` | claude-code | epic | queued | 0/3 stories | - | | P2 | `gh-69-sync-no-done-signal-for-plans` | claude-code | epic | queued | — | relates-to→gh-64-sync-duplicate-shipped-plan | | P2 | `gh-70-link-validation-gap` | claude-code | epic | queued | — | - | | P2 | `github-issue-tracker-sync` | claude-code | epic | archived 🤖 | — | relates-to→ai-feedback-loop-github-issues | +| P2 | `parity-reassessment-per-platform` | claude-code | epic | planned | 0/2 stories | blocks→hermes-platform-support | | 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 | — | - | @@ -80,11 +82,13 @@ _Empty — no work is paused._ | P3 | `gh-66-update-epic-missing-flags` | claude-code | epic | queued | — | blocks→gh-69-sync-no-done-signal-for-plans | | P3 | `init-detects-foreign-agent-instruction-files` | claude-code | epic | planned | 0/1 stories | - | | P3 | `multi-platform-agent-support` | claude-code | epic | queued | 2/5 children archived | - | -| P1 | └─ `platform-parity-mechanism` | openspec | epic | queued ⚠ no change on disk | — | - | +| P1 | └─ `platform-parity-mechanism` | openspec | epic | active ⚠ no change on disk | — | - | | P1 | └─ `rules-block-hardcodes-claude-slash-commands` | claude-code | epic | archived | — | - | | P1 | └─ `edd-harness-agent-behavior-testing` | decision | epic | archived | — | - | | P2 | └─ `hermes-platform-support` | openspec | epic | queued ⚠ no change on disk | — | depends-on→platform-parity-mechanism; depends-on→edd-harness-agent-behavior-testing; depends-on→rules-block-hardcodes-claude-slash-commands | | P3 | └─ `codex-platform-support` | openspec | epic | queued ⚠ no change on disk | — | depends-on→platform-parity-mechanism; depends-on→edd-harness-agent-behavior-testing; depends-on→rules-block-hardcodes-claude-slash-commands; depends-on→hermes-platform-support | +| P3 | `parity-ledger-exemptions` | claude-code | epic | planned | 0/1 stories | - | +| P3 | `parity-propagation-skill` | claude-code | epic | planned | 0/1 stories | - | | 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 | - | @@ -108,20 +112,23 @@ _Empty — no work is paused._ ## Briefing (what a fresh session sees) ``` +⚠ pm 0.24.0 → 0.25.0 available — run `/reload-plugins` (if you just updated the plugin), then `/pm:upgrade`. + - **`rules-target` — a read-only query printing the absolute path of the file the resolved + CONDUCTOR STATE — where we are and what's next -NOW: (no active epic set) +NOW: `platform-parity-mechanism` (openspec, epic, P1) — — NEXT UP (by priority, then lane): • `autodetour-parser-misses-am-and-f` (P2, claude-code, queued) — 0/3 stories • `edd-adapter-discards-agent-stdout` (P2, claude-code, queued) — — + • `edd-corpus-expansion-nondeterministic-surfaces` (P2, claude-code, queued) — 0/3 stories • `edd-measures-installed-plugin-not-worktree` (P2, claude-code, queued) — — • `gh-64-sync-duplicate-shipped-plan` (P2, claude-code, queued) — 0/3 stories - • `gh-69-sync-no-done-signal-for-plans` (P2, claude-code, queued) — — - (+4 more — see PROJECT.md) - lanes: openspec 2 · superpowers 5 · claude-code 55 · decision 7 + (+5 more — see PROJECT.md) + lanes: openspec 2 · superpowers 5 · claude-code 56 · decision 7 -planned: 2 — see PROJECT.md +planned: 5 — see PROJECT.md EPIC LINKS: • `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 @@ -133,6 +140,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 + • `parity-reassessment-per-platform` blocks `hermes-platform-support` — Hermes is the first platform that must go through this reassessment, under the ledger gate • `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 @@ -142,7 +150,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): `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): `autodetour-parser-misses-am-and-f`, `edd-adapter-discards-agent-stdout`, `edd-corpus-expansion-nondeterministic-surfaces`, `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/docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md b/docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md new file mode 100644 index 0000000..4a59a44 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md @@ -0,0 +1,118 @@ +# Design: platform parity mechanism (structural gate) + +**Epic:** `platform-parity-mechanism` — child of `multi-platform-agent-support` +**Date:** 2026-07-31 +**Blocks:** `hermes-platform-support`, `codex-platform-support` + +## Why this exists + +Claude Code is the permanent base platform; every other platform is held to parity with it. +Parity enforcement has three layers, and this epic owns the first: + +| Layer | Catches | Status | +|---|---|---| +| **Mechanical (this epic)** | *"you added a capability and never considered porting it"* | this spec | +| Procedural (a propagation skill) | the per-platform work of propagating a base change | deferred — `parity-propagation-skill` | +| Semantic (EDD) | the counterpart exists but *behaves* differently | shipped | + +The mechanical layer catches the one failure mode that exists **even with a single platform**, +and the one EDD structurally cannot catch: a forgotten capability has no scenario, so a corpus +run stays green while the gap widens. This is why the gate lands *before* the first port — +otherwise drift gets an unobserved window during exactly the period when the most artifacts are +being added. + +## Scope: scaffolding, not a system + +Deliberately minimal. The hard part of multi-platform support is the Hermes port, not this +ledger, and building a rich gate against zero second platforms would be inventing structure for +a problem not yet met. What ships is the shape the port fills in. + +**In scope:** one JSON ledger, one test, two assertions. + +**Explicitly out of scope**, each with its own filed epic so it is not lost: + +- The **propagation skill** (`parity-propagation-skill`, P3 planned) — writing the procedure + before any port has happened would infer steps from design docs rather than from what a port + actually required. +- **Exemptions** (`parity-ledger-exemptions`, P3 planned) — see "What was dropped" below. +- **Per-platform reassessment** (`parity-reassessment-per-platform`, P2 planned, blocks Hermes) + — a recurring obligation, re-opened on each platform added, not a one-time task. +- **Corpus expansion** (`edd-corpus-expansion-nondeterministic-surfaces`, P2) — the EDD harness + existing is not the same as EDD being *done*: the corpus covers one scenario against 22 + artifacts. + +`scripts/` is out of scope entirely. The engine is already platform-neutral and shared, so it +has nothing to port. + +## The ledger + +`docs/parity-ledger.json` — hand-declared capabilities, machine-parseable. JSON rather than a +Markdown table because a test has to read it, and the zero-dependency law prefers JSON (native +`JSON.parse`) over pulling a parser. + +```json +{ + "platforms": ["claude-code"], + "capabilities": [ + { + "id": "detour-lifecycle", + "artifacts": ["commands/detour.md", "commands/resume.md", "agents/reconciler.md"], + "platforms": { "claude-code": "slash commands + reconciler subagent" } + } + ] +} +``` + +**A capability is the unit, not a file.** Platforms implement the same capability differently — +Codex uses filename-derived prompt files, Hermes registers commands in plugin code — so a +file-for-file mapping would force a correspondence that cannot exist. A capability may claim +several artifacts; the per-platform value is a *description of the mechanism*, not a path. + +**Unported platforms are absent from `platforms[]`, not present-with-nulls.** Nothing claims +support for a platform that has not been ported, so there is no half-truth to maintain. The +Hermes port adds itself to the list and fills its column as it goes. + +## The gate + +One test in `scripts/test/`, the same shape as the existing dispatch-key drift tests, which +already walk a directory and assert every entry is documented. Two assertions: + +1. **Every artifact is claimed by exactly one capability.** Walk `commands/`, `agents/`, + `skills/`, `hooks/`, `.claude-plugin/` — 22 files today. A new `commands/foo.md` with no + ledger row fails CI. This is the assertion the epic exists for. +2. **Every claimed path exists.** A deleted command leaves a stale row asserting parity for + something gone; that fails too. + +Both can fail *today*, with one platform and no exemptions. That is the bar. + +The test deliberately does **not** assert that any platform's column is complete. With one +platform that would be 22 instant failures; the assertion becomes meaningful only when a second +platform declares support, which is the port's job under this gate. + +## What was dropped, and why + +An earlier draft had a third assertion: *"every exemption carries a non-empty reason."* With +zero exemptions it could never fail — the vacuous-coverage pattern that appeared four separate +times while building the EDD harness (four of five guard tests passing on an unrelated +file-count rule; a hook-JSON regex matching zero commands; an `InstructionsLoaded` recorder +whose own error handler hid that it was broken). A test that cannot fail reads as coverage +while measuring nothing. + +So exemptions are dropped from the initial ledger entirely and filed as +`parity-ledger-exemptions`. When Hermes hits a genuine cannot-support case, the concept gets +designed against a real example, with a test that can actually fail. + +## Testing + +The two assertions are the test. Beyond them: + +- A fixture-based unit test proving assertion 1 **fails** on an unclaimed artifact, and + assertion 2 **fails** on a claimed-but-missing path. Without these, the gate itself is + unverified — the same trap as above. +- No agent runs, no network. This is a static consistency check over the repo. + +## Consequences + +Once this lands, `hermes-platform-support` has no remaining unarchived `depends-on`. Its port +proceeds *under* the gate: every capability it adds or ports is claimed as it goes, rather than +reconstructed afterward. From d168b1e3d5f661d30cb0f742811675e55f583532 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Mon, 3 Aug 2026 12:14:30 -0700 Subject: [PATCH 2/7] docs(pm): implementation plan for the platform parity mechanism Claude-Session: https://claude.ai/code/session_01BigZtNRPv6wBuZZ9ipUHW5 --- .../2026-08-03-platform-parity-mechanism.md | 489 ++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-platform-parity-mechanism.md diff --git a/docs/superpowers/plans/2026-08-03-platform-parity-mechanism.md b/docs/superpowers/plans/2026-08-03-platform-parity-mechanism.md new file mode 100644 index 0000000..99d6042 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-platform-parity-mechanism.md @@ -0,0 +1,489 @@ +# Platform Parity Mechanism 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:** Add a mechanical parity gate — one hand-declared JSON ledger of pm's capabilities, and one test that fails when a shipped artifact is not claimed by exactly one capability, or when a claimed path does not exist. + +**Architecture:** A single pure function, `parityViolations(rootDir, ledger)`, computes all violations by walking five artifact directories and comparing them to the ledger. The real gate calls it with the repo root and `docs/parity-ledger.json`; the fixture tests call the *same* function with a temp dir and an inline ledger, so the tests that prove the gate can fail exercise the identical code path that runs in CI. Everything lives under `scripts/test/` — this is repo-maintenance tooling, not shipped engine code. + +**Tech Stack:** Node 18+ built-ins only (`node:fs`, `node:path`, `node:assert`, `node:test`). Run with `node --test scripts/test/*.test.mjs`. + +**Spec:** `docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md` + +## Global Constraints + +- **Zero dependencies.** 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. This applies to `scripts/test/` as well as `scripts/`. +- **`node --test scripts/test/*.test.mjs` must pass — currently 287 tests, 0 failing** (measured 2026-08-03 at HEAD `5c8f91c`). `.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. This plan adds no exceptions — the gate is a static consistency check over files already on disk. +- **No version bump, no `CHANGELOG.md` entry, no `MIGRATIONS` entry.** Stated explicitly rather than skipped silently: this ships no new subcommand, no flag, and no behavior any user or host agent invokes; `state.json`'s schema is untouched. It is a repo-maintenance gate in the same class as the existing SKILL.md/README.md dispatch-drift tests, which are likewise unversioned. Current version stays `0.25.0`. +- **No README.md change and no Mintlify sync.** Also stated explicitly, per CLAUDE.md's documentation-currency rule: nothing here is user-visible — no subcommand, no flag, no changed epic/tracker/autonomy behavior — so neither `README.md` nor `pm-plugin.dev` needs to follow. The `mintlify-doc-sync` skill is not invoked for this plan. +- Conventional commits (`feat|fix|docs|test|chore|refactor|perf`). +- **Do not push and do not open a pull request.** Commits stay local; the branch is finished separately via the `pr-workflow` skill. + +### Frozen decisions — use these exact values, do not re-derive + +**The walked roots** (exactly these five, repo-relative, in this order): + +``` +commands/ agents/ skills/ hooks/ .claude-plugin/ +``` + +**The walk rule:** **recursive**, and **every regular file regardless of extension**. Two consequences chosen deliberately: + +- `skills/` today holds exactly one file at `skills/conductor/SKILL.md` — a non-recursive walk would find nothing there. Recursion is required, not optional. +- The first `skills/conductor/references/foo.md` (or a second `.claude-plugin/*.json`) **will** fail the gate until it is claimed. That is the gate working, not a false positive. No extension filter, no allowlist, no skip-list — an exclusion list is exactly the hole this epic exists to close. + +Paths are compared as repo-relative POSIX strings (`commands/status.md`), produced with `path.relative(rootDir, abs).split(path.sep).join("/")`. + +**`docs/parity-ledger.json` lives outside all five roots**, so the ledger never claims itself. Verified against the tree at HEAD. + +**The 22 artifacts at HEAD** — 16 `commands/`, 3 `agents/`, 1 `skills/`, 1 `hooks/`, 1 `.claude-plugin/`. Task 2's ledger partitions exactly these. + +## File Structure + +- **`scripts/test/parity-helpers.mjs`** (create) — the one exported function `parityViolations()`, plus the walk. Not named `*.test.mjs`, so `node --test scripts/test/*.test.mjs` does not treat it as a test file; CI's syntax loop (`for f in scripts/lib/*.mjs scripts/test/*.mjs`) still syntax-checks it. Precedent: `scripts/test/helpers.mjs`. +- **`scripts/test/parity.test.mjs`** (create) — the real-tree gate (Task 2) and the fixture tests proving each violation direction can fail (Task 1). One new file rather than appending to `conductor-09.test.mjs`, matching how `platform.test.mjs` was added for a new capability. +- **`docs/parity-ledger.json`** (create) — the hand-declared ledger. Not under `scripts/`, because it is a project document read by a test, not engine data. + +Nothing under `scripts/conductor.mjs` or `scripts/lib/` is touched. The engine is platform-neutral and shared; per the spec it has nothing to port and therefore nothing to claim. + +--- + +### Task 1: The violation checker and its fixture tests + +Build the pure function first, proven against temp-dir fixtures. The real repo is not touched in this task — that is Task 2 — so this task's tests pass on their own and stay meaningful even if the ledger is later restructured. + +**Files:** +- Create: `scripts/test/parity-helpers.mjs` +- Create: `scripts/test/parity.test.mjs` + +**Interfaces:** +- Consumes: `tmpRepo()` from `scripts/test/helpers.mjs` — returns the path of a fresh empty temp directory (`fs.mkdtempSync`). +- Produces: + ```js + // scripts/test/parity-helpers.mjs + export const PARITY_ROOTS = ["commands", "agents", "skills", "hooks", ".claude-plugin"]; + export function walkArtifacts(rootDir): string[] // sorted repo-relative POSIX paths + export function parityViolations(rootDir, ledger): { + unclaimed: string[], // on disk, in no capability + doubleClaimed: string[], // claimed by 2+ capabilities + missing: string[], // claimed by a capability, not on disk + } + ``` + `ledger` is the parsed object: `{ platforms: string[], capabilities: [{ id, artifacts: string[], platforms: {} }] }`. All three arrays are sorted; empty arrays mean no violations. Task 2 uses `parityViolations` only. + +- [ ] **Step 1: Write the failing fixture tests** + +Create `scripts/test/parity.test.mjs` with exactly this content: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpRepo } from "./helpers.mjs"; +import { parityViolations, walkArtifacts } from "./parity-helpers.mjs"; + +// ───────────────── fixture tests: prove each violation direction can fail ───────────────── +// +// These call the SAME parityViolations() the real-tree gate below calls. A separate +// re-implementation against a temp dir would prove nothing about the gate that runs in CI — +// that is the vacuous-coverage trap this epic was written to avoid. + +/** Write `files` (repo-relative paths) into a fresh temp dir and return its path. */ +function fixtureRepo(files) { + const dir = tmpRepo(); + for (const rel of files) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, "# fixture\n"); + } + return dir; +} + +test("walkArtifacts finds nested files under the walked roots and ignores everything else", () => { + const dir = fixtureRepo([ + "commands/status.md", + "skills/conductor/SKILL.md", + ".claude-plugin/plugin.json", + "docs/parity-ledger.json", // outside the roots — must not be walked + "README.md", // outside the roots — must not be walked + ]); + assert.deepEqual(walkArtifacts(dir), [ + ".claude-plugin/plugin.json", + "commands/status.md", + "skills/conductor/SKILL.md", + ]); +}); + +test("an artifact claimed by no capability is reported as unclaimed", () => { + const dir = fixtureRepo(["commands/status.md", "commands/orphan.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [{ id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }], + }; + const v = parityViolations(dir, ledger); + assert.deepEqual(v.unclaimed, ["commands/orphan.md"]); + assert.deepEqual(v.doubleClaimed, []); + assert.deepEqual(v.missing, []); +}); + +test("an artifact claimed by two capabilities is reported as double-claimed", () => { + const dir = fixtureRepo(["commands/status.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [ + { id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }, + { id: "index", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }, + ], + }; + const v = parityViolations(dir, ledger); + assert.deepEqual(v.doubleClaimed, ["commands/status.md"]); + assert.deepEqual(v.unclaimed, []); + assert.deepEqual(v.missing, []); +}); + +test("a claimed path that does not exist on disk is reported as missing", () => { + const dir = fixtureRepo(["commands/status.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [{ + id: "briefing", + artifacts: ["commands/status.md", "commands/deleted.md"], + platforms: { "claude-code": "slash command" }, + }], + }; + const v = parityViolations(dir, ledger); + assert.deepEqual(v.missing, ["commands/deleted.md"]); + assert.deepEqual(v.unclaimed, []); + assert.deepEqual(v.doubleClaimed, []); +}); + +test("a ledger that exactly partitions the artifacts on disk reports no violations", () => { + const dir = fixtureRepo(["commands/status.md", "agents/reconciler.md", "skills/conductor/SKILL.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [ + { id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }, + { id: "reconcile", artifacts: ["agents/reconciler.md"], platforms: { "claude-code": "subagent" } }, + { id: "discipline", artifacts: ["skills/conductor/SKILL.md"], platforms: { "claude-code": "skill" } }, + ], + }; + assert.deepEqual(parityViolations(dir, ledger), { unclaimed: [], doubleClaimed: [], missing: [] }); +}); + +test("a walked root that does not exist is skipped rather than throwing", () => { + // A platform port may land `commands/` before `agents/`; a missing root is not a violation. + const dir = fixtureRepo(["commands/status.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [{ id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }], + }; + assert.deepEqual(parityViolations(dir, ledger), { unclaimed: [], doubleClaimed: [], missing: [] }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test scripts/test/parity.test.mjs` + +Expected: every test FAILS with a module-resolution error — `Cannot find module '.../scripts/test/parity-helpers.mjs'`. + +- [ ] **Step 3: Write the checker** + +Create `scripts/test/parity-helpers.mjs` with exactly this content: + +```js +// Parity gate: the one place the ledger-vs-tree comparison is implemented. Both the real-tree +// gate and the fixture tests in parity.test.mjs call parityViolations(), so the tests that +// prove the gate CAN fail exercise the same code CI runs. See +// docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md. +import fs from "node:fs"; +import path from "node:path"; + +/** The artifact trees every capability must be declared over. Recursive, no extension filter: + * a new nested file (e.g. skills/conductor/references/foo.md) SHOULD fail until it is claimed. */ +export const PARITY_ROOTS = ["commands", "agents", "skills", "hooks", ".claude-plugin"]; + +/** Sorted repo-relative POSIX paths of every regular file under PARITY_ROOTS. */ +export function walkArtifacts(rootDir) { + const found = []; + const visit = (abs) => { + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const child = path.join(abs, entry.name); + if (entry.isDirectory()) visit(child); + else if (entry.isFile()) found.push(path.relative(rootDir, child).split(path.sep).join("/")); + } + }; + for (const root of PARITY_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs)) visit(abs); + } + return found.sort(); +} + +/** Compare the tree under rootDir against a parsed parity ledger. + * Returns { unclaimed, doubleClaimed, missing } — all sorted; all empty means parity holds. */ +export function parityViolations(rootDir, ledger) { + const onDisk = new Set(walkArtifacts(rootDir)); + + const claimCount = new Map(); + for (const cap of ledger.capabilities) { + for (const artifact of cap.artifacts) { + claimCount.set(artifact, (claimCount.get(artifact) || 0) + 1); + } + } + + const unclaimed = [...onDisk].filter((p) => !claimCount.has(p)).sort(); + const doubleClaimed = [...claimCount].filter(([, n]) => n > 1).map(([p]) => p).sort(); + const missing = [...claimCount.keys()].filter((p) => !onDisk.has(p)).sort(); + return { unclaimed, doubleClaimed, missing }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test scripts/test/parity.test.mjs` + +Expected: PASS, 6/6. + +- [ ] **Step 5: Run the full suite** + +Run: `node --test scripts/test/*.test.mjs` + +Expected: 293 tests, 0 failing (287 + the 6 added here). It takes roughly two minutes; do not interrupt it. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/test/parity-helpers.mjs scripts/test/parity.test.mjs +git commit -m "test(parity): violation checker for the parity gate, with fixtures per direction" +``` + +--- + +### Task 2: The ledger and the real-tree gate + +Now point the checker at the repo. The gate test is written *before* the ledger exists, so its first run fails against the real tree with all 22 artifacts unclaimed — that is what proves the gate is wired to the real repo and not only to fixtures. + +**Files:** +- Create: `docs/parity-ledger.json` +- Modify: `scripts/test/parity.test.mjs` (append the real-tree gate) + +**Interfaces:** +- Consumes: `parityViolations(rootDir, ledger)` from Task 1. +- Produces: `docs/parity-ledger.json` — read by the gate, and by the `hermes-platform-support` port, which appends `"hermes"` to `platforms[]` and adds a `"hermes"` key to each capability's `platforms` object as it goes. + +- [ ] **Step 1: Write the failing real-tree gate** + +Append to `scripts/test/parity.test.mjs`: + +```js +// ───────────────── the gate: the real ledger against the real tree ───────────────── + +// fileURLToPath, not new URL(...).pathname — same convention as helpers.mjs, and correct for +// paths containing spaces or percent-encodable characters. +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function realLedger() { + return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "docs", "parity-ledger.json"), "utf8")); +} + +test("every shipped artifact is claimed by exactly one capability in docs/parity-ledger.json", () => { + const v = parityViolations(REPO_ROOT, realLedger()); + assert.deepEqual(v.unclaimed, [], + `artifact(s) on disk that no capability claims — add them to docs/parity-ledger.json: ${v.unclaimed.join(", ")}`); + assert.deepEqual(v.doubleClaimed, [], + `artifact(s) claimed by more than one capability — a capability is the unit of parity, so each artifact belongs to exactly one: ${v.doubleClaimed.join(", ")}`); +}); + +test("every path claimed in docs/parity-ledger.json exists on disk", () => { + const v = parityViolations(REPO_ROOT, realLedger()); + assert.deepEqual(v.missing, [], + `docs/parity-ledger.json claims parity for path(s) that no longer exist — remove the stale row(s): ${v.missing.join(", ")}`); +}); + +test("the ledger declares claude-code, and every capability describes its claude-code mechanism", () => { + // claude-code is the permanent base platform; a capability with no base mechanism is a + // half-declared row. Ported platforms are ABSENT from platforms[] until they are real, so + // nothing here asserts completeness for any other platform. + const ledger = realLedger(); + assert.ok(ledger.platforms.includes("claude-code"), "platforms[] must include the base platform"); + const undescribed = ledger.capabilities + .filter((c) => !c.platforms["claude-code"] || !c.platforms["claude-code"].trim()) + .map((c) => c.id); + assert.deepEqual(undescribed, [], + `capability(ies) with no claude-code mechanism described: ${undescribed.join(", ")}`); +}); +``` + +- [ ] **Step 2: Run the gate to verify it fails against the real tree** + +Run: `node --test scripts/test/parity.test.mjs` + +Expected: the three new tests FAIL with `ENOENT: no such file or directory, open '.../docs/parity-ledger.json'`. The 6 fixture tests from Task 1 still pass. + +- [ ] **Step 3: Write the ledger** + +Create `docs/parity-ledger.json` with exactly this content. It partitions all 22 artifacts at HEAD; `platforms` holds only `claude-code`, because no other platform has been ported and the spec forbids present-with-nulls rows. + +```json +{ + "platforms": ["claude-code"], + "capabilities": [ + { + "id": "conductor-discipline", + "artifacts": ["skills/conductor/SKILL.md"], + "platforms": { "claude-code": "skill loaded by name (skills//SKILL.md)" } + }, + { + "id": "epic-index", + "artifacts": [ + "commands/epic.md", + "commands/status.md", + "commands/next.md", + "commands/sync.md" + ], + "platforms": { "claude-code": "slash commands (/pm:epic, /pm:status, /pm:next, /pm:sync)" } + }, + { + "id": "detour-lifecycle", + "artifacts": [ + "commands/detour.md", + "commands/resume.md", + "commands/gate-guard.md", + "agents/reconciler.md" + ], + "platforms": { "claude-code": "slash commands + reconciler subagent + a PreToolUse gate-guard hook" } + }, + { + "id": "epic-hierarchy-orchestration", + "artifacts": [ + "commands/hierarchy.md", + "agents/hierarchy-child-executor.md", + "agents/merge-conflict-resolver.md" + ], + "platforms": { "claude-code": "slash command dispatching two subagents into git worktrees" } + }, + { + "id": "repo-configuration", + "artifacts": [ + "commands/tracker.md", + "commands/lane-routing.md", + "commands/review-mode.md" + ], + "platforms": { "claude-code": "slash commands writing settings into .conductor/state.json" } + }, + { + "id": "install-and-upgrade", + "artifacts": [ + "commands/init.md", + "commands/upgrade.md", + "commands/changelog.md", + "commands/changesets.md" + ], + "platforms": { "claude-code": "slash commands (/pm:init scaffolds; /pm:upgrade migrates and rewrites the rules block)" } + }, + { + "id": "feedback-channel", + "artifacts": ["commands/feedback.md"], + "platforms": { "claude-code": "slash command instructing the agent to file a GitHub issue (the engine never calls gh)" } + }, + { + "id": "session-lifecycle-hooks", + "artifacts": ["hooks/hooks.json"], + "platforms": { "claude-code": "SessionStart / PreCompact / PostToolUse hooks in hooks.json, each passing --platform claude-code" } + }, + { + "id": "plugin-packaging", + "artifacts": [".claude-plugin/plugin.json"], + "platforms": { "claude-code": "plugin manifest consumed by the Claude Code marketplace loader" } + } + ] +} +``` + +- [ ] **Step 4: Run the gate to verify it passes** + +Run: `node --test scripts/test/parity.test.mjs` + +Expected: PASS, 9/9. + +If `unclaimed` is non-empty, an artifact was added since this plan was written — add it to the capability it belongs to rather than loosening the walk. + +- [ ] **Step 5: Prove the gate is pointed at the real tree** + +The fixture tests prove the function; this proves the wiring. Run these three commands in order: + +```bash +printf -- '---\ndescription: scratch\n---\n' > commands/zzz-scratch.md +node --test scripts/test/parity.test.mjs # expect FAIL: unclaimed → commands/zzz-scratch.md +rm commands/zzz-scratch.md +``` + +Expected: the middle command fails with `artifact(s) on disk that no capability claims — add them to docs/parity-ledger.json: commands/zzz-scratch.md`. Confirm `git status` is clean of `zzz-scratch.md` afterwards. + +- [ ] **Step 6: Run the full suite** + +Run: `node --test scripts/test/*.test.mjs` + +Expected: 296 tests, 0 failing (287 + 6 from Task 1 + 3 here). + +- [ ] **Step 7: Commit** + +```bash +git add docs/parity-ledger.json scripts/test/parity.test.mjs +git commit -m "feat(parity): declare the capability ledger and gate the tree against it" +``` + +--- + +### Task 3: Wire the gate into the project's own rules + +The gate only works if the next person adding a command knows it exists. `CLAUDE.md` is where this repo's hard constraints live, so the obligation is recorded there next to the other release-discipline rules. + +**Files:** +- Modify: `CLAUDE.md` (in "The `pm` engine — hard constraints (must follow)", after the "Release discipline" bullet) + +**Interfaces:** +- Consumes: `docs/parity-ledger.json` from Task 2 (referenced by path). +- Produces: nothing other tasks depend on. + +- [ ] **Step 1: Add the constraint bullet** + +Insert this bullet into `CLAUDE.md` immediately after the bullet beginning "**Release discipline.**": + +```markdown +- **Parity ledger.** Every file under `commands/`, `agents/`, `skills/`, `hooks/`, and + `.claude-plugin/` must be claimed by exactly one capability in `docs/parity-ledger.json`, and + every path it claims must exist. `scripts/test/parity.test.mjs` enforces both and fails CI + otherwise. Adding a command/agent/skill file means adding it to a capability in the same + commit — either an existing one or a new one with its `claude-code` mechanism described. + Unported platforms are **absent** from `platforms[]`, never present with null values; a port + adds itself and fills its column as it goes. See + `docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md`. +``` + +- [ ] **Step 2: Verify the suite still passes** + +Run: `node --test scripts/test/*.test.mjs` + +Expected: 296 tests, 0 failing. (`CLAUDE.md` is not a walked root, so the ledger is unaffected; this run confirms the doc-drift tests over `README.md`/`SKILL.md` are still green.) + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs(pm): record the parity-ledger obligation in the engine's hard constraints" +``` + +--- + +## Done means + +- `node --test scripts/test/*.test.mjs` → 296 tests, 0 failing. +- Creating an unclaimed file under any of the five roots fails the suite (demonstrated in Task 2, Step 5). +- `docs/parity-ledger.json` declares one platform, nine capabilities, 22 artifacts, no nulls, no exemptions. +- No version bump, no `CHANGELOG.md`/`MIGRATIONS` entry, no `README.md` or Mintlify change — by the explicit decision recorded in Global Constraints, not by omission. +- The epic's consequence holds: `hermes-platform-support` has no remaining unarchived `depends-on`. From f5f81a67c34c367cc6c11bf5fe446e7284b1dec2 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Mon, 3 Aug 2026 12:31:44 -0700 Subject: [PATCH 3/7] test(parity): violation checker for the parity gate, with fixtures per direction --- scripts/test/parity-helpers.mjs | 45 ++++++++++++++ scripts/test/parity.test.mjs | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 scripts/test/parity-helpers.mjs create mode 100644 scripts/test/parity.test.mjs diff --git a/scripts/test/parity-helpers.mjs b/scripts/test/parity-helpers.mjs new file mode 100644 index 0000000..f277c29 --- /dev/null +++ b/scripts/test/parity-helpers.mjs @@ -0,0 +1,45 @@ +// Parity gate: the one place the ledger-vs-tree comparison is implemented. Both the real-tree +// gate and the fixture tests in parity.test.mjs call parityViolations(), so the tests that +// prove the gate CAN fail exercise the same code CI runs. See +// docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md. +import fs from "node:fs"; +import path from "node:path"; + +/** The artifact trees every capability must be declared over. Recursive, no extension filter: + * a new nested file (e.g. skills/conductor/references/foo.md) SHOULD fail until it is claimed. */ +export const PARITY_ROOTS = ["commands", "agents", "skills", "hooks", ".claude-plugin"]; + +/** Sorted repo-relative POSIX paths of every regular file under PARITY_ROOTS. */ +export function walkArtifacts(rootDir) { + const found = []; + const visit = (abs) => { + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const child = path.join(abs, entry.name); + if (entry.isDirectory()) visit(child); + else if (entry.isFile()) found.push(path.relative(rootDir, child).split(path.sep).join("/")); + } + }; + for (const root of PARITY_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs)) visit(abs); + } + return found.sort(); +} + +/** Compare the tree under rootDir against a parsed parity ledger. + * Returns { unclaimed, doubleClaimed, missing } — all sorted; all empty means parity holds. */ +export function parityViolations(rootDir, ledger) { + const onDisk = new Set(walkArtifacts(rootDir)); + + const claimCount = new Map(); + for (const cap of ledger.capabilities) { + for (const artifact of cap.artifacts) { + claimCount.set(artifact, (claimCount.get(artifact) || 0) + 1); + } + } + + const unclaimed = [...onDisk].filter((p) => !claimCount.has(p)).sort(); + const doubleClaimed = [...claimCount].filter(([, n]) => n > 1).map(([p]) => p).sort(); + const missing = [...claimCount.keys()].filter((p) => !onDisk.has(p)).sort(); + return { unclaimed, doubleClaimed, missing }; +} diff --git a/scripts/test/parity.test.mjs b/scripts/test/parity.test.mjs new file mode 100644 index 0000000..e3571c9 --- /dev/null +++ b/scripts/test/parity.test.mjs @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpRepo } from "./helpers.mjs"; +import { parityViolations, walkArtifacts } from "./parity-helpers.mjs"; + +// ───────────────── fixture tests: prove each violation direction can fail ───────────────── +// +// These call the SAME parityViolations() the real-tree gate below calls. A separate +// re-implementation against a temp dir would prove nothing about the gate that runs in CI — +// that is the vacuous-coverage trap this epic was written to avoid. + +/** Write `files` (repo-relative paths) into a fresh temp dir and return its path. */ +function fixtureRepo(files) { + const dir = tmpRepo(); + for (const rel of files) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, "# fixture\n"); + } + return dir; +} + +test("walkArtifacts finds nested files under the walked roots and ignores everything else", () => { + const dir = fixtureRepo([ + "commands/status.md", + "skills/conductor/SKILL.md", + ".claude-plugin/plugin.json", + "docs/parity-ledger.json", // outside the roots — must not be walked + "README.md", // outside the roots — must not be walked + ]); + assert.deepEqual(walkArtifacts(dir), [ + ".claude-plugin/plugin.json", + "commands/status.md", + "skills/conductor/SKILL.md", + ]); +}); + +test("an artifact claimed by no capability is reported as unclaimed", () => { + const dir = fixtureRepo(["commands/status.md", "commands/orphan.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [{ id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }], + }; + const v = parityViolations(dir, ledger); + assert.deepEqual(v.unclaimed, ["commands/orphan.md"]); + assert.deepEqual(v.doubleClaimed, []); + assert.deepEqual(v.missing, []); +}); + +test("an artifact claimed by two capabilities is reported as double-claimed", () => { + const dir = fixtureRepo(["commands/status.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [ + { id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }, + { id: "index", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }, + ], + }; + const v = parityViolations(dir, ledger); + assert.deepEqual(v.doubleClaimed, ["commands/status.md"]); + assert.deepEqual(v.unclaimed, []); + assert.deepEqual(v.missing, []); +}); + +test("a claimed path that does not exist on disk is reported as missing", () => { + const dir = fixtureRepo(["commands/status.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [{ + id: "briefing", + artifacts: ["commands/status.md", "commands/deleted.md"], + platforms: { "claude-code": "slash command" }, + }], + }; + const v = parityViolations(dir, ledger); + assert.deepEqual(v.missing, ["commands/deleted.md"]); + assert.deepEqual(v.unclaimed, []); + assert.deepEqual(v.doubleClaimed, []); +}); + +test("a ledger that exactly partitions the artifacts on disk reports no violations", () => { + const dir = fixtureRepo(["commands/status.md", "agents/reconciler.md", "skills/conductor/SKILL.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [ + { id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }, + { id: "reconcile", artifacts: ["agents/reconciler.md"], platforms: { "claude-code": "subagent" } }, + { id: "discipline", artifacts: ["skills/conductor/SKILL.md"], platforms: { "claude-code": "skill" } }, + ], + }; + assert.deepEqual(parityViolations(dir, ledger), { unclaimed: [], doubleClaimed: [], missing: [] }); +}); + +test("a walked root that does not exist is skipped rather than throwing", () => { + // A platform port may land `commands/` before `agents/`; a missing root is not a violation. + const dir = fixtureRepo(["commands/status.md"]); + const ledger = { + platforms: ["claude-code"], + capabilities: [{ id: "briefing", artifacts: ["commands/status.md"], platforms: { "claude-code": "slash command" } }], + }; + assert.deepEqual(parityViolations(dir, ledger), { unclaimed: [], doubleClaimed: [], missing: [] }); +}); From 7ce0a5609954dad6bd583d9f3c362e4502841b6c Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Mon, 3 Aug 2026 12:42:28 -0700 Subject: [PATCH 4/7] feat(parity): declare the capability ledger and gate the tree against it --- docs/parity-ledger.json | 73 ++++++++++++++++++++++++++++++++++++ scripts/test/parity.test.mjs | 37 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 docs/parity-ledger.json diff --git a/docs/parity-ledger.json b/docs/parity-ledger.json new file mode 100644 index 0000000..ed09b63 --- /dev/null +++ b/docs/parity-ledger.json @@ -0,0 +1,73 @@ +{ + "platforms": ["claude-code"], + "capabilities": [ + { + "id": "conductor-discipline", + "artifacts": ["skills/conductor/SKILL.md"], + "platforms": { "claude-code": "skill loaded by name (skills//SKILL.md)" } + }, + { + "id": "epic-index", + "artifacts": [ + "commands/epic.md", + "commands/status.md", + "commands/next.md", + "commands/sync.md" + ], + "platforms": { "claude-code": "slash commands (/pm:epic, /pm:status, /pm:next, /pm:sync)" } + }, + { + "id": "detour-lifecycle", + "artifacts": [ + "commands/detour.md", + "commands/resume.md", + "commands/gate-guard.md", + "agents/reconciler.md" + ], + "platforms": { "claude-code": "slash commands + reconciler subagent + a PreToolUse gate-guard hook" } + }, + { + "id": "epic-hierarchy-orchestration", + "artifacts": [ + "commands/hierarchy.md", + "agents/hierarchy-child-executor.md", + "agents/merge-conflict-resolver.md" + ], + "platforms": { "claude-code": "slash command dispatching two subagents into git worktrees" } + }, + { + "id": "repo-configuration", + "artifacts": [ + "commands/tracker.md", + "commands/lane-routing.md", + "commands/review-mode.md" + ], + "platforms": { "claude-code": "slash commands writing settings into .conductor/state.json" } + }, + { + "id": "install-and-upgrade", + "artifacts": [ + "commands/init.md", + "commands/upgrade.md", + "commands/changelog.md", + "commands/changesets.md" + ], + "platforms": { "claude-code": "slash commands (/pm:init scaffolds; /pm:upgrade migrates and rewrites the rules block)" } + }, + { + "id": "feedback-channel", + "artifacts": ["commands/feedback.md"], + "platforms": { "claude-code": "slash command instructing the agent to file a GitHub issue (the engine never calls gh)" } + }, + { + "id": "session-lifecycle-hooks", + "artifacts": ["hooks/hooks.json"], + "platforms": { "claude-code": "SessionStart / PreCompact / PostToolUse hooks in hooks.json, each passing --platform claude-code" } + }, + { + "id": "plugin-packaging", + "artifacts": [".claude-plugin/plugin.json"], + "platforms": { "claude-code": "plugin manifest consumed by the Claude Code marketplace loader" } + } + ] +} diff --git a/scripts/test/parity.test.mjs b/scripts/test/parity.test.mjs index e3571c9..69612d1 100644 --- a/scripts/test/parity.test.mjs +++ b/scripts/test/parity.test.mjs @@ -103,3 +103,40 @@ test("a walked root that does not exist is skipped rather than throwing", () => }; assert.deepEqual(parityViolations(dir, ledger), { unclaimed: [], doubleClaimed: [], missing: [] }); }); + +// ───────────────── the gate: the real ledger against the real tree ───────────────── + +// fileURLToPath, not new URL(...).pathname — same convention as helpers.mjs, and correct for +// paths containing spaces or percent-encodable characters. +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function realLedger() { + return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "docs", "parity-ledger.json"), "utf8")); +} + +test("every shipped artifact is claimed by exactly one capability in docs/parity-ledger.json", () => { + const v = parityViolations(REPO_ROOT, realLedger()); + assert.deepEqual(v.unclaimed, [], + `artifact(s) on disk that no capability claims — add them to docs/parity-ledger.json: ${v.unclaimed.join(", ")}`); + assert.deepEqual(v.doubleClaimed, [], + `artifact(s) claimed by more than one capability — a capability is the unit of parity, so each artifact belongs to exactly one: ${v.doubleClaimed.join(", ")}`); +}); + +test("every path claimed in docs/parity-ledger.json exists on disk", () => { + const v = parityViolations(REPO_ROOT, realLedger()); + assert.deepEqual(v.missing, [], + `docs/parity-ledger.json claims parity for path(s) that no longer exist — remove the stale row(s): ${v.missing.join(", ")}`); +}); + +test("the ledger declares claude-code, and every capability describes its claude-code mechanism", () => { + // claude-code is the permanent base platform; a capability with no base mechanism is a + // half-declared row. Ported platforms are ABSENT from platforms[] until they are real, so + // nothing here asserts completeness for any other platform. + const ledger = realLedger(); + assert.ok(ledger.platforms.includes("claude-code"), "platforms[] must include the base platform"); + const undescribed = ledger.capabilities + .filter((c) => !c.platforms["claude-code"] || !c.platforms["claude-code"].trim()) + .map((c) => c.id); + assert.deepEqual(undescribed, [], + `capability(ies) with no claude-code mechanism described: ${undescribed.join(", ")}`); +}); From 04c54c870ae871d03adbe5b2649492db94fd71a8 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Mon, 3 Aug 2026 12:52:15 -0700 Subject: [PATCH 5/7] docs(pm): record the parity-ledger obligation in the engine's hard constraints --- CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 5b3f46d..2dc3b96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,14 @@ (`.claude/skills/release-checklist/SKILL.md`) — repo-maintenance tooling, not part of what the plugin ships to users. Follow it every time `plugin.json`'s version bumps; don't re-derive the checklist from memory. +- **Parity ledger.** Every file under `commands/`, `agents/`, `skills/`, `hooks/`, and + `.claude-plugin/` must be claimed by exactly one capability in `docs/parity-ledger.json`, and + every path it claims must exist. `scripts/test/parity.test.mjs` enforces both and fails CI + otherwise. Adding a command/agent/skill file means adding it to a capability in the same + commit — either an existing one or a new one with its `claude-code` mechanism described. + Unported platforms are **absent** from `platforms[]`, never present with null values; a port + adds itself and fills its column as it goes. See + `docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md`. - Engine subcommands are dispatched at the bottom of `conductor.mjs`; every new subcommand needs 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 From c63efc1dbb2da863d097aa3935930b8600afb393 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Mon, 3 Aug 2026 13:05:14 -0700 Subject: [PATCH 6/7] fix(parity): walk symlinked artifacts and split the exactly-one assertion Symlinked artifacts under a walked root were silently skipped since readdirSync's isFile() is false for symlinks (lstat semantics) -- a symlinked file could ship via git without ever needing to be claimed in the parity ledger. walkArtifacts now recurses only on isDirectory() and treats every other entry (including symlinks) as a leaf. Also split the "exactly one capability" gate test into two separate tests (unclaimed, doubleClaimed) so a stray unclaimed file can no longer mask a genuine double-claim by throwing first. --- scripts/test/parity-helpers.mjs | 8 ++++++-- scripts/test/parity.test.mjs | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/test/parity-helpers.mjs b/scripts/test/parity-helpers.mjs index f277c29..6a27b33 100644 --- a/scripts/test/parity-helpers.mjs +++ b/scripts/test/parity-helpers.mjs @@ -9,14 +9,18 @@ import path from "node:path"; * a new nested file (e.g. skills/conductor/references/foo.md) SHOULD fail until it is claimed. */ export const PARITY_ROOTS = ["commands", "agents", "skills", "hooks", ".claude-plugin"]; -/** Sorted repo-relative POSIX paths of every regular file under PARITY_ROOTS. */ +/** Sorted repo-relative POSIX paths of every non-directory entry under PARITY_ROOTS — including + * symlinks. fs.readdirSync uses lstat semantics, so a symlinked file is neither isFile() nor + * isDirectory() true together with isFile(); it must still be recorded (a symlinked artifact + * ships via git like any other file and must be claimed), so we recurse only on isDirectory() + * and treat everything else as a leaf. */ export function walkArtifacts(rootDir) { const found = []; const visit = (abs) => { for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { const child = path.join(abs, entry.name); if (entry.isDirectory()) visit(child); - else if (entry.isFile()) found.push(path.relative(rootDir, child).split(path.sep).join("/")); + else found.push(path.relative(rootDir, child).split(path.sep).join("/")); } }; for (const root of PARITY_ROOTS) { diff --git a/scripts/test/parity.test.mjs b/scripts/test/parity.test.mjs index 69612d1..6b6f51b 100644 --- a/scripts/test/parity.test.mjs +++ b/scripts/test/parity.test.mjs @@ -38,6 +38,15 @@ test("walkArtifacts finds nested files under the walked roots and ignores everyt ]); }); +test("walkArtifacts records symlinked artifacts, not just regular files", () => { + const dir = fixtureRepo(["commands/status.md"]); + fs.symlinkSync( + path.join(dir, "commands", "status.md"), + path.join(dir, "commands", "linked.md"), + ); + assert.deepEqual(walkArtifacts(dir), ["commands/linked.md", "commands/status.md"]); +}); + test("an artifact claimed by no capability is reported as unclaimed", () => { const dir = fixtureRepo(["commands/status.md", "commands/orphan.md"]); const ledger = { @@ -114,10 +123,14 @@ function realLedger() { return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "docs", "parity-ledger.json"), "utf8")); } -test("every shipped artifact is claimed by exactly one capability in docs/parity-ledger.json", () => { +test("every shipped artifact is claimed by at least one capability in docs/parity-ledger.json", () => { const v = parityViolations(REPO_ROOT, realLedger()); assert.deepEqual(v.unclaimed, [], `artifact(s) on disk that no capability claims — add them to docs/parity-ledger.json: ${v.unclaimed.join(", ")}`); +}); + +test("no shipped artifact is claimed by more than one capability in docs/parity-ledger.json", () => { + const v = parityViolations(REPO_ROOT, realLedger()); assert.deepEqual(v.doubleClaimed, [], `artifact(s) claimed by more than one capability — a capability is the unit of parity, so each artifact belongs to exactly one: ${v.doubleClaimed.join(", ")}`); }); From 3cba2e9f18a47df23816e6c8ee106d8c1bb18555 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Mon, 3 Aug 2026 17:40:57 -0700 Subject: [PATCH 7/7] fix(parity): skip git-ignored paths so local cruft cannot block a commit walkArtifacts() read the filesystem directly, so a macOS .DS_Store recreated under skills/ (ignored by .gitignore) tripped the parity gate with "add them to docs/parity-ledger.json" -- wrong advice for a file that never ships. Since --no-verify is banned here, that hard-blocked every macOS developer while CI on a clean checkout was unaffected. Filter the walk through a single `git check-ignore --stdin` call, failing open (returning everything) if git is missing, errors, or rootDir isn't a git repository -- fixtureRepo()'s temp dirs stay ungitted and must keep passing unchanged. Deliberately not `git ls-files`: that means "tracked", and an untracked-but-not-ignored new artifact must still be walked or the gate stops catching the exact miss it exists for. --- CLAUDE.md | 11 +++++---- scripts/test/parity-helpers.mjs | 41 ++++++++++++++++++++++++++++----- scripts/test/parity.test.mjs | 36 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2dc3b96..8ed4e78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,11 +45,12 @@ re-derive the checklist from memory. - **Parity ledger.** Every file under `commands/`, `agents/`, `skills/`, `hooks/`, and `.claude-plugin/` must be claimed by exactly one capability in `docs/parity-ledger.json`, and - every path it claims must exist. `scripts/test/parity.test.mjs` enforces both and fails CI - otherwise. Adding a command/agent/skill file means adding it to a capability in the same - commit — either an existing one or a new one with its `claude-code` mechanism described. - Unported platforms are **absent** from `platforms[]`, never present with null values; a port - adds itself and fills its column as it goes. See + every path it claims must exist. Git-ignored paths (e.g. a macOS `.DS_Store`) are skipped by + the walk, so local cruft can never trip this gate. `scripts/test/parity.test.mjs` enforces + both and fails CI otherwise. Adding a command/agent/skill file means adding it to a capability + in the same commit — either an existing one or a new one with its `claude-code` mechanism + described. Unported platforms are **absent** from `platforms[]`, never present with null + values; a port adds itself and fills its column as it goes. See `docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md`. - Engine subcommands are dispatched at the bottom of `conductor.mjs`; every new subcommand needs a matching command doc under `commands/` and coverage in `scripts/test/*.test.mjs`. diff --git a/scripts/test/parity-helpers.mjs b/scripts/test/parity-helpers.mjs index 6a27b33..a05951b 100644 --- a/scripts/test/parity-helpers.mjs +++ b/scripts/test/parity-helpers.mjs @@ -4,16 +4,45 @@ // docs/superpowers/specs/2026-07-31-platform-parity-mechanism-design.md. import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; /** The artifact trees every capability must be declared over. Recursive, no extension filter: - * a new nested file (e.g. skills/conductor/references/foo.md) SHOULD fail until it is claimed. */ + * a new nested file (e.g. skills/conductor/references/foo.md) SHOULD fail until it is claimed. + * Paths git-ignores (e.g. a macOS `.DS_Store` under skills/) are excluded — see + * filterGitIgnored() below for why that must be done with `git check-ignore`, not + * `git ls-files`. */ export const PARITY_ROOTS = ["commands", "agents", "skills", "hooks", ".claude-plugin"]; +/** Given repo-relative candidate paths, return the subset git does NOT ignore, using a single + * `git check-ignore --stdin` call (not one spawn per file). Fails OPEN: if git is missing, + * errors, or rootDir is not a git repository (exit code other than 0/1 — notably 128), every + * candidate is returned unfiltered rather than throwing or silently dropping paths. + * + * Deliberately `git check-ignore`, NOT `git ls-files`: ls-files answers "is this tracked", + * and a brand-new unstaged commands/foo.md is untracked-but-not-ignored — filtering on + * tracked-ness would stop the gate from ever seeing it, reintroducing the exact miss this + * gate exists to catch. Only *ignored* paths may be excluded; untracked-and-not-ignored + * paths must still be walked. */ +function filterGitIgnored(rootDir, candidates) { + if (candidates.length === 0) return candidates; + const result = spawnSync("git", ["check-ignore", "--stdin"], { + cwd: rootDir, + input: candidates.join("\n") + "\n", + encoding: "utf8", + }); + if (result.status === 0) { + const ignored = new Set(result.stdout.split("\n").filter(Boolean)); + return candidates.filter((p) => !ignored.has(p)); + } + if (result.status === 1) return candidates; // none ignored + return candidates; // git missing, not a repo (128), or other error — fail open +} + /** Sorted repo-relative POSIX paths of every non-directory entry under PARITY_ROOTS — including - * symlinks. fs.readdirSync uses lstat semantics, so a symlinked file is neither isFile() nor - * isDirectory() true together with isFile(); it must still be recorded (a symlinked artifact - * ships via git like any other file and must be claimed), so we recurse only on isDirectory() - * and treat everything else as a leaf. */ + * symlinks, excluding anything git ignores. fs.readdirSync uses lstat semantics, so a + * symlinked file is neither isFile() nor isDirectory() true together with isFile(); it must + * still be recorded (a symlinked artifact ships via git like any other file and must be + * claimed), so we recurse only on isDirectory() and treat everything else as a leaf. */ export function walkArtifacts(rootDir) { const found = []; const visit = (abs) => { @@ -27,7 +56,7 @@ export function walkArtifacts(rootDir) { const abs = path.join(rootDir, root); if (fs.existsSync(abs)) visit(abs); } - return found.sort(); + return filterGitIgnored(rootDir, found).sort(); } /** Compare the tree under rootDir against a parsed parity ledger. diff --git a/scripts/test/parity.test.mjs b/scripts/test/parity.test.mjs index 6b6f51b..349f6d9 100644 --- a/scripts/test/parity.test.mjs +++ b/scripts/test/parity.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; import { tmpRepo } from "./helpers.mjs"; import { parityViolations, walkArtifacts } from "./parity-helpers.mjs"; @@ -113,6 +114,41 @@ test("a walked root that does not exist is skipped rather than throwing", () => assert.deepEqual(parityViolations(dir, ledger), { unclaimed: [], doubleClaimed: [], missing: [] }); }); +/** Like fixtureRepo(), but a real git repository: `git init`, an identity (some git commands + * need one even without a commit), and a .gitignore. Used to exercise the git-ignore filtering + * in walkArtifacts() — fixtureRepo()'s plain temp dirs are NOT git repos, which is exactly the + * fail-open case those other fixture tests already cover. */ +function gitFixtureRepo(files, gitignore) { + const dir = fixtureRepo(files); + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["config", "--local", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "--local", "user.name", "Test"], { cwd: dir }); + if (gitignore) fs.writeFileSync(path.join(dir, ".gitignore"), gitignore); + return dir; +} + +test("walkArtifacts excludes a git-ignored file (e.g. a macOS .DS_Store under skills/)", () => { + const dir = gitFixtureRepo(["commands/status.md", "commands/.DS_Store"], ".DS_Store\n"); + assert.deepEqual(walkArtifacts(dir), ["commands/status.md"]); +}); + +test("walkArtifacts still returns an untracked, never-staged, not-ignored file — the anti-trap assertion", () => { + // This is the test that stops a future refactor from swapping `git check-ignore` for + // `git ls-files`: ls-files means "tracked", and this file is deliberately never staged. + // Filtering on tracked-ness instead of ignored-ness would make this file vanish from the + // walk, silently reintroducing the exact miss the parity gate exists to catch. + const dir = gitFixtureRepo(["commands/status.md"], ".DS_Store\n"); + fs.writeFileSync(path.join(dir, "commands", "brand-new.md"), "# fixture\n"); + assert.deepEqual(walkArtifacts(dir), ["commands/brand-new.md", "commands/status.md"]); +}); + +test("walkArtifacts fails open (returns everything) when rootDir is not a git repository", () => { + // fixtureRepo() dirs are plain temp dirs, not git repos — `git check-ignore` exits 128 + // ("not a git repository") there, and the fix must not throw or drop paths in that case. + const dir = fixtureRepo(["commands/status.md", "commands/.DS_Store"]); + assert.deepEqual(walkArtifacts(dir), ["commands/.DS_Store", "commands/status.md"]); +}); + // ───────────────── the gate: the real ledger against the real tree ───────────────── // fileURLToPath, not new URL(...).pathname — same convention as helpers.mjs, and correct for