Skill: jerome-benoit/obsidian-wiki (version 1.2.2)

--- name: obsidian-wiki description: > Build and maintain a compiled LLM Wiki inside an Obsidian vault following Karpathy's pattern. Use when working with an Obsidian vault as a persistent knowledge base: ingesting raw sources into `raw/`, compiling or updating interlinked wiki pages under `wiki/`, generating an index, linting for broken links/orphans/staleness, maintaining taxonomy/schema files, or answering questions from pages already compiled into the wiki. NOT for: general Obsidian note-taking or plugin management (use `obsidian` skill), simple note creation, vault syncing, or generic Q&A that doesn't involve a compiled wiki vault. --- # Obsidian Wiki Implements Karpathy's LLM Wiki pattern inside an Obsidian vault. The agent is the compiler; Obsidian is the IDE; the wiki is the codebase. ## Prerequisites - `bash` — required by all scripts; `python3` — required by `wiki-lint.sh`, `wiki-manifest.sh`, and all Python scripts - `pdftotext` — optional, for `extract-book-digests.sh` (install via `poppler`) - `markdownlint-cli2` — optional, for markdown structure checks (auto-installed via `npx`) ## Vault Resolution Resolve the vault path at runtime. Store it in `$VAULT` for all operations. Preferred methods (in order): 1. If the user specifies a vault path, use it directly 2. Read the Obsidian config file to find the open vault: ```bash python3 -c " import json, pathlib, os # Obsidian stores vault config in a platform-dependent location for p in [ 'Library/Application Support/obsidian/obsidian.json', '.config/obsidian/obsidian.json', '.var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json', ]: f = pathlib.Path.home() / p if f.exists(): for v in json.loads(f.read_text()).get('vaults',{}).values(): if v.get('open'): print(v['path']); break break " ``` 3. Use `obsidian-cli print-default --path-only` if available 4. Before any write, print the resolved `$VAULT` path. If it was inferred via Obsidian config or `obsidian-cli`, get user confirmation before continuing. The skill and scripts make no assumptions about the vault location. All paths are relative to `$VAULT`. Scripts require Bash and python3; they use BSD-compatible grep/sed/awk and are tested on macOS and Linux. ## Vault Layout ``` <vault>/ ├── raw/ # Immutable source documents │ ├── articles/ # Web clips, blog posts │ ├── papers/ # arXiv, IEEE, ACM papers │ ├── specifications/ # Standards, RFCs, W3C/ECMA/ISO specs, protocol docs │ ├── projects/ # Project notes, meeting transcripts │ ├── books/ # Book chapters, excerpts │ └── assets/ # Images, diagrams (downloaded locally) ├── wiki/ # LLM-compiled pages (agent-owned) │ ├── entities/ # People, systems, projects, orgs │ ├── concepts/ # Ideas, patterns, technologies, methods │ ├── syntheses/ # Cross-cutting summaries, comparisons │ ├── sources/ # One summary page per ingested source │ ├── reports/ # Agent-generated dashboards (agent creates during maintain workflow) │ ├── index.md # Auto-generated TOC │ └── log.md # Append-only chronological record ├── _meta/ │ ├── schema.md # Wiki conventions (co-evolves with user) │ └── taxonomy.md # Canonical tag vocabulary ├── .wiki-meta/ # Machine state (not for humans) │ └── manifest.json # Delta tracking: ingested files + SHA-256 hashes ├── AGENTS.md # Agent instructions for this vault └── .obsidian/ # Obsidian config (don't touch) ``` ### Ownership rules - **`raw/`** — immutable. The agent reads but never modifies. - **`wiki/`** — agent-owned. The agent creates, updates, and maintains all pages. - **`_meta/`** — co-owned. The agent proposes changes; the user approves. - **`.wiki-meta/`** — machine-only. Delta tracking, caches. - Everything else in the vault is untouched. ## Page Format See `_meta/schema.md` (initialized from `references/schema-template.md`) for full conventions. Essential reminders: - Filenames: `kebab-case.md`, ASCII only - Wikilinks: `[[filename|Display Title]]` — in tables: `[[filename\|Display Title]]` - Provenance: `%%from: raw/path%%` or `%%inferred%%` ## Workflows ### 1. Setup Initialize the vault structure. Create dirs, then: - Copy `references/schema-template.md` → `_meta/schema.md` - Copy `references/taxonomy-template.md` → `_meta/taxonomy.md` - Copy `references/agents-template.md` → `AGENTS.md` - Customize `AGENTS.md` for the vault's domain - Propose changes to `_meta/schema.md` and `_meta/taxonomy.md`; edit them only after user approval - Create `wiki/index.md`, `wiki/log.md` **Source of truth**: once copied, `_meta/` and `AGENTS.md` are the live instances. `references/` are generic starter templates — they do not stay in sync. ### 2. Ingest 1. Run `scripts/wiki-manifest.sh "$VAULT" diff` to see pending sources 2. For each pending file: - Read the source, identify entities, concepts, claims, relationships - Create a source summary in `wiki/sources/` - Create or update entity/concept pages with [[wikilinks]] - Track provenance in frontmatter `sources:` field 3. Mark ingested: `scripts/wiki-manifest.sh "$VAULT" mark <file>` 4. Regenerate index: `scripts/wiki-index.sh "$VAULT"` 5. Append to `wiki/log.md`: ``` ## [YYYY-MM-DD] ingest | <Source Title> - Source: raw/<path> - Pages created: <list> - Pages updated: <list> ``` **Source types:** - `.md` / `.txt` — read directly with `read` tool - `.pdf` — use `pdf` tool to extract content (copy to workspace first if the file is outside it, then clean up) - `.epub` / `.html` — convert to text or extract content before ingesting - URL — use `web_fetch` to save as .md in the appropriate `raw/` subfolder (`articles/` for blog posts, `specifications/` for specs, etc.), then ingest the .md - Images in `raw/assets/` — referenced by pages, not ingested independently (the manifest tracks only ingestable document sources) **Choosing the right `raw/` subfolder:** | Subfolder | What goes here | Examples | |---|---|---| | `articles/` | Web clips, blog posts, news | Blog post, HN discussion, newsletter | | `papers/` | Academic & research papers | arXiv, IEEE, ACM, NeurIPS papers | | `specifications/` | Standards & technical specs | RFCs, W3C specs, ECMA standards, ISO/IEEE standards, OpenAPI specs, protocol docs, language specifications | | `projects/` | Project notes, meeting transcripts | Sprint retro, design doc, ADR | | `books/` | Book chapters, excerpts | Textbook PDF, epub chapter | | `assets/` | Images, diagrams (not ingested) | Screenshots, architecture diagrams | ### 3. Query 1. Read `wiki/index.md` to find relevant pages 2. Read relevant pages, synthesize an answer with [[wikilink]] citations 3. Offer to save valuable answers as new pages in `wiki/syntheses/` ### 4. Lint Run `scripts/wiki-lint.sh "$VAULT"` for automated checks (frontmatter, broken wikilinks, orphans, stale pages, tag drift, wikilink format, markdown structure, unlinked mentions). Requires python3; markdown structure checks additionally require npx. Use `--fix` to auto-correct fixable issues. Then manually review for: - Contradictions between pages - Missing pages: concepts mentioned in text but lacking their own page - Weak cross-references that should be strengthened ### 5. Maintain Periodic (heartbeat or manual): 1. Run lint 2. Scan for unlinked mentions of entity/concept names → add [[wikilinks]] 3. If new tags emerged, propose an update to `_meta/taxonomy.md`; edit it only after user approval 4. Populate `wiki/reports/` with dashboards (open questions, contradictions, stale) 5. Review stale pages, flag for update or archival ### 6. Navigate Use the `obsidian` skill (if available) for CLI operations (search, open, move/rename with wikilink refactoring). For bulk reads/writes, use `read`/`write` tools directly. ### 7. Search ```bash bash scripts/wiki-search.sh "$VAULT" "query terms" ``` Uses `qmd` for semantic search when available; falls back to `grep`. ## Scripts Bundled shell scripts require Bash (BSD-compatible grep/sed/awk). `wiki-lint.sh` and `wiki-manifest.sh` require `python3`. `extract-book-digests.sh` additionally requires `pdftotext`. `wiki-manifest.sh` uses `shasum` or `sha256sum` when available and falls back to Python `hashlib` otherwise. `wiki-lint.sh` optionally uses `markdownlint-cli2` (via npx) for markdown structure checks. Resolve `<skill-dir>` to the directory containing this SKILL.md. ### `scripts/wiki-index.sh "$VAULT"` Regenerate `wiki/index.md` from frontmatter of all wiki pages. ### `scripts/wiki-lint.sh "$VAULT" [--fix]` All-in-one wiki linter. Runs all checks by default. `--fix` auto-corrects fixable issues (wikilink format, markdown structure, and unlinked mentions via crosslink). Internally calls `wiki-lint-links.py` for wikilink resolution, `markdownlint-cli2` (via npx) for markdown structure, and `wiki-crosslink.py` for unlinked mention detection. ### `scripts/wiki-manifest.sh "$VAULT" <command>` Delta tracking via SHA-256 hashes in `.wiki-meta/manifest.json`. ```bash wiki-manifest.sh "$VAULT" status # raw vs ingested vs pending counts wiki-manifest.sh "$VAULT" diff # list files needing ingest wiki-manifest.sh "$VAULT" mark <file> # mark a file as ingested ``` ### `scripts/extract-book-digests.sh <books-dir> <output-dir>` Extract first 12 pages of each PDF as text via `pdftotext`. Used for cross-validating wiki source pages against actual book content. Writes `.txt` outputs to `<output-dir>`; prefer a directory under `$VAULT/.wiki-meta/` unless the user explicitly asks for another location. ### `scripts/wiki_lib.py` Shared Python library. **Not a standalone script.** Used internally by `wiki-lint-links.py`, `wiki-crosslink.py`, and `wiki-graph.py`. Exports: `WikiMap`, `read_md`, `parse_link_target`, and shared regexes. ### `scripts/wiki-crosslink.py "$VAULT" [--fix]` Find page titles and aliases mentioned in wiki text that are not already wrapped in `[[wikilinks]]`. Reports `UNLINKED:<file>\t<mention>\t<target>` lines and a `STATS:scanned=N unlinked=N fixed=N errors=N` summary. `--fix` auto-adds the wikilinks in-place. ### `scripts/wiki-graph.py "$VAULT" [--format json|graphml] [--stats]` Export the wiki's wikilink graph. Default output: `.wiki-meta/graph.json`. `--format graphml` writes `.wiki-meta/graph.graphml` instead. `--stats` prints hub, articulation point, and orphan analysis to stdout. ### `scripts/wiki-search.sh "$VAULT" "<query>"` Search the wiki. Uses `qmd search` if `qmd` is in PATH; otherwise falls back to `grep` + title/summary display. ## Multi-Agent Setup The vault root supports agent instructions files for coding agents: - Copy `references/agents-template.md` as `AGENTS.md` at vault root. - **For Claude Code:** symlink `ln -s AGENTS.md CLAUDE.md` so both agents read the same file. - **For other agents** (Cursor, Windsurf, Gemini): symlink or copy to their expected filename. Customize the copied file for your vault's domain. The `references/` copy is a read-only template — it does not stay in sync with the live instance. ## Tips - **Ingest one source at a time** for best quality. Stay involved. - **Good answers → wiki pages.** Don't let syntheses disappear into chat history. - **Graph view** in Obsidian shows wiki shape: hubs, orphans, clusters. - **Dataview plugin** queries frontmatter if installed. - **Git** the vault for version history (recommended). - **Web Clipper** browser extension gets articles into `raw/` fastest.

path: markdownlint-cli2.json
content: {
  "config": {
    "default": true,
    "MD013": {
      "line_length": 700,
      "code_blocks": false,
      "tables": false,
      "headings": false,
      "strict": false,
      "stern": false
    },
    "MD024": {
      "siblings_only": true
    },
    "MD025": {
      "front_matter_title": ""
    },
    "MD041": {
      "front_matter_title": "^\\s*title\\s*[:=]"
    },
    "MD049": {
      "style": "consistent"
    },
    "MD060": false
  }
}


path: references/agents-template.md
content: # <Vault Name> — LLM Wiki

