feat(scripts): gate hand-written skill counts against the tree - #3034
Conversation
A plugin's prose counts its own skills by hand — "bundling fourteen skills", "the other thirteen skills are zero-config" — and nothing recomputed those numbers when a skill landed. Adding one silently falsified every sentence that had counted the old set, in files nobody opens during that change. The measured record: PR #3011 went stale on six such counts, each caught by a different human or automated reviewer rather than by anything mechanical, and session-flow's own CHANGELOG records an "other eleven skills" line that was already off by one before that PR touched it. A reviewer catch is not a control. check-skill-count-claims.sh matches a closed set of composition-claim grammars across each plugin's README, manifest, and skill bodies, and compares the claimed number against the skills the plugin actually has. It found four live drifts on a clean main: - testing said "Four skills" with five, and its table omitted /testing:audit entirely — the count drift marked a wholly undocumented skill, now added. - verification said "Two skills" with three. - improvement said "One skill" with two, in its first release. - docs-hygiene said "the other five skills do not use markdownlint-cli2" in an eight-skill plugin, and the substance was wrong too: extract-ssot does use it. That sentence now names its subjects instead of counting them, which is what this plugin's own audit-noise `enum-list` shape prescribes. Scope is stated in the script header rather than left implicit. The recognized grammars are a closed set, because most sentences pairing a number with the word "skills" assert nothing about plugin size ("gate one skill", "nine skills moved into three new plugins"); a gate that fires on those trains people to route around it. One form was evaluated and rejected: a definite-article reference reads as a claim about a named pair as often as about the whole set. CHANGELOG.md is out of scope — a dated entry describes the release it shipped with, and rewriting it to match today's tree would falsify history. Document-internal counts ("the four buckets" in a document defining five) are a real second class this gate does not hold, and the header says so. Verification: 16 black-box assertions, the negative ones load-bearing; six mutations of the gate's logic each turn the suite red. shellcheck at the repo rcfile, shfmt, actionlint, portability, lane-coverage, changelog-parity, and all 11 affected suites are green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
check-shell-portability.sh --all reported 13 GNU-only-construct hits across three test files. Every one is a false positive, and none could be spelled portably, so each gets a per-site portability-ok: marker with its reason. Two classes: - lib/resolve-convention-pattern.test.sh (7) carries \w, \s and \b as TEST DATA — PCRE tokens written into a fixture config and handed to the resolver so the case can assert the resolver REJECTS them. None is a pattern this suite passes to grep or sed, so none can behave differently on a BSD userland, and the shorthand cannot be removed because the shorthand is the thing under test. - claude-ops-paths.test.sh (3) and batch-common.test.sh (1) carry Windows paths — 'C:\temp\skills', '\\server\share\skills', 'telemetry\skills', 'D:\work\acme\keepme'. The backslashes are the input these cases normalize, not GNU classes. Same disposition the fleet already applies to HKCU\SOFTWARE registry paths in managed-scope.test.sh and permission-state.test.sh. Deliberately NOT a whole-file portability-scope: declaration on the regex corpus, tidier though that diff would be. The gate reserves the whole-file escape for its OWN fixture corpus; this file is the convention resolver's, and stretching a blanket exemption to a neighbouring gate's tests is how such hatches become mutes. The claude-ops markers ride their own case arms rather than a shared comment block above them. A first pass used the block, which cleared the adjacent arm but not one two lines below — block adjacency does not reach past intervening code. Co-locating each marker means reordering the arms cannot silently detach an exemption from the site it excuses. No behavior change: every assertion is untouched and all three suites pass. The lib edit does not fan out — sync-resolve-convention-pattern.sh copies the implementation, not the test, and --check confirms the one plugin copy still matches. shellcheck at the repo rcfile, shfmt, changelog-parity in all four modes, manifests and catalog are green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 927f5aab16
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The PR was opened through the GitHub API as an App installation token, and GitHub's recursion guard means events raised by that token do not start `pull_request` workflow runs. Observed on PR #3034: the two `pull_request_target` workflows (pr-issue-linkage, do-not-merge) attached and passed, while both `pull_request` workflows (ci, pr-title) never ran. This empty commit raises a `synchronize` event from a git push, which is the event every prior ci.yml run on this branch was triggered by. Squash-merge drops it from main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
All three were found by Codex review on #3034, and all three were verified against the tree before acting -- each is a way the gate reported success while a stale count stood. 1. A plugin whose LAST skill is removed loses its skills/ directory, and the scan skipped any plugin without one. That is precisely stale-count-by- removal, the case the gate most needs to hold, reported as success. Every plugin is now scanned; a missing or empty skills/ is a count of zero. 2. Matching worked per physical line, so an ordinary markdown wrap between the verb and the count hid a claim -- and reflowing a paragraph could permanently disable the gate for a claim it used to hold. Each line is now tested alone and joined with its successor, stopping at block boundaries so a heading or table row cannot manufacture a claim. This found a live wrapped claim in plugins/naming/README.md (correct) and one in claude-config/skills/setup/SKILL.md (a prerequisite-scope subset count, now exempted with its reason). 3. The cheap prefilter tested `*[Ss]kill*` on raw text, which rejects an all-caps SKILLS -- so a heading or emphasized sentence was dropped before the case fold could reach it. The fold now runs first. A prefilter that changes which claims are found is a hole, not an optimization. Fixing 2 surfaced two defects of my own that the review did not name: - Joining double-counted every wrapped-adjacent claim, reporting 46 sites where 24 exist. The guard must re-test the pattern against the next line rather than check containment of the matched extent: most forms open with a boundary group that captures the join's own space, so the extent never appears verbatim in the next line and containment passes everything. - One sentence can satisfy two grammars at once ("It ships" + "two skills, one concern"), so the guard re-tests EVERY form, not just the one that matched, or the single claim lands at two line numbers. Also fixed while in here: moving the prefilter below normalization made the gate spawn three processes per line across ~100k lines, taking it from sub-second to multi-minute. normalize_line is now pure bash writing to a global, and it handles BOTH dash spellings -- the real UTF-8 character a README carries and the JSON escape a manifest carries. The escape branch was lost once to an editor normalizing it, so it is built from a variable now. Verification: 21 black-box assertions, up from 16; the four new fixtures cover removal, a wrap falling inside the claim phrase, an all-caps claim, and a two-grammar overlap. Each fix is mutation-tested -- reverting any one of the three, plus the two dedup guards, turns the suite red. shellcheck at the repo rcfile, shfmt, portability, editorconfig and typos all clean; the gate reports 24 claims matching the tree with 2 exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
Resolves three conflicts, all version/changelog collisions from main moving under this branch: - plugins/claude-ops/.claude-plugin/plugin.json — main landed 0.33.0 with an ELEVENTH skill (audit-skill-visibility) and the matching "Eleven skills" description. Took main's side wholesale and re-applied this branch's bump on top as 0.33.1, so the new skill's description survives intact rather than being clobbered by a stale ten-skill string. - plugins/claude-ops/CHANGELOG.md — kept main's 0.33.0 entry and moved this branch's portability-annotation entry above it as 0.33.1. - plugins/repo-hygiene/CHANGELOG.md — main used 0.10.6 for its disable-model-invocation change, so this branch's entry renumbers to 0.10.7 and the manifest follows. Two different changes cannot share one version. The skill-count gate passes on the merged tree and verifies main's new claim: plugins/claude-ops/README.md:4 and the manifest both read "eleven skills" against eleven skill directories. That is the gate doing its job on a count that changed while this branch was open. Verified on the merged tree: the gate's 21 assertions, --check at 24 claims with 2 exempted, and changelog-parity in all four modes (212 headings preserved across 6 changed changelogs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
|
Claude finished @kyle-sexton's task in 1m 30s —— View job Security review
Reviewed the full diff ( No security issues found. Details considered:
This lane defers to zizmor's advisory checks for supply-chain/unpinned-action/permission findings, which don't apply here since the workflow addition follows the existing pinned-SHA, minimal-permission pattern already used elsewhere in (Note: the earlier Codex review comments on this PR are correctness/false-negative concerns in the count-detection logic, not security findings — out of scope for this security-only lane.) |
|
Last security-reviewed head: |
|
Claude finished @kyle-sexton's task in 4m 51s —— View job Code review —
|
comment-hygiene failed the hygiene lane on two tracker refs in the new gate's header, and both are real violations of a convention every other script here already follows: scripts/check-skill-count-claims.sh:18 tracker-ref:repo-issue scripts/check-skill-count-claims.sh:56 tracker-ref:pr-reference The rule allows a bare `(#N)` in a `#` comment -- check-shell-portability.sh alone carries six of them -- and rejects the fully-qualified `owner/repo#N` form and the `PR #N` prefix. Both flagged lines are rewritten to the bare form and the paragraphs rewrapped. This class could not be caught locally: comment-hygiene runs as an external composite action (melodic-software/ci-workflows) that this repo does not vendor, so there is no local invocation to run before pushing. CI is the first place it can fail. Swept every file this branch touches in a scanned extension for the same two forms plus `Fixes #`/`Closes #`/`issue #`: no others. Unchanged and re-verified: shellcheck at the repo rcfile, shfmt, the gate's 21 assertions, and --check at 24 claims with 2 exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
|
Claude finished @kyle-sexton's task in 57s —— View job Security review
Skipped — no security-relevant surface in the new commits. The prior security review on this PR covered head --- a/scripts/check-skill-count-claims.sh
+++ b/scripts/check-skill-count-claims.sh
@@ -14,12 +14,12 @@
- # nobody opens during that change. The measured record: a single PR
- # (melodic-software/claude-code-plugins#3011) went stale on six such counts, each
+ # nobody opens during that change. The measured record: a single PR (#3011) went
+ # stale on six such counts, each
@@ -53,8 +53,8 @@
- # cardinality, and picking the wrong one flags correct prose. PR #3011 hit
- # this class too (a "four buckets" line surviving the addition of a fifth),
+ # cardinality, and picking the wrong one flags correct prose. That same PR
+ # (#3011) hit this class too (a "four buckets" line surviving a fifth),This rewords two comment lines inside No security issues found — same conclusion as the previous review at head |
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
|
Claude finished @kyle-sexton's task in 5m 12s —— View job Code review —
|
Review finding on #3034, verified against the code and confirmed real. The --check FAIL message recovered the plugin's real count as `expected + 1`. That works on the normal minus-one path (actual - 1 + 1 == actual) but not when the clamp fires: `expected` is clamped up from -1 to 0 for a plugin with NO skills, so the message reported "has one" directly above "the number should be zero" -- the two lines contradicting each other. The count is now carried through the report row and read from it, never recomputed. Reconstruction was the bug; the row already knew the answer. Enforcement was never affected -- exit code and MISMATCH detection were correct throughout, and no plugin in the fleet has zero skills today. What was wrong is the diagnostic in exactly the last-skill-removed case that whole-fleet zero-skill scanning was added for, which makes it the path most likely to be the first one a real user hits. The zeta fixture now carries an "other three skills" line so the clamp path is actually exercised, and a 22nd assertion pins both halves of the message. Mutation-tested: restoring the `expected + 1` reconstruction turns the suite red on that assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM
|
Claude finished @kyle-sexton's task in 53s —— View job Security review
Skipped — no security-relevant surface in the new commit. The last reviewed head was Reviewed the change against the skip gate (§3 of
This matches the prior conclusion at |
|
Claude finished @kyle-sexton's task in 1m 22s —— View job Code review —
|
No linked issue ## Summary The skill-count gate's header states which *class* of stale count it holds (repository-derivable) and which it does not (document-internal), then asserts "Nothing else was scoped out" — which reads as completeness the gate does not have. It never named the *surface* boundary: the gate reads files in the repo, so a class-A count written anywhere else is equally derivable and equally unguarded, because nothing here can see it. ## Fix Adds a `SURFACES, not just classes` paragraph to the header and qualifies the "nothing else" sentence to the files the gate actually reads. The omission has a measured cost, and it is this gate's own introduction that paid it: PR #3034 carried a hand-written version-and-assertion table in its body, and that table went stale within the hour when a merge with `main` renumbered two plugins. Those counts were class A — plainly derivable from the tree — and invisible to the gate, because a PR description is not a file in the repo. A reviewer caught it, which is precisely the control this gate exists to replace. So the header now says: prose describing the CURRENT repo that lives on GitHub rather than on disk — a PR or issue body, a wiki page — derives its counts at write time or drifts, and a green gate must not be read as covering it. A second commit narrows that list. The first draft included "a release note", which contradicted a decision stated 40 lines above it in the same header: `CHANGELOG.md` is deliberately not scanned, because a dated entry describes the release it shipped with and rewriting it to match today's tree falsifies history rather than fixing a claim. A GitHub release note is that same kind of record. The header now states that carve-out explicitly as travelling off-disk with the count, rather than leaving it implied — off-disk surfaces get the same current-state test as the scanned ones, not a broader one. Raised by an automated reviewer; the contradiction was real. Comment-only change throughout. No behavior, no new surface scanned, no token-list edit. ## Verification - `shellcheck` at the repo's own `.shellcheckrc` (not a laxer severity) — clean - `shfmt -d` — clean - `scripts/check-skill-count-claims.test.sh` — all assertions passed - `scripts/check-skill-count-claims.sh --check` — every claim matches the tree - `typos`, `editorconfig-checker` — clean - `scripts/check-changelog-parity.sh --check` and `--check-bump origin/main` — no version bump required; `scripts/` is not a versioned plugin, and this PR touches nothing under `plugins/` - Tracker references in the added comment use the bare `(#N)` form the `comment-hygiene` lane requires, checked before pushing because that lane is an external composite action this repo does not vendor and so cannot be run locally All of the above were re-run after the second commit, not just the first. This body deliberately carries no hand-written counts of assertions, claims, or versions — the exact failure mode the change documents. The numbers are in the command output above, where they are regenerated rather than transcribed. **This body was itself corrected once.** After the second commit it still described the superseded wording, which is the same off-disk drift the change documents, occurring in this PR's own description. Fixed here rather than left as a demonstration. ## Related Refs #3034 — the PR that introduced this gate, and whose own body supplied the stale-count evidence cited above. --------- Co-authored-by: Claude <noreply@anthropic.com>
…aky (#3057) No linked issue ## Summary `scripts/check-shell-portability.sh --all` was treated as environmentally unreliable across four separate attempts, each written off as a command timeout or a container restart. That diagnosis was wrong, and it was the self-perpetuating kind: "it got unlucky" invites retrying the same run, which fails the same way and yields no new information. Nothing in the repo recorded the real reason. ## Fix Adds a `COST of --all` block to the script's header. The cause is structural, not environmental. Per-file cost grows superlinearly with file length, so a handful of the longest files dominate the whole sweep while the great majority cost almost nothing. It cannot fit a 600s command timeout on a tree this size, so it must be detached rather than retried. Two traps are recorded alongside it, because neither is discoverable from a failing run: - `pgrep -f 'check-shell-portability'` matches the **waiting** shell's own command line, so a waiter built on it can never see its condition clear. Two waiters were lost to this before it was spotted. Waiting on the pid is correct. - A per-file sweep keyed only on the timeout status silently passes over any file exiting 1, so its silence reads as a clean audit when nothing was actually checked. The block also states why CI runs the changed-file mode and never `--all` — true of `ci.yml`'s `shell-portability-lint` already, but written down nowhere. **On the numbers:** they are stated as a dated observation rather than a standing claim, and no file list or file count is hardcoded. The exact figures move with the corpus; the shape does not. That is deliberately the same surfaces-and-derivability discipline the sibling skill-count gate's header gained in #3050 — hardcoding "seven files over N lines" here would have created precisely the drift that gate exists to catch. Comment-only. No behavior, no token-list edit, no new construct class. ## Verification - `shellcheck` at the repo's own `.shellcheckrc` — clean (re-run after rebase) - `shfmt -d` — clean (re-run after rebase) - `scripts/check-shell-portability.test.sh`, which CI runs ahead of the gate — `PASS=333 FAIL=0` - `typos`, `editorconfig-checker` — clean - `scripts/check-changelog-parity.sh --check` — no version bump required; `scripts/` is not a versioned plugin and nothing under `plugins/` is touched - Tracker references use the bare `(#N)` form the `comment-hygiene` lane requires, checked before pushing because that lane is an external composite action this repo does not vendor and cannot be run locally - Rebased onto current `main` rather than left on the base it was written against, so `stale-base-overlap-gate` sees a current merge base The claim this documents was itself measured end to end: `--all` completed at exit 0 with `No unexcused GNU-only constructs in 1329 shell file(s)`, which is the verification that had been outstanding since #3034. ## Related Refs #3050 — the sibling gate's surfaces boundary, whose derivability discipline this block deliberately follows. Refs #3034 — the PR whose outstanding `--all` verification this work completed. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADsDCToTtjvYut3ZQXJHDM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
No linked issue
Summary
A plugin's prose counts its own skills by hand — "bundling fourteen skills", "the other thirteen skills are zero-config" — and nothing recomputed those numbers when a skill landed. Adding one silently falsified every sentence that had counted the old set, in files nobody opens during that change.
The measured record: PR #3011 went stale on six such counts, each caught by a different human or automated reviewer rather than by anything mechanical, and
session-flow's own CHANGELOG records an "other eleven skills" line that was already off by one before that PR touched it. A reviewer catch is not a control.This adds the gate, and fixes the four live drifts it found on a clean
main.Fix
New gate —
scripts/check-skill-count-claims.sh+.test.sh+skill-count-claim-exemptions.txt, wired intoci.ymlasskill-count-claim-gate(self-test first, then--check) and added toci-status.needs. It matches a closed set of composition-claim grammars across each plugin's README, manifest, and skill bodies, and compares the claimed number — spelled or numeric — against the skills the plugin actually has.The spelled form is why nothing found this before: the claims read "fourteen skills", not "14 skills", so no numeric grep ever hit them.
Four live drifts found and fixed:
testing/testing:auditentirelyverificationimprovementdocs-hygieneextract-ssotdoes use itThe
testingcase is the payoff: a count drift marked a wholly undocumented skill, so a reader on that front page had no way to learnauditexists. Both halves are fixed — the count and the missing table row.For
docs-hygienethe sentence was substantively false too, so it now names its subjects instead of counting them. That is the treatment this plugin's ownaudit-noiseenum-listshape prescribes for a hardcoded consumer count; the two agree on the diagnosis and differ only in enforcement.Scope is stated in the script header, not left implicit:
discipline, which has 17 skills, says exactly that.CHANGELOG.mdis out of scope. A dated entry describes the release it shipped with; rewriting it to match today's tree would falsify history.Portability annotations.
check-shell-portability.sh --allwas permanently red on 13 hits across three files, which made it unusable as an audit tool: a mode that always fails teaches you to ignore it, so a genuine 14th hit would land in the noise. Every one is a false positive in one of two classes — PCRE tokens as test data inresolve-convention-pattern.test.sh(the shorthand is the thing under test; the assertions prove the resolver rejects it), and Windows path fixtures (C:\temp\skills,\\server\share\skills,D:\work\acme\keepme). In each case the construct cannot be spelled portably because it is the input under test, so a per-siteportability-ok:with a stated reason is the correct disposition. The whole-fileportability-scope:escape was deliberately not used — the gate reserves it for its own fixture corpus, and these are neighbouring gates' tests.Review findings addressed
Three Codex P2s, all verified real against the tree before acting (
b8df7ec2):skills/directory, and the scan skipped any plugin without one — the stale-count-by-removal case the gate most needs, reported as success. Every plugin is now scanned; missing or emptyskills/is a count of zero.plugins/naming/README.md(correct) and one inclaude-config/skills/setup/SKILL.md(a prerequisite-scope subset count, now exempted with its reason).*[Ss]kill*on raw text, which rejects an all-capsSKILLS, dropping a heading before the case fold could reach it. The fold now runs first.Fixing 2 surfaced two defects the review did not name: joining double-counted every wrapped-adjacent claim (46 sites reported where 24 exist — the guard must re-test the pattern against the next line, not check containment of the matched extent, because most forms open with a boundary group that captures the join's own space); and one sentence can satisfy two grammars at once, so the guard re-tests every form rather than only the one that matched.
One Claude review finding (
3369fbc6): the--checkFAIL message reconstructed the plugin's real count asexpected + 1, which is wrong exactly when the zero-clamp fires — it reported "has one" directly above "should be zero". The count is now carried through the report row and read from it. Enforcement was never affected; the diagnostic was, in precisely the last-skill-removed case the zero-skill scan was built for.Verification
22 black-box assertions, with the negative ones load-bearing — ordinary prose like "gate one skill" and "nine skills moved into three new plugins" must not flag.
Mutation-tested. Reverting any of these turns the suite red: mismatch-reported-as-ok,
--check-never-fails, drop-the-minus-one-basis, drop-the-stale-exemption-guard, scan-CHANGELOG, exemption-never-hits, drop-the-case-fold, skip-plugins-without-a-skills-dir, no-line-joining, narrow-the-dedup-guard-to-one-form, and restore-the-expected + 1-reconstruction.Gates run locally, all green on the merged tree:
scripts/check-skill-count-claims.sh --check— 24 claims match the tree, 2 exemptedbash scripts/check-skill-count-claims.test.sh— all 22 assertions passedshellcheck --rcfile=.shellcheckrcandshfmt -don both new scripts and the three annotated filesactionlintonci.ymlscripts/check-shell-portability.sh --all—No unexcused GNU-only constructs in 1325 shell file(s)(wasexit 1with 13 hits)scripts/check-changelog-parity.shin all four modes — 212 headings preserved across 6 changed changelogsscripts/check-lane-coverage.sh --check— all 39 lanes reachable fromci-status.needsscripts/validate-plugins.sh, catalog sync, cheat-sheet generator tests, sync-manifest checkscripts/affected-tests.sh --run— all 11 selected suites passed, no unmapped filesThe gate proved itself on the merge.
mainlanded aclaude-opseleventh skill while this branch was open. Resolving that conflict took main's manifest wholesale (so the new skill's description survived rather than being clobbered by a stale ten-skill string), and the gate then verified the new claim:plugins/claude-ops/README.md:4and its manifest both read "eleven skills" against eleven skill directories.Version bumps + CHANGELOG entries:
testing0.6.2,verification0.5.3,improvement0.1.1,docs-hygiene0.17.2,claude-ops0.33.1,repo-hygiene0.10.7.(This section previously listed
claude-ops 0.32.10andrepo-hygiene 0.10.6— stale after the merge withmainrenumbered both. A hand-written count in prose going stale is the exact defect this PR gates against, and the PR body is not a surface the gate covers. Caught by review.)Related
claude-opseleventh skill mid-review; its count is what the gate verified on the mergedocs/adr/0016-source-skill-recommendation-from-the-catalog-not-the-listing.md— the decision record from feat(session-flow): add show-options, a nothing-hidden skill menu #3011plugins/docs-hygiene/skills/audit-noise/SKILL.md— theenum-listnoise shape, this gate's advisory counterpartscripts/check-skill-leaf-names.sh— the existing gate whose script/self-test/CI-lane shape this one follows