feat(ci): add skill-quality static gate lane for changed skills (#530)#593
Conversation
Wire the previously-uninvoked skill-quality checker into CI as a deterministic skill-regression net (issue #530, stage 1). A new `skill-quality-gate` job runs `scripts/check-changed-skills.sh` on every PR: for each skill under `plugins/*/skills/**` the PR touches, it runs `plugins/skill-quality/scripts/check-skill.sh` with the PR base as `CHECK_SKILL_BASE_REF`, so a rewrite that silently drops a `description` trigger phrase (auto-invocation regression) fails the check instead of the work lane's manual diff read. The job also schema-validates every `evals/evals.json` via the existing `check-jsonschema` composite action (validate-evals), and self-tests the orchestrator. The job never skips (a skipped required job reports success to branch protection); only the PR-diff step is event-gated, and it is added to the `ci-status` required aggregate. No plugin is modified, so no version bump. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
|
Claude finished @kyle-sexton's task in 3m 56s —— View job PR Review:
|
| File | Verdict |
|---|---|
.github/workflows/ci.yml |
✅ Correct — minor doc comment suggestion |
scripts/check-changed-skills.sh |
✅ Correct — one HEAD explicitness nit |
scripts/check-changed-skills.test.sh |
✅ Correct — grep -qF, trap, two missing scenarios |
None of the issues above would cause the gate to incorrectly pass or fail in production CI. The grep -q / grep -qF issue is the only one that could produce a false-pass in the test suite itself (in theory). I'd consider items 1 and 2 in the test file as the highest-priority follow-ups.
|
Claude finished @kyle-sexton's task in 5m 7s —— View job PR Review:
|
| Area | Verdict |
|---|---|
| Branch state | |
.github/workflows/ci.yml new job |
✅ Correct — one informational doc comment suggestion |
scripts/check-changed-skills.sh |
✅ Correct — one HEAD explicitness nit |
scripts/check-changed-skills.test.sh |
✅ Correct — grep -qF, trap, two missing scenarios |
The branch divergence / exclusion removal is the only merge-blocking concern. The test robustness items (grep -qF, trap, two missing scenarios) are the highest-priority code improvements; none would cause the gate to incorrectly pass or fail in production CI.
|
Thanks for the review. Classification of each finding (fixes in
(👍 is per-comment; GitHub allows one reaction per user.) |
|
Thanks — second round. Classification (code fixes in
(👍 is per-comment; GitHub allows one reaction per user.) |
#617) (#622) Closes #617 ## Summary Docs-only PRs — operator precedent codifications and other `docs/topics/` churn, a large share of current throughput per #617 — ran the full plugin contract suite (`plugin-gate`: Node + Python installs, every `plugins/**/*.test.sh`, manifest + catalog validation) and the full `miro-plugin` Node build for a diff that cannot affect either. Both lanes now short-circuit their expensive steps when the diff is confined to a small, positive, provably-inert allowlist, reporting an honest evaluated-and-not-applicable success. ## Fix **Mechanism — in-job detection, never a job skip.** GitHub's docs are explicit: *"If a workflow is skipped due to path filtering, branch filtering or a commit message, then checks associated with that workflow will remain in a 'Pending' state. A pull request that requires those checks to be successful will be blocked from merging."* whereas *"If, however, a job within a workflow is skipped due to a conditional, it will report its status as 'Success.'"* ([troubleshooting-required-status-checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks)). So workflow-level `paths:`/`paths-ignore:` is ruled out for required checks. It is doubly ruled out here: the sole required check is the `ci-status` aggregate, which fails unless every lane's `result` is exactly `success` — a conditionally-*skipped* job's result is `skipped`, not `success`, so even a job-level `if:` would red the aggregate. The safe pattern is therefore the one #593/#609 already use: the job always runs, self-tests its detector first, and gates only its inner expensive steps, staying `success` and reporting to branch protection either way. New files: - **`scripts/check-docs-only.sh`** — emits `docs_only=true|false` to `$GITHUB_OUTPUT`; `true` iff every path changed vs the base ref matches an allowlist prefix. Fail-closed toward *running* the full suite: an unresolvable base ref, an empty/missing allowlist, or an empty diff all emit `false` with a stderr note and exit 0 (a code PR is not an error — "has code" is never encoded as a non-zero exit that would red every code PR). - **`scripts/docs-only-paths.txt`** — the allowlist as external, extensible **data** (same shape as #609's token list). **Allowlist, not blocklist, by design**: a blocklist ("skip on any doc the validators don't currently read") silently turns false-green the day a validator starts reading a new doc; a positive list fails safe — any unlisted path, including a brand-new code input, forces the full suite. Seeded with `docs/topics/` only. Adding an entry is a two-part proof (inert + a self-test case). - **`scripts/check-docs-only.test.sh`** — the honesty proof, run unconditionally in both wrapped jobs before anything expensive. In `ci.yml`, `plugin-gate` and `miro-plugin` each gain: `fetch-depth: 0`, an always-run detector self-test, a PR-only detect step (`id: scope`), an `if: steps.scope.outputs.docs_only != 'true'` guard on every install/test step, and an explicit *"not applicable to a docs-only diff — reporting success"* log step. On `push` the detect step doesn't run, the output is empty, and every real step runs — main always gets the full suite. **Job classification** (only jobs that *cannot* be affected by a docs-only diff are scoped; when unsure, not scoped): | Lane | Scoped? | Rationale | |---|---|---| | `plugin-gate` | **Yes** | Heaviest lane. `run-plugin-tests.sh` is hermetic (tests build their own fixture repos); `validate-plugins.sh` reads only `README.md`, `docs/PLUGIN-ARTIFACT-PROTOCOL.md`, `docs/CATALOG-TAXONOMY.md` (all excluded from the allowlist) + `plugins/**` + toolchain files. A `docs/topics/`-only diff touches none of these. | | `miro-plugin` | **Yes** | Every step is `working-directory: plugins/miro`; a docs-only diff provably cannot touch it. | | `hygiene` | No | Markdownlint / typos / comment-hygiene **do** read the changed docs — this is the lane that *should* run on a docs PR. | | `skill-quality-gate` | No | Already self-scopes to changed skills (empty set on a non-skill docs diff); eval-schema step is cheap. | | `zizmor` | No | Advisory reusable workflow; scoping a reusable call partly belongs to `ci-workflows`. | | `hook-utils-sync`, `standards-contract-sync`, `cross-plugin-source-drift`, `silent-skip-gate`, `runner-policy` | No (deferred) | Provably docs-inert, but off the critical path (fast bash tests, no heavy installs) — wrapping them adds surface for negligible latency gain. Trigger to revisit: if any becomes install-bearing. | **Deferred with trigger:** scoping `miro-plugin` to `plugins/miro/**` specifically (skipping it on *any* non-miro diff, not just docs-only) is a larger, separate optimization broader than #617's docs-only framing; trigger: a dedicated follow-up issue for per-lane path scoping. ## Verification In the worktree (`ubuntu`/Git Bash): - **`bash scripts/check-docs-only.test.sh` → `PASS=16 FAIL=0`.** Cases pin: `docs/topics/`-only (incl. new nested files) → `true`; `README.md`, `docs/PLUGIN-ARTIFACT-PROTOCOL.md`, `docs/CATALOG-TAXONOMY.md` → **`false`** (the grep payoff — these are consumed by `plugin-gate`); any non-topics `docs/**`, any `plugins/**` incl. `SKILL.md`, `scripts/`, `.github/`, toolchain lockfiles, a sibling prefix (`docs/topics-archive/`), and mixed docs+code → `false`; unresolvable base ref → `false` exit 0; empty allowlist → `false`; `$GITHUB_OUTPUT` written. - **`shellcheck --rcfile .shellcheckrc`** on both scripts → clean. **`shfmt -d`** → no diff. **`actionlint .github/workflows/ci.yml`** → clean. - **Provenance of the "inert" claim:** `git grep` over `plugins/**/*.test.sh`, `validate-plugin-contracts.mjs`, `generate-catalog.mjs`, and the manifests confirmed `generate-catalog.mjs --check` consumes `README.md` and `docs/CATALOG-TAXONOMY.md` and `validate-plugin-contracts.mjs` requires `docs/PLUGIN-ARTIFACT-PROTOCOL.md` — which is exactly why those are excluded from the allowlist and the docs-only win is `docs/topics/`-scoped, not "all markdown". - **zizmor:** base ref flows via `env: BASE_REF` (no `${{ }}` interpolation inside `run:`), matching the existing `hook-utils-sync`/`skill-quality-gate` steps; `persist-credentials: false` retained; no new permissions. Honest scope note: the win is narrow by construction — it covers `docs/topics/`-only PRs and deliberately does **not** cover README/philosophy edits, `SKILL.md` edits (code), or plugin-`CHANGELOG` edits (code). Narrow-but-sound is the point. ## Related - #532 — false-green / liveness-assertion convention this honors (evaluated-and-not-applicable success, not a silent skip). - #593 — the merged `skill-quality-gate` whose never-skip, self-test-first, event-gated job shape this mirrors. - #609 — open PR also touching `ci.yml` (adds the `portability-lint` lane + one line to the `ci-status` needs list). Different file regions from this PR (new job block + needs line vs. the `plugin-gate`/`miro-plugin` step bodies); composes without conflict. Whichever merges second rebases. - No `plugins/<name>/` directory is touched, so no version bump / CHANGELOG entry (matching CI-only precedent #490/#593). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y diffs (#628) (#659) ## Summary Docs-only PRs still paid the `hygiene` job's path-scoped linter cost — `actionlint` and four `check-jsonschema` steps — for a diff that provably cannot affect any of them. #617 (via #622) short-circuited `plugin-gate` and `miro-plugin`, which cut runner-minutes but not the merge-latency bottleneck. This applies the SAME self-test-first, fail-closed, inner-step-gated discipline to `hygiene`'s genuinely path-scoped subset, so those runs report an honest evaluated-and-not-applicable success instead of running. `ShellCheck` and `exec-bit` are deliberately NOT gated: both scan the whole repo extension- and directory-agnostically (`ShellCheck` lints every tracked `*.sh`/`*.bash` via `git ls-files`; `exec-bit` flags every tracked shebang file recorded `100644` via `git grep -- .`), so a shell or shebang file added under an otherwise docs-only prefix like `docs/topics/` is real input they must still catch. They run on every diff alongside the docs-linting checks that read the changed docs directly: `markdown`, `typos`, `editorconfig`, `gitleaks`, `eol-renormalize`, `comment-hygiene`, `machine-specific-paths`. > **Scope of the perf win (honest):** the initial revision of this PR also gated `ShellCheck` (~53s, the dominant cost) and `exec-bit`. Bot review (P2, thread on `ci.yml`) surfaced that both are whole-repo scanners, so gating them opened a **fail-closed hole** — a docs-only PR adding a mode-`100644` shebang file (or a `*.sh`) under `docs/topics/` skipped both and the ternary mapped them to `success`, letting a file the commit procedure requires `exec-bit` to catch merge unlinted. Ungating them (commit `f73be27`) closes the hole but removes the dominant ShellCheck skip, so the remaining docs-only win is limited to `actionlint` + the four `check-jsonschema` steps. A perf-recovery follow-up (cache/pin the ShellCheck install, which downloads a release tarball every run) is tracked separately and lives in `melodic-software/ci-workflows`, not this repo. ## Fix Reuses the existing `scripts/check-docs-only.sh` detector and `scripts/docs-only-paths.txt` allowlist from #622 unchanged — no new detector. In the `hygiene` job (`.github/workflows/ci.yml`): - Added the always-run `Test the docs-only detector` self-test and a PR-only `Detect a docs-only diff` step (`id: scope`), mirroring `plugin-gate`/`miro-plugin`. The checkout already had `fetch-depth: 0`, so the base ref resolves. - Gated exactly the five path-scoped steps with `if: steps.scope.outputs.docs_only != 'true'`: `actionlint`, `marketplace_schema`, `plugin_schema`, `dependabot_schema`, `workflow_schema`. `shellcheck` and `exec_bit` stay unconditional. - The job never skips and the aggregator is never weakened. The `hygiene` job uses `continue-on-error: true` per step plus a `CHECK_RESULTS` feed into `scripts/aggregate-hygiene-results.sh`, which fails closed on any non-`success` outcome (including `skipped`). A gated step that skips would therefore red the job — so each gated entry in `CHECK_RESULTS` maps the intentional docs-only skip to `success` in the expression layer: ```yaml actionlint=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.actionlint.outcome }} ``` This is honest, not a swallow: the ternary condition (`docs_only == 'true'`) is the exact inverse of each step's gate, so `success` is fed ONLY for a step that provably did not run — no real outcome is ever masked. The two whole-repo scanners (`shellcheck`, `exec-bit`) feed their raw `.outcome`. The aggregator script is untouched and still fails closed on any real `failure` (or a `skipped` it isn't told to tolerate). This is the same evaluated-and-not-applicable success `plugin-gate` reports with its log step, expressed here through the feed the hygiene job already uses. - Added an explicit `Report docs-irrelevant checks not applicable` log step (gated on `docs_only == 'true'`) so a human reading a green run understands why those checks show skipped. Also extended the two non-workflow artifacts per the established two-part-proof discipline: - `scripts/docs-only-paths.txt` — updated the inertness-proof comment to name only the genuinely path-scoped gated subset (`actionlint` + the four schema checks, none of which read `docs/topics/`) and to state explicitly that `ShellCheck` and `exec-bit` scan the whole repo and stay unconditional (as do the docs-linters). Allowlist itself unchanged (still `docs/topics/` only — no growth). - `scripts/check-docs-only.test.sh` — added three cases pinning the remaining `check-jsonschema` inputs to `false` (`.claude-plugin/marketplace.json`, `plugins/*/.claude-plugin/plugin.json`, `.github/dependabot.yml`); the `actionlint` / workflow-schema inputs (`.github/workflows/ci.yml`) were already pinned false. These fail the moment someone widens the allowlist to cover an input a gated check reads. ## Verification Run in the worktree (Git Bash): - **`bash scripts/check-docs-only.test.sh` → `PASS=19 FAIL=0`.** Confirms `docs/topics/`-only → `true` and every gated check's input (`.github/workflows/ci.yml`, `.claude-plugin/marketplace.json`, `plugins/*/.claude-plugin/plugin.json`, `.github/dependabot.yml`, lockfiles) → `false`. - **`scripts/aggregate-hygiene-results.sh --self-test` → `Hygiene result aggregation contract passed.`** The aggregator is unchanged and still fails closed on a named empty/`failure`/non-`success` outcome. - **`actionlint .github/workflows/ci.yml` → clean.** Validates the five added gate conditions and the five ternary expressions in `CHECK_RESULTS`. - **`shellcheck --rcfile .shellcheckrc` and `shfmt -d`** on the edited/reused scripts → clean, no diff. Honest scope of what this PR's own CI proves: this PR touches `.github/` and `scripts/`, so on it `docs_only=false` and the full hygiene suite runs — a live proof only of the run-path (nothing is wrongly skipped on a code diff). The skip-path is not live-observable here by construction; it is proven by the detector unit test (`docs/topics/`-only → `true`) plus the gate/expression logic being the exact inverse of each other. Correctness of the intentional-skip → `success` mapping rests on that unit test and the expression, not on a fabricated observation. **Versioning/changelog:** none. No `plugins/<name>/` directory is touched — this is a repo-root CI-only change — so per the precedent #622 followed (matching CI-only #490/#593) there is no plugin version bump or CHANGELOG entry. Closes #628 ## Related - #628 — this issue. - #617 (closed) / #622 (merged) — the established precedent this extends: same `scripts/check-docs-only.sh` detector, same self-test-first / fail-closed / inner-step-gated discipline, applied to `hygiene` instead of `plugin-gate`/`miro-plugin`. - #532 — the false-green / liveness class this must not reintroduce. A job-level skip (or teaching the aggregator to pass on `skipped`) is exactly that shape and is deliberately avoided: the job always runs, the detector self-tests first, and only inner steps are gated with their skip reported as an honest not-applicable success. 🤖 Generated with a Claude Code implementation subagent (issue #628) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Summary Two repo-local CI gates from the 2026-07-20 merged-PR quality audit, both wired into `.github/workflows/ci.yml` as new required lanes. Each follows the established repo-local gate precedent (#593/#609/#622/#628): a self-testable script, a dedicated CI step, fail-closed behavior, and clear diagnostics — with a stale-guarded baseline that grandfathers pre-existing debt without red-lining fixtures/plugins a member issue already owns. Placement is repo-local only; promotion to `ci-workflows` is explicitly deferred ("if it stabilizes") and out of scope. No plugin version bump: the CI scripts and `ci.yml` carry no plugin version to bump. The only `CHANGELOG.md` edits in this PR are a format-only normalization of five pre-existing unbracketed changelogs (see Gate 2) — no version fields change. This repo has no repo-root version artifact. ## Fix **Gate 1 — orphaned-fixture (`scripts/check-orphaned-fixtures.sh`, lane `orphaned-fixture-gate`).** Repo-wide static scan of every file under a skill's `**/evals/fixtures/`. A fixture is *consumed* when its skill-relative path appears in the sibling `evals/evals.json` (an eval `files[]` entry), or its basename appears — **bounded by non-filename characters, so a referenced `valid.json` does not also mark a `valid.json.bak` sibling consumed** — in that `evals.json` or any `*.test.*` file in the plugin. Consumption matching errs toward not-blocking a legitimate fixture. Scope is `evals/fixtures/` specifically — unit-test fixture dirs (`tests/fixtures`, `scripts/fixtures`) are excluded because those are often generated or loaded by directory, not named, and a name-matcher cannot honestly grade them. `--check` fails on an un-grandfathered orphan **and** on a stale baseline entry (one shadowing no orphan), so the baseline cannot outlive its debt. Existing debt grandfathered in `scripts/orphaned-fixtures-baseline.txt` — **exact fixture paths, matched by full-string equality (not prefix)**, so the baseline is a snapshot of today's known orphans: a new orphan under an already-listed directory, or a `<name>.json.bak`/`<name>.jsonl` sibling of a listed file, matches no line and is red-lined rather than silently grandfathered. Every entry cites an owning issue: - The `plugins/autonomy/skills/setup/evals/fixtures/` corpus (security-binding golden suite + `otlp-demo` sample corpus), enumerated file-by-file — tracked by #662; autonomy is WP-lane-owned. - `plugins/knowledge/skills/youtube-digest/evals/fixtures/variation-matrix-backlog.json` — a data file named only from `SKILL.md`/vendor prose, consumed by no grader — tracked by #688 (grade-or-demote). **Gate 2 — CHANGELOG-parity (`scripts/check-changelog-parity.sh`, lane `changelog-parity-gate`).** Covers both gaps the audit named: - `--check` (static, every event): a `plugins/<name>/.claude-plugin/plugin.json` carrying a `version` must ship `plugins/<name>/CHANGELOG.md`. Catches the cited violator — `autonomy` shipped 5 minor bumps with no CHANGELOG.md. - `--check-bump <base>` (PR-only, mirrors the `sync-*.sh --check-bump` gates): a manifest version change must **add** a `## [<version>]` release heading for the new version — present at head, absent at `<base>`. The heading is matched as a fixed string anchored to line start, so the version appearing in prose or a fenced example never satisfies (or falsely pre-exists) the entry, and SemVer build metadata (`1.0.1+build.1`) never leaks into a regex. Three distinct failures, never conflated: `UNDOCUMENTED BUMP` (no entry — an unrelated whitespace/title/old-release edit cannot satisfy the gate), `CHANGELOG FORMAT` (the version is present but written unbracketed, `## <version>`), and `PRE-EXISTING CHANGELOG ENTRY` (the heading already existed at `<base>`, so the bump shipped no fresh note). Applies to **every** plugin, grandfathered or not. Enforcing the bracketed `## [<version>]` Keep-a-Changelog heading surfaced five pre-existing changelogs still on the unbracketed `## <version>` form (`discovery`, `docs-hygiene`, `knowledge`, `playbooks`, `session-flow`); this PR normalizes them to the documented convention (format-only, headings only — no version or entry-content changes). Reconstructing `autonomy`'s history is autonomy-plugin (WP-lane) work, so it is grandfathered by name in `scripts/changelog-parity-baseline.txt` (stale-guarded: `--check` fails if it later gains a CHANGELOG.md or drops its version). The baseline never relaxes `--check-bump`. Both lanes are registered in the `ci-status` `needs:` aggregate (the single source of truth for required lanes). ## Verification All output below is real, run in the worktree against `origin/main`. **Self-tests (run unconditionally in CI so a broken detector cannot mask a regression):** ``` =========== ORPHAN GATE SELF-TEST =========== ok: files[]-referenced fixture passes --check ok: test-asserted fixture passes --check ok: suffix sibling of a referenced fixture red-lines (bounded basename match) ok: un-consumed fixture fails --check (synthetic orphan caught) ok: grandfathered orphan (exact path) passes --check ok: prefix-sibling of a baselined path red-lines (exact-match, no grandfather leak) ok: stale baseline entry fails --check ok: discover labels CONSUMED and ORPHAN ok: bad mode -> exit 2 PASS=9 FAIL=0 =========== CHANGELOG GATE SELF-TEST =========== ok: versioned plugin with CHANGELOG passes --check ok: versioned plugin without CHANGELOG fails --check (synthetic gap caught) ok: grandfathered missing-changelog passes --check ok: stale baseline entry fails --check (reported exactly once) ok: bump + '## [x.y.z]' entry passes --check-bump ok: bump + unbracketed heading -> FORMAT error (not UNDOCUMENTED) ok: bump adding a NEW '## [x.y.z]' entry (absent at base) passes --check-bump ok: bump reusing a base-pre-existing '## [x.y.z]' entry fails --check-bump ok: SemVer build-metadata version with a proper entry passes (no regex leak) ok: version string in prose/indented example does not satisfy the anchored heading match ok: bump + unrelated changelog edit (no new-version entry) fails --check-bump ok: bump without changelog fails --check-bump (synthetic undocumented bump caught) ok: unchanged version passes --check-bump ok: new plugin skipped by --check-bump ok: unresolvable base ref -> exit 2 ok: --check-bump without base ref -> exit 2 ok: bad mode -> exit 2 PASS=17 FAIL=0 ``` **Both gates pass on current HEAD (existing debt grandfathered):** ``` --- orphaned-fixture-gate: --check --- No orphaned eval fixtures (every file under **/evals/fixtures/ is consumed by a grader or grandfathered). --- changelog-parity-gate: --check --- Every versioned plugin has a CHANGELOG.md (or a stale-guarded baseline entry). --- changelog-parity-gate: --check-bump vs origin/main --- Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry. ``` **Live synthetic catch — Gate 1 (orphaned fixture, no baseline):** ``` ORPHANED FIXTURE: plugins/demo/skills/s/evals/fixtures/never-referenced.json is under evals/fixtures/ but no eval case references it and no test asserts on it. Reference it from a grader (an eval files[] entry or a test), delete it, or grandfather it in /nonexistent with the owning issue. exit=1 ``` **Live synthetic catch — Gate 2 (missing changelog, no baseline):** ``` MISSING CHANGELOG: plugins/demo carries a versioned plugins/demo/.claude-plugin/plugin.json but no plugins/demo/CHANGELOG.md. Add plugins/demo/CHANGELOG.md, or grandfather 'demo' in /nonexistent with its owning issue. exit=1 ``` **Static analysis:** `shellcheck --rcfile .shellcheckrc` clean on all four new scripts; `actionlint .github/workflows/ci.yml` clean; new `.sh` files committed mode 100755, all files LF. Closes #663 ## Related - #663 — this issue. - #662 — sibling autonomy issue owning the security-binding orphan burn-down (grandfathered by Gate 1's baseline). - #688 — grade-or-demote triage for the `knowledge` youtube-digest orphan (grandfathered by Gate 1's baseline). - #593 / #609 / #622 / #628 — the repo-local gate precedent (self-testable script + dedicated `ci.yml` lane + fail-closed) this follows structurally. 🤖 Generated with a Claude Code implementation subagent (issue #663) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
This repo edits its own skills daily via the autonomous pipeline, yet no CI lane invoked the
skill-qualitychecker — 120evals/evals.jsonwent unexecuted and the 17-check static gate (skill-quality:check) was invoked by nothing. This wires that checker into CI as a deterministic skill-regression net.Implements stage 1 only of #530 (the "cheap, now" deterministic lane). Stages 2-5 (model-graded judges, frozen regression suite, pass^k autopilot-unlock metric, clean-state isolation) remain a separate decompose target per the ratified operator decision on the issue.
Fix
New
skill-quality-gatejob in.github/workflows/ci.yml, added to theci-statusrequired aggregate:scripts/check-changed-skills.sh) — on a PR, for each skill underplugins/*/skills/**the diff touches, runsplugins/skill-quality/scripts/check-skill.shwithCHECK_SKILL_SKILLS_ROOTset to the owning plugin'sskills/dir andCHECK_SKILL_BASE_REFset to the PR base. Because the checker's trigger-keyword-preservation check diffs against that base, a rewrite that silently drops adescriptiontrigger phrase (an auto-invocation regression) fails the check — replacing the work lane's manual high-blast-radius diff read with a deterministic one. Vendor subtrees map to the owning skill; deleted skills are filtered; any FAIL (or env error) exits non-zero.markdownlint(check 6) is deliberately skipped here (CHECK_SKILL_SKIP_MARKDOWNLINT=1) — SKILL.md markdown is already gated by the hygiene lane.check-jsonschemacomposite action schema-validates everyevals/evals.jsonagainst the bundledplugins/skill-quality/reference/evals.schema.json.scripts/check-changed-skills.test.sh) — runs first so a broken orchestrator cannot mask a real regression behind a green gate.No silent-skip (#445 precedent): the job itself never skips (a skipped required job reports success to branch protection) — only the inner PR-diff step is event-gated (mirroring
hook-utils-sync's--check-bump), and the self-test + eval-schema steps give push-to-main a passing path. Missing base ref or missing checker fails closed (exit 2).No new reusable action: reuses this repo's own local script plus the already-referenced
check-jsonschemacomposite action — no change tomelodic-software/ci-workflows.No plugin modified → no version bump (consistent with CI-only PR #490, which bumped nothing). No plugin frontmatter, triggers, hooks, or cross-plugin contracts are touched.
Verification
Local, in the worktree:
bash scripts/check-changed-skills.test.sh→PASS=8 FAIL=0(covers changed-skill detection, vendor-subtree mapping, dedupe, deletion filtering, env passthrough, pass/fail aggregation, fail-closed on bad base ref / missing checker).shellcheck --rcfile .shellcheckrc scripts/check-changed-skills.sh scripts/check-changed-skills.test.sh→ clean.check-skill.shagainst real skills (scripts/check-changed-skills.sh origin/main):PASS, exit 0.'lint my skill'trigger fromskill-quality/check) →FAIL: dropped trigger keyword(s) vs origin/main (auto-invocation regression): 'lint my skill', exit 1.check-jsonschema --schemafile plugins/skill-quality/reference/evals.schema.jsonover allplugins/*/skills/*/evals/evals.json→ 120/120 pass today (glob matches exactly the 120 eval sets, excludes vendor).Closes #603
Related