v0.8.27: nc-revisions — NetConfig change-history / revision diff

New tool: show how a thread/system/site changed over time by diffing
Cloverleaf NetConfig revision snapshots, annotated with who saved each and
when. Handles the non-zero-padded NetConfig<TS> revision dirs by parsing the
prologue date into a sortable key; scopes diffs to the requested thread/system
via nc-parse. Flags: <thread>[.<site>], --system, --site, --format
timeline|diff, --limit, --since. Wired as nc_revisions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-28 16:53:10 -07:00
parent 111be2c744
commit 5214d87a04
5 changed files with 573 additions and 5 deletions

View File

@ -4,6 +4,61 @@ All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here.
Versioning is loose-semver; bumps trigger the in-process self-update on every Versioning is loose-semver; bumps trigger the in-process self-update on every
running client via `LARRY_BASE_URL` + `MANIFEST`. running client via `LARRY_BASE_URL` + `MANIFEST`.
## v0.8.27 — 2026-05-28
**★ NEW TOOL: `nc-revisions.sh` — NetConfig change-history / revision-diff.**
Shows how a THREAD, a multi-thread SYSTEM, or a WHOLE SITE changed over time by
diffing Cloverleaf's own per-save NetConfig revision SNAPSHOTS, annotated with
WHO saved each revision and WHEN. Deterministic, pure bash+awk, NO API, NO
network — runs identically on an API-blocked host.
- **Revision storage (verified on the real integrator).** Each save snapshots
the full NetConfig into `$HCIROOT/<site>/revisions/NetConfig<TS>/NetConfig`,
where `<TS>` is the save time as `M D YYYY H M S` with NO zero-padding (so
`NetConfig5212025121420` = 5/21/2025 12:14:20). Because the components are
un-padded the dir NAME is NOT lexically sortable across months/hours, so we do
NOT sort on it. WHO + DATE come from the snapshot's NetConfig PROLOGUE
(`who:` / `date:`, TAB-separated, leading-space-indented; the block is
`prologue … end_prologue`). The `date:` string (`Month DD, YYYY H:MM:SS AM/PM
TZ`) is parsed into a sortable key `YYYYMMDDHHMMSS` so the timeline is in true
chronological order. The live `<site>/NetConfig` is included last as
`(current)`; a secondary key (snapshot dir-timestamp; `~` sentinel for live)
breaks ties when two saves share the same last-editor prologue stamp.
- **Scoped, not whole-file.** The diff/summary is reduced to the relevant
protocol-block(s) via `nc-parse.sh` (single thread, or every name-matching
thread for `--system`), so it is NOT the whole 10k-line NetConfig unless
`--site` (whole-NetConfig scope).
- **Flags.** `--format timeline` (default) = plain-text who/when/summary, one
revision per block, summary counting routing threads added/removed/modified
in scope between consecutive revisions; `--format diff` = the scoped unified
diff between consecutive revisions, one block per transition. `--limit N`
(most-recent N), `--since <date>` (YYYY-MM-DD or YYYYMMDD). Invocation:
`nc-revisions <thread>` (defaults to `$HCISITE`), `<thread>.<site>`,
`<site>/<thread>`, `--system <pat> [--site S]`, or `--site <site>`.
- **Plain text + control-byte safety.** Output matches the v0.8.24/26 OneNote
conventions: no markdown. Timeline runs through `_sanitize_ctl`
(unconditional strip — human-readable artifact); `--format diff` through the
tty-gated `_sanitize_ctl_tty` (raw, byte-identical on a pipe/redirect so a
downstream consumer of the diff sees exact bytes). `--help` sed range stops at
the end of the comment header (no code leak).
- **Wired into `larry.sh`.** Added to the `tools` registry, the `tool_nc_revisions`
dispatcher, the `execute_tool` case, and a full `nc_revisions` LLM-tool schema
mirroring the other NetConfig tools.
Proved on the real test integrator (`HCIROOT=/tmp/clvf_realtest/integrator`):
`nc-revisions ADTto_uds.ancout2` rendered an 8-revision timeline spanning
Apr 2024 → Nov 2025 (correctly ordered across months despite un-padded dir
names), surfacing a `~1 modified` host change and the eventual `-1 removed` in
the live config; `--format diff` showed the scoped one-line `{ HOST 10.33.176.5 }
→ { HOST 10.34.48.11 }` change. `--system codametrix --site ancout --format diff`
showed `ADTto_CodaMetrix` added then its `{ PORT 8000 } → { PORT 39500 }` and
inbound-archive (`INFILE`/`INSAVE`) edits by BRYJOHN. `bash -n` clean on all
touched files; MANIFEST regenerated and `--check` passes.
## v0.8.26 — 2026-05-28 ## v0.8.26 — 2026-05-28
**★ HARDENING: extend the v0.8.25 control-byte sanitize across the whole tool **★ HARDENING: extend the v0.8.25 control-byte sanitize across the whole tool

View File

@ -23,16 +23,16 @@
# scripts/make-manifest.sh and bump VERSION. # scripts/make-manifest.sh and bump VERSION.
# Top-level scripts # Top-level scripts
larry.sh 7fccca0d10a0a742d66efd21da703d780c8359411995cf69925123575b14321c larry.sh 4de38e7e33507a8c8fde539cb29c031eabbec508674bf51dd9f441e536a76509
larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa
larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831
larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0
install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423 install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423
# Metadata # Metadata
VERSION 6520a3a0746d8a2969ca4c76db2109929b36882541fcdbe3fb6de1718903d97f VERSION 0ba962133bcfa080a96ec6b746ae1a4eb7816576618aa6c88d2c14506258715b
MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde
CHANGELOG.md 0d7a88d389d6723ee2dd289e5d143d4ada8f232ca0df43c392be9bca856f70b6 CHANGELOG.md 3506542eac6c71d4cb29391b234f3fd30aec3853a5f5122b5bceb73d11079f46
# Agent personas (system-prompt overlays) # Agent personas (system-prompt overlays)
agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1
@ -103,6 +103,7 @@ lib/nc-inbound.sh 52d28c5f8d97bdf96f0fc7b5300d35b106b8e1226578f4cda430deb2a8b4a9
lib/nc-make-jump.sh 08a0bc58a299c95c60a59a5202792daf0ada3a8a0be7dc1b4cccc5724f5c9c79 lib/nc-make-jump.sh 08a0bc58a299c95c60a59a5202792daf0ada3a8a0be7dc1b4cccc5724f5c9c79
lib/nc-msgs.sh 20517922d1153ec7827c833987497fb305d087b579911d1b9067d65ae156a19f lib/nc-msgs.sh 20517922d1153ec7827c833987497fb305d087b579911d1b9067d65ae156a19f
lib/nc-document.sh 47211e99089c0446d25a1e84545a734894720a1c9ad8f59b920332035e4ea880 lib/nc-document.sh 47211e99089c0446d25a1e84545a734894720a1c9ad8f59b920332035e4ea880
lib/nc-revisions.sh c27856f7decfc4c2e2c990f59eb20136fdff9cf0a52b9d9fbd9370613666a802
lib/nc-diff-interface.sh 6b64ec3070a3d75d1d79632c9aeb357177fdbcc77c474aa78e6f6929fda1a324 lib/nc-diff-interface.sh 6b64ec3070a3d75d1d79632c9aeb357177fdbcc77c474aa78e6f6929fda1a324
lib/nc-find.sh 8c79e0acad7de56e4e1f12d61e071a4b98c4e2310a1f7fb183697df521215e3f lib/nc-find.sh 8c79e0acad7de56e4e1f12d61e071a4b98c4e2310a1f7fb183697df521215e3f
lib/nc-insert-protocol.sh ad1fa0bafbf4fdfb12bad20f9c22c3eed519f8846774331e26aa9becd6f8898a lib/nc-insert-protocol.sh ad1fa0bafbf4fdfb12bad20f9c22c3eed519f8846774331e26aa9becd6f8898a

