Browse Source

feat(github-ops): Adopt term.sh design system in auditor family

Retrofit the read-only auditors (repo-scorecard, check-security-posture,
check-issues) to source skills/_lib/term.sh for human-facing framing:
cyan section headers, colored status marks, a score pip-bar. The --json /
data product on stdout stays plain — stream separation preserved (verified
zero ANSI on stdout). Color follows the stderr TTY (term_init 2) so piping
--json | jq keeps framing colored. Every glyph falls back to ASCII under
TERM_ASCII=1 (a full scorecard renders pure-ASCII).

term.sh: term_init gains an optional fd arg; new term_mark checklist
primitive and TERM_ARROW pointer glyph; both with registered ASCII proxies.
Drop the "experimental" flag — TERMINAL-DESIGN.md is now the standard for
TTY-facing scripts.

Tests: +6 github-ops assertions (source-check + ASCII-fallback purity),
40/40 offline; all 11 skill suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 14 hours ago
parent
commit
2de6e0724b

+ 15 - 0
CHANGELOG.md

@@ -14,6 +14,21 @@ feature releases live in the README "Recent Updates" section.
   SKILL-SUBAGENT-REFERENCE, naming-conventions, SKILL-RESOURCE-PROTOCOL) and carries a
   precedence table for when they disagree. `skill-agent-updates.md` now routes here first.
 
