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

Merged
Defame1297 merged 89 commits from refactor/adr0020-skill-retrofit into main 2026-09-01 13:47:47 +00:00
7 changed files with 1041 additions and 90 deletions
Showing only changes of commit 59f27dbd94 - Show all commits

View File

@@ -30,8 +30,8 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell
Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file:
- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it.
- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket.
- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it — nor one inside a code fence, an indented block, or an HTML comment, nor one whose path does not resolve to a real, non-empty file on disk.
- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short sentence pointing at `AGENTS.md` ("See AGENTS.md at the repo root for ..."), keep the provider-specific bucket. A bare or negated mention is not a pointer and will not be credited.
The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like.
@@ -45,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate
bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file>
```
Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter.
Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exits `2` and `3` are not `FAIL`s and nothing was graded under either, so neither is a reason to touch the adapter: `2` means the invocation or the input is wrong (a bad, missing, or extra argument, an unknown option, or a file that is not UTF-8), and `3` means a named file exists but could not be read.
## Step 4 — Report

View File

@@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg
| File | Purpose |
|------|---------|
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
Takes `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure.
Takes exactly `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored.
## What counts as a reference to AGENTS.md
Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line.
A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass.
Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer.
## Exit codes
The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these.
| Code | Meaning | What to do |
|------|---------|------------|
| `0` | Passes every check | Nothing |
| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file |
| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter |
| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter |

View File

@@ -14,6 +14,10 @@ Arguments:
adapter-file Path to the provider-specific file to check.
agents-md-file Path to the AGENTS.md file it should defer to.
Exactly two positional arguments are accepted. Extra ones are rejected
rather than ignored: a third path silently graded nothing but the first
two, so a typo'd invocation passed against the wrong file.
Options:
--no-import-syntax The target provider has no native cross-file import
mechanism. Require a plain-text pointer line naming
@@ -26,26 +30,69 @@ Options:
it's considered no longer "thin". Must be a
non-negative integer. Default: 60.
--help, -h Show this help and exit 0.
-- End of options; every later argument is positional.
Both flags also accept the --flag=value form (--max-lines=40). An unknown
option is reported as an unknown option, not as a missing file.
What counts as a reference:
In both modes the named path must be a real path segment ending in
AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must
resolve on disk, relative to the adapter file, to a non-empty file. An
adapter deferring to a path that is not there defers to nothing.
A mention inside a fenced code block, an indented code block, or an HTML
comment is not credited in either mode. Nothing resolves those, so an
adapter whose only "import" is fenced silently defers to nothing.
With --no-import-syntax the pointer must read as a pointer: the sentence
naming AGENTS.md has to carry a deference cue (see, read, refer to,
documented in, conventions, ...) and must not be a negation ("do not read
AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer.
Exit codes:
0 Adapter file passes all checks
1 One or more checks failed (empty file, no reference to AGENTS.md,
excessive duplication, or file too long)
2 Usage or input error — a bad or missing argument, a path that is not a
file, or a file that is not UTF-8. Nothing was graded, so there is no
FAIL line and no adapter edit to make: fix the invocation or the file's
encoding and re-run. Kept distinct from 1 because the skill's own
closeout tells the agent to fix every non-zero exit by editing the
provider file, which for a mistyped flag edits the wrong file forever.
2 Usage or input error — a bad, missing, or extra argument, an unknown
option, a path that is not a file, or a file that is not UTF-8. Nothing
was graded, so there is no FAIL line and no adapter edit to make: fix
the invocation or the file's encoding and re-run. Kept distinct from 1
because the skill's own closeout tells the agent to fix every non-zero
exit by editing the provider file, which for a mistyped flag edits the
wrong file forever.
3 A named input file exists but could not be read (permissions, a
directory swapped in mid-run, I/O error). Also not a FAIL: nothing was
graded and the adapter's contents are unknown, so editing it is
guesswork. Fix the file's readability and re-run.
EOF
}
NO_IMPORT_SYNTAX=0
MAX_LINES=60
ARGS=()
END_OF_OPTS=0
require_int() {
# $1 = the value to validate
if [[ ! "$1" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2
exit 2
fi
}
while [[ $# -gt 0 ]]; do
if [[ $END_OF_OPTS -eq 1 ]]; then
ARGS+=("$1")
shift
continue
fi
case "$1" in
--)
END_OF_OPTS=1
shift
;;
--help|-h)
usage
exit 0
@@ -54,17 +101,37 @@ while [[ $# -gt 0 ]]; do
NO_IMPORT_SYNTAX=1
shift
;;
--no-import-syntax=*)
echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2
exit 2
;;
--max-lines)
if [[ $# -lt 2 ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 2
fi
MAX_LINES="$2"
if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2
require_int "$MAX_LINES"
shift 2
;;
--max-lines=*)
MAX_LINES="${1#--max-lines=}"
if [[ -z "$MAX_LINES" ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 2
fi
shift 2
require_int "$MAX_LINES"
shift
;;
-*)
# Reported as an unknown option rather than falling through to the
# positional bucket, where it used to surface as "'--bogus' is not a
# file" — the right exit code attached to a diagnostic that sends the
# reader looking for a path they never typed.
echo "Error: unknown option '$1'." >&2
echo "" >&2
usage >&2
exit 2
;;
*)
ARGS+=("$1")
@@ -80,6 +147,13 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then
exit 2
fi
if [[ ${#ARGS[@]} -gt 2 ]]; then
echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2
echo "" >&2
usage >&2
exit 2
fi
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON'
import sys
import os
@@ -89,45 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5]
no_import_syntax = no_import_syntax == "1"
max_lines = int(max_lines)
EXIT_FAIL = 1
EXIT_USAGE = 2
EXIT_UNREADABLE = 3
if not os.path.isfile(adapter_path):
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr)
sys.exit(2)
sys.exit(EXIT_USAGE)
if not os.path.isfile(agents_md_path):
print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr)
sys.exit(2)
sys.exit(EXIT_USAGE)
def read_text(path):
r"""File contents as text, UTF-8, BOM stripped.
r"""File contents as text, UTF-8, every BOM stripped.
The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is
not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had
its first line — the `@AGENTS.md` import, which is the whole adapter —
silently treated as prose. The check then said "no reference to AGENTS.md"
told the author to add the line already sitting in front of them. Same
class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it
at the reader so no later check has to know about it.
The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM
is not whitespace in Python, so a CLAUDE.md saved by an editor that emits
one had its first line — the `@AGENTS.md` import, which is the whole
adapter — silently treated as prose. The check then said "no reference to
AGENTS.md" and told the author to add the line already sitting in front of
them. Same class of silent BOM miss recorded in scripts/skill-size-check.sh;
strip it at the reader so no later check has to know about it.
Every U+FEFF goes, not just one at offset 0. Stripping exactly the first
one left the mirror-image false FAIL for a doubled BOM (two concatenated
files, or a tool that re-adds one) and for a BOM mid-file at the head of
the import line. U+FEFF has no meaning as a character in a markdown
instruction file, so removing all of them cannot lose signal.
Decoding is strict, not errors="replace". Replacement mangles the file and
the checks then grade the mangling: a UTF-16 adapter whose first line is
`@AGENTS.md` decoded to interleaved NULs and failed as "no reference",
which is a true FAIL for a false reason and points the fix at the wrong
thing. A file this gate cannot read gets an encoding diagnostic and exit 2,
thing. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and
UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they
decoded clean and produced exactly that false diagnosis anyway. The NUL
byte is the complete signal and is checked first: no plausible markdown
adapter contains one, and every UTF-16/32 encoding of ASCII is full of
them. A file this gate cannot read gets an encoding diagnostic and exit 2,
the same policy the ADR-0020 validators' read_text() uses.
A file that exists but cannot be read at all is neither a pass nor a FAIL —
nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's
closeout into "fix the FAIL by editing the provider file", which for a file
it cannot open is an instruction to edit blind.
"""
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
with open(path, "rb") as fh:
raw = fh.read()
except OSError as exc:
print(f"Error: '{path}' exists but could not be read ({exc.strerror}). "
"Nothing was checked — fix whatever is blocking the read "
"(permissions, ownership, the underlying device) and re-run; do "
"not edit the adapter on the strength of this.", file=sys.stderr)
sys.exit(EXIT_UNREADABLE)
if b"\x00" in raw:
print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so "
"it is almost certainly UTF-16 or UTF-32 (with or without a BOM). "
"Re-save it as UTF-8; this check does not guess at other "
"encodings.", file=sys.stderr)
sys.exit(EXIT_USAGE)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte "
f"{exc.start}) — re-save it as UTF-8; this check does not guess "
"at other encodings.", file=sys.stderr)
sys.exit(2)
return text[1:] if text.startswith("\ufeff") else text
sys.exit(EXIT_USAGE)
return text.replace("\ufeff", "")
adapter_content = read_text(adapter_path)
agents_md_content = read_text(agents_md_path)
adapter_dir = os.path.dirname(os.path.abspath(adapter_path))
has_fail = False
@@ -136,34 +245,229 @@ if not adapter_content.strip():
print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.")
print(" Fix: Add at least an import (or text pointer) to AGENTS.md.")
print()
sys.exit(1)
sys.exit(EXIT_FAIL)
IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$')
lines = adapter_content.splitlines()
import_lines = [ln for ln in lines if IMPORT_RE.match(ln)]
# A prose pointer is any line naming AGENTS.md that is not itself an import
# line — an inert `@AGENTS.md` in a provider that resolves no imports points
# a reader at nothing.
pointer_lines = [ln for ln in lines if not IMPORT_RE.match(ln) and "AGENTS.md" in ln]
# --- Inert regions -----------------------------------------------------------
#
# A reference only counts where something would actually resolve it. Fenced
# code blocks, indented code blocks and HTML comments are shown to the reader
# (or hidden from them) as literal text; Claude Code resolves an @import in
# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact
# copy-the-example-into-the-file mistake this gate exists to catch — exited 0
# with the adapter deferring to nothing.
#
# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the
# mask: four leading spaces is what opens an indented code block in CommonMark,
# so an import has to sit within three. The mask deliberately does not apply
# that rule to prose pointers, where four-space indentation is ordinary list
# continuation rather than code.
FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$')
COMMENT_RE = re.compile(r'<!--.*?(?:-->|\Z)', re.DOTALL)
def line_offsets(text):
"""[(char offset, line without its terminator)] over `text`."""
out = []
off = 0
for raw in text.splitlines(keepends=True):
out.append((off, raw.rstrip("\r\n")))
off += len(raw)
return out
def build_inert_mask(text, offsets):
"""Per-character flags: 1 where a reference would never be resolved."""
mask = bytearray(len(text))
fence = None # (fence char, opening run length)
for start, line in offsets:
m = FENCE_RE.match(line)
if fence is None:
if m:
fence = (m.group(2)[0], len(m.group(2)))
for i in range(start, start + len(line)):
mask[i] = 1
continue
for i in range(start, start + len(line)):
mask[i] = 1
if (m and m.group(2)[0] == fence[0]
and len(m.group(2)) >= fence[1]
and not m.group(3).strip()):
fence = None
for m in COMMENT_RE.finditer(text):
if m.start() < len(mask) and mask[m.start()]:
continue # a literal "<!--" printed inside a fence opens nothing
for i in range(m.start(), min(m.end(), len(mask))):
mask[i] = 1
return mask
# --- Reference shapes --------------------------------------------------------
#
# `\S*AGENTS\.md` had no path-separator boundary, so `@NOTAGENTS.md` and
# `@zzzAGENTS.md` counted as imports of AGENTS.md. The matched path must end in
# AGENTS.md as a whole segment.
IMPORT_RE = re.compile(r'^ {0,3}@(?P<path>\S+?)\s*$')
# A mention in prose: an optional relative path, then AGENTS.md, with no
# identifier character glued to the front (so NOTAGENTS.md does not match) and
# nothing glued to the back.
MENTION_RE = re.compile(r'(?<![0-9A-Za-z_.\-/])((?:[\w.\-~]+/)*AGENTS\.md)(?![0-9A-Za-z])')
# A pointer has to read as a pointer. `"AGENTS.md" in ln` passed
# "Do NOT read AGENTS.md; it is obsolete." and "We deleted AGENTS.md last
# year." — both of which point the reader away from the file. Require a
# deference cue in the naming sentence, and reject a negated one.
DIRECTIVE_RE = re.compile(
r'\b(see|read|refer|refers|referring|consult|consults|follow|follows|'
r'defer|defers|deferring|described|documented|documents|covered|covers|'
r'found|listed|specified|defined|governed|per|use|uses|using|apply|obey|'
r'start|check|live|lives|contains|holds|carries|inherit|inherits|import|'
r'imports|conventions|instructions|guidelines|guidance|rules|standards|'
r'reference|setup)\b', re.I)
NEGATION_RE = re.compile(
r"(\bnot\b|n't\b|\bnever\b|\bno longer\b|\bdeleted\b|\bremoved\b|"
r"\bobsolete\b|\bdeprecated\b|\bignore\b|\bignores\b|\bignoring\b|"
r"\bdisregard\b|\bsuperseded\b|\bgone\b|\bunused\b|\bstale\b)", re.I)
SENTENCE_SPLIT_RE = re.compile(r'(?<=[.;:!?])\s+')
def sentence_around(line, index):
"""(sentence of `line` containing character `index`, its start offset)."""
bounds = [0]
for m in SENTENCE_SPLIT_RE.finditer(line):
bounds.append(m.end())
bounds.append(len(line) + 1)
for i in range(len(bounds) - 1):
if bounds[i] <= index < bounds[i + 1]:
return line[bounds[i]:bounds[i + 1]], bounds[i]
return line, 0
def reads_as_pointer(line, match):
"""Does the sentence naming AGENTS.md actually point the reader at it?
The matched path is blanked out before the cues are applied. It is a
filename, not prose, and leaving it in let its own characters vote: the
perfectly ordinary `docs/does/not/exist/AGENTS.md` tripped the negation
cue on the `not` path segment, so a pointer got rejected for the wrong
reason and the near-miss line then reported the wrong diagnosis.
"""
sentence, sentence_start = sentence_around(line, match.start())
rel_start = match.start() - sentence_start
rel_end = match.end() - sentence_start
probe = sentence[:rel_start] + " AGENTS.md " + sentence[rel_end:]
if NEGATION_RE.search(probe):
return False
return bool(DIRECTIVE_RE.search(probe))
def resolve(raw_path):
"""An import/pointer path resolved the way the provider would resolve it."""
p = os.path.expanduser(raw_path)
if not os.path.isabs(p):
p = os.path.join(adapter_dir, p)
return os.path.normpath(p)
def target_problem(raw_path):
"""None if `raw_path` names a real, non-empty file; else why not."""
resolved = resolve(raw_path)
if not os.path.isfile(resolved):
return f"'{raw_path}' resolves to {resolved}, which does not exist"
try:
if os.path.getsize(resolved) == 0:
return f"'{raw_path}' resolves to {resolved}, which is empty"
with open(resolved, "rb") as fh:
if not fh.read().strip():
return f"'{raw_path}' resolves to {resolved}, which is blank"
except OSError as exc:
return f"'{raw_path}' resolves to {resolved}, which cannot be read ({exc.strerror})"
return None
def names_agents_md(path):
return path == "AGENTS.md" or path.endswith("/AGENTS.md")
offsets = line_offsets(adapter_content)
lines = [line for _, line in offsets]
mask = build_inert_mask(adapter_content, offsets)
def is_inert(abs_index):
return abs_index < len(mask) and bool(mask[abs_index])
# Lines shaped like an @AGENTS.md import, whether or not the target resolves.
# Used to exclude them from the duplication denominator and from the prose
# pointer scan, both of which only care about the shape.
import_shaped_lines = set()
# (line, raw path) for every import whose target actually resolves.
live_imports = []
# Diagnostics for imports that are the right shape but resolve to nothing.
dead_imports = []
# Imports that exist only inside a fence or an HTML comment.
inert_imports = []
for start, line in offsets:
m = IMPORT_RE.match(line)
if not m or not names_agents_md(m.group("path")):
continue
at_index = start + line.index("@")
if is_inert(at_index):
inert_imports.append(line.strip())
continue
import_shaped_lines.add(line)
problem = target_problem(m.group("path"))
if problem:
dead_imports.append(problem)
else:
live_imports.append(line)
live_pointers = []
dead_pointers = []
inert_pointers = []
mention_only = []
for start, line in offsets:
if line in import_shaped_lines:
continue
for m in MENTION_RE.finditer(line):
if is_inert(start + m.start()):
inert_pointers.append(line.strip())
continue
if not reads_as_pointer(line, m):
mention_only.append(sentence_around(line, m.start())[0].strip())
continue
problem = target_problem(m.group(1))
if problem:
dead_pointers.append(problem)
else:
live_pointers.append(line)
if no_import_syntax:
has_reference = bool(pointer_lines)
has_reference = bool(live_pointers)
near_misses = dead_pointers + [f"{d} (inside a code fence or HTML comment)" for d in inert_pointers]
near_misses += [f"'{s}' names AGENTS.md but does not point at it" for s in mention_only]
else:
has_reference = bool(import_lines)
has_reference = bool(live_imports)
near_misses = dead_imports + [f"'{d}' is inside a code fence or HTML comment, where no import is resolved" for d in inert_imports]
if not has_reference:
has_fail = True
print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}")
if no_import_syntax:
print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.")
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text. The pointer has to read as a pointer and name a file that is really there — a bare or negated mention (\"we deleted AGENTS.md\") defers nothing, and neither does a mention buried in a code fence or an HTML comment.")
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\", outside any fence, naming a path that exists relative to this file.")
else:
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.")
print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.")
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own, indented no more than three spaces, and the path must resolve to a real non-empty file. Naming the file mid-sentence or inside backticks is prose this check will not credit; putting the line inside a ``` fence, an indented code block, or an HTML comment is worse, because nothing resolves it and it looks right.")
print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line at the top level of the file, or pass --no-import-syntax if this provider resolves no imports.")
for miss in near_misses:
print(f" Near miss: {miss}")
print()
# --- Duplication check ---
non_import_lines = [ln for ln in lines if not IMPORT_RE.match(ln)]
non_import_lines = [ln for ln in lines if ln not in import_shaped_lines]
adapter_lines = [ln.strip() for ln in non_import_lines if ln.strip()]
agents_lines = {ln.strip() for ln in agents_md_content.splitlines() if ln.strip()}
@@ -187,6 +491,6 @@ if non_blank_count > max_lines:
print()
if has_fail:
sys.exit(1)
sys.exit(EXIT_FAIL)
sys.exit(0)
PYTHON

