4 Commits

Author SHA1 Message Date
1ec3e8a1ea docs(skills): stop routing content at the README this branch deleted
skill-author still told authors to move description overflow "to the
body or to README.md" while this branch deleted every per-skill
README.md, every references/README.md and the README scaffold template.
factory-audit's skill-file-structure.md bans non-spec files at the
skill root, and the line that used to carve README out of that rule
went with them. So skill-author created the file, factory-audit failed
it, and nothing read it. 8ce5392 fixed the two scripts and missed the
reference prose.

The two contract.md files now differ deliberately: a skill's overflow
goes to the body or a references/ file, an agent's to the body alone,
because an agent is a single file with no references/ directory to
disclose to. agent-description-quality.md's "the plugin's README.md" is
left alone, plugin READMEs being the ones that survive.

Deleting retrofit.md also dropped three instructions baa2f5d did not
restore with the cut list, two of which retrofit.md itself recorded as
having no validator behind them: re-cite sources.md's Contributing
files after content moves, since validate-provenance exits 0 on exactly
that drift, and re-check a relocated gate's reachability, since a
Gotcha moved into one flow's file is invisible to the others and the
word counts improve either way. The third is that boundary clauses are
plural — contract.md read as a cap where git-remotes carries four.

Also: contract.md named an unqualified scripts/validate.sh that does
not exist in skill-author, which skill-file-structure.md calls a hard
error; and agent-body-and-delegation.md's simile pointed at a stale
README row as the characteristic skill defect, a defect class that can
no longer occur, replaced with a SKILL.md naming a references/ file
that is not there.

skill-author 1.0.4, agent-author 1.0.3, factory-audit 1.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
2026-09-20 12:34:42 +00:00
c84f1f4145 docs: close the self-contradictions left by the branch's own cuts
CONTEXT.md used two terms it no longer defines. This branch deleted the
Preload tax and Skill context contract entries as audit finding 31, but
the Hand-invoked skill definition and the example dialogue still used
both, bolded, which is this file's convention for a defined term. The
definitional file contradicted itself while AGENTS.md tells every
session to read it as authoritative. Rephrased in place, the way
e2e957e handled the one the audit's own note records.

ADR-0024 said 10 .bats files deploy across 6 skills; ADR-0025 merged
two of those directories the next day, on this branch, leaving 5. It
was also the only ADR ADR-0025 invalidated without an amendment banner,
as was ADR-0016, which still named agent-audit in the present tense as
the live enforcer. Both get the banner the other nine carry, and the
figure and names are corrected in place as well, since these sit in
text asserting present fact rather than a superseded decision.

ADR-0019's correction block from 1614bce was inserted mid-paragraph and
swallowed the original's trailing sentence, leaving the quote malformed
and the next line starting lowercase mid-sentence. gates.md took the
same correction and is not affected.

