#!/usr/bin/env bash # nc-paths.sh — deterministic route-chain path ENUMERATOR for Larry-Anywhere v3. # # This is the SINGLE walker backend for Cloverleaf message routing. It replaces # the old dark `nc-parse.sh chain` BFS-node-set command (which only ever # returned a flat set of reachable nodes, never enumerated paths, and was never # wired into the LLM). It ports the v2 `paths` semantics # (cloverleaf_tools/cli/legacy_workflow_commands.py paths_cmd + the three # _enumerate_* helpers, lines 315-464) faithfully: # # - Downstream DFS from a start thread, following the DATAXLATE DEST list # (find_outgoing). A leaf (no outgoing) OR a cycle hit terminates that path # and the terminal node is included in the emitted chain. # - Upstream DFS (mirror), following incoming threads (find_incoming). # - All-mode: enumerate from every entry point (a thread with no incoming), # deduped — gives the whole-site chain inventory (v2 list_full_routes). # # ROUTING RESOLUTION: next hop is resolved ONLY from the DATAXLATE { DEST } # list (via nc-parse.sh destinations / sources). It NEVER reads ICLSERVERPORT. # This is deliberate: Bryan's old paths.tcl walked routes via # `keylget data ICLSERVERPORT`, which THROWS on any thread lacking that key # (every outbound/client thread), so the trace died on the first client thread. # The DEST list is present on every routing thread regardless of direction and # simply yields nothing (no crash) when a thread has no routes. DO NOT # reintroduce an ICLSERVERPORT-based hop here. # # CROSS-SITE BY DEFAULT (Bryan's resolved decision, 2026-05-28): when a chain's # terminal thread (a downstream leaf with no further DEST in its own site) is # ALSO an entry/inbound thread declared in ANOTHER discovered site's NetConfig # (correlated by shared thread name), the walk CONTINUES into that site — so the # mux -> ancout -> CodaMetrix style chain is followed end to end across the site # boundary. Pass --site-only to scope the walk to a single site. # # Robust cycle detection across sites: every walk carries the full ancestor set # keyed by "site\037thread"; revisiting any (site,thread) ancestor terminates the # path (the terminal node is still emitted), so the enumeration always # terminates. A global max-depth cap (default 128, matching v2) is a second # backstop. # # Output columns: SITE THREAD HOPS PATH # THREAD = the start/anchor thread of the row # HOPS = number of threads in the chain (len of the path list) # PATH = the chain joined by " -> " (space-arrow-space) # One row per enumerated root-to-leaf path; a branching thread yields N rows. # # Usage: # nc-paths.sh --netconfig [flags] # explicit NetConfig # nc-paths.sh [flags] # resolve site under $HCIROOT # nc-paths.sh --all [--site ] [flags] # whole-site entry chains # # Flags: # --upstream only the upstream chains feeding the thread # --downstream only the downstream chains from the thread # (neither flag = full paths containing the thread, # v2 default, falling back to downstream-from-thread) # --all enumerate from every entry point (no thread arg) # --site scope all-mode (or site resolution) to one site # --site-only do NOT cross site boundaries (downstream only) # --hciroot override $HCIROOT for site/cross-site discovery # --netconfig operate on one explicit NetConfig (implies the site is # basename(dirname(file)); cross-site still scans $HCIROOT) # --max-depth N recursion cap (default 128) # --format tsv|table|jsonl default: table # # 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" die() { printf 'nc-paths: %s\n' "$*" >&2; exit 1; } # ───────────────────────────────────────────────────────────────────────────── # Arg parsing # ───────────────────────────────────────────────────────────────────────────── THREAD="" SITE_ARG="" NETCONFIG="" HCIROOT_OVERRIDE="" DIR_MODE="full" # full | up | down ALL_MODE=0 SITE_ONLY=0 MAX_DEPTH=128 FORMAT="table" POSITIONAL=() while [ $# -gt 0 ]; do case "$1" in --upstream) DIR_MODE="up" ;; --downstream) DIR_MODE="down" ;; --all) ALL_MODE=1 ;; --site) shift; SITE_ARG="${1:-}" ;; --site-only) SITE_ONLY=1 ;; --hciroot) shift; HCIROOT_OVERRIDE="${1:-}" ;; --netconfig) shift; NETCONFIG="${1:-}" ;; --max-depth) shift; MAX_DEPTH="${1:-128}" ;; --format) shift; FORMAT="${1:-table}" ;; -h|--help) sed -n '2,70p' "$NC_SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; --*) die "unknown flag: $1" ;; *) POSITIONAL+=("$1") ;; esac shift done case "$FORMAT" in tsv|table|jsonl) ;; *) die "bad --format: $FORMAT (tsv|table|jsonl)" ;; esac # Positional shapes: # (manual: thread only; site from $HCISITE/$HCISITEDIR) # (manual muscle-memory: thread + site) if [ "${#POSITIONAL[@]}" -ge 1 ]; then THREAD="${POSITIONAL[0]}"; fi if [ "${#POSITIONAL[@]}" -ge 2 ] && [ -z "$SITE_ARG" ]; then SITE_ARG="${POSITIONAL[1]}"; fi if [ "${#POSITIONAL[@]}" -gt 2 ]; then die "too many positional args: ${POSITIONAL[*]}"; fi if [ "$ALL_MODE" = "0" ] && [ -z "$THREAD" ]; then die "no thread given (and --all not set). Try: nc-paths.sh OR nc-paths.sh --all --site " fi ROOT="${HCIROOT_OVERRIDE:-${HCIROOT:-}}" # ───────────────────────────────────────────────────────────────────────────── # Site discovery — map every discovered NetConfig to a site name. # Two parallel arrays (portable to bash 3.2 on macOS; no associative-array dep). # SITE_NAMES[i] = site (basename of NetConfig's parent dir) # SITE_NCS[i] = absolute NetConfig path # An explicit --netconfig is always included; cross-site scanning still walks # $HCIROOT so a terminal can hop into another site. # ───────────────────────────────────────────────────────────────────────────── SITE_NAMES=() SITE_NCS=() _add_site() { local name="$1" nc="$2" i [ -f "$nc" ] || return 0 # de-dupe by NetConfig path for ((i=0; i<${#SITE_NCS[@]}; i++)); do [ "${SITE_NCS[$i]}" = "$nc" ] && return 0 done SITE_NAMES+=("$name") SITE_NCS+=("$nc") } _discover_sites() { # explicit NetConfig first (its site name is the parent dir basename) if [ -n "$NETCONFIG" ]; then [ -f "$NETCONFIG" ] || die "not a file: $NETCONFIG" _add_site "$(basename "$(dirname "$NETCONFIG")")" "$NETCONFIG" fi # When --site-only with an explicit NetConfig, do not scan further. if [ "$SITE_ONLY" = "1" ] && [ -n "$NETCONFIG" ]; then return 0 fi # Otherwise discover all sites under $HCIROOT (for cross-site joins / site # resolution / all-mode), same walk nc-find.sh uses. if [ -n "$ROOT" ]; then local nc sname while IFS= read -r nc; do sname=$(basename "$(dirname "$nc")") # When --site-only (no explicit NetConfig) and a site was named, keep only it. if [ "$SITE_ONLY" = "1" ] && [ -n "$SITE_ARG" ] && [ "$sname" != "$SITE_ARG" ]; then continue fi _add_site "$sname" "$nc" done < <(find "$ROOT" -maxdepth 2 -name NetConfig -type f 2>/dev/null | sort) fi } # Resolve the NetConfig path for a given site name (first match wins). _nc_for_site() { local want="$1" i for ((i=0; i<${#SITE_NAMES[@]}; i++)); do if [ "${SITE_NAMES[$i]}" = "$want" ]; then printf '%s' "${SITE_NCS[$i]}" return 0 fi done return 1 } # Given a thread name, find the FIRST discovered (site,nc) pair whose NetConfig # declares that thread as a protocol. Emits "site\037nc" or returns 1. US=$'\037' # unit separator — safe field delimiter for site/thread keys _locate_thread() { local want="$1" i sname nc for ((i=0; i<${#SITE_NCS[@]}; i++)); do sname="${SITE_NAMES[$i]}"; nc="${SITE_NCS[$i]}" if "$NCP" list-protocols "$nc" 2>/dev/null | grep -qxF "$want"; then printf '%s%s%s' "$sname" "$US" "$nc" return 0 fi done return 1 } # ───────────────────────────────────────────────────────────────────────────── # One-hop primitives (DEST-based, never ICLSERVERPORT). # ───────────────────────────────────────────────────────────────────────────── _outgoing() { "$NCP" destinations "$1" "$2" 2>/dev/null; } # nc thread -> dest names _incoming() { "$NCP" sources "$1" "$2" 2>/dev/null; } # nc thread -> source names # Is an entry point (no incoming) in ? _is_entry_in() { local nc="$1" t="$2" [ -z "$(_incoming "$nc" "$t")" ] } # ───────────────────────────────────────────────────────────────────────────── # Path enumeration. Emitted paths are written to $OUT_PATHS as one line each: # sitechain where chain = thread1 -> thread2 -> ... # We carry the running chain as a space-joined token list of "site\037thread" # keys, and the ancestor set as newline-joined keys (for cycle detection). # ───────────────────────────────────────────────────────────────────────────── OUT_PATHS=$(mktemp) trap 'rm -f "$OUT_PATHS"' EXIT # _emit_chain ANCHOR_SITE KEYCHAIN # KEYCHAIN = space-separated list of "site\037thread" keys # Renders to "anchor_sitet1 -> t2 -> ..." (thread names only in PATH). _emit_chain() { local anchor_site="$1" keychain="$2" local out="" k thr first=1 for k in $keychain; do thr="${k#*$US}" if [ "$first" = "1" ]; then out="$thr"; first=0; else out="$out -> $thr"; fi done printf '%s\t%s\n' "$anchor_site" "$out" } # Downstream DFS. Mirrors v2 _enumerate_downstream_paths + cross-site hop. # $1 anchor_site — site to report in the SITE column for these rows # $2 cur_site — site of current thread # $3 cur_nc — NetConfig of current thread # $4 cur_thread — current thread name # $5 keychain — space-joined ancestor keys NOT including current # $6 seen — newline-joined ancestor keys (for cycle detection) # $7 depth _walk_down() { local anchor_site="$1" cur_site="$2" cur_nc="$3" cur_thread="$4" local keychain="$5" seen="$6" depth="$7" local curkey="${cur_site}${US}${cur_thread}" local newchain if [ -z "$keychain" ]; then newchain="$curkey"; else newchain="$keychain $curkey"; fi # cycle / depth cap → terminate, include current node (v2 semantics) if [ "$depth" -gt "$MAX_DEPTH" ] || printf '%s\n' "$seen" | grep -qxF "$curkey"; then _emit_chain "$anchor_site" "$newchain" return 0 fi # gather outgoing within the current site local outgoing=() local d while IFS= read -r d; do [ -z "$d" ] && continue outgoing+=("$d") done < <(_outgoing "$cur_nc" "$cur_thread") if [ "${#outgoing[@]}" -gt 0 ]; then local nseen nseen="$seen"$'\n'"$curkey" for d in "${outgoing[@]}"; do _walk_down "$anchor_site" "$cur_site" "$cur_nc" "$d" "$newchain" "$nseen" $((depth+1)) done return 0 fi # No outgoing in this site = a leaf for this site. CROSS-SITE HOP: # if cross-site is enabled and this leaf thread is an entry/inbound thread in # ANOTHER site's NetConfig (shared name) that DOES have outgoing there, # continue the walk into that site. if [ "$SITE_ONLY" = "0" ]; then local i osite onc okey for ((i=0; i<${#SITE_NCS[@]}; i++)); do osite="${SITE_NAMES[$i]}"; onc="${SITE_NCS[$i]}" [ "$osite" = "$cur_site" ] && [ "$onc" = "$cur_nc" ] && continue # the thread must exist in the other site AND have outgoing there "$NCP" list-protocols "$onc" 2>/dev/null | grep -qxF "$cur_thread" || continue [ -n "$(_outgoing "$onc" "$cur_thread")" ] || continue okey="${osite}${US}${cur_thread}" # cycle guard across sites: don't re-enter an ancestor (site,thread) printf '%s\n' "$seen" | grep -qxF "$okey" && continue # Continue the chain in the other site. We DROP the duplicate boundary # node: cur_thread is already the last node in newchain, and it is the # same thread name in osite, so we recurse on its destinations directly, # carrying newchain as the prefix and marking both (site,thread) keys seen. local nseen2 nseen2="$seen"$'\n'"$curkey"$'\n'"$okey" local dd while IFS= read -r dd; do [ -z "$dd" ] && continue _walk_down "$anchor_site" "$osite" "$onc" "$dd" "$newchain" "$nseen2" $((depth+1)) done < <(_outgoing "$onc" "$cur_thread") # only join into the first matching downstream site, then stop scanning return 0 done fi # true terminal — emit the chain _emit_chain "$anchor_site" "$newchain" } # Upstream DFS. Mirrors v2 _enumerate_upstream_paths. Cross-site upstream: # if a thread has no incoming in its own site but the same-named thread is a # downstream/leaf in another site, follow that site's incoming (the feeders). # builds the chain as a PREFIX (sources come before current) _walk_up() { local anchor_site="$1" cur_site="$2" cur_nc="$3" cur_thread="$4" local keychain="$5" seen="$6" depth="$7" local curkey="${cur_site}${US}${cur_thread}" local newchain if [ -z "$keychain" ]; then newchain="$curkey"; else newchain="$curkey $keychain"; fi if [ "$depth" -gt "$MAX_DEPTH" ] || printf '%s\n' "$seen" | grep -qxF "$curkey"; then _emit_chain "$anchor_site" "$newchain" return 0 fi local incoming=() local s while IFS= read -r s; do [ -z "$s" ] && continue incoming+=("$s") done < <(_incoming "$cur_nc" "$cur_thread") if [ "${#incoming[@]}" -gt 0 ]; then local nseen nseen="$seen"$'\n'"$curkey" for s in "${incoming[@]}"; do _walk_up "$anchor_site" "$cur_site" "$cur_nc" "$s" "$newchain" "$nseen" $((depth+1)) done return 0 fi # cross-site upstream hop: same-named thread fed in another site if [ "$SITE_ONLY" = "0" ]; then local i osite onc okey for ((i=0; i<${#SITE_NCS[@]}; i++)); do osite="${SITE_NAMES[$i]}"; onc="${SITE_NCS[$i]}" [ "$osite" = "$cur_site" ] && [ "$onc" = "$cur_nc" ] && continue "$NCP" list-protocols "$onc" 2>/dev/null | grep -qxF "$cur_thread" || continue [ -n "$(_incoming "$onc" "$cur_thread")" ] || continue okey="${osite}${US}${cur_thread}" printf '%s\n' "$seen" | grep -qxF "$okey" && continue local nseen2 nseen2="$seen"$'\n'"$curkey"$'\n'"$okey" local ss while IFS= read -r ss; do [ -z "$ss" ] && continue _walk_up "$anchor_site" "$osite" "$onc" "$ss" "$newchain" "$nseen2" $((depth+1)) done < <(_incoming "$onc" "$cur_thread") return 0 done fi _emit_chain "$anchor_site" "$newchain" } # ───────────────────────────────────────────────────────────────────────────── # Drivers # ───────────────────────────────────────────────────────────────────────────── # Enumerate every full path in a site by starting from each entry point. # Cross-site continuation happens naturally inside _walk_down. Dedup by the # rendered "site\tchain" line. _enumerate_all_in_site() { local site="$1" nc="$2" local entry tmp tmp=$(mktemp) # entry points = threads with no incoming in this site "$NCP" list-protocols "$nc" 2>/dev/null | while IFS= read -r entry; do [ -z "$entry" ] && continue if _is_entry_in "$nc" "$entry"; then printf '%s\n' "$entry" >> "$tmp" fi done # if no entry points (every thread has an incoming, e.g. a pure cycle), # fall back to all protocols as start points (v2 fallback) if [ ! -s "$tmp" ]; then "$NCP" list-protocols "$nc" 2>/dev/null > "$tmp" fi while IFS= read -r entry; do [ -z "$entry" ] && continue _walk_down "$site" "$site" "$nc" "$entry" "" "" 0 done < "$tmp" rm -f "$tmp" } main_enumerate() { _discover_sites [ "${#SITE_NCS[@]}" -gt 0 ] || die "no NetConfig found (set \$HCIROOT, or pass --netconfig / --hciroot)" local raw raw=$(mktemp) trap 'rm -f "$OUT_PATHS" "$raw"' EXIT if [ "$ALL_MODE" = "1" ]; then # whole-site entry chains; scope to --site if given (else every site) local i sname snc for ((i=0; i<${#SITE_NAMES[@]}; i++)); do sname="${SITE_NAMES[$i]}"; snc="${SITE_NCS[$i]}" if [ -n "$SITE_ARG" ] && [ "$sname" != "$SITE_ARG" ]; then continue; fi _enumerate_all_in_site "$sname" "$snc" >> "$raw" done else # locate the thread's home site local home_site home_nc loc if [ -n "$NETCONFIG" ]; then home_nc="$NETCONFIG"; home_site="$(basename "$(dirname "$NETCONFIG")")" "$NCP" list-protocols "$home_nc" 2>/dev/null | grep -qxF "$THREAD" \ || die "thread not found in $home_nc: $THREAD" elif [ -n "$SITE_ARG" ]; then home_nc="$(_nc_for_site "$SITE_ARG")" || die "site not found under \$HCIROOT: $SITE_ARG" home_site="$SITE_ARG" "$NCP" list-protocols "$home_nc" 2>/dev/null | grep -qxF "$THREAD" \ || die "thread not found in site $SITE_ARG: $THREAD" else loc="$(_locate_thread "$THREAD")" || die "thread not found in any discovered site: $THREAD" home_site="${loc%%$US*}"; home_nc="${loc#*$US}" fi case "$DIR_MODE" in up) _walk_up "$home_site" "$home_site" "$home_nc" "$THREAD" "" "" 0 >> "$raw" ;; down) _walk_down "$home_site" "$home_site" "$home_nc" "$THREAD" "" "" 0 >> "$raw" ;; full) # v2 default: every full path (entry-point enumeration) that CONTAINS the # thread; fall back to downstream-from-thread if none contain it. local all_tmp all_tmp=$(mktemp) _enumerate_all_in_site "$home_site" "$home_nc" > "$all_tmp" # cross-site: also enumerate full paths in any site whose entry chains # could pass through the thread (the home site's own entry enumeration # already crosses outward; inbound feeders in other sites are picked up # because those sites' entry chains are enumerated in all-mode — but for # a single-thread query we only have the home site's chains, so we also # scan every discovered site's chains to catch upstream feeders). if [ "$SITE_ONLY" = "0" ]; then local j js jn for ((j=0; j<${#SITE_NAMES[@]}; j++)); do js="${SITE_NAMES[$j]}"; jn="${SITE_NCS[$j]}" [ "$jn" = "$home_nc" ] && continue _enumerate_all_in_site "$js" "$jn" >> "$all_tmp" done fi # keep only chains containing the thread (match on " -> THREAD ->", # leading "THREAD ->", or trailing "-> THREAD", or exact) local kept kept=$(awk -F'\t' -v t="$THREAD" ' { chain=$2 # pad with arrows for unambiguous boundary matching padded=" -> " chain " -> " if (index(padded, " -> " t " -> ") > 0) print $0 }' "$all_tmp" | sort -u) if [ -n "$kept" ]; then printf '%s\n' "$kept" >> "$raw" else _walk_down "$home_site" "$home_site" "$home_nc" "$THREAD" "" "" 0 >> "$raw" fi rm -f "$all_tmp" ;; esac fi # dedup the raw "sitechain" lines, preserving first-seen order awk '!seen[$0]++' "$raw" > "$OUT_PATHS" rm -f "$raw" trap 'rm -f "$OUT_PATHS"' EXIT } # ───────────────────────────────────────────────────────────────────────────── # Render: OUT_PATHS holds "sitechain" lines. Build SITE THREAD HOPS PATH. # THREAD = first node of the chain (the anchor/root for this row) # HOPS = number of nodes in the chain # ───────────────────────────────────────────────────────────────────────────── render() { if [ ! -s "$OUT_PATHS" ]; then printf 'No paths found.\n' return 0 fi # produce a 4-col TSV: site thread hops path local tsv tsv=$(awk -F'\t' ' { site=$1; chain=$2 # first node first=chain sub(/ -> .*/, "", first) # hop count = number of " -> " separators + 1 n=split(chain, parts, / -> /) printf "%s\t%s\t%d\t%s\n", site, first, n, chain }' "$OUT_PATHS") case "$FORMAT" in tsv) printf 'site\tthread\thops\tpath\n' printf '%s\n' "$tsv" ;; jsonl) printf '%s\n' "$tsv" | awk -F'\t' ' function esc(s){ gsub(/\\/,"\\\\",s); gsub(/"/,"\\\"",s); return s } { printf "{\"site\":\"%s\",\"thread\":\"%s\",\"hops\":%s,\"path\":\"%s\"}\n", esc($1),esc($2),$3,esc($4) }' ;; table) { printf 'SITE\tTHREAD\tHOPS\tPATH\n' printf '%s\n' "$tsv" } | awk -F'\t' ' { for (i=1;i<=NF;i++){ if (length($i)>w[i]) w[i]=length($i); cell[NR,i]=$i }; rows=NR; cols=NF } END { for (r=1; r<=rows; r++) { for (c=1; c<=cols; c++) printf "%-*s ", w[c], cell[r,c] printf "\n" if (r==1) { for (c=1; c<=cols; c++) { for (k=0;k&2 fi return 0 } main_enumerate render