refactor(skills): retrofit the corpus to the ADR-0020 context contract (#129)

Retrofits all 39 skills to ADR-0020's description/body context contract, then fixes what six rounds of independent review found in that retrofit — including four ways the hot gate itself failed open.

Closes #99, #107, #108, #110, #111, #114, #115, #120.

## The retrofit (waves 1-5)

| | Start | Now |
|---|---|---|
| Description FAILs (>400 chars) | 26 | **0** |
| Body FAILs (>900 words, body-only) | 9 | **0** |
| Dangling routing targets | 2 | **0** |
| `Kyberforge.CompositionNote` | 10 | **0** |
| Preload tax | 21,005 chars | **~10,500** |

Under the 12,000-char success criterion. Per-wave detail is on #99.

## The review fixes

**The gate failed open four ways, three of them found after the retrofit shipped.** An unrecognised follower token made a dangling target vanish. A skill directory with no `SKILL.md` resolved as a valid target, so a commit could be green locally and red in a fresh clone — three existing fixtures were relying on that, one of which made the install-leak A/B pass vacuously. Then the free-standing `/name` sweep turned out to be gated on the sentence carrying a boundary marker, so route notation in any other sentence was invisible — not an ERROR, not a SUGGESTION, not an INFO — which left the documented "`/name` always blocks" promise false from a second direction. All four fixed and pinned.

**Two checks were silently not running.** `validate-provenance.sh` checks 7-8 were dead across nine skills. Waking them exposed a deeper problem: they assume `Research doc:` names a source index, but 30 of 121 entries point at topic content documents, so every new check-7 INFO was a false positive and check 8 was saved from a false-FAIL flood only by an *unannounced* skip. Checks 7/8 are now scoped to source indexes and every skip announces itself (#121).

**The retrofit's own anti-goal, four times.** ADR-0020 warns that a blunt gate gets satisfied by deleting content rather than relocating it. `diagnose` and `skill-audit` relocated prose and then read it unconditionally; `prototype` and `vale-config` deleted rules outright that survived nowhere. All four addressed.

## Verification

- `bash tests/run-tests.sh --strict` — 24 suites, 0 skipped, 0 failed
- `bash tests/run-bats.sh` — 325 tests, 0 failures
- `pre-commit run --all-files` — 17/17
- `pre-commit run --hook-stage pre-push --all-files` — 16/16, with `apm marketplace check` and `apm pack --check-clean` run against the remote, not skipped
- `scripts/skill-size-check.sh` over all 39 skills — rc 0, 0 ERROR/FAIL, SUGGESTION-only
- Preload tax measured at **10,498 chars**, max description 390 — both inside budget
- Every new test proven non-vacuous by a deliberate mutation of the behaviour it covers

**Per-commit sync, stated accurately:** the ten commits from the latest review round each pass `check-plugin-content-sync` in isolation, verified by checking each out in a detached worktree with a clean between. The earlier gitea window (`dfacf05..bedbd1d`, nine commits) does **not** — its mirror was regenerated in one batch at `bbc7300`. An earlier revision of this description claimed the property held for every commit; it does not, and a bisect through that window lands on a red commit. **Squash-merge** to collapse it, or accept that this range is not bisectable.

## Version bump

Six plugins and the catalog take a **patch**, not a minor. The branch is **89 commits — 40 `fix` / 30 `refactor` / 12 `docs` / 5 `chore` / 2 `test` — zero `feat`, zero `!`, zero `BREAKING CHANGE`** — and adds no skill, agent, command or hook. (Two earlier revisions of this section cited a stale histogram, most recently 78 commits; the figures above are measured at HEAD.) Both rules this repo ships (`forge/references/version-bump.md`, landing in this PR, and `git-commits/references/conventional-commits-spec.md`) make that a patch, and the catalog set is unchanged at 7 entries.

Not settled by that: four published files were removed from the installed tree, three moved, and `caveman` gained `disable-model-invocation`, retiring its old triggers. Under a strict reading those are major-class and currently ship under `refactor:` with no marker. Whether the deployed skill surface is a public contract is written down nowhere — worth deciding, but it outlives this PR.

## Deliberately not in scope

#112 (cherry-pick ownership, now resolved in favour of `git-commits`), #113 (`rtk git` normalisation), #116 (research fan-out), #101 (audit-skill merge), #122 (non-spec skill-root files), #123 (no PRD producer) stay open. #117 is the one worth reading: the contract's remedy is to move prose into `references/`, which is exactly where neither the size gate nor Vale looks — and the blind spot is wider than #117 currently records, since there is no root `.vale.ini` at all, so every ADR, `CONTEXT.md` and `README.md` is unlinted too.

That blind spot let this branch carry two `level: error` `Kyberforge.SentenceOpenerThereIs` violations into `references/` files it created — `provider-adapter-author/references/provider-matrix.md:31` and `agent-audit/references/finding-criteria.md:95`. Both are reworded in `afadaae`, confirmed by routing each file through the audit's own `vale-wrap.sh` (1 error each before, 0 after). Five further occurrences sit in `references/` files already on `main`; those are the pre-existing corpus and stay with #117, which is the real fix.

Also unfixed and not this PR's: `apm install` appends a duplicate `SessionStart` entry to `.claude/settings.json`, so a fresh clone cannot get pre-push green without an edit AGENTS.md warns against. Reproduces identically on `main`.

Co-authored-by: Defame1297 <gitea@rkdr.net>
Reviewed-on: https://git.dev.rkdr.net/Defame1297/holocron/pulls/129
Co-authored-by: Claude Code AI - Gitea MCP <claude@noreply.git.dev.rkdr.net>
Co-committed-by: Claude Code AI - Gitea MCP <claude@noreply.git.dev.rkdr.net>
This commit was merged in pull request #129.
This commit is contained in:
Claude Code AI - Gitea MCP
2026-09-01 13:47:46 +00:00
committed by Defame1297
parent 0e91a3ae66
commit 598a7c326a
420 changed files with 15303 additions and 4740 deletions

View File

@@ -6,7 +6,7 @@ Audit a skill directory against the agentskills.io specification and the house c
1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags non-imperative description openers, composition and architecture notes, vague wording, padding phrases, and "There is/are" sentence openers
2. Reads all files in the skill directory
3. Applies qualitative checks across five dimension groups, loading one rubric from `references/` per group
3. Applies qualitative checks across five dimension groups — always loading `references/finding-criteria.md`, then one rubric from `references/` per group the criteria put in play
4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — and a result block with handoff to `skill-author`
`validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words).
@@ -35,7 +35,8 @@ Provide the path to the skill directory to audit when invoking.
| `assets/vale/styles/Kyberforge/PaddingPhrase.yml` | Vale rule — flags generic "see references/" padding phrasing in conditional references |
| `assets/vale/styles/Kyberforge/SentenceOpenerThereIs.yml` | Vale rule — flags body sentences starting with "There is"/"There are" |
| `assets/vale/styles/Kyberforge/VagueWording.yml` | Vale rule — flags known filler wording (e.g. "helps with", "utilize") |
| `references/description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked (`disable-model-invocation`) contract, and the internal-mechanics FAIL |
| `references/finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file loaded on every run; it decides which rubrics below are worth loading |
| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked (`disable-model-invocation`) contract, the three-part shape, when an indirect trigger is warranted, near-miss exclusions, and a before/after pair |
| `references/body-discipline.md` | Rubric for the body-discipline dimension — the core test, the 600/900 body-only budget against the 2,770-word whole-file backstop, the mandatory-dispatch rule, and the Gotchas constraints |
| `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed |
| `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift |

View File

@@ -30,14 +30,14 @@ Resolve all three paths against this skill's own directory so they work from a r
```bash
bash scripts/validate.sh <skill-dir>
bash scripts/validate-provenance.sh <skill-dir>
scripts/vale-wrap.sh <skill-dir>/SKILL.md
bash scripts/vale-wrap.sh <skill-dir>/SKILL.md
```
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both.
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation.
If any of the three cannot run, or exits non-zero for a reason other than findings, read `references/validation-scripts.md` — it carries the manual fallback and the misleading exit codes. Ordinary content FAILs are the expected outcome here and need no fallback.
`validate-provenance.sh` prints nothing on success. Its FAIL and INFO findings become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim.
`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a usage or environment error, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason. Never grade an exit 2 as a clean pass: empty stdout there means nothing was checked, not that nothing was wrong.
`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment:
@@ -53,9 +53,9 @@ Read `SKILL.md`, `README.md`, and every text file under `scripts/`, `references/
## Step 3 — Qualitative audit
Load a dimension's rubric before judging that dimension. Each is self-contained, and each is grounded in the agentskills.io specification plus the house context-budget contract (ADR-0020).
Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call.
| Dimension | Read |
| Dimension | Rubric |
|---|---|
| description | `references/description-quality.md` |
| body-discipline | `references/body-discipline.md` |
@@ -63,7 +63,7 @@ Load a dimension's rubric before judging that dimension. Each is self-contained,
| file-structure, internal-consistency | `references/file-structure.md` |
| formatting, scripts | `references/formatting-and-scripts.md` |
Cite file and line number for every finding.
Each rubric is self-contained and grounded in the agentskills.io specification plus the house context budget (ADR-0020). Cite file and line number for every finding.
## Step 4 — Report

View File

@@ -31,7 +31,16 @@ Include content the agent lacks:
Move to `references/`, behind an explicit "If X, read `references/<file>.md`" trigger — the literal
conditional form, never a generic pointer. Write the real filename in the skill under audit; the
angle brackets are a placeholder here, and a literal `references/file.md` in a body is an ERROR
from the ADR-0020 gate because no such file exists on disk. Move:
from the ADR-0020 gate because no such file exists on disk.
**A dispatch table satisfies this requirement on its own.** A table row already pairs a condition
with a target, which is exactly what the literal form encodes; restating each row underneath as a
prose conditional duplicates the routing in the one body whose whole purpose is to be short. Where a
body dispatches, audit the table for condition/target completeness and stop there — do not require
the conditional form as well. The literal form is what a body needs when it loads a reference
*without* a dispatch table: a single mid-procedure deepening, an escape hatch, an error path.
Move:
- Lookup tables and spec restatements
- Output schemas, templates and example blocks
@@ -71,10 +80,28 @@ table** plus the gates common to every branch, and each flow lives in its own se
`references/` file. Inlining all of them is a FAIL regardless of word count, because every
invocation then pays for every branch it did not take.
The reference shape in this repo is `apm-workflow`: a **421-word body** dispatching to roughly
3,000 words of references across five mutually exclusive invocations. Its whole-file count is 554
words — cite 421 when calibrating a body, or the conflation this section warns against reappears
in the finding itself.
The reference shape in this repo is `apm-workflow`: a **294-word body** dispatching to 3,154 words
of references across five mutually exclusive flows. Its whole-file count is 348 words — cite 294
when calibrating a body, or the conflation this section warns against reappears in the finding
itself. The 3,154 counts the five flow files only; `references/sources.md` is a provenance record
and is never loaded at runtime, so counting it inflates the dispatched total.
### What earns the wiring exemption
A dispatch table earns the exemption above on its properties, not on which skill it appears in.
Audit any dispatching body against these four:
- Every flow the skill handles has a row, and every row names a target file that exists on disk.
- Each row pairs a condition the agent can evaluate from the request with exactly one target. A row
keyed on a literal slash invocation fails this: a model-invoked activation never produces that
string, so the routing silently falls to whatever else the row carries.
- One line after the table tells the agent to read the file its row matched, and only that one.
- The gates every branch needs sit in the body, not inside one flow's file — see the reachability
precondition below.
A table missing any of the four is not exempt, and the literal-conditional requirement applies to it
as written. The exemption covers the wiring form only: every other rule in this file applies to a
dispatching skill exactly as it applies to any other.
## Gotchas sections
@@ -96,19 +123,32 @@ Constraints:
`validate.sh` emits it through `suggest()` and the run still exits 0.
- **A Gotcha that paraphrases a step in the body below it is a FAIL.** It has no independent
content, and it teaches the agent that Gotchas can be skimmed because the real instruction is
coming. This one is the auditor's call — no script detects it.
coming. This one is the auditor's call — no script detects it. The Fix is conditional: delete the
Gotcha only if the surviving copy is reachable from every branch that needs it — see the
reachability precondition below.
- **A Gotchas section exceeding 25% of the body is a SUGGESTION** — the body has been inverted into
a preamble. Same tier and same reasoning as the entry count, and independent of it: either can
fire without the other.
- Place the section near the top. A gotcha read after the mistake is worthless, which is also why
Gotchas is the one construct exempt from moving to `references/`.
Worked negative example — `git-commits` carries twelve entries, of which four restate content
that already appears below or in the description:
Worked negative example — **`git-commits` v0.1.2 at commit `5e23250`, a fixed pre-retrofit
snapshot, not the current file.** The live skill is v0.1.3 and matches none of the citations below;
they are quoted as they stood before the ADR-0020 retrofit, and are not to be refreshed against
`HEAD`. The snapshot is reachable only from a checkout of the authoring repo — an installed plugin
cache holds no git history and no such path — so read the citations below as quoted rather than
going to look for the file. From a checkout:
```text
git show 5e23250:<the git plugin>/.apm/skills/git-commits/SKILL.md
```
That body carried twelve Gotchas, four of which restated content already below them or already in
the description:
| Gotcha | Restates |
|---|---|
| `:31` "Communicates SemVer impact" | the description |
| `:31` "SemVer mapping is not optional" | the description |
| `:32` "Confirmation gates are mandatory for destructive operations" | step 9 at `:52` |
| `:33` "Never skip hooks with `--no-verify`" | step 9 at `:52` |
| `:36` "Never commit secrets" | step 2 at `:45` |
@@ -118,6 +158,25 @@ All four are FAILs under the paraphrase rule. The entry count and the section's
fails the run on its own. What makes this worth auditing directly is that the four paraphrase FAILs
pass every word gate there is; only reading the construct finds them.
### The paraphrase rule has a reachability precondition
**A Gotcha that restates a step may be deleted only when the surviving copy is reachable from every
branch that needs it.** In a dispatch body it usually is not: each flow file is loaded alone, so a
step in one is invisible to an invocation that took another branch. When the restated rule is a
safety gate more than one flow needs, the Fix is to **move it into the body's common-gates section**,
never to drop it in favour of the per-flow copy.
Row four is the case that proves it. Following the rule literally, the retrofit deleted the
always-loaded secrets Gotcha and kept step 2 of `references/create-commit.md` — but `git-commits`
dispatches to exactly one flow file, and `references/rewrite-history.md` stages changes and runs
`--amend`, which commits newly staged content exactly as a fresh commit does. A grep for `secret`
across the skill in that state returned one hit, on a path two of three branches never reach: that
branch could commit a credential with no check anywhere in its loaded context, against this repo's
governance hard prohibition. v0.1.3 carries the rule as gate 2 of "Gates on every flow" instead.
So check reachability before writing the Fix. Rows one to three are unaffected — the description is
loaded on every invocation, and confirmation is likewise a common gate rather than a per-flow step.
## Calibrating control
**Be prescriptive** when operations are fragile, consistency matters, or a specific sequence must be
@@ -146,23 +205,5 @@ Use pypdf, pdfplumber, PyMuPDF, or pdf2image...
Use pdfplumber for text extraction. For scanned PDFs requiring OCR, use pdf2image instead.
```
## Auditing guidance
Flag as FAIL if:
- A sentence answers "no" to the core test — it is padding
- The body exceeds 900 words counted body-only (`validate.sh` reports it)
- Two or more mutually exclusive flows are inlined instead of dispatched
- A Gotcha paraphrases a step in the body below it
- A decision point presents a menu of options with no default
- An instruction repeats content already in the description
- A prescriptive sequence is used where flexibility is fine, or the reverse
Flag as SUGGESTION if:
- The body exceeds 600 words counted body-only but stays at or under 900
- The Gotchas section carries more than five entries
- The Gotchas section exceeds 25% of the body
- A rationale is missing from an include/exclude rule — present but unexplained
- Gotchas are correct but placed late in the body rather than near the top
- Content that only one branch reaches is inlined where a `references/` file would serve
The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`,
which Step 3 loads on every run.

View File

@@ -40,8 +40,11 @@ A model-invoked description carries exactly three things:
the agent is deciding whether to act, not reading a catalogue entry.
2. **At most one capability clause.** What it does, in one clause. Never an enumeration.
3. **Boundary clause.** Compressed form: `Not <thing> -> <skill-name>.` The target must resolve to
a real skill directory or agent file in the authoring source; `validate.sh` checks that
deterministically and a dangling target already surfaces as a Structure FAIL.
a real skill directory or agent file in the authoring source. `validate.sh` checks that
deterministically and grades it by notation: an unresolved `/name` or arrow target is an ERROR
and reaches the report as a Structure FAIL, while an unresolved prose-form target ("use `y`
instead") is only a SUGGESTION unless a second target in the same sentence resolves. Take the
script's tier as given and report it once, under Structure.
Everything else belongs in the body or in `README.md`.
@@ -80,38 +83,5 @@ description: >
(`data-model` is illustrative. In a real description the target has to resolve.)
## Auditing guidance
Flag as FAIL if:
- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines.
`validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut.
- **Internal mechanics appear in the description.** Any of:
- capability enumeration or a feature list;
- output-format detail ("Produces a compact findings report with Why and Fix per finding");
- composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting
shared skill", "the human-facing entry point", "replaces the old flat invocation");
- implementation detail ("self-validates via a bundled deterministic script").
None of it can change a routing decision and all of it is preloaded.
`Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is
judgment. This is the rule that deflates a description, so apply it before reaching for length.
- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted
as user phrasings, usually in the same order. One register, whichever routes better.
- **Descriptive rather than imperative phrasing** (`This skill ...`, `This is the ...`).
`Kyberforge.DescriptionOpener` catches any opener matching `^This`.
- **Vague capabilities** ("helps with APIs" where "parses and validates OpenAPI specs" 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
file in the authoring source. `validate.sh` reports the unresolved name.
- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0.
- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent
of the 400-character house ceiling above.
Flag as SUGGESTION if:
- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL
tier only stops outliers. Report it rather than treating a 399-character description as clean.
- A near-miss exclusion is present but targets a weak near-miss.
- An indirect trigger is present and warranted but could name the omitted phrasing more precisely.
The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`,
which Step 3 loads on every run.

View File

@@ -24,9 +24,19 @@ knows to look at. Flag any other directory as a FAIL.
## Cross-plugin path references
A plugin is copied to a cache on install, and a path that climbs out of the skill directory stops
resolving there. Flag any `../`, `../../`, or absolute repo path (`plugins/<plugin>/skills/<other>/`
and its APM-native equivalent `.apm/skills/<other>/`) appearing in `SKILL.md`, `scripts/`,
`references/` or `assets/`.
resolving there. Flag a path in `SKILL.md`, `scripts/`, `references/` or `assets/` when it
**resolves outside the skill directory** — an absolute repo path
(`plugins/<plugin>/skills/<other>/` and its APM-native equivalent `.apm/skills/<other>/`), a
plugin-root path (`docs/`, `bin/`), or a `../` chain that leaves the skill root.
Resolve before flagging, twice over:
- **Resolve the path.** `$SKILL_DIR/../assets/templates` climbs one level from a `scripts/`
directory and lands back inside the same skill, so it resolves in a cache install and is not a
finding. A bare `../` is not the defect; leaving the skill is.
- **Skip fenced code blocks.** A path inside a fenced block is an example, and rubrics quote outside
paths deliberately as negative examples of what not to write. Flag a fenced path only when the
surrounding prose presents it as the form to copy.
**Referring to another skill's file.** There is one sanctioned spelling, and it is possessive:
`skill-audit's references/validation-scripts.md`. Write the skill by name and let the reader
@@ -39,9 +49,10 @@ on-disk check. Flag any other spelling of a cross-skill reference.
Two directories are exempt, and the exemptions are structural rather than discretionary:
- **`references/sources.md`.** Its `Research doc:` fields are development-time provenance pointers,
not runtime references. They are expected to be unresolvable after install, and
`validate-provenance.sh` handles that by skipping upstream checks silently when the path is
absent. Flagging them would make every correctly-provenanced skill fail.
not runtime references. They are expected to be unresolvable after install, so
`validate-provenance.sh` does not treat an absent path as a FAIL — it emits an INFO naming the
slug and stating that checks 7 and 8 did not run for it. Flagging them as broken references
would make every correctly-provenanced skill fail.
- **`tests/`.** Test files are dev-only and may reference repo-level infrastructure such as a shared
`tests/test_helper/`. The exemption is conditional on the dependency being declared: if `tests/`
exists and `tests/README.md` is absent or does not document it, that is a FAIL.
@@ -60,19 +71,5 @@ The skill has to agree with itself. Three checks:
A stale README row is the most common finding here and the easiest to miss from inside an
authoring pass, because the author knows what was intended and reads it into the gap.
## Auditing guidance
Flag as FAIL if:
- A directory outside the four permitted ones exists
- Test files sit in `scripts/`
- A non-spec file sits at the skill root
- A cross-plugin or parent-relative path appears outside the two exempt locations
- `tests/` exists but `tests/README.md` is missing or does not document its repo-level dependency
- `README.md` is absent, or its file table has a missing or stale row
- `SKILL.md` describes a script invocation the script does not accept
Flag as SUGGESTION if:
- An optional directory exists but holds only a placeholder README
- `README.md` is accurate but describes a file's purpose more thinly than `SKILL.md` does
The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`,
which Step 3 loads on every run.

View File

@@ -0,0 +1,138 @@
---
source_keys:
- agentskills-spec
- agentskills-best-practices
- agentskills-optimizing-descriptions
- agentskills-using-scripts
---
# Finding Criteria
Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The
reasoning each criterion stands on, its worked examples and its house rules stay in that
dimension's rubric, which Step 3 loads only for a dimension this file puts in play.
Two rules on using it:
- A criterion that plainly applies is a finding. Write it up citing file and line.
- A criterion that might apply, or whose call the wording here does not settle, is a reason to load
that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics
to read; it does not settle a close call on its own.
## description — `references/description-quality.md`
Flag as FAIL if:
- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines.
`validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut.
- **Internal mechanics appear in the description.** Any of:
- capability enumeration or a feature list;
- output-format detail ("Produces a compact findings report with Why and Fix per finding");
- composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting
shared skill", "the human-facing entry point", "replaces the old flat invocation");
- implementation detail ("self-validates via a bundled deterministic script").
None of it can change a routing decision and all of it is preloaded.
`Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is
judgment. This is the rule that deflates a description, so apply it before reaching for length.
- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted
as user phrasings, usually in the same order. One register, whichever routes better.
- **Descriptive rather than imperative phrasing** (`This skill ...`, `This is the ...`).
`Kyberforge.DescriptionOpener` catches any opener matching `^This`.
- **Vague capabilities** ("helps with APIs" where "parses and validates OpenAPI specs" was
available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is
judgment.
- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0 of
`references/description-quality.md`.
- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent
of the 400-character house ceiling above.
Flag as SUGGESTION if:
- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL
tier only stops outliers. Report it rather than treating a 399-character description as clean.
- A near-miss exclusion is present but targets a weak near-miss.
- An indirect trigger is present and warranted but could name the omitted phrasing more precisely.
**An unresolved boundary target is not graded here.** `validate.sh` owns that call and tiers it by
notation — `/name` or an arrow form is an ERROR, the bare prose form a SUGGESTION unless a second
target in the same sentence resolves — and Step 1 has already filed it under `### Structure` at that
tier. Re-grading it as a description FAIL puts one target in the report twice at two tiers. What is
left to judgment here is semantic and the script cannot reach it: whether a target that *does*
resolve is the right sibling to exclude, and whether a clause naming no target at all ("examine the
files manually") should have named one.
## body-discipline — `references/body-discipline.md`
Flag as FAIL if:
- A sentence answers "no" to the core test — it is padding
- The body exceeds 900 words counted body-only (`validate.sh` reports it)
- Two or more mutually exclusive flows are inlined instead of dispatched
- A Gotcha paraphrases a step in the body below it that every branch reaching the Gotcha also
reaches
- A decision point presents a menu of options with no default
- An instruction repeats content already in the description
- A prescriptive sequence is used where flexibility is fine, or the reverse
Flag as SUGGESTION if:
- The body exceeds 600 words counted body-only but stays at or under 900
- The Gotchas section carries more than five entries
- The Gotchas section exceeds 25% of the body
- A rationale is missing from an include/exclude rule — present but unexplained
- Gotchas are correct but placed late in the body rather than near the top
- Content that only one branch reaches is inlined where a `references/` file would serve
## patterns — `references/patterns.md`
Flag as FAIL if:
- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable
assumption
- An inner code fence is unescaped inside a markdown block, breaking the render
- A checklist wraps a single step
- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form
- The agent must produce a specific format and no output template is given
Flag as SUGGESTION if:
- Gotchas are correctly formed but placed late in the body
- An output template is present but permissive where the consumer needs it exact
- A conditional reference names a trigger that is real but broader than the branch it guards
## file-structure and internal-consistency — `references/file-structure.md`
Flag as FAIL if:
- A directory outside the four permitted ones exists
- Test files sit in `scripts/`
- A non-spec file sits at the skill root
- A path that resolves outside the skill directory appears outside the two exempt locations, in
prose rather than in a fenced example
- `tests/` exists but `tests/README.md` is missing or does not document its repo-level dependency
- `README.md` is absent, or its file table has a missing or stale row
- `SKILL.md` describes a script invocation the script does not accept
Flag as SUGGESTION if:
- An optional directory exists but holds only a placeholder README
- `README.md` is accurate but describes a file's purpose more thinly than `SKILL.md` does
## formatting and scripts — `references/formatting-and-scripts.md`
Flag as FAIL if:
- A script prompts interactively, in any form
- A script exposes no `--help`
- A destructive script has no `--dry-run`
- Data and diagnostics share a stream, so the output cannot be piped
- A relative path named in the body does not resolve
- Heading levels are inconsistent enough to break the document's structure
Flag as SUGGESTION if:
- Exit codes are meaningful but undocumented in `--help`
- A code block is untagged where a language applies
- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear
- List indentation or section spacing is inconsistent without breaking the render

View File

@@ -44,20 +44,5 @@ follow from that:
undocumented one is a coin flip.
- **`--dry-run` present for destructive operations.**
## Auditing guidance
Flag as FAIL if:
- A script prompts interactively, in any form
- A script exposes no `--help`
- A destructive script has no `--dry-run`
- Data and diagnostics share a stream, so the output cannot be piped
- A relative path named in the body does not resolve
- Heading levels are inconsistent enough to break the document's structure
Flag as SUGGESTION if:
- Exit codes are meaningful but undocumented in `--help`
- A code block is untagged where a language applies
- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear
- List indentation or section spacing is inconsistent without breaking the render
The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`,
which Step 3 loads on every run.

View File

@@ -50,19 +50,5 @@ forms are judgment.
`references/` when only one dispatch branch produces that output. A template inlined for a branch
most invocations never take is body-discipline padding.
## Auditing guidance
Flag as FAIL if:
- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable
assumption
- An inner code fence is unescaped inside a markdown block, breaking the render
- A checklist wraps a single step
- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form
- The agent must produce a specific format and no output template is given
Flag as SUGGESTION if:
- Gotchas are correctly formed but placed late in the body
- An output template is present but permissive where the consumer needs it exact
- A conditional reference names a trigger that is real but broader than the branch it guards
The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`,
which Step 3 loads on every run.

View File

@@ -15,7 +15,7 @@
- **URL:** https://agentskills.io/specification.md
- **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md
- **Description:** Complete SKILL.md format specification — frontmatter fields, constraints, body content, optional directories, progressive disclosure levels, file references, validation
- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/validation-scripts.md
- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md
- **Status:** `extracted`
## agentskills-best-practices
@@ -23,7 +23,7 @@
- **URL:** https://agentskills.io/skill-creation/best-practices.md
- **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md
- **Description:** Best practices for skill creators — starting from real expertise, spending context wisely, calibrating control, instruction patterns (gotchas, templates, checklists, validation loops)
- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md
- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md, references/finding-criteria.md
- **Status:** `extracted`
## agentskills-optimizing-descriptions
@@ -31,7 +31,7 @@
- **URL:** https://agentskills.io/skill-creation/optimizing-descriptions.md
- **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md
- **Description:** How to systematically test and improve skill descriptions for triggering accuracy — eval queries, trigger rate testing, train/validation splits, optimization loop
- **Contributing files:** SKILL.md, references/description-quality.md
- **Contributing files:** SKILL.md, references/description-quality.md, references/finding-criteria.md
- **Status:** `extracted`
## agentskills-evaluating-skills
@@ -47,7 +47,7 @@
- **URL:** https://agentskills.io/skill-creation/using-scripts.md
- **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md
- **Description:** Using scripts in skills — one-off commands, self-contained scripts with inline dependencies, designing scripts for agentic use (no interactive prompts, --help, structured output, idempotency)
- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/validation-scripts.md
- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md
- **Status:** `extracted`
## agentskills-quickstart

View File

@@ -99,9 +99,12 @@ Three ways to read the result wrong:
## Script-specific failures
- **`validate-provenance.sh` printed nothing.** That is a pass, not a skip. It also exits 0
silently when the skill has no `source_keys` and no `references/sources.md` — nothing to
validate is not a finding.
- **`validate-provenance.sh` printed nothing *and exited 0*.** That is a pass, not a skip — it
exits 0 silently when the skill has no `source_keys` and no `references/sources.md`, and nothing
to validate is not a finding. Check the exit code before you believe the silence: a target that
is not a directory, a directory holding no `SKILL.md`, a missing or extra argument, and an absent
`python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it
as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings.
- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full
Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is
scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted.

View File

@@ -13,17 +13,37 @@ Arguments:
Exit codes:
0 All checks passed (or nothing to validate)
1 One or more checks failed
2 Usage error, or the argument is not a skill directory
An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a
non-zero exit as findings, so a usage error leaving exit 1 with nothing on
stdout was indistinguishable from a clean-but-failing run. Environment and
argument problems exit 2; only real findings exit 1.
Checks performed:
0 source_keys present but references/sources.md absent
1 FILL IN: placeholders in sources.md
2 source_keys in SKILL.md → slug exists in sources.md
3 source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys)
4 Contributing files listed in sources.md exist on disk
3 source_keys in references/*.md → slug exists in sources.md (INFO if no
source_keys; an explicit 'source_keys: []' declares the file house-authored
and passes silently)
4 Contributing files listed in sources.md exist on disk. An explicit
'(none)' skips silently; a Contributing files block this parser cannot
read is reported as an INFO saying checks 4 and 5 did not run, never
skipped silently.
5 Contributing files back-reference the parent slug in their source_keys
6 Research doc field present and not placeholder
7 Slug in sources.md present in upstream research doc (INFO only)
7 Slug in sources.md present in upstream research doc (INFO only). A section
annotation ('§ ...', '→ ...', '(...)') is stripped before the path is
resolved; a path that still does not resolve is reported as an INFO saying
checks 7 and 8 did not run, never skipped silently.
8 Extracted non-(none) slug in research doc present in sources.md
Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE
INDEX — a file whose basename is sources.md, whose H2 headings ARE source
slugs. A Research doc pointing at a topic document is reported as an INFO
saying the two checks are not applicable, and every other reason they do not
run is announced the same way.
EOF
}
@@ -32,11 +52,57 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
exit 0
fi
# Usage and environment problems exit 2, findings exit 1. See the usage text
# above for why the two must not share a code. This is a deliberate divergence
# from validate.sh, which has no 2 tier: validate.sh always prints PASS lines,
# so a usage error there is visibly not a findings report. This script prints
# NOTHING on a clean run, so exit 1 plus empty stdout was the only signal a
# caller got either way.
if [[ $# -lt 1 ]]; then
echo "Error: skill-dir is required." >&2
echo "" >&2
usage >&2
exit 1
exit 2
fi
# Extra positional arguments were silently dropped, so a typo'd flag or a second
# path looked like it had been honoured.
if [[ $# -gt 1 ]]; then
echo "Error: expected exactly one argument, got $#: $*" >&2
echo "" >&2
usage >&2
exit 2
fi
# python3 is a HARD dependency. Without this preflight a missing interpreter
# produced 'line NN: python3: command not found' and exit 127 — an exit code no
# caller maps to anything, from a message that names this script's line number
# rather than the missing dependency.
if ! command -v python3 > /dev/null 2>&1; then
echo "Error: python3 is required but was not found on PATH." >&2
echo " Why: skipping the provenance checks entirely would be a vacuous pass." >&2
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
exit 2
fi
# A path that is not a directory, or a directory that is not a skill, used to
# reach the Python body, find no sources.md and no source_keys, take the
# "nothing to validate" early exit and report exit 0 with no output — which
# references/validation-scripts.md explicitly told the auditor to read as a
# pass. A typo'd target was therefore indistinguishable from a clean skill.
# vale-wrap.sh hard-errors on a nonexistent path for exactly this reason.
if [[ ! -d "$1" ]]; then
echo "Error: not a directory: $1" >&2
echo " Why: a nonexistent target would otherwise report a silent pass." >&2
echo " Fix: pass the path of the skill directory to validate." >&2
exit 2
fi
if [[ ! -f "$1/SKILL.md" ]]; then
echo "Error: not a skill directory (no SKILL.md): $1" >&2
echo " Why: a directory with no SKILL.md has no provenance chain to validate, and reporting that as a pass hides the wrong-target mistake." >&2
echo " Fix: pass the skill directory itself, not its parent or its references/ subdirectory." >&2
exit 2
fi
python3 -u - "$1" <<'PYTHON'
@@ -44,13 +110,67 @@ import sys
import os
import re
# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams
# default to ASCII, and every finding this script prints contains an em dash.
# Pinning only the reads moved the crash from the read to the write — a
# UnicodeEncodeError inside print_findings(), which loses the whole report
# after all the checks have already run.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding='utf-8')
except AttributeError: # pragma: no cover — Python < 3.7
pass
skill_dir = os.path.abspath(sys.argv[1])
sources_md_path = os.path.join(skill_dir, "references", "sources.md")
refs_dir = os.path.join(skill_dir, "references")
# --- Helpers ---
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
# The trailing character class used to be CONSUMING — `[^`\n]` — so a
# `FILL IN:` at end of line matched nothing and escaped checks 1 and 6
# entirely. `- **Description:** FILL IN:` is the most likely spelling of a
# half-written entry, and it was the one spelling the placeholder gate could
# not see. The exclusion it was really expressing is "not inside backticks",
# which a lookahead states without eating a character.
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:(?!`)')
# --- Input ----------------------------------------------------------------
# Ported from validate.sh, where the same two problems were already fixed.
#
# read_text() pins UTF-8 explicitly instead of inheriting
# locale.getpreferredencoding(), which is ASCII under LC_ALL=C — an ordinary em
# dash in a references file then aborted the run with a bare UnicodeDecodeError
# traceback, or, at the one call site that wrapped its read in `except
# Exception: return False`, reported the unreadable file as having no
# source_keys and therefore as clean. A file that genuinely is not UTF-8 still
# fails; it just says which file and why.
#
# strip_bom() runs on every read because a leading BOM defeats
# parse_frontmatter()'s `^---` anchor, which silently disabled check 2 on a
# BOM-prefixed SKILL.md: no frontmatter parsed means no source_keys parsed
# means nothing to validate.
class EncodingError(Exception):
pass
def strip_bom(text):
return text[1:] if text.startswith(u'\ufeff') else text
def read_text(path):
"""File contents as text, UTF-8 and BOM-free, with a diagnostic instead of a traceback."""
try:
with open(path, encoding='utf-8') as fh:
return strip_bom(fh.read())
except UnicodeDecodeError as exc:
raise EncodingError(
"not valid UTF-8 (%s at byte %d) — re-save the file as UTF-8; "
"this gate does not guess at other encodings"
% (exc.reason, exc.start))
def parse_frontmatter(content):
"""Return (frontmatter_str, body_str) or (None, content) if no frontmatter."""
@@ -89,13 +209,84 @@ def parse_source_keys(fm):
in_metadata = False
return keys
# An explicit `source_keys: []` — top-level or under metadata: — is a
# DECLARATION that the file is house-authored and has no external source.
# parse_source_keys() returns [] both for that and for a file with no
# source_keys key at all, so the two are indistinguishable downstream and
# check 3 emitted the same INFO for each (#111). That left no honest way to
# record "this file has no external source": the only ways to silence the INFO
# were to invent a slug or borrow an unrelated one, both false provenance
# claims that then have to be maintained in sources.md as well. A bare
# `source_keys:` with nothing after it is NOT accepted here — that reads as a
# truncated or half-written entry, not a decision.
#
# The indent is pinned to the two positions parse_source_keys() actually reads
# — column 0, or two spaces under `metadata:`. A permissive `^\s*` matched a
# `source_keys: []` nested at ANY depth under an unrelated key, which
# parse_source_keys() never reads, so a stray nested key silenced the check-3
# INFO for a file that had declared nothing.
EMPTY_SOURCE_KEYS_RE = re.compile(r'^(?: )?source_keys:\s*\[\s*\]\s*$')
def declares_empty_source_keys(fm):
"""True when frontmatter carries an explicit, empty `source_keys: []`."""
if fm is None:
return False
return any(EMPTY_SOURCE_KEYS_RE.match(line) for line in fm.splitlines())
def parse_h2_slugs(content):
"""Return list of H2 heading values from a markdown file."""
return re.findall(r'^## (.+)$', content, re.MULTILINE)
# ===== BEGIN SHARED CONTRIBUTING-FILES PARSER =====
# ONE parser, embedded VERBATIM in two scripts:
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh
# The block between these markers must stay byte-identical in both. It is
# copied rather than imported because a cache-installed plugin's scripts cannot
# read files outside their own plugin directory, so there is no single file both
# can share — the same constraint that forces the ADR-0020 boundary resolver to
# be duplicated across three scripts. Edit one copy, then paste it over the
# other.
#
# tests/test-adr0020-contract.sh hashes both copies and fails on drift. Before
# it did, the agent-audit copy's docstring merely ASSERTED the two were
# "behaviourally identical" and nothing checked it — which is how the two
# already-diverged spellings of the bullet loop went unnoticed.
#
# Requires: re (imported by the host script).
def parse_contributing_files(content, slug):
"""Find the Contributing files value for a given slug H2 in content."""
# Find the H2 block for slug, then look for Contributing files line
"""Find the Contributing files for a given slug H2 in content.
Both authored forms are accepted, because both are in use across the
corpus and only recognising the first silently skipped the contributing-
file checks on every sources.md written the other way:
- **Contributing files:** SKILL.md, references/a.md
**Contributing files:**
- SKILL.md (what this source contributed)
- references/a.md (what this source contributed)
Returns a list of paths with any trailing parenthetical note stripped.
Note the bullet form's notes may themselves contain commas, so the list
is built per bullet rather than by splitting the joined value.
The three return values are NOT interchangeable, and callers depend on
the distinction:
[path, ...] the entry names contributing files
[] the entry EXPLICITLY records "(none)"
None the entry says nothing this parser can read
Only an explicit "(none)" yields []. A "Contributing files:" heading
followed by a numbered list, by `*` bullets, or by prose parses nothing
and returns None, never [] — a caller reads [] as a deliberate "no
contributing files" record and SKIPS its check on that basis, so a parse
failure returning [] would silently disable the check instead of leaving
the unreadable entry exposed to it.
"""
pattern = re.compile(
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
re.MULTILINE | re.DOTALL
@@ -104,28 +295,113 @@ def parse_contributing_files(content, slug):
if not m:
return None
block = m.group(1)
def strip_note(entry):
# "references/a.md (why)" -> "references/a.md"
return re.sub(r'\s*\(.*$', '', entry).strip()
# Inline form: value on the same line, comma-separated, no notes.
cf_m = re.search(r'^\- \*\*Contributing files:\*\* (.+)$', block, re.MULTILINE)
if cf_m:
value = cf_m.group(1).strip()
if value.startswith("(none"):
return []
return [p for p in (strip_note(x) for x in value.split(","))
if p] or None
# Bullet form: heading on its own line, one file per following bullet.
cf_m = re.search(r'^\*\*Contributing files:\*\*\s*$', block, re.MULTILINE)
if not cf_m:
return None
return cf_m.group(1).strip()
files = []
for line in block[cf_m.end():].splitlines():
line = line.strip()
if not line:
if files:
break
continue
if not line.startswith("- "):
break
entry = line[2:].strip()
if entry.startswith("(none"):
return []
entry = strip_note(entry)
if entry:
files.append(entry)
return files or None
# ===== END SHARED CONTRIBUTING-FILES PARSER =====
def parse_research_doc(content, slug):
"""Find the Research doc value for a given slug H2 in content."""
def parse_research_docs(content, slug):
"""Every Research doc value under a given slug H2, in document order.
The caller uses the first and reports the rest. Returning only the first —
what this did before — meant a second '- **Research doc:**' line in one
entry was silently ignored, so an author who added a doc rather than
replacing one got checks 7 and 8 run against the old path and no hint that
the new one was never looked at.
"""
pattern = re.compile(
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
re.MULTILINE | re.DOTALL
)
m = pattern.search(content)
if not m:
return None
return []
block = m.group(1)
rd_m = re.search(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)
if not rd_m:
return None
return rd_m.group(1).strip()
return [v.strip() for v in
re.findall(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)]
# A Research doc value is a path, and very often a path PLUS an annotation
# naming the section the slug came from:
#
# plugins/git/docs/research/docs/git/gitflow.md (whole-document reference)
# plugins/git/docs/research/docs/git/remotes.md → `## Pushing (`git push`)`
# .../pre-commit/hooks-reference.md § "pre-commit-hooks (official collection)"
#
# os.path.isfile() is false for every one of those strings, and checks 7 and 8
# used to skip SILENTLY whenever the path did not resolve. The effect was that
# both checks were dead on eight of the nine git skills — git-history, the one
# skill writing a bare path, was the only place they ran, which is why it was
# the only skill ever reporting a check-7 INFO. Strip the annotation before
# resolving, and report when the result still does not resolve: a check that
# quietly does not run is worse than one that fails.
RESEARCH_DOC_ANNOTATION_RE = re.compile(r'[§→(]')
def strip_research_doc_annotation(value):
"""Path part of a Research doc value, with any section annotation removed."""
return RESEARCH_DOC_ANNOTATION_RE.split(value, maxsplit=1)[0].strip()
def research_doc_is_none(value):
"""True when a Research doc value declares that no research doc backs the slug.
Both '(none)' and the bare 'none — org convention, ...' spelling are in
use; recognising only the parenthesised one would report the other as an
unresolvable path. Checked BEFORE the annotation strip, because '(none)'
is itself a parenthesis and would strip to the empty string.
"""
return re.match(r'\(?none\b', value.strip(), re.IGNORECASE) is not None
# The Status value is what gates check 8, so every spelling this parser fails
# to read is a check that does not run. Two were unreadable:
#
# - **Status:** `extracted` — partial fetch (a trailing note)
# **Status:** (the bullet form, the same
# - `extracted` shape parse_contributing_files
# already accepts)
#
# Both used to parse to a string that compared unequal to "`extracted`", and
# check 8 skipped on that inequality without a word. Returning the BACKTICKED
# TOKEN — not the whole line — is what makes the trailing note harmless, and it
# lets the caller name the actual status when it announces a skip.
STATUS_TOKEN_RE = re.compile(r'^`([^`]*)`')
def parse_status(content, slug):
"""Find the Status value for a given slug H2 in content."""
"""Find the Status value for a given slug H2 in content.
Returns the status with its backticks stripped ('extracted', 'referenced',
'no content extracted'), or None when the entry has no Status line.
"""
pattern = re.compile(
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
re.MULTILINE | re.DOTALL
@@ -134,10 +410,28 @@ def parse_status(content, slug):
if not m:
return None
block = m.group(1)
raw = None
st_m = re.search(r'^\- \*\*Status:\*\* (.+)$', block, re.MULTILINE)
if not st_m:
return None
return st_m.group(1).strip()
if st_m:
raw = st_m.group(1).strip()
else:
st_m = re.search(r'^\*\*Status:\*\*\s*$', block, re.MULTILINE)
if not st_m:
return None
for line in block[st_m.end():].splitlines():
line = line.strip()
if not line:
continue
if not line.startswith("- "):
break
raw = line[2:].strip()
break
if raw is None:
return None
token = STATUS_TOKEN_RE.match(raw)
return token.group(1).strip() if token else raw
def find_repo_root(start_dir):
"""Walk up from start_dir until we find a directory containing .git."""
@@ -153,13 +447,22 @@ def find_repo_root(start_dir):
findings = []
has_fail = False
# A finding identical in every field is the same finding, and the same file is
# now reached by more than one check — the walk that looks for source_keys and
# check 3 both read every references/*.md, so an unreadable one would otherwise
# be reported twice with the same words. Distinct findings about the same file
# still both appear.
def _record(entry):
if entry not in findings:
findings.append(entry)
def emit_fail(desc, fpath, why, fix):
global has_fail
has_fail = True
findings.append(("FAIL", desc, fpath, why, fix, None))
_record(("FAIL", desc, fpath, why, fix, None))
def emit_info(desc, fpath, note):
findings.append(("INFO", desc, fpath, None, None, note))
_record(("INFO", desc, fpath, None, None, note))
def print_findings():
for entry in findings:
@@ -181,11 +484,22 @@ def print_findings():
# --- Scan for any file with source_keys ---
def file_has_source_keys(fpath):
def emit_unreadable(rel, exc):
"""Report a file this script cannot decode. Never a silent skip."""
emit_fail(
f"File is {exc}",
rel,
f"'{rel}' cannot be decoded, so its frontmatter — and any source_keys in it — "
f"cannot be read. This used to be swallowed by a bare 'except Exception: return False', "
f"which reported the unreadable file as having no source_keys and therefore as clean.",
f"Re-save '{rel}' as UTF-8."
)
def file_has_source_keys(fpath, rel):
try:
with open(fpath) as f:
content = f.read()
except Exception:
content = read_text(fpath)
except EncodingError as exc:
emit_unreadable(rel, exc)
return False
fm, _ = parse_frontmatter(content)
if fm is None:
@@ -198,26 +512,33 @@ def find_files_with_source_keys():
for root, dirs, files in os.walk(skill_dir):
# Skip hidden dirs
dirs[:] = [d for d in dirs if not d.startswith('.')]
for fname in files:
for fname in sorted(files):
if fname.endswith('.md'):
abs_path = os.path.join(root, fname)
if file_has_source_keys(abs_path):
rel = os.path.relpath(abs_path, skill_dir)
rel = os.path.relpath(abs_path, skill_dir)
if file_has_source_keys(abs_path, rel):
results.append((rel, abs_path))
return results
sources_md_exists = os.path.isfile(sources_md_path)
files_with_source_keys = find_files_with_source_keys()
# Early exit: nothing to validate
# Early exit: nothing to validate. The scan itself can raise a finding — an
# unreadable file — so print before leaving; the clean case still prints
# nothing and exits 0.
if not sources_md_exists and not files_with_source_keys:
sys.exit(0)
print_findings()
sys.exit(1 if has_fail else 0)
# Load sources.md if it exists
sources_content = None
if sources_md_exists:
with open(sources_md_path) as f:
sources_content = f.read()
try:
sources_content = read_text(sources_md_path)
except EncodingError as exc:
emit_unreadable("references/sources.md", exc)
print_findings()
sys.exit(1)
sources_slugs = set(parse_h2_slugs(sources_content))
else:
sources_slugs = set()
@@ -248,8 +569,11 @@ for line in sources_content.splitlines():
# --- Check 2: source_keys in SKILL.md → slug exists in sources.md ---
skill_md_path = os.path.join(skill_dir, "SKILL.md")
if os.path.isfile(skill_md_path):
with open(skill_md_path) as f:
skill_content = f.read()
try:
skill_content = read_text(skill_md_path)
except EncodingError as exc:
emit_unreadable("SKILL.md", exc)
skill_content = ""
skill_fm, _ = parse_frontmatter(skill_content)
skill_source_keys = parse_source_keys(skill_fm)
for slug in skill_source_keys:
@@ -262,24 +586,43 @@ if os.path.isfile(skill_md_path):
)
# --- Check 3: source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys) ---
# os.walk, not os.listdir: find_files_with_source_keys() above already walks
# references/ recursively, so a source_keys-bearing file in
# references/<subdir>/ was collected there — and then never validated here,
# because the flat listdir could not see it. The two halves of the same check
# disagreed about which files exist.
if os.path.isdir(refs_dir):
for fname in sorted(os.listdir(refs_dir)):
if not fname.endswith('.md'):
continue
if fname == "sources.md":
continue
fpath = os.path.join(refs_dir, fname)
ref_paths = []
for root, dirs, files in os.walk(refs_dir):
dirs[:] = sorted(d for d in dirs if not d.startswith('.'))
for fname in sorted(files):
if not fname.endswith('.md'):
continue
fpath = os.path.join(root, fname)
if os.path.relpath(fpath, refs_dir) == "sources.md":
continue
ref_paths.append(fpath)
for fpath in ref_paths:
rel = os.path.relpath(fpath, skill_dir)
with open(fpath) as f:
ref_content = f.read()
try:
ref_content = read_text(fpath)
except EncodingError as exc:
emit_unreadable(rel, exc)
continue
ref_fm, _ = parse_frontmatter(ref_content)
ref_keys = parse_source_keys(ref_fm)
if not ref_keys:
# An explicit `source_keys: []` is a deliberate declaration that
# the file is house-authored, and passes silently. The INFO is for
# files that never said either way.
if declares_empty_source_keys(ref_fm):
continue
emit_info(
f"No source_keys frontmatter",
rel,
"This references file has no source_keys — provenance cannot be verified. "
"Add source_keys frontmatter listing the slugs from references/sources.md that informed this file."
"Add source_keys frontmatter listing the slugs from references/sources.md that informed this file, "
"or declare an explicit 'source_keys: []' if the file is house-authored and has no external source."
)
else:
for slug in ref_keys:
@@ -296,14 +639,51 @@ if os.path.isdir(refs_dir):
repo_root = find_repo_root(skill_dir)
# Collect all research doc paths we'll check (for Check 8)
research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it
research_docs_seen = {} # abs_path → (rel_path, slugs referencing it, content)
for slug in parse_h2_slugs(sources_content):
# Check 4: Contributing files exist
cf_value = parse_contributing_files(sources_content, slug)
if cf_value and not cf_value.startswith("(none"):
# Split by comma
cf_files = [p.strip() for p in cf_value.split(",") if p.strip()]
# Every per-slug parser below — parse_contributing_files, parse_research_docs,
# parse_status — locates its block with pattern.search(), so a slug written
# twice resolves to the FIRST block every time. Iterating the raw heading list
# therefore checked the first block's fields twice and the second block's
# never: a duplicated slug is half-validated, and looked fully validated. The
# duplicate is announced and the repeat visit dropped.
all_slugs = parse_h2_slugs(sources_content)
unique_slugs = []
for _slug in all_slugs:
if _slug in unique_slugs:
continue
unique_slugs.append(_slug)
_count = all_slugs.count(_slug)
if _count > 1:
emit_info(
f"Duplicate '## {_slug}' entry in sources.md — only the first block is checked",
f"references/sources.md (## {_slug})",
f"'## {_slug}' appears {_count} times. Every field parser here takes the first match, so the "
f"second and later blocks' Contributing files, Research doc and Status are never validated — "
f"checks 4, 5, 6, 7 and 8 did not run for them. "
f"Merge the blocks into one entry, or give each a distinct slug and reference it from source_keys."
)
for slug in unique_slugs:
# Checks 4 and 5: Contributing files exist, and back-reference the slug.
# `[]` and None are NOT the same answer here. `[]` is the author writing
# "(none)" — there is nothing to check and the skip is correct. None is a
# Contributing-files block this parser cannot read, and skipping THAT
# silently disables both checks on the one entry least likely to be right,
# which is the failure mode parse_contributing_files' own docstring warns
# about. Say so out loud instead, the same way an unresolvable Research doc
# value does.
cf_files = parse_contributing_files(sources_content, slug)
if cf_files is None:
emit_info(
f"Contributing-file checks skipped for '{slug}' — the Contributing files block could not be parsed",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry has no Contributing files list this parser can read — a missing field, a bare heading, '*' bullets, a numbered list, or prose all read as unparsable rather than as an empty declaration. "
f"Checks 4 and 5 did not run for this slug, so nothing verified that its contributing files exist or name it back. "
f"Write the value as '- **Contributing files:** <comma-separated paths>', or as a '**Contributing files:**' heading followed by '- ' bullets — "
f"or record '(none)' if this source contributed no files."
)
elif cf_files:
for cf_rel in cf_files:
cf_abs = os.path.join(skill_dir, cf_rel)
if not os.path.isfile(cf_abs):
@@ -318,8 +698,11 @@ for slug in parse_h2_slugs(sources_content):
# Skip sources.md itself
if cf_rel == "references/sources.md":
continue
with open(cf_abs) as f:
cf_content = f.read()
try:
cf_content = read_text(cf_abs)
except EncodingError as exc:
emit_unreadable(cf_rel, exc)
continue
cf_fm, _ = parse_frontmatter(cf_content)
cf_keys = parse_source_keys(cf_fm)
if slug not in cf_keys:
@@ -331,7 +714,17 @@ for slug in parse_h2_slugs(sources_content):
)
# Check 6: Research doc field required
rd_value = parse_research_doc(sources_content, slug)
rd_values = parse_research_docs(sources_content, slug)
if len(rd_values) > 1:
emit_info(
f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry has {len(rd_values)} Research doc lines; checks 7 and 8 ran against the first "
f"('{rd_values[0]}') and never looked at the rest. "
f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, "
f"or name the extra document inside the first value's annotation where it is at least visible."
)
rd_value = rd_values[0] if rd_values else None
if rd_value is None:
emit_fail(
f"Research doc field missing",
@@ -346,39 +739,115 @@ for slug in parse_h2_slugs(sources_content):
f"The '## {slug}' entry has an unfilled Research doc value.",
f"Set '- **Research doc:**' to a real path relative to repo root, or '(none)' if not applicable."
)
else:
# Check 7: Upstream forward — slug should appear in research doc
if repo_root and not rd_value.startswith("(none"):
rd_abs = os.path.join(repo_root, rd_value)
if os.path.isfile(rd_abs):
with open(rd_abs) as f:
rd_content = f.read()
elif not research_doc_is_none(rd_value):
# Check 7: Upstream forward — slug should appear in research doc.
# Every path out of here that does NOT run the check says so out loud.
rd_path = strip_research_doc_annotation(rd_value)
if not repo_root:
emit_info(
f"Upstream checks skipped for '{slug}' — no repo root above the skill directory",
f"references/sources.md (## {slug})",
f"'{rd_value}' is a path relative to the repo root, but no ancestor of the skill directory contains a .git entry, "
f"so it cannot be resolved. Checks 7 and 8 did not run for this slug. "
f"Run this script against a skill inside a checkout."
)
elif not rd_path:
emit_info(
f"Upstream checks skipped for '{slug}' — Research doc value names no path",
f"references/sources.md (## {slug})",
f"The Research doc value '{rd_value}' is entirely annotation — stripping the section marker leaves no path. "
f"Checks 7 and 8 did not run for this slug. "
f"Give the value a file path relative to the repo root, or record '(none)' if no research doc backs this entry."
)
else:
rd_abs = os.path.join(repo_root, rd_path)
if not os.path.isfile(rd_abs):
emit_info(
f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' does not exist",
f"references/sources.md (## {slug})",
f"'{rd_value}' resolves to '{rd_path}' relative to the repo root and no file is there. "
f"Checks 7 and 8 did not run for this slug, so nothing verified that the research doc still backs it. "
f"Point the value at one existing file — a brace expansion, a comma-separated list of paths, or a bare section title does not resolve — "
f"or record '(none)' if no research doc backs this entry."
)
elif os.path.basename(rd_path) != "sources.md":
# Checks 7 and 8 both assume the Research doc is a research
# SOURCE INDEX — a sources.md whose H2 headings ARE source
# slugs. 30 of the 121 corpus entries point instead at a TOPIC
# DOCUMENT (remotes.md, gitflow.md, api-reference.md), whose
# H2s are headings like '## Core Philosophy'. A slug can never
# match one, so check 7 reported all 30 as "slug not found" —
# every one a false positive — and check 8, aimed at documents
# that carry no '- **Status:**' line at all, was saved from a
# matching flood of false FAILs only by an UNANNOUNCED skip on
# that missing status. The premise, not the corpus, was wrong.
#
# A topic-document reference is a legitimate, useful value; it
# just is not something these two checks can verify. Say that
# once, out loud, instead of failing 30 entries for it.
emit_info(
f"Upstream checks not applicable for '{slug}' — research doc '{rd_path}' is a topic document, not a source index",
f"references/sources.md (## {slug})",
f"Checks 7 and 8 match slugs against the H2 headings of a research source index — a file named 'sources.md', "
f"where each H2 IS a source slug. '{os.path.basename(rd_path)}' is a topic document, so its H2s are section "
f"headings and no slug will ever match one. Checks 7 and 8 did not run for this slug. "
f"This needs no fix: point the value at the research corpus's own sources.md only if you want the "
f"provenance link machine-verified."
)
else:
try:
rd_content = read_text(rd_abs)
except EncodingError as exc:
emit_info(
f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' is {exc}",
f"references/sources.md (## {slug})",
f"'{rd_path}' could not be decoded, so checks 7 and 8 did not run for this slug. "
f"Re-save the research doc as UTF-8."
)
continue
rd_slugs = set(parse_h2_slugs(rd_content))
if slug not in rd_slugs:
emit_info(
f"Slug '{slug}' not found as H2 in research doc '{rd_value}'",
f"Slug '{slug}' not found as H2 in research doc '{rd_path}'",
f"references/sources.md (## {slug})",
f"The research doc '{rd_value}' does not have a '## {slug}' heading. "
f"The research doc '{rd_path}' does not have a '## {slug}' heading. "
f"The provenance link may be imprecise — the slug name in sources.md may differ from the research doc's heading."
)
# Track for Check 8
# Track for Check 8. The content is carried with the entry so
# check 8 reuses this read rather than decoding the file a
# second time, with a second chance to fail differently.
if rd_abs not in research_docs_seen:
research_docs_seen[rd_abs] = (rd_value, set())
research_docs_seen[rd_abs] = (rd_path, set(), rd_content)
research_docs_seen[rd_abs][1].add(slug)
# --- Check 8: Upstream reverse ---
for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items():
with open(rd_abs) as f:
rd_content = f.read()
for rd_abs, (rd_rel, known_slugs, rd_content) in research_docs_seen.items():
for rd_slug in parse_h2_slugs(rd_content):
# Parse this slug's Contributing files and Status in the research doc
rd_cf = parse_contributing_files(rd_content, rd_slug)
rd_status = parse_status(rd_content, rd_slug)
# Skip if contributing files start with (none
if rd_cf and rd_cf.startswith("(none"):
# Skip if the research doc explicitly records no contributing files
if rd_cf == []:
continue
# Skip if status is not `extracted`
if rd_status != "`extracted`":
# Skip if status is not `extracted` — and say so when the skip is what
# kept the slug out of the FAIL below. A status of `referenced` or
# `no content extracted` is a real reason not to demand the slug, but
# it was applied in silence, so an entry that should have been in
# sources.md and a status line nobody had updated produced the same
# output: nothing. Only a MATERIAL skip is announced; when the slug is
# already in sources.md the check passes either way and there is no
# fail-open to disclose.
if rd_status != "extracted":
if rd_slug not in sources_slugs:
shown = f"`{rd_status}`" if rd_status else "absent"
emit_info(
f"Check 8 skipped for research-doc slug '{rd_slug}' — its Status is {shown}, not `extracted`",
f"{rd_rel} (## {rd_slug})",
f"'{rd_rel}' has '## {rd_slug}' with contributing files but Status {shown}, and this skill's "
f"sources.md has no '## {rd_slug}' entry. Check 8 only demands an entry for an `extracted` slug, "
f"so it did not run here. If that status is stale — the content was extracted and the line was never "
f"updated — this skill is missing a source entry; if it is accurate, nothing needs doing."
)
continue
# This slug should be in sources.md
if rd_slug not in sources_slugs:

View File

@@ -59,6 +59,24 @@ import glob
import yaml
# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams
# default to ASCII, and this script's own message text carries em dashes (the
# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash
# from the read to the write — a UnicodeEncodeError raised while PRINTING, after
# every check has already run, which loses the whole report and (here) flips a
# clean exit 0 into a traceback and an exit 1. read_text() in the shared
# resolver block below pins the reads; this pins the writes.
#
# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two
# validate.sh copies print findings, skill-size-check.sh has its own top-level
# equivalent, and tests/test-adr0020-contract.sh hashes that block for
# byte-identity across all three.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding='utf-8')
except AttributeError: # pragma: no cover — Python < 3.7
pass
skill_dir = os.path.abspath(sys.argv[1])
skill_md = os.path.join(skill_dir, "SKILL.md")
@@ -179,9 +197,27 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)):
names.add(os.path.basename(path.rstrip('/')).lower())
# A directory is a skill only if it HOLDS a SKILL.md. An empty
# leftover — a deleted skill whose directory survived, a scaffolding
# stub, an editor's stray mkdir — is untracked by git, so it exists
# on the machine that made it and nowhere else. Counting it made a
# boundary target resolve locally and dangle in a fresh clone: the
# same install-dependence the deployed-tree rule above exists to
# remove, arriving through a different door.
if os.path.isfile(os.path.join(path, 'SKILL.md')):
names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)):
# The same rule one directory over, which until now had no
# counterpart here at all: the skills branch above tests for a
# SKILL.md, the agents branch took every glob hit on trust. A
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
# tell the two apart, so a leftover of that shape resolved a routing
# target on the machine holding it and dangled everywhere else —
# identical install-dependence, arriving through the one door
# nobody guarded.
if not os.path.isfile(path):
continue
base = os.path.basename(path)
if base.endswith('.agent.md'):
base = base[:-len('.agent.md')]
@@ -372,8 +408,17 @@ def known_targets(start_dir):
# condition, pc-run's "run pre-commit hooks" reads as a route to a
# non-existent `pre-commit` skill.
# * A BARE arrow target counts only in ADR-0020's compressed boundary form,
# `Not <thing> -> <skill-name>`. Without that, diagnose's process chain
# "fix -> regression-test" reads as a route to `regression-test`.
# `Not <thing> -> <skill-name>`. The example that motivated it is gone:
# diagnose's process chain "fix -> regression-test", which without the
# gate read as a route to a non-existent `regression-test` skill, was cut
# when issue #99 retrofitted that description. So the gate is currently
# UNEXERCISED — gating and not gating produce the same verdict corpus-wide.
# Keep it anyway. It is a false-positive guard against prose no one has
# written yet, and any new process chain re-arms it. Unexercised is not the
# same as unnecessary, and the branch it guards is still load-bearing: the
# bare-arrow rule is the sole extractor for three real targets in
# kyberforge's audit skills (agent-audit -> agent-author, agent-audit ->
# skill-audit, skill-audit -> skill-author), all written unbackticked.
# * A backticked hyphenated token counts only inside a boundary sentence.
# Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard
# FAIL with no escape hatch. Gating it costs nothing (measured over this
@@ -451,6 +496,66 @@ def known_targets(start_dir):
# ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so.
#
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN, and that is a repair of the promise
# above rather than a widening of it. Until the sweeps existed, notation was
# only ever seen as the OBJECT OF A ROUTE VERB (`use
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
# SUGGESTION, not even the target's name:
# Do not use for Y — /no-such-skill instead.
# Do not use for Y; /no-such-skill handles that.
# Do not use for Y (/no-such-skill covers it).
# Do not use for Y — that is /no-such-skill's job.
# Do not use for Y — defer to /no-such-skill.
# Do not use for Y — /no-such-skill.
# Do not use for Y; -> no-such-skill covers it.
# For W, /no-such-skill is the right entry point.
# The target was never EXTRACTED, so the notation-first rule in _add() had
# nothing to apply itself to and the "always blocks" promise was false for the
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
# than a gap: its printed remedy tells the author to "write it as `/name` or
# `-> name` and it will be checked properly", and taking that advice turned a
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
# it.
#
# THE TWO SWEEPS ARE GATED DIFFERENTLY, and the asymmetry is the whole point.
# `/name` is Claude Code's invocation syntax and nothing else — no English
# sentence contains one by accident — so the ADR-0020 amendment and
# docs/spec/gates.md both promise it blocks UNCONDITIONALLY, for any name. So
# NOTATION_SLASH is swept over every sentence, boundary marker or not. Gating it
# on BOUNDARY_MARKER made that promise false for the last sentence of
# Do not use for Z — use /real-skill instead.
# For W, /no-such-skill is the right entry point.
# which exited 0 in total silence: the boundary clause is one sentence up, so
# the sweep never looked at the sentence carrying the broken route. Extraction is
# per-sentence by design (corroboration is scoped to one sentence), which is
# exactly what made the gap invisible.
#
# NOTATION_ARROW stays gated on BOUNDARY_MARKER, and so does the backtick sweep.
# Neither form is unambiguous: `-> name` is also how a process chain is written
# ("reproduce -> minimise -> regression-test") and a code span is how a tool, a
# file and a skill are all cited. Ungating either would fire on prose that
# carries no routing intent at all — the false-positive class this whole
# extractor is tuned against.
#
# BOTH `/name` PATTERNS REFUSE A TOKEN THAT IS PART OF A PATH: a following `/`,
# or a `.` followed by a non-space, means `references/foo.md`, `docs/a/b.md` or
# `https://x/y`, not a route. A sentence's closing `.` is not followed by a
# non-space, so `— /no-such-skill.` still counts.
#
# THAT GUARD IS WRITTEN `(?![\w-])` AND NOT `\b`, because `\b` is not a guard at
# all here: it holds after a hyphen, so when the trailing lookahead rejected the
# full segment the engine simply backtracked to a shorter hyphen-terminated
# prefix and reported THAT as a route. Every one of these was a hard blocking
# ERROR naming a skill nobody had written:
# the config lives at /opt-tools/bin/thing. -> 'opt'
# see /api-docs/v2.md for the schema. -> 'api' AND 'api-docs'
# the file /no-such-skill.md documents it. -> 'no-such'
# `(?![\w-])` forbids the shortened prefix outright, so the whole segment is
# rejected as the path it is. MARKED_TARGET carries the same guard: it had no
# trailing lookahead whatsoever, so `see /api-docs/v2.md` raised the second of
# the two errors above through the route-verb path rather than the sweep.
#
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution.
@@ -461,7 +566,8 @@ ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see"
r"|that'?s|compose|composes|call|calls"
r"|routes?\s+to|delegates?\s+to|prefers?|switch(?:es)?\s+to"
r"|hands?\s+off\s+to)")
MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
MARKED_TARGET = (r"(?:`/?(%s)`|(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S))"
% (NAME_ANY, NAME_ANY))
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
@@ -474,12 +580,53 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
# The two EXPLICIT ROUTE NOTATION sweeps. NOTATION_SLASH runs over EVERY
# sentence; NOTATION_ARROW is scoped to a boundary sentence by its caller (see
# the asymmetry note in the header). NOTATION_SLASH is deliberately not a reuse
# of MARKED_TARGET's `/name` alternative: that one only ever runs behind a route
# verb or an arrow, and it may match a namespaced or path-adjacent token in
# positions this free-standing sweep must refuse.
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
# -> minimise -> regression-test.` — and costs it only in a sentence that already
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
# chain written `Do not use for X — reproduce -> regression-test.` was already a
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
# arrow, not whether prose can. An author who means the chain and not a route
# writes it in its own sentence, where neither pattern looks.
NOTATION_SLASH = re.compile(
r"(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S)" % NAME_ANY, re.I)
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
# `.vale.ini` — was invisible to both patterns below, and the two resulting
# failures were different sizes (issue #110):
# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep
# still extracted the target, so the route was checked, but the gate
# reported "no boundary clause" on a clause that was present and working.
# Three authors in two retrofit waves reworded a correct clause to satisfy
# the regex, one of them stripping the very filename that discriminates the
# skill from its neighbour.
# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only
# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill`
# produced no target, no dangling report and no missing-clause SUGGESTION.
# Silence, not noise — the worse of the two failure modes.
# A dot inside a filename is followed by a non-space; a sentence-ending dot is
# followed by whitespace or by end of string. So the class admits a `.` only
# when the next character is not whitespace, which crosses `AGENTS.md` and
# still stops at a real sentence end.
CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))"
ARROW_BOUNDARY = re.compile(
r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I)
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
# A boundary clause takes two shapes and BOTH count: the prose markers, and
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I)
# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# shapes common in these descriptions defeat the naive "period, space, capital"
@@ -499,9 +646,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58
# SUGGESTION / 2 dangling before and after) — this protects the descriptions
# issue #99 is about to rewrite, not the ones already measured.
# re.I here too, and NOT as a tidy-up: this was the one pattern in the file
# built without it, contradicting the uniformity note on CONT_*/ARROW_* above.
# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is
# where an abbreviation most often lands — matched none of the lookbehinds, so
# the clause split at the abbreviation, the corroborating target was stranded on
# the far side of the cut, and a genuinely dangling target silently demoted from
# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described
# directly above, still live for exactly the capitalised half of the input.
SENTENCE_SPLIT = re.compile(
u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])')
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
# The token that may follow a route target without turning it into a compound
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
@@ -560,11 +715,26 @@ def _notation(text, start, arrow):
def _add(out, text, name, start, end, strict=None, arrow=False):
"""Record one target as (name, may_dangle, notation).
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
The header above promises that route notation "always blocks", and for the
`/name` form that was false: `-> name` reached this function with
strict=True from its two call sites, but `/name` did not, so it fell to
_terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The
target then reached unresolved_targets() unblockable — and, before the
companion fix there, unreported as well. `... use /no-such-skill
afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an
author who wants a route checked unconditionally.
"""
if not name:
return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name,
_terminal(text, end) if strict is None else strict,
_notation(text, start, arrow)))
notation))
def _scan(text, route_re, cont_re, out):
@@ -604,7 +774,19 @@ def _extract_sentence(sentence):
for match in ARROW_BOUNDARY.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True)
# `/name` wherever it sits, in ANY sentence — not only where a route verb or
# an arrow happens to precede it, and NOT only inside a boundary sentence.
# See the EXPLICIT ROUTE NOTATION note in the header for the eight phrasings
# this recovers and for why silence was the failure mode. The sweep takes no
# follower test: _add() reads the notation first and marks it.
for match in NOTATION_SLASH.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1))
if boundary:
# The arrow and backtick forms are ambiguous in ordinary prose, so they
# stay scoped to a sentence that carries a boundary marker.
for match in NOTATION_ARROW.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True)
for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1))
return out
@@ -623,6 +805,85 @@ def boundary_targets(description):
return sorted({name for name, _, _ in _extract(description)})
def _arrow_targets(description):
"""Names extracted from ARROW notation specifically.
Kept apart from boundary_targets() because the arrow form is the one shape
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
written that way from which nothing could be extracted is a parse failure
that deserves its own message, and telling it apart needs the arrow targets
alone rather than every target in the description.
"""
out = []
for sentence in SENTENCE_SPLIT.split(description):
for match in ARROW_MARKED.finditer(sentence):
name, _, _ = _first(match)
if name:
out.append(name)
for match in ARROW_BOUNDARY.finditer(sentence):
out.append(match.group(1))
return out
def boundary_clause_status(description):
"""'absent', 'unparsed' or 'present' — three outcomes, not two.
Issue #110's standing request: the gate must distinguish "no boundary
clause" from "boundary clause I could not parse". Reporting the first for
the second sends the author hunting for a problem that is not there, and
three of them reworded a correct clause to satisfy a regex instead.
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
detected and NO target came out of it. The arrow form always names one, so
zero targets means the name is written in a shape the extractor cannot see
— a single-word bare target (`Not X -> forge`, which has to be written
`` `forge` `` or `/forge`) is the live example, since single-word names are
deliberately not matchable bare.
A PROSE clause yielding no target is NOT reported: "Do not use for anything
else" is a complete and legitimate boundary clause that names nowhere to go.
"""
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
return 'unparsed'
if has_boundary_clause(description):
return 'present'
return 'absent'
def multi_target_arrow_clauses(description):
"""[(first, second)] for arrow clauses naming more than one target.
Issue #107: only the FIRST target after an arrow is resolved. The
conjunction continuation (CONT_*) is wired to the prose route verbs and
never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither
resolved nor reported, and then printed "1 of 1 boundary target(s) resolve"
on a clause naming two — a gate under-reporting its own coverage, which is
the one failure mode ADR-0020 says a gate must not have.
The clause is REJECTED rather than the arrow scan extended. Extending it
would widen the resolver's deliberately conservative false-positive tuning
across every arrow in the corpus; rejecting costs nothing and makes the
one-arrow-per-target convention — already what every retrofitted gitea
skill does in practice — explicit instead of folkloric. The caller emits a
SUGGESTION telling the author to split.
"""
hits = []
for sentence in SENTENCE_SPLIT.split(description):
matches = (list(ARROW_MARKED.finditer(sentence))
+ list(ARROW_BOUNDARY.finditer(sentence)))
for match in matches:
first, _, _ = _first(match)
if not first:
continue
cont = CONT_ANY.match(sentence, match.end())
if not cont:
continue
second, _, _ = _first(cont)
if second:
hits.append((first, second))
return hits
def unresolved_targets(description, known):
"""Targets resolving to nothing, split into (blocking, reported).
@@ -639,6 +900,17 @@ def unresolved_targets(description, known):
Everything else is reported and left alone. `known` is the resolved
universe from known_targets(); passing an empty set is not meaningful —
callers check for that first and decline out loud instead.
A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed
whitelist of maybe eighty words, so the follower rule says "this token is
outside a list I keep" and not "this is prose" — and the old `continue`
turned that into invisibility at every tier. The gate then failed OPEN on
its own unfamiliarity: any target followed by a word nobody thought to
enumerate was neither blocked nor mentioned, so the check that did not run
said nothing about not running. The follower rule may withdraw the power to
BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note
above is the argument for it — but it may not withdraw visibility, which is
the same rule the corroboration tier already follows.
"""
blocking, reported = set(), set()
for sentence in SENTENCE_SPLIT.split(description):
@@ -647,7 +919,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known}
for name, may_dangle, notation in found:
key = normalize_target(name)
if key in known or not may_dangle:
if key in known:
continue
if not may_dangle:
reported.add(name)
continue
if notation or (resolved - {key}):
blocking.add(name)
@@ -727,6 +1002,47 @@ def description_value(fm_text):
return re.sub(r'\s+', ' ', value).strip()
def hand_invoked(fm_text):
"""True when the frontmatter marks this file as reached only by hand.
`disable-model-invocation: true` removes a skill from the model-visible
listing entirely — it is not preloaded, and the Skill tool refuses to call
it — so its description is never matched against user intent. ADR-0020 and
skill-author's contract give such a skill ONE plain human-facing sentence:
no trigger list, no boundary clause. No validator knew the field existed
(issue #108), so the boundary-clause SUGGESTION fired on exactly the shape
the contract mandates, and its remedy — "add a boundary clause so the router
knows where NOT to send this skill" — was addressed to a router that cannot
see the skill at all. An author who followed the advice made the file worse.
Only the ROUTING rules are lifted. The body word budget still applies: the
body is loaded on invocation like any other, and competes with the caller's
live conversation the same way. So does the 400-character description FAIL —
a hand-invoked description is not preloaded, but it is still the one line
the user reads when choosing from the `/` menu, and the ceiling is the
outlier stop rather than the style target.
A parse failure returns False rather than raising. This is a MODIFIER on
other checks, not a check of its own: the frontmatter's validity is decided,
and failed, by description_value() on the same text, and raising a second
exception here would report one broken file twice with two different
diagnoses.
"""
try:
data = yaml.safe_load(fm_text)
except Exception:
return False
if not isinstance(data, dict):
return False
value = data.get('disable-model-invocation')
if isinstance(value, str):
# PyYAML already resolves the unquoted YAML 1.1 booleans, so this only
# catches a QUOTED "true" — which a host reads as truthy and which no
# gate should treat as opting back in to the routing rules.
return value.strip().lower() in ('true', 'yes', 'on')
return value is True
# --- Body-shape checks (skills only; agents have no references/ dir) -------
# Deterministic and countable, so they are enforced here. Whether a given
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
@@ -841,7 +1157,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end())
if end < 0:
end = len(masked)
if REFERENCE_PAST.search(masked[start:end]):
# The pointer's OWN SPAN is excised before the sweep. Run over the
# whole line, the past-tense test matched the very path it was judging,
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
# `references/removed-flags.md` and `references/gone.md` produced no
# ERROR at all, while `references/missing.md` — an identical break —
# errored. The exemption is about what the SENTENCE says about the
# pointer, never about what the pointer is called.
line = masked[start:match.start()] + masked[match.end():end]
if REFERENCE_PAST.search(line):
continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue
@@ -900,6 +1224,15 @@ except FrontmatterError as exc:
dir_name = os.path.basename(skill_dir)
# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation:
# true` takes the skill out of the model-visible listing entirely, so the
# trigger/capability/boundary rules and the 250-character routing target do not
# apply to it — the audit's own references/description-quality.md Step 0 says
# so, and until this line existed no check here knew the field existed. What the
# flag does NOT lift: the body word budget and the 400-character description
# ceiling. See the shared resolver's hand_invoked().
by_hand = hand_invoked(fm)
# --- Checks ---
# name present
@@ -1014,10 +1347,13 @@ if desc:
f"skill is invoked. Keep a trigger clause, at most one capability clause, "
f"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body or README.md")
elif dlen > DESC_SUGGEST_CHARS:
elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
f"what moves the corpus average; the FAIL tier only stops outliers")
elif by_hand:
ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character "
f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)")
else:
ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})")
@@ -1071,13 +1407,41 @@ if gotchas is not None:
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular skill warrants a boundary clause is judgment. Both accepted
# shapes count — the prose markers and the compressed `Not <thing> -> <name>`.
if desc:
if has_boundary_clause(desc):
#
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could not
# parse" are different findings, and reporting the first for the second sends
# the author hunting for a problem that is not there (issue #110).
#
# Skipped entirely for a hand-invoked skill — the contract gives it one plain
# sentence with no boundary clause, so the finding would be wrong and its remedy
# names a router that cannot see the skill (issue #108).
if desc and by_hand:
ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger "
"rules do not apply; audited as one plain human-facing sentence")
elif desc:
status = boundary_clause_status(desc)
if status == 'present':
ok("description has a boundary clause")
else:
elif status == 'absent':
suggest("description has no boundary clause — add the prose form (\"Do not use "
"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
"so the router knows where NOT to send this skill")
else:
suggest("description has an arrow boundary clause (\"Not X -> y\") from which no "
"target could be read, so the dangling-target check did not run on it — "
"the clause is PRESENT and unparsed, not missing. Most often the target is "
"a single word, which is deliberately not matchable bare because "
"`research`, `triage` and `forge` are all ordinary English: write it as "
"`name` or /name")
# One arrow, one target. A second name after the same arrow is resolved by
# nothing and reported by nothing, so the clause claims coverage it does not
# have and this script printed "1 of 1 boundary target(s) resolve" on a
# clause naming two (issue #107).
for first, second in multi_target_arrow_clauses(desc):
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
f"'{second}') and only the first is resolved — the second is checked by "
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
f"Not Y -> {second}.\"")
# --- ADR-0020: resolvable boundary targets ---------------------------------
# The resolution universe comes from the SKILL's own location: the authoring