In the audit note: two of §12's five open follow-ups were already
closed (e4ed343 repointed the a8cd5e8 citations at 598a7c3; #101 closed
2026-09-16, so Closes #101 is a no-op), the same stale hash sat at :330
with a wrong line number, the vale-wrap counts had drifted from 63/19
to 65/14 and are now pinned to a commit per §1's own convention, and
the deleted-suite tally said eight where the diff shows nine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
2026-09-20 12:34:28 +00:00
384756b343 test(gates): pin hook wiring and enforce the bats TAP plan
The repo's gates were not pinned to their wiring. Deleting the
check-skill-version-bump block from .pre-commit-config.yaml left the
whole suite green; deleting eight blocks at once, run-tests among them,
also left it green. Only 4 of 20 hook ids had their wiring pinned
anywhere, so a merge conflict resolved badly could stop the suite
running at pre-push forever while every test still reported green.

test-adr0020-contract now derives the repo-authored hooks from the
repo: local entries and pins each one's id, entry and stages against an
explicit expected set, both directions, with the same non-vacuity
guards the file already applies to its own fixtures. Upstream hooks and
their rev: values are untouched, so a rev bump does not churn the test.
Mutation-checked: a removed block, a repointed entry and a hook moved
off pre-push each go red; a rev bump, a comment edit and reordering
stay green. 29 -> 44 assertions.

run-bats computed each file's TAP plan and then discarded it, so a
process printing "1..10", three ok lines and exit 0 was counted as
"3 tests, 0 failures" with seven tests silently gone. That is exactly
the wrapper-swallows-the-status case the runner's own comment puts in
its threat model, and the plan was the only surviving signal. The plan
is now enforced in both directions when a file emits exactly one.

Also: test-no-pipefail-early-exit-grep's live-tree floor goes from 20 to
50 against an actual 57, matching test-vale-wrap's per-glob discipline,
and test-vale-wrap's header names the real path to vale-wrap.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
2026-09-20 12:34:07 +00:00
8cfd54f925 fix(gates): close the review findings in the gates and their docs
Two reproduced bugs in check-skill-version-bump:

- The origin/main-tip check fired even when the pushed skill was
  byte-identical to main's tip, so a cherry-pick or backport failed a
  push that ships nothing. The merge-base intersection ea119d8 added
  covers that only when some base carries the content, which a
  criss-cross history gives and a linear one does not. A new
  same_subtree compares tree object ids, so the exemption holds
  whatever route the history took.
- The failure line reported "baseline: none" when the skill was absent
  at every merge-base but present at the tip, and the Fix: line then
  named no version. The author writes the natural 1.0.0 and gets a
  second blocked push. It now falls back to the tip's version.

ADR-0022 is not amended: the documented behaviour does not change, and
ea119d8 set the precedent by fixing the same failure class script-only.

1614bce verified that executables.allow grants are version-blind and
corrected ADR-0019, gates.md and apm.yml, but missed the gate script's
own header and its operator-facing FAIL message, which still told the
reader deployment was silently broken, and gates.md's hook summary,
which still called it a silent-failure guard. All three now match.

Also: README's offline guarantee carries the populated-apm_modules
condition gates.md and AGENTS.md already state; the scripts/ layout row
drops "sync" for the three deleted sync scripts; the check-rtk-prefix
README rationale names the 12 subdirectory READMEs that survive rather
than the skill-root ones this branch deleted; gates.md re-cites its
three head -1 sites by enclosing function per its own :238 rule; and
deploy-manifest drops a pointer to a provider-manifest.sh that has
never existed on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
2026-09-20 12:33:50 +00:00
25 changed files with 485 additions and 75 deletions

View File

@@ -237,9 +237,11 @@ repos:
entry: scripts/check-rtk-prefix.sh
language: script
files: '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$'
# README.md is excluded on purpose, not by oversight. A skill-directory
# README is consumer-facing prose that no agent ever loads, and the
# `git clone` lines in the six tests/README.md files are setup
# README.md is excluded on purpose, not by oversight. The 12 README.md
# files still in scope sit in a skill's scripts/, tests/ and assets/
# subdirectories -- consumer-facing prose that no agent ever loads (the
# skill-directory READMEs this was first written for are deleted) -- and
# the `git clone` lines in the six tests/README.md files are setup
# instructions for a third party who has no rtk installed. Prefixing
# those would be actively wrong -- see ADR-0023's consumer section.
exclude: '(^|/)README\.md$'

View File

@@ -30,10 +30,10 @@ _Avoid_: router body, thin body
**Hand-invoked skill**:
A skill reached only by typing its slash command, declared `disable-model-invocation: true`. The host
withholds it from the model-visible listing entirely, so it pays no preload tax and its description
becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other skill can route to
a hand-invoked skill** — a `` Call `x` `` step in another skill's body stops working the moment `x`
takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`.
withholds it from the model-visible listing entirely, so it costs nothing in always-on context and
its description becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other
skill can route to a hand-invoked skill** — a `` Call `x` `` step in another skill's body stops
working the moment `x` takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`.
_Avoid_: manual skill, disabled skill
**Delegation discipline**:
@@ -155,9 +155,9 @@ _Avoid_: namespace, category
> **Dev:** "This one only fires when someone types the slash command. Does its description still need
> trigger words?"
> **Maintainer:** "No — that's a **hand-invoked skill**. The host withholds it from the model-visible
> listing, so it pays no **preload tax** at all and the description is human-facing text."
> listing, so it costs nothing in always-on context and the description is human-facing text."
> **Dev:** "Then the body can be as long as it needs to be?"
> **Maintainer:** "Different budget. The **skill context contract** gates the body whether or not the
> **Maintainer:** "Different budget. ADR-0020's authoring rules gate the body whether or not the
> skill is model-invoked — the description competes with every other skill's description, the body
> competes with the caller's live conversation. Four mutually exclusive flows means a **dispatch
> body**: table in `SKILL.md`, one `references/` file per flow."

View File

@@ -12,7 +12,7 @@ Content ships as six installable plugins, each an apm (Agent Package Manager) pa
| `providers/claude-code/` | Claude Code adapter, deployed to `~/.claude/` via `scripts/install.sh` |
| `core/` | Provider-agnostic always-on content — `core/AGENTS.md` and `core/instructions/` |
| `docs/` | Specs (`docs/spec/`), architectural decisions (`docs/adr/`), governance, research, and notes |
| `scripts/` | Install, sync, and check scripts used by the git hooks |
| `scripts/` | Install and check scripts used by the git hooks |
| `tests/` | `run-tests.sh`, `run-bats.sh`, the `test-*.sh` suites, and the bats submodules |
The six plugins:
@@ -86,7 +86,7 @@ pre-commit run --hook-stage pre-push --all-files
See [`docs/spec/gates.md`](docs/spec/gates.md) for what each hook enforces and why.
**Offline?** No pre-push hook needs the network: root `apm.yml`'s marketplace has no remote package entries (the last one, `mattpocock-skills`, was removed), so `apm-pack-check-clean` resolves everything from local sources. All pre-push hooks pass offline.
**Offline?** No pre-push hook needs the network **once `apm install` has populated `apm_modules/`**. Root `apm.yml`'s marketplace has no remote package entries (the last one, `mattpocock-skills`, was removed), so `apm-pack-check-clean` resolves everything from local sources, and `apm-audit-ci`'s install-replay is cache-only against a populated install. On a **fresh clone** there is no cache: `apm-audit-ci`'s `deployed-files-present` fails outright, and its `drift` and `config-consistency` checks clone from the holocron remote. The offline guarantee is a property of a populated `apm_modules/`, not of the hook set — run `apm install` once on a new checkout and it holds from then on.
## Editing plugin content

View File

@@ -1,5 +1,13 @@
# Plugin-scope agent-author omits `tools:` and all Claude-only fields from `.apm/agents/*.agent.md`
**Amended by ADR-0025 (2026-09-15).** `agent-audit` was removed and its flow merged with
`skill-audit`'s into `factory-audit`, which dispatches to a skill flow and an agent flow at Step 0.
Read `agent-audit` below as `factory-audit`'s agent flow, and `validate.sh` as that flow's
validator. The decision is unchanged — plugin-scope `.apm/agents/*.agent.md` still carries only the
allowlisted fields, and the allowlist is still read as data from a reference file, now
`factory-audit/references/agent-field-inventory.md`. The present-tense skill names below are
updated accordingly.
This ADR is a narrower, downstream consequence discovered while designing issue #89's
implementation under ADR-0015's broader direction (Microsoft APM replaces hand-authored
plugin/marketplace authoring). It does not restate ADR-0015's rationale — see that ADR for
@@ -47,8 +55,8 @@ Absent `tools:` means inherit-all-tools on both harnesses — the one value that
on either target, unlike a present, harness-specific value that is guaranteed wrong on at least
one of them.
`agent-audit`, at plugin scope, is intended to flag — as a **SUGGESTION**, not a FAIL, since
this is an upstream schema limitation rather than an authoring mistake — any agent whose
`factory-audit`'s agent flow, at plugin scope, is intended to flag — as a **SUGGESTION**, not a
FAIL, since this is an upstream schema limitation rather than an authoring mistake — any agent whose
description or body implies a need for tool restriction or a Claude-only behavior the
frontmatter can no longer express. This would give visibility into the gap without pretending
the schema can do something it can't. **Not yet implemented**: `check_apm_agent_file()` in
@@ -71,7 +79,7 @@ write Claude's space-separated `tools:` string. Rejected because it ships a valu
silently wrong (or possibly a hard error) on Copilot, and which harness "wins" would be an
arbitrary, undocumented asymmetry.
**Same as above, but `agent-audit` flags the cross-harness breakage as a tracked finding
**Same as above, but `factory-audit` flags the cross-harness breakage as a tracked finding
(rejected).** Rejected for the same core reason — it still ships a wrong value to a real
harness. Tracking the breakage doesn't prevent it, and the chosen decision already gets
equivalent visibility (a SUGGESTION finding) without ever shipping the wrong value in the first
@@ -141,7 +149,7 @@ admitted as the portable-by-construction half of what was lost. It restores a re
confirmed write fence against the tool-call path, not a complete write sandbox. The consequence
below is narrowed accordingly.
Enforcement follows the decision: `agent-audit`'s plugin-scope validator reads its allowlist as
Enforcement follows the decision: `factory-audit`'s plugin-scope validator reads its allowlist as
data from the `apm-agent-allowlist` section of
`plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md` (now
`factory-audit/references/agent-field-inventory.md`, see ADR-0025), and that line now reads
@@ -161,9 +169,9 @@ whether a field is safe under verbatim copy in a single vendor-neutral file.
Plugin scope is now "directory containing `apm.yml` → single vendor-neutral file lands in
`<root>/.apm/agents/`." Project and user scope, and the rest of ADR-0005, are unaffected.
- **ADR-0008 is partially superseded** — its counterpart-derivation/pair-validation mechanism
no longer applies at plugin scope; `agent-audit` takes the single file directly there. Project
no longer applies at plugin scope; `factory-audit` takes the single file directly there. Project
and user scope, where a real pair still exists, are unaffected.
- **ADR-0009 is not superseded.** The mechanism it established — `agent-audit` reading field
- **ADR-0009 is not superseded.** The mechanism it established — `factory-audit` reading field
lists from `references/field-inventory.md` (now
`factory-audit/references/agent-field-inventory.md`, see ADR-0025) rather than hardcoding them,
with a `source_keys`

View File

@@ -85,12 +85,13 @@ question is only what catches a missed edit.
> does not silently stop the hook deploying. Whether apm behaved this way when this ADR was written
> was not established. **The decision stands** — `scripts/check-executables-allow-sync.sh` is now
> justified by this repo's own requirement that the key track `plugins/kyberforge/apm.yml`'s
> `version:`, not by an apm-level failure mode. `docs/spec/gates.md` carries the same correction. A comment in the `executables:` block is not enough:
this repo gates generated-content drift, marketplace mirror drift and vale style drift
deterministically, and a silent-staleness failure is strictly worse than any of them. So
`scripts/check-executables-allow-sync.sh` runs at pre-push, parsing `version:` out of
`plugins/kyberforge/apm.yml` and asserting root `apm.yml` carries the matching
`kyberforge#<version>` key. The comment stays as the human-facing pointer; the hook is what
> `version:`, not by an apm-level failure mode. `docs/spec/gates.md` carries the same correction.
A comment in the `executables:` block is not enough: this repo gates generated-content drift,
marketplace mirror drift and vale style drift deterministically, and a silent-staleness failure is
strictly worse than any of them. So `scripts/check-executables-allow-sync.sh` runs at pre-push,
parsing `version:` out of `plugins/kyberforge/apm.yml` and asserting root `apm.yml` carries the
matching `kyberforge#<version>` key. The comment stays as the human-facing pointer; the hook is what
actually holds. It parses with PyYAML where importable and falls back to a two-shape scan
otherwise, so a missing pip package cannot become the thing that blocks every push.

View File

@@ -13,6 +13,14 @@ ADR-0015. The root `marketplace:` block in `apm.yml` and the compiled
`.claude-plugin/marketplace.json` it produces are **kept** — see "Also delete the marketplace
catalogue" under considered options.
**Amended by ADR-0025 (2026-09-15).** The decision stands unchanged — apm is the only supported
install path, and `.apm/` still ships the per-skill `tests/` directories this ADR accepted as
dev-fixture leakage. What moved is **consequence 2's skill count**. `skill-audit` and `agent-audit`
merged into `factory-audit`, collapsing two `.bats`-carrying skill directories into one, so the same
10 `.bats` files now deploy across **5** skills, not the six counted here on 2026-09-14. The figure
below is corrected in place; "all six `apm.yml` files" in the same paragraph counts plugins, not
skills, and is unaffected.
## Context
ADR-0018 moved this repo's own consumption of its own plugins onto `apm install`. From that point
@@ -167,7 +175,7 @@ README note is the only available mitigation, and a note is not a gate.
**2. Consumers now receive dev-fixture files.** apm installs from `.apm/`, and `.apm/` contains the
per-skill `tests/` directories the mirror explicitly stripped (ADR-0017's depth-scoped
`<category>/<name>/tests` exclusion). 10 `.bats` files across 6 skills therefore now deploy into
`<category>/<name>/tests` exclusion). 10 `.bats` files across 5 skills therefore now deploy into
every consumer's skill directories. Suppressing them would mean switching all six `apm.yml` files
from `includes: auto` to explicit include lists — and an explicit list that is wrong silently drops
content, which is the same failure class ADR-0017 was written to fix. Trading a cosmetic problem for

