From 93506d05cdefd6547afd95becd5a3bcbaf4ffab1 Mon Sep 17 00:00:00 2001 From: michael-wojcik <5386199+michael-wojcik@users.noreply.github.com> Date: Sat, 2 May 2026 01:36:20 -0400 Subject: [PATCH 1/2] Register watch-inbox + unwatch-inbox + prune-memory in plugin.json (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #603 shipped commands/watch-inbox.md and commands/unwatch-inbox.md without adding them to .claude-plugin/plugin.json's commands array; commands/prune-memory.md (from #493) had the same gap. Result: all three commands were non-discoverable as slash commands and non-invokable via the Skill tool, leaving the entire inbox-wake mechanism shipped in #591/#603 silently non-functional in the installed plugin. Append the three entries to plugin.json's commands array (15 total). Bump version 3.21.0 → 3.21.1 across the four version-tracked files (plugin.json, marketplace.json, root README.md, pact-plugin/README.md) and update test_inbox_wake_version_bump.py constants accordingly. Add pact-plugin/tests/test_plugin_manifest_parity.py with four set-membership invariants pinning manifest-vs-filesystem parity for both commands and agents in both directions. Counter-test-by-revert verified RED-on-revert for each. The invariant prevents this bug class from recurring: any future commands/*.md or agents/*.md added without manifest registration will fail CI before merge. --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- pact-plugin/.claude-plugin/plugin.json | 7 +- pact-plugin/README.md | 2 +- .../tests/test_inbox_wake_version_bump.py | 8 +-- .../tests/test_plugin_manifest_parity.py | 64 +++++++++++++++++++ 6 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 pact-plugin/tests/test_plugin_manifest_parity.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 52de8ec5..dda66352 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "PACT", "source": "./pact-plugin", "description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents", - "version": "3.21.0", + "version": "3.21.1", "author": { "name": "Synaptic-Labs-AI" }, diff --git a/README.md b/README.md index 87d619aa..3c4b8838 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ When installed as a plugin, PACT lives in your plugin cache: │ └── cache/ │ └── pact-plugin/ │ └── PACT/ -│ └── 3.21.0/ # Plugin version +│ └── 3.21.1/ # Plugin version │ ├── agents/ │ ├── commands/ │ ├── skills/ diff --git a/pact-plugin/.claude-plugin/plugin.json b/pact-plugin/.claude-plugin/plugin.json index 8127798e..62e3fafd 100644 --- a/pact-plugin/.claude-plugin/plugin.json +++ b/pact-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "PACT", - "version": "3.21.0", + "version": "3.21.1", "description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents", "author": { "name": "Synaptic-Labs-AI", @@ -36,7 +36,10 @@ "./commands/wrap-up.md", "./commands/pause.md", "./commands/pin-memory.md", - "./commands/telegram-setup.md" + "./commands/telegram-setup.md", + "./commands/watch-inbox.md", + "./commands/unwatch-inbox.md", + "./commands/prune-memory.md" ], "agents": [ "./agents/pact-preparer.md", diff --git a/pact-plugin/README.md b/pact-plugin/README.md index f64b658a..fabb6470 100644 --- a/pact-plugin/README.md +++ b/pact-plugin/README.md @@ -1,6 +1,6 @@ # PACT — Orchestration Harness for Claude Code -> **Version**: 3.21.0 +> **Version**: 3.21.1 Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically. diff --git a/pact-plugin/tests/test_inbox_wake_version_bump.py b/pact-plugin/tests/test_inbox_wake_version_bump.py index f6b633cd..caeef3ee 100644 --- a/pact-plugin/tests/test_inbox_wake_version_bump.py +++ b/pact-plugin/tests/test_inbox_wake_version_bump.py @@ -1,8 +1,8 @@ """ -Version-bump consistency invariants for the 3.21.0 release. +Version-bump consistency invariants for the current release. The plugin version is tracked in 4 files; all four must carry the same -version literal, with zero stale references to the prior 3.20.4. +version literal, with zero stale references to the prior version. """ import json @@ -11,8 +11,8 @@ import pytest REPO_ROOT = Path(__file__).resolve().parent.parent.parent -TARGET_VERSION = "3.21.0" -PRIOR_VERSION = "3.20.4" +TARGET_VERSION = "3.21.1" +PRIOR_VERSION = "3.21.0" # ---------- 4-file version invariants ---------- diff --git a/pact-plugin/tests/test_plugin_manifest_parity.py b/pact-plugin/tests/test_plugin_manifest_parity.py new file mode 100644 index 00000000..a931c6d0 --- /dev/null +++ b/pact-plugin/tests/test_plugin_manifest_parity.py @@ -0,0 +1,64 @@ +""" +Manifest-vs-filesystem parity invariants for pact-plugin/.claude-plugin/plugin.json. + +Pins set-membership symmetry between the `commands` and `agents` arrays in +plugin.json and the `*.md` files on disk under commands/ and agents/. +A command or agent file that ships without a manifest entry is non-discoverable +and non-invokable; a manifest entry without a backing file is a stale reference. +""" + +import json +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parent.parent +MANIFEST = PLUGIN_ROOT / ".claude-plugin" / "plugin.json" +COMMANDS_DIR = PLUGIN_ROOT / "commands" +AGENTS_DIR = PLUGIN_ROOT / "agents" + + +def _load_manifest() -> dict: + return json.loads(MANIFEST.read_text()) + + +def _on_disk(rel_dir: str, dir_path: Path) -> set[str]: + return {f"./{rel_dir}/{p.name}" for p in dir_path.glob("*.md")} + + +def test_every_command_md_file_is_registered(): + manifest = _load_manifest() + registered = set(manifest["commands"]) + on_disk = _on_disk("commands", COMMANDS_DIR) + missing = on_disk - registered + assert not missing, ( + f"Commands present on disk but missing from plugin.json: {sorted(missing)}" + ) + + +def test_no_stale_command_entries(): + manifest = _load_manifest() + registered = set(manifest["commands"]) + on_disk = _on_disk("commands", COMMANDS_DIR) + stale = registered - on_disk + assert not stale, ( + f"Commands registered in plugin.json without a backing file: {sorted(stale)}" + ) + + +def test_every_agent_md_file_is_registered(): + manifest = _load_manifest() + registered = set(manifest["agents"]) + on_disk = _on_disk("agents", AGENTS_DIR) + missing = on_disk - registered + assert not missing, ( + f"Agents present on disk but missing from plugin.json: {sorted(missing)}" + ) + + +def test_no_stale_agent_entries(): + manifest = _load_manifest() + registered = set(manifest["agents"]) + on_disk = _on_disk("agents", AGENTS_DIR) + stale = registered - on_disk + assert not stale, ( + f"Agents registered in plugin.json without a backing file: {sorted(stale)}" + ) From 4304f39e1d755580be043dc01345d836f71af7bb Mon Sep 17 00:00:00 2001 From: michael-wojcik <5386199+michael-wojcik@users.noreply.github.com> Date: Sat, 2 May 2026 01:57:35 -0400 Subject: [PATCH 2/2] Address peer review M1+F3+F4 (#608/#609) Remediation for three reviewer follow-ups accepted on PR #609. M1 (3-way reviewer convergence): rename test_inbox_wake_version_bump.py to test_plugin_version_bump.py. The file guards a generic 4-file plugin-version-consistency invariant; the inbox_wake_ provenance prefix dated to #603 and no longer reflects the test's scope. F3 (3-way convergence): add test_every_skill_subdir_has_skill_md to test_plugin_manifest_parity.py. Skills register via a directory pointer ("./skills/") in plugin.json, so a subdir without a SKILL.md ships silently non-discoverable rather than being caught by the array-based parity invariants. Counter-test-by-revert: renaming skills/orchestration/SKILL.md fails only the new test (cardinality 1). F4 (test-engineer): add test_agent_frontmatter_name_matches_filename to the same file plus a small regex-based _frontmatter_name helper. Claude Code resolves agents by frontmatter name; the filename-stem parity test passes when frontmatter and filename diverge. The new invariant pins them in lockstep. Counter-test-by-revert: mutating pact-architect.md frontmatter name fails only the new test (cardinality 1). No PyYAML dependency added. 14 manifest-parity + plugin-version-bump tests pass. Full suite 7212 pass + 1 pre-existing unrelated failure (skills/orchestration/SKILL.md exceeds 600 lines, covered by #594). --- .../tests/test_plugin_manifest_parity.py | 63 +++++++++++++++++++ ...on_bump.py => test_plugin_version_bump.py} | 0 2 files changed, 63 insertions(+) rename pact-plugin/tests/{test_inbox_wake_version_bump.py => test_plugin_version_bump.py} (100%) diff --git a/pact-plugin/tests/test_plugin_manifest_parity.py b/pact-plugin/tests/test_plugin_manifest_parity.py index a931c6d0..fb4d73e6 100644 --- a/pact-plugin/tests/test_plugin_manifest_parity.py +++ b/pact-plugin/tests/test_plugin_manifest_parity.py @@ -5,15 +5,41 @@ plugin.json and the `*.md` files on disk under commands/ and agents/. A command or agent file that ships without a manifest entry is non-discoverable and non-invokable; a manifest entry without a backing file is a stale reference. + +Also pins: +- every `skills//` ships a `SKILL.md` (skills register via directory + pointer in plugin.json, so a missing SKILL.md is silent non-discoverability) +- every `agents/*.md` frontmatter `name:` equals the filename stem (Claude Code + resolves agents by frontmatter name, not filename) """ import json +import re from pathlib import Path PLUGIN_ROOT = Path(__file__).resolve().parent.parent MANIFEST = PLUGIN_ROOT / ".claude-plugin" / "plugin.json" COMMANDS_DIR = PLUGIN_ROOT / "commands" AGENTS_DIR = PLUGIN_ROOT / "agents" +SKILLS_DIR = PLUGIN_ROOT / "skills" + +_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) +_NAME_LINE_RE = re.compile(r"^name:\s*(.+?)\s*$", re.MULTILINE) + + +def _frontmatter_name(md_path: Path) -> str | None: + """Extract the `name:` field from a markdown file's YAML frontmatter. + + Returns None if no frontmatter block or no name field is found. + """ + text = md_path.read_text(encoding="utf-8") + fm = _FRONTMATTER_RE.match(text) + if not fm: + return None + name_match = _NAME_LINE_RE.search(fm.group(1)) + if not name_match: + return None + return name_match.group(1).strip().strip('"').strip("'") def _load_manifest() -> dict: @@ -62,3 +88,40 @@ def test_no_stale_agent_entries(): assert not stale, ( f"Agents registered in plugin.json without a backing file: {sorted(stale)}" ) + + +def test_every_skill_subdir_has_skill_md(): + """Every `skills//` must contain a `SKILL.md` file. + + Skills register via directory pointer (`"skills": "./skills/"` in + plugin.json), so a subdirectory without a `SKILL.md` is silently + non-discoverable rather than caught by manifest-array parity. + """ + missing = sorted( + f"./skills/{d.name}/" + for d in SKILLS_DIR.iterdir() + if d.is_dir() and not (d / "SKILL.md").is_file() + ) + assert not missing, ( + f"Skill subdirectories without a SKILL.md: {missing}" + ) + + +def test_agent_frontmatter_name_matches_filename(): + """Every `agents/*.md` frontmatter `name:` field must equal the filename stem. + + Claude Code resolves agents by frontmatter `name:`, not filename. A divergence + means dispatching the agent by its filename-derived id silently routes to a + different identifier than the file's declared name. + """ + mismatches = [] + for md in sorted(AGENTS_DIR.glob("*.md")): + declared = _frontmatter_name(md) + if declared != md.stem: + mismatches.append( + f"{md.name}: frontmatter name={declared!r}, filename stem={md.stem!r}" + ) + assert not mismatches, ( + "Agent frontmatter `name:` diverges from filename stem:\n " + + "\n ".join(mismatches) + ) diff --git a/pact-plugin/tests/test_inbox_wake_version_bump.py b/pact-plugin/tests/test_plugin_version_bump.py similarity index 100% rename from pact-plugin/tests/test_inbox_wake_version_bump.py rename to pact-plugin/tests/test_plugin_version_bump.py