#!/usr/bin/env bash set -euo pipefail # Run agent-audit's validate.sh over every REAL plugin-scope agent file in this # repo (plugins/*/.apm/agents/*.agent.md). # # Why this exists: validate.sh was previously exercised only by # scripts/check-scope-walkup-sync.sh, and only against synthetic fixtures built # in mktemp trees. It had never once run against the four agent files it # actually governs. That is how ADR-0016 could be amended to bless a # `disallowedTools` frontmatter field while validate.sh's allowlist still # rejected it -- the spec and its enforcer disagreed, every gate stayed green, # and the contradiction only surfaced when someone ran the validator by hand. # # A validator that checks fixtures but never artifacts is the same # green-because-nothing-was-checked shape as a suite that runs on an empty file # set. This closes it: the artifacts are the input. # # Run from repo root, or pass REPO_ROOT as the first argument (tests do). # Needs no network. REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" # A nonexistent REPO_ROOT must fail loudly rather than fall through to the # zero-files floor below with a confusing message -- a typo'd or stale path is a # different problem from a repo that genuinely has no agents, and the fix # differs too. if [[ ! -d "$REPO_ROOT" ]]; then echo "APM agent validation failed: REPO_ROOT '$REPO_ROOT' is not a directory." >&2 exit 1 fi REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh" # The validator's own absence is a hard failure, never a skip. If validate.sh # moves or is deleted, every assertion below evaporates and the hook would # otherwise exit 0 having validated nothing -- indistinguishable, from # pre-commit's silent-on-pass output, from a run where all four agents passed. if [[ ! -f "$VALIDATE" ]]; then echo "APM agent validation failed: validator not found at $VALIDATE — this script's path has gone stale, so no agent file was checked. Update it to wherever agent-audit's validate.sh now lives." >&2 exit 1 fi # validate.sh is a bash wrapper around a heredoc'd python3 program. Without # python3 it dies with a bare "command not found" per file and no pointer, which # reads like a validation failure rather than a missing dependency. Fail closed, # but say which it is. if ! command -v python3 >/dev/null 2>&1; then echo "APM agent validation failed: python3 not found on PATH — agent-audit's validate.sh is a python3 program and cannot run. Install python3; this gate does not degrade to a pass." >&2 exit 1 fi # --- Discover the agent files --- # `-not -path` mirrors tests/run-bats.sh: worktrees under .claude/ are other # checkouts of this same repo, not additional content. AGENT_FILES=() while IFS= read -r f; do [[ -n "$f" ]] && AGENT_FILES+=("$f") done < <( find "$REPO_ROOT/plugins" -type f -name '*.agent.md' \ -path '*/.apm/agents/*' \ -not -path '*/.claude/worktrees/*' \ 2>/dev/null | sort ) # --- Derive the EXPECTED set from the index, not from a hardcoded count --- # Same reasoning as tests/run-bats.sh: a magic number goes stale the moment a # plugin gains or loses an agent, and slack in a floor is exactly where a # silently-deleted file hides. `git ls-files` needs no maintenance -- a newly # `git add`ed agent file joins the expectation immediately. # # Direction matters, and it is the same direction run-bats.sh uses: every # TRACKED file must have been discovered, but a discovered file need not be # tracked. An untracked new agent file is ordinary work in progress (and is # still validated below), while a file that vanished from the worktree without # leaving the index is an accident and fails here. A deliberate `git rm` leaves # the index, so intentional removal passes. # # The exact-equality check on --show-toplevel keeps this off the mktemp fixture # trees in tests/test-check-apm-agents-valid.sh, which resolve no worktree. That # degradation is announced rather than silent, and the zero-file floor below is # unconditional regardless. EXPECTED_FILES=() DERIVED=false GIT_TOPLEVEL="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" if [[ -n "$GIT_TOPLEVEL" && "$GIT_TOPLEVEL" == "$REPO_ROOT" ]]; then DERIVED=true while IFS= read -r f; do [[ -n "$f" ]] && EXPECTED_FILES+=("$REPO_ROOT/$f") done < <( git -C "$REPO_ROOT" ls-files -- 'plugins/*/.apm/agents/*.agent.md' \ | grep -Ev '(^|/)\.claude/worktrees/' \ | sort || true ) else echo "Note: $REPO_ROOT is not a git worktree root, so the expected agent file set could not be derived from the index — only the zero-file floor below applies" >&2 fi if [[ "$DERIVED" == true && ${#EXPECTED_FILES[@]} -gt 0 ]]; then MISSING=() for expected in ${EXPECTED_FILES[@]+"${EXPECTED_FILES[@]}"}; do found=false for actual in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do if [[ "$actual" == "$expected" ]]; then found=true break fi done [[ "$found" == true ]] || MISSING+=("${expected#"$REPO_ROOT"/}") done if [[ ${#MISSING[@]} -gt 0 ]]; then echo "APM agent validation failed: ${#MISSING[@]} of ${#EXPECTED_FILES[@]} tracked agent file(s) were not discovered under $REPO_ROOT — they were deleted without being removed from the index, or this script's search path no longer reaches them:" >&2 for m in ${MISSING[@]+"${MISSING[@]}"}; do echo " $m" >&2 done exit 1 fi fi # Unconditional floor, separate from the derived check above: a tree with # nothing tracked (a tarball export, a fresh scaffold, a moved plugins/ root) # still must not validate an empty set and call it green. Zero files is an # error, not a pass -- that is the entire defect this script was written to # close, one level up. if [[ ${#AGENT_FILES[@]} -eq 0 ]]; then echo "APM agent validation failed: found 0 plugin-scope agent file(s) under $REPO_ROOT/plugins — the search path is wrong or every .apm/agents/ directory has been emptied. Zero files is never a pass." >&2 exit 1 fi # --- Validate --- FAIL=0 FAILED_FILES=() for f in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do rel="${f#"$REPO_ROOT"/}" # validate.sh prints its FAIL lines on stdout and its own errors on stderr; # both are captured and replayed under the filename so the reason travels with # the file that caused it. pre-commit shows a failing hook's output verbatim, # so this is what a developer reads. out="" rc=0 out="$(bash "$VALIDATE" "$f" 2>&1)" || rc=$? if [[ "$rc" -ne 0 ]]; then FAIL=1 FAILED_FILES+=("$rel") echo "FAIL: $rel (validate.sh exit $rc)" >&2 if [[ -n "$out" ]]; then printf '%s\n' "$out" | sed 's/^/ /' >&2 else echo " (validate.sh produced no output — see its exit code above; 2 means script error, e.g. a missing references/field-inventory.md)" >&2 fi fi done if [[ "$FAIL" -ne 0 ]]; then echo "" >&2 echo "APM agent validation failed: ${#FAILED_FILES[@]} of ${#AGENT_FILES[@]} agent file(s) did not pass agent-audit's validate.sh." >&2 exit 1 fi echo "APM agent validation passed: ${#AGENT_FILES[@]} plugin-scope agent file(s) validated against agent-audit's validate.sh."