View File

@ -1 +1 @@
0.8.26 0.8.27

View File

@ -78,7 +78,7 @@ set -o pipefail
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Config # Config
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.8.26" LARRY_VERSION="0.8.27"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -355,6 +355,7 @@ nc-make-jump.sh|Generate the 3-thread "jump" pattern for cross-environment data
nc-tclgen.sh|Generate annotated TCL UPOC scaffolding (skeletons for common Cloverleaf proc patterns) nc-tclgen.sh|Generate annotated TCL UPOC scaffolding (skeletons for common Cloverleaf proc patterns)
nc-document.sh|Generate a markdown knowledge entry documenting a Cloverleaf subsystem/interface nc-document.sh|Generate a markdown knowledge entry documenting a Cloverleaf subsystem/interface
#Diff & regression #Diff & regression
nc-revisions.sh|NetConfig change-history / revision-diff: how a thread, system, or whole site changed over time across Cloverleaf's NetConfig revision snapshots, annotated with WHO saved each revision and WHEN (from the prologue). Default plain-text timeline; --format diff for the scoped unified diff between consecutive revisions. Usage: nc-revisions <thread>[.<site>] | --system <pat> [--site S] | --site <site> [--format timeline|diff] [--limit N] [--since DATE]
nc-diff-interface.sh|Diff one Cloverleaf interface across two environments nc-diff-interface.sh|Diff one Cloverleaf interface across two environments
nc-smat-diff.sh|Diff smat (message-archive) content across two environments nc-smat-diff.sh|Diff smat (message-archive) content across two environments
nc-regression.sh|End-to-end regression test orchestrator between two Cloverleaf environments nc-regression.sh|End-to-end regression test orchestrator between two Cloverleaf environments
@ -4114,6 +4115,43 @@ tool_nc_document() {
"$LARRY_LIB_DIR/nc-document.sh" "${args[@]}" 2>&1 "$LARRY_LIB_DIR/nc-document.sh" "${args[@]}" 2>&1
} }
# nc_revisions — NetConfig change-history / revision-diff. Diffs Cloverleaf's own
# per-save NetConfig revision SNAPSHOTS (under $HCIROOT/<site>/revisions/) for the
# requested scope — a single thread, a multi-thread system (case-insensitive name
# match), or the whole site — and annotates each revision with WHO saved it and
# WHEN (parsed from the NetConfig prologue). Deterministic, API-free. Default
# format=timeline (plain-text who/when/summary, one revision per block);
# format=diff emits the scoped unified diff between consecutive revisions. The
# scope is reduced to the relevant protocol-block(s) via nc-parse.sh so the diff
# is NOT the whole 10k-line NetConfig unless --site. --limit keeps the most-recent
# N; --since filters by date (YYYY-MM-DD or YYYYMMDD).
tool_nc_revisions() {
local thread="$1" system="$2" site="$3" fmt="${4:-timeline}" limit="${5:-0}"
local since="${6:-}" hciroot="${7:-${HCIROOT:-}}"
_lib_err_if_missing || return
[ -n "$thread" ] || [ -n "$system" ] || [ -n "$site" ] \
|| { echo "ERROR: nc_revisions needs one of: thread, system (+site), or site"; return 1; }
local args=()
if [ -n "$thread" ]; then
args+=(--thread "$thread")
[ -n "$site" ] && args+=(--site "$site")
elif [ -n "$system" ]; then
args+=(--system "$system")
[ -n "$site" ] && args+=(--site "$site")
else
args+=(--site "$site")
fi
case "$fmt" in
timeline|diff) args+=(--format "$fmt") ;;
"") args+=(--format timeline) ;;
*) echo "ERROR: unknown nc_revisions format: $fmt (timeline|diff)"; return 1 ;;
esac
case "$limit" in ''|0) : ;; *[!0-9]*) : ;; *) args+=(--limit "$limit") ;; esac
[ -n "$since" ] && args+=(--since "$since")
[ -n "$hciroot" ] && args+=(--hciroot "$hciroot")
"$LARRY_LIB_DIR/nc-revisions.sh" "${args[@]}" 2>&1
}
tool_nc_diff_interface() { tool_nc_diff_interface() {
local interface="$1" left="$2" right="$3" out_path="${4:-}" include_tables="${5:-0}" local interface="$1" left="$2" right="$3" out_path="${4:-}" include_tables="${5:-0}"
local left_label="${6:-}" right_label="${7:-}" depth="${8:-1}" local left_label="${6:-}" right_label="${7:-}" depth="${8:-1}"
@ -4188,6 +4226,9 @@ execute_tool() {
"$(J '.notes // ""')" \ "$(J '.notes // ""')" \
"$(J '.onenote_table // 0' | sed "s/false/0/;s/true/1/")" \ "$(J '.onenote_table // 0' | sed "s/false/0/;s/true/1/")" \
"$(J '.raw_tcl // 0' | sed "s/false/0/;s/true/1/")" ;; "$(J '.raw_tcl // 0' | sed "s/false/0/;s/true/1/")" ;;
nc_revisions) tool_nc_revisions "$(J '.thread // ""')" "$(J '.system // ""')" "$(J '.site // ""')" \
"$(J '.format // "timeline"')" "$(J '.limit // 0')" \
"$(J '.since // ""')" "$(J '.hciroot // ""')" ;;
nc_find) tool_nc_find "$(J '.mode')" "$(J '.query')" "$(J '.format // "table"')" "$(J '.hciroot // ""')" ;; nc_find) tool_nc_find "$(J '.mode')" "$(J '.query')" "$(J '.format // "table"')" "$(J '.hciroot // ""')" ;;
nc_insert_protocol) tool_nc_insert_protocol "$(J '.netconfig')" "$(J '.block')" "$(J '.mode // "end"')" "$(J '.anchor // ""')" ;; nc_insert_protocol) tool_nc_insert_protocol "$(J '.netconfig')" "$(J '.block')" "$(J '.mode // "end"')" "$(J '.anchor // ""')" ;;
nc_add_route) tool_nc_add_route "$(J '.netconfig')" "$(J '.protocol_name')" "$(J '.route')" ;; nc_add_route) tool_nc_add_route "$(J '.netconfig')" "$(J '.protocol_name')" "$(J '.route')" ;;
@ -4244,6 +4285,7 @@ TOOLS_JSON=$(cat <<'TOOLS_END'
{"name":"nc_msgs","description":"Query Cloverleaf smat (SQLite!) databases for messages from a thread. Filters: time range, exact HL7 field match. Native v3 — reads smatdb directly with sqlite3 -ascii, no hcidbdump/dbExtract needed. Format text shows messages line-by-line with metadata; count returns just the count; json returns structured data. Operates on LOCAL smatdbs; for a remote env's smatdb, use ssh_pull_smat first (sampled mode is cheaper than pulling the whole DB).","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread name. The .smatdb file under $HCISITEDIR/exec/processes/*/<thread>.smatdb is auto-located unless db is given."},"after":{"type":"string","description":"Time-after filter. Accepts \"3 days ago\", \"2026-05-20 14:30:00\", \"2026-05-20\", or a unix timestamp."},"before":{"type":"string","description":"Time-before filter, same formats as after."},"field":{"type":"string","description":"HL7 field path for exact-match filter, e.g. PID.18 or MSH.10."},"value":{"type":"string","description":"Value the field must equal. Use with field. Repeatable filters not supported via this single tool call — chain calls if you need multi-field AND."},"limit":{"type":"integer","description":"Max messages to return. Default 10."},"format":{"type":"string","enum":["text","json","count","raw"],"description":"text = human-readable with metadata; count = just the number; json = structured; raw = raw bytes separated by 0x1c."},"sitedir":{"type":"string","description":"Override $HCISITEDIR for thread-to-db location."},"db":{"type":"string","description":"Explicit .smatdb path; overrides auto-locate."}},"required":["thread"]}}, {"name":"nc_msgs","description":"Query Cloverleaf smat (SQLite!) databases for messages from a thread. Filters: time range, exact HL7 field match. Native v3 — reads smatdb directly with sqlite3 -ascii, no hcidbdump/dbExtract needed. Format text shows messages line-by-line with metadata; count returns just the count; json returns structured data. Operates on LOCAL smatdbs; for a remote env's smatdb, use ssh_pull_smat first (sampled mode is cheaper than pulling the whole DB).","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread name. The .smatdb file under $HCISITEDIR/exec/processes/*/<thread>.smatdb is auto-located unless db is given."},"after":{"type":"string","description":"Time-after filter. Accepts \"3 days ago\", \"2026-05-20 14:30:00\", \"2026-05-20\", or a unix timestamp."},"before":{"type":"string","description":"Time-before filter, same formats as after."},"field":{"type":"string","description":"HL7 field path for exact-match filter, e.g. PID.18 or MSH.10."},"value":{"type":"string","description":"Value the field must equal. Use with field. Repeatable filters not supported via this single tool call — chain calls if you need multi-field AND."},"limit":{"type":"integer","description":"Max messages to return. Default 10."},"format":{"type":"string","enum":["text","json","count","raw"],"description":"text = human-readable with metadata; count = just the number; json = structured; raw = raw bytes separated by 0x1c."},"sitedir":{"type":"string","description":"Override $HCISITEDIR for thread-to-db location."},"db":{"type":"string","description":"Explicit .smatdb path; overrides auto-locate."}},"required":["thread"]}},
{"name":"nc_document","description":"Document a Cloverleaf INTERFACE end-to-end as a PLAIN-TEXT knowledge entry in Bryan's confirmed Legacy 'ADT Messages' template. OUTPUT IS PLAIN TEXT BY DEFAULT (no markdown) so it pastes cleanly into OneNote, which does NOT render markdown: UPPERCASE headings underlined with dashes, no bold, no backticks, no pipe tables, no '---' rules. Structure: Title; Description prose; Message Flow (one hop per record: Epic-feed → Cloverleaf-routing → Final-Delivery, built from the nc_paths route chain); per-delivery breakdown (inbound PROTOCOL TYPE/HOST/PORT/ISSERVER + inbound TRXID/TPS proc, route TRXID filter + TYPE + PREPROCS/POSTPROCS, XLATE, destination host:port/process). The two tabular sections (Message Flow, Delivery breakdown) render as INDENTED label:value blocks by default (read in any font, zero setup); set onenote_table=true to render them instead as TAB-separated rows (header + one row per record, no leading/trailing pipes) for paste-into-OneNote → Insert > Table. EVERYTHING THIS TOOL EMITS IS DETERMINISTIC, PURE BASH, AND API-FREE — it runs identically on an API-blocked host and never calls a model. ★ KEY FEATURE — for every referenced UPOC proc it locates the .tcl under $HCIROOT/<site>/tclprocs/ and DETERMINISTICALLY surfaces, INLINE in the Description, a compact bit-line: the proc's COMMENTS (the author's own filter notes), the HL7 FIELDS it references (PID.8, PV1.45, EVN.1…), the MATCHED literal event codes (A01 A02 A03…), TABLE lookups (e.g. PeriCalm_Loc), and the DISPOSITION (pass vs kill). Those inline bits are ALWAYS ON. The raw proc TCL appendix is OPT-IN via raw_tcl=true (off by default). ★ WHEN YOU (the model, running WITH the API) GET THIS TOOL'S OUTPUT: transparently POLISH those surfaced UPOC bits into smoother, human-readable filter descriptions inside the Description prose (e.g. turn 'fields: PV1.45 PID.8 · matches: A01 A02 A03 · table: Pericalm_Loc · disposition: kill non-matching' into 'passes only A01/A02/A03 admit/transfer events for female patients whose location is in the Pericalm_Loc table, killing everything else'). Do NOT invent facts not present in the surfaced bits; just smooth them. On an API-blocked host the deterministic bit-lines are the deliverable. MODES: (a) SINGLE INTERFACE — set thread (the delivery/outbound thread, e.g. 'ADTto_CodaMetrix'; optionally site to disambiguate) → one fully-detailed interface section. (b) SYSTEM/PATTERN — set name (case-insensitive substring/regex, e.g. 'codametrix') → one section per matching delivery thread across ALL sites. Give EXACTLY ONE of thread or name. Returns the doc text and (if out is given) writes it there.","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"SINGLE-INTERFACE mode: the delivery (outbound) thread to document, e.g. 'ADTto_CodaMetrix' or 'ADTto_periwatch'. Accepts a bare thread name or a 'site/thread' node. Give this OR name, not both."},"name":{"type":"string","description":"SYSTEM mode: case-insensitive substring/regex matching delivery thread names across all sites, e.g. 'codametrix', 'periwatch', 'epic_adt'. One section per match. Give this OR thread, not both."},"site":{"type":"string","description":"Home site of the thread (the NetConfig's parent dir). Optional — disambiguates a thread name present in multiple sites."},"out":{"type":"string","description":"Optional output file path. Convention: $LARRY_HOME/knowledge/<system>.txt (plain text)."},"hciroot":{"type":"string","description":"Override $HCIROOT for the NetConfig scan."},"title":{"type":"string","description":"Doc title. Default derived from thread/name."},"onenote_table":{"type":"boolean","description":"Render the tabular sections (Message Flow, Delivery breakdown) as TAB-separated rows (header + one data row per record, real tabs, no leading/trailing pipes) for paste-into-OneNote → Insert > Table. Default false = indented label:value blocks. Non-tabular sections stay plain text either way."},"raw_tcl":{"type":"boolean","description":"Also emit the raw proc-source appendix (verbatim TCL of every referenced UPOC proc). Default false — the readable extracted UPOC bits stay inline in each description regardless; only the verbatim appendix is gated behind this."},"status":{"type":"string","description":"System status fill-in (production/test/decommissioning/...)."},"poc_internal":{"type":"string","description":"Internal owner fill-in."},"poc_vendor":{"type":"string","description":"Vendor POC fill-in."},"escalation":{"type":"string","description":"Escalation path fill-in."},"open_items":{"type":"string","description":"Open items / known issues fill-in. Can be multi-line, will be inserted as-is."},"notes":{"type":"string","description":"Freeform notes fill-in."}},"required":[]}}, {"name":"nc_document","description":"Document a Cloverleaf INTERFACE end-to-end as a PLAIN-TEXT knowledge entry in Bryan's confirmed Legacy 'ADT Messages' template. OUTPUT IS PLAIN TEXT BY DEFAULT (no markdown) so it pastes cleanly into OneNote, which does NOT render markdown: UPPERCASE headings underlined with dashes, no bold, no backticks, no pipe tables, no '---' rules. Structure: Title; Description prose; Message Flow (one hop per record: Epic-feed → Cloverleaf-routing → Final-Delivery, built from the nc_paths route chain); per-delivery breakdown (inbound PROTOCOL TYPE/HOST/PORT/ISSERVER + inbound TRXID/TPS proc, route TRXID filter + TYPE + PREPROCS/POSTPROCS, XLATE, destination host:port/process). The two tabular sections (Message Flow, Delivery breakdown) render as INDENTED label:value blocks by default (read in any font, zero setup); set onenote_table=true to render them instead as TAB-separated rows (header + one row per record, no leading/trailing pipes) for paste-into-OneNote → Insert > Table. EVERYTHING THIS TOOL EMITS IS DETERMINISTIC, PURE BASH, AND API-FREE — it runs identically on an API-blocked host and never calls a model. ★ KEY FEATURE — for every referenced UPOC proc it locates the .tcl under $HCIROOT/<site>/tclprocs/ and DETERMINISTICALLY surfaces, INLINE in the Description, a compact bit-line: the proc's COMMENTS (the author's own filter notes), the HL7 FIELDS it references (PID.8, PV1.45, EVN.1…), the MATCHED literal event codes (A01 A02 A03…), TABLE lookups (e.g. PeriCalm_Loc), and the DISPOSITION (pass vs kill). Those inline bits are ALWAYS ON. The raw proc TCL appendix is OPT-IN via raw_tcl=true (off by default). ★ WHEN YOU (the model, running WITH the API) GET THIS TOOL'S OUTPUT: transparently POLISH those surfaced UPOC bits into smoother, human-readable filter descriptions inside the Description prose (e.g. turn 'fields: PV1.45 PID.8 · matches: A01 A02 A03 · table: Pericalm_Loc · disposition: kill non-matching' into 'passes only A01/A02/A03 admit/transfer events for female patients whose location is in the Pericalm_Loc table, killing everything else'). Do NOT invent facts not present in the surfaced bits; just smooth them. On an API-blocked host the deterministic bit-lines are the deliverable. MODES: (a) SINGLE INTERFACE — set thread (the delivery/outbound thread, e.g. 'ADTto_CodaMetrix'; optionally site to disambiguate) → one fully-detailed interface section. (b) SYSTEM/PATTERN — set name (case-insensitive substring/regex, e.g. 'codametrix') → one section per matching delivery thread across ALL sites. Give EXACTLY ONE of thread or name. Returns the doc text and (if out is given) writes it there.","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"SINGLE-INTERFACE mode: the delivery (outbound) thread to document, e.g. 'ADTto_CodaMetrix' or 'ADTto_periwatch'. Accepts a bare thread name or a 'site/thread' node. Give this OR name, not both."},"name":{"type":"string","description":"SYSTEM mode: case-insensitive substring/regex matching delivery thread names across all sites, e.g. 'codametrix', 'periwatch', 'epic_adt'. One section per match. Give this OR thread, not both."},"site":{"type":"string","description":"Home site of the thread (the NetConfig's parent dir). Optional — disambiguates a thread name present in multiple sites."},"out":{"type":"string","description":"Optional output file path. Convention: $LARRY_HOME/knowledge/<system>.txt (plain text)."},"hciroot":{"type":"string","description":"Override $HCIROOT for the NetConfig scan."},"title":{"type":"string","description":"Doc title. Default derived from thread/name."},"onenote_table":{"type":"boolean","description":"Render the tabular sections (Message Flow, Delivery breakdown) as TAB-separated rows (header + one data row per record, real tabs, no leading/trailing pipes) for paste-into-OneNote → Insert > Table. Default false = indented label:value blocks. Non-tabular sections stay plain text either way."},"raw_tcl":{"type":"boolean","description":"Also emit the raw proc-source appendix (verbatim TCL of every referenced UPOC proc). Default false — the readable extracted UPOC bits stay inline in each description regardless; only the verbatim appendix is gated behind this."},"status":{"type":"string","description":"System status fill-in (production/test/decommissioning/...)."},"poc_internal":{"type":"string","description":"Internal owner fill-in."},"poc_vendor":{"type":"string","description":"Vendor POC fill-in."},"escalation":{"type":"string","description":"Escalation path fill-in."},"open_items":{"type":"string","description":"Open items / known issues fill-in. Can be multi-line, will be inserted as-is."},"notes":{"type":"string","description":"Freeform notes fill-in."}},"required":[]}},
{"name":"nc_revisions","description":"NetConfig CHANGE-HISTORY / REVISION-DIFF tracer. Shows how a THREAD, a multi-thread SYSTEM, or a WHOLE SITE changed over time by diffing Cloverleaf's own per-save NetConfig revision SNAPSHOTS (the dirs Cloverleaf writes under $HCIROOT/<site>/revisions/NetConfig<timestamp>/, each holding a full NetConfig), annotated with WHO saved each revision and WHEN — parsed from each snapshot's NetConfig prologue (who: / date:). USE THIS — not grep_files / read_file / bash_exec over the revisions dir — for ANY of: 'who changed X and when', 'what changed in this thread/site over time', 'revision history', 'when did the port/host change', 'show me the change history', 'diff the revisions', 'audit the NetConfig edits'. DETERMINISTIC, PURE BASH, API-FREE — runs identically on an API-blocked host. Revisions are ordered chronologically by the prologue date (NOT by the un-padded dir name, which is not lexically sortable); the LIVE NetConfig is included last as '(current)'. SCOPE is reduced to the relevant protocol-block(s) via nc-parse so the diff is NOT the whole 10k-line NetConfig unless site-wide. TWO FORMATS: format=timeline (DEFAULT) = a plain-text who/when/summary table, one revision per block, where the summary counts routing threads added/removed/modified in scope between consecutive revisions; format=diff = the actual unified diff of the scoped NetConfig section between consecutive revisions (oldest→newest), one block per transition. MODES (give exactly one scope): (a) single THREAD — set thread (a bare name resolved in site / $HCISITE, or 'thread.site', or a 'site/thread' node); (b) SYSTEM — set system (case-insensitive name pattern, e.g. 'codametrix') plus site, one scoped diff per matching thread folded together; (c) whole SITE — set ONLY site (omit thread/system) to track the entire NetConfig. --limit keeps the most-recent N revisions; --since filters to revisions on/after a date (YYYY-MM-DD or YYYYMMDD). Resolves the site under $HCIROOT (or pass hciroot).","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"SINGLE-THREAD mode: the thread/protocol to track, e.g. 'ADTto_CodaMetrix'. Accepts a bare name (resolved in site or $HCISITE), 'thread.site', or a 'site/thread' node. Give this OR system OR site-only."},"system":{"type":"string","description":"SYSTEM mode: case-insensitive name pattern matching one or more threads (e.g. 'codametrix', 'periwatch'); the scoped diff folds every matching thread's block together. Pair with site."},"site":{"type":"string","description":"The site (NetConfig's parent dir / revisions dir owner). Required for system mode and for whole-site mode (set site and OMIT thread/system to track the entire NetConfig). For single-thread mode it disambiguates / overrides $HCISITE."},"format":{"type":"string","enum":["timeline","diff"],"description":"timeline (default) = plain-text who/when/summary table, one revision per block. diff = the scoped unified diff between consecutive revisions, one block per transition."},"limit":{"type":"integer","description":"Only the most-recent N revisions. 0/omitted = all."},"since":{"type":"string","description":"Only revisions on/after this date. YYYY-MM-DD or YYYYMMDD."},"hciroot":{"type":"string","description":"Override $HCIROOT for site/revisions discovery."}},"required":[]}},
{"name":"nc_find","description":"Cross-site thread search. Native v3 replacement for v1 tbn/tbp/tbh/tbpr/where. Walks every NetConfig under $HCIROOT and returns matching threads with site, port, host, process, direction, file, line. Modes: name=partial name match (like tbn); port=exact port (like tbp); host=substring on host (like tbh); process=substring on PROCESSNAME (like tbpr); where=exact name match across all sites (like the v1 `<thread> where`); xlate=threads referencing a specific .xlt; tclproc=threads referencing a specific TCL proc.","input_schema":{"type":"object","properties":{"mode":{"type":"string","enum":["name","port","host","process","where","xlate","tclproc"],"description":"Search mode."},"query":{"type":"string","description":"Query value: partial name, port number, host substring, process name, exact thread name, xlate filename, or tclproc name."},"format":{"type":"string","enum":["table","tsv","jsonl"],"description":"Output format. Default table."},"hciroot":{"type":"string","description":"Override $HCIROOT."}},"required":["mode","query"]}}, {"name":"nc_find","description":"Cross-site thread search. Native v3 replacement for v1 tbn/tbp/tbh/tbpr/where. Walks every NetConfig under $HCIROOT and returns matching threads with site, port, host, process, direction, file, line. Modes: name=partial name match (like tbn); port=exact port (like tbp); host=substring on host (like tbh); process=substring on PROCESSNAME (like tbpr); where=exact name match across all sites (like the v1 `<thread> where`); xlate=threads referencing a specific .xlt; tclproc=threads referencing a specific TCL proc.","input_schema":{"type":"object","properties":{"mode":{"type":"string","enum":["name","port","host","process","where","xlate","tclproc"],"description":"Search mode."},"query":{"type":"string","description":"Query value: partial name, port number, host substring, process name, exact thread name, xlate filename, or tclproc name."},"format":{"type":"string","enum":["table","tsv","jsonl"],"description":"Output format. Default table."},"hciroot":{"type":"string","description":"Override $HCIROOT."}},"required":["mode","query"]}},
{"name":"nc_insert_protocol","description":"Insert a new protocol block into a NetConfig file. ALL WRITES GO THROUGH THE JOURNAL — original is snapshotted, diff is saved, the file is atomically replaced. Use larry_rollback_list to view, larry-rollback.sh CLI to undo. mode=end appends; mode=after needs anchor=existing-protocol-name; mode=before needs anchor.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Target NetConfig file path."},"block":{"type":"string","description":"The full protocol block text (starting with 'protocol NAME {' and ending with '}'). Get this from nc_make_jump output."},"mode":{"type":"string","enum":["end","after","before"],"description":"Insertion position. Default end."},"anchor":{"type":"string","description":"For mode=after|before: existing protocol name to position relative to."}},"required":["netconfig","block"]}}, {"name":"nc_insert_protocol","description":"Insert a new protocol block into a NetConfig file. ALL WRITES GO THROUGH THE JOURNAL — original is snapshotted, diff is saved, the file is atomically replaced. Use larry_rollback_list to view, larry-rollback.sh CLI to undo. mode=end appends; mode=after needs anchor=existing-protocol-name; mode=before needs anchor.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Target NetConfig file path."},"block":{"type":"string","description":"The full protocol block text (starting with 'protocol NAME {' and ending with '}'). Get this from nc_make_jump output."},"mode":{"type":"string","enum":["end","after","before"],"description":"Insertion position. Default end."},"anchor":{"type":"string","description":"For mode=after|before: existing protocol name to position relative to."}},"required":["netconfig","block"]}},
{"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"]}},