+### Changed (terminal output)
+- **Terminal design system promoted from experimental to the standard** for claude-mods
+  shell scripts (`docs/TERMINAL-DESIGN.md`). The `github-ops` audit family
+  (`repo-scorecard.sh`, `check-security-posture.sh`, `check-issues.sh`) now sources
+  `skills/_lib/term.sh` for all human-facing framing — cyan section headers, colored
+  status marks, a score pip-bar — while the `--json`/data product on stdout stays plain
+  (stream separation preserved; verified zero ANSI on stdout). Every glyph falls back to
+  ASCII under `TERM_ASCII=1` (a full scorecard renders pure-ASCII), and color follows the
+  stderr TTY so piping `--json | jq` keeps the framing colored.
+- **`term.sh` additions**: `term_init` takes an optional fd (`term_init 2`) so
+  stream-separated tools detect color on the stream the human actually sees; new
+  `term_mark <ok|bad|warn|skip|na|unknown>` checklist primitive (with registered ASCII
+  proxies) and a `TERM_ARROW` (-> ) pointer glyph. github-ops test suite gained 6
+  assertions (source-check + ASCII-fallback purity); 40/40 offline.
+
 ### Fixed (docs)
 - **`SKILL-SUBAGENT-REFERENCE.md` was self-contradictory and misleading** (surfaced by
   external PR #12): it declared "no other top-level keys are permitted" and its

+ 1 - 1
docs/TERMINAL-DESIGN.md

@@ -1,6 +1,6 @@
 # Terminal Panel Design System
 
-> **Status:** Experimental. First consumer: `fleet-ops`.
+> **Status:** Active — the standard for terminal output across claude-mods shell scripts. Toolkit: [`skills/_lib/term.sh`](../skills/_lib/term.sh). Consumers: `fleet-ops` (panels), `github-ops` audit family (stream-separated `term_init 2` + `term_mark` checklists). New TTY-facing scripts should source `term.sh` rather than hand-roll ANSI.
 >
 > **Format:** Adapted from [google-labs-code/design.md](https://github.com/google-labs-code/design.md) — a structured design-spec template — and remapped to bash CLIs. Where that spec talks about screens, components, and tokens, this one talks about panels, sections, and glyphs.
 

+ 37 - 4
skills/_lib/term.sh

@@ -4,10 +4,12 @@
 # Source from any skill script:
 #   LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" && pwd)"
 #   . "$LIB/term.sh"
-#   term_init
+#   term_init          # detect on stdout (panels printed to stdout)
+#   term_init 2        # detect on stderr (stream-separated tools: data→stdout,
+#                      #                    framing→stderr — color follows fd 2)
 #
 # Honors: NO_COLOR, FORCE_COLOR, TERM_ASCII=1, FLEET_ASCII=1 (legacy).
-# Status: experimental — see docs/TERMINAL-DESIGN.md.
+# See docs/TERMINAL-DESIGN.md for the design system this implements.
 
 # Guard against double-sourcing.
 [[ -n "${__TERM_SH_LOADED:-}" ]] && return 0
@@ -61,14 +63,20 @@ TERM_GLYPH_ALERT=""
 # Empty-state tip glyph (💡)
 TERM_GLYPH_TIP=""
 
+# Pointer/arrow glyph (→ / ->) — for "problem → remedy" leads.
+TERM_ARROW=""
+
 # Spinner frame banks (set by term_init; arrays keep order).
 TERM_SPIN_WORKING=()
 TERM_SPIN_HEARTBEAT=()
 
 # ─── term_init ────────────────────────────────────────────────────────────
 term_init() {
-  # TTY detection — stdout only.
-  if [[ -t 1 ]]; then TERM_TTY=1; else TERM_TTY=0; fi
+  # TTY/color detection follows the chosen fd (default 1 = stdout). Stream-separated
+  # tools that print framing to stderr should call `term_init 2` so color tracks the
+  # stream the human actually sees, even when stdout is piped to jq.
+  local fd=${1:-1}
+  if [[ -t "$fd" ]]; then TERM_TTY=1; else TERM_TTY=0; fi
 
   # ASCII fallback: explicit env, or non-UTF locale.
   if [[ "${TERM_ASCII:-}" == "1" ]] || [[ "${FLEET_ASCII:-}" == "1" ]]; then
@@ -111,6 +119,7 @@ term_init() {
     TERM_GLYPH_BRANCH="(b)"
     TERM_GLYPH_ALERT="!"
     TERM_GLYPH_TIP="(i)"
+    TERM_ARROW="->"
     TERM_SPIN_WORKING=('|' '/' '-' '\')
     TERM_SPIN_HEARTBEAT=('.' ':' '*' ':')
   else
@@ -130,6 +139,7 @@ term_init() {
     TERM_GLYPH_BRANCH="⎇"
     TERM_GLYPH_ALERT="▲"
     TERM_GLYPH_TIP="💡"
+    TERM_ARROW="→"
     TERM_SPIN_WORKING=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
     TERM_SPIN_HEARTBEAT=('·' '∙' '•' '●' '•' '∙')
   fi
@@ -228,6 +238,29 @@ term_state_icon() {
   esac
 }
 
+# ─── Checklist mark ───────────────────────────────────────────────────────
+# term_mark <state>  — compact single-glyph status mark, colored + ASCII-aware.
+# The lightweight checklist counterpart to the emoji-heavy term_state_icon: use
+# it for ✓/✗ audit rows. Every glyph has a registered ASCII fallback (TERM_ASCII=1).
+#   ok ✓/+ green · bad|gap ✗/x red · warn ▲/! orange · skip|na —/- dim · unknown ?/? yellow
+term_mark() {
+  local g c
+  case "$1" in
+    ok)        g="✓"; c="green" ;;
+    bad|gap)   g="✗"; c="red" ;;
+    warn)      g="▲"; c="orange" ;;
+    skip|na)   g="—"; c="dim" ;;
+    unknown)   g="?"; c="yellow" ;;
+    *)         g="·"; c="" ;;
+  esac
+  if [[ "$TERM_ASCII_MODE" -eq 1 ]]; then
+    case "$1" in
+      ok) g="+" ;; bad|gap) g="x" ;; warn) g="!" ;; skip|na) g="-" ;; unknown) g="?" ;; *) g="." ;;
+    esac
+  fi
+  if [[ -n "$c" ]]; then term_color "$c" "$g"; else printf '%s' "$g"; fi
+}
+
 # ─── Primitives ───────────────────────────────────────────────────────────
 
 # term_repeat <char> <n>

+ 15 - 4
skills/github-ops/scripts/check-issues.sh

@@ -28,6 +28,17 @@ set -uo pipefail
 EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
 GH_TIMEOUT="${GH_TIMEOUT:-15}"   # seconds; bounds the network call
 
