8000 GitHub - gitmoot/workspace-janitor: Safe, Jev-assisted workspace hygiene for developer and AI-agent machines · GitHub
[go: up one dir, main page]

Skip to content

About

Safe, Jev-assisted workspace hygiene for developer and AI-agent machines

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

209 Commits

Folders and files

Repository files navigation

workspace-janitor

Safe, Jev-assisted workspace hygiene for developer and AI-agent machines.

janitor inventories what is on a machine, applies deterministic safety rules, and produces reversible cleanup plans. It is not an autonomous deleter: generic deletion requires quarantine and a separate confirmed expiry; a dedicated provider cache may instead use an explicitly approved, confirmed official prune.

Operator guides: threat model, privacy and outbound Jev example, policy reference and isolated examples, recovery and restore, contributing and security reporting, and Linux release-readiness checklist. The checklist prepares an artifact; a pushed vX.Y.Z tag publishes a GitHub release of it (see the checklist's GitHub releases section). Deployment requires separate owner approval.

Status

This repository implements inventory, safety, planning, the optional Jev advisor, and reversible quarantine and restore. The local SQLite journal and manifest retain a record of each move; deletion is separate and disabled by default.

Command State
janitor scan implemented (reports safety verdicts)
janitor history [--confirm] preview/trim unreferenced old scan snapshots; no SQLite file compaction
janitor watch Linux top-level watcher; inventory and guidance only
janitor cycle Linux daily one-shot metadata scan, weekly deep scan when due, disk alerts; expiry opt-in
janitor service generate writes user service/timer files to an explicit directory; never installs them
janitor status implemented
janitor policy check implemented
janitor doctor implemented
janitor version implemented
janitor plan implemented
janitor explain implemented
janitor apply --quarantine implemented (dry-run by default)
janitor apply --prune --action <id> implemented for explicitly dedicated uv/npm caches (dry-run by default, Linux only)
janitor apply --expire implemented (deletion disabled by default)
janitor restore <cleanup-id> implemented (preview by default)
janitor archive --rule <name> Linux exact-file evidence archival, integrity verification and restore; no source/archive deletion
janitor archive --workspace <name> opt-in Linux copy-only verified tar of one released directory (plus a HEAD bundle for linked worktrees), integrity verification and restore; no source/archive deletion. With local_expiry_enabled, quarantine/expiry may remove the local copy only while the pinned archive exactly matches it

Inventory

janitor scan records what is on the machine and what is using it. It makes no decisions and mutates nothing.

Collector What it reads Bounds
filesystem lstat metadata for each root's children per-root depth, per-directory entries, total entries
deep_size recursive directory sizes, opt-in via --deep-size per-root entry/depth bounds; every incomplete size is a lower bound, and unreadable or unobserved content also carries unknown evidence
git common dir, remote, branch, HEAD, dirty count, stashes, commits on no remote-tracking branch, last activity, worktree metadata, locks per-command timeout, read-only verbs only
processes cwd and exe links plus comm from procfs; descriptor link targets only when exact evidence exports are configured directory-entry bound; no arguments, environments or descriptor contents
agents registered-agent directories from an adapter command timeout per adapter
gitmoot read-only Gitmoot job, task, and cleanup-obligation ledger for exact paths command timeout, entry bound; missing or uncertain state fails closed
services systemd WorkingDirectory/Exec*, cron command paths, PM2 pm_cwd/pm_exec_path per-file byte bound, per-directory entries
evidence_exports approved manifest, exact export header/metadata and archive identity; full content verification before mutation at most 64 rules, explicit byte ceiling, command timeout; existing protections stay blocking
workspace_archives pinned workspace archive manifest and the released tree's member list (paths, types, sizes, modes, owners, mtimes, link targets); full archive and per-file content verification before mutation; live-process and dependent re-checks at most 64 rules, member bound from the pinned manifest, command timeout; existing protections stay blocking

Three properties hold across all of them:

  • Filesystem discovery is top-level and metadata-only by default. No discovered directory is walked recursively unless --deep-size asks for it. Reference collectors may read local Gitmoot and service metadata. Explicit evidence-export rules also inspect their approved manifests and file headers; they never authorize a whole-directory archive.
  • Unknown state fails closed. A collector that cannot observe something records unknown: evidence and, where the safety contract requires it, a blocking protection. A Git command that times out protects the path; it never reports the repository clean. An unlistable directory protects its parent. A symlink the policy may not resolve is ambiguous, so it is protected.
  • Process secrets remain unread. The process collector reads neither cmdline nor environ; the service collectors skip Environment, EnvironmentFile, and crontab assignments; and no Git command may touch the network — the collector runs a fixed allowlist of local read-only subcommands.

Publication checks ignore Git replacement graphs. Primary repositories include all local refs and saved operation heads; retained submodule metadata is checked for stashes and unpublished commits, including deinitialized submodules. An unreadable or oversized metadata namespace stays protected. Apply fingerprints include Git last activity, so an index/reflog-only change invalidates an old plan.

--no-store makes a scan reporting-only: it creates no database, and when one already exists it is opened strictly read-only — no migration, no permission change, no journal-mode change. A database this build cannot read that way is reported in prior_comparison and the comparison is skipped rather than upgraded silently.

Every reference collector is bounded by limits.command_timeout in wall clock, enforced by the scan rather than by the collector's cooperation, so a stalled filesystem or an adapter that ignores cancellation cannot hang a scan; it becomes a partial report with an unknown.

Safety

The safety engine is the only authority on whether a path may be mutated. Classification, policy, and any model answer are inputs to planning; none of them can clear a protection. janitor scan reports a typed verdict per entry, and every refusal carries its evidence and the remedy that would clear it.

Guard Refuses when
collected_protections a collector observed an active process, registered agent, or service reference
unknown_evidence any collector left an unknown: observation
filesystem_identity device and inode are unknown, so nothing can be revalidated
git_state dirty files, stashes, unpublished or unverifiable commits, broken metadata, or a lock
symlink_containment a symlink is unresolved or resolves outside its root
protected_paths the entry overlaps a protected path, the state directory, or the quarantine directory
sensitive_names the name matches a protected pattern, without ever reading the value
live_databases the file looks like a database that may have an open writer
durable_evidence the entry is classified as durable evidence
job_ownership a running, queued, or blocked job claims the path
quarantine_filesystem the destination is on another filesystem with no copy policy, or cannot be inspected
free_space a cross-filesystem copy would exceed the destination's headroom, or its size is unmeasured

Space is only checked where it can be spent: a same-filesystem quarantine is a rename and consumes none. A cross-filesystem copy needs a real size, so a directory whose size was never measured — the default, since deep sizing is opt-in — is refused rather than approved against its four-kilobyte directory entry. Same-filesystem quarantine also frees no payload space: the bytes remain on that filesystem until an independently authorized expiry removes them.

Four properties hold:

  • Deterministic and typed. The same evidence always yields the same verdict, with protections in the same order, in JSON and in the terminal.
  • Fail closed. Unknown, degraded, or unobserved state protects the path. Absence of a signal is never read as absence of risk.
  • Model output cannot weaken a protection. A recommendation for a refused path is clamped to investigate with the refusal recorded in its reasons. Policy is the same: it can add protection, never remove it, and there is no switch that disables a core invariant.
  • Revalidated before mutation. A verdict describes one moment. Apply must re-run every guard against a fresh observation immediately before mutating, and anything that changed since planning refuses outright.

Why revalidation uses an open handle

Comparing paths and metadata is not enough to close the time-of-check to time-of-use window. Measured on ext4 while building this engine: removing a directory and recreating it under the same name reused the inode and reported identical device, inode, and all three timestamps, so every metadata comparison saw no change. A handle opened with O_PATH refers to one kernel object and reports it as unlinked, which is how a replacement is caught. Apply is expected to open the object, revalidate through the handle, and mutate relative to it.

Each scan stores its inventory, the metadata fingerprint of every entry, and a report per collector saying whether it ran, was partial, failed, or was skipped. A later scan compares fingerprints with the previous one and marks each entry unchanged, changed, or new.

Build

make build        # dist/janitor, static, CGO_ENABLED=0
make check        # gofmt check, vet, build, test

The SQLite driver is pure Go (modernc.org/sqlite), so the binary is static and needs no C toolchain.

Usage

janitor --help
janitor doctor                 # create directories, open the store, report checks
janitor status                 # resolved paths, policy source, stored state
janitor policy check           # validate the policy, print the effective policy
janitor scan                   # inventory the configured roots
janitor scan /repos --deep-size    # one root, with bounded recursive sizes
janitor scan --no-git --no-processes --no-services   # filesystem metadata only
janitor --format json scan     # machine-readable inventory and collector reports
janitor history              # read-only count of old, unreferenced scan snapshots
janitor history --confirm    # discard only those snapshots; does not shrink SQLite file

Weekly stale-workspace report (opt-in through report.enabled): a host timer runs janitor report stale --role <role> --format text and sends the output to each owning role as a Gitmoot message. Items with an unknown owner go to report.coordinator_ 8000 role. Owners reply with janitor report release <path>... --role <role> or janitor report keep <path>... --role <role> [--until DATE]. One call may name several paths; it records nothing unless every path is accepted. With the default release_action: none, a release is only recorded. With archive, the daily cycle archives released items that have not changed through the verified category path. See policy reference.

Scratch cleanup (opt-in through scratch_cleanup): janitor scratch previews which idle, unused entries of a scratch directory such as /tmp would be skipped, deleted as regenerable, or archived and then deleted. The skip, regenerable and sensitive-name lists all come from the policy file. --dry-run=false --confirm acts, and janitor cycle runs each directory once per every. janitor scratch restore <run> <entry> verifies one archived entry, and with --dry-run=false --confirm restores it. See policy reference.

Exit codes

Code Meaning
0 the command completed
1 the command ran and failed
2 the invocation or configuration was rejected before any work
3 the command exists in the contract but is not implemented in this build

Planning

janitor plan turns one scan into typed actions: keep, relocate, quarantine, investigate, and — only where an operator wrote a rule saying so — delete_candidate. The built-in policy never recommends a direct deletion.

Provider adapters recognize selected uv, npm, pnpm, Bun, Gradle, Go, Playwright, and Puppeteer cache roots, plus project-local virtualenvs, dependencies, and derived outputs. Plans name the reinstall or rebuild cost. Shared provider caches default to investigate; a mutating recommendation needs an exact operator cache rule. max_bytes applies only above the stated logical byte count; ttl requires the newest content to be old enough. Either bound requires a complete scan --deep-size for directories. Unmeasured or partial evidence is not treated as expired. Gitmoot-managed paths stay under Gitmoot's cleanup ledger: even a final owner with a live obligation is only reclaimable through Gitmoot, never by a generic janitor action.

Rules run in precedence order, and the first tier that has an opinion decides:

Tier Source
safety a refusal from the safety engine; nothing overrides it
protected protected paths and canonical roots
explicit rules the operator wrote, such as cache rules
builtin the built-in classification defaults
fallback investigate, when nothing else applied

Within a tier, the safer action wins and the losing rule is reported as a conflict. File order is never consulted: rules sort by tier, then specificity, then name, so reordering a policy file cannot change a decision.

A plan is bound to the scan, evidence digest, policy digest, advisor, and well-formed advice considered (even if rejected), and is immutable once stored. Re-planning unchanged inputs produces the same plan id and reuses the stored plan; using a plan whose evidence or policy has changed is refused. Approval is recorded beside the plan:

janitor plan                              # build and store a plan
janitor plan --approve <action-id|path>   # approve one mutating action
janitor plan --approve-all                # approve every mutating action
janitor explain /path/to/thing            # trace one decision

explain prints the winning rules and their reasons, the alternatives that were rejected and why, the collected evidence, the safety verdict, and whether the action is approved.

Quarantine, restore, and expiry

apply requires a stored plan with individually approved mutating actions. It checks the latest scan and policy binding, then recollects each target and reruns the safety guards immediately before moving it. Approved overlapping paths are refused as a batch. A destination on another filesystem is always refused in this version; there is no copy-and-delete fallback. Filesystem mutation currently runs on Linux; other platform builds refuse the action because their no-replace and anchored-delete primitives are not implemented. An approved relocate action is refused rather than silently sent to quarantine; relocation is not part of this command.

janitor apply --quarantine                         # preview approved moves
janitor apply --quarantine --confirm --dry-run=false
janitor restore <cleanup-id>                       # preview recovery
janitor restore --confirm <cleanup-id>             # refuse occupied originals
janitor apply --expire                             # inspect retained receipts

The cleanup id appears in apply output. Each action has a manifest beside its quarantined object and a durable SQLite transition journal. A stopped batch can resume by repeating the confirmed apply, or restore each completed move with the cleanup id. Linked worktrees move through Git so its administrative links remain valid. Renames preserve item permissions, timestamps, and symlink identity; no destination or restored source is overwritten. A prepared receipt that never moved can be cancelled with the same restore command, releasing its source for a new plan.

Official provider pruning

An approved delete_candidate for an exact, dedicated cache may use janitor apply --prune --action <id> instead of generic quarantine. This irreversible operation is limited to the configured absolute uv or npm binary on Linux. Set dedicated: true and official_binary only when the cache is exclusively owned and idle. Preview is the default; confirmed execution requires --confirm --dry-run=false, prints the exact argv before execution, takes a lock, journals the attempt and outcome, and enforces limits.command_timeout on the provider process group. The provider receives offline settings and no inherited credential environment (not a network isolation boundary); janitor does not infer ownership from a cache-shaped path. Other provider commands are guidance only.

Quarantine dry-runs show an expected physical reclaim estimate where it can be measured. Sparse holes, external hardlinks, and nested selected roots do not count as independently reclaimable bytes; unavailable estimates are reported as unavailable, not zero.

Expiry is not deletion. To explicitly enable it, set retention.delete_enabled: true, wait for the item's retention period, then run janitor apply --expire --confirm --dry-run=false as a separate invocation. It rechecks the quarantined object, original source, live references, Git state, and recorded fingerprint before anchored deletion. Changed or newly referenced items enter investigate; they are not deleted. Once the blocking evidence is resolved, a separately confirmed expiry retries the full safety evaluation before returning an investigate item to quarantine and considering deletion. It can also be restored while under investigation.

Prevention without autonomous cleanup

janitor watch monitors only the immediate children of configured roots. It coalesces create, rename, and delete events into bounded whole-root inventory scans; no event directly moves or deletes anything. A newly discovered review clone gets canonical-location guidance, not relocation. Newly created directories are revisited after two seconds so a clone whose .git metadata arrives after its directory can receive guidance. Startup and inotify overflow trigger full reconciliation; partial collector results remain unknown rather than proving a deletion. Event-triggered scans are metadata-only and never advance the deep-scan clock.

Repeated events on an already-present, explicitly protected top-level directory (such as .gitmoot) do not each persist a scan while its directory identity and mode stay unchanged. The first suppressed event emits a reason: "deferred" JSON diagnostic with incomplete: true, no scan_id, and next_reconcile_at; a full reason: "churn" scan follows within one hour and reports deferred_events. A genuinely new, removed, replaced, or permission-changed top-level path still triggers an immediate full scan. Startup, inotify overflow, and two-second settling still reconcile. The hourly bound applies to protected-directory churn alone: overflow or real changes can require more scans. Protected metadata may be up to one hour stale between scans; safety protections remain in force, and the daily cycle does its own full scan. Completed scans keep a bounded recent history (256) plus scans pinned by audit records or the two latest completed deep-size scans. The janitor history command previews eligible scan and inventory row counts; trimming runs in a transaction and never deletes a referenced plan/action, cycle, advisory attempt, model usage or cleanup receipt. Row deletion leaves SQLite pages allocated; freeing filesystem bytes requires a separately coordinated offline compaction after a durable read-back-verified backup and with every writer stopped. Neither history trimming nor compaction changes filesystem cleanup policy. Overflow and real changes can still make the watcher expensive; monitor its scan rate and disk use.

On first upgrade, a large backlog trim can hold SQLite's write lock longer than its five-second busy timeout. Stop watch and cycle writers, take and read back a durable backup, then run janitor history --confirm before restarting them; otherwise a competing scan can fail. Invalid collector documents remain pinned for investigation rather than blocking every new scan or being silently discarded. A newly persisted scan is also pinned during its own trim if host time moves backwards.

janitor cycle is a one-shot timer target: each invocation records a metadata scan, or a deep scan if prevention.deep_interval has elapsed since the last successful scheduled deep scan (default seven days). collectors.deep_size controls ad-hoc scan, not the scheduled cycle: a due cycle explicitly runs deep-size collection even when that collector is false in policy. To avoid scheduled deep scans, do not install the timer; use janitor scan --no-deep-size for metadata-only inventory. prevention.min_free_bytes or prevention.min_free_percent triggers a per-filesystem alert. It reports potential physical bytes by reclaimable, protected, and unknown class; a bounded or uncertain estimate is unmeasured, not zero. Alerts are deduplicated per filesystem by a durable SQLite cooldown, default 24 hours. Only configured inventory roots are measured.

Optional prevention.targets adds persisted per-filesystem pressure state; see capacity target fields. scan, cycle, and the watcher's independent timer share a bounded sample and durable hysteresis. janitor --format json status exposes data.capacity without sampling. Missing, stale, failed, or policy-mismatched observations are unknown and hold admission rather than reporting health. This contract is advisory: it does not yet pause jobs, authorize cleanup, or replace the independent dispatch disk guard.

Status, history and daily cycle reports also include comparable inventory growth, declared budgets and owners, recent operation measurements and reasons recovery stopped. Unknown measurements remain null; filesystem availability changes are not attributed solely to Janitor. See capacity reporting and the admission hook for field meanings, reporting bounds and the default-off consumer contract.

Separate opt-in bounded cache recovery uses exact owner-approved retired cache rules and existing official prune adapters. It stops at target, store/byte/time budgets, or provider failure; unknown ownership, active/queued producers, and incomplete evidence remain protected. Reports separate store allocation from net filesystem movement. Exhausted safe recovery reports no_safe_reclaim with an admission hold; durable cooldowns prevent automatic erase/rebuild loops. No live cache is enabled merely by configuring a free-space target.

Automatic expiry is off by default. To opt in, set both retention.delete_enabled: true and prevention.auto_expire: true. The cycle then calls the same confirmed apply --expire path as an operator: process lock, fresh reference collection, original-path and receipt checks, and anchored deletion. It reports each expiry outcome; an expired timestamp alone is not permission. All confirmed apply and restore operations share one cross-process lock on Linux; a second process fails closed instead of racing. On other platforms confirmed apply remains unsupported, while confirmed restore retains its prior behavior without this Linux-only lock.

Expiry checks references at or under the original path, not its siblings: a home directory's parent always has live processes. A directory recreated at the original path, such as a cache a service regenerates, is a new object: expiry neither deletes it nor is blocked by it, unless something references a path inside it. A recreated original identical to the quarantined copy (same tree, types, modes, owners, symlink targets and file bytes) makes the copy redundant: expiry removes only the copy, under every guard on the copy, and records superseded_by_original even while the original is referenced. See recovery.

Automatic quarantine is also off by default. With prevention.auto_quarantine: true the cycle plans its own scan from rules alone, approves every quarantine action that overlaps no other, and applies them through the same revalidating path. Items stay restorable with janitor restore <cleanup-id> until their retention expires. Rules propose quarantine only for caches, generated artifacts, and linked worktrees that are clean, have every commit on a remote-tracking branch, and have had no checkout, staging, commit, or top-level change for seven days. A home .cache directory is never one cache; select its tools with cache rules. The cycle holds the scan lock across scan, plan, and quarantine, preventing a watcher scan from invalidating that transaction. A stale manual plan still fails closed and must be rebuilt and approved from the current scan. Automatic approval skips paths overlapping an active cleanup receipt, including parents and children; plan.skipped and the cycle's quarantine_report explain those skips.

janitor service generate --output /absolute/private/directory --binary /absolute/janitor writes units without installing or enabling them: the offline janitor-watch.service, janitor-cycle.service, and janitor-cycle.timer (>), plus a separate janitor-advisory.service and uninstalled janitor-cycle-advisory.conf drop-in template. Both offline services clear OPENROUTER_API_KEY and request IPAddressDeny=any; watch and cycle never call the model. The OS enforces the network boundary for installed units; direct invocations do not acquire it.

Jev advice

Rules-only planning is the default and is complete on its own. With jev.enabled: true and an OpenRouter key in the environment variable named by jev.api_key_env (OPENROUTER_API_KEY by default), plan offers entries the rules left ambiguous — and only those, after every rule has run — to Jev's rolling alias ~typesafe/jev-latest through https://openrouter.ai/api/v1/systemone. The direct TypeSafe endpoint is rejected. A missing key is not an error: the plan is built from rules alone and says so. The CLI reads process environment; it does not load dotenv files itself. Supply only the named variable to a future live process through the host's secure environment mechanism. Explicit older jev.model pins must be changed to ~typesafe/jev-latest; other identifiers are rejected. The requested alias and provider-returned model are recorded separately. Existing historical decisions are not rewritten; the changed request model naturally misses old pinned cache keys. Rolling releases do not change deterministic safety thresholds or grant cleanup authority.

What is sent is an allowlisted projection, never the entry itself: a <root>/-relative path with configured, protected, and secret-looking segments redacted; kind, depth, a size bucket, and age in days; Git counts and booleans; evidence signal names; and protection kinds. Remotes, branches, commit ids, evidence details, symlink targets, owners, and absolute paths are never sent.

Each entry gets four typed questions: its class, an action, a retention, and the probability that moving it would lose work. The action options are only keep, quarantine, and investigate. A missing, malformed, or low-confidence answer, or one judged unsafe, becomes investigate. Every answer then goes through the safety engine and is applied only if it is safer than the rules' decision; a rejected proposal is recorded in the trace.

janitor plan --jev-dry-run   # print the exact requests; send and store nothing
janitor plan --jev-debug     # also print the requests that were sent
janitor plan --no-jev        # rules only for this run

Requests are batched within max_batch and max_state_tokens and under a conservative 28k-token total estimate for Jev's 32k context. They are paced by min_interval, bounded by timeout, and retried up to max_retries times on 429, 529, 5xx, and transport errors, honoring Retry-After. Redirects are refused; a rejected key or request is not retried. After breaker_failures consecutive failures the run stops calling the model and plans the rest from rules. Well-formed answers are cached by entry fingerprint, request schema, model, and policy for cache_ttl, so re-planning unchanged entries sends nothing; malformed answers are retried on the next plan.

Usage is recorded for every answered request, even with --no-store. plan shows the run's tokens and estimated cost, and status shows the totals. Cost is an estimate: reported input tokens times price_per_mtok_usd.

Opt-in daily advisory (review only)

janitor advisory run requires jev.enabled: true and a successful cycle stored on the same UTC day. It does not modify the watcher or cycle; a standalone scan is insufficient. The run reads only OPENROUTER_API_KEY from an owner-only regular --key-file (default /root/.env) through no-follow file descriptors. It rejects symlinks, unsafe ownership/mode, malformed or duplicate keys, and never sources the file or loads other variables. Manual plan still uses its existing environment-based credential behavior.

At most one run starts per UTC day. SQLite reserves each HTTP attempt before sending, including retries, and never refunds errors or crashes: 7 attempts, 140 offered entries counted per attempt, 100000 estimated input tokens and $0.01 estimated input cost. An unknown/understated configured price, missing key, failed cycle or spent budget blocks calls. Partial inventories label the report incomplete. A report-only discovery root still forbids mutation, but its root protection alone does not block sanitized review-only advice for an otherwise safe ambiguous entry. Sensitive, active, protected-path and unknown entries remain excluded. Failed/partial global reference collectors, including Gitmoot's ledger, omit all entries; per-entry unknown evidence omits only affected entries. Zero candidates send nothing. Restart/concurrent launches cannot reopen a running or interrupted day's claim. Systemd unit aliases count as known only when a bounded, unchanged chain reaches a unit already scanned under a configured definition root (or the exact /dev/null mask). Broken, escaping, looping or changed aliases keep the service collector partial and therefore block all outbound advice. On non-Linux builds, configured systemd roots are not a supported deployment path: a concurrent FIFO swap before opening a unit can block the scanner. The reserved day is rechecked immediately before each HTTP transport attempt, with a request deadline at UTC midnight. A pause after reservation cannot start a new-day request against the old day's budget. A newer inventory supersedes the completed cycle; the advisory then stops rather than consulting stale evidence. If a provider response lacks input-token usage, the advisory rejects that answer and stops later batches; it never treats an unmeasured cost as zero.

janitor advisory report [--day YYYY-MM-DD] reads the private state database's review-only report: scan binding, skipped/unknown/budget counts, suggested recommendations, errors, reservations and provider-reported token usage. An interrupted run overlays the durable reservation ledger rather than the stale claim-time JSON, and marks provider usage unknown; reserved input cost is not an actual bill. Advisory execution refuses a state directory not owned privately by the current user, including a symlink or group/world-accessible directory. The scheduled command logs only counts, not paths. The report contains no approval or executable apply command; deterministic guards remain in force. Both token-to-cost figures are estimates, not exact billing. Root-relative metadata may still identify private work after heuristic redaction. 81A2

Host rollout is separate and requires operator approval. After reviewing the exact binary, policy, key-file owner/mode and generated units, the operator may install janitor-advisory.service as a user unit and install janitor-cycle-advisory.conf specifically at janitor-cycle.service.d/advisory.conf, then reload systemd. Installing only the three offline units does not opt in. The optional drop-in starts advice via > only after a successful cycle; the CLI also checks the persisted same-day cycle. Do not add an EnvironmentFile, enable the advisory unit independently, or change watch/cycle's offline restrictions. The operator owns the first bounded live run and checks the actual service result, provider usage and private report. Public releases and automatic cleanup remain held.

Configuration

Locations follow the XDG base directory specification, and each one can be overridden explicitly:

Path Default Overrides, highest first
config dir $XDG_CONFIG_HOME/workspace-janitor or ~/.config/workspace-janitor --config-dir, JANITOR_CONFIG_DIR, XDG_CONFIG_HOME
state dir $XDG_STATE_HOME/workspace-janitor or ~/.local/state/workspace-janitor --state-dir, JANITOR_STATE_DIR, XDG_STATE_HOME
cache dir $XDG_CACHE_HOME/workspace-janitor or ~/.cache/workspace-janitor --cache-dir, JANITOR_CACHE_DIR, XDG_CACHE_HOME
policy file <config dir>/policy.yaml --policy, JANITOR_POLICY

The state directory holds janitor.db and the quarantine directory. All of them, including the quarantine directory, are created by janitor doctor with owner-only permissions. janitor status reports which source each path came from.

If no home directory and no overrides are available, the run fails with field-level errors rather than guessing a location.

A run with every path overridden needs no home directory, but the built-in defaults derive their single discovery root from the home directory. Such a run therefore requires a policy file that declares at least one root; without one it fails and says so.

Policy

See the annotated example and policy reference with isolated worked examples. Three rules govern loading:

  • Unknown fields are rejected. A typo must fail loudly rather than silently disable a protection.
  • Omitted keys keep their default; written keys are used as written. An explicit max_batch: 0 fails validation instead of being corrected.
  • A broken policy file never falls back to defaults. A missing file does.

Protections are additive: paths and name patterns in the document are added to the built-in credential protections, never replace them. The state directory and the effective quarantine directory are always protected, wherever retention.quarantine_dir puts the latter.

Output contracts

Every JSON document is wrapped in a versioned envelope and is byte-stable for identical input:

{
  "schema_version": 1,
  "kind": "status",
  "data": { }
}

schema_version is the domain contract version, which also governs persisted records. The database carries its own schema version, migrated forward on open. janitor version prints the build, contract, and schema versions.

Development

cmd/janitor          entrypoint
internal/cli         command tree, global flags, exit codes
internal/config      XDG path resolution and strict YAML policy loading
internal/core        versioned domain types: entries, evidence, protections,
                     recommendations, scans, plans, actions, retention, usage
internal/collect     bounded, fail-closed collectors and fingerprints
internal/safety      deterministic protections, remediation, and apply-time
                     revalidation
internal/store       SQLite schema, migrations, and transactional persistence
internal/output      deterministic JSON and terminal rendering
internal/buildinfo   version and build metadata

Tests run against isolated fixture homes and never scan the operator's real configuration, state, process table, service definitions or repositories. Path resolution takes an injected environment lookup; fixture policies point procfs, systemd, cron and PM2 collectors at synthetic locations. Git tests use scratch repositories and the installed Git executable with an isolated HOME and disabled system/global Git configuration.

License

Apache 2.0. See LICENSE.

About

Safe, Jev-assisted workspace hygiene for developer and AI-agent machines

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0