View File

@@ -117,7 +117,8 @@ This is the area you named as hardest to understand and slowest. Root cause: mos
- [x] ~~`check-vale-style-sync`: 413 lines + 798 test lines guarding a byte-identical 526-line `vale-wrap.sh` and style directory copied between skill-audit and agent-audit. About 350 of its lines run Vale glob probes against the hook file patterns. Disappears if the two audit skills merge (finding 14); the probes belong in `test-vale-wrap.sh`.~~ **Done (2026-09-15, ~~`467bbd7`~~ → `620f20b`)** — hook, script and test all deleted; see the settled note below for the corrected probe arithmetic.
- `check-scope-walkup-sync`: ~~365~~ → **381** lines (plus **297** test lines; re-measured 2026-09-16 at HEAD) cross-checking four independent ports of the same package-root walk-up. Disappears if the ports share one script ~~or the skills merge~~ — the second half is refuted below, and the first is unreachable.
> **Grilled, held (2026-09-14):** both of the above are gated on findings 14/15 (merging skill-audit+agent-audit and skill-author+agent-author), deliberately held for a separate session rather than decided here. Correction for that session: the audit's §8 grouping is wrong — these merges don't need ADR-0012 revisited (that ADR governs the unrelated `core` plugin's three `agentsmd-*` skills). The actual constraint is ADR-0014 (no-cross-skill file sharing on plugin cache-install), and merging sidesteps it rather than requiring it be reversed. The open question for that session is a design one — a shared skill's `description` carrying both skill- and agent-audit trigger phrases — not an ADR supersession. ADR-0012 revisit is needed only for finding 24.
> **Settled (2026-09-15) — split verdict, and the first bullet held in full.** Finding 14 landed as `factory-audit` (ADR-0025). **`check-vale-style-sync` is deleted**, hook, script and test, exactly as the first bullet predicted — and its probes **were** rehomed into `test-vale-wrap.sh`, as cases 28-30 (case 31 carries the override allowlist), so both halves of that bullet are closed. `docs/spec/gates.md` records the rehoming, not an open gap. *(Updated later on 2026-09-15.)* The one assertion this note used to call still uncovered — cross-manifest *agreement* between `.pre-commit-hooks.yaml`'s and `.pre-commit-config.yaml`'s `files:` regexes — ~~is now ported as case 33, which pairs the hooks by `id:`~~ → was ported as case 33, and case 33 was deleted with `.pre-commit-hooks.yaml` in `4de5b6b` (finding 36), so there is no second manifest left to agree with. Case 32 covers the separate zero-match question. It was a real gap while it lasted: narrowing the local skill hook to `^plugins/kyberforge/` left 6 of 38 skills prefiltered and the suite green. `bash tests/test-vale-wrap.sh` now reports ~~`61 passed, 0 failed`~~ → `63 passed, 0 failed` (it was 56 before cases 0 and 33 and the Part B mutation self-tests; 61 on 2026-09-15, and 63 once case 34 — the static `.vale.ini` style-load check — landed on 2026-09-16. Without vale on PATH it reports 19 and exits 77, up from 17). The bullet's "about 350 of its lines run Vale glob probes" overstates the probe half: at `a5962ba` the script is **413 lines**, of which the `.vale.ini` coverage section is **332** (`67..398`) and the machinery that actually invokes vale against a probe path is **204** (`195..398`). The balance of that section is `StylesPath`, `BasedOnStyles` and per-rule-override greps — text assertions, not probes. (Its test file is **797** lines, as the note above says, not the 798 the bullet carries.) **`check-scope-walkup-sync` stays**, and the second bullet's "or the skills merge" is wrong: two of its four walk-up ports are in the *author* skills (`new-agent.sh`, `new-skill.sh`), which this merge does not touch, and the audit-side pair is Python against the author-side pair's Bash, so the gate can never degrade into a text diff. Full reasoning in §10's 2026-09-15 note. Finding 15 would not remove it either.
> **Settled (2026-09-15) — split verdict, and the first bullet held in full.** Finding 14 landed as `factory-audit` (ADR-0025). **`check-vale-style-sync` is deleted**, hook, script and test, exactly as the first bullet predicted — and its probes **were** rehomed into `test-vale-wrap.sh`, as cases 28-30 (case 31 carries the override allowlist), so both halves of that bullet are closed. `docs/spec/gates.md` records the rehoming, not an open gap. *(Updated later on 2026-09-15.)* The one assertion this note used to call still uncovered — cross-manifest *agreement* between `.pre-commit-hooks.yaml`'s and `.pre-commit-config.yaml`'s `files:` regexes — ~~is now ported as case 33, which pairs the hooks by `id:`~~ → was ported as case 33, and case 33 was deleted with `.pre-commit-hooks.yaml` in `4de5b6b` (finding 36), so there is no second manifest left to agree with. Case 32 covers the separate zero-match question. It was a real gap while it lasted: narrowing the local skill hook to `^plugins/kyberforge/` left 6 of 38 skills prefiltered and the suite green. `bash tests/test-vale-wrap.sh` reports ~~`61 passed, 0 failed`~~ → ~~`63 passed, 0 failed`~~ → **`65 passed, 0 failed` (pinned at `1614bce`)** (it was 56 before cases 0 and 33 and the Part B mutation self-tests; 61 on 2026-09-15, and 63 once case 34 — the static `.vale.ini` style-load check — landed on 2026-09-16. Without vale on PATH it reports ~~19~~ → **14** and exits 77, ~~up from 17~~). The bullet's "about 350 of its lines run Vale glob probes" overstates the probe half: at `a5962ba` the script is **413 lines**, of which the `.vale.ini` coverage section is **332** (`67..398`) and the machinery that actually invokes vale against a probe path is **204** (`195..398`). The balance of that section is `StylesPath`, `BasedOnStyles` and per-rule-override greps — text assertions, not probes. (Its test file is **797** lines, as the note above says, not the 798 the bullet carries.) **`check-scope-walkup-sync` stays**, and the second bullet's "or the skills merge" is wrong: two of its four walk-up ports are in the *author* skills (`new-agent.sh`, `new-skill.sh`), which this merge does not touch, and the audit-side pair is Python against the author-side pair's Bash, so the gate can never degrade into a text diff. Full reasoning in §10's 2026-09-15 note. Finding 15 would not remove it either.
> > **Re-measured and pinned (2026-09-20, at `1614bce`).** The two `test-vale-wrap.sh` counts in the note above were written as current readings rather than pinned to a commit, and both went stale when `ea119d8` added cases to that suite after this note. Measured here, not copied forward: `bash tests/test-vale-wrap.sh` → `Results: 65 passed, 0 failed`, exit 0; `env PATH=/usr/bin:/bin bash tests/test-vale-wrap.sh` → `Results: 14 passed, 0 failed`, exit 77. The struck 63 and 19 were correct for the commits they were taken at; no attempt is made here to attribute the 19 → 14 move, only to record the reading at `1614bce`. Take the counts from a run against a named commit, never from this note — that is the same reason §1 carries its "Pinned (2026-09-16, review round)" note.
- [x] ~~`check-marketplace-mirror-sync`: guards `.github/plugin/marketplace.json`. The script header calls it Copilot's legacy convention path and says Copilot also accepts the Claude path; the vendored Copilot docs list it as primary. Verify against current Copilot CLI before deleting hook, script, test, and mirror file.~~
> **Grilled and done (2026-09-14):** verified against GitHub's current Copilot CLI plugin docs (not the vendored copy, which risked drift). Copilot CLI's marketplace discovery checks paths in order — `marketplace.json`, `.plugin/marketplace.json`, `.github/plugin/marketplace.json`, `.claude-plugin/marketplace.json` — falling through to whichever exists first. `.claude-plugin/marketplace.json` (apm's own `claude` output) already satisfies that chain's last step, so the dedicated `.github/plugin/marketplace.json` mirror bought Copilot users its *preferred* discovery path rather than a required one. Decided against reopening ADR-0018 (native install for both Claude Code and Copilot CLI stays supported) to justify this — the deletion holds either way, since Copilot's own fallback covers it. Deleted `.github/plugin/marketplace.json`, `scripts/sync-marketplace-mirror.sh` (81 lines), `tests/test-sync-marketplace-mirror.sh` (304 lines), and the `check-marketplace-mirror-sync` pre-push hook; removed the dangling references to the deleted script in `scripts/sync-plugin-content.sh` and `tests/test-sync-plugin-content.sh` (both had comments citing its reasoning by name), and updated `docs/spec/architecture.md`'s description of the marketplace-manifest compile step. `tests/test-sync-plugin-content.sh` (92 cases) still passes in full.
>
@@ -164,6 +165,7 @@ This is the area you named as hardest to understand and slowest. Root cause: mos
Pre-commit stays roughly as is minus `skill-frontmatter`, and minus `check-ast` once finding 9 removes the only `.py` files. ~~Tests 26 files to about 10 (12,400 to about 5,000 lines).~~ Keep bats and its three submodules; the 351 bats tests ship inside plugins and are the right tool there. ~~Do not port the bash suites to bats; delete them instead.~~ **Struck (2026-09-16, grill):** see finding 8's closing note — the suites are regression coverage (findings 3 and 5; finding 16 found the same of the validators they test).
> **Re-measured (2026-09-14, at `a6434e0`):** the tests target was stated against the 2026-09-10 baseline and both its numbers are stale. `tests/` now holds **20 `test-*.sh` suites totalling 9,123 lines** (plus the two runners, 490). Six suites have gone since the baseline: `test-check-manifests.sh` (`e647f14`), `test-skill-frontmatter.sh` (`c8a7c9e`), `test-governance-layer.sh` and `test-instructions-and-docs.sh` (`5f9f2b3`), `test-sync-marketplace-mirror.sh` (`0dffff3`), `test-sync-plugin-content.sh` (`718c79a`). ~~Restated on the same basis the target is **20 files to about 10, 9,123 to about 5,000 lines**~~ — **struck (2026-09-16):** the target itself is withdrawn (see the struck sentence above); for the record, `tests/` holds **19** suites totalling **10,000** lines at `4b17703`, after `4de5b6b` deleted `test-check-release-needed.sh` and `test-vale-hooks-consumer.sh`. Finding 9's `check-ast` clause is moot anyway, since finding 9 is not proceeding.
> > **Corrected (2026-09-20, at `1614bce`) — the deletion tally is nine, not ~~six~~ → ~~eight~~.** The six named above plus the two the 2026-09-16 strike adds come to eight, and a ninth was never folded into the running tally: **`test-check-vale-style-sync.sh`**, removed by `620f20b` with the `factory-audit` merge (finding 14) — the same commit finding 2's bullet already credits for deleting that gate's hook and script. The full `main...HEAD` set is nine: `test-check-manifests.sh` (`e647f14`), `test-check-release-needed.sh` (`4de5b6b`), `test-check-vale-style-sync.sh` (`620f20b`), `test-governance-layer.sh` and `test-instructions-and-docs.sh` (`5f9f2b3`), `test-skill-frontmatter.sh` (`c8a7c9e`), `test-sync-marketplace-mirror.sh` (`0dffff3`), `test-sync-plugin-content.sh` (`718c79a`), `test-vale-hooks-consumer.sh` (`4de5b6b`). Method: `git diff --name-status main...HEAD -- tests/ | grep '^D'`. The pinned "19 suites at `4b17703`" is unaffected — `620f20b` precedes that commit, so the file count already reflected the deletion even though the tally did not. At `1614bce` `tests/` holds **19** `test-*.sh` suites totalling **10,897** lines.
## 4. Plugins
@@ -328,6 +330,8 @@ The shared pattern: per-skill `README.md` files no model reads, a `docs/research
> **The self-containment constraint does not support this finding the way it supports 14/15** — there is no cross-skill duplication here to merge away. `agentsmd-audit`'s three scripts share essentially nothing with `validate-adapter.sh` (no `read_text`, no BOM handling, no NUL check; they exit 1 on usage errors). Merging would *expose* that they are unhardened — costing lines, not saving them.
>
> Two further blockers if it were ever revisited: the merge dissolves `agentsmd-author`'s standing prohibition *"Never write to a provider file yourself, in any circumstance"* (SKILL.md:21), a hazard `c59e4bf` closed after the validator's own size-FAIL remediation text "actively invited the prohibited edit"; and `skill-size-check.sh:121` + `tests/test-skill-size-check.sh:729` both cite `a8cd5e8`'s exit-2 split as precedent for their own, so deleting it orphans two live cross-references.
>
> > **Corrected (2026-09-20, at `1614bce`) — neither cross-reference points at `a8cd5e8` any more, and one line number was wrong when written.** `e4ed343` ("docs(gates): cite the reachable squash commit for the exit-2 split") repointed both at `598a7c3`, which `main` reaches. The citations now sit at `scripts/skill-size-check.sh:121` and `tests/test-skill-size-check.sh:737` — `:737`, not the `:729` above. `grep -rn a8cd5e8 scripts/ tests/` returns nothing. The blocker itself is unaffected: the exit-2 precedent still exists, under a hash a branch reaches. Same correction as §12's follow-up, closed there on the same date.
25. [x] **lint: delete the `lint-runner` agent.** Its body is "call `vale-run`, reformat output", which `--output=JSON` already gives; it exists for backends that do not exist. It is the example boundary clause in three `agent-author` templates and ADR-0016, so those need a new example. About 40% of `vale-config` is install tables and settings lists the model can fetch from vale.sh. Keep the house-verified matrices (`E100`/`E201`, `Packages` below glob, frontmatter, ignore paths). `lint/docs/research/docs/vale/` overlaps the skill's own references by about two thirds. Effort S.
> **Decision (2026-09-16):** Not proceeding — the human declined this finding. The `lint-runner` agent stays.
@@ -654,8 +658,8 @@ Seven parallel reviewers went over the whole branch against `main`, each coverin
**Open follow-ups:**
- **Gitea #101.** Close it through this branch's PR with `Closes #101`. A comment is posted.
- **~~Gitea #101.~~ Closed — the instruction was already a no-op when written (2026-09-20).** ~~Close it through this branch's PR with `Closes #101`.~~ A comment is posted. Read back from the Gitea API on 2026-09-20, #101 is `"state": "closed"` with `"closed_at": "2026-09-16T16:03:35Z"` — closed on 2026-09-16, this §12 note's own date, so there is nothing left for a `Closes #101` trailer to do. No PR change needed. The comment on the issue stands.
- **Gitea #66.** It needs re-scoping, because its `.mcp.json` target is gone. A comment is posted.
- **The dropped `LESSONS.md` entry.** The entry saying that "read at session start" is only a hope was removed, and no issue tracks it.
- **The ADR-0020 constants.** They could move into the shared library that `skill-size-check` now sources, which would remove the last duplicated copy.
- **The `a8cd5e8` citations.** `scripts/skill-size-check.sh` and `tests/test-skill-size-check.sh` still cite `a8cd5e8`, which no branch reaches. `598a7c3` is the reachable equivalent.
- **~~The `a8cd5e8` citations.~~ Closed (2026-09-20, by `e4ed343`).** ~~`scripts/skill-size-check.sh` and `tests/test-skill-size-check.sh` still cite `a8cd5e8`, which no branch reaches. `598a7c3` is the reachable equivalent.~~ `e4ed343` ("docs(gates): cite the reachable squash commit for the exit-2 split", 2026-09-16 15:39 UTC) landed after this follow-up was written and repointed both at `598a7c3`: `scripts/skill-size-check.sh:121` and `tests/test-skill-size-check.sh:737`. Verified at `1614bce` — `grep -rn a8cd5e8 scripts/ tests/` returns nothing. §4.4's finding 24 note carried the same stale citation (with `:729`, a wrong line number) and is corrected in place there.

View File

@@ -57,8 +57,9 @@ Eight hooks, grouped below by what they guard rather than by the order `.pre-com
| `check-scope-walkup-sync` | `validate.sh`, `validate-provenance.sh`, `new-agent.sh` and `new-skill.sh`'s four independent `$HOME`/`.git`/`apm.yml` walk-up ports still agree behaviorally |
| `check-executables-allow-sync` | root `apm.yml`'s `executables.allow` key names kyberforge's actual version (see [apm gates](#apm-gates)) |
`check-executables-allow-sync` is the odd one in this group: it guards a *silent failure* rather than
drift in generated text.
`check-executables-allow-sync` is the odd one in this group: the drift it guards is in a
hand-written key rather than in generated text, and it is a record-keeping gate — the grant itself is
version-blind, so a stale key deploys fine (see [apm gates](#apm-gates)).
**Artifact validators**
@@ -105,7 +106,10 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
version at the tip of the same `main` ref (ADR-0022's second 2026-09-16 amendment). The tip
check stops two branches that make the same bump (`1.0.0` → `1.0.1`) with different content from
both landing, since the identical version lines merge without a conflict. A skill absent at the
tip is held to the merge-base alone; when `main` has not moved, the two are the same commit. Each
tip is held to the merge-base alone, and so is one whose directory at the pushed commit is the
*same tree object* as at the tip: it ships exactly what main ships, whatever route the history
took there — a criss-cross merge, a cherry-pick, a backport — so there is nothing for a bump to
announce. When `main` has not moved, the two baselines are the same commit. Each
failure line names the baseline it missed: `(not above merge-base)` or
`(not above origin/main tip)`. The tip is `origin/main` as last fetched.
- **It fails closed when it has no trustworthy baseline:** neither `origin/main` nor `main`
@@ -634,8 +638,10 @@ boundary, and a stricter form would only move the same trust to a different stri
- **Prose bullets.** Most of `branch-operations.md`, `merging.md` and `rewrite-history.md` instruct
in list items, not fences. Those are clause-1 sites the gate cannot see, because it cannot
distinguish them from clause-2 mentions in the same list.
- **`README.md`, excluded by pattern.** A skill-directory README is consumer-facing prose no agent
loads, and the `git clone https://github.com/bats-core/…` lines in the six `tests/README.md`
- **`README.md`, excluded by pattern.** The skill-directory READMEs the exclusion was first written
for are deleted; what it still covers is the 12 `README.md` files inside a skill's `scripts/`,
`tests/` and `assets/` subdirectories — consumer-facing prose no agent loads — and the
`git clone https://github.com/bats-core/…` lines in the six `tests/README.md`
files are setup instructions for a third party who has no `rtk`. Prefixing those would be actively
wrong, not merely noisy — see ADR-0023's consumer section.
- **Quoting.** The line splitter breaks on `;`, `|`, `&&`, `||`, `$(` and backticks without tracking
@@ -939,8 +945,8 @@ pass, and a skip fails the push.
`test-vale-wrap.sh` without Vale skips only its Vale-dependent cases, not the whole suite. The cases
that are plain greps and awk over the Vale config and `.pre-commit-config.yaml` still run: case 0, 16, 26,
27, the static half of 28, 31 Parts A and B, 32 and 34. A static failure exits 1, because a
real defect is not a setup error. Only an all-static-pass run exits 77.
27, the static half of 28, 31 Parts A and B, 32, 34 and the static half of 35. A static failure
exits 1, because a real defect is not a setup error. Only an all-static-pass run exits 77.
### Mentioning banned phrasing without tripping the rule
@@ -1010,9 +1016,10 @@ near-miss negatives it must leave alone, and the suite from 5 cases to **7**.
an `echo` or `printf` feeding any of them is the same race. Those are guarded by **convention** —
absorb the writer's status with `|| true`, or take the verdict from a here-string — and deliberately
not by this test: most legitimate uses of them in this tree are already absorbed, and the scanner
cannot see absorption from the pipeline text alone, so flagging them would be noise. Two live
`grep … | head -1` sites (`tests/test-vale-wrap.sh:620` and `:1046`) were fixed by hand with that
idiom. Pipes from a non-builtin writer (`run_wrap … | grep -q`) are out of scope for the same reason:
cannot see absorption from the pipeline text alone, so flagging them would be noise. The three live
`grep … | head -1` sites in `tests/test-vale-wrap.sh` — in `unguarded_expansions()`, in case 20B's
`--output line` line-number read, and in case 28's per-file `RESULTS28` lookup — carry that idiom by
hand. Pipes from a non-builtin writer (`run_wrap … | grep -q`) are out of scope for the same reason:
in practice they either absorb the writer's exit status with `|| true` or write only once, at exit.
**Known limitation: heredoc bodies are scanned as code.** A `cat <<'EOF'` body containing a

View File

@@ -6,7 +6,7 @@ description: >
Not read-only review -> `factory-audit`. Not skills -> `skill-author`.
allowed-tools: Bash Read Write Edit
metadata:
version: "1.0.2"
version: "1.0.3"
category: factory
source_keys:
- context7-websites-code-claude

View File

@@ -29,7 +29,8 @@ A description carries exactly three things:
3. **Boundary clause** — form: `Not <thing> -> <name>.` Add one only where a near-miss agent or
skill could steal delegations.
Banned from a description; move it to the body or to `README.md`:
Banned from a description; move it to the body — an agent is a single file with no `references/`
directory to move it to:
- Capability enumeration or feature lists
- Per-scope emission mechanics — which files the author skill writes at which scope changes no

View File

@@ -7,7 +7,7 @@ description: >
fixes -> agent-author.
allowed-tools: Bash Read
metadata:
version: "1.0.1"
version: "1.0.2"
category: factory
source_keys:
- agentskills-home

View File

@@ -44,7 +44,8 @@ A plugin-scope agent is a single `.apm/agents/<name>.agent.md` file with no sibl
directory. It cannot progressively disclose to itself — it can only delegate to skills. So a
procedure spelled out in an agent body that a skill the agent invokes already owns is not a
shortcut: it is a second copy of that procedure, and the second copy drifts. This is the
characteristic agent defect, the way a stale README row is the characteristic skill defect.
characteristic agent defect, the way a `SKILL.md` naming a `references/` file that is not there is
the characteristic skill defect.
**An agent body that restates a procedure owned by a skill it can invoke is a FAIL.** The Fix is
always the same shape: invoke `<skill>` instead.

View File

@@ -46,7 +46,7 @@ A model-invoked description carries exactly three things:
instead") is only a SUGGESTION unless a second target in the same sentence resolves. Take the
script's tier as given and report it once, under Structure.
Everything else belongs in the body or in `README.md`.
Everything else belongs in the body or in a `references/` file.
## Indirect triggers — conditional, never blanket

View File

@@ -6,7 +6,7 @@ description: >
Not read-only review -> `factory-audit`. Not agent files -> `agent-author`.
allowed-tools: Bash Read Write Edit
metadata:
version: "1.0.3"
version: "1.0.4"
category: factory
source_keys:
- agentskills-home

View File

@@ -7,9 +7,9 @@ source_keys:
# The description and body contract
House contract. Every rule here is enforced by `/factory-audit` —
`scripts/validate.sh` for the counts and the boundary targets, the bundled Vale styles for the
prose patterns, and its reference files for the judgment calls.
House contract. Every rule here is enforced by `/factory-audit` — the counts and the boundary
targets by `factory-audit`'s `scripts/validate.sh`, the prose patterns by the Vale styles it
bundles, the judgment calls by its reference files.
## Why the budget exists
@@ -28,10 +28,11 @@ A description carries exactly three things:
Focus on user intent, not the skill's internal mechanics.
2. **At most one capability clause** — what it does, one clause, no enumeration. Be specific
("parses and validates OpenAPI specs", not "helps with APIs").
3. **Boundary clause** — form: `Not <thing> -> <skill-name>.` Add one only where a near-miss skill
could steal activations.
3. **Boundary clause** — form: `Not <thing> -> <skill-name>.` Write one per genuine near-miss
skill that could steal activations: at least one, not exactly one — `git-remotes` carries
four. What is banned is a clause invented for a skill that was never going to compete.
Banned from a description; move it to the body or to `README.md`:
Banned from a description; move it to the body or to a `references/` file:
- Capability enumeration or feature lists
- Output-format detail ("Produces a compact findings report with Why and Fix per finding")

View File

@@ -58,7 +58,7 @@ Then proceed — edits are reversible via git, no approval checkpoint needed.
## Step 4 — Apply changes
Edit any file in the skill directory that the signals point to: SKILL.md, `scripts/`,
`references/`, `assets/`, `tests/`, README.md.
`references/`, `assets/`, `tests/`.
**Generalize, do not patch.** Find the underlying gap, not the specific example that failed. A fix
scoped only to the test cases you have seen will overfit and perform worse on new inputs.
@@ -89,6 +89,16 @@ order, stopping once the gate clears; the order puts the cuts that lose the leas
Still over after all four means the skill does two jobs: split it rather than compressing prose.
**Re-cite what moved.** After content moves between files, update `references/sources.md`'s
`Contributing files` for every slug whose content moved, and drop any file the edit deleted.
`factory-audit`'s `scripts/validate-provenance.sh` exits 0 on exactly that drift, so a stale
provenance claim ships unless you fix it here.
**Re-check every relocated gate's reachability.** A Gotcha or gate moved out of the body into one
flow's `references/` file is invisible to every other branch, and the word counts improve either
way. For each one you move, list the flows that need it: it belongs in one flow's file only when
exactly one flow reaches it, otherwise in the body's common-gates section.
If a signal points to a script or reference file, edit that file directly rather than adding a
workaround in SKILL.md.

View File

@@ -2,11 +2,15 @@
set -euo pipefail
# Fails the push when root apm.yml's executables.allow key stops naming
# kyberforge's actual version. apm matches that key by exact dict lookup
# (apm_cli/security/executables.py) — a version bump that misses the key
# update deploys nothing, with no error anywhere. See ADR-0019, "The allow
# key is version-pinned, and that is a live failure mode", for the full
# argument; nothing else in the pre-push gate compares these two files.
# kyberforge's actual version. The grant itself is version-blind — apm matches
# the version-less name alongside '<package>#<version>' (exec_gate.py builds
# the candidate list, _map_grants matches it) — so a stale key keeps granting
# kyberforge's hooks/ and bin/ and deployment does not break. What this gate
# buys is repo-level, not apm-level: a version bump without the matching key
# edit fails this repo's own pre-push, so the key stays an accurate record of
# what was approved. See ADR-0019's 2026-09-19 correction and docs/spec/gates.md,
# "check-executables-allow-sync"; nothing else in the pre-push gate compares
# these two files.
#
# Run from repo root or pass REPO_ROOT as arg.
@@ -213,10 +217,10 @@ if [[ -n "$STALE_KEYS" ]]; then
echo " Found instead:" >&2
printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2
fi
echo " Why: apm matches this key by exact dict lookup — there is no wildcard and no version-less" >&2
echo " form — so a key naming any other version silently stops granting kyberforge's hooks/" >&2
echo " and bin/. The SessionStart hook then stops deploying and the apm install goes stale" >&2
echo " with no error anywhere (ADR-0019, 'The allow key is version-pinned')." >&2
echo " Why: nothing is broken right now — apm's grant is version-blind, so a key naming another" >&2
echo " version still grants kyberforge's hooks/ and bin/. This gate is a repo-level record" >&2
echo " check: a version bump without the matching key edit fails here, which is what keeps" >&2
echo " the key an accurate record of what was approved (ADR-0019, 2026-09-19 correction)." >&2
echo " Fix: bump the key in root apm.yml to '$EXPECTED_KEY:' — the version bump in" >&2
echo " plugins/kyberforge/apm.yml is not complete without it." >&2
exit 1

View File

@@ -36,7 +36,11 @@ set -euo pipefail
# without a conflict, so the merge-base alone would let main ship both under
# one version. When <main> has not moved since the merge-base, the two
# baselines are one commit and the skill is checked once. The tip is read as
# last fetched.
# last fetched. A skill whose directory at the pushed commit is the SAME TREE
# OBJECT as at the tip skips the tip comparison: it ships exactly what main
# ships, so there is nothing to announce. The merge-base intersection catches
# that only when some base carries the content — true of the criss-cross shape
# above, false of a branch that cherry-picks a fix main already has.
#
# Pushing main itself: with origin/main as the baseline, a push of main diffs
# the new commits against what the remote already has, so it is covered. A
@@ -281,6 +285,17 @@ in_tree() {
git rev-parse --verify -q "$1:$2" > /dev/null
}
# same_subtree <commit-a> <commit-b> <path>: both name <path> with the same
# object, so the two commits ship byte-identical content there. Compared as
# object ids rather than by diffing: a tree id is the content, whatever route
# the history took to it. A path missing on either side is not a match.
same_subtree() {
local a b
a="$(git rev-parse --verify -q "$1:$3")" || return 1
b="$(git rev-parse --verify -q "$2:$3")" || return 1
[[ "$a" == "$b" ]]
}
OFFENDERS=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
# Bases the skill exists at, in merge-base order, with the version read at
@@ -306,7 +321,18 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
break
fi
done
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md"; then
# A skill byte-identical to the tip's copy of it is already what main ships,
# so there is nothing left for a bump to announce. The merge-base rule alone
# would already have exempted it, but only when a base carries that same
# content — which a criss-cross history gives and a linear one does not. A
# branch cut before a fix landed on main and then cherry-picking that fix has
# one merge-base, predating the fix, so the skill counts as changed against
# it and reaches the tip comparison carrying exactly the tip's version. Same
# content, same version, and the only escapes would be a spurious bump —
# leaving main carrying two versions of identical content — or a rebase the
# push does not otherwise need.
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" \
&& ! same_subtree "$MAIN_TIP" "$PUSHED_COMMIT" "$dir"; then
at_tip=true
fi
# Absent at every baseline: new, renamed-to, or merged-into. Exempt.
@@ -317,15 +343,22 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
tip_ver=""
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
# The baseline the two messages below name. A skill added on main after the
# branch was cut is absent at every merge-base, so no base names a version
# while the tip does — and the tip's is the version the push is actually held
# to. Reporting "none" there sends the author to the natural 1.0.0 and costs
# them a second blocked push on the same mistake.
report_ver="${first_base_ver:-$tip_ver}"
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${first_base_ver:-none})")
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${report_ver:-none})")
continue
fi
version_at "$PUSHED_COMMIT" "$dir/SKILL.md"
cur_ver="$VERSION"
if [[ -z "$cur_ver" ]]; then
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${first_base_ver:-none})")
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${report_ver:-none})")
continue
fi
# Named by sha only when there is more than one base to tell apart; a

