#!/usr/bin/env bash
# scripts/ledger-scan.sh -- machine-derived "what is still OPEN" view of the repo.
#
# The session ledger (docs/session-ledger.md) is a human narrative; it can drift.
# This script is the DRIFT CHECK: it greps the repo for the open-work markers that
# can be reliably derived -- PROPOSED/OPEN decisions, OPEN security-ledger rows,
# and the next-free D/DOCFIX/BUNDLEFIX numbers -- so the narrative ledger can be
# reconciled against ground truth at session start and before every handoff.
# Read-only; mutates nothing. Exit: 0 always (it reports; it does not judge).
#
# Reconcile rule: the narrative ledger must not claim CLOSED anything this scan
# shows OPEN, and must not omit a decision/number this scan surfaces.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="${VR_REPO:-$(cd "$HERE/.." && pwd)}"
DD="$REPO/docs/design-decisions.md"
SL="$REPO/docs/security-ledger.md"
SESSL="$REPO/docs/session-ledger.md"
# --- fence validator (delimited-section convention) --------------------------
# Validates STRUCTURAL integrity of the ledger ownership fences:
# <!-- SECTION: <name> | OWNER: <owner> --> ... <!-- END: <name> -->
# FATAL: unmatched / overlapping (no nesting) / duplicate-name / unclosed.
# WARN (non-fatal): unknown section name, missing/empty OWNER. No fences = OK
# (transition state). Separate --fences MODE; default report below is unchanged.
_vf_trim(){ local x="$1"; x="${x#"${x%%[![:space:]]*}"}"; x="${x%"${x##*[![:space:]]}"}"; printf '%s' "$x"; }
validate_fences() {
local f="$1"
[ -r "$f" ] || { echo " HOLD: session-ledger not readable: $f"; return 2; }
local errs=0 warns=0 nfence=0 ln=0
local open_name="" open_line=0 seen=" " known=" machine-derived main-chat jumphost shared "
local line inner name owner ename
while IFS= read -r line || [ -n "$line" ]; do
ln=$((ln+1))
case "$line" in
*'<!-- SECTION:'*)
nfence=$((nfence+1))
inner="${line#*<!-- SECTION:}"; inner="${inner%%-->*}"
name="$(_vf_trim "${inner%%|*}")"
if [ "$inner" != "${inner#*OWNER:}" ]; then owner="$(_vf_trim "${inner#*OWNER:}")"; else owner="__none__"; fi
if [ -n "$open_name" ]; then echo " ERROR line $ln: SECTION $name opened while $open_name (line $open_line) still open -- no nesting"; errs=$((errs+1)); fi
if [ -z "$name" ]; then echo " ERROR line $ln: SECTION marker with unparseable name"; errs=$((errs+1))
else
case "$seen" in *" $name "*) echo " ERROR line $ln: duplicate SECTION name $name"; errs=$((errs+1));; esac
case "$known" in *" $name "*) : ;; *) echo " WARN line $ln: unknown section name $name (known: main-chat jumphost machine-derived shared)"; warns=$((warns+1));; esac
if [ "$owner" = "__none__" ] || [ -z "$owner" ]; then echo " WARN line $ln: SECTION $name has missing/empty OWNER"; warns=$((warns+1)); fi
open_name="$name"; open_line="$ln"; seen="$seen$name "
fi
;;
*'<!-- END:'*)
inner="${line#*<!-- END:}"; ename="$(_vf_trim "${inner%%-->*}")"
if [ -z "$open_name" ]; then echo " ERROR line $ln: END $ename with no open SECTION"; errs=$((errs+1))
elif [ "$ename" != "$open_name" ]; then echo " ERROR line $ln: END $ename does not match open SECTION $open_name (line $open_line)"; errs=$((errs+1)); open_name=""
else open_name=""; fi
;;
esac
done < "$f"
[ -n "$open_name" ] && { echo " ERROR: unclosed SECTION $open_name opened at line $open_line"; errs=$((errs+1)); }
echo " fences: $nfence errors: $errs warnings: $warns"
if [ "$errs" -gt 0 ]; then echo " FENCE VALIDATION: FAIL"; return 1; fi
[ "$nfence" -eq 0 ] && echo " (no fences yet -- partial/unfenced ledger is OK during transition)"
echo " FENCE VALIDATION: OK"; return 0
}
if [ "${1:-}" = "--fences" ]; then
echo "=== ledger-scan --fences: session-ledger fence integrity ($SESSL) ==="
validate_fences "$SESSL"; exit $?
fi
echo "=== ledger-scan: open-work ground truth (repo: $REPO) ==="
echo "-- PROPOSED / OPEN decisions --"
if [ -r "$DD" ]; then
# a decision header line whose status line (within the next few lines) is PROPOSED/OPEN.
# A decision is OPEN iff the LAST Status line in its block is PROPOSED/OPEN.
# (A later RESOLVED/CLOSED/SUPERSEDED amendment closes it -- so we track the last
# status per block, not the first; this is the D-063-amendment drift the first
# real run surfaced.)
OPEN_DEC="$(awk '
/^## D-[0-9]+:/ { if (hdr!="" && laststat ~ /PROPOSED|OPEN/) print " " hdr; sub(/^## /,"",$0); hdr=$0; laststat="" }
/\*\*Status:\*\*/ { laststat=$0 }
END { if (hdr!="" && laststat ~ /PROPOSED|OPEN/) print " " hdr }
' "$DD")"
if [ -n "$OPEN_DEC" ]; then printf '%s\n' "$OPEN_DEC"; else echo " (none)"; fi
else echo " (design-decisions.md not found)"; fi
echo "-- OPEN security-ledger rows --"
if [ -r "$SL" ]; then
grep -E '^\|[[:space:]]*SEC-[0-9]' "$SL" | awk -F'|' 'toupper($(NF-1)) ~ /OPEN|PENDING/ {gsub(/^ *| *$/,"",$2); gsub(/^ *| *$/,"",$7); print " " $2 " (" $7 ")"}' || echo " (none)"
[ -z "$(grep -E '^\|[[:space:]]*SEC-[0-9]' "$SL" | awk -F'|' 'toupper($(NF-1)) ~ /OPEN|PENDING/')" ] && echo " (none open)"
else echo " (security-ledger.md not found)"; fi
echo "-- next-free numbers (highest assigned + 1) --"
# Exclude "next-free" pointer lines from the count -- a "next-free D-072" pointer in
# prose must NOT inflate the highest-assigned (the first real run counted exactly that).
nextfree_mentions() { # <regex> <label> -- highest MENTION excluding next-free pointers
local pat="$1" label="$2" hi
hi="$(grep -rhE "$pat" "$REPO/docs" "$REPO/runbooks" 2>/dev/null \
| grep -viE 'next[- ]free' \
| grep -oE "$pat" | grep -oE '[0-9]+' | sort -n | tail -1)"
if [ -n "$hi" ]; then printf ' %s: highest=%s next-free=%03d\n' "$label" "$hi" "$((10#$hi + 1))"
else printf ' %s: none found\n' "$label"; fi
}
# D is AUTHORITATIVE from decision headers in design-decisions.md, not prose mentions.
if [ -r "$DD" ]; then
DHI="$(grep -oE '^## D-[0-9]+' "$DD" | grep -oE '[0-9]+' | sort -n | tail -1)"
if [ -n "$DHI" ]; then printf ' D: highest-header=%s next-free=%03d\n' "$DHI" "$((10#$DHI + 1))"; else echo " D: none"; fi
else echo " D: design-decisions.md not found"; fi
nextfree_mentions 'DOCFIX-0[0-9]{2}' "DOCFIX"
nextfree_mentions 'BUNDLEFIX-0[0-9]{2}' "BUNDLEFIX"
echo "-- reconcile against docs/session-ledger.md --"
if [ -r "$REPO/docs/session-ledger.md" ]; then
echo " ledger present; compare the sections above against its narrative"
else
echo " NO docs/session-ledger.md -- create/update it (this session's continuity record)"
fi
exit 0