ktg-plugin-marketplace/CLAUDE.md
Kjell Tore Guttormsen ee2259f63f
feat(release): file the Forgejo release object as part of a release
A pushed git tag is filed by Forgejo under /tags; only an explicit release
object appears under /releases. release-plugin.mjs only ever made a tag, so
every plugin's public releases page sat a version behind the ref the catalog
pinned -- llm-security showed v7.8.3 against a v8.0.0 tag.

Measured 2026-09-18 against the instance API: 24 repos in org `open`, 21 with
at least one tag, 11 of those 21 with no release object for their newest tag.
That reproduces the order's own independently-measured list exactly.

- parseForgejoRepo / planForgejoRelease / ensureForgejoRelease: pure, tested.
  The release body is the tag's own message VERBATIM or empty -- never
  generated prose. Read via %(contents:subject)+%(contents:body), never
  %(contents), which drags the SSH signature block into the notes.
- The step fires only on a run that PUBLISHES (--create-tag --write, or
  --push): filing a release object is itself a publish and must not ride
  along on a local --write past the operator's one-shot push token.
- Synchronous (curl via execFileSync), like check-versions.mjs's
  checkHomepage: runRelease is called without an await and its return value
  becomes the exit code, so an async step would let a rejected POST surface
  after the run had already exited 0 and called the release complete.
- 429 and the 502/503/504 family are retried with backoff, never swallowed.
  An unthrottled sweep drew 17 HTTP 429s and the first version of that sweep
  read every one as an empty list -- "verified nothing" was indistinguishable
  from "verified everything, all clean".
- The token reaches curl through a 0600 header file, never argv.

scripts/backfill-forgejo-releases.mjs covers the backlog and retries the one
step, reusing the same planner and API shell so the two cannot drift. Only
the newest tag is considered. Documented exception: ktg-plugin-marketplace
pre-polyrepo-archive, an archive marker, not a release; the register is keyed
by repo AND tag so that repo's next real release is still backfilled.

Tests written red first: 20 new (12 release path, 8 backfill), and the two
real-git integration tests were probed known-negative -- breaking the wiring
turns 68/0 into 66/2. Suite 193/193; check-versions 12/12 OK.

The backfill of the 10 outstanding release objects is NOT done: it was denied
in-session as a public-surface write and is the operator's call.

Order: 20260917T235642Z-730962924-from-from-ai-to-chitta

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:23:52 +02:00

26 KiB
Raw Blame History

ktg-plugin-marketplace (catalog)

Catalog repository for the ktg-plugin-marketplace. After the polyrepo migration this repo hosts only the marketplace manifest and the catalog-level docs; every plugin and the shared design-system live in their own Forgejo repositories under https://git.fromaitochitta.com/open/.

What lives here

  • .claude-plugin/marketplace.json — the marketplace manifest (plugin entries point at external repos)
  • README.md — the landing/catalog page
  • CONVENTIONS.md — marketplace-wide conventions inherited by every plugin repo
  • GOVERNANCE.md — governance + fork-and-own model
  • .mailmap, .gitleaks.toml, .gitleaksignore — shared git-hygiene baselines

