Skip to content

Commit 9c13ad1

Browse files
authored
chore(scripts): add cross-layer frontmatter parity check to check_agents (#741)
check_agents.sh and its PowerShell twin previously stripped frontmatter and compared only the agent bodies, so the permissionMode layer drift fixed in #729 shipped silently. Add a parity check on the behavioral frontmatter fields tools and permissionMode across the plugin and project copies (fail with exit 2 on mismatch); color stays exempt as an intended project-only field. Adds regression cases (tools drift, permissionMode drift, color-only diff) and updates the CUSTOM_EXTENSIONS.md CI-enforcement note.
1 parent a20a30f commit 9c13ad1

4 files changed

Lines changed: 109 additions & 6 deletions

File tree

docs/CUSTOM_EXTENSIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ pwsh scripts/sync_references.ps1 # Windows
212212

213213
The eight agent definitions exist in two layers — `project/.claude/agents/` and the standalone `plugin/agents/` bundle. They are near-duplicates but **not** byte-identical by design: frontmatter differs per layer (the project copy carries `color:`; neither carries the non-canonical `temperature:` field; `permissionMode: acceptEdits` is kept identical across both layers for the two write agents, documentation-writer and refactor-assistant), and the body genericizes exactly one repo-specific "language-specific rules" sentence in the plugin copy.
214214

215-
**CI enforcement**: `.github/workflows/validate-skills.yml` runs `scripts/check_agents.sh` (PowerShell twin: `scripts/check_agents.ps1`). It strips frontmatter and normalizes that single sentence, then fails (exit 2) if the agent **bodies** otherwise diverge — so a behavioral instruction edited in one layer but not the other cannot ship silently.
215+
**CI enforcement**: `.github/workflows/validate-skills.yml` runs `scripts/check_agents.sh` (PowerShell twin: `scripts/check_agents.ps1`). It strips frontmatter and normalizes that single sentence, then fails (exit 2) if the agent **bodies** otherwise diverge, or if the behavioral frontmatter fields `tools` or `permissionMode` differ between layers (intended per-layer fields like `color` are exempt) — so a behavioral instruction or capability edited in one layer but not the other cannot ship silently.
216216

217217
**Advisory frontmatter (`applies_to`, `keywords`)**: every agent declares `applies_to` (a glob list) and `keywords`. These are **not** official Claude Code runtime path-triggers — the runtime delegates to a sub-agent only by its `description`. They are advisory inputs consumed by the `fleet-orchestrator` skill's Top-K routing score (`score = 2 * matched_applies_to_globs + 1 * matched_keywords`, see `global/skills/_internal/fleet-orchestrator/SKILL.md`). Opening a file that matches an `applies_to` glob does **not** auto-invoke the agent; keep delegation intent in `description`.
218218

scripts/check_agents.ps1

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
# Drift guard for the 8 agent definitions duplicated across plugin/agents/
44
# and project/.claude/agents/. See check_agents.sh for the full rationale:
55
# frontmatter differs per layer and one repo-path sentence is genericized in
6-
# the plugin copy; the bodies must otherwise stay identical.
6+
# the plugin copy; the bodies must otherwise stay identical. In addition the
7+
# behavioral frontmatter fields `tools` and `permissionMode` must match across
8+
# layers (intended per-layer fields like `color` are exempt).
79
# Exit: 0 = in sync, 2 = drift.
810

911
$ErrorActionPreference = 'Stop'
@@ -39,6 +41,25 @@ function Get-NormalizedBody {
3941
return ($body -join "`n")
4042
}
4143

44+
# Extract the value of a frontmatter key from the first '---' block. Returns
45+
# the empty string when the key is absent, so a key declared on only one layer
46+
# surfaces as a drift.
47+
function Get-FrontmatterField {
48+
param([string]$Path, [string]$Key)
49+
$dashes = 0
50+
foreach ($line in (Get-Content -LiteralPath $Path -Encoding utf8)) {
51+
if ($line -eq '---') {
52+
$dashes++
53+
if ($dashes -ge 2) { break }
54+
continue
55+
}
56+
if ($dashes -eq 1 -and $line -match ('^' + [regex]::Escape($Key) + ':')) {
57+
return ($line -replace ('^' + [regex]::Escape($Key) + ':\s*'), '').TrimEnd()
58+
}
59+
}
60+
return ''
61+
}
62+
4263
$drift = 0
4364
foreach ($a in $Agents) {
4465
$p = Join-Path $RootDir "plugin/agents/$a.md"
@@ -49,10 +70,21 @@ foreach ($a in $Agents) {
4970
Write-Host "FAIL: agent body drift: plugin/agents/$a.md vs project/.claude/agents/$a.md"
5071
$drift = 1
5172
}
73+
74+
# Behavioral frontmatter parity: layers may differ in declarative fields
75+
# (color, model, ...) but must agree on what the agent can do.
76+
foreach ($field in @('tools', 'permissionMode')) {
77+
$pv = Get-FrontmatterField $p $field
78+
$cv = Get-FrontmatterField $c $field
79+
if ($pv -ne $cv) {
80+
Write-Host "FAIL: frontmatter '$field' drift for $($a): plugin='$pv' project='$cv'"
81+
$drift = 1
82+
}
83+
}
5284
}
5385

5486
if ($drift -eq 0) {
55-
Write-Host "check_agents: OK ($($Agents.Count) agent pairs in sync)"
87+
Write-Host "check_agents: OK ($($Agents.Count) agent pairs: bodies + behavioral frontmatter in sync)"
5688
exit 0
5789
}
5890

scripts/check_agents.sh

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
# near-duplicate pair cannot silently diverge (e.g. a behavioral instruction
1616
# edited in one copy but not the other).
1717
#
18+
# In addition, the two BEHAVIORAL frontmatter fields `tools` and
19+
# `permissionMode` must match across layers: they change what a sub-agent is
20+
# allowed to do, so a per-layer divergence (such as the #729 permissionMode
21+
# drift) is a real defect, not an intended per-layer difference. Intended
22+
# per-layer fields (`color`, plus declarative ones like `model`/`maxTurns`/
23+
# `effort`/`memory`/`applies_to`/`keywords`/`initialPrompt`) are deliberately
24+
# NOT compared, so future intended per-layer diffs are not blocked.
25+
#
1826
# Reference drift is intentionally NOT guarded here: the plugin skill
1927
# reference tree is a curated RE-STRUCTURING of rules/ (content is split and
2028
# recombined across files, e.g. observability -> observability + logging), so
@@ -46,6 +54,24 @@ strip_and_norm() {
4654
| sed -E 's/^If .*language-specific rules.*read them before starting\.$/<RULES_PATH_NOTE>/'
4755
}
4856

57+
# Extract the value of a frontmatter key (e.g. `tools`, `permissionMode`) from
58+
# the first '---' block of an agent file. Matches `^<key>:` only inside the
59+
# frontmatter, trims surrounding whitespace, and prints the empty string when
60+
# the key is absent (so a key declared on one layer but not the other surfaces
61+
# as a drift).
62+
frontmatter_field() {
63+
awk -v key="$2" '
64+
BEGIN { f = 0 }
65+
/^---$/ { f++; if (f >= 2) exit; next }
66+
f == 1 && $0 ~ "^" key ":" {
67+
sub("^" key ":[ \t]*", "")
68+
sub(/[ \t]+$/, "")
69+
print
70+
exit
71+
}
72+
' "$1"
73+
}
74+
4975
drift=0
5076
for a in "${AGENTS[@]}"; do
5177
p="$ROOT_DIR/plugin/agents/$a.md"
@@ -59,15 +85,27 @@ for a in "${AGENTS[@]}"; do
5985
diff <(strip_and_norm "$p") <(strip_and_norm "$c") 2>/dev/null | sed 's/^/ /' >&2 || true
6086
drift=1
6187
fi
88+
89+
# Behavioral frontmatter parity: the layers may differ in declarative
90+
# fields (color, model, ...) but must agree on what the agent can do.
91+
for field in tools permissionMode; do
92+
pv="$(frontmatter_field "$p" "$field")"
93+
cv="$(frontmatter_field "$c" "$field")"
94+
if [ "$pv" != "$cv" ]; then
95+
echo "FAIL: frontmatter '$field' drift for $a: plugin='$pv' project='$cv'" >&2
96+
drift=1
97+
fi
98+
done
6299
done
63100

64101
if [ "$drift" -eq 0 ]; then
65-
echo "check_agents: OK (${#AGENTS[@]} agent pairs in sync)"
102+
echo "check_agents: OK (${#AGENTS[@]} agent pairs: bodies + behavioral frontmatter in sync)"
66103
exit 0
67104
fi
68105

69106
echo "" >&2
70107
echo "check_agents: drift detected between plugin/agents and project/.claude/agents." >&2
71-
echo "The body content must match (frontmatter and the single rules-path" >&2
72-
echo "sentence may differ by design). Reconcile the divergent lines above." >&2
108+
echo "The body content and the behavioral frontmatter fields ('tools'," >&2
109+
echo "'permissionMode') must match. Other frontmatter (e.g. 'color') and the" >&2
110+
echo "single rules-path sentence may differ by design. Reconcile the lines above." >&2
73111
exit 2

tests/scripts/test-check-agents.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,39 @@ sed -i 's|^If .*language-specific rules.*read them before starting\.$|If any lan
6363
assert_exit 0 $rc "rules-path sentence variant -> exit 0"
6464
rm -rf "$sb"
6565

66+
# Portable in-place replace: BSD sed (macOS) needs `-i ''` while GNU sed
67+
# (Linux/CI) rejects the empty arg, so use a temp file + mv that works on both.
68+
replace_in_file() {
69+
local pattern="$1" file="$2" tmp
70+
tmp="$(mktemp)"
71+
sed "$pattern" "$file" > "$tmp" && mv "$tmp" "$file"
72+
}
73+
74+
# 5. A `tools` frontmatter divergence in one layer is flagged.
75+
sb=$(mktemp -d); mk_sandbox "$sb"
76+
replace_in_file 's|^tools: Read, Grep, Glob, Bash$|tools: Read, Grep, Glob, Bash, Write|' \
77+
"$sb/plugin/agents/qa-reviewer.md"
78+
( cd "$sb" && bash scripts/check_agents.sh >/dev/null 2>&1 ); rc=$?
79+
assert_exit 2 $rc "tools frontmatter drift -> exit 2"
80+
rm -rf "$sb"
81+
82+
# 6. A `permissionMode` frontmatter divergence in one layer is flagged.
83+
sb=$(mktemp -d); mk_sandbox "$sb"
84+
replace_in_file 's|^permissionMode: acceptEdits$|permissionMode: bypassPermissions|' \
85+
"$sb/project/.claude/agents/documentation-writer.md"
86+
( cd "$sb" && bash scripts/check_agents.sh >/dev/null 2>&1 ); rc=$?
87+
assert_exit 2 $rc "permissionMode frontmatter drift -> exit 2"
88+
rm -rf "$sb"
89+
90+
# 7. An intended per-layer frontmatter field (color) is tolerated even when it
91+
# differs — the guard only compares `tools` and `permissionMode`.
92+
sb=$(mktemp -d); mk_sandbox "$sb"
93+
replace_in_file 's|^color: .*$|color: magenta|' \
94+
"$sb/project/.claude/agents/qa-reviewer.md"
95+
( cd "$sb" && bash scripts/check_agents.sh >/dev/null 2>&1 ); rc=$?
96+
assert_exit 0 $rc "color-only frontmatter diff -> exit 0"
97+
rm -rf "$sb"
98+
6699
echo ""
67100
echo "=== Results: $PASS passed, $FAIL failed ==="
68101
if [ ${#ERRORS[@]} -gt 0 ]; then

0 commit comments

Comments
 (0)