From 63ce5a3fa8fa335126a3d38c6eb45a02f947ed62 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 11 Jul 2026 17:27:16 +0100 Subject: [PATCH] =?UTF-8?q?feat(hygiene):=20implement=20tidy/noise/deps/do?= =?UTF-8?q?cs=20modes=20=E2=80=94=20pre-scan=20+=20delegate=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the hygiene conductor. The four non-perf modes go live as cheap, read-only local PRE-SCANs that DELEGATE the full audit + any execution to the owning skill — the conductor never runs a heavy audit and never mutates a repo: - tidy → git-debris pre-scan (stale branches/stashes/[gone] refs/dirty trees across libs + org repos) → /repo_cleanup (Brain executor). - deps → capped/pinned specifiers in library pyproject.toml → /dep_audit. - docs → docs/api/*.rst + currentmodule counts (3 doc repos) → /audit_docs. - noise → advisory (no cheap local signal) → /cli_noise_clean. (noise/deps/docs are read-only PyAutoHeart observation skills — measurement lives in Heart; hygiene pre-scans, prioritises and routes.) Pre-scans are typed 'debris' (rankable removable count — tidy), 'surface' (sizes the audit, NOT a problem count — deps/docs), or 'advisory' (noise). Only 'debris' drives the default ranking, so the recommendation is honest: tidy when debris exists, else prompt the periodic audits. Extended --json HygieneDecision carries {mode,kind,status,count,summary,delegate}. perf stays staged (phase 3). Docs: AGENTS.md modes table + pre-scan-kind model; /hygiene veneer (only perf staged); repo_cleanup/SKILL.md (tidy is the front door, skill stays executor). Tests: PyAutoBrain 30 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Tw3EwV55k6VzxorYng3Kfn --- agents/conductors/hygiene/AGENTS.md | 45 +++--- agents/conductors/hygiene/hygiene.sh | 199 +++++++++++++++++++++------ skills/hygiene/hygiene.md | 24 ++-- skills/repo_cleanup/SKILL.md | 15 +- tests/test_hygiene_conductor.py | 60 ++++++++ 5 files changed, 263 insertions(+), 80 deletions(-) create mode 100644 tests/test_hygiene_conductor.py diff --git a/agents/conductors/hygiene/AGENTS.md b/agents/conductors/hygiene/AGENTS.md index 5dbbece..2e2cb39 100644 --- a/agents/conductors/hygiene/AGENTS.md +++ b/agents/conductors/hygiene/AGENTS.md @@ -19,30 +19,39 @@ paired repo**, boundaries) recorded in PyAutoMind ## Modes -| Mode | Question | Emits | -|------|----------|-------| -| `perf` | Which unit tests / integration-mode workspace scripts / imports are slow, and what is the cheapest win? | timing findings + route (refactor/bug; JAX-adapt only on a clear win) — *phase 3* | -| `tidy` | What git debris (stale branches, stashes, `[gone]` refs, dirty checkouts) is safe to remove? | the `repo_cleanup` sweep — *phase 2* | -| `noise` | What CLI noise (warnings, stray prints, library chatter) do tests/scripts emit? | the `cli_noise_clean` audit — *phase 2* | -| `deps` | Which dependency caps drift behind PyPI, and at what risk? | the `dep_audit` summary — *phase 2* | -| `docs` | Which `docs/api/*.rst` module paths are stale? | the `audit_docs` findings — *phase 2* | -| *(default)* | Across all of the above, what is the single most useful hygiene action now? | a prioritised `HygieneDecision` worklist | +Each mode does a cheap, read-only local **pre-scan** and **delegates** the full +audit + any execution to the owning skill — the conductor never runs a heavy +audit and never mutates a repo. A pre-scan is one of three kinds, which is what +makes its count comparable (or not): + +- **debris** — finds directly-removable items; a real, rankable count (`tidy`). +- **surface** — only *sizes* the audit; the real problems emerge when the + delegated skill runs, so the count is **not** a problem count (`deps`, `docs`). +- **advisory** — no cheap local signal at all (`noise`). + +| Mode | Pre-scan (kind) | Delegates to | +|------|-----------------|--------------| +| `perf` | *staged — phase 3* (dev-loop timing: slow tests / integration-mode scripts / imports) | `refactor` / `bug` | +| `tidy` | git debris — stale branches, stashes, `[gone]` refs, dirty checkouts (**debris**) | `/repo_cleanup` (Brain) | +| `noise` | none — needs a pytest + workspace-script run (**advisory**) | `/cli_noise_clean` (Heart) | +| `deps` | capped/pinned specifiers in library `pyproject.toml` (**surface**) | `/dep_audit` (Heart, hits PyPI) | +| `docs` | `docs/api/*.rst` + `currentmodule` counts across the 3 doc repos (**surface**) | `/audit_docs` (Heart, imports) | +| *(default)* | all of the above | a ranked `HygieneDecision` worklist — recommends `tidy` when debris exists (the only directly-actionable count), else prompts the periodic audits | ``` -pyauto-brain hygiene # audit across modes → prioritised worklist +pyauto-brain hygiene # audit across modes → ranked worklist pyauto-brain hygiene perf # dev-loop timing (staged: phase 3) -pyauto-brain hygiene tidy # git debris (staged: phase 2) -pyauto-brain hygiene noise # CLI noise (staged: phase 2) -pyauto-brain hygiene deps # dependency-cap drift (staged: phase 2) -pyauto-brain hygiene docs # stale API docs (staged: phase 2) +pyauto-brain hygiene tidy # git debris → /repo_cleanup +pyauto-brain hygiene noise # CLI noise → /cli_noise_clean +pyauto-brain hygiene deps # dependency-cap surface → /dep_audit +pyauto-brain hygiene docs # API-docs surface → /audit_docs pyauto-brain hygiene --json ``` -> **Staged.** This is the phase-1 scaffold: the conductor is real, routable and -> bounded, but the modes are stubs — each prints its staged notice. Phase 2 -> absorbs `repo_cleanup` (`tidy`) and `cli_noise_clean` (`noise`) and consults -> `dep_audit` (`deps`) / `audit_docs` (`docs`); phase 3 builds `perf` and any -> PyAutoHeart observation legs. +Repos are read under `PYAUTO_ROOT` (default `~/Code/PyAutoLabs`). `noise`/`deps`/ +`docs` route to **read-only PyAutoHeart observation skills** — measurement lives +in Heart; hygiene pre-scans, prioritises and routes. `perf` and any new standing +Heart legs (`import_time` / `cli_noise`) remain phase 3. ## Fundamental principles diff --git a/agents/conductors/hygiene/hygiene.sh b/agents/conductors/hygiene/hygiene.sh index e45fac9..3017c13 100755 --- a/agents/conductors/hygiene/hygiene.sh +++ b/agents/conductors/hygiene/hygiene.sh @@ -7,41 +7,123 @@ # Owns code-quality upkeep across the organism and emits a HygieneDecision the # human/session executes, delegating the actual fixes to the dev-flow conductors # (refactor/bug/feature) via ship_*. It reasons; it never edits source itself, -# and (like profiling) it stays stdlib/bash so it never drags the JAX stack into -# the Brain. +# never mutates a repo, and (like profiling) it stays stdlib/bash so it never +# drags the JAX stack into the Brain. +# +# Each mode does a cheap, read-only local PRE-SCAN (signals bash can compute in +# seconds) and DELEGATES the full audit + any execution to the owning skill: +# tidy -> /repo_cleanup (Brain), noise -> /cli_noise_clean, deps -> /dep_audit, +# docs -> /audit_docs (the last three are read-only PyAutoHeart observation +# skills — measurement lives in Heart; hygiene routes and prioritises). # # Usage: # hygiene.sh # audit across modes -> prioritised worklist (default) # hygiene.sh perf # dev-loop timing: unit tests / integration scripts / imports (phase 3) -# hygiene.sh tidy # git debris: the repo_cleanup sweep (phase 2) -# hygiene.sh noise # CLI noise: the cli_noise_clean audit (phase 2) -# hygiene.sh deps # dependency-cap drift: dep_audit vs PyPI (phase 2) -# hygiene.sh docs # stale API docs: audit_docs over docs/api/*.rst (phase 2) +# hygiene.sh tidy # git debris pre-scan -> /repo_cleanup +# hygiene.sh noise # CLI-noise route -> /cli_noise_clean +# hygiene.sh deps # dependency-cap pre-scan -> /dep_audit +# hygiene.sh docs # API-docs pre-scan -> /audit_docs # hygiene.sh --json # machine-readable HygieneDecision # -# PHASE 1 SCAFFOLD: the conductor is real, routable and bounded, but the modes -# are staged stubs — each reports where it lands. Per-mode behaviour and -# exit-code semantics arrive with the modes (phases 2-3). +# perf stays staged for phase 3; the other four modes are live (pre-scan + +# delegate). The conductor reads repos under PYAUTO_ROOT (default ~/Code/PyAutoLabs). set -uo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" source "$HERE/../../_common.sh" +# PYAUTO_ROOT is exported/defaulted by _common.sh (~/Code/PyAutoLabs). Scan the +# canonical checkouts there, never the worktree symlinks. +ROOT="${PYAUTO_ROOT:-$HOME/Code/PyAutoLabs}" +LIB_REPOS=(PyAutoConf PyAutoFit PyAutoArray PyAutoGalaxy PyAutoLens) +ORG_REPOS=(PyAutoBrain PyAutoBuild PyAutoHeart PyAutoMind) +DOC_REPOS=(PyAutoFit PyAutoGalaxy PyAutoLens) + MODE_ORDER=(perf tidy noise deps docs) -declare -A MODE_PHASE=( - [perf]="phase 3" [tidy]="phase 2" [noise]="phase 2" [deps]="phase 2" [docs]="phase 2" +declare -A MODE_DELEGATE=( + [perf]="(phase 3 — not yet implemented)" + [tidy]="/repo_cleanup" + [noise]="/cli_noise_clean" + [deps]="/dep_audit" + [docs]="/audit_docs" ) -declare -A MODE_DESC=( - [perf]="dev-loop timing — unit tests / integration-mode scripts / import cost; route to refactor/bug" - [tidy]="git debris — the repo_cleanup sweep" - [noise]="CLI noise — the cli_noise_clean audit" - [deps]="dependency-cap drift — dep_audit vs PyPI" - [docs]="stale API docs — audit_docs over docs/api/*.rst" +# A mode's pre-scan is one of three kinds, which is what makes its count +# comparable (or not): 'debris' pre-scans find directly-removable items (a real, +# rankable count); 'surface' pre-scans only size the audit (the actual problems +# emerge only when the delegated skill runs — the count is NOT a problem count); +# 'advisory' has no cheap local signal. Only 'debris' counts drive the ranking. +declare -A MODE_KIND=( + [tidy]="debris" [deps]="surface" [docs]="surface" [noise]="advisory" ) -mode="default" -json=0 +# --- Pre-scan helpers (read-only; each echoes "count|one-line summary"). ------- + +# tidy: git debris across managed checkouts — stale branches, stashes, [gone] +# tracking refs, dirty trees. The prioritisable count is the total debris. +prescan_tidy() { + local branches=0 stashes=0 gone=0 dirty=0 scanned=0 repo dir + for repo in "${LIB_REPOS[@]}" "${ORG_REPOS[@]}"; do + dir="$ROOT/$repo" + [[ -d "$dir/.git" || -f "$dir/.git" ]] || continue + scanned=$((scanned + 1)) + local b s g + b=$(git -C "$dir" for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null \ + | grep -vxE 'main|master|HEAD' | wc -l | tr -d ' ') + s=$(git -C "$dir" stash list 2>/dev/null | wc -l | tr -d ' ') + g=$(git -C "$dir" branch -vv 2>/dev/null | grep -c '\[gone\]' || true) + branches=$((branches + b)); stashes=$((stashes + s)); gone=$((gone + g)) + [[ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]] && dirty=$((dirty + 1)) + done + local total=$((branches + stashes + gone + dirty)) + echo "${total}|${scanned} repos: ${branches} stale branches, ${stashes} stashes, ${gone} [gone] refs, ${dirty} dirty checkouts" +} + +# deps: count capped dependency specifiers (<, <=, ==) in library pyproject.toml. +# A cheap "how many caps could be stale" signal; /dep_audit does the PyPI compare. +prescan_deps() { + local caps=0 files=0 repo pj + for repo in "${LIB_REPOS[@]}"; do + pj="$ROOT/$repo/pyproject.toml" + [[ -f "$pj" ]] || continue + files=$((files + 1)) + local c + c=$(grep -oE '[<>=!~]=?[[:space:]]*[0-9]' "$pj" 2>/dev/null | grep -cE '<|==' || true) + caps=$((caps + c)) + done + echo "${caps}|${caps} capped/pinned specifiers across ${files} library pyproject.toml" +} + +# docs: count docs/api/*.rst files and currentmodule directives in the doc repos. +# /audit_docs does the actual import validation. +prescan_docs() { + local rst=0 cm=0 repo d + for repo in "${DOC_REPOS[@]}"; do + d="$ROOT/$repo/docs/api" + [[ -d "$d" ]] || continue + local n c + n=$(find "$d" -maxdepth 1 -name '*.rst' 2>/dev/null | wc -l | tr -d ' ') + c=$(grep -rhE '^\s*\.\.\s+currentmodule::' "$d" 2>/dev/null | wc -l | tr -d ' ') + rst=$((rst + n)); cm=$((cm + c)) + done + echo "${cm}|${rst} api .rst files, ${cm} currentmodule directives across ${#DOC_REPOS[@]} repos" +} + +# noise: no cheap local signal (needs running pytest + workspace scripts). +prescan_noise() { + echo "-1|no cheap local signal — runs pytest + workspace scripts (PYAUTO_TEST_MODE=2)" +} + +prescan() { + case "$1" in + tidy) prescan_tidy ;; deps) prescan_deps ;; + docs) prescan_docs ;; noise) prescan_noise ;; + esac +} + +# --- Arg parse. ---------------------------------------------------------------- + +mode="default"; json=0 for arg in "$@"; do case "$arg" in perf|tidy|noise|deps|docs) mode="$arg" ;; @@ -53,46 +135,77 @@ for arg in "$@"; do done if [[ "$mode" == "help" ]]; then - # Print the "# Usage:" block from this script's own header (robust to the - # header moving — no hard-coded line numbers). awk '/^# Usage:/{u=1;next} u{ if($0 ~ /^# /){sub(/^# /," "); print} else exit }' "$HERE/hygiene.sh" exit 0 fi -# --- JSON footing (staged): a HygieneDecision shell the Brain session can read. +# --- JSON footing: a HygieneDecision the Brain session can consume. ------------ +emit_json_row() { # mode + local m="$1" + if [[ "$m" == "perf" ]]; then + printf '{"mode":"perf","status":"staged","lands":"phase 3"}'; return + fi + local res count summary kind status + res="$(prescan "$m")"; count="${res%%|*}"; summary="${res#*|}"; kind="${MODE_KIND[$m]}" + if [[ "$kind" == "advisory" ]]; then status="advisory" + elif [[ "$kind" == "surface" ]]; then status="surface" + elif [[ "$count" == "0" ]]; then status="clean" + else status="debris"; fi + printf '{"mode":"%s","kind":"%s","status":"%s","count":%s,"summary":"%s","delegate":"%s"}' \ + "$m" "$kind" "$status" "$([[ "$count" == "-1" ]] && echo null || echo "$count")" \ + "${summary//\"/\\\"}" "${MODE_DELEGATE[$m]}" +} + if [[ "$json" -eq 1 ]]; then if [[ "$mode" == "default" ]]; then - printf '{"decision":"HygieneDecision","status":"staged-phase-1","mode":"default","modes":[' + printf '{"decision":"HygieneDecision","mode":"default","rows":[' sep="" - for m in "${MODE_ORDER[@]}"; do - printf '%s{"mode":"%s","status":"staged","lands":"%s"}' "$sep" "$m" "${MODE_PHASE[$m]}" - sep="," - done + for m in "${MODE_ORDER[@]}"; do printf '%s' "$sep"; emit_json_row "$m"; sep=","; done printf ']}\n' else - printf '{"decision":"HygieneDecision","status":"staged-phase-1","mode":"%s","lands":"%s"}\n' \ - "$mode" "${MODE_PHASE[$mode]}" + printf '{"decision":"HygieneDecision","mode":"%s","row":' "$mode"; emit_json_row "$mode"; printf '}\n' fi exit 0 fi -# --- Human footing. -echo "== HygieneDecision (phase-1 scaffold — modes staged) ==" -echo "The hygiene conductor owns code-quality upkeep: developer-loop cost and repo" -echo "tidiness, distinct from Heart (proof-of-works) and profiling (modelling speed)." -echo "It finds and prioritises debt, then delegates the fix to refactor/bug/feature." +# --- Human footing. ------------------------------------------------------------ +echo "== HygieneDecision ==" +echo "The hygiene conductor pre-scans code-quality debt (read-only) and delegates the" +echo "audit + fix to the owning skill — it never mutates a repo itself." echo + +render_row() { # mode + local m="$1" + if [[ "$m" == "perf" ]]; then + printf ' %-6s %-9s %s\n' "perf" "staged" "dev-loop timing (phase 3) — not yet implemented" + return + fi + local res count summary kind tag + res="$(prescan "$m")"; count="${res%%|*}"; summary="${res#*|}"; kind="${MODE_KIND[$m]}" + if [[ "$kind" == "advisory" ]]; then tag="advisory" + elif [[ "$kind" == "surface" ]]; then tag="surface" + elif [[ "$count" == "0" ]]; then tag="clean" + else tag="${count} debris"; fi + printf ' %-6s %-9s %s\n' "$m" "$tag" "$summary" + printf ' %-6s %-9s → run %s for the full audit\n' "" "" "${MODE_DELEGATE[$m]}" +} + if [[ "$mode" == "default" ]]; then - printf ' %-6s %-9s %s\n' "MODE" "LANDS" "SCOPE" - for m in "${MODE_ORDER[@]}"; do - printf ' %-6s %-9s %s\n' "$m" "${MODE_PHASE[$m]}" "${MODE_DESC[$m]}" - done + # Only 'debris' pre-scans yield a directly-actionable count, so the ranking is + # honest: recommend tidy when there is removable debris; otherwise the + # 'surface'/'advisory' modes are periodic audits (their real findings only + # emerge when the delegated skill runs — the pre-scan can't rank them). + tidy_n="$(prescan tidy)"; tidy_n="${tidy_n%%|*}" + for m in "${MODE_ORDER[@]}"; do render_row "$m"; done echo - echo "Run 'pyauto-brain hygiene ' for a mode once it lands; --json for the" - echo "machine footing. Design: PyAutoMind research/pyautobrain/hygiene_agent_decision.md." + if [[ "$tidy_n" -gt 0 ]]; then + echo "Recommended next: hygiene tidy (${tidy_n} removable debris items), then run /repo_cleanup." + echo " deps/docs report audit *surface*, not problems — run their skills periodically to surface real findings." + else + echo "Recommended next: no removable debris — run deps/docs/noise audits periodically (their pre-scans size the surface only)." + fi + echo "Design: PyAutoMind research/pyautobrain/hygiene_agent_decision.md." else - echo " mode: $mode" - echo " scope: ${MODE_DESC[$mode]}" - echo " lands: ${MODE_PHASE[$mode]} — not yet implemented (phase-1 scaffold)." + render_row "$mode" fi exit 0 diff --git a/skills/hygiene/hygiene.md b/skills/hygiene/hygiene.md index 20d73d0..12fb8f9 100644 --- a/skills/hygiene/hygiene.md +++ b/skills/hygiene/hygiene.md @@ -9,18 +9,18 @@ Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. ## Do -1. Run `bin/pyauto-brain hygiene [perf | tidy | noise | deps | docs]` (no arg = - audit across modes → a prioritised worklist). This is a **dry run** — it emits - a `HygieneDecision`. Nothing is executed. -2. Execute the emitted plan through the normal dev workflow: hygiene *finds and - prioritises* quality debt and *delegates the fix* — restructuring to - `/refactor`, regressions to `/bug`, larger changes to `/feature` — shipped via +1. Run `bin/pyauto-brain hygiene [tidy | noise | deps | docs]` (no arg = pre-scan + across modes → a ranked worklist). This is a **dry run** — each mode does a + cheap read-only pre-scan and emits a `HygieneDecision` naming the skill to run + for the full audit. Nothing is executed or mutated. +2. Execute the emitted plan: run the named delegate — `/repo_cleanup` (git + debris), `/cli_noise_clean`, `/dep_audit`, `/audit_docs` — for the full audit, + then route any code fixes to `/refactor` / `/bug` / `/feature`, shipped via `ship_library` / `ship_workspace`. -The Hygiene Agent **reasons; it never edits source.** Measurement lives in Heart -(the `script_timing` / `test_run` signals); hygiene acts on it. +The Hygiene Agent **reasons; it never edits source and never mutates a repo.** +Measurement lives in Heart (`noise`/`deps`/`docs` route to read-only PyAutoHeart +skills, plus the `script_timing` / `test_run` signals); hygiene pre-scans + routes. -> **Staged (phase 1):** the conductor is real and bounded but its modes are -> stubs. `tidy` / `noise` / `deps` / `docs` land in phase 2 (absorbing -> `repo_cleanup` + `cli_noise_clean`, consulting `dep_audit` + `audit_docs`); -> `perf` in phase 3. +> **Staged:** only `perf` (dev-loop timing) remains staged — it lands in phase 3 +> with any new PyAutoHeart legs. `tidy` / `noise` / `deps` / `docs` are live. diff --git a/skills/repo_cleanup/SKILL.md b/skills/repo_cleanup/SKILL.md index 8d6791a..8f3cfad 100644 --- a/skills/repo_cleanup/SKILL.md +++ b/skills/repo_cleanup/SKILL.md @@ -22,13 +22,14 @@ Cleanup is not release work, so it never touches PyAutoBuild. It reads the PyAutoMind registry (`active.md`) to know what's claimed. Organ boundary + execution-environment model: [`../WORKFLOW.md`](../WORKFLOW.md). -> **The hygiene conductor.** This skill is the inline form; its reasoning is now -> owned by the PyAutoBrain **Hygiene Agent** (`agents/conductors/hygiene/`), -> which generalises hygiene across the organism and absorbs this sweep as its -> `tidy` mode. That conductor's phase-1 scaffold exists and documents the -> boundary; the `tidy` mode itself lands in phase 2. Until it does, run the -> reasoning here per this file — this remains the working form for git-debris -> cleanup. +> **The hygiene conductor.** The PyAutoBrain **Hygiene Agent** +> (`agents/conductors/hygiene/`) generalises hygiene across the organism; its +> `tidy` mode (`pyauto-brain hygiene tidy`) is the front door — it pre-scans the +> git debris (stale branches / stashes / `[gone]` refs / dirty checkouts) and +> routes here. **This skill remains the executor:** the conductor reasons and +> never mutates a repo, so the interactive per-bucket audit + deletion below is +> what actually does the cleanup. Reach for `hygiene tidy` to see the debris and +> the recommendation; run this skill to act on it. **Distinct from:** `/health worktrees` (Heart read-only diagnostic — consulted here, but this also mutates); post-merge cleanup in `CLAUDE.md` (once per shipped task — diff --git a/tests/test_hygiene_conductor.py b/tests/test_hygiene_conductor.py new file mode 100644 index 0000000..588b7ea --- /dev/null +++ b/tests/test_hygiene_conductor.py @@ -0,0 +1,60 @@ +"""Contract tests for the hygiene conductor's CLI footing. + +Hermetic: PYAUTO_ROOT points at an empty temp dir so the read-only pre-scans +return zero/empty signals — the JSON *structure* and exit codes are asserted +without depending on the state of the real checkouts. +""" + +import json +import os +import subprocess +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" +MODES = {"perf", "tidy", "noise", "deps", "docs"} + + +def _run(args, root): + env = {**os.environ, "PYAUTO_ROOT": str(root)} + return subprocess.run( + [str(BRAIN), "hygiene", *args], + capture_output=True, text=True, env=env, + ) + + +def test_default_json_is_a_hygiene_decision_with_all_modes(tmp_path): + r = _run(["--json"], tmp_path) + assert r.returncode == 0, r.stderr + doc = json.loads(r.stdout) + assert doc["decision"] == "HygieneDecision" + assert doc["mode"] == "default" + assert {row["mode"] for row in doc["rows"]} == MODES + # perf is staged; the other four carry a pre-scan kind. + kinds = {row["mode"]: row.get("kind") for row in doc["rows"]} + assert kinds["tidy"] == "debris" + assert kinds["deps"] == "surface" and kinds["docs"] == "surface" + assert kinds["noise"] == "advisory" + + +def test_single_mode_json_round_trips(tmp_path): + for mode in MODES - {"perf"}: + r = _run([mode, "--json"], tmp_path) + assert r.returncode == 0, r.stderr + doc = json.loads(r.stdout) + assert doc["mode"] == mode + assert doc["row"]["mode"] == mode + assert doc["row"]["delegate"].startswith("/") + + +def test_unknown_mode_exits_2(tmp_path): + r = _run(["bogus"], tmp_path) + assert r.returncode == 2 + assert "unknown argument" in r.stderr + + +def test_help_lists_the_usage_block(tmp_path): + r = _run(["--help"], tmp_path) + assert r.returncode == 0 + assert "hygiene.sh" in r.stdout + assert "--json" in r.stdout