Skip to content

feat(repo-hygiene): capture every branch tip and require it before any deletion batch - #3900

Merged
kyle-sexton merged 6 commits into
mainfrom
claude/3853-branch-tip-capture
Sep 7, 2026
Merged

kyle-sexton merged 6 commits into
mainfrom
claude/3853-branch-tip-capture

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #3853

Summary

Makes recovery from a wrong branch deletion mechanical. git-branch-audit.sh now reports every branch's tip commit as a structured Tip: field and writes all tips to a durable capture file; a new git-branch-delete.sh becomes the git tier's only sanctioned deletion path and refuses to delete anything unless that capture exists, covers every branch in the batch, and still matches each branch's live tip. The value is in the ordering: the tip is recorded, pinned, and ledgered before the destructive step, the destructive step itself is an atomic compare-and-delete on the recorded tip, and the test restores deleted branches from the capture alone after a gc --prune=now.

Fix

Where the capture lives and why it survives. <git-common-dir>/repo-hygiene/branch-tips/<utc-stamp>-<pid>.tsv, i.e. the main checkout's .git/, even when the audit ran in a linked worktree (--capture-file PATH overrides). .git/ is untouched by tree resets and by linked-worktree removal, and is findable by someone who knows only the repository. The path convention is documented in context/git-branch-cleanup.md (§4.2 and the new §4.8) so recovery needs no memory of how the run was invoked.