+# Terminal design system (skills/_lib/term.sh). Framing prints to stderr, so detect
+# color on fd 2. Degrade to plain output if the shared lib isn't reachable.
+__lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
+else
+  term_header() { printf '%s\n' "${1:-}"; }
+  term_color()  { shift; printf '%s' "$*"; }
+  term_mark()   { case "${1:-}" in ok) printf '+';; bad|gap) printf 'x';; warn) printf '!';; skip|na) printf '-';; unknown) printf '?';; *) printf '.';; esac; }
+  TERM_ARROW="->"
+fi
+
 REPO=""; REMOTE="origin"; STALE_DAYS=30; LIMIT=50; ADVISORY=0; JSON=0
 while [ $# -gt 0 ]; do
   case "$1" in
@@ -100,10 +111,10 @@ if [ "$flagged_n" -eq 0 ]; then
 fi
 
 {
-  echo "OPEN ISSUES worth a look — $REPO ($flagged_n of $total open flagged):"
-  printf '%s' "$analysis" | jq -r '.flagged[]
-    | "  #\(.number)  [\(if .external then "external" else "yours" end)\(if .stale then ",stale" else "" end)]  by \(.author.login)  \(.title)"'
-  echo " gh issue view <n> --repo $REPO    (read-only; this never blocks a push)"
+  term_header "OPEN ISSUES: $REPO" "$flagged_n of $total open flagged"
+  printf '%s' "$analysis" | jq -r --arg m "$(term_mark warn)" '.flagged[]
+    | "  \($m) #\(.number)  [\(if .external then "external" else "yours" end)\(if .stale then ",stale" else "" end)]  by \(.author.login)  \(.title)"'
+  echo "$(term_color dim "  ${TERM_ARROW} gh issue view <n> --repo $REPO    (read-only; this never blocks a push)")"
 } >&2
 
 exit "$EX_FINDINGS"

+ 23 - 11
skills/github-ops/scripts/check-security-posture.sh

@@ -48,6 +48,16 @@ set -uo pipefail
 EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
 GH_TIMEOUT="${GH_TIMEOUT:-20}"   # seconds; bounds every network call
 
+# Terminal design system (skills/_lib/term.sh). Framing prints to stderr, so detect
+# color on fd 2. Degrade to plain output if the shared lib isn't reachable.
+__lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
+else
+  term_header() { printf '%s\n' "${1:-}"; }
+  term_color()  { shift; printf '%s' "$*"; }
+  term_mark()   { case "${1:-}" in ok) printf '+';; bad|gap) printf 'x';; warn) printf '!';; skip|na) printf '-';; unknown) printf '?';; *) printf '.';; esac; }
+fi
+
 REPO=""; REMOTE="origin"; ORG=""; COMMANDS=0; JSON=0; STRICT=0; ADVISORY=0
 while [ $# -gt 0 ]; do
   case "$1" in
@@ -276,25 +286,27 @@ print_human() { # repo_json
   repo="$(printf '%s' "$o" | jq -r '.repo')"
   vis="$(printf '%s' "$o" | jq -r '.visibility')"
   {
-    echo "SECURITY POSTURE — $repo ($vis)"
-    printf '%s' "$o" | jq -r '
+    term_header "SECURITY POSTURE: $repo" "$vis"
+    printf '%s' "$o" | jq -r \
+      --arg ok "$(term_mark ok)" --arg bad "$(term_mark bad)" \
+      --arg na "$(term_mark na)" --arg unk "$(term_mark unknown)" '
       .features[] |
       if .state=="on" then
-        "   \(.feature)" +
+        "  \($ok) \(.feature)" +
           (if (.open_alerts // 0) > 0 then "  — \(.open_alerts) OPEN alert(s)" + (if .max_severity then ", max \(.max_severity)" else "" end) else "" end) +
           (if .max_severity=="unknown" then "  (alerts: couldn’t read — needs security_events scope)" else "" end)
       elif .state=="n/a" then
-        "   \(.feature)  n/a (needs GitHub Advanced Security on a private repo)"
+        "  \($na) \(.feature)  n/a (needs GitHub Advanced Security on a private repo)"
       elif .state=="unknown" then
-        "  ? \(.feature)  n/a (couldn’t read)"
+        "  \($unk) \(.feature)  n/a (couldn’t read)"
       else
-        "   \(.feature)  [\(.severity)]"
+        "  \($bad) \(.feature)  [\(.severity)]"
       end'
     # Enable commands for gaps.
     local has_gap
     has_gap="$(printf '%s' "$o" | jq '[.features[]|select(.applicable==true and (.state=="off"))]|length')"
     if [ "$has_gap" -gt 0 ]; then
-      echo "  ── enable commands (review before running; this script never runs them):"
+      term_header "enable commands" "review before running; this script never runs them"
       printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|"     \(.enable_command)"'
     fi
   } >&2
@@ -334,7 +346,7 @@ if [ -n "$ORG" ]; then
     obj="$(audit_repo "$r")"; rc=$?
     if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then
       unread=$((unread+1))
-      [ "$JSON" -eq 1 ] || echo "  ? $r — couldn't read (skipped)" >&2
+      [ "$JSON" -eq 1 ] || echo "  $(term_mark unknown) $r — couldn't read (skipped)" >&2
       continue
     fi
     swept=$((swept+1))
@@ -343,8 +355,8 @@ if [ -n "$ORG" ]; then
     if [ "$JSON" -eq 0 ] && [ "$COMMANDS" -eq 0 ]; then
       gaps="$(printf '%s' "$obj" | jq '[.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown") or ((.open_alerts//0)>0)))]|length')"
       vis="$(printf '%s' "$obj" | jq -r '.visibility')"
-      if [ "$gaps" -eq 0 ]; then echo "   $r ($vis) — clean" >&2
-      else echo "   $r ($vis) — $gaps gap(s)/alert(s)" >&2; fi
+      if [ "$gaps" -eq 0 ]; then echo "  $(term_mark ok) $r ($vis) — clean" >&2
+      else echo "  $(term_mark bad) $r ($vis) — $gaps gap(s)/alert(s)" >&2; fi
     fi
   done
 
@@ -356,7 +368,7 @@ if [ -n "$ORG" ]; then
     echo "# review before running — these change repo settings" >&2
     printf '%s' "$all" | jq -r '.[] | "# \(.repo)", (.features[]|select(.applicable==true and .state=="off")|"  \(.enable_command)")'
   else
-    echo "── swept $swept repo(s) in $ORG; $unread unreadable. ✗ = action available." >&2
+    term_header "swept $swept repo(s) in $ORG" "$unread unreadable" >&2
   fi
   [ "$any_findings" -eq 1 ] && exit "$EX_FINDINGS"
   exit "$EX_OK"

+ 40 - 28
skills/github-ops/scripts/repo-scorecard.sh

@@ -62,6 +62,18 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 SEC="$HERE/check-security-posture.sh"
 ISS="$HERE/check-issues.sh"
 
+# Terminal design system (skills/_lib/term.sh). Framing prints to stderr, so detect
+# color on fd 2. Degrade to plain output if the shared lib isn't reachable.
+__lib="$(cd "$HERE/../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
+else
+  term_header()  { printf '%s\n' "${1:-}"; }
+  term_color()   { shift; printf '%s' "$*"; }
+  term_mark()    { case "${1:-}" in ok) printf '+';; bad|gap) printf 'x';; warn) printf '!';; skip|na) printf '-';; unknown) printf '?';; *) printf '.';; esac; }
+  term_pip_bar() { :; }
+  TERM_ARROW="->"
+fi
+
 REPO=""; REMOTE="origin"; ORG=""; JSON=0; MIN_SCORE=""
 while [ $# -gt 0 ]; do
   case "$1" in
@@ -271,29 +283,29 @@ score_repo() { # OWNER/REPO -> echoes JSON object; returns 0|10|7
   }
   # security first (highest weight). Map maxsev to a rank.
   if [ "$sec_status" = "gap" ]; then
-    addfix 0 gap "security: $sec_detail  check-security-posture.sh --repo $R --commands"
+    addfix 0 gap "security: $sec_detail ${TERM_ARROW} check-security-posture.sh --repo $R --commands"
   elif [ "$sec_status" = "warn" ]; then
-    addfix 3 warn "security: $sec_detail  check-security-posture.sh --repo $R --commands"
+    addfix 3 warn "security: $sec_detail ${TERM_ARROW} check-security-posture.sh --repo $R --commands"
   elif [ "$sec_status" = "n/a" ]; then
-    addfix 5 "n/a" "security: couldn't read  re-run check-security-posture.sh --repo $R"
+    addfix 5 "n/a" "security: couldn't read ${TERM_ARROW} re-run check-security-posture.sh --repo $R"
   fi
   if [ "$md_status" = "gap" ]; then
-    addfix 1 gap "metadata: missing ${md_detail}  set description / >=3 topics / add the missing file(s)"
+    addfix 1 gap "metadata: missing ${md_detail} ${TERM_ARROW} set description / >=3 topics / add the missing file(s)"
   elif [ "$md_status" = "warn" ]; then
-    addfix 4 warn "metadata: missing ${md_detail}  set description / >=3 topics / add the missing file(s)"
+    addfix 4 warn "metadata: missing ${md_detail} ${TERM_ARROW} set description / >=3 topics / add the missing file(s)"
   fi
   if [ "$rel_status" = "gap" ]; then
-    addfix 2 gap "release: $rel_detail  cut a GitHub release (github-ops mode update)"
+    addfix 2 gap "release: $rel_detail ${TERM_ARROW} cut a GitHub release (github-ops mode update)"
   elif [ "$rel_status" = "warn" ]; then
-    addfix 4 warn "release: $rel_detail  gh release create $latest_tag"
+    addfix 4 warn "release: $rel_detail ${TERM_ARROW} gh release create $latest_tag"
   fi
   if [ "$iss_status" = "gap" ]; then
-    addfix 2 gap "issues: $iss_detail  check-issues.sh --repo $R"
+    addfix 2 gap "issues: $iss_detail ${TERM_ARROW} check-issues.sh --repo $R"
   elif [ "$iss_status" = "warn" ]; then
-    addfix 5 warn "issues: $iss_detail  check-issues.sh --repo $R"
+    addfix 5 warn "issues: $iss_detail ${TERM_ARROW} check-issues.sh --repo $R"
   fi
   if [ "$act_status" = "gap" ]; then
-    addfix 1 gap "actions: $act_detail  inspect the failing run (gh run list --repo $R)"
+    addfix 1 gap "actions: $act_detail ${TERM_ARROW} inspect the failing run (gh run list --repo $R)"
   elif [ "$act_status" = "warn" ]; then
     addfix 5 warn "actions: $act_detail"
   fi
@@ -332,9 +344,9 @@ score_repo() { # OWNER/REPO -> echoes JSON object; returns 0|10|7
   return 0
 }
 
-# Glyph for a dimension status (human matrix).
+# Colored, ASCII-aware status glyph for a dimension (human card).
 mark() { case "$1" in
-  ok) printf 'ok ';; warn) printf 'warn';; gap) printf 'GAP ';; "n/a") printf 'n/a ';; *) printf '?   ';; esac; }
+  ok) term_mark ok;; warn) term_mark warn;; gap) term_mark bad;; "n/a") term_mark na;; *) term_mark unknown;; esac; }
 
 # Human single-repo card (data to stdout; framing to stderr).
 print_card() { # repo_json
@@ -342,18 +354,18 @@ print_card() { # repo_json
   repo="$(jq -r '.repo' <<<"$o")"; vis="$(jq -r '.visibility' <<<"$o")"
   score="$(jq -r '.score' <<<"$o")"; grade="$(jq -r '.grade' <<<"$o")"
   {
-    echo "REPO SCORECARD — $repo ($vis)"
-    echo "  SCORE: $score/100   GRADE: $grade"
-    echo "  ── dimensions (weight) ──────────────────────────────────"
-    printf '  %-9s [%s]  %s\n' "security"  "$(mark "$(jq -r '.dimensions.security.status' <<<"$o")")"  "$(jq -r '.dimensions.security.detail' <<<"$o")  (w35)"
-    printf '  %-9s [%s]  %s\n' "metadata"  "$(mark "$(jq -r '.dimensions.metadata.status' <<<"$o")")"  "$(jq -r '.dimensions.metadata.detail' <<<"$o")  (w25)"
-    printf '  %-9s [%s]  %s\n' "release"   "$(mark "$(jq -r '.dimensions.release.status' <<<"$o")")"   "$(jq -r '.dimensions.release.detail' <<<"$o")  (w15)"
-    printf '  %-9s [%s]  %s\n' "issues"    "$(mark "$(jq -r '.dimensions.issues.status' <<<"$o")")"    "$(jq -r '.dimensions.issues.detail' <<<"$o")  (w15)"
-    printf '  %-9s [%s]  %s\n' "actions"   "$(mark "$(jq -r '.dimensions.actions.status' <<<"$o")")"   "$(jq -r '.dimensions.actions.detail' <<<"$o")  (w10)"
+    term_header "REPO SCORECARD: $repo" "$vis"
+    echo "  SCORE  $(term_pip_bar score "$score" 100)  $score/100   GRADE $grade"
+    term_header "dimensions (weight)"
+    printf '  %-9s %s  %s\n' "security"  "$(mark "$(jq -r '.dimensions.security.status' <<<"$o")")"  "$(jq -r '.dimensions.security.detail' <<<"$o")  $(term_color dim "(w35)")"
+    printf '  %-9s %s  %s\n' "metadata"  "$(mark "$(jq -r '.dimensions.metadata.status' <<<"$o")")"  "$(jq -r '.dimensions.metadata.detail' <<<"$o")  $(term_color dim "(w25)")"
+    printf '  %-9s %s  %s\n' "release"   "$(mark "$(jq -r '.dimensions.release.status' <<<"$o")")"   "$(jq -r '.dimensions.release.detail' <<<"$o")  $(term_color dim "(w15)")"
+    printf '  %-9s %s  %s\n' "issues"    "$(mark "$(jq -r '.dimensions.issues.status' <<<"$o")")"    "$(jq -r '.dimensions.issues.detail' <<<"$o")  $(term_color dim "(w15)")"
+    printf '  %-9s %s  %s\n' "actions"   "$(mark "$(jq -r '.dimensions.actions.status' <<<"$o")")"   "$(jq -r '.dimensions.actions.detail' <<<"$o")  $(term_color dim "(w10)")"
     local nf; nf="$(jq -r '.top_fixes | length' <<<"$o")"
     if [ "$nf" -gt 0 ]; then
-      echo "  ── top fixes (highest-severity first) ───────────────────"
-      jq -r '.top_fixes[] | "     • " + .' <<<"$o"
+      term_header "top fixes (highest-severity first)"
+      jq -r --arg b "$(term_mark warn)" '.top_fixes[] | "     \($b) " + .' <<<"$o"
     fi
   } >&2
 }
@@ -373,7 +385,7 @@ fi
 # ---- Fleet sweep ----------------------------------------------------------
 if [ -n "$ORG" ]; then
   valid_owner "$ORG" || { echo "repo-scorecard: invalid owner '$ORG'" >&2; exit "$EX_USAGE"; }
-  echo "repo-scorecard: sweeping $ORG …" >&2
+  echo "$(term_color dim "repo-scorecard: sweeping $ORG …")" >&2
   list="$(runner gh repo list "$ORG" --no-archived --limit 200 --json nameWithOwner,visibility 2>/dev/null)" \
     || skip "gh repo list failed for $ORG (not authed / offline / rate-limited?)"
   [ -n "$list" ] || skip "no repos returned for $ORG"
@@ -386,7 +398,7 @@ if [ -n "$ORG" ]; then
     obj="$(score_repo "$r")"; rc=$?
     if [ "$rc" -eq 7 ] || [ -z "$obj" ] || ! printf '%s' "$obj" | jq -e . >/dev/null 2>&1; then
       unread=$((unread+1))
-      [ "$JSON" -eq 1 ] || echo "  ?    $r — couldn't read (skipped)" >&2
+      [ "$JSON" -eq 1 ] || echo "  $(term_mark unknown)    $r — couldn't read (skipped)" >&2
       continue
     fi
     swept=$((swept+1))
@@ -395,9 +407,9 @@ if [ -n "$ORG" ]; then
     sc="$(jq -r '.score' <<<"$obj")"
     if [ -n "$MIN_SCORE" ] && [ "$sc" -lt "$MIN_SCORE" ]; then below_min=$((below_min+1)); fi
     if [ "$JSON" -eq 0 ]; then
-      # matrix row: per-dimension single-char marks + score + grade.
+      # matrix row: per-dimension colored marks + score + grade.
       m() { case "$(jq -r ".dimensions.$1.status" <<<"$obj")" in
-        ok) printf '+';; warn) printf '~';; gap) printf 'X';; "n/a") printf '?';; *) printf ' ';; esac; }
+        ok) term_mark ok;; warn) term_mark warn;; gap) term_mark bad;; "n/a") term_mark na;; *) printf ' ';; esac; }
       printf '  %-34s S:%s M:%s R:%s I:%s A:%s  %3s %s\n' \
         "$r" "$(m security)" "$(m metadata)" "$(m release)" "$(m issues)" "$(m actions)" \
         "$sc" "$(jq -r '.grade' <<<"$obj")" >&2