View File

@@ -70,13 +70,23 @@ PY
# this repo's live skills. Echoes the subject skill's directory.
#
# <root>/plugins/fixture-plugin/.apm/skills/<subject>/SKILL.md
# <root>/plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/
# <root>/plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/SKILL.md
# <root>/plugins/fixture-plugin/.apm/agents/fixture-sibling-agent.agent.md
#
# The sibling gets a real SKILL.md, and that is load-bearing rather than
# tidiness: a directory under skills/ is a resolvable name only when it
# HOLDS one. An empty leftover directory is untracked by git, so counting
# one made a target resolve on the machine that made it and dangle in a
# fresh clone. This helper used to mkdir the sibling and write nothing into
# it, so the corroborator every blocking-tier test depends on silently
# stopped resolving the moment that rule was enforced.
make_fixture_tree() {
local root="$1" subject="$2"
local apm="$root/plugins/fixture-plugin/.apm"
mkdir -p "$apm/skills/$subject" "$apm/skills/fixture-sibling-skill" "$apm/agents"
touch "$apm/agents/fixture-sibling-agent.agent.md"
make_sized_skill "$apm/skills/fixture-sibling-skill" \
"Use when doing the other thing. Do not use for anything else." 10
echo "$apm/skills/$subject"
}
}
@@ -568,3 +578,198 @@ EOF
assert_output --partial "boundary-target resolution DID NOT RUN"
assert_output --partial "Unchecked target(s): some-other-skill"
}
# ---------------------------------------------------------------------------
# ADR-0020 — the hand-invocation carve-out (issue #108)
#
# A skill carrying `disable-model-invocation: true` is absent from the
# model-visible listing entirely: not preloaded, and the Skill tool refuses to
# call it. Its description is never matched against user intent, so
# references/description-quality.md Step 0 gives it ONE plain human-facing
# sentence — no trigger list, no boundary clause — and calls a
# missing-boundary-clause finding on such a skill "a wrong finding, not a strict
# one". Until this ran, nothing here knew the field existed, so the audit
# reported exactly the shape its own rubric mandates, with advice naming a
# router that cannot see the skill.
#
# The carve-out is narrow. Both size gates are unaffected and both are pinned
# below: the body is loaded on invocation like any other body, and the
# 400-character ceiling is an outlier stop rather than a routing budget.
# ---------------------------------------------------------------------------
# Helper: a skill directory carrying `disable-model-invocation: true`.
make_hand_invoked_skill() {
local dir="$1" desc="$2" body_words="$3"
local name
name="$(basename "$dir")"
mkdir -p "$dir"
{
echo "---"
echo "name: $name"
echo "description: $desc"
echo "disable-model-invocation: true"
echo "---"
echo ""
python3 -c "print(' '.join(['word'] * $body_words))"
} > "$dir/SKILL.md"
}
@test "ADR-0020: a hand-invoked skill is not asked for a boundary clause" {
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" \
"Tell the agent to zoom out and give broader context or a higher level perspective." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "has no boundary clause"
assert_output --partial "hand-invoked"
}
@test "ADR-0020: the SAME description without the flag IS asked for a boundary clause" {
# The control. Without it the case above is satisfied by an audit that
# stopped checking boundary clauses altogether.
local skill="$TMPDIR/my-skill"
make_sized_skill "$skill" \
"Tell the agent to zoom out and give broader context or a higher level perspective." 10
run bash "$SCRIPT" "$skill"
assert_success
assert_output --partial "has no boundary clause"
}
@test "ADR-0020: a hand-invoked skill is exempt from the 250-character description target" {
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" \
"$(python3 -c "print('Tell the agent to zoom out. ' + 'x' * 273)")" 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "over the 250-character"
}
@test "ADR-0020: a hand-invoked description over 400 chars still FAILS" {
# The half the carve-out does NOT lift. 400 is an outlier stop, not a
# routing-quality target: a hand-invoked description is still the one line
# the user reads when choosing from the `/` menu.
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" \
"$(python3 -c "print('Tell the agent to zoom out. ' + 'x' * 374)")" 10
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "400-character"
}
@test "ADR-0020: a hand-invoked body over 900 words still FAILS" {
# The body is loaded on invocation exactly like any other body and competes
# with the caller's live conversation the same way, so no body tier moves.
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" "Tell the agent to zoom out." 901
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "900-word"
}
# ---------------------------------------------------------------------------
# ADR-0020 — one arrow, one target (issue #107)
#
# Only the FIRST target after an arrow was resolved: the conjunction
# continuation is wired to the prose route verbs and never to arrows. So this
# script printed "1 of 1 boundary target(s) resolve" on a clause naming two,
# and the second was resolved by nothing and reported by nothing. A typo in it
# shipped through a green gate. The shape is now rejected rather than the
# extractor widened.
# ---------------------------------------------------------------------------
@test "ADR-0020: an arrow clause naming two targets is reported, not silently half-checked" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
# A bare `Not ... ->` sentence carries no BOUNDARY_MARKER, so the backtick
# sweep does not run and the second target is invisible to every other rule
# in the resolver — this is the exact shape #107 measured.
make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> \`fixture-sibling-skill\` or \`fixture-missing-second\`." 10
run bash "$SCRIPT" "$skill"
assert_success
assert_output --partial "names more than one target"
}
@test "ADR-0020: one arrow per target — the convention the suggestion asks for — is silent" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> \`fixture-sibling-skill\`." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "names more than one target"
}
# ---------------------------------------------------------------------------
# ADR-0020 — a dotted filename in a boundary clause (issue #110)
#
# `[^.;]` could not cross the `.` in `AGENTS.md`, so a clause naming a dotted
# file between "Not" and the arrow was invisible. With a backticked target that
# was a MISDIAGNOSIS — "no boundary clause" reported on a clause that was
# present and working. With a BARE target it was worse: the target was never
# extracted, so the dangling check silently did not run on it.
# ---------------------------------------------------------------------------
@test "ADR-0020: a boundary clause naming a dotted filename is not reported as missing" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
make_sized_skill "$skill" "Use when doing the thing. Not AGENTS.md -> \`fixture-sibling-skill\`." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "has no boundary clause"
assert_output --partial "description has a boundary clause"
}
@test "ADR-0020: a BARE target after a dotted filename is extracted and checked" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
# The silent half of #110: this clause produced no target at all, so it was
# neither resolved nor reported — a route to a non-existent skill shipping
# through a green gate with no finding of any kind.
make_sized_skill "$skill" "Use when doing the thing. Not AGENTS.md -> fixture-missing-dotted." 10
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "routes to 'fixture-missing-dotted'"
}
@test "ADR-0020: an arrow clause yielding no target is reported as unparsed, not as missing" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
# A single-word target is deliberately not matchable bare, because
# `research`, `triage` and `forge` are all skill names AND ordinary English.
# The clause is present; saying it is missing sends the author to add a
# second copy of a clause that is already there.
make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> forge." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "has no boundary clause"
assert_output --partial "no target could be read"
}
# ---------------------------------------------------------------------------
# Encoding, write side: sys.stdout/stderr.reconfigure(encoding='utf-8')
#
# read_text() in the shared resolver block pins the READS to UTF-8. That moved
# the LC_ALL=C crash to the WRITE: this script's own message text carries em
# dashes (the ADR-0020 boundary SUGGESTION is one), so the streams' ASCII
# default raised UnicodeEncodeError while PRINTING — after every check had
# already run, losing the whole report at the last step.
# ---------------------------------------------------------------------------
@test "under LC_ALL=C the report is printed, not lost to a UnicodeEncodeError" {
local dir="$TMPDIR/locale-skill"
mkdir -p "$dir"
cat > "$dir/SKILL.md" <<EOF
---
name: locale-skill
description: A valid skill description that is well within the limit.
---
## Step 1
Do the thing.
EOF
run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$dir"
assert_success
assert_output --partial "description has no boundary clause"
refute_output --partial "UnicodeEncodeError"
refute_output --partial "Traceback"
}