diff --git a/docs/explanations/2026-07-25-explanation-hook-test-mutation-gate.html b/docs/explanations/2026-07-25-explanation-hook-test-mutation-gate.html new file mode 100644 index 00000000..6e25ade0 --- /dev/null +++ b/docs/explanations/2026-07-25-explanation-hook-test-mutation-gate.html @@ -0,0 +1,221 @@ + + + + + +hook test mutation gate + + + +

hook test mutation gate

+

2026-07-25 · testsAndMisc

+

The session-autopsy hook tests claimed to be non-vacuous; this commit turns that claim into a pre-commit gate that re-proves it by mutation, every time the proof could go stale.

+ + + +

Background

+

The session-autopsy system analyzes Claude Code transcripts after each session. Two small bash hooks wire it into the harness: session_autopsy_end.sh feeds the ended session's transcript to the analyzer, and session_autopsy_start.sh prints a one-line nudge when unreviewed automation candidates exist.

+

Both hooks share an unusual contract: they always exit 0, deliberately, so a broken analyzer can never block a session from starting or ending. That contract has a testing consequence — any test that asserts the hook's exit status passes even if the hook's entire body is deleted. The twelve tests in test_session_autopsy_hooks.py therefore assert side effects: that the end hook launches the analyzer with the right transcript path (captured via a shimmed setsid on PATH), and that the start hook prints exactly the expected banner, or exactly nothing.

+

"Asserts a side effect" is itself just a claim. The previous commit proved it once with a throwaway mutation harness in a session scratchpad — and that proof would have evaporated with the session, leaving an unfalsifiable assertion about tests whose whole purpose is to not be vacuous.

+ +

Intuition

+

Mutation testing asks: if I deliberately break the code, do the tests notice? A test that survives every plausible break is asserting nothing. The tool copies both hooks, breaks one guard per copy, points the suite at the broken copy through AUTOPSY_HOOKS_DIR, and records which tests fail:

+
+
copy hooksdeployed files untouched
+ +
apply 1 of 8 mutationse.g. drop the transcript guard
+ +
run the 12 testsAUTOPSY_HOOKS_DIR=copy
+ +
record killssurvivor of all 8 ⇒ FAIL
+
+

The subtlety that shaped the design: gutting a whole hook is the wrong mutation. A gutted hook emits no launch and no output — which is exactly what the nine negative tests ("given junk input, stay silent") already expect. Gut-only mutation brands those nine tests vacuous when they are not:

+
+

Gut the whole body

Kills 3 positive tests. The 9 negative tests still pass — silence is what they wanted. Misleading

+

Break one guard at a time

An always-announcing start hook, a dropped numeric check… each negative test dies to the mutation that removes the guard it exercises. All 12 killed

+
+

One more failure mode is closed: mutations are string edits over the hook text, so a harmless rename inside a hook would make a mutation silently stop matching — testing nothing. The staleness guard makes that a loud error instead.

+ +

Code

+

The tool — meta/scripts/mutate_session_autopsy_hooks.py (new, 253 lines). Eight named mutations, each a targeted string transform breaking one guard; a runner that copies the hooks, applies one mutation, and executes the suite against the copy; and a verdict that fails on any never-killed test:

+
    +
  1. Skip with exit 0 when ~/.claude/hooks has no deployed hooks — a CI runner has nothing to mutate, and a skip must not fail the build.
  2. +
  3. Raise if a mutation leaves the hook text unchanged (staleness guard): a no-op mutation would quietly weaken the proof rather than report a survivor.
  4. +
  5. Gate, don't warn: survivors mean a non-zero exit, per the house rule that self-checks must fail, not print advice.
  6. +
+

The wiring — one new local pre-commit hook, scoped by a files: regex to the only two files that can invalidate the proof: the test suite and the mutation definitions. The run costs ~40 seconds, so it must not fire on unrelated commits:

+
- id: session-autopsy-hook-mutations
+  entry: python3 meta/scripts/mutate_session_autopsy_hooks.py
+  files: ^(linux_configuration/tests/test_session_autopsy_hooks\.py|meta/scripts/mutate_session_autopsy_hooks\.py)$
+