@@ -436,7 +448,7 @@ if [ -n "$ORG" ]; then
         schema:"claude-mods.github-ops.repo-scorecard/v1"})}'
   else
     {
-      echo "── roll-up: $ORG ───────────────────────────────────────────"
+      term_header "roll-up: $ORG"
       printf '%s' "$rollup" | jq -r '
         "  scored: \(.repos_scored)   unreadable: \(.repos_unreadable)",
         "  avg score: \(.avg_score)   median: \(.median_score)",
@@ -444,7 +456,7 @@ if [ -n "$ORG" ]; then
         "  repos with a GAP — security:\(.failing_by_dimension.security) metadata:\(.failing_by_dimension.metadata) release:\(.failing_by_dimension.release) issues:\(.failing_by_dimension.issues) actions:\(.failing_by_dimension.actions)",
         "  worst: " + ([ .worst[] | "\(.repo) (\(.score)/\(.grade))" ] | join(", "))'
       [ -n "$MIN_SCORE" ] && echo "  below --min-score $MIN_SCORE: $below_min repo(s)"
-      echo "  legend: +=ok ~=warn X=gap ?=n/a  ·  S=security M=metadata R=release I=issues A=actions"
+      echo "  legend: $(term_mark ok)=ok $(term_mark warn)=warn $(term_mark bad)=gap $(term_mark na)=n/a  ·  S=security M=metadata R=release I=issues A=actions"
     } >&2
   fi
   { [ "$any_findings" -eq 1 ] || [ "$below_min" -gt 0 ]; } && exit "$EX_FINDINGS"

