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.
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 |
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-sizeasks 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
cmdlinenorenviron; the service collectors skipEnvironment,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.
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
investigatewith 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.
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.
make build # dist/janitor, static, CGO_ENABLED=0
make check # gofmt check, vet, build, testThe SQLite driver is pure Go (modernc.org/sqlite), so the binary is static
and needs no C toolchain.
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 fileWeekly 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.
| 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 |
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 decisionexplain 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.
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 receiptsThe 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.
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.
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.
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 runRequests 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.
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.
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.
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: 0fails 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.
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.
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.
Apache 2.0. See LICENSE.