Skip to content
Back to skills

Repo Hygiene

ASecurity

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).

  • 10 stars
  • 0 votes
  • 3 copies
  • 5 views
  • Added September 13, 2026
developmentgobashgit

Security analysis

A100/100

Scanned September 23, 2026

npx -y skills add dwarvesf/dwarves-kit --skill repo-hygiene --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Repo Hygiene?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Repo Hygiene
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/dwarvesf-repo-hygiene/badge)](https://www.skillsdirectory.com/skills/dwarvesf-repo-hygiene)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: repo-hygiene
description: 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).
disable-model-invocation: false
---

# 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:

0. **A tracked record.** A folder holding no tracked `.md` / `.markdown` / `.mdx` / `.txt` is
   residue a `git mv` left behind, not a mega-goal, and is skipped.
1. **An explicit status marker** in the folder's TOP-LEVEL docs, a `Status:` or `State:`
   heading, bold label, or list item at the start of a line. Files under `goals/` 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.
2. **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` ``.
3. **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` then
`git 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

1. **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; }
   ```

2. **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.

3. **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-candidates` raised or it reports the overflow and caps.

4. **Tier 2, delta only, model-read.** Zero FIX and zero REMOVE rows = zero dispatch; report
   the UNSURE rows and stop. Otherwise dispatch `kit: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, unlike
   `backlog-reconcile`, whose delta can span a whole board.

5. **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.

6. **Apply.** `git mv <path> <destination>` per confirmed detector-3 FIX, creating the
   destination directory first. Nothing else is applied. Never `rm`, never `git 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.

7. **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).

8. **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 or
   `gh pr create` failure 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 of
  `doc-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, so
`bin/audit due` stops naming it. The kit ships no scheduler: `due` is what a human or a job reads.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.