Files
holocron/plugins/kyberforge/skills/agent-audit/scripts/validate.sh
Defame1297 9fe734573d fix(gates): close the /name fail-open and stop the path guard inventing targets
Two defects in the routing-target resolver, both latent in the corpus but hot for
anything written next.

The free-standing `/name` sweep sat inside `if boundary:`, so route notation in a
sentence carrying no boundary marker was never extracted at all — not an ERROR, not
a SUGGESTION, not an INFO. That contradicted ADR-0020's amendment and gates.md,
which both promise `/name` blocks unconditionally. The sweep now runs over every
sentence. `-> name` and backticked forms stay gated deliberately: an arrow also
writes a process chain and a code span cites tools, files and skills alike, so
ungating either fires on ordinary prose.

The path guard used `\b`, which still holds after a hyphen, so the engine
backtracked to a shorter hyphen-terminated prefix whenever the lookahead rejected
the full segment. `/api-docs/v2.md` in a boundary clause raised blocking ERRORs for
'api' and 'api-docs' — names no author wrote, with no corroboration escape.
`(?![\w-])` forbids the shortened prefix outright; MARKED_TARGET, which had no
trailing guard at all, gained one.

Zero arguments now exits 2 rather than 0, so a mis-scoped `files:` pattern is no
longer indistinguishable from a clean corpus. Both hook manifests pass filenames
and pre-commit skips a filename-passing hook when nothing matches, so the hook
never sees an empty argv — that contract is now asserted by a test rather than
left in prose.

Deleting the sweep entirely used to leave every suite green. It now kills eight
assertions. The suite also gains its first slash-path and URL fixtures, in both
directions.

Refs: #107, #110, #124
ADR: 0020
2026-09-01 12:37:12 +00:00

