feat(kyberforge): ADR-0020 context contract for skills and agents #103

Merged
Defame1297 merged 18 commits from refactor/trim-skills-agents-context into main 2026-08-16 21:20:02 +00:00
Collaborator

What

Skill name + description pairs are preloaded into every session — ~6,200 tokens across 39 skills before any skill is invoked. This branch sets a contract that bounds that, adds blocking gates for it, and retrofits kyberforge's own four author/audit skills to comply.

Full rationale in docs/adr/0020-skill-description-and-body-context-contract.md.

Why the descriptions grew

Not drift — the rules mandated it. skill-author/SKILL.md:104 required indirect triggers and description-quality.md:21 required authors to "err toward being pushy", both enforced. The rule that would have deflated them, skill-author/SKILL.md:102 ("not the skill's internal mechanics"), was judgment-only with no FAIL condition behind it. The enforced rules inflated; the deflating rule never bit.

There is also a correctness argument, not only a token one. writing-skills/SKILL.md:154-158 records a measured failure where an agent followed a description's workflow summary instead of reading the body. git-commits was exactly that shape — 74% capability enumeration, including a rules table an agent could act on without ever loading the skill.

Gates (blocking, no baseline file)

Gate SUGGESTION FAIL Measures
description 250 chars 400 the folded YAML value
body 600 words 900 body only, after the closing ---

The pre-existing 500-line / 2,770-word spec backstop is unchanged and still counts the whole file including frontmatter. These are two independent gate families; a file can sit well inside one and fail the other.

Also added: every boundary-clause routing target must resolve to a real skill or agent. It reports 3 findings across all 43 artifacts with zero false positives, and caught gitea-labels- milestones (a stray space from YAML folding) without being told about it.

Agents take the description gates but deliberately no body gate — a skill body competes with the caller's live conversation, an agent body becomes the system prompt of a fresh context. A test pins that absence so it cannot be "fixed" into consistency.

Result

Skill Description Body words
skill-author 614 → 225 2,623 → 599
agent-author 903 → 224 2,582 → 616
agent-audit 863 → 250 1,752 → 691
skill-audit 984 → 239 1,349 → 581
total 3,364 → 938 (−72%) 8,306 → 2,487 (−70%)

All four now use the dispatch pattern — body carries the dispatch table plus common gates, each branch self-contained in references/. apm-workflow was the exemplar.

Two pre-existing gate failures fixed along the way

Both were red at HEAD before this work started, and both were invisible in an ordinary local run:

  • RUN_TESTS_STRICT leaked into test-run-tests.sh's fixture children. The meta-test is itself a suite the runner discovers, so under the gate's own invocation the variable propagated down and flipped fixtures strict. One control case failed; six more were silently asserting against the wrong stream. bash tests/run-tests.sh was green while pre-push was red for everyone.
  • pretty-format-json --autofix was re-sorting apm's output. .claude/settings.json is apm-owned (ADR-0018/0019) but was missing from the hook's exclude list, so since 2e395a4 it has been committed in a key order apm would never write — permanent apm audit --ci drift on a file with an empty git diff. Content was always byte-identical; only key order differed.

Verification

All 16 pre-push hooks pass. Test suite green in all three modes (bash tests/run-tests.sh, --strict, and RUN_TESTS_STRICT=1).

Follow-ups

  • #99 — retrofit the remaining 26 descriptions and 9 bodies. pre-commit run skill-size-check --all-files lists every violation with its measured value.
  • #100 — three remaining dangling routing targets (one of the four is fixed here as a forced consequence of the new check).
  • #101 — merge skill-audit + agent-audit; reopens ADR-0008.

Note the gates ship hot with no baseline, so editing any non-compliant skill requires retrofitting it first. That is deliberate, and #99 is prioritised accordingly.

## What Skill `name` + `description` pairs are preloaded into every session — ~6,200 tokens across 39 skills before any skill is invoked. This branch sets a contract that bounds that, adds blocking gates for it, and retrofits kyberforge's own four author/audit skills to comply. Full rationale in `docs/adr/0020-skill-description-and-body-context-contract.md`. ## Why the descriptions grew Not drift — the rules mandated it. `skill-author/SKILL.md:104` required indirect triggers and `description-quality.md:21` required authors to "err toward being pushy", both enforced. The rule that would have deflated them, `skill-author/SKILL.md:102` ("not the skill's internal mechanics"), was judgment-only with no FAIL condition behind it. The enforced rules inflated; the deflating rule never bit. There is also a correctness argument, not only a token one. `writing-skills/SKILL.md:154-158` records a measured failure where an agent followed a description's workflow summary instead of reading the body. `git-commits` was exactly that shape — 74% capability enumeration, including a rules table an agent could act on without ever loading the skill. ## Gates (blocking, no baseline file) | Gate | SUGGESTION | FAIL | Measures | |---|---|---|---| | description | 250 chars | 400 | the folded YAML value | | body | 600 words | 900 | body only, after the closing `---` | The pre-existing 500-line / 2,770-word spec backstop is **unchanged** and still counts the whole file including frontmatter. These are two independent gate families; a file can sit well inside one and fail the other. Also added: every boundary-clause routing target must resolve to a real skill or agent. It reports 3 findings across all 43 artifacts with zero false positives, and caught `gitea-labels- milestones` (a stray space from YAML folding) without being told about it. Agents take the description gates but **deliberately no body gate** — a skill body competes with the caller's live conversation, an agent body becomes the system prompt of a fresh context. A test pins that absence so it cannot be "fixed" into consistency. ## Result | Skill | Description | Body words | |---|---|---| | `skill-author` | 614 → 225 | 2,623 → 599 | | `agent-author` | 903 → 224 | 2,582 → 616 | | `agent-audit` | 863 → 250 | 1,752 → 691 | | `skill-audit` | 984 → 239 | 1,349 → 581 | | **total** | 3,364 → 938 (−72%) | 8,306 → 2,487 (−70%) | All four now use the dispatch pattern — body carries the dispatch table plus common gates, each branch self-contained in `references/`. `apm-workflow` was the exemplar. ## Two pre-existing gate failures fixed along the way Both were red at `HEAD` before this work started, and both were invisible in an ordinary local run: - **`RUN_TESTS_STRICT` leaked** into `test-run-tests.sh`'s fixture children. The meta-test is itself a suite the runner discovers, so under the gate's own invocation the variable propagated down and flipped fixtures strict. One control case failed; six more were silently asserting against the wrong stream. `bash tests/run-tests.sh` was green while pre-push was red for everyone. - **`pretty-format-json --autofix` was re-sorting apm's output.** `.claude/settings.json` is apm-owned (ADR-0018/0019) but was missing from the hook's exclude list, so since `2e395a4` it has been committed in a key order apm would never write — permanent `apm audit --ci` drift on a file with an empty `git diff`. Content was always byte-identical; only key order differed. ## Verification All 16 pre-push hooks pass. Test suite green in all three modes (`bash tests/run-tests.sh`, `--strict`, and `RUN_TESTS_STRICT=1`). ## Follow-ups - #99 — retrofit the remaining 26 descriptions and 9 bodies. `pre-commit run skill-size-check --all-files` lists every violation with its measured value. - #100 — three remaining dangling routing targets (one of the four is fixed here as a forced consequence of the new check). - #101 — merge `skill-audit` + `agent-audit`; reopens ADR-0008. Note the gates ship hot with no baseline, so editing any non-compliant skill requires retrofitting it first. That is deliberate, and #99 is prioritised accordingly.
Claude added the Kind/Feature
Priority
High
2
labels 2026-08-14 22:04:08 +00:00
Claude added 5 commits 2026-08-14 22:04:08 +00:00
Deploys kyberforge's SessionStart apm-currency hook into
.claude/settings.json (ADR-0019), bumps bin 1.1.2->1.1.3 and
kyberforge 1.4.1->1.5.0 in apm.lock.yaml with new exec_status
fields, and removes lint's now-empty .apm/hooks/ source dir
(the generated hooks/hooks.json mirror is untouched).
Every installed skill's name+description is preloaded every session -
23,612 chars (~6,200 tokens) across 39 skills. The authoring rules
optimised for triggering reliability with no counter-pressure on size:
skill-author:104 and description-quality.md:21 both mandate padding,
while skill-author:102 (the rule that would deflate it) is
judgment-only and absent from description-quality.md's FAIL conditions.

ADR-0020 sets the description shape (trigger + one capability +
boundary), two-tier size gates sitting below the unchanged
agentskills.io ceilings, a mandatory dispatch pattern for multi-branch
bodies, a Gotchas constraint, an agent-side delegation check, and
invocation as a design axis. Gates ship blocking with no baseline.

Adds five CONTEXT.md glossary entries: preload tax, skill context
contract, dispatch body, hand-invoked skill, delegation discipline.

Refs: ADR-0020
Skill name+description pairs are preloaded into every session, costing
~6,200 tokens across 39 skills before any skill is invoked. The authoring
rules mandated that growth: skill-author:104 and description-quality.md:21
both required padding, while skill-author:102 (the deflating rule) had no
FAIL condition behind it.

Gates (blocking, no baseline file):
- description 250 chars SUGGESTION / 400 FAIL, measured on the folded
  YAML value
- body-only 600 words SUGGESTION / 900 FAIL, independent of the unchanged
  whole-file 2770-word / 500-line spec backstop
- every boundary-clause routing target must resolve to a real skill or
  agent; catches skill-improve, neuledge-context and gitea-labels
- agents take the description gates but deliberately no body gate; a test
  pins that absence

Vale: DescriptionOpener widened to ^This\b, new CompositionNote rule
banning architecture notes from descriptions. 10 hits, 0 false positives.

Kyberforge's own four skills retrofitted: descriptions 3,364 -> 938 chars
(-72%), bodies 8,306 -> 2,487 words (-70%), all via the apm-workflow
dispatch pattern. Fixes the skill-improve dangling route and the
agent-author misroute to manual review.

Also fixes a pre-existing false positive where any line-initial 'read '
was flagged as interactive input, which had already caused two scripts to
be rewritten around it.

Refs: ADR-0020
plugins/lint/hooks/hooks.json was no longer produced from .apm/hooks/,
so check-plugin-content-sync failed on it at HEAD:

  DRIFT plugins/lint/hooks/hooks.json: stale, no longer produced from .apm/hooks/

Surfaced by running sync-plugin-content.sh --all during unrelated work.
Unrelated to ADR-0020; committed separately so the contract change stays
reviewable on its own.

Per ADR-0017's 2026-08-14 amendment, a hooks.json a sync no longer
generates is deleted as stale.
Two pre-existing failures, both red at HEAD before ADR-0020 work began,
both invisible in an ordinary local run.

RUN_TESTS_STRICT leaked from the environment into test-run-tests.sh's
fixture children. The meta-test is itself a suite the runner discovers,
so under the gate's own invocation the variable propagated outer runner
-> batch_run -> the fixture's copy of run-tests.sh, flipping it strict.
Case 10c (a deliberate control asserting a skip is tolerated WITHOUT
strict) then failed. Six further cases were silently running strict too
and asserting against the wrong stream — case 9 was matching the stderr
strict block rather than the stdout skip list it was written to check.
run_fake now spawns via 'env -u RUN_TESTS_STRICT', so fixture strictness
is a property of the case, never of how the file was launched. No
assertion weakened; run-tests.sh itself is untouched.

pretty-format-json --autofix was re-sorting apm's output on the way into
every commit. .claude/settings.json is apm-owned (ADR-0018/0019) and its
exclude list named fifteen generated manifests but not this file, so
since 2e395a4 it has been committed in a key order apm would never write
— permanent drift on a file with an empty git diff. Content was always
byte-identical; only JSON key order differed. The exclude ships in the
same commit as the corrected file because otherwise the hook re-breaks
it during staging.

apm.lock.yaml: generated_at churn, plus lint's exec_status corrected from
'deployed' to 'gated_pending_approval' — executables.allow grants only
kyberforge#1.5.0, so lint's hooks/bin are genuinely gated.

New coverage: an ambient RUN_TESTS_STRICT must not reach a fixture that
did not ask for it, and under --strict the skip report goes to stderr
only with the stdout list suppressed. Neither was pinned.
Claude added this to the Skills & Agents milestone 2026-08-14 22:04:26 +00:00
Defame1297 added 7 commits 2026-08-16 16:56:40 +00:00
Three ways the gates could report green having measured nothing. All three were
invisible to a passing test suite, because pre-commit prints nothing at all for a
hook that exits 0 — a gate that declines to check and a gate that checked and
passed produce the identical signal.

- A UTF-8 BOM, a leading blank line, a trailing space after a `---` marker or
  CRLF line endings defeated the `^---\n` frontmatter matcher. Every ADR-0020
  check was then skipped and the file passed: measured at the time, a
  550-character description with a 1,000-word body exited 0 behind a BOM.
  All four shapes are now tolerated, and frontmatter that genuinely cannot be
  parsed is a hard ERROR rather than a silent skip.
- An agent file with a valueless `description:` followed by another key let a
  line regex capture the *next* key, which looked non-empty, so the
  missing-or-empty branch never fired and every gate below it early-returned on
  the empty folded value — zero output, exit 0, on a blocking gate. The one
  field this contract is entirely about was the one field a gate could fail to
  notice was absent. Presence is now decided on the YAML-folded value and
  nowhere else, and a missing or empty description is a hard FAIL in all three
  validators.
- The hand-rolled frontmatter fallback disagreed with PyYAML across the FAIL
  boundary on folded scalars, so which reader happened to be available decided
  the verdict. A fallback that mis-parses a scalar shape reports a vacuous pass,
  which is worse than not running, so it is deleted: python3 and PyYAML are hard
  requirements that fail loudly with an install pointer.

Boundary-target resolution no longer derives its universe from its own location.
A `${BASH_SOURCE}`-relative repo root leaked this repo's 39-skill universe into
every consumer repo running the hook through pre-commit, so a consumer skill
routing to `skill-audit` resolved against a plugin it had never installed. The
interim form resolved through `.claude/` and `.agents/`, which are gitignored
`apm install` output — the same commit reported 2 dangling targets on a machine
that had run the install and 6 on a fresh clone. Resolution now walks up from the
file being checked to an authoring root (nearest ancestor holding
`plugins/*/.apm/{skills,agents}`, else the nearest `.git`, in two passes so a
nested `.git` cannot outrank a real monorepo root); the universe is every skill
and agent under `<root>/plugins/*/` plus the file's own apm package and that
package's declared `dependencies.apm`. Deployed trees are consulted only when no
authoring root exists at all — the consumer case. One commit now gets one verdict,
which a gate shipping hot with no baseline file has to.

