#!/bin/bashset -euo pipefailSHARED=$(cd "$(dirname "${BASH_SOURCE[0]}")/../settings" 2>/dev/null && pwd || true)if [ ! -f "$SHARED/secrets.sh" ]; then echo "fatal: no AGENTS/settings/secrets.sh beside this sidecar" >&2; exit 1; fi. "$SHARED/secrets.sh"STRICT=0KEEP=0TEMPLATE="AGENTS/templates/git.md"TRIGGERS="AGENTS/skills"POSTURES='READ-ONLY|SAFE|GATED|DESTRUCTIVE|RELEASE'PAIRS=()for arg in "$@"; do case "$arg" in --strict) STRICT=1;; --keep) KEEP=1;; -h|--help) sed -n '2,11p' "$0"; exit 0;; -*) echo "fatal: unknown flag $arg" >&2; exit 1;; *) PAIRS+=("$arg");; esacdoneif ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "fatal: not a git repository" >&2; exit 1; ficd "$(git rev-parse --show-toplevel)"if [ ${#PAIRS[@]} -eq 0 ]; then if [ ! -d "$TRIGGERS" ]; then echo "fatal: no $TRIGGERS/ to scan" >&2; exit 1; fi PAIRS=("$TRIGGERS")fitrigger_name() { local doc=$1 if [ "$(basename "$doc")" = 'SKILL.md' ]; then basename "$(dirname "$doc")"; return; fi basename "$doc" .md}is_trigger_name() { [ "$(basename "$1")" = 'SKILL.md' ] || return 1 printf '%s' "$(basename "$(dirname "$1")")" | grep -qE '^[a-z]+(-[a-z]+)*$'}# a sub-tool is invoked by another script rather than by a trigger, so it has no doc to pair with;# listing them beats guessing, since nothing in the filename says which kind a script isis_subtool() { case "$(basename "$1")" in permissions.sh|scopes.sh|secrets.sh|handover.sh) return 0;; *) return 1;; esac}# a pair is named by its doc, so a directory expands to the docs inside it and a sidecar maps back# to the doc that is supposed to drive it — that mapping is what surfaces an orphaned scriptEXPANDED=()for path in "${PAIRS[@]}"; do if [ -d "$path" ]; then for nested in "$path"/*/SKILL.md "$path"/SKILL.md; do [ -f "$nested" ] || continue is_trigger_name "$nested" || continue EXPANDED+=("$nested") done for nested in "$path"/*/*.sh "$path"/*.sh; do [ -f "$nested" ] || continue is_subtool "$nested" && continue [ -f "$(dirname "$nested")/SKILL.md" ] || EXPANDED+=("$nested") done elif [ -f "$path" ]; then EXPANDED+=("$path") else echo "fatal: no such trigger file: $path" >&2; exit 1; fidonePAIRS=("${EXPANDED[@]}")# the index that documents each trigger: a host project reaches it through the AGENTS.md symlink,# and this repo is the one place where the same file is called README.mdINDEX=''if [ -f AGENTS.md ]; then INDEX=AGENTS.mdelif [ -f README.md ]; then INDEX=README.md; fi# repo-local scratch: the sandbox denies writes outside cwd, and macos mktemp ignores TMPDIRTMPROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/tmp"TMPTAG=$(basename "${BASH_SOURCE[0]}" .sh)mkdir -p "$TMPROOT"# findings collect as "SEV|file|line|category|detail" — line is its own field so the report can# sort numerically; joining it to the path first sorts 121 above 31. the run fails on ERROR onlyFINDINGS=$(mktemp "$TMPROOT/$TMPTAG-findings.XXXXXX")# a failed run leaves scratch behind to read; --keep does the same after a clean onecleanup() { st=$?; if [ "$KEEP" -eq 0 ] && [ "$st" -eq 0 ]; then rm -f "$FINDINGS"; fi; }trap cleanup EXITerr() { printf 'ERROR|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" >> "$FINDINGS"; }warn() { printf 'WARN|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" >> "$FINDINGS"; }# the line a pattern first lands on, so a finding points at the file's own line rather than line 1where() { local hit hit=$(grep -nE "$2" "$1" 2>/dev/null | head -n 1 | cut -d: -f1 || true) printf '%s' "${hit:-1}"}# ==============# CHECKS# each takes a doc path and appends findings; to add one, write a function and list it below# ==============# "one trigger doc per workflow, each paired with its shell sidecar" — the pair is what makes a# trigger reachable, so the sidecar is looked for beside the doc rather than in one fixed foldercheck_pair() { local doc=$1 name sidecar name=$(trigger_name "$doc") sidecar="$(dirname "$doc")/$name.sh" if ! printf '%s' "$name" | grep -qE '^[a-z]+(-[a-z]+)*$'; then err "$doc" 1 filename "the skill folder is the command name, so it is lowercase kebab-case" fi if [ ! -f "$sidecar" ]; then err "$doc" 1 unpaired "no $sidecar; every trigger doc starts with a shell sidecar" return 0 fi if [ ! -x "$sidecar" ]; then warn "$sidecar" 1 not_executable "chmod +x, so the documented invocation works as written" fi}# the wayfinding block from AGENTS.md, which is how anyone reading the doc learns its boundariescheck_doc_wayfinding() { local doc=$1 name opens_on=1 frontmatter_end name=$(trigger_name "$doc") # a skill doc opens with yaml frontmatter, since the harness only reads it from line 1 if [ "$(sed -n '1p' "$doc")" = '---' ]; then frontmatter_end=$(awk 'NR > 1 && $0 == "---" { print NR; exit }' "$doc") if [ -n "$frontmatter_end" ]; then opens_on=$((frontmatter_end + 1)); fi fi if [ "$(sed -n "${opens_on}p" "$doc")" != '```javascript' ]; then err "$doc" "$opens_on" wayfinding "line $opens_on opens the wayfinding block: \`\`\`javascript" fi if ! grep -qE "^ \* @file SKILL\.md - " "$doc"; then err "$doc" 1 wayfinding "@file must read 'SKILL.md - <short, specific title>'" fi if ! grep -q '^ \* @description' "$doc"; then err "$doc" 1 wayfinding "no @description; the header is what stops a reader guessing" fi if ! grep -q '^ \* @see' "$doc"; then err "$doc" 1 wayfinding "no @see; list every related internal file" return 0 fi if ! grep -q "^ \* @see.*$TEMPLATE" "$doc"; then warn "$doc" "$(where "$doc" '^ \* @see')" wayfinding "@see should name $TEMPLATE, the shape it follows" fi if ! grep -q "^ \* @see.*$(dirname "$doc")/$name\.sh" "$doc"; then warn "$doc" "$(where "$doc" '^ \* @see')" wayfinding \ "@see should name its own sidecar, $(dirname "$doc")/$name.sh" fi}check_trigger() { local doc=$1 name name=$(trigger_name "$doc") if ! grep -qE '^disable-model-invocation:[[:space:]]*(true|yes|on|1)[[:space:]]*$' "$doc"; then err "$doc" 1 trigger "frontmatter needs 'disable-model-invocation: true'; prose is not a gate" fi}check_invocation() { local doc=$1 name home name=$(trigger_name "$doc") home=$(dirname "$doc") if ! grep -qE "($home|skills/$name)/$name\.sh([[:space:]]|\"|$)" "$doc"; then err "$doc" 1 invocation "the doc never runs $home/$name.sh" fi if ! grep -qE '^```!' "$doc"; then warn "$doc" 1 invocation "run the sidecar from a \`\`\`! block, so it lands before the model reads" fi}check_branches() { local doc=$1 if grep -qE 'report-only|never fails' "$doc"; then return 0; fi if ! grep -qE '(exit code|sidecar exit)[^0-9]*(>|>=|!=)[[:space:]]*[0-9]|nonzero' "$doc"; then err "$doc" 1 no_failure_branch "no failure branch; say what happens when the sidecar exits nonzero" fi if ! grep -qE '(exit code|sidecar exit)[^0-9]*=+[[:space:]]*0' "$doc"; then err "$doc" 1 no_success_branch "no success branch; say what the telemetry means and what follows" fi}check_artifact() { local doc=$1 type for type in audits honest insights logs plans study; do if ! grep -q "docs/$type/" "$doc"; then continue; fi if ! grep -q "AGENTS/templates/$type\.md" "$doc"; then warn "$doc" "$(where "$doc" "docs/$type/")" artifact \ "writes docs/$type/ without naming AGENTS/templates/$type.md" fi done}check_sidecar_header() { local doc=$1 name sidecar name=$(trigger_name "$doc") sidecar="$(dirname "$doc")/$name.sh" if [ ! -f "$sidecar" ]; then return 0; fi if [ "$(sed -n '1p' "$sidecar")" != '#!/bin/bash' ]; then err "$sidecar" 1 shebang "line 1 must read '#!/bin/bash'" fi if ! grep -qE "^# @file $name\.sh - " "$sidecar"; then err "$sidecar" 1 wayfinding "@file must read '$name.sh - <short, specific title>'" fi if ! grep -q '^# @description' "$sidecar"; then err "$sidecar" 1 wayfinding "no @description; the header is what stops a reader guessing" fi if ! grep -q '^# @see' "$sidecar"; then err "$sidecar" 1 wayfinding "no @see; list every related internal file" fi if ! grep -q 'set -euo pipefail' "$sidecar"; then warn "$sidecar" 1 no_strict_mode "no 'set -euo pipefail'; a silent partial run is worse than a stop" fi}check_sidecar_lint() { local doc=$1 name sidecar hit line name=$(trigger_name "$doc") sidecar="$(dirname "$doc")/$name.sh" if [ ! -f "$sidecar" ]; then return 0; fi if ! bash -n "$sidecar" 2>/dev/null; then err "$sidecar" 1 syntax "does not parse; 'bash -n' is the first gate ci runs" return 0 fi if ! command -v shellcheck >/dev/null 2>&1; then return 0; fi while IFS= read -r hit; do if [ -z "$hit" ]; then continue; fi line=$(printf '%s' "$hit" | cut -d: -f2) warn "$sidecar" "${line:-1}" shellcheck "$(printf '%s' "$hit" | cut -d: -f4- | sed 's/^ *//')" done < <(shellcheck -f gcc --severity=warning "$sidecar" 2>/dev/null || true)}check_index() { local doc=$1 name entry name=$(trigger_name "$doc") if [ -z "$INDEX" ]; then return 0; fi entry=$(grep -nE "\(AGENTS/skills/$name/SKILL\.md\)" "$INDEX" | head -n 1 || true) if [ -z "$entry" ]; then err "$doc" 1 unindexed "$INDEX does not list /$name; nobody will find it" return 0 fi if ! printf '%s' "$entry" | grep -qE "($POSTURES)"; then warn "$INDEX" "${entry%%:*}" no_posture "@$name is listed without a posture keyword" fi}check_scrub() { local doc=$1 name file name=$(trigger_name "$doc") for file in "$doc" "$(dirname "$doc")/$name.sh"; do if [ -f "$file" ]; then scan_secrets "$file"; fi done}for pair in "${PAIRS[@]}"; do case "$pair" in *.sh) err "$pair" 1 unpaired "no ${pair%.sh}.md; a sidecar without a trigger doc is unreachable" continue;; esac check_pair "$pair" check_doc_wayfinding "$pair" check_trigger "$pair" check_invocation "$pair" check_branches "$pair" check_artifact "$pair" check_sidecar_header "$pair" check_sidecar_lint "$pair" check_index "$pair" check_scrub "$pair"doneERRORS=$(grep -c '^ERROR|' "$FINDINGS" || true)WARNINGS=$(grep -c '^WARN|' "$FINDINGS" || true)SECRETS=$(grep -c '|secret|' "$FINDINGS" || true)cat <<EOF=== git.sh sidecar ===template: $TEMPLATEscanned: ${#PAIRS[@]} pair(s)index: ${INDEX:-none found}errors: $ERRORSwarnings: $WARNINGSsecrets: $SECRETS--- findings ---EOFif [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then echo "none — every machine-checkable rule holds"else sort -t'|' -k1,1 -k2,2 -k3,3n "$FINDINGS" \ | awk -F'|' '{ printf "%-5s %-50s %-17s %s\n", $1, $2 ":" $3, $4, $5 }'fiif [ "$SECRETS" -gt 0 ]; then cat <<EOF--- secrets ---STOP: $SECRETS unambiguous credential match(es) above- do NOT truncate or edit anything yet; ask the user which match is real and what to do about it- a key that already reached a commit is leaked, and truncating the file does not un-leak it- rotate the credential first, then agree what the file should say in its placeEOFficat <<'EOF'--- needs a human (template rules no script can judge) ---- the trigger fires only on the explicit command, and is never inferred from intent- failure hands back the raw terminal error, never a summary or a paraphrase of it- success evaluates the telemetry first, and only then takes the documented action- every scenario the sidecar can report has a branch in the doc that reads it- destructive steps stay gated behind an explicit confirmation the user has to type- the sidecar is the only half that touches git; the doc only decides what its output means- the posture in the index matches what the sidecar actually does, not what it was written to do- a key that reached a commit is already leaked; rotate it before rewriting anything========================EOFif [ "$ERRORS" -gt 0 ]; then exit 1; fiif [ "$STRICT" -eq 1 ] && [ "$WARNINGS" -gt 0 ]; then exit 1; fiexit 0