Skip to content

Latest commit

 

History

History
214 lines (178 loc) · 52.6 KB

File metadata and controls

214 lines (178 loc) · 52.6 KB

Mole Agent Guide

This file is the shared source of truth for any AI agent working on this repo (Claude Code, Codex, etc.). CLAUDE.md is a symlink to this file. Put machine-specific or personal overrides in AGENTS.local.md / CLAUDE.local.md; both are gitignored.

Project

Mole is a macOS system cleanup and optimization tool with shell and Go components. It performs file cleanup, app protection checks, and maintenance tasks, so safety rules matter more than speed.

Product Direction

Mole is a terminal-first macOS maintenance toolkit. Its core job is to help power users inspect reclaimable space, remove known-safe leftovers, uninstall apps safely, run bounded maintenance, and check health from a CLI, script, or compact TUI. It is not a general Mac control center, package manager, background monitor, or GUI feature mirror.

What Mole Should Do

  • Make cleanup and uninstall actions boring, reviewable, logged, protected by path/app rules, and dry-run capable.
  • Prefer reversible user-facing removals through Trash where the command surface expects recoverability.
  • Keep clean, uninstall, purge, and installer focused on reclaimable files, app leftovers, rebuildable caches, installer artifacts, and exact known cleanup targets.
  • Keep analyze as a disk explorer and ad hoc cleanup surface. Optimize first paint, navigation, sorting, filtering, and safe deletion before adding dashboard-style features.
  • Keep status as a compact read-only health dashboard plus stable JSON/NDJSON automation output. It may surface actionable signals, but should not become an iStat clone, alerting daemon, or configurable metrics workbench.
  • Keep optimize focused on explicit, bounded maintenance tasks that can be explained before execution and tested without real authorization prompts.
  • Keep command UX dense and terminal-native: short labels, stable alignment, predictable shortcuts, one-screen summaries, then optional drill-down.
  • Keep routine per-item cleanup skips and timeouts out of the default summary. Do not add retry reminders or tuning variables there; retain diagnostics in logs and --debug, honest partial totals, and visible command-level failures. See .claude/skills/bugs/references/state-accounting-and-progress.md.
  • Keep Mole Mac references as a cross-link or support path. The CLI and Mac app can share product values without requiring feature parity.

What Mole Should Not Do

  • Do not add broad system modification, privacy reset, package management, app bundle patching, or device-management features just because they are technically possible.
  • Do not remove or rewrite third-party app bundle contents, signed resources, user documents, credentials, sessions, active databases, or active developer-tool state.
  • Do not add background agents, persistent monitoring, notifications, schedulers, menu bar behavior, or GUI-like state unless explicitly requested and justified as CLI scope.
  • Do not broaden leftover matching from exact app or bundle evidence into vendor-wide, TeamID-prefix, generic-name, or fallback wildcard deletion.
  • Do not turn status into a noisy dashboard. Extra rows, live alerts, and tuning controls need a common user action, not just an available metric.
  • Do not add prompts, preferences, or output modes to solve every edge case. Prefer quieter defaults, preview/read-only guidance, or declining unsupported operations. A new flag, environment variable, or config key is the same weight as a new setting: it passes only when no single default is right for everyone, and the fix-by-default alternative has to be stated and rejected first. Reaching for a knob to close an issue is the default failure here, not an edge case.
  • Do not treat Mole Mac features as required CLI gaps. The CLI should stay narrower, scriptable, and safety-first when parity would add complexity or ambiguity.

Product Decision Filter

Before accepting a new feature, answer these questions in the PR, issue, or review notes when the fit is not obvious:

  1. Does it clearly belong to clean, uninstall, analyze, optimize, status, purge, history, installer, update, completion, touchid, or remove?
  2. Is it safe by default, previewable where destructive, testable without real auth, and explainable in one terminal screen?
  3. Can the user verify what will change before Mole changes it?
  4. Is the target data locally rebuildable, disposable, or backed by exact app/bundle evidence?
  5. Would this be better as Mole Mac UI, documentation, a warning, or an explicit "not supported" answer?

If the answer is no or unclear, decline the feature, narrow it, or park it until the product value beats the added surface area.

A magnitude answer needs a sample from real users, not the maintainer's disk. When keep-or-kill turns on how much space a target holds, how many users hit it, or what share of a directory it is, ship a read-only probe first: list, du, and status only, no writes and no sudo, seconds to run, output delimited so a reporter can paste it whole. A local measurement can be wrong by orders of magnitude in either direction, killing a target that is large on real machines or hiding one the maintainer's disk never had. Have the probe print structure, not only totals, because the design usually turns on the ratio: rebuildable versus authored, active versus stale, already covered versus not. Write the reversal threshold as a number before the sample arrives, then reconcile against it.

Repository Map

  • AGENTS.md is the cross-agent source of truth. CLAUDE.md must remain a symlink to it so Claude and Codex receive the same project contract.
  • .claude/skills/ is the canonical home for project skills. .agents/skills/ contains relative symlinks for Codex discovery; do not maintain copied skill bodies.
  • .claude/agents/ contains focused Claude review profiles. They must read the current contract from this file instead of copying a frozen version of the safety or portability rules.
  • mole - the CLI entrypoint. It is a router only: it parses args, renders the menu, and dispatches. Business logic does not belong here. Self-update lives in lib/manage/update.sh and self-removal in lib/manage/remove.sh; both are sourced (not execd) because the interactive menu and the update banner call them in-process. VERSION= stays in mole because install.sh reads it out of this file with sed. SCRIPT_DIR= must stay a single line-initial assignment in mole, with the resolver above it: install.sh rewrites that line with an unanchored sed and the test harness (tests/update.bats) with an anchored one, so a second occurrence is silently rewritten too and anything resolving below it overwrites the pin. The symlink cases in tests/cli.bats lock the resolver, not this invariant.
  • lib/core/ - shared shell safety, UI, file operations, operation logs, app protection logic, and centralized timeout constants (timeouts.sh).
  • lib/core/app_protection_data.sh - readonly bundle ID and pattern arrays consumed by app_protection.sh. Data only, no logic.
  • cmd/analyze/ - Go disk-analysis TUI. main.go is bootstrap only; model.go holds types and accessor methods; update.go holds the Bubble Tea Update chain.
  • tests/fuzz_corpus/ holds property-test corpora consumed by path_validation_fuzz.bats.
  • scripts/ - check, test, build, and release helpers. audit_bundle_drift.sh backs the monthly bundle audit; audit_function_duplication.py gates same-body-different-name shell functions and runs inside check.sh (--list shows every group); audit_destructive_sinks.py enforces explicit safety annotations on raw recursive deletion commands; per-PR perf is covered by tests/core_performance.bats.
  • docs/SECURITY_DESIGN.md - design doc for the path validation / app protection / # SAFE annotation contract.
  • SECURITY_AUDIT.md - security review notes.