Narrowed in the same pass: a routing target inferred from the prose boundary form
and corroborated by nothing else reports at SUGGESTION instead of blocking. A
blocking check with no escape hatch is the wrong trade when the inference from
prose is the weak part of it.

New deterministic checks, all previously untested or absent: every
`references/<file>.md` a body names must exist (ERROR — a broken pointer is not a
style opinion); a description with no boundary clause at all, a Gotchas section
over five entries, and a Gotchas section over 25% of the body are SUGGESTIONs.
Where no universe can be determined the target check prints `INFO ... DID NOT
RUN` rather than passing quietly. Each prose-scanning check needed its own
false-positive fix — a fenced example of a Gotchas section was being read as the
section itself — and those fixes are pinned rather than assumed.

The resolver is one block copied verbatim into all three scripts between
BEGIN/END markers, because a cache-installed plugin's scripts cannot read outside
their own plugin directory. Nothing asserted the copies were still identical; a
one-line edit to a single copy passed every constant-agreement assertion, since
constants are not what drifts.

Tests land here rather than in a later commit. The existing suites assert the old
behaviour and go red against these scripts, so splitting them would leave a commit
whose own `run-tests` pre-push gate fails in isolation.

Refs: ADR-0020
The ADR-0020 body trim took `skill-audit` from 2,623 body words to a dispatch
shape, and two things went out with it that were not padding.

The manual structural fallback was one. Its replacement was a single sentence
telling the auditor to report an INFO when `validate.sh` cannot run — so with no
`python3` or no PyYAML, `skill-audit` reported the gap honestly and then audited
nothing structural at all. Every ADR-0020 measurement, the whole-file ceilings,
the name-to-directory match, the `references/` pointer check and the script
hygiene checks silently left the audit. A skill's whole Structure dimension
hanging on one optional interpreter is the same vacuous-pass shape the gate
scripts were just fixed for, one layer up.

The `E100 Runtime error ... does not exist` diagnostic was the other. That exit
code means an explicit relative `--config` was passed to `vale-wrap.sh` while
vale itself was installed and working; without the note, Step 1's fallback reads
exit 2 as "vale unavailable" and downgrades the description, body-discipline and
patterns dimensions to full LLM judgment for a config error it could have fixed.
That misreading is already recorded in CONTEXT.md as the reason both audit skills
stopped passing `--config` at all.

Both are restored in `references/validation-scripts.md`, loaded only when a Step 1
script fails — so the body pays nothing for them on a clean run, which is what the
dispatch pattern is for. The file also carries the by-hand boundary-target
procedure and the three ways to misread the result, including that
`INFO ... DID NOT RUN` is not a pass.

