#!/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/logs.md"ARTIFACTS="docs/logs"EXPECTED_SECTIONS=$'context\nchanges\ninsights\nadvice'MAX_PROSE_LINES=50MAX_NOTE_BULLETS=5MAX_COMMAS=3LOGS=()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;; *) LOGS+=("$arg");; esacdoneif [ ${#LOGS[@]} -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 "$ARTIFACTS" ]; then echo "fatal: no $ARTIFACTS/ to scan" >&2; exit 1; fi LOGS=("$ARTIFACTS"/*.md)fiEXPANDED=()for path in "${LOGS[@]}"; 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 log file: $path" >&2; exit 1; fidoneLOGS=("${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")cleanup() { 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"; }entries() { awk ' /^## / { if (start) print start "\t" NR - 1 "\t" heading; start = NR; heading = substr($0, 4); next } END { if (start) print start "\t" NR "\t" heading } ' "$1"}subsection() { awk -v s="$2" -v e="$3" -v want="### $4" ' NR < s || NR > e { next } $0 == want { inside = 1; next } /^#+ / { inside = 0 } inside { print NR "\t" $0 } ' "$1"}blocks() { awk -v s="$2" -v e="$3" ' NR < s || NR > e { next } /^#### / { if (start) print start "\t" NR - 1 "\t" heading; start = NR; heading = substr($0, 6); next } END { if (start) print start "\t" (NR < e ? NR : e) "\t" heading } ' "$1"}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}\.md$'; then err "$file" 1 filename "one log file per day, named YYYY-MM-DD.md" fi case "$dir" in "$ARTIFACTS"|"./$ARTIFACTS"|*"/$ARTIFACTS") ;; *) warn "$file" 1 location "logs live in $ARTIFACTS/";; esac}check_header() { local file=$1 expected actual expected="# $ARTIFACTS/$(basename "$file")" actual=$(sed -n '1p' "$file") if [ "$actual" != "$expected" ]; then err "$file" 1 header "line 1 must read '$expected'" fi}check_threads() { local file=$1 start end heading number expected=1 count=0 while IFS=$'\t' read -r start end heading; do count=$((count + 1)) if ! printf '%s' "$heading" | grep -qE '^Thread #[0-9]+: [^ ]+ - .'; then err "$file" "$start" thread_heading "threads read '## Thread #N: project - short description'" continue fi number=$(printf '%s' "$heading" | sed -n 's/^Thread #\([0-9]\{1,\}\):.*/\1/p') if [ "$number" -ne "$expected" ]; then err "$file" "$start" thread_numbering "thread $number where $expected was expected" fi expected=$((number + 1)) done < <(entries "$file") if [ "$count" -eq 0 ]; then warn "$file" 1 empty "seeded, holds no threads yet" fi}check_subsections() { local file=$1 start end heading actual while IFS=$'\t' read -r start end heading; do actual=$(awk -v s="$start" -v e="$end" 'NR >= s && NR <= e && /^### / { print substr($0, 5) }' "$file") if [ -z "$actual" ]; then err "$file" "$start" section_order "no ### subsections; expected $(printf '%s' "$EXPECTED_SECTIONS" | tr '\n' '>' | sed 's/>$//')" elif [ "$actual" != "$EXPECTED_SECTIONS" ]; then err "$file" "$start" section_order "got $(printf '%s' "$actual" | tr '\n' '>' | sed 's/>$//')" fi done < <(entries "$file")}check_prose() { local file=$1 start end heading count while IFS=$'\t' read -r start end heading; do count=$(awk -v s="$start" -v e="$end" ' NR < s || NR > e { next } /^#### PROMPTS/ { skip = 1; next } /^#### / { skip = 0 } skip { next } /^#+ / { next } /^[[:space:]]*$/ { next } { n++ } END { print n + 0 } ' "$file") if [ "$count" -gt "$MAX_PROSE_LINES" ]; then err "$file" "$start" thread_length "$count lines of prose; the cap is $MAX_PROSE_LINES, so split or synthesize" fi done < <(entries "$file")}check_changes() { local file=$1 start end heading body while IFS=$'\t' read -r start end heading; do body=$(subsection "$file" "$start" "$end" changes | cut -f2- | grep -v '^[[:space:]]*$' || true) if [ -z "$body" ]; then warn "$file" "$start" changes_empty "name what shipped, or say outright that nothing did" fi done < <(entries "$file")}check_advice() { local file=$1 start end heading lineno text items while IFS=$'\t' read -r start end heading; do items=0 while IFS=$'\t' read -r lineno text; do case "$text" in *"- [ ] "*|*"- [x] "*) items=$((items + 1)); continue;; *"- "*) warn "$file" "$lineno" advice_shape "advice items are checkboxes, so they can be ticked off";; esac done < <(subsection "$file" "$start" "$end" advice) if [ "$items" -eq 0 ]; then warn "$file" "$start" advice_empty "list what to pick up next, as checkboxes" fi done < <(entries "$file")}check_notes() { local file=$1 tstart tend bstart bend bheading bullets first date date=$(basename "$file" .md) while IFS=$'\t' read -r tstart tend _; do while IFS=$'\t' read -r bstart bend bheading; do case "$bheading" in "NOTE"*) ;; *) continue;; esac if ! printf '%s' "$bheading" \ | grep -qE '^NOTE: [0-9]{4}-[0-9]{2}-[0-9]{2} ([01][0-9]|2[0-3]):[0-5][0-9]$'; then err "$file" "$bstart" note_heading "notes read '#### NOTE: YYYY-MM-DD HH:MM'" continue fi case "$bheading" in "NOTE: $date "*) ;; *) err "$file" "$bstart" note_date "note is not dated $date; it belongs in that day's log";; esac bullets=$(awk -v s="$bstart" -v e="$bend" 'NR > s && NR <= e && /^- /' "$file" | wc -l | tr -d ' ') if [ "$bullets" -gt "$MAX_NOTE_BULLETS" ]; then err "$file" "$bstart" note_bullets "$bullets bullets; a note is capped at $MAX_NOTE_BULLETS" fi first=$(awk -v s="$bstart" -v e="$bend" 'NR > s && NR <= e && NF { print; exit }' "$file") case "$first" in "- "*) warn "$file" "$bstart" note_subject "open with a subject line, then the bullets";; esac done < <(blocks "$file" "$tstart" "$tend") done < <(entries "$file")}check_pending_notes() { local file=$1 last start end heading bstart bend bheading last=$(entries "$file" | tail -n 1 | cut -f1) if [ -z "$last" ]; then return 0; fi while IFS=$'\t' read -r start end heading; do if [ "$start" = "$last" ]; then continue; fi while IFS=$'\t' read -r bstart bend bheading; do case "$bheading" in "NOTE"*) ;; *) continue;; esac warn "$file" "$bstart" note_pending "a closed thread still holds a note; absorb it into the prose" done < <(blocks "$file" "$start" "$end") done < <(entries "$file")}check_prompts() { local file=$1 tstart tend bstart bend bheading count lineno text while IFS=$'\t' read -r tstart tend _; do count=0 while IFS=$'\t' read -r bstart bend bheading; do case "$bheading" in PROMPTS*) count=$((count + 1));; *) if [ "$count" -gt 0 ]; then warn "$file" "$bstart" prompts_last "prompts sit at the bottom of the thread they drove" fi continue;; esac while IFS=$'\t' read -r lineno text; do case "$text" in *[![:space:]]*) ;; *) continue;; esac if ! printf '%s' "$text" | grep -qE '^- ([01][0-9]|2[0-3]):[0-5][0-9] .'; then err "$file" "$lineno" prompt_shape "prompts read '- HH:MM the rewritten ask', one per line" fi done < <(awk -v s="$bstart" -v e="$bend" 'NR > s && NR <= e { print NR "\t" $0 }' "$file") done < <(blocks "$file" "$tstart" "$tend") if [ "$count" -eq 0 ]; then warn "$file" "$tstart" prompts_missing "no prompts; the ask belongs next to what came of it" elif [ "$count" -gt 1 ]; then err "$file" "$tstart" prompts_split "$count prompt blocks; a thread keeps one list" fi done < <(entries "$file")}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_formatting() { local file=$1 lineno=0 line commas while IFS= read -r line; do lineno=$((lineno + 1)) case "$line" in "#"*) continue;; esac case "$line" in *—*) warn "$file" "$lineno" em_dash "em dash; a comma or a full stop reads cleaner here";; esac case "$line" in *'**'*) warn "$file" "$lineno" bold "bold in a log; the heading already carries the emphasis";; esac commas=$(printf '%s' "$line" | tr -cd ',' | wc -c | tr -d ' ') if [ "$commas" -ge "$MAX_COMMAS" ]; then warn "$file" "$lineno" comma_chain "$commas commas; one clause per line reads faster" fi done < "$file"}check_placeholders() { local file=$1 hit token while IFS= read -r hit; do if [ -z "$hit" ]; then continue; fi token=$(printf '%s' "${hit#*:}" \ | grep -oE 'YYYY-MM-DD|HH:MM|\*example:\*|short description|Repeat the above format' | head -n 1) err "$file" "${hit%%:*}" placeholder "template scaffolding survived: $token" done < <(grep -nE 'YYYY-MM-DD|HH:MM|\*example:\*|short description|Repeat the above format' "$file" || true)}for log in "${LOGS[@]}"; do check_filename "$log" check_header "$log" check_threads "$log" check_subsections "$log" check_prose "$log" check_changes "$log" check_advice "$log" check_notes "$log" check_pending_notes "$log" check_prompts "$log" check_width "$log" check_formatting "$log" check_placeholders "$log" scan_secrets "$log"doneERRORS=$(grep -c '^ERROR|' "$FINDINGS" || true)WARNINGS=$(grep -c '^WARN|' "$FINDINGS" || true)SECRETS=$(grep -c '|secret|' "$FINDINGS" || true)cat <<EOF=== logs.sh sidecar ===template: $TEMPLATEscanned: ${#LOGS[@]} log file(s)width_cap: $MAX_WIDTH charsprose_cap: $MAX_PROSE_LINES lines per threaderrors: $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) ---- notes are incorporated and deleted; prompts are pruned and rewritten, and stay a list forever- a prompt that started a new thread belongs to the thread it created, not the one before it- prune a prompt once it is trivial, redundant, or superseded by the one after it- keep the prompt that changed direction, dropped a constraint, or corrected a wrong assumption- rewrite every prompt short; never paste the original wording back in- focus on outcomes, not the conversation; no play-by-plays- insights are a brutally honest retrospective, including what went wrong- err on the side of brevity, and capture only the signals worth reading tomorrow- 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