Compiled knowledge base maintained by an AI agent, following
[Karpathy's LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f).

## Rules

- `raw/` is immutable — drop sources here, never modify existing ones
- `wiki/` is agent-owned — do not edit manually
- `_meta/` is co-owned — propose changes first; edit only after user approval
- `.wiki-meta/` is machine state — do not touch

## Quick Start

1. Drop a file into `raw/`
2. Ask the agent to ingest it
3. Browse `wiki/` in Obsidian (graph view recommended)

## Conventions

- `_meta/schema.md` — page format, naming, wikilink rules
- `_meta/taxonomy.md` — canonical tags

## Agent Workflows

The agent uses scripts from the **obsidian-wiki skill directory** (not the vault).
Refer to the skill's SKILL.md for exact commands and `$VAULT` argument usage.

- **Ingest:** check pending sources → read source → create/update pages → mark ingested → regenerate index
- **Lint:** `wiki-lint.sh "$VAULT"` — run all structural health checks (frontmatter, wikilinks, orphans, stale, tags, wikilink format, markdown structure, unlinked mentions)
- **Fix:** `wiki-lint.sh "$VAULT" --fix` — auto-correct fixable issues (wikilink format, markdown structure, unlinked mentions)
- **Cross-link:** `wiki-crosslink.py "$VAULT"` — find unlinked mentions; `--fix` to auto-add wikilinks
- **Graph:** `wiki-graph.py "$VAULT" --stats` — hub/orphan/articulation point analysis; default exports `.wiki-meta/graph.json`
- **Search:** `wiki-search.sh "$VAULT" "query"` — semantic search via qmd if available, grep fallback
- **Log:** append to `wiki/log.md` after each ingest session
- **Reports:** populate `wiki/reports/` with dashboards during maintenance


path: references/schema-template.md
content: # Wiki Schema

This document defines the conventions for the compiled wiki.
It co-evolves: the agent proposes changes, the user approves.

## Page Categories

| Category | Directory | What goes here |
|---|---|---|
| **Entities** | `wiki/entities/` | People, systems, projects, organizations, tools |
| **Concepts** | `wiki/concepts/` | Ideas, patterns, technologies, methods, algorithms |
| **Syntheses** | `wiki/syntheses/` | Cross-cutting summaries, comparisons, trend analyses |
| **Sources** | `wiki/sources/` | One page per ingested source: summary + metadata |
| **Reports** | `wiki/reports/` | Agent-generated dashboards (don't edit manually) |

## Naming Conventions

- Filenames: `kebab-case.md` using ASCII characters only (e.g., `reinforcement-learning.md`)
- Titles: Title Case in frontmatter `title:` field

## Wikilink Conventions

- **Obsidian resolves wikilinks by FILENAME only** — not by frontmatter title or aliases
- Always use `[[filename|Display Title]]` format
- Example: `[[convolutional-neural-network|Convolutional Neural Network]]`
- For sections: `[[filename#Section|Display Text]]`
- Every entity/concept mentioned should have a corresponding page (or be on the "missing pages" list)
- Run the skill's lint/fix flow after bulk page creation to auto-fix wikilink format and markdown structure issues

## Frontmatter Required Fields

Use simple unquoted `key: value` format — no quoted keys, no block scalars,
no multiline values. Tags and aliases use inline `[a, b]` arrays.

```yaml
title: <string>
type: entity | concept | synthesis | source | report
tags: [<from taxonomy.md>]
sources: [<raw file paths>]
created: YYYY-MM-DD
updated: YYYY-MM-DD
```

Special pages `index.md` and `log.md` use `type: index` and `type: log` respectively.
Special pages (`index.md`, `log.md`) use reduced frontmatter: only `title`, `type`, and `updated` are required. They are excluded from orphan detection and content-page lint checks.

## Optional Frontmatter

```yaml
confidence: high | medium | low
status: active | review | stale | archived
aliases: [<alternative names>]
```

## Source Attribution

- Frontmatter `sources:` lists which raw files contributed
- Inline `%%from: raw/path/to/source.md%%` for paragraph-level attribution
- Inline `%%inferred%%` for LLM synthesis across multiple sources
- Inline `%%ambiguous: explanation%%` when sources disagree
- **Do NOT use `^[...]`** — that is Obsidian's inline footnote syntax

## Content Guidelines

- **Be concise.** Wiki pages are reference material, not essays.
- **Lead with the definition.** First sentence should define the entity/concept.
- **Use bullet lists** for structured information.
- **Cross-reference aggressively.** If a concept is mentioned, link it.
- **Track open questions.** Every page can have a `## Open Questions` section.
- **Note contradictions explicitly.** Don't silently pick a side.

## Evolution

When you (the agent) identify a convention that should change:
1. Propose the change in conversation
2. If approved, update this schema
3. Log the schema change in `wiki/log.md`


path: references/taxonomy-template.md
content: # Tag Taxonomy

Canonical tags for the wiki. Use only these tags in frontmatter.
New tags require discussion and addition to this file.

## Core

- `ai` — Artificial Intelligence (general)
- `ml` — Machine Learning
- `dl` — Deep Learning
- `nlp` — Natural Language Processing
- `cv` — Computer Vision
- `rl` — Reinforcement Learning
- `llm` — Large Language Models
- `agents` — AI Agents & Agentic Systems
- `diffusion` — Diffusion Models & Generative AI
- `safety` — AI Safety & Alignment
- `software-engineering` — Software development practices
- `performance` — Benchmarking & optimization
- `dev-tooling` — Developer tools & workflows

## Source Types

- `paper` — Academic paper
- `survey` — Survey or review paper
- `benchmark` — Benchmark or evaluation
- `method` — New method or algorithm
- `dataset` — Dataset description
- `specification` — Standard, RFC, or technical specification

## Entity Types

- `person` — Individual (researcher, engineer, etc.)
- `organization` — Company, lab, university
- `project` — Named project or initiative
- `tool` — Software tool or library

---

*Add domain-specific tags below this line after vault setup.*


path: scripts/extract-book-digests.sh
content: #!/usr/bin/env bash
# extract-book-digests.sh — Extract first 12 pages of each PDF as text
# Usage: bash extract-book-digests.sh <books-dir> <output-dir> [--help]
set -euo pipefail

for arg in "$@"; do
  case "$arg" in --help|-h) echo "Usage: bash extract-book-digests.sh <books-dir> <output-dir>"; echo "Extract first 12 pages of each PDF as text via pdftotext."; exit 0 ;; --*) echo "Error: unknown option '$arg'" >&2; exit 2 ;; esac
done

command -v pdftotext >/dev/null 2>&1 || { echo "Error: pdftotext is required (install poppler-utils (Linux) or brew install poppler (macOS))" >&2; exit 1; }

BOOKS="${1:?Usage: extract-book-digests.sh <books-dir> <output-dir>}"
OUT="${2:?Usage: extract-book-digests.sh <books-dir> <output-dir>}"
[ -d "$BOOKS" ] || { echo "Error: books dir not found: $BOOKS" >&2; exit 1; }
mkdir -p "$OUT"

_err=$(mktemp)
_failed=0
trap 'rm -f "$_err" 2>/dev/null' EXIT

# Case-insensitive PDF matching
while IFS= read -r pdf; do
  [ -f "$pdf" ] || continue
  # Strip extension case-insensitively
  name=$(basename "$pdf")
  name="${name%.[Pp][Dd][Ff]}"
  dest="$OUT/$name.txt"
  echo -n "Extracting $name ... "
  if pdftotext -f 1 -l 12 "$pdf" "$dest" 2>"$_err"; then
    sz=$(wc -c < "$dest" | tr -d ' ')
    echo "OK (${sz} bytes)"
  else
    echo "FAIL: $(cat "$_err")"
    rm -f "$dest"
    _failed=$((_failed + 1))
  fi
  : > "$_err"
done < <(find "$BOOKS" -maxdepth 1 -type f -iname '*.pdf' -print 2>/dev/null | sort)

echo ""
echo "Digests: $(find "$OUT" -maxdepth 1 -type f -name '*.txt' 2>/dev/null | wc -l | tr -d ' ') files in $OUT"

if [ "$_failed" -gt 0 ]; then
  echo "$_failed PDF(s) failed to extract." >&2
  exit 1
fi


path: scripts/lib.sh
content: #!/usr/bin/env bash
# lib.sh — Shared helpers for obsidian-wiki scripts
# Source this file: . "$(dirname "$0")/lib.sh"

# Extract YAML frontmatter (between first two --- lines).
# Safety: stops after 200 lines to avoid runaway on malformed files.
# Handles CRLF line endings transparently.
get_frontmatter() {
  awk '/^---\r?$/ { n++; if(n==2) exit; next } n==1 { sub(/\r$/, ""); print } NR>200 { exit }' "$1" 2>/dev/null
}

# Extract a single frontmatter field value.
# Usage: get_field <file> <key>  →  prints the raw value (quotes and trailing whitespace stripped)
# Note: <key> must be a plain alphanumeric/hyphen field name (no regex metacharacters).
get_field() {
  get_frontmatter "$1" | sed -n "/^${2}:/{s/^${2}: *//; s/^[\"']//; s/[\"']\$//; s/[[:space:]]*$//; p; q;}"
}


path: scripts/wiki-crosslink.py
content: #!/usr/bin/env python3
"""wiki-crosslink.py — Find unlinked mentions of wiki page titles/aliases.

Scans wiki pages for text mentions of known page titles and aliases that are
not already wrapped in [[wikilinks]]. Useful for enriching cross-references.

Usage:
  python3 wiki-crosslink.py <vault-path>          # report unlinked mentions
  python3 wiki-crosslink.py <vault-path> --fix     # auto-add [[wikilinks]]
  python3 wiki-crosslink.py <vault-path> --help

Structured output:
  UNLINKED:<file>\t<mention>\t<target-filename>
  STATS:scanned=N unlinked=N fixed=N

Skips:
  - Code blocks and inline code
  - Obsidian comments (%% ... %%)
  - YAML frontmatter
  - Existing [[wikilinks]]
  - Titles <= 3 characters (unless explicitly defined as aliases in frontmatter)
"""
import os, sys, re, tempfile

# Ensure wiki_lib is importable from same directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wiki_lib import (
    WikiMap, read_md, parse_link_target, frontmatter_re,
    wikilink_re, fence_re, fence_unclosed_re, inline_code_re, comment_re,
)

# Markdown link/image regex: [text](url) or ![alt](url) or [text][ref] or [ref]: url ...
_md_link_re = re.compile(r"!?\[[^\]]*\]\([^)]*\)|!?\[[^\]]*\]\[[^\]]*\]|^\[[^\]]*\]:.*$", re.MULTILINE)

# ── Argument parsing ──────────────────────────────────────

if "--help" in sys.argv or "-h" in sys.argv:
    print("Usage: python3 wiki-crosslink.py <vault-path> [--fix]")
    print("Find page titles/aliases mentioned in text without [[wikilinks]].")
    print("  --fix   Auto-add [[wikilinks]] for unlinked mentions")
    sys.exit(0)

vault = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else None
if not vault:
    print("Usage: python3 wiki-crosslink.py <vault-path> [--fix]", file=sys.stderr)
    sys.exit(2)

_known_flags = {"--fix", "--help", "-h"}
for arg in sys.argv[1:]:
    if arg.startswith("-") and arg not in _known_flags:
        print(f"Error: unknown option '{arg}'", file=sys.stderr)
        sys.exit(2)

fix_mode = "--fix" in sys.argv

wiki_dir = os.path.join(vault, "wiki")
if not os.path.isdir(wiki_dir):
    print(f"Error: {wiki_dir} not found", file=sys.stderr)
    sys.exit(2)

# ── Build maps ────────────────────────────────────────────

wm = WikiMap(vault)

# Collect explicit aliases from frontmatter (exempt from 3-char filter)
_aliases_re = re.compile(r"^aliases:\s*\[(.+?)\]\s*$", re.MULTILINE)
_explicit_aliases = set()
for md in wm.content_pages():
    content = read_md(md)
    if not content:
        continue
    fm_match = frontmatter_re.match(content)
    if not fm_match:
        continue
    fm = fm_match.group(1)
    m = _aliases_re.search(fm)
    if m:
        for alias in m.group(1).split(","):
            alias = alias.strip().strip("'\"")
            if alias:
                _explicit_aliases.add(alias)