`references/file-structure.md` gains the one sanctioned spelling for a cross-skill
reference. The possessive form (``skill-audit's references/validation-scripts.md``)
is the only spelling both rules accept: a full repo path is what that section
already forbids, and a bare `references/<file>.md` is now a hard ERROR from the
ADR-0020 pointer check, which requires the file to exist in the skill's *own*
directory. Without the rule the two constraints look mutually exclusive.

Refs: ADR-0020
ADR-0020 shipped its gates hot with no baseline file, so 26 of 39 descriptions
and 9 of 39 bodies are over their FAIL tier and editing any of them for any
reason requires bringing the skill into contract first. `references/improve.md`
said exactly that and stopped there — it mandated a retrofit and supplied no
procedure for one.

Four dry-run retrofits confirmed what that costs. Asked the same questions —
what to cut first, when a body is two flows rather than one, what else has to
change alongside — they invented six to ten different answers, so the same skill
retrofitted twice produced two different skills and neither run could be reviewed
against anything.

`references/retrofit.md` fixes the answers: an ordered cut list ranked by tokens
removed against behaviour lost (inverting that order is how a retrofit deletes the
instruction the skill existed to carry), the test for whether a body holds two
mutually exclusive flows, the reference-file conventions, the collateral checklist
for `README.md` and `references/sources.md`, and a worked description retrofit.

It also states the trap the dry runs kept hitting: retrofit the skill in place,
inside its package. The boundary-target universe is built by walking up from the
file being checked, so a scratch copy has no authoring root above it, the check
prints `INFO ... DID NOT RUN`, and the run still exits 0 — a line that reads as a
pass and is not one. A retrofit signed off on a copy carries an unverified
boundary target into the corpus.

Loaded from the improve flow only when a budget is actually exceeded, so a routine
improvement pays nothing for it.

Refs: ADR-0020, #99
Six defects, each one a place where two files that an author reads in the same
sitting told them different things — or where the trim dropped a rule and nothing
noticed because no gate covers prose.

**"Use proactively" contradicted itself across the pair.** All three agent
templates said to add it where the runtime should delegate unprompted, while
`agent-audit`'s `KyberforgeCopilot.ProactivePhrase` rule grades it a hard FAIL in
any `*.agent.md` — which is the Copilot half of every project/user pair *and* the
vendor-neutral plugin-scope file, since that compiles to a real Copilot agent
downstream. Following the template produced a file the repo's own gate rejects.
The phrase is now permitted in exactly one place, the Claude Code `.md`, and
`references/contract.md` carries the per-file table plus the consequence authors
ask about next: a pair whose CC half has it and whose Copilot half does not is
correct, because `agent-audit` checks that both halves describe the same job, not
that they match word for word.

**The output-schema rule contradicted itself inside one file.** `contract.md`
said any content only one branch reaches moves to `references/`, and then offered
an "Output format template" body pattern with no qualification. Stated once now,
so it is not re-litigated: an output schema stays in the body only when every flow
produces it and it is roughly 50 words or less. No third option.

**Gotchas tiers disagreed with the script.** `validate.sh` emits the entry count
through `suggest()` and exits 0, while `skill-author` and `skill-audit` both
called more than five entries a FAIL. Whether a given gotcha earns its place is
judgment, so the prose moves to the script's tier rather than the reverse. The
paraphrase rule stays a FAIL and is explicitly marked as the auditor's call — no
script detects it.

**The dispatch exemplar was cited at the wrong number.** `apm-workflow`'s body is
421 words; 554 is its whole-file count. Both `contract.md` and `body-discipline.md`
cited 554 while describing a body budget, so an author calibrating against the
exemplar overshot by ~30% — the exact whole-file/body-only conflation those two
sections exist to warn against, reproduced inside the warning.

**"Error handling" came back as a required body element.** It was one of four and
is the one that gets dropped, and dropping it is not neutral: an agent handed
malformed input with no instruction invents a recovery, and a subagent's invented
recovery is invisible to its caller until the output is wrong. Restored in
`agent-audit`'s rubric as a SUGGESTION, in `agent-author`'s contract and both
scope checklists as a required element, and as an `## Errors` section in all three
templates.

**`skill-author` Step 4 gains the one check the audit misses.** An empty body
reports `PASS SKILL.md body word count 0` — a word gate cannot tell "concise"
from "absent". Step 4 now hand-checks for a non-empty section, and its commit
verification is conditioned on actually being inside a git worktree, which a skill
under `~/.claude/skills/` is not.

Also here: absolute repo paths removed from `skill-author`'s SKILL.md and
contract.md in favour of naming the skill (`zoom-out`'s description is quoted
inline instead of pointed at), the boundary-target universe documented to match
the resolver, a two-hops-from-SKILL.md limit on reference chains, and
`new-agent.sh`'s next-steps output naming the description budget and the
deliberate absence of an agent body gate.

Refs: ADR-0020
7607522 fixed the symptom in the wrong place. It made `test-run-tests.sh`'s
`run_fake()` spawn fixtures via `env -u RUN_TESTS_STRICT`, which stops that one
suite inheriting strictness — and leaves every future suite to defend itself the
same way. The variable's only job is done the moment `run-tests.sh` latches it
into the `STRICT` shell local, so it is unset there now and the leak is gone for
every child. The `env -u` stays as this suite's own defence in depth rather than
as the fix.

Two corrections to that commit's account of the bug, both overstated and both
cheap to have checked:

- The blast radius was two assertions, cases 10c and 10g, not six. Nothing else
  in the repo reads `RUN_TESTS_STRICT`.
- The pre-push gate was never red. It invokes `bash tests/run-tests.sh --strict`,
  and the flag sets a shell local that is never exported, so the flag spelling
  never leaked at all. Only the env-var spelling did.

That asymmetry between the two documented spellings is the real finding, and
nothing asserted against it. Case 10b compared the parent's verdict, which is the
half that already matched; the halves that differed were the environments the two
spellings handed every dispatched suite. New case 10i asks a child directly —
`${VAR+set}`, so an exported empty value still counts as a leak — and asserts the
two observations equal each other rather than a hardcoded expectation, so they
cannot drift apart in a direction the case did not anticipate.
The ADR was written against base commit `f9b919d` and then not updated as the
implementation moved, so several of its numbers were measuring one thing and being
read as another — the exact conflation the ADR exists to stop, reproduced inside
it. Corrections, all reproducible now that each figure states its method:

- The preload tax is 23,427 chars / ~5,900 tokens, not 23,612 / ~6,200.
- `MAX_WORDS=2770` is a density proxy for the agentskills.io ~5,000-token ceiling,
  not "2× p90". Neither percentile reaches it: 2× the body-only p90 is 2,698 and
  2× the whole-file p90 is 3,052. Reading it as a percentile pairs a whole-file
  gate against a body-only distribution.
- `apm-workflow` is a 421-word body; 554 is its whole-file count. `skill-author`
  and `agent-author` were 2,623 and 2,582 body words — 2,760 and 2,758 whole-file,
  which is where "within twelve words of the gate" comes from. Two numbers for one
  file is the point, and only one of them is what either gate measures.
- Every `file:line` citation now says it resolves against `f9b919d`, since this
  change rewrites most of the cited files.

Three things the ADR asserted that no validator implemented are now filed by tier
in an exhaustive enforcement table — deterministic, prose-pattern, or auditor
judgment — because a rule filed under "Enforcement" that nothing enforces is the
failure mode this ADR is most exposed to. The Gotchas entry count moves to
SUGGESTION to match the script; the paraphrase FAIL is marked as an auditor's,
since semantic equivalence is not pattern-matchable.

Two gaps recorded rather than quietly left:

- The agent body-gate exemption lives in `agent-audit`'s validator and in the
  `skill-size-check` hook's `SKILL.md`-only `files:` pattern — *not* in
  `scripts/skill-size-check.sh`, which measures whatever path it is handed and
  today reports 900-word body FAILs on `git-orchestrate` (933),
  `gitea-orchestrate` (1,199) and `apm-orchestrate` (1,080). Agents escape by file
  pattern, not because the script knows the difference, so widening that pattern
  would silently enforce a gate this ADR declines to set.
- The `skill-audit`/`agent-audit` merge is deferred to #101. This change made the
  split deeper, not shallower: the dispatch retrofit took them from 3 and 4
  reference files to 7 and 8, and their two same-named `description-quality.md`
  files now differ on 100 of ~120 lines after normalising skill/agent. The merge
  reopens ADR-0008 and touches every call site in `skill-author`, `agent-author`
  and `forge`, so it is its own change. #100 carries the dangling-target fixes.

AGENTS.md and CONTEXT.md take the same corrections plus the two live setup
changes: PyYAML is now a hard requirement rather than an optional accelerator (a
fallback that mis-parses an unfamiliar scalar shape reports a clean pass on a file
it never measured), and `.claude/settings.json`'s `pretty-format-json` exclusion is
documented as load-bearing rather than as a tidy-up candidate.

LESSONS.md's autofix entry is corrected on its own provenance, which it got wrong
in both directions. `git log --date=iso` puts the introducing commit at 18:47 and
the fix at 21:54 — three hours, not "weeks" — and `git branch -a --contains` puts
the introducing commit on this branch only, not on main. It was manufactured
inside the same PR that diagnosed it. The added lesson is that "pre-existing" is a
claim about history and history is queryable: a defect found while working on a
branch feels inherited, and the feeling is not evidence.

Refs: ADR-0020, #99, #100, #101
The four preceding commits change `plugins/kyberforge/.apm/` content that reaches
the compiled artifacts — three validators, a new reference file in each of
`skill-audit` and `skill-author`, and the authoring rules across both author
skills — so per `apm-workflow`'s configure policy the package earns a bump, minor
for the new capability.

Root `apm.yml`'s `executables.allow` key moves with it, in this commit and not a
later one. apm approves a package's `hooks/` and `bin/` by an exact
`<name>#<version>` dictionary lookup with no wildcard and no version-less form, so
a `kyberforge#1.5.0` key left behind a 1.6.0 package errors nowhere: the entry
stops matching, the `SessionStart` freshness hook stops deploying, and the install
goes quietly stale. That is the failure ADR-0019 records as having actually
happened, and `check-executables-allow-sync` exists to catch it.

The catalog bump was missing from the working tree and is added here.
`apm-workflow`'s marketplace policy is explicit that an existing entry's
`version:` moving earns the catalog a **patch** — the set of packages is
unchanged, only its metadata moved — and that the root `version:` stays in step
with `marketplace.version`, since apm audit reads one and the compiled manifest
carries the other. Nothing enforces this: `apm pack --check-clean` catches a bump
made in `apm.yml` but never re-packed, while a bump never made at all fails
nothing.

Manifests regenerated with `apm pack` plus `scripts/sync-marketplace-mirror.sh`
for `.github/plugin/marketplace.json`, which no apm output profile targets.
`.agents/plugins/marketplace.json` is unchanged — the codex profile's shape
carries no version field for either the catalog or its entries.
Author
Collaborator

Review, and the fixes it produced

Ran a multi-dimension review over this PR — gate logic, constant duplication, test quality, generated-content integrity, retrofit content conservation, doc accuracy, kyberforge self-compliance, and readiness to actually drive #99. It produced ~50 findings, of which 41 are fixed here, 3 are deferred with a record, and the rest were corrections to the review's own claims.

The seven commits from b6e68e9 to e7ebc66 are that work.

Three defects passed a fully green gate

This is the part worth taking seriously. All 16 pre-push hooks and all 200 bats tests were green while each of these was live:

  1. A UTF-8 BOM silently disabled every ADR-0020 check. re.match(r'^---\n', ...) missed, and the code hit continue with no output. A SKILL.md with a 550-character description and a 1,000-word body exited 0 with zero findings. A leading blank line, a trailing space after the opening ---, or a missing closing --- did the same. The code comment deferred the case to skill-frontmatter — which is two greps that pass on all of those inputs. The deferral was to nothing.
  2. An agent file with a valueless description: passed a blocking pre-push gate with no output at all. ^description:\s*(.+) under re.MULTILINE let \s* cross the newline and capture the following key, so desc_val was truthy and even the pre-existing "description is missing" FAIL never fired.
  3. The gate gave different answers on different machines. Boundary-target resolution ran through .claude/skills/, which is gitignored apm install output. Same commit, 2 dangling targets on a developer machine and 6 on a fresh clone. Four cross-plugin gitea → git targets resolved only through deployed output.

None of these was reachable by running the test suite. Each needed someone deliberately trying to break the gate.

What changed

Commit
b6e68e9 Gate scripts: the three defects above, plus PyYAML made a hard requirement (the hand-rolled fallback disagreed with it across the 400-char FAIL boundary, so the same file could pass on one machine and fail on another), ${BASH_SOURCE}-derived resolution replaced with an authoring-root walk-up from the file being checked, and the dangling-target check narrowed so an uncorroborated prose target reports at SUGGESTION instead of blocking a commit with no escape hatch. Tests ship in the same commit.
a85bdbe Restores skill-audit's manual structural fallback and E100 diagnostic. Without them it audited nothing structural when python3 was absent while still printing a coverage line, and misread a vale config error as "vale missing". agent-audit had kept both — the asymmetry was the bug.
2540e50 skill-author/references/retrofit.md. Four dry-run retrofits established that the previous instruction mandated a retrofit and supplied no procedure, so each agent invented six to ten decisions. Following the new file took git-history from 450 chars / 1,044 words to 189 / 247 with both validators green.
311e7cd Reconciles rules the trim left disagreeing — notably a template instructing authors to write "Use proactively" where agent-audit's own vale rule makes it a hard error for that file type, and the exemplar mis-cited as a 554-word body when apm-workflow's body is 421 (554 is whole-file — the exact conflation this ADR exists to stop).
d02765d RUN_TESTS_STRICT fixed at its source. Also corrects the narrative: --strict never leaked (it sets a shell local), only the env spelling did, and the blast radius was two assertions, not six.
64ffb9f ADR-0020 and repo docs against measured ground truth. The preload headline was not reproducible under the method the ADR itself documents — 23,427 chars, not 23,612. The "merge skill-audit + agent-audit" decision is now recorded as DEFERRED with an issue number rather than stated as done.
e7ebc66 kyberforge 1.6.0, catalog 0.4.2. The executables.allow key moves in the same commit — splitting it silently stops kyberforge's SessionStart hook deploying.

False positives removed

The gate ships hot with no baseline and no suppression mechanism, so a false positive blocks a commit with no way out. Two classes were found and closed:

  • A backticked hyphenated tool name in a boundary sentence was a hard FAIL: Do not use for running hooks — run `pre-commit` instead. Ten of ten plausible descriptions tripped it, and #99 will drive ~26 authors through description rewrites where pc-run, vale-run and the apm-* skills are about hyphenated tools.
  • There was no legal way to reference another skill's references/ file — the unqualified form failed the hook, the absolute-path form is graded FAIL by file-structure.md.

Both true positives (research → neuledge-context, gitea-issues → gitea-labels) are pinned by test so a future FP fix cannot delete them.

State

  • Pre-push gate 16/16, nothing skipped
  • Tests 24 suites (was 19), 0 skipped, 0 failed; 200 bats
  • Corpus baseline unchanged: 26 description / 9 body / 2 dangling, all in un-retrofitted skills
  • Shared resolver byte-identical across all five copies; mirror diff -r clean

pre-commit run --all-files is deliberately red on two hooks — skill-size-check (37 errors) and Kyberforge.CompositionNote (10 errors across four gitea-* skills). That is the intended ship-hot state, tracked in #99.

Deferred, with records

  • #99 — the 26/9 retrofit. Body rewritten against measured numbers and now carries the retrofit procedure, the validate-in-place trap, and the note that kyberforge is itself 3-of-7 non-compliant (forge, apm-workflow, apm-install) — including that apm-workflow is cited as the dispatch exemplar while failing the gate.
  • #100 — two remaining dangling routing targets.
  • #101 — merging skill-audit + agent-audit; this PR deepens the split, which is now stated in the ADR rather than left implicit.
  • One residual, unfixed and out of scope: a filesystem path after a route verb (— see /etc/hosts instead) extracts /etc. Pre-existing, narrow.

Release tag

check-release-needed fails any push to main until a tag covers .pre-commit-hooks.yaml's paths — 21 changed since v1.0.0. A v2.0.0 is prepared but deliberately not pushed: it would point at a branch commit rather than a merge commit, unlike v1.0.0. It should be cut against the merge commit after this lands. The bump is major because all three exported hooks can now fail a file that passed at v1.0.0, and PyYAML became a hard dependency.

## Review, and the fixes it produced Ran a multi-dimension review over this PR — gate logic, constant duplication, test quality, generated-content integrity, retrofit content conservation, doc accuracy, kyberforge self-compliance, and readiness to actually drive #99. It produced ~50 findings, of which 41 are fixed here, 3 are deferred with a record, and the rest were corrections to the review's own claims. The seven commits from `b6e68e9` to `e7ebc66` are that work. ### Three defects passed a fully green gate This is the part worth taking seriously. All 16 pre-push hooks and all 200 bats tests were green while each of these was live: 1. **A UTF-8 BOM silently disabled every ADR-0020 check.** `re.match(r'^---\n', ...)` missed, and the code hit `continue` with no output. A `SKILL.md` with a 550-character description *and* a 1,000-word body exited 0 with zero findings. A leading blank line, a trailing space after the opening `---`, or a missing closing `---` did the same. The code comment deferred the case to `skill-frontmatter` — which is two greps that pass on all of those inputs. The deferral was to nothing. 2. **An agent file with a valueless `description:` passed a blocking pre-push gate with no output at all.** `^description:\s*(.+)` under `re.MULTILINE` let `\s*` cross the newline and capture the following key, so `desc_val` was truthy and even the pre-existing "description is missing" FAIL never fired. 3. **The gate gave different answers on different machines.** Boundary-target resolution ran through `.claude/skills/`, which is gitignored `apm install` output. Same commit, 2 dangling targets on a developer machine and **6 on a fresh clone**. Four cross-plugin `gitea → git` targets resolved only through deployed output. None of these was reachable by running the test suite. Each needed someone deliberately trying to break the gate. ### What changed | Commit | | |---|---| | `b6e68e9` | Gate scripts: the three defects above, plus PyYAML made a hard requirement (the hand-rolled fallback disagreed with it across the 400-char FAIL boundary, so the same file could pass on one machine and fail on another), `${BASH_SOURCE}`-derived resolution replaced with an authoring-root walk-up from the file being checked, and the dangling-target check narrowed so an uncorroborated prose target reports at SUGGESTION instead of blocking a commit with no escape hatch. Tests ship in the same commit. | | `a85bdbe` | Restores `skill-audit`'s manual structural fallback and E100 diagnostic. Without them it audited *nothing* structural when `python3` was absent while still printing a coverage line, and misread a vale config error as "vale missing". `agent-audit` had kept both — the asymmetry was the bug. | | `2540e50` | `skill-author/references/retrofit.md`. Four dry-run retrofits established that the previous instruction mandated a retrofit and supplied no procedure, so each agent invented six to ten decisions. Following the new file took `git-history` from 450 chars / 1,044 words to 189 / 247 with both validators green. | | `311e7cd` | Reconciles rules the trim left disagreeing — notably a template instructing authors to write "Use proactively" where `agent-audit`'s own vale rule makes it a hard error for that file type, and the exemplar mis-cited as a 554-word body when `apm-workflow`'s body is 421 (554 is whole-file — the exact conflation this ADR exists to stop). | | `d02765d` | `RUN_TESTS_STRICT` fixed at its source. Also corrects the narrative: `--strict` never leaked (it sets a shell local), only the env spelling did, and the blast radius was two assertions, not six. | | `64ffb9f` | ADR-0020 and repo docs against measured ground truth. The preload headline was not reproducible under the method the ADR itself documents — 23,427 chars, not 23,612. The "merge `skill-audit` + `agent-audit`" decision is now recorded as DEFERRED with an issue number rather than stated as done. | | `e7ebc66` | kyberforge 1.6.0, catalog 0.4.2. The `executables.allow` key moves in the same commit — splitting it silently stops kyberforge's `SessionStart` hook deploying. | ### False positives removed The gate ships hot with no baseline and no suppression mechanism, so a false positive blocks a commit with no way out. Two classes were found and closed: - A backticked hyphenated tool name in a boundary sentence was a hard FAIL: ``Do not use for running hooks — run `pre-commit` instead.`` Ten of ten plausible descriptions tripped it, and #99 will drive ~26 authors through description rewrites where `pc-run`, `vale-run` and the `apm-*` skills are *about* hyphenated tools. - There was no legal way to reference another skill's `references/` file — the unqualified form failed the hook, the absolute-path form is graded FAIL by `file-structure.md`. Both true positives (`research` → `neuledge-context`, `gitea-issues` → `gitea-labels`) are pinned by test so a future FP fix cannot delete them. ### State - Pre-push gate **16/16**, nothing skipped - Tests **24 suites** (was 19), 0 skipped, 0 failed; 200 bats - Corpus baseline unchanged: **26 description / 9 body / 2 dangling**, all in un-retrofitted skills - Shared resolver byte-identical across all five copies; mirror `diff -r` clean `pre-commit run --all-files` is deliberately red on two hooks — `skill-size-check` (37 errors) and `Kyberforge.CompositionNote` (10 errors across four `gitea-*` skills). That is the intended ship-hot state, tracked in #99. ### Deferred, with records - **#99** — the 26/9 retrofit. Body rewritten against measured numbers and now carries the retrofit procedure, the validate-in-place trap, and the note that kyberforge is itself 3-of-7 non-compliant (`forge`, `apm-workflow`, `apm-install`) — including that `apm-workflow` is cited as the dispatch exemplar *while failing the gate*. - **#100** — two remaining dangling routing targets. - **#101** — merging `skill-audit` + `agent-audit`; this PR deepens the split, which is now stated in the ADR rather than left implicit. - One residual, unfixed and out of scope: a filesystem path after a route verb (`— see /etc/hosts instead`) extracts `/etc`. Pre-existing, narrow. ### Release tag `check-release-needed` fails any push to `main` until a tag covers `.pre-commit-hooks.yaml`'s paths — 21 changed since `v1.0.0`. A **v2.0.0** is prepared but deliberately **not** pushed: it would point at a branch commit rather than a merge commit, unlike `v1.0.0`. It should be cut against the merge commit after this lands. The bump is major because all three exported hooks can now fail a file that passed at `v1.0.0`, and PyYAML became a hard dependency.
Claude reviewed 2026-08-16 17:38:10 +00:00
Claude left a comment
Author
Collaborator

Review: request changes

This is strong work — the ADR is the most rigorously argued in the repo, the numbers in it reproduce exactly, and the gates it specifies are mostly implemented as specified. The problems below are concentrated in one place: the resolver and frontmatter parser in scripts/skill-size-check.sh have three paths that exit 0 without measuring, and one that produces hard false failures in exactly the consumer case ADR-0020 §Decision was written to protect. For a gate shipping hot with no baseline, those are the defects that matter most, so they should land before merge rather than as follow-ups.

Everything reproduced below was confirmed by execution, not read off the source.


Blocker

B1 — In a consumer repo the deployed .claude/.agents trees are never consulted, because the .git fallback always wins. scripts/skill-size-check.sh:414-419.

_authoring_root() falls back to the nearest .git ancestor, so it returns truthy in any git repo. _collect_authoring_root() then contributes zero names (no plugins/*/.apm/), and the else: _deployed_roots(start) branch is unreachable. The consumer code path is dead in every git-tracked repo — i.e. in the only case it exists for.

Reproduced:

consumer/.git/    consumer/.claude/skills/alpha/SKILL.md   ("… — use /betaagent instead.")
                  consumer/.agents/agents/betaagent.agent.md

with .git      → ERROR: description routes to 'betaagent', which does not resolve …   rc=1
mv .git .gitX  → rc=0

Deleting .git fixes the failure, which is backwards. This contradicts ADR-0020:110-111 ("Deployed trees are consulted only when no authoring root exists — the consumer case") and defeats the guarantee at :118-127. A consumer installing holocron through pre-commit gets an unblockable hard FAIL on any skill with a boundary clause pointing at an installed sibling. Suggested fix: fall through to _deployed_roots when the authoring root contributes no names, or union it in when the root holds no plugins/*.

Major — vacuous-green paths

B2 — An indented --- inside the frontmatter silently truncates it. :694. FRONTMATTER_RE closes on \r?\n[ \t]*---[ \t]*, so a --- at block-scalar indentation ends the frontmatter early and the rest of the description is reclassified as body, with no warning. Reproduced: a folded description containing an indented ---, a ~700-character total, and a dangling /nonexistent-target — output is one spurious "no boundary clause" SUGGESTION and rc=0. Both a description FAIL and an ERROR-tier dangling target pass silently. Anchoring the closing marker at column 0 is safe, since block-scalar content must be indented.

B3 — A non-string description is str()-coerced and measured as a Python repr. :733, shared verbatim by all three validators. Reproduced, all rc=0:

frontmatter measured as
description: + - alpha / - beta "['alpha', 'beta']" (17 chars)
description: true "True" (4 chars)
description: + a: 1 "{'a': 1}" (8 chars)

A mis-indented folded scalar collapsing into a block sequence is one of the likeliest YAML slips in the exact field this ADR exists for. The inconsistency is sharp: valueless, null, '', "" and an empty > are all hard FAILs with tests pinning them (tests/test-adr0020-frontmatter.sh:253-266) — a list, mapping or bool is not. Should raise FrontmatterError.

B4 — An unreadable file kills the batch; the bash half records a silent pass. :208-217 catches only UnicodeDecodeError, so PermissionError propagates and aborts the run mid-loop — every file after it goes unmeasured. Independently, :114 discards awk's exit status, so on an awk read failure lines/words come back empty, bash arithmetic reads them as 0, and both spec ceilings pass in total silence. Confirmed under a non-root uid.

B5 — An unterminated code fence disables the ERROR-tier references/*.md existence check for the rest of the body. :785-804 treats an unclosed fence as running to EOF and blanks everything after it. Confirmed: a body with an unclosed fence followed by a pointer to a nonexistent reference → rc=0, no error; close the fence and the ERROR fires. Same suppression hits gotcha_stats.

Major — cross-script divergence

B6 — The whole-file line/word ceilings are measured by two implementations that disagree. :114 uses awk NR/NF; skill-audit/scripts/validate.sh:901,909 uses Python splitlines()/split(). Python splits on \x0b \x0c \x1c \x85 
 
 and all Unicode spaces; awk does not. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0; validate.sh reports FAIL … 606 lines, rc=1. With U+00A0: awk 814 words (silent pass) vs Python 3013 (FAIL … exceeds 2770). This is exactly the "author fixes one gate and is blocked by the other" bug, on the two axes nothing tests — tests/test-adr0020-differential.sh:290 deliberately excludes MAX_LINES/MAX_WORDS from the cross-script comparison. Latent today (no corpus file has non-ASCII whitespace), but the header comment at :111-113 asserts the equivalence that does not hold. Moving the counts into the Python block that already reads the file fixes B4 and B6 together.

Major — content lost or mis-gated in the retrofit

  • agent-audit/references/description-quality.md:113-116 states "No script checks this for an agent file — validate.sh resolves boundary targets for skills only". False — check_boundary() is called at both scopes (validate.sh:1124, :1213) and fires on real agent files. The auditor will hand-resolve what the script already resolved, and can contradict it.
  • skill-audit/SKILL.md:38 routes to references/validation-scripts.md when the script "fails, cannot run, or reports something needing interpretation". validate.sh exits 1 on ordinary content FAILs — the normal outcome for the entire #99 population — so 1,302 words of troubleshooting prose load on nearly every real audit. main:SKILL.md:40 scoped this to "cannot execute". Narrow it to "exits non-zero for a reason other than findings".
  • Three rules dropped with no survivor anywhere: the least-privilege tools guidance (main:agent-author/SKILL.md:109 — the restrict half); the improve-flow regression check (main:skill-author/SKILL.md:300, "No previously-passing audit checks were broken"); and the agent-body sizing heuristic (main:agent-author/SKILL.md:247). The last matters because 3 of 4 agents already sit at 933/1,080/1,199 body words and ADR-0020 removed the word gate — that heuristic was the only remaining brake, and the delegation check only catches procedures an invocable skill owns.
  • agent-author/SKILL.md:44 ("read only the file for the resolved scope") now hides two scope-independent rules behind project/user scope: the mcp__<server>__* glob syntax for disallowedTools (only at project-user-scope.md:36, yet disallowedTools is the only permitted fence at plugin/APM scope), and the five tools no subagent ever receives (AskUserQuestion, EnterPlanMode, ExitPlanMode, ScheduleWakeup, WaitForMcpServers).

Minor

  • :578-587 — any /word after a route verb is an unconditional hard FAIL with no suppression, so a description legitimately naming /compact, /clear or /init cannot be committed.
  • :255,296-297,385 — glob metacharacters in the checkout path ([, ], *, ?) silently disable the whole resolver; it degrades to the "DID NOT RUN" INFO with rc=0.
  • :532 — corroboration leaks across sentence boundaries when a sentence starts lowercase or with a backtick, promoting an unrelated unresolvable name from SUGGESTION to blocking ERROR.
  • re.I is applied inconsistently across the extraction patterns (:521-527), so `Skill-Audit` in a boundary sentence is never extracted. Recall gap only.
  • agent-audit/scripts/validate.sh:946 — narrowing extract_field to [^\S\r\n]* regressed tools: as a YAML block sequence: main emits the subagent-unavailable SUGGESTION, HEAD emits nothing. Block sequence is the shape Copilot files use, and no test covers it.
  • agent-audit/scripts/validate.sh:1062,1166 — a nonexistent agent file exits 1 with a bare FileNotFoundError traceback and no FAIL line. This is the path check-apm-agents-valid.sh takes for a file deleted from the worktree but still tracked. skill-audit handles it cleanly at :65-67.
  • tests/test-skill-size-check.sh:539-566 — the skill-improve probe ships already-stale (fixed by this very change), so that iteration permanently takes a pass "SKIP: …" that asserts nothing while counting toward the pass total. It also contradicts test-adr0020-targets.sh:247, which correctly pins the live set at {gitea-labels, neuledge-context}.
  • tests/test-adr0020-frontmatter.sh:83-84,239-240 — the yaml-none fixture (---\n---\n) fails FRONTMATTER_RE outright and never reaches the data is None branch it is labelled for; it passes because both messages contain the word frontmatter.
  • AGENTS.md (14-hook paragraph) and scripts/check-executables-allow-sync.sh assert a version-exact executables.allow match that apm 0.28.0 does not implement — _map_grants strips the version and compares the bare name, so any kyberforge#* entry grants. Verified by setting the key to a nonexistent kyberforge#9.9.9 and watching the SessionStart hook still deploy. Pre-existing and not in this diff, but the PR bumps the key on that premise. The bump is correct and required by the local gate; only the stated rationale is stale.
  • .pre-commit-hooks.yaml — the external ADR-0014 contract — changed, but check-release-needed.sh:20 returns 0 unless PRE_COMMIT_REMOTE_BRANCH == refs/heads/main, which a Gitea merge-button merge never sets. Consumers pinning rev: v1.0.0 get none of ADR-0020. Fine if tagging is a deliberate post-merge step; flagging it because the gate that would remind you is inert on this path.

Untested (working today, verified by hand)

The two-pass walk-up itself has no fixture anywhere placing a .git inside a plugin — the specific case ADR-0020:104-107 says the two passes exist for. Also uncovered: the .git fallback root, all ~55 lines of _declared_dependency_dirs, normalize_target namespace stripping, read_text/EncodingError, REFERENCE_QUALIFIER, and the non-file path branches (missing path, directory named SKILL.md, broken symlink) in both the bash preamble and the Python loop — the case the script asserts most loudly about itself, in duplicate.


Verification

Check Result
bash tests/run-tests.sh (plain, --strict, RUN_TESTS_STRICT=1) 24 passed, 0 skipped, 0 failed — per-suite output byte-identical across all three
All 14 pre-push hooks pass individually
skill-size-check --all-files 26 desc FAIL / 9 body FAIL / 2 dangling / 0 missing refs / 58 SUGGESTIONs — matches AGENTS.md exactly
Kyberforge.CompositionNote exactly 10 errors across the four documented gitea-* skills
Widened DescriptionOpener (^This\b) zero new hits across 39 skills + 4 agents — no regression
Version consistency kyberforge 1.6.0 and marketplace 0.4.2 agree across all 15 version-bearing locations; executables.allow bumped in lockstep
.claude/settings.json apm install in a scratch copy regenerated it byte-for-byte; the new 6th pretty-format-json exclude is load-bearing (sorted output differs from committed bytes)
Generated mirror diff -r clean except tests/, which apm pack excludes by design
Thresholds 250/251, 400/401, 600/601, 900/901, 500/501, 2770/2771 all inclusive and matching the ADR
Shared resolver block byte-identical (667 lines) across all three scripts and both mirrors; all four constants agree
RUN_TESTS_STRICT leak fix genuine and at the source (run-tests.sh:54); mutation-tested — reverting it turns test-run-tests.sh red
ADR numeric claims every one reproduces exactly at base commit f9b919d

The RUN_TESTS_STRICT and pretty-format-json fixes are both real and both correctly diagnosed.

One design note, not blocking

The ADR rejects a shrinking baseline file "in favour of hot gates" in a single clause — the least-argued decision in an otherwise exhaustively-argued document. A baseline would have delivered identical convergence pressure (fail on growth) without making two-thirds of the corpus un-editable, and the ADR itself names the resulting risk at :287-289: a gate expensive enough to be inconvenient gets bypassed with SKIP= and loses its authority. With 26 descriptions, 9 bodies and 10 Vale errors outstanding, the first person who needs a one-line gitea-prs fix meets a two-gate retrofit. Worth a sentence of justification in the ADR even if the decision stands.

Related: this bundles the contract, the four-skill retrofit, and two unrelated pre-existing CI fixes into 126 files. The CI fixes were blocking, so bundling is defensible — but 7607522 and d02765d were independently mergeable and would have unblocked everyone else's pushes days earlier.

## Review: request changes This is strong work — the ADR is the most rigorously argued in the repo, the numbers in it reproduce exactly, and the gates it specifies are mostly implemented as specified. The problems below are concentrated in one place: **the resolver and frontmatter parser in `scripts/skill-size-check.sh` have three paths that exit 0 without measuring, and one that produces hard false failures in exactly the consumer case ADR-0020 §Decision was written to protect.** For a gate shipping hot with no baseline, those are the defects that matter most, so they should land before merge rather than as follow-ups. Everything reproduced below was confirmed by execution, not read off the source. --- ### Blocker **B1 — In a consumer repo the deployed `.claude`/`.agents` trees are never consulted, because the `.git` fallback always wins.** `scripts/skill-size-check.sh:414-419`. `_authoring_root()` falls back to the nearest `.git` ancestor, so it returns truthy in *any* git repo. `_collect_authoring_root()` then contributes zero names (no `plugins/*/.apm/`), and the `else: _deployed_roots(start)` branch is unreachable. The consumer code path is dead in every git-tracked repo — i.e. in the only case it exists for. Reproduced: ``` consumer/.git/ consumer/.claude/skills/alpha/SKILL.md ("… — use /betaagent instead.") consumer/.agents/agents/betaagent.agent.md with .git → ERROR: description routes to 'betaagent', which does not resolve … rc=1 mv .git .gitX → rc=0 ``` Deleting `.git` fixes the failure, which is backwards. This contradicts ADR-0020:110-111 ("Deployed trees are consulted **only** when no authoring root exists — the consumer case") and defeats the guarantee at :118-127. A consumer installing holocron through pre-commit gets an unblockable hard FAIL on any skill with a boundary clause pointing at an installed sibling. Suggested fix: fall through to `_deployed_roots` when the authoring root contributes no names, or union it in when the root holds no `plugins/*`. ### Major — vacuous-green paths **B2 — An indented `---` inside the frontmatter silently truncates it.** `:694`. `FRONTMATTER_RE` closes on `\r?\n[ \t]*---[ \t]*`, so a `---` at block-scalar indentation ends the frontmatter early and the rest of the description is reclassified as body, with no warning. Reproduced: a folded description containing an indented `---`, a ~700-character total, and a dangling `/nonexistent-target` — output is one spurious "no boundary clause" SUGGESTION and **rc=0**. Both a description FAIL and an ERROR-tier dangling target pass silently. Anchoring the closing marker at column 0 is safe, since block-scalar content must be indented. **B3 — A non-string `description` is `str()`-coerced and measured as a Python repr.** `:733`, shared verbatim by all three validators. Reproduced, all rc=0: | frontmatter | measured as | |---|---| | `description:` + `- alpha` / `- beta` | `"['alpha', 'beta']"` (17 chars) | | `description: true` | `"True"` (4 chars) | | `description:` + `a: 1` | `"{'a': 1}"` (8 chars) | A mis-indented folded scalar collapsing into a block sequence is one of the likeliest YAML slips in the exact field this ADR exists for. The inconsistency is sharp: valueless, `null`, `''`, `""` and an empty `>` are all hard FAILs with tests pinning them (`tests/test-adr0020-frontmatter.sh:253-266`) — a list, mapping or bool is not. Should raise `FrontmatterError`. **B4 — An unreadable file kills the batch; the bash half records a silent pass.** `:208-217` catches only `UnicodeDecodeError`, so `PermissionError` propagates and aborts the run mid-loop — every file after it goes unmeasured. Independently, `:114` discards awk's exit status, so on an awk read failure `lines`/`words` come back empty, bash arithmetic reads them as 0, and both spec ceilings pass in total silence. Confirmed under a non-root uid. **B5 — An unterminated code fence disables the ERROR-tier `references/*.md` existence check for the rest of the body.** `:785-804` treats an unclosed fence as running to EOF and blanks everything after it. Confirmed: a body with an unclosed fence followed by a pointer to a nonexistent reference → rc=0, no error; close the fence and the ERROR fires. Same suppression hits `gotcha_stats`. ### Major — cross-script divergence **B6 — The whole-file line/word ceilings are measured by two implementations that disagree.** `:114` uses awk `NR`/`NF`; `skill-audit/scripts/validate.sh:901,909` uses Python `splitlines()`/`split()`. Python splits on `\x0b \x0c \x1c \x85 
 
` and all Unicode spaces; awk does not. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0; `validate.sh` reports `FAIL … 606 lines`, rc=1. With U+00A0: awk 814 words (silent pass) vs Python 3013 (`FAIL … exceeds 2770`). This is exactly the "author fixes one gate and is blocked by the other" bug, on the two axes nothing tests — `tests/test-adr0020-differential.sh:290` deliberately excludes `MAX_LINES`/`MAX_WORDS` from the cross-script comparison. Latent today (no corpus file has non-ASCII whitespace), but the header comment at `:111-113` asserts the equivalence that does not hold. Moving the counts into the Python block that already reads the file fixes B4 and B6 together. ### Major — content lost or mis-gated in the retrofit - `agent-audit/references/description-quality.md:113-116` states "No script checks this for an agent file — `validate.sh` resolves boundary targets for skills only". **False** — `check_boundary()` is called at both scopes (`validate.sh:1124`, `:1213`) and fires on real agent files. The auditor will hand-resolve what the script already resolved, and can contradict it. - `skill-audit/SKILL.md:38` routes to `references/validation-scripts.md` when the script "**fails**, cannot run, or reports something needing interpretation". `validate.sh` exits 1 on ordinary content FAILs — the normal outcome for the entire #99 population — so 1,302 words of troubleshooting prose load on nearly every real audit. `main:SKILL.md:40` scoped this to "cannot execute". Narrow it to "exits non-zero for a reason other than findings". - Three rules dropped with no survivor anywhere: the least-privilege `tools` guidance (`main:agent-author/SKILL.md:109` — the *restrict* half); the improve-flow regression check (`main:skill-author/SKILL.md:300`, "No previously-passing audit checks were broken"); and the agent-body sizing heuristic (`main:agent-author/SKILL.md:247`). The last matters because 3 of 4 agents already sit at 933/1,080/1,199 body words and ADR-0020 removed the word gate — that heuristic was the only remaining brake, and the delegation check only catches procedures an invocable skill owns. - `agent-author/SKILL.md:44` ("read only the file for the resolved scope") now hides two scope-independent rules behind project/user scope: the `mcp__<server>__*` glob syntax for `disallowedTools` (only at `project-user-scope.md:36`, yet `disallowedTools` is the *only* permitted fence at plugin/APM scope), and the five tools no subagent ever receives (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `ScheduleWakeup`, `WaitForMcpServers`). ### Minor - `:578-587` — any `/word` after a route verb is an unconditional hard FAIL with no suppression, so a description legitimately naming `/compact`, `/clear` or `/init` cannot be committed. - `:255,296-297,385` — glob metacharacters in the checkout path (`[`, `]`, `*`, `?`) silently disable the whole resolver; it degrades to the "DID NOT RUN" INFO with rc=0. - `:532` — corroboration leaks across sentence boundaries when a sentence starts lowercase or with a backtick, promoting an unrelated unresolvable name from SUGGESTION to blocking ERROR. - `re.I` is applied inconsistently across the extraction patterns (`:521-527`), so `` `Skill-Audit` `` in a boundary sentence is never extracted. Recall gap only. - `agent-audit/scripts/validate.sh:946` — narrowing `extract_field` to `[^\S\r\n]*` regressed `tools:` as a YAML **block sequence**: main emits the subagent-unavailable SUGGESTION, HEAD emits nothing. Block sequence is the shape Copilot files use, and no test covers it. - `agent-audit/scripts/validate.sh:1062,1166` — a nonexistent agent file exits 1 with a bare `FileNotFoundError` traceback and no `FAIL` line. This is the path `check-apm-agents-valid.sh` takes for a file deleted from the worktree but still tracked. `skill-audit` handles it cleanly at `:65-67`. - `tests/test-skill-size-check.sh:539-566` — the `skill-improve` probe ships already-stale (fixed by this very change), so that iteration permanently takes a `pass "SKIP: …"` that asserts nothing while counting toward the pass total. It also contradicts `test-adr0020-targets.sh:247`, which correctly pins the live set at `{gitea-labels, neuledge-context}`. - `tests/test-adr0020-frontmatter.sh:83-84,239-240` — the `yaml-none` fixture (`---\n---\n`) fails `FRONTMATTER_RE` outright and never reaches the `data is None` branch it is labelled for; it passes because both messages contain the word `frontmatter`. - `AGENTS.md` (14-hook paragraph) and `scripts/check-executables-allow-sync.sh` assert a version-exact `executables.allow` match that apm 0.28.0 does not implement — `_map_grants` strips the version and compares the bare name, so any `kyberforge#*` entry grants. Verified by setting the key to a nonexistent `kyberforge#9.9.9` and watching the `SessionStart` hook still deploy. Pre-existing and not in this diff, but the PR bumps the key on that premise. The bump is correct and required by the local gate; only the stated rationale is stale. - `.pre-commit-hooks.yaml` — the external ADR-0014 contract — changed, but `check-release-needed.sh:20` returns 0 unless `PRE_COMMIT_REMOTE_BRANCH == refs/heads/main`, which a Gitea merge-button merge never sets. Consumers pinning `rev: v1.0.0` get none of ADR-0020. Fine if tagging is a deliberate post-merge step; flagging it because the gate that would remind you is inert on this path. ### Untested (working today, verified by hand) The two-pass walk-up itself has **no fixture anywhere** placing a `.git` inside a plugin — the specific case ADR-0020:104-107 says the two passes exist for. Also uncovered: the `.git` fallback root, all ~55 lines of `_declared_dependency_dirs`, `normalize_target` namespace stripping, `read_text`/`EncodingError`, `REFERENCE_QUALIFIER`, and the non-file path branches (missing path, directory named `SKILL.md`, broken symlink) in *both* the bash preamble and the Python loop — the case the script asserts most loudly about itself, in duplicate. --- ### Verification | Check | Result | |---|---| | `bash tests/run-tests.sh` (plain, `--strict`, `RUN_TESTS_STRICT=1`) | 24 passed, 0 skipped, 0 failed — per-suite output byte-identical across all three | | All 14 pre-push hooks | pass individually | | `skill-size-check --all-files` | 26 desc FAIL / 9 body FAIL / 2 dangling / 0 missing refs / 58 SUGGESTIONs — matches AGENTS.md exactly | | `Kyberforge.CompositionNote` | exactly 10 errors across the four documented `gitea-*` skills | | Widened `DescriptionOpener` (`^This\b`) | zero new hits across 39 skills + 4 agents — no regression | | Version consistency | kyberforge 1.6.0 and marketplace 0.4.2 agree across all 15 version-bearing locations; `executables.allow` bumped in lockstep | | `.claude/settings.json` | `apm install` in a scratch copy regenerated it byte-for-byte; the new 6th `pretty-format-json` exclude is load-bearing (sorted output differs from committed bytes) | | Generated mirror | `diff -r` clean except `tests/`, which `apm pack` excludes by design | | Thresholds | 250/251, 400/401, 600/601, 900/901, 500/501, 2770/2771 all inclusive and matching the ADR | | Shared resolver block | byte-identical (667 lines) across all three scripts and both mirrors; all four constants agree | | RUN_TESTS_STRICT leak fix | genuine and at the source (`run-tests.sh:54`); mutation-tested — reverting it turns `test-run-tests.sh` red | | ADR numeric claims | every one reproduces exactly at base commit `f9b919d` | The `RUN_TESTS_STRICT` and `pretty-format-json` fixes are both real and both correctly diagnosed. ### One design note, not blocking The ADR rejects a shrinking baseline file "in favour of hot gates" in a single clause — the least-argued decision in an otherwise exhaustively-argued document. A baseline would have delivered identical convergence pressure (fail on growth) without making two-thirds of the corpus un-editable, and the ADR itself names the resulting risk at :287-289: a gate expensive enough to be inconvenient gets bypassed with `SKIP=` and loses its authority. With 26 descriptions, 9 bodies and 10 Vale errors outstanding, the first person who needs a one-line `gitea-prs` fix meets a two-gate retrofit. Worth a sentence of justification in the ADR even if the decision stands. Related: this bundles the contract, the four-skill retrofit, and two unrelated pre-existing CI fixes into 126 files. The CI fixes were blocking, so bundling is defensible — but `7607522` and `d02765d` were independently mergeable and would have unblocked everyone else's pushes days earlier.
@@ -50,0 +110,4 @@
- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was
available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is
judgment.
- **A boundary clause naming a target that does not resolve** to a real skill directory or agent
Author
Collaborator

This is factually wrong about the validator. check_boundary() is called at both scopes — agent-audit/scripts/validate.sh:1124 (plugin/APM) and :1213 (project/user) — and I confirmed it fires on a real agent file (apm-orchestrate.agent.md emits a boundary SUGGESTION).

Consequence: the auditor hand-resolves what the script already resolved, and a hand-derived verdict can contradict the script's on the same file.

This is factually wrong about the validator. `check_boundary()` is called at **both** scopes — `agent-audit/scripts/validate.sh:1124` (plugin/APM) and `:1213` (project/user) — and I confirmed it fires on a real agent file (`apm-orchestrate.agent.md` emits a boundary SUGGESTION). Consequence: the auditor hand-resolves what the script already resolved, and a hand-derived verdict can contradict the script's on the same file.
@@ -41,2 +36,3 @@
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both.
Note any Provenance FAILs and INFO findings from `validate-provenance.sh` — they surface in the report as a `### Provenance` dimension (separate from `### Structure`). The script embeds full FAIL/INFO format with Why and Fix per finding; surface them verbatim.
If any of the three fails, cannot run, or reports something needing interpretation, read `references/validation-scripts.md` — it carries the manual fallback and the misleading exit codes.
Author
Collaborator

This trigger is too broad. validate.sh exits 1 on ordinary content FAILs — the normal outcome of auditing any non-compliant skill, i.e. the entire #99 population — so "if any of the three fails" loads 1,302 words of script-troubleshooting prose on nearly every real audit. That is a context-budget regression inside the skill that enforces the context budget, and the reference file itself opens "Nothing here is needed on a clean run".

main:SKILL.md:40 scoped this precisely: "cannot execute (python3 unavailable, Bash denied, or permission error)". Suggest narrowing to "cannot run, or exits non-zero for a reason other than findings".

This trigger is too broad. `validate.sh` exits 1 on ordinary content FAILs — the normal outcome of auditing any non-compliant skill, i.e. the entire #99 population — so "if any of the three **fails**" loads 1,302 words of script-troubleshooting prose on nearly every real audit. That is a context-budget regression inside the skill that enforces the context budget, and the reference file itself opens "Nothing here is needed on a clean run". `main:SKILL.md:40` scoped this precisely: "cannot execute (python3 unavailable, Bash denied, or permission error)". Suggest narrowing to "cannot run, or exits non-zero for a reason other than findings".
Author
Collaborator

Two problems on this line.

1 — the exit status is discarded. On an awk read failure lines/words come back empty, bash arithmetic treats both as 0, and the 500-line and 2,770-word ceilings both record a silent pass — in a script whose stated rule (:92, :855) is that a measurement not taken must never be quiet.

2 — this disagrees with skill-audit/scripts/validate.sh:901,909, which measures the same two ceilings with Python splitlines()/split(). Python splits on \x0b \x0c \x1c \x85 
 
 and every Unicode space; awk splits on neither. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0, while validate.sh reports FAIL … 606 lines, rc=1. Padded with U+00A0 → awk 814 words (silent pass) vs Python 3013 (FAIL … exceeds 2770).

That is the "fix one gate, get blocked by the other" bug, on the two axes nothing tests — tests/test-adr0020-differential.sh:290 deliberately excludes MAX_LINES/MAX_WORDS from the cross-script comparison. The header comment at :111-113 asserts the equivalence that does not hold (wc -w matches awk only under LC_ALL=C).

Moving both counts into the Python block that already reads the file fixes this and the PermissionError abort at :208-217 together.

Two problems on this line. **1 — the exit status is discarded.** On an awk read failure `lines`/`words` come back empty, bash arithmetic treats both as 0, and the 500-line and 2,770-word ceilings both record a silent pass — in a script whose stated rule (`:92`, `:855`) is that a measurement not taken must never be quiet. **2 — this disagrees with `skill-audit/scripts/validate.sh:901,909`,** which measures the same two ceilings with Python `splitlines()`/`split()`. Python splits on `\x0b \x0c \x1c \x85 
 
` and every Unicode space; awk splits on neither. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0, while `validate.sh` reports `FAIL … 606 lines`, rc=1. Padded with U+00A0 → awk 814 words (silent pass) vs Python 3013 (`FAIL … exceeds 2770`). That is the "fix one gate, get blocked by the other" bug, on the two axes nothing tests — `tests/test-adr0020-differential.sh:290` deliberately excludes `MAX_LINES`/`MAX_WORDS` from the cross-script comparison. The header comment at `:111-113` asserts the equivalence that does not hold (`wc -w` matches awk only under `LC_ALL=C`). Moving both counts into the Python block that already reads the file fixes this and the `PermissionError` abort at `:208-217` together.
@@ -71,0 +411,4 @@
for dep_dir in _declared_dependency_dirs(package):
_collect_package(dep_dir, names)
root = _authoring_root(start)
Author
Collaborator

Blocker. _authoring_root() falls back to the nearest .git ancestor, so it returns truthy in any git repo. _collect_authoring_root() then contributes zero names (no plugins/*/.apm/), and this else branch never runs — _deployed_roots is dead code in every git-tracked repo, which is the only case it was written for.

Reproduced in a bare consumer repo with .claude/skills/alpha/SKILL.md routing to /betaagent and .agents/agents/betaagent.agent.md present:

with .git      → ERROR: routes to 'betaagent', which does not resolve …   rc=1
mv .git .gitX  → rc=0

Deleting .git fixes the failure. This contradicts ADR-0020:110-111 and defeats the install-independence guarantee at :118-127 — a consumer running this hook through pre-commit gets an unblockable hard FAIL on any boundary clause naming an installed sibling.

Fix: fall through to _deployed_roots when the authoring root contributes no names, or union it in when the root holds no plugins/*.

**Blocker.** `_authoring_root()` falls back to the nearest `.git` ancestor, so it returns truthy in *any* git repo. `_collect_authoring_root()` then contributes zero names (no `plugins/*/.apm/`), and this `else` branch never runs — `_deployed_roots` is dead code in every git-tracked repo, which is the only case it was written for. Reproduced in a bare consumer repo with `.claude/skills/alpha/SKILL.md` routing to `/betaagent` and `.agents/agents/betaagent.agent.md` present: ``` with .git → ERROR: routes to 'betaagent', which does not resolve … rc=1 mv .git .gitX → rc=0 ``` Deleting `.git` fixes the failure. This contradicts ADR-0020:110-111 and defeats the install-independence guarantee at :118-127 — a consumer running this hook through pre-commit gets an unblockable hard FAIL on any boundary clause naming an installed sibling. Fix: fall through to `_deployed_roots` when the authoring root contributes no names, or union it in when the root holds no `plugins/*`.
@@ -71,0 +691,4 @@
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
# be measured must never report green, so every caller of these two ERRORs on a
# miss instead of moving on.
FRONTMATTER_RE = re.compile(
Author
Collaborator

Vacuous green. The closing marker \r?\n[ \t]*---[ \t]* matches at any indentation, so a --- line inside a block scalar terminates the frontmatter early and the remainder of the description is silently reclassified as body.

Reproduced — a folded description: containing an indented ---, ~700 chars total, ending in use /nonexistent-target instead:

SUGGESTION: … has no boundary clause
rc=0

A description FAIL and an ERROR-tier dangling target both pass, with the only output being a spurious suggestion that the boundary clause is missing. This is the #1 failure mode the ADR is written against.

Anchoring the close at column 0 (\r?\n---[ \t]*) is safe — block-scalar content must be indented.

**Vacuous green.** The closing marker `\r?\n[ \t]*---[ \t]*` matches at any indentation, so a `---` line inside a block scalar terminates the frontmatter early and the remainder of the description is silently reclassified as body. Reproduced — a folded `description:` containing an indented `---`, ~700 chars total, ending in `use /nonexistent-target instead`: ``` SUGGESTION: … has no boundary clause rc=0 ``` A description FAIL *and* an ERROR-tier dangling target both pass, with the only output being a spurious suggestion that the boundary clause is missing. This is the #1 failure mode the ADR is written against. Anchoring the close at column 0 (`\r?\n---[ \t]*`) is safe — block-scalar content must be indented.
@@ -71,0 +730,4 @@
value = data.get('description')
if value is None:
return ''
if not isinstance(value, str):
Author
Collaborator

Vacuous green, and shared verbatim by all three validators. A non-string YAML value is str()-coerced and then measured as a Python repr. All rc=0:

frontmatter measured as
description: + - alpha / - beta "['alpha', 'beta']" (17 chars)
description: true "True" (4 chars)
description: + a: 1 "{'a': 1}" (8 chars)

A mis-indented folded scalar collapsing into a block sequence is one of the two likeliest YAML slips in precisely the field this ADR exists for — and the boundary/routing extraction then runs over the repr.

The inconsistency is sharp: valueless, null, '', "" and an empty > are all hard FAILs, with tests/test-adr0020-frontmatter.sh:253-266 pinning all five. A list, mapping or bool is not. Should raise FrontmatterError.

**Vacuous green**, and shared verbatim by all three validators. A non-string YAML value is `str()`-coerced and then measured as a Python repr. All rc=0: | frontmatter | measured as | |---|---| | `description:` + `- alpha` / `- beta` | `"['alpha', 'beta']"` (17 chars) | | `description: true` | `"True"` (4 chars) | | `description:` + `a: 1` | `"{'a': 1}"` (8 chars) | A mis-indented folded scalar collapsing into a block sequence is one of the two likeliest YAML slips in precisely the field this ADR exists for — and the boundary/routing extraction then runs over the repr. The inconsistency is sharp: valueless, `null`, `''`, `""` and an empty `>` are all hard FAILs, with `tests/test-adr0020-frontmatter.sh:253-266` pinning all five. A list, mapping or bool is not. Should raise `FrontmatterError`.
Claude reviewed 2026-08-16 17:38:31 +00:00
Claude left a comment
Author
Collaborator

Submitted as a comment review — Gitea rejects REQUEST_CHANGES from the PR author's own account. Treat it as request-changes: B1 is a confirmed blocker and B2/B3/B5 are confirmed vacuous-green paths.

Submitted as a comment review — Gitea rejects REQUEST_CHANGES from the PR author's own account. Treat it as request-changes: B1 is a confirmed blocker and B2/B3/B5 are confirmed vacuous-green paths.
@@ -50,0 +110,4 @@
- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was
available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is
judgment.
- **A boundary clause naming a target that does not resolve** to a real skill directory or agent
Author
Collaborator

This is factually wrong about the validator. check_boundary() is called at both scopes — agent-audit/scripts/validate.sh:1124 (plugin/APM) and :1213 (project/user) — and I confirmed it fires on a real agent file (apm-orchestrate.agent.md emits a boundary SUGGESTION).

Consequence: the auditor hand-resolves what the script already resolved, and a hand-derived verdict can contradict the script's on the same file.

This is factually wrong about the validator. `check_boundary()` is called at **both** scopes — `agent-audit/scripts/validate.sh:1124` (plugin/APM) and `:1213` (project/user) — and I confirmed it fires on a real agent file (`apm-orchestrate.agent.md` emits a boundary SUGGESTION). Consequence: the auditor hand-resolves what the script already resolved, and a hand-derived verdict can contradict the script's on the same file.
@@ -41,2 +36,3 @@
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both.
Note any Provenance FAILs and INFO findings from `validate-provenance.sh` — they surface in the report as a `### Provenance` dimension (separate from `### Structure`). The script embeds full FAIL/INFO format with Why and Fix per finding; surface them verbatim.
If any of the three fails, cannot run, or reports something needing interpretation, read `references/validation-scripts.md` — it carries the manual fallback and the misleading exit codes.
Author
Collaborator

This trigger is too broad. validate.sh exits 1 on ordinary content FAILs — the normal outcome of auditing any non-compliant skill, i.e. the entire #99 population — so "if any of the three fails" loads 1,302 words of script-troubleshooting prose on nearly every real audit. That is a context-budget regression inside the skill that enforces the context budget, and the reference file itself opens "Nothing here is needed on a clean run".

main:SKILL.md:40 scoped this precisely: "cannot execute (python3 unavailable, Bash denied, or permission error)". Suggest narrowing to "cannot run, or exits non-zero for a reason other than findings".

This trigger is too broad. `validate.sh` exits 1 on ordinary content FAILs — the normal outcome of auditing any non-compliant skill, i.e. the entire #99 population — so "if any of the three **fails**" loads 1,302 words of script-troubleshooting prose on nearly every real audit. That is a context-budget regression inside the skill that enforces the context budget, and the reference file itself opens "Nothing here is needed on a clean run". `main:SKILL.md:40` scoped this precisely: "cannot execute (python3 unavailable, Bash denied, or permission error)". Suggest narrowing to "cannot run, or exits non-zero for a reason other than findings".
Author
Collaborator

Two problems on this line.

1 — the exit status is discarded. On an awk read failure lines/words come back empty, bash arithmetic treats both as 0, and the 500-line and 2,770-word ceilings both record a silent pass — in a script whose stated rule (:92, :855) is that a measurement not taken must never be quiet.

2 — this disagrees with skill-audit/scripts/validate.sh:901,909, which measures the same two ceilings with Python splitlines()/split(). Python splits on \x0b \x0c \x1c \x85 
 
 and every Unicode space; awk splits on neither. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0, while validate.sh reports FAIL … 606 lines, rc=1. Padded with U+00A0 → awk 814 words (silent pass) vs Python 3013 (FAIL … exceeds 2770).

That is the "fix one gate, get blocked by the other" bug, on the two axes nothing tests — tests/test-adr0020-differential.sh:290 deliberately excludes MAX_LINES/MAX_WORDS from the cross-script comparison. The header comment at :111-113 asserts the equivalence that does not hold (wc -w matches awk only under LC_ALL=C).

Moving both counts into the Python block that already reads the file fixes this and the PermissionError abort at :208-217 together.

Two problems on this line. **1 — the exit status is discarded.** On an awk read failure `lines`/`words` come back empty, bash arithmetic treats both as 0, and the 500-line and 2,770-word ceilings both record a silent pass — in a script whose stated rule (`:92`, `:855`) is that a measurement not taken must never be quiet. **2 — this disagrees with `skill-audit/scripts/validate.sh:901,909`,** which measures the same two ceilings with Python `splitlines()`/`split()`. Python splits on `\x0b \x0c \x1c \x85 
 
` and every Unicode space; awk splits on neither. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0, while `validate.sh` reports `FAIL … 606 lines`, rc=1. Padded with U+00A0 → awk 814 words (silent pass) vs Python 3013 (`FAIL … exceeds 2770`). That is the "fix one gate, get blocked by the other" bug, on the two axes nothing tests — `tests/test-adr0020-differential.sh:290` deliberately excludes `MAX_LINES`/`MAX_WORDS` from the cross-script comparison. The header comment at `:111-113` asserts the equivalence that does not hold (`wc -w` matches awk only under `LC_ALL=C`). Moving both counts into the Python block that already reads the file fixes this and the `PermissionError` abort at `:208-217` together.
@@ -71,0 +411,4 @@
for dep_dir in _declared_dependency_dirs(package):
_collect_package(dep_dir, names)
root = _authoring_root(start)
Author
Collaborator

Blocker. _authoring_root() falls back to the nearest .git ancestor, so it returns truthy in any git repo. _collect_authoring_root() then contributes zero names (no plugins/*/.apm/), and this else branch never runs — _deployed_roots is dead code in every git-tracked repo, which is the only case it was written for.

