diff --git a/CHANGELOG.md b/CHANGELOG.md index b13344c..a6ed319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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//revisions/NetConfig/NetConfig`, + where `` 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 `/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 ` (YYYY-MM-DD or YYYYMMDD). Invocation: + `nc-revisions ` (defaults to `$HCISITE`), `.`, + `/`, `--system [--site S]`, or `--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 **★ HARDENING: extend the v0.8.25 control-byte sanitize across the whole tool diff --git a/MANIFEST b/MANIFEST index 8af70b0..d4fa98b 100644 --- a/MANIFEST +++ b/MANIFEST @@ -23,16 +23,16 @@ # scripts/make-manifest.sh and bump VERSION. # Top-level scripts -larry.sh 7fccca0d10a0a742d66efd21da703d780c8359411995cf69925123575b14321c +larry.sh 4de38e7e33507a8c8fde539cb29c031eabbec508674bf51dd9f441e536a76509 larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423 # Metadata -VERSION 6520a3a0746d8a2969ca4c76db2109929b36882541fcdbe3fb6de1718903d97f +VERSION 0ba962133bcfa080a96ec6b746ae1a4eb7816576618aa6c88d2c14506258715b MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde -CHANGELOG.md 0d7a88d389d6723ee2dd289e5d143d4ada8f232ca0df43c392be9bca856f70b6 +CHANGELOG.md 3506542eac6c71d4cb29391b234f3fd30aec3853a5f5122b5bceb73d11079f46 # Agent personas (system-prompt overlays) agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 @@ -103,6 +103,7 @@ lib/nc-inbound.sh 52d28c5f8d97bdf96f0fc7b5300d35b106b8e1226578f4cda430deb2a8b4a9 lib/nc-make-jump.sh 08a0bc58a299c95c60a59a5202792daf0ada3a8a0be7dc1b4cccc5724f5c9c79 lib/nc-msgs.sh 20517922d1153ec7827c833987497fb305d087b579911d1b9067d65ae156a19f lib/nc-document.sh 47211e99089c0446d25a1e84545a734894720a1c9ad8f59b920332035e4ea880 +lib/nc-revisions.sh c27856f7decfc4c2e2c990f59eb20136fdff9cf0a52b9d9fbd9370613666a802 lib/nc-diff-interface.sh 6b64ec3070a3d75d1d79632c9aeb357177fdbcc77c474aa78e6f6929fda1a324 lib/nc-find.sh 8c79e0acad7de56e4e1f12d61e071a4b98c4e2310a1f7fb183697df521215e3f lib/nc-insert-protocol.sh ad1fa0bafbf4fdfb12bad20f9c22c3eed519f8846774331e26aa9becd6f8898a diff --git a/VERSION b/VERSION index 85ccf6f..54e9046 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.26 +0.8.27 diff --git a/larry.sh b/larry.sh index 2e4f535..eec1fa2 100755 --- a/larry.sh +++ b/larry.sh @@ -78,7 +78,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.8.26" +LARRY_VERSION="0.8.27" 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-document.sh|Generate a markdown knowledge entry documenting a Cloverleaf subsystem/interface #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 [.] | --system [--site S] | --site [--format timeline|diff] [--limit N] [--since DATE] nc-diff-interface.sh|Diff one Cloverleaf interface 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 @@ -4114,6 +4115,43 @@ tool_nc_document() { "$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//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() { local interface="$1" left="$2" right="$3" out_path="${4:-}" include_tables="${5:-0}" local left_label="${6:-}" right_label="${7:-}" depth="${8:-1}" @@ -4188,6 +4226,9 @@ execute_tool() { "$(J '.notes // ""')" \ "$(J '.onenote_table // 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_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')" ;; @@ -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/*/.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//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/.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//revisions/NetConfig/, 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 ` 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_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__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"]}}, diff --git a/lib/nc-revisions.sh b/lib/nc-revisions.sh new file mode 100755 index 0000000..ee16c70 --- /dev/null +++ b/lib/nc-revisions.sh @@ -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//revisions/NetConfig/ +# where 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: +# date: +# 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//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 in $HCISITE +# nc-revisions.sh . a thread in a specific site +# nc-revisions.sh / v1 node form (nc-paths output feeds in) +# nc-revisions.sh --system [--site S] a multi-thread system +# (case-insensitive name match) +# nc-revisions.sh --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 only revisions on/after . 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: +# bare thread (site from --site / $HCISITE) +# . thread.site (cross-site) — split on the LAST dot +# / 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 | nc-revisions.sh --system [--site S] | nc-revisions.sh --site " +fi +[ -n "$SITE_ARG" ] || die "no site resolvable (set \$HCISITE, pass ., or --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: SORTKEYWHOHUMANDATE (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 → 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