# Build sorted list of (mention_text, target_basename) pairs to search for.
# Longer mentions first to avoid partial matches shadowing full matches.
# Exclude ambiguous titles/aliases — auto-fixing those could link to the wrong page.
_candidates = []
for mention, target in wm.title_to_file.items():
    # Skip short titles unless they are explicit frontmatter aliases
    if len(mention) <= 3 and mention not in _explicit_aliases:
        continue
    # Skip ambiguous titles or targets (could resolve to multiple pages)
    if wm.is_ambiguous(mention) or wm.is_ambiguous(target):
        continue
    _candidates.append((mention, target))
# Sort longest first so "Tree of Thoughts" matches before "Tree"
_candidates.sort(key=lambda x: len(x[0]), reverse=True)


def _strip_protected(text, protected):
    """Restore protected blocks into a copy (for display only; not used in scanning)."""
    for key, val in protected.items():
        text = text.replace(key, val)
    return text


def _find_unlinked(content, self_bn):
    """Return list of (mention_text, target_basename) for unlinked mentions.

    Scans the body text (after stripping frontmatter, code, comments, and
    existing wikilinks) for occurrences of known titles/aliases.
    """
    # Strip frontmatter
    body = re.sub(r"^---\n.*?\n---\n?", "", content, count=1, flags=re.DOTALL)

    # Replace code blocks and inline code with spaces (preserve offsets)
    protected = {}
    counter = [0]

    def protect(m):
        key = f"\x00P{counter[0]:06d}\x00"
        counter[0] += 1
        protected[key] = " " * len(m.group(0))
        return key

    safe = fence_re.sub(protect, body)
    safe = fence_unclosed_re.sub(protect, safe)  # catch unclosed fences
    safe = inline_code_re.sub(protect, safe)
    safe = comment_re.sub(protect, safe)

    # Replace existing wikilinks with spaces so we don't double-match
    safe = wikilink_re.sub(lambda m: " " * len(m.group(0)), safe)

    # Replace Markdown links/images [text](url) to avoid matching inside them
    safe = _md_link_re.sub(lambda m: " " * len(m.group(0)), safe)

    results = []
    seen_mentions = set()  # avoid reporting same mention twice

    for mention, target in _candidates:
        if target == self_bn:
            continue
        if mention in seen_mentions:
            continue

        # Word-boundary aware search (handles Unicode; also rejects hyphens to avoid
        # false positives on compounds like "pre-processing" matching "processing")
        pattern = re.compile(
            r"(?<![a-zA-Z0-9\-])" + re.escape(mention) + r"(?![a-zA-Z0-9\-])",
            re.UNICODE,
        )
        if pattern.search(safe):
            results.append((mention, target))
            seen_mentions.add(mention)
            # Mask this mention so shorter overlapping candidates don't fire
            safe = pattern.sub(" " * len(mention), safe)

    return results


def _apply_fixes(content, unlinked):
    """Replace bare mention text with [[target|mention]] wikilinks."""
    # Strip frontmatter for body-only processing
    fm_end = 0
    if content.startswith("---\n"):
        m = re.search(r"^---\n.*?\n---\n?", content, re.DOTALL)
        if m:
            fm_end = m.end()
    frontmatter = content[:fm_end]
    body = content[fm_end:]

    # Protect code/comment regions from rewrites
    protected = {}
    counter = [0]

    def protect(m):
        key = f"\x00P{counter[0]:06d}\x00"
        counter[0] += 1
        protected[key] = m.group(0)
        return key

    safe = fence_re.sub(protect, body)
    safe = fence_unclosed_re.sub(protect, safe)  # catch unclosed fences
    safe = inline_code_re.sub(protect, safe)
    safe = comment_re.sub(protect, safe)
    # Also protect existing wikilinks and markdown links
    safe = wikilink_re.sub(protect, safe)
    safe = _md_link_re.sub(protect, safe)

    # Apply replacements (longest mentions first — already sorted)
    # Use escaped pipe in table rows to avoid breaking table layout
    for mention, target in unlinked:
        pattern = re.compile(
            r"(?<![a-zA-Z0-9\-])" + re.escape(mention) + r"(?![a-zA-Z0-9\-])",
            re.UNICODE,
        )
        new_lines = []
        for line in safe.split("\n"):
            in_table = line.lstrip().startswith("|")
            pipe = "\\|" if in_table else "|"
            repl = f"[[{target}{pipe}{mention}]]"
            new_lines.append(pattern.sub(repl, line))
        safe = "\n".join(new_lines)
        # Re-protect newly added wikilinks so they aren't processed again
        safe = wikilink_re.sub(protect, safe)

    # Restore protected regions
    for key, val in protected.items():
        safe = safe.replace(key, val)

    return frontmatter + safe


# ── Main scan ─────────────────────────────────────────────

pages = wm.content_pages()
total_scanned = 0
total_unlinked = 0
total_fixed = 0
total_errors = 0

for md in pages:
    content = read_md(md)
    if content is None:
        relpath = os.path.relpath(md, vault)
        print(f"ERROR:{relpath}\tread error", file=sys.stderr)
        total_errors += 1
        continue
    total_scanned += 1
    self_bn = os.path.splitext(os.path.basename(md))[0]
    relpath = os.path.relpath(md, vault)

    unlinked = _find_unlinked(content, self_bn)
    if not unlinked:
        continue

    total_unlinked += len(unlinked)
    for mention, target in unlinked:
        print(f"UNLINKED:{relpath}\t{mention}\t{target}")

    if fix_mode:
        new_content = _apply_fixes(content, unlinked)
        if new_content != content:
            fd, tmp = tempfile.mkstemp(dir=os.path.dirname(md), suffix=".tmp")
            try:
                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    f.write(new_content)
                os.replace(tmp, md)
                total_fixed += len(unlinked)
            except Exception as e:
                try:
                    os.unlink(tmp)
                except OSError:
                    pass
                print(f"ERROR:{relpath}\t{e}", file=sys.stderr)
                total_errors += 1

print(f"STATS:scanned={total_scanned} unlinked={total_unlinked} fixed={total_fixed} errors={total_errors}")
if total_errors > 0:
    sys.exit(2)
if total_unlinked > 0 and not fix_mode:
    sys.exit(1)  # issues found but not fixed
# In fix mode, exit 0 if fixes succeeded (errors already caught above)


path: scripts/wiki-graph.py
content: #!/usr/bin/env python3
"""wiki-graph.py — Export the wiki wikilink graph as JSON or GraphML.

Builds a node+edge graph from wikilinks across all wiki pages and writes it
to .wiki-meta/graph.json (default) or .wiki-meta/graph.graphml.

Usage:
  python3 wiki-graph.py <vault-path>                       # JSON → .wiki-meta/graph.json
  python3 wiki-graph.py <vault-path> --format graphml      # GraphML → .wiki-meta/graph.graphml
  python3 wiki-graph.py <vault-path> --stats               # print hub/orphan analysis
  python3 wiki-graph.py <vault-path> --format graphml --stats
  python3 wiki-graph.py <vault-path> --help

Stats include:
  total nodes, total edges, avg degree, top 10 hubs,
  orphans (no links in or out), articulation points (removal increases connected components)
"""
import os, sys, json, re, tempfile
from collections import defaultdict

# Ensure wiki_lib is importable from same directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wiki_lib import WikiMap, read_md, frontmatter_re

# ── Argument parsing ──────────────────────────────────────

if "--help" in sys.argv or "-h" in sys.argv:
    print("Usage: python3 wiki-graph.py <vault-path> [--format json|graphml] [--stats]")
    print("Export the wiki wikilink graph.")
    print("  --format json     Output JSON to .wiki-meta/graph.json (default)")
    print("  --format graphml  Output GraphML to .wiki-meta/graph.graphml")
    print("  --stats           Print hub/orphan/articulation point analysis to stdout")
    sys.exit(0)

vault = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else None
if not vault:
    print("Usage: python3 wiki-graph.py <vault-path> [--format json|graphml] [--stats]",
          file=sys.stderr)
    sys.exit(2)

_known_flags = {"--format", "--stats", "--help", "-h"}
fmt = "json"
stats_mode = "--stats" in sys.argv

args = sys.argv[2:]
i = 0
while i < len(args):
    if args[i] == "--format":
        if i + 1 >= len(args):
            print("Error: --format requires a value (json or graphml)", file=sys.stderr)
            sys.exit(2)
        fmt = args[i + 1].lower()
        if fmt not in ("json", "graphml"):
            print(f"Error: unknown format '{fmt}'. Use json or graphml.", file=sys.stderr)
            sys.exit(2)
        i += 2
    elif args[i] in ("--stats", "--help", "-h"):
        i += 1
    elif args[i].startswith("-"):
        print(f"Error: unknown option '{args[i]}'", file=sys.stderr)
        sys.exit(2)
    else:
        i += 1

wiki_dir = os.path.join(vault, "wiki")
if not os.path.isdir(wiki_dir):
    print(f"Error: {wiki_dir} not found", file=sys.stderr)
    sys.exit(2)

# ── Build maps ────────────────────────────────────────────

wm = WikiMap(vault)

# Warn about duplicate basenames (graph keyed by basename)
for dup_bn in sorted(wm._ambiguous_basenames):
    print(f"WARNING: duplicate basename '{dup_bn}' — graph node may be inaccurate", file=sys.stderr)

# Gather node metadata (type and tags from frontmatter)
node_meta = {}  # basename → {type, tags}
_type_re = re.compile(r"^type:\s*['\"]?(\S+?)['\"]?\s*$", re.MULTILINE)
_tags_re = re.compile(r"^tags:\s*\[(.+?)\]\s*$", re.MULTILINE)
for md in wm.content_pages():
    bn = os.path.splitext(os.path.basename(md))[0]
    content = read_md(md)
    node_type = "unknown"
    node_tags = []
    if content:
        fm_match = frontmatter_re.match(content)
        if fm_match:
            fm = fm_match.group(1)
            mt = _type_re.search(fm)
            if mt:
                node_type = mt.group(1)
            mg = _tags_re.search(fm)
            if mg:
                node_tags = [t.strip().strip("'\"") for t in mg.group(1).split(",") if t.strip()]
    node_meta[bn] = {"type": node_type, "tags": node_tags}

# ── Build graph ───────────────────────────────────────────

# edges: list of (source_bn, target_bn)
edges_set = set()
links_out = defaultdict(int)   # source → count
links_in  = defaultdict(int)   # target → count

for md in wm.content_pages():
    src_bn = os.path.splitext(os.path.basename(md))[0]
    for target in wm.extract_links(md):
        status, resolved = wm.resolve_target(target)
        if resolved and status not in ("broken", "ambiguous"):
            if src_bn != resolved:
                edge = (src_bn, resolved)
                if edge not in edges_set:
                    edges_set.add(edge)
                    links_out[src_bn] += 1
                    links_in[resolved] += 1

# Ensure all known pages appear as nodes (even with no links)
all_nodes = set(wm.known_files) - {"index", "log"}

# ── Build output structures ───────────────────────────────

nodes = []
for bn in sorted(all_nodes):
    meta = node_meta.get(bn, {"type": "unknown", "tags": []})
    nodes.append({
        "id":       bn,
        "title":    wm.file_to_title.get(bn, bn),
        "type":     meta["type"],
        "tags":     meta["tags"],
        "links_out": links_out[bn],
        "links_in":  links_in[bn],
    })

edges = [{"source": s, "target": t, "weight": 1} for s, t in sorted(edges_set)]

# ── Stats computation ─────────────────────────────────────

