cloverleaf-larry/lib/lessons.sh
Bryan Johnson 5bc3195f98 v0.8.30: write/mutate tool validation pass — 2 fixes; rollback proven reliable
Tested all mutating tools (nc_table/nc_add_route/nc_insert_protocol/
nc_create_thread/nc_make_jump/nc_tclgen) on a throwaway copy: every change is
journaled and rolls back byte-identical across --session/--entry/--target/
--last granularities. Fixed nc-create-thread --host brace-collision (emitted
invalid TCL { HOST x} }; now balanced { HOST x }, and { HOST {} } when omitted)
and lessons.sh:142 printf option-injection. Read fixture verified untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:28:21 -07:00

238 lines
8.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# lessons.sh — local-first lesson capture for Larry-Anywhere.
#
# When Bryan teaches Larry something during a session — a correction, a new
# pattern, a gotcha, a site-specific quirk — it gets appended here. Lessons
# stay LOCAL by default; they never auto-leave the client box. When Bryan can
# reach his dev machine again, he runs `lessons.sh export` and copies the
# bundle back to me (the home Larry) so I can commit it into the canonical
# agents/ persona files in the cloverleaf-larry repo.
#
# Storage: $LARRY_HOME/lessons/<YYYY-MM-DD>.md (one file per day, append-only).
# Plus a journal at $LARRY_HOME/lessons/_index.tsv for machine parsing.
#
# Subcommands:
# add "text" [--topic X] [--site Y] [--severity info|warn|fix]
# append a lesson
# list newest first, with index ids
# show <id> one lesson
# export [--since YYYY-MM-DD] one big markdown bundle to stdout
# (paste this back to me on your dev machine)
# export --bundle PATH write the bundle to a file
# export --gh-issue format as a GitHub Issue body + open with `gh` if available
# clear [--before YYYY-MM-DD] delete uploaded lessons (use after a successful upload)
# count just the number of unbundled lessons
set -o pipefail
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
LESSONS_DIR="$LARRY_HOME/lessons"
LESSONS_INDEX="$LESSONS_DIR/_index.tsv"
mkdir -p "$LESSONS_DIR" 2>/dev/null
chmod 700 "$LESSONS_DIR" 2>/dev/null || true
if [ ! -f "$LESSONS_INDEX" ]; then
printf 'timestamp\tid\ttopic\tsite\tseverity\tfile\n' > "$LESSONS_INDEX"
fi
# v0.7.5: shared CR-safety primitives. Source if available.
_LESSONS_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
if [ -r "$_LESSONS_LIB_DIR/cygwin-safe.sh" ]; then
# shellcheck disable=SC1090,SC1091
. "$_LESSONS_LIB_DIR/cygwin-safe.sh"
else
coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; }
fi
die() { printf 'lessons: %s\n' "$*" >&2; exit 1; }
_next_id() {
local n
n=$(awk -F'\t' 'NR>1{print $2}' "$LESSONS_INDEX" | sort -n | tail -1)
# v0.7.5: coerce_int on awk's last-line output — index file may have CRLF
# if a Windows editor touched it, and `tail -1` would keep the CR.
printf '%04d' $(( $(coerce_int "$n" 0) + 1 ))
}
cmd_add() {
local text="$1"; shift
local topic="" site="" severity="info"
while [ $# -gt 0 ]; do
case "$1" in
--topic) shift; topic="$1" ;;
--site) shift; site="$1" ;;
--severity) shift; severity="$1" ;;
*) die "unknown flag: $1" ;;
esac
shift
done
[ -n "$text" ] || die "lesson text is required"
local id day file ts
id=$(_next_id)
day=$(date +%Y-%m-%d)
ts=$(date -Iseconds 2>/dev/null || date)
file="$LESSONS_DIR/${day}.md"
if [ ! -f "$file" ]; then
{
printf '# Lessons captured %s\n\n' "$day"
printf '_From Larry-Anywhere on %s. Append-only._\n\n' "$(hostname 2>/dev/null || echo unknown)"
} > "$file"
fi
{
printf '\n## %s — %s' "$id" "$ts"
[ -n "$topic" ] && printf ' · **%s**' "$topic"
[ -n "$site" ] && printf ' · site=`%s`' "$site"
[ "$severity" != "info" ] && printf ' · severity=%s' "$severity"
[ -n "${HCIROOT:-}" ] && printf ' · HCIROOT=`%s`' "$HCIROOT"
[ -n "${HCISITE:-}" ] && printf ' · HCISITE=`%s`' "$HCISITE"
printf '\n\n%s\n' "$text"
} >> "$file"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$ts" "$id" "${topic:-}" "${site:-${HCISITE:-}}" "$severity" "$file" >> "$LESSONS_INDEX"
printf 'lesson %s captured → %s\n' "$id" "$file"
}
cmd_list() {
# v0.7.5: coerce_int on wc output (Cygwin CR-taint defense).
local n; n=$(( $(coerce_int "$(wc -l < "$LESSONS_INDEX")" 0) - 1 ))
printf 'lessons: %d captured (newest first)\n\n' "$n"
printf ' id timestamp topic site file\n'
awk -F'\t' 'NR>1 { lines[++i] = sprintf(" %s %-26s %-22s %-14s %s", $2, $1, ($3=="" ? "—" : $3), ($4=="" ? "—" : $4), $6) }
END { for (k=i; k>=1; k--) print lines[k] }' "$LESSONS_INDEX"
}
cmd_show() {
local id="$1"
[ -n "$id" ] || die "usage: lessons.sh show <id>"
local file ts topic
awk -F'\t' -v id="$id" 'NR>1 && $2==id { print; exit }' "$LESSONS_INDEX" \
| { IFS=$'\t' read -r ts iid topic site sev file
[ -z "$file" ] && { die "no such lesson id: $id"; }
printf '## %s (%s)\n topic: %s · site: %s · severity: %s · file: %s\n\n' \
"$id" "$ts" "${topic:-}" "${site:-}" "$sev" "$file"
awk -v marker="## $id" '
$0 ~ marker, /^## [0-9]{4} —/ { if (NR>1 && /^## [0-9]{4} —/ && $0 !~ marker) exit; print }
' "$file"
}
}
cmd_export() {
local since="" out="" mode="stdout"
while [ $# -gt 0 ]; do
case "$1" in
--since) shift; since="$1" ;;
--bundle) shift; out="$1"; mode="file" ;;
--gh-issue) mode="gh-issue" ;;
*) die "unknown flag: $1" ;;
esac
shift
done
local tmp; tmp=$(mktemp)
{
printf '# Larry-Anywhere lesson bundle\n\n'
printf 'Generated: %s\n' "$(date -Iseconds 2>/dev/null || date)"
printf 'Source: `%s` (host `%s`)\n\n' "$LESSONS_DIR" "$(hostname 2>/dev/null || echo unknown)"
printf 'Send this back to home-Larry (paste into chat) so it can be committed\n'
printf 'to the canonical agents/ persona files in cloverleaf-larry.\n\n'
printf '%s\n\n' '---'
# Walk files newest-first, optionally filtered by --since
find "$LESSONS_DIR" -maxdepth 1 -name '*.md' -type f 2>/dev/null \
| sort -r \
| while IFS= read -r f; do
local day; day=$(basename "$f" .md)
if [ -n "$since" ] && [ "$day" \< "$since" ]; then continue; fi
cat "$f"
printf '\n---\n\n'
done
} > "$tmp"
case "$mode" in
stdout) cat "$tmp"; rm -f "$tmp" ;;
file) mv "$tmp" "$out"; printf 'wrote bundle to %s\n' "$out" >&2 ;;
gh-issue)
if command -v gh >/dev/null 2>&1; then
gh issue create --repo bojj27/cloverleaf-larry \
--title "Lessons from $(hostname 2>/dev/null)$(date +%Y-%m-%d)" \
--body-file "$tmp" \
--label "lessons" 2>&1
rm -f "$tmp"
else
printf 'gh not installed — falling back to stdout. Copy this body into a manual Issue:\n\n' >&2
cat "$tmp"
rm -f "$tmp"
fi
;;
esac
}
cmd_clear() {
local before=""
while [ $# -gt 0 ]; do
case "$1" in
--before) shift; before="$1" ;;
*) die "unknown flag: $1" ;;
esac
shift
done
local files; files=$(find "$LESSONS_DIR" -maxdepth 1 -name '*.md' -type f 2>/dev/null | sort)
[ -n "$files" ] || { echo "no lesson files to clear"; return 0; }
if [ -z "$before" ]; then
printf 'clear ALL lesson files? '
printf '%s\n' "$files" | sed 's/^/ /'
printf 'proceed? [y/N]: '
read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; }
printf '%s\n' "$files" | xargs rm -f
# Reset index but keep header
head -1 "$LESSONS_INDEX" > "$LESSONS_INDEX.new"
mv "$LESSONS_INDEX.new" "$LESSONS_INDEX"
echo "cleared all lesson files"
else
printf 'clear lesson files BEFORE %s? files matched:\n' "$before"
local matched
matched=$(printf '%s\n' "$files" | while IFS= read -r f; do
local day; day=$(basename "$f" .md)
[ "$day" \< "$before" ] && echo "$f"
done)
[ -z "$matched" ] && { echo "(none)"; return 0; }
printf '%s\n' "$matched" | sed 's/^/ /'
printf 'proceed? [y/N]: '
read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; }
printf '%s\n' "$matched" | xargs rm -f
# Trim index entries
awk -F'\t' -v before="$before" 'NR==1 || $1 >= before' "$LESSONS_INDEX" > "$LESSONS_INDEX.new"
mv "$LESSONS_INDEX.new" "$LESSONS_INDEX"
echo "cleared lesson files before $before"
fi
}
cmd_count() {
# v0.7.5: coerce_int on wc output (Cygwin CR-taint defense).
local n; n=$(( $(coerce_int "$(wc -l < "$LESSONS_INDEX")" 0) - 1 ))
echo "$n"
}
case "${1:-list}" in
add) shift; [ $# -ge 1 ] || die "usage: lessons.sh add \"text\" [--topic X --site Y --severity Z]"; text="$1"; shift; cmd_add "$text" "$@" ;;
list) cmd_list ;;
show) shift; cmd_show "${1:-}" ;;
export) shift; cmd_export "$@" ;;
clear) shift; cmd_clear "$@" ;;
count) cmd_count ;;
-h|--help|help) sed -n '2,30p' "$0" ;;
*) die "unknown subcommand: ${1:-}" ;;
esac