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>
14 KiB
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 —
mechanicalorjudgment. 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:
- Open the source body's file (
<vault>/<file_path>frompql 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. - For each
[text](target)link in the body:- Anchor-only (
#slug): checkslugagainst the source file's index. Missing → flag. - Cross-file (
path.md#slug): resolvepath.mdrelative to the source file's directory; checkslugagainst that file's index. Missing file or missing slug → flag.
- Anchor-only (
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-Nto the D's metadata block (typically right afterDomain:/Status:lines). - Missing on the Q side: append
Resolved → D-Nto 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 callpql ticket refine write <T> --description ...after creation). - Draft a T description first — Open an
AskUserQuestionfor 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'sfile_thresholdto 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:
- Placeholder filter. Token contains
T-NNN,D-NNN,Q-NNN,R-NNN,...,<,>, or*— it's a pattern, not a real path. - Source-relative resolution. Resolve the token against the
source file's directory (
<vault>/<file_path>'s dir). If[ -e <resolved> ], the reference is live. - Repo-relative resolution. If
[ -e <token> ]from the repo root, the reference is live. - 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
datefield to today, optionally add a one-line "still open as of YYYY-MM-DD: " 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.