def _compute_stats(edges, all_nodes):
    n = len(all_nodes)
    e = len(edges)
    avg_degree = (2 * e / n) if n > 0 else 0.0

    # Derive link counts from edges (pure — no globals)
    _links_out = defaultdict(int)
    _links_in  = defaultdict(int)
    degree     = defaultdict(int)  # undirected for hub ranking
    for edge in edges:
        _links_out[edge["source"]] += 1
        _links_in[edge["target"]] += 1
        degree[edge["source"]] += 1
        degree[edge["target"]] += 1

    top_hubs = sorted(all_nodes, key=lambda bn: degree[bn], reverse=True)[:10]

    orphans = sorted(
        bn for bn in all_nodes
        if _links_out[bn] == 0 and _links_in[bn] == 0
    )

    # Articulation point detection via Tarjan's algorithm — O(V+E)
    adj = defaultdict(set)
    for edge in edges:
        adj[edge["source"]].add(edge["target"])
        adj[edge["target"]].add(edge["source"])

    _disc = {}
    _low = {}
    _parent = {}
    _ap_set = set()
    _timer = [0]

    def _tarjan_iterative(root):
        """Iterative Tarjan's articulation point detection (avoids RecursionError)."""
        # Stack entries: (node, neighbor_iterator, children_count)
        _parent[root] = None
        _disc[root] = _low[root] = _timer[0]
        _timer[0] += 1
        stack = [(root, iter(sorted(adj[root])), 0)]
        while stack:
            u, neighbors, children = stack[-1]
            try:
                v = next(neighbors)
                if v not in _disc:
                    children += 1
                    stack[-1] = (u, neighbors, children)
                    _parent[v] = u
                    _disc[v] = _low[v] = _timer[0]
                    _timer[0] += 1
                    stack.append((v, iter(sorted(adj[v])), 0))
                elif v != _parent.get(u):
                    _low[u] = min(_low[u], _disc[v])
            except StopIteration:
                stack.pop()
                if stack:
                    parent_u = stack[-1][0]
                    _low[parent_u] = min(_low[parent_u], _low[u])
                    # Check articulation point conditions
                    p_node, p_neighbors, p_children = stack[-1]
                    if _parent.get(p_node) is None and p_children > 1:
                        _ap_set.add(p_node)
                    if _parent.get(p_node) is not None and _low[u] >= _disc[p_node]:
                        _ap_set.add(p_node)

    # Handle disconnected components
    for node in sorted(all_nodes):
        if node not in _disc:
            _tarjan_iterative(node)

    articulation_points = sorted(_ap_set)

    return {
        "total_nodes": n,
        "total_edges": e,
        "avg_degree":  round(avg_degree, 2),
        "top_hubs":    top_hubs,
        "orphans":     orphans,
        "articulation_points": articulation_points,
    }

stats = _compute_stats(edges, all_nodes)

# ── JSON output ───────────────────────────────────────────

def _write_json(vault, nodes, edges, stats):
    meta_dir = os.path.join(vault, ".wiki-meta")
    os.makedirs(meta_dir, exist_ok=True)
    out_path = os.path.join(meta_dir, "graph.json")
    payload = {"nodes": nodes, "edges": edges, "stats": stats}
    fd, tmp = tempfile.mkstemp(dir=meta_dir, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(payload, f, indent=2, ensure_ascii=False)
        os.replace(tmp, out_path)
    except Exception:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise
    return out_path

# ── GraphML output ────────────────────────────────────────

def _write_graphml(vault, nodes, edges):
    meta_dir = os.path.join(vault, ".wiki-meta")
    os.makedirs(meta_dir, exist_ok=True)
    out_path = os.path.join(meta_dir, "graph.graphml")

    def esc(s):
        return (str(s)
                .replace("&", "&amp;")
                .replace('"', "&quot;")
                .replace("<", "&lt;")
                .replace(">", "&gt;"))

    lines = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        '<graphml xmlns="http://graphml.graphdrawing.org/graphml"',
        '         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
        '         xsi:schemaLocation="http://graphml.graphdrawing.org/graphml'
        ' http://graphml.graphdrawing.org/graphml/1.1/graphml.xsd">',
        '  <key id="title"     for="node" attr.name="title"     attr.type="string"/>',
        '  <key id="type"      for="node" attr.name="type"      attr.type="string"/>',
        '  <key id="tags"      for="node" attr.name="tags"      attr.type="string"/>',
        '  <key id="links_out" for="node" attr.name="links_out" attr.type="int"/>',
        '  <key id="links_in"  for="node" attr.name="links_in"  attr.type="int"/>',
        '  <key id="weight"    for="edge" attr.name="weight"    attr.type="int"/>',
        '  <graph id="wiki" edgedefault="directed">',
    ]
    for node in nodes:
        lines.append(f'    <node id="{esc(node["id"])}">')
        lines.append(f'      <data key="title">{esc(node["title"])}</data>')
        lines.append(f'      <data key="type">{esc(node["type"])}</data>')
        lines.append(f'      <data key="tags">{esc(",".join(node["tags"]))}</data>')
        lines.append(f'      <data key="links_out">{node["links_out"]}</data>')
        lines.append(f'      <data key="links_in">{node["links_in"]}</data>')
        lines.append(f'    </node>')
    for i, edge in enumerate(edges):
        lines.append(f'    <edge id="e{i}" source="{esc(edge["source"])}" target="{esc(edge["target"])}">')
        lines.append(f'      <data key="weight">{edge["weight"]}</data>')
        lines.append(f'    </edge>')
    lines += ['  </graph>', '</graphml>']

    fd, tmp = tempfile.mkstemp(dir=meta_dir, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + "\n")
        os.replace(tmp, out_path)
    except Exception:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise
    return out_path

# ── Execute ───────────────────────────────────────────────

if fmt == "json":
    out = _write_json(vault, nodes, edges, stats)
    print(f"Graph written: {out} ({len(nodes)} nodes, {len(edges)} edges)")
else:
    out = _write_graphml(vault, nodes, edges)
    print(f"Graph written: {out} ({len(nodes)} nodes, {len(edges)} edges)")

if stats_mode:
    print(f"\n=== Graph Stats ===")
    print(f"  Nodes:       {stats['total_nodes']}")
    print(f"  Edges:       {stats['total_edges']}")
    print(f"  Avg degree:  {stats['avg_degree']}")
    print(f"  Top hubs:    {', '.join(stats['top_hubs'][:10]) or '(none)'}")
    print(f"  Orphans:     {len(stats['orphans'])}")
    if stats["orphans"]:
        for o in stats["orphans"][:10]:
            print(f"    - {o}")
        if len(stats["orphans"]) > 10:
            print(f"    ... and {len(stats['orphans']) - 10} more")
    print(f"  Articulation pts: {len(stats['articulation_points'])}")
    if stats["articulation_points"]:
        for b in stats["articulation_points"][:10]:
            print(f"    - {b}")
        if len(stats["articulation_points"]) > 10:
            print(f"    ... and {len(stats['articulation_points']) - 10} more")


path: scripts/wiki-index.sh
content: #!/usr/bin/env bash
# wiki-index.sh — Regenerate wiki/index.md from frontmatter
# Usage: bash wiki-index.sh <vault-path> [--help]
set -eo pipefail
export LC_ALL=C
. "$(dirname "$0")/lib.sh"

for arg in "$@"; do
  case "$arg" in --help|-h) echo "Usage: bash wiki-index.sh <vault-path>"; echo "Regenerate wiki/index.md from frontmatter of all wiki pages."; exit 0 ;; --*) echo "Error: unknown option '$arg'" >&2; exit 2 ;; esac
done

VAULT="${1:?Usage: wiki-index.sh <vault-path>}"
WIKI="$VAULT/wiki"
INDEX="$WIKI/index.md"

[ -d "$WIKI" ] || { echo "Error: $WIKI not found" >&2; exit 1; }

get_title() {
  local _t
  _t=$(get_field "$1" title)
  if [ -z "$_t" ]; then
    echo "Warning: no title in ${1#"$VAULT"/}" >&2
    basename "$1" .md | tr '-' ' '
  else
    printf '%s\n' "$_t"
  fi
}

