mn:health — Vault Health Check & Analytics
Invocation marker (both runtimes): begin your reply with the exact line
🧠 mn:health (mnemo) → running— the user-visible confirmation that this skill actually loaded. Emit it once per invocation, before any other output.
Portable paths
Resolve <mnemo-root> once to the absolute plugin root before reading bundled files or running bundled scripts. In Claude Code, use ${CLAUDE_PLUGIN_ROOT}; in Codex, derive it from this loaded SKILL.md path (skill directory → skills/ → plugin root). Replace <mnemo-root> with that quoted absolute path in every command — never execute the placeholder literally and never hunt versioned cache directories.
When another mnemo skill must run, use the runtime-native path: Claude Code invokes mn:<skill> through its Skill tool; Codex reads <mnemo-root>/skills/<skill>/SKILL.md completely and follows it with the prepared input. For user-facing explicit syntax, render /mn:<skill> in Claude Code and $mnemo:<skill> in Codex.
Run a comprehensive health check on the Obsidian vault: orphans, broken links, missing sections, stale notes, and growth statistics.
Prerequisites & config
Obsidian must be open; obsidian CLI on PATH. Config at ~/.mnemo/config.json requires vault, taxonomy, taxonomy_roles, and links_section for new configs; the deterministic legacy Zettelkasten fallback remains supported. Validate the exact role contract in Step 4 before semantic labeling. Schema in <mnemo-root>/references/config-schema.md.
Workflow
Steps 1-4 run in parallel — single assistant message batching the independent indexed reads (orphans, unresolved, tag counts; Step 4 reuses Step 3's tag output, no extra call). Use the bundled shell-free helper with single-quoted JSON herestrings (<<< '…') for every dynamic value. These queries take ~180ms total vs ~720ms sequential.
Step 0: claude-mem Sanity Check (optional, ~20ms)
Claude Code only. In Codex, skip this step and omit the ⚠️ claude-mem report line — do not inspect another runtime's plugin cache. In Claude Code, surface two common gotchas if claude-mem is installed and enabled. If cascade.claude_mem.enabled is false, skip this section silently — many users intentionally disable claude-mem for CPU/RAM reasons.
bash "<mnemo-root>/scripts/check-cm-version.sh"
# Or from source: plugins/mnemo/scripts/check-cm-version.sh
Script emits three lines: version: X, stale: N, path: .... Interpret:
stale > 0→ warn: "claude-mem has {stale} old version folder(s) cached. Restart all Claude windows — old Stop hooks point to a path that no longer exists."version < 12→ warn: "claude-mem v{version} is behind v12 — you're missing file-read gate, tier routing, and knowledge agents. Run/plugin update claude-mem."- Empty path → claude-mem not installed. Skip the section entirely.
Step 0.5: Cross-runtime Recall Status (conditional, read-only)
When recall.runtimeMemory.enabled is true, report whether the other runtime's project memory can be mapped safely. Run only the helper's metadata-projection status probe: it decodes and retains project/scope metadata only (bounded raw bytes may be streamed past to locate later headers), and it never returns or summarizes counterpart body content during a health audit:
python3 "<mnemo-root>/scripts/runtime-memory.py" status <<< '{"runtime":"{claude|codex}"}'
Pass the active runtime. available means the helper proved an exact same-repository mapping; unavailable is not corruption and must not trigger repair, copying, broad scans, or claude-mem startup. Omit the report line when the feature is disabled.
The report names only the counterpart runtime represented by this probe: active Claude reports Codex memory …; active Codex reports Claude memory …. Never describe the active runtime as its own counterpart and never collapse the result into “Claude memory available, Codex memory available.”
Step 1: Orphan Detection
python3 "<mnemo-root>/scripts/safe-read.py" orphans <<< '{"vault":"{vault}"}'
List notes with zero backlinks. These are invisible in Graph View.
Step 2: Unresolved Links (Ghost Notes)
python3 "<mnemo-root>/scripts/safe-read.py" unresolved <<< '{"vault":"{vault}"}'
Show [[wikilinks]] pointing to non-existent files. Ghost notes are NORMAL (entity discovery) — don't flag on raw count alone.
Actionable — top unresolved targets = missing hub notes (via obsidian eval, authoritative; CLI unresolved can lag/lie — see <mnemo-root>/references/gotchas.md):
python3 "<mnemo-root>/scripts/safe-read.py" top-unresolved <<< '{"vault":"{vault}"}'
A short name with many refs (e.g. [[Diadoc]] ×30) = create a hub note Diadoc.md → [[{configured moc prefix}…]] so all those links resolve (alias doesn't work for bare links — by design).
Step 3: Tag Distribution
python3 "<mnemo-root>/scripts/safe-read.py" tags <<< '{"vault":"{vault}"}'
Show top 15 tags. Flag tags used only once (potential typos).
Step 4: Notes by Type
Reuse the obsidian tags counts output from Step 3 — do not call it again (the tag index is one query; Steps 3 and 4 are two views of the same result). Read config.taxonomy and config.taxonomy_roles once, then enumerate every configured taxonomy entry as {type key, prefix, tag, mapped roles}. Extract its count using that entry's configured tag; never assume a tag from the type key, prefix, or a built-in Zettelkasten name.
Validate taxonomy_roles before labeling semantic roles: its key set must be exactly fact, insight, source, session, and moc; every target must be an existing taxonomy key; and the functional roles must self-map as session → session and moc → moc. If the map is absent but all five legacy Zettelkasten keys exist, use the deterministic legacy mapping documented in config-schema.md for this report and recommend persisting it with setup. For any other missing/invalid map, still report raw configured taxonomy types, but mark semantic role routing unavailable and ask the user to run the runtime-native setup command — do not guess.
For the per-type roles column, invert the validated map mechanically: mapped_roles = [role for role, target in taxonomy_roles.items() if target == type_key]. Across the entire taxonomy table, concatenating every row's mapped_roles must yield exactly the multiset fact, insight, source, session, moc — each role appears once total. A type with no matching target says none; never infer roles from a PARA type's conceptual meaning, copy roles from another row, or assign the three content roles to both project and resource. With setup's canonical PARA map, the self-check is project/area/archive → none, resource → fact, insight, source, session → session, and moc → moc.
Total notes count:
python3 "<mnemo-root>/scripts/safe-read.py" files-total <<< '{"vault":"{vault}"}'
Step 5: Missing Links Section (batched grep — 3600x faster)
Do NOT loop obsidian read per file — on a 1000-note vault that's ~180s. Use the helper's single filesystem pass.
python3 "<mnemo-root>/scripts/safe-read.py" missing-links <<< '{"vault":"{vault}","links_section":"{links_section}","prefixes":["{prefix_1}","{prefix_2}","{prefix_N}"]}'
Replace the placeholders in prefixes with every configured taxonomy prefix from config.taxonomy; do not silently omit a custom note type.
Measured on 999-note vault: ~49ms vs ~180s serial — 3600x speedup. Safe to run always.
Report notes missing the section.
Step 6: Bad Filenames (# or . in the filename stem)
python3 "<mnemo-root>/scripts/safe-read.py" bad-filenames <<< '{"vault":"{vault}"}'
Files with # in the name are permanent orphans — [[Note #1]] parses as [[Note]] + heading anchor #1, so nothing resolves to them (even existing links). Flag for rename (# → — or drop the #). Same for . mid-name (breaks CLI create). See <mnemo-root>/references/tool-routing.md (naming rules).
Step 7: Review Candidates (content-staleness, type-aware)
A temporal signal, distinct from orphans (Step 1, which is structural): notes untouched longer than the threshold for their type are candidates for a re-read. Threshold precedence: per-note ttl: <days> → review.staleDays.<type> → review.staleDays.default → 30 (legacy). Age is measured from the newest of date or reviewed — so stamping reviewed: {today} on a still-valid note resets its clock. That snooze is what stops a stale list from rotting into guilt-debt (the canonical failure mode of review dates — see Gotchas).
python3 "<mnemo-root>/scripts/safe-read.py" review-candidates <<< '{"vault":"{vault}","limit":30}'
Output: CANDIDATES\t{n}, then THRESHOLDS\t{json}, then one tab-separated row per note ({overdue_days} {type} {anchor_date} {anchor_src} {threshold_days} {relpath}), most-overdue first. {threshold_days} is the budget actually applied to that note (per-note ttl: if set, else its type's staleDays, else default) — show it as (type, Nd budget), never invent a ttl. Pure filesystem — independent of the obsidian CLI graph cache (no lag/lie risk). A missing review config section reproduces the legacy uniform 30-day behavior, so this is safe before any config migration.
Don't AND this with backlinks — a well-linked note can still hold outdated claims. Report candidates on their own; cross-reference Step 1 yourself if you specifically want "old and orphaned."
Step 7.5: Content Lint (optional deep pass — gated by review.lint.enabled)
Steps 1-7 are cheap and structural. This is the content pass — Karpathy's "lint": actually re-read the notes to judge whether claims have rotted, instead of trusting the calendar. Together the checks cover his four: orphans (Step 1) + concepts-mentioned-but-no-page (Step 2 unresolved links) + stale claims (Step 7) + contradictions (here). Gated by config.json → review.lint.enabled (default false) because it reads note bodies and costs tokens — skip this step silently when disabled.
When enabled, take the top review.lint.maxCandidates (default 15) candidates from Step 7 and have them read & judged on the model set by config.json → review.lint.model (default haiku; sonnet/opus for higher-quality verdicts). This health skill itself runs as a haiku fork and cannot upgrade its own model, so:
- If
review.lint.modelishaiku(or unset) → do the lint inline in this fork. - Otherwise → spawn one subagent on
model: {review.lint.model}(Claude Code: Task tool,subagent_type: Exploreor general; Codex:spawn_agent) that reads the candidate note bodies in one batched pass (filesystem read, not oneobsidian readper file) and returns the verdicts. Keeping the cheap Steps 1-7 onhaikuwhile the lint runs onopusis the whole point of the split.- Report the subagent's verdicts verbatim — never assume "all still-valid". The fork only aggregates what the subagent returned; it does not re-judge. The Step 9 Content-lint block and its count MUST reflect the subagent's actual breakdown: if the subagent returned 13 still-valid + 2 update-needed out of 15, the report says
13 still-valid, 2 update-needed, NOT15 still-valid. Defaulting the count to the candidate total (as if everything passed) is a reporting bug — wait for the subagent's verdicts before writing the report.
- Report the subagent's verdicts verbatim — never assume "all still-valid". The fork only aggregates what the subagent returned; it does not re-judge. The Step 9 Content-lint block and its count MUST reflect the subagent's actual breakdown: if the subagent returned 13 still-valid + 2 update-needed out of 15, the report says
Emit a verdict per candidate:
still-valid → close the loop: stamp
reviewed: {today}into the note's leading frontmatter with the bundled optimistic writer. Read the note first, then replace the exact leading frontmatter block (never adate:/reviewed:mention in the body) with the same block containing the updated field:python3 "<mnemo-root>/scripts/vault-write.py" <<< '{"action":"replace","vault":"{vault}","note":"{candidate note}","old_str":"{exact leading frontmatter block copied from safe-read}","new_str":"{same JSON-escaped frontmatter block with reviewed: today}"}'This exact-one replacement fails closed if the note changed after the read. A confirmed-valid note then stops resurfacing without a manual edit. This auto-stamp is on by default (
config.json→review.lint.autoStampReviewed, default true); only if the user set it tofalsedo you just recommend the stamp and write nothing. When the lint runs in a spawned subagent (model ≠ haiku), that subagent does the stamping — it already holds the verdict and the note path. If a stamp write fails (Obsidian offline, concurrent edit, or validation failure), don't drop it silently — collect those note paths and surface them under the Content lint report block so the user can stamp them manually.update-needed → one line on what specifically looks outdated.
contradicts [[Other Note]] → name the conflicting note; flag the older one against the newer.
Verdicts are triage, not truth — on haiku especially, expect false positives; even on opus, surface them as questions. The only write health ever performs is the reviewed: auto-stamp above, and only ever on a still-valid verdict — never content, never on update-needed/contradicts. It fires only inside this lint pass (which is itself off unless review.lint.enabled is true), and can be turned back to suggest-only with autoStampReviewed: false. The user stays in control.
Step 7.55: Autocompact Window (report-only, Claude Code, only when the nudge is on)
Run this only when hooks.autocompactNudge is true in ~/.mnemo/config.json — otherwise the hook is off and there is nothing to diagnose. It exists because that hook's failure mode is silence: when the window cannot be established it stays quiet, and quiet is indistinguishable from "nothing to warn about". This is the one place that tells the difference out loud.
python3 "<mnemo-root>/scripts/context-window.py" --explain
Read-only and offline: it reports what to set, and never edits anyone's Claude Code settings — autoCompactWindow belongs to Claude Code, not to mnemo, and mnemo does not ship a default for it (a value that suits one account silences the nudge on another and halves the usable context on a third).
Report the returned hint verbatim, and flag these two cases explicitly:
resolved: false— the nudge is enabled but can never fire. Quote the formula from the hint:autoCompactWindow= desired threshold + the reserve (33000 on a model whose max output is ≥ 20k). Asking for compaction at 460000 means setting 493000; setting 460000 gets it at 427000.clamped: true— the configured value is above the model's context window, so it does nothing. This is the trap worth naming: on a 200k model any value above 200000 has no effect at all, which looks identical to a working setting.
Step 7.6: Handoff Triage (report-only, skipped when there is no handoff)
Steps 7/7.5 judge whether notes have rotted. This judges whether the handoff has — a different failure with a different cause. The archiver may never move a block that still holds an open - [ ] (an unkept promise must not slip silently into cold storage), so a handoff does not shrink by archiving harder; it shrinks by someone deciding what those open items still mean. Run it whenever handoff_note is configured and the file exceeds handoff.maxKB:
python3 "<mnemo-root>/scripts/handoff-resolver.py" "{vault_path}/{handoff_note}.md" \
--keep-days {handoff.keepDays} --limit 25
🛑 Legacy-only by construction. The resolver parses ## YYYY-MM-DD blocks, so on a handoff already migrated to the pointer index it reports 0 blocks · 0 open and there is nothing to triage — skip the step and omit its report block. The blocks themselves live on in {handoff_note} Archive … parts; point the resolver at those when you want the triage view of archived history.
When the handoff is still in block format, offer the migration — never run it. It rewrites a note in a vault that has no version control, so it is the user's call, and every step is dry-run by default. Offer, in this order:
python3 "<mnemo-root>/scripts/migrate-handoff-to-index.py" "{vault_path}/{handoff_note}.md" # blocks → pointers
python3 "<mnemo-root>/scripts/split-handoff-archive.py" "{vault_path}/{handoff_note} Archive.md" # cold archive → per-month parts
python3 "<mnemo-root>/scripts/relink-orphan-pointers.py" "{vault_path}/{handoff_note}.md" # pointers with no target → their part
python3 "<mnemo-root>/scripts/backfill-tails-from-archive.py" "{vault_path}" # archived tails → their session notes
Add --apply to each only after the user agrees to that step; restore-handoff-from-bak.py undoes the first one. Order matters: relinking needs the parts to exist, and the backfill is what keeps recently-archived tails visible to the digest — without it they sit in cold storage the digest does not read.
Offline and read-only — it never writes and never calls a network. Report its three blocks as-is:
- Ceiling. How many bytes a complete resolution of every open item would free. Quote it honestly even when it is small: on the maintainer's live vault it was 2.47% (20 KB of 805 KB). Triage buys correctness, not size — say so instead of implying a cleanup will fix a bloated file.
- Blocks by payoff.
fully-resolvable(every open item carries an external anchor → resolving frees the whole block) ·partial(one anchorless item pins the rest — the common case) ·no-anchor·prose-pinned(kept hot by its header, so closing checkboxes frees nothing) ·fresh. - Anchored candidates. Items whose own text carries a Linear key or PR number, i.e. the ones an arbiter could actually settle.
🛑 Never quote the inherited-anchor number as resolvability. An anchor in the block header is not the item's anchor: counting it inflates the resolvable share from 27.6% to 60.6% (measured), e.g. a tail about docs/plans/… counted as resolvable only because its header ended with "(PR 8)". The script reports anchors (the item's own) and inherited separately — only the former is resolvable.
Report-only, permanently. Closing someone's open item is a decision about their unfinished work, and the vault is human-authored: surface the worklist, never tick a box. Checking whether an anchor actually closed (a merged PR, a Done issue) needs credentials this script deliberately does not have — that is a job for an agent acting with the user's go-ahead.
Step 8: Top Hubs
Resolve the moc semantic role through taxonomy_roles, enumerate notes by that mapped type's configured prefix, then count backlinks for each:
python3 "<mnemo-root>/scripts/safe-read.py" moc-names <<< '{"vault":"{vault}","prefix":"{moc_prefix}"}'
For each enumerated hub name, count backlinks:
python3 "<mnemo-root>/scripts/safe-read.py" backlinks <<< '{"file":"{moc_name}","vault":"{vault}"}'
Sort by count, show top 5. Keep the enumerated hub-name list — Step 8.5 reuses it to find topics that have no mapped moc note.
Step 8.5: Research-Gap Candidates (report-only — where the vault wants to grow)
Karpathy's lint also proposes what to research next. Steps 1-8 already collected the raw signal — turn it into growth suggestions without any new CLI calls (reuse Step 2's obsidian eval top-10 unresolved targets, Step 3/4's tag counts, and the MOC-name list enumerated in Step 8). Two cheap, computable gap types:
- Topic cluster with no mapped
mocnote — a non-taxonomy topic tag with ≥5 notes (Milo's "mental squeeze point", the same triggerconfig-schema.mduses for creating a hub) whose{moc_prefix}{Topic}is absent from Step 8's enumerated hub-name list. Suggest using the configured prefix: "12 notes tagged#auth, no hub → create{moc_prefix}Auth?" - Recurring external with no mapped
sourcenote — a top unresolved target from Step 2'sobsidian evallist (the authoritative metadataCache top-10, not the CLIunresolvedoutput) cited ≥5 times that reads like an external tool/paper/vendor (not a short project name, which instead wants a hub note) and has no note using the prefix reached throughtaxonomy_roles.source. Suggest: "[[LangGraph]]cited ×9, no source-role note → capture{source_prefix}LangGraph?"
These are suggestions, never auto-created (same non-destructive stance as the rest of health). Skip a type silently when it yields nothing. Do not web-search to fill the gap — mnemo maintains a human-authored vault: it points at the gap, the user decides whether to fill it. (This is the on-philosophy half of Karpathy's "suggest new article candidates"; the auto-web-imputation half is deliberately omitted.)
Step 9: Output Report
📊 Vault Health Report ({date})
⚠️ claude-mem: {warning or "v12.3.9, clean"}
🔄 Cross-runtime recall: {Claude memory available | Codex memory available | enabled, counterpart unavailable ({reason})}
Total: {N} notes
Configured taxonomy (all entries, including types with no semantic role):
- {type_key}: {N} | prefix `{prefix}` | tag `#{tag}` | roles: {mapped_roles joined by ", ", or `none`}
Semantic routing (`taxonomy_roles`): fact→{type_key}, insight→{type_key}, source→{type_key}, session→{type_key}, moc→{type_key}
Other (no configured taxonomy tag): {N}
🔴 Orphans: {N}
- Note Name 1
- Note Name 2
🟡 Missing {links_section}: {N}
- Note Name 1
🚫 Bad filenames (`#`/`.`): {N} — permanent broken links, rename
- {configured prefix}Foo (PR #12) → rename to "PR 12"
🔍 Top unresolved targets (missing hub notes?):
1. [[Diadoc]] ×34 → create hub note?
2. [[Python]] ×28
🔗 Unresolved wikilinks: {N} total
📏 Tags: {N} total, {N} used once
🏆 Top-5 Hubs (most backlinks):
1. {moc_prefix}Security (34)
2. {moc_prefix}AI ML Tools (28)
...
💤 Review candidates (stale by type-aware age): {N}
- {fact_prefix}API X gotcha — 45d overdue ({fact_type}, {N}d budget)
- {source_prefix}vendor API pricing — 35d overdue ({source_type}, {N}d budget)
(snooze a still-valid note: add `reviewed: {today}` to its frontmatter)
🔬 Content lint: {N judged} — {S} still-valid, {U} update-needed, {C} contradicts ← only when review.lint.enabled
(counts MUST equal the lint's actual verdicts — never default to all-still-valid; see Step 7.5)
- {fact_prefix}API X gotcha → UPDATE-NEEDED: superseded by [[{fact_prefix}API X v2]]
- {source_prefix}vendor API pricing → still-valid (stamped reviewed: {today})
📮 Handoff: {N} KB / {N} blocks / {N} open items ← only when a handoff_note is configured, over handoff.maxKB, AND still in block format
full resolution would free {N} KB ({N}%) — triage buys correctness, not size
{N} fully-resolvable · {N} partial (one anchorless item pins the block) · {N} no-anchor · {N} prose-pinned
🌱 Research-gap candidates (where the vault wants to grow): {N}
- #auth ×12 notes, no hub → create {moc_prefix}Auth?
- [[LangGraph]] ×9, no source-role note → capture {source_prefix}LangGraph?
🧠 Claude memory/ index: {KB}KB / {lines} lines {✅ lean | ⚠️ bloated → autodream}
Omit the 🔬 Content lint block entirely when review.lint.enabled is false. Omit the 🌱 Research-gap candidates block when Step 8.5 found nothing. Omit the 📮 Handoff block when Step 7.6 was skipped or reported an index-format handoff (0 blocks) — a triage line reading "0 blocks · 0 open · frees 0%" says nothing and reads like a failure. The still-valid line above shows the default (autoStampReviewed: true — the note was stamped); with autoStampReviewed: false render it as → still-valid (recommend reviewed: {today}) instead, since nothing was written.
Both Claude-specific lines are conditional: skip the ⚠️ claude-mem line if Step 0 found nothing to warn about, and omit both it and the 🧠 Claude memory/ index line entirely in Codex. The 🔄 Cross-runtime recall line is runtime-neutral and appears only when Step 0.5 says the opt-in feature is enabled.
Step 10: Claude memory/ index health (autodream check)
Claude Code only. In Codex, skip this step and omit its report line; never scan ~/.claude/projects/ broadly as a proxy for Codex memory. Step 0.5 may verify one exact counterpart mapping without reading memory content, but Codex-generated read-only state lives under ${CODEX_HOME:-~/.codex}/memories/, is not governed by Claude's auto-memory truncation behavior, and remains outside this size audit.
In Claude Code, separate from the Obsidian vault, an always-loaded index lives at the discoverable auto-memory directory's MEMORY.md. Claude's current loader includes only the first 200 loaded-content lines or 25,000 loaded-content bytes; leading YAML frontmatter and block-level HTML comments are stripped before those limits are counted. Warn early (before either cliff), not at some lax raw-file size. The early byte threshold is configurable via config.json → memory.indexWarnKB (default 22). Use the bounded metadata-only helper so CLAUDE_CONFIG_DIR, discoverable settings, and auto-memory disable controls share the same fail-closed path resolution as cross-runtime recall:
python3 "<mnemo-root>/scripts/runtime-memory.py" claude-index-status <<< '{}'
The JSON returns only availability, reason, verified directory, raw and loaded-content byte/line counts, both hard limits, configured early threshold, and typed warning reasons — never index text. Treat hard_byte_limit and hard_line_limit as loader-limit failures and early_byte_threshold as the configurable early warning. If unavailable, report the reason without guessing another project or scanning ~/.claude/projects/ broadly. The helper cannot observe managed-policy or ad-hoc --settings inputs, so report its path as discoverable, not guaranteed effective, when those scopes may apply.
If flagged → recommend autodream (memory consolidation): slim the index into topic files + MEMORY-archive-index.md, no loss. Procedure: ~/.claude/memory/autodream-principles.md. This is the only memory/ check here — health otherwise audits Obsidian, not Claude's memory/.
Gotchas
Common failures in <mnemo-root>/references/gotchas.md. Skill-specific rules:
obsidian orphansmay return empty on small vaults — this is OK, not an error.- Reference notes (taxonomy docs, templates) aren't orphans even if few backlinks — they're meant to be lookups.
- Ghost notes (unresolved wikilinks) are a feature, not a bug — they enable entity discovery. Don't flag on raw count; instead surface the top targets (Step 2 eval) — frequent hub notes (actionable).
- CLI graph queries cache & can lie —
orphans/unresolved/backlinkslag writes and have shown a note as resolved AND broken at once. For critical checks useobsidian evalonmetadataCache(see<mnemo-root>/references/gotchas.md). Treat counts as advisory if notes were created this session. - Do not auto-fix anything — only report. User decides what to clean up. The one exception is
review.lint.autoStampReviewed(default true): it lets the content-lint stampreviewed: {today}on a still-valid note (Step 7.5) to close the snooze loop. That is the sole frontmatter write health can make — only thereviewed:field of a confirmed-valid note, never content, never anything else. It fires only when the content lint itself is enabled (review.lint.enabled, default false), so a default install still writes nothing; setautoStampReviewed: falseto keep the lint suggest-only. - Counterpart unavailable is informational — Step 0.5 is a bounded status probe, not a repair trigger. Never infer another project, start a daemon, create a symlink, or copy runtime memory to make it pass.
- Step 5 uses filesystem grep (~3600x faster than per-file reads — 49ms vs 180s on a 999-note vault) — safe on any vault size.
- Review candidates (Step 7) are temporal, not structural — don't conflate with orphans. A note can be both, either, or neither. The script is age-only by design (cheap, no graph dependency).
- Content-lint verdicts (Step 7.5) are triage-grade — time-based staleness is a proxy, not the signal (a 6-month-old note may be perfectly valid; "not read ≠ not valuable"). On
haikuespecially, contradiction/update calls have false positives. Surface them as questions, never as facts, and never act on them automatically. The whole point ofreviewed:is to let a quick human confirm and silence a false flag.