Imported from mneves75/devtrim (
AGENTS.md). Install upstream withnpx skills add mneves75/devtrim. Copyright stays with the author.
AGENTS.md — devtrim
Rust CLI (edition 2024, MSRV 1.88, pinned release toolchain 1.98.1). Sync code, no async runtime, minimal dependencies. Ratatui 0.30 uses Crossterm 0.29 with default features disabled; do not enable its optional layout cache without a new audit.
Start and ownership
Read MEMORY.md and the current memory/ journal, then README.md.
src/app.rs owns CLI dispatch; src/ops/ owns category scan/apply;
src/safety.rs owns deletion validation; src/journal.rs owns audit records.
src/tui.rs, src/analyze.rs, and src/status.rs own the terminal surfaces.
Read SECURITY.md before changing an authority boundary and
CODING_STANDARDS.md before reviewing. The user's task is the specification
unless they name another source; pin the starting commit for change reviews.
Record existing edits before writing. Assign independent agents disjoint files;
the primary agent integrates and verifies. Stay in this checkout unless an
isolated worktree is authorized. Give an authorized worktree its own
CARGO_TARGET_DIR, test HOME, benchmark corpus, and evidence directory.
Never run cleanup against the developer's real HOME to test a change.
Agent execution
For Fable 5.1 and GPT-6 Astra, specify the outcome, preserved boundaries, verification, and stopping condition. Batch independent reads, edit targeted hunks, and keep useful local work moving during independent reviews. Run the smallest meaningful check first; widen for changed safety boundaries and final delivery. A failed gate needs a diagnosis, not repeated unchanged runs. Preserve the baseline, ownership, decisions, evidence paths, and remaining work across compaction. Model/effort selection belongs to the harness, not this repo; verify availability before claiming a model ran.
These procedures follow the Fable 5.1 guide and Astra guide, checked 2026-09-04. User instructions take precedence over skill procedures.
Commands
- Rust-focused local checks:
bash scripts/verify.sh focused; broader cached/offline checks:bash scripts/verify.sh offline. Neither replaces fresh audits, TruffleHog, fuzz, video, or review gates. - Real terminal smoke:
python3 scripts/tests/tui.py target/debug/devtrim(isolated HOME/PATH, explicit terminal dimensions, visible-content assertions). - Feature evals:
rustup run 1.98.1 cargo test --locked --test evals --test eval_system --test eval_reports --test eval_safety(part of the test gate; every command against planted fixtures through the real binary). - Native Docker (opt-in, needs Apple
container, the Docker CLI with buildx, and network):scripts/tests/native-docker.sh target/debug/devtrim. - Page load (opt-in, needs Chrome): serve the checkout with
python3 -m http.server 4173 --bind 127.0.0.1, thennode scripts/perf/pageload.mjs "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" http://127.0.0.1:4173/ 30 50 index.html MANUAL.html;python3 scripts/tests/pageload-controls.py <chrome>proves it can fail. - Paint-control validation (no browser):
python3 -B scripts/tests/pageload-controls-validation.py; stale LCP candidates must fail even when their timestamps exceed the budget. - Read-only views:
python3 scripts/tests/read-only-views.py target/debug/devtrim(analyze navigation/progress/help/resize; status sampling/resize/blocked-probe quit; terminal restoration). - Format:
rustup run 1.98.1 cargo fmt --all -- --check - Lint:
rustup run 1.98.1 cargo clippy --locked --all-targets --all-features -- -D warnings - Structural lint:
ast-grep test --skip-snapshot-teststhenast-grep scan --config sgconfig.yml - Test:
rustup run 1.98.1 cargo test --locked --all-targets --all-features - MSRV:
rustup run 1.88.0 cargo test --locked --all-targets --all-features - Audit:
cargo audit - Fuzz (local release gate, nightly): with the nightly toolchain's bin dir first on PATH (
PATH="$(dirname "$(rustup which cargo --toolchain nightly)"):$HOME/.cargo/bin:$PATH"), runcargo fuzz run <target> -- -max_total_time=60for each target infuzz/fuzz_targets/. A standalone stable cargo earlier on PATH breaks cargo-fuzz's inner build. - Video:
cd video && npm ci && npm audit --package-lock-only --audit-level=low && npm run lint && npm run format:check && npm run build - Build:
rustup run 1.98.1 cargo build --release --locked --target aarch64-apple-darwin - Site/manual:
python3 -m http.server 4173, then open/index.htmlor/MANUAL.html
Conventions
- Every cleanup category = one file in
src/ops/, implementing theOptrait. - Findings use typed actions. Confirmation flags may bypass a gate but MUST NOT add or widen actions.
- Apply consumes only exact previewed findings; never rescan for deletion targets after confirmation.
trash-emptyomits direct Trash children named as an ASCII-case variant of.git, warns, and leaves them in place so one protected item cannot block other exact previewed children. An item whose size cannot be measured (an unreadable folder inside it) offers nothing and is named in oneproject::unread_folders_finding; the others are still listed and purged and the run exits nonzero. Apply continues past an item the sink refuses — a trashed project still holding its repository — recording each refusal so the run reports nonzero. Each direct Trash child is granted uv's marker (safety::VerifiedTarget::grant_trashed_markers) under the same shape and tag checks, so the uv cacheclean cachesmoved there can be purged. The same grant (MarkerGrant::owned_permissions) lets the permanent sink add owner write and search permission, through each folder's open handle and just before emptying it, to a read-only folder the current user owns, so the Go module cacheclean cachesmoved there can be purged too; a folder owned by another user still refuses. Every other permanent removal refuses a tree with such a folder whole, before removing anything (Writability::Require).trash-empty --only-devtrimnarrows the purge to what devtrim itself moved to the Trash: the sink journals each Trash move with the item's device, inode and birth time (journal::TrashedIdentity, captured before the move, which keeps them while the Trash renames the item), and only a direct Trash child matching a successfultrashrecord is offered, filtered before anything is measured. The selection only narrows; the field is additive, accepted ontrashrecords only, and absent from older records, which therefore never match; a history that cannot be read whole refuses the command and offers nothing, since the missed record could be the one that tells an item apart.- Deletion-list evidence. Every added or widened deletion-list entry carries evidence for its exact scope: the owner, the observed contents, and why removal is appropriate. Cite specific vendor documentation or a versioned owner source. Where neither exists, say so explicitly, name what was observed, and state the limit — an empty directory or a cache-like name is not evidence. A parent entry needs evidence for the whole subtree it authorizes.
safety::DeletionEntryandHistoryRootmake the field required, so omission will not compile and a blank one will not pass; whether the cited source actually supports deletion stays a hard review requirement no gate can settle. scripts/tests/planted-violations.pyproves the named safety assertions can still fail: it breaks each guarded branch on a throwaway copy and requires the tagged assertion to fail, rejecting a mutant that does not compile, selects no test, or fails somewhere else. It runs inverify.sh offline, ordinary CI, and the read-only release job, never in the credential-bearing release script;release-policy.shenforces all four facts. It covers only the cases it names — currently the twoagentsdeletion boundaries, the Codex current-release executable check, the refusal of a Codex release a process still executes, the refusal of an executable mappinglsofcannot name, the five per-list evidence loops, the two Git activity-probe hardenings, reflog activity and HEAD's own commit date, the no-replace quarantine rename, thelsofname decoding, the refusal of an unreportedlsofprocess that is still running, the refusal of a build-process list that never settles, the Xcode apply refusal of a non-directory target, swiftly's toolchains kept at scan and at apply and its unparsable or unmapped configuration refusing,simulatorsreading only the exact no-simctlanswer as nothing to manage, each condition of the uv source-distribution marker exception and the tag reading both paths share, the uv cache lock and its symlink guard, the uv marker grant bound to uv's held lock and to its own root, the Trash grant's scope, thexcodescan and apply keeping SwiftPM's package checkouts and never splitting a folder that is itself a repository, theartifactsnested-repository check at scan and at apply,artifactsandnode_modulescontinuing past a refusal, thenode_moduleswalk, scan and apply leaving build output whole, a default project folder linked to the home folder or above it never widening the scan, by spelling or by the volume's identity,agentshistory andinstallersfollowingretain_daysrather than the project window,purge's misroute refusal,trash-emptyandxcodecontinuing past a refusal, the TUI selection bound to the approved plan, the TUI's permanent mode turning off with nothing selected, UTC activity dates, the tracked-file check by the volume's identity (a case-only rename, a Unicode case rename, and an ASCII alias) and each category's apply recheck, the keypair name's volume folding, the program-keypair check at scan and at apply, thenode_modulesmanifest at scan and at apply, each category's repository containment withholding a finding judged before the failure, each category reporting the folders its walk could not read, the repository veto when a candidate fails after its owner is known, the new-repository recognition with its symbolic-HEAD, reference-storage, resolving-reference, no-commit-object and complete-listing conditions, the Homebrew split at scan and its shape at apply, the three newest artifact names' corroboration, Terraform state at scan and at apply, the Trash identity selection and its journaling at the sink, DerivedData's open-file, age, empty-folder and apply checks while Xcode runs, the typedlsofnames and its mount fallback, and the open-file match by canonical path and by identity, coding agents counting as running builds, acachesscan continuing past an unmeasurable cache, anxcodescan continuing past an unreadable folder, Gradle's caches refused at scan and at apply while Gradle runs, the Go module cache's layout check and its permanent-removal refusal for a read-only folder,codex_homesjudged at scan and refusing a missing folder or~/.codexreached through a link, the group kill on interrupt and the registry's unregistration at reaping, the subprocess time bound (a hung command is killed and reported as a timeout, and a hung liveness probe refuses instead of reading as nothing running) — plus the feature evals'eval/cases, which rebuild the binary and select an integration test (target=names an integration-test crate) — and review owns the rest.- An owner-reported cache root (
brew --cache,npm config get cache,go env GOMODCACHE) is resolved to its real path before it is judged, as$HOMEis, so a home reached through a link does not make the cache look foreign; a root that does not exist yet is kept as reported. - Homebrew's cache (
brew --cache) is offered whole unless it is the standard~/Library/Caches/Homebrewand a direct child is a Git repository — the clone Homebrew's Git download strategy keeps per Git-sourced formula (<name>--git), judged by its marker, not its name — in which case its other non-empty, non-link direct children are offered underTargetAuthority::BrewCacheEntryand the clones stay; apply reasserts that shape (parent exactly the standard cache, neither a link nor a repository), so an entry nested inside a clone is refused before the sink. cachesscan contains an unmeasurable cache to itself: a cache whosedir_sizefails (an unreadable folder inside it, a size overflow) offers nothing and is named in oneproject::unread_folders_findingerror finding, so the run exits nonzero while the other caches are listed; a failing owner command (npm,brew) still fails the category.cachesoffers Gradle'scachesandwrapper/dists(GRADLE, relative toGRADLE_USER_HOME, default~/.gradle; never that folder, which holdsgradle.propertiesandinit.d; a relativeGRADLE_USER_HOMEor one outside the home folder is skipped with a warning, and so is a Gradle home that is or lies inside a Git repository, judged asproject::owning_repojudges one). Each folder must also hold what Gradle writes there, at scan and at apply (holds_gradle_layout, Gradle 9.8.1 user-home layout):caches/modules-2, or awrapper/distschild namedgradle-<version>-binor-all;GRADLE_USER_HOMEalone never makes a folder a cache. Gradle locks them only among its own processes and cleans them when its daemon stops (Gradle 9.8.1 docs), so whilepgrep -a -ffinds Gradle's daemon (org.gradle.launcher.daemon.bootstrap.GradleDaemon), a daemon-less build (launcher.GradleMain) or the Wrapper (wrapper.GradleWrapperMain) they are left out with a diagnostic, and apply probes again at each Gradle finding and refuses it; a failed probe is one error finding for Gradle alone.- The Go module cache is the folder
go env GOMODCACHEreports, asked from$HOMEwithGOTOOLCHAIN=local(go.dev/doc/toolchain: otherwise ago.modthere could makegodownload a toolchain), resolved like the other owner roots, and offered only when it lies inside the home folder and holds Go'scache/download(go.dev/ref/mod, "Module cache"); apply asksgoagain and requires the same folder, only for a target holding thatcache/download.goabsent offers nothing;gofailing is one error finding for the Go cache alone. Go makes the cache read-only, so apply refuses a permanent removal when any folder in it is read-only, and a move to the Trash when the cache folder itself is — refusing before the sink, never a half-removed cache. cachesapply continues past a refused finding, recording every failure so the run still reports nonzero. The list spans unrelated tools and one entry can be refused for a reason of its own — uv busy with its cache, a nested repository — so stopping at the first failure would block every cache listed after it.artifactsandnode_modulesapply preflight every finding before changing anything — a failed probe or recheck there stops the whole plan untouched — then judge each finding again, with fresh Git and tracked-file observations and the initial build-process snapshot, just before its own removal, and continue past a finding that recheck or the sink refuses, recording each failure. Removing the earlier findings takes time, so a repository that becomes active meanwhile keeps its output.- A repository at or above the home folder (dotfiles kept as a repository at
~) owns nothing:project::owning_reporeturns no owner for it (safety::holds_home, failing closed), so a project under it with no repository of its own is not judged at scan or at apply. Its last commit says nothing about that project. - Feature evals (
tests/evals.rs,tests/eval_system.rs,tests/eval_reports.rs,tests/eval_safety.rs) run every command through the real binary against a planted fixture: positives the feature claims, and one near miss per documented exclusion. A preview must leave the fixture byte for byte unchanged and an apply must change exactly the previewed targets (support::Tree, whose own controls prove it reports each kind of unauthorized change); the journal must show each removal write-ahead. Drift between consent and apply is driven inside one invocation through/usr/bin/script. Only assertions named byplanted-violations.pyare mutation-proven; the other eval assertions remain review obligations. Stubs prove devtrim's side of external tools;scripts/tests/native-docker.shproves Docker against a real disposable engine, while the real Trash move, realsimctland the maintenance tasks stay unproven end to end. xcodescan contains a DeviceSupport or DerivedData folder it cannot read or measure (dir_size, the package-folder checks, a directory entry or its type) to that folder: it offers nothing and is named in oneproject::unread_folders_findingerror finding, so the run exits nonzero while every other folder is judged; a support directory itself that cannot be read still fails the category.xcodeapply continues past a refused finding in the same way. A DerivedData folder that holds a Git repository is refused by the sink, so stopping at the first failure would strand every build tree after it; each refusal is recorded and the run still reports nonzero.- The uv cache is removed only under uv's own lock: a running uv holds a shared
flockon<cache>/.lockwhere the filesystem supports shared locks, as APFS does, anduv cache cleantakes it exclusively (uv 0.9.24crates/uv-cache/src/lib.rs,crates/uv-fs/src/locked_file.rs; stdFile::lockisflock(2)on macOS). Apply refuses a cache reached through a symlink or symlinked ancestor before creating anything, then opens the lock no-follow and non-blocking, creating it as uv does, takes it exclusively without waiting, and holds it until the cache has moved; a held lock refuses only that finding. The lock is also the grant:safety::UvCacheLockhas no constructor but taking that lock, and outside the Trash only a target granted by a lock on its own exact root lets the sink tolerate uv's marker, so aCACHEDIR.TAGroot reached throughartifacts— a repository-localUV_CACHE_DIR— keeps the refusal and can never be removed under a running uv.trash-emptygrants the marker to direct Trash children, where no uv uses the tree, under the same shape and tag checks. agentsapply continues past a refused finding, likecaches, and reports an age-gate refusal as history that became active rather than as a target outside its namespace. The gate is re-read at apply, so a session resumed between preview and apply is an expected refusal that must drop only its own finding.- Codex standalone releases are a third closed
agentsauthority,CODEX_RELEASES, aDeletionEntrywhose evidence is required. A release is offered only when the installer lock andcurrentsymlink prove an older direct-child layout-v1 package with canonical version numbers, and a system-widelsof -d txtsnapshot reports no process executing a file inside it. Thelsofprobe explicitly requests descriptor and name fields; failure or a mapping without an absolute name refuses release cleanup. Apply rechecks eligibility and probes liveness again at each release rather than once per plan. The snapshot cannot see every other user's process or one started after the probe. Current, newer, same-core, staging, and unknown layouts remain outside this authority. Vendor files are opened no-follow, non-blocking and close-on-exec. A busy or unverifiable installer refuses only its release findings during apply. ~/.claude/jobsis never a history root: it is the background-session supervisor's state (state.json,timeline.jsonl,tmp), a pinned session stays alive while idle and a shed one is woken from it, and thepins.jsonbeside it is a liveness file a closed category must not need to consult.- A
HistoryRootinagentsearns its place only when the space it offers is worth the preview lines it costs. Measured against the development machine, Codex lanes gave 558 findings for 0.25 GB, its.tmptree 324 for 0.07 GB, and the Claude Code file-edit history 123 for 0.10 GB; all three are excluded, and what remains offers 4.02 GB in 57 findings. - Every human apply prints the data-loss notice; every interactive mutation confirms regardless of danger,
trash-emptyincluded.-yskips y/N only;--yoloskips all interactive prompts. Operation-specific acknowledgments such astrash-empty --confirm=<gb>still apply, and that acknowledgment is measured over the exact previewed findings, never the whole Trash.--jsonkeeps stdout one document and prints no notice; on a terminal an apply without-yor--yolostill asks on stderr (the drift evals answer that prompt through/usr/bin/script). A non-interactive refusal names what the plan needs:-yfor y/N plans,--yolofor typed-size plans. - Global flags are capability-scoped: commands reject mutation flags they cannot honor.
scan --shredmay change previewed actions; Docker and simulator cleanup reject--shredbecause they execute typed commands rather than filesystem deletion actions;trash-emptyaccepts apply/confirmation flags but rejects--shred; report-only commands accept no mutation flags. - Unknown activity, ownership, symlink resolution, owner-command status, configuration fields, or size measurement fails closed. Failing closed is scoped to what failed, never wider: a repository whose Git query, tracked-file check, tree search or size measurement fails offers nothing — not even a finding judged before the failure — and is reported as one
Action::Infofinding with no target carryingscan_error(project::unjudged_finding), so the run exits nonzero while every other repository is judged; folders the project walk cannot read (project::UnreadFolder) are reported the same way, one finding per category (project::unread_folders_finding), and their subtrees are not entered, while a scan root itself that cannot be read still fails; a failed liveness probe still fails its whole category. scan_allowns shared liveness/Git observations for one preview, including failures. Standalone scans start fresh; apply must never reuse preview observations.scan_allruns the ten categories concurrently on scoped threads and joins them in registry order, so findings and errors stay byte-identical to a serial scan; a panicked scan thread becomes that category's error. Apply is never parallel, because the journal serializes each attempt/result pair. Cross-category diagnostics arrive in completion order, not registry order.Ophas exactly one scan entry point, taking&ScanObservations. Observations live for exactly one scan: each repository's Git date and the build-process probe are computed once, the mutex is released before the subprocess runs, and a failed probe is memoized and re-yielded to every category so it blocks rather than silently passing.- Hugging Face cleanup targets only
~/.cache/huggingface/hub; its parent, tokens, and settings are never built-in cache authority. - Filesystem findings retain an exact internal
PathBuf; serialized display text is never parsed back into deletion authority. - Only
safety::validate_path_for_deletioncreatesVerifiedTarget; only the private sink insrc/ops/mod.rsconsumes it. Raw filesystem deletion anywhere else is a blocking ast-grep violation. - Apply derives Trash versus permanent mode from the previewed typed
Action, never from a runtime flag. The Trash move goes throughtrash_context()insrc/ops/mod.rs, which selectsNSFileManager(trash::macos::DeleteMethod::NsFileManager), never Finder's AppleScript, which needs Automation permission; a real move writes to the developer's own Trash, so no test performs one. - Permanent deletion must be explicit in preview and danger scoring.
- Never add shell-string execution. Process execution uses a fixed program; a dynamic argument is allowed only when a closed typed authority validates and carries it.
- Every scan and apply subprocess runs through
process::BoundedCommand::output_withinwith one of three limits insrc/process.rs:QUERY_TIMEOUT(120 s: Git,simctl,docker,npm,brew,go),PROBE_TIMEOUT(180 s:pgrep,lsof; a system-widelsoftook 16 s at load average 300 and load reached about 950) andMUTATION_TIMEOUT(900 s: the typed Docker, simulator and maintenance commands an apply runs). Past the limit the child is killed and reaped and the call fails withTimedOutnaming the program and the limit; it is neverNotFoundand never an empty probe, so a timed-out liveness probe refuses and the affected repository or category fails while the others complete.no-unbounded-subprocessrejects.output(),.status()and.spawn()insrc/outside tests,src/process.rs,src/status.rsandsrc/uninstall.rs. Each command leads a process group of its own and a timeout kills the whole group (rustix::process::kill_process_group, before the leader is reaped, so its id cannot be reused); a group of its own never receives the terminal's Ctrl-C, so every non-TUI run installsprocess::exit_on_interrupt(signal-hook, SIGINT and SIGTERM): live groups sit in one registry insrc/process.rs, registered when the spawn finishes (a counter of spawns in flight covers the gap, and an interrupt waits for it for at most two seconds) and unregistered under the same lock that reaps the leader (a reaped leader's group is never signalled); a signal kills them all and exits 130 or 143 without writing any message, which could block on a full stderr, and a second signal exits at once. A SIGINT ignored at startup (a background job of a non-interactive shell) is replaced, since there is no safe way to read the earlier disposition. The TUI installsprocess::forward_interruptsinstead (raw mode, where Ctrl-C is a key, and a terminal only its own thread may restore): its loop restores the terminal, then callskill_and_exitwith the signal, during an apply too; a second signal kills and exits at once without restoring it. A descendant holding the output after a clean exit is abandoned, not killed. The 900 s limit covers the typed Docker, simulator and maintenance commands. simulatorsdeletes only unavailable devices. Working simulators are one report-onlyAction::Nonefinding sized from simctl'sdataPathSize, never a tree walk, parsed apart from the device authority and omitted with a diagnostic when a size is missing or malformed; apply skips it on actionability and returns before measuring when nothing in the plan is actionable, because the CLI applies such plans. Exactly twoxcrunanswers mean there is nothing to manage, a diagnostic rather than an error:xcrun --versionexiting 1 withinvalid active developer pathormissing DEVELOPER_DIR path(no developer directory), andxcrun simctlexiting 72 withunable to find utility "simctl", not a developer tool or in PATH(the Command Line Tools without Xcode); any other failure still fails the category.- Only
Finding::commandcreates command authority from the closedCommandAuthorityenum; apply verifies that capability, its validated arguments, and its serializedActionbefore execution. Docker authority binds an absolute local Unix-socket endpoint; simulator authority binds one exact UDID. - Docker volumes are never pruned. Xcode Archives are never pruned.
- Xcode and Swift toolchain apply must reassert the scanner's exact direct-child target shape before calling the shared deletion sink. Xcode offers and applies only real directory children of
iOS DeviceSupport,watchOS DeviceSupport,tvOS DeviceSupport,visionOS DeviceSupport(the names LLVM's lldb looks for, llvm-project 82911deGetDeviceSupportDirectoryNames; the pre-Xcode-27XROS/xrOSfolder is left out, since its case is unsettled and a case-only difference from the folder on disk is refused at the sink) andDerivedData; a file (Finder's.DS_Store) or a symlink beside them is never a symbol or build tree. The one deeper shape is a DerivedData folder holding SwiftPM package checkouts (a non-emptySourcePackages/checkouts): each checkout is a Git clone the sink never removes, so the folder is offered as its real directory children exceptSourcePackages(any ASCII case), which stays. A folder that is itself a Git repository or worktree (a top-level Git marker) is never split: its directories are that repository's worktree, which the sink, checking each directory alone, could not see. Apply accepts such a grandchild only while its folder is a real directory still holding checkouts and still no repository, and refusesSourcePackagesoutright. toolchainsnever offers a toolchain swiftly installed. swiftly selects the toolchain in use through$SWIFTLY_HOME_DIR/config.json(default~/.swiftly/config.json), not a symlink, so every entry ofinstalledToolchains, andinUse, is mapped to the.xctoolchainnames swiftly gives it (swiftly 1.1.4ToolchainVersion.identifier, keeping both the 1.0swift-X.Y-RELEASEand the 1.1swift-X.Y.0-RELEASEspelling of anX.Y.0release) and matched by device and inode; each such toolchain is a report-only finding namingswiftly uninstall <version>, and apply reads the configuration again. No configuration means no swiftly; one that cannot be read, parsed or mapped withholds the whole category with an error finding.- Two windows, never one:
active_daysgates a repository's regenerable output (node_modules,artifacts,purge);retain_daysgates what nothing regenerates (agentshistory,installers). Unset,retain_daysisactive_daysor 30, whichever is longer (safety::activity_windows), so lowering the project window to free build output never shortens how long irreplaceable files are kept. Skip notes name the setting that held a finding back. - A linked worktree whose
.gitfile names agitdirthat no longer exists (project::orphaned_worktree, parsed as Git parses it: the exact prefixgitdir:, every byte after it but trailing CR/LF, relative to the file's folder — git v2.51.0setup.cread_gitfile_gently) is left out ofnode_modulesandartifactsand named in a skip note, instead of letting Git's failure there empty the category; apply still refuses anything whose activity it cannot read. A new repository — one holding no commit object, asgit initleaves it before the first commit, staged files being only blobs — is skipped and named the same way.project::unborn_branchasks only after the activity query has failed, so no repository pays for it.cat-file --batch-all-objects --unordered --batch-check=%(objecttype), which also lists alternate stores, succeeding with empty stderr and nocommitline is what proves there is no history to judge, whatever the references say — Git skips an object folder or pack it cannot read, says so on stderr and still exits 0, so a complaint keeps the error: a branch turned into a dangling symbolic reference, a file holding an object ID where the loose-ref folder was, and an orphan checkout beside other branches all keep their history and their error. Three more answers keep a damaged repository an error rather than a new one:symbolic-ref -q HEADsucceeds (a detached HEAD is not new),refs verifysucceeds (a loose-ref folder turned into a junk file, which Git's ownlogcalls "no commits yet", is damage; a Git before 2.47 lacks the command and keeps the error), andfor-each-ref --count=1succeeds (a branch whose commit was deleted is not new). Anode_modulescandidate whose checks fail once its owning repository is known blocks that whole repository, as a failure judging it does, so a second scan root inside the same repository cannot let its findings through. - Scan roots say where to look, never what may go. Without
--rootor configuredroots, they are the conventional project folders that exist (safety::DEFAULT_PROJECT_ROOTS: Mole V1.56.0'sMOLE_PURGE_DEFAULT_SEARCH_PATHSnames plusDeveloper, with Codex's.codex/worktreesand withoutLibrary/CloudStorageor.claude/worktrees, which Claude Code does not use: its worktrees live inside the repository); a default folder that cannot be resolved or read, or that resolves to the home folder or one of its ancestors — judged by device and inode as well as spelling (safety::holds_home), because realpath keeps whichever of/Users/<name>and the data-volume firmlink/System/Volumes/Data/Users/<name>it was given — is skipped with a warning. Never widen the default to the home folder or the disk: macOS guards Desktop, Documents and Downloads with privacy prompts, those and cloud storage hold dataless and synced files, and outside project folders there is no Git owner to judge staleness. Every human preview of a command that walks roots (Op::scans_roots,scan,largest) names them and theirRootsOrigin; JSON does not. - A
node_moduleswith corroborated build output (artifacts::is_build_output) strictly between its owning repository and itself belongs to that output — Next.jsoutput: 'standalone'copies one into.next/standalone— and is never offered; apply refuses it. The walk never enters build output below the scan root, as theartifactswalk does not, so onepurgeplan never counts the same bytes twice; that pruning bounds where the scan looks, like the roots, which apply does not recheck either, so a repository nested inside another's build output is out of sight rather than refused. Build output thatartifactskeeps (tracked files, a keypair, a repository) keeps thenode_modulesinside it too. - Test fixtures that must live outside the protected system temporary directory go under
target/throughops::TestFixture, whoseDropremoves them while a failing test unwinds; a fixture cleaned only on a test's last line leaks a stale repository for the nextpurgeof the checkout. node_modulesapply must reassert the scanner's exact category shape: a realnode_modulesdirectory leaf inside its owning repo, never a symlink or through a symlinked category ancestor, nor below an ASCII-case variant of.git, anothernode_modules, or a non-normal path component. Anode_modulesholding any file its repository tracks is committed content: it is never offered, and apply rechecks. Anode_modulesis an install only when apackage.jsonthat is a regular file, read without following a link, sits beside it — the manifest it was installed from and the only thing an install recreates it from — and scan and apply both require it: React Native copies package images into<App>.app/assets/node_modules, so every archived or unpacked iOS build holds one whose removal breaks the bundle's signature, and corepack's pnpm ships its dependencies indist/node_modules.- Every directory deletion preflights device boundaries and nested Git repository/worktree markers before either Trash or permanent mutation. Git metadata names are denied ASCII-case-insensitively by scanners, ownership and category checks, target validation, and open-handle preflight. The one tolerated marker is the empty
.gituv writes into its source-distribution bucket (uv 0.9.24crates/uv-cache/src/lib.rs:439-449), which Git rejects as an invalid gitfile, granted throughsafety::MarkerGrant: an empty regular file spelled exactly.git, in a directory namedsdists-v<digits>that is a direct child of the deletion root, only when that root carries a validCACHEDIR.TAGread no-follow and non-blocking, and only in a target granted the exception by holding uv's lock on that root, or bytrash-emptyfor a direct Trash child — a tag that cannot be read withholds the exception and never refuses an ordinary target. The Trash preflight and the permanent path apply the same rule through oneMarkerRules, which reads the tag once for the preflight and the removal walk; anything else — a worktree pointer, a case variant, a directory, a symlink, a FIFO or other special file, an untagged root, another depth or bucket — still refuses, each proven by a planted case against the shared preflight. The walk's repetition of these checks through open handles guards against a change after preflight that no deterministic test reaches; do not describe it as proven. artifactsmatches only its closed corroborated-name list plus validCACHEDIR.TAGsignatures; ambiguous names (build,dist,out,vendor,bin,obj,coverage) are never added. Its three newest names each need their owner's file beside them and carry the owner's source in a comment on their match arm: Android Gradle Plugin's native build staging folder.cxxbesidebuild.gradle(.kts)(AGP 9.4 DSL reference,Cmake.buildStagingDirectory),.terragrunt-cachebesideterragrunt.hcl(Terragrunt reference, "Terragrunt cache": safe to delete any time), and Nuxt's.outputbesidenuxt.config.*(Nuxt 4.x docs, re-created by everynuxt build). Its scanner never traverses an ASCII-case variant ofnode_modules, apply independently refuses those ancestors, and corroboration is re-verified at apply. Following Mole V1.56.0 (lib/clean/project.sh), a directory holding any file its repository tracks is never offered — CocoaPods recommends committingPods— nor is a tree holding a Git metadata name anywhere below its root (a SwiftPM.buildkeeps its dependencies' clones incheckouts), nor one holding an entry named*-keypair.json, found in one walk that follows no link — the keypair's name matched as the volume folds it (any case, plus the Kelvin sign and long s APFS folds ontokands), a Git marker's in any ASCII case:cargo build-sbf, whichanchor buildruns, writes a Solana program's keypair intotarget/deployonly when none exists (cargo-build-sbf v4.4.0src/post_processing.rs:167-171,188,201), so a rebuild after removal mints a different program address. A tree holding Terraform state (*.tfstate,*.tfstate.backup, any ASCII case) is refused the same way, at scan and at apply: Terraform's local backend writes it into the working directory, which under Terragrunt is inside.terragrunt-cache, and nothing regenerates it. Both are rechecked at apply. The tracked-file check (project::TrackedIndex) lists the repository's index once — per repository in a scan, afresh for each apply — and asks the volume whether any tracked path's ancestor at the target's depth is the target itself, by device and inode. Git compares spellings, while APFS resolves names case-insensitively, normalization-insensitively and with full Unicode case folding (STRASSEnames a directory created asStraße;kit, one spelled with a Kelvin sign), so only the volume can say that a directory renamed that way still holds what Git tracks; a case-sensitive volume keeps different spellings apart.installersmatches only direct children ofDownloadsandDesktopwhose extension is on its closed list (dmg,pkg,mpkg,iso,xip), matched ASCII-case-insensitively, and only after the configured retention window (retain_days). Scanning never recurses, formats that can carry source or user data are never added, and apply reasserts the full shape, refusing symlinks and any target outside those two directories.codex_homesin~/.config/devtrim.tomlnames additional Codex homes. Codex keeps all its state underCODEX_HOME(default~/.codex; ChatGPT Learn "Environment variables") and its standalone installer writes${CODEX_HOME:-$HOME/.codex}/packages/standalone, soagentsapplies every Codex entry (.codex/…relative paths: the regenerable cache, the history roots,CODEX_RELEASES) to each listed home exactly as to~/.codex, at scan and at apply, labelling each finding with its home; nothing is inferred from a folder's name.safety::codex_homes_fromrequires each entry to expand to an absolute path of normal components naming a real directory inside the home folder, reached through no link, neither~/.codexby spelling or by device and inode nor inside or around it or another entry;Ctx::from_clivalidates it so a malformed setting fails every command, andagentsreads it again (safety::configured_codex_homes) for each scan and apply, where an unreadable one withholds only the homes it adds.agentskeeps two closed tiers, never one, beside the separate Codex release authority above. A regenerable entry is an exact$HOME-relative cache its owner rebuilds on demand and is offered unconditionally; a history entry (session transcripts, shell snapshots, and archived sessions) is not regenerable and is offered only once the newest regular file anywhere in its subtree is older than the configured retention window (retain_days). Only regular files vote on that age, because a directory mtime moves on creation and on removal. History findings are children at exactly the configured depth below their root — Codex sessions nest<year>/<month>/<day>— so neither the root nor a deeper descendant can borrow its authority. A shell snapshot is history, not cache: it is written once per session, sourced by every later shell call in that session, and never rewritten if it disappears. Authentication material, configuration, memories, skills, agent definitions, installed plugins and.claude.jsonbackups are on neither list. Apply reasserts tier membership, exact depth, symlink refusal, and the age gate re-read from disk.~/.claude/projectsis never a history root. It holds Claude Code auto memory (<project>/memory/) keyed by repository root while transcripts beside it are keyed by working directory, and since v2.1.248 Claude Code retains a Desktop- or Cowork-originated transcript at any age with nothing in the filename to distinguish one. File age is therefore not evidence that anything there is finished with, and identifying the exception would mean reading transcript contents.- Claude Code's temp tree (
/private/tmp/claude-<uid>/<project>/<session>/, holding each session's scratchpad and task output) is never deletion authority;analyzebrowses it read-only. Its owner keeps a scratchpad as long as the session's transcript and removes both in its retention sweep (cleanupPeriodDays, default 30 days, per the.claudedirectory docs), so an idle gate shorter than that takes scratch files from a session that can still be resumed. macOS already deletes anything under/tmpuntouched for 3 days by atime, mtime and ctime (/usr/libexec/tmp_cleaner, daily at 00:00 since macOS 14), so a gate of 3 days or more adds nothing. It would also be the first authority outside$HOME, in a world-writable directory where another user can create the path first, andCLAUDE_CODE_TMPDIRmoves the whole tree. Measured on 2026-09-26: 29.9 GiB in 249 sessions, 14.2 GiB idle 2–3 days and 0.07 GiB idle 3+ days. ~/Librarystays protected wholesale.safety::MANAGED_LIBRARY_CACHESis the closed carve-out of exactLibrary/Cachessubdirectories and is the single source of truth for both the protection boundary and thecachescategory, so a path can never be previewed but refused, or protected but unlisted. A name qualifies only when one developer tool owns that directory and rebuilds it on demand. A cache its owner rebuilds only when a command runs is also listed insafety::REBUILT_BY_COMMANDwith that command, and its finding names the command instead of promising a rebuild on next use; every name there is a carve-out entry, which a test enforces; a shared or ambiguous name is never added, and the pnpm entry is the metadata cache rather than the content-addressable store that installed dependency trees hard-link into.Caches/JetBrainsis excluded because on macOS it is the IDE system directory whose product subdirectories hold the non-regenerableLocalHistorystore, andCaches/denobecause it isDENO_DIR, holding default-path Deno KV databases andlocalStorage. Matching is exact or<entry>/-prefixed, so a neighbour sharing a prefix stays protected.dockerreports the host-side VM disk image as a non-actionable finding measured in allocated blocks, not logical length, because the image is sparse. It is emitted whendockeris not installed. An installeddockerwhose daemon is unreachable fails the category, and that error names the image and its size; a refused non-local endpoint and a malformeddockerresponse remain hard errors. devtrim never compacts or deletes a runtime disk image, and its notes never promise when the runtime returns freed blocks. The build-cache estimate isdocker system dfSIZE, labelled "up to", when ACTIVE is 0, because the daemon omits records shared with the image store from RECLAIMABLE andbuilder prune -aremoves them; with records in use it is RECLAIMABLE, labelled "at least". Only the Build Cache row parses ACTIVE, and a non-integer there fails the category.- Every deletion and typed command writes a write-ahead journal record (attempt before, result after) via
src/journal.rs, synced per record; an unwritable journal blocks apply. Rotation is shift-and-rename at journal-open time only, never truncation, never mid-apply.historyis read-only, reads rotated files, and emits its own single JSON document. An append first terminates a tail left without a newline by a short write, so no record fuses with a fragment. - Findings capture preview-time device/inode identity; the sink refuses actionable filesystem findings whose identity is missing or drifted and verifies it through a cap-std parent-directory handle. Permanent deletion continues through that handle. Trash remains path-based after the identity check (no fd-anchored macOS Trash API) — keep that disclosure accurate.
analyzeis a read-only explorer and must never create deletion authority. Deletion in devtrim is always bound to a closed, corroborated category; an explorer that deleted the highlighted path would replace structural evidence with the operator's aim. It measures on a worker thread with a cancellation flag so the interface never blocks, never follows a symlink, never enters a foreign device, and discloses unreadable subtrees as partial lower bounds.statusis read-only and adds no dependency: fixed-argv tools and fail-closed parsers supply every value. Unreadable metrics are unavailable with a reason, never zero; health lists missing inputs. Memory used isactive + wired + compressed, excluding reclaimable inactive pages. Index variable-widthnetstat -ibrows from the end and match exact keys, never substrings.uninstallis a conservative report, never a complete inventory or deletion authority. Match the exact bundle identifier from the bundle'sInfo.plist, not display names. Product-named data is invisible to this match; disclose that limit. A folder it cannot read in full is measured as a disclosed lower bound (worktrees::measure_lenient), never a reason to drop the report;icloudnames an unreadable folder in one error entry and lists the files it could read. Omit group containers because entitlement names cannot establish ownership by suffix. Deletion would widen the shared/Applicationsand~/Libraryprotection boundary and requires a separate decision.optimizeuses fixed program/argv with no caller data. Keep root-requiring or hours-long tasks (mdutil -E,periodic, macOS/usr/sbin/purge) and incomplete DNS flushing out, with regression coverage.--applyrequires an explicit--task;optimizestays outsideops::all()so scan never reports maintenance commands as reclaimable space.devtrim purgeis a composite ofnode-modulesandartifactswith no authority of its own, outsideops::all()so a scan never lists a target twice. It scans both with one set of observations, orders by project total then target size, and routes each finding back to its own category by leaf name; that category reasserts its exact shape, so a misrouted finding is refused, never deleted. It never matches an ambiguous name, never preselects anything beyond the preview, and never bypasses the staleness gate. It is Trash-first like every category; the name follows Mole'smo purge, whose permanent deletion and ambiguous matching are deliberately not ported.- Human
scanleads with one line per category (actionable size, finding count, the command that acts on it), largest first, then lists a category whole up to eight findings and otherwise its five largest with a count of the rest;--alllists everything. A category whose scan failed keeps a row marked failed, naming the command that retries it, so a failure never reads as nothing to offer; under--shredthe hint names--shred. Report-only commands total what they report and say nothing is removed instead of printing0 B actionable.--jsonis always the complete, unsummarized document. largestis report-only visibility:Action::Infofindings, lenient traversal with disclosed skip counts, never deletion authority, no TUI entry.- Demo video: edit
video/src/DevtrimDemo.tsx, render withnpx remotion render DevtrimDemo ../media/demo.mp4 --overwrite --mutedfromvideo/, and verify a menu frame plus a single video stream before shipping. - Config
protectentries are deny-only, enforced insidevalidate_path_for_deletion, and fail closed on malformed input. No flag may bypass them. - Liveness probes (
build_process_cwds,xcode_activity) use fixed argvpgrep/lsof; probe failure blocks the affected findings and surfaces as an error, never a silent pass. While an Xcode-family process runs, DerivedData is judged folder by folder (xcode::derived_data_in_use): a folder whose newest regular file changed withinactive_daysstays — the compilers and linkers a build runs are not Xcode processes, so age is the main guard — as does one holding a file an Xcode process has open (lsof -p <pids> -F tDn, which must exit 0 — unlike the working-directory probe, an exit 1 is not rechecked but refuses, because Xcode's processes are long-lived — and must name every file whose type is not onsafety::LSOF_NON_PATH_TYPES(sockets, Skywalk channels, kernel controls, pipes, queues) by an absolute path and give its device, since Apple'slsofprintsno more informationor(revoked)for a file it cannot resolve and still exits 0; an ambiguous escape refuses). Only open files whose own device is the folder's are judged against it, which sets aside network shares, other disks and devices whatever their names; among those, lsof's fallback for a file it could not name — the mount followed by that volume's mount source in parentheses, matched exactly againststatfsof the folder (xcode::volume_mount_source) — makes the folder unjudgeable. The rest are compared by the folder's spelling, its canonical path, which must resolve, and by device and inode of the child after everyDerivedDatacomponent in each open file's path (xcode::held_open_by_identity), since the kernel may name a file through a link or the/System/Volumes/Datafirmlink; a folder holding no regular file yet is judged by its own modification time, and an unreadable modification time refuses. A package folder is judged whole before it is split. Apply probes Xcode's open files once for its plan and judges each target's folder again as it reaches it. A failed probe is one DerivedData error entry (project::unjudged_finding) while DeviceSupport is still judged, and a folder whose use cannot be judged blocks only itself. DerivedData liveness covers Xcode itself and theSWBBuildService/XCBBuildServiceprocesses IDE builds run through, not onlyxcodebuild.lsofescapes names for display; its unambiguous escapes are decoded and an ambiguous^Xrefuses, because a working directory whose displayed spelling differs from its path matches no repository.lsof -pexits 1 when any listed process is gone or unreadable, so its output is parsed per process (pthennlines) and exit 1 passes only when every unreported PID is absent from a freshpgrepof the same pattern; a still-running unreported PID, a failed recheck, or malformed output refuses. An exit 0 that leaves a requested PID out is judged the same way, never trusted. A PID first seen in a recheck is looked up and judged by the same rules, up to fivelsofruns in all (LIVENESS_LOOKUPS); a list still changing after that refuses. Every livenesspgreppasses-a— the build-process and Xcode probes throughPGREP_MATCH_ARGS, the Gradle probe (caches::gradle_processes,-a -f) literally — because pgrep omits the caller's ancestors by default. - The Git activity probe and the tracked-file check treat a scanned repository as hostile, sharing one builder: fixed
-coverrides for hooks and fsmonitor, plus--no-lazy-fetchand--no-pager; the activity probe adds the signature-display override and--no-show-signature. So preview never runs a program.git/confignames. The builder also removes ambientGIT_*_PATHSPECSsettings, so no query's matching depends on the caller's environment. Agitthat cannot honor a flag fails the query, which refuses and names Git's own reason. Activity is the newer of HEAD's commit date, read on its own, and HEAD's newest reflog entry; with reflogs disabled it is the commit date. Both dates render in UTC (TZ=UTC0with--date=format-local:), the clockiso_days_agocounts in;%csand--date=format:use each entry's recorded offset, which put an evening west of Greenwich on the previous day. completionsandmanpagewrite plain stdout and refuse--jsonwith the standard error envelope.- AGENTS.md and CLAUDE.md must stay byte-identical (release gate compares them).
- Whole worktrees are never deleted;
leftoversandworktreesare report-only, outsideops::all().worktrees(src/worktrees.rs) runs one hardenedgit worktree list --porcelain -zper repository found under the normalized roots (a walk that stops at each Git marker and never enters.gitornode_modules), keyed by identity so a repository is listed once. A listed path is repository data: it is sized only when it is a real directory inside a canonical scan root whose.gitfile names a gitdir in this repository'sworktrees(leads_back), never by running Git inside it; the main worktree is listed unsized, an orphaned worktree is a skip note, and the report never says safe, stale or removable or shows a date.clean leftoversadds the direct real-directory children of a repository's top-level.scratchonly whencheck-ignoresays Git ignores it andTrackedIndexfinds nothing tracked below it; a Git failure there is that repository'sunjudged_finding. Both measure withworktrees::measure_lenient(no link, one device, nested worktrees skipped, unreadable entries a disclosed lower bound). --jsonemits exactly one document; failed/partial operations return nonzero. A run-time error names the operation it failed in.analyzereports each lower-bound entry as an error, andstatus --jsonis its own documented vitals document.- Gitleaks installation gates must pass the runtime synthetic-token positive control before the tool directory reaches
PATHor any clean secret scan is trusted. - User-facing strings are English; size values are estimated logical bytes.
- The TUI runs each scan and apply on a worker thread while its loop keeps drawing (the working screen counts the seconds elapsed) and reading keys: every key typed meanwhile is dropped except a quit, and input still queued when the result is drawn is discarded. Keys typed before a plan is displayed must never toggle permanent mode or approve it;
scripts/tests/tui.pyproves this in a real PTY with a positive control. During a scanqor Ctrl-C restores the terminal, kills every command the scan started and exits 130. During an apply no key quits until it returns, since quitting could leave a target half moved or half deleted, and the working screen says so; SIGINT and SIGTERM still restore, kill and exit 130 or 143 as the CLI does, leaving the attempthistoryreports as interrupted.scripts/tests/tui.pyproves each stop of a scan blocked on a stubbed probe that never answers, with the probe running until the stop as its positive control, and that an apply blocked the same way in its liveness preflight refusesqand Ctrl-C yet stops on SIGTERM with nothing removed. - Permanent-deletion quarantine and its restore rename with
RENAME_EXCL(renameat_with+NOREPLACE), never a check-then-rename that could overwrite a file recreated at the name. - Typed commands run through
ops::run_command_authority, which journals them and reports a failure with the command, its exit status and stderr. src/tui.rsis a presentation adapter over existingOpscan/apply owners. It must not duplicate scanners, deletion logic, or danger policy. TUI apply requires a matching typed approval; CLI bypass flags never pre-authorize it.- TUI selection only narrows. Space leaves an actionable finding of an applying operation out,
Atoggles all or none, and the plan is the displayed findings minus those left out, so the approval, danger and typed confirmation are computed over exactly that subset; changing the selection invalidates an earlier approval. Read-only and non-actionable rows are never choices. Rows are one line each. The detail pane is the only place a long path appears whole, so it grows to hold the highlighted finding, places the selection state before the path, never leaves a space in a row's last cell (where it would read as padding and join two names), and when the terminal is too short says how many lines it hides rather than cutting them silently, and thatEntershows them: a full detail view that scrolls within its content, where no key reaches the plan andEscreturns to the results unchanged; every row and the pane show the action and danger the current mode gives the finding, throughreport::effective_actions, never a local copy of that policy. The mode leads the results title so it survives the minimum width; the confirmation states how many findings it covers and how many were left out; the footer offers Space,Aandaonly when there is something to select, and names what the nextAdoes ("A none" while nothing is left out, "A all" after). No action key passes in silence: one that cannot act — every action key in a read-only view, Space on a report row,sturning permanent mode on with no Trash item selected (turning it off always works) — sets a status saying why, and in Scan everything that status names the highlighted finding's own category view ("b, then 2 (caches)") only when that view can apply it, using the scan's category sections, so the read-only scan never gains an apply path. Every footer key line and status fits whole at 64×18, which a render test asserts, because the footer does not wrap. - An apply summary never calls Trash moves reclaimed space:
Summary.bytes_trashed_estimateis the part ofbytes_freed_estimatestill on disk, and human output names it withdevtrim trash-emptyas the step that frees it. src/theme.rsis the only place that decides how a span looks. Render code names a semanticToken; noColor::literal may appear insrc/tui.rs. Colors stay named ANSI rather than RGB so they resolve through the user's terminal theme, andNO_COLORdegrades every token to a modifier that keeps the same distinction, with the danger ladder ordered. A render-level test as
Truncated - read the full file at https://github.com/mneves75/devtrim/blob/ef066b6f11ee1879af98ba226e3084b6d50c0e70/AGENTS.md.