Reproduced in a bare consumer repo with .claude/skills/alpha/SKILL.md routing to /betaagent and .agents/agents/betaagent.agent.md present:

with .git      → ERROR: routes to 'betaagent', which does not resolve …   rc=1
mv .git .gitX  → rc=0

Deleting .git fixes the failure. This contradicts ADR-0020:110-111 and defeats the install-independence guarantee at :118-127 — a consumer running this hook through pre-commit gets an unblockable hard FAIL on any boundary clause naming an installed sibling.

Fix: fall through to _deployed_roots when the authoring root contributes no names, or union it in when the root holds no plugins/*.

**Blocker.** `_authoring_root()` falls back to the nearest `.git` ancestor, so it returns truthy in *any* git repo. `_collect_authoring_root()` then contributes zero names (no `plugins/*/.apm/`), and this `else` branch never runs — `_deployed_roots` is dead code in every git-tracked repo, which is the only case it was written for. Reproduced in a bare consumer repo with `.claude/skills/alpha/SKILL.md` routing to `/betaagent` and `.agents/agents/betaagent.agent.md` present: ``` with .git → ERROR: routes to 'betaagent', which does not resolve … rc=1 mv .git .gitX → rc=0 ``` Deleting `.git` fixes the failure. This contradicts ADR-0020:110-111 and defeats the install-independence guarantee at :118-127 — a consumer running this hook through pre-commit gets an unblockable hard FAIL on any boundary clause naming an installed sibling. Fix: fall through to `_deployed_roots` when the authoring root contributes no names, or union it in when the root holds no `plugins/*`.
@@ -71,0 +691,4 @@
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
# be measured must never report green, so every caller of these two ERRORs on a
# miss instead of moving on.
FRONTMATTER_RE = re.compile(
Author
Collaborator

Vacuous green. The closing marker \r?\n[ \t]*---[ \t]* matches at any indentation, so a --- line inside a block scalar terminates the frontmatter early and the remainder of the description is silently reclassified as body.

Reproduced — a folded description: containing an indented ---, ~700 chars total, ending in use /nonexistent-target instead:

SUGGESTION: … has no boundary clause
rc=0

A description FAIL and an ERROR-tier dangling target both pass, with the only output being a spurious suggestion that the boundary clause is missing. This is the #1 failure mode the ADR is written against.

Anchoring the close at column 0 (\r?\n---[ \t]*) is safe — block-scalar content must be indented.

**Vacuous green.** The closing marker `\r?\n[ \t]*---[ \t]*` matches at any indentation, so a `---` line inside a block scalar terminates the frontmatter early and the remainder of the description is silently reclassified as body. Reproduced — a folded `description:` containing an indented `---`, ~700 chars total, ending in `use /nonexistent-target instead`: ``` SUGGESTION: … has no boundary clause rc=0 ``` A description FAIL *and* an ERROR-tier dangling target both pass, with the only output being a spurious suggestion that the boundary clause is missing. This is the #1 failure mode the ADR is written against. Anchoring the close at column 0 (`\r?\n---[ \t]*`) is safe — block-scalar content must be indented.
@@ -71,0 +730,4 @@
value = data.get('description')
if value is None:
return ''
if not isinstance(value, str):
Author
Collaborator

Vacuous green, and shared verbatim by all three validators. A non-string YAML value is str()-coerced and then measured as a Python repr. All rc=0:

frontmatter measured as
description: + - alpha / - beta "['alpha', 'beta']" (17 chars)
description: true "True" (4 chars)
description: + a: 1 "{'a': 1}" (8 chars)

A mis-indented folded scalar collapsing into a block sequence is one of the two likeliest YAML slips in precisely the field this ADR exists for — and the boundary/routing extraction then runs over the repr.

The inconsistency is sharp: valueless, null, '', "" and an empty > are all hard FAILs, with tests/test-adr0020-frontmatter.sh:253-266 pinning all five. A list, mapping or bool is not. Should raise FrontmatterError.

**Vacuous green**, and shared verbatim by all three validators. A non-string YAML value is `str()`-coerced and then measured as a Python repr. All rc=0: | frontmatter | measured as | |---|---| | `description:` + `- alpha` / `- beta` | `"['alpha', 'beta']"` (17 chars) | | `description: true` | `"True"` (4 chars) | | `description:` + `a: 1` | `"{'a': 1}"` (8 chars) | A mis-indented folded scalar collapsing into a block sequence is one of the two likeliest YAML slips in precisely the field this ADR exists for — and the boundary/routing extraction then runs over the repr. The inconsistency is sharp: valueless, `null`, `''`, `""` and an empty `>` are all hard FAILs, with `tests/test-adr0020-frontmatter.sh:253-266` pinning all five. A list, mapping or bool is not. Should raise `FrontmatterError`.
Defame1297 added 4 commits 2026-08-16 19:57:01 +00:00
Review of the ADR-0020 gate found four ways it could exit 0 without measuring, and
one way it hard-failed a repo it had no business failing. On a gate shipping hot
with no baseline, a silent pass is the worst outcome available and a false block is
the second worst.

Consumer resolution was the blocker. _authoring_root() fell back to the nearest
.git, so it returned truthy in ANY git repo; _collect_authoring_root() then
contributed nothing and the deployed-tree branch was dead code in precisely the
consumer case it exists for. A consumer repo routing to an installed sibling got an
unblockable ERROR, and deleting .git "fixed" it. It now keys on which of the two
walk-up passes matched. A name-count delta was tried first and is wrong: a
single-plugin monorepo re-collects its own package and adds no new name, so the
delta reads zero and drags the deployed trees — including a global ~/.claude — back
into the universe. That reintroduces the install-dependence ADR-0020 forbids, one
layer down.

The three silent passes: an indented `---` inside a block scalar truncated the
frontmatter and reclassified the rest of the description as body; a non-string
description was str()-coerced, so `description: true` measured as the four-character
"True"; and an unterminated fence blanked the rest of the body, disabling the
ERROR-tier references/ check and the gotcha counts.

Two measurement defects came with them. The awk line/word counts discarded awk's
exit status, so an unreadable file passed both spec ceilings in total silence, and
awk NR/NF disagreed with the audit script's splitlines()/split() on Unicode
whitespace — the "fix one gate, get blocked by the other" bug, on the two axes the
differential test deliberately excluded. Both counts now run in the Python block
that already reads the file. A type error also no longer reports itself as a syntax
error.

Also: glob metacharacters in the checkout path silently disabled the resolver;
re.I was applied to some extraction patterns and not others; agent-audit missed
`tools:` written as a YAML block sequence, the shape Copilot files use; and a
nonexistent agent file raised a bare FileNotFoundError instead of a diagnostic.

The shared resolver block stays byte-identical across all three scripts. Corpus
output is unchanged — 26 description FAIL, 9 body FAIL, 2 dangling, 0 missing
references, 58 SUGGESTIONs — so no documented count moves.

Refs: #99
ADR: 0020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W3iwF9ncfRZddGBxsMCYi
Every defect fixed in f7cc279 was reachable because nothing asserted against it.
The gate had 43 assertions and none of them covered a consumer repo, a non-string
description, an unclosed fence, or the two spec ceilings. Each case below fails
against the pre-fix code and passes against the current one; every one was proved
non-vacuous by mutating a scratch copy of the script and watching the test go red,
independently twice.

The two that mattered most had no fixture anywhere. A consumer repo WITH .git is
the shape the resolver exists to serve, and only the no-.git case had ever been
tested, which is exactly why the blocker was invisible. And ADR-0020 says the
walk-up runs in two passes specifically so a nested .git cannot beat a plugins/
root further up — no fixture had ever placed a .git inside a plugin.

test-adr0020-differential.sh loses _non_adr_hook_error(). It excluded MAX_LINES and
MAX_WORDS from the cross-script comparison on the untested assumption that awk and
splitlines() agree. They do not, and the divergence stayed invisible for exactly as
long as the exclusion stood. The ceilings are now compared like any other rule.

Two existing assertions were repairs, not additions. The skill-improve probe had
been fixed by this very branch, so its iteration permanently took an
assertion-free SKIP that still counted as a pass; both branches now fail loudly and
each names the other file's pin so the two stay in step. And the yaml-none fixture
emitted `---/---`, which never matched the frontmatter pattern at all — it passed on
the bare word "frontmatter", present in both messages, while never reaching the
branch it was named for. Needles throughout that file now name their branch.

Refs: #99
ADR: 0020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W3iwF9ncfRZddGBxsMCYi
Diffing each retrofitted SKILL.md against its replacement references/ files found
rules that existed on main and now existed nowhere — relocated in intent, deleted in
fact. A trim that loses a rule is not progressive disclosure, it is data loss with a
smaller word count.

Three had no survivor. The least-privilege guidance for `tools` kept its mechanics
and lost the "restrict to what the agent needs" half, so the remaining text read as
encouragement to omit the field. The improve flow lost its regression check, so
nothing compared the closing audit against the pre-edit state and a PASS quietly
becoming a SUGGESTION went unnoticed — restored on both halves of the author pair,
since agent-author had dropped its equivalent too. And agent bodies lost "would the
agent get this wrong without it?", which mattered more than it looks: ADR-0020
deliberately sets no body word gate for agents, three of the four already sit
between 933 and 1,199 words, and the delegation check only fires on procedure a
skill already owns. That heuristic was the only brake left.

Two more were reachable only from the wrong scope. agent-author tells the reader to
load only the file for the resolved scope, but the mcp__ glob syntax for
disallowedTools and the five tools no subagent ever receives had both landed in
project-user-scope.md. disallowedTools is the ONLY permitted fence at plugin/APM
scope, so the scope that needs the syntax most could not reach it, and a plugin-scope
run could write a body telling the agent to ask the user a question.

Two documents were actively wrong rather than merely thin. agent-audit told auditors
that validate.sh resolves boundary targets for skills only; it runs at both scopes,
so the auditor was hand-resolving what the script had already decided and could
contradict it. And skill-audit routed to its script-troubleshooting reference
whenever validate.sh "fails" — but it exits 1 on ordinary content FAILs, the normal
outcome for the whole #99 population, so 1,302 words loaded on nearly every audit.
A context-budget regression inside the skill that enforces the context budget.

Finally, two illustrations taught the shape the gate ERRORs on, unfenced, while an
adjacent rubric called it a hard ERROR.

LESSONS.md records the reference-chain depth rule flipping from "one level deep" to
"two hops, never three". ADR-0020 is silent on it and the reversal rode entirely on
the diff; the looser rule is what mandatory dispatch requires.

Refs: #99
ADR: 0020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W3iwF9ncfRZddGBxsMCYi
Both AGENTS.md and ADR-0020 said deployed .claude/.agents trees are consulted "only
when no authoring root exists". That stopped being true in f7cc279: the walk-up
finds a root in any git repo, so the condition is now whether that root holds
plugins, not whether one was found at all. Left alone, the two documents describe a
resolver that no longer exists — and this repo's prose is load-bearing, since the
next agent reads it instead of the code.

Both now also record why a name-count delta is not an equivalent test, because it is
the obvious simplification and it is wrong: a single-plugin monorepo re-collects its
own package, adds no new name, and would pull the deployed trees back in.

ADR: 0020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W3iwF9ncfRZddGBxsMCYi
Author
Collaborator

Review findings addressed — pushed as 79c9089

Four commits on top of e7ebc66. Every blocker and major from the review above is closed; the two items I left open are design decisions, called out at the bottom.

f7cc279 fix(kyberforge) — close the vacuous-green and consumer-resolution defects
b0d6d08 test — pin the nine ADR-0020 gate defects that shipped untested
ede3f06 fix(kyberforge) — restore the authoring rules the ADR-0020 trim dropped
79c9089 docs — make the resolution contract match what the gate actually does

The blocker, and a second one found while fixing it

B1 is fixed, but the first fix was wrong and the review wave caught it — worth recording because the wrong version is the obvious one.

_authoring_root() fell back to the nearest .git, so it returned truthy in any git repo and the deployed-tree branch was dead code. The intuitive repair is "fall through when the authoring root contributed no new names". That is wrong: a single-plugin monorepo re-collects its own package, adds no new name, so the delta reads zero and _deployed_roots() runs — pulling in every .claude//.agents/ tree up to ten levels, including the user's global ~/.claude/skills. Same commit, two verdicts, decided by whether apm install had been run. That is ADR-0020:118-127's failure one layer down.

It now keys on which of the two walk-up passes matched, which is what the comment always claimed. Verified by instrumenting _deployed_roots(): zero calls across the corpus on the real working tree, and a bare plugins/ + apm.yml tree produces findings identical to it with zero INFO lines.

Vacuous-green paths closed

B2, B3 and B5 all exited 0 while measuring nothing:

  • an indented --- inside a block scalar truncated the frontmatter — a 815-char description and a dangling /nonexistent-target both passed, the only output a spurious "no boundary clause" SUGGESTION;
  • a non-string description was str()-coerced, so description: true measured as the 4-char "True" — sharpened by the fact that valueless/null/''/""/empty-> were already hard FAILs with tests pinning them;
  • an unterminated fence blanked the rest of the body, disabling the ERROR-tier references/ check and the gotcha counts.

B4 and B6 are fixed together: both whole-file counts now run in the Python block that already reads the file. This removes the discarded awk exit status and the awk-vs-splitlines() disagreement on Unicode whitespace. _non_adr_hook_error() is deleted from the differential test — it excluded MAX_LINES/MAX_WORDS from the cross-script comparison on an untested assumption, which is exactly why the divergence stayed invisible.

Also closed: the glob.escape gap, the re.I inconsistency, tools: as a YAML block sequence, and the FileNotFoundError traceback. A type error also no longer reports itself as frontmatter is not valid YAML.

Authoring rules restored

Three rules existed on main and existed nowhere after the trim: least-privilege tools (the restrict half), the improve-flow regression check, and the agent-body "would the agent get this wrong without it?" heuristic. The last is the one I'd have pushed back hardest on — ADR-0020 deliberately sets no body word gate for agents, three of four already sit at 933/1,080/1,199 words, and the delegation check only fires on procedure a skill already owns. That heuristic was the only brake left. It turned out agent-author had also lost its own regression check, so that one is restored on both halves of the pair.

Two rules were reachable only from the wrong scope (the mcp__ glob syntax for disallowedTools, and the five tools no subagent receives) — disallowedTools is the only permitted fence at plugin/APM scope, so the scope needing the syntax most couldn't reach it.

Two documents were actively wrong: agent-audit told auditors validate.sh resolves boundary targets for skills only (it runs at both scopes), and skill-audit loaded 1,302 words of script-troubleshooting prose whenever validate.sh "fails" — which is every ordinary content FAIL, i.e. the whole #99 population.

Tests

Nine regression tests, one per defect, each proven non-vacuous by mutating a scratch copy and watching it go red — done independently twice, by the author and again by a reviewer who re-ran every mutation itself rather than trusting the report.

Two gaps worth naming: no fixture anywhere placed a .git inside a plugin, the exact case ADR-0020:104-107 says the two-pass walk-up exists for; and only the no-.git consumer case had ever been tested, which is precisely why B1 was invisible. Both now have fixtures.

Two existing assertions were repairs. The skill-improve probe had been fixed by this branch, so its iteration permanently took an assertion-free SKIP that still counted as a pass. And the yaml-none fixture emitted ---/---, which never matched the frontmatter pattern at all — it passed on the bare word "frontmatter", present in both messages, while never reaching the branch it was named for.

Verification

  • bash tests/run-tests.sh --strict → 24 passed, 0 skipped, 0 failed
  • bash tests/run-bats.sh → 208 tests, 0 failures (was 202)
  • All 16 pre-push hooks pass, including on the push that landed this
  • Corpus unchanged: 26 description FAIL / 9 body FAIL / 2 dangling / 0 missing references / 58 SUGGESTIONs; agent body FAILs still 933 / 1199 / 1080
  • Shared resolver span byte-identical across all three scripts
  • Vale: exactly the 10 pre-existing CompositionNote errors, zero new findings on touched files

AGENTS.md and ADR-0020 both said deployed trees are consulted "only when no authoring root exists". That stopped being true, so both now describe the shipped rule and record why the name-count delta is not equivalent — the prose here is load-bearing and the next agent reads it instead of the code.

Still open — your call, not mine

  1. /compact false positive. Any /word after a route verb is an unconditional hard FAIL, so a description legitimately naming a host built-in (/clear, /compact, /init) cannot be committed. Fixing it needs either a known-builtins allowlist or a suppression mechanism, and the gate has neither by design.
  2. Sentence-boundary corroboration leak (:532). Changes how targets get promoted from SUGGESTION to blocking ERROR, so it can move the documented corpus counts.

Also unchanged, per your call: no release tag. check-release-needed.sh:20 returns 0 unless PRE_COMMIT_REMOTE_BRANCH == refs/heads/main, which a merge-button merge never sets — so the gate will not remind you, and consumers pinning rev: v1.0.0 get none of ADR-0020 until it is tagged manually.

The design note from the review still stands and is not addressed here: the ADR rejects a shrinking baseline in a single clause, and it is the least-argued decision in the document.

## Review findings addressed — pushed as `79c9089` Four commits on top of `e7ebc66`. Every blocker and major from the review above is closed; the two items I left open are design decisions, called out at the bottom. | | | |---|---| | `f7cc279` | `fix(kyberforge)` — close the vacuous-green and consumer-resolution defects | | `b0d6d08` | `test` — pin the nine ADR-0020 gate defects that shipped untested | | `ede3f06` | `fix(kyberforge)` — restore the authoring rules the ADR-0020 trim dropped | | `79c9089` | `docs` — make the resolution contract match what the gate actually does | ### The blocker, and a second one found while fixing it B1 is fixed, but the first fix was wrong and the review wave caught it — worth recording because the wrong version is the obvious one. `_authoring_root()` fell back to the nearest `.git`, so it returned truthy in any git repo and the deployed-tree branch was dead code. The intuitive repair is "fall through when the authoring root contributed no new names". That is wrong: a **single-plugin** monorepo re-collects its own package, adds no new name, so the delta reads zero and `_deployed_roots()` runs — pulling in every `.claude/`/`.agents/` tree up to ten levels, **including the user's global `~/.claude/skills`**. Same commit, two verdicts, decided by whether `apm install` had been run. That is ADR-0020:118-127's failure one layer down. It now keys on **which of the two walk-up passes matched**, which is what the comment always claimed. Verified by instrumenting `_deployed_roots()`: zero calls across the corpus on the real working tree, and a bare `plugins/` + `apm.yml` tree produces findings identical to it with zero `INFO` lines. ### Vacuous-green paths closed B2, B3 and B5 all exited 0 while measuring nothing: - an indented `---` inside a block scalar truncated the frontmatter — a 815-char description **and** a dangling `/nonexistent-target` both passed, the only output a spurious "no boundary clause" SUGGESTION; - a non-string `description` was `str()`-coerced, so `description: true` measured as the 4-char `"True"` — sharpened by the fact that valueless/`null`/`''`/`""`/empty-`>` were already hard FAILs with tests pinning them; - an unterminated fence blanked the rest of the body, disabling the ERROR-tier `references/` check and the gotcha counts. B4 and B6 are fixed together: both whole-file counts now run in the Python block that already reads the file. This removes the discarded awk exit status *and* the awk-vs-`splitlines()` disagreement on Unicode whitespace. `_non_adr_hook_error()` is deleted from the differential test — it excluded `MAX_LINES`/`MAX_WORDS` from the cross-script comparison on an untested assumption, which is exactly why the divergence stayed invisible. Also closed: the `glob.escape` gap, the `re.I` inconsistency, `tools:` as a YAML block sequence, and the `FileNotFoundError` traceback. A type error also no longer reports itself as `frontmatter is not valid YAML`. ### Authoring rules restored Three rules existed on `main` and existed nowhere after the trim: least-privilege `tools` (the *restrict* half), the improve-flow regression check, and the agent-body "would the agent get this wrong without it?" heuristic. The last is the one I'd have pushed back hardest on — ADR-0020 deliberately sets no body word gate for agents, three of four already sit at 933/1,080/1,199 words, and the delegation check only fires on procedure a skill already owns. That heuristic was the only brake left. It turned out `agent-author` had also lost its own regression check, so that one is restored on both halves of the pair. Two rules were reachable only from the wrong scope (the `mcp__` glob syntax for `disallowedTools`, and the five tools no subagent receives) — `disallowedTools` is the only permitted fence at plugin/APM scope, so the scope needing the syntax most couldn't reach it. Two documents were actively wrong: `agent-audit` told auditors `validate.sh` resolves boundary targets for skills only (it runs at both scopes), and `skill-audit` loaded 1,302 words of script-troubleshooting prose whenever `validate.sh` "fails" — which is every ordinary content FAIL, i.e. the whole #99 population. ### Tests Nine regression tests, one per defect, each proven non-vacuous by mutating a scratch copy and watching it go red — done independently twice, by the author and again by a reviewer who re-ran every mutation itself rather than trusting the report. Two gaps worth naming: **no fixture anywhere placed a `.git` inside a plugin**, the exact case ADR-0020:104-107 says the two-pass walk-up exists for; and only the *no*-`.git` consumer case had ever been tested, which is precisely why B1 was invisible. Both now have fixtures. Two existing assertions were repairs. The `skill-improve` probe had been fixed by this branch, so its iteration permanently took an assertion-free `SKIP` that still counted as a pass. And the `yaml-none` fixture emitted `---/---`, which never matched the frontmatter pattern at all — it passed on the bare word "frontmatter", present in both messages, while never reaching the branch it was named for. ### Verification - `bash tests/run-tests.sh --strict` → 24 passed, 0 skipped, 0 failed - `bash tests/run-bats.sh` → 208 tests, 0 failures (was 202) - All 16 pre-push hooks pass, including on the push that landed this - Corpus **unchanged**: 26 description FAIL / 9 body FAIL / 2 dangling / 0 missing references / 58 SUGGESTIONs; agent body FAILs still 933 / 1199 / 1080 - Shared resolver span byte-identical across all three scripts - Vale: exactly the 10 pre-existing `CompositionNote` errors, zero new findings on touched files `AGENTS.md` and ADR-0020 both said deployed trees are consulted "only when no authoring root exists". That stopped being true, so both now describe the shipped rule and record why the name-count delta is not equivalent — the prose here is load-bearing and the next agent reads it instead of the code. ### Still open — your call, not mine 1. **`/compact` false positive.** Any `/word` after a route verb is an unconditional hard FAIL, so a description legitimately naming a host built-in (`/clear`, `/compact`, `/init`) cannot be committed. Fixing it needs either a known-builtins allowlist or a suppression mechanism, and the gate has neither by design. 2. **Sentence-boundary corroboration leak** (`:532`). Changes how targets get promoted from SUGGESTION to blocking ERROR, so it can move the documented corpus counts. Also unchanged, per your call: no release tag. `check-release-needed.sh:20` returns 0 unless `PRE_COMMIT_REMOTE_BRANCH == refs/heads/main`, which a merge-button merge never sets — so the gate will not remind you, and consumers pinning `rev: v1.0.0` get none of ADR-0020 until it is tagged manually. The design note from the review still stands and is not addressed here: the ADR rejects a shrinking baseline in a single clause, and it is the least-argued decision in the document.
Defame1297 added 2 commits 2026-08-16 20:55:41 +00:00
A prose-form routing target blocks a commit only when its own sentence names
another target that resolves. That makes the sentence splitter part of the
ADR-0020 contract rather than an implementation detail, and the naive
"period, space, capital" rule got it wrong in both directions:

- OVER-SPLIT: `e.g. "..."` is not a sentence end, but the quote looks like a
  start. The clause was cut in half and the corroborator stranded on the far
  side, so a genuinely dangling target silently demoted to SUGGESTION — a
  measurement taken and then discarded, the vacuous-green shape this gate
  exists to prevent. Seven such splits are live in the current corpus.
- UNDER-SPLIT: a sentence opening with a code span or a lowercase skill name
  was not seen as a start, so two sentences merged and a resolving target
  vouched for an unresolvable one it never stood beside — a hard FAIL with no
  escape hatch, which is the exact failure corroboration was added to prevent.

The splitter now excludes the five abbreviations that occur in routing prose
and admits a backtick or lowercase letter as a sentence opener. Applied
byte-identically to all three copies of the shared resolver.

Verified zero-delta against the corpus: 37 ERROR / 58 SUGGESTION / 2 dangling
before and after, findings byte-identical. The exposure this closes is to the
descriptions #99 is about to rewrite, not to the ones already measured — which
is why the deferral reason recorded on PR #103 ("can move the documented corpus
counts") does not hold and the fix lands here rather than after the retrofit.

Three regression tests, one per direction plus the backtick opener, each proven
non-vacuous by reverting the splitter alone and watching it go red.

Refs: #99
ADR: 0020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the second open design decision on PR #103. The `/compact` finding was
recorded as a false positive needing an allowlist or a suppression mechanism.
It is neither: the routing universe is the apm marketplace, so a target either
resolves to a skill or an agent or it does not resolve, and `/compact`,
`/clear` and `/init` are Claude Code slash commands with no counterpart in
Copilot CLI or Codex. `.apm/` source compiles for all three, so a
vendor-neutral description routing to one is a portability defect and the hard
FAIL is a true positive.

An allowlist was rejected for a concrete reason, not a stylistic one: it
answers a different question ("does this exist on some host?"), it cannot
answer that portably from a single source file, and it goes stale the next
time a host ships a command — reintroducing the same-commit-two-verdicts
failure ADR-0020 already closed for deployed trees.

Nothing is blocked today: zero of the 43 descriptions name a host built-in,
and an author who needs to mention one writes it un-slashed, which is not
route notation and carries no routing claim.

Recorded in ADR-0020 and in both author-facing contract references, so the
next agent reads the decision rather than "fixing" the gate.

ADR: 0020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Collaborator

Both open design decisions closed — pushed as 54d7bd8

The two items left open at the bottom of the previous comment are resolved. Two commits on top of 79c9089.

75a13c8 fix(kyberforge) — scope corroboration to a real sentence boundary
54d7bd8 docs — rule host built-ins out of the routing target universe

1. The /compact false positive is not a false positive

Recorded as needing an allowlist or a suppression mechanism. It needs neither. The routing universe is the apm marketplace: a target resolves to a skill or an agent, or it does not resolve. /compact, /clear and /init are Claude Code slash commands with no counterpart in Copilot CLI or Codex, and .apm/ source compiles for all three — so a vendor-neutral description routing to one is a portability defect and the hard FAIL is correct.

The allowlist was rejected on a concrete failure mode rather than taste: it answers a different question ("does this exist on some host?"), it cannot answer that portably from a single source file, and it goes stale the next time a host ships a command — reintroducing exactly the same-commit-two-verdicts failure ADR-0020:118-127 already closed for deployed trees.

Nothing is blocked today. Zero of the 43 descriptions name a host built-in; the four /-targets in the corpus are /caveman, /gitea, /gitea-workflow and /skill-author. An author who genuinely needs to mention one writes it un-slashed — the `compact` built-in — which is not route notation and carries no routing claim.

Recorded in ADR-0020 and in both author-facing references/contract.md files, since the ADR is not what an author reads mid-write.

2. The deferral reason for the corroboration leak was false

It was deferred because it "can move the documented corpus counts". Measured before touching anything, in both directions:

variant ERROR SUGGESTION dangling
shipped 37 58 2
+ abbreviation guard 37 58 2
+ backtick/lowercase openers 37 58 2
both (shipped here) 37 58 2

Findings byte-identical, not merely equinumerous. Worth flagging that the first version of that measurement was itself wrong — the lookbehind was off by the final period ((?<!\be\.g) never fires at the position after e.g., which is .g.), so it measured a no-op and would have "confirmed" zero delta for a patch that did nothing. Re-measured with (?<!\be\.g\.).

What the leak actually was

Blocking is scoped to a sentence — a prose-form target earns a hard ERROR only when its own sentence names another target that resolves. That makes SENTENCE_SPLIT part of the contract, not a detail of it, and "period, space, capital" was wrong in both directions:

  • Over-split. e.g. "…" ends no sentence, but the quote looks like a start. The clause was cut in half, the corroborator stranded on the far side, and a genuinely dangling target silently demoted to SUGGESTION — a measurement taken and then discarded, which is the same vacuous-green family as B2/B3/B5. Seven such splits are live in the current corpus.
  • Under-split. A sentence opening with a code span or a lowercase skill name was not seen as a start, so two sentences merged and a resolving target vouched for an unresolvable one it never stood beside — a hard FAIL with no escape hatch, i.e. the precise failure corroboration was added to prevent.

The splitter now excludes the five abbreviations that occur in routing prose and admits a backtick or lowercase letter as an opener. Applied byte-identically to all three copies of the shared resolver; check-plugin-content-sync and the contract test both green.

Landing it here rather than after #99 is deliberate. The exposure is entirely to descriptions that don't exist yet — and #99 is about to write 26 of them.

Tests

Three regression cases in tests/test-adr0020-targets.sh, one per direction plus the backtick opener. Each proven non-vacuous by reverting only the splitter and confirming it goes red with the right symptom: the abbreviation case downgrades ERROR → SUGGESTION, and both opener cases upgrade SUGGESTION → ERROR. The reverted run is 36 passed / 3 failed; the fixed run is 39 / 0.

Verification

  • bash tests/run-tests.sh --strict → 24 passed, 0 skipped, 0 failed
  • All 16 pre-push hooks pass, including on the push that landed this
  • Corpus unchanged: 37 ERROR (26 description / 9 body / 2 dangling) / 58 SUGGESTION / 0 missing references
  • Shared resolver span byte-identical across all three scripts; mirror diff clean
  • Vale: no new findings — no SKILL.md or .agent.md was touched

Still outstanding — unchanged from before

No release tag. check-release-needed.sh:20 returns 0 unless PRE_COMMIT_REMOTE_BRANCH == refs/heads/main, which a merge-button merge never sets, so the gate will not remind you. v2.0.0 must be cut manually against the merge commit or consumers pinning rev: v1.0.0 get none of ADR-0020.

The design note from the original review also still stands and is not addressed here: the ADR rejects a shrinking baseline in a single clause, and that remains the least-argued decision in the document.

## Both open design decisions closed — pushed as `54d7bd8` The two items left open at the bottom of the previous comment are resolved. Two commits on top of `79c9089`. | | | |---|---| | `75a13c8` | `fix(kyberforge)` — scope corroboration to a real sentence boundary | | `54d7bd8` | `docs` — rule host built-ins out of the routing target universe | ### 1. The `/compact` false positive is not a false positive Recorded as needing an allowlist or a suppression mechanism. It needs neither. The routing universe is the apm marketplace: a target resolves to a skill or an agent, or it does not resolve. `/compact`, `/clear` and `/init` are Claude Code slash commands with no counterpart in Copilot CLI or Codex, and `.apm/` source compiles for all three — so a vendor-neutral description routing to one is a portability defect and the hard FAIL is correct. The allowlist was rejected on a concrete failure mode rather than taste: it answers a different question ("does this exist on *some* host?"), it cannot answer that portably from a single source file, and it goes stale the next time a host ships a command — reintroducing exactly the same-commit-two-verdicts failure ADR-0020:118-127 already closed for deployed trees. Nothing is blocked today. Zero of the 43 descriptions name a host built-in; the four `/`-targets in the corpus are `/caveman`, `/gitea`, `/gitea-workflow` and `/skill-author`. An author who genuinely needs to mention one writes it un-slashed — ``the `compact` built-in`` — which is not route notation and carries no routing claim. Recorded in ADR-0020 and in **both** author-facing `references/contract.md` files, since the ADR is not what an author reads mid-write. ### 2. The deferral reason for the corroboration leak was false It was deferred because it "can move the documented corpus counts". Measured before touching anything, in both directions: | variant | ERROR | SUGGESTION | dangling | |---|---|---|---| | shipped | 37 | 58 | 2 | | + abbreviation guard | 37 | 58 | 2 | | + backtick/lowercase openers | 37 | 58 | 2 | | **both (shipped here)** | **37** | **58** | **2** | Findings byte-identical, not merely equinumerous. Worth flagging that the first version of that measurement was itself wrong — the lookbehind was off by the final period (`(?<!\be\.g)` never fires at the position after `e.g.`, which is `.g.`), so it measured a no-op and would have "confirmed" zero delta for a patch that did nothing. Re-measured with `(?<!\be\.g\.)`. ### What the leak actually was Blocking is scoped to a sentence — a prose-form target earns a hard ERROR only when its own sentence names another target that resolves. That makes `SENTENCE_SPLIT` part of the contract, not a detail of it, and "period, space, capital" was wrong in both directions: - **Over-split.** `e.g. "…"` ends no sentence, but the quote looks like a start. The clause was cut in half, the corroborator stranded on the far side, and a genuinely dangling target silently demoted to SUGGESTION — a measurement taken and then discarded, which is the same vacuous-green family as B2/B3/B5. Seven such splits are live in the current corpus. - **Under-split.** A sentence opening with a code span or a lowercase skill name was not seen as a start, so two sentences merged and a resolving target vouched for an unresolvable one it never stood beside — a hard FAIL with no escape hatch, i.e. the precise failure corroboration was added to prevent. The splitter now excludes the five abbreviations that occur in routing prose and admits a backtick or lowercase letter as an opener. Applied byte-identically to all three copies of the shared resolver; `check-plugin-content-sync` and the contract test both green. Landing it here rather than after #99 is deliberate. The exposure is entirely to descriptions that don't exist yet — and #99 is about to write 26 of them. ### Tests Three regression cases in `tests/test-adr0020-targets.sh`, one per direction plus the backtick opener. Each proven non-vacuous by reverting **only** the splitter and confirming it goes red with the right symptom: the abbreviation case downgrades ERROR → SUGGESTION, and both opener cases upgrade SUGGESTION → ERROR. The reverted run is 36 passed / 3 failed; the fixed run is 39 / 0. ### Verification - `bash tests/run-tests.sh --strict` → 24 passed, 0 skipped, 0 failed - All 16 pre-push hooks pass, including on the push that landed this - Corpus **unchanged**: 37 ERROR (26 description / 9 body / 2 dangling) / 58 SUGGESTION / 0 missing references - Shared resolver span byte-identical across all three scripts; mirror `diff` clean - Vale: no new findings — no `SKILL.md` or `.agent.md` was touched ### Still outstanding — unchanged from before No release tag. `check-release-needed.sh:20` returns 0 unless `PRE_COMMIT_REMOTE_BRANCH == refs/heads/main`, which a merge-button merge never sets, so the gate will not remind you. **v2.0.0** must be cut manually against the merge commit or consumers pinning `rev: v1.0.0` get none of ADR-0020. The design note from the original review also still stands and is not addressed here: the ADR rejects a shrinking baseline in a single clause, and that remains the least-argued decision in the document.
Defame1297 approved these changes 2026-08-16 21:19:58 +00:00
Defame1297 merged commit 9385c77ac7 into main 2026-08-16 21:20:02 +00:00
Defame1297 deleted branch refactor/trim-skills-agents-context 2026-08-16 21:20:03 +00:00
Sign in to join this conversation.