Skip to content

Commit 3bf11a8

Browse files
Jammy2211Jammy2211claude
authored
Adopt the health shell scripts; worktree_status becomes a /health leg (#35)
- scripts/{health,health_sync,health_release,health_audit}.sh move here from PyAutoMind/scripts — the Heart owns the health surface; a health dashboard living in the intent organ was a boundary leak. Mind keeps forwarding shims. - skills/worktree_status/SKILL.md -> reference.md: retired as a standalone installed skill; it is now the procedure behind the new `/health worktrees` leg (same pattern as the pyauto-status legs). Co-authored-by: Jammy2211 <JNightingale2211@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 008189b commit 3bf11a8

5 files changed

Lines changed: 691 additions & 5 deletions

File tree

scripts/health.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# health.sh — the `health` shell dispatcher.
3+
#
4+
# Front door for the local health/dev shell tools, mirroring the Claude `/health`
5+
# command door. Routes to the implementations defined in the sibling scripts
6+
# (source all four via ~/.bashrc). Lives in PyAutoHeart — the Heart owns the
7+
# health surface; PyAutoMind/scripts keeps forwarding shims for old sourcing
8+
# paths:
9+
#
10+
# health cross-repo git-sync dashboard (health_sync.sh -> _health_sync)
11+
# health sync explicit alias of the above
12+
# health release last release-prep run dashboard (health_release.sh -> _health_release)
13+
# health audit structural repo-health audit (health_audit.sh -> _health_audit)
14+
# health help this usage
15+
#
16+
# Distinct from the `pyauto-heart` binary (the Heart organ CLI) — this is the
17+
# local shell convenience layer. Any argument after the subcommand is passed
18+
# through (e.g. `health release <run-dir>`).
19+
20+
health() {
21+
local sub="${1:-sync}"
22+
# Consume the subcommand token only if one was actually given, so bare
23+
# `health` (defaults to sync) and `health <sub> <args…>` both pass the
24+
# remaining "$@" straight through to the implementation.
25+
[ "$#" -gt 0 ] && shift
26+
case "$sub" in
27+
sync) _health_sync "$@" ;;
28+
release) _health_release "$@" ;;
29+
audit) _health_audit "$@" ;;
30+
-h|--help|help)
31+
cat <<'EOF'
32+
health — local health/dev shell dispatcher (mirrors the Claude /health door).
33+
34+
Usage: health [sync|release|audit]
35+
36+
health cross-repo git-sync dashboard (branch, behind/ahead, dirty)
37+
health sync same as bare `health`
38+
health release last PyAutoBuild release-prep run dashboard
39+
health audit structural repo-health audit (non-repo dirs, stashes, dead branches)
40+
41+
Release-run helpers: health-report / health-json / health-triage.
42+
EOF
43+
;;
44+
*)
45+
echo "health: unknown subcommand '$sub' (try: health help)" >&2
46+
return 2
47+
;;
48+
esac
49+
}

