# Vault Lint

> Health check and editorial lint of a markdown notes tree, run on demand: link scan and draft loop, triage with the user, atomic fixes, editorial checks, dated lint report. Use when the user wants to lint the vault, check vault health, find broken wiki links or orphaned notes, process or apply a lint draft, or asks whether the lint ran. Works from any directory.

- Skill: `campbellsmurphy/vault-lint` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add campbellsmurphy/vault-lint`
- Raw SKILL.md: https://api.skillmd.com/api/skills/campbellsmurphy/vault-lint/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: campbellsmurphy (https://skillmd.com/u/campbellsmurphy)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/campbellsmurphy/vault-lint

---


# Vault lint

An editorial lint for a plain-markdown, wiki-linked notes tree (Obsidian-shaped, but nothing here needs Obsidian). It finds broken links, orphans and stubs mechanically, then does the part a scanner cannot: noticing that two pages disagree, that an index has drifted from its folder, that a topic is referenced everywhere and written nowhere.

**Adapt before use.** Set the tree, and supply a scanner (see below).

```
VAULT=~/vault
```

## Three layers, keep them straight

1. **Scanner**: a deterministic, read-only, stdlib-only script that emits JSON to stdout. It owns the mechanics of checks 1 to 3: broken and ambiguous links, convention warnings, orphans, stubs, data-namespace exemptions, a README allowlist, and stripping code blocks so fenced examples don't register as links. **Never re-derive these by hand.** Run it and interpret the JSON.
2. **Draft loop**: a pre-pass that runs the scanner, writes a dated draft of proposed fixes into the tree, and appends a row to a run-log CSV. Optionally applies the single-match tier itself.
3. **This skill**: the interactive brain. Triage, editorial judgment, review with the user, apply approved fixes, report. This is the only sanctioned apply path.

**Not included in this repo:** the scanner and the draft loop, which are tied to one tree's conventions. This is the contract to write your own, and it is a couple of hundred lines of stdlib Python. Emit at minimum:

```json
{
  "scanner_version": "YYYY-MM-DD.N",
  "excluded_dirs": ["daily", "00-inbox"],
  "data_namespaces": ["..."],
  "broken":    [{"file": "...", "line": 12, "link": "...", "candidates": []}],
  "ambiguous": [{"file": "...", "line": 40, "link": "...", "candidates": ["a.md", "b.md"]}],
  "warnings":  [{"file": "...", "kind": "slash-command|external|...", "count": 3}],
  "orphans":   ["path.md"],
  "stubs":     ["path.md"]
}
```

The `scanner_version`, `excluded_dirs` and `data_namespaces` fields are not decoration: print them at the top of every report as a provenance stamp, or run-to-run count deltas become meaningless the first time you change what the scanner excludes.

**One dial, scope.** `full` (all checks, every step) or `quick` (link layer plus fixes, no dated report). Keep a rule that anything keying off report dates only advances on full runs.

## Procedure

1. **Preflight.** Read the carry-forward ledger, which is the memory between runs, and reconcile against it as you go: tick off what is resolved, add what is new. Do not re-derive open items from old dated reports. Then look for today's draft: if one exists and is unprocessed, work from it; otherwise generate it.
2. **Mechanical triage.** From the draft and scan JSON, sort findings into a high-confidence tier (single-match fixes) and a judgment tier (no match, multiple candidates, convention warnings). Propose a concrete fix per item:
   - **Broken links**: re-target when the intended file is identifiable, rename, or remove.
   - **Convention warnings**: report once per file, not once per occurrence. Fixed remedies beat case-by-case judgment, so write down the remedy per warning kind and apply it mechanically.
   - **Ambiguous links**: pick per context or ask. Never resolve silently by guessing.
   - **Orphans**: suggest one to three places an inbound link belongs. **Stubs**: flag expand-or-delete. Generated or imported data pages should be exempt in the scanner, and if they leak through, report an aggregate count and never propose deleting them.
3. **Editorial checks** (full scope only). See `references/checks.md`.
4. **Review gate.** Present everything in one message: proposed fixes by tier, contradictions with both quotes for the user to arbitrate, and any draft tables. Apply nothing yet. Unknowns stay TBC or become ledger items; never fabricate a resolution.
5. **Apply approved fixes.** One batched pass in a single process. On a synced store (iCloud, Dropbox), rapid write-then-read returns stale snapshots: batch all writes atomically, verify from what you wrote in memory, and do not immediately re-read from disk. Then re-run the scanner and confirm broken links are zero, or explain exactly what remains and why.
6. **Close out** (full scope). Write the dated report opening with the provenance stamp, update the processed draft's frontmatter so it stops looking pending, and bring the ledger up to date. If you are applying fixes approved from an earlier same-day report, append a follow-up section to that report rather than writing a new one.

## Trust posture

Draft-first everywhere: propose, get sign-off, apply, verify.

**How a tier earns the right to auto-apply.** Do not start here. Run the lint interactively until the record justifies it: in this setup, the single-match link tier graduated only after four consecutive runs in which every single-match proposal was approved unchanged. The judgment tier stays interactive permanently. When a tier does graduate, it must re-derive its fixes from a fresh scan rather than consuming a reviewed draft, and it must still report what it changed in the close-out.

Never edit: the daily folder (append-only by definition), the inbox, or filed source files. Reference them, never edit them. Keep an explicit path guard in the apply function rather than relying on the caller, because the single-match tier will eventually run unattended.

## Scheduled mode

If you run this headless on a timer, the rules change enough to need their own contract: see `references/scheduled-mode.md`.

## Gotchas (learned the hard way)

- **Synced-store staleness.** See step 5. One process, batched writes, no immediate re-reads.
- **Run evidence is thin.** A launchd or cron job that relaunches for file-access reasons can write 0-byte logs, and a "today's draft already exists" skip path writes no log row at all. Draft and CSV mtimes end up being the only proof a run happened. Decide deliberately how you will know the job ran.
- **Two draft species coexist.** Loop-generated drafts have a fixed table format; agent-authored delta drafts are looser. Read the frontmatter rather than assuming the format.
- **Same-day reruns break date arithmetic.** Any "previous report" lookup that globs for a date will ignore a `-2` suffixed rerun and compute a slightly stale cutoff. Pass the date explicitly when it matters.
- **Check the capability before asking for permission to use it.** A lint once reported six stale rendered pages, obtained explicit approval to republish them, and only then established that the publishing tool was absent from the session. Permission cannot supply a missing capability: measure which blocker is live before putting a request to the user.

## Output locations

- Report: `$VAULT/<ops-area>/reports/<YYYY-MM-DD>-lint-report.md`, with a `-2` suffix for a second run the same day. Full runs only.
- Ledger: `$VAULT/<ops-area>/lint-open-items.md`
- Drafts: `$VAULT/<ops-area>/lint-drafts/`
- Run log: a CSV owned by the loop. The skill never writes it.