Recorded honestly rather than papered over: the start-no-readable-guard mutation kills nothing on its own, because that guard is provably redundant (jq's stderr is muted and an empty count already fails the arithmetic) — only the compound always-announce mutation reaches those cases.

+ +

Quiz

+

Click an answer to check it — rationale appears after you pick.

+
+

+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index a13f4af7..51dd1cd1 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@
testsAndMisc

Change explanations

-

15 explanations · interactive walkthroughs of notable changes, newest first.

+

16 explanations · interactive walkthroughs of notable changes, newest first.

+ + 2026-07-25 + hook test mutation gate + 2026-07-21 run.sh for the KCD2 Dice Solver diff --git a/docs/superpowers/evidence/session-autopsy-badge-and-aliases-2026-07-25.json b/docs/superpowers/evidence/session-autopsy-badge-and-aliases-2026-07-25.json new file mode 100644 index 00000000..43bf45be --- /dev/null +++ b/docs/superpowers/evidence/session-autopsy-badge-and-aliases-2026-07-25.json @@ -0,0 +1,41 @@ +{ + "intent": "Make the session-autopsy surfacing honest and the compiled scripts reachable without Claude. The statusline badge counted all 182 candidates (nobody reviews 182 weak n-grams), and the compiled scripts had no zero-token entry point, so the deterministic 90% of each workflow still implied opening a Claude session.", + "scope": [ + "python_pkg/session_autopsy/report.py (significant_ids + SIGNIFICANT_WEEKLY_TOKENS)", + "python_pkg/session_autopsy/cli.py (mark-reviewed uses the same filter)", + "python_pkg/session_autopsy/tests/test_report.py (updated expectations)", + "linux_configuration/zsh/token-savers.zsh (new; symlinked into ~/.oh-my-zsh/custom/)", + "docs/explanations/ (new explanation doc + refreshed index, via the newly compiled explain_diff.sh scaffold)", + "Non-goal: changing detector rankings or REPORT.md content; only what the badge counts." + ], + "changes": [ + "state.json's unreviewed_count now counts only candidates with >=100k estimated weekly tokens (SIGNIFICANT_WEEKLY_TOKENS), so the badge went from a meaningless 182 to 7 items actually worth attention; the SessionStart one-liner and statusline badge inherit it for free since both read state.json.", + "report --mark-reviewed marks the same significant set, keeping dismiss/appear symmetric.", + "token-savers.zsh adds finish-gate / phone-deploy / autopsy / yay-clean shell functions so the deterministic halves run at zero LLM tokens; Claude enters only on failure triage." + ], + "verification": [ + { + "command": "python3 -m pytest python_pkg/session_autopsy/tests/ -q", + "result": "pass", + "evidence": "81 passed; test_render_and_write_report now asserts unreviewed==1 with a mixed significant/insignificant corpus." + }, + { + "command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.session_autopsy report; jq .unreviewed_count ~/.claude/autopsy/state.json; statusline.sh pipe-test", + "result": "pass", + "evidence": "unreviewed_count 182 -> 7 on the real corpus; statusline renders '[Fable] ~ | ⚙7'." + }, + { + "command": "zsh -c 'source ~/.oh-my-zsh/custom/token-savers.zsh && autopsy measure && yay-clean --dry-run corsix-th-git'", + "result": "pass", + "evidence": "Both functions execute the real scripts from a fresh zsh; shellcheck -s bash clean after adding '; }' brace style." + } + ], + "risks": [ + "The 100k threshold is a judgment constant; a genuinely useful small candidate stays out of the badge (it remains in REPORT.md and candidates --json).", + "Shell functions shadow no existing commands today; a future binary named 'autopsy' or 'phone-deploy' on PATH would be shadowed by these functions in interactive shells." + ], + "rollback": [ + "Revert the three python files to restore all-candidates counting; delete linux_configuration/zsh/token-savers.zsh and the ~/.oh-my-zsh/custom symlink.", + "After rollback, 'autopsy report' stops resolving and state.json returns to counting every candidate." + ] +} diff --git a/linux_configuration/zsh/token-savers.zsh b/linux_configuration/zsh/token-savers.zsh new file mode 100644 index 00000000..525b782d --- /dev/null +++ b/linux_configuration/zsh/token-savers.zsh @@ -0,0 +1,22 @@ +# ============================================================================ +# token-savers.zsh — run the session-autopsy compiled scripts WITHOUT Claude. +# The deterministic 90% of these workflows costs zero LLM tokens from a shell; +# bring Claude in only when a step fails and needs triage. +# Symlinked into ~/.oh-my-zsh/custom/ (house pattern, see claude-tty-fix.zsh). +# ============================================================================ + +# finish-gate [file...] — /finish's deterministic gate on the current repo: +# diff sizing, pre-commit (one autofix retry), tests. Exit 10/20 on failures. +finish-gate() { bash "$HOME/.claude/scripts/finish.sh" gate . "$@"; } + +# phone-deploy [app-dir] [--release] — build + data-preserving install + launch +# + screenshot on the phone. Never uninstalls by construction. +phone-deploy() { bash "$HOME/.claude/scripts/phone_deploy.sh" "${1:-.}" "${@:2}"; } + +# autopsy [subcommand ...] — the transcript analyzer; defaults to `report`. +# Try: autopsy candidates | autopsy measure | autopsy report --mark-reviewed +autopsy() { PYTHONPATH="$HOME/testsAndMisc" python3 -m python_pkg.session_autopsy "${1:-report}" "${@:2}"; } + +# yay-clean [--dry-run] ...|--all — remove yay cache dirs plain rm chokes +# on (read-only fakeroot/Bazel trees). The cache was 314GB on 2026-07-24. +yay-clean() { bash "$HOME/.claude/scripts/yay_cache_clean.sh" "$@"; } diff --git a/python_pkg/session_autopsy/cli.py b/python_pkg/session_autopsy/cli.py index d7880b98..a3bfa964 100644 --- a/python_pkg/session_autopsy/cli.py +++ b/python_pkg/session_autopsy/cli.py @@ -290,7 +290,7 @@ def cmd_report(args: argparse.Namespace) -> int: report.write_report(home, records, result, _now()) if args.mark_reviewed: report.write_state( - home, [cand.id for cand in result.candidates], _now(), mark_reviewed=True + home, report.significant_ids(result.candidates), _now(), mark_reviewed=True ) _emit("report: all candidates marked reviewed") _emit(f"report: {len(result.candidates)} candidates → {home / report.REPORT_FILE}") diff --git a/python_pkg/session_autopsy/report.py b/python_pkg/session_autopsy/report.py index 14ef27d0..2e2325fa 100644 --- a/python_pkg/session_autopsy/report.py +++ b/python_pkg/session_autopsy/report.py @@ -22,6 +22,9 @@ THOUSAND = 1_000 MILLION = 1_000_000 TABLE_TOP = 40 DETAIL_TOP = 25 +# The ⚙ badge / SessionStart nudge count only candidates at least this big — +# nobody reviews 180 weak n-grams, so the count must mean "worth your time". +SIGNIFICANT_WEEKLY_TOKENS = 100_000 def fmt_tokens(count: int) -> str: @@ -40,6 +43,23 @@ def fmt_tokens(count: int) -> str: return str(count) +def significant_ids(candidates: list[Candidate]) -> list[str]: + """Ids of the candidates big enough to surface in the badge. + + Args: + candidates: Ranked candidates. + + Returns: + Ids with at least :data:`SIGNIFICANT_WEEKLY_TOKENS` estimated weekly + tokens at stake, in rank order. + """ + return [ + cand.id + for cand in candidates + if cand.est_weekly_savings >= SIGNIFICANT_WEEKLY_TOKENS + ] + + def load_state(home: Path) -> dict[str, object]: """Read state.json, tolerating absence and corruption. @@ -374,4 +394,4 @@ def write_report( (home / REPORT_FILE).write_text( render_report(records, result, compiled, now), encoding="utf-8" ) - return write_state(home, [cand.id for cand in result.candidates], now) + return write_state(home, significant_ids(result.candidates), now) diff --git a/python_pkg/session_autopsy/tests/test_report.py b/python_pkg/session_autopsy/tests/test_report.py index 79ac861f..2388e96f 100644 --- a/python_pkg/session_autopsy/tests/test_report.py +++ b/python_pkg/session_autopsy/tests/test_report.py @@ -152,7 +152,8 @@ def test_render_and_write_report(tmp_path: Path) -> None: record( "a", obs=Observations( - skill_invocations=[invocation()] * 3, error_signatures={"boom: x": 12} + skill_invocations=[invocation(output=500_000)] * 3, + error_signatures={"boom: x": 12}, ), ), ] @@ -170,7 +171,8 @@ def test_render_and_write_report(tmp_path: Path) -> None: assert "Context-bloat trend" in text assert "Compiled scoreboard" in text unreviewed = report.write_report(tmp_path, records, result, NOW) - assert unreviewed == len(result.candidates) + assert unreviewed == 1 + assert len(result.candidates) > 1 assert (tmp_path / report.REPORT_FILE).is_file()