scripts/health_audit.sh

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#!/usr/bin/env bash
2+
# health_audit.sh — on-demand structural repo-health audit.
3+
#
4+
# Defines `_health_audit` (run via `health audit`) that scans ~/Code/PyAutoLabs/
5+
# for state the git-sync dashboard (`health` / `health sync`) doesn't surface:
6+
#
7+
# 1. Top-level directories with no .git (intentionally-not-a-repo or bug).
8+
# Skip prefixes "." (hidden) and "z_" (user's personal/staging convention).
9+
# 2. Stashes older than $PYAUTO_AUDIT_STASH_DAYS (default 14) — drift-from-
10+
# stash is a real failure mode.
11+
# 3. Local-only branches with no upstream and last commit older than
12+
# $PYAUTO_AUDIT_BRANCH_DAYS (default 30) — likely abandoned work.
13+
#
14+
# Run on demand. Always exits 0 — informational, the user reads + decides.
15+
#
16+
# Usage (normally sourced via ~/.bashrc, run through the `health` dispatcher):
17+
# source ~/Code/PyAutoLabs/PyAutoMind/scripts/health_audit.sh
18+
# health audit
19+
#
20+
# Override via env vars:
21+
# PYAUTO_AUDIT_ROOT scan root (default $HOME/Code/PyAutoLabs)
22+
# PYAUTO_AUDIT_STASH_DAYS stash age threshold in days (default 14)
23+
# PYAUTO_AUDIT_BRANCH_DAYS branch age threshold in days (default 30)
24+
25+
PYAUTO_AUDIT_ROOT="${PYAUTO_AUDIT_ROOT:-$HOME/Code/PyAutoLabs}"
26+
PYAUTO_AUDIT_STASH_DAYS="${PYAUTO_AUDIT_STASH_DAYS:-14}"
27+
PYAUTO_AUDIT_BRANCH_DAYS="${PYAUTO_AUDIT_BRANCH_DAYS:-30}"
28+
29+
_health_audit() {
30+
local root="$PYAUTO_AUDIT_ROOT"
31+
if [[ ! -d "$root" ]]; then
32+
echo "health audit: $root does not exist" >&2
33+
return 1
34+
fi
35+
36+
local now stash_thresh branch_thresh
37+
now=$(date +%s)
38+
stash_thresh=$(( PYAUTO_AUDIT_STASH_DAYS * 86400 ))
39+
branch_thresh=$(( PYAUTO_AUDIT_BRANCH_DAYS * 86400 ))
40+
41+
# Section 1: non-git directories. Skip prefixes are hardcoded — if the
42+
# legitimate exceptions ever exceed 3-4 patterns, switch to a snooze file.
43+
local skip_prefixes=("." "z_")
44+
local non_git=() dir name skip prefix
45+
for dir in "$root"/*/; do
46+
[[ ! -d "$dir" ]] && continue
47+
name="$(basename "$dir")"
48+
skip=false
49+
for prefix in "${skip_prefixes[@]}"; do
50+
[[ "$name" == "$prefix"* ]] && skip=true && break
51+
done
52+
[[ "$skip" == "true" ]] && continue
53+
[[ -e "$dir.git" ]] && continue
54+
non_git+=("$name")
55+
done
56+
57+
# Section 2: old stashes. `--format='%gd|%ad|%ct|%s'` gives the stash ref,
58+
# short date, commit timestamp, and subject in one line per entry.
59+
local stash_lines=() repo line ref short_date ts subj age
60+
for dir in "$root"/*/.git; do
61+
[[ -e "$dir" ]] || continue
62+
repo="${dir%/.git}"
63+
name="$(basename "$repo")"
64+
while IFS='|' read -r ref short_date ts subj; do
65+
[[ -z "$ts" ]] && continue
66+
age=$(( now - ts ))
67+
(( age < stash_thresh )) && continue
68+
stash_lines+=("$name: $ref ($short_date) $subj")
69+
done < <(git -C "$repo" stash list --date=short --format='%gd|%ad|%ct|%s' 2>/dev/null)
70+
done
71+
72+
# Section 3: abandoned local-only branches. `for-each-ref` with empty
73+
# `%(upstream)` filters local-only branches, then we age-filter on commit
74+
# timestamp. Pipe delimiter (not tab) because bash treats consecutive
75+
# whitespace IFS chars as one separator, which would collapse the empty
76+
# upstream column into the timestamp.
77+
local branch_lines=() branch upstream branch_ts iso
78+
for dir in "$root"/*/.git; do
79+
[[ -e "$dir" ]] || continue
80+
repo="${dir%/.git}"
81+
name="$(basename "$repo")"
82+
while IFS='|' read -r branch upstream branch_ts; do
83+
[[ -z "$branch" ]] && continue
84+
[[ -n "$upstream" ]] && continue
85+
age=$(( now - branch_ts ))
86+
(( age < branch_thresh )) && continue
87+
iso=$(date -d "@$branch_ts" +%Y-%m-%d 2>/dev/null)
88+
branch_lines+=("$name: $branch (last: $iso)")
89+
done < <(git -C "$repo" for-each-ref --format='%(refname:short)|%(upstream)|%(committerdate:unix)' refs/heads/ 2>/dev/null)
90+
done
91+
92+
# Output. Sections suppressed when empty; each prints its own header.
93+
local printed=false
94+
if (( ${#non_git[@]} > 0 )); then
95+
echo "Non-git directories under $root:"
96+
for name in "${non_git[@]}"; do echo " $name/"; done
97+
printed=true
98+
fi
99+
if (( ${#stash_lines[@]} > 0 )); then
100+
[[ "$printed" == "true" ]] && echo ""
101+
echo "Old stashes (>$PYAUTO_AUDIT_STASH_DAYS days):"
102+
for line in "${stash_lines[@]}"; do echo " $line"; done
103+
printed=true
104+
fi
105+
if (( ${#branch_lines[@]} > 0 )); then
106+
[[ "$printed" == "true" ]] && echo ""
107+
echo "Abandoned local-only branches (no upstream, last commit >$PYAUTO_AUDIT_BRANCH_DAYS days):"
108+
for line in "${branch_lines[@]}"; do echo " $line"; done
109+
printed=true
110+
fi
111+
[[ "$printed" == "false" ]] && echo "health audit: clean (no findings under $root)"
112+
return 0
113+
}

scripts/health_release.sh

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env bash
2+
# health_release.sh — release-prep run dashboard.
3+
#
4+
# Defines `_health_release` (run via `health release`) that reads the latest
5+
# PyAutoBuild full release-prep run (the one symlinked from
6+
# PyAutoBuild/test_results/latest/) and prints a dashboard:
7+
#
8+
# - Run timestamp + path + ready/not-ready verdict + total duration
9+
# - Per-workspace pass / fail / skipped / timeout / duration table
10+
# - Failure counts grouped by classification
11+
# - Top-25 slowest scripts (any status) — surfaces timing regressions
12+
# before they cross the timeout threshold
13+
# - Slow-skip / needs-fix banner counts
14+
# - Pointer to triage.md if present (free-form analytical clustering)
15+
#
16+
# Usage (normally sourced via ~/.bashrc, run through the `health` dispatcher):
17+
# source ~/Code/PyAutoLabs/PyAutoMind/scripts/health_release.sh
18+
# health release
19+
#
20+
# Override the run path (e.g. to inspect a specific historical run) by passing
21+
# it as the first argument:
22+
# health release ~/Code/PyAutoLabs/PyAutoBuild/test_results/runs/2026-04-29T14-48-47Z
23+
#
24+
# Sibling helpers: health-report / health-json / health-triage open the last
25+
# run's report.md / report.json / triage.md.
26+
#
27+
# Note: distinct from the Claude `/health full` command (the conversational
28+
# layer over the same run artefacts). This function prints straight to stdout,
29+
# no Claude needed.
30+
31+
PYAUTO_STATUS_FULL_DEFAULT="${PYAUTO_STATUS_FULL_DEFAULT:-$HOME/Code/PyAutoLabs/PyAutoBuild/test_results/latest}"
32+
33+
_health_release() {
34+
local run_dir="${1:-$PYAUTO_STATUS_FULL_DEFAULT}"
35+
36+
if [[ ! -e "$run_dir" ]]; then
37+
cat >&2 <<EOF
38+
health release: no run found at $run_dir
39+
40+
To produce one, from PyAutoBuild root:
41+
source ../activate.sh
42+
python autobuild/run_all.py
43+
EOF
44+
return 1
45+
fi
46+
47+
# Resolve symlink so the printed path is the actual run dir.
48+
run_dir="$(readlink -f "$run_dir")"
49+
50+
local report_json="$run_dir/report.json"
51+
if [[ ! -f "$report_json" ]]; then
52+
echo "health release: $report_json missing — run incomplete?" >&2
53+
return 1
54+
fi
55+
56+
python3 - "$run_dir" <<'PY'
57+
import json
58+
import sys
59+
from pathlib import Path
60+
61+
run_dir = Path(sys.argv[1])
62+
with open(run_dir / "report.json") as f:
63+
r = json.load(f)
64+
65+
ready = r.get("ready")
66+
total = float(r.get("total_duration_seconds", 0.0) or 0.0)
67+
summary = r.get("summary", {}) or {}
68+
n_pass = summary.get("passed", 0)
69+
n_fail = summary.get("failed", 0)
70+
n_skip = summary.get("skipped", 0)
71+
n_to = summary.get("timeout", 0)
72+
73+
GREEN = "\033[32m"
74+
RED = "\033[31m"
75+
YEL = "\033[33m"
76+
DIM = "\033[2m"
77+
RST = "\033[0m"
78+
79+
verdict = f"{GREEN}READY{RST}" if ready else f"{RED}NOT READY{RST}"
80+
print(f"{'=' * 76}")
81+
print(f" PyAuto Status Full")
82+
print(f"{'=' * 76}")
83+
print(f"Run: {r.get('run_label','')}")
84+
print(f"Path: {run_dir}")
85+
print(f"Status: {verdict} (passed: {n_pass}, failed: {n_fail}, skipped: {n_skip}, timeout: {n_to})")
86+
print(f"Total: {total:.1f}s ({total/60:.1f} min)")
87+
print()
88+
89+
# Per-workspace
90+
print("Per-workspace")
91+
print("-" * 76)
92+
print(f"{'Workspace':<22} {'Passed':>6} {'Failed':>6} {'Skipped':>7} {'Timeout':>7} {'Duration':>10}")
93+
pp = r.get("per_project", {}) or {}
94+
ppd = r.get("per_project_duration_seconds", {}) or {}
95+
for proj in sorted(pp.keys()):
96+
c = pp[proj]
97+
f = c.get("failed", 0)
98+
t = c.get("timeout", 0)
99+
color = GREEN if (f == 0 and t == 0) else RED
100+
print(
101+
f"{color}{proj:<22}{RST} "
102+
f"{c.get('passed',0):>6} {f:>6} "
103+
f"{c.get('skipped',0):>7} {t:>7} "
104+
f"{ppd.get(proj,0):>9.1f}s"
105+
)
106+
print()
107+
108+
# Failures by classification
109+
failures = r.get("failures", []) or []
110+
if failures:
111+
by_class = {}
112+
for fr in failures:
113+
cls = fr.get("classification", "unknown")
114+
by_class.setdefault(cls, []).append(fr)
115+
labels = {
116+
"source_code_bug": "Source code bugs",
117+
"workspace_issue": "Workspace issues",
118+
"workspace_data": "Missing data files",
119+
"environment": "Environment issues",
120+
"timeout": "Timeouts",
121+
"known_numerical": "Known numerical",
122+
"unknown": "Unclassified",
123+
}
124+
print(f"Failures by classification ({len(failures)} total)")
125+
print("-" * 76)
126+
for cls in sorted(by_class.keys(), key=lambda c: -len(by_class[c])):
127+
items = by_class[cls]
128+
print(f" {labels.get(cls, cls):<22} {len(items)}")
129+
print()
130+
131+
# Slowest 25
132+
slowest = r.get("slowest", []) or []
133+
if slowest:
134+
print(f"Slowest {len(slowest)} scripts")
135+
print("-" * 76)
136+
print(f"{'Duration':>9} {'Status':<8} {'Project':<16} Script")
137+
for s in slowest:
138+
proj = s.get("project", "")
139+
stat = s.get("status", "")
140+
fil = s.get("file", "")
141+
# Trim absolute paths to last 3 segments for readability.
142+
short = "/".join(fil.split("/")[-3:])
143+
dur = float(s.get("duration_seconds", 0.0) or 0.0)
144+
color = RED if stat in ("failed", "timeout") else (YEL if dur > 180 else "")
145+
print(f"{color}{dur:>8.1f}s {stat:<8} {proj:<16} {short}{RST}")
146+
print()
147+
148+
# Parked scripts banners
149+
slow_skips = r.get("slow_skips") or []
150+
nf_skips = r.get("needs_fix_skips") or []
151+
if slow_skips or nf_skips:
152+
print("Parked scripts (workspace no_run.yaml banners)")
153+
print("-" * 76)
154+
if slow_skips:
155+
print(f" SLOW skips: {len(slow_skips)} (need performance fix)")
156+
if nf_skips:
157+
print(f" NEEDS_FIX skips: {len(nf_skips)} (parked broken)")
158+
print()
159+
160+
# Pointers
161+
print("Pointers")
162+
print("-" * 76)
163+
print(f" Markdown report: {run_dir}/report.md {DIM}(health-report){RST}")
164+
print(f" Run JSON: {run_dir}/report.json {DIM}(health-json){RST}")
165+
triage = run_dir / "triage.md"
166+
if triage.exists():
167+
print(f" {GREEN}Triage notes: {triage}{RST} {DIM}(health-triage){RST}")
168+
PY
169+
}
170+
171+
# _pyauto_run_file <subpath> [run-dir-arg] — resolve a file inside the latest
172+
# (or supplied) run directory. Used by the pyauto-{report,json,triage} viewers.
173+
_pyauto_run_file() {
174+
local subpath="$1"
175+
local run_dir="${2:-$PYAUTO_STATUS_FULL_DEFAULT}"
176+
177+
if [[ ! -e "$run_dir" ]]; then
178+
echo "pyauto: no run found at $run_dir" >&2
179+
return 1
180+
fi
181+
run_dir="$(readlink -f "$run_dir")"
182+
183+
local target="$run_dir/$subpath"
184+
if [[ ! -f "$target" ]]; then
185+
echo "pyauto: $target missing" >&2
186+
return 1
187+
fi
188+
printf '%s' "$target"
189+
}
190+
191+
# health-report [run-dir] — view report.md in the pager.
192+
health-report() {
193+
local f
194+
f="$(_pyauto_run_file report.md "$1")" || return 1
195+
"${PAGER:-less}" "$f"
196+
}
197+
198+
# health-json [run-dir] — view report.json. Uses jq for color + paging when
199+
# available, falls back to plain cat otherwise.
200+
health-json() {
201+
local f
202+
f="$(_pyauto_run_file report.json "$1")" || return 1
203+
if command -v jq >/dev/null 2>&1; then
204+
jq -C . "$f" | "${PAGER:-less}" -R
205+
else
206+
"${PAGER:-less}" "$f"
207+
fi
208+
}
209+
210+
# health-triage [run-dir] — view triage.md in the pager.
211+
health-triage() {
212+
local f
213+
f="$(_pyauto_run_file triage.md "$1")" || return 1
214+
"${PAGER:-less}" "$f"
215+
}

0 commit comments

Comments
 (0)