Просмотр исходного кода

feat(net-ops): Adopt term.sh full panel grammar

Retrofit the shared _lib/output.sh so the three probe ladders (linux/macos/
reverse) render the enclosing term.sh panel — net-ops brand header, │ rail,
colored section sub-headers, term_status_row check rows, footer health
indicator — when stdout is a TTY (or FORCE_COLOR). One lib change panelizes all
three probes; each just sets PANEL_TITLE.

The greppable contract is preserved: piped / non-TTY text keeps the byte-stable
[PASS]/[FAIL] lines + SUMMARY block (humans, LLMs scanning for the first FAIL,
the --watch dispatcher, and CI all depend on it), and --json is untouched. So
the panel is purely the human-at-a-TTY default; data consumers are unaffected.

The dump/fixer audits (linux+macos dns-audit, resolved-reset, resolver-clean)
adopt term.sh via term_header for colorized, ASCII-aware section headers — the
bare-header style is the right fit for raw cat/grep dumps (the deliberate
exception per docs/TERMINAL-DESIGN.md), not the checklist panel.

Tests: an OS-independent terminal-design block drives output.sh directly
(panel frame + ASCII purity, the legacy text contract, json untouched, term.sh
primitive purity) so it runs even where the live probe ladder is skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 1 день назад
Родитель
Сommit
b9654b0a67

+ 67 - 7
skills/net-ops/scripts/_lib/output.sh

@@ -1,21 +1,59 @@
 # net-ops :: _lib/output.sh
-# Output mode handling for probe scripts. Supports two modes:
-#   - text (default): human-readable [PASS]/[FAIL] lines + summary block
-#   - json: newline-delimited JSON, one record per check + a summary record
+# Output mode handling for probe scripts. Three renderings of the same stream:
+#   - panel (default at a TTY): the term.sh enclosing panel — section sub-headers
+#     on the │ rail, colored term_mark check rows, a footer health indicator.
+#   - text (piped / non-TTY): legacy [PASS]/[FAIL] lines + a SUMMARY block. Kept
+#     byte-stable because humans AND LLMs scan it for the first [FAIL]; tests and
+#     pipes depend on it.
+#   - json (--json): newline-delimited JSON, one record per check + a summary.
+#
+# The panel is the human default (per docs/TERMINAL-DESIGN.md); it never touches
+# the piped/--json data product, so stream behaviour is unchanged for consumers.
 #
 # Usage in a probe script:
 #   source "$(dirname "$0")/../_lib/output.sh"
+#   PANEL_TITLE="net · linux probe"     # optional; titles the panel header
 #   parse_output_flags "$@"
-#   # then use section / pass / fail as before — they auto-route to the right mode
+#   # then use section / pass / fail / info / emit_summary as before
 
 JSON_MODE="${JSON_MODE:-0}"
 
+# Shared terminal toolkit (skills/_lib/term.sh) for the panel rendering. Absent ->
+# the legacy text path is used, so this degrades cleanly with no behaviour change.
+__net_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__net_lib:-}" ] && [ -f "$__net_lib/term.sh" ]; then
+    . "$__net_lib/term.sh"
+    __NET_HAVE_TERM=1
+else
+    __NET_HAVE_TERM=0
+fi
+
+# Panel header title — a probe script may override before the first section().
+PANEL_TITLE="${PANEL_TITLE:-net-ops}"
+__PANEL_OPEN=0
+
 parse_output_flags() {
     for a in "$@"; do
         [[ "$a" == "--json" ]] && JSON_MODE=1
     done
 }
 
