#!/usr/bin/env bash # # board-roll.sh — keep a LIVE orchestration board under its byte cap by rolling # the oldest archival entries out to its append-only LEDGER. # # WHY: coordinator boards (MOS-ORCHESTRATION-BOARD-LIVE.md, MS-LEAD-BOARD-LIVE.md) # enforce a "< 8 KB LIVE" discipline via a self-guard that ABORTs the board write # when the file exceeds the cap. In practice the LIVE board keeps bumping the cap, # so coordinators hand-trim + retry every time (observed: 38 ABORT-OVER-CAP cycles # in a 24h window on one coordinator). This automates that trim, mechanically and # reversibly, so the audit trail is preserved in the LEDGER instead of hand-deleted. # # CONTRACT (conservative by design — it NEVER guesses what is safe to move): # The LIVE board must declare an explicit ROLL ZONE with HTML-comment markers: # # # ### 2026-07-22 (newest tick — stays longest) # ... # ### 2026-07-19 (oldest tick — rolled first) # ... # # # Everything OUTSIDE the markers (title, protocol blockquote, curated always-current # `##` sections) is PINNED and never touched. Inside the zone, entries are delimited # by a heading marker (default `### `) and are assumed newest-first (top) → oldest-last # (bottom), matching board convention. board-roll moves whole oldest (bottom-most) # entry blocks out of the zone and APPENDS them verbatim to the LEDGER, one block at a # time, until the LIVE file is back under the cap or the zone is empty. # # If no markers are present, it exits 3 without changing anything (safe default — # adding the markers is a deliberate opt-in by the board owner). # # USAGE: # board-roll.sh --live --ledger [options] # # OPTIONS: # --live LIVE board file (required) # --ledger append-only LEDGER file (required; created if absent) # --cap size ceiling for LIVE (default 8192) # --marker entry-heading prefix inside the roll zone (default "### ") # --dry-run report what would move + resulting size; change nothing # -h, --help print usage and exit 0 # # EXIT CODES: # 0 LIVE is under cap (already, or after rolling); on --dry-run, 0 = a plan exists # (or nothing to do) # 2 usage / argument / IO error (bad flag, missing file, unwritable target) # 3 cannot satisfy the cap: no roll markers present, MORE THAN ONE marker pair # (multiple zones are refused, not guessed), OR the zone was emptied and LIVE # is still over cap (curated pinned sections need a manual trim) # # NOTE: line endings are normalized to LF on rewrite (boards are LF markdown); a # trailing newline is always ensured. Writes are atomic (temp file + mv) so a # failure never leaves LIVE or LEDGER half-written. set -euo pipefail START_MARK='' END_MARK='' usage() { cat <<'EOF' Usage: board-roll.sh --live --ledger [options] Roll the oldest entries out of a LIVE orchestration board into its LEDGER until the LIVE file is under a byte cap. Conservative: only content inside explicit / markers is moved. Options: --live LIVE board file (required) --ledger append-only LEDGER file (required; created if absent) --cap size ceiling for LIVE (default 8192) --marker entry-heading prefix inside the roll zone (default "### ") --dry-run report what would move; change nothing -h, --help show this help and exit 0 Exit: 0 under cap (or dry-run plan) · 2 usage/IO error · 3 cannot meet cap (no markers, or pinned sections alone exceed the cap). EOF } die() { echo "board-roll: $*" >&2; exit 2; } LIVE=""; LEDGER=""; CAP=8192; MARKER='### '; DRYRUN=0 while [[ $# -gt 0 ]]; do case "$1" in --live) LIVE="${2:-}"; shift 2 || die "--live needs a value" ;; --ledger) LEDGER="${2:-}"; shift 2 || die "--ledger needs a value" ;; --cap) CAP="${2:-}"; shift 2 || die "--cap needs a value" ;; --marker) MARKER="${2:-}"; shift 2 || die "--marker needs a value" ;; --dry-run) DRYRUN=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown option: $1" ;; esac done [[ -n "$LIVE" ]] || { usage >&2; die "--live is required"; } [[ -n "$LEDGER" ]] || { usage >&2; die "--ledger is required"; } [[ -f "$LIVE" ]] || die "LIVE file not found: $LIVE" [[ "$CAP" =~ ^[0-9]+$ ]] || die "--cap must be a non-negative integer, got: $CAP" # --- read LIVE into a line array (newlines stripped; re-added on write) --------- mapfile -t LINES < "$LIVE" # byte size of an array rendered as LF-terminated text render_size() { if [[ $# -eq 0 ]]; then printf 0; return; fi printf '%s\n' "$@" | wc -c } orig_size=$(render_size "${LINES[@]}") # --- already under cap → nothing to do ----------------------------------------- if (( orig_size < CAP )); then echo "board-roll: LIVE is ${orig_size}B (< cap ${CAP}B) — nothing to roll." exit 0 fi # --- locate the roll-zone markers ---------------------------------------------- # Exactly ONE marker pair is supported. If a board carries more than one START or # END marker we REFUSE (exit 3, zero changes) rather than guess: a naive # first-START..last-END span would swallow the curated content and the intermediate # markers sitting between two intended zones and silently relocate that pinned text # to the LEDGER — the exact data-loss this tool exists to prevent. Refusing matches # the "no markers = exit 3" conservative posture. start_idx=-1; end_idx=-1; start_count=0; end_count=0 for i in "${!LINES[@]}"; do if [[ "${LINES[$i]}" == "$START_MARK" ]]; then if (( start_count == 0 )); then start_idx=$i; fi start_count=$(( start_count + 1 )) fi if [[ "${LINES[$i]}" == "$END_MARK" ]]; then end_idx=$i end_count=$(( end_count + 1 )) fi done if (( start_count > 1 || end_count > 1 )); then echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but has ${start_count} START / ${end_count} END" >&2 echo " markers — only a SINGLE '$START_MARK' … '$END_MARK' roll zone is supported." >&2 echo " Multiple zones are refused (not guessed) so content between zones is never relocated." >&2 echo " Consolidate the archival ticks into one zone, or trim manually." >&2 exit 3 fi if (( start_idx < 0 || end_idx < 0 || end_idx <= start_idx )); then echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but no usable roll zone" >&2 echo " (need '$START_MARK' then '$END_MARK'). Add the markers around the" >&2 echo " archival tick section to opt this board into automatic rolling." >&2 exit 3 fi # preamble = lines [0 .. start_idx] (inclusive of START marker) # zone = lines (start_idx .. end_idx) (exclusive of both markers) # footer = lines [end_idx .. end] (inclusive of END marker) preamble=(); zone=(); footer=() for i in "${!LINES[@]}"; do if (( i <= start_idx )); then preamble+=("${LINES[$i]}") elif (( i < end_idx )); then zone+=("${LINES[$i]}") else footer+=("${LINES[$i]}") fi done # --- split the zone into a fixed head + entry blocks ---------------------------- # zone_head = any zone lines before the first entry marker (kept, never rolled). # blocks[k] = newline-joined text of entry k (marker line .. line before next marker). zone_head=(); declare -a block_start=() first_block=-1 for i in "${!zone[@]}"; do if [[ "${zone[$i]}" == "$MARKER"* ]]; then [[ $first_block -eq -1 ]] && first_block=$i block_start+=("$i") fi done if (( first_block == -1 )); then echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but the roll zone has no" >&2 echo " '${MARKER}' entries to move. Trim the pinned sections manually." >&2 exit 3 fi for (( i=0; i= CAP && keep > 0 )); do keep=$(( keep - 1 )) current_size=$(build_live_size) done moved_count=$(( nblocks - keep )) if (( moved_count == 0 )); then # zone had entries but none movable brought us under (shouldn't happen: keep hits 0) echo "board-roll: could not reduce LIVE below cap (${current_size}B >= ${CAP}B)." >&2 exit 3 fi # --- dry-run report ------------------------------------------------------------- plan_headers() { local k for (( k=keep; k= CAP )); then echo " WARNING: still >= cap after emptying the zone; pinned sections need a manual trim." >&2 exit 3 fi exit 0 fi # --- commit the roll atomically ------------------------------------------------- live_tmp="$(mktemp "${LIVE}.roll.XXXXXX")" || die "cannot create temp next to LIVE" ledger_tmp="" # shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT` cleanup() { rm -f "$live_tmp" "$ledger_tmp" 2>/dev/null || true; } trap cleanup EXIT # new LIVE = preamble + zone_head + kept blocks + footer { printf '%s\n' "${preamble[@]}" "${zone_head[@]}" for (( k=0; k "$live_tmp" # LEDGER gets the moved blocks appended verbatim, in original top→bottom order, # under a provenance separator. LEDGER is append-only, so we only ever add at EOF. ledger_tmp="$(mktemp "${LEDGER}.roll.XXXXXX")" || die "cannot create temp next to LEDGER" if [[ -f "$LEDGER" ]]; then cat "$LEDGER" > "$ledger_tmp"; fi # ensure a trailing newline on existing content before appending if [[ -s "$ledger_tmp" && -n "$(tail -c1 "$ledger_tmp")" ]]; then printf '\n' >> "$ledger_tmp"; fi { printf '\n\n' \ "$moved_count" "$([[ $moved_count -eq 1 ]] && echo y || echo ies)" "$(basename "$LIVE")" for (( k=keep; k> "$ledger_tmp" # atomic swap (both, LEDGER first so a crash never drops content that left LIVE) mv "$ledger_tmp" "$LEDGER"; ledger_tmp="" mv "$live_tmp" "$LIVE"; live_tmp="" trap - EXIT # read the real on-disk size back (truthful, not the predicted value) final_size=$(wc -c < "$LIVE") echo "board-roll: rolled ${moved_count} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) to $(basename "$LEDGER"); LIVE ${orig_size}B → ${final_size}B (cap ${CAP}B)." if (( final_size >= CAP )); then echo "board-roll: still >= cap after rolling all zone entries; pinned sections need a manual trim." >&2 exit 3 fi exit 0