+ 28 - 0
skills/github-ops/tests/run.sh

@@ -136,6 +136,34 @@ if grep -nE 'gh (release create|repo edit|release delete|secret set|pr merge)' "
   no "rs: a mutating gh subcommand appears outside a printed remediation string"
 else ok "rs: mutating gh subcommands only appear as printed remediation text"; fi
 
+echo
+echo "-- terminal design system (term.sh adoption + ASCII fallback) --"
+
+# All three auditors must source the shared toolkit, not hand-roll ANSI.
+for s in "$CI" "$SP" "$RS"; do
+  b="$(basename "$s")"
+  if grep -q '_lib/term.sh' "$s"; then ok "$b sources _lib/term.sh"
+  else no "$b does not source _lib/term.sh"; fi
+done
+
+LIBTERM="$ROOT/../_lib/term.sh"
+if [ -f "$LIBTERM" ]; then
+  ok "term.sh present"
+  # Under TERM_ASCII=1 every framing primitive must fall back to pure ASCII
+  # (design principle #3: every glyph has a registered ASCII proxy).
+  marks="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; printf "%s%s%s%s%s%s%s" \
+    "$(term_mark ok)" "$(term_mark bad)" "$(term_mark warn)" "$(term_mark na)" \
+    "$(term_mark unknown)" "$(term_header hdr)" "$TERM_ARROW"')"
+  if printf '%s' "$marks" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
+    no "term.sh TERM_ASCII=1 still emits non-ASCII bytes"
+  else ok "term.sh TERM_ASCII=1 primitives are pure ASCII"; fi
+  # A fallback that silently drops the glyph (empty) is a bug, not a fallback.
+  m="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; term_mark ok')"
+  [ -n "$m" ] && ok "term_mark renders non-empty in ASCII mode" || no "term_mark ok is empty"
+else
+  no "term.sh missing at $LIBTERM"
+fi
+
 echo
 echo "=== $pass passed, $fail failed ==="
 [ "$fail" -eq 0 ]