1739 lines
87 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: validate.sh <agent-file>
Validate an agent definition file against the agent definition spec.
At plugin/APM scope, <agent-file> is a single vendor-neutral
.apm/agents/<name>.agent.md file with no counterpart. Its frontmatter allowlist
is not restated here: it is read at load time from the apm-agent-allowlist
section of references/field-inventory.md, which is the authoritative list.
At project or user scope, <agent-file> is either half of a Claude Code .md /
Copilot .agent.md pair.
Arguments:
agent-file Path to the agent file (or either half of a project/user-scope pair).
Exit codes:
0 All checks passed (may include SUGGESTIONs)
1 One or more checks failed
2 Script error (unrecognized file extension or missing field-inventory.md)
EOF
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
if [[ $# -lt 1 ]]; then
echo "Error: agent-file is required." >&2
echo "" >&2
usage >&2
exit 1
fi
# PyYAML is a HARD dependency, not a nice-to-have. The description VALUE has to
# be measured after YAML folding is resolved, and the hand-rolled reader that
# used to stand in for PyYAML disagreed with it across the 400-character FAIL
# boundary — same description, two verdicts, depending on which reader ran.
# Refusing to start is the only honest option; the repo's jq / apm / vale
# dependencies are declared the same way.
# Check the interpreter separately from the library: `python3 -c` fails the same
# way whether python3 is missing or PyYAML is, and reporting the wrong missing
# dependency sends the reader to install the wrong thing.
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 ADR-0020 description and boundary-target gates 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 1
fi
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
echo "Error: PyYAML is required but is not importable by python3." >&2
echo " Why: skipping the ADR-0020 description and boundary-target gates would be a vacuous pass." >&2
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 -u - "$1" "$SCRIPT_DIR" <<'PYTHON'
import sys
import os
import re
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
agent_file = os.path.abspath(sys.argv[1])
script_dir = sys.argv[2]
fname = os.path.basename(agent_file)
# --- Detect provider (check .agent.md before .md) ---
if fname.endswith('.agent.md'):
provider = 'copilot'
name_stem = fname[:-len('.agent.md')]
elif fname.endswith('.md'):
provider = 'claude-code'
name_stem = fname[:-len('.md')]
else:
print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr)
sys.exit(2)
# --- Load field-inventory.md ---
inv_path = os.path.normpath(os.path.join(script_dir, '..', 'references', 'field-inventory.md'))
if not os.path.isfile(inv_path):
print(f"Error: field-inventory.md not found at {inv_path}", file=sys.stderr)
sys.exit(2)
# Encoding is pinned to UTF-8 rather than inherited from the locale: under
# LC_ALL=C the inherited default is ASCII, and this file legitimately carries
# non-ASCII prose. read_text() in the shared resolver block below does the same
# thing for every other file; this one is read before that block is defined.
try:
with open(inv_path, encoding='utf-8') as f:
inv_content = f.read()
except UnicodeDecodeError as exc:
print(f"Error: field-inventory.md at {inv_path} is not valid UTF-8 "
f"({exc.reason} at byte {exc.start}) — re-save it as UTF-8.",
file=sys.stderr)
sys.exit(2)
def parse_section_tokens(content, section_name):
lines = content.splitlines()
for i, line in enumerate(lines):
if line.strip() == f'## {section_name}':
for j in range(i + 1, len(lines)):
stripped = lines[j].strip()
if stripped and not stripped.startswith('#') and not stripped.startswith('---'):
return set(stripped.split())
return set()
cc_only_fields = parse_section_tokens(inv_content, 'claude-code-only-fields')
copilot_only_fields = parse_section_tokens(inv_content, 'copilot-only-fields')
apm_agent_allowlist = parse_section_tokens(inv_content, 'apm-agent-allowlist')
# Tools the runtime withholds from subagents regardless of the tools field
SUBAGENT_UNAVAILABLE_TOOLS = {
'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'ScheduleWakeup', 'WaitForMcpServers',
}
# Copilot body length limit (chars) — content beyond this is silently truncated
COPILOT_BODY_LIMIT = 30000
# ADR-0020 description budget. An agent's name + description is preloaded into
# every session exactly like a skill's, so agents take the SAME description
# gates. These two constants are DUPLICATED from scripts/skill-size-check.sh
# and skill-audit/scripts/validate.sh rather than shared from one file: a
# cache-installed plugin's scripts cannot read files outside their own plugin
# directory, so there is no single source to share (same rationale as
# vale-wrap.sh's per-plugin duplication). tests/test-skill-size-check.sh
# asserts all copies agree, so drift fails CI rather than silently diverging.
#
# Agents deliberately take NO body word gate, and adding one here would
# contradict ADR-0020: a skill body is loaded into the caller's context and
# competes with the live conversation, while an agent body becomes the system
# prompt of a fresh context. The rationale for the 900-word skill ceiling does
# not transfer. Agent body length falls out of the delegation rule instead.
DESC_SUGGEST_CHARS = 250
DESC_MAX_CHARS = 400
# --- Helpers (shared by every scope) ---
failed = False
suggestions = []
def fail(msg):
# stderr, matching scripts/skill-size-check.sh's ERROR routing. All three
# scripts in the ADR-0020 family now agree: findings that fail the run go to
# stderr, everything advisory (SUGGESTION / INFO) goes to stdout. Both repo
# callers (check-apm-agents-valid.sh, check-scope-walkup-sync.sh) capture
# `2>&1`, so nothing a human reads moves.
global failed
failed = True
print(f"FAIL {msg}", file=sys.stderr)
def suggest(msg):
suggestions.append(msg)
def info(msg):
# A check that DECLINED to run says so out loud, rather than passing
# silently. Silence is what let a whole gate family go missing unnoticed.
print(f"INFO {msg}")
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER =====
# ONE resolver, embedded VERBATIM in three scripts:
# scripts/skill-size-check.sh
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh
# The block between these markers must stay byte-identical in all three. 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 all
# three can share (same constraint that forces the ADR-0020 constants to be
# duplicated). Edit one copy, then paste it over the other two.
#
# Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard
# dependency, preflighted in bash before the interpreter starts).
# --- Input ----------------------------------------------------------------
# Every file this resolver's callers read goes through read_text(), which pins
# UTF-8 explicitly instead of inheriting locale.getpreferredencoding(). Under
# LC_ALL=C that inherited encoding is ASCII, so a perfectly ordinary em dash in
# a SKILL.md aborted the run with a bare UnicodeDecodeError traceback — loud,
# but pointing at the interpreter rather than at the file or the fix. A file
# that genuinely is not UTF-8 still fails; it just says so.
class EncodingError(Exception):
pass
def read_text(path):
"""File contents as text, UTF-8, with a diagnostic instead of a traceback."""
try:
with open(path, encoding='utf-8') as fh:
return 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))
# --- Universe ------------------------------------------------------------
# The set of names a boundary clause may resolve against is derived from an
# AUTHORING ROOT found by walking up FROM THE TARGET FILE. It is NEVER derived
# from this script's own location: deriving it from ${BASH_SOURCE} leaked
# holocron's 39-skill universe into every consumer repo that ran this hook
# through pre-commit, so a consumer skill routing to `skill-audit` resolved
# against a plugin it had never installed.
#
# An authoring root is the nearest ancestor holding plugins/*/.apm/skills/ or
# plugins/*/.apm/agents/ (a plugin monorepo), falling back to the nearest
# ancestor holding .git. When one is found the universe is:
# 1. every skill and agent under <root>/plugins/*/ — sibling plugins resolve,
# which is what a monorepo means,
# 2. the target's own apm package,
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
# root came from the plugins/ probe. They are `apm install` output, gitignored,
# and present only on a machine that has run it: four cross-plugin targets in
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
# cannot give two answers.
#
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
# landed on a bare .git ancestor or on nothing at all. That is the consumer
# case: the file being checked lives in or beside a deployed tree, inside an
# ordinary git repo, with no monorepo to read. The two cases are told apart by
# which probe matched, never by how many names a root contributed; see
# known_targets().
def _is_fs_root(path):
return os.path.dirname(path) == path
def _collect_package(pkg_dir, names):
"""Add every skill/agent name a package directory exposes, any layout."""
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
# whole pattern into a character class that matches nothing, and the
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)):
# 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')]
else:
base = base[:-len('.md')]
names.add(base.lower())
def _apm_package_root(start_dir):
"""Nearest ancestor that is an apm package root (apm.yml or .apm/).
The filesystem root is never a candidate: a stray /.apm/skills/ — a
scaffolding test's leftover, say, and one really does exist on at least one
machine here — would otherwise become the package root of every path on it.
Capped at ten levels so a pathological path can't become a filesystem
crawl; that covers every real layout by a wide margin.
"""
current = os.path.abspath(start_dir)
for _ in range(10):
if _is_fs_root(current):
return None
if (os.path.isfile(os.path.join(current, 'apm.yml'))
or os.path.isdir(os.path.join(current, '.apm'))):
return current
current = os.path.dirname(current)
return None
def _authoring_root(start_dir):
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
Returns (root, matched_plugins_probe). The flag reports WHICH probe
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
.git fallback and for no match at all. known_targets() needs that
distinction — only a real plugins/ root makes the deployed trees
redundant, and a name-count delta cannot tell the two apart.
Two passes, not one interleaved walk: a nested .git (a submodule, a
worktree of a sub-package) must not win over a real plugins/ root further
up. Both passes stop before the filesystem root for the same reason
_apm_package_root does.
"""
probes = (
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
lambda d: os.path.exists(os.path.join(d, '.git')))
for index, probe in enumerate(probes):
current = os.path.abspath(start_dir)
for _ in range(12):
if _is_fs_root(current):
break
if probe(current):
return current, index == 0
current = os.path.dirname(current)
return None, False
def _collect_authoring_root(root, names):
"""Every plugin in the monorepo contributes its names."""
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
if os.path.isdir(pkg):
_collect_package(pkg, names)
def _declared_dependency_dirs(pkg_dir):
"""Directories of the apm packages pkg_dir's manifest DECLARES.
Reads dependencies.apm and resolves each entry to a directory on disk:
a monorepo-relative `path:` (against the package root and the nearest
ancestor manifest, which is the monorepo root) or an installed
apm_modules/<name>/. Entries that resolve to nothing are skipped — an
undeployed dependency contributes no names rather than an error.
"""
manifest = os.path.join(pkg_dir, 'apm.yml')
if not os.path.isfile(manifest):
return []
try:
data = yaml.safe_load(read_text(manifest)) or {}
except Exception:
return []
if not isinstance(data, dict):
return []
deps = data.get('dependencies')
deps = deps.get('apm') if isinstance(deps, dict) else None
if not isinstance(deps, list):
return []
roots = [pkg_dir]
ancestor = os.path.dirname(os.path.abspath(pkg_dir))
for _ in range(10):
if _is_fs_root(ancestor):
break
if os.path.isfile(os.path.join(ancestor, 'apm.yml')):
roots.append(ancestor)
break
ancestor = os.path.dirname(ancestor)
found = []
for entry in deps:
candidates = []
if isinstance(entry, dict):
rel = entry.get('path')
name = entry.get('name')
if not name and rel:
name = os.path.basename(str(rel).rstrip('/'))
if rel:
candidates.extend(os.path.join(r, str(rel)) for r in roots)
if name:
candidates.append(os.path.join(pkg_dir, 'apm_modules', str(name)))
elif isinstance(entry, str):
name = re.split(r'[#@]', entry)[0].strip().rstrip('/').split('/')[-1]
if name:
candidates.append(os.path.join(pkg_dir, 'apm_modules', name))
candidates.extend(os.path.join(r, 'plugins', name) for r in roots)
for candidate in candidates:
if os.path.isdir(candidate):
found.append(candidate)
return found
def _deployed_roots(start_dir):
""".claude/ and .agents/ trees above start_dir — what a host really sees.
Consulted ONLY when no plugin monorepo root was found; see the header. The
filesystem root is skipped for the same reason _apm_package_root skips it:
a stray /.claude/skills/ must not join every path's universe.
"""
found = []
current = os.path.abspath(start_dir)
for _ in range(10):
if _is_fs_root(current):
break
for name in ('.claude', '.agents'):
base = os.path.join(current, name)
if os.path.isdir(base):
found.append(base)
current = os.path.dirname(current)
return found
def known_targets(start_dir):
"""Every skill/agent name a boundary clause in start_dir may name."""
names = set()
start = os.path.abspath(start_dir)
# Siblings: a cache-installed plugin and a deployed .claude/skills/ tree
# both put peers one level up, with no plugins/ directory above them. The
# grandparent is guarded against the filesystem root exactly like the two
# walk-up loops above — for a start dir of /skills/<x> the grandparent is
# `/`, and collecting there picks up this machine's stray /.apm/skills/.
parent = os.path.dirname(start)
grandparent = os.path.dirname(parent)
if (os.path.basename(parent) in ('skills', 'agents')
and os.path.isdir(parent) and not _is_fs_root(grandparent)):
_collect_package(grandparent, names)
package = _apm_package_root(start)
if package:
_collect_package(package, names)
for dep_dir in _declared_dependency_dirs(package):
_collect_package(dep_dir, names)
# A .git ancestor is an authoring root only if it actually holds plugins.
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
# git repo; without the distinction that fallback wins in every consumer
# checkout, _collect_authoring_root() contributes nothing, and the deployed
# branch below is dead code in the exact case it exists for. So condition
# on WHICH probe matched, which _authoring_root() reports directly. A
# name-count delta looks equivalent and is not: _collect_authoring_root()
# re-collects the checked file's own plugin, whose names the blocks above
# already added, so a one-plugin monorepo shows a delta of zero and would
# wrongly reach for the deployed trees — including the user's global
# ~/.claude/skills, making the verdict depend on what happens to be
# installed (ADR-0020 lines 118-127).
root, root_has_plugins = _authoring_root(start)
if root:
_collect_authoring_root(root, names)
if not root_has_plugins:
for base in _deployed_roots(start):
_collect_package(base, names)
return names
# --- Extraction -----------------------------------------------------------
# False positives are the design constraint here, not recall. The rules:
# * A BARE target must be hyphenated AND sit in a boundary sentence (one
# carrying "do not"/"instead"/"rather than"/"not for"). Without the second
# 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>`. 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
# corpus: 54 targets before and after); DELETING it costs 7 real targets
# across three gitea skills, so it is gated, not removed.
# * SINGLE-WORD targets are deliberately NOT matchable bare — `research`,
# `triage`, `forge`, `prototype` and `tdd` are all real skill names and all
# ordinary English, so a bare-word rule would flag most of the corpus. A
# single-word target must be written `` `forge` `` or /forge to be seen.
# That is a known recall limitation, accepted over the false positives.
# Tool names (Read/Write/Edit) are excluded by the lowercase-only pattern; MCP
# tool names (issue_write) by its rejection of underscores; file names by its
# rejection of dots and slashes.
#
# ATTRIBUTIVE USE. The boundary-sentence gate above does NOT solve the
# `pre-commit` false positive, and the comment that claimed it did was wrong:
# "instead", "rather than", "do not" and "not for" are exactly the words a
# boundary clause uses, so the gate is open precisely where the risk is. All of
# these were hard dangling FAILs with no suppression:
# Use pre-commit hooks instead of ad-hoc scripts.
# Invoke the pull-request template instead of writing one by hand.
# Use conventional-commits formatting rather than free-form messages.
# Composes label-resolution logic instead of duplicating it.
# Do not use for X — run the `pre-push` hooks instead.
# What separates every one of them from a real route is grammar, not marking:
# the hyphenated token is a compound MODIFIER of the noun that follows it
# ("pre-commit hooks", "pull-request template"), where a route target is
# terminal — followed by punctuation, a conjunction, or a boundary word. So a
# target whose next token is an ordinary lowercase noun is CONFIRM-ONLY: it
# still resolves and still counts as a route when the name exists, but it can
# never raise a dangling error on its own.
#
# This is deliberately NOT the simpler "only marked targets may dangle" rule,
# which would have been wrong here: BOTH live true positives in this corpus are
# BARE — research's "(use neuledge-context)" and gitea-issues' "Composes
# gitea-labels-\n milestones", where the `>` fold yields "gitea-labels-
# milestones" and the trailing hyphen is what keeps it terminal. Marking is a
# poor proxy, so the follower token is the signal, and it is applied to
# backticked targets too.
#
# TERMINAL IS NOT ENOUGH — IN-SENTENCE CORROBORATION. The follower test clears
# `pre-push` in the example above only because that example happens to be
# followed by the noun "hooks". Move the same token into terminal position and
# it was a hard FAIL again, with no suppression mechanism anywhere in this gate:
# Do not use for running hooks — run `pre-commit` instead.
# Do not use for the commit message — see `commit-msg`.
# Do not use for type errors — run `type-check` first.
# Instead, use `semantic-release`.
# Do not use for the old flow — use the clean-up instead.
# Do not run end-to-end, run unit-tests.
# Every one of those is grammatically identical to a genuinely broken route:
# "route verb + hyphenated name + terminal" is also exactly how prose cites a
# tool, a hook, a file format or an English compound. Nothing local separates
# them, and the skills most exposed are the ones this contract sends authors
# back to rewrite first — pc-run, pc-author, vale-run, vale-config and the apm-*
# family are all ABOUT hyphenated tools.
#
# So the confidence to BLOCK a commit comes from the sentence, not the token: a
# prose-form target may raise a hard error only when its own sentence names at
# least one OTHER target that RESOLVES. A routing sentence proves itself by
# routing somewhere real; a lone unresolvable name proves nothing. That is not a
# rule fitted to the fixtures — it is the shape of both live true positives,
# which sit beside `write-docs` and `gitea-labels-milestones` respectively, and
# it changes this corpus's verdict by exactly nothing.
#
# An uncorroborated unresolvable target is NOT discarded: every caller reports
# it at its SUGGESTION tier, naming the target. The finding stays visible on
# every run; only the power to block a commit is withdrawn, which is the part
# that had no escape hatch.
#
# EXPLICIT ROUTE NOTATION is exempt from corroboration and always blocks:
# ADR-0020's compressed arrow (`Not <thing> -> <name>`) and Claude Code's
# invocation form (`/<name>`). Neither is ever how English cites a tool — nobody
# writes `-> pre-commit` or `/pre-commit` to mean the hook — so there is no
# 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.
NS = r"(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?"
NAME_ANY = NS + r"[a-z0-9]+(?:-[a-z0-9]+)*"
NAME_HYPH = NS + r"[a-z0-9]+(?:-[a-z0-9]+)+"
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)(?![\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)
# re.I on ALL of them, uniformly. The patterns are built from the same
# lowercase NAME_* fragments, so half of them carrying the flag and half not
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
# docstring, which exists precisely because extraction is case-insensitive and
# the universe is not.
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)
# 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%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"
# rule, in OPPOSITE directions:
# OVER-SPLIT. `e.g. "set up the manifest"` ends no sentence, but the quote
# looks like one starting. The clause is cut in half, the corroborating
# target lands on the far side of the cut, and a genuinely dangling target
# silently demotes to SUGGESTION — the gate takes a measurement and then
# throws it away, which is the vacuous-green shape this file exists to stop.
# UNDER-SPLIT. A real sentence opening with a code span or a lowercase skill
# name ("... Composes it. `gitea-prs` also uses it.") is not seen as a start
# at all, so two sentences merge and a resolving target vouches for an
# unresolvable one it never stood beside — a hard FAIL with no escape hatch,
# which is exactly the failure the corroboration rule was added to prevent.
# Both are closed here: the five abbreviations that actually occur in routing
# prose are excluded as sentence ends, and the opener class admits a backtick or
# 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`"“(])', 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
# head noun that names the artifact itself ("the git-workflow skill"). Anything
# else — `hooks`, `template`, `formatting`, `logic` — is attributive prose.
FOLLOWER = re.compile(r"[`\s]*([a-z][a-z0-9]*)")
FOLLOWER_OK = frozenset("""
and or nor but for to when if unless while after before with from in on at by
of as than then instead rather directly first only always never also even
both either neither so because since per via plus alone here there this that
these those it its they them is are was were be been being has have had will
would can could should must may might does do did
skill skills agent agents plugin plugins command commands
""".split())
def normalize_target(target):
"""Comparison key: namespace stripped, lowercased.
Extraction is case-insensitive (re.I) but the universe is built from
lowercase directory names, so `Git-Commits` at the start of a sentence
resolved to nothing until this normalization existed.
"""
return target.split(':')[-1].lower()
def has_boundary_clause(description):
return bool(BOUNDARY_MARKER.search(description)
or BOUNDARY_ARROW.search(description))
def _first(match):
"""(name, start, end) offsets for the first group that matched."""
for index in range(1, (match.re.groups or 0) + 1):
if match.group(index):
return match.group(index), match.start(index), match.end(index)
return None, None, None
def _terminal(text, pos):
"""True if the token at pos does not make the preceding name a modifier."""
follower = FOLLOWER.match(text, pos)
return not follower or follower.group(1) in FOLLOWER_OK
def _notation(text, start, arrow):
"""True if the name is written in route NOTATION rather than in prose.
Two forms qualify: `/name` (Claude Code's invocation syntax, detected from
the character before the name) and `-> name` (ADR-0020's compressed boundary
form, passed in by the caller that matched the arrow). A backticked name
does NOT qualify — a code span is how a tool, a file and a skill are all
cited, so it carries no intent the follower test hasn't already read.
"""
return arrow or (start > 0 and text[start - 1] == '/')
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))
def _scan(text, route_re, cont_re, out):
for match in route_re.finditer(text):
name, start, end = _first(match)
if not name:
continue
_add(out, text, name, start, end)
# "use git-history or git-branches instead" / "use gitea-issues /
# gitea-prs" — keep consuming conjoined targets after the first.
pos = match.end()
while True:
cont = cont_re.match(text, pos)
if not cont:
break
_add(out, text, *_first(cont))
pos = cont.end()
def _extract_sentence(sentence):
"""[(name, may_dangle, notation)] for the routing targets in ONE sentence.
Kept separate from _extract() because corroboration is scoped to a single
sentence: a target's evidence is what stands beside it, not what the rest of
the description happens to mention.
"""
out = []
boundary = bool(BOUNDARY_MARKER.search(sentence))
_scan(sentence,
ROUTE_ANY if boundary else ROUTE_MARKED,
CONT_ANY if boundary else CONT_MARKED,
out)
for match in ARROW_MARKED.finditer(sentence):
# `-> name` and `-> /name` are route notation, not prose: nothing
# reads as a compound modifier after an arrow, so no follower test.
_add(out, sentence, *_first(match), strict=True, arrow=True)
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
def _extract(description):
"""[(name, may_dangle, notation)] for every routing target."""
out = []
for sentence in SENTENCE_SPLIT.split(description):
out.extend(_extract_sentence(sentence))
return out
def boundary_targets(description):
"""Every routing target, for reporting and for confirming a route."""
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).
`blocking` earns a hard error; `reported` is SUGGESTION tier — named on
every run, never fatal. Three conditions gate the promotion, and all of them
are documented at length in the ATTRIBUTIVE USE and CORROBORATION notes
above:
1. the target must be terminal, not a compound modifier ("pre-commit
hooks" is prose about a tool, not a route),
2. it must be written in route notation (`/name`, `-> name`), OR
3. its own sentence must name another target that DOES resolve.
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):
found = _extract_sentence(sentence)
resolved = {normalize_target(name) for name, _, _ in found
if normalize_target(name) in known}
for name, may_dangle, notation in found:
key = normalize_target(name)
if key in known:
continue
if not may_dangle:
reported.add(name)
continue
if notation or (resolved - {key}):
blocking.add(name)
else:
reported.add(name)
return sorted(blocking), sorted(reported - blocking)
# --- Frontmatter ----------------------------------------------------------
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
# blank line, trailing whitespace after either `---`, or CRLF line endings all
# defeated the old `^---\n(.*?)\n---`, and the miss was SILENT: every ADR-0020
# check was skipped and the file reported green (measured: a 550-character
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
# be measured must never report green, so every caller of these two ERRORs on a
# miss instead of moving on.
#
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
# YAML block-scalar content must be indented deeper than its key, so an
# indented `---` inside a folded description is CONTENT; letting it close the
# frontmatter truncated the description mid-value and silently reclassified the
# rest as body, which is a vacuous green in both directions at once. Leading
# whitespace is still tolerated on the OPENING marker, where no such content
# can exist.
FRONTMATTER_RE = re.compile(
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
def strip_bom(text):
return text[1:] if text.startswith(u'') else text
class FrontmatterError(Exception):
pass
def description_value(fm_text):
"""The description VALUE, with YAML folding resolved.
PyYAML is a HARD requirement, preflighted in bash. The hand-rolled fallback
this replaced diverged from a real parser across the FAIL boundary — one
corpus description measured 270 characters parsed and 412 unparsed, and a
quoted `"description"` key or an explicit `description: null` returned empty
from it, silently skipping the description AND routing checks. A gate that
disagrees with itself depending on which reader ran is worse than no gate.
This is the ONLY reader any of the three scripts may use to decide whether a
description is present. A line regex cannot: `description:` with no value
followed by `model: sonnet` lets `\\s*` cross the newline and captures the
NEXT key, which reads as a non-empty description, skips the "missing or
empty" failure, and then early-returns out of every ADR-0020 gate on the
genuinely empty folded value. That combination exited 0 with zero output on
a BLOCKING pre-push gate.
"""
try:
data = yaml.safe_load(fm_text)
except Exception as exc:
# Every FrontmatterError message is a COMPLETE clause, never a detail a
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
# is not valid YAML (...)", which is true only of this branch: the two
# type failures below come from frontmatter that parsed fine, and
# telling their author the YAML is invalid sends them hunting for a
# syntax error that is not there — on a blocking gate with no baseline.
raise FrontmatterError('frontmatter is not valid YAML (%s)'
% re.sub(r'\s+', ' ', str(exc)).strip())
if not isinstance(data, dict):
raise FrontmatterError('frontmatter is not a YAML mapping')
value = data.get('description')
if value is None:
return ''
if not isinstance(value, str):
# NOT str()-coerced. `description: true` became the 4-character "True"
# and sailed through the 400-character gate; a list or mapping was
# measured as its Python repr. Neither is a description a host can
# preload, so this is a parse failure, reported as one.
raise FrontmatterError(
'description is a %s, not a string' % type(value).__name__)
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
# both gotcha checks are SUGGESTION tier. A missing reference file is not a
# style opinion — it is a broken pointer — so that one is ERROR tier.
#
# Both read a FENCE-MASKED copy of the body. Scanning the raw body made a
# ```-fenced example a hard ERROR — and the skills most likely to carry one are
# skill-author and skill-audit, which DOCUMENT the references/ convention — and
# let a `## Gotchas` heading inside a fenced block stand in for the real
# section. Masking preserves every byte offset (content becomes spaces,
# newlines stay), so a span found in the mask slices the original.
GOTCHA_MAX_ENTRIES = 5
GOTCHA_MAX_BODY_FRACTION = 0.25
# The heading has to BE "Gotchas", not merely contain the word: `## Gotcha
# handling` and `## Why gotchas matter` are prose sections, and treating one as
# the Gotchas section measured a span that was never a gotcha list.
GOTCHA_HEADING = re.compile(r'^(#{1,6})[ \t]+(?:[^\n]*?[ \t])?gotchas?[ \t]*:?[ \t]*$',
re.I | re.M)
# Column 0 only. `^[ \t]{0,3}` counted a two-space-indented CHILD bullet as a
# top-level entry, so a five-entry section with sub-bullets reported nine.
GOTCHA_ENTRY = re.compile(r'^(?:[-*+]|\d+[.)])[ \t]+', re.M)
FENCE_OPEN = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
REFERENCE_POINTER = re.compile(
r'(?<![\w./-])(?:\./)?references/([A-Za-z0-9][A-Za-z0-9._/-]*\.md)')
# A pointer named in a sentence that says the file is GONE is a historical
# mention, not a dispatch entry: "the old `references/legacy.md` was removed in
# v2" is prose and must not be a hard ERROR. Narrow on purpose — a live
# dispatch table never describes its own target as removed, so this costs no
# recall.
REFERENCE_PAST = re.compile(
r'\b(?:removed|deleted|renamed|superseded|replaced|obsolete|deprecated'
r'|former|formerly|gone|no longer|used to)\b', re.I)
# A pointer QUALIFIED by another skill's name — "skill-audit's
# references/validation-scripts.md" — names a file that is deliberately NOT in
# this skill's directory. Requiring it on the local disk left NO legal spelling
# for a cross-skill reference at all: the only alternative, a full repo path
# (`plugins/kyberforge/.apm/skills/skill-audit/references/...`), is itself a
# FAIL under skill-audit's own file-structure rubric, because a path that climbs
# out of the skill directory stops resolving once the plugin is cache-installed.
# The possessive form is the sanctioned spelling, and it is skipped here. It is
# not checked further — this function has no way to locate another skill's
# directory, and inventing one would reintroduce exactly the cross-plugin path
# assumption the rubric forbids.
REFERENCE_QUALIFIER = re.compile(u"[A-Za-z0-9][A-Za-z0-9._-]*`?['’]s[ \t]+`?$")
def mask_fenced(text):
"""Body with fenced code blocks blanked out, byte offsets preserved."""
out = []
fence = None
for line in text.splitlines(keepends=True):
stripped = line.rstrip('\r\n')
opener = FENCE_OPEN.match(stripped)
marker = opener.group(1) if opener else None
if fence is None:
if marker:
fence = marker
out.append(' ' * len(stripped) + line[len(stripped):])
continue
out.append(line)
else:
out.append(' ' * len(stripped) + line[len(stripped):])
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
and not stripped.strip()[len(marker):].strip()):
fence = None
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
# be wrong. Masking to end-of-body blanks the rest of the body, silently
# disabling the ERROR-tier references/ check and the gotcha counts.
# Returning the raw text instead exposes the unclosed example's own
# content, so a fenced example naming a nonexistent references/ file
# becomes a hard ERROR it would not have been had the fence been closed —
# confirmed, not hypothetical. The loud-false-positive direction is the one
# chosen: this script's rule is that a file it cannot measure must never
# report green, and masking-onward is exactly that failure. Both outcomes
# need an already-malformed file, and the false positive costs one fence.
if fence is not None:
return text
return ''.join(out)
def gotcha_stats(body):
"""(entry count, section word count) for the first Gotchas section, or None.
The section runs to the next heading at the same level or shallower.
Entries are top-level list items; a section written as subheadings instead
of a list counts those. Headings and entries are read from the fence mask;
the word count is taken from the original slice, because fenced lines are
real body words and the fraction is measured against the whole body.
"""
masked = mask_fenced(body)
match = GOTCHA_HEADING.search(masked)
if not match:
return None
level = len(match.group(1))
rest = masked[match.end():]
nxt = re.search(r'^#{1,%d}[ \t]+' % level, rest, re.M)
end = match.end() + (nxt.start() if nxt else len(rest))
section = masked[match.end():end]
entries = len(GOTCHA_ENTRY.findall(section))
if entries == 0 and level < 6:
entries = len(re.findall(r'^#{%d,6}[ \t]+' % (level + 1), section, re.M))
return entries, len(body[match.end():end].split())
def missing_reference_pointers(body, skill_dir):
"""references/<file>.md named in the body but absent from disk."""
masked = mask_fenced(body)
missing = set()
for match in REFERENCE_POINTER.finditer(masked):
start = masked.rfind('\n', 0, match.start()) + 1
end = masked.find('\n', match.end())
if end < 0:
end = len(masked)
# 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
if not os.path.isfile(os.path.join(skill_dir, 'references', match.group(1))):
missing.add('references/' + match.group(1))
return sorted(missing)
# ===== END ADR-0020 SHARED BOUNDARY RESOLVER =====
def parse_frontmatter(content):
m = FRONTMATTER_RE.match(strip_bom(content))
if not m:
return None, content
return m.group(1), strip_bom(content)[m.end():]
def extract_field(fm, field):
"""The raw text after `field:` ON ITS OWN LINE, or None.
The character class is `[^\\S\\r\\n]`, never `\\s`: under re.MULTILINE a
`\\s*` after the colon crosses the newline, so `description:` with no value
followed by `model: sonnet` captured `model: sonnet` as the description.
That made the value look present, skipped the "missing or empty" failure,
and then every ADR-0020 gate early-returned on the genuinely empty folded
value — a valueless description exited 0 with zero output on a BLOCKING
pre-push gate. This function is now used only for fields with no folding
semantics (name, tools); description goes through description_value(), the
shared resolver's YAML reader, which is the only thing that can see through
`>`, `null`, `''` and a quoted `"description"` key alike.
"""
m = re.search(rf'^{re.escape(field)}:[^\S\r\n]*(.+)', fm, re.MULTILINE)
return m.group(1).strip() if m else None
def get_frontmatter_keys(fm):
keys = set()
for line in fm.splitlines():
m = re.match(r'^([a-zA-Z][a-zA-Z0-9_-]*):', line)
if m:
keys.add(m.group(1))
return keys
def agent_description(fm, local_fname):
"""The folded description VALUE, or None if it could not be read."""
try:
return description_value(fm)
except FrontmatterError as exc:
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or
# a description of the wrong type. Do not prefix a diagnosis here; the
# last one named a syntax error for two failures that have none.
fail(f"{exc} — the ADR-0020 description and boundary-target gates could "
f"not run — {local_fname}")
return None
def check_description_budget(value, local_fname, by_hand=False):
"""ADR-0020 description gates — identical for every scope.
`by_hand` is ADR-0020's hand-invocation carve-out (issue #108): an agent
carrying `disable-model-invocation: true` is absent from the model-visible
listing, so the 250-character SUGGESTION — a routing-quality budget — has
no listing to apply to. The 400-character ceiling is unaffected.
"""
if not value:
return
dlen = len(value)
if dlen > DESC_MAX_CHARS:
fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character "
f"ADR-0020 ceiling. It is preloaded into every session whether or not the "
f"agent 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 — {local_fname}")
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 "
f"— {local_fname}")
def check_boundary(value, fpath, local_fname, by_hand=False):
"""ADR-0020 boundary clause + resolvable boundary targets.
agent-author's SKILL.md states that an agent's boundary targets must
resolve, but until this ran no script checked it — the contract was
documented and unenforced. The resolution universe is derived from the
AGENT FILE's own location (the authoring root above it, its own apm
package, and that package's declared apm dependencies), never from this
script's path, and — when an authoring root exists — never from a deployed
.claude/ tree, so a fresh clone and a machine that has run `apm install`
return the same verdict.
"""
if not value:
return
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular agent warrants a boundary clause is judgment. All four
# agents in this corpus currently lack one.
#
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could
# not parse" are different findings (issue #110). And a hand-invoked agent is
# exempt from the clause altogether (issue #108) — the boundary-target
# resolution below still runs, because a target it DOES name should still
# resolve.
status = boundary_clause_status(value) if not by_hand else 'present'
if status == 'absent':
suggest(f"description has no boundary clause — add the prose form (\"Do not use "
f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
f"so the router knows where NOT to send this agent — {local_fname}")
elif status == 'unparsed':
suggest(f"description has an arrow boundary clause (\"Not X -> y\") from which no "
f"target could be read, so the dangling-target check did not run on it — "
f"the clause is PRESENT and unparsed, not missing. Most often the target "
f"is a single word, which is deliberately not matchable bare: write it as "
f"`name` or /name — {local_fname}")
if not by_hand:
# One arrow, one target: a second name after the same arrow is resolved
# by nothing and reported by nothing (issue #107).
for first, second in multi_target_arrow_clauses(value):
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}.\" — {local_fname}")
targets = boundary_targets(value)
if not targets:
return
known = known_targets(os.path.dirname(os.path.abspath(fpath)))
if not known:
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
f"determined for this path (no authoring root above it, no apm package "
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
f"tree). Unchecked target(s): {', '.join(targets)} — {local_fname}")
return
# blocking vs reported: a target only earns a FAIL when it is written in
# route notation or its own sentence corroborates it by naming another target
# that resolves. See the shared resolver's CORROBORATION note.
blocking, reported = unresolved_targets(value, known)
for target in blocking:
fail(f"description routes to '{target}', which resolves to no skill or agent "
f"in this monorepo, in this package, or in a package it declares in "
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
f"target sends the router nowhere — {local_fname}")
for target in reported:
suggest(f"description routes to '{target}', which resolves to no skill or agent "
f"in this monorepo, in this package, or in a package it declares in "
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
f"else in that sentence resolves, so it is equally likely to be a tool, a "
f"file format or an English compound. If it IS a route, write it as "
f"`/{target}` or `-> {target}` and it will be checked properly — "
f"{local_fname}")
def extract_tools_list(fm):
"""Tool names from the `tools` field — inline scalar OR YAML block sequence.
Read off the PARSED mapping, never off extract_field(). That function's
capture is newline-bounded on purpose (`[^\\S\\r\\n]*(.+)`), so a `tools:`
written as a block sequence — the shape Copilot agent files use — captured
nothing at all and the subagent-unavailable-tool check silently stopped
firing on exactly the files it was written for. Both spellings are legal
YAML, so both are read here.
"""
try:
data = yaml.safe_load(fm)
except Exception:
# Not this function's failure to report: the frontmatter's validity is
# decided (and failed) by agent_description() on the same text.
return set()
if not isinstance(data, dict):
return set()
val = data.get('tools')
if isinstance(val, list):
items = [str(item).strip() for item in val]
elif isinstance(val, str):
items = re.split(r'[\s,]+', val.strip())
else:
return set()
return {item for item in items if item}
def is_copilot_cloud_ide(fpath):
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
return '.github/copilot/agents' in os.path.abspath(fpath).replace(os.sep, '/')
# --- Detect scope ---
# APM_TYPE_RE matches a top-level (column-0) `type:` line in apm.yml whose value is
# exactly one of the four package content types. Group 1 captures an optional
# opening quote; \1 requires the same character (or nothing) to close it, so
# "skill" and '"skill"' both match but a mismatched quote doesn't. The value
# must then be followed by whitespace or end-of-line — not just a non-word
# character — so a malformed value like `prompts-only` is correctly rejected
# instead of false-matching on the `prompts` prefix.
APM_TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\1(?:\s|$)")
def find_apm_package_root(apm_yml_path):
"""Return True if apm_yml_path has a top-level type: line (i.e. is a package
manifest, not a type:-less marketplace-only apm.yml)."""
# errors='replace', not a hard failure: this only asks whether a `type:`
# line exists, and a stray undecodable byte elsewhere in someone else's
# apm.yml must not abort scope detection.
with open(apm_yml_path, encoding='utf-8', errors='replace') as f:
for line in f:
if APM_TYPE_RE.match(line):
return True
return False
def detect_scope(start_dir):
home = os.path.expanduser('~')
original_start = os.path.abspath(start_dir)
# Agent files conventionally live exactly two path segments below their
# scope root — <root>/.claude/agents, <root>/.github/agents,
# <root>/.copilot/agents, or <root>/.apm/agents (see new-agent.sh's
# CC_DIR/CP_DIR and user-scope dirs). Stripping those two segments
# recovers the same root new-agent.sh would have been invoked with to
# produce this exact file, independent of how far the walk below has to
# travel to find (or fail to find) a marker — mirrors new-agent.sh's
# `root` vs `current` distinction even though validate.sh is handed a
# file's directory, not the scope root itself.
#
# That arithmetic is only trustworthy when the path actually has this
# shape: parent directory literally named "agents", grandparent one of
# the four known scope-dir names. A hand-placed or otherwise
# non-conventional agent file (never produced by new-agent.sh) has no
# such guarantee — blindly trusting two-segments-up there could point at
# an unrelated ancestor. conventional_shape gates every use of
# conventional_root below; when it's false, the walked-to `current`
# directory is used instead, the same fallback this function used before
# conventional_root existed.
scope_dir_name = os.path.basename(os.path.dirname(original_start))
conventional_shape = (
os.path.basename(original_start) == 'agents'
and scope_dir_name in ('.claude', '.github', '.copilot', '.apm')
)
conventional_root = os.path.dirname(os.path.dirname(original_start))
current = original_start
while True:
# The filesystem root is never a candidate, the same guard the shared
# resolver's walk-up loops carry. Without it a file under a marker-less
# temp directory walked all the way to `/` and returned it as the scope
# root, which then reported `counterpart file not found:
# /.claude/agents/<name>.md` — a path that names someone else's machine,
# not the user's project. When the walk runs out, the agent file's own
# directory (or its conventional root) is the honest answer.
if _is_fs_root(current):
return 'project', conventional_root if conventional_shape else original_start
apm_yml = os.path.join(current, 'apm.yml')
if os.path.isfile(apm_yml) and find_apm_package_root(apm_yml):
return 'plugin', current
# $HOME is the user-scope boundary — checked before the .git test
# below, so a dotfiles-managed $HOME (yadm, chezmoi bare-repo, etc.)
# can't shadow user scope by being its own .git repo. 'user' scope
# requires EITHER start_dir to BE $HOME itself (no walk-up — the
# new-agent.sh "root exactly $HOME" case) OR start_dir to sit at the
# conventional two-segments-below-root depth (i.e. $HOME IS that
# root, matching the real ~/.claude/agents or ~/.copilot/agents
# shape). Any other walk-up into $HOME — a marker-less directory
# nested deeper than that convention — resolves to project scope
# instead: a stray directory under $HOME can't be silently
# redirected into the shared global ~/.claude or ~/.copilot agent
# directories.
if current == home:
if original_start == home or (conventional_shape and conventional_root == home):
return 'user', home
return 'project', conventional_root if conventional_shape else current
# .git is a directory in a normal checkout but a file (`gitdir: ...`)
# in a git worktree — exists() covers both. Returns conventional_root,
# not current: new-agent.sh's project-scope file placement always
# uses its `$ROOT` argument directly, never the walked-up `.git`
# location, so a <root> one or more levels below the repo's .git
# (a subdirectory of a larger git-tracked tree — explicitly a
# supported case per new-agent.sh's usage text) must resolve to the
# same root new-agent.sh actually wrote to, not to the .git dir —
# unless the path lacks the conventional shape, in which case that
# arithmetic isn't trustworthy and current is used instead.
if os.path.exists(os.path.join(current, '.git')):
return 'project', conventional_root if conventional_shape else current
parent = os.path.dirname(current)
if parent == current:
return 'project', conventional_root if conventional_shape else current
current = parent
agent_dir = os.path.dirname(agent_file)
scope, scope_root = detect_scope(agent_dir)
# --- Plugin/APM scope: single vendor-neutral file, no counterpart ---
def check_apm_agent_file(fpath, allowlist, stem):
local_fname = os.path.basename(fpath)
try:
content = read_text(fpath)
except EncodingError as exc:
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
f"failure, not a skip — {local_fname}")
return
except OSError as exc:
# A path that cannot be opened gets a FAIL line naming it, not a bare
# FileNotFoundError traceback. scripts/check-apm-agents-valid.sh takes
# this path for an agent file deleted from the worktree but still
# tracked in the index — a real, expected state, and the caller needs to
# be told which file, not handed an interpreter stack.
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
f"be measured, so this is a hard failure, not a skip — {local_fname}")
return
fm, body = parse_frontmatter(content)
if fm is None:
fail(f"no parseable YAML frontmatter block — expected a `---` line, the fields, "
f"then a closing `---` line (a BOM, leading blank lines, trailing spaces "
f"after either marker and CRLF endings are all tolerated). Nothing could be "
f"measured, so this is a hard failure, not a skip — {local_fname}")
return
# The apm-agent.md template embeds its authoring guidance as HTML
# comments inside the frontmatter block (so they render invisible in a
# Markdown preview but stay visible in the raw file). get_frontmatter_keys
# silently ignores any line that isn't a `key:` match, so a comment left
# behind at ship time would otherwise pass unnoticed — yet apm compile
# copies this frontmatter verbatim to both harnesses, and `<!-- -->` is
# not valid YAML, so yaml.safe_load breaks on both downstream (ADR-0016).
if re.search(r'<!--|-->', fm):
fail(f"frontmatter still contains template HTML comments (<!-- ... -->) "
f"— delete them before shipping — {local_fname}")
# Allowlist: the permitted keys are data, read at load time from
# references/field-inventory.md's `## apm-agent-allowlist` section — do not
# restate them here, or this comment goes stale the next time that line
# changes. apm compile verbatim-copies frontmatter to every target, so a key
# outside the list is unsafe on at least one harness (ADR-0016). Note the
# list admits denylist-shaped restrictions (disallowedTools) but never
# allowlist-shaped ones (tools), whose value shape differs per harness.
fm_keys = get_frontmatter_keys(fm)
for key in sorted(fm_keys):
if key not in allowlist:
fail(f"field '{key}' is not in the vendor-neutral APM agent allowlist "
f"({', '.join(sorted(allowlist))}) — {local_fname}")
# name — required, kebab-case, must match filename stem (file is <name>.agent.md)
name_val = extract_field(fm, 'name')
if not name_val:
fail(f"name field is missing or empty — {local_fname}")
else:
if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
if name_val != stem:
fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}")
# description — required, non-empty, no placeholder
# Presence is decided on the FOLDED value, never on a line regex. Deciding
# it on extract_field's raw capture is what let `description:` with no value
# pass this gate in total silence: the capture picked up the next key, so
# "missing or empty" never fired, and every ADR-0020 check below then
# early-returned on the empty folded value. Exit 0, zero output, no gate run.
folded = agent_description(fm, local_fname)
if folded is None:
pass # frontmatter is not valid YAML — agent_description already failed
elif not folded:
fail(f"description field is missing or empty — {local_fname}")
else:
if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
by_hand = hand_invoked(fm)
check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body — required, non-empty, no placeholder; same Copilot truncation risk
# applies since this file compiles verbatim into a real Copilot file downstream.
if not body.strip():
fail(f"system prompt body is empty — {local_fname}")
else:
if PLACEHOLDER_RE.search(body):
fail(f"body contains unfilled FILL IN: placeholder — {local_fname}")
if len(body) > COPILOT_BODY_LIMIT:
suggest(f"body exceeds {COPILOT_BODY_LIMIT:,} characters ({len(body):,} chars) — "
f"content beyond the limit is silently truncated by the Copilot runtime "
f"once apm compile emits it downstream — {local_fname}")
if scope == 'plugin':
check_apm_agent_file(agent_file, apm_agent_allowlist, name_stem)
for s in suggestions:
print(f"SUGGESTION {s}")
sys.exit(1 if failed else 0)
# --- Project/user scope: unchanged CC/Copilot pair validation ---
# --- Derive counterpart path ---
if scope == 'project':
if provider == 'claude-code':
counterpart = os.path.join(scope_root, '.github', 'agents', name_stem + '.agent.md')
counterpart_provider = 'copilot'
else:
counterpart = os.path.join(scope_root, '.claude', 'agents', name_stem + '.md')
counterpart_provider = 'claude-code'
else: # user
home = os.path.expanduser('~')
if provider == 'claude-code':
counterpart = os.path.join(home, '.copilot', 'agents', name_stem + '.agent.md')
counterpart_provider = 'copilot'
else:
counterpart = os.path.join(home, '.claude', 'agents', name_stem + '.md')
counterpart_provider = 'claude-code'
def check_file(fpath, file_provider):
local_fname = os.path.basename(fpath)
try:
content = read_text(fpath)
except EncodingError as exc:
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
f"failure, not a skip — {local_fname}")
return
except OSError as exc:
# Same reason as check_apm_agent_file's: a diagnostic naming the path
# beats a FileNotFoundError traceback. The counterpart is pre-checked at
# the bottom of this script, but agent_file itself never was.
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
f"be measured, so this is a hard failure, not a skip — {local_fname}")
return
fm, body = parse_frontmatter(content)
if fm is None:
fail(f"no parseable YAML frontmatter block — expected a `---` line, the fields, "
f"then a closing `---` line (a BOM, leading blank lines, trailing spaces "
f"after either marker and CRLF endings are all tolerated). Nothing could be "
f"measured, so this is a hard failure, not a skip — {local_fname}")
return
# name — required for CC and Copilot CLI; optional for Copilot cloud/IDE agents
cloud_ide = (file_provider == 'copilot' and is_copilot_cloud_ide(fpath))
name_val = extract_field(fm, 'name')
if not cloud_ide:
if not name_val:
fail(f"name field is missing or empty — {local_fname}")
else:
if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
# Stem check applies to Copilot CLI only; CC docs say filename need not match name
if file_provider == 'copilot':
stem = local_fname[:-len('.agent.md')]
if name_val != stem:
fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}")
elif name_val and not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
# cloud/IDE: name is optional, but if present it must be valid
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
# description
# Presence is decided on the FOLDED value, never on a line regex. Deciding
# it on extract_field's raw capture is what let `description:` with no value
# pass this gate in total silence: the capture picked up the next key, so
# "missing or empty" never fired, and every ADR-0020 check below then
# early-returned on the empty folded value. Exit 0, zero output, no gate run.
folded = agent_description(fm, local_fname)
if folded is None:
pass # frontmatter is not valid YAML — agent_description already failed
elif not folded:
fail(f"description field is missing or empty — {local_fname}")
else:
if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
by_hand = hand_invoked(fm)
check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body
if not body.strip():
fail(f"system prompt body is empty — {local_fname}")
else:
if PLACEHOLDER_RE.search(body):
fail(f"body contains unfilled FILL IN: placeholder — {local_fname}")
# Copilot body length limit
if file_provider == 'copilot' and len(body) > COPILOT_BODY_LIMIT:
suggest(f"body exceeds {COPILOT_BODY_LIMIT:,} characters ({len(body):,} chars) — content beyond the limit is silently truncated by the Copilot runtime — {local_fname}")
# CC-only fields in Copilot file
if file_provider == 'copilot':
fm_keys = get_frontmatter_keys(fm)
for key in sorted(fm_keys):
if key in cc_only_fields:
fail(f"CC-only field '{key}' present in Copilot file — {local_fname}")
# Copilot-only fields in CC file
if file_provider == 'claude-code':
fm_keys = get_frontmatter_keys(fm)
for key in sorted(fm_keys):
if key in copilot_only_fields:
fail(f"Copilot-only field '{key}' present in CC file — {local_fname}")
# Subagent-unavailable tools listed in tools field
tools = extract_tools_list(fm)
unavailable = tools & SUBAGENT_UNAVAILABLE_TOOLS
for tool in sorted(unavailable):
suggest(f"'{tool}' is listed in tools but is never available to subagents — the runtime withholds it regardless — {local_fname}")
# --- Check counterpart exists ---
if not os.path.isfile(counterpart):
fail(f"counterpart file not found: {counterpart}")
sys.exit(1)
# --- Check both files ---
check_file(agent_file, provider)
check_file(counterpart, counterpart_provider)
for s in suggestions:
print(f"SUGGESTION {s}")
sys.exit(1 if failed else 0)
PYTHON