What is captured. Per branch: name, tip SHA, tier, PR state, upstream, ahead, behind, commits not on origin/<default>, and a UTC timestamp; header lines name the repo, its common dir, the default branch, and the restore command. Rows are written for every branch regardless of verdict, since a verdict can be wrong in either direction. Rows are recognised by shape on both sides (nine tab-separated columns, a commit id in the second, the run's stamp in the last), never by a leading #: # is a legal first character of a branch name, and a comment heuristic made such a branch count one row short at the seal, failing the whole audit with TipCaptureError: short write and leaving no branch in that repository deletable.

Enforcement, and what happens when capture fails (fail closed).

  • Audit side: the capture is written to a .part file, created exclusively (noclobber) so a <stamp>-<pid> collision across PID namespaces sharing a mount, or an interrupted run, is refused rather than interleaved into one file (the foreign .part is left alone). It is renamed into place only after a shape-based row count on disk equals the rows produced. Any failure (unresolvable common dir, unwritable directory, existing .part, short write, failed rename) prints TipCaptureError: <why> and no TipCapture: path. A branch whose tip cannot be resolved gets Tip: unresolved and no row.
  • Delete side: git-branch-delete.sh --capture PATH BRANCH... runs a batch-wide precondition before the first deletion: capture present, readable, correct header, taken in this repository (compared by common dir), every branch has a row, every captured tip equals the live tip, a SAFE-by-ancestry tip is actually merged into origin/<default> (the check git branch -d used to make, now made before anything is written), and no branch is current/default/protected-pattern/worktree-checked-out; REVIEW needs --force-review, PROTECTED and WORKTREE are never deletable. Any refusal prints Refused: <branch> (<what is missing and how to produce it>), Summary: planned=0 refused=N deleted=0, exit 3, and nothing is deleted, including the branches that would have passed.

Per-branch vs per-batch, and partial failure. Preconditions are per batch (all or nothing). In --apply, each branch is then processed in order: re-verify the live tip, git update-ref refs/repo-hygiene/deleted/<branch> <tip> (the pin: git-prune.sh --apply runs gc right after deletion and gc prunes unreachable objects past gc.pruneExpire, so a SHA in a file alone is not a recoverable capture), append to the ledger (<capture>.deleted.tsv, beside the capture's real file with symlinks resolved), and only then delete with git update-ref -d refs/heads/<branch> <tip>. That delete is a compare-and-delete inside git's ref lock: it refuses (cannot lock ref ... is at X but expected Y) when the tip is no longer the captured one. The per-branch re-check closes the window between the batch check and the pin; the conditional delete closes the window between the pin and the delete, which a plain git branch -D left open (a tip moved there was deleted and its commit lost to the next prune). A failure in any step before the delete prints Aborted: <branch> (<why>) and stops: branches already deleted keep their pin and ledger row, the failing branch and everything after it are untouched, Summary: counts deleted/failed/aborted/untouched, exit 1. A delete refused because the tip moved also aborts: the branch stays at its new tip, its pin stays (harmless, and it records the tip the audit saw), and the ledger gains a # not deleted: note. Safe delete for SAFE by ancestry; force delete for SAFE with a merged PR (squash), LIKELY-SAFE, and forced REVIEW; the delete mechanism is the same atomic form in every mode. Every Deleted: line and the closing Restore: line carry git branch <branch> <tip>.

Ledger path choice. The ledger follows the capture's real location rather than refusing a capture outside the sink, because --capture-file to a writable location is the documented fallback when the sink itself is unwritable; refusing it would remove the only recovery path from a TipCaptureError. The pin, not the ledger, is what protects the tip.

Docs. SKILL.md §4.2 states the precondition; context/git-branch-cleanup.md §4.7 routes deletion through the script, documents the atomic delete, the ledger and the pins, and adds §4.8 (restore steps); context/action-router.md names the script in the git row. The delete script is deliberately not in allowed-tools (mutating; the pairing test's allowlist is unchanged and passes). The session guard does not match git branch -D (that is #3852), so "only through the script" is enforced by the script's preconditions plus the skill text, not by a hook.

Interaction with sibling #3854 (not implemented here). A lossy-but-deletable verdict block would sit at the confirmation gate, immediately before git-branch-delete.sh --apply; the delete script's tier gate (SAFE/LIKELY-SAFE free, REVIEW behind --force-review) is where a new tier would need to be admitted. The two changes touch adjacent but distinct places.

Version. 0.10.35. Main is 0.10.33; open PR #3887 (claude/3850-repo-hygiene) takes 0.10.34. Re-checked against current main and every open head (61 refs) immediately before the latest push: only this branch carries 0.10.35. git merge-tree against #3887 shows textual conflicts in four files: CHANGELOG.md (adjacent new entries), plugin.json (version line), git-branch-audit.sh (header comment block, and the setup block after CURRENT_BRANCH where #3887 adds its clean_pr_map block and this PR adds the capture setup), and git-branch-cleanup.md (the output-contract line). All are additive on both sides. Merge against current main is clean.

Verification

Ran in this container (Linux, git 2.43, ShellCheck 0.11.0, shfmt 3.12.0):

  • git-branch-delete.test.sh (new, 106 cases) and git-branch-audit.test.sh (extended, +25 cases): pass. The delete suite demonstrates (a) a dry-run plans and deletes nothing and an apply deletes four branches, one named #42-hash, with ledger rows and pins; (b) no --capture, unreadable capture, bad header, foreign-repo capture, missing row, moved tip, forged SAFE row on an unmerged branch, mixed batch with one REVIEW branch, protected/current/worktree: each exits 3 with every branch still present, including the deletable siblings; backup-ref failure mid-batch aborts with the blocked branch and the following one untouched; an unwritable ledger aborts before anything is deleted; a symlinked capture puts the ledger beside the real file; (c) after gc --prune=now, each deleted branch is recreated with git branch <name> <tip> using only the name-to-tip lookup in the capture file, and its content is intact; a control shows the same prune destroys an unpinned tip; (d) both tip-move windows, opened deterministically: a FIFO at the ledger path parks the script, with no reader present, at the ledger append that sits after the pin and before the delete, and the pin appearing is the signal to move the tip. Window B moves the parked branch's own tip (between pin and delete): the delete refuses, the branch stays at the moved tip, the pin records the captured tip, the ledger carries the # not deleted: note, and the moved commit survives reflog expire plus gc --prune=now. Window A parks on a first branch and moves the second branch's tip (after the batch check, before its re-check): the first branch is deleted, the second aborts at the re-check with no pin and no ledger row.
  • Mutation battery on a scratch copy (each mutation applied alone, suites re-run): remove the apply-time re-check: 3 failures, all window A (stale pin written, ledger row written, wrong abort step); revert the delete to git branch -D: 7 failures, all window B, including the branch gone and the moved commit pruned, which reproduces the loss the review demonstrated; restore the grep -c -v '^#' seal and the comment-skipping parser: every capture case in both suites fails with TipCaptureError, which is the bricking the review reported. Earlier battery on the same tree (drop tip-moved refusal 6, drop the pin 9, drop missing-row refusal 1, skip refused branches 17, ignore ledger write failure 1, audit stops printing Tip: 3, audit hides TipCaptureError: 2, audit writes no rows 9) unchanged. No surviving mutation.
  • scripts/affected-tests.sh --run in four shards, foreground: shards 0/4, 2/4, 3/4 exit 3 (all shell suites passed; the NOT RUN entries are other ecosystems). Shard 1/4 exit 1 solely from plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh (2/24 process-budget cases), reproduced identically on a clean git archive origin/main export, so pre-existing. The audit suite ran in shard 1, the delete suite in shard 2, allowed-tools-pairing.test.sh in shard 1; all passed.
  • shellcheck -x and shfmt -d clean on the four changed shell files. check-purged-em-dashes.sh --check clean; no em dashes in added lines (byte check on the diff).
  • check-changelog-parity.sh: --check, --check-order, --check-bump origin/main, --check-preserved origin/main all exit 0.
  • Exec bit: git-branch-delete.test.sh was committed 100644 and failed the CI lint job's exec-bit gate on 0e701107a; it is 100755 from f6f87d91e on (git ls-tree confirmed). CI on the current head (ae18b8433): lint, test-linux (0..3), hook-utils, changes, managed-files-guard, GitGuardian and ci-status all success; test-windows and the review lanes skipped (draft).

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

@kyle-sexton
kyle-sexton marked this pull request as ready for review September 7, 2026 03:36
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 6m 12s —— View job


Security review complete

  • Gather context (PR body, changed files)
  • Run /review:security-review per custom instructions
  • Post findings

Reviewed the diff introducing git-branch-delete.sh and the tip-capture changes to git-branch-audit.sh against origin/main (gh pr diff). This lane covers only security-relevant findings (logic/trust-boundary), not style or general code quality.

Two IMPORTANT findings, both in the capture-trust model that gates deletion — posted as inline comments with suggested fixes:

  1. git-branch-delete.sh#L203-215delete_mode() treats a SAFE row as a force delete whenever the captured pr column merely contains the substring MERGED, which skips the merge-ancestry check at L291-295. Both tier and pr are read verbatim from the capture TSV with no re-verification against gh/git at delete time, so a forged or hand-edited capture (SAFE + a pr field containing MERGED) bypasses the only remaining safety check for SAFE-tier deletions — a narrower case than the "forged SAFE row" test already in git-branch-delete.test.sh, which only forges the tier column and so never exercises this path.
  2. git-branch-delete.sh#L186-189 — the "capture was taken in a different repository" check is skipped whenever the capture's # common_dir: line reads the literal unknown. That value is trusted verbatim from the file with no independent recomputation, so any capture carrying it (hand-edited, copied from another repo, or produced when clean_git_common_dir fails to resolve — git-branch-audit.sh#L87, #L127) — disables the sole guard against applying one repository's tip capture to another.

Everything else in the new deletion path held up under adversarial reading: branch names can't carry the tab/newline/#-leading-comment characters that would let a crafted branch name corrupt the TSV parsing (git ref names disallow control characters), the .part/noclobber exclusive-create plus shape-based row count correctly prevents two audits from interleaving into one capture, and the atomic update-ref -d <ref> <expected-tip> genuinely closes the pin-to-delete race the PR targets (verified by re-reading the two-window design, not just by trusting the PR's own mutation-testing narrative).

Not re-reported here (out of this lane's scope): GitHub Actions hardening (zizmor's lane) — not applicable, no workflow files changed.

Branch

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 4s —— View job


Claude finished the code review

  • Gather context (PR body, changed files)
  • Invoke /review:code-review and follow its instructions
  • Post findings as inline comments (none warranted — see below)
  • Post summary comment

Summary

Reviewed the diff (origin/main...HEAD, 10 files, +1189/-25) against plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh, the new git-branch-delete.sh, both test files, lib/clean-common.sh, and the doc updates (SKILL.md, action-router.md, git-branch-cleanup.md). Security-scoped findings are left to the dedicated claude-security-review lane per this repo's REVIEW.md split, since that lane exists here.

No blocking findings. I traced the two windows this PR closes end to end:

  • Batch precondition → per-branch re-check → pin → ledger → atomic delete ordering in git-branch-delete.sh (plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh lines 236–297 for the batch/per-branch gates, 840–882 in the pre-diff apply loop) is internally consistent: the re-check at apply time (step 1) closes the window between the batch check and the pin, and the compare-and-delete git update-ref -d refs/heads/<branch> <tip> (step 4) closes the window between the pin and the delete. I didn't find a path where a moved tip can slip through both checks.
  • The [[ $REFUSED -gt 0 && $CAP_ROWS -eq 0 ]] && continue short-circuit (line 261) only suppresses per-branch capture-tip messages when the capture itself failed to parse (bad header / unreadable / no rows) — when the capture parses but a batch-level refusal fires for another reason (e.g. foreign-repo capture), each branch still runs its own capture-tip check. Exit code is 3 either way and nothing is deleted, so this isn't a correctness gap, just an intentional dedup of the refusal output.
  • Row-shape recognition (9 tab-separated columns, commit id in column 2) instead of a #-comment heuristic is applied consistently on both the audit's seal (git-branch-audit.sh lines 361–370) and the delete script's parser (lines 156–172 pre-diff), so a branch named #42-hash round-trips correctly — confirmed by reading both sides rather than trusting the PR description.
  • clean_git_common_dir (new in lib/clean-common.sh) and the ledger's real-path resolution (resolve_file in git-branch-delete.sh) correctly separate "where the capture lives" from "where a --capture-file override or symlink points," matching the documented fallback semantics.
  • Doc/code parity checked directly: SKILL.md §4.2 and context/git-branch-cleanup.md §4.7/§4.8 use the actual flag names (--capture, --dry-run, --apply, --force-review) and paths the script implements, and git-branch-delete.sh is correctly absent from SKILL.md's allowed-tools (verified by reading the frontmatter, not just the claim).
  • No stale git branch -d/-D references were left behind outside the two spots that describe the old rationale for context.

I did not independently re-run the shell test suites or scripts/affected-tests.sh in this pass — the PR body's verification section is author-claimed and unverified by me; treat it as such rather than as independently confirmed here.

Comment thread plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh
Comment thread plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: ae18b8433029cecedfcb5bb52c44bdf9ada7fd81. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

claude and others added 5 commits September 7, 2026 10:43
…y deletion batch

git-branch-audit.sh reports a Tip: field for every branch and writes the tips,
with tier, PR, upstream, ahead/behind and a timestamp, to a sealed capture under
the main checkout's .git/repo-hygiene/branch-tips/, printed as TipCapture:.
git-branch-delete.sh is the only sanctioned deletion path: it refuses the whole
batch without a capture, a captured tip per branch, and an unmoved tip; in
--apply it pins each tip under refs/repo-hygiene/deleted/<branch>, appends the
deletion to a ledger, and only then deletes. The test restores deleted branches
from the capture alone after gc --prune=now.

Closes #3853

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Replaces the `A && pass || fail` chains (SC2015) with assert_branch and
assert_eq helpers so a failing pass() can never fall through to fail().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
The suite was committed 100644 while every sibling test is 100755, which
fails the exec-bit lint gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…and the capture format shape-based

The delete step is now `git update-ref -d refs/heads/<branch> <tip>`, a
compare-and-delete inside git's ref lock. The per-branch re-check closes
the window between the batch check and the pin; the conditional delete
closes the window between the pin and the delete, which `git branch -D`
left open: a tip moved there was deleted and its commit lost to gc. A
refused delete aborts the batch with the branch and its pin intact and a
`# not deleted:` note in the ledger. The merged check `git branch -d`
used to make is now a batch-level refusal for SAFE-by-ancestry rows, so
a forged SAFE row no longer yields a ledger row for a branch still
present.

Capture rows are recognised by shape (nine columns, a commit id second)
on both sides instead of by a leading `#`, which is a legal first
character of a branch name and made the seal count one row short, failing
the whole audit. The `.part` is created with noclobber so a stamp-pid
collision cannot interleave two runs, and a foreign `.part` is left
alone. The ledger is derived from the capture's real path, so a
symlinked capture no longer puts it beside the link.

Tests open both tip-move windows deterministically: a FIFO at the ledger
path parks the script between pin and delete, and the pin appearing is
the signal to move the tip. Removing the re-check fails the window-A
cases; reverting to `git branch -D` fails the window-B cases including
the moved commit being pruned; restoring the comment-based seal fails
every capture case with TipCaptureError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…_dir

delete_mode() treated a SAFE row as a force delete whenever the captured pr
column contained the substring MERGED, which skipped the ancestry check.
Both tier and pr are untrusted capture text, so require the audit's exact
format (`#<n> MERGED`, optionally ` (tip drift)`). A test forges pr to
UNMERGED (contains MERGED, not the format) and expects the ancestry refusal.

An unresolved capture common_dir (missing or the literal unknown) skipped
the only cross-repository check. Treat it as a refusal, matching the audit's
fail-closed posture when it cannot resolve one.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@cursor
cursor Bot force-pushed the claude/3853-branch-tip-capture branch from ae18b84 to 5a8093b Compare September 7, 2026 10:47
markdownlint MD038 rejects spaces inside code spans. Spell the optional
suffix as `#<n> MERGED (tip drift)` instead of a code span that starts
with a space.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@kyle-sexton
kyle-sexton merged commit c62f87b into main Sep 7, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude/3853-branch-tip-capture branch September 7, 2026 13:33
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…ing them when no telemetry sink is set (#3913)

Closes #3862

## Summary

Guard decisions left the process only through `HOOK_TELEMETRY_SINK`,
which is inert unless an environment variable names an executable. On an
ordinary install every decision was discarded as it was made, so "why
was this denied", "has it denied this all along", and "did the guard run
at all" had no evidence to answer from.

New `plugins/disk-hygiene/lib/guard_decision_log.py` appends one JSON
object per line to
`<CLAUDE_PLUGIN_DATA>/guard-decisions/decisions.jsonl`, default on, no
configuration. `destructive_guard.py` records every branch that reaches
a verdict; `guard_launch_monitor.py` records the did-not-run state the
guard structurally cannot write about itself.

Version taken: **0.23.0**. Verified free against current `main`
(`6db96637d`, 0.21.9) and against the head of every open PR at the
moment of opening: #3880 claims 0.21.10, #3783 claims 0.22.0, and
#3851/#3887/#3900/#3779 leave the manifest at 0.21.9. 0.23.0 is strictly
greater than all of them, so it cannot collide under any merge order;
the 0.22.x gap is what `--check-order` explicitly reads as correctly
ordered.

## Fix

**Where the record lives, and why it survives.** Under the plugin's own
persistent data root, resolved by the guard's existing
`resolve_authorized_data_root()` (the `--authorized-data-root` /
`--plugin-root` / `CLAUDE_PLUGIN_DATA` ladder) beside the run records
already kept there. Because this plugin is the one that deletes things,
the filename was checked against the engine's own discovery hints in
`reference/baseline-policy.json`: `decisions.jsonl` and
`decisions.previous.jsonl` match none of the 14 bundled name globs
(`*.tmp`, `tmp-*`, `tmp_*`, `scratch*`, `*.lock`, `__pycache__`,
`*.partial`, `*.crdownload`, `*.tmp.*`, `.claude.json.tmp.*`,
`temp_git_*`, `.pulumi-write-test-*`, `.DS_Store`, `Thumbs.db`), so the
plugin's own hints never nominate its audit trail. Appending directly
rather than writing a temp file and renaming is deliberate for the same
reason: an atomic-write staging name would land on `*.tmp.*`, which is a
bundled hint.

**What is recorded.** `schema_version`, `timestamp` (UTC, milliseconds),
`hook`, `decision`, `rule`, `tool`, `mode`, `command`, `reason`.
`decision` is `allow` / `ask` / `deny` / `none` (ran, issued no
`permissionDecision`) / `not-run`. `rule` names the branch that fired,
so `kill-switch-disabled-apply` is distinguishable from
`not-exact-engine-command`: two different answers to "why". `command` is
the input that drove it and `reason` is the exact text the host was
given, so the record and the host cannot disagree. Both are clipped to
400 characters, which keeps it a record of the decision rather than a
copy of the payload and keeps every line short enough that concurrent
hook processes appending to the same file do not interleave.

**Bounded, enforced.** The live file rotates to
`decisions.previous.jsonl` at 1 MiB via `os.replace`, so the record
occupies at most about 2 MiB forever with no operator pruning. The bound
is checked from the offset the append already returns (`handle.tell()`
in append mode), so enforcing it costs no extra syscall.

**Cost.** Measured with `strace -f -e
trace=clone,clone3,fork,vfork,execve,openat,write` against a detached
worktree of `origin/main` at `6db96637d`, five invocations per arm plus
a warm-path detail run:

| Path | Before | After |
| --- | --- | --- |
| defer (a Bash command not naming the engine, the always-on branch) | 1
`execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 0 record syscalls |
| decision (deny), warm data root | 1 `execve`, 1 `clone3` | 1 `execve`,
1 `clone3`, 1 `openat` + 1 `write` |
| decision (deny), first write of an install | 1 `execve`, 1 `clone3` |
plus 1 failed `openat` and 1 `mkdir` |

The single `clone3` is `CLONE_THREAD`, the existing watchdog thread, not
a process. **The process and exec census is unchanged on every path.**
The plugin-level defer branch, which is what this always-on hook takes
for work unrelated to disk-hygiene, writes nothing at all and is
byte-for-byte the path it was. Wall clock over 40 invocations per arm,
alternated twice, moved inside run-to-run noise on this host (52 to 58
ms both before and after, the sign of the difference changing between
repetitions), which is why the syscall census rather than a duration is
the figure cited.

**Failure behavior: the verdict never changes.** Two boundaries, both
load-bearing. `guard_decision_log.record` returns a bool and catches
`BaseException` around the whole write. `_record_decision` in the guard
wraps its own call, because the data root and mode are resolved in the
argument list, outside `record`'s protection, and one of its call sites
is `main`'s own `except BaseException` handler, where a raise would
reach the interpreter's default handler: exit 1, which PreToolUse treats
as non-blocking, so the command the guard just denied would run. Every
record call is made after the verdict has been emitted, and its result
is discarded.

**What is deliberately not recorded.** The plugin-level defer (hot path,
and not a decision anyone reconstructs later). The watchdog expiry path:
that callback runs while the main thread is presumed wedged inside a
filesystem call and stays syscall-free for exactly that reason, so a
write there could hang on the same filesystem. Both are stated in the
README rather than left implicit.

**Adjacency, stayed out of.** #3861 (the guard's fail-open when no
interpreter resolves) is `needs-human`. This change touches the guard's
decision branches but not interpreter resolution, and adds no new
fail-open path; the `not-run` record makes the fail-open class more
visible after the fact without adjudicating it.

**Escape hatch.** `DISK_HYGIENE_GUARD_DECISION_LOG` set to `0` / `off` /
`false` / `no` turns the record off. Opt-out, not opt-in: any other
value, including an absent one, records.

## Verification

All runs local and in the foreground; draft CI is not cited as test
evidence.

- `bash scripts/affected-tests.sh --run --shard N/4`: shards 0, 1, 2
exit **3** (success, with `NOT RUN` non-shell ecosystems), 0 `FAIL`
lines each. Shard 3 exits 1 with exactly three `FAIL` lines, all from
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(`process budget: the trace probe actually counted something`, `process
budget: a one-install report costs at most 26 process creations`).
**Reproduced unchanged on a clean detached worktree of `origin/main` at
`6db96637d`**: same suite, same 2 cases, exit 1. Pre-existing, not this
change. Every changed file maps to at least one suite; the three
docs/manifest files resolve through the recorded no-suite allowlist.
- Direct suites: `hygiene.test.sh` RC=0 (350 cases),
`guard_launch_monitor.test.sh` RC=0 (28 cases),
`run-python-hook.test.sh` RC=0, `test_guard_decision_log.py` +
`test_hook_telemetry.py` RC=0 (15 new cases).
- All four parity modes green: `--check`, `--check-order`, `--check-bump
origin/main`, `--check-preserved origin/main`.
- `scripts/run-ruff.sh check plugins/disk-hygiene`: all checks passed.
`format --check`: my four touched/new Python files are clean. Five files
remain unformatted in this plugin (`killswitch_config.py`, `hygiene.py`,
`guard_launch_monitor.py:137`, `test_hygiene.py:2373`,
`test_kill_switch_probe.py:96`); all five are identically unformatted on
`origin/main`, so none is introduced here.
- Gates run clean: `check-purged-em-dashes.sh`,
`check-drive-root-litter.sh`, `check-silent-skips.sh`,
`check-discriminating-test-skips.sh`, `check-fixture-git-isolation.sh`,
`check-hook-exec-form.sh`, `check-killswitch-hoist.sh`, all RC=0. New
test file committed `100755` (verified with `git ls-tree`), the new
library `100644` matching its sibling `hook_telemetry.py`.
- No shell files changed, so shellcheck and shfmt have nothing to say
about this diff. `lib/hook-utils.sh` untouched.

**Mutation proof (the new assertions discriminate).** Nine mutations
applied one group at a time, each reverted:

| Mutation | Caught by |
| --- | --- |
| rotation call disabled | 3 lib cases (`rotates_at_the_bound`,
`discards_only_the_generation_before_last`, `a_failing_rotation...`) |
| `_clip` returns text unchanged |
`long_command_and_reason_are_truncated` |
| `enabled()` hardcoded True | 5 lib subtests +
`the_record_can_be_turned_off_without_changing_a_verdict` |
| deny rule string collapsed onto the kill-switch rule |
`denied_engine_command_is_recorded_with_its_rule_and_input` |
| a record added on the defer path | `engine_gate_defer_records_nothing`
|
| `_record_decision`'s try/except removed |
`a_broken_decision_record_never_changes_a_verdict` (record raises),
`record_decision_swallows_a_failure_in_data_root_resolution`, and the
**pre-existing**
`every_call_graph_function_failure_denies_at_exit_2_never_1` for both
`resolve_mode` and `resolve_authorized_data_root` |
| `_record_not_run` call removed from the monitor | 3 monitor cases |

The write-failure proof is
`test_a_broken_decision_record_never_changes_a_verdict`, which drives
all five verdict shapes (`allow`, `ask`, `deny`-by-authority,
`deny`-by-kill-switch, and the no-output defer) three times:
unsabotaged, against a data root whose parent is a regular file (a real
filesystem `OSError` on both the append and the `mkdir` behind it), and
with `record` raising `RuntimeError`. All three runs produce the
identical verdict list, and the unwritable root is asserted to still not
exist afterwards.
`test_a_write_failure_leaves_the_deny_exit_status_untouched` pins the
same thing end to end through `main`.

**Hermeticity fix included.** `run_guard_engine_gate` previously passed
`SCRIPT_DIR / "data-root"` as the authorized data root. With records
being written, that would have littered the checkout on every test run,
so it now takes a per-test temp path, and `GuardTests.setUp` pops an
inherited `CLAUDE_PLUGIN_DATA` so a developer's real plugin data
directory is never written to by the suite.

## Related

- Closes #3862; parent #3347 finding F12.
- Sibling #3861 (guard fail-open when no interpreter resolves) is
`needs-human` and deliberately untouched; the `not-run` record is what
makes that class visible after the fact.
- Sibling finding on a false denial: `rule` plus `command` plus `reason`
is what answers it from the record instead of by reproduction.
- Hook budget convention: `docs/conventions/hook-budget/README.md`,
`.claude/rules/hook-budget.md`. Measured share stated in the plugin
README's trust-surface record.
- Version contention checked against open PRs #3783 (0.22.0) and #3880
(0.21.10).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…accept-loss gate (#3918)

Closes #3854

## Summary

`repo-hygiene:clean` classified every branch as safe, needing review, or
protected, with no way to say "this loses commits, and that is
acceptable
here". Operators either lost work or were blocked. This adds a LOSSY
tier:
a branch that is deletable but carries unmerged commits, surfaced as its
own
block before the deletion confirmation rather than folded into the safe
list,
and gated behind an explicit `--accept-loss` acknowledgement.

## Fix

Rebased onto origin/main after #3900 merged. The overlapping tip-capture
commits were dropped; the LOSSY commits replayed on top of the merged
delete
path, and `git-branch-delete.sh` now admits LOSSY under `--accept-loss`.

- `git-branch-audit.sh` refines a REVIEW verdict into LOSSY when the tip
resolved, `origin/<default>` is present, the count of commits on no
remote
  ref and no tag succeeded and is positive, and the PR state is neither
MERGED nor OPEN. Every missing or failed signal yields `Loss:
undetermined`
and REVIEW; SAFE and LIKELY-SAFE are untouched. The LOSSY set is printed
  again as its own LossBlock, and Summary gains `lossy=`.
- `git-branch-delete.sh` admits LOSSY only under `--accept-loss`; a
batch that
carries one without it is refused whole. `--force-review` does not admit
LOSSY and `--accept-loss` does not admit REVIEW. A LOSSY apply pins the
captured tip, ledgers the tier, compare-and-deletes, and restores after
  `gc --prune=now`.
- SKILL.md §4.2 and `context/git-branch-cleanup.md` state the tier's
checkable boundary and that the LossBlock is its own section before the
  deletion confirmation, with its own affirmative answer. Plugin version
  `0.10.36` (above main `0.10.35`).

Ambiguous loss is REVIEW, not LOSSY: the block sits immediately before
the
deletion confirmation, so misclassifying an unrecoverable branch as
lossy-but-deletable is the failure that matters. That is stricter than
"not SAFE"; it also refuses the deletable tier when the loss cannot be
measured.

## Verification

- `plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh`
(115 cases)
- `plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh`
(147 cases),
including LOSSY apply through the sanctioned path and the near-miss
batch
  (SAFE + LOSSY on the SAFE confirmation refuses the whole batch)
- `scripts/affected-tests.sh --run` (shell suites covering the diff; the
one
  `block-hook-bypass` FAIL is the suite's symlink case when the worktree
  itself lives under `/tmp`, reproduced on origin/main, not this change)
- all four `check-changelog-parity.sh` modes vs origin/main
- `shellcheck -x` and `shfmt -d` on the changed scripts
- mutation: dropping LOSSY admission, admitting LOSSY without
`--accept-loss`,
and treating a missing `origin/<default>` as LOSSY are all killed by the
  new tests

## Remaining work

None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Kyle Sexton <ksextonclaude@outlook.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

repo-hygiene:clean: emit each branch tip and require tip capture before any deletion batch

3 participants