#!/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"UTF8_LOCALE=$(locale -a 2>/dev/null | grep -iE '^(C|en_US)\.(utf-?8)$' | head -n 1 || true)if [ -n "$UTF8_LOCALE" ]; then export LC_ALL="$UTF8_LOCALE"; fiMAX_WIDTH=100STRICT=0KEEP=0TEMPLATE="AGENTS/templates/plans.md"EXPECTED_SECTIONS=$'Context\nGoal\nSolution\nRisks\nChecklist\nReadiness\nNotes'EXPECTED_READINESS=$'Blockers\nAgents\nPermissions'PERMISSION_ACTIONS=$'add to allow\nnarrow deny\nremove from deny\nadd to deny\nadd to allowWrite\nadd to allowRead\nadd to denyRead\nadd to allowedDomains\nadd to deniedDomains\nadd to excludedCommands'PERMISSION_LAYERS=$'permissions\nsandbox filesystem\nsandbox domain'PERMISSION_SCOPES=$'user\nproject\nlocal\ncli'PLANS=()for arg in "$@"; do case "$arg" in --strict) STRICT=1;; --keep) KEEP=1;; -h|--help) sed -n '2,12p' "$0"; exit 0;; -*) echo "fatal: unknown flag $arg" >&2; exit 1;; *) PLANS+=("$arg");; esacdoneif [ ${#PLANS[@]} -eq 0 ]; then if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "fatal: not a git repository, and no paths given" >&2; exit 1; fi cd "$(git rev-parse --show-toplevel)" if [ ! -d docs/plans ]; then echo "fatal: no docs/plans/ to scan" >&2; exit 1; fi PLANS=(docs/plans/*.md)fiEXPANDED=()for path in "${PLANS[@]}"; do if [ -d "$path" ]; then for nested in "$path"/*.md; do [ -f "$nested" ] && EXPANDED+=("$nested"); done elif [ -f "$path" ]; then EXPANDED+=("$path") else echo "fatal: no such plan: $path" >&2; exit 1; fidonePLANS=("${EXPANDED[@]}")TMPROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/tmp"TMPTAG=$(basename "${BASH_SOURCE[0]}" .sh)mkdir -p "$TMPROOT"FINDINGS=$(mktemp "$TMPROOT/$TMPTAG-findings.XXXXXX")SCRATCH=$(mktemp -d "$TMPROOT/$TMPTAG-scratch.XXXXXX")cleanup() { st=$?; if [ "$KEEP" -eq 0 ] && [ "$st" -eq 0 ]; then rm -rf "$FINDINGS" "$SCRATCH"; 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"; }section() { awk -v want="## $2" ' $0 == want { inside = 1; next } /^## / { inside = 0 } inside { print NR "\t" $0 } ' "$1"}table_cells() { printf '%s' "$1" | sed 's/\\|/~/g' | awk -F'|' '{ n = NF - 2; if (n < 0) n = 0; print n }'}row_cell() { printf '%s' "$1" | sed 's/\\|/~/g' \ | awk -F'|' -v n="$2" '{ cell = $(n + 1); gsub(/^[ \t]+|[ \t]+$/, "", cell); print cell }'}col_index() { printf '%s' "$1" | sed 's/\\|/~/g' | awk -F'|' -v want="$2" ' { for (i = 2; i < NF; i++) { cell = $i gsub(/^[ \t]+|[ \t]+$/, "", cell) if (tolower(cell) == want) { print i - 1; exit } } }'}count_cell() { case "$1" in ""|"—"|"–"|"-") printf '0';; *[!0-9]*) printf '';; *) printf '%s' "$1";; esac}stage_items() { local file=$1 want=$2 lineno text stage current=0 count=0 while IFS=$'\t' read -r lineno text; do case "$text" in "### "*) stage=$(printf '%s' "$text" | sed -n 's/^### \([0-9]\{1,\}\)\..*/\1/p') current=${stage:-0} continue;; "- [ ] "*|"- [x] "*) if [ "$current" = "$want" ]; then count=$((count + 1)); fi continue;; esac done < <(section "$file" Checklist) printf '%s' "$count"}check_filename() { local file=$1 base dir base=$(basename "$file") dir=$(dirname "$file") if ! printf '%s' "$base" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}-operation-[A-Za-z0-9-]+\.md$'; then err "$file" 1 filename "expected YYYY-MM-DD-operation-<title>.md" fi case "$dir" in docs/plans|./docs/plans|*/docs/plans) ;; *) warn "$file" 1 location "one plan per file, all of them in docs/plans/";; esac}check_tracked() { local file=$1 if ! git ls-files --error-unmatch "$file" >/dev/null 2>&1; then warn "$file" 1 untracked "plans are tracked in git; this one is not committed yet" fi}check_header() { local file=$1 title summary blank title=$(sed -n '1p' "$file") summary=$(sed -n '2p' "$file") blank=$(sed -n '3p' "$file") case "$title" in "# AGENT PLAN: Operation"*) ;; "# AGENT PLAN:"*) warn "$file" 1 title "the template names every plan an Operation";; *) err "$file" 1 title "line 1 must read '# AGENT PLAN: Operation <title>'";; esac if [ -z "$summary" ] || [ "${summary:0:1}" = "#" ]; then err "$file" 2 summary "line 2 must be the one plain-english line: what this plan does" fi if [ -n "$blank" ]; then err "$file" 3 summary "line 3 must be blank; the summary is one line and never wraps" fi}check_sections() { local file=$1 actual actual=$(grep -E '^## ' "$file" | sed 's/^## //' || true) if [ "$actual" != "$EXPECTED_SECTIONS" ]; then err "$file" 1 section_order "got $(printf '%s' "$actual" | tr '\n' '>' | sed 's/>$//')" fi}check_width() { local file=$1 lineno=0 line while IFS= read -r line; do lineno=$((lineno + 1)) if [ ${#line} -gt "$MAX_WIDTH" ]; then err "$file" "$lineno" width "${#line} chars; the cap is $MAX_WIDTH" fi done < "$file"}check_fences() { local file=$1 count count=$(grep -cE '^[[:space:]]*```' "$file" || true) if [ $((count % 2)) -ne 0 ]; then err "$file" 1 fences "$count fence markers; one is unclosed" fi}check_clauses() { local file=$1 name lineno text started for name in Context Solution Risks; do started=0 while IFS=$'\t' read -r lineno text; do if [ -z "$text" ]; then continue; fi case "$text" in "- "*) started=1; continue;; esac if [ "$started" -eq 0 ]; then started=1; continue; fi err "$file" "$lineno" wrapped_clause "$name lines carry a single clause and never wrap" done < <(section "$file" "$name") done}check_goal() { local file=$1 body lineno text body=$(section "$file" Goal | cut -f2-) case "$body" in *'```'*|*'|'*) ;; *) warn "$file" 1 goal_diagram "the goal needs a tree, diagram or table beneath its one line";; esac while IFS=$'\t' read -r lineno text; do case "$text" in *"- [ ]"*|*"- [x]"*) warn "$file" "$lineno" goal_markers "no change markers or stage numbers in the goal";; esac done < <(section "$file" Goal)}check_risks() { local file=$1 lineno text while IFS=$'\t' read -r lineno text; do case "$text" in "- "*) ;; *) continue;; esac printf '%s' "$text" | grep -qE '^- `[^`]+`' \ || err "$file" "$lineno" risk_label 'risk needs a leading `noun` label, never a category' done < <(section "$file" Risks)}check_checklist() { local file=$1 lineno text stage previous=0 items=0 deferred=0 while IFS=$'\t' read -r lineno text; do if [ -z "$text" ]; then continue; fi case "$text" in "### Deferred Work") items=0 deferred=$lineno continue;; "### "*) items=0 if [ "$deferred" -ne 0 ]; then warn "$file" "$lineno" stage_after_deferred "deferred work closes the checklist" fi stage=$(printf '%s' "$text" | sed -n 's/^### \([0-9]\{1,\}\)\..*/\1/p') if [ -z "$stage" ]; then err "$file" "$lineno" stage_header "stages are '### <n>. <name>'" else if [ "$stage" -ne $((previous + 1)) ]; then warn "$file" "$lineno" stage_order "stage $stage follows $previous; the numbering skips" fi previous=$stage fi continue;; "- [ ] "*|"- [x] "*) items=1; continue;; " "*) continue;; "**"*) continue;; esac if [ "$items" -eq 1 ]; then warn "$file" "$lineno" checklist_prose "no prose between items; point at a note instead" fi done < <(section "$file" Checklist)}check_agents_row() { local file=$1 lineno=$2 text=$3 header=$4 stage agentic gated human total items stage=$(row_cell "$text" "$(col_index "$header" stage)" | sed -n 's/^\([0-9]\{1,\}\)\..*/\1/p') if [ -z "$stage" ]; then err "$file" "$lineno" agents_row "each row leads with a numbered stage from the checklist" return fi agentic=$(count_cell "$(row_cell "$text" "$(col_index "$header" agentic)")") gated=$(count_cell "$(row_cell "$text" "$(col_index "$header" gated)")") human=$(count_cell "$(row_cell "$text" "$(col_index "$header" human-only)")") if [ -z "$agentic" ] || [ -z "$gated" ] || [ -z "$human" ]; then err "$file" "$lineno" agents_counts "a count is a number or an em dash, never prose" return fi total=$((agentic + gated + human)) items=$(stage_items "$file" "$stage") if [ "$items" -eq 0 ]; then err "$file" "$lineno" agents_stage "stage $stage has no checklist items to classify" elif [ "$total" -ne "$items" ]; then err "$file" "$lineno" agents_sum "counts sum to $total; stage $stage holds $items items" fi}# "every permission rule is quoted exactly from a settings file", so the cell carries a real# rule string; the action vocabulary is closed because deny beats allowcheck_permissions_row() { local file=$1 lineno=$2 text=$3 header=$4 rule layer scope action rule=$(row_cell "$text" "$(col_index "$header" rule)") layer=$(row_cell "$text" "$(col_index "$header" layer)") scope=$(row_cell "$text" "$(col_index "$header" scope)") action=$(row_cell "$text" "$(col_index "$header" suggestion)") if [ "$layer" = "permissions" ]; then printf '%s' "$rule" | grep -qE '`[A-Za-z]+\([^`]*\)`' \ || err "$file" "$lineno" permission_rule 'quote the rule itself, as `Tool(pattern)`' else printf '%s' "$rule" | grep -qE '`[^`]+`' \ || err "$file" "$lineno" permission_rule 'quote the path or host in backticks' fi if ! printf '%s' "$PERMISSION_LAYERS" | grep -qxF "$layer"; then err "$file" "$lineno" permission_layer "layer is one of: $(printf '%s' "$PERMISSION_LAYERS" | tr '\n' '/')" fi if ! printf '%s' "$PERMISSION_SCOPES" | grep -qxF "$scope"; then err "$file" "$lineno" permission_scope "scope is one of: $(printf '%s' "$PERMISSION_SCOPES" | tr '\n' '/')" fi if ! printf '%s' "$PERMISSION_ACTIONS" | grep -qxF "$action"; then err "$file" "$lineno" permission_action "action is one of: $(printf '%s' "$PERMISSION_ACTIONS" | tr '\n' '/')" fi}check_table_header() { local file=$1 lineno=$2 header=$3 sub=$4 name missing="" case "$sub" in Agents) set -- stage agentic gated human-only;; Permissions) set -- rule layer scope suggestion;; *) return 0;; esac for name in "$@"; do if [ -z "$(col_index "$header" "$name")" ]; then missing="$missing $name"; fi done if [ -n "$missing" ]; then err "$file" "$lineno" table_header "$sub is missing a column:$missing" return 1 fi return 0}check_readiness() { local file=$1 lineno text actual sub="" header=0 cells hdr="" if [ -z "$(section "$file" Readiness)" ]; then return; fi actual=$(section "$file" Readiness | cut -f2- | grep -E '^### ' | sed 's/^### //' || true) if [ "$actual" != "$EXPECTED_READINESS" ]; then err "$file" 1 readiness_order "got $(printf '%s' "$actual" | tr '\n' '>' | sed 's/>$//')" fi while IFS=$'\t' read -r lineno text; do case "$text" in "### "*) sub=$(printf '%s' "$text" | sed 's/^### //'); header=0; hdr=""; continue;; "|"*) ;; *) continue;; esac cells=$(table_cells "$text") if [ "$header" -eq 0 ]; then header=$cells hdr=$text check_table_header "$file" "$lineno" "$hdr" "$sub" || hdr="" continue fi if [ "$cells" -ne "$header" ]; then err "$file" "$lineno" table_shape "$cells columns where the header has $header" continue fi case "$text" in *---*) continue;; esac if [ -z "$hdr" ]; then continue; fi case "$sub" in Agents) check_agents_row "$file" "$lineno" "$text" "$hdr";; Permissions) check_permissions_row "$file" "$lineno" "$text" "$hdr";; esac done < <(section "$file" Readiness)}check_notes() { local file=$1 lineno text number expected=1 missing orphan hit : > "$SCRATCH/defined" while IFS=$'\t' read -r lineno text; do number=$(printf '%s' "$text" | sed -n 's/^\([0-9]\{1,\}\)\. .*/\1/p') if [ -z "$number" ]; then continue; fi printf '%s\n' "$number" >> "$SCRATCH/defined" if [ "$number" -ne "$expected" ]; then err "$file" "$lineno" note_numbering "note $number where $expected was expected; renumbering breaks every reference" fi expected=$((number + 1)) done < <(section "$file" Notes) sort -un "$SCRATCH/defined" -o "$SCRATCH/defined" grep -oE 'see #[0-9]+([[:space:]]*,[[:space:]]*#[0-9]+)*' "$file" 2>/dev/null \ | grep -oE '[0-9]+' | sort -un > "$SCRATCH/refs" || true if [ ! -s "$SCRATCH/refs" ]; then : > "$SCRATCH/refs"; fi while IFS= read -r missing; do if [ -z "$missing" ]; then continue; fi hit=$(grep -nE "see #$missing\\b" "$file" | head -n 1 | cut -d: -f1 || true) err "$file" "${hit:-1}" dangling_note "(see #$missing) resolves to nothing" done < <(comm -23 "$SCRATCH/refs" "$SCRATCH/defined") while IFS= read -r orphan; do if [ -z "$orphan" ]; then continue; fi hit=$(grep -nE "^$orphan\\. " "$file" | head -n 1 | cut -d: -f1 || true) warn "$file" "${hit:-1}" orphan_note "note $orphan: dead weight, or a missing (see #$orphan)" done < <(comm -13 "$SCRATCH/refs" "$SCRATCH/defined")}for plan in "${PLANS[@]}"; do check_filename "$plan" check_tracked "$plan" check_header "$plan" check_sections "$plan" check_width "$plan" check_fences "$plan" check_clauses "$plan" check_goal "$plan" check_risks "$plan" check_checklist "$plan" check_readiness "$plan" check_notes "$plan" scan_secrets "$plan"doneERRORS=$(grep -c '^ERROR|' "$FINDINGS" || true)WARNINGS=$(grep -c '^WARN|' "$FINDINGS" || true)SECRETS=$(grep -c '|secret|' "$FINDINGS" || true)cat <<EOF=== plans.sh sidecar ===template: $TEMPLATEscanned: ${#PLANS[@]} plan(s)width_cap: $MAX_WIDTH charserrors: $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) ---- written before complex or architectural work, not after it- maximally clear, concise, action-oriented; plain english over jargon, facts over metaphor- body sections state conclusions only; the reasoning lives in the numbered notes- solution lines are decisions; a line that could be pasted into the checklist belongs there- risks sort by blast radius and irreversibility, never by likelihood- stages run in sequence and each ships as its own pr- readiness states feasibility only; a row proposing new work belongs in the checklist- every permission rule is quoted from a settings file, or the row says it is a proposal- a bash step writing outside the working directory has a sandbox row, not only a permission one- blockers are unrelated work found in other open plans, not work this plan creates- every list is ordered deliberately; if the order is not obvious, a note says why- a claim with a number in it is verified before it lands, or it does not land- notes are self-contained, since readers jump in from one line and jump straight back- 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