View File

@@ -20,5 +20,3 @@ DEPLOY_EXECUTABLES=(
DEPLOY_DIRS=(
"core:.claude/core"
)
# Provider skill adapters are declared in providers/*/provider-manifest.sh, not here.

View File

@@ -180,6 +180,15 @@ for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
# file bats really did run, so it has to be distinguishable from a file that
# produced nothing whatsoever.
file_plan="$(grep -c '^1\.\.[0-9]' "$SCRATCH_ROOT/$i.log" || true)"
# The planned count itself, extracted only when the file emitted exactly one
# plan line. Zero plans is the broken-harness case the aggregate guard below
# names, and two or more means the log is not one file's TAP stream at all --
# in neither case does "the planned count" mean anything, so the per-file
# comparison is skipped and the previous behaviour stands.
file_planned=""
if [[ "$file_plan" -eq 1 ]]; then
file_planned="$(sed -n 's/^1\.\.\([0-9][0-9]*\).*$/\1/p' "$SCRATCH_ROOT/$i.log")"
fi
# String-compared below, not `-ne`. `-ne` is arithmetic and bash evaluates an
# empty string as 0 there -- `[[ "" -ne 0 ]]` is false -- so an *empty* status
# file read as a clean exit. The `|| echo 1` fallback only covers a *missing*
@@ -189,14 +198,28 @@ for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
TOTAL_OK=$((TOTAL_OK + file_ok))
TOTAL_NOT_OK=$((TOTAL_NOT_OK + file_not_ok))
TOTAL_PLANS=$((TOTAL_PLANS + file_plan))
# Two independent failure signals, deliberately OR-ed: a file can report `not
# ok` lines while its process still exits 0 (a bats formatter or wrapper that
# swallows the status), and a file can exit non-zero having emitted no `not
# ok` at all (a crash, a timeout, an unbound variable in setup_file). Real
# bats normally emits both at once, so each signal masks the other and
# dropping either half is invisible without tests that produce one without
# the other -- tests/test-run-bats.sh has those.
if [[ "$file_not_ok" -gt 0 || "$status" != "0" ]]; then
# Three independent failure signals, deliberately OR-ed: a file can report
# `not ok` lines while its process still exits 0 (a bats formatter or wrapper
# that swallows the status), a file can exit non-zero having emitted no `not
# ok` at all (a crash, a timeout, an unbound variable in setup_file), and a
# file can emit FEWER results than its own plan line promised. Real bats
# normally emits all three consistently, so each signal masks the others and
# dropping any one of them is invisible without tests that produce one without
# the rest -- tests/test-run-bats.sh has those.
#
# The plan is the third signal and it is now enforced, not merely counted. It
# is the one that survives precisely the wrapper-swallows-the-status case
# named above: a process printing `1..10`, three `ok` lines and exit 0 used to
# be counted as "3 tests, 0 failures" and go green with seven tests silently
# gone, because the plan was computed for the aggregate zero-count guard below
# and then discarded. Mismatch either way is a failure -- more results than
# planned is as broken a TAP stream as fewer.
file_short=false
if [[ -n "$file_planned" && $((file_ok + file_not_ok)) -ne "$file_planned" ]]; then
file_short=true
echo "Error: $rel planned $file_planned test(s) but emitted $((file_ok + file_not_ok)) result line(s) — the run was truncated, or its exit status was swallowed" >&2
fi
if [[ "$file_not_ok" -gt 0 || "$status" != "0" || "$file_short" == true ]]; then
FAIL=1
fi
done

View File

@@ -41,6 +41,12 @@
# passing hook, and a SUGGESTION deliberately does not fail, so dropping
# one word from the config silences the tier ADR-0020 depends on while
# every test and every hook still reports green.
# 4. The WIRING of every repo-authored hook — its id, its `entry:` and its
# `stages:`. Same failure shape as 3, one level up: the tests drive these
# scripts by path, so nothing noticed whether .pre-commit-config.yaml
# still invoked them. On a scratch copy, deleting eight local hook blocks
# at once — `run-tests` among them — left every suite green. Upstream
# hooks and every `rev:` are deliberately out of scope.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -680,6 +686,199 @@ while IFS=$'\t' read -r status msg; do
fi
done <<< "$VERBOSE_REPORT"
# ---------------------------------------------------------------------------
# 4. Every repo-authored hook is still WIRED, at the stage it claims
# ---------------------------------------------------------------------------
# Same failure mode as assertion 3, one level up. Every gate this repo owns is
# driven in tests by PATH -- tests/test-skill-version-bump.sh runs
# scripts/check-skill-version-bump.sh directly -- so the script staying correct
# and the hook still existing are independent facts, and only the first one was
# pinned. Deleting the `run-tests` block from .pre-commit-config.yaml (a routine
# merge-conflict casualty) stops the entire suite from running at pre-push
# forever, and `bash tests/run-tests.sh --strict` still prints all green: the
# suite cannot notice that nothing invokes it. Verified on a scratch copy --
# eight repo-authored hook blocks were deleted at once and every suite stayed
# green.
#
# So: id + entry + stages, for the repo-authored hooks only. "Repo-authored"
# is derived from the config (every hook under a `repo: local` entry), not
# hardcoded, and the derived set is then compared against the expected list
# below -- a new local hook that nobody pinned is itself a failure.
#
# Deliberately NOT pinned: the stock upstream hooks (gitleaks, check-yaml,
# pretty-format-json, shellcheck, conventional-pre-commit, the `repo: meta`
# pair) and every `rev:`. Those are somebody else's contract; a rev bump must
# not churn this test.
echo ""
echo "--- every repo-authored hook is wired, with the entry and stages it claims ---"
WIRING_REPORT="$(python3 - "$REPO_ROOT" <<'PY'
import os
import re
import sys
import yaml
root = sys.argv[1]
def emit(status, msg):
print("%s\t%s" % (status, msg))
# The expected wiring of every hook this repo authors: id -> (entry, stages).
# ADDING OR REMOVING A REPO-AUTHORED HOOK DELIBERATELY REQUIRES EDITING THIS
# LIST. That is the point -- the list is the second party to the agreement, so a
# hook cannot leave .pre-commit-config.yaml without someone saying so here.
# Upstream hooks are absent on purpose and must stay absent.
EXPECTED = {
# pre-push
'run-tests': (
'bash tests/run-tests.sh --strict', ['pre-push']),
'check-executables-allow-sync': (
'bash scripts/check-executables-allow-sync.sh', ['pre-push']),
'apm-audit-ci': (
'bash -c \'for d in . plugins/*/; do (cd "$d" && apm audit --ci) || '
'{ echo "apm audit --ci failed in $d" >&2; exit 1; }; done\'',
['pre-push']),
'check-apm-agents-valid': (
'bash scripts/check-apm-agents-valid.sh', ['pre-push']),
'apm-pack-check-clean': (
'apm pack --check-versions --check-clean --dry-run', ['pre-push']),
'check-scope-walkup-sync': (
'bash scripts/check-scope-walkup-sync.sh', ['pre-push']),
'check-skill-version-bump': (
'bash scripts/check-skill-version-bump.sh', ['pre-push']),
'validate-marketplace': (
'claude plugin validate --strict .claude-plugin/marketplace.json',
['pre-push']),
# pre-commit
'skill-size-check': (
'scripts/skill-size-check.sh', ['pre-commit']),
'check-rtk-prefix': (
'scripts/check-rtk-prefix.sh', ['pre-commit']),
'vale-audit-prefilter-skill': (
'plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh',
['pre-commit']),
'vale-audit-prefilter-agent': (
'plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh',
['pre-commit']),
}
path = os.path.join(root, '.pre-commit-config.yaml')
try:
with open(path, encoding='utf-8') as fh:
cfg = yaml.safe_load(fh) or {}
except Exception as exc:
emit('FAIL', '.pre-commit-config.yaml did not parse: %s' % exc)
sys.exit(0)
local = {}
duplicates = []
for repo in cfg.get('repos') or []:
if repo.get('repo') != 'local':
continue
for hook in (repo.get('hooks') or []):
hid = hook.get('id')
if hid in local:
duplicates.append(hid)
local[hid] = hook
# Non-vacuity, first. Every assertion below iterates the derived set, so an
# empty one would report nothing and the section would pass having checked
# nothing at all -- the same shape of silent hole this whole file exists to
# close.
if not EXPECTED:
emit('FAIL', 'the expected repo-authored hook list is empty — '
'every wiring assertion below is vacuous')
sys.exit(0)
if not local:
emit('FAIL', '.pre-commit-config.yaml declares no `repo: local` hooks at '
'all — every gate this repo authors has been unwired, or the '
'config moved and this assertion is now measuring nothing')
sys.exit(0)
emit('PASS', '.pre-commit-config.yaml declares %d repo-authored (`repo: local`) '
'hooks — the wiring assertions below are not vacuous' % len(local))
if duplicates:
emit('FAIL', '.pre-commit-config.yaml declares duplicate local hook ids '
'(%s) — pre-commit runs one of them and the other is dead '
'config' % ', '.join(sorted(set(duplicates))))
# The set, before the per-hook detail: a deleted block shows up here as a
# missing id even if nothing else in the file changed.
missing = sorted(set(EXPECTED) - set(local))
extra = sorted(set(local) - set(EXPECTED))
if missing or extra:
parts = []
if missing:
parts.append('NOT WIRED in .pre-commit-config.yaml: %s' % ', '.join(missing))
if extra:
parts.append('wired but not pinned in this test: %s' % ', '.join(extra))
emit('FAIL', 'the repo-authored hook set has drifted — %s. A hook that '
'leaves the config stops running at push time while every '
'test stays green; a hook that arrives unpinned can leave '
'again unnoticed. Fix the config, or update EXPECTED in '
'tests/test-adr0020-contract.sh deliberately.' % '; '.join(parts))
else:
emit('PASS', '.pre-commit-config.yaml wires exactly the %d expected '
'repo-authored hooks, no more and no fewer' % len(EXPECTED))
# Per hook: the entry that runs and the stage it runs at. Both are single
# tokens whose loss is invisible -- an entry repointed at a path that no longer
# exists makes pre-commit fail loudly, but an entry repointed at a DIFFERENT
# real script does not, and a hook moved off pre-push simply never fires.
entry_paths = []
for hid in sorted(EXPECTED):
hook = local.get(hid)
if hook is None:
continue # already reported as missing above
exp_entry, exp_stages = EXPECTED[hid]
got_entry = hook.get('entry')
got_stages = hook.get('stages')
problems = []
if got_entry != exp_entry:
problems.append('entry is %r, expected %r' % (got_entry, exp_entry))
if list(got_stages or []) != exp_stages:
problems.append('stages is %r, expected %r — a hook at the wrong stage '
'(or at none) never fires' % (got_stages, exp_stages))
if problems:
emit('FAIL', 'hook %s: %s' % (hid, '; '.join(problems)))
else:
emit('PASS', 'hook %s runs `%s` at %s'
% (hid, exp_entry, ','.join(exp_stages)))
# Collect the in-repo script/manifest paths the pinned entry names, so the
# pin cannot agree with a config that points at nothing.
for token in re.split(r'\s+', exp_entry):
token = token.strip('\'"')
if re.fullmatch(r'[A-Za-z0-9_.\-/]+\.(sh|json)', token):
entry_paths.append((hid, token))
# Every pinned entry path exists on disk. Without this, EXPECTED and the config
# could agree perfectly on a script that was deleted.
if not entry_paths:
emit('FAIL', 'no pinned entry named an in-repo script or manifest — the '
'existence check below iterated zero times and proved nothing')
else:
broken = ['%s -> %s' % (hid, token)
for hid, token in entry_paths
if not os.path.exists(os.path.join(root, token))]
if broken:
emit('FAIL', 'pinned hook entries point at files that do not exist: %s'
% ', '.join(broken))
else:
emit('PASS', 'all %d in-repo files named by a pinned hook entry exist '
'on disk' % len(entry_paths))
PY
)"
while IFS=$'\t' read -r status msg; do
[[ -n "$status" ]] || continue
if [[ "$status" == PASS ]]; then
pass "$msg"
else
fail "$msg"
fi
done <<< "$WIRING_REPORT"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]