get_summary() {
  local _s
  _s=$(awk '
    /^---\r?$/ && !d { f=!f; if(!f) d=1; next }
    f || /^#/ || /^$/ { next }
    /^%%/ { c=!c; next }
    c { next }
    { gsub(/%%([^%]|%[^%])*%%/, ""); gsub(/\r$/, ""); gsub(/\[\[[^|\]]*\\\|/, ""); gsub(/\[\[[^|\]]*\|/, ""); gsub(/\[\[/, ""); gsub(/\]\]/, ""); sub(/[[:space:]]+$/, ""); if(length>120) $0=substr($0,1,117)"..."; print; exit }
  ' "$1" 2>/dev/null) || true
  if [ -z "$_s" ]; then
    echo "Warning: no summary extracted from ${1#"$VAULT"/}" >&2
  fi
  printf '%s\n' "$_s"
}

build_section() {
  local _cat="$1" _hdr="$2"
  local _dir="$WIKI/$_cat"
  local _file="" _title="" _filename="" _summary="" _found=0
  [ -d "$_dir" ] || return 0
  echo "$_hdr"
  echo ""
  while IFS= read -r _file; do
    [ -n "$_file" ] || continue
    _found=1
    _title=$(get_title "$_file")
    _filename=$(basename "$_file" .md)
    _summary=$(get_summary "$_file")
    printf '%s\n' "- [[${_filename}|${_title}]] — ${_summary}"
  done < <(find "$_dir" -maxdepth 1 -type f -name '*.md' -print 2>/dev/null | sort)
  [ "$_found" -eq 1 ] || echo "_No pages yet._"
  echo ""
}

total=$(find "$WIKI" -type f -name '*.md' ! -path "$WIKI/index.md" ! -path "$WIKI/log.md" -print 2>/dev/null | wc -l | tr -d ' ')

_tmp_index=$(mktemp "$WIKI/index.md.XXXXXX")
trap 'rm -f "$_tmp_index" 2>/dev/null' EXIT
{
  echo "---"
  echo "title: Wiki Index"
  echo "type: index"
  echo "updated: $(date +%Y-%m-%d)"
  echo "---"
  echo ""
  echo "# Wiki Index"
  echo ""
  echo "*Auto-generated. Do not edit manually.*"
  echo ""
  build_section entities  "## 👤 Entities"
  build_section concepts  "## 💡 Concepts"
  build_section syntheses "## 📊 Syntheses"
  build_section sources   "## 📄 Sources"
  build_section reports   "## 📋 Reports"
  echo "---"
  echo ""
  echo "**Total pages:** ${total}"
  echo ""
  echo "Last compiled: $(date '+%Y-%m-%d %H:%M')"
} > "$_tmp_index"
mv "$_tmp_index" "$INDEX"

echo "Index written: $INDEX ($total pages)"


path: scripts/wiki-lint-links.py
content: #!/usr/bin/env python3
"""Lint and fix wikilink format for Obsidian resolution.

Obsidian resolves wikilinks by FILENAME only, not by frontmatter title or aliases.
Since filenames are kebab-case and links use Title Case, every [[Title]] link
must become [[filename|Title]] for Obsidian to resolve it.

Modes:
  --check     Report format issues without modifying files (default)
  --fix       Apply format fixes to files
  --lint      Full lint: broken links, orphans, format issues (structured output)
  --lint --fix  Full lint + apply format fixes
  --dry-run   Legacy alias for --check

Structured output (--lint mode):
  MAP:<n> entries, <n> known files
  AMBIG:<detail>
  DUPLICATE:<basename>\t<path1>\t<path2>
  BROKEN:<file>\t<link>
  ORPHAN:<file>
  FORMAT:<file>  |  FIXED:<file>
  ERROR:<file>\t<message>
  STATS:broken=<n> orphans=<n> format=<n> ambig=<n> dup=<n> errors=<n>
"""
import os, sys, re, glob, tempfile
from collections import defaultdict

# Ensure wiki_lib is importable from same directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wiki_lib import (
    WikiMap, read_md, parse_link_target,
    wikilink_re, fence_re, fence_unclosed_re, inline_code_re, comment_re,
)

# ── Argument parsing ──────────────────────────────────────

if "--help" in sys.argv or "-h" in sys.argv:
    print("Usage: python3 wiki-lint-links.py <vault-path> [--check|--fix|--lint]")
    print("Lint and fix wikilink format for Obsidian resolution.")
    print("  --check    Report format issues (default)")
    print("  --fix      Apply format fixes")
    print("  --lint     Full lint: broken links, orphans, format (structured)")
    print("  --dry-run  Legacy alias for --check")
    sys.exit(0)

vault = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else None
if not vault:
    print("Usage: python3 wiki-lint-links.py <vault-path> [--check|--fix|--lint]", file=sys.stderr)
    sys.exit(2)

_known_flags = {"--check", "--fix", "--lint", "--dry-run", "--help", "-h"}
for arg in sys.argv[1:]:
    if arg.startswith("-") and arg not in _known_flags:
        print(f"Error: unknown option '{arg}'", file=sys.stderr)
        sys.exit(2)

fix_mode = "--fix" in sys.argv
lint_mode = "--lint" in sys.argv
check_mode = not fix_mode and not lint_mode

wiki = os.path.join(vault, "wiki")
if not os.path.isdir(wiki):
    print(f"Error: {wiki} not found", file=sys.stderr)
    sys.exit(2)

# ── Phase 1: Build maps (via WikiMap) ─────────────────────

wm = WikiMap(vault)

# Expose maps used by rewrite_link / process_file below
title_to_file    = wm.title_to_file
file_to_title    = wm.file_to_title
known_files      = wm.known_files
_warnings        = wm.warnings
ambiguous_titles = wm._ambiguous_titles
_ambiguous_basenames = wm._ambiguous_basenames

def resolve_target(target):
    return wm.resolve_target(target)

def extract_links(filepath):
    return wm.extract_links(filepath)

# ── Phase 2: Link extraction and rewriting ────────────────

counters = {"rewrites": 0, "ambiguities": 0, "errors": 0}


def rewrite_link(match, in_table=False):
    """Rewrite a wikilink to canonical [[filename|Title]] format."""
    inner = match.group(1)
    if "\\|" in inner:
        parts = inner.split("\\|", 1)
        target_full, display = parts[0], (parts[1] if len(parts) > 1 else None)
        pipe_sep = "\\|"
    elif "|" in inner:
        target_full, display = inner.split("|", 1)
        pipe_sep = "\\|" if in_table else "|"
    else:
        target_full, display, pipe_sep = inner, None, ("\\|" if in_table else "|")

    if "#" in target_full:
        target, section = target_full.split("#", 1)[0], "#" + target_full.split("#", 1)[1]
    else:
        target, section = target_full, ""

    status, resolved = resolve_target(target)
    if status == "ambiguous":
        counters["ambiguities"] += 1
        return match.group(0)
    if status == "broken":
        return match.group(0)

    # Link resolves — determine if rewrite is needed
    if target != resolved:
        counters["rewrites"] += 1
        disp = display if display else file_to_title.get(resolved, target)
        return f"[[{resolved}{section}{pipe_sep}{disp}]]"
    # Table context: ensure escaped pipe even when target is already canonical
    if in_table and display is not None and "|" in match.group(0) and "\\|" not in match.group(0):
        counters["rewrites"] += 1
        return f"[[{resolved}{section}\\|{display}]]"
    if display is None and resolved in file_to_title:
        counters["rewrites"] += 1
        return f"[[{resolved}{section}{pipe_sep}{file_to_title[resolved]}]]"
    return match.group(0)


def process_file(md):
    """Process a single file for format issues. Returns True if content changed."""
    original = read_md(md)
    if original is None:
        raise OSError(f"cannot read {md}")

    protected = {}
    _prot_counter = [0]

    def protect(m):
        key = f"\x00P{_prot_counter[0]:06d}\x00"
        _prot_counter[0] += 1
        protected[key] = m.group(0)
        return key

    safe = fence_re.sub(protect, original)
    safe = fence_unclosed_re.sub(protect, safe)  # catch unclosed fences
    safe = inline_code_re.sub(protect, safe)
    safe = comment_re.sub(protect, safe)

    new_lines = []
    for line in safe.split("\n"):
        stripped = line.lstrip()
        in_table = stripped.startswith("|")

        def _rewrite(match, _t=in_table):
            return rewrite_link(match, _t)

        new_lines.append(wikilink_re.sub(_rewrite, line))
    new_content = "\n".join(new_lines)

    for key, val in protected.items():
        new_content = new_content.replace(key, val)

    if new_content != original:
        if fix_mode:
            fd, tmp = tempfile.mkstemp(dir=os.path.dirname(md), suffix=".tmp")
            try:
                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    f.write(new_content)
                os.replace(tmp, md)
            except Exception:
                os.unlink(tmp)
                raise
        return True
    return False


# ── Execution ─────────────────────────────────────────────

if __name__ == "__main__":
    all_mds = sorted(glob.glob(os.path.join(wiki, "**", "*.md"), recursive=True))
    all_mds = [md for md in all_mds if os.path.isfile(md)]
    special_root = {os.path.join(wiki, "index.md"), os.path.join(wiki, "log.md")}
    all_mds = [md for md in all_mds if md not in special_root]

    if lint_mode:
        # --- Structured output for wiki-lint.sh ---

        print(f"MAP:{len(title_to_file)} entries, {len(known_files)} known files")

        dup_count = 0
        map_ambig_count = 0
        build_errors = 0
        for w in _warnings:
            if w[0] == "DUPLICATE":
                print(f"DUPLICATE:{w[1]}\t{w[2]}\t{w[3]}")
                dup_count += 1
            elif w[0].startswith("AMBIG"):
                print(f"AMBIG:{w[1]} maps to both {w[2]} and {w[3]}")
                map_ambig_count += 1
            elif w[0] == "SKIP":
                print(f"ERROR:{w[1]}\t{w[2]}")
                build_errors += 1

        # Pass 1: broken links + orphan detection
        # Design: links FROM index.md and log.md are excluded from inbound tracking
        # because these are auto-generated pages. A page linked only from the
        # auto-generated index is still considered orphan.
        inbound = defaultdict(set)
        broken = []
        for md in all_mds:
            bn = os.path.splitext(os.path.basename(md))[0]
            relpath = os.path.relpath(md, vault)
            for target in extract_links(md):
                status, resolved = resolve_target(target)
                if status == "broken":
                    broken.append((relpath, target))
                elif resolved:
                    # Track inbound even for ambiguous (lenient orphan detection)
                    if resolved != bn:
                        inbound[resolved].add(bn)

        orphans = sorted(
            wm._basename_paths[bn]
            for bn in known_files
            if bn not in ("index", "log")
            and not inbound.get(bn)
            and bn in wm._basename_paths
        )

        # Pass 2: format issues
        format_files = []
        for md in all_mds:
            try:
                if process_file(md):
                    format_files.append(os.path.relpath(md, vault))
            except Exception as e:
                counters["errors"] += 1
                print(f"ERROR:{os.path.relpath(md, vault)}\t{e}")

        # Output structured results
        for relpath, link in broken:
            print(f"BROKEN:{relpath}\t{link}")
        for orphan in orphans:
            print(f"ORPHAN:{orphan}")
        tag = "FIXED" if fix_mode else "FORMAT"
        for relpath in format_files:
            print(f"{tag}:{relpath}")

        total_ambig = counters['ambiguities'] + map_ambig_count
        total_errors = counters['errors'] + build_errors
        print(
            f"STATS:broken={len(broken)} orphans={len(orphans)} "
            f"format={len(format_files)} ambig={total_ambig} "
            f"dup={dup_count} errors={total_errors}"
        )

        if total_errors > 0:
            sys.exit(2)
        if broken or orphans or format_files or total_ambig or dup_count:
            sys.exit(1)

    else:
        # --- Human-readable output (backward-compatible --check / --fix) ---

        for w in _warnings:
            if w[0] == "DUPLICATE":
                print(f"  \u26a0\ufe0f  DUPLICATE BASENAME: '{w[1]}' exists at {w[2]} and {w[3]}")
            elif w[0] == "AMBIG_TITLE":
                print(f"  \u26a0\ufe0f  AMBIGUOUS: '{w[1]}' maps to both {w[2]} and {w[3]} (keeping {w[3]})")
            elif w[0] == "AMBIG_ALIAS":
                print(f"  \u26a0\ufe0f  AMBIGUOUS: alias '{w[1]}' maps to both {w[2]} and {w[3]} (keeping {w[3]})")
            elif w[0] == "AMBIG_CI":
                print(f"  \u26a0\ufe0f  AMBIGUOUS: title '{w[1]}' maps to both {w[2]} and {w[3]} (case-insensitive)")
            elif w[0] == "AMBIG_FILE":
                print(f"  \u26a0\ufe0f  AMBIGUOUS: filename '{w[1]}' maps to both {w[2]} and {w[3]}")
            elif w[0] == "SKIP":
                print(f"  \u26a0\ufe0f  SKIP (read error): {w[1]}: {w[2]}")

        print(f"Link map: {len(title_to_file)} entries, {len(known_files)} known files")

        for md in all_mds:
            try:
                if process_file(md):
                    relpath = os.path.relpath(md, vault)
                    if check_mode:
                        print(f"  WOULD REWRITE: {relpath}")
                    else:
                        print(f"  REWRITTEN: {relpath}")
            except Exception as e:
                counters["errors"] += 1
                print(f"  \u274c ERROR processing {os.path.relpath(md, vault)}: {e}", file=sys.stderr)

        print(f"\nTotal rewrites: {counters['rewrites']}")
        if counters["ambiguities"] > 0:
            print(f"Ambiguous links skipped: {counters['ambiguities']}")
        if counters["errors"] > 0:
            print(f"Processing errors: {counters['errors']}")
        if check_mode:
            print("(check mode \u2014 no files changed)")
        if counters["errors"] > 0:
            sys.exit(2)
        if counters["rewrites"] > 0 or counters["ambiguities"] > 0:
            sys.exit(1)


path: scripts/wiki-lint.sh
content: #!/usr/bin/env bash
# wiki-lint.sh — Lint the wiki (all checks by default, --fix to auto-correct)
#
# Usage:
#   bash wiki-lint.sh <vault-path>          # check all (like eslint)
#   bash wiki-lint.sh <vault-path> --fix    # check all + auto-fix what's fixable
#                                            # (wikilink format, markdown structure,
#                                            #  and unlinked mentions via crosslink)
#   bash wiki-lint.sh <vault-path> --help
#
# Checks:
#   1. Frontmatter completeness
#   2. Broken wikilinks        [via wiki-lint-links.py — authoritative resolver]
#   3. Orphan pages            [via wiki-lint-links.py — authoritative resolver]
#   4. Stale pages
#   5. Tag drift
#   6. Wikilink format         [fixable via wiki-lint-links.py]
#   7. Markdown structure      [fixable via markdownlint-cli2]
#   8. Unlinked mentions      [advisory, via wiki-crosslink.py]
#
# No set -e: grep returns 1 on no-match which is normal control flow here.
set -o pipefail

if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
  echo "Usage: bash wiki-lint.sh <vault-path> [--fix]"
  echo "Lint the wiki. All checks run by default."
  echo "  --fix    Auto-correct fixable issues (wikilink format, markdown structure)"
  exit 0
fi

# Parse args
export LC_ALL=C
. "$(dirname "$0")/lib.sh"
FIX_MODE=false
VAULT=""
for arg in "$@"; do
  case "$arg" in
    --fix) FIX_MODE=true ;;
    --help|-h) echo "Usage: bash wiki-lint.sh <vault-path> [--fix]"; exit 0 ;;
    --*) echo "Error: unknown option '$arg'. Use --help for usage." >&2; exit 2 ;;
    *) [ -z "$VAULT" ] && VAULT="$arg" ;;
  esac
done
[ -z "$VAULT" ] && { echo "Usage: wiki-lint.sh <vault-path> [--fix]" >&2; exit 2; }

WIKI="$VAULT/wiki"
[ -d "$WIKI" ] || { echo "Error: $WIKI not found" >&2; exit 1; }
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"

# Track skipped checks for honest summary
skipped_checks=""
mark_skipped() {
  skipped_checks="${skipped_checks:+$skipped_checks, }$1"
}

# Check python3 availability once
HAS_PYTHON3=false
command -v python3 >/dev/null 2>&1 && HAS_PYTHON3=true
$HAS_PYTHON3 || echo "Warning: python3 not found — some checks will be skipped" >&2

echo "=== Wiki Lint Report ==="
echo "Vault: $VAULT"
echo "Date:  $(date '+%Y-%m-%d %H:%M')"
echo ""

# Temp files used across checks
_lint_out=""
_lint_err=""
_mdl_tmp=""
STALE_LOG=$(mktemp)
DRIFT_LOG=$(mktemp)
CANONICAL_FILE=""
MDL_LOG=$(mktemp)

# --- Find wiki content pages (excludes index.md and log.md at wiki root) ---
CONTENT_PAGES=$(mktemp)
find "$WIKI" -type f -name '*.md' -print 2>/dev/null | grep -xFv "$WIKI/index.md" | grep -xFv "$WIKI/log.md" | sort > "$CONTENT_PAGES"
find_content_pages() { cat "$CONTENT_PAGES"; }

trap 'rm -f "$CONTENT_PAGES" "$_lint_out" "$_lint_err" "$_mdl_tmp" "$STALE_LOG" "$DRIFT_LOG" "$CANONICAL_FILE" "$MDL_LOG" "${_xl_out:-}" "${_xl_err:-}" 2>/dev/null' EXIT

