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_plugin_manifest_parity.py b/pact-plugin/tests/test_plugin_manifest_parity.py new file mode 100644 index 00000000..fb4d73e6 --- /dev/null +++ b/pact-plugin/tests/test_plugin_manifest_parity.py @@ -0,0 +1,127 @@ +""" +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. + +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: + 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)}" + ) + + +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 91% rename from pact-plugin/tests/test_inbox_wake_version_bump.py rename to pact-plugin/tests/test_plugin_version_bump.py index f6b633cd..caeef3ee 100644 --- a/pact-plugin/tests/test_inbox_wake_version_bump.py +++ b/pact-plugin/tests/test_plugin_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 ----------