Commands

./scripts/check.sh --format
MOLE_TEST_NO_AUTH=1 ./scripts/test.sh
MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats
MOLE_DRY_RUN=1 ./mole clean
MOLE_TEST_NO_AUTH=1 ./mole clean --dry-run
MOLE_TEST_NO_AUTH=1 ./mole purge --dry-run
MOLE_TEST_NO_AUTH=1 ./mole installer --dry-run
find bin lib -name '*.sh' -print0 | xargs -0 -n1 bash -n
make build
go test ./...

Public docs and examples should prefer the installed mo command. Use ./mole in this repository when verifying source-tree behavior before installation. analyze and analyse are both accepted command spellings.

Critical Safety Rules

  • Route deletion through the safe helpers in lib/core/file_ops.sh. Raw rm -rf and find -delete are allowed only with a # SAFE: <one-sentence reason> annotation on the same line, which is the contract docs/SECURITY_DESIGN.md Layer 2 defines and scripts/audit_destructive_sinks.py enforces from both check.sh and .github/workflows/test.yml. Self-created mktemp files use the same annotation for direct rm -f; do not route scratch paths through mole_delete, which would add Trash routing and an operation-log entry to temporary work.
  • Use mole_delete from lib/core/file_ops.sh for removals so Trash routing, operation logs, dry-run behavior, and path protection stay consistent.
  • Never modify protected paths such as /System, /Library/Apple, or com.apple.*.
  • Route user-facing cleanup through Trash where the project expects recoverability, especially for analyze-driven ad hoc cleanup.
  • Never let verification block on sudo, AppleScript, or macOS authorization prompts unless the task explicitly targets auth behavior.
  • Use MOLE_DRY_RUN=1 before destructive cleanup flows.
  • Use MOLE_TEST_NO_AUTH=1 for tests, manual repro, and verification unless real auth behavior is being tested.
  • Any new direct use of sudo, osascript, or launchctl must have a MOLE_TEST_MODE / MOLE_TEST_NO_AUTH guard or be fully mocked in tests.
  • Never auto-delete Software Update-owned staging trees such as /Library/Updates or /macOS Install Data. Directory age, process lists, and Software Update plist state cannot prove those trees stay inactive across a scan-to-delete window; keep this surface read-only.
  • Never delete, truncate, or vacuum the active PowerLog database at /private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/CurrentBackgroundProcessingDB.BGSQL or its -wal / -shm companions. Size and mtime cannot prove that Apple has closed every SQLite connection; keep abnormal-size handling read-only.
  • Never run a privileged path-based delete or move through an invoking-user-mutable ancestor. safe_sudo_remove, safe_sudo_find_delete, and mole_delete must downgrade or fail closed there; privileged Trash moves must cross into dedicated immutable root-owned staging under /Library before the invoking user moves the item into Trash.
  • install.sh stays fail-closed on verification failure. A checksum or attestation mismatch aborts and says why; it must never downgrade to a source build, which turns "the binary was tampered with" into a quieter path with weaker verification. Resolving no release tag and falling back to main must warn that this is a nightly source install. The abort cases in tests/install_checksum.bats pin both. Keep the README install URL on unpinned main: pinning it there blocks fixes from reaching new installs.
  • A gate that refuses must name which cause it hit and what to run next. acquire_install_lock reports stable reasons through INSTALL_LOCK_FAILURE; unsafe-ancestor variants use INSTALL_LOCK_UNSAFE_ANCESTOR_REASON. Preserve one factual cause line plus one cause-specific next action, keep a new earlier gate at least as actionable as the failure it replaces, and pin reason-code routing rather than catch-all prose. Source-invariant tests skip comments and fail when they match zero intended code sites. tests/install_checksum.bats pins the branches; incident examples and test traps live in .claude/skills/bugs/references/test-validity-and-refusal-diagnostics.md.
  • The mo update self-heal fallback (_update_self_heal_reinstall) exists because the local bootstrap (temp file, registry, exec) is frozen on the user's machine and a broken installed version cannot fix itself (#1297). Keep it streaming install.sh from main straight into bash with no local temp files. Stable success is asserted against the installed binary's bounded version response, never installer output (the V1.47.1 false-success shape), and install.sh must bound its own --version / --help verification probes too. Nightly success additionally requires a per-attempt install receipt; pin the source archive to the resolved commit when HEAD is known, and never reuse an older COMMIT_HASH when it is not. Keep updates single-flight per install directory so receipt, commit metadata, and binary verification cannot cross concurrent generations: both writers take the same target-adjacent mutex, preferring absolute /usr/bin/lockf because the kernel drops that lock even if the holder is killed. lockf only ships with newer macOS, so requiring it made install and update exit before writing a file on every older release (#1348); where it is absent both fall back to an atomic mkdir in the lock directory, reclaiming it only against proof the recorded owner is gone (dead pid, or a live pid whose start time no longer matches). Distinguish the two fail-closed cases: a lock command that runs and refuses is contention, a platform that never had one is not, and only a system with neither primitive is turned away. Do not build the wrapper as a shell array; the empty one is the fallback path and an empty array under set -u is an unbound-variable error on the bash 3.2 macOS ships. Regression tests live in tests/update.bats and tests/install_checksum.bats.
  • Never machine-parse plutil -p. man plutil says of it: "The output format is not stable and not designed for machine parsing", and it is not. macOS 15 prints a JSON boolean true as 1 while macOS 26 and 27 print true, so a filter pinned to one spelling silently cleans nothing on the other, which is how #1512 was fixed and then regressed for eight days with CI green. Where a boolean prints as 1 nothing can tell it from the integer 1 at all. That rendering is also depth-blind, so a key nested inside a dict or an array comes back looking like a top-level key: {"outer":{"inner":true}} offered a directory named inner for deletion, a wrong deletion rather than a missed one. Read the XML from plutil -convert xml1 instead, where nesting is explicit and each type is its own tag, and compare tags for equality (plutil spells an empty container <dict/>, which a prefix match counts as an opening tag and desyncs the depth). Decode &amp; last, since a key holding the literal text &lt; arrives escaped twice. Locked by the #1512 cases in tests/clean_app_caches.bats, and that file now runs on the macos-14/15 compatibility job for exactly this reason.
  • _MOLE_COMPLETE_LSOF_MODE is memoized and has no reset, so never probe it before the sudo session exists. The first call to _mole_complete_lsof_mode caches direct, sudo or unknown for the rest of the process and every later call short-circuits on it. Today the order is correct everywhere: bin/clean.sh adopts or prompts for sudo at 1484-1507 before any cleanup step runs, and lib/uninstall/batch.sh primes at 1826 before _batch_execute_removals, whose first remove_file_list is at 2170. That ordering is load-bearing rather than incidental. Two separate design rounds proposed probing during the uninstall PREVIEW to decide what to show, which runs before 1826: the memo would have frozen at unknown and the runs where admin IS available, brew casks and system apps, would have started keeping caches they delete today, silently and with no test covering it. If a preview ever needs the answer, the memo has to be cleared after ensure_sudo_session succeeds, not just read earlier. Note also that the mode probe itself is immune to the MO_DEBUG stderr contamination fixed elsewhere in this file, because it positively matches p1 and u0 lines rather than testing the buffer for emptiness; verified with a trace-prefixed fixture and a non-root positive control.
  • mo remove preserves non-default config roots. install.sh --config can merge into a shared directory such as ~/.local; a Mole library and familiar top-level bin / lib names do not prove ownership of their contents. Do not move a custom root wholesale or substitute another install's default config when ownership is unknown. Resolve the pinned install separately from source checkout and Homebrew layouts, show the custom path as kept for manual review, and move only the reserved ~/.config/mole root to Trash. The channel receipt still follows the selected install before the default. Locked by the #1589 cases in tests/uninstall.bats and tests/update.bats.
  • Do not change ESC timeout behavior in lib/core/ui.sh unless explicitly requested.
  • Preserve operation logging to the project log path unless the user explicitly asks to change MO_NO_OPLOG behavior.
  • PRs touching destructive sinks need line-by-line review. For find_app_files, mole_delete, remove_file_list, container traversal, identifier-prefix wildcards, or recursion that ends in deletion, audit every primary and fallback branch for matcher breadth, protected-path coverage, and preserved confirmation. Exact bundle ID or path evidence is required; vendor prefixes and common-name globs are not. Treat specialist or AI review output as a claim to verify, never as approval.

Working Rules

  • Treat .claude/skills/bugs/SKILL.md as an on-demand router, not a universal review preflight. Load only the linked reference families signaled by deletion evidence, uncertain probes, bounded Shell/macOS work, persisted state or accounting, progress, test validity, or refusal diagnostics. Unrelated documentation, release copy, and administrative work should not pay for incident history.
  • Check should_protect_path() before adding cleanup behavior.
  • Check app protection helpers before adding app cache, uninstall, or leftover cleanup behavior.
  • Bundle protection matching is case-sensitive glob (bundle_matches_pattern), and macOS system bundles report inconsistent casing across releases (macOS 26 ships com.apple.bootcampassistant alongside the older com.apple.BootCampAssistant). When the monthly bundle drift audit reports gaps, add the exact IDs as the audit printed them, and check the runtime blanket com.apple.* guard before rating the gap's severity. The audit workflow's issue path requires the bundle-drift label to exist in the repo.
  • A new cleanup target needs measured value and an explicit non-target list. State bytes actually reclaimable on a real app version, not just the target's total footprint; name sibling directories excluded as user data and prove protection covers every reachable cleanup path. "It looks like a cache" is not evidence, and zero measured value stays out of scope. An encrypted or opaque index cannot prove a directory is unreferenced, so exclude it. A third-party owner command is still a deletion sink: the supported release must expose every mutated root machine-readably, dry-run and real mode must share one candidate plan, downstream traversal must enforce no-follow physical containment, and partial failures must be observable. Selective prune and dependency-store GC additionally require one lock or generation protocol across the complete mutation. A documented whole-cache reset may omit a shared lock only when the root contains no authored, session, installed, or toolchain state and interruption is equivalent to an ordinary cache miss. Unless the owner explicitly guarantees safe same-machine concurrent use, rebind a tri-state owner-process guard at the command boundary. Mole still validates and whitelists lexical and physical roots, rebinds their identities at the sink, propagates timeout or signal cancellation, and never falls back to direct deletion.
  • Classify cleanup by recovery contract, not by directory name or download cost. Re-downloading is a real cost but not an automatic veto when the user explicitly runs clean: Go's module cache is owner-documented, machine-resolvable, and independently whitelistable, so it is reset through go clean -modcache. Directly consumed or mixed-state stores still stay: registry/src, Cargo git, $DENO_DIR, ~/.ivy2/cache, ~/.m2/repository, ~/.nuget/packages, ~/.cabal/packages, and ~/.cpan/sources. Cargo's compressed registry/cache is redundant with extracted sources, while Cargo 1.88+ owns age-aware GC for sources and git dependencies. Downloaded model and experiment roots (~/.cache/huggingface, ~/.cache/torch, ~/.cache/tensorflow, ~/.cache/wandb) and toolchain payloads (~/.sbt/boot, ~/.sbt/launchers, ~/.stack/programs) stay off the blanket delete path. DENO_DIR is review-only because the owner command removes the entire root, including origin storage and downloaded runtime payloads. A default whitelist row is not protection once the user saves a custom file, so fix the delete path itself and remove whitelist inventory entries for targets Mole no longer deletes.
  • Keep AI-tool cache cleanup conservative. Claude Code, opencode, Copilot CLI, Zed, Warp, Ghostty, and similar developer tools may have active versions, config, credentials, or session state that must not be removed accidentally.
  • Leaked automation browsers are identified by a playwright_chromiumdev_profile browser root with ppid 1, never by the playwright-cli daemon. cli-client/session.js spawns cliDaemon.js detached and unref'd, so ppid 1 is that daemon's normal state for the life of an active session; matching it kills a live session. A profile directory is stale only when pgrep -f returns exactly 1; any other status is an unknown state that keeps it. Locked by tests/clean_automation_browsers.bats.
  • Do not clean tiny macOS UI state just because it is rebuildable. Wallpaper previews, preference thumbnails, and similar cover/state caches can create visible blank or cloud-download UI while reclaiming only a few MB; keep them unless there is strong user value and a regression test.
  • Homebrew cleanup must be preview-first. Show the exact brew autoremove candidates before removal, preserve dry-run behavior, and keep tests on mocked brew; do not let a cleanup path execute real package-manager removals in verification.
  • A failed third-party brew info lookup may use Caskroom ownership only when the unique installed cask has an app symlink pointing to the exact selected app, rechecked after the lookup. A matching basename or copied bundle is insufficient; timeout and signal failures still abort. Locked by the #1558 cases in tests/brew_uninstall.bats.
  • An ownership check that refuses must name the app it could not classify and send the user to brew info --cask, never brew list --cask. A cask brew cannot parse still lists cleanly under brew list --cask, so that listing diagnoses nothing and leaves a multi-app batch with no way to tell which selection aborted. Locked by the #1579 case in tests/uninstall.bats.
  • Sudo gates must not treat typed password characters as "skip". Only an explicit skip key should skip privileged cleanup; direct typed input must proceed into the real sudo prompt and have a regression test.
  • Long cleanup scans need both an overall wall-clock budget and inner-loop checkpoints. A timed-out producer must not feed partial output into a deletion loop: materialize only completed scans, discard results on nonzero status, and propagate timeout/failure instead of reporting success. Probe and action must use the same pattern, type, age, and depth. If a project/artifact scan times out, degrade to partial or skipped-slow-scan output instead of appearing hung.
  • Orphan leftover mdfind / size timeouts fail closed for that item and must not cancel later mo clean sections. A leftover sink timeout stays sticky. Locked by the #1584 cases in tests/clean_apps.bats.
  • Join simulator data on runtimeIdentifier from -j output, never on a printed runtime name. simctl runtime list heads each image with the image version (iOS 26.4.1) while simctl list devices groups under the runtime's short name (iOS 26.4), so a name join calls every point release an orphan and hands the user a simctl runtime delete for a runtime its simulators are still bound to. A reclaim recommendation additionally needs state: Ready, deletable: true, and exactly one installed image serving that identifier, because the device list cannot say which of two images its devices belong to. Locked by the #1505 test in tests/clean_orphaned_runtimes.bats.
  • Every simctl read goes through _run_simctl. clean_dev_mobile pins its probe count in tests/dev_extended.bats; a raw run_with_timeout ... xcrun simctl bypasses the stub, so added probes stay invisible to that assertion instead of being weighed by it.
  • System-service orphan scans must parse plist Program / ProgramArguments values as absolute paths only. Use non-interactive sudo for unreadable root-owned plists when needed, reject PlistBuddy error text as data, and keep CI tests on /Library/LaunchDaemons rather than relying on /Library/PrivilegedHelperTools.
  • Treat a launchd plist and its Program helper as one cleanup family. A standalone helper-app path under /Library/PrivilegedHelperTools/*.app/Contents/MacOS/* stays protected even when an updater temporarily removes the executable leaf; before deleting any direct helper file, re-scan LaunchDaemons and LaunchAgents completely and keep it when a surviving plist still references it or the reference scan is inconclusive.
  • Uninstall leftover expansion must stay exact and boring: bundle ID or app-name variants only, reject generic/common words, keep short-name floors, skip broad locations like Preferences/ByHost, and only remove helper remnants after the parent app is confirmed gone and protected-path checks pass.
  • Any new uninstall teardown path (launch services, login items, cask zap, helper bootout) must route through the shared-bundle-id sibling guard, covering /Volumes copies, inverse-name, and shared-identity variants, with a Bats regression per variant.
  • Preference repair and optimize cleanup must skip protected and whitelisted plists before attempting removal.
  • Git worktree staleness is not decidable. Clean only whitelisted rebuildable artifacts inside a worktree, never the worktree itself, and never emit a "safe to delete" verdict. Branch/remote heuristics fail on detached worktrees, ordinary status hides ignored files, and ignored entries may be the only copy of private state. A status surface may report blockers only: dirty, unpushed, locked, or ignored entries outside MOLE_PURGE_TARGETS.
  • Purge discovery skips dot-directory containers by design. Add each supported container, such as ~/.codex/worktrees, explicitly to MOLE_PURGE_DEFAULT_SEARCH_PATHS; do not broaden discovery to all dot directories. The scan layer already handles hidden descendants once their parent container is known, while project roots deeper than the existing two-level probe remain intentionally out of scope.
  • Purge candidates with deployment keypair files, nested Git repositories, or Git-tracked content are protected at discovery and immediately before deletion. Use physical Git ancestry, literal pathspecs, and disabled fsmonitor hooks. The content walk runs on the tree-walk budget (MOLE_TIMEOUT_HINT_SCAN_SEC), and a probe that times out or fails is status 2, not evidence: the candidate is kept, discovery passes it through, the review step prints Could not inspect X; kept and marks the run incomplete, and the sink still refuses it. Never fold that state into a silent drop. Non-interactive purge requires explicit --yes; --dry-run remains read-only without it.
  • CLI entrypoints that source user-scoped state reject an externally root invocation before sourcing anything. Run Mole as the regular user and retain its narrow internal sudo requests; do not restore root execution of user-owned package managers or cache initialization.
  • The CoreSimulator Volumes and Cryptex roots are Apple-owned and left alone, neither deleted nor listed: mount absence does not establish ownership or obsolescence. Unused runtimes are surfaced through simctl instead. VS Code extension cleanup accepts only boolean true entries in .obsolete, never false values or strings.
  • A purge target is never a container. is_project_container rejects any basename in MOLE_PURGE_TARGETS, not a separately maintained list of package directories. A stray ~/node_modules otherwise matches the container probe on its first package's package.json, every package becomes a project root, the scan starts below the artifact, and filter_nested_artifacts never sees the parent to collapse into, so package-internal dist/ and build/ reach the delete list. Removing those leaves package.json in place, npm reports the tree as up to date, and recovery needs npm ci, which is the network restore purge promises never to require (#1459). vendor/ and Pods/ have the same shape, which is why the rule is the target list itself rather than three hand-written names. A directory that legitimately shares an artifact name stays reachable by listing it in ~/.config/mole/purge_paths, which bypasses discovery; that existing escape hatch is why this needs no new flag. The three other scan-root entry points in lib/clean/project.sh are a maintainer-authored default list, the user config file, and the consumer of discovery, so this one probe is the whole surface. Locked by the two #1459 tests in tests/purge.bats.
  • Remaining-byte review totals measure actual survivors after cleanup. A zero safe_clean result can still mean a protected or unwritable target was kept; dry-run output must not claim a final retained size. Hidden result rows must not call note_activity. Locked by tests/clean_dev_caches.bats and tests/clean_hints.bats.
  • Do not add a shell-side directory size cache. APFS does not propagate mtime up the tree, so a parent directory's mtime is unchanged when a descendant grows or shrinks and the cache hands the user a stale reclaimable number. Measure every time; get_path_size_kb is already timeout-bounded.
  • Keep shell code formatted with ./scripts/check.sh --format.
  • Prefer targeted Bats tests during development; run the full suite before committing.
  • Do not add AI attribution trailers to commits.
  • start_section / end_section / note_activity have three intentionally different implementations in lib/core/base.sh, bin/clean.sh, and bin/purge.sh. Source order decides which one wins, and the wording, color, and dry-run export semantics differ on purpose. Read the cross-reference comment in lib/core/base.sh before changing any of them.
  • Judge duplication by body and purpose, not by name. scripts/audit_function_duplication.py hashes normalized bodies and gates new same-body groups, including renamed copies a grep sweep misses. It cannot find two differently written helpers that duplicate one decision, so pair the audit with a caller-and-purpose sweep. Treat generated counts as command output, not durable prose in this file.
  • Test-orphan pattern: before declaring a symbol dead, grep lib, bin, cmd, scripts, tests, and top-level entry/install scripts; check dynamic lookup through eval, declare -f, and compgen; then re-grep after removal. Trace variables and config written by a removed helper. Tests alone are not production callers, and sub-agent reports are leads, not verdicts.
  • mole_clean_process_guard in lib/core/base.sh is the only translator of the probe tri-state (0 running, 1 not, 2 could not tell). State 2 denies; a copy that folds it into "not running" deletes a live app's files while every other copy still reads correctly in review. Compound guards call it for the process question and add their own evidence after; eligibility goes through mole_cleanup_targets_exist (predicate list must match _safe_clean_impl's), refusals through mole_report_guard_stop. Locked by mole_clean_process_guard denies on an unknown process state and cleanup delete guards do not re-implement the process-state translation. Left open-coded on purpose: the scan-stage state -eq 2 blocks pick per-section wording, and the Codex open-file probe inverts the contract.
  • A declare -f probe into bin/ is a shared shim, never a per-file copy. Asking whether safe_clean_guarded or defer_cleanup_family exists is asking whether bin/clean.sh is loaded: always in production, never in a standalone Bats case. Use mole_defer_cleanup_family, or call safe_clean_guarded directly and let the test supply it. The audited fallback cap may decrease after consolidation but needs an explicit reason to increase. Do not hoist _safe_clean_impl into lib/core/; many tests intentionally replace safe_clean, and bypassing that seam turns assertions into real deletion attempts under their fixture HOME.

Hotspot Ownership

These files are intentionally large. Do not start by splitting them. Keep edits narrow, preserve local safety boundaries, and run the listed tests when touching each area.

  • lib/clean/user.sh owns user-level cleanup flows, browser caches, cloud/app support cleanup, device firmware, and Apple Silicon caches. Run MOLE_TEST_NO_AUTH=1 bats tests/clean_user_core.bats tests/clean_browser_versions.bats tests/clean_app_caches.bats tests/clean_cached_device_firmware.bats when touching this area, or MOLE_TEST_NO_AUTH=1 ./scripts/test.sh if behavior crosses sections. Chrome / Edge / Brave old-version cleanup is one table-driven helper (_clean_chromium_old_versions) plus three thin public wrappers; the wrapper names are the test surface, so keep them. clean_edge_updater_old_versions is deliberately NOT part of it: it prunes staged updater payloads strictly older than the installed Edge (falling back to keep-latest by sort -V when the installed version is unreadable), has no Current symlink, and never escalates to a sudo removal, so folding it in would silently change its semantics.
  • lib/core/app_protection.sh owns uninstall/data/path protection policy and bundle matching; lib/core/app_protection_data.sh owns the protected app category lists. Run MOLE_TEST_NO_AUTH=1 bats tests/uninstall_safety.bats tests/uninstall_naming_variants.bats tests/bundle_resolver.bats.
  • lib/clean/project.sh owns purge discovery, project artifact filtering, purge menus, and purge config. Run MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats.
  • bin/uninstall.sh owns uninstall command orchestration, app inventory, metadata refresh, and list/json output. Run MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_scan_bash32.bats. mdls -name kMDItemDisplayName returns an app bundle's on-disk file name, never its localized name, so it always differs from app_name and always won the old selection, leaving the CFBundleDisplayName and CFBundleName branches unreachable and shipping folder names like VideoFusion-macOS to users (#1520). The name Finder shows lives in Contents/Resources/<lang>.lproj/InfoPlist.strings, read in AppleLanguages order, stopping at the first preferred language the bundle localizes at all so an English-preferring Mac never inherits another language's override. Validate any change here against NSFileManager.displayName over every installed app; the one difference that cannot be closed from the bundle is a LaunchServices regional rename such as TV to Videos. The resolved name is cached, so a resolution change also bumps MOLE_UNINSTALL_META_CACHE_FILE.
  • lib/uninstall/batch.sh owns batch uninstall execution, the shared-bundle-id sibling guard, launch service and login item teardown, and brew cask removal routing. Run MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/brew_uninstall.bats tests/uninstall_remove_file_list.bats.
  • lib/clean/dev.sh owns developer-tool cleanup, language/toolchain caches, AI agent caches, and Codex runtime handling. Run MOLE_TEST_NO_AUTH=1 bats tests/clean_dev_caches.bats tests/dev_extended.bats.
  • lib/clean/app_caches.sh owns per-app cache cleanup and the Autodesk Fusion old-bundle pruner. Run MOLE_TEST_NO_AUTH=1 bats tests/clean_app_caches.bats. Fusion deletes whole bundles, so keep its complete evidence chain: 40-hex directory, exactly one com.autodesk.fusion360 bundle, an older CFBundleVersion, owner rechecks around metadata work, and final identity binding through safe_remove. Real mode performs the candidate guard before sizing and again at the sink; dry-run rechecks after sizing because it has no sink. The Finder-alias branch is not exercised under test mode and needs a Fusion-installed Mac. Re-anchor the race tests before reducing any expensive alias resolution; the general lifecycle contract lives in .claude/skills/bugs/references/deletion-evidence-and-final-sink.md.
  • lib/optimize/tasks.sh owns optimize task registration and system maintenance actions. Run MOLE_TEST_NO_AUTH=1 bats tests/optimize.bats tests/optimize_db.bats.
  • bin/clean.sh owns clean command orchestration, section output, and safe cleanup execution. Run MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats tests/clean_apps.bats tests/cli.bats. Section output follows one fixed rhythm: title, then loading state, then content, then one trailing blank line, for every section. When touching any step of it, re-run the command and read the whole rendered output (column alignment, block spacing, icon consistency) instead of patching the one step that was reported. _safe_clean_impl skips targets that do not currently exist before expensive policy probes, then filters protected, whitelisted, and compiled-model targets before consulting the dry-run guard or registering previews. Every surviving target is revalidated at its action boundary, so preview and real cleanup keep the same eligible set.
  • lib/manage/update.sh owns self-update, registry/bootstrap replacement, and self-heal fallback behavior. Preserve fail-closed version checks and test both normal update and broken-bootstrap recovery with MOLE_TEST_NO_AUTH=1 bats tests/update.bats.
  • cmd/analyze/update.go owns the Bubble Tea Update chain and message handlers (Init, scanCmd, updateKey, goBack, switchToOverviewMode, enterSelectedDir). This is the largest file in cmd/analyze/ and the natural landing spot for new key bindings, message types, or navigation behavior. Run go test ./cmd/analyze. cmd/analyze/main.go is bootstrap only (flag parsing, main(), helpers); cmd/analyze/model.go holds types and the model struct.
  • cmd/analyze/cache.go owns analyze cache schema, expiry, load/save, invalidation, and cacheability decisions. Computation changes must invalidate stale persisted data in the same change. Run go test ./cmd/analyze.
  • cmd/analyze/analyze_test.go and cmd/status/view_test.go are test hotspots. Add new cases near related behavior; split later only when touching many adjacent cases. Run go test ./cmd/....
  • lib/core/file_ops.sh owns the deletion funnel, Trash/permanent routing, operation-log outcomes, size accounting, and last-mile path validation. lib/core/base.sh owns shared shell primitives and source-order-sensitive section helpers. Keep policy in the existing protection helpers rather than adding a second delete path. Run MOLE_TEST_NO_AUTH=1 bats tests/file_ops_mole_delete.bats tests/file_ops_size.bats tests/file_ops_safe_remove_symlink.bats tests/user_file_ops.bats tests/core_safe_functions.bats.
  • cmd/analyze/scanner.go owns disk traversal, Spotlight integration, cancellation, and all scan concurrency budgets. Treat its semaphores as independent resource limits and measure before changing them. Run go test ./cmd/analyze.
  • lib/clean/apps.sh owns application-data cleanup, orphan service discovery, and the narrow verified-container-stub exception. lib/clean/hints.sh is read-only guidance and must stay bounded, timeout-aware, and non-destructive. Run MOLE_TEST_NO_AUTH=1 bats tests/clean_apps.bats tests/clean_hints.bats.
  • lib/ui/menu_paginated.sh owns the shared Bash 3.2-compatible selection UI and terminal restoration. Preserve trap chaining, TTY restoration, and empty-selection behavior. Run MOLE_TEST_NO_AUTH=1 bats tests/menu_trap_restore.bats tests/uninstall.bats.
  • lib/core/ui.sh owns shared loading frames and inline progress updates. Keep mo_load_spinner_frames as the frame loader for clean, purge, and uninstall; preserve complete UTF-8 frames under LC_ALL=C and update a live spinner's text without restarting it. Run MOLE_TEST_NO_AUTH=1 bats tests/core_common.bats tests/clean_core.bats; the rendering contract lives in .claude/skills/bugs/references/state-accounting-and-progress.md.
  • cmd/status/view.go owns status rendering only; collection and JSON/NDJSON contracts live elsewhere in cmd/status/. Keep narrow-terminal layout and automation output independent. Run go test ./cmd/status and MOLE_TEST_NO_AUTH=1 bats tests/cli.bats when command routing changes.
  • bin/installer.sh owns installer discovery, immutable delete-plan validation, the paginated selection flow, and incomplete-cleanup exit semantics. Run MOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats.

Verification

  • Shell changes: run ./scripts/check.sh --format, then the relevant Bats test or MOLE_TEST_NO_AUTH=1 ./scripts/test.sh.
  • Go changes: run go test ./....
  • Cleanup behavior: verify with dry-run or test mode first.
  • File operation changes: run MOLE_TEST_NO_AUTH=1 bats tests/file_ops_mole_delete.bats tests/user_file_ops.bats.
  • Installer changes: run MOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats.
  • Purge changes: run MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats.
  • Whitelist or management changes: run MOLE_TEST_NO_AUTH=1 bats tests/manage_whitelist.bats tests/manage_sudo.bats.
  • Uninstall changes: run MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_remove_file_list.bats.
  • Documentation-only changes: check links and commands.
  • Read the suite's own summary line, never a count you invent. scripts/test.sh splits timing-sensitive files into sequential Bats runs whose TAP output differs from the main parallel batch. Counting one output prefix therefore undercounts a complete suite. The judges are the test runner's summary and captured exit status; reconcile any derived count against them before reporting it.
  • The core bats subset job does not build the Go helpers. .github/workflows/test.yml runs bats tests/cli.bats and friends on macos-14/15 without make build, so anything shelling out to mo analyze or mo status there reaches bin/analyze.sh's missing-binary message instead of the Go flag parser. A local run after make build hides this and reports green. Assert on behavior both binaries share, or leave those two commands to cmd/*/usage_test.go.
  • Run the suite with $TERM set. A backgrounded ./scripts/test.sh inherits no TTY, tput fails, and the bats validator dies on a broken pipe partway through, leaving a truncated log and a nonzero exit that looks like a red suite. TERM=xterm-256color MOLE_TEST_NO_AUTH=1 ./scripts/test.sh runs to completion. Classify that one as setup, not as a product failure.
  • A cancelled CI run is not a passing one. Every per-commit workflow sets cancel-in-progress: true on ${{ github.workflow }}-${{ github.ref }} (release.yml sets no concurrency), so a later push or merge on main cancels the checks still running for the previous commit. Land a fix and then merge a queued PR and the fix commit's own Check and Validation end up cancelled, which gh run list reports without any red. Verify against the sha that currently contains the change, and read --json status,conclusion rather than colors.
  • Never pipe a test, check, or CI run into tail or head. The pipeline reports the pager's exit code, so a red run reads green. Let it print in full, or capture to a file and check the status separately.
  • A Bats case that evals one function out of bin/ degrades silently when that function gains a helper. Bash does not abort on x=$(missing_command) even under set -euo pipefail: the variable is simply empty and the function keeps running, so the case still passes while exercising the pre-change fallback path. Extract the whole helper set the function calls, not just the function under test.
  • A bare [[ ... ]] inside a run ... /bin/bash <<'EOF' heredoc may assert nothing. A later successful command can make the inner script return zero, so end meaningful in-heredoc assertions with || exit 1, or print the value and assert on Bats $output. A final assertion and a line inside a mock may legitimately be bare; classify the call site before changing it. Every guard test must be observed red against the pre-fix path and green after restoration. The complete vacuous-test and shared-HOME checklist lives in .claude/skills/bugs/references/test-validity-and-refusal-diagnostics.md.

make check, make format, make test, make test-go, and make verify are wrappers around the scripts above. make verify intentionally runs check plus Go tests only; use the full Bats suite before risky cleanup, uninstall, or release work.

If golangci-lint reports issues from deleted temporary worktrees or non-existent paths, clear its local cache and rerun the linter:

golangci-lint cache clean
golangci-lint run ./cmd/...

GitHub Operations

  • Re-read the live issue or PR title, body, comments, state, labels, and author language before any public reply or closeout.
  • Keep CLI issues and Mole Mac app issues separate. A fix in mole-mac does not imply a close in this CLI repo, and a CLI fix does not prove a Mac app issue is fixed unless the Mac app release path is verified.
  • When closing a fixed bug or shipped feature, use project wording from the issue context and include the expected release path only when confirmed.
  • Mole Mac invitation: leave it off by default. A resolved defect reply is complete without it, and a paid product appended to a bug answer reads as a pitch the reporter did not ask for. Add it only when the thread itself supplies the reason: the reporter said the CLI was hard to use, asked for something that is Mole Mac's job rather than the CLI's, or is plainly not a terminal user. Never add it when the reply corrects the reporter's own misreading, when the reporter contributed the fix, when the thread already concerns Mole Mac, or on PR thank-you notes, feature requests, and questions. When it does belong, it is one final sentence kept separate from the resolution facts: 也欢迎试试我的 Mole Mac:https://mole.fit/,更易用,也更精致。 for Chinese and You’re also welcome to try my Mole Mac app at https://mole.fit/ for a more polished, easier-to-use experience. for English. When in doubt, leave it out and close the reply on the reporter's next step.
  • Discussion content cleanup: when the maintainer classifies a Discussion as cleanup-only, such as spam, an empty or accidental post, duplicate promotion, or obsolete housekeeping with no technical answer needed, close it directly without replying. Do not apply this shortcut to substantive bug reports, Q&A, feature requests, or not-planned product decisions; those still need a concise disposition before closure.
  • Remote diagnostics for unreproducible reports: for Mole Mac reports, ask the reporter to download the script with curl -fL 'https://mole.fit/downloads/Mole-Diagnose.command' -o "$HOME/Desktop/Mole-Diagnose.command", then run chmod +x "$HOME/Desktop/Mole-Diagnose.command" && open -R "$HOME/Desktop/Mole-Diagnose.command". Tell them to inspect it before double-clicking it and email the resulting Mole-Diagnose-*.zip; never ask them to attach the archive publicly because it contains local paths and logs. For CLI-only issues, prefer the relevant mo command output or mo status JSON.
  • Check for an open PR before fixing an issue yourself. Run gh pr list --state open --search '<issue number or keyword>' at the start of triage, alongside reading the code, not after a patch already exists. When a PR addresses the issue, the default path is review, maintainer-edit if needed, then squash merge; do not land an equivalent fix on main and close the contributor's PR as superseded. Self-fixing is for a stale, misdirected, or absent PR, and refusing a PR requires naming the mergeable alternative. When an issue's closeout is in scope, done means the PR is merged or properly declined, not just that main is pushed.
  • Small mechanical fixes on a contributor PR belong on the contributor's branch, not in a review comment. Once the maintainer has authorized the merge, confirm gh pr view <num> --json maintainerCanModify, run gh pr checkout <num>, make the change, and commit with the contributor as author (git -c user.name=... -c user.email=... commit, taking the address from gh pr view <num> --json author or the branch's git log --format=%ae) so the squash carries no maintainer Co-authored-by. Push with a bare git push: gh pr checkout sets branch.<name>.pushRemote to the fork, while git push origin HEAD lands an unrelated branch on this repo and the PR never sees the change. When maintainerCanModify is false, or the change is a design choice, a refactor, or work only the author can test, it stays a review comment. Two CI traps on these PRs: gh pr ready starts nothing, because every pull_request trigger here uses the default [opened, synchronize, reopened] types and excludes ready_for_review, so close and reopen the PR instead; and a first-time contributor's runs sit at action_required until approved through repos/<owner>/<repo>/actions/runs/<id>/approve.
  • Default issue closeout pipeline once a fix is confirmed: commit lands on main (that alone makes it installable via nightly), verify the fix is actually on main, then reply in the reporter's language, opening with @reporter, in short paragraphs rather than one block, with the concrete update command: mo update --nightly now, the next stable release only when that path is confirmed. Closing needs the maintainer's word, but that word covers the whole pipeline: "该回复回复,该关闭关闭" or an equivalent authorizes commit, reply, and close in one turn, so run them to the end instead of returning for a separate confirmation at each step. The closing comment should invite reopening if the problem persists.
  • Announcements are a separate artifact from the changelog: one tweet above the fold with no line break, leading with what the tool does for the user and ending with the GitHub link; public copy about the CLI never positions it against the Mac app (the CLI is free and open source, Mole Mac is the polished paid path, both appear together); WeChat is opt-in for release announcements, never included by default.

