Use for the whole-repo decay audit, "run the repo-hygiene loop", "what has rotted in this repo", "is anything mis-shelved", "dọn repo", or a scheduled cadence run. NOT for the machine surface outside a git repo (disk-reclaim), NOT for doc claims drifting from code (doc-drift), NOT for board rows (backlog-reconcile), NOT for one file you already know is stale (just move it).
Install
npx skillscat add dwarvesf/dwarves-kit/repo-hygiene Install via the SkillsCat registry.
Repo hygiene
Scope and triggers
Also fires on "audit repo hygiene", "find the stale files". An audit-loop instance (docs/patterns/audit-loop.md). Enumerates files inside a git repo that have decayed (unreferenced docs, stale staging drops, records parked in a control surface, logs past their documented budget, large cold gitignored dirs), verdicts each with evidence inline, co-locates mis-shelved records on a branch, and gates through a PR. The machine surface outside a git repo (abandoned home-folder tool dirs, package caches) belongs to ops-toolkit tools/disk-reclaim, which owns read-first machine cleanup.
Overview
Audit one git repo for files that have decayed and ship the result as a PR. This is the
repo-hygiene instance of docs/patterns/audit-loop.md. It is the first instance whose item
set is the repo's own FILES rather than their claims: doc-drift asks whether a doc still
tells the truth, this asks whether the file should still be sitting where it is.
Two constraints shape everything below, both learned running this pass by hand:
- Every finding carries its evidence inline. Verification, not detection, was about 90
percent of the manual pass's cost. A findings list without evidence only moves that cost. - The loop SURFACES, it never deletes. Route versus trash needed a human call four times
in one pass, and twice the human chose against the recommendation. The only fix this
instance applies is a MOVE, and even a move gates through the PR.
The loop is REPO-SCOPED. The machine surface, an abandoned tool directory in a home folder, a
package cache, a downloads pile, anything outside a git checkout, belongs to ops-toolkit tools/disk-reclaim, which already owns read-first machine cleanup with its own safe set. If
the ask is "my disk is full", it is the wrong loop.
The four slots (per the audit-loop pattern)
| Slot | This instance |
|---|---|
| Item set | six item classes over ONE git repo, enumerated by bash lib/repohygiene/repohygiene.sh scan --repo <dir>: an unreferenced non-code file past its age threshold, a staging drop past its age threshold, a record parked in a central control directory whose owner is one tool or experiment, an append-only log past the budget the repo itself documents, a gitignored directory that is large and cold, and a session handoff file whose cited board rows are no longer open |
| Contract | a file earns its place: something references it, or it is young, or it sits with its owner, or it is inside its own stated budget. A gitignored directory has no contract at all here, only a size and a date |
| Evidence class | Tier 1: the scanner's own output, one line per finding, each carrying the proof inline (the exact path-boundary grep and its zero-hit result, an age in days against its threshold, a duplicate's path plus its sha256, an owner plus the commits that name it, a line count plus the file:line where the repo states the threshold, a size plus a newest-mtime). Tier 2: agents/audit-scanner.md, dispatched only on the FIX and REMOVE rows, never on the whole set |
| Apply mechanics | git mv for a detector-3 FIX, and nothing else. Detectors 1, 2, 4, and 5 are report-only in every case. No deletion is ever applied, proposed as a command, or staged |
The five detectors
| # | Name | What it flags | Threshold | Evidence format |
|---|---|---|---|---|
| 1 | unreferenced-doc |
a tracked non-code file that no other tracked file references | last touched more than --stale-days ago (default 180) |
the exact git grep -I -n -E '(^|[^A-Za-z0-9_-])<basename>' -- ':(exclude)<path>' and its 0 hits outside itself, plus the last-touch date and age |
| 2 | stale-inbox |
an entry directly under a staging dir (_inbox, inbox, _staging) that no tracked file names, and that is not an OS artifact |
mtime older than --inbox-days (default 30) |
the age in days against the threshold, plus duplicate-of <path> (identical sha256 <first12>) when a content-identical copy exists elsewhere in the repo |
| 3 | misplaced-record |
a record in a central control directory (_meta, docs/research, docs/briefs) whose owner is one tool or experiment, and a closed mega-goal still parked in the control surface |
owner accounts for at least half the commits touching the file; a mega-goal folder must also be closed by its own record (see below) | the owner, the count of owning commits out of the file's total, the latest commit subject, and the destination path it should co-locate to; for a mega-goal, the file:line of its status marker and its checked-against-open counts |
| 4 | log-budget |
an append-only log past the line budget the repo's own docs state FOR THAT LOG | the repo's documented numbers, never the scanner's | total lines against the threshold, the busiest YYYY-MM against the per-month threshold, and the file:line of the sentence that states them, quoted |
| 5 | cold-ignored-dir |
a gitignored directory that is large and cold | size at or above --cold-mb (default 100) with no file newer than --cold-days (default 90) |
the size in MB and the fact that no file is newer than the threshold, tagged REPORT ONLY, gitignored, never a deletion proposal |
Detector 2 runs detector 1's reference grep before it flags anything. A staging entry a
tracked file names is somebody's deliberate home, not a drop awaiting triage, and age alone
never distinguished the two. The first family-office sweep ran three of five false positive on
exactly this: a note a tracked doc names as the home for third-party phone numbers it keeps OUT
of the tracked tree, a rename map cited as evidence in an ingest record, and a bot's landing
zone whose nightly drain job is documented. Acting on the first would have stripped a
deliberate privacy split. The grep excludes the whole staging dir, so a drop can never cite
itself, and the basename carries the path, so one pass covers _inbox/x.md and a bare x.md
alike. An OS artifact (.DS_Store, Thumbs.db, desktop.ini, .localized) is skipped
outright: nobody dropped it and routing it is not a decision anybody owes.
Detector 4 reads a budget only from a clause that names the log AND carries an <N> ... lines
phrase. Naming the log and carrying a line count somewhere on the same line is not enough: one
sentence routinely states one file's budget while merely mentioning another. A SPEC line
reading Slim HANDOFF.md to <=100 lines (status-only; journal content stays in INGEST_LOG)
handed a real INGEST_LOG a 100-line budget owned by HANDOFF.md, and two decisions-table rows
handed it a per-month threshold of 0001 scraped out of digit runs in unrelated prose. A
number loose in the prose is not a budget, whatever else shares its line.
Detector 3 resolves the owner from the CONVENTIONAL-COMMIT SCOPE of the commits that touched
the file, not from the file's contents. Content was tried first and is too noisy: a research
note names every tool it surveyed, so a file owned by one tool mentions four others. What a
file's own history says about who wrote it does not have that problem.
Mega-goal completion is read from the folder, not the log
Detector 3 also judges mega-goal FOLDERS, and there the OWNER question comes second to the
CLOSED question. A closed mega-goal is a record and co-locates with its owner; an open one is
a live engine and stays where it is. The test runs in a fixed precedence and stops at the
first step that answers:
- A tracked record. A folder holding no tracked
.md/.markdown/.mdx/.txtis
residue agit mvleft behind, not a mega-goal, and is skipped. - An explicit status marker in the folder's TOP-LEVEL docs, a
Status:orState:
heading, bold label, or list item at the start of a line. Files undergoals/are excluded,
so one drafted sub-goal never describes the goal. An OPEN marker anywhere (charter, draft,
held, blocked, pending, deferred, in progress) wins over a closed one, because the loop's
only mutation is a move and a live engine must not be moved out of the control surface. - Its own checkboxes. An unchecked item anywhere in the folder (
- [ ], and the- [~]
in-progress form) means the goal is not complete, whatever the commits say. A box counts at
the start of a line, a blockquote, or a table cell, on a bullet or a number, which keeps
prose ABOUT checkboxes out of the count: every POINTER_PROMPT.md in the estate writes the
convention out mid-sentence as`- [ ] NN-... PR #N`. - Commit evidence, only for a folder that declares nothing at all, and then the verdict
is UNSURE. A commit keyword alone never earns a mega-goal folder a FIX.
FIX needs all of: a closed marker, no open item, at least one CHECKED item, a basename with no
glob metacharacter in it, and a commit scope that resolves a majority owner to give the move a
destination. Each refusal names itself in the UNSURE row it produces instead. The checked-item
requirement is the fail-closed catch: every way the box scan can come back empty also yields
zero checked items, so a folder whose checklist could not be read never reaches a move.
This precedence exists because commit keywords alone were the first test, and on the first
live run they misread three of five real folders: a sweep commit reading "co-locate completed
mega-goals" carries a keyword about OTHER goals, and "mochi build complete, 08 shipped" closed
nothing while that folder's ROADMAP still carried four open sub-goals and a section blocked on
a human. The cost is deliberate and asymmetric: a stale unchecked box on a genuinely finished
goal suppresses one finding, while a wrong FIX moves a live engine. Measured before and after:lib/repohygiene/docs/proof-of-done.md.
Session handoff files
A sixth item class, not a path-boundary detector: _meta/handoffs/*.md, .claude/handoffs/*.md,**/docs/handoffs/*.md. Contract: a handoff stays only while it cites at least one OPEN board
row, or names open work that no row carries.
Evidence, Tier 1 mechanical: extract every board ID the file cites ([A-Z]+-[0-9]+), read each
row's Status from the owning repo's board AS IT STANDS ON ORIGIN, git fetch thengit show origin/<default>:_meta/BACKLOG.md (and _meta/BACKLOG-archive.md when the row is not
on the live board). Never a local clone that may be behind: on 2026-09-17 five checkers read
stale local clones and produced three false findings.
| Verdict | Condition |
|---|---|
| REMOVE | every cited row closed (shipped, dropped, done, resolved) and no uncited open item; git history keeps the file. Proposed only, never applied, same as every other REMOVE this loop issues |
| FIX | an open item with no row; file the row first, the board owns the work and the handoff owns the context. Never applied by this loop, which touches no board |
| OK | any cited row is still open |
Origin of the rule: the handoff skill's hard rule, "a handoff dies with its rows."
Verdict mapping
| Finding | Verdict | Applied? |
|---|---|---|
| detector 3, one owner confirmed by Tier 2 | FIX | yes, git mv |
| detector 3, a mega-goal closed by its own marker, nothing open, something checked, one majority owner, no glob in its name | FIX | yes, git mv |
| detector 3, two or more owners, or a closed mega-goal that fails any other FIX condition | UNSURE, naming which condition failed | no |
| detector 3, a mega-goal that declares no status, nothing open, commit evidence only | UNSURE | no |
| detector 3, a mega-goal with any open checklist item and no closed marker | not emitted | no |
| detector 2 with a content-identical copy elsewhere | REMOVE, the copy being the named successor | never |
| detector 1, detector 2 without a duplicate | UNSURE | never |
| detector 4 | FIX, rotate or compact per the repo's own procedure | never |
| detector 5 | UNSURE, always | never |
A REMOVE here is a PROPOSAL with a named successor, which is what the pattern's grammar
means, and it is still the operator who deletes. This instance never issues a delete.
DANGER never comes from Tier 1. No detector reads a file's content for a policy claim, so
"this record tells the operator to do something now wrong" is a judgment only the lead can
make after reading the file, and it must quote the contradiction. UNTESTABLE does not arise
either: every detector runs against a local checkout the scanner can read, so evidence it
cannot gather is a scan that failed, not a vantage problem.
Process
Refusal guard. REFUSE if the target is not a git repo. The machine surface belongs to
disk-reclaim, and running here against a bare directory would silently audit nothing.git -C "$TARGET" rev-parse --show-toplevel >/dev/null 2>&1 || { echo "REFUSE: '$TARGET' is not a git repo -- the machine surface belongs to ops-toolkit tools/disk-reclaim, not to this loop"; exit 1; }Branch in a worktree first (native worktree tool). Every sibling instance branches
before it judges anything, and auditing on the main branch is the failure they all exist
to prevent. A run that turns out to have nothing to move reports inline at step 8 and the
branch goes away unused.Tier 1, mechanical, one pass, zero model cost.
bash lib/repohygiene/repohygiene.sh scan --repo <dir>Output is TSV:
detector,verdict,path,evidence. Only findings are emitted; an
item that passes its contract prints nothing, so an empty body is a CLEAN run.Run detector 1 twice on a first pass over a repo: once at the default, and once at
--stale-days 0. Age is a NOISE FILTER, not part of the contract, and the manual pass
that motivated this instance found its unreferenced file four days after it was written.
The second run needs--max-candidatesraised or it reports the overflow and caps.Tier 2, delta only, model-read. Zero FIX and zero REMOVE rows = zero dispatch; report
the UNSURE rows and stop. Otherwise dispatchkit:audit-scanner(preferred: its tools
roster physically cannot write, so the propose/apply split holds mechanically in an
unattended cadence run; fall back to a general-purpose subagent only where the kit agent
roster is unavailable) with the FIX and REMOVE rows, this instance's contract, and its
evidence class. Its job is narrow: does the stated owner actually own this record, and is
the destination path right. A scanner timeout, error, or an out-of-vocabulary verdict is
treated as UNSURE, never coerced to OK. The dispatch set needs no chunking: it is bounded
by the FIX and REMOVE rows, which are a small fraction of one repo's findings, unlikebacklog-reconcile, whose delta can span a whole board.Verdict each finding with the audit-loop grammar, per the Verdict mapping above.
Treat the scanner's verdict as a proposal: a Tier-2 judgment can move a row from FIX to
UNSURE, never the other way.Apply.
git mv <path> <destination>per confirmed detector-3 FIX, creating the
destination directory first. Nothing else is applied. Neverrm, nevergit rm, never a
deletion staged "for the operator to confirm in the PR", including for a detector-2 exact
duplicate: an exact duplicate is still someone's copy, and the manual pass proved the
human overrules the recommendation about a third of the time.Re-verify. Re-run the scan against every moved path: the detector-3 row must be gone,
and no detector-1 finding may have appeared for the moved file at its new home (a move
that breaks the last reference to a file trades one finding for another).Ship. Commit, push, open a PR whose body carries every finding with its evidence,
grouped by detector, with the UNSURE rows listed for the operator and the applied moves
listed separately. Follow the repo's own session-close conventions. A push orgh pr createfailure after Apply exits non-zero and names the orphan branch, never a
silent success. Nothing to move: no branch, report the findings inline.
Cadence
Run after a batch of merges that added records to a central directory, after a staging-dir
intake session, before a repo changes owner or gets archived, or on a schedule via /loop
per the audit-loop driver ladder. One repo per invocation: the scan is bounded by one
checkout, and a multi-repo sweep is that command in a loop, not a mode inside it.
Red flags
- Proposing, staging, or running any deletion. This loop moves and reports, nothing else.
- Recommending the deletion of anything gitignored. Detector 5 is REPORT ONLY by contract,
and the scanner cannot see what a gitignored path is for. - A finding without its evidence inline. Verification is the expensive half; a bare path
hands the whole cost back to the operator, which is the failure this instance was built
against. - Inventing a line threshold for detector 4. If the repo documents no budget, the verdict is
UNSURE with the counts, not a number the scanner made up. Borrowing a number that belongs to
a DIFFERENT file counts as inventing one: the rule is about whose budget it is, not about
whether a constant was hardcoded. - Treating an experiment's own result dump or draft folder as decayed. Those are frozen
records of a run and are excluded, on the same reasoning that keeps dated records out ofdoc-drift's item set. - Reading a commit subject as proof that a mega-goal closed. A commit says what one run did,
not what the goal's own roadmap still has open, and this loop's one applied verdict rides on
the difference. - Auditing a directory that is not a git repo. That is
disk-reclaim's surface.
Cadence
This instance has a declared cadence in the ## Cadence table of docs/patterns/audit-loop.md.
Record a finished pass with bin/audit ran repo-hygiene "<one line>", including a CLEAN one, sobin/audit due stops naming it. The kit ships no scheduler: due is what a human or a job reads.