# ── 1. Missing / Incomplete Frontmatter ───────────────────
echo "## Frontmatter"
fm_count=0
while IFS= read -r file; do
  first=$(head -1 "$file" 2>/dev/null | tr -d '\r' || true)
  if [ "$first" != "---" ]; then
    echo "  ⚠️  ${file#"$VAULT"/} — no frontmatter"
    fm_count=$((fm_count + 1))
  else
    fm=$(get_frontmatter "$file")
    for field in title type tags sources created updated; do
      if ! echo "$fm" | grep -q "^${field}:"; then
        echo "  ⚠️  ${file#"$VAULT"/} — missing field: $field"
        fm_count=$((fm_count + 1))
      fi
    done
    # Validate type value (only if type field exists — absence already reported above)
    _type=$(get_field "$file" type)
    if [ -n "$_type" ]; then
      case "$_type" in
        entity|concept|synthesis|source|report|index|log) ;;
        *) echo "  ⚠️  ${file#"$VAULT"/} — invalid type: $_type"; fm_count=$((fm_count + 1)) ;;
      esac
    fi
  fi
done < <(find_content_pages)
[ "$fm_count" -eq 0 ] && echo "  ✅ All pages have complete frontmatter"
echo ""

# ── 2–3 & 6. Broken links, Orphans, Wikilink format ──────
# All three checks use the authoritative resolver in wiki-lint-links.py.
# This eliminates the old heuristic shell-side resolution that diverged on
# aliases and lossy normalization.
echo "## Broken Wikilinks"
broken_count=0
orphan_count=0
fmt_count=0

LINK_SCRIPT="$SKILL_DIR/scripts/wiki-lint-links.py"
if $HAS_PYTHON3 && [ -f "$LINK_SCRIPT" ]; then
  _lint_args="--lint"
  $FIX_MODE && _lint_args="--lint --fix"

  _lint_out=$(mktemp)
  _lint_err=$(mktemp)
  # shellcheck disable=SC2086
  python3 "$LINK_SCRIPT" "$VAULT" $_lint_args > "$_lint_out" 2>"$_lint_err"
  _lint_rc=$?
  [ -s "$_lint_err" ] && sed 's/^/  /' "$_lint_err" >&2

  _resolver_failed=false
  if [ "$_lint_rc" -eq 2 ]; then
    echo "  ❌ wiki-lint-links.py failed (exit 2)"
    head -20 "$_lint_out" | sed 's/^/  /'
    broken_count=1
    _resolver_failed=true
  fi

  # Collect results from structured output
  _broken_lines=""
  _orphan_lines=""
  _format_lines=""
  _ambig_lines=""
  _ambig_count=0
  _fmt_fixed=0
  _fmt_remaining=0
  _dup_count=0
  while IFS= read -r line; do
    case "$line" in
      BROKEN:*)
        _file="${line#BROKEN:}"
        _target="${_file#*	}"
        _file="${_file%%	*}"
        _broken_lines="${_broken_lines}  🔗 $_file → [[$_target]]
"
        broken_count=$((broken_count + 1))
        ;;
      ORPHAN:*)
        _orphan_lines="${_orphan_lines}  🏝️  ${line#ORPHAN:}
"
        orphan_count=$((orphan_count + 1))
        ;;
      FORMAT:*)
        _format_lines="${_format_lines}  WOULD REWRITE: ${line#FORMAT:}
"
        _fmt_remaining=$((_fmt_remaining + 1))
        ;;
      FIXED:*)
        _format_lines="${_format_lines}  REWRITTEN: ${line#FIXED:}
"
        _fmt_fixed=$((_fmt_fixed + 1))
        ;;
      DUPLICATE:*)
        _format_lines="${_format_lines}  ⚠️  DUPLICATE BASENAME: ${line#DUPLICATE:}
"
        _dup_count=$((_dup_count + 1))
        ;;
      AMBIG:*)
        _ambig_lines="${_ambig_lines}  ⚠️  AMBIGUOUS: ${line#AMBIG:}
"
        ;;
      ERROR:*)
        echo "  ❌ ${line#ERROR:}"
        ;;
      STATS:*)
        _ambig_count=$(echo "$line" | sed 's/.*ambig=\([0-9]*\).*/\1/')
        _ambig_count=${_ambig_count:-0}
        case "$_ambig_count" in *[!0-9]*) _ambig_count=0 ;; esac
        ;;
      MAP:*) ;;
    esac
  done < "$_lint_out"

  # Print broken wikilinks
  if [ -n "$_broken_lines" ]; then
    printf '%s' "$_broken_lines"
  elif ! $_resolver_failed; then
    echo "  ✅ All wikilinks resolve"
  fi
  echo ""

  # Print orphans
  echo "## Orphan Pages"
  if [ -n "$_orphan_lines" ]; then
    printf '%s' "$_orphan_lines"
  elif ! $_resolver_failed; then
    echo "  ✅ No orphan pages"
  fi
  echo ""

  # Print wikilink format
  echo "## Wikilink Format"
  if [ -n "$_ambig_lines" ]; then
    printf '%s' "$_ambig_lines"
  fi
  if [ -n "$_format_lines" ]; then
    printf '%s' "$_format_lines"
  fi
  if $_resolver_failed; then
    echo "  ❌ Resolver failed — results incomplete"
    fmt_count=1
  else
    # fmt_count = remaining issues (not fixed ones)
    fmt_count=$((_fmt_remaining + _dup_count + _ambig_count))
    if [ "$fmt_count" -eq 0 ] && [ "$_fmt_fixed" -eq 0 ]; then
      echo "  ✅ All wikilinks correctly formatted"
    else
      [ "$_fmt_fixed" -gt 0 ] && echo "  🔧 Fixed $_fmt_fixed wikilink issue(s)"
      [ "$_dup_count" -gt 0 ] && echo "  ⚠️  $_dup_count duplicate basename(s) (resolve manually)"
      [ "$_ambig_count" -gt 0 ] && echo "  ⚠️  $_ambig_count ambiguous link(s) skipped (resolve manually)"
      [ "$_fmt_remaining" -gt 0 ] && echo "  ⚠️  $_fmt_remaining format issue(s) remaining"
    fi
  fi
else
  mark_skipped "broken-links"
  mark_skipped "orphans"
  mark_skipped "wikilink-format"
  echo "  ⏭️  Skipped (python3 or wiki-lint-links.py not found)"
fi
echo ""

# ── 4. Stale Pages ────────────────────────────────────────
echo "## Stale Pages"
stale_count=0
if $HAS_PYTHON3; then
  cutoff=$(python3 -c "from datetime import datetime,timedelta;print((datetime.now()-timedelta(days=30)).strftime('%Y-%m-%d'))" 2>/dev/null || echo "")
  if [ -n "$cutoff" ]; then
    while IFS= read -r file; do
      updated=$(get_field "$file" updated)
      if [ -n "$updated" ]; then
        # Validate YYYY-MM-DD format
        case "$updated" in
          [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
          *) echo "  ⚠️  ${file#"$VAULT"/} — malformed date: $updated"; stale_count=$((stale_count + 1)); continue ;;
        esac
        [ "$updated" \< "$cutoff" ] && {
          echo "  ⏰ ${file#"$VAULT"/} — last updated $updated"
          echo "x" >> "$STALE_LOG"
        }
      fi
    done < <(find_content_pages)
    stale_count=$(( stale_count + $(wc -l < "$STALE_LOG" 2>/dev/null | tr -d ' ') ))
    [ "$stale_count" -eq 0 ] && echo "  ✅ No stale pages"
  else
    mark_skipped "stale"
    echo "  ⏭️  Skipped (python3 date comparison failed)"
  fi
else
  mark_skipped "stale"
  echo "  ⏭️  Skipped (python3 required for date comparison)"
fi
echo ""

# ── 5. Tag Drift ──────────────────────────────────────────
echo "## Tag Drift"
drift_count=0
taxonomy="$VAULT/_meta/taxonomy.md"
if [ -f "$taxonomy" ]; then
  CANONICAL_FILE=$(mktemp)
  grep -E '^- `[^`]+`' "$taxonomy" 2>/dev/null | sed 's/^- `//;s/`.*//' > "$CANONICAL_FILE"
  while IFS= read -r file; do
    tags_line=$(get_frontmatter "$file" | sed -n '/^tags:/{s/^tags: *\[//;s/\].*//; p; q;}')
    [ -z "$tags_line" ] && continue
    echo "$tags_line" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | while IFS= read -r tag; do
      [ -z "$tag" ] && continue
      if ! grep -qx "$tag" "$CANONICAL_FILE" 2>/dev/null; then
        echo "  🏷️  ${file#"$VAULT"/} — unknown tag: $tag"
        echo "x" >> "$DRIFT_LOG"
      fi
    done
  done < <(find_content_pages)
  drift_count=$(wc -l < "$DRIFT_LOG" 2>/dev/null | tr -d ' ')
  rm -f "$CANONICAL_FILE"
  CANONICAL_FILE=""
  [ "$drift_count" -eq 0 ] && echo "  ✅ All tags in taxonomy"
else
  mark_skipped "tag-drift"
  echo "  ⏭️  Skipped (taxonomy file not found: _meta/taxonomy.md)"
fi
echo ""

# ── 7. Markdown Structure (via markdownlint-cli2) ────────
echo "## Markdown Structure"
mdl_count=0
MDL_CONFIG="$SKILL_DIR/markdownlint-cli2.json"
# Find npx: PATH, then Volta, then NVM, then nix-profile
NPX_BIN=""
if command -v npx >/dev/null 2>&1; then
  NPX_BIN="npx"
elif [ -x "${VOLTA_HOME:-$HOME/.volta}/bin/npx" ]; then
  NPX_BIN="${VOLTA_HOME:-$HOME/.volta}/bin/npx"
elif [ -x "${NVM_DIR:-$HOME/.nvm}/current/bin/npx" ]; then
  NPX_BIN="${NVM_DIR:-$HOME/.nvm}/current/bin/npx"
elif [ -d "${NVM_DIR:-$HOME/.nvm}/versions/node" ]; then
  _nvm_latest=$(/bin/ls -1d "${NVM_DIR:-$HOME/.nvm}/versions/node/"v* 2>/dev/null | sed 's/.*\/v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
  _nvm_latest="${NVM_DIR:-$HOME/.nvm}/versions/node/v$_nvm_latest"
  [ -x "$_nvm_latest/bin/npx" ] && NPX_BIN="$_nvm_latest/bin/npx"
elif [ -x "$HOME/.nix-profile/bin/npx" ]; then
  NPX_BIN="$HOME/.nix-profile/bin/npx"
fi
if [ -n "$NPX_BIN" ] && [ -f "$MDL_CONFIG" ]; then
  _mdl_opts=()
  $FIX_MODE && _mdl_opts+=(--fix)
  _mdl_tmp=$(mktemp)
  find "$WIKI" -type f -name '*.md' ! -path "$WIKI/index.md" ! -path "$WIKI/log.md" -print0 2>/dev/null | xargs -0 "$NPX_BIN" --yes markdownlint-cli2 "${_mdl_opts[@]}" --config "$MDL_CONFIG" > "$_mdl_tmp" 2>&1
  _mdl_rc=$?
  if [ "$_mdl_rc" -ne 0 ] && [ "$_mdl_rc" -ne 1 ]; then
    echo "  ❌ markdownlint-cli2 failed (exit $_mdl_rc)"
    head -20 "$_mdl_tmp" | sed 's/^/  /'
    mdl_count=1
  else
    while IFS= read -r line; do
      [ -z "$line" ] && continue
      case "$line" in
        *"npm "*|*"npx "*|*"Finding"*|*"markdownlint-cli2 "*v*|*"Linting:"*|*"Summary:"*) continue ;;
      esac
      echo "  $line"
      echo "x" >> "$MDL_LOG"
    done < "$_mdl_tmp"
    mdl_count=$(wc -l < "$MDL_LOG" 2>/dev/null | tr -d ' ')
  fi
  if [ "$mdl_count" -eq 0 ]; then
    echo "  ✅ No markdown structure issues"
  elif $FIX_MODE; then
    echo "  🔧 Reported $mdl_count issue(s) (fixable ones auto-corrected)"
  fi