Release

Never rewrite history that a published V* tag can reach, in any editor or agent. Every descendant commit gets a new SHA, --tag-name-filter cat carries the tags onto the rebuilt commits, and GitHub writes the tag's commit SHA into archive/refs/tags/<TAG>.tar.gz as a pax global header, so the tarball's checksum changes while every file stays byte for byte identical. Anything that pinned a hash derived from that tarball is then silently wrong, starting with the homebrew-core formula. This is not hypothetical: on 2026-09-17, four days after V1.54.0 shipped, a git filter-branch --msg-filter run stripped a Co-authored-by: Cursor trailer from a commit dated 2026-05-06 that had 956 descendants, the release commit was rebuilt, the tag moved with it, and brew upgrade mole has failed the source checksum for every Intel user since, because Homebrew dropped Intel bottles so they all build from source (#1591). Before any filter-branch, filter-repo or history-rewriting rebase, list the published tags the rewritten range reaches and treat every downstream checksum derived from them as invalidated; if that list is not empty, do not rewrite. Strip AI co-author trailers when merging a pull request, never retroactively. That enumeration was finally run for the 2026-09-17 rewrite itself: 25 published tags were reachable from it, so 25 source-tarball checksums moved, not one. Only V1.54.0 broke anything, because homebrew-core pins the checksum of the formula's current version alone, and install.sh anchors on the release assets' SHA256SUMS rather than on the tag archive it downloads. Any external consumer pinning an older tag's tarball is outside what can be checked from here.

Tag-driven flow via release.yml on capital-V tag pushes. The full release runbook (distribution channels, pre-flight checklist, tag/publish commands, curated notes handoff, release-only pitfalls) lives in .claude/skills/release-flow/SKILL.md; read it before starting any release-flavored task. Notes formatting stays owned by .claude/skills/release-notes/SKILL.md. One rule that always applies: restate which distribution channels a release-flavored run will touch and confirm with the maintainer before acting; channel scope is specified by the maintainer, never inferred.