Catalog maintenance

  • Marketplace conventions: see CONVENTIONS.md.
  • Adding/updating a plugin entry: edit .claude-plugin/marketplace.json (external source: "url" with a pinned ref) and re-state the plugin in README.md with its verified version.
  • Plugin source, issues, and releases live in each plugin's own repository — not here.
  • Every tag this repo cuts is ANNOTATED — git tag -a, never a bare git tag. A lightweight tag is a branch-like ref: it can be moved to another commit with nothing recorded that it ever pointed elsewhere. The catalog pins every plugin to ref: vX.Y.Z, so a movable tag is a movable pin — a supply chain property, not a tidiness one (repo-standard TAG-ANNOTATED, ERROR). Two tagging sites, and only one of them was ever enforced:
    • Plugin tags are cut by scripts/release-plugin.mjs (--create-tag --write), which has used git tag -a since its first commit (9b1838f) and has never had the defect. Verified 2026-08-17 against the published surface, not the source: git ls-remote --tags on repo-mailbox returns both refs/tags/v0.25.0 (tag object a9b5eb8) and the peeled refs/tags/v0.25.0^{} (commit 95ac710) — a lightweight tag has no peeled ref. Do not "fix" release-plugin.mjs; it is not the drift site.
    • The catalog's own tags are cut BY HAND, and that is the site with no enforcement. All four newest (v7.7.2, v7.7.1, v7.7.0, v5.0.3) are lightweight, from the monorepo era. Control proving the measurement discriminates: pre-polyrepo-archive reports tag, and a local probe pair (git tag -atag, git update-ref refs/tags/xcommit) reproduces both outcomes. The published lightweight tags STAY (decided 2026-08-17, forward-only). The only remedy for an already-published tag is git tag -a -f <name> <name>^{}, which force-moves a ref others may have fetched — precisely the traceless move the finding warns about. Applying it as the fix would demonstrate the defect. Consequence to state plainly, not to hide: TAG-ANNOTATED stays ERROR until a NEW annotated tag becomes the newest, because checkTagIntegrity's tags_lightweight_accepted register key reaches tag HISTORY only and cannot excuse the newest tag. And "newest" is compareTags order (semver triple), so a fresh-start v1.0.0 would NOT clear it — a clearing tag must sort above v7.7.2. Non-semver tags (config-audit/v5.0.0, pre-polyrepo-archive) parse to [0,0,0,…] and never rank. The ERROR is one the org ALREADY ACCEPTED, and the acceptance cannot land — measured 2026-08-17. repo-standard's register (register/repos.json:231) lists v7.7.2 under tags_lightweight_accepted["ktg-plugin-marketplace"], accepted 2026-08-14 with a rationale recorded at :211-223: it is a monorepo-era llm-security tag (2026-05-19, pre-split), not a catalog release; the README install block pins no ref and all 12 marketplace.json refs are plugin-repo tags, so zero install paths consume it. But checkTagIntegrity judges the newest tag at :711 before it reads the register at :719, and accepted is only ever applied to tags.slice(0, -1). So the entry for v7.7.2 is inert: the decision is written down and the gate cannot honour it. Run against this repo (importing repo-standard's own exported checkTagIntegrity, its real register, and git for-each-ref refs/tags/v*), the catalog scores exactly two findings: [ERROR] TAG-ANNOTATED on v7.7.2, and [OK] TAG-ANNOTATED-ACCEPTED covering the other 7 lightweight tags. There is no TAG-ANNOTATED-HISTORY WARN — history is fully claimed. Consequence for whoever reads the next org-wide run: this repo's single ERROR is not an unmade decision, it is a made decision the checker structurally cannot see. That is the same "we decided this" / "nobody looked" collapse the register exists to prevent, one level up. The fix belongs in repo-standard, not here — reported, not patched. Note: ~/.gitconfig sets tag.gpgsign true, which makes a bare git tag <name> fail ("no tag message?") rather than silently cut a lightweight one. CORRECTED 2026-09-18 — annotated tags on this machine ARE signed. The 2026-08-17 note here said the probe's -a tag "came out unsigned all the same (0 PGP blocks)". That was a defect in the MEASUREMENT, not a fact about the tag: ~/.gitconfig sets gpg.format ssh with user.signingkey ~/.ssh/id_ed25519.pub, so the signature is an SSH signature and counting PGP blocks reads 0 on a perfectly signed tag. Re-probed in a fresh temp repo: git tag -a0 BEGIN PGP, 1 BEGIN SSH SIGNATURE. The live llm-security v8.0.0 tag object carries the same SSH signature. Verifiseringsloven ansikt 4 in this repo's own notes — "found nothing" was the wrong question asked, not an absence. What this still does NOT buy: Forgejo shows "no known key found for this signature in the database" on the release page, because the signing key is not registered as a verification key on the Forgejo account. The tag is signed; nothing upstream can check it. And signing is orthogonal to TAG-ANNOTATED: it is the -a that makes the tag a real tag object, not the signature. Do not treat tag.gpgsign as the enforcement for annotation.
  • Releasing a plugin (canonical path — scripts/release-plugin.mjs): since the polyrepo split, a release is a THREE-part act — tag the plugin repo, bump the catalog ref, AND file the Forgejo release object. Forgetting the catalog ref strands users on the old version; forgetting the release object leaves the plugin's public releases page a version behind (both are drift this helper exists to prevent). Run node scripts/release-plugin.mjs <plugin> [--version X.Y.Z] — dry-run by default; it REFUSES unless plugin.json == README badge == the target version AND the vX.Y.Z tag exists, then prints the planned bump. Apply with --write [--commit] [--push]; --create-tag --write mints+pushes a missing plugin tag first (--create-tag is a WRITE and obeys --write — on a dry-run it only reports what it would mint). On --write it bumps the catalog ref AND the catalog README's per-plugin `vX.Y.Z` label together (and git adds both on --commit). Because it only moves both to a verified, tagged, consistent version, check-versions.mjs is green by construction. Never hand-edit a ref or a README label for a release — use this. Pure planner + label reconciler + pre-flight/write step covered by scripts/release-plugin.test.mjs.
    • The Forgejo RELEASE OBJECT is the third part of a release (added 2026-09-18, order 20260917T235642Z-730962924-from-from-ai-to-chitta). A pushed git tag is filed by Forgejo under /tags; only an explicit release object appears under /releases. Until this was added, release-plugin.mjs made a tag and nothing else — grep releases scripts/*.mjs (excluding .test.) returned 0, against 6 hits for tags in the same file as the control that the query could find. Consequence, measured 2026-09-18 against the instance's own API: 24 repos in org open, 21 with at least one tag, and 11 of those 21 had NO release object for their newest tag — llm-security's releases page said v7.8.3 while the catalog pinned v8.0.0. That is the same tag-vs-published drift the catalog-ref bump exists to prevent, one surface further out, and it is what a human reads to answer "what version is out?".
      • The release body is the tag's own message, VERBATIM, or empty. Never generated prose — an invented release note is a claim about the release that nobody made. Read it with %(contents:subject) + %(contents:body), never %(contents): ~/.gitconfig sets tag.gpgsign with gpg.format ssh, so %(contents) drags the whole -----BEGIN SSH SIGNATURE----- block into the release notes (verified on llm-security v8.0.0).
      • It fires only on a run that PUBLISHES (--create-tag --write, or --push). Filing a release object is itself a publish, so it must not ride along on a local --write and slip past the operator's one-shot push token. A non-publishing --write says so and names the backfill instead of going quiet.
      • The step is synchronous (curl via execFileSync), like check-versions.mjs's checkHomepage, and for a sharper reason: runRelease is called from main() WITHOUT an await and returns the code main() hands to process.exit. An async step would return a Promise nobody awaits, so a rejected POST would surface as an unhandled rejection after the run had already exited 0 and called the release complete.
      • 429 and the 502/503/504 gateway family are retried with backoff, never swallowed. Both were seen live on 2026-09-18 — an unthrottled sweep of the 24 repos drew 17 HTTP 429s, and the first version of that sweep coerced every one of them to an empty list, so "verified nothing" and "verified everything, all clean" were indistinguishable. Anything else, including a 403 from a token without write:repository, is an ANSWER and is raised.
      • The token reaches curl through a 0600 header file, never through argvargv is readable via ps(1), and this token can write to every repository in the org.
    • Backfill / retry: scripts/backfill-forgejo-releases.mjs (dry-run by default, --write applies, --repo <owner>/<name> narrows). It reuses release-plugin.mjs's own planner and API shell rather than reimplementing them, so the backlog and the release path cannot drift apart. It considers only the newest tag (that is the FERDIG-criterion the order set), and reports the denominator. Documented exception: ktg-plugin-marketplace pre-polyrepo-archive — an archive marker for the monorepo before the polyrepo split, not a release (non-semver, and no install path consumes it). The exclusion register is keyed by repo AND tag, never by repo alone, so that repo's next real release is still backfilled.
    • --create-tag --write and --push each require the operator's push-approval token FIRST (Q3, decided 2026-09-12): ~/.claude/hooks/pre-push-gate.sh matches git push in command text and cannot see a push this script issues via execFileSync inside node — the script mints+pushes a plugin tag and pushes the catalog itself, both invisible to that gate. So before either push, run: mkdir -p ~/.claude/runtime/push-approvals && touch "~/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')" (pwd must be this catalog directory — tag-push and catalog-push share ONE token, one publish from the operator's perspective). The script consumes the token itself right after a push succeeds, the same way post-push-consume.sh does for a direct push; the token stays intact for a retry only when the run makes ZERO successful pushes — once any push in the run succeeds (e.g. the tag push in --create-tag --write --commit --push), the token is spent even if a later push in that same run then fails. Covered by pushAuthorisation/requirePushAuthorisation/ pushWithToken/consumeToken in scripts/release-plugin.test.mjs.
  • Pre-flight gate (--write runs check-versions BEFORE it writes): the helper calls runGate() first and aborts with exit 1 — nothing written — if ANY plugin is ERROR, not just the one being released (check-versions' exit code is catalog-wide). Previously the gate ran after both writes, so a red catalog left a half-applied release in the working tree for a parallel session to carry to the public remote. The pre-flight reads the ERROR set only, never failed/--strict: pre-bump, the plugin being released is supposed to be WARN (catalog ref behind plugin.json), so gating on WARN would brick every release. The post-write gate at the end stays — pre-flight validates the old state, that one validates the new state. --create-tag is deliberately NOT behind this gate (decided 2026-08-10): it mints and pushes the plugin tag before the pre-flight runs, but every precondition it checks is local to that one plugin (plugin.json == target, badge agrees, tag absent), so the tag is correct by construction. A red other plugin can only make the tag EARLY, never WRONG, and the tag-absent check makes the retry idempotent — gating it would let plugin Y block the tagging of plugin X, the same over-coupling that reading ERROR-only avoids. What WAS closed is the worse half: --create-tag used to push on the documented dry-run path, with no --write at all. It now requires --write (shouldCreateTag, tested).
  • Version-consistency gate: run node scripts/check-versions.mjs before committing any ref change. For each plugin it checks (against the sibling repo) that the catalog ref resolves to a real git tag (ERROR if dangling — breaks install), that plugin.json version == README version-badge (ERROR), that the catalog README's per-plugin `vX.Y.Z` label == the catalog ref (ERROR — the human-facing doc must not misstate the installed version), that the catalog ref matches plugin.json version (WARN — catalog lags or an unreleased bump), and that plugin.json's homepage, if present, actually resolves (ERROR if dead — a live curl check, synchronous by design, see the comment at checkHomepage for why it must never become async). Exit 1 on any ERROR; --strict also fails on WARN. Pure-function core covered by scripts/check-versions.test.mjs (node --test scripts/check-versions.test.mjs).
    • homepage is OPTIONAL, not required (decided 2026-08-26): measured that 11 of 12 plugins omit the field. Requiring it would flip those 11 to ERROR and, via release-plugin.mjs's ERROR-set pre-flight (catalog-wide), block every release in the marketplace until each of those 11 sibling repos re-tags — a marketplace-wide freeze delivered by a script fix. The gate only fires when the field is present AND resolves to a definitive 4xx/5xx; a network failure (timeout/DNS) reads as "not checked", never as a confirmed dead link.
    • SKIP is UNVERIFIED, not clean, and now blocks the gate by default. Measured 2026-08-18: a fresh clone with zero sibling repos printed 12 plugins — 0 OK, 0 WARN, 0 ERROR, 12 SKIP at exit 0 — a run that verified nothing was indistinguishable from a run that verified everything and found it clean (Verifiseringsloven ansikt 4, in the org's own release gate). Any SKIP now fails the run unless --allow-skip is passed explicitly, and the summary line always reports the denominator: — verified N/M. runGate's hasError/hasWarn/failed fields are unchanged in meaning; the new SKIP-based failure is a separate unverified field folded into failedrelease-plugin.mjs's pre-flight (preflightErrors) reads the ERROR set directly off results, never failed, so it is untouched by this.
  • Stat-badge mirroring (part of the same gate): each plugin block in the catalog README ends in a stat line (7 agents · 16 scanners · 21 commands · 1398 tests · [Full documentation →]). The gate compares every number on that line against the plugin's own shields badge for the same axis, and ERRORs when they disagree — the catalog must not overstate a plugin. The rule is per-AXIS, not per-plugin: an axis the plugin does not badge is skipped silently, so there is no exception list to maintain. Re-measured 2026-08-13 at the pinned refs, all 12 plugins: 42 catalog axis-claims — 24 badge-covered · 18 badge-less across 8 of the 12 plugins and therefore ungated, with 0 disagreements on the gated set. The measurement imports check-versions.mjs's OWN exported extractStatBadges / extractCatalogStats and feeds them git show <ref>:README.md, so coverage is read by the same code that gates it rather than by a second parser that could drift. The prior 27 · 15 across 7 (2026-08-04) was correct and is not overturned — re-running the measurement against catalog commit c7fbbd3 reproduces 27 · 15 across 7 exactly. Coverage FELL because three axes lost their badge when a ref moved, each verified at both tags: okr v1.8.2 → v1.10.0 dropped agents-7, hooks-3 and references-17 (the catalog restates the first two → 2 gated), and graceful-handoff v3.1.0 → v3.2.1 dropped tests-30 and hooks-0 (the catalog restates only test1 gated). 27 3 = 24, 15 + 3 = 18, +1 ungated plugin (okr). This is the CLAUDE.md-documented graceful-handoff badge-drop reaching a release: it was visible on main on 2026-08-04 and is now what installs. All 18 ungated values were measured at their refs in the same pass (2026-08-13) — 16 exact, 1 correct-but-split, 1 defect. Exact: voyage 24 agents / 7 hooks / 832 tests, linkedin-studio 6 skills, graceful-handoff 1 pipeline / 48 tests, ai-psychosis 1 skill / 1 command, ms-ai-architect 29 commands / 5 skills / 2 hooks, okr 7 agents / 3 hooks, claude-design 5 tests. Split: voyage 6 commands (+1 helper) measures 7 command files, and the split is the plugin's own — its README at v5.9.1 reads "6 slash commands (…) + trekendsession helper", and that file's description: calls itself a helper. Defect: repo-standard 170 tests measures 243 at v0.11.1 (node --test scripts/*.test.mjs, the version its package.json declares). The catalog is faithfully mirroring repo-standard's own README, which says "170 tests over the pure classifiers" at that tag — so the plugin's prose is the stale source and the fix belongs there first. Understating, not overstating, which is why nothing screamed. RESOLVED 2026-08-14/15 (commit 3f5afee): repo-standard's checks axis is now consistent at 20. The three-way disagreement at v0.11.1 — catalog bullet said "Twelve", catalog stat line said 14, plugin's own README said "twelve checks" — is gone, and it was resolved on repo-standard's side, not by the catalog picking a number: v0.11.2's CHANGELOG records the plugin removing its own stale prose counts ("170 tests" and "twelve checks") rather than correcting them, making the README's own "Check | What fails it" table the sole source of truth. That table is unchanged between v0.11.1 and v0.11.2 (verified: diff of the table is empty) and has 20 rows, counted independently this session (git show v0.11.2:README.md in the sibling repo, First screen through Description). The catalog's repo-standard block (README.md:206 bullet, README.md:212 stat line) now reads "20 checks" in both places, matching. The earlier "19 exported check* functions" count was a different unit (exported functions, not documented table rows) and is superseded by the table now being canonical — no further ask to repo-standard is needed. ⚠️ The 251 selftest checks correction is not fully settled, though the weight is on 370: this file and the catalog's own 08-02 stat line both record the axis as corrected to 370, check-versions.mjs said 374 (most likely a transcription slip), and repo-mailbox's README at v0.19.0 implies 390 (183 + 134 + 73) — its own prose being ungated too. Moot for the gate (they badge the axis now, 398 at v0.20.2, green) and was not re-measured for years. Re-measured 2026-09-05 against the now-pinned v0.33.1: badge reads selftest_checks-868 (git show v0.33.1:README.md in the sibling repo), the catalog's own stat line already agrees (corrected 09-04), gate green. Repo-mailbox's own listed per-script counts still only sum to 792 (220+360+73+99+40) against the 868 badge — the same badge-less-prose pattern recurring. It stands as the proof of the cost: an ungated number rots, and so does the record of having fixed it. N+ in the catalog is read as a lower bound, not an equality, so 500+ was never gate-visible — ungated axes rot in silence and need a re-run of this pass whenever a ref moves. Never hand-edit a stat line to silence the gate — the plugin's badge is the source for every stat number; fix the catalog to match it.
  • Counting rules for a badge-less axis (calibrated against the badged plugins, 2026-08-02). When the catalog must count an axis itself, count it the way the badges do, or the numbers stop being comparable across plugin blocks: hooks = hook ENTRIES in hooks/hooks.json (not events, not matchers — the three diverge), and tests = tests from node --test, not pass (config-audit's badge 1398 is its tests count; pass was 1375). Measure in an extraction of the tag (git archive <ref> | tar -x -C <tmp>), never the sibling working tree.
    • Re-calibrated 2026-08-13 — the file-counting rules now have a measured referent, 11/11. agents = agents/**/*.md (4/4: llm-security 6, config-audit 7, ms-ai-architect 12, linkedin-studio 20), commands = commands/**/*.md (3/3: config-audit 21, okr 16, linkedin-studio 30), skills = skills/*/SKILL.md (4/4: repo-mailbox 3, claude-design 1, graceful-handoff 1, repo-standard 1). tests = tests re-confirmed against config-audit ( tests 1398 == badge 1398, pass 1374 — count tests, and note the suite need not be green for the census to be valid). scanners is NOT calibrated: neither files-under-scanners/ (llm-security 55, config-audit 61) nor top-level .mjs (27 / 32) reproduces the badges (23 / 16), so the axis counts something the tree does not name. Harmless today — both scanner claims are gated and there is no ungated one to count — but do not invent a rule if that changes.
    • ⚠️ git ls-tree QUOTES non-ASCII paths, and a naive count silently drops them. okr's commands/innføring.md and commands/møter.md come back as "commands/innf\303\270ring.md", so a ^commands/.+\.md$ match counted 14 against a badge of 16 and read as a defect in okr. It was a defect in the measurement. Always pass -c core.quotePath=false. The same pass also produced a fully bogus "13/13 MATCH" from a zsh loop where set -- $spec did not word-split, leaving every field empty so "" == "" passed — a verification that cannot fail has verified nothing. Count in Node, not in a shell loop.
    • type: prompt entries COUNT (decided 2026-08-13). The rule read "hook COMMAND entries" until now. That wording was calibrated in 2026-08 against plugins that predate prompt-hooks, so it was never a ruling on them — it had no case to rule on. A prompt entry fires on the same event and does the same job from the reader's side, so excluding it would understate the plugin. Re-measured 2026-08-13 at all 12 pinned refs before the rewrite, because restating an old measurement in new words is itself a claim: every plugin that both badges hooks and ships a hooks.json has entries == command-entries (llm-security 9/9, config-audit 4/4, linkedin-studio 9/9, ai-psychosis 4/4, repo-mailbox 1/1), so the two phrasings agree on the whole badged set and the rewrite contradicts nothing. okr @ v1.10.0 is the only plugin with a non-command entry — 3 entries, 2 command + 1 prompt (PreCompact) — and it does not badge the axis, which is precisely why the badged set never tested the rule. The catalog's 3 hooks stands, measured.
    • The agents axis IS badge-covered — 4 of the 6 plugins that claim it (measured 2026-08-13). llm-security agents-6, config-audit agents-7, ms-ai-architect agents-12, linkedin-studio agents-20; all four match the catalog exactly and are gated. Ungated: voyage (24 agents) and okr (7 agents) — badge-less. Both measured 2026-08-13 at their refs: 24 and 7, exact. Do not record this axis as uncalibrated: it has a referent.
    • A badge whose value is not an integer does not gate the axis. extractStatBadges keeps only integer-valued badges, so graceful-handoff's Pipeline/STATE--helper-deterministic badge leaves 1 pipeline ungated even though a badge for the axis visibly exists. When asking "is this axis gated?", read the badge's VALUE, not its label.
  • The stat mirror reads the plugin README AT THE PINNED ref, never the sibling working tree. The catalog documents what installs, and that is the tag. A plugin that commits past its tag without bumping its version — measured 2026-08-02 on both llm-security (scanners 23→22, tests 2013→2034) and config-audit (tests 1398→1441) — would otherwise make the gate demand that the catalog restate unreleased numbers, which is exactly backwards. When the gate flags a stat, check git show <ref>:README.md in the plugin repo before believing the working tree.