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.
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.
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.
- 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, andinstallerfocused on reclaimable files, app leftovers, rebuildable caches, installer artifacts, and exact known cleanup targets. - Keep
analyzeas a disk explorer and ad hoc cleanup surface. Optimize first paint, navigation, sorting, filtering, and safe deletion before adding dashboard-style features. - Keep
statusas 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
optimizefocused 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.
- 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
statusinto 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.
Before accepting a new feature, answer these questions in the PR, issue, or review notes when the fit is not obvious:
- Does it clearly belong to clean, uninstall, analyze, optimize, status, purge, history, installer, update, completion, touchid, or remove?
- Is it safe by default, previewable where destructive, testable without real auth, and explainable in one terminal screen?
- Can the user verify what will change before Mole changes it?
- Is the target data locally rebuildable, disposable, or backed by exact app/bundle evidence?
- 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.
AGENTS.mdis the cross-agent source of truth.CLAUDE.mdmust 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 inlib/manage/update.shand self-removal inlib/manage/remove.sh; both aresourced (notexecd) because the interactive menu and the update banner call them in-process.VERSION=stays inmolebecauseinstall.shreads it out of this file withsed.SCRIPT_DIR=must stay a single line-initial assignment inmole, with the resolver above it:install.shrewrites that line with an unanchoredsedand 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 intests/cli.batslock 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 byapp_protection.sh. Data only, no logic.cmd/analyze/- Go disk-analysis TUI.main.gois bootstrap only;model.goholds types and accessor methods;update.goholds the Bubble Tea Update chain.tests/fuzz_corpus/holds property-test corpora consumed bypath_validation_fuzz.bats.scripts/- check, test, build, and release helpers.audit_bundle_drift.shbacks the monthly bundle audit;audit_function_duplication.pygates same-body-different-name shell functions and runs insidecheck.sh(--listshows every group);audit_destructive_sinks.pyenforces explicit safety annotations on raw recursive deletion commands; per-PR perf is covered bytests/core_performance.bats.docs/SECURITY_DESIGN.md- design doc for the path validation / app protection / # SAFE annotation contract.SECURITY_AUDIT.md- security review notes.
./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.
- Route deletion through the safe helpers in
lib/core/file_ops.sh. Rawrm -rfandfind -deleteare allowed only with a# SAFE: <one-sentence reason>annotation on the same line, which is the contractdocs/SECURITY_DESIGN.mdLayer 2 defines andscripts/audit_destructive_sinks.pyenforces from bothcheck.shand.github/workflows/test.yml. Self-created mktemp files use the same annotation for directrm -f; do not route scratch paths throughmole_delete, which would add Trash routing and an operation-log entry to temporary work. - Use
mole_deletefromlib/core/file_ops.shfor removals so Trash routing, operation logs, dry-run behavior, and path protection stay consistent. - Never modify protected paths such as
/System,/Library/Apple, orcom.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=1before destructive cleanup flows. - Use
MOLE_TEST_NO_AUTH=1for tests, manual repro, and verification unless real auth behavior is being tested. - Any new direct use of
sudo,osascript, orlaunchctlmust have aMOLE_TEST_MODE/MOLE_TEST_NO_AUTHguard or be fully mocked in tests. - Never auto-delete Software Update-owned staging trees such as
/Library/Updatesor/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.BGSQLor its-wal/-shmcompanions. 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, andmole_deletemust downgrade or fail closed there; privileged Trash moves must cross into dedicated immutable root-owned staging under/Librarybefore the invoking user moves the item into Trash. install.shstays 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 tomainmust warn that this is a nightly source install. The abort cases intests/install_checksum.batspin both. Keep the README install URL on unpinnedmain: 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_lockreports stable reasons throughINSTALL_LOCK_FAILURE; unsafe-ancestor variants useINSTALL_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.batspins the branches; incident examples and test traps live in.claude/skills/bugs/references/test-validity-and-refusal-diagnostics.md. - The
mo updateself-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 frommainstraight 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), andinstall.shmust bound its own--version/--helpverification 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 olderCOMMIT_HASHwhen 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/lockfbecause the kernel drops that lock even if the holder is killed.lockfonly 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 atomicmkdirin 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 underset -uis an unbound-variable error on the bash 3.2 macOS ships. Regression tests live intests/update.batsandtests/install_checksum.bats. - Never machine-parse
plutil -p.man plutilsays 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 as1while macOS 26 and 27 printtrue, 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 as1nothing 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 namedinnerfor deletion, a wrong deletion rather than a missed one. Read the XML fromplutil -convert xml1instead, 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&last, since a key holding the literal text<arrives escaped twice. Locked by the#1512cases intests/clean_app_caches.bats, and that file now runs on the macos-14/15 compatibility job for exactly this reason. _MOLE_COMPLETE_LSOF_MODEis memoized and has no reset, so never probe it before the sudo session exists. The first call to_mole_complete_lsof_modecachesdirect,sudoorunknownfor the rest of the process and every later call short-circuits on it. Today the order is correct everywhere:bin/clean.shadopts or prompts for sudo at 1484-1507 before any cleanup step runs, andlib/uninstall/batch.shprimes at 1826 before_batch_execute_removals, whose firstremove_file_listis 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 atunknownand 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 afterensure_sudo_sessionsucceeds, not just read earlier. Note also that the mode probe itself is immune to theMO_DEBUGstderr contamination fixed elsewhere in this file, because it positively matchesp1andu0lines rather than testing the buffer for emptiness; verified with a trace-prefixed fixture and a non-root positive control.mo removepreserves non-default config roots.install.sh --configcan merge into a shared directory such as~/.local; a Mole library and familiar top-levelbin/libnames 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/moleroot to Trash. The channel receipt still follows the selected install before the default. Locked by the#1589cases intests/uninstall.batsandtests/update.bats.- Do not change ESC timeout behavior in
lib/core/ui.shunless explicitly requested. - Preserve operation logging to the project log path unless the user explicitly asks to change
MO_NO_OPLOGbehavior. - 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.
- Treat
.claude/skills/bugs/SKILL.mdas 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 shipscom.apple.bootcampassistantalongside the oldercom.apple.BootCampAssistant). When the monthly bundle drift audit reports gaps, add the exact IDs as the audit printed them, and check the runtime blanketcom.apple.*guard before rating the gap's severity. The audit workflow's issue path requires thebundle-driftlabel 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 throughgo clean -modcache. Directly consumed or mixed-state stores still stay:registry/src, Cargogit,$DENO_DIR,~/.ivy2/cache,~/.m2/repository,~/.nuget/packages,~/.cabal/packages, and~/.cpan/sources. Cargo's compressedregistry/cacheis 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_DIRis 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_profilebrowser root with ppid 1, never by the playwright-cli daemon.cli-client/session.jsspawnscliDaemon.jsdetached 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 whenpgrep -freturns exactly 1; any other status is an unknown state that keeps it. Locked bytests/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 autoremovecandidates before removal, preserve dry-run behavior, and keep tests on mockedbrew; do not let a cleanup path execute real package-manager removals in verification. - A failed third-party
brew infolookup 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#1558cases intests/brew_uninstall.bats. - An ownership check that refuses must name the app it could not classify and send the user to
brew info --cask, neverbrew list --cask. A cask brew cannot parse still lists cleanly underbrew list --cask, so that listing diagnoses nothing and leaves a multi-app batch with no way to tell which selection aborted. Locked by the#1579case intests/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 latermo cleansections. A leftover sink timeout stays sticky. Locked by the#1584cases intests/clean_apps.bats. - Join simulator data on
runtimeIdentifierfrom-joutput, never on a printed runtime name.simctl runtime listheads each image with the image version (iOS 26.4.1) whilesimctl list devicesgroups under the runtime's short name (iOS 26.4), so a name join calls every point release an orphan and hands the user asimctl runtime deletefor a runtime its simulators are still bound to. A reclaim recommendation additionally needsstate: 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#1505test intests/clean_orphaned_runtimes.bats. - Every simctl read goes through
_run_simctl.clean_dev_mobilepins its probe count intests/dev_extended.bats; a rawrun_with_timeout ... xcrun simctlbypasses the stub, so added probes stay invisible to that assertion instead of being weighed by it. - System-service orphan scans must parse plist
Program/ProgramArgumentsvalues 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/LaunchDaemonsrather than relying on/Library/PrivilegedHelperTools. - Treat a launchd plist and its
Programhelper 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
/Volumescopies, 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 toMOLE_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 printsCould not inspect X; keptand 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-runremains 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
VolumesandCryptexroots are Apple-owned and left alone, neither deleted nor listed: mount absence does not establish ownership or obsolescence. Unused runtimes are surfaced throughsimctlinstead. VS Code extension cleanup accepts only booleantrueentries in.obsolete, never false values or strings. - A purge target is never a container.
is_project_containerrejects any basename inMOLE_PURGE_TARGETS, not a separately maintained list of package directories. A stray~/node_modulesotherwise matches the container probe on its first package'spackage.json, every package becomes a project root, the scan starts below the artifact, andfilter_nested_artifactsnever sees the parent to collapse into, so package-internaldist/andbuild/reach the delete list. Removing those leavespackage.jsonin place, npm reports the tree as up to date, and recovery needsnpm ci, which is the network restore purge promises never to require (#1459).vendor/andPods/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 inlib/clean/project.share 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#1459tests intests/purge.bats. - Remaining-byte review totals measure actual survivors after cleanup. A zero
safe_cleanresult 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 callnote_activity. Locked bytests/clean_dev_caches.batsandtests/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_kbis 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_activityhave three intentionally different implementations inlib/core/base.sh,bin/clean.sh, andbin/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 inlib/core/base.shbefore changing any of them.- Judge duplication by body and purpose, not by name.
scripts/audit_function_duplication.pyhashes 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 througheval,declare -f, andcompgen; 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_guardinlib/core/base.shis the only translator of the probe tri-state (0running,1not,2could not tell). State2denies; 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 throughmole_cleanup_targets_exist(predicate list must match_safe_clean_impl's), refusals throughmole_report_guard_stop. Locked bymole_clean_process_guard denies on an unknown process stateandcleanup delete guards do not re-implement the process-state translation. Left open-coded on purpose: the scan-stagestate -eq 2blocks pick per-section wording, and the Codex open-file probe inverts the contract.- A
declare -fprobe intobin/is a shared shim, never a per-file copy. Asking whethersafe_clean_guardedordefer_cleanup_familyexists is asking whetherbin/clean.shis loaded: always in production, never in a standalone Bats case. Usemole_defer_cleanup_family, or callsafe_clean_guardeddirectly 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_implintolib/core/; many tests intentionally replacesafe_clean, and bypassing that seam turns assertions into real deletion attempts under their fixtureHOME.
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.showns user-level cleanup flows, browser caches, cloud/app support cleanup, device firmware, and Apple Silicon caches. RunMOLE_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.batswhen touching this area, orMOLE_TEST_NO_AUTH=1 ./scripts/test.shif 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_versionsis deliberately NOT part of it: it prunes staged updater payloads strictly older than the installed Edge (falling back to keep-latest bysort -Vwhen the installed version is unreadable), has noCurrentsymlink, and never escalates to a sudo removal, so folding it in would silently change its semantics.lib/core/app_protection.showns uninstall/data/path protection policy and bundle matching;lib/core/app_protection_data.showns the protected app category lists. RunMOLE_TEST_NO_AUTH=1 bats tests/uninstall_safety.bats tests/uninstall_naming_variants.bats tests/bundle_resolver.bats.lib/clean/project.showns purge discovery, project artifact filtering, purge menus, and purge config. RunMOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats.bin/uninstall.showns uninstall command orchestration, app inventory, metadata refresh, and list/json output. RunMOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_scan_bash32.bats.mdls -name kMDItemDisplayNamereturns an app bundle's on-disk file name, never its localized name, so it always differs fromapp_nameand always won the old selection, leaving theCFBundleDisplayNameandCFBundleNamebranches unreachable and shipping folder names likeVideoFusion-macOSto users (#1520). The name Finder shows lives inContents/Resources/<lang>.lproj/InfoPlist.strings, read inAppleLanguagesorder, 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 againstNSFileManager.displayNameover 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 bumpsMOLE_UNINSTALL_META_CACHE_FILE.lib/uninstall/batch.showns batch uninstall execution, the shared-bundle-id sibling guard, launch service and login item teardown, and brew cask removal routing. RunMOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/brew_uninstall.bats tests/uninstall_remove_file_list.bats.lib/clean/dev.showns developer-tool cleanup, language/toolchain caches, AI agent caches, and Codex runtime handling. RunMOLE_TEST_NO_AUTH=1 bats tests/clean_dev_caches.bats tests/dev_extended.bats.lib/clean/app_caches.showns per-app cache cleanup and the Autodesk Fusion old-bundle pruner. RunMOLE_TEST_NO_AUTH=1 bats tests/clean_app_caches.bats. Fusion deletes whole bundles, so keep its complete evidence chain: 40-hex directory, exactly onecom.autodesk.fusion360bundle, an olderCFBundleVersion, owner rechecks around metadata work, and final identity binding throughsafe_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.showns optimize task registration and system maintenance actions. RunMOLE_TEST_NO_AUTH=1 bats tests/optimize.bats tests/optimize_db.bats.bin/clean.showns clean command orchestration, section output, and safe cleanup execution. RunMOLE_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_implskips 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.showns self-update, registry/bootstrap replacement, and self-heal fallback behavior. Preserve fail-closed version checks and test both normal update and broken-bootstrap recovery withMOLE_TEST_NO_AUTH=1 bats tests/update.bats.cmd/analyze/update.goowns the Bubble TeaUpdatechain and message handlers (Init, scanCmd, updateKey, goBack, switchToOverviewMode, enterSelectedDir). This is the largest file incmd/analyze/and the natural landing spot for new key bindings, message types, or navigation behavior. Rungo test ./cmd/analyze.cmd/analyze/main.gois bootstrap only (flag parsing,main(), helpers);cmd/analyze/model.goholds types and the model struct.cmd/analyze/cache.goowns analyze cache schema, expiry, load/save, invalidation, and cacheability decisions. Computation changes must invalidate stale persisted data in the same change. Rungo test ./cmd/analyze.cmd/analyze/analyze_test.goandcmd/status/view_test.goare test hotspots. Add new cases near related behavior; split later only when touching many adjacent cases. Rungo test ./cmd/....lib/core/file_ops.showns the deletion funnel, Trash/permanent routing, operation-log outcomes, size accounting, and last-mile path validation.lib/core/base.showns shared shell primitives and source-order-sensitive section helpers. Keep policy in the existing protection helpers rather than adding a second delete path. RunMOLE_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.goowns disk traversal, Spotlight integration, cancellation, and all scan concurrency budgets. Treat its semaphores as independent resource limits and measure before changing them. Rungo test ./cmd/analyze.lib/clean/apps.showns application-data cleanup, orphan service discovery, and the narrow verified-container-stub exception.lib/clean/hints.shis read-only guidance and must stay bounded, timeout-aware, and non-destructive. RunMOLE_TEST_NO_AUTH=1 bats tests/clean_apps.bats tests/clean_hints.bats.lib/ui/menu_paginated.showns the shared Bash 3.2-compatible selection UI and terminal restoration. Preserve trap chaining, TTY restoration, and empty-selection behavior. RunMOLE_TEST_NO_AUTH=1 bats tests/menu_trap_restore.bats tests/uninstall.bats.lib/core/ui.showns shared loading frames and inline progress updates. Keepmo_load_spinner_framesas the frame loader for clean, purge, and uninstall; preserve complete UTF-8 frames underLC_ALL=Cand update a live spinner's text without restarting it. RunMOLE_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.goowns status rendering only; collection and JSON/NDJSON contracts live elsewhere incmd/status/. Keep narrow-terminal layout and automation output independent. Rungo test ./cmd/statusandMOLE_TEST_NO_AUTH=1 bats tests/cli.batswhen command routing changes.bin/installer.showns installer discovery, immutable delete-plan validation, the paginated selection flow, and incomplete-cleanup exit semantics. RunMOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats.
- Shell changes: run
./scripts/check.sh --format, then the relevant Bats test orMOLE_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.shsplits 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.ymlrunsbats tests/cli.batsand friends on macos-14/15 withoutmake build, so anything shelling out tomo analyzeormo statusthere reachesbin/analyze.sh's missing-binary message instead of the Go flag parser. A local run aftermake buildhides this and reports green. Assert on behavior both binaries share, or leave those two commands tocmd/*/usage_test.go. - Run the suite with
$TERMset. A backgrounded./scripts/test.shinherits no TTY,tputfails, 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.shruns to completion. Classify that one as setup, not as a product failure. - A
cancelledCI run is not a passing one. Every per-commit workflow setscancel-in-progress: trueon${{ github.workflow }}-${{ github.ref }}(release.ymlsets noconcurrency), so a later push or merge onmaincancels the checks still running for the previous commit. Land a fix and then merge a queued PR and the fix commit's ownCheckandValidationend upcancelled, whichgh run listreports without any red. Verify against the sha that currently contains the change, and read--json status,conclusionrather than colors. - Never pipe a test, check, or CI run into
tailorhead. 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 ofbin/degrades silently when that function gains a helper. Bash does not abort onx=$(missing_command)even underset -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 arun ... /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-HOMEchecklist 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/...- 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-macdoes 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 andYou’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 runchmod +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 resultingMole-Diagnose-*.zip; never ask them to attach the archive publicly because it contains local paths and logs. For CLI-only issues, prefer the relevantmocommand output ormo statusJSON. - 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 onmainand 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 thatmainis 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, rungh pr checkout <num>, make the change, and commit with the contributor as author (git -c user.name=... -c user.email=... commit, taking the address fromgh pr view <num> --json authoror the branch'sgit log --format=%ae) so the squash carries no maintainerCo-authored-by. Push with a baregit push:gh pr checkoutsetsbranch.<name>.pushRemoteto the fork, whilegit push origin HEADlands an unrelated branch on this repo and the PR never sees the change. WhenmaintainerCanModifyis 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 readystarts nothing, because everypull_requesttrigger here uses the default[opened, synchronize, reopened]types and excludesready_for_review, so close and reopen the PR instead; and a first-time contributor's runs sit ataction_requireduntil approved throughrepos/<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 onmain, then reply in the reporter's language, opening with@reporter, in short paragraphs rather than one block, with the concrete update command:mo update --nightlynow, 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.
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.