+# Panel applies in text mode only, when stdout is a TTY (or FORCE_COLOR forces a
+# render for verification). Piped/non-TTY text consumers get the legacy format.
+_panel_active() {
+    [[ "$JSON_MODE" -eq 1 || "$__NET_HAVE_TERM" -eq 0 ]] && return 1
+    [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]
+}
+
+# Lazily open the panel frame (term_init + header + a breath row) on first use.
+_panel_open() {
+    [[ "$__PANEL_OPEN" -eq 1 ]] && return 0
+    term_init
+    term_panel_open net-ops "$PANEL_TITLE"
+    term_panel_vert
+    __PANEL_OPEN=1
+}
+
 # JSON-safe string escaper. Handles backslash, double-quote, and control chars.
 _json_escape() {
     local s="$1"
@@ -27,7 +65,7 @@ _json_escape() {
     printf '%s' "$s"
 }
 
-# These three are the public API. They write either text or JSON depending on mode.
+# These are the public API. They route to panel / text / JSON per mode.
 PASS_COUNT=0
 FAIL_COUNT=0
 FIRST_FAIL=""
@@ -37,6 +75,10 @@ section() {
     CURRENT_SECTION="$1"
     if [[ "$JSON_MODE" -eq 1 ]]; then
         printf '{"type":"section","name":"%s"}\n' "$(_json_escape "$1")"
+    elif _panel_active; then
+        _panel_open
+        term_panel_vert
+        term_panel_line "$(term_color cyan "$1")"
     else
         echo
         echo "=== $1 ==="
@@ -48,6 +90,9 @@ pass() {
     if [[ "$JSON_MODE" -eq 1 ]]; then
         printf '{"type":"check","section":"%s","label":"%s","status":"pass","detail":"%s"}\n' \
             "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
+    elif _panel_active; then
+        _panel_open
+        term_status_row ok "$1" "${2:-}"
     else
         echo "[PASS] $1${2:+ :: $2}"
     fi
@@ -59,16 +104,28 @@ fail() {
     if [[ "$JSON_MODE" -eq 1 ]]; then
         printf '{"type":"check","section":"%s","label":"%s","status":"fail","detail":"%s"}\n' \
             "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
+    elif _panel_active; then
+        _panel_open
+        term_status_row bad "$1" "${2:-}"
     else
         echo "[FAIL] $1${2:+ :: $2}"
     fi
 }
 
-# Call from end of probe to emit summary record / block.
+# Call from end of probe to emit summary record / block / panel footer.
 emit_summary() {
     if [[ "$JSON_MODE" -eq 1 ]]; then
         printf '{"type":"summary","pass":%d,"fail":%d,"first_fail":"%s"}\n' \
             "$PASS_COUNT" "$FAIL_COUNT" "$(_json_escape "$FIRST_FAIL")"
+    elif _panel_active; then
+        _panel_open
+        [[ -n "$FIRST_FAIL" ]] && { term_panel_vert; term_panel_line "$(term_color dim "first fail: $FIRST_FAIL")"; }
+        term_panel_vert
+        local state="healthy" health="$PASS_COUNT ok"
+        if [[ "$FAIL_COUNT" -gt 0 ]]; then
+            state="critical"; health="$FAIL_COUNT fail ${TERM_DOT} $PASS_COUNT ok"
+        fi
+        term_panel_close "--json for data ${TERM_DOT} --redact to mask" "$(term_health "$state" "$health")"
     else
         echo
         echo "=== SUMMARY ==="
@@ -87,6 +144,9 @@ info() {
     if [[ "$JSON_MODE" -eq 1 ]]; then
         # Optional: emit info records. Keep silent for cleaner JSON parsing.
         return 0
+    elif _panel_active; then
+        term_panel_line "$(term_color dim "$*")"
+    else
+        echo "$@"
     fi
-    echo "$@"
 }

+ 15 - 9
skills/net-ops/scripts/linux/dns-audit.sh

@@ -4,17 +4,23 @@
 # but rung 5 (getent / resolvectl) FAIL.
 
 set -u
+# Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
+# headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
+# (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
+__nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
+else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
 
 # shellcheck source=../_lib/redact.sh
 source "$(dirname "$0")/../_lib/redact.sh"
 parse_redact_flag "$@"
 maybe_redact_self "$@"
 
-echo "=== /etc/nsswitch.conf (hosts line) ==="
+term_header "/etc/nsswitch.conf (hosts line)"
 grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || echo "  (no hosts entry)"
 
 echo
-echo "=== /etc/resolv.conf ==="
+term_header "/etc/resolv.conf"
 if [[ -L /etc/resolv.conf ]]; then
     echo "  Type: symlink -> $(readlink /etc/resolv.conf)"
 else
@@ -25,7 +31,7 @@ echo "  --- contents ---"
 cat /etc/resolv.conf 2>/dev/null | sed 's/^/  /'
 
 echo
-echo "=== systemd-resolved ==="
+term_header "systemd-resolved"
 if systemctl is-active systemd-resolved >/dev/null 2>&1; then
     echo "  Service: active"
     echo "  --- resolvectl status ---"
@@ -35,7 +41,7 @@ else
 fi
 
 echo
-echo "=== NetworkManager DNS config ==="
+term_header "NetworkManager DNS config"
 if command -v nmcli >/dev/null 2>&1; then
     echo "  --- nmcli dev show (DNS lines) ---"
     nmcli dev show 2>/dev/null | grep -E 'DEVICE|IP4.DNS|IP6.DNS|DOMAIN' | sed 's/^/  /'
@@ -48,7 +54,7 @@ else
 fi
 
 echo
-echo "=== dnsmasq ==="
+term_header "dnsmasq"
 if pgrep -x dnsmasq >/dev/null; then
     pid=$(pgrep -x dnsmasq | head -1)
     echo "  Running, PID $pid"
@@ -61,15 +67,15 @@ for d in /etc/dnsmasq.d /etc/NetworkManager/dnsmasq.d; do
 done
 
 echo
-echo "=== Local DNS listeners ==="
+term_header "Local DNS listeners"
 ss -tulnp 2>/dev/null | awk 'NR==1 || $5 ~ /:53$/' | sed 's/^/  /'
 
 echo
-echo "=== /etc/hosts (non-comment) ==="
+term_header "/etc/hosts (non-comment)"
 grep -vE '^\s*(#|$)' /etc/hosts 2>/dev/null | sed 's/^/  /' || echo "  (no custom entries)"
 
 echo
-echo "=== VPN / WireGuard interfaces ==="
+term_header "VPN / WireGuard interfaces"
 ip -br link 2>/dev/null | awk '/^(wg|tun|tap|nordlynx|proton|mullvad|nextdns)/' | sed 's/^/  /' || true
 if command -v wg >/dev/null 2>&1; then
     echo "  --- wg show ---"
@@ -77,7 +83,7 @@ if command -v wg >/dev/null 2>&1; then
 fi
 
 echo
-echo "=== ATTRIBUTION HINTS ==="
+term_header "ATTRIBUTION HINTS"
 # Inspect nameservers visible across the stack for known patterns
 ns_list=$( {
     awk '/^nameserver/{print $2}' /etc/resolv.conf 2>/dev/null

+ 1 - 0
skills/net-ops/scripts/linux/probe.sh

@@ -32,6 +32,7 @@ done
 source "$(dirname "$0")/../_lib/redact.sh"
 # shellcheck source=../_lib/output.sh
 source "$(dirname "$0")/../_lib/output.sh"
+PANEL_TITLE="linux probe"
 parse_redact_flag "$@"
 parse_output_flags "$@"
 maybe_redact_self "$@"

+ 12 - 6
skills/net-ops/scripts/linux/resolved-reset.sh

@@ -7,6 +7,12 @@
 # Requires sudo for the apply path.
 
 set -eu
+# Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
+# headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
+# (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
+__nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
+else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
 
 APPLY=0
 for arg in "$@"; do
@@ -31,7 +37,7 @@ if ! systemctl is-active systemd-resolved >/dev/null 2>&1; then
     exit 0
 fi
 
-echo "=== BEFORE ==="
+term_header "BEFORE"
 resolvectl status 2>/dev/null | head -60
 
 # Find links with non-empty per-link DNS (potential stale state)
@@ -47,7 +53,7 @@ if [[ -z "$LINKS_WITH_DNS" ]]; then
 fi
 
 echo
-echo "=== LINKS WITH EXPLICIT DNS ==="
+term_header "LINKS WITH EXPLICIT DNS"
 echo "$LINKS_WITH_DNS" | while IFS='|' read -r idx name; do
     echo "  Link $idx ($name)"
 done
@@ -64,7 +70,7 @@ if [[ "$EUID" -ne 0 ]]; then
 fi
 
 echo
-echo "=== RESETTING ==="
+term_header "RESETTING"
 echo "$LINKS_WITH_DNS" | while IFS='|' read -r idx name; do
     if resolvectl revert "$name" 2>/dev/null; then
         echo "[OK]   reverted $name"
@@ -74,7 +80,7 @@ echo "$LINKS_WITH_DNS" | while IFS='|' read -r idx name; do
 done
 
 echo
-echo "=== FLUSHING CACHE ==="
+term_header "FLUSHING CACHE"
 resolvectl flush-caches && echo "  cache flushed"
 
 # Restart for good measure if user really wanted a reset
@@ -82,7 +88,7 @@ systemctl restart systemd-resolved
 echo "  systemd-resolved restarted"
 
 echo
-echo "=== VERIFICATION ==="
+term_header "VERIFICATION"
 if out=$(getent hosts google.com 2>&1) && [[ -n "$out" ]]; then
     echo "[PASS] getent hosts google.com -> $(echo "$out" | awk '{print $1}')"
 else
@@ -96,5 +102,5 @@ else
 fi
 
 echo
-echo "=== AFTER ==="
+term_header "AFTER"
 resolvectl status 2>/dev/null | head -40

+ 17 - 11
skills/net-ops/scripts/macos/dns-audit.sh

@@ -5,17 +5,23 @@
 # macOS resolver chain.
 
 set -u
+# Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
+# headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
+# (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
+__nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
+else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
 
 # shellcheck source=../_lib/redact.sh
 source "$(dirname "$0")/../_lib/redact.sh"
 parse_redact_flag "$@"
 maybe_redact_self "$@"
 
-echo "=== scutil --dns (FULL) ==="
+term_header "scutil --dns (FULL)"
 scutil --dns 2>/dev/null
 
 echo
-echo "=== /etc/resolver/* (per-domain DNS overrides — VPN clients use these) ==="
+term_header "/etc/resolver/* (per-domain DNS overrides — VPN clients use these)"
 if [[ -d /etc/resolver ]] && [[ -n "$(ls -A /etc/resolver 2>/dev/null)" ]]; then
     for f in /etc/resolver/*; do
         [[ -f "$f" ]] || continue
@@ -28,17 +34,17 @@ else
 fi
 
 echo
-echo "=== Configuration profiles with DNS settings ==="
+term_header "Configuration profiles with DNS settings"
 profiles list -type configuration 2>/dev/null | head -40
 echo
 echo "  (run 'sudo profiles show -type configuration' for full payloads)"
 
 echo
-echo "=== /etc/hosts (non-comment lines) ==="
+term_header "/etc/hosts (non-comment lines)"
 grep -vE '^\s*(#|$)' /etc/hosts 2>/dev/null || echo "  (no custom entries)"
 
 echo
-echo "=== /etc/resolv.conf (legacy, usually a stub on macOS) ==="
+term_header "/etc/resolv.conf (legacy, usually a stub on macOS)"
 if [[ -f /etc/resolv.conf ]]; then
     cat /etc/resolv.conf
 else
@@ -46,7 +52,7 @@ else
 fi
 
 echo
-echo "=== mDNSResponder state ==="
+term_header "mDNSResponder state"
 if pgrep -x mDNSResponder >/dev/null; then
     pid=$(pgrep -x mDNSResponder | head -1)
     echo "PID: $pid"
@@ -54,11 +60,11 @@ if pgrep -x mDNSResponder >/dev/null; then
 fi
 
 echo
-echo "=== Network services priority order ==="
+term_header "Network services priority order"
 networksetup -listnetworkserviceorder 2>/dev/null | head -30
 
 echo
-echo "=== DNS servers per active service ==="
+term_header "DNS servers per active service"
 networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r svc; do
     [[ "$svc" == \** ]] && continue  # disabled
     dns=$(networksetup -getdnsservers "$svc" 2>/dev/null)
@@ -66,7 +72,7 @@ networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r sv
 done
 
 echo
-echo "=== Search domains per active service ==="
+term_header "Search domains per active service"
 networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r svc; do
     [[ "$svc" == \** ]] && continue
     sd=$(networksetup -getsearchdomains "$svc" 2>/dev/null)
@@ -74,11 +80,11 @@ networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r sv
 done
 
 echo
-echo "=== Third-party network kexts loaded ==="
+term_header "Third-party network kexts loaded"
 kextstat 2>/dev/null | grep -iE 'cisco|anyconnect|proton|mullvad|nord|littlesnitch|lulu|nextdns|warp' || echo "  (none detected)"
 
 echo
-echo "=== ATTRIBUTION HINTS ==="
+term_header "ATTRIBUTION HINTS"
 # Aggregate every nameserver we can see across all resolver surfaces, then
 # pattern-match each unique entry to a known VPN/DNS client signature.
 ns_list=$( {

+ 1 - 0
skills/net-ops/scripts/macos/probe.sh

@@ -36,6 +36,7 @@ source "$(dirname "$0")/../_lib/redact.sh"
 source "$(dirname "$0")/../_lib/output.sh"
 # shellcheck source=../_lib/cache.sh
 source "$(dirname "$0")/../_lib/cache.sh"
+PANEL_TITLE="macos probe"
 parse_redact_flag "$@"
 parse_output_flags "$@"
 parse_quick_flag "$@"

+ 12 - 6
skills/net-ops/scripts/macos/resolver-clean.sh

@@ -7,6 +7,12 @@
 # Requires sudo.
 
 set -eu
+# Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
+# headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
+# (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
+__nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
+else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
 
 APPLY=0
 PROTECT_PATTERNS="${PROTECT_PATTERNS:-100\.100\.100\.100}"
@@ -36,7 +42,7 @@ if [[ ! -d /etc/resolver ]] || [[ -z "$(ls -A /etc/resolver 2>/dev/null)" ]]; th
     exit 0
 fi
 
-echo "=== BEFORE ==="
+term_header "BEFORE"
 for f in /etc/resolver/*; do
     [[ -f "$f" ]] || continue
     ns=$(awk '/^nameserver/{print $2}' "$f" | tr '\n' ',')
@@ -59,7 +65,7 @@ if [[ "${#TARGETS[@]}" -eq 0 ]]; then
 fi
 
 echo
-echo "=== TARGETS FOR REMOVAL ==="
+term_header "TARGETS FOR REMOVAL"
 for f in "${TARGETS[@]}"; do
     echo "  $f"
 done
@@ -77,7 +83,7 @@ if [[ "$EUID" -ne 0 ]]; then
 fi
 
 echo
-echo "=== REMOVING ==="
+term_header "REMOVING"
 for f in "${TARGETS[@]}"; do
     if rm -f "$f"; then
         echo "[OK]   $f"
@@ -87,13 +93,13 @@ for f in "${TARGETS[@]}"; do
 done
 
 echo
-echo "=== FLUSHING DNS CACHE ==="
+term_header "FLUSHING DNS CACHE"
 dscacheutil -flushcache
 killall -HUP mDNSResponder 2>/dev/null || true
 echo "  done."
 
 echo
-echo "=== VERIFICATION ==="
+term_header "VERIFICATION"
 if out=$(dscacheutil -q host -a name google.com 2>&1) && echo "$out" | grep -q "ip_address:"; then
     addr=$(echo "$out" | awk '/ip_address:/{print $2; exit}')
     echo "[PASS] dscacheutil google.com -> $addr"
@@ -108,7 +114,7 @@ else
 fi
 
 echo
-echo "=== AFTER ==="
+term_header "AFTER"
 if [[ -n "$(ls -A /etc/resolver 2>/dev/null)" ]]; then
     for f in /etc/resolver/*; do
         [[ -f "$f" ]] || continue

+ 1 - 0
skills/net-ops/scripts/reverse-probe.sh

@@ -30,6 +30,7 @@ TIMEOUT=4
 source "$(dirname "$0")/_lib/redact.sh"
 # shellcheck source=_lib/output.sh
 source "$(dirname "$0")/_lib/output.sh"
+PANEL_TITLE="reverse probe"
 parse_redact_flag "$@"
 parse_output_flags "$@"
 maybe_redact_self "$TARGET" "$@"

+ 54 - 0
skills/net-ops/tests/run.sh

@@ -40,6 +40,60 @@ root="$(cd "$here/.." && pwd)"
 echo "=== net-ops self-tests ==="
 echo "Root: $root"
 
+# ---------------------------------------------------------------------------
+echo
+echo "--- terminal design system (term.sh panel adoption) ---"
+# ---------------------------------------------------------------------------
+# OS-independent: drives _lib/output.sh directly (no live probe), so it runs on
+# every platform including Windows where the probe ladder is skipped below.
+OUTLIB="$root/scripts/_lib/output.sh"
+TERMLIB="$root/../_lib/term.sh"
+
+assert "output.sh sources shared term.sh" contains "$(cat "$OUTLIB")" '_lib/term.sh'
+assert "probe scripts set a PANEL_TITLE" \
+    bash -c 'grep -q "PANEL_TITLE=" "$1"' _ "$root/scripts/linux/probe.sh"
+
+# Exercise the public output API in one of the three modes.
+_drive() {
+    bash -c '
+        OUT="$1"; shift
+        . "$OUT"
+        PANEL_TITLE="linux probe"
+        parse_output_flags "$@"
+        section "1. LINK LAYER"; pass "iface up" "eth0"; fail "carrier" "no link"
+        emit_summary
+    ' _ "$OUTLIB" "$@"
+}
+
+# Panel path (FORCE_COLOR forces the render): the enclosing frame appears and is
+# pure ASCII under TERM_ASCII=1.
+panel_ascii="$(TERM_ASCII=1 FORCE_COLOR=1 _drive 2>/dev/null)"
+assert "panel renders the enclosing frame" contains "$panel_ascii" "+-- "
+assert "panel footer carries a health indicator" contains "$panel_ascii" "fail"
+assert "panel is pure ASCII under TERM_ASCII=1" \
+    bash -c '! printf "%s" "$1" | LC_ALL=C grep -q "[^[:print:][:cntrl:]]"' _ "$panel_ascii"
+
+# Legacy text path (piped / non-TTY): the greppable [PASS]/[FAIL]/SUMMARY contract
+# is byte-stable, so humans, LLMs, tests, and the --watch dispatcher keep working.
+legacy="$(_drive 2>/dev/null)"
+assert "piped text keeps [PASS] anchor" contains "$legacy" "[PASS]"
+assert "piped text keeps [FAIL] anchor" contains "$legacy" "[FAIL]"
+assert "piped text keeps SUMMARY block" contains "$legacy" "=== SUMMARY ==="
+assert "piped text carries no ANSI" not_contains "$legacy" $'\033'
+
+# JSON unaffected by the panel.
+js="$(_drive --json 2>/dev/null)"
+assert "json mode still emits a summary record" contains "$js" '"type":"summary"'
+assert "json mode carries no panel chrome" not_contains "$js" "+-- "
+
+# term.sh primitives are pure ASCII under TERM_ASCII=1.
+if [[ -f "$TERMLIB" ]]; then
+    prim="$(TERM_ASCII=1 LT="$TERMLIB" bash -c '. "$LT"; term_init; printf "%s%s%s%s" \
+        "$(term_mark ok)" "$(term_status_row ok a b)" "$(term_panel_open net-ops x)" "$TERM_DOT"')"
+    assert "term.sh primitives pure ASCII under TERM_ASCII=1" \
+        bash -c '! printf "%s" "$1" | LC_ALL=C grep -q "[^[:print:][:cntrl:]]"' _ "$prim"
+fi
+
 # Determine the local OS probe for testing
 case "$(uname -s)" in
     Darwin) probe="$root/scripts/macos/probe.sh"; audit="$root/scripts/macos/dns-audit.sh" ;;