#!/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/.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 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 " 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 "$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 = 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