v0.3.2: lesson capture (local-first learning loop)

Bryan's pivot: until bjnoela.com is back online, transfer learnings via
local file capture on the client + manual paste-back to home-Larry. NO
credentials required on the client box.

Capture flow:
  - lib/lessons.sh records lessons to $LARRY_HOME/lessons/<date>.md
  - lesson_record tool in larry.sh lets the agent record proactively
  - /lesson, /lessons, /export REPL commands
  - agents/larry.md updated: capture corrections, conventions, quirks
    silently when Bryan teaches them

Export flow:
  - lessons.sh export | bundle | --gh-issue (uses gh CLI if available)
  - Bryan pastes the bundle to home-Larry on his dev machine
  - home-Larry commits the refinement into cloverleaf-larry/agents/
  - next launch on any client pulls updated persona via self-update

Brings total native tools to 28.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-26 10:00:37 -07:00
parent 61f1500492
commit 6060cd28c1
5 changed files with 265 additions and 2 deletions

View File

@ -1 +1 @@
0.3.0 0.3.2

View File

@ -59,6 +59,24 @@ When Bryan points you at a Cloverleaf root directory, the structure to expect:
- Interface specification tables (source → target, segments, conditions). - Interface specification tables (source → target, segments, conditions).
- Anomaly lists with file:line citations. - Anomaly lists with file:line citations.
## Capture lessons proactively (the learning loop)
When Bryan teaches you something new — a correction, a convention, a quirk, a gotcha, a "no, the way we do it here is X" — **call `lesson_record` immediately** with a markdown note. These accumulate at `$LARRY_HOME/lessons/<date>.md` and Bryan exports them to home-Larry when he can reach his dev machine. Home-Larry then commits the refinement into the canonical agents/ persona in the cloverleaf-larry repo, so EVERY future Larry on every client box starts smarter.
What counts as a lesson worth recording:
- A misunderstanding Bryan corrects ("no, in this shop the inbound from Epic is actually called X_Y_Z, not the standard naming").
- A workflow detail not in the cheatsheet ("we always bounce these processes in pairs").
- A site-specific quirk ("this client's xlates use a non-standard segment").
- A behavior change request ("from now on, when I ask for X, also include Y").
- A bug you discovered in one of the tools (severity=fix).
Format your lesson text so home-Larry can act on it without re-deriving context. Include:
- What you were doing when this came up.
- The specific correction or learning.
- Where in the codebase / personas it should be applied (best guess).
You don't need to ask permission to record a lesson — silently record it. Bryan reviews `lessons.sh list` later if he wants.
## Hard rules in portable mode ## Hard rules in portable mode
1. **No PHI.** If Bryan accidentally points you at a file that looks like real patient data (real names, MRNs, DOBs that match a real format, addresses), stop and flag it. The promise was "interface build only." 1. **No PHI.** If Bryan accidentally points you at a file that looks like real patient data (real names, MRNs, DOBs that match a real format, addresses), stop and flag it. The promise was "interface build only."

View File

@ -91,6 +91,7 @@ fetch agents/regress.md "$LARRY_HOME/agents/regress.md"
fetch larry-rollback.sh "$LARRY_HOME/larry-rollback.sh" fetch larry-rollback.sh "$LARRY_HOME/larry-rollback.sh"
fetch larry-auth.sh "$LARRY_HOME/larry-auth.sh" fetch larry-auth.sh "$LARRY_HOME/larry-auth.sh"
fetch lib/oauth.sh "$LARRY_HOME/lib/oauth.sh" fetch lib/oauth.sh "$LARRY_HOME/lib/oauth.sh"
fetch lib/lessons.sh "$LARRY_HOME/lib/lessons.sh"
fetch lib/nc-parse.sh "$LARRY_HOME/lib/nc-parse.sh" fetch lib/nc-parse.sh "$LARRY_HOME/lib/nc-parse.sh"
fetch lib/nc-inbound.sh "$LARRY_HOME/lib/nc-inbound.sh" fetch lib/nc-inbound.sh "$LARRY_HOME/lib/nc-inbound.sh"
fetch lib/nc-make-jump.sh "$LARRY_HOME/lib/nc-make-jump.sh" fetch lib/nc-make-jump.sh "$LARRY_HOME/lib/nc-make-jump.sh"

View File