470
lib/nc-revisions.sh Executable file
View File

@ -0,0 +1,470 @@
#!/usr/bin/env bash
# nc-revisions.sh — NetConfig change-history / revision-diff tool for Larry-Anywhere v3.
#
# Shows how a THREAD (or a multi-thread SYSTEM, or a whole SITE) changed over
# time by diffing Cloverleaf's own NetConfig revision SNAPSHOTS, annotated with
# WHO saved each revision and WHEN. Deterministic, pure bash+awk, NO API, NO
# network — runs identically on an API-blocked host (e.g. Gundersen).
#
# HOW CLOVERLEAF STORES REVISIONS (verified on the real integrator):
# Every save snapshots the FULL NetConfig into a per-revision directory under
# $HCIROOT/<site>/revisions/NetConfig<TS>/
# where <TS> is the save timestamp rendered as M D YYYY H M S with NO zero
# padding (so `NetConfig5212025121420` = 5/21/2025 12:14:20 and
# `NetConfig1126202513418` = 11/26/2025 1:34:18). Because the components are
# un-padded the directory NAME is NOT lexically sortable across months/hours,
# so we DO NOT sort on it. Each snapshot dir contains a full `NetConfig` file
# whose PROLOGUE carries the authoritative author + human-readable date:
# prologue
# who: <USER>
# date: <Month DD, YYYY H:MM:SS AM/PM TZ>
# type: net
# version: 3.20
# end_prologue
# (who/date are TAB-separated, leading-space-indented). We parse the prologue
# `date:` into a sortable key YYYYMMDDHHMMSS so the timeline is in true
# chronological order regardless of the un-padded dir name. The LIVE
# $HCIROOT/<site>/NetConfig is included as the most-recent ("(current)") point.
#
# WHAT EACH ROW SUMMARISES: for the requested scope we extract the relevant
# protocol-block(s) from each revision via nc-parse.sh (so the diff is SCOPED —
# we never diff the whole 10k-line NetConfig unless --site) and report, between
# consecutive revisions, the count of routing threads ADDED / REMOVED / MODIFIED
# (a thread whose block body changed). --site widens the scope to the entire
# NetConfig (protocol set + per-thread body changes across the whole file).
#
# Usage:
# nc-revisions.sh <thread> thread in $HCISITE
# nc-revisions.sh <thread>.<site> a thread in a specific site
# nc-revisions.sh <site>/<thread> v1 node form (nc-paths output feeds in)
# nc-revisions.sh --system <pattern> [--site S] a multi-thread system
# (case-insensitive name match)
# nc-revisions.sh --site <site> the WHOLE NetConfig for one site
#
# Flags:
# --format timeline (DEFAULT) who/when/summary table, one revision per block,
# plain text (label:value, aligned) for OneNote paste.
# --format diff the actual unified diff of the scoped NetConfig section
# between CONSECUTIVE revisions (oldest→newest), one block
# per transition.
# --limit N only the last N revisions (most recent N).
# --since <date> only revisions on/after <date>. Accepts YYYY-MM-DD or
# YYYYMMDD (compared against the parsed sortable key).
# --thread NAME thread to track (alternative to the positional form).
# --system PATTERN multi-thread system mode (see above).
# --site NAME the site to operate in (whole-NetConfig scope on its own;
# or scopes --thread/--system to a specific site).
# --hciroot DIR override $HCIROOT for site discovery.
# -h | --help this help.
#
# PIPE-FIRST: timeline rows and diff blocks are greppable; one revision (or one
# transition) per logical block, no decorative noise. The whole output is run
# through the shared control-byte sanitiser (lib/cygwin-safe.sh): the timeline
# is a human-readable artifact so it strips UNCONDITIONALLY; --format diff is
# tty-gated (raw bytes pass through on a pipe/redirect so a downstream consumer
# sees byte-identical diff text).
#
# Exit codes: 0 OK, 1 usage error, 2 not found.
set -u
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCP="$LIB_DIR/nc-parse.sh"
# Shared sanitisers (see lib/cygwin-safe.sh). _sanitize_ctl strips C0 control
# bytes unconditionally (timeline = human-readable); _sanitize_ctl_tty strips
# only when stdout is a terminal (diff to a pipe stays byte-identical). Degrade
# safe to raw passthrough if the lib is somehow unavailable.
if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then
# shellcheck disable=SC1090,SC1091
. "$LIB_DIR/cygwin-safe.sh"
else
_sanitize_ctl() { cat; }
_sanitize_ctl_tty() { cat; }
fi
die() { printf 'nc-revisions: %s\n' "$*" >&2; exit 1; }
# ─────────────────────────────────────────────────────────────────────────────
# Arg parsing
# ─────────────────────────────────────────────────────────────────────────────
THREAD=""
SYSTEM=""
SITE_ARG=""
HCIROOT_OVERRIDE=""
FORMAT="timeline"
LIMIT=0
SINCE=""
WHOLE_SITE=0
POSITIONAL=()
while [ $# -gt 0 ]; do
case "$1" in
--thread) shift; THREAD="${1:-}" ;;
--system) shift; SYSTEM="${1:-}" ;;
--site) shift; SITE_ARG="${1:-}"; WHOLE_SITE=1 ;;
--hciroot) shift; HCIROOT_OVERRIDE="${1:-}" ;;
--format) shift; FORMAT="${1:-timeline}" ;;
--limit) shift; LIMIT="${1:-0}" ;;
--since) shift; SINCE="${1:-}" ;;
-h|--help) sed -n '2,67p' "$NC_SELF" | sed 's/^# \{0,1\}//'; exit 0 ;;
--*) die "unknown flag: $1" ;;
*) POSITIONAL+=("$1") ;;
esac
shift
done
case "$FORMAT" in timeline|diff) ;; *) die "bad --format: $FORMAT (timeline|diff)" ;; esac
# coerce LIMIT to a clean int (CR-safe)
LIMIT=$(coerce_int "$LIMIT" 0 2>/dev/null || printf '%s' "$LIMIT")
case "$LIMIT" in ''|*[!0-9]*) LIMIT=0 ;; esac
# Positional shapes for a single thread:
# <thread> bare thread (site from --site / $HCISITE)
# <thread>.<site> thread.site (cross-site) — split on the LAST dot
# <site>/<thread> v1 node form (nc-paths output feeds back in)
# An explicit --thread / --system wins; a positional is only consumed when neither
# was given.
if [ -z "$THREAD" ] && [ -z "$SYSTEM" ] && [ "$WHOLE_SITE" = "0" ] && [ "${#POSITIONAL[@]}" -ge 1 ]; then
THREAD="${POSITIONAL[0]}"
fi
[ "${#POSITIONAL[@]}" -gt 1 ] && die "too many positional args: ${POSITIONAL[*]}"
# Resolve a site embedded in the thread token.
if [ -n "$THREAD" ]; then
case "$THREAD" in
*/*) # v1 node form site/thread (split on FIRST slash)
_s="${THREAD%%/*}"; _t="${THREAD#*/}"
if [ -n "$_s" ] && [ -n "$_t" ]; then THREAD="$_t"; [ -z "$SITE_ARG" ] && SITE_ARG="$_s"; fi ;;
*.*) # thread.site form (split on LAST dot)
_t="${THREAD%.*}"; _s="${THREAD##*.}"
if [ -n "$_t" ] && [ -n "$_s" ]; then THREAD="$_t"; [ -z "$SITE_ARG" ] && SITE_ARG="$_s"; fi ;;
esac
fi
# Default the site to $HCISITE when nothing else named it.
if [ -z "$SITE_ARG" ]; then SITE_ARG="${HCISITE:-}"; fi
# Exactly one scope must be determinable.
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
: # whole-site scope; SITE_ARG holds the site
elif [ -n "$SYSTEM" ]; then
:
elif [ -n "$THREAD" ]; then
:
else
die "no scope given. Try: nc-revisions.sh <thread> | nc-revisions.sh --system <pat> [--site S] | nc-revisions.sh --site <site>"
fi
[ -n "$SITE_ARG" ] || die "no site resolvable (set \$HCISITE, pass <thread>.<site>, or --site <site>)"
ROOT="${HCIROOT_OVERRIDE:-${HCIROOT:-}}"
[ -n "$ROOT" ] || die "no \$HCIROOT and no --hciroot; pass one or set the env var"
[ -d "$ROOT" ] || die "hciroot not a directory: $ROOT"
SITE_DIR="$ROOT/$SITE_ARG"
[ -d "$SITE_DIR" ] || die "site dir not found: $SITE_DIR"
REV_DIR="$SITE_DIR/revisions"
LIVE_NC="$SITE_DIR/NetConfig"
US=$'\037' # field separator for the discovered-revisions table
# ─────────────────────────────────────────────────────────────────────────────
# PROLOGUE PARSE — extract who + date from a NetConfig's prologue block, and
# render a sortable key YYYYMMDDHHMMSS from the human-readable date string.
# The prologue (lines 1..end_prologue) carries TAB-separated `who:` / `date:`
# fields. Emits exactly: SORTKEY<US>WHO<US>HUMANDATE (one line), or nothing.
# ─────────────────────────────────────────────────────────────────────────────
_prologue_facts() { # netconfig_file
local nc="$1"
[ -f "$nc" ] || return 0
awk -v US="$US" '
BEGIN {
m["January"]="01"; m["February"]="02"; m["March"]="03"; m["April"]="04"
m["May"]="05"; m["June"]="06"; m["July"]="07"; m["August"]="08"
m["September"]="09"; m["October"]="10"; m["November"]="11"; m["December"]="12"
who=""; dh=""
}
NR==1 && $0 !~ /^prologue/ { exit } # not a prologue-led NetConfig
/^[[:space:]]*who:/ { line=$0; sub(/^[[:space:]]*who:[[:space:]]*/,"",line); who=line }
/^[[:space:]]*date:/ { line=$0; sub(/^[[:space:]]*date:[[:space:]]*/,"",line); dh=line }
/^end_prologue/ { exit }
END {
# dh looks like: November 26, 2025 1:04:18 PM PST
key="00000000000000"
if (dh != "") {
d=dh
gsub(/,/," ",d)
n=split(d, f, /[ \t]+/)
# f: [1]=Month [2]=Day [3]=Year [4]=H:MM:SS [5]=AM/PM [6]=TZ
mon = (f[1] in m) ? m[f[1]] : "00"
day = f[2]+0; if (day<10) day="0" day
yr = f[3]+0
split(f[4], t, ":")
hh=t[1]+0; mm=t[2]+0; ss=t[3]+0
ap = toupper(f[5])
if (ap=="PM" && hh<12) hh+=12
if (ap=="AM" && hh==12) hh=0
if (hh<10) hh="0" hh
if (mm<10) mm="0" mm
if (ss<10) ss="0" ss
if (yr>0 && mon!="00") key = sprintf("%04d%s%s%s%s%s", yr, mon, day, hh, mm, ss)
}
printf "%s%s%s%s%s\n", key, US, who, US, dh
}
' "$nc"
}
# ─────────────────────────────────────────────────────────────────────────────
# REVISION DISCOVERY — build a chronologically-ordered list of (sortkey, who,
# humandate, label, netconfig-path) records, one per revision PLUS the live
# NetConfig. Written to $REVTBL, US-delimited, sorted ascending by sortkey.
# Honors --since and --limit (limit keeps the most-recent N).
#
# TIE-BREAK: the prologue `date:` is the LAST-EDITOR stamp, so two snapshots (and
# the live NetConfig copied from the newest snapshot) can share the SAME prologue
# date. To keep the order deterministic we carry a SECONDARY key, written as a
# leading sort-only column (stripped before $REVTBL):
# - snapshots → the dir-name digit string (the actual save timestamp), so two
# same-prologue snapshots still order by when they were saved.
# - live NetConfig → the sentinel `~~~~~~~~~~~~~~` (C-locale: `~` > any digit),
# so the CURRENT state always sorts LAST on a tie.
# ─────────────────────────────────────────────────────────────────────────────
REVTBL=$(mktemp)
trap 'rm -f "$REVTBL"' EXIT
_discover_revisions() {
local raw; raw=$(mktemp)
local d nc facts key who dh rest sec
# snapshot revisions
if [ -d "$REV_DIR" ]; then
for d in "$REV_DIR"/NetConfig*; do
[ -d "$d" ] || continue
nc="$d/NetConfig"
[ -f "$nc" ] || continue
facts=$(_prologue_facts "$nc")
[ -n "$facts" ] || continue
key="${facts%%$US*}"; rest="${facts#*$US}"
who="${rest%%$US*}"; dh="${rest#*$US}"
# secondary key = the dir-name digit run, zero-padded so a numeric save
# timestamp orders correctly as a string.
sec=$(basename "$d" | tr -cd '0-9'); sec=$(printf '%014d' "${sec:-0}" 2>/dev/null || printf '%s' "$sec")
printf '%s%s%s%s%s%s%s%s%s%s%s\n' \
"$key" "$US" "$sec" "$US" "$who" "$US" "$dh" "$US" "$(basename "$d")" "$US" "$nc" >> "$raw"
done
fi
# the live (current) NetConfig as the newest point — its secondary key is the
# `~` sentinel so it always sorts AFTER a same-prologue-date snapshot.
if [ -f "$LIVE_NC" ]; then
facts=$(_prologue_facts "$LIVE_NC")
if [ -n "$facts" ]; then
key="${facts%%$US*}"; rest="${facts#*$US}"
who="${rest%%$US*}"; dh="${rest#*$US}"
printf '%s%s%s%s%s%s%s%s%s%s%s\n' \
"$key" "$US" "~~~~~~~~~~~~~~" "$US" "$who" "$US" "$dh" "$US" "(current)" "$US" "$LIVE_NC" >> "$raw"
fi
fi
# --since filter: normalise YYYY-MM-DD / YYYYMMDD to an 8-digit prefix and
# compare against the sortkey's leading 8 digits (the date portion).
local since8=""
if [ -n "$SINCE" ]; then
since8=$(printf '%s' "$SINCE" | tr -cd '0-9')
since8="${since8:0:8}"
fi
# sort ascending by (sortkey, secondary), apply --since, then drop the secondary
# column so $REVTBL is the clean 5-field record: key|who|dh|label|nc.
LC_ALL=C sort -t"$US" -k1,1 -k2,2 "$raw" \
| awk -F"$US" -v US="$US" -v since8="$since8" '
{ if (since8=="" || substr($1,1,8) >= since8)
print $1 US $3 US $4 US $5 US $6 }
' > "$raw.sorted"
rm -f "$raw"
if [ "$LIMIT" -gt 0 ]; then
# keep the most-recent LIMIT (tail), preserving ascending order
tail -n "$LIMIT" "$raw.sorted" > "$REVTBL"
else
cp "$raw.sorted" "$REVTBL"
fi
rm -f "$raw.sorted"
}
# ─────────────────────────────────────────────────────────────────────────────
# SCOPED EXTRACTION — emit the relevant NetConfig text for ONE revision so the
# diff/summary is scoped to the requested thread/system (NOT the whole file)
# unless --site. The extraction is deterministic via nc-parse.sh.
# _scoped_text <netconfig> → the protocol-block(s) in scope (or whole file).
# For --system we concatenate every matching protocol's block (sorted by name)
# so a thread added/removed between revisions shows up as a block appearing/
# disappearing in the scoped text.
# ─────────────────────────────────────────────────────────────────────────────
_scope_threads() { # netconfig → the in-scope thread names, sorted unique
local nc="$1"
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
"$NCP" list-protocols "$nc" 2>/dev/null | LC_ALL=C sort -u
elif [ -n "$SYSTEM" ]; then
"$NCP" list-protocols "$nc" 2>/dev/null | grep -iE -- "$SYSTEM" 2>/dev/null | LC_ALL=C sort -u
else
# single thread (only if present in this revision)
"$NCP" list-protocols "$nc" 2>/dev/null | grep -xF -- "$THREAD"
fi
}
_scoped_text() { # netconfig → scoped NetConfig text
local nc="$1"
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
cat "$nc"
return 0
fi
local t
while IFS= read -r t; do
[ -z "$t" ] && continue
printf 'protocol %s {\n' "$t"
"$NCP" protocol-block "$nc" "$t" 2>/dev/null | sed '1d' # block already starts with the header; re-emit a stable header
done < <(_scope_threads "$nc")
}
# Summarise the change between two revisions' in-scope thread SETS + bodies.
# Emits a compact phrase: "+N added, -N removed, ~N modified (names…)".
# Pure: compares the thread name sets and per-thread block bodies.
_summary_between() { # nc_old nc_new
local a="$1" b="$2"
local ta tb
ta=$(mktemp); tb=$(mktemp)
_scope_threads "$a" > "$ta"
_scope_threads "$b" > "$tb"
local added removed common t
added=$(LC_ALL=C comm -13 "$ta" "$tb")
removed=$(LC_ALL=C comm -23 "$ta" "$tb")
common=$(LC_ALL=C comm -12 "$ta" "$tb")
rm -f "$ta" "$tb"
local n_add n_rem n_mod=0 mod_names=""
n_add=$(printf '%s\n' "$added" | grep -c . 2>/dev/null); [ -z "$n_add" ] && n_add=0
n_rem=$(printf '%s\n' "$removed" | grep -c . 2>/dev/null); [ -z "$n_rem" ] && n_rem=0
[ -z "$added" ] && n_add=0
[ -z "$removed" ] && n_rem=0
# modified = common thread whose block body differs
while IFS= read -r t; do
[ -z "$t" ] && continue
if ! diff -q <("$NCP" protocol-block "$a" "$t" 2>/dev/null) \
<("$NCP" protocol-block "$b" "$t" 2>/dev/null) >/dev/null 2>&1; then
n_mod=$((n_mod+1))
mod_names="${mod_names:+$mod_names }$t"
fi
done <<< "$common"
# detail names (capped so the row stays one block)
local add_names rem_names
add_names=$(printf '%s\n' "$added" | grep . | head -6 | tr '\n' ' ' | sed 's/ $//')
rem_names=$(printf '%s\n' "$removed" | grep . | head -6 | tr '\n' ' ' | sed 's/ $//')
local mn; mn=$(printf '%s' "$mod_names" | tr ' ' '\n' | grep . | head -6 | tr '\n' ' ' | sed 's/ $//')
local parts=""
[ "$n_add" -gt 0 ] && parts="${parts:+$parts, }+${n_add} added${add_names:+ ($add_names)}"
[ "$n_rem" -gt 0 ] && parts="${parts:+$parts, }-${n_rem} removed${rem_names:+ ($rem_names)}"
[ "$n_mod" -gt 0 ] && parts="${parts:+$parts, }~${n_mod} modified${mn:+ ($mn)}"
[ -z "$parts" ] && parts="no change in scope"
printf '%s' "$parts"
}
# ─────────────────────────────────────────────────────────────────────────────
# Scope label for the header (human-readable description of what we're tracking).
# ─────────────────────────────────────────────────────────────────────────────
_scope_label() {
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
printf 'whole site %s (entire NetConfig)' "$SITE_ARG"
elif [ -n "$SYSTEM" ]; then
printf 'system "%s" in site %s' "$SYSTEM" "$SITE_ARG"
else
printf 'thread %s in site %s' "$THREAD" "$SITE_ARG"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# RENDER: timeline
# ─────────────────────────────────────────────────────────────────────────────
render_timeline() {
local n; n=$(grep -c . "$REVTBL" 2>/dev/null); [ -z "$n" ] && n=0
printf 'NetConfig revision history — %s\n' "$(_scope_label)"
printf '%s revision(s) found%s\n\n' "$n" "$( [ "$LIMIT" -gt 0 ] && printf ' (last %s)' "$LIMIT" )"
if [ "$n" = "0" ]; then
printf '(no revisions with a parseable prologue under %s)\n' "$REV_DIR" >&2
return 0
fi
# iterate ascending; for each revision (after the first) summarise vs the prior.
local prev_nc="" idx=0
local key who dh label nc
while IFS="$US" read -r key who dh label nc; do
[ -z "$nc" ] && continue
idx=$((idx+1))
printf 'revision %s\n' "$label"
printf 'date %s\n' "${dh:-(unknown)}"
printf 'who %s\n' "${who:-(unknown)}"
if [ -z "$prev_nc" ]; then
printf 'changed (baseline — earliest revision in range)\n'
else
printf 'changed %s\n' "$(_summary_between "$prev_nc" "$nc")"
fi
printf '\n'
prev_nc="$nc"
done < "$REVTBL"
}
# ─────────────────────────────────────────────────────────────────────────────
# RENDER: diff (unified diff of the scoped section between CONSECUTIVE revisions)
# ─────────────────────────────────────────────────────────────────────────────
render_diff() {
local n; n=$(grep -c . "$REVTBL" 2>/dev/null); [ -z "$n" ] && n=0
printf 'NetConfig revision diff — %s\n' "$(_scope_label)"
printf '%s revision(s) found%s\n\n' "$n" "$( [ "$LIMIT" -gt 0 ] && printf ' (last %s)' "$LIMIT" )"
if [ "$n" -lt 2 ]; then
printf '(need at least 2 revisions to diff; found %s under %s)\n' "$n" "$REV_DIR" >&2
return 0
fi
local prev_nc="" prev_lbl="" prev_dh=""
local key who dh label nc
while IFS="$US" read -r key who dh label nc; do
[ -z "$nc" ] && continue
if [ -n "$prev_nc" ]; then
printf '=== %s (%s) -> %s (%s, by %s) ===\n' \
"$prev_lbl" "${prev_dh:-?}" "$label" "${dh:-?}" "${who:-?}"
local a b
a=$(mktemp); b=$(mktemp)
_scoped_text "$prev_nc" > "$a"
_scoped_text "$nc" > "$b"
# unified diff of the scoped text; label the headers with the revision name.
diff -u --label "$prev_lbl" --label "$label" "$a" "$b" 2>/dev/null
local rc=$?
[ "$rc" = "0" ] && printf '(no change in scope)\n'
rm -f "$a" "$b"
printf '\n'
fi
prev_nc="$nc"; prev_lbl="$label"; prev_dh="$dh"
done < "$REVTBL"
}
# ─────────────────────────────────────────────────────────────────────────────
# Drive
# ─────────────────────────────────────────────────────────────────────────────
_discover_revisions
case "$FORMAT" in
timeline)
# Human-readable artifact → strip control bytes UNCONDITIONALLY.
{ render_timeline; } | _sanitize_ctl
exit "${PIPESTATUS[0]}"
;;
diff)
# Diff text may feed a downstream consumer → tty-gated strip (raw on a pipe).
{ render_diff; } | _sanitize_ctl_tty
exit "${PIPESTATUS[0]}"
;;
esac