From 539658b3d728b5b5045a69897637413926f9ca9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 23:59:47 +0000 Subject: [PATCH 1/4] build: reproducible dev/test infra + one-command repo check + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root pyproject was still the `uv init` stub and pytest — required by every plugin's test suite — was undeclared, so the tests only ran after a manual install. Bring the root up to the standard review-diff already sets: - declare a `dev` dependency-group (pytest, ruff) and `package = false` - add `[tool.pytest.ini_options]` so `uv run --group dev pytest` discovers every plugin's suite from the repo root - add `scripts/check_all.py`: a single entrypoint that runs the marketplace version check, the shared-artifact sync check, and the full test suite, reporting a combined pass/fail - add a CI workflow running check_all plus review-diff's 100% coverage gate Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LCdtWgMHgeoqErt6s2NR3i --- .github/workflows/ci.yml | 28 +++++++++++ pyproject.toml | 20 +++++++- scripts/check_all.py | 55 +++++++++++++++++++++ uv.lock | 101 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/check_all.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ba2848 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + checks: + name: Repo-wide checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 + - name: Run version, shared-sync, and test checks + run: uv run --group dev python scripts/check_all.py + + review-diff-coverage: + name: review-diff coverage gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 + - name: Run review-diff suite with its 100% coverage gate + working-directory: plugins/review-diff + run: uv run --group dev pytest diff --git a/pyproject.toml b/pyproject.toml index dee1076..b306a01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,25 @@ [project] name = "claude-plugins" version = "0.1.0" -description = "Add your description here" +description = "Claude Code plugin marketplace (oliphant-plugins) and its dev/CI tooling." readme = "README.md" requires-python = ">=3.11" dependencies = [] + +[tool.uv] +package = false + +[dependency-groups] +# Repo-wide dev tooling. Individual plugins that need more (e.g. review-diff's +# coverage gate) declare their own dependency-group in their own pyproject.toml. +dev = ["pytest>=8", "ruff>=0.6"] + +[tool.pytest.ini_options] +# `uv run --group dev pytest` from the repo root discovers every plugin's tests. +# review-diff's coverage-gated config lives in its own pyproject and only applies +# when pytest is invoked from that directory; at the repo root those tests still +# run (without the coverage gate), so nothing is excluded here. +testpaths = ["plugins"] + +[tool.ruff] +line-length = 100 diff --git a/scripts/check_all.py b/scripts/check_all.py new file mode 100644 index 0000000..2ed8452 --- /dev/null +++ b/scripts/check_all.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# ABOUTME: One-command repo health check: version sync, shared-artifact sync, and tests. +# ABOUTME: The single entrypoint CI and contributors run before publishing. +"""Run every repo-wide guard in one place and report a combined result. + +Checks, in order: + 1. ``check_marketplace_versions.py`` — marketplace.json matches each plugin.json + 2. ``sync_shared.py --check`` — generated shared copies match their canonical source + 3. ``pytest`` — every plugin's test suite + +Usage: + python scripts/check_all.py # run all checks, exit non-zero on any failure + uv run --group dev python scripts/check_all.py + +Each check runs even if an earlier one fails, so a single run surfaces every +problem at once. Exit code is 0 only when all checks pass. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# (label, argv). argv[0] is always the current interpreter for portability. +CHECKS: list[tuple[str, list[str]]] = [ + ("marketplace versions", [sys.executable, "scripts/check_marketplace_versions.py"]), + ("shared-artifact sync", [sys.executable, "scripts/sync_shared.py", "--check"]), + ("test suite", [sys.executable, "-m", "pytest", "-q"]), +] + + +def main() -> int: + results: list[tuple[str, bool]] = [] + for label, argv in CHECKS: + print(f"\n=== {label} ===", flush=True) + completed = subprocess.run(argv, cwd=REPO_ROOT) + results.append((label, completed.returncode == 0)) + + print("\n=== summary ===") + for label, ok in results: + print(f" {'✓' if ok else '✗'} {label}") + + failed = [label for label, ok in results if not ok] + if failed: + print(f"\n{len(failed)} check(s) failed: {', '.join(failed)}") + return 1 + print("\nAll checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 47331f1..7bc1c84 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,108 @@ version = 1 +revision = 3 requires-python = ">=3.11" [[package]] name = "claude-plugins" version = "0.1.0" source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" }, + { url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" }, + { url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" }, + { url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" }, + { url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" }, + { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" }, +] From d527691d99cfc77e69e85b8069fe2b95fce86ad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:00:55 +0000 Subject: [PATCH 2/4] refactor: move trigger-eval-workspace out of plugins/ into evals/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins/trigger-eval-workspace/ was not a plugin — it holds skill-trigger eval datasets (query → should_trigger fixtures), is not registered in marketplace.json, and is referenced by nothing. Keeping it under plugins/ (the directory meant to hold only shippable plugins) was misleading. Move the nine *-eval.json files to a top-level evals/ directory with a README explaining the format, so plugins/ now contains exactly the nine registered plugins. Update CLAUDE.md's structure tree to match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LCdtWgMHgeoqErt6s2NR3i --- CLAUDE.md | 2 ++ evals/README.md | 26 +++++++++++++++++++ .../bdd-generate-eval.json | 0 .../bdd-spec-eval.json | 0 .../beads-workflow-eval.json | 0 .../compound-capture-eval.json | 0 .../compound-retrieve-eval.json | 0 .../hexagonal-agents-eval.json | 0 .../mochi-creator-eval.json | 0 .../tdd-workflow-eval.json | 0 .../verification-stack-eval.json | 0 11 files changed, 28 insertions(+) create mode 100644 evals/README.md rename {plugins/trigger-eval-workspace => evals}/bdd-generate-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/bdd-spec-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/beads-workflow-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/compound-capture-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/compound-retrieve-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/hexagonal-agents-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/mochi-creator-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/tdd-workflow-eval.json (100%) rename {plugins/trigger-eval-workspace => evals}/verification-stack-eval.json (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 66fe05d..8e8d36c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,9 +23,11 @@ claude-plugins/ │ ├── review-diff/ # Local web-based diff review fed back to Claude Code │ └── stick-shift/ # Manually-driven ("disassembled") SDLC via slash commands ├── scripts/ +│ ├── check_all.py # One-command repo health check (versions + sync + tests) │ ├── check_marketplace_versions.py # Asserts marketplace.json matches each plugin.json │ ├── sync_shared.py # Asserts/regenerates per-plugin copies of shared artifacts │ └── shared/ # Canonical sources for artifacts duplicated across plugins +├── evals/ # Skill-trigger eval datasets (fixtures, not a plugin) ├── ai_docs/ # Background research/reference docs (not shipped in plugins) ├── docs/ # Planning and specifications ├── README.md # Marketplace installation instructions diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..9b4e55f --- /dev/null +++ b/evals/README.md @@ -0,0 +1,26 @@ +# evals/ + +Skill-trigger eval datasets — **not** a plugin, which is why they live here and +not under `plugins/`. + +Each `*-eval.json` file is a list of `{ "query": ..., "should_trigger": bool }` +cases for one skill or workflow. They check that a skill's description fires on +the requests it should and stays quiet on the ones it shouldn't: + +```json +[ + {"query": "help me create Mochi flashcards for the key concepts", "should_trigger": true}, + {"query": "write documentation for our API endpoints", "should_trigger": false} +] +``` + +| File | Covers | +|---|---| +| `mochi-creator-eval.json` | mochi-creator card creation | +| `compound-capture-eval.json` / `compound-retrieve-eval.json` | compound-knowledge capture / retrieve | +| `hexagonal-agents-eval.json` | hexagonal-agents scaffolding | +| `bdd-spec-eval.json` / `bdd-generate-eval.json` / `tdd-workflow-eval.json` / `beads-workflow-eval.json` | autonomous-sdlc workflows | +| `verification-stack-eval.json` | observability-harness verification | + +These are fixtures for evaluating skill-description quality; they are not shipped +inside any plugin. diff --git a/plugins/trigger-eval-workspace/bdd-generate-eval.json b/evals/bdd-generate-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/bdd-generate-eval.json rename to evals/bdd-generate-eval.json diff --git a/plugins/trigger-eval-workspace/bdd-spec-eval.json b/evals/bdd-spec-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/bdd-spec-eval.json rename to evals/bdd-spec-eval.json diff --git a/plugins/trigger-eval-workspace/beads-workflow-eval.json b/evals/beads-workflow-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/beads-workflow-eval.json rename to evals/beads-workflow-eval.json diff --git a/plugins/trigger-eval-workspace/compound-capture-eval.json b/evals/compound-capture-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/compound-capture-eval.json rename to evals/compound-capture-eval.json diff --git a/plugins/trigger-eval-workspace/compound-retrieve-eval.json b/evals/compound-retrieve-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/compound-retrieve-eval.json rename to evals/compound-retrieve-eval.json diff --git a/plugins/trigger-eval-workspace/hexagonal-agents-eval.json b/evals/hexagonal-agents-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/hexagonal-agents-eval.json rename to evals/hexagonal-agents-eval.json diff --git a/plugins/trigger-eval-workspace/mochi-creator-eval.json b/evals/mochi-creator-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/mochi-creator-eval.json rename to evals/mochi-creator-eval.json diff --git a/plugins/trigger-eval-workspace/tdd-workflow-eval.json b/evals/tdd-workflow-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/tdd-workflow-eval.json rename to evals/tdd-workflow-eval.json diff --git a/plugins/trigger-eval-workspace/verification-stack-eval.json b/evals/verification-stack-eval.json similarity index 100% rename from plugins/trigger-eval-workspace/verification-stack-eval.json rename to evals/verification-stack-eval.json From 9ff0857f5d20825eedfc695d77e9657929724a92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:02:20 +0000 Subject: [PATCH 3/4] feat: make marketplace check bidirectional (catch unregistered/stray dirs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_marketplace_versions.py only validated marketplace entries against their plugin.json — it could not catch the inverse: a plugin directory added but never registered in the catalog, or a non-plugin directory living under plugins/ (the exact problem trigger-eval-workspace had). Both slipped through. Add a reverse pass over plugins/* that fails when a directory has no marketplace entry, distinguishing "unregistered plugin" (has plugin.json) from "not a plugin" (no plugin.json, belongs elsewhere). Document the now-bidirectional contract in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LCdtWgMHgeoqErt6s2NR3i --- CLAUDE.md | 7 ++++-- scripts/check_marketplace_versions.py | 34 ++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e8d36c..3e5ff56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ claude-plugins/ │ └── stick-shift/ # Manually-driven ("disassembled") SDLC via slash commands ├── scripts/ │ ├── check_all.py # One-command repo health check (versions + sync + tests) -│ ├── check_marketplace_versions.py # Asserts marketplace.json matches each plugin.json +│ ├── check_marketplace_versions.py # Asserts marketplace.json ⇄ plugin.json (versions + registration) │ ├── sync_shared.py # Asserts/regenerates per-plugin copies of shared artifacts │ └── shared/ # Canonical sources for artifacts duplicated across plugins ├── evals/ # Skill-trigger eval datasets (fixtures, not a plugin) @@ -179,7 +179,10 @@ When changing a plugin: 1. Bump the version in `plugins/{name}/.claude-plugin/plugin.json` 2. Copy that version into the matching entry in `.claude-plugin/marketplace.json` 3. Only bump `metadata.version` if the marketplace itself changed (plugin added/removed) -4. Run `python scripts/check_marketplace_versions.py` to confirm they match +4. Run `python scripts/check_marketplace_versions.py` to confirm they match. This + check is bidirectional: it also fails if a directory under `plugins/` has no + marketplace entry, or if a non-plugin directory (no `.claude-plugin/plugin.json`) + is sitting under `plugins/`. ## Plugin Installation diff --git a/scripts/check_marketplace_versions.py b/scripts/check_marketplace_versions.py index 7d225a8..f9cd1b2 100644 --- a/scripts/check_marketplace_versions.py +++ b/scripts/check_marketplace_versions.py @@ -20,11 +20,23 @@ MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json" +def _plugin_dirs() -> list[Path]: + """Direct children of plugins/ that aren't dotdirs or caches.""" + plugins_dir = REPO_ROOT / "plugins" + return sorted( + child + for child in plugins_dir.iterdir() + if child.is_dir() and not child.name.startswith(".") and child.name != "__pycache__" + ) + + def main() -> int: marketplace = json.loads(MARKETPLACE.read_text()) mismatches: list[str] = [] missing: list[str] = [] + registered = {entry["name"] for entry in marketplace.get("plugins", [])} + for entry in marketplace.get("plugins", []): name = entry["name"] catalog_version = entry.get("version") @@ -40,6 +52,18 @@ def main() -> int: f"{name}: plugin.json={source_version} != marketplace.json={catalog_version}" ) + # Reverse direction: every directory under plugins/ must be a registered + # plugin. Catches both a plugin that was added but never registered, and a + # non-plugin directory that doesn't belong under plugins/ at all. + unregistered: list[str] = [] + non_plugin: list[str] = [] + for child in _plugin_dirs(): + has_manifest = (child / ".claude-plugin" / "plugin.json").exists() + if not has_manifest: + non_plugin.append(child.name) + elif child.name not in registered: + unregistered.append(child.name) + if missing: print("Missing plugin.json files:") for line in missing: @@ -48,8 +72,16 @@ def main() -> int: print("Version drift (plugin.json is source of truth — update marketplace.json):") for line in mismatches: print(f" - {line}") + if unregistered: + print("Plugins not registered in marketplace.json (add a catalog entry):") + for name in unregistered: + print(f" - {name}") + if non_plugin: + print("Directories under plugins/ that aren't plugins (move them out of plugins/):") + for name in non_plugin: + print(f" - {name} (no .claude-plugin/plugin.json)") - if missing or mismatches: + if missing or mismatches or unregistered or non_plugin: return 1 print(f"OK: {len(marketplace.get('plugins', []))} plugin versions in sync.") From b945b3cd9f5fa531361c6fb9ff8895f8ba4c7944 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:07:03 +0000 Subject: [PATCH 4/4] refactor: extract shared .local.md config loader from the two resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compound-knowledge's resolve_paths.py and understand's resolve_config.py each carried an identical copy of the `.claude/.local.md` reader: the same _KV regex, _parse_config, _find_config (project>user>default precedence), and argv handling. Two copies of one parser meant two places to fix a config-format bug. Extract that scaffolding into a canonical scripts/shared/config_loader.py (parse_config / find_config / cli_roots), synced byte-identically into each plugin's scripts/ via sync_shared.py — the same mechanism already used for feedback_manager.py. Each resolver keeps only its own typing/defaults/path normalization and calls the shared loader. Both still work run-as-script (sibling import via sys.path[0]) and under pytest. Bump compound-knowledge 0.8.2 -> 0.8.3 and understand 0.1.1 -> 0.1.2, sync marketplace.json, and document the new shared artifact in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LCdtWgMHgeoqErt6s2NR3i --- .claude-plugin/marketplace.json | 4 +- CLAUDE.md | 7 +-- .../.claude-plugin/plugin.json | 2 +- .../scripts/config_loader.py | 48 +++++++++++++++++++ .../scripts/resolve_paths.py | 32 ++----------- plugins/understand/.claude-plugin/plugin.json | 2 +- plugins/understand/scripts/config_loader.py | 48 +++++++++++++++++++ plugins/understand/scripts/resolve_config.py | 32 ++----------- scripts/shared/config_loader.py | 48 +++++++++++++++++++ scripts/sync_shared.py | 8 ++++ 10 files changed, 168 insertions(+), 63 deletions(-) create mode 100644 plugins/compound-knowledge/scripts/config_loader.py create mode 100644 plugins/understand/scripts/config_loader.py create mode 100644 scripts/shared/config_loader.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6628e29..331ffad 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -71,7 +71,7 @@ "name": "compound-knowledge", "source": "./plugins/compound-knowledge", "description": "Capture solved problems, retrieve past solutions, and graduate accumulated knowledge into living context. Skills: compound-capture, compound-retrieve, compound-graduate.", - "version": "0.8.2", + "version": "0.8.3", "author": { "name": "Joshua Oliphant" }, @@ -128,7 +128,7 @@ "name": "understand", "source": "./plugins/understand", "description": "Process information for real understanding with the explain-back skill: explain from memory, grade against the real source, struggle-then-teach per gap, output Mochi cards and a resumable session record. An antidote to the illusion of clarity.", - "version": "0.1.1", + "version": "0.1.2", "author": { "name": "Joshua Oliphant" }, diff --git a/CLAUDE.md b/CLAUDE.md index 3e5ff56..09efb55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,9 +131,10 @@ python scripts/check_marketplace_versions.py # exits non-zero on any drift ### Shared-source sync (generate from one source) Some artifacts must be physically present in multiple self-contained plugins but should never -diverge — e.g. `feedback_manager.py` (shipped by 5 plugins) and `prompt_design_principles.md` -(shared by `mochi-creator` and `understand`). The canonical copy lives under `scripts/shared/`; -each plugin's copy is generated from it. +diverge — e.g. `feedback_manager.py` (shipped by 5 plugins), `config_loader.py` (the +`.claude/.local.md` reader shared by `compound-knowledge` and `understand`), and +`prompt_design_principles.md` (shared by `mochi-creator` and `understand`). The canonical copy +lives under `scripts/shared/`; each plugin's copy is generated from it. ```bash python scripts/sync_shared.py # check for drift (exits non-zero on mismatch) diff --git a/plugins/compound-knowledge/.claude-plugin/plugin.json b/plugins/compound-knowledge/.claude-plugin/plugin.json index 3b818ed..b8164e2 100644 --- a/plugins/compound-knowledge/.claude-plugin/plugin.json +++ b/plugins/compound-knowledge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "compound-knowledge", - "version": "0.8.2", + "version": "0.8.3", "description": "Capture solved problems, retrieve past solutions, and graduate accumulated knowledge into living context. Skills: compound-capture (post-solve documentation), compound-retrieve (pre-work knowledge search), compound-graduate (promote lessons into CLAUDE.md/AGENTS.md).", "license": "MIT", "author": { diff --git a/plugins/compound-knowledge/scripts/config_loader.py b/plugins/compound-knowledge/scripts/config_loader.py new file mode 100644 index 0000000..71af6c6 --- /dev/null +++ b/plugins/compound-knowledge/scripts/config_loader.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# ABOUTME: Shared loader for a plugin's `.claude/.local.md` key:value config. +# ABOUTME: Canonical source — synced into each plugin's scripts/ via scripts/sync_shared.py. +"""Read `key: value` lines from a plugin's `.claude/.local.md` config file. + +Resolution order is project-level (`/.claude/.local.md`) then +user-level (`/.claude/.local.md`), else empty. Callers layer their +own typing, defaults, and path normalization on top of the raw dict returned +here — this module only owns the file format and the project>user>default +precedence, which every consuming plugin shares. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_KV = re.compile(r"^([a-z_]+)\s*:\s*(.+)$") + + +def parse_config(path: Path) -> dict: + """Parse `key: value` lines from a .local.md config file (missing file → {}).""" + settings: dict = {} + if not path.exists(): + return settings + for line in path.read_text(encoding="utf-8").split("\n"): + match = _KV.match(line.strip()) + if match: + settings[match.group(1)] = match.group(2).strip() + return settings + + +def find_config(project_root: Path, home: Path, config_name: str) -> tuple[dict, str]: + """Return (settings, source) using project-level then user-level config.""" + project_cfg = project_root / ".claude" / config_name + if project_cfg.exists(): + return parse_config(project_cfg), "project" + user_cfg = home / ".claude" / config_name + if user_cfg.exists(): + return parse_config(user_cfg), "user" + return {}, "default" + + +def cli_roots(argv: list) -> tuple[Path, Path]: + """Resolve (project_root, home) from argv[1:] for the standard resolver CLI.""" + project_root = Path(argv[1]).expanduser().resolve() if len(argv) > 1 else Path.cwd() + home = Path(argv[2]).expanduser().resolve() if len(argv) > 2 else Path.home() + return project_root, home diff --git a/plugins/compound-knowledge/scripts/resolve_paths.py b/plugins/compound-knowledge/scripts/resolve_paths.py index b770104..e09c6db 100755 --- a/plugins/compound-knowledge/scripts/resolve_paths.py +++ b/plugins/compound-knowledge/scripts/resolve_paths.py @@ -5,35 +5,12 @@ """ import json -import re import sys from pathlib import Path -CONFIG_NAME = "compound-knowledge.local.md" -_KV = re.compile(r"^([a-z_]+)\s*:\s*(.+)$") - - -def _parse_config(path: Path) -> dict: - """Parse `key: value` lines from a .local.md config file.""" - settings: dict = {} - if not path.exists(): - return settings - for line in path.read_text(encoding="utf-8").split("\n"): - match = _KV.match(line.strip()) - if match: - settings[match.group(1)] = match.group(2).strip() - return settings +import config_loader - -def _find_config(project_root: Path, home: Path): - """Return (settings, source) using project-level then user-level config.""" - project_cfg = project_root / ".claude" / CONFIG_NAME - if project_cfg.exists(): - return _parse_config(project_cfg), "project" - user_cfg = home / ".claude" / CONFIG_NAME - if user_cfg.exists(): - return _parse_config(user_cfg), "user" - return {}, "default" +CONFIG_NAME = "compound-knowledge.local.md" def _norm(path: str) -> str: @@ -43,7 +20,7 @@ def _norm(path: str) -> str: def resolve(project_root: Path, home: Path) -> dict: """Resolve write_path, read_paths, and vault_root from config or defaults.""" - settings, source = _find_config(project_root, home) + settings, source = config_loader.find_config(project_root, home, CONFIG_NAME) # write_path: explicit > solutions_path alias > default if "write_path" in settings: @@ -73,8 +50,7 @@ def resolve(project_root: Path, home: Path) -> dict: def main(argv: list) -> int: - project_root = Path(argv[1]).expanduser().resolve() if len(argv) > 1 else Path.cwd() - home = Path(argv[2]).expanduser().resolve() if len(argv) > 2 else Path.home() + project_root, home = config_loader.cli_roots(argv) print(json.dumps(resolve(project_root, home), indent=2)) return 0 diff --git a/plugins/understand/.claude-plugin/plugin.json b/plugins/understand/.claude-plugin/plugin.json index bbf2dba..e6a2def 100644 --- a/plugins/understand/.claude-plugin/plugin.json +++ b/plugins/understand/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "understand", - "version": "0.1.1", + "version": "0.1.2", "description": "Process information for real understanding. The explain-back skill makes you explain from memory, grades against the real source, teaches only after you attempt, and outputs Mochi cards plus a resumable session record. An antidote to the illusion of clarity.", "license": "MIT", "author": { diff --git a/plugins/understand/scripts/config_loader.py b/plugins/understand/scripts/config_loader.py new file mode 100644 index 0000000..71af6c6 --- /dev/null +++ b/plugins/understand/scripts/config_loader.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# ABOUTME: Shared loader for a plugin's `.claude/.local.md` key:value config. +# ABOUTME: Canonical source — synced into each plugin's scripts/ via scripts/sync_shared.py. +"""Read `key: value` lines from a plugin's `.claude/.local.md` config file. + +Resolution order is project-level (`/.claude/.local.md`) then +user-level (`/.claude/.local.md`), else empty. Callers layer their +own typing, defaults, and path normalization on top of the raw dict returned +here — this module only owns the file format and the project>user>default +precedence, which every consuming plugin shares. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_KV = re.compile(r"^([a-z_]+)\s*:\s*(.+)$") + + +def parse_config(path: Path) -> dict: + """Parse `key: value` lines from a .local.md config file (missing file → {}).""" + settings: dict = {} + if not path.exists(): + return settings + for line in path.read_text(encoding="utf-8").split("\n"): + match = _KV.match(line.strip()) + if match: + settings[match.group(1)] = match.group(2).strip() + return settings + + +def find_config(project_root: Path, home: Path, config_name: str) -> tuple[dict, str]: + """Return (settings, source) using project-level then user-level config.""" + project_cfg = project_root / ".claude" / config_name + if project_cfg.exists(): + return parse_config(project_cfg), "project" + user_cfg = home / ".claude" / config_name + if user_cfg.exists(): + return parse_config(user_cfg), "user" + return {}, "default" + + +def cli_roots(argv: list) -> tuple[Path, Path]: + """Resolve (project_root, home) from argv[1:] for the standard resolver CLI.""" + project_root = Path(argv[1]).expanduser().resolve() if len(argv) > 1 else Path.cwd() + home = Path(argv[2]).expanduser().resolve() if len(argv) > 2 else Path.home() + return project_root, home diff --git a/plugins/understand/scripts/resolve_config.py b/plugins/understand/scripts/resolve_config.py index b68c3f1..9858968 100755 --- a/plugins/understand/scripts/resolve_config.py +++ b/plugins/understand/scripts/resolve_config.py @@ -5,12 +5,12 @@ """ import json -import re import sys from pathlib import Path +import config_loader + CONFIG_NAME = "understand.local.md" -_KV = re.compile(r"^([a-z_]+)\s*:\s*(.+)$") DEFAULTS = { "mochi_deck": "", @@ -21,29 +21,6 @@ } -def _parse_config(path: Path) -> dict: - """Parse `key: value` lines from a .local.md config file.""" - settings: dict = {} - if not path.exists(): - return settings - for line in path.read_text(encoding="utf-8").split("\n"): - match = _KV.match(line.strip()) - if match: - settings[match.group(1)] = match.group(2).strip() - return settings - - -def _find_config(project_root: Path, home: Path): - """Return (settings, source) using project-level then user-level config.""" - project_cfg = project_root / ".claude" / CONFIG_NAME - if project_cfg.exists(): - return _parse_config(project_cfg), "project" - user_cfg = home / ".claude" / CONFIG_NAME - if user_cfg.exists(): - return _parse_config(user_cfg), "user" - return {}, "default" - - def _as_bool(value: str) -> bool: """Interpret a config string as a boolean.""" return value.strip().lower() in ("true", "yes", "1", "on") @@ -51,7 +28,7 @@ def _as_bool(value: str) -> bool: def resolve(project_root: Path, home: Path) -> dict: """Resolve understand-plugin settings from config or defaults.""" - settings, source = _find_config(project_root, home) + settings, source = config_loader.find_config(project_root, home, CONFIG_NAME) merged = dict(DEFAULTS) merged.update({k: v for k, v in settings.items() if k in DEFAULTS}) @@ -77,8 +54,7 @@ def resolve(project_root: Path, home: Path) -> dict: def main(argv: list) -> int: - project_root = Path(argv[1]).expanduser().resolve() if len(argv) > 1 else Path.cwd() - home = Path(argv[2]).expanduser().resolve() if len(argv) > 2 else Path.home() + project_root, home = config_loader.cli_roots(argv) print(json.dumps(resolve(project_root, home), indent=2)) return 0 diff --git a/scripts/shared/config_loader.py b/scripts/shared/config_loader.py new file mode 100644 index 0000000..71af6c6 --- /dev/null +++ b/scripts/shared/config_loader.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# ABOUTME: Shared loader for a plugin's `.claude/.local.md` key:value config. +# ABOUTME: Canonical source — synced into each plugin's scripts/ via scripts/sync_shared.py. +"""Read `key: value` lines from a plugin's `.claude/.local.md` config file. + +Resolution order is project-level (`/.claude/.local.md`) then +user-level (`/.claude/.local.md`), else empty. Callers layer their +own typing, defaults, and path normalization on top of the raw dict returned +here — this module only owns the file format and the project>user>default +precedence, which every consuming plugin shares. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_KV = re.compile(r"^([a-z_]+)\s*:\s*(.+)$") + + +def parse_config(path: Path) -> dict: + """Parse `key: value` lines from a .local.md config file (missing file → {}).""" + settings: dict = {} + if not path.exists(): + return settings + for line in path.read_text(encoding="utf-8").split("\n"): + match = _KV.match(line.strip()) + if match: + settings[match.group(1)] = match.group(2).strip() + return settings + + +def find_config(project_root: Path, home: Path, config_name: str) -> tuple[dict, str]: + """Return (settings, source) using project-level then user-level config.""" + project_cfg = project_root / ".claude" / config_name + if project_cfg.exists(): + return parse_config(project_cfg), "project" + user_cfg = home / ".claude" / config_name + if user_cfg.exists(): + return parse_config(user_cfg), "user" + return {}, "default" + + +def cli_roots(argv: list) -> tuple[Path, Path]: + """Resolve (project_root, home) from argv[1:] for the standard resolver CLI.""" + project_root = Path(argv[1]).expanduser().resolve() if len(argv) > 1 else Path.cwd() + home = Path(argv[2]).expanduser().resolve() if len(argv) > 2 else Path.home() + return project_root, home diff --git a/scripts/sync_shared.py b/scripts/sync_shared.py index d8015cd..f5743ee 100755 --- a/scripts/sync_shared.py +++ b/scripts/sync_shared.py @@ -40,6 +40,14 @@ "plugins/mochi-creator/scripts/feedback_manager.py", ], ), + ( + "config_loader.py", + "scripts/shared/config_loader.py", + [ + "plugins/compound-knowledge/scripts/config_loader.py", + "plugins/understand/scripts/config_loader.py", + ], + ), ( "prompt_design_principles.md", "scripts/shared/prompt_design_principles.md",