feat(manifests): generate all eight host manifests from one source, with per-host version exemptions stated as data - #149
Conversation
Each agent host hardcodes its own manifest path, so the .*-plugin/ directories cannot
be collapsed — that is imposed by the hosts. What was collapsible is the maintenance:
8 manifests across 5 hosts, ~90% identical, hand-copied.
That had already rotted. Measured before this change:
.claude-plugin/plugin.json 3.7.0
.qoder-plugin/plugin.json 3.7.0
.codebuddy-plugin/plugin.json 3.7.0
.codex-plugin/plugin.json 0.11.0+codex.20260707001844 <- 3 weeks stale
.qoder-plugin/marketplace.json == .codebuddy-plugin/marketplace.json (byte-identical)
.claude-plugin/marketplace.json differs from .qoder-plugin's by one $schema line
The release discipline covers two of the eight files; the rest had no owner. And
validate.py's checks 2b/2c compare versions across siblings but enumerate only
.qoder-plugin and .codebuddy-plugin BY HAND — codex was never on that list, which is
exactly why it drifted while validation stayed green. The success message had the same
omission ("Claude + Qoder + CodeBuddy + Antigravity").
Changes:
- .claude-plugin/plugin.source.json — single source of truth. It lives under
.claude-plugin/ despite serving all five hosts because the pre-push leak gate
whitelists identity strings only inside the published-manifest dirs, and this file is
where the author name / GitHub handle are authored. At the repo root it would need a
local edit to an untracked git hook, which a fresh clone silently loses, re-blocking
the push. It is NOT Claude-specific; the rationale is recorded at its SOURCE_FILE
definition.
- scripts/gen_plugin_manifests.py — generator + --check. One TARGETS entry per host,
carrying field set AND field order (these files are reviewed as diffs). Verified
byte-identical against all 8 committed manifests before regenerating; the only diff on
first run was the codex version — the generator's first action was catching the bug it
was built to catch.
- scripts/validate.py — new check 4b compares every manifest to the generated output,
covering hosts by construction rather than by enumeration, so a newly declared host is
guarded the moment it is added. The success line now derives its host list from TARGETS
too.
- .codex-plugin/plugin.json — the one-line drift fix (0.11.0+codex.… -> 3.7.0).
Firing evidence (the reason 4b exists): tampering with .codex-plugin/plugin.json produces
exactly one error, from 4b, while checks 2b/2c stay silent — the same blindness that let
the real drift through. Covered by test_validate_catches_codex_drift.
Adding host N+1 is now one TARGETS entry, not a directory to hand-maintain.
Tests: 16 new; full suite 557 passed on top of the b92 merge (191c518).
Follow-up, filed as B127: add .claude-plugin/plugin.source.json and
scripts/gen_plugin_manifests.py to test_evidence_inputs, so editing the source without
regenerating invalidates the evidence fingerprint. Now a two-string data edit in
.claude/close-gate.json, since B92 moved that list out of a bash literal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6GyyDraAt4D5EWebppwtP
…d been stale for three weeks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6GyyDraAt4D5EWebppwtP
…changes Both scripts/validate.py and CHANGELOG.md are inside the gate's declared test_evidence_inputs, so the committed fingerprint no longer matched its inputs. Regenerated with scripts/gen-test-evidence.sh (which stamps set=task). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6GyyDraAt4D5EWebppwtP
…s' central claim was wrong The three commits already on this branch assert that .codex-plugin/plugin.json had drifted three weeks stale at 0.11.0+codex.20260707001844, and that changing it to 3.7.0 fixed a bug. That claim is false and this commit undoes the change. scripts/invariants/manifest-version-sync.sh:2-4, in a comment: the invariant syncs exactly .claude-plugin / .qoder-plugin / .codebuddy-plugin, and "Antigravity (version-less) + codex (own X.Y.Z+codex.<stamp> scheme) are deliberately out of the synced set." git log --follow confirms it: codex's version was set twice ever (a7ec887 -> 0.11.0, 52f00ac -> 0.11.0+codex.20260707001844) and never tracked the shared number. How the error was made, since it is the reason this change is worth keeping: the generator's author read scripts/validate.py, never read scripts/invariants/, saw one version differ from three, and called it a drift. The intent WAS written down — in a shell comment, two directories from the manifests it governs, invisible from the files themselves. That is the actual defect this branch should have been about, and now is. Changes: - .codex-plugin/plugin.json restored to 0.11.0+codex.20260707001844 (byte-identical to 52f00ac). - .claude-plugin/plugin.source.json gains hostVersionOverrides, declaring codex's independence as data with the reason inline, beside the shared values rather than two directories away. - gen_plugin_manifests.py: host_version() honours it, for plugin.json and for a marketplace entry's version (validate.py check 2b compares a host against ITSELF, so an overridden host must stay self-consistent, not match the shared version). - Three new tests (19 total, was 16): * test_regenerating_never_drags_an_overridden_host_onto_the_shared_version — the mistake, pinned. Fails if regeneration ever collapses an override again. * test_synced_hosts_are_exactly_the_invariants_synced_set — parses the invariant's own `for` loop and asserts the generator's notion of "synced" equals it, so the two lists cannot silently disagree. * test_validate_catches_a_hand_edited_codex_field_other_than_version — 4b's reach is every field, which is what makes it broader than the version-shaped checks beside it. - test_validate_catches_codex_drift renamed and rebuilt: it had tampered codex's version TO the value that is now correct, so it was asserting against the wrong thing. It now uses an obviously-invalid value and documents that the point is undetected EDITS, not a wrong version. - validate.py check 4b's docstring and the generator's module docstring rewritten around the real finding. - CHANGELOG entry moved Fixed -> Added and rewritten: this adds a mechanism and makes three scattered intents visible; it does not fix a version bug, because there was none. The generator now reproduces all 8 committed manifests with ZERO diff — the golden oracle actually passing, rather than passing except for one file the author had decided was wrong. Not amended, per the repo's rule that a published commit is never amended; the false claim stays in the history with this correction on top. Verified: gen --check 8/8 · validate.py OK · manifest-version-sync PASS · 19 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6GyyDraAt4D5EWebppwtP
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6GyyDraAt4D5EWebppwtP
Audit narrativeSeven commits, Note on the last two commits. Layer 1 — Golden pathReal stdout, not retyped. Eight Layer 2 — Negative paths2a. Hand-edit codex's version → only check 4b fires
2b. Hand-edit a non-version field → also caught4b's reach is every field, which is what makes it broader than the two version-shaped checks beside it. 2c. This branch's own regression, reproduced on demandDelete Exactly two tests go red and 17 stay green — the two that exist for this, and nothing else. This is the firing evidence for the correction in Layer 3 — Before / after
Layer 4 — Cost transparency(N/A — no paid API involved.) The generator and both checks are pure stdlib Python plus Layer 5 — PerformanceNo go/no-go targets were set for this work, so these are measurements rather than pass/fail:
Adding 19 tests moved the full-suite count 541 → 560 (16 at Why the pytest count is unchanged across the merge, verified rather than assumed: Layer 6 — Findings tierS1 (blocks merge): none. S2 (ships with follow-up):
S3 (recorded for later):
Layer 7 — Close gate13 ✓ / 0 ✗, exit 0 (12 before the merge; Layer 8 — Reviewer asks
Layer 9 — What's nextLOCKED only:
Nothing else is committed. In particular this PR does not propose extending the generator to sibling repos, and does not touch how any host actually loads its plugin. Raw evidence — full suite at
|
…-gen # Conflicts: # .claude/.last-test-run
The merge brought in changes to declared evidence inputs (CHANGELOG.md among them), so the fingerprint no longer matched. Regenerated as the last step, per the documented order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6GyyDraAt4D5EWebppwtP
TL;DR
f34bfafundoes that and pins it with a test. The false claim stays in the history; published commits are not amended here.§1 What was done
Every agent host hardcodes where it looks for its manifest, so
.claude-plugin/·.codex-plugin/·.qoder-plugin/·.codebuddy-plugin/and the rootplugin.jsoncannot be merged into one. That constraint comes from outside this repo and this PR does not fight it. What it collapses is the maintenance of the eight files inside them — and, more importantly, it makes the rules governing them visible.State on
ca75ccc:Three different rules, documented in three different places:
forloop inscripts/invariants/manifest-version-sync.shX.Y.Z+codex.<stamp>schemescripts/validate.pyNothing showed all three at once, and none of them is visible from a manifest.
CLAUDE.md's release discipline ("bump both manifests together") covers two of the eight files; the other six had no owner.This PR's own history is the demonstration. Its first three commits read
validate.py, never readscripts/invariants/, saw one version differ from three, changed codex to3.7.0, and described it in the commit message and CHANGELOG as repairing a three-week-old drift.git log --followshows codex's version was set twice ever and never tracked the shared number.f34bfafrestores it byte-identically to52f00acand adds the tests that make the mistake un-repeatable.Use cases
Changing the version, description, author, or keywords — edit
.claude-plugin/plugin.source.json, runpython3 scripts/gen_plugin_manifests.py. Hand-editing a generated manifest is now a validation failure naming the source file and the command.Adding a sixth host — one entry in
TARGETS: output path(s), which fields, in what order. Covered by the sync check from that moment.A host that must NOT follow the shared version — declare it in
hostVersionOverrides, with the reason. Regenerating will not flatten it, and a test fails if that ever changes.Files:
.claude-plugin/plugin.source.json(new) — single source of truth: identity, author, repository, license, keywords, the four description variants the hosts need, the marketplace entry shape, codex'sinterfaceblock, andhostVersionOverrides.scripts/gen_plugin_manifests.py(new) — generator +--check(unified diff, non-zero exit).TARGETShas one entry per host; each key list encodes field set and field order.host_version()resolves overrides for bothplugin.jsonand a marketplace entry's version.scripts/validate.py— check 4b regenerates and compares every manifest byte-for-byte. Success line derives its host list fromTARGETSinstead of restating it (the old literal had itself gone stale, omitting codex).scripts/test_gen_plugin_manifests.py(new) — 19 tests.CHANGELOG.md—[Unreleased]/ Added..claude/.last-test-run— regenerated;validate.pyandCHANGELOG.mdare declared evidence inputs..codex-plugin/plugin.json— net zero change across the branch: modified bya7af748, restored byf34bfaf.§2 Why this approach
Generate rather than extend a list. Adding codex to the hand-written sibling list would have covered today's file and left the mechanism intact — host N+1 would be missing from that list identically. Check 4b covers hosts by construction. Same reasoning applied to the success message, which had already drifted by being a literal.
Exemptions as data, not as comments. The version rules were documented; documentation two directories away from the file it governs did not survive contact with a reader.
hostVersionOverridesputs the exemption where someone changing the version will see it, andtest_synced_hosts_are_exactly_the_invariants_synced_setparses the invariant's ownforloop so the two notions of "synced" cannot silently diverge.Reproduce before overwriting. The generator had to emit all eight committed files byte-identically before being allowed to write. There is no independent spec of what these manifests should contain, so "matches what five hosts already accept" is the strongest available correctness statement. Worth noting how this oracle behaved: on the first attempt it reported one diff — codex — and that diff was taken as a finding rather than as an oracle failure. The lesson is in the tests now; the oracle was right and the author overrode it.
Preserve per-host oddities. Claude Code's
plugin.jsonputsdescriptionbeforeversionwhile the others put it after; only.claude-plugin/marketplace.jsoncarries$schema; the root Antigravity manifest is ASCII-escaped and its schema forbidsversion. All preserved verbatim, all asserted.The source lives under
.claude-plugin/despite serving all five hosts. The pre-push leak gate whitelists identity strings by file, only inside published-manifest directories, and this file is where the author name and handle are authored. At the repo root it needed an edit to.git/hooks/pre-push, which is untracked — a fresh clone or hook re-install loses the exemption and blocks the push again. Inside an already-whitelisted directory it cannot regress that way. Rationale is at theSOURCE_FILEdefinition; the trade-off is a reviewer ask.§3 Requirements satisfied
No spec/plan pair — this began as maintenance, not a planned phase. It answers the repo's standing conventions:
CLAUDE.mdrelease discipline — generalized from 2 files to 8, enforced rather than remembered.[Unreleased]— present, under Added, user-facing tone.pytest scripts/560 passed at0d2b4a9(541 before this branch; 557 at3111b22before the 3 testsf34bfafadded). Output in the audit comment.hooks/tasklist-first.shreportedUNVERIFIEDthroughout..claude/tasklist.mdis a shared gitignored file that a concurrent session owns (16 items, its own nonce); writing it would have destroyed that session's list. Per the hook's own contract anUNVERIFIEDline is not a pass, so it is disclosed here rather than left silent in a transcript. A host task list was used instead, which the hook documents as an accepted satisfier but cannot detect.user-story.md: SKIP — internal tooling, no user-observable surface.