else
  if [ -z "$NPX_BIN" ]; then
    mark_skipped "markdown (npx not found)"
    echo "  ⏭️  Skipped (npx not found)"
  else
    mark_skipped "markdown (config missing: $MDL_CONFIG)"
    echo "  ⏭️  Skipped (config missing)"
  fi
fi
echo ""

# ── 8. Unlinked Mentions (advisory, non-blocking) ───────────
echo "## Unlinked Mentions (advisory)"
if $HAS_PYTHON3; then
  CROSSLINK_SCRIPT="$SKILL_DIR/scripts/wiki-crosslink.py"
  if [ -f "$CROSSLINK_SCRIPT" ]; then
    _xl_out=$(mktemp)
    _xl_err=$(mktemp)
    _xl_args=""
    $FIX_MODE && _xl_args="--fix"
    python3 "$CROSSLINK_SCRIPT" "$VAULT" $_xl_args > "$_xl_out" 2>"$_xl_err"
    _xl_rc=$?
    if [ "$_xl_rc" -eq 2 ]; then
      echo "  ❌ wiki-crosslink.py failed (exit 2)"
      [ -s "$_xl_err" ] && head -5 "$_xl_err" | sed 's/^/  /'
      rm -f "$_xl_out" "$_xl_err"
    else
      _xl_unlinked=0
      _xl_stats_line=""
      while IFS= read -r line; do
        case "$line" in
          UNLINKED:*)
            _xl_file="${line#UNLINKED:}"
            _xl_target="${_xl_file##*	}"
            _xl_mention="${_xl_file#*	}"
            _xl_mention="${_xl_mention%	*}"
            _xl_file="${_xl_file%%	*}"
            echo "  💡 $_xl_file — \"$_xl_mention\" → [[$_xl_target]]"
            ;;
          STATS:*)
            _xl_unlinked=$(echo "$line" | sed 's/.*unlinked=\([0-9]*\).*/\1/')
            _xl_unlinked=${_xl_unlinked:-0}
            _xl_stats_line="$line"
            ;;
        esac
      done < "$_xl_out"
      rm -f "$_xl_out" "$_xl_err"
      if [ "${_xl_unlinked:-0}" -eq 0 ]; then
        echo "  ✅ No unlinked mentions found"
      elif $FIX_MODE; then
        _xl_fixed=$(echo "$_xl_stats_line" | sed 's/.*fixed=\([0-9]*\).*/\1/')
        echo "  🔧 ${_xl_fixed:-0} mention(s) auto-linked. ${_xl_unlinked} total detected."
      else
        echo "  ℹ️  ${_xl_unlinked} unlinked mention(s) found. Run wiki-crosslink.py --fix to add wikilinks."
      fi
    fi  # end of _xl_rc check
  else
    echo "  ⏭️  Skipped (wiki-crosslink.py not found)"
  fi
else
  echo "  ⏭️  Skipped (python3 not found)"
fi
echo ""

# ── Summary ───────────────────────────────────────────────
total=$(( fm_count + broken_count + orphan_count + stale_count + drift_count + fmt_count + mdl_count ))
echo "=== Summary ==="
printf "  %-14s %d\n" "Frontmatter:" "$fm_count"
printf "  %-14s %d\n" "Wikilinks:" "$broken_count"
printf "  %-14s %d\n" "Orphans:" "$orphan_count"
printf "  %-14s %d\n" "Stale:" "$stale_count"
printf "  %-14s %d\n" "Tag drift:" "$drift_count"
printf "  %-14s %d\n" "Link format:" "$fmt_count"
printf "  %-14s %d\n" "Markdown:" "$mdl_count"
printf "  %-14s %d\n" "Total:" "$total"
echo "  (Unlinked mentions are advisory only — not counted in total)"
if [ -n "$skipped_checks" ]; then
  echo "  ⚠️  Skipped:    $skipped_checks"
fi
echo ""
if [ "$total" -eq 0 ]; then
  if [ -n "$skipped_checks" ]; then
    echo "No issues found in executed checks (some checks were skipped)."
  else
    echo "🎉 Wiki is healthy!"
  fi
else
  echo "Run fixes or ask the agent to resolve issues."
fi
exit $(( total > 125 ? 125 : total ))


path: scripts/wiki-manifest.sh
content: #!/usr/bin/env bash
# wiki-manifest.sh — Delta tracking for incremental wiki compilation
# Usage:
#   bash wiki-manifest.sh <vault-path> status     — accurate counts via hash check
#   bash wiki-manifest.sh <vault-path> diff        — list pending files
#   bash wiki-manifest.sh <vault-path> mark <file> — record a file as ingested
#   bash wiki-manifest.sh --help
set -euo pipefail
export LC_ALL=C

if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
  echo "Usage: bash wiki-manifest.sh <vault-path> {status|diff|mark <file>}"
  echo "Delta tracking via SHA-256 hashes for incremental wiki compilation."
  exit 0
fi

for arg in "$@"; do
  case "$arg" in --help|-h) ;; --*) echo "Error: unknown option '$arg'" >&2; exit 2 ;; esac
done

command -v python3 >/dev/null 2>&1 || { echo "Error: python3 is required" >&2; exit 1; }

VAULT="${1:?Usage: wiki-manifest.sh <vault-path> <command> [file]}"
VAULT="${VAULT%/}"  # strip trailing slash
CMD="${2:-status}"
ARG="${3:-}"

META_DIR="$VAULT/.wiki-meta"
MANIFEST="$META_DIR/manifest.json"
mkdir -p "$META_DIR"
[ -f "$MANIFEST" ] || echo '{}' > "$MANIFEST"

# Portable SHA-256
file_hash() {
  if command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$1" | cut -d' ' -f1
  elif command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" | cut -d' ' -f1
  else
    python3 -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"
  fi
}

# Find all raw sources (grouped correctly for BSD find)
find_raw() {
  [ -d "$VAULT/raw" ] || return 0
  find "$VAULT/raw" -type f \( -iname '*.md' -o -iname '*.pdf' -o -iname '*.txt' -o -iname '*.epub' -o -iname '*.html' \) ! -name '.gitkeep' -print 2>/dev/null | sort
}

# Compute diff: new + changed files (reused by status and diff)
# Single Python call: loads manifest + hashes all raw files → outputs NEW:/CHANGED:/OK: lines
compute_diff() {
  find_raw | python3 -c "
import json, sys, hashlib, os

manifest_path = sys.argv[1]
vault = sys.argv[2]

try:
    with open(manifest_path) as f:
        manifest = json.load(f)
except (json.JSONDecodeError, ValueError):
    print('Error: manifest.json is corrupted (invalid JSON). Fix or delete it.', file=sys.stderr)
    sys.exit(1)
if not isinstance(manifest, dict):
    print('Error: manifest.json has wrong shape (expected object). Fix or delete it.', file=sys.stderr)
    sys.exit(1)

for line in sys.stdin:
    filepath = line.rstrip('\\n')
    if not filepath:
        continue
    relpath = os.path.relpath(filepath, vault)
    try:
        current_hash = hashlib.sha256(open(filepath, 'rb').read()).hexdigest()
    except OSError as e:
        print(f'ERROR:{relpath}\\t{e}', file=sys.stderr)
        continue
    entry = manifest.get(relpath)
    if entry is None:
        print(f'NEW:{relpath}')
    elif not isinstance(entry, dict):
        print(f'NEW:{relpath}')  # malformed entry treated as missing
    elif entry.get('hash', '') != current_hash:
        print(f'CHANGED:{relpath}')
    else:
        print(f'OK:{relpath}')
" "$MANIFEST" "$VAULT" || { echo "Error: compute_diff failed" >&2; exit 1; }
}

case "$CMD" in
  status)
    diff_output=$(compute_diff)
    if [ -z "$diff_output" ]; then
      total_raw=0
    else
      total_raw=$(echo "$diff_output" | wc -l | tr -d ' ')
    fi
    new_count=$(echo "$diff_output" | grep -c '^NEW:' || true)
    changed_count=$(echo "$diff_output" | grep -c '^CHANGED:' || true)
    uptodate_count=$(echo "$diff_output" | grep -c '^OK:' || true)
    pending=$(( new_count + changed_count ))
    echo "=== Manifest Status ==="
    echo "Raw sources:  $total_raw"
    echo "Up to date:   $uptodate_count"
    echo "New:          $new_count"
    echo "Changed:      $changed_count"
    echo "Pending:      $pending"
    ;;

  diff)
    echo "=== Files Needing Ingest ==="
    _diff_out=$(compute_diff)
    if [ -z "$_diff_out" ] || ! echo "$_diff_out" | grep -qE '^(NEW|CHANGED):'; then
      echo "  ✅ All sources up to date"
    else
      echo "$_diff_out" | while IFS= read -r _line; do
        _status="${_line%%:*}"
        _relpath="${_line#*:}"
        case "$_status" in
          NEW)     echo "  📥 NEW:     $_relpath" ;;
          CHANGED) echo "  🔄 CHANGED: $_relpath" ;;
        esac
      done
    fi
    ;;

  mark)
    [ -n "$ARG" ] || { echo "Usage: wiki-manifest.sh <vault> mark <file-path>" >&2; exit 2; }
    # Canonicalize path
    if [ -f "$VAULT/$ARG" ]; then
      abs_file="$VAULT/$ARG"
    elif [ -f "$ARG" ]; then
      # Ensure it's absolute
      case "$ARG" in
        /*) abs_file="$ARG" ;;
        *)  abs_file="$(cd "$(dirname "$ARG")" && pwd)/$(basename "$ARG")" ;;
      esac
    else
      echo "Error: file not found: $ARG" >&2; exit 1
    fi
    # Reject symlinks (find_raw uses -type f which excludes them)
    if [ -L "$abs_file" ]; then
      echo "Error: symlinks are not supported (find_raw excludes them): $ARG" >&2; exit 1
    fi
    relpath="${abs_file#"$VAULT"/}"
    # Enforce that file is under raw/
    case "$relpath" in
      raw/*) ;;
      *) echo "Error: file must be under raw/: $relpath" >&2; exit 1 ;;
    esac
    # Enforce supported file type (case-insensitive, must match find_raw extensions)
    _ext_lower=$(printf '%s' "${relpath##*.}" | tr '[:upper:]' '[:lower:]')
    case "$_ext_lower" in
      md|pdf|txt|epub|html) ;;
      *) echo "Error: unsupported file type .${relpath##*.} (supported: .md, .pdf, .txt, .epub, .html, case-insensitive)" >&2; exit 1 ;;
    esac
    current_hash=$(file_hash "$abs_file")
    file_size=$(wc -c < "$abs_file" | tr -d ' ')
    now=$(python3 -c "from datetime import datetime,timezone;print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))")
    _tmp_manifest=$(mktemp "$META_DIR/manifest.json.XXXXXX")
    trap 'rm -f "$_tmp_manifest" 2>/dev/null' EXIT
    python3 -c "
import json, sys
manifest_path, relpath, h, ts, sz = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], int(sys.argv[5])
try:
    with open(manifest_path) as f:
        d = json.load(f)
except (json.JSONDecodeError, ValueError):
    print('Error: .wiki-meta/manifest.json is corrupted (invalid JSON). Fix or delete it.', file=sys.stderr)
    sys.exit(1)
if not isinstance(d, dict):
    print('Error: .wiki-meta/manifest.json has wrong shape (expected object). Fix or delete it.', file=sys.stderr)
    sys.exit(1)
d[relpath] = {'hash': h, 'ingestedAt': ts, 'size': sz}
with open(sys.argv[6], 'w') as f:
    json.dump(d, f, indent=2, sort_keys=True)
print(f'Marked: {relpath} (hash={h})')
" "$MANIFEST" "$relpath" "$current_hash" "$now" "$file_size" "$_tmp_manifest" && mv "$_tmp_manifest" "$MANIFEST" || { rm -f "$_tmp_manifest"; echo "Error: failed to update manifest" >&2; exit 1; }
    ;;

  *)
    echo "Unknown command: $CMD" >&2
    echo "Usage: wiki-manifest.sh <vault-path> {status|diff|mark <file>}" >&2
    exit 2
    ;;
esac


path: scripts/wiki-search.sh
content: #!/usr/bin/env bash
# wiki-search.sh — Search the wiki (qmd if available, else grep)
#
# Usage:
#   bash wiki-search.sh <vault-path> "<query>"
#   bash wiki-search.sh <vault-path> -- "<query>"   # for queries starting with --
#   bash wiki-search.sh --help
set -o pipefail
export LC_ALL=C
. "$(dirname "$0")/lib.sh"

# Parse args: support -- as option terminator
VAULT=""
QUERY=""
_past_separator=false
for arg in "$@"; do
  if [ "$arg" = "--" ] && ! $_past_separator; then
    _past_separator=true
    continue
  fi
  if ! $_past_separator; then
    case "$arg" in
      --help|-h) echo "Usage: bash wiki-search.sh <vault-path> \"<query>\""; echo "Search wiki pages. Uses qmd if available, falls back to grep."; echo "Use -- before queries starting with --."; exit 0 ;;
      --*) echo "Error: unknown option '$arg'. Use -- to pass queries starting with --." >&2; exit 2 ;;
    esac
  fi
  if [ -z "$VAULT" ]; then
    VAULT="$arg"
  elif [ -z "$QUERY" ]; then
    QUERY="$arg"
  fi
done

[ -z "$VAULT" ] && { echo "Usage: wiki-search.sh <vault-path> \"<query>\"" >&2; exit 2; }
[ -z "$QUERY" ] && { echo "Usage: wiki-search.sh <vault-path> \"<query>\"" >&2; exit 2; }
WIKI="$VAULT/wiki"

[ -d "$WIKI" ] || { echo "Error: $WIKI not found" >&2; exit 1; }

# ── Strategy 1: qmd (semantic search) ────────────────────
if command -v qmd >/dev/null 2>&1; then
  echo "🔍 Searching with qmd: \"$QUERY\""
  # Run qmd from the wiki dir; fall back to grep if qmd fails
  if (cd "$WIKI" && qmd search -- "$QUERY"); then
    exit 0
  fi
  echo "  ⚠️  qmd failed, falling back to grep" >&2
  echo ""
fi

# ── Strategy 2: grep fallback ─────────────────────────────
echo "🔍 Searching wiki for: \"$QUERY\""
echo ""

found=0
while IFS= read -r file; do
  if grep -iqF -- "$QUERY" "$file" 2>/dev/null; then
    title=$(get_field "$file" title)
    [ -z "$title" ] && title=$(basename "$file" .md | tr '-' ' ')
    relpath="${file#"$VAULT"/}"
    # Extract first matching line for context
    snippet=$(grep -iF -- "$QUERY" "$file" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//' | cut -c1-120)
    printf '  📄 %s\n     %s\n     ↳ %s\n\n' "$title" "$relpath" "$snippet"
    found=$((found + 1))
  fi
done < <(find "$WIKI" -type f -name '*.md' 2>/dev/null | sort)

if [ "$found" -eq 0 ]; then
  echo "  No results found for: \"$QUERY\""
  exit 1
fi
echo "  $found result(s) found."


path: scripts/wiki_lib.py
content: #!/usr/bin/env python3
"""wiki_lib.py — Shared library for obsidian-wiki scripts.

NOT a standalone script. Imported by:
  - wiki-lint-links.py
  - wiki-crosslink.py
  - wiki-graph.py

Exports:
  read_md(path)           — read + CRLF-normalize a markdown file
  parse_link_target(inner) — extract target from wikilink inner text
  wikilink_re, fence_re, fence_unclosed_re, inline_code_re, comment_re, frontmatter_re  — shared regexes
  WikiMap(vault_path)     — build title/alias/filename maps + resolve
"""
import os, re, glob
from collections import defaultdict

# ── Shared regexes ────────────────────────────────────────

wikilink_re    = re.compile(r"\[\[([^\]]+)\]\]")
fence_re       = re.compile(r"^ {0,3}(`{3,}|~{3,}).*?^ {0,3}\1", re.MULTILINE | re.DOTALL)  # CommonMark: 0-3 leading spaces, matched types
fence_unclosed_re = re.compile(r"^ {0,3}(?:`{3,}|~{3,}).*", re.MULTILINE | re.DOTALL)  # fallback for unclosed fences
inline_code_re = re.compile(r"(`+)(?!`)(.+?)\1")  # handles 1+ backtick delimiters
comment_re     = re.compile(r"%%.*?%%", re.DOTALL)
frontmatter_re = re.compile(r"^---\n(.*?)\n---", re.DOTALL)


def read_md(path):
    """Read a markdown file, normalizing CRLF. Returns content or None on error."""
    try:
        with open(path, encoding="utf-8") as f:
            return f.read().replace("\r\n", "\n")
    except (OSError, UnicodeDecodeError):
        return None


def parse_link_target(inner):
    """Extract target from wikilink inner text (strips display text and section)."""
    if "\\|" in inner:
        target_full = inner.split("\\|", 1)[0]
    elif "|" in inner:
        target_full = inner.split("|", 1)[0]
    else:
        target_full = inner
    return target_full.split("#", 1)[0] if "#" in target_full else target_full


class WikiMap:
    """Build and query the title/alias/filename maps for a wiki vault.

    Attributes (public):
        vault_path          — absolute path to vault root
        wiki_path           — absolute path to vault/wiki/
        title_to_file       — {title_or_alias: basename}  (case-sensitive)
        file_to_title       — {basename: title}
        known_files         — set of all basenames in wiki/**/*.md
        warnings            — list of (kind, *args) tuples collected during build

    Methods:
        resolve_target(target) → (status, canonical_filename)
        extract_links(filepath) → [target, ...]
        content_pages()        → sorted list of .md paths (excl. index.md, log.md)
    """

    def __init__(self, vault_path):
        self.vault_path = os.path.abspath(vault_path)
        self.wiki_path  = os.path.join(self.vault_path, "wiki")

        self.title_to_file  = {}
        self.file_to_title  = {}
        self.known_files    = set()
        self.warnings       = []

        # Internal state
        self._ambiguous_titles      = set()
        self._ambiguous_basenames   = set()  # basenames with duplicate paths only
        self._basename_paths        = {}
        self._lower_to_canonical    = {}
        self._title_to_file_lower   = {}

        self._build_maps()

    # ── Build Phase ───────────────────────────────────────

    def _build_maps(self):
        wiki = self.wiki_path
        title_to_file   = self.title_to_file
        file_to_title   = self.file_to_title
        warnings        = self.warnings
        ambiguous_titles = self._ambiguous_titles

        # 1. Title + alias maps (ordered subdirs only)
        for subdir in ["sources", "entities", "syntheses", "concepts", "reports"]:
            for md in sorted(glob.glob(os.path.join(wiki, subdir, "*.md"))):
                if not os.path.isfile(md):
                    continue
                bn = os.path.splitext(os.path.basename(md))[0]
                if bn in ("index", "log"):
                    continue
                content = read_md(md)
                if content is None:
                    warnings.append(("SKIP", os.path.relpath(md, self.vault_path), "read error"))
                    continue
                fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
                fm = fm_match.group(1) if fm_match else ""

                # title:
                m = re.search(r'^title:\s*["\']?(.+?)["\']?\s*$', fm, re.MULTILINE)
                if m:
                    title = m.group(1)
                    if title in title_to_file and title_to_file[title] != bn:
                        warnings.append(("AMBIG_TITLE", title, title_to_file[title], bn))
                        ambiguous_titles.add(title)
                    title_to_file[title] = bn
                    file_to_title[bn] = title

                # aliases:
                m = re.search(r"^aliases:\s*\[(.+?)\]\s*$", fm, re.MULTILINE)
                if m:
                    for alias in m.group(1).split(","):
                        alias = alias.strip().strip("'\"")
                        if alias:
                            if alias in title_to_file and title_to_file[alias] != bn:
                                warnings.append(("AMBIG_ALIAS", alias, title_to_file[alias], bn))
                                ambiguous_titles.add(alias)
                            title_to_file[alias] = bn

        # 2. Case-insensitive shadow map
        title_to_file_lower = self._title_to_file_lower
        for k, v in title_to_file.items():
            kl = k.lower()
            if kl in title_to_file_lower and title_to_file_lower[kl] != v:
                warnings.append(("AMBIG_CI", k, title_to_file_lower[kl], v))
                ambiguous_titles.add(k)
                for orig_k, orig_v in title_to_file.items():
                    if orig_k.lower() == kl and orig_v == title_to_file_lower[kl]:
                        ambiguous_titles.add(orig_k)
                        break
            title_to_file_lower[kl] = v

        # 3. All known filenames (detect duplicate basenames)
        known_files      = self.known_files
        basename_paths   = self._basename_paths
        ambiguous_bns    = self._ambiguous_basenames
        for md in glob.glob(os.path.join(wiki, "**", "*.md"), recursive=True):
            if os.path.isfile(md):
                bn      = os.path.splitext(os.path.basename(md))[0]
                relpath = os.path.relpath(md, self.vault_path)
                if bn in basename_paths:
                    warnings.append(("DUPLICATE", bn, basename_paths[bn], relpath))
                    ambiguous_titles.add(bn)
                    ambiguous_bns.add(bn)
                else:
                    basename_paths[bn] = relpath
                known_files.add(bn)

        # 4. Case-insensitive filename map
        lower_to_canonical = self._lower_to_canonical
        for f in sorted(known_files):
            fl = f.lower()
            if fl in lower_to_canonical and lower_to_canonical[fl] != f:
                warnings.append(("AMBIG_FILE", fl, lower_to_canonical[fl], f))
                ambiguous_titles.add(fl)
            lower_to_canonical[fl] = f

        # Recompute lowercase ambiguity set after all passes
        self._ambiguous_titles_lower = {t.lower() for t in ambiguous_titles}

    # ── Public API ────────────────────────────────────────

    def is_ambiguous(self, name):
        """Check if a title, alias, or filename is ambiguous (maps to multiple pages)."""
        return (name in self._ambiguous_titles
                or name.lower() in self._ambiguous_titles_lower
                or name in self._ambiguous_basenames)

    # ── Resolution ────────────────────────────────────────

    def resolve_target(self, target):
        """Resolve a wikilink target to a known filename.

        Returns (status, canonical_filename) where status is one of:
          "exact"     — target is a known filename
          "ci-file"   — case-insensitive filename match
          "title"     — matched via title or alias
          "ambiguous" — multiple candidates
          "broken"    — no match found
        """
        ambiguous_titles       = self._ambiguous_titles
        ambiguous_titles_lower = self._ambiguous_titles_lower
        known_files            = self.known_files
        ambiguous_bns          = self._ambiguous_basenames
        lower_to_canonical     = self._lower_to_canonical
        title_to_file          = self.title_to_file
        title_to_file_lower    = self._title_to_file_lower

        # 1. Exact filename match
        if target in known_files:
            if target in ambiguous_bns:
                return ("ambiguous", target)
            return ("exact", target)

        # 2. Case-insensitive filename match
        if target.lower() in lower_to_canonical:
            canonical = lower_to_canonical[target.lower()]
            if canonical in ambiguous_titles or canonical.lower() in ambiguous_titles_lower:
                return ("ambiguous", canonical)
            return ("ci-file", canonical)

        # 3. Title/alias match (case-sensitive, then case-insensitive)
        fn = title_to_file.get(target) or title_to_file_lower.get(target.lower())
        if fn:
            if (target in ambiguous_titles or target.lower() in ambiguous_titles_lower
                    or fn in ambiguous_titles or fn.lower() in ambiguous_titles_lower):
                return ("ambiguous", fn)
            return ("title", fn)

        # 4. No match
        return ("broken", None)

    # ── Link extraction ───────────────────────────────────

    def extract_links(self, filepath):
        """Extract unique wikilink targets from a file, skipping code/comments."""
        content = read_md(filepath)
        if content is None:
            return []
        content = fence_re.sub("", content)
        content = fence_unclosed_re.sub("", content)  # catch unclosed fences
        content = inline_code_re.sub("", content)
        content = comment_re.sub("", content)
        seen   = set()
        result = []
        for m in wikilink_re.finditer(content):
            t = parse_link_target(m.group(1))
            if t and t not in seen:
                seen.add(t)
                result.append(t)
        return result

    # ── Page enumeration ──────────────────────────────────

    def content_pages(self):
        """Return sorted list of .md paths in wiki/, excluding index.md and log.md."""
        wiki = self.wiki_path
        special = {
            os.path.join(wiki, "index.md"),
            os.path.join(wiki, "log.md"),
        }
        pages = []
        for md in glob.glob(os.path.join(wiki, "**", "*.md"), recursive=True):
            if os.path.isfile(md) and md not in special:
                pages.append(md)
        return sorted(pages)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)