View File

@@ -212,3 +212,308 @@ EOF
assert_output --partial "no reference"
assert_output --partial "line of its own"
}
# --- Q1: a reference only counts where something would resolve it -------------
@test "an @AGENTS.md inside a backtick code fence is not credited as an import" {
ADAPTER="$TMPDIR/CLAUDE.md"
cat > "$ADAPTER" <<'EOF'
# Claude notes
Put this at the top of the file:
```
@AGENTS.md
```
EOF
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "an @AGENTS.md inside a tilde code fence is not credited as an import" {
ADAPTER="$TMPDIR/CLAUDE.md"
cat > "$ADAPTER" <<'EOF'
~~~
@AGENTS.md
~~~
EOF
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "an @AGENTS.md in a four-space indented code block is not credited as an import" {
ADAPTER="$TMPDIR/CLAUDE.md"
cat > "$ADAPTER" <<'EOF'
# Claude notes
@AGENTS.md
EOF
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "an @AGENTS.md inside a multi-line HTML comment is not credited as an import" {
ADAPTER="$TMPDIR/CLAUDE.md"
cat > "$ADAPTER" <<'EOF'
# Claude notes
<!--
@AGENTS.md
-->
EOF
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "an @AGENTS.md indented up to three spaces is still credited" {
ADAPTER="$TMPDIR/CLAUDE.md"
printf ' @AGENTS.md\n' > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_success
}
# --- Q2: encodings that decode as valid UTF-8 but are not UTF-8 ---------------
@test "a BOM-less UTF-16LE adapter is an encoding error, not a missing reference" {
ADAPTER="$TMPDIR/CLAUDE.md"
python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-16-le'))" "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 2
assert_output --partial "not valid UTF-8"
refute_output --partial "no reference"
}
@test "a BOM-less UTF-32LE adapter is an encoding error, not a missing reference" {
ADAPTER="$TMPDIR/CLAUDE.md"
python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-32-le'))" "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 2
assert_output --partial "not valid UTF-8"
refute_output --partial "no reference"
}
@test "a doubled UTF-8 BOM does not hide the @import line" {
ADAPTER="$TMPDIR/CLAUDE.md"
python3 -c "import sys; open(sys.argv[1], 'wb').write(('@AGENTS.md\n').encode('utf-8'))" "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_success
}
@test "a BOM in front of a mid-file @import line does not hide it" {
ADAPTER="$TMPDIR/CLAUDE.md"
python3 -c "import sys; open(sys.argv[1], 'wb').write(('# Claude notes\n\n@AGENTS.md\n').encode('utf-8'))" "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_success
}
# --- Q3: a file that exists but cannot be read is not a FAIL -----------------
@test "an adapter that exists but cannot be read exits 3 with a diagnostic and no FAIL" {
ADAPTER="$TMPDIR/CLAUDE.md"
echo "@AGENTS.md" > "$ADAPTER"
chmod 000 "$ADAPTER"
# chmod is not enough under a uid that bypasses it (root in CI containers).
# /proc/self/mem is a regular file whose read returns EIO for every uid, so
# it exercises the same branch where chmod cannot.
if cat "$ADAPTER" >/dev/null 2>&1; then
if [ -e /proc/self/mem ]; then
ADAPTER=/proc/self/mem
else
chmod 644 "$TMPDIR/CLAUDE.md"
skip "no way to make a readable-by-stat, unreadable-by-open file here"
fi
fi
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
chmod 644 "$TMPDIR/CLAUDE.md"
assert_failure 3
assert_output --partial "could not be read"
refute_output --partial "FAIL"
}
# --- Q4: the reference has to name, and resolve to, a real AGENTS.md ---------
@test "an @import naming a path that does not exist is not credited" {
ADAPTER="$TMPDIR/CLAUDE.md"
echo "@docs/does/not/exist/AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
assert_output --partial "does not exist"
}
@test "@NOTAGENTS.md and @zzzAGENTS.md are not imports of AGENTS.md" {
# The decoys are real files, so the on-disk resolution check cannot be what
# rejects them. Only the path-segment boundary can — without the fixtures
# this test passes against a substring match and proves nothing.
cp "$AGENTS_MD" "$TMPDIR/NOTAGENTS.md"
cp "$AGENTS_MD" "$TMPDIR/zzzAGENTS.md"
ADAPTER="$TMPDIR/CLAUDE.md"
echo "@NOTAGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
echo "@zzzAGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "an @import resolving to a zero-byte AGENTS.md is not credited" {
SUB="$TMPDIR/empty"
mkdir -p "$SUB"
: > "$SUB/AGENTS.md"
ADAPTER="$SUB/CLAUDE.md"
echo "@AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
assert_output --partial "empty"
}
@test "an @import naming a real relative path to AGENTS.md is credited" {
mkdir -p "$TMPDIR/docs"
cp "$AGENTS_MD" "$TMPDIR/docs/AGENTS.md"
ADAPTER="$TMPDIR/CLAUDE.md"
echo "@docs/AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
assert_success
}
# --- Q5: --no-import-syntax needs a pointer, not a mention -------------------
@test "with --no-import-syntax, a negated mention of AGENTS.md is not a pointer" {
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
Do NOT read AGENTS.md; it is obsolete.
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "with --no-import-syntax, a past-tense mention of a deleted AGENTS.md is not a pointer" {
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
We deleted AGENTS.md last year.
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "with --no-import-syntax, a pointer inside a code fence is not credited" {
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
Example of what to write:
```
See AGENTS.md at the repo root for shared conventions.
```
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "with --no-import-syntax, a pointer inside an HTML comment is not credited" {
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
# Copilot instructions
<!-- See AGENTS.md at the repo root for shared conventions. -->
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "with --no-import-syntax, a name merely ending in AGENTS.md is not a pointer to it" {
# Real decoy files, so the on-disk resolution check cannot be what rejects
# these — only the token boundary in the mention pattern can. zzzAGENTS.md
# is the load-bearing case: dropping the boundary from NOTAGENTS.md leaves
# the fragment "NOT" behind, which the negation cue then rejects for an
# unrelated reason, so that case alone would prove nothing.
cp "$AGENTS_MD" "$TMPDIR/zzzAGENTS.md"
cp "$AGENTS_MD" "$TMPDIR/NOTAGENTS.md"
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
See zzzAGENTS.md at the repo root for shared conventions.
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
cat > "$ADAPTER" <<'EOF'
See NOTAGENTS.md at the repo root for shared conventions.
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}
@test "with --no-import-syntax, a pointer naming a path that does not exist is not credited" {
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
See docs/does/not/exist/AGENTS.md for shared conventions.
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
assert_output --partial "does not exist"
}
# --- argument handling -------------------------------------------------------
@test "a third positional argument is rejected instead of silently ignored" {
ADAPTER="$TMPDIR/CLAUDE.md"
echo "@AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" "$TMPDIR/also-not-graded.md"
assert_failure 2
assert_output --partial "exactly 2 positional arguments"
# The usage text this prints mentions the word FAIL, so refute the shape of
# a real finding line rather than the bare word.
refute_output --partial "FAIL Adapter"
}
@test "an unknown option is reported as an unknown option, not as a missing file" {
ADAPTER="$TMPDIR/CLAUDE.md"
echo "@AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" --bogus "$ADAPTER" "$AGENTS_MD"
assert_failure 2
assert_output --partial "unknown option '--bogus'"
refute_output --partial "'--bogus' is not a file"
refute_output --partial "FAIL Adapter"
}
@test "--max-lines=N is accepted in the equals form" {
ADAPTER="$TMPDIR/CLAUDE.md"
{
echo "@AGENTS.md"
for i in $(seq 1 10); do echo "Provider-specific line $i unrelated to AGENTS.md content."; done
} > "$ADAPTER"
run bash "$SCRIPT" --max-lines=5 "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "thin"
run bash "$SCRIPT" --max-lines=40 "$ADAPTER" "$AGENTS_MD"
assert_success
}
@test "with --no-import-syntax, a bare mention with no deference cue is not a pointer" {
ADAPTER="$TMPDIR/copilot-instructions.md"
cat > "$ADAPTER" <<'EOF'
# Copilot instructions
This repo also has an AGENTS.md.
Prefer inline suggestions over chat for one-line edits.
EOF
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
assert_failure 1
assert_output --partial "no reference"
}

View File

@@ -30,8 +30,8 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell
Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file:
- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it.
- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket.
- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it — nor one inside a code fence, an indented block, or an HTML comment, nor one whose path does not resolve to a real, non-empty file on disk.
- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short sentence pointing at `AGENTS.md` ("See AGENTS.md at the repo root for ..."), keep the provider-specific bucket. A bare or negated mention is not a pointer and will not be credited.
The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like.
@@ -45,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate
bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file>
```
Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter.
Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exits `2` and `3` are not `FAIL`s and nothing was graded under either, so neither is a reason to touch the adapter: `2` means the invocation or the input is wrong (a bad, missing, or extra argument, an unknown option, or a file that is not UTF-8), and `3` means a named file exists but could not be read.
## Step 4 — Report

View File

@@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg
| File | Purpose |
|------|---------|
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
Takes `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure.
Takes exactly `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored.
## What counts as a reference to AGENTS.md
Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line.
A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass.
Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer.
## Exit codes
The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these.
| Code | Meaning | What to do |
|------|---------|------------|
| `0` | Passes every check | Nothing |
| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file |
| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter |
| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter |

View File

@@ -14,6 +14,10 @@ Arguments:
adapter-file Path to the provider-specific file to check.
agents-md-file Path to the AGENTS.md file it should defer to.
Exactly two positional arguments are accepted. Extra ones are rejected
rather than ignored: a third path silently graded nothing but the first
two, so a typo'd invocation passed against the wrong file.
Options:
--no-import-syntax The target provider has no native cross-file import
mechanism. Require a plain-text pointer line naming
@@ -26,26 +30,69 @@ Options:
it's considered no longer "thin". Must be a
non-negative integer. Default: 60.
--help, -h Show this help and exit 0.
-- End of options; every later argument is positional.
Both flags also accept the --flag=value form (--max-lines=40). An unknown
option is reported as an unknown option, not as a missing file.
What counts as a reference:
In both modes the named path must be a real path segment ending in
AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must
resolve on disk, relative to the adapter file, to a non-empty file. An
adapter deferring to a path that is not there defers to nothing.
A mention inside a fenced code block, an indented code block, or an HTML
comment is not credited in either mode. Nothing resolves those, so an
adapter whose only "import" is fenced silently defers to nothing.
With --no-import-syntax the pointer must read as a pointer: the sentence
naming AGENTS.md has to carry a deference cue (see, read, refer to,
documented in, conventions, ...) and must not be a negation ("do not read
AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer.
Exit codes:
0 Adapter file passes all checks
1 One or more checks failed (empty file, no reference to AGENTS.md,
excessive duplication, or file too long)
2 Usage or input error — a bad or missing argument, a path that is not a
file, or a file that is not UTF-8. Nothing was graded, so there is no
FAIL line and no adapter edit to make: fix the invocation or the file's
encoding and re-run. Kept distinct from 1 because the skill's own
closeout tells the agent to fix every non-zero exit by editing the
provider file, which for a mistyped flag edits the wrong file forever.
2 Usage or input error — a bad, missing, or extra argument, an unknown
option, a path that is not a file, or a file that is not UTF-8. Nothing
was graded, so there is no FAIL line and no adapter edit to make: fix
the invocation or the file's encoding and re-run. Kept distinct from 1
because the skill's own closeout tells the agent to fix every non-zero
exit by editing the provider file, which for a mistyped flag edits the
wrong file forever.
3 A named input file exists but could not be read (permissions, a
directory swapped in mid-run, I/O error). Also not a FAIL: nothing was
graded and the adapter's contents are unknown, so editing it is
guesswork. Fix the file's readability and re-run.
EOF
}
NO_IMPORT_SYNTAX=0
MAX_LINES=60
ARGS=()
END_OF_OPTS=0
require_int() {
# $1 = the value to validate
if [[ ! "$1" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2
exit 2
fi
}
while [[ $# -gt 0 ]]; do
if [[ $END_OF_OPTS -eq 1 ]]; then
ARGS+=("$1")
shift
continue
fi
case "$1" in
--)
END_OF_OPTS=1
shift
;;
--help|-h)
usage
exit 0
@@ -54,17 +101,37 @@ while [[ $# -gt 0 ]]; do
NO_IMPORT_SYNTAX=1
shift
;;
--no-import-syntax=*)
echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2
exit 2
;;
--max-lines)
if [[ $# -lt 2 ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 2
fi
MAX_LINES="$2"
if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2
require_int "$MAX_LINES"
shift 2
;;
--max-lines=*)
MAX_LINES="${1#--max-lines=}"
if [[ -z "$MAX_LINES" ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 2
fi
shift 2
require_int "$MAX_LINES"
shift
;;
-*)
# Reported as an unknown option rather than falling through to the
# positional bucket, where it used to surface as "'--bogus' is not a
# file" — the right exit code attached to a diagnostic that sends the
# reader looking for a path they never typed.
echo "Error: unknown option '$1'." >&2
echo "" >&2
usage >&2
exit 2
;;
*)
ARGS+=("$1")
@@ -80,6 +147,13 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then
exit 2
fi
if [[ ${#ARGS[@]} -gt 2 ]]; then
echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2
echo "" >&2
usage >&2
exit 2
fi
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON'
import sys
import os
@@ -89,45 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5]
no_import_syntax = no_import_syntax == "1"
max_lines = int(max_lines)
EXIT_FAIL = 1
EXIT_USAGE = 2
EXIT_UNREADABLE = 3
if not os.path.isfile(adapter_path):
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr)
sys.exit(2)
sys.exit(EXIT_USAGE)
if not os.path.isfile(agents_md_path):
print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr)
sys.exit(2)
sys.exit(EXIT_USAGE)
def read_text(path):
r"""File contents as text, UTF-8, BOM stripped.
r"""File contents as text, UTF-8, every BOM stripped.
The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is
not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had
its first line — the `@AGENTS.md` import, which is the whole adapter —
silently treated as prose. The check then said "no reference to AGENTS.md"
told the author to add the line already sitting in front of them. Same
class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it
at the reader so no later check has to know about it.
The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM
is not whitespace in Python, so a CLAUDE.md saved by an editor that emits
one had its first line — the `@AGENTS.md` import, which is the whole
adapter — silently treated as prose. The check then said "no reference to
AGENTS.md" and told the author to add the line already sitting in front of
them. Same class of silent BOM miss recorded in scripts/skill-size-check.sh;
strip it at the reader so no later check has to know about it.
Every U+FEFF goes, not just one at offset 0. Stripping exactly the first
one left the mirror-image false FAIL for a doubled BOM (two concatenated
files, or a tool that re-adds one) and for a BOM mid-file at the head of
the import line. U+FEFF has no meaning as a character in a markdown
instruction file, so removing all of them cannot lose signal.
Decoding is strict, not errors="replace". Replacement mangles the file and
the checks then grade the mangling: a UTF-16 adapter whose first line is
`@AGENTS.md` decoded to interleaved NULs and failed as "no reference",
which is a true FAIL for a false reason and points the fix at the wrong
thing. A file this gate cannot read gets an encoding diagnostic and exit 2,
thing. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and
UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they
decoded clean and produced exactly that false diagnosis anyway. The NUL
byte is the complete signal and is checked first: no plausible markdown
adapter contains one, and every UTF-16/32 encoding of ASCII is full of
them. A file this gate cannot read gets an encoding diagnostic and exit 2,
the same policy the ADR-0020 validators' read_text() uses.
A file that exists but cannot be read at all is neither a pass nor a FAIL —
nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's
closeout into "fix the FAIL by editing the provider file", which for a file
it cannot open is an instruction to edit blind.
"""
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
with open(path, "rb") as fh:
raw = fh.read()
except OSError as exc:
print(f"Error: '{path}' exists but could not be read ({exc.strerror}). "
"Nothing was checked — fix whatever is blocking the read "
"(permissions, ownership, the underlying device) and re-run; do "
"not edit the adapter on the strength of this.", file=sys.stderr)
sys.exit(EXIT_UNREADABLE)
if b"\x00" in raw:
print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so "
"it is almost certainly UTF-16 or UTF-32 (with or without a BOM). "
"Re-save it as UTF-8; this check does not guess at other "
"encodings.", file=sys.stderr)
sys.exit(EXIT_USAGE)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte "
f"{exc.start}) — re-save it as UTF-8; this check does not guess "
"at other encodings.", file=sys.stderr)
sys.exit(2)
return text[1:] if text.startswith("\ufeff") else text
sys.exit(EXIT_USAGE)
return text.replace("\ufeff", "")
adapter_content = read_text(adapter_path)
agents_md_content = read_text(agents_md_path)
adapter_dir = os.path.dirname(os.path.abspath(adapter_path))
has_fail = False
@@ -136,34 +245,229 @@ if not adapter_content.strip():
print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.")
print(" Fix: Add at least an import (or text pointer) to AGENTS.md.")
print()
sys.exit(1)
sys.exit(EXIT_FAIL)
IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$')
lines = adapter_content.splitlines()
import_lines = [ln for ln in lines if IMPORT_RE.match(ln)]
# A prose pointer is any line naming AGENTS.md that is not itself an import
# line — an inert `@AGENTS.md` in a provider that resolves no imports points
# a reader at nothing.
pointer_lines = [ln for ln in lines if not IMPORT_RE.match(ln) and "AGENTS.md" in ln]
# --- Inert regions -----------------------------------------------------------
#
# A reference only counts where something would actually resolve it. Fenced
# code blocks, indented code blocks and HTML comments are shown to the reader
# (or hidden from them) as literal text; Claude Code resolves an @import in
# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact
# copy-the-example-into-the-file mistake this gate exists to catch — exited 0
# with the adapter deferring to nothing.
#
# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the
# mask: four leading spaces is what opens an indented code block in CommonMark,
# so an import has to sit within three. The mask deliberately does not apply
# that rule to prose pointers, where four-space indentation is ordinary list
# continuation rather than code.
FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$')
COMMENT_RE = re.compile(r'<!--.*?(?:-->|\Z)', re.DOTALL)
def line_offsets(text):
"""[(char offset, line without its terminator)] over `text`."""
out = []
off = 0
for raw in text.splitlines(keepends=True):
out.append((off, raw.rstrip("\r\n")))
off += len(raw)
return out
def build_inert_mask(text, offsets):
"""Per-character flags: 1 where a reference would never be resolved."""
mask = bytearray(len(text))
fence = None # (fence char, opening run length)
for start, line in offsets:
m = FENCE_RE.match(line)
if fence is None:
if m:
fence = (m.group(2)[0], len(m.group(2)))
for i in range(start, start + len(line)):
mask[i] = 1
continue
for i in range(start, start + len(line)):
mask[i] = 1
if (m and m.group(2)[0] == fence[0]
and len(m.group(2)) >= fence[1]
and not m.group(3).strip()):
fence = None
for m in COMMENT_RE.finditer(text):
if m.start() < len(mask) and mask[m.start()]:
continue # a literal "<!--" printed inside a fence opens nothing
for i in range(m.start(), min(m.end(), len(mask))):
mask[i] = 1
return mask
# --- Reference shapes --------------------------------------------------------
#
# `\S*AGENTS\.md` had no path-separator boundary, so `@NOTAGENTS.md` and
# `@zzzAGENTS.md` counted as imports of AGENTS.md. The matched path must end in
# AGENTS.md as a whole segment.
IMPORT_RE = re.compile(r'^ {0,3}@(?P<path>\S+?)\s*$')
# A mention in prose: an optional relative path, then AGENTS.md, with no
# identifier character glued to the front (so NOTAGENTS.md does not match) and
# nothing glued to the back.
MENTION_RE = re.compile(r'(?<![0-9A-Za-z_.\-/])((?:[\w.\-~]+/)*AGENTS\.md)(?![0-9A-Za-z])')
# A pointer has to read as a pointer. `"AGENTS.md" in ln` passed
# "Do NOT read AGENTS.md; it is obsolete." and "We deleted AGENTS.md last
# year." — both of which point the reader away from the file. Require a
# deference cue in the naming sentence, and reject a negated one.
DIRECTIVE_RE = re.compile(
r'\b(see|read|refer|refers|referring|consult|consults|follow|follows|'
r'defer|defers|deferring|described|documented|documents|covered|covers|'
r'found|listed|specified|defined|governed|per|use|uses|using|apply|obey|'
r'start|check|live|lives|contains|holds|carries|inherit|inherits|import|'
r'imports|conventions|instructions|guidelines|guidance|rules|standards|'
r'reference|setup)\b', re.I)
NEGATION_RE = re.compile(
r"(\bnot\b|n't\b|\bnever\b|\bno longer\b|\bdeleted\b|\bremoved\b|"
r"\bobsolete\b|\bdeprecated\b|\bignore\b|\bignores\b|\bignoring\b|"
r"\bdisregard\b|\bsuperseded\b|\bgone\b|\bunused\b|\bstale\b)", re.I)
SENTENCE_SPLIT_RE = re.compile(r'(?<=[.;:!?])\s+')
def sentence_around(line, index):
"""(sentence of `line` containing character `index`, its start offset)."""
bounds = [0]
for m in SENTENCE_SPLIT_RE.finditer(line):
bounds.append(m.end())
bounds.append(len(line) + 1)
for i in range(len(bounds) - 1):
if bounds[i] <= index < bounds[i + 1]:
return line[bounds[i]:bounds[i + 1]], bounds[i]
return line, 0
def reads_as_pointer(line, match):
"""Does the sentence naming AGENTS.md actually point the reader at it?
The matched path is blanked out before the cues are applied. It is a
filename, not prose, and leaving it in let its own characters vote: the
perfectly ordinary `docs/does/not/exist/AGENTS.md` tripped the negation
cue on the `not` path segment, so a pointer got rejected for the wrong
reason and the near-miss line then reported the wrong diagnosis.
"""
sentence, sentence_start = sentence_around(line, match.start())
rel_start = match.start() - sentence_start
rel_end = match.end() - sentence_start
probe = sentence[:rel_start] + " AGENTS.md " + sentence[rel_end:]
if NEGATION_RE.search(probe):
return False
return bool(DIRECTIVE_RE.search(probe))
def resolve(raw_path):
"""An import/pointer path resolved the way the provider would resolve it."""
p = os.path.expanduser(raw_path)
if not os.path.isabs(p):
p = os.path.join(adapter_dir, p)
return os.path.normpath(p)
def target_problem(raw_path):
"""None if `raw_path` names a real, non-empty file; else why not."""
resolved = resolve(raw_path)
if not os.path.isfile(resolved):
return f"'{raw_path}' resolves to {resolved}, which does not exist"
try:
if os.path.getsize(resolved) == 0:
return f"'{raw_path}' resolves to {resolved}, which is empty"
with open(resolved, "rb") as fh:
if not fh.read().strip():
return f"'{raw_path}' resolves to {resolved}, which is blank"
except OSError as exc:
return f"'{raw_path}' resolves to {resolved}, which cannot be read ({exc.strerror})"
return None
def names_agents_md(path):
return path == "AGENTS.md" or path.endswith("/AGENTS.md")
offsets = line_offsets(adapter_content)
lines = [line for _, line in offsets]
mask = build_inert_mask(adapter_content, offsets)
def is_inert(abs_index):
return abs_index < len(mask) and bool(mask[abs_index])
# Lines shaped like an @AGENTS.md import, whether or not the target resolves.
# Used to exclude them from the duplication denominator and from the prose
# pointer scan, both of which only care about the shape.
import_shaped_lines = set()
# (line, raw path) for every import whose target actually resolves.
live_imports = []
# Diagnostics for imports that are the right shape but resolve to nothing.
dead_imports = []
# Imports that exist only inside a fence or an HTML comment.
inert_imports = []
for start, line in offsets:
m = IMPORT_RE.match(line)
if not m or not names_agents_md(m.group("path")):
continue
at_index = start + line.index("@")
if is_inert(at_index):
inert_imports.append(line.strip())
continue
import_shaped_lines.add(line)
problem = target_problem(m.group("path"))
if problem:
dead_imports.append(problem)
else:
live_imports.append(line)
live_pointers = []
dead_pointers = []
inert_pointers = []
mention_only = []
for start, line in offsets:
if line in import_shaped_lines:
continue
for m in MENTION_RE.finditer(line):
if is_inert(start + m.start()):
inert_pointers.append(line.strip())
continue
if not reads_as_pointer(line, m):
mention_only.append(sentence_around(line, m.start())[0].strip())
continue
problem = target_problem(m.group(1))
if problem:
dead_pointers.append(problem)
else:
live_pointers.append(line)
if no_import_syntax:
has_reference = bool(pointer_lines)
has_reference = bool(live_pointers)
near_misses = dead_pointers + [f"{d} (inside a code fence or HTML comment)" for d in inert_pointers]
near_misses += [f"'{s}' names AGENTS.md but does not point at it" for s in mention_only]
else:
has_reference = bool(import_lines)
has_reference = bool(live_imports)
near_misses = dead_imports + [f"'{d}' is inside a code fence or HTML comment, where no import is resolved" for d in inert_imports]
if not has_reference:
has_fail = True
print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}")
if no_import_syntax:
print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.")
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text. The pointer has to read as a pointer and name a file that is really there — a bare or negated mention (\"we deleted AGENTS.md\") defers nothing, and neither does a mention buried in a code fence or an HTML comment.")
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\", outside any fence, naming a path that exists relative to this file.")
else:
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.")
print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.")
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own, indented no more than three spaces, and the path must resolve to a real non-empty file. Naming the file mid-sentence or inside backticks is prose this check will not credit; putting the line inside a ``` fence, an indented code block, or an HTML comment is worse, because nothing resolves it and it looks right.")
print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line at the top level of the file, or pass --no-import-syntax if this provider resolves no imports.")
for miss in near_misses:
print(f" Near miss: {miss}")
print()
# --- Duplication check ---
non_import_lines = [ln for ln in lines if not IMPORT_RE.match(ln)]
non_import_lines = [ln for ln in lines if ln not in import_shaped_lines]
adapter_lines = [ln.strip() for ln in non_import_lines if ln.strip()]
agents_lines = {ln.strip() for ln in agents_md_content.splitlines() if ln.strip()}
@@ -187,6 +491,6 @@ if non_blank_count > max_lines:
print()
if has_fail:
sys.exit(1)
sys.exit(EXIT_FAIL)
sys.exit(0)
PYTHON