Files
clide/.claude/skills/clean-house/references/rules.md
T
Jeroen SchweitzerandClaude Opus 4.8 f90bcbf5f3 add commit, penpot-login, and clean-house skills
Track the project-local Claude Code skills that were living untracked in
.claude/skills/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:31:54 +02:00

387 lines
14 KiB
Markdown

# clean-house rule catalog
The rules clean-house runs and the reasoning behind each. New rules
land here as their own entry. Catalog churn — additions, refinements,
retirements — is tracked in pql's `CHANGELOG.md` and `git log
internal/skill/clean-house/`. When a rule is retired, leave a
**Retired** stub (ID + one-line reason + retirement commit) so the
trail of "we used to check X" is recoverable from this file alone.
## Reading a rule
Each entry below has:
- **ID** — Stable identifier (e.g. `RULE-ANCHOR-DRIFT`). Used in
AskUserQuestion prompts and skip-ledger entries so the user learns
what's being checked.
- **Category** — `mechanical` or `judgment`. Drives whether findings
batch into one prompt or each get their own.
- **Finding ID** — Format string for the per-finding stable
identifier. Skip-ledger entries are written as `<rule-id>
<finding-id> <ISO-date>`; subsequent runs use the same format to
recognize "same finding skipped 3 runs in a row" and escalate.
Use only stable inputs (record IDs, file paths, slug strings, body
hashes) — never line numbers or timestamps.
- **Detection** — Concrete steps to find violations. Names the
pql command or file primitive used.
- **Fix** — Mechanical: deterministic action. Judgment: the prompt
option list and what each option does.
- **Why** — One paragraph on the failure mode this rule guards
against. Future-you reading the rule benefits from the reasoning,
not just the check.
---
## RULE-ANCHOR-DRIFT
**Category:** mechanical
**Finding ID:** `<source-record>:<link-target>` (e.g.
`D-8:#q-1-markdown-mirror-for-tickets` or
`D-8:questions.md#q-1`).
**Detection:**
Anchor-only markdown links (`[text](#slug)`) resolve against the
**source file's full heading set**, not the body of a single record
— record-level headings (`### D-N: …`) live as siblings in the
file and are valid link targets. The previous detection (which used
`pql decisions read`'s body-only `headings` array) missed these
and produced false positives.
Per source body:
1. Open the source body's file (`<vault>/<file_path>` from
`pql decisions list`) and extract every ATX heading. Build a
slug index for the file using the GFM convention (lowercase,
hyphenate spaces, drop punctuation, disambiguate duplicates with
`-1`/`-2`). Cache per file — every record in that file uses the
same index.
2. For each `[text](target)` link in the body:
- Anchor-only (`#slug`): check `slug` against the **source file's**
index. Missing → flag.
- Cross-file (`path.md#slug`): resolve `path.md` relative to the
source file's directory; check `slug` against that file's
index. Missing file or missing slug → flag.
**Fix:**
If the slug exists in a sibling file's index (same directory) and
the link is anchor-only, rewrite to `path.md#slug` (the canonical
cross-file form). If the slug exists in the source file's index
with edit-distance ≤ 2 from the link target, rewrite to the
matched slug. Otherwise downgrade to judgment ("no auto-fix; the
heading was removed, not renamed") with options: drop the link /
point elsewhere / mark as intentionally dangling.
**Fix:**
If exactly one heading with a closely-matching slug exists (slug
edit-distance ≤ 2 or substring match), rewrite the link to point at
that heading. If no plausible match exists, downgrade the finding to
judgment ("no auto-fix; the heading was removed, not renamed") and
prompt the user with options: drop the link / point elsewhere / mark
as intentionally dangling.
**Why:**
When a heading is renamed for clarity, every cross-reference pointing
at the old slug silently breaks. The rendered docs still look fine —
the link just goes nowhere. Without periodic sweeps these decay
indefinitely; the cost of the sweep is small compared to the cost of
a reader following a dead link and losing trust in the index.
---
## RULE-MISSING-Q-BACKLINK
**Category:** mechanical
**Finding ID:** `<d-id>:<q-id>` (e.g. `D-7:Q-2`). Order is always
D-first regardless of which side is missing the backlink.
**Detection:**
For each D record body, find lines of the shape `Resolves: Q-N`
(plain text, typically in the metadata block at the top of the
record). For each Q-N referenced, fetch that Q record's body via
`pql decisions read Q-N` and check whether it contains a line of the
shape `Resolved → D-N` (or equivalent backlink phrasing — match the
project's own convention; default pattern is `Resolved → D-N`).
Also check the reverse direction: any Q record claiming
`Resolved → D-N` whose D-N body lacks `Resolves: Q-N`.
**Fix:**
Insert the missing backlink in-place using `Edit`:
- Missing on the D side: add `Resolves: Q-N` to the D's metadata
block (typically right after `Domain:` / `Status:` lines).
- Missing on the Q side: append `Resolved → D-N` to the Q's status
line (or in the conventional position for that project).
**Why:**
Bidirectional Q↔D links are the navigation backbone of the DQR
system. When one side drifts, search-by-decision finds nothing for
that question and search-by-question doesn't surface its resolution.
The asymmetric state usually arises from a hand-edit on one record
that forgot to update the other; the fix is purely mechanical because
the correct content is already determined by the existing pointer
in the other direction.
---
## RULE-RECORD-SORT
**Category:** mechanical
**Finding ID:** `<file-path>` (e.g. `decisions/architecture.md`).
The whole file is one finding — sort applies to the file as a
unit.
**Detection:**
For each `decisions/*.md` file, read it directly (no pql) and find
all top-level `## D-N` / `## Q-N` / `## R-N` headings in order. Strip
the prefix, parse the numeric suffix, and check whether the sequence
is monotonically ascending within each ID family (D, Q, R kept
separate — files commonly mix families).
Only flag a file if it is **otherwise tidy** — currently no out-of-
order amendments interleaved with new records. The heuristic: if the
sort would touch fewer than three records out of position, apply it;
if more, downgrade to judgment ("this file looks deliberately
arranged — confirm before reordering"). Three is a soft threshold; if
the file's last commit message contains `WIP` or `do-not-sort`, skip.
**Fix:**
Reorder the records within the file so each ID family is ascending.
Preserve everything else (headings between record blocks, any prose
prelude/postlude). Use `Edit` with full block replacement, not in-
place line shuffling — easier to verify the diff.
**Why:**
Records added at the bottom of a file are easy to write but hard to
find. Sorted IDs let a reader find D-37 by jumping to the
two-thirds mark of the file rather than scanning. The cost of the
sort is one reordering pass; the benefit is paid back on every
subsequent read.
---
## RULE-EOF-NORMALIZATION
**Category:** mechanical
**Finding ID:** `<file-path>` (one finding per file).
**Detection:**
For each `decisions/*.md` file (and any other markdown the skill
touched during this run), read directly. Flag if:
- File does not end with exactly one `\n`.
- Any line contains trailing whitespace before its `\n`.
**Fix:**
Trim trailing whitespace from each line. Ensure exactly one trailing
newline at end of file. Use `Edit` only if a violation was found —
this rule must not produce a no-op diff.
**Why:**
Editor and git config drift causes whitespace creep that's invisible
in rendering but pollutes diffs (every record edit ends up touching
unrelated lines). A periodic normalization keeps future diffs clean
without forcing per-editor enforcement on every contributor.
---
## RULE-SUNSET-WITHOUT-TICKET
**Category:** judgment
**Finding ID:** `<record-id>:<phrase-hash-8>` where phrase-hash-8 is
the first 8 hex chars of `sha256(<matched-phrase>)`. Lets the
ledger distinguish two sunset phrases in the same record.
**Detection:**
For each D record body (via `pql decisions read <id>`), grep for
sunset-shaped phrases. Default regex set:
```
(?i)\b(delete|remove|sunset|kill[ -]?switch|tear[ -]?down) when\b
(?i)\bmust (track|monitor|watch|follow)\b
(?i)\brevisit (when|after|once)\b
(?i)\b(deprecate|retire) (when|after|once)\b
```
For each match, run `pql ticket list --decision <id>`. If no tickets
exist, the D has work-shaped intent without a tracked T — flag.
**Fix (prompt options):**
- **File T now** — Run `pql ticket new task "<phrase>" --decision <id>`,
then prompt the user for a description (or call `pql ticket refine
write <T> --description ...` after creation).
- **Draft a T description first** — Open an `AskUserQuestion` for the
description, then file as above.
- **Mark as already covered** — Add a note to the D body
("Tracked under T-N") via `Edit`. Skill does not assert which T;
user provides the ID.
- **Skip** — Adds to the ledger.
- **Stop asking** — Skip remaining sunset findings, summarize.
**Why:**
Sunset-shaped intent ("we'll handle X when Y happens") is the most
common source of accumulated debt in a DQR system: the trigger
condition arrives, nobody remembers the D, the work doesn't happen.
Linking each sunset to a T is the simplest defense — a T is a
backlog item that surfaces in `plan whatsnext` / `plan board`. The
fix is judgment because the right action depends on whether the D's
condition is still relevant, whether it's already covered, and what
the right scope is for the resulting T.
---
## RULE-FILE-OVER-THRESHOLD
**Category:** judgment
**Finding ID:** `<file-path>` (one finding per file; threshold
choice is judgment, not a per-line problem).
**Detection:**
For each `decisions/*.md` file, count lines (via `wc -l` or
equivalent). Default threshold: **350 lines**. Configurable per
project — read `decisions/.clean-house.yaml` if present, key
`file_threshold`. Fall back to 350.
**Fix (prompt options):**
- **Split now (which axis?)** — Prompt for the split axis: by ID
family (D/Q/R), by domain, by date range, custom. Then perform the
split: create new file(s), move records, update any anchor links
pointing into the moved records (run RULE-ANCHOR-DRIFT in apply
mode against the affected files after the split).
- **Accept and raise the threshold** — Update
`decisions/.clean-house.yaml`'s `file_threshold` to the next
reasonable round number above the current line count.
- **Defer** — Skip until next run.
**Why:**
A single decisions file growing past ~350 lines is the point at which
linear scanning starts to lose to grep, and where the cost of
splitting (renaming anchors) is still small. Beyond ~600 lines the
split cost compounds. The threshold is judgment because some projects
deliberately keep one file per domain and accept the size; others
split aggressively. The skill surfaces the question; it doesn't
decide.
---
## RULE-DEAD-FILE-REFERENCE
**Category:** judgment
**Finding ID:** `<record-id>:<path-token>` (path token as written
in the body, not resolved).
**Detection:**
For each D record body (via `pql decisions read <id>`), grep for
path-shaped tokens. Default regex:
```
\b([\w./-]+\.(md|go|py|sql|yaml|yml|toml))\b
```
The first detection pass produced 6/6 false positives in
real-world use; the regex is necessary but not sufficient. For
each match, apply the filters below in order — if any matches,
**skip without flagging**:
1. **Placeholder filter.** Token contains `T-NNN`, `D-NNN`,
`Q-NNN`, `R-NNN`, `...`, `<`, `>`, or `*` — it's a pattern,
not a real path.
2. **Source-relative resolution.** Resolve the token against the
source file's directory (`<vault>/<file_path>`'s dir). If
`[ -e <resolved> ]`, the reference is live.
3. **Repo-relative resolution.** If `[ -e <token> ]` from the
repo root, the reference is live.
4. **Basename-fallback.** Run `find <repo-root> -name <basename>`
(where basename is the last path component). If exactly one
match exists, treat the reference as live and emit a low-
priority "consider rewriting to the absolute path" note (not a
judgment finding). If multiple matches exist, do not flag —
the token is too ambiguous.
Only after all four filters miss does the reference qualify as
truly dead and warrant a judgment prompt.
**Fix (prompt options):**
- **Update reference** — Prompt for the new path; rewrite the
reference via `Edit`. If the user types a path, validate it exists
before applying.
- **Mark superseded** — Add a note to the D ("This decision
references files no longer present; the underlying constraint
was retired by D-N") and prompt for the superseding D ID.
- **Defer** — Skip until next run.
**Why:**
Decisions reference code paths to ground their reasoning in the
codebase that motivated them. When the code moves or gets deleted,
the D's reasoning becomes harder to verify. Dead refs are a signal
that either the decision should be updated or the decision itself is
no longer load-bearing — both are judgment calls the user needs to
make.
---
## RULE-STALE-OPEN-Q
**Category:** judgment
**Finding ID:** `<q-id>` (one finding per Q-record; staleness
threshold is global per run, so a Q is either stale or not).
**Detection:**
Run `pql decisions list --type question --status open`. For each
returned record, check the `date` field. Default staleness threshold:
**60 days** (configurable via `decisions/.clean-house.yaml` key
`stale_open_q_days`). Flag any Q with `date` older than the
threshold.
**Fix (prompt options):**
- **Still relevant** — Touch the Q's `date` field to today, optionally
add a one-line "still open as of YYYY-MM-DD: <reason>" to the body.
- **Nudge to load-bearing index** — If the project keeps a
load-bearing Qs list (a curated index of unresolved questions
affecting current work), prompt for inclusion and add the Q there.
- **Mark withdrawn** — Change the Q's status to `withdrawn` (the DQR
system's terminal state for "no longer worth answering"); the
user supplies a one-line reason.
- **Defer** — Skip until next run.
**Why:**
Open questions are useful when they're current; stale ones become
ambient noise that drowns the signal of new questions. Most
projects don't enforce explicit Q-lifecycle, so without a periodic
prompt the open list grows forever. The right action depends on
whether the question is still open in fact — not just in metadata —
which only the user can confirm.