diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bbf33c..3530258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,7 @@ jobs: VETTED=( tests/repair-merge-hooks.test.sh tests/metrics-pipeline.test.sh + tests/status-surface.test.sh ) fail=0 for t in "${VETTED[@]}"; do diff --git a/CHANGELOG.md b/CHANGELOG.md index bc450e2..5ab90f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 0.5.0 — 2026-05-18 + +### Substrate status surface (Phase 2 of substrate completion) + +The metrics pipeline shipped in v0.4.0 now has a readable face. A single command surfaces substrate health across 8 dimensions — Plant, Setpoints, Gates, Primitives, Companion skills, Bridge, RCS stability, Last upgrade — in colored text for humans or JSON for tooling. + +- **NEW** `bin/bstack-status` — health summary dispatcher. + - `bstack status` — colored text summary (ANSI-aware, TTY-detecting; `--no-color` opt-out). + - `bstack status --json` — single JSON object: `{bstack_version, workspace, profile, generated_at, setpoints, summary}` with `summary` carrying derived counts (`setpoints_in_target`, `primitives`, `gates_total`, `gate_bypass_attempts_24h`, `rcs_l3_lambda`, `last_upgrade`). Intended for CI / external consumers / status badges. + - `bstack status --setpoint S` — detailed single-setpoint view (text or `--json`). + - `bstack status --aggregate` — placeholder for Phase 8 federation; exits 3 with explanation. + - `--no-collect` — render from cached `~/.bstack/metrics/latest.json` even if stale (default behavior auto-runs `bstack metrics collect` when the cache is missing or > 5 min old). +- **EDIT** `bin/bstack` — register `status` subcommand. New `Observability:` `--help` line. +- **NEW** `tests/status-surface.test.sh` — 8 fixture-based tests covering: all 8 sections render, `--json` shape, `--setpoint` text + JSON modes, unknown-setpoint error, `--aggregate` placeholder, `--no-color` strips ANSI, auto-collect on stale cache. Added to vetted CI suite. + +### Composition with v0.4.0 + +`bstack-status` is a pure reader. It calls `bstack metrics collect` itself when needed so users running `bstack status` cold (e.g. fresh install) still see a populated panel. Cache TTL (5 min for status's own collection trigger; 60s for metrics's own cache) keeps repeated invocations cheap. + +### Data sources + +| Section | Source | +|---|---| +| Plant | derived from S11 (governance files) + S12 (hooks wired) in `~/.bstack/metrics/latest.json` | +| Setpoints | all setpoints in `latest.json`, classified vs alert thresholds, counted as `in_target/measured` | +| Gates | grep on `.control/policy.yaml` for `^\s+- id: G[0-9]+` | +| Primitives | grep on `CLAUDE.md` (workspace) or `assets/templates/CLAUDE.md.template` for primitive table rows; falls back to 20 | +| Companion skills | S10 in `latest.json` | +| Bridge | S13 in `latest.json` | +| RCS stability | parse `~//research/rcs/data/parameters.toml` for `l3` lambda (gracefully degrades when absent) | +| Last upgrade | `~/.bstack/just-upgraded-from` (if present) + `~/.bstack/last-update-check` cache | + +### Bug fix from earlier development (caught in smoke) + +The first iteration of `primitive_count()` used `grep -cE PATTERN file || echo 0` which double-emits `"0\n0"` on BSD/macOS grep (where `grep -c` exits 1 on zero matches AND outputs `0`). Replaced with `|| true` + `tr -d` whitespace + default. Same fix applied to `gate_count()` and `gate_bypass_count_24h()`. The regex was also corrected from `^\| \*\*P[0-9]+\*\*` (matches no rows) to `^\| P[0-9]+ \|` (matches actual table format). + +### SLO targets (introduced) + +- `bstack status` (cached metrics): p50 < 500ms, p99 < 1s +- `bstack status` (cold, auto-collects first): p50 < 2.5s, p99 < 6s +- `bstack status --setpoint `: p50 < 100ms, p99 < 300ms (single jq read) + +Spec reference: §6 Phase 2 of [specs/2026-05-18-substrate-completion.md](specs/2026-05-18-substrate-completion.md). + ## 0.4.0 — 2026-05-18 ### Setpoint measurement pipeline (Phase 1 of substrate completion) diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/bin/bstack b/bin/bstack index 307645c..f5734b9 100755 --- a/bin/bstack +++ b/bin/bstack @@ -40,6 +40,7 @@ Orchestration: Observability: metrics collect|observe Setpoint measurement pipeline (≥ 0.4.0). + status [--json|--setpoint X] Substrate health summary (≥ 0.5.0). Release (maintainers): release tag Tag the current VERSION and create the @@ -189,6 +190,7 @@ case "$cmd" in update-check) exec "$BIN_DIR/bstack-update-check" "$@" ;; wave) exec "$BIN_DIR/bstack-wave" "$@" ;; metrics) exec "$BIN_DIR/bstack-metrics" "$@" ;; + status) exec "$BIN_DIR/bstack-status" "$@" ;; release) sub="${1:-}" [ $# -gt 0 ] && shift diff --git a/bin/bstack-status b/bin/bstack-status new file mode 100755 index 0000000..050a2f9 --- /dev/null +++ b/bin/bstack-status @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +# bstack-status — substrate health surface (Phase 2, v0.5.0). +# +# Reads the metrics produced by `bstack metrics collect` (Phase 1) and +# renders a human- or machine-readable summary of substrate health +# covering: plant, setpoints, gates, primitives, companion skills, +# bridge freshness, RCS stability, and last upgrade. +# +# Subcommands: +# (default) Render colored text summary +# --json Emit a single JSON object (machine-readable) +# --setpoint Detailed single-setpoint view +# --aggregate Federation view (Phase 8 placeholder) +# --no-color Strip ANSI even on TTY +# --no-collect Use cached latest.json even if stale +# --help Help text +# +# Reads: +# ~/.bstack/metrics/latest.json produced by bstack metrics collect +# .control/policy.yaml workspace gates + setpoint declarations +# ~/.bstack/just-upgraded-from last-upgrade marker (if present) +# ~/.bstack/gate-audit.jsonl bypass-attempt log (Phase 5; optional) +# research/rcs/data/parameters.toml RCS L3 lambda (if workspace has it) +# +# Env overrides: +# BSTACK_DIR override bstack root +# BSTACK_METRICS_DIR override ~/.bstack/metrics/ +# BSTACK_STATE_DIR override ~/.bstack/ +# BROOMVA_WORKSPACE workspace root for policy.yaml/RCS reads +# +# SLO targets (per spec §7.3): +# bstack status (cached metrics): p50 < 500ms, p99 < 1s +set -euo pipefail + +BSTACK_DIR="${BSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +METRICS_DIR="${BSTACK_METRICS_DIR:-$HOME/.bstack/metrics}" +STATE_DIR="${BSTACK_STATE_DIR:-$HOME/.bstack}" +WORKSPACE="${BROOMVA_WORKSPACE:-$PWD}" +LATEST="$METRICS_DIR/latest.json" +POLICY="$WORKSPACE/.control/policy.yaml" +UPGRADE_MARKER="$STATE_DIR/just-upgraded-from" +LAST_CHECK="$STATE_DIR/last-update-check" +GATE_AUDIT="$STATE_DIR/gate-audit.jsonl" + +NO_COLOR="" +NO_COLLECT=0 +JSON_MODE=0 +SETPOINT_FILTER="" +AGGREGATE=0 + +usage() { + cat <<'EOF' +bstack-status — substrate health surface + +Modes: + bstack status Colored text summary (default) + bstack status --json Single JSON object on stdout + bstack status --setpoint S Detailed view of one setpoint + bstack status --aggregate Cross-workspace rollup (Phase 8, placeholder) + +Flags: + --no-color Strip ANSI even on TTY + --no-collect Skip auto-collect when latest.json is stale + --help, -h This message +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --json) JSON_MODE=1; shift ;; + --setpoint) SETPOINT_FILTER="${2:?}"; shift 2 ;; + --aggregate) AGGREGATE=1; shift ;; + --no-color) NO_COLOR=1; shift ;; + --no-collect) NO_COLLECT=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "bstack-status: unknown flag '$1'" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$AGGREGATE" = "1" ]; then + echo "bstack-status: --aggregate not implemented until Phase 8 (federation)" >&2 + exit 3 +fi + +# ─── Color setup (TTY + NO_COLOR aware) ──────────────────────────────── +if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ -z "${NO_COLOR_ENV:-}" ]; then + GREEN=$'\033[32m' + YELLOW=$'\033[33m' + RED=$'\033[31m' + DIM=$'\033[2m' + BOLD=$'\033[1m' + RESET=$'\033[0m' +else + GREEN="" YELLOW="" RED="" DIM="" BOLD="" RESET="" +fi + +SEP="─────────────────────────────────────────────" +OK="${GREEN}✓${RESET}" +WARN="${YELLOW}⚠${RESET}" +FAIL="${RED}✗${RESET}" + +# ─── Helpers ─────────────────────────────────────────────────────────── +collect_metrics_if_needed() { + # Trigger collect if latest.json missing or older than 5min, unless + # --no-collect was passed. + if [ "$NO_COLLECT" = "1" ]; then return 0; fi + local stale=0 + if [ ! -f "$LATEST" ]; then + stale=1 + else + local now age + now=$(date +%s) + if stat -f %m "$LATEST" >/dev/null 2>&1; then + age=$(( now - $(stat -f %m "$LATEST") )) + else + age=$(( now - $(stat -c %Y "$LATEST") )) + fi + [ "$age" -gt 300 ] && stale=1 + fi + if [ "$stale" = "1" ]; then + "$BSTACK_DIR/bin/bstack-metrics" collect >/dev/null 2>&1 || true + fi +} + +# Read primitive count from CLAUDE.md if available; else default 20. +# Note: BSD/macOS grep -c exits 1 on zero matches, so a "|| echo 0" fallback +# would double-print. Use "|| true" + trim whitespace + default to 0. +primitive_count() { + local claude n + for claude in "$WORKSPACE/CLAUDE.md" "$BSTACK_DIR/assets/templates/CLAUDE.md.template"; do + [ -f "$claude" ] || continue + n=$(grep -cE '^\| P[0-9]+ \|' "$claude" 2>/dev/null || true) + n=$(printf '%s' "${n:-0}" | tr -d '[:space:]') + if [ -n "$n" ] && [ "$n" -gt 0 ] 2>/dev/null; then + printf '%s\n' "$n" + return 0 + fi + done + echo 20 +} + +# Count hard gates declared in workspace policy.yaml. +gate_count() { + [ ! -f "$POLICY" ] && { echo 0; return; } + local n + n=$(grep -cE '^\s+- id: G[0-9]+' "$POLICY" 2>/dev/null || true) + printf '%s\n' "${n:-0}" | tr -d '[:space:]' + echo +} + +# Count bypass-attempt audit entries in the last 24h. +gate_bypass_count_24h() { + [ ! -f "$GATE_AUDIT" ] && { echo 0; return; } + local cutoff n + cutoff=$(date -u -v-1d +%FT%TZ 2>/dev/null || date -u -d '-1 day' +%FT%TZ 2>/dev/null || echo "") + if [ -z "$cutoff" ]; then + n=$(wc -l < "$GATE_AUDIT" 2>/dev/null || true) + else + n=$(jq -s --arg cutoff "$cutoff" '[.[] | select(.ts > $cutoff)] | length' "$GATE_AUDIT" 2>/dev/null || true) + fi + printf '%s\n' "${n:-0}" | tr -d '[:space:]' + echo +} + +# Read RCS L3 stability margin (λ₃) from canonical parameters.toml. +rcs_l3_lambda() { + local toml="$WORKSPACE/research/rcs/data/parameters.toml" + if [ ! -f "$toml" ]; then echo ""; return; fi + # Look for derived.lambda.l3 or derived.lambda[3]; fallback grep. + local v + v=$(grep -E '^\s*l3\s*=' "$toml" 2>/dev/null | head -1 | sed -E 's/.*=\s*([0-9.]+).*/\1/' || true) + [ -z "$v" ] && v=$(grep -E 'lambda.*l3' "$toml" 2>/dev/null | head -1 | sed -E 's/[^0-9.]//g' || true) + echo "$v" +} + +# Read profile from policy.yaml (governed | baseline | autonomous). +profile_name() { + [ ! -f "$POLICY" ] && { echo "unknown"; return; } + local p + p=$(grep -E '^profile:' "$POLICY" | head -1 | awk '{print $2}' | tr -d '#' | tr -d '[:space:]') + [ -z "$p" ] && p="unknown" + echo "$p" +} + +# Human-readable upgrade summary. +upgrade_line() { + if [ -f "$UPGRADE_MARKER" ]; then + local from to age + from=$(cat "$UPGRADE_MARKER" 2>/dev/null | tr -d '[:space:]') + to=$(cat "$BSTACK_DIR/VERSION" | tr -d '[:space:]') + if stat -f %m "$UPGRADE_MARKER" >/dev/null 2>&1; then + age=$(( $(date +%s) - $(stat -f %m "$UPGRADE_MARKER") )) + else + age=$(( $(date +%s) - $(stat -c %Y "$UPGRADE_MARKER") )) + fi + local hum + if [ "$age" -lt 60 ]; then hum="${age}s ago" + elif [ "$age" -lt 3600 ]; then hum="$((age / 60))m ago" + elif [ "$age" -lt 86400 ]; then hum="$((age / 3600))h ago" + else hum="$((age / 86400))d ago"; fi + echo "v$from → v$to (auto, $hum)" + elif [ -f "$LAST_CHECK" ]; then + local cached + cached=$(cat "$LAST_CHECK" 2>/dev/null | head -1) + case "$cached" in + UP_TO_DATE*) echo "on v$(cat "$BSTACK_DIR/VERSION" | tr -d '[:space:]') (up to date)" ;; + UPGRADE_AVAILABLE*) echo "v$(echo "$cached" | awk '{print $2}') → v$(echo "$cached" | awk '{print $3}') (available)" ;; + *) echo "on v$(cat "$BSTACK_DIR/VERSION" | tr -d '[:space:]')" ;; + esac + else + echo "on v$(cat "$BSTACK_DIR/VERSION" | tr -d '[:space:]')" + fi +} + +# Compose a one-line description of which setpoints are out-of-target. +setpoint_outliers() { + [ ! -f "$LATEST" ] && return + jq -r ' + .setpoints + | to_entries + | map(.value) + | map(select( + (.value != null) and ( + ((.alert_below // null) != null and .value < .alert_below) + or + ((.alert_above // null) != null and .value > .alert_above) + ) + )) + | map("\(.id) \(.name) \(.value) " + + (if .alert_below then "< \(.alert_below)" else "> \(.alert_above) " end)) + | join(", ") + ' "$LATEST" 2>/dev/null +} + +setpoint_in_target_count() { + [ ! -f "$LATEST" ] && { echo "0/0"; return; } + jq -r ' + .setpoints | to_entries | map(.value) as $sp + | ( + ($sp | map(select(.value != null)) | map(select( + (((.alert_below // null) != null and .value < .alert_below) + or ((.alert_above // null) != null and .value > .alert_above)) + | not + )) | length) as $in_target + | ($sp | map(select(.value != null)) | length) as $measured + | "\($in_target)/\($measured)" + ) + ' "$LATEST" 2>/dev/null +} + +severity_emoji_for_setpoint() { + # Args: id ; reads from $LATEST + jq -r --arg id "$1" ' + .setpoints[$id] // {} + | if .value == null then "-" + elif ((.alert_below // null) != null and .value < .alert_below) then + (if .severity == "blocking" then "fail" else "warn" end) + elif ((.alert_above // null) != null and .value > .alert_above) then + (if .severity == "blocking" then "fail" else "warn" end) + else "ok" end + ' "$LATEST" 2>/dev/null +} + +# ─── --setpoint path ─────────────────────────────────────────── +if [ -n "$SETPOINT_FILTER" ]; then + collect_metrics_if_needed + if [ ! -f "$LATEST" ]; then + echo "bstack-status: no metrics available — run 'bstack metrics collect' first" >&2 + exit 1 + fi + if [ "$JSON_MODE" = "1" ]; then + jq -e --arg id "$SETPOINT_FILTER" '.setpoints[$id]' "$LATEST" || { + echo "bstack-status: setpoint '$SETPOINT_FILTER' not found" >&2 + exit 4 + } + exit 0 + fi + # Text view + if ! jq -e --arg id "$SETPOINT_FILTER" '.setpoints[$id]' "$LATEST" >/dev/null 2>&1; then + echo "bstack-status: setpoint '$SETPOINT_FILTER' not found in metrics" >&2 + exit 4 + fi + sev=$(severity_emoji_for_setpoint "$SETPOINT_FILTER") + case "$sev" in + ok) marker="$OK" ;; + warn) marker="$WARN" ;; + fail) marker="$FAIL" ;; + *) marker="${DIM}-${RESET}" ;; + esac + printf "%s %s\n" "$marker" "$BOLD$SETPOINT_FILTER$RESET" + jq -r --arg id "$SETPOINT_FILTER" ' + .setpoints[$id] | to_entries + | map(if .value | type == "object" then {key, value: (.value|tostring)} else . end) + | map(" \(.key): \(.value)") | join("\n") + ' "$LATEST" + exit 0 +fi + +# ─── --json path (full summary as JSON) ─────────────────────────────── +if [ "$JSON_MODE" = "1" ]; then + collect_metrics_if_needed + bstack_version=$(cat "$BSTACK_DIR/VERSION" | tr -d '[:space:]') + workspace_name=$(basename "$WORKSPACE") + prim_count=$(primitive_count) + gates_total=$(gate_count) + bypass_24h=$(gate_bypass_count_24h) + profile=$(profile_name) + l3=$(rcs_l3_lambda) + in_target=$(setpoint_in_target_count) + upgrade=$(upgrade_line) + + metrics_blob='{"setpoints": {}}' + [ -f "$LATEST" ] && metrics_blob=$(cat "$LATEST") + + echo "$metrics_blob" | jq \ + --arg bstack_version "$bstack_version" \ + --arg workspace "$workspace_name" \ + --arg profile "$profile" \ + --arg in_target "$in_target" \ + --arg primitives "$prim_count" \ + --arg gates_total "$gates_total" \ + --arg bypass_24h "$bypass_24h" \ + --arg l3 "$l3" \ + --arg upgrade "$upgrade" \ + --arg generated_at "$(date -u +%FT%TZ)" \ + '{ + bstack_version: $bstack_version, + workspace: $workspace, + profile: $profile, + generated_at: $generated_at, + setpoints: (.setpoints // {}), + summary: { + setpoints_in_target: $in_target, + primitives: ($primitives | tonumber), + gates_total: ($gates_total | tonumber), + gate_bypass_attempts_24h: ($bypass_24h | tonumber), + rcs_l3_lambda: (if $l3 == "" then null else ($l3 | tonumber) end), + last_upgrade: $upgrade + } + }' + exit 0 +fi + +# ─── Default: colored text summary ──────────────────────────────────── +collect_metrics_if_needed + +bstack_version=$(cat "$BSTACK_DIR/VERSION" | tr -d '[:space:]') +workspace_name=$(basename "$WORKSPACE") +profile=$(profile_name) +prim_count=$(primitive_count) +gates_total=$(gate_count) +bypass_24h=$(gate_bypass_count_24h) +l3=$(rcs_l3_lambda) +in_target=$(setpoint_in_target_count) +outliers=$(setpoint_outliers) +upgrade=$(upgrade_line) + +# Plant section (uses S11 + S12 if present). +plant_gov="—" +plant_hooks="—" +if [ -f "$LATEST" ]; then + plant_gov=$(jq -r '"\(.setpoints.S11.value // 0)/5"' "$LATEST" 2>/dev/null) + plant_hooks=$(jq -r '"\(.setpoints.S12.value // 0)/3"' "$LATEST" 2>/dev/null) +fi + +plant_marker="$OK" +[ "$plant_gov" != "5/5" ] && plant_marker="$WARN" +[ "$plant_hooks" != "3/3" ] && plant_marker="$WARN" + +# Setpoints section +setpoint_marker="$OK" +if [ -n "$outliers" ]; then setpoint_marker="$WARN"; fi +sp_detail="$in_target in target" +[ -n "$outliers" ] && sp_detail="$sp_detail ($outliers)" + +# Gates section +gates_marker="$OK" +[ "$bypass_24h" -gt 0 ] && gates_marker="$WARN" +gates_detail="$gates_total reachable, $bypass_24h bypass attempts last 24h" + +# Companion skills section (S10) +skills_marker="$OK" +skills_detail="—" +if [ -f "$LATEST" ]; then + s10_val=$(jq -r '.setpoints.S10.value // 0' "$LATEST" 2>/dev/null) + s10_tgt=$(jq -r '.setpoints.S10.target // 27' "$LATEST" 2>/dev/null) + skills_detail="$s10_val/$s10_tgt installed" + [ "$s10_val" -lt "$s10_tgt" ] && skills_marker="$WARN" +fi + +# Bridge section (S13) +bridge_marker="$OK" +bridge_detail="—" +if [ -f "$LATEST" ]; then + s13_val=$(jq -r '.setpoints.S13.value // "null"' "$LATEST" 2>/dev/null) + s13_alert=$(jq -r '.setpoints.S13.alert_above // 3600' "$LATEST" 2>/dev/null) + if [ "$s13_val" = "null" ]; then + bridge_detail="(no stamp)" + bridge_marker="$WARN" + else + bridge_detail="freshness ${s13_val}s (alert >${s13_alert}s)" + # shellcheck disable=SC2086 + if [ $s13_val -gt $s13_alert ] 2>/dev/null; then bridge_marker="$WARN"; fi + fi +fi + +# RCS section +rcs_marker="$OK" +if [ -n "$l3" ]; then + rcs_detail="λ₃ = $l3 STABLE" +else + rcs_marker="${DIM}-${RESET}" + rcs_detail="${DIM}(not configured for this workspace)${RESET}" +fi + +# Primitives section (mechanism on disk would require Phase 5 doctor extension) +prim_marker="$OK" +prim_detail="$prim_count/20 declared" +[ "$prim_count" -ne 20 ] && prim_marker="$WARN" + +# Bottom-line counts +blocking=0 +informational=0 +if [ -f "$LATEST" ]; then + blocking=$(jq -r ' + [.setpoints | to_entries[] | .value + | select(.value != null) + | select(((.alert_below // null) != null and .value < .alert_below) + or ((.alert_above // null) != null and .value > .alert_above)) + | select(.severity == "blocking")] | length + ' "$LATEST" 2>/dev/null) + informational=$(jq -r ' + [.setpoints | to_entries[] | .value + | select(.value != null) + | select(((.alert_below // null) != null and .value < .alert_below) + or ((.alert_above // null) != null and .value > .alert_above)) + | select(.severity != "blocking")] | length + ' "$LATEST" 2>/dev/null) +fi + +# Render +echo "${BOLD}bstack v${bstack_version}${RESET} — ${workspace_name} [${profile}]" +echo "$SEP" +printf "%-16s %b %s\n" "Plant" "$plant_marker" "${plant_gov} governance files, ${plant_hooks} hooks wired" +printf "%-16s %b %s\n" "Setpoints" "$setpoint_marker" "$sp_detail" +printf "%-16s %b %s\n" "Gates" "$gates_marker" "$gates_detail" +printf "%-16s %b %s\n" "Primitives" "$prim_marker" "$prim_detail" +printf "%-16s %b %s\n" "Companion skills" "$skills_marker" "$skills_detail" +printf "%-16s %b %s\n" "Bridge" "$bridge_marker" "$bridge_detail" +printf "%-16s %b %s\n" "RCS stability" "$rcs_marker" "$rcs_detail" +printf "%-16s %s %s\n" "Last upgrade" " " "$upgrade" +echo "$SEP" +if [ "$blocking" -gt 0 ]; then + printf "%s%d blocking violation(s)%s, %d informational alert(s).\n" "$RED" "$blocking" "$RESET" "$informational" + exit 1 +elif [ "$informational" -gt 0 ]; then + printf "%s%d informational alert(s)%s. No blocking violations.\n" "$YELLOW" "$informational" "$RESET" +else + printf "%sAll setpoints within target%s. No alerts.\n" "$GREEN" "$RESET" +fi +exit 0 diff --git a/tests/status-surface.test.sh b/tests/status-surface.test.sh new file mode 100755 index 0000000..102edce --- /dev/null +++ b/tests/status-surface.test.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# tests/status-surface.test.sh — Phase 2 (v0.5.0) status surface smoke. +# +# Validates: +# 1. Text mode renders the expected sections +# 2. --json output is valid JSON with documented top-level shape +# 3. --setpoint S returns single-setpoint view (text) +# 4. --setpoint S --json returns just that setpoint +# 5. --setpoint S999 (unknown) errors cleanly +# 6. --aggregate placeholders error with exit 3 (Phase 8) +# 7. --no-color strips ANSI sequences +# 8. Status auto-collects metrics when latest.json is stale/missing +set -uo pipefail + +BSTACK_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BSTACK_STATUS="$BSTACK_REPO/bin/bstack-status" + +PASS=0 +FAIL=0 +FAILED_TESTS=() + +assert_pass() { PASS=$((PASS + 1)); echo " ✓ $1"; } +assert_fail() { + FAIL=$((FAIL + 1)) + FAILED_TESTS+=("$1") + echo " ✗ $1" + [ -n "${2:-}" ] && echo " ${2}" +} + +fresh_env() { + local md ws sd + md=$(mktemp -d) + ws=$(mktemp -d) + sd=$(mktemp -d) + # Minimal workspace fixtures so metrics + status have something to read. + mkdir -p "$ws/.claude" "$ws/.control" "$ws/docs/conversations" + : > "$ws/CLAUDE.md" + : > "$ws/AGENTS.md" + : > "$ws/.control/policy.yaml" + echo "{\"hooks\":{\"Stop\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"x\"}]}],\"PreToolUse\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"y\"}]}]}}" > "$ws/.claude/settings.json" + echo "$md $ws $sd" +} + +# ── Test 1: text mode renders expected sections ───────────────────────── +echo "" +echo "Test 1: text mode renders all sections" +read -r MD WS SD < <(fresh_env) +out=$(BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --no-color 2>&1) +missing=() +for section in "Plant" "Setpoints" "Gates" "Primitives" "Companion skills" "Bridge" "RCS stability" "Last upgrade"; do + echo "$out" | grep -qF "$section" || missing+=("$section") +done +if [ "${#missing[@]}" -eq 0 ]; then + assert_pass "all 8 sections rendered" +else + assert_fail "missing sections: ${missing[*]}" "$out" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Test 2: --json output has documented top-level shape ──────────────── +echo "" +echo "Test 2: --json output has documented top-level shape" +read -r MD WS SD < <(fresh_env) +out=$(BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --json 2>&1) +if echo "$out" | jq -e ' + .bstack_version and .workspace and .profile and .generated_at + and (.setpoints | type == "object") + and (.summary.setpoints_in_target | type == "string") + and (.summary.primitives | type == "number") + and (.summary.gates_total | type == "number") +' >/dev/null 2>&1; then + assert_pass "JSON has bstack_version/workspace/profile/generated_at/setpoints/summary" +else + assert_fail "JSON output shape invalid" "$out" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Test 3: --setpoint S13 returns text view ──────────────────────────── +echo "" +echo "Test 3: --setpoint S13 returns single-setpoint text view" +read -r MD WS SD < <(fresh_env) +out=$(BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --no-color --setpoint S13 2>&1) +if echo "$out" | grep -q '^. S13$' && echo "$out" | grep -q 'bridge_freshness_seconds'; then + assert_pass "S13 detail view rendered with marker + field listing" +else + assert_fail "S13 detail view missing expected lines" "$out" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Test 4: --setpoint S --json returns single setpoint ────────────── +echo "" +echo "Test 4: --setpoint S13 --json returns single-setpoint JSON" +read -r MD WS SD < <(fresh_env) +out=$(BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --setpoint S13 --json 2>&1) +if echo "$out" | jq -e '.id == "S13" and .name == "bridge_freshness_seconds"' >/dev/null 2>&1; then + assert_pass "--setpoint S13 --json returns matching id+name" +else + assert_fail "--setpoint S13 --json shape invalid" "$out" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Test 5: --setpoint S999 errors cleanly (exit 4) ───────────────────── +echo "" +echo "Test 5: --setpoint S999 (unknown) errors with exit 4" +read -r MD WS SD < <(fresh_env) +# Capture exit without enabling -e: command sub already swallows non-zero +# under `set -uo pipefail` (no -e). Explicit `|| true` makes it bullet-proof. +out=$(BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --setpoint S999 --json 2>&1 || true) +rc=$? # this is the exit of `true`, always 0 — re-check via run-and-grab +BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --setpoint S999 --json >/dev/null 2>&1 +rc=$? +if [ "$rc" = "4" ] || [ "$rc" = "1" ]; then + assert_pass "unknown setpoint returns non-zero exit ($rc)" +else + assert_fail "unknown setpoint returned exit $rc (expected 4 or 1)" "$out" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Test 6: --aggregate exits 3 (Phase 8 placeholder) ────────────────── +echo "" +echo "Test 6: --aggregate exits 3 (not-yet-implemented)" +"$BSTACK_STATUS" --aggregate >/dev/null 2>&1 +rc=$? +out=$("$BSTACK_STATUS" --aggregate 2>&1 || true) +if [ "$rc" = "3" ] && echo "$out" | grep -qF "Phase 8"; then + assert_pass "--aggregate exits 3 with Phase 8 message" +else + assert_fail "--aggregate did not produce expected error" "rc=$rc out=$out" +fi + +# ── Test 7: --no-color strips ANSI sequences ─────────────────────────── +echo "" +echo "Test 7: --no-color produces ANSI-free output" +read -r MD WS SD < <(fresh_env) +out=$(BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --no-color 2>&1) +if echo "$out" | grep -q $'\033'; then + assert_fail "ANSI escape sequences present despite --no-color" +else + assert_pass "no ANSI escape sequences in --no-color output" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Test 8: auto-collect on stale/missing latest.json ─────────────────── +echo "" +echo "Test 8: status auto-collects when latest.json absent" +read -r MD WS SD < <(fresh_env) +# Don't pre-populate latest.json — status should run collect itself. +BSTACK_DIR="$BSTACK_REPO" BSTACK_METRICS_DIR="$MD" BSTACK_STATE_DIR="$SD" \ + BROOMVA_WORKSPACE="$WS" "$BSTACK_STATUS" --json >/dev/null 2>&1 +if [ -f "$MD/latest.json" ] && jq -e '.setpoints' "$MD/latest.json" >/dev/null 2>&1; then + assert_pass "status auto-populated latest.json" +else + assert_fail "status did not auto-collect" "$(ls "$MD" 2>&1)" +fi +rm -rf "$MD" "$WS" "$SD" + +# ── Summary ────────────────────────────────────────────────────────────── +echo "" +echo "─────────────────────────────────────" +echo "Passed: $PASS" +echo "Failed: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "" + echo "Failed tests:" + for t in "${FAILED_TESTS[@]}"; do + echo " - $t" + done + exit 1 +fi +echo "All tests passed." +exit 0