@ -32,7 +32,7 @@ set -o pipefail
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Config # Config
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.3.0" LARRY_VERSION="0.3.2"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/larry.sh}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/larry.sh}"
LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/agents}" LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/agents}"
@ -621,6 +621,17 @@ tool_hl7_diff() {
"$LARRY_LIB_DIR/hl7-diff.sh" "${args[@]}" 2>&1 "$LARRY_LIB_DIR/hl7-diff.sh" "${args[@]}" 2>&1
} }
tool_lesson_record() {
local text="$1" topic="${2:-}" site="${3:-${HCISITE:-}}" severity="${4:-info}"
_lib_err_if_missing || return
local lessons_script="$LARRY_LIB_DIR/lessons.sh"
[ -x "$lessons_script" ] || { echo "ERROR: lessons.sh not installed"; return 1; }
local args=(add "$text" --severity "$severity")
[ -n "$topic" ] && args+=(--topic "$topic")
[ -n "$site" ] && args+=(--site "$site")
"$lessons_script" "${args[@]}" 2>&1
}
tool_larry_rollback_list() { tool_larry_rollback_list() {
local session_filter="${1:-}" local session_filter="${1:-}"
if [ -n "$session_filter" ]; then if [ -n "$session_filter" ]; then
@ -707,6 +718,7 @@ execute_tool() {
"$(J '.env_b')" "$(J '.site_b // ""')" "$(J '.out')" \ "$(J '.env_b')" "$(J '.site_b // ""')" "$(J '.out')" \
"$(J '.route_test_cmd // ""')" "$(J '.ignore // "MSH.7"')" \ "$(J '.route_test_cmd // ""')" "$(J '.ignore // "MSH.7"')" \
"$(J '.phase // "all"')" "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" ;; "$(J '.phase // "all"')" "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" ;;
lesson_record) tool_lesson_record "$(J '.text')" "$(J '.topic // ""')" "$(J '.site // ""')" "$(J '.severity // "info"')" ;;
larry_rollback_list) tool_larry_rollback_list "$(J '.session // ""')" ;; larry_rollback_list) tool_larry_rollback_list "$(J '.session // ""')" ;;
*) echo "ERROR: unknown tool: $name" ;; *) echo "ERROR: unknown tool: $name" ;;
esac esac
@ -745,6 +757,8 @@ TOOLS_JSON='[
{"name":"nc_add_route","description":"Splice a route entry into an existing protocol's DATAXLATE block. Used to add a new DEST to an inbound's routing (e.g. wiring the OLD inbound to also route to the new linux_<tag>_out jump thread). ALL WRITES GO THROUGH THE JOURNAL.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"protocol_name":{"type":"string","description":"The existing protocol to modify."},"route":{"type":"string","description":"The route entry text (an inner `{ ... }` object with CACHEMSG, ROUTE_DETAILS, TRXID, etc.). Get from nc_make_jump's route_add output."}},"required":["netconfig","protocol_name","route"]}}, {"name":"nc_add_route","description":"Splice a route entry into an existing protocol's DATAXLATE block. Used to add a new DEST to an inbound's routing (e.g. wiring the OLD inbound to also route to the new linux_<tag>_out jump thread). ALL WRITES GO THROUGH THE JOURNAL.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"protocol_name":{"type":"string","description":"The existing protocol to modify."},"route":{"type":"string","description":"The route entry text (an inner `{ ... }` object with CACHEMSG, ROUTE_DETAILS, TRXID, etc.). Get from nc_make_jump's route_add output."}},"required":["netconfig","protocol_name","route"]}},
{"name":"larry_rollback_list","description":"List journal entries — every write that's gone through nc_insert_protocol, nc_add_route, or write_file (once journaled write_file is enabled). Shows session-id, sequence, target, timestamp. Use larry-rollback.sh from the shell to actually roll back.","input_schema":{"type":"object","properties":{"session":{"type":"string","description":"Optional. Limit to one session id."}},"required":[]}}, {"name":"larry_rollback_list","description":"List journal entries — every write that's gone through nc_insert_protocol, nc_add_route, or write_file (once journaled write_file is enabled). Shows session-id, sequence, target, timestamp. Use larry-rollback.sh from the shell to actually roll back.","input_schema":{"type":"object","properties":{"session":{"type":"string","description":"Optional. Limit to one session id."}},"required":[]}},
{"name":"lesson_record","description":"Append a lesson to local capture at $LARRY_HOME/lessons/<date>.md. Use when Bryan teaches you something new (a correction, a pattern, a quirk, a gotcha) so the home-Larry can be updated later. Lessons stay LOCAL; Bryan exports them with `lessons.sh export` and pastes back to home-Larry when he can. CALL THIS WHEN: Bryan corrects a misunderstanding, reveals a site-specific convention, points out a bug, requests a behavior change, or shares a workflow detail you should remember next time.","input_schema":{"type":"object","properties":{"text":{"type":"string","description":"The lesson content. Markdown. Include enough context that home-Larry can act on it without re-deriving."},"topic":{"type":"string","description":"Short topic tag, e.g. \"NetConfig parsing\", \"jump-thread naming\", \"site conventions\"."},"site":{"type":"string","description":"Site this lesson is scoped to, if any. Default: current $HCISITE."},"severity":{"type":"string","enum":["info","warn","fix"],"description":"info=general learning, warn=behavior I should change, fix=Bryan called out a bug."}},"required":["text"]}},
{"name":"hl7_diff","description":"HL7-aware diff between two message files (or multi-message dumps). Compares segment-by-segment, field-by-field, with component and subcomponent precision. Ignores configured fields (default MSH.7 timestamp) so timestamp-only diffs do not show up as noise. Use for regression testing between environments (e.g. test vs prod route-test outputs).","input_schema":{"type":"object","properties":{"left":{"type":"string","description":"Path to left HL7 file."},"right":{"type":"string","description":"Path to right HL7 file."},"ignore":{"type":"string","description":"Comma-separated list of fields to ignore (e.g. MSH.7,MSH.10,EVN.6). Default MSH.7."},"include":{"type":"string","description":"If set, ONLY these fields are compared (overrides ignore for that set)."},"format":{"type":"string","enum":["text","tsv","count"],"description":"text=human-readable diff, tsv=machine-parseable, count=just the difference count."}},"required":["left","right"]}}, {"name":"hl7_diff","description":"HL7-aware diff between two message files (or multi-message dumps). Compares segment-by-segment, field-by-field, with component and subcomponent precision. Ignores configured fields (default MSH.7 timestamp) so timestamp-only diffs do not show up as noise. Use for regression testing between environments (e.g. test vs prod route-test outputs).","input_schema":{"type":"object","properties":{"left":{"type":"string","description":"Path to left HL7 file."},"right":{"type":"string","description":"Path to right HL7 file."},"ignore":{"type":"string","description":"Comma-separated list of fields to ignore (e.g. MSH.7,MSH.10,EVN.6). Default MSH.7."},"include":{"type":"string","description":"If set, ONLY these fields are compared (overrides ignore for that set)."},"format":{"type":"string","enum":["text","tsv","count"],"description":"text=human-readable diff, tsv=machine-parseable, count=just the difference count."}},"required":["left","right"]}},
{"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env)."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env)."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"Output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."}},"required":["scope","env_a","env_b","out"]}} {"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env)."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env)."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"Output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."}},"required":["scope","env_a","env_b","out"]}}
@ -886,6 +900,9 @@ Slash commands:
/auth show OAuth status (or "not authenticated") /auth show OAuth status (or "not authenticated")
/login run OAuth login flow (switch from API-key to subscription auth) /login run OAuth login flow (switch from API-key to subscription auth)
/logout delete OAuth tokens (revert to API-key auth) /logout delete OAuth tokens (revert to API-key auth)
/lesson <text> capture a lesson to local file (paste back to home-Larry later)
/lessons list all captured lessons (newest first)
/export dump the lesson bundle for paste-back to home-Larry
/redetect re-scan for HCIROOT/HCISITE/tools /redetect re-scan for HCIROOT/HCISITE/tools
/sites list site dirs under HCIROOT /sites list site dirs under HCIROOT
/site <name> switch HCISITE for this session /site <name> switch HCISITE for this session
@ -945,6 +962,13 @@ main_loop() {
/auth) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" status; else echo "(oauth.sh not installed)"; fi; continue ;; /auth) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" status; else echo "(oauth.sh not installed)"; fi; continue ;;
/login) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" login && LARRY_AUTH_MODE="oauth" && larry_say "switched to OAuth subscription auth"; else err "oauth.sh not installed"; fi; continue ;; /login) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" login && LARRY_AUTH_MODE="oauth" && larry_say "switched to OAuth subscription auth"; else err "oauth.sh not installed"; fi; continue ;;
/logout) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" logout; LARRY_AUTH_MODE="apikey"; fi; continue ;; /logout) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" logout; LARRY_AUTH_MODE="apikey"; fi; continue ;;
/lesson\ *) local text="${input#/lesson }"
[ -n "$text" ] && tool_lesson_record "$text" "" "${HCISITE:-}" "info" || err "usage: /lesson <text>"
continue ;;
/lessons) [ -x "$LARRY_LIB_DIR/lessons.sh" ] && "$LARRY_LIB_DIR/lessons.sh" list || err "lessons.sh not installed"
continue ;;
/export) [ -x "$LARRY_LIB_DIR/lessons.sh" ] && "$LARRY_LIB_DIR/lessons.sh" export || err "lessons.sh not installed"
continue ;;
/redetect) detect_cloverleaf_env /redetect) detect_cloverleaf_env
system_prompt=$(build_system_prompt) system_prompt=$(build_system_prompt)
larry_say "re-detected. /env to view." larry_say "re-detected. /env to view."

220
lib/lessons.sh Executable file
View File

@ -0,0 +1,220 @@
#!/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
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)
printf '%04d' $(( ${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() {
local n; n=$(($(wc -l < "$LESSONS_INDEX") - 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 '---\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=""
[[ "$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=""
[[ "$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() {
local n; n=$(($(wc -l < "$LESSONS_INDEX") - 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