feat(disk-hygiene): add scan --quiet to drop the duplicated children_rollup - #3783
Conversation
…rollup Every `scan` emitted its full `children_rollup` to stdout, one row per immediate child, with no way to ask for less. The rollup is the only part of the payload that grows with the frontier, and it is already written to the snapshot file on disk, so a caller that only needs the summary paid for a second copy of detail it would never read. Trimming at the call site is not available and should not be: the Bash guard fails closed on pipes, redirects and shell operators, and that rejection stays untouched. So the flag belongs in the engine. `scan --quiet` omits `children_rollup` from stdout and replaces the long closing note with a short one naming where the rows went. Everything a keep-or-review decision rests on survives: `status`, `target`, `snapshot`, the three coverage terms, `empty_directory_count`, both byte totals, `truncated_paths`, `errors`, `policy_sources` and `os_autoclean`. The snapshot file carries the rollup in full in both modes, asserted by test, so the flag drops a duplicate rather than data. Root-children mode emits its own `scan-complete`, so quiet reaches that payload too rather than silently doing nothing exactly where the frontier is widest. The default is unchanged and pinned by test. A caller already parsing `children_rollup` off stdout must not be quietened by an upgrade, so quiet is opt-in and a future flip of that default has to break a test to happen. `destructive_guard.py` admits `--quiet` as a third valueless scan flag, at most one per invocation and with no trailing value, alongside `--confirmed-large-scan` and `--root-children`. It shapes stdout only, so it reaches no path and skips no check the same invocation without it would not already reach. No pipe, redirect or shell-operator allowance is added. Measured on a real `--max-depth 1` home-directory scan: 7,247 bytes to 893 (88 % smaller). On a 200-child target: 67,262 to 5,052 (93 %). Closes #3352 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 2m 57s —— View job Code review —
|
Independent review — fresh context, producer rationale withheld — flipped to readyPosted by the autonomous worker lane ( Reproduced, not read
Both deviations from the brief upheld
One correction to the PR's own reasoningThe body says the search for an existing verbosity convention found zero hits. That premise is overstated: two first-party output-verbosity Non-blocking findings
Gates re-run green in the foreground: Generated by Claude Code |
|
Claude finished @kyle-sexton's task in 1m 52s —— View job Security review complete
No security issues found. This PR touches one security-relevant file,
I did note a pre-existing (not introduced by this PR) parsing quirk in the same function: since valueless flags are stripped by |
|
Last security-reviewed head: |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 819a6ee1fc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…osity # Conflicts: # plugins/disk-hygiene/.claude-plugin/plugin.json # plugins/disk-hygiene/CHANGELOG.md
|
Merge lane claiming this PR at head
Verified after resolving: no conflict markers; One test note, recorded rather than acted on. Generated by Claude Code |
… --quiet Addresses the two P2 review findings on this PR, both verified against the branch code before fixing. 1. `scan_stdout_payload` replaced `note` unconditionally, so `--root-children --quiet` lost the mode's coverage qualification: that the volume root and every skipped OS-owned/hidden/system/reparse entry were never walked, and that `children_rollup` therefore covers the selected children only. Nothing else on stdout encodes that limit. The skipped entries live in the snapshot's `root_children_skipped` alone, and `truncated_paths` does not stand in for them, so the generic quiet note was dropping a fact rather than a duplicate. Root-children mode now has its own quiet note that keeps the coverage sentence and drops only the rollup prose. 2. The root-children stdout payload omitted `empty_directory_count`, which `safety-model.md` documents as part of the quiet field set. A consumer following that contract broke on this mode. The field is present in the snapshot for both modes, so it is now reported on stdout for both. safety-model.md states both, replacing the previous claim that the field set applied uniformly. test_hygiene.py's root-children quiet case asserted `QUIET_SCAN_NOTE`, which pinned the defect. It now pins the corrected contract: the root-children note, that it differs from the ordinary one, that it still names the never-walked entries and `root_children_skipped`, and that `empty_directory_count` is present. Verification: `python -m pytest -q test_hygiene.py` 345 passed, 132 subtests passed. run-ruff.sh check: all checks passed. check-changelog-parity.sh and check-purged-em-dashes.sh both pass. 0.22.0 is unreleased (main is 0.21.9), so the entry is extended rather than bumped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPLatLkg4329L8eyfxhuMa
|
Lane
Finding 1 confirmed. Fixed with a Finding 2 confirmed. The root-children stdout payload did not build One thing worth flagging beyond the two findings. Verification: Threads left open, and why. I cannot resolve them: this session is served only a pinned subset of GraphQL PR-review operations and Generated by Claude Code |
|
Lane What was actually blocking
I verified each against the code at 1. Root-children coverage warning under 2. Replied in each thread with this evidence, then resolved both. Verification
Lane action: advanced to Generated by Claude Code |
…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>
Closes #3352
Summary
scanhad no output-verbosity flag: every run emitted its fullchildren_rollupto stdout, one row per immediate child, whatever the caller needed. The rollup is the only part of the payload that grows with the frontier, and it is already written to the snapshot file on disk, so a caller that only needs the summary paid for a second copy of detail it would never read.Trimming at the call site is not available and should not be. The Bash guard fails closed on pipes, redirects and shell operators, and that rejection is load-bearing: an exception for
| headis an exception for everything spellable as| head. Nothing in this change relaxes it. The fix is in the engine.Fix
scan --quietomitschildren_rollupfrom stdout and replaces the long closing note with a short one naming where the rows went. Everything a keep-or-review decision rests on survives:status,target,snapshot,entries,hinted_entries,unhinted_entries,empty_directory_count,target_logical_bytes,target_reclaimable_local_bytes,truncated_paths,errors,policy_sources,os_autoclean.Three calls worth flagging for review:
The default is unchanged, and that is deliberate. Quiet is opt-in. An existing consumer parsing
children_rollupoff stdout must not be quietened by an upgrade, and a verbosity flag is not worth a silent breaking change to the payload every caller already reads.test_default_scan_still_carries_the_rollup_and_the_explaining_notepins it, so a future flip of that default has to break a test to happen.snapshotis kept in quiet output, though the issue's field list does not name it. The whole argument for the flag is "read per-child detail from the snapshot instead"; a quiet payload that does not say where the snapshot is cannot be acted on. This is the one deviation from the literal acceptance checkbox.Root-children mode is covered too. That mode emits its own
scan-completepayload with its own rollup. A flag that shapes one of the two and silently does nothing in the other is a trap for the caller who reaches for it exactly where the frontier is widest, so both go through the same shaping helper.destructive_guard.pyadmits--quietas a third valueless scan flag alongside--confirmed-large-scanand--root-children: at most one per invocation, no trailing value, everything else still fails closed. It shapes stdout only, so it reaches no path and skips no check the same invocation without it would not already reach. No pipe, redirect or shell-operator allowance is added anywhere.handoff-verifyis deliberately left alone. The issue notes its note block is re-emitted on every single-path call. That note is a deletion-safety warning ("verify ONE path per deletion... re-verify after any delay"), it is roughly 400 bytes against the rollup's thousands, and its repetition is the point of it. The 17x multiplication comes from the per-path round-trip, which is the separate--pathissue. Suppressing a safety warning to save 400 bytes is the wrong trade, so it is not made here.Verification
The issue's ~63,000-token premise does not reproduce at that magnitude on this host, and the real numbers are below. The reported figure was an estimate from output size on a user home directory, not a token count; it corresponds to a home directory with roughly 110 immediate children. The reduction ratio holds regardless of where on that curve a target sits.
Real
--max-depth 1scans, default vs--quiet, stdout bytes with a ~4-bytes-per-token estimate:/root(real home dir)/home/user(real)So on this container the audited seven-scan run would have cost ~12,700 tokens rather than ~63,000; on a home directory wide enough to produce the reported ~9,000 tokens per scan,
--quiettakes the same seven scans to roughly 5,000 tokens total. The saving grows with the frontier because the rollup is the only per-child term: quiet's own per-child growth is onetruncated_pathsstring, an order of magnitude flatter, whichtest_quiet_stdout_stops_growing_with_the_child_countpins.Default output byte-identical to
origin/main, checked empirically by loading both engine revisions against one fixture: same parsed payload, same 5,751-byte stdout, and the only key--quietremoves ischildren_rollup. (Two note string literals were re-wrapped to fit the added indentation; they concatenate to the same text, which is what this check proves.)Commands run, all in the foreground:
scripts/affected-tests.sh --run— exit 3, the success code: 151 shell suites passed or skipped, 11 Python/mjs suites selected for their own lanes.python3 -m unittest test_hygiene test_guard_launch_monitorinplugins/disk-hygiene/skills/clean/scripts— 368 tests, OK.python3 -m unittest test_hook_telemetryinplugins/disk-hygiene/lib— 3 tests, OK.scripts/run-ruff.sh check plugins/disk-hygiene— All checks passed.scripts/run-ruff.sh format --check .in the clean scripts directory — the touched test file is clean;hygiene.pyreports one pre-existing blank-line finding that is present unchanged onorigin/mainand is not touched here.scripts/check-changelog-parity.shin all four modes (--check,--check-order,--check-bump origin/main,--check-preserved origin/main) — exit 0 each.scripts/check-purged-em-dashes.sh— 105 files scanned, no em dashes.New coverage: seven tests. Default pinned; quiet's field set proved to be the default's minus exactly
children_rollupwith every surviving value equal; the snapshot proved to carry the rollup in both modes (a quiet run that lost it would be data loss wearing a verbosity flag); root-children mode's quiet path; the growth ratio; the shaping helper's non-mutation of its input; and the guard's grammar, which allows--quietonce in every position and still denies--quiet --quiet,--quiet v,--quiet=1and-q.Pre-existing and unrelated:
test_save_point.py::test_new_origin_falls_back_to_directory_namefails identically on pristineorigin/main.Related
0.21.8to0.22.0, with the matching CHANGELOG entry🤖 Generated with Claude Code
https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Generated by Claude Code