View File

@@ -204,7 +204,14 @@ while IFS= read -r rel; do
[[ -f "$REPO_ROOT/$rel" ]] && FILES+=("$REPO_ROOT/$rel")
done < <(git -C "$REPO_ROOT" ls-files -- '*.sh' '*.bats' '*.bash')
if [[ "${#FILES[@]}" -lt 20 ]]; then
# Floored at 50 against a real 57 (58 tracked `*.sh`/`*.bats`/`*.bash` files, less
# this one, which excludes itself above). Same discipline as the per-glob floors in
# tests/test-vale-wrap.sh: a guard against a broken `git ls-files` invocation or a
# moved search root resolving to a fraction of the tree, not a headcount to keep in
# step. Seven files of slack is one deliberate multi-file deletion -- a plugin's
# whole tests/ directory is three or four .bats files -- and nowhere near enough to
# absorb a discovery that degraded to a single directory.
if [[ "${#FILES[@]}" -lt 50 ]]; then
fail "only ${#FILES[@]} tracked shell files found — the scan is looking in the wrong place"
else
LIVE_HITS="$(scan ${FILES[@]+"${FILES[@]}"})"

View File

@@ -444,6 +444,38 @@ else
pass "a root under .claude/worktrees/ runs its own files and skips nested worktrees"
fi
# --- 11. A file that emits fewer results than its own plan promised fails the
# run. This is the third aggregation signal, and the only one left standing in
# exactly the case run-bats.sh's own comment puts in its threat model: a bats
# formatter or wrapper that swallows the exit status. A process printing `1..10`,
# three `ok` lines and exiting 0 emits no `not ok` and no non-zero status, so both
# other halves stay silent -- the plan was already being computed for the
# aggregate zero-count guard and was then thrown away, so the run was counted as
# "6 tests, 0 failures" and went green with fourteen tests silently gone.
echo ""
echo "--- a file emitting fewer results than its plan fails the run ---"
DIR11="$(make_fake_repo)"
FIXTURES+=("$DIR11")
seed_bats_files "$DIR11"
install_stub_bats "$DIR11" <<'EOF'
#!/usr/bin/env bash
echo "1..10"
echo "ok 1 first"
echo "ok 2 second"
echo "ok 3 third"
exit 0
EOF
run_fake "$DIR11"
if [[ $FAKE_RC -eq 0 ]]; then
fail "a file delivering 3 of its 10 planned tests exited 0 — a truncated run reported as a pass"
elif ! grep -q "planned 10 test(s) but emitted 3 result line(s)" <<< "$FAKE_OUT"; then
fail "the run failed but not with the plan-shortfall message: $FAKE_OUT"
elif grep -q "^6 tests, 0 failures$" <<< "$FAKE_OUT"; then
pass "a plan promising more tests than were delivered fails the run and names the shortfall"
else
fail "the plan-shortfall run failed with the wrong count: $FAKE_OUT"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]

View File

@@ -633,6 +633,77 @@ write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F" F3
expect_fail "an unbumped edit on a criss-cross branch still fails, naming the baseline sha" \
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base [0-9a-f]{40}\)" "$F"
echo ""
echo "--- 41. a cherry-picked fix identical to main's tip passes ---"
# Case 40 pins the same property — content identical to main's tip ships
# nothing — for a criss-cross history, where the merge-base intersection alone
# already exempts the skill because one base carries that content. This is the
# LINEAR shape, where no base does: the branch was cut before the fix landed on
# main and then cherry-picked it, so the single merge-base predates the fix and
# the skill reaches the tip comparison carrying exactly the tip's version. The
# only escapes would be a spurious 1.0.2 — leaving main with two versions of
# identical content — or a rebase the push does not otherwise need.
#
# C0 alpha 1.0.0
# +-- main: FIX bumps alpha to 1.0.1 (origin/main)
# +-- feature: cherry-picks FIX
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo alpha 'version: "1.0.1"' "fixed body"; commit "$F" "fix alpha"
FIX_SHA="$(cd "$F" && git rev-parse HEAD)"
# -x: without it the picked commit can come out byte-identical to FIX — same
# tree, same parent, same author and committer second — and git reuses the sha,
# so the branch silently fast-forwards onto main and the case under test is
# gone. The trailer -x adds guarantees a distinct commit.
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature \
&& git cherry-pick -x "$FIX_SHA" > /dev/null)
if [[ "$(cd "$F" && git rev-parse HEAD)" != "$FIX_SHA" ]]; then
pass "fixture check: the cherry-pick made a distinct commit, not a fast-forward onto main"
else
fail "fixture check: the branch fast-forwarded onto main, so the tip is the merge-base"
fi
if [[ "$(cd "$F" && git merge-base --all origin/main HEAD | wc -l)" -eq 1 ]]; then
pass "fixture check: the linear history has exactly one merge-base"
else
fail "fixture check: expected one merge-base, got $(cd "$F" && git merge-base --all origin/main HEAD)"
fi
if [[ -z "$(cd "$F" && git diff origin/main HEAD -- plugins)" ]]; then
pass "fixture check: nothing under plugins/ differs between origin/main and the branch"
else
fail "fixture check: plugins/ differs, so this is not the case under test"
fi
expect_pass "a cherry-picked skill identical to main's tip passes without a further bump" "$F"
# The ratchet still holds on the same shape: a further edit is no longer
# identical to the tip, so the tip rule applies again.
write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F"
expect_fail "an unbumped edit on top of the cherry-pick still fails against the tip" \
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above origin/main tip\)" "$F"
echo ""
echo "--- 42. the named baseline is the tip when no merge-base carries the skill ---"
# A skill added on main after the branch was cut is absent at every merge-base,
# so only the tip names a version — and the tip's is the version the push is
# held to. Reporting "none" sends the author to the natural 1.0.0 and costs a
# second blocked push on the same mistake.
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo delta 'version: "3.2.1"' "main's delta"; commit "$F" "add delta on main"
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature)
write_skill "$F" demo delta "" "branch delta"; commit "$F" "add delta on branch"
expect_fail "a missing version names the tip's version, not 'none'" \
"delta: metadata\.version missing or not MAJOR\.MINOR\.PATCH at HEAD \(baseline: 3\.2\.1\)" "$F"
expect_fail "the baseline is never reported as none while the tip carries one" \
"delta: metadata\.version missing or not MAJOR\.MINOR\.PATCH at HEAD \(baseline: [0-9]" "$F"
# The same message shape for the SKILL.md-missing branch of the report.
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo delta 'version: "3.2.1"' "main's delta"; commit "$F" "add delta on main"
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature)
mkdir -p "$F/plugins/demo/.apm/skills/delta/references"
echo "ref" > "$F/plugins/demo/.apm/skills/delta/references/x.md"; commit "$F" "delta without SKILL.md"
expect_fail "a missing SKILL.md names the tip's version too" \
"delta: SKILL\.md missing at HEAD \(baseline: 3\.2\.1\)" "$F"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Regression test for scripts/vale-wrap.sh: Vale's `text.frontmatter.description`
# Regression test for plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh: Vale's `text.frontmatter.description`
# NLP scope silently stops matching when the description value is a YAML block
# scalar spanning 2+ physical lines. vale-wrap.sh flattens it to one line before
# handing off to the real vale binary — this asserts that actually happens.