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/CLAUDE.md b/CLAUDE.md index 5b3f46d..8ed4e78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,15 @@ (`.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. 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`. - **State-transition flags are not pure functions of current state.** `reconcileNeeded` in 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/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/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`. 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. diff --git a/scripts/test/parity-helpers.mjs b/scripts/test/parity-helpers.mjs new file mode 100644 index 0000000..a05951b --- /dev/null +++ b/scripts/test/parity-helpers.mjs @@ -0,0 +1,78 @@ +// 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"; +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. + * 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, 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) => { + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const child = path.join(abs, entry.name); + if (entry.isDirectory()) visit(child); + else 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 filterGitIgnored(rootDir, 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..349f6d9 --- /dev/null +++ b/scripts/test/parity.test.mjs @@ -0,0 +1,191 @@ +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 { execFileSync } from "node:child_process"; +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("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 = { + 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: [] }); +}); + +/** 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 +// 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 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(", ")}`); +}); + +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(", ")}`); +});