"""Knowledge Base service — full LLM Wiki implementation.

Based on the LLM-powered wiki pattern:
  raw/      — drop zone (user dumps files, agent processes + moves)
  library/  — organized archive (category/YYYY-MM/file)
  wiki/     — LLM-compiled articles with [[backlinks]]
    entities/  — person, organization, tool pages
    concepts/  — topic and concept pages
    sources/   — source summary pages
    .index.md  — content catalog
    .concepts.md — concept map
  output/   — generated reports, slides, charts
  SCHEMA.md — conventions, structure, workflows
  log.md    — chronological operation log

Rules:
- LLM is editor, not writer. Every sentence traces to a source.
- Gaps marked [TODO: ...], never hallucinated.
- Contradictions flagged explicitly.
- Incremental compilation — only new/changed sources processed.
- Tiered context loading — index first, then targeted reads.
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from agntspace.llm.base import LLMProvider
    from agntspace.vault.reader import VaultReader
    from agntspace.vault.writer import VaultWriter

# File-type registry — single source of truth for classify + extract +
# render (decisions.md §2026-04-18). Populated from bundled defaults at
# init; Phase 2 will augment from skill YAML + plugin hooks.
from agntspace.kb.file_types import ExtractResult, FileTypeRegistry
from agntspace.kb.file_types_defaults import build_default_registry, load_file_type_skills

log = logging.getLogger(__name__)

# All wiki subdirectories that may contain articles
_WIKI_SUBDIRS = ("entities", "concepts", "sources", "source_summary", "synthesis", "decisions", "projects")  # arch:deterministic — fixed wiki directory layout, structural invariant shared with VaultPaths resolver


def _fresh_compile_state() -> dict:
    """Return an empty compile-state skeleton."""
    return {
        "last_ingest": None,
        "last_compile": None,
        "processed_hashes": {},
        "sources": {},
    }


class _EchoDetected(Exception):
    """Raised when a local-LLM extraction duplicates an existing source summary.

    Used to break out of nested loops in the ingest pipeline without leaking
    state. The outer handler quarantines the file and continues with the
    next inbox item.
    """


# ── Page templates per entity type ──
#
# Phase 4 (Rule 12) migration: templates previously lived as a 350-line
# Python dict at this location. They now live as individual .md files
# under ``defaults/skills/core/wiki-article-templates/templates/``.
# Each file is named ``{entity_type}.md`` and uses Python str.format()
# placeholders ({title}, {date}, etc.) for dynamic content.
#
# The loader below reads them via the SkillLoader (vault-first, then
# bundled defaults) and caches at the module level. A degraded-mode
# fallback provides a minimal concept template when the skill files
# are unavailable.

_TEMPLATES_CACHE: dict[str, str] | None = None

_CONCEPT_FALLBACK = """---
title: "{title}"
entity_type: concept
provenance: "{provenance}"
category: "{category}"
taxonomy_path: "{taxonomy_path}"
status: draft
sources: {sources}
author: agent
agent: "{agent}"
date_created: "{date}"
date_updated: "{date}"
---

# {title}

## Overview
[TODO: summary from sources]

## Open Questions
[TODO: what don't we know?]

## Sources
{source_list}
"""  # arch:deterministic -- minimal degraded-mode template when skill files are missing


def _load_article_templates(skill_loader: object | None = None) -> dict[str, str]:
    """Load per-entity-type article templates from the skill directory.

    Resolution order:
      1. Module-level cache (returned immediately if populated).
      2. ``wiki-article-templates`` skill's ``templates/`` directory
         via the SkillLoader. Each ``.md`` file becomes a key in the
         returned dict (filename stem = entity type).
      3. Degraded-mode fallback: a single ``concept`` template that
         matches the pre-Phase-4 hardcoded behavior for the most
         common entity type.

    The cache is cleared by ``invalidate_template_cache()`` for tests
    and hot-reload.
    """
    global _TEMPLATES_CACHE
    if _TEMPLATES_CACHE is not None:
        return _TEMPLATES_CACHE

    templates: dict[str, str] = {}

    # Try the skill loader path
    if skill_loader is not None and hasattr(skill_loader, "get_skill"):
        try:
            skill = skill_loader.get_skill("wiki-article-templates")
            if skill and skill.path:
                from pathlib import Path as _Path
                # skill.path is relative to skills_dir and points at SKILL.md
                skill_rel = _Path(skill.path)
                if skill_rel.name == "SKILL.md":
                    skill_rel = skill_rel.parent
                templates_dir = skill_loader._skills_dir / skill_rel / "templates"
                if templates_dir.is_dir():
                    for md_file in sorted(templates_dir.glob("*.md")):
                        entity_type = md_file.stem
                        templates[entity_type] = md_file.read_text(encoding="utf-8")
        except Exception:
            log.debug("kb: failed to load article templates from skill", exc_info=True)

    # Fallback: if nothing loaded, provide the concept template
    if not templates:
        templates["concept"] = _CONCEPT_FALLBACK

    _TEMPLATES_CACHE = templates
    return templates


def invalidate_template_cache() -> None:
    """Clear the article template cache. Tests call this between cases."""
    global _TEMPLATES_CACHE
    _TEMPLATES_CACHE = None


# Backwards-compat shim: code that references ``_TEMPLATES[entity_type]``
# still works because ``_load_article_templates()`` returns a dict with
# the same keys. The only call site (``_get_template``) is updated below.
# This comment exists so a grep for ``_TEMPLATES`` in this file finds it
# and understands the migration.



_SCHEMA_TEMPLATE = """---
title: "{topic} — Knowledge Base Schema"
type: kb_schema
author: user
library_layout: thematic
---

# {topic}

## Purpose
<!-- What is this knowledge base about? What questions should it answer? -->

## Conventions
- Every claim must trace to a source. No hallucinated content.
- Gaps marked with [TODO: ...] rather than invented filler.
- Articles use `[[Title]]` wikilinks for cross-references.
- Each article has frontmatter: title, entity_type, category, status, sources, author.
- Contradictions tracked in "Counter-Arguments & Data Gaps" sections.

## Entity Types
- **person** — people (wiki/entities/person-name.md)
- **organization** — companies, institutions (wiki/entities/org-name.md)
- **concept** — topics, ideas, methods (wiki/concepts/concept-name.md)
- **source_summary** — per-source summary (wiki/sources/filename.md)
- **synthesis** — cross-source analysis, insights connecting multiple sources (wiki/synthesis/slug.md)
- **decision_record** — structural decisions about wiki organization (wiki/decisions/slug.md)

## Status Values
- **draft** — auto-generated, not yet verified
- **verified** — human-reviewed and confirmed
- **stale** — may be outdated by newer sources

## Structure
- `inbox/` — drop zone for new source files
- `library/` — organized sources (agent-maintained)
- `wiki/entities/` — entity pages (people, organizations)
- `wiki/concepts/` — concept and topic pages
- `wiki/sources/` — source summary pages
- `wiki/synthesis/` — cross-source analysis and insights
- `wiki/decisions/` — structural decision records (why the wiki is shaped this way)
- `wiki/projects/` — project-scoped knowledge (project-specific entities, concepts, notes)
- `output/` — research outputs, reports, slides

## Project Scoping
When sources are project-specific, articles go to `wiki/projects/<project-slug>/`.
Each project gets an overview page at `wiki/projects/<project-slug>/<project-slug>.md`.
Global knowledge stays in top-level categories. Project articles link to global ones via wikilinks.

## Article Sections (required)
Every article must include:
- Summary paragraph after the title
- Key content sections
- **Open Questions** — what we don't know yet about this topic
- **Sources** — links to source files

## Notes
<!-- Domain-specific instructions for the agent -->
"""

_INDEX_TEMPLATE = """---
title: "{topic} — Index"
type: kb_index
author: agent
agent: librarian
updated: "{date}"
source_count: 0
article_count: 0
---

# {topic} — Knowledge Base

## Sources

<!-- Each source: link, one-line summary, date ingested -->

## Entities

<!-- Entity pages: [[link]] — one-line description -->

## Concepts

<!-- Concept pages: [[link]] — one-line description -->

## Statistics
- Sources ingested: 0
- Entity pages: 0
- Concept pages: 0
- Last updated: {date}
"""

# ── Source type classification ──

_SOURCE_TYPES = {
    "paper": {"exts": {".pdf"}, "prompt_focus": "Extract thesis, methodology, key findings, citations. Academic rigor."},
    "article": {"exts": {".md", ".html", ".htm", ".txt", ".rst"}, "prompt_focus": "Extract main arguments, key facts, quotes, author perspective."},
    "data": {"exts": {".csv", ".tsv", ".yaml", ".yml"}, "prompt_focus": "Describe the data structure, key fields, notable patterns, size."},
    "chat_history": {"exts": {".jsonl"}, "prompt_focus": "Extract key decisions, insights, topics discussed, action items from this conversation."},
    "export": {"exts": {".json"}, "prompt_focus": "Extract key topics, decisions, people mentioned, and actionable insights."},
    "transcript": {"exts": {".vtt", ".srt"}, "prompt_focus": "Extract key discussion points, decisions made, action items, attendees mentioned."},
    "code": {"exts": {".py", ".js", ".ts"}, "prompt_focus": "Describe purpose, key functions, architecture, dependencies."},
    "image": {"exts": {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}, "prompt_focus": "Describe what the image shows, any text/labels visible, context."},
}

_CLASSIFY_PROMPT_FALLBACK = """Classify this source document and extract information.

Source file: {filename}
Source type: {source_type}
Type-specific focus: {type_focus}

IMPORTANT: The body text may start with ads, navigation, or sidebar content captured by
web clippers. Use the SOURCE METADATA (title, description, URL) to determine the article's
TRUE topic. Ignore unrelated noise at the start of the body.

KB Schema:
{schema}

Existing wiki index (what's already in the KB — for DEDUP ONLY):
{index}

RULES:
- Every fact must trace to the source text. Do NOT invent information.
- If something is unclear, write [TODO: clarify from source].
- Note any contradictions with the existing wiki index.
- Use the frontmatter title/description as the authoritative topic — not the first line of body text.
- ANTI-CONTAMINATION: The "Existing wiki index" above is for dedup only — so you can suggest UPDATE instead of CREATE. Do NOT copy concept or entity names from that list into the concepts, entities, or summary blocks unless the concept is EXPLICITLY DISCUSSED in the source content.

Produce:

```summary
(3-5 sentence summary of the source, citing specific details)
```

```concepts
(ONLY concepts explicitly discussed or depicted in the source content.
Do NOT tag the source with existing KB concepts not present in this source.)
- concept1
- concept2
```

```entities
(ONLY people/orgs named and described in the source content.)
- person: Name — role/description
- organization: Org Name — what they do
```

```contradictions
(any facts that contradict existing wiki content, or "none found")
```

```wiki_updates
List ONLY entities and concepts that are SIGNIFICANT to this source — not every noun mentioned.

Significance test: would someone search for this concept to understand the source's domain?
- YES: a named methodology explained in the source, a person with a described role
- NO: generic terms, passing mentions, concepts from the existing wiki index that this source does not discuss

CREATE sparingly. Prefer UPDATE to existing pages. Maximum 5 CREATE lines per source.

Format:
- CREATE entities/person-slug: Full name and role | REASON: why this person matters
- CREATE entities/org-slug: Organization name | REASON: significance to the domain
- CREATE concepts/topic-slug: What this concept is about | REASON: why it deserves its own page
- UPDATE concepts/existing-slug: What new info to add from this source
```

```bibliography
- title: (exact title of the work — not the filename)
- authors: (comma-separated list of authors, or "unknown")
- date: (publication date YYYY-MM-DD or YYYY, or "unknown")
- publisher: (journal, conference, website, publisher name, or "unknown")
- doi: (DOI if visible, or "")
```"""

_COMPILE_PROMPT_FALLBACK = """You are a knowledge base wiki compiler.

KB SCHEMA:
{schema}

WIKI INDEX:
{index}

KNOWLEDGE TAXONOMY (assign each article a taxonomy_path from this tree):
{taxonomy}

EXISTING ARTICLES (summaries):
{existing_articles}

NEW SOURCES TO PROCESS:
{sources}

VALID SUBDIRECTORIES (use ONLY these — no others):
- entities/ — people and organizations
- concepts/ — topics, ideas, methods, standards
- synthesis/ — cross-source analysis (insights spanning multiple sources)
- sources/ — per-source summaries (created by ingest, not compile)
- decisions/ — structural decision records

RULES — CRITICAL:
1. You are an EDITOR, not a writer. Every sentence must trace to a source.
2. NEVER invent information. If data is missing, write [TODO: ...].
3. Use [[wiki-links]] generously for cross-references.
4. Flag contradictions explicitly in a "Counter-Arguments & Data Gaps" section.
5. Include [Source: filename] citations for every claim.
6. Use ONLY the valid subdirectories listed above. Meetings, events, places, decisions, and documents all go in concepts/ unless a dedicated subdir already exists in the KB.
7. ALWAYS include taxonomy_path in frontmatter — pick the most specific matching node from the taxonomy.
8. WRITE FULL CONTENT — not stubs. Extract ALL available information from the sources into article sections. A person page must include their role, affiliations, contributions. A concept page must explain what it is, how it works, where it's used. Only use [TODO: ...] for information genuinely absent from the sources.
9. UPDATE existing articles that are stubs (mostly [TODO] placeholders). Fill them with real content from sources.
10. Include an "Open Questions" section listing what the sources don't answer.
11. Deduplicate: do NOT create new articles for entities/concepts that already have pages. Use the EXISTING slug.
12. QUALITY OVER QUANTITY. Only create articles for concepts that are CENTRAL to the sources — not every term mentioned. A concept article should have at least 3 sentences of real content. If you can't write 3 sourced sentences about it, it's not worth an article. Maximum 10 new articles per compile pass.

Output format — for each article (new OR updated stub):
```article:{{subdir}}/{{slug}}
---
title: "Article Title"
entity_type: "person|organization|concept|event|place|document|decision|thing"
category: "category-name"
taxonomy_path: "parent-slug/child-slug"
status: draft
related: ["other-slug"]
sources: ["source1.md"]
source_count: 1
author: agent
agent: "{agent}"
date_created: "{date}"
date_updated: "{date}"
---

(FULL article content with [[wiki-links]] and [Source: file] citations — NOT stubs)

## Open Questions
(what the sources don't tell us)

## Counter-Arguments & Data Gaps
(contradictions, unknowns)
```

After all entity/concept articles, if 2+ sources connect in a non-obvious way, create a SYNTHESIS page:
```article:synthesis/{{slug}}
---
title: "Synthesis: descriptive title"
entity_type: synthesis
category: "cross-source"
taxonomy_path: "relevant/path"
status: draft
related: ["entity-or-concept-1", "entity-or-concept-2"]
sources: ["source1.md", "source2.md"]
source_count: 2
author: agent
agent: "{agent}"
date_created: "{date}"
date_updated: "{date}"
---

(Cross-source insight — what do these sources reveal TOGETHER that neither says alone?)

## Connections
(how the sources relate)

## Implications
(what this means for the domain)

## Open Questions
(what this cross-reference raises but doesn't answer)
```

Only create synthesis pages when there is a genuine cross-source insight. Do NOT create them for trivial overlaps (same person mentioned in 2 sources is NOT a synthesis — that's just an entity page).

After all articles:
```concepts
## Category Name
- [[Concept One]] — brief description [Source: file]
```"""

_RESEARCH_OUTPUT_FORMATS = {  # arch:deterministic — output format templates for KB research; structural framing, not content strategy
    "markdown": "",  # default — no extra instruction, uses wiki article format
    "comparison": (
        "OUTPUT FORMAT: Comparison Table.\n"
        "Structure your answer as a markdown table comparing the relevant entities/concepts side by side.\n"
        "Use columns for each entity and rows for each comparison dimension.\n"
        "Include a brief analysis paragraph after the table summarizing key differences and similarities.\n"
        "Cite sources in each cell with [Source: file]."
    ),
    "slides": (
        "OUTPUT FORMAT: Marp Slide Deck.\n"
        "Structure your answer as a Marp-compatible markdown presentation.\n"
        "Start with:\n```\n---\nmarp: true\ntheme: default\npaginate: true\n---\n```\n"
        "Use `---` to separate slides. Each slide should have:\n"
        "- A clear heading (# or ##)\n"
        "- 3-5 bullet points maximum\n"
        "- Speaker notes in HTML comments <!-- notes --> where useful\n"
        "Aim for 5-10 slides covering: title, overview, key findings, details, conclusions.\n"
        "Keep text concise — slides are for presenting, not reading.\n"
        "Cite sources on a final 'References' slide."
    ),
    "briefing": (
        "OUTPUT FORMAT: Executive Briefing.\n"
        "Structure your answer as a concise executive briefing:\n"
        "1. **Bottom Line Up Front (BLUF)** — 1-2 sentence answer\n"
        "2. **Key Facts** — 3-5 bullet points with the essential data\n"
        "3. **Context** — 1 short paragraph of background\n"
        "4. **Implications** — what this means, what to watch\n"
        "5. **Sources** — numbered reference list\n"
        "Total length: under 500 words. Every fact cited with [Source: file]."
    ),
}

_RESEARCH_PROMPT_FALLBACK = """You are a knowledge base research assistant.

KB SCHEMA:
{schema}

WIKI ARTICLES:
{context}

QUESTION:
{query}

RULES:
- Answer using ONLY wiki content. Cite articles: [See: Article Title]
- If the wiki lacks information, say exactly what's missing.
- Use [[wiki-links]] for concepts.
- Never invent facts not in the wiki.
- Mark uncertainties with [Unverified] or [TODO: needs source].

{output_instruction}"""

_REFLECT_PROMPT_FALLBACK = """You are a knowledge base structural analyst. The wiki must understand its own shape — not just what it knows, but WHY it knows things that way.

WHAT JUST CHANGED:
- Articles created: {articles_created}
- Files ingested: {files_ingested}

RECENTLY CREATED/MODIFIED ARTICLES (full content):
{recent_articles}

FULL WIKI STATE (titles and types):
{existing_articles}

KB SCHEMA:
{schema}

Analyze with these questions:
1. **Why were these articles created this way?** — What framing was chosen? Was "bowtie analysis" categorized as a concept rather than a process? Was a person filed globally rather than under a project? What alternatives existed?
2. **What got connected and what didn't?** — Which sources drove which articles? Are there obvious connections the compile missed?
3. **Ingestion bias** — Are early sources over-represented as hubs? Is the wiki shaped by what was ingested first rather than what matters most?
4. **Blind spots** — Topics the domain clearly requires but the wiki lacks. Questions the wiki should answer but can't.
5. **Structural debt** — Taxonomy mismatches, concepts that should be merged, entities that should be split.

RULES:
- Only create decision records for SIGNIFICANT structural choices — not routine additions.
- Each decision record must explain what ALTERNATIVE framing was possible and WHY this one was chosen.
- Be brutally honest. The point is to catch biases, not confirm choices.
- If nothing significant happened, say so.

Output format — ONLY if there are significant decisions:
```decision
title: Short decision title
decision: What was decided
context: What triggered this
alternatives: What other approaches were possible
reasoning: Why this choice
consequences: What this means going forward
affects: article-slug-1, article-slug-2
```

If no significant decisions were made, output:
```no_decisions
Routine update — no structural decisions to record.
```"""

_CONSOLIDATE_PROMPT_FALLBACK = """You are rewriting a wiki article that has accumulated incremental updates over time.

The article below contains the original content plus multiple "Updated from compile" sections appended at different dates. Your job is to merge everything into a single coherent, well-structured article.

Rules:
1. Preserve ALL factual content — do not lose any information from any update
2. Resolve contradictions: newer information supersedes older (use the dates to determine recency)
3. Remove the "Updated from compile" markers and date stamps — integrate the content naturally
4. Maintain the original structure (sections, headers) but reorganize if the updates make it clearer
5. Keep all [[wikilinks]] intact
6. Keep all [Source: ...] citations intact
7. Mark any remaining gaps with [TODO: ...]
8. Preserve the writing style and tone of the original
9. Output ONLY the consolidated article body (no frontmatter, no fences)"""

_LINT_PROMPT_FALLBACK = """You are a knowledge base health checker performing a deep wiki audit.

KB SCHEMA:
{schema}

WIKI INDEX:
{index}

STRUCTURAL ISSUES (pre-computed from file analysis — already verified):
{structural_issues}

ARTICLES (full content for semantic analysis):
{articles}

Your job is the SEMANTIC analysis. The structural issues above are already confirmed.
Focus on what only an LLM can detect:

1. **Contradictions** — conflicting facts across articles (same entity, different claims)
2. **Stale claims** — information that newer sources have likely superseded
3. **Missing cross-references** — related articles that should link to each other but don't
4. **Important concepts without pages** — ideas mentioned repeatedly across articles that deserve their own page
5. **Data gaps** — important questions the KB should answer but doesn't, topics that could be filled with a web search
6. **Unsourced claims** — statements not traced to any source file
7. **Quality issues** — articles that are mostly [TODO] placeholder text, or very thin compared to available sources

Output:
```issues
- [contradiction] Article "A" says X but Article "B" says Y
- [stale] Article "X" claim about Y may be outdated — check against newer sources
- [unsourced] Article "X" claims Y without citing a source
- [quality] Article "X" is mostly placeholder text (N TODOs, only M sentences of real content)
```

```suggestions
- [new_article] "Title" — mentioned in articles X, Y, Z but has no dedicated page
- [new_link] Article "A" should cross-reference [[B]] — they discuss related topics
- [investigate] "Question?" — researching this would fill a gap in the KB
- [web_search] "Search query" — this information gap could likely be filled with a web search
- [verify] Claim in "X" should be cross-checked — it may conflict with "Y"
- [merge] Articles "A" and "B" cover the same topic and should be consolidated
```"""


class IngestFrozenError(RuntimeError):
    """Raised when ``KnowledgeBaseService.ingest()`` is called while the global
    pipeline freeze flag is set.

    This is a SHORT-CIRCUIT — the call never enters the per-file extraction
    loop, never touches the LLM, never moves files. Callers should:

      * **Work queue retries** → catch + re-queue with extended backoff. The
        freeze WILL lift eventually; queued work must not be dead-lettered.
      * **HTTP / agent / drag-drop entry points** → return 423 Locked or
        equivalent so the user sees an honest "frozen" reason, not a silent
        no-op.
      * **Watcher** → already short-circuits via ``is_paused()`` for its own
        reasons; the freeze gate at ``ingest()`` catches anything that slips
        past the watcher's pause check.

    Distinct from ``watcher.paused`` (which only stops auto-discovery). Freeze
    is the global "no ingestion ever, period" emergency lever.
    """


class KnowledgeBaseService:
    """Manages personal knowledge bases — full LLM Wiki implementation."""

    def __init__(
        self,
        vault_reader: VaultReader,
        vault_writer: VaultWriter,
        llm: LLMProvider,
        skills_dir: Path | None = None,
        skill_loader: object | None = None,
        model_router: object | None = None,
        bibliography: object | None = None,
        work_queue: object | None = None,
    ) -> None:
        self._reader = vault_reader
        self._writer = vault_writer
        self._llm = llm
        self._skills_dir = skills_dir
        self._skill_loader = skill_loader
        self._model_router = model_router
        self._bibliography = bibliography
        self._work_queue = work_queue
        self._orchestrator: object | None = None  # injected post-hoc for activity tracking
        self._activity: object | None = None  # injected post-hoc by main.py
        self._decisions: object | None = None  # DecisionLogger, injected by main.py
        self._graph: object | None = None  # KnowledgeGraph, injected by main.py
        self._contract: object | None = None  # ContractEnforcer, injected by main.py
        self._settings_service: object | None = None  # SettingsService, injected by main.py — backs is_pipeline_frozen()
        # When True, LLM failures raise instead of enqueuing (work queue handles retries)
        self._called_from_queue = False
        # Per-retry escalation state — set by the work queue handler before
        # re-entering `_do_ingest`. Read once and honored for the matching
        # file, then cleared. Parallel to `_language_drift_escalation` so the
        # retry reason stays clear in logs and source_meta flags.
        self._empty_extraction_escalation: dict | None = None
        # Per-KB locks to prevent concurrent ingest (watcher + API racing)
        self._ingest_locks: dict[str, asyncio.Lock] = {}
        # File-type registry — the extraction contract (decisions.md §2026-04-18,
        # revised). Built from bundled defaults at init, then augmented from
        # per-type skill YAMLs (Phase 2 — `defaults/skills/file-types/*/config/file-type.yaml`
        # plus user vault overrides in `system/skills/file-types/`). Plugins
        # register further via ``kb_service._file_types.register_extractor(...)``
        # in their async_init.
        #
        # Precedence (last registration wins because loader passes overwrite=True):
        #   1. Bundled Python defaults (this file's DEFAULT_SPECS)
        #   2. Shipped skill YAMLs (defaults/skills/file-types/)
        #   3. User vault skill YAMLs (system/skills/file-types/ — SkillLoader
        #      scans these as part of its normal skill tree discovery)
        #   4. Plugins that register with overwrite=True
        self._file_types: FileTypeRegistry = build_default_registry()
        if self._skill_loader is not None:
            try:
                load_file_type_skills(self._file_types, self._skill_loader)
            except Exception:
                log.debug("file-types skill loader failed at init", exc_info=True)

    def _resolve(self, relative_path: str) -> Path:
        """Resolve a logical vault path to a physical Path.

        Delegates to VaultPaths if available, otherwise falls back to
        vault_dir / relative_path.
        """
        if self._reader.paths:
            return self._reader.paths.resolve(relative_path)
        return self._reader._vault_dir / relative_path

    def _to_logical(self, physical_path: Path) -> str:
        """Convert a physical path back to a logical vault-relative path."""
        if self._reader.paths:
            return self._reader.paths.to_logical(physical_path)
        return str(physical_path.relative_to(self._reader._vault_dir)).replace("\\", "/")

    def _load_skill_rules(self, skill_name: str) -> str:
        """Load quality rules from a skill's SKILL.md body.

        Returns the "## Quality Rules" section (and below) if found, empty string otherwise.
        """
        if not self._skills_dir:
            return ""
        skill_path = self._skills_dir / "core" / skill_name / "SKILL.md"
        if not skill_path.exists():
            return ""
        try:
            import frontmatter as fm
            post = fm.load(str(skill_path))
            content = post.content
            # Extract from "## Quality Rules" onward (skip instructions, include rules)
            marker = "## Quality Rules"
            idx = content.find(marker)
            if idx >= 0:
                return content[idx:]
            return ""
        except Exception:
            return ""

    def _get_skill_prompt(self, skill_name: str) -> str:
        """Load system prompt from skill definition via SkillLoader.

        Falls back to empty string if the skill loader is unavailable or
        the skill has no System Prompt section.
        """
        if not self._skill_loader:
            return ""
        try:
            prompt = self._skill_loader.get_system_prompt(skill_name)
            return prompt or ""
        except Exception:
            return ""

    def _get_skill_rules(self, skill_name: str) -> str:
        """Load quality rules from skill definition via SkillLoader.

        Falls back to the legacy ``_load_skill_rules`` method if the
        skill loader is unavailable.
        """
        if self._skill_loader:
            try:
                rules = self._skill_loader.get_skill_rules(skill_name)
                if rules:
                    return rules
            except Exception:
                pass
        return self._load_skill_rules(skill_name)

    def _resolve_model(self, tier: str) -> str | None:
        """Resolve a model tier to a specific model via the router. Returns None if no router."""
        if self._model_router and hasattr(self._model_router, "resolve"):
            return self._model_router.resolve(tier)
        return None

    def _is_local_mode(self) -> bool:
        """True when the active model router is configured for local (Ollama) mode."""
        return bool(
            self._model_router
            and getattr(self._model_router, "_profile", "") == "local"
        )

    def _llm_timeout(self, op: str, default: int | None = None) -> float:
        """Per-operation LLM timeout. Local models are slower, so local gets
        generous timeouts. Cloud gets tight timeouts to fail fast and retry.

        `default` is a fallback when `op` isn't in the budget map — lets new
        operations be added without touching this method.
        """
        local = self._is_local_mode()
        # operation → (local_seconds, cloud_seconds)
        budget = {
            "classify":   (180, 90),
            "compile":    (300, 180),
            "synthesize": (300, 120),
            "reflect":    (180, 90),
            "research":   (300, 120),
            "lint":       (300, 120),
            "consolidate":(240, 120),
        }
        fallback_pair = (default, default) if default is not None else (180, 90)
        loc, cld = budget.get(op, fallback_pair)
        return float(loc if local else cld)

    # ── Observability helpers (Rule #13: every ingest step is visible) ──
    #
    # Every KB ingest path (watcher, API, work queue, tool) runs through
    # _do_ingest and MUST emit:
    #   1. an ActivityLogger event (category=knowledge) so the agent sees
    #      the work in get_daily_summary() / get_relationship_context()
    #   2. a DecisionLogger record (actor=librarian) so the causal chain
    #      is reconstructable from the /decisions page
    #   3. knowledge-graph entities/triples so the ingested source becomes
    #      queryable structured knowledge, not just a markdown artefact
    # These helpers centralise the fire-and-forget pattern so call sites
    # stay readable. All three are non-fatal: a broken observability layer
    # must never break ingest.

    @asynccontextmanager
    async def _scoped_pipeline(self, reason: str, scope_prefix: str = "kb"):
        """Context manager that wraps a KB service method body with the
        full observability + governance stack:

          1. Inherit an outer correlation scope if we're nested inside
             one (chat turn, workflow, HTTP request). Otherwise start a
             fresh ``{scope_prefix}-xxx`` chain.
          2. Authorize the ``write_vault`` + ``modify_wiki`` contract
             actions inside the scope, going through ``contract.check()``
             when a ContractEnforcer is wired, falling back to
             ``mark_action_authorized`` for tests.
          3. Apply a scope-wide ``trusted_scope(reason)`` on the writer
             so every vault write inside inherits ``trust_reason=reason``
             without having to thread kwargs through dozens of call
             sites.

        Every public KB service method that writes to the vault
        (``ingest``, ``compile_wiki``, ``reflect``, ``research``,
        ``synthesize``, ``lint``, ``run_wiki_job``) routes its body
        through this helper. Adding a new method that writes to the
        vault? Wrap it too — the runtime guard will reject every write
        otherwise (Rule #3 "Contract is the law" made enforceable in
        code, not convention).
        """
        from agntspace.activity.decisions import (
            correlation_scope,
            current_correlation_id,
            new_correlation_id,
        )

        _existing = current_correlation_id()
        _scope = correlation_scope(_existing or new_correlation_id(scope_prefix))
        with _scope, self._writer.trusted_scope(reason):
            self._authorize_ingest_scope()
            yield

    def _authorize_ingest_scope(self) -> None:
        """Authorize the vault writes this ingest batch will make.

        Every ``correlation_scope`` starts with an empty authorized-actions
        set, so the VaultWriter runtime guard will reject unscoped writes.
        The canonical way to grant permission is to call
        ``contract.check(action)`` which marks the action authorized on
        success. When there's no contract (tests, minimal installs) we
        fall back to ``mark_action_authorized`` directly so the writer
        guard still sees the scope as green.

        Actions authorized here match what the pipeline actually does:
          - ``write_vault`` — source summaries, index, wiki article writes
          - ``modify_wiki`` — in-place updates to existing wiki articles
          - ``canvas_push`` — (future) pipeline visualization if enabled
        """
        contract = getattr(self, "_contract", None)
        actions_to_authorize = ("write_vault", "modify_wiki")
        for action in actions_to_authorize:
            authorized = False
            if contract is not None and hasattr(contract, "check"):
                try:
                    result = contract.check(action)
                    # ContractEnforcer returns an ActionResult with an
                    # ``allowed`` attribute; if it's blocked we don't
                    # mark it. The writer guard will then catch the
                    # violation on the first write — exactly right.
                    authorized = bool(getattr(result, "allowed", False))
                except Exception:
                    log.debug("kb: contract.check(%s) failed", action, exc_info=True)
            if not authorized:
                # Fallback: mark directly (tests, minimal installs)
                try:
                    from agntspace.activity.decisions import mark_action_authorized
                    mark_action_authorized(action)
                except Exception:
                    log.debug("kb: mark_action_authorized(%s) failed", action, exc_info=True)

    def _log_activity(
        self,
        action: str,
        summary: str,
        details: dict | None = None,
        importance: str = "medium",
    ) -> None:
        """Emit a knowledge/ingest activity event. Never raises."""
        activity = getattr(self, "_activity", None)
        if activity is None or not hasattr(activity, "log"):
            return
        try:
            activity.log("knowledge", action, summary, details or {}, importance=importance)
        except Exception:
            log.debug("kb: activity.log failed", exc_info=True)

    async def _record_decision(
        self,
        action_type: str,
        action_target: str,
        *,
        contract_action: str = "ingest",
        contract_result: str = "allowed",
        triggered_by: str = "ingest",
        rationale: str = "",
        details: dict | None = None,
        context_skills: list[str] | None = None,
    ) -> None:
        """Record a librarian decision. Never raises."""
        decisions = getattr(self, "_decisions", None)
        if decisions is None or not hasattr(decisions, "record"):
            return
        try:
            await decisions.record(
                actor="librarian",
                action_type=action_type,
                action_target=action_target,
                contract_action=contract_action,
                contract_result=contract_result,
                triggered_by=triggered_by,
                context_skills=context_skills or ["kb-ingest"],
                rationale=rationale,
                details=details or {},
            )
        except Exception:
            log.debug("kb: decisions.record failed", exc_info=True)

    # Junk-pattern filtering is skill-driven (Rule 12). All rejection rules
    # live in ``defaults/skills/core/kb-ingest/config/junk-patterns.yaml``
    # and are loaded at call time via the SkillLoader. See the YAML file
    # for the full taxonomy (exact_names, substrings, leading_tokens,
    # limits) and multilingual coverage.
    #
    # The engine here is deliberately dumb: it loads the rules, applies
    # them in order, and returns a boolean. Tuning the behavior means
    # editing the YAML, not this method.

    # Lightweight runtime cache for parsed junk patterns so we don't
    # re-parse the YAML on every candidate. The cache is invalidated
    # when ``invalidate_junk_cache()`` is called (e.g. from tests or
    # a skill-reload hook) or when the process restarts. Skills hot-
    # reload on mtime — if we ever want live-reload without restart,
    # extend SkillLoader to bust this cache on skill mtime change.
    _junk_patterns_cache: dict | None = None

    _FALLBACK_LIMITS: dict[str, object] = {  # arch:deterministic — degraded-mode safety net used only when kb-ingest/config/junk-patterns.yaml is missing; the YAML is the real strategy, this is what the engine does when users have no strategy file to tune
        "max_chars": 60, "min_chars": 2, "max_words": 6,
        "reject_trailing_period": True,
    }

    @classmethod
    def invalidate_junk_cache(cls) -> None:
        """Clear the cached junk patterns. Tests call this between cases."""
        cls._junk_patterns_cache = None

    # Phase 2 (Rule 12) caches: throughput cap + skip patterns from
    # the kb-ingest skill config. Same pattern as the junk-pattern cache:
    # class-level so the YAML is read at most once per process unless
    # explicitly invalidated.
    _throughput_cache: dict | None = None
    _skip_patterns_cache: tuple[str, ...] | None = None

    @classmethod
    def invalidate_throughput_cache(cls) -> None:
        cls._throughput_cache = None

    @classmethod
    def invalidate_skip_patterns_cache(cls) -> None:
        cls._skip_patterns_cache = None

    def _load_throughput_cap(self, key: str, *, default: int) -> int:
        """Load one numeric cap from kb-ingest/config/throughput.yaml.

        Returns the configured value, or the documented default if the
        skill loader is missing / config is absent / value is invalid.
        Cached at the class level so multiple call sites in the same
        ingest cycle don't re-parse the YAML.
        """
        if type(self)._throughput_cache is None:
            cfg: dict = {}
            loader = getattr(self, "_skill_loader", None)
            if loader is not None and hasattr(loader, "load_skill_config_file"):
                try:
                    raw = loader.load_skill_config_file("kb-ingest", "throughput.yaml")
                    if isinstance(raw, dict):
                        cfg = raw
                except Exception:
                    log.debug("kb: throughput.yaml load failed", exc_info=True)
            type(self)._throughput_cache = cfg
        try:
            return int(type(self)._throughput_cache.get(key, default))
        except (TypeError, ValueError):
            return default

    def _load_skip_patterns(self) -> tuple[str, ...]:
        """Load multilingual skip patterns from kb-ingest/config/skip-patterns.yaml.

        Returns a flat tuple of lowercased patterns across all language
        sections. Falls back to a minimal English-only set when the
        skill loader / file / data is unavailable -- the same documented
        default as the pre-Phase-2 hardcoded constants.
        """
        if type(self)._skip_patterns_cache is not None:
            return type(self)._skip_patterns_cache

        # Degraded-mode default mirrors the pre-Phase-2 hardcoded set.
        # See ``kb-ingest/config/skip-patterns.yaml`` for the real source.
        fallback: tuple[str, ...] = (
            "time report", "time-report", "timereport",
            "day template", "template",
            "untitled",
            "links",
        )

        loader = getattr(self, "_skill_loader", None)
        if loader is None or not hasattr(loader, "load_skill_config_file"):
            type(self)._skip_patterns_cache = fallback
            return fallback

        try:
            raw = loader.load_skill_config_file("kb-ingest", "skip-patterns.yaml")
        except Exception:
            log.debug("kb: skip-patterns.yaml load failed", exc_info=True)
            type(self)._skip_patterns_cache = fallback
            return fallback

        if not isinstance(raw, dict):
            type(self)._skip_patterns_cache = fallback
            return fallback

        section = raw.get("mundane_filenames")
        flat: list[str] = []
        if isinstance(section, dict):
            for _lang, words in section.items():
                if isinstance(words, list):
                    for w in words:
                        if isinstance(w, str) and w.strip():
                            flat.append(w.strip().lower())
        elif isinstance(section, list):
            for w in section:
                if isinstance(w, str) and w.strip():
                    flat.append(w.strip().lower())

        if not flat:
            type(self)._skip_patterns_cache = fallback
            return fallback

        # Dedup while preserving insertion order
        seen: set[str] = set()
        unique: list[str] = []
        for p in flat:
            if p not in seen:
                seen.add(p)
                unique.append(p)
        result = tuple(unique)
        type(self)._skip_patterns_cache = result
        return result

    def _get_junk_patterns(self) -> dict:
        """Load and flatten junk patterns from the kb-ingest skill config.

        Returns a dict with four flat collections derived from the
        multilingual YAML:

            {
                "exact_names": set[str],      # all languages merged, lowercased
                "substrings":  tuple[str, ...],  # all languages merged, lowercased
                "leading_tokens": tuple[str, ...],  # all languages merged, lowercased
                "limits": {max_chars, min_chars, max_words, reject_trailing_period},
            }

        If the skill config is missing or malformed, returns a minimal
        fallback (limits only, no pattern filtering). This is the
        Rule 12-compliant "no hardcoded patterns" behavior: if the
        strategy YAML isn't there, the engine cannot invent policy —
        it applies only the structural limits that are inherent to
        what-is-an-entity-name (length, word count, trailing period).
        """
        if type(self)._junk_patterns_cache is not None:
            return type(self)._junk_patterns_cache

        patterns = {
            "exact_names": set(),
            "substrings": tuple(),
            "leading_tokens": tuple(),
            "limits": dict(type(self)._FALLBACK_LIMITS),
        }

        loader = getattr(self, "_skill_loader", None)
        if loader is None:
            type(self)._junk_patterns_cache = patterns
            return patterns

        try:
            raw = loader.load_skill_config_file("kb-ingest", "junk-patterns.yaml")
        except Exception:
            log.debug("kb: junk-patterns.yaml load failed", exc_info=True)
            type(self)._junk_patterns_cache = patterns
            return patterns

        if not isinstance(raw, dict):
            type(self)._junk_patterns_cache = patterns
            return patterns

        def _flatten(section: object) -> list[str]:
            """Flatten a multilingual ``{lang: [words...]}`` section into one
            lowercased list. Accepts either a dict of language → list or a
            flat list for backwards compatibility."""
            out: list[str] = []
            if isinstance(section, dict):
                for _lang, words in section.items():
                    if isinstance(words, list):
                        for w in words:
                            if isinstance(w, str) and w.strip():
                                out.append(w.strip().lower())
            elif isinstance(section, list):
                for w in section:
                    if isinstance(w, str) and w.strip():
                        out.append(w.strip().lower())
            return out

        patterns["exact_names"] = set(_flatten(raw.get("exact_names")))
        patterns["substrings"] = tuple(_flatten(raw.get("substrings")))
        patterns["leading_tokens"] = tuple(_flatten(raw.get("leading_tokens")))
        patterns["trailing_attribution_tokens"] = tuple(_flatten(raw.get("trailing_attribution_tokens")))

        yaml_limits = raw.get("limits")
        if isinstance(yaml_limits, dict):
            for key in ("max_chars", "min_chars", "max_words", "reject_trailing_period"):
                if key in yaml_limits:
                    patterns["limits"][key] = yaml_limits[key]

        type(self)._junk_patterns_cache = patterns
        return patterns

    def _is_junk_name(self, name: str) -> bool:
        """Reject LLM prose masquerading as an entity/concept name.

        All rejection strategy lives in
        ``kb-ingest/config/junk-patterns.yaml``. This method only applies
        what the YAML says. Length/word-count limits also come from the
        YAML; the ``_FALLBACK_LIMITS`` are used only when the YAML is
        missing (degraded mode, no silent hardcoded list).
        """
        if not name:
            return True
        lower = name.strip().lower()
        if not lower:
            return True

        patterns = self._get_junk_patterns()
        limits = patterns["limits"]

        if lower in patterns["exact_names"]:
            return True
        for sub in patterns["substrings"]:
            if sub in lower:
                return True
        # Leading tokens are word-boundary matches, not raw prefix matches.
        # The YAML loader strips trailing whitespace (standard YAML behavior),
        # so a token like ``"a "`` loads as ``"a"``. We restore the word
        # boundary at check time by appending a space: ``lower.startswith("a ")``
        # — which correctly matches ``"a type of"`` but NOT ``"acme corp"``.
        # A single-word candidate that equals a leading token (e.g. ``"a"``)
        # is matched too via the equality fallback, because a bare article
        # is not a valid entity name regardless of context.
        for prefix in patterns["leading_tokens"]:
            if not prefix:
                continue
            if lower == prefix or lower.startswith(prefix + " "):
                return True

        if limits.get("reject_trailing_period") and lower.endswith("."):
            return True

        # Comma-attribution rejection: news prose introduces people as
        # "Name, role at Org" — the LLM extractor sometimes emits the whole
        # phrase as one entity name. If the text after the FIRST comma starts
        # with a known role/attribution token, the candidate is prose, not
        # a name. Strategy YAML drives the multilingual token list; real
        # names with commas (e.g. "University of California, Berkeley",
        # "Apple, Inc.", "King Charles, III") have non-role trailing parts
        # and are preserved.
        trailing_tokens = patterns.get("trailing_attribution_tokens") or ()
        if trailing_tokens and "," in lower:
            after_comma = lower.split(",", 1)[1].strip()
            if after_comma:
                for token in trailing_tokens:
                    if not token:
                        continue
                    # Token may already include a trailing space (e.g. "who ");
                    # in that case we match the leading-word variant via
                    # plain startswith. Otherwise we require a word boundary
                    # — bare equality, space-separated continuation, or
                    # punctuation-separated continuation — so "ceo" matches
                    # "ceo" / "ceo at acme" / "ceo, founder" but NOT "ceolite".
                    if token.endswith(" "):
                        if after_comma.startswith(token):
                            return True
                    else:
                        if after_comma == token:
                            return True
                        if after_comma.startswith(token + " "):
                            return True
                        if after_comma.startswith(token + ","):
                            return True

        max_words = limits.get("max_words")
        if isinstance(max_words, int) and max_words > 0 and len(lower.split()) > max_words:
            return True

        return False

    def _filter_concepts_by_source(
        self, concepts_text: str, source_text: str,
    ) -> str:
        """Strip concept lines that don't appear in the source content.

        The classify LLM receives existing KB article titles in its system
        prompt (for dedup). Small and medium models parrot those titles into
        the ``concepts`` block even when the source doesn't mention them.
        This is the engine-level safety net (prompt rules are Layer 1).

        Each bullet line is checked: the concept name (lowercased) must
        appear as a substring in the lowercased source_text, OR every
        significant word (3+ chars) in a multi-word concept must appear.
        Lines that fail are stripped and logged.

        Returns the filtered concepts text (may be empty string).
        """
        if not concepts_text or not source_text:
            return concepts_text
        src_lower = source_text.lower()
        kept: list[str] = []
        stripped: list[str] = []
        for line in concepts_text.split("\n"):
            raw = line.strip()
            if not raw:
                continue
            # Extract concept name from bullet: "- Bowtie Analysis" → "Bowtie Analysis"
            name = raw.lstrip("-•*").strip()
            if not name:
                continue
            nl = name.lower()
            # Direct substring match
            if nl in src_lower:
                kept.append(line)
                continue
            # Multi-word: accept if every word (3+ chars) appears in source
            words = [w for w in nl.split() if len(w) >= 3]
            if len(words) >= 2 and all(w in src_lower for w in words):
                kept.append(line)
                continue
            # Single short word (< 3 chars per word, or single word): check direct
            if len(words) <= 1 and nl in src_lower:
                kept.append(line)
                continue
            stripped.append(name)
        if stripped:
            log.info(
                "KB concepts filter: stripped %d concept(s) not in source text: %s",
                len(stripped), stripped,
            )
        return "\n".join(kept)

    # Allowed entity types that the LLM may use as a prefix in the
    # ``entities:`` block. This is NOT strategy — it's the structural
    # set of valid values for the ``entity_type`` field in the ontology.
    # The ontology YAML is the source of truth; this set is derived from
    # it. If the ontology ever becomes loadable at class-definition
    # time, this list can be replaced with a dynamic lookup. For now
    # the list is duplicated here with an arch:deterministic marker
    # because changing it requires a matching ontology change.
    _ALLOWED_ENTITY_TYPE_PREFIXES = frozenset({  # arch:deterministic — mirrors the ``types`` set in defaults/skills/_meta/ontology/config/ontology.yaml; not user-tunable strategy, it's the schema
        "person", "organization", "concept", "event", "place",
        "document", "decision", "project", "task", "goal",
        "ai_agent", "skill", "thing",
    })

    def _parse_entities_block(self, block: str) -> list[tuple[str, str]]:
        """Parse the ``entities`` block of a classify response into (type, name).

        Expected input format (one bullet per line):

            - person: Sarah Chen — CTO
            - organization: Acme Corp — aerospace
            - place: Stockholm

        **Strict prefix rule (Rule 12 Phase 1):** lines WITHOUT a valid
        ``entity_type:`` prefix are rejected, not silently defaulted to
        ``concept``. The old behavior — "if it doesn't have a prefix,
        call it a concept" — was the root cause of the person-as-concept
        bug: small/fast-tier LLMs routinely drop the prefix, and the
        silent default then stored real people as concepts in the graph.

        The new behavior:
          1. Lines with a valid prefix (from the allowed set) are parsed
             and returned as ``(type, name)``.
          2. Lines with a bogus prefix (something before ``:`` that isn't
             a known entity type) are rejected with a log line so drift
             is visible.
          3. Lines WITHOUT any prefix are rejected. We do not guess.

        Junk names (from the skill's junk-patterns.yaml) are filtered on
        top of all of the above.
        """
        results: list[tuple[str, str]] = []
        if not block:
            return results

        patterns = self._get_junk_patterns()
        limits = patterns["limits"]
        min_chars = int(limits.get("min_chars", 2))
        max_chars = int(limits.get("max_chars", 60))

        rejected_unprefixed = 0
        rejected_bad_prefix = 0

        for raw_line in block.splitlines():
            line = raw_line.strip()
            if not line or line.startswith("#"):
                continue
            if line.startswith("-"):
                line = line[1:].strip()
            if not line:
                continue

            # Strict prefix parsing. No silent default.
            if ":" not in line:
                rejected_unprefixed += 1
                continue
            head, rest = line.split(":", 1)
            head_clean = head.strip().lower()
            if head_clean not in self._ALLOWED_ENTITY_TYPE_PREFIXES:
                rejected_bad_prefix += 1
                continue
            etype = head_clean
            line = rest.strip()

            # Name portion is everything before a dash/em-dash description.
            for sep in (" — ", " – ", " - "):
                if sep in line:
                    line = line.split(sep, 1)[0]
                    break
            name = line.strip().strip(".,;:")
            if len(name) < min_chars or len(name) > max_chars:
                continue
            if self._is_junk_name(name):
                continue
            results.append((etype, name))

        if rejected_unprefixed or rejected_bad_prefix:
            log.info(
                "kb-ingest: entities block — rejected %d unprefixed and %d "
                "bad-prefix lines (strict parsing; no silent concept default)",
                rejected_unprefixed, rejected_bad_prefix,
            )

        # De-duplicate within one ingest while preserving order
        seen: set[tuple[str, str]] = set()
        out: list[tuple[str, str]] = []
        for pair in results:
            key = (pair[0], pair[1].lower())
            if key in seen:
                continue
            seen.add(key)
            out.append(pair)
        return out

    def _parse_concepts_block(self, block: str) -> list[str]:
        """Parse the ``concepts`` block into a list of concept names.

        Applies the same skill-driven junk filter as ``_parse_entities_block``.
        Concepts do not carry a type prefix (they are, by definition, all
        of type ``concept``), so no prefix rule applies here.
        """
        if not block:
            return []

        patterns = self._get_junk_patterns()
        limits = patterns["limits"]
        min_chars = int(limits.get("min_chars", 2))
        max_chars = int(limits.get("max_chars", 60))

        out: list[str] = []
        for raw in block.splitlines():
            line = raw.strip().lstrip("-*0123456789.").strip()
            if not line or line.startswith("#"):
                continue
            line = line.strip(".,;:")
            if not (min_chars <= len(line) <= max_chars):
                continue
            if self._is_junk_name(line):
                continue
            out.append(line)
        # Dedup (case-insensitive) preserving first occurrence
        seen: set[str] = set()
        dedup: list[str] = []
        for c in out:
            k = c.lower()
            if k in seen:
                continue
            seen.add(k)
            dedup.append(c)
        return dedup

    def _populate_graph_from_ingest(
        self,
        *,
        source_file: str,
        source_slug: str,
        entities_block: str,
        concepts_block: str,
        kb_slug: str,
        source_text: str = "",
    ) -> dict:
        """Add source + mentioned entities + concepts as triples to the graph.

        Returns a dict with counts suitable for a decision record. Never
        raises — graph failures must not break ingest. The source becomes
        a ``document`` entity; each extracted entity/concept gets an
        ``about_entity`` or ``about_concept`` triple from the source.

        **Rule 12 Phase 1 change (2026-04-11):** the old regex Title-Case
        fallback — which silently fabricated ``concept`` triples when the
        LLM returned an empty extraction — has been removed. Silent
        fabrication was the root cause of the person-as-concept bug:
        real people detected by the Title-Case scan were stored as
        concepts because the fallback hardcoded ``object_type="concept"``.

        The new behavior on empty extraction:
          - The source ``document`` entity is still registered (so the
            file is not invisible in the graph).
          - NO fabricated entity or concept triples are added.
          - A high-importance ``ingest_empty_extraction`` activity event
            is emitted so the agent (and the user) see that this file
            produced no usable extraction and can decide whether to
            re-ingest at a higher model tier.

        ``source_text`` is used for hallucination detection: each extracted
        entity/concept name is checked against the source text. Names not
        present in the source are rejected as potential hallucinations
        (logged at info level). This catches the "Real Person + FakeCompany"
        pattern where one entity is real and the other is fabricated.
        """
        result = {
            "entities": 0, "concepts": 0, "triples": 0,
            "used_fallback": False, "empty_extraction": False,
        }
        graph = getattr(self, "_graph", None)
        if graph is None or not hasattr(graph, "add_triple"):
            return result
        try:
            source_date = datetime.now(UTC).strftime("%Y-%m-%d")
        except Exception:
            source_date = ""
        # Register the source document itself
        try:
            graph.registry.add(
                source_slug or source_file,
                entity_type="document",
                authority="system",
            )
        except Exception:
            log.debug("kb: graph.registry.add(source) failed", exc_info=True)
        entities = self._parse_entities_block(entities_block)
        concepts = self._parse_concepts_block(concepts_block)

        # ── Hallucination gate: reject entities/concepts not in source text ──
        # Each name must appear (case-insensitive) in the source material.
        # Also checks entity registry aliases (EU → European Union, etc.)
        # and individual words (multi-word names where each word appears).
        _src_lower = source_text.lower() if source_text else ""
        if _src_lower:
            _registry = getattr(getattr(self, "_graph", None), "registry", None) if getattr(self, "_graph", None) else None

            def _name_in_source(name: str) -> bool:
                nl = name.lower()
                # Direct substring match
                if nl in _src_lower:
                    return True
                # Check entity registry aliases (e.g., "EU" has alias "European Union")
                if _registry:
                    try:
                        slug = _registry.resolve(name)
                        if slug:
                            canonical = (_registry.get_name(slug) or "").lower()
                            if canonical and canonical in _src_lower:
                                return True
                            # Check all aliases
                            entity = _registry.entities.get(slug, {})
                            for alias in entity.get("aliases", []):
                                if alias.lower() in _src_lower:
                                    return True
                    except Exception:
                        pass
                # Multi-word: accept if every word (3+ chars) appears in source
                words = [w for w in nl.split() if len(w) >= 3]
                if len(words) >= 2 and all(w in _src_lower for w in words):
                    return True
                return False

            _pre_entity_count = len(entities)
            entities = [(t, n) for t, n in entities if _name_in_source(n)]
            _rejected_e = _pre_entity_count - len(entities)
            if _rejected_e:
                log.info("KB %s: rejected %d hallucinated entities for %s (not in source text)",
                         kb_slug, _rejected_e, source_file)

            _pre_concept_count = len(concepts)
            concepts = [c for c in concepts if _name_in_source(c)]
            _rejected_c = _pre_concept_count - len(concepts)
            if _rejected_c:
                log.info("KB %s: rejected %d hallucinated concepts for %s (not in source text)",
                         kb_slug, _rejected_c, source_file)

        # LLM extraction produced nothing usable — either the fenced blocks
        # were missing/malformed, everything was filtered as junk, or all
        # names were hallucinated. Record at high importance so the agent
        # can decide on retry/escalation, and move on.
        if not entities and not concepts:
            result["empty_extraction"] = True
            log.warning(
                "KB %s: LLM produced no usable entities/concepts for %s — "
                "no triples added",
                kb_slug, source_file,
            )
            self._log_activity(
                "ingest_empty_extraction",
                f"{source_file}: LLM returned no usable entities or concepts",
                {
                    "slug": kb_slug,
                    "file": source_file,
                    "entities_block_empty": not entities_block,
                    "concepts_block_empty": not concepts_block,
                    "fabrication": False,
                    "next_action": "escalate_or_accept",
                },
                importance="high",
            )
        for etype, name in entities:
            try:
                graph.add_triple(
                    subject=source_slug or source_file,
                    predicate="about_entity",
                    obj=name,
                    authority="system",
                    source_file=f"knowledge/{kb_slug}/wiki/sources/{source_file}",
                    source_date=source_date,
                    confidence=0.75,
                    subject_type="document",
                    object_type=etype,
                    epistemic_status="believed",
                )
                result["entities"] += 1
                result["triples"] += 1
            except Exception:
                log.debug("kb: add_triple(about_entity) failed for %s", name, exc_info=True)
        for concept in concepts:
            try:
                graph.add_triple(
                    subject=source_slug or source_file,
                    predicate="about_concept",
                    obj=concept,
                    authority="system",
                    source_file=f"knowledge/{kb_slug}/wiki/sources/{source_file}",
                    source_date=source_date,
                    confidence=0.75,
                    subject_type="document",
                    object_type="concept",
                    epistemic_status="believed",
                )
                result["concepts"] += 1
                result["triples"] += 1
            except Exception:
                log.debug("kb: add_triple(about_concept) failed for %s", concept, exc_info=True)
        # Persist once after the batch of triples from this file.
        try:
            if hasattr(graph, "save"):
                graph.save()
        except Exception:
            log.debug("kb: graph.save() failed", exc_info=True)
        return result

    def _populate_graph_from_images(
        self,
        *,
        source_slug: str,
        source_file: str,
        kb_slug: str,
        extract_result: Any,
    ) -> dict:
        """Phase 5 v2 — emit figure entities + ``depicts`` triples from
        embedded-image vision descriptions.

        Walks ``extract_result.elements`` for Image elements with a linked
        description paragraph (same ``parent_id``). For each pair:
          1. Register a figure entity with slug ``{source}-page{N}-img{J}``
             (entity_type=``document`` — figures reuse the document type;
             the slug suffix is the distinguishing convention).
          2. Emit ``(figure, part_of, source_document)`` — structural link
             so queries can navigate figure → parent document.
          3. Parse the description prose for Title-Case entity candidates,
             apply the shared junk filter, and emit
             ``(figure, depicts, concept_or_entity)`` for each survivor.
             Every triple carries ``situational_context.extracted_from_vision
             = True`` + ``image_page`` + ``image_idx`` + ``source_document``
             for full provenance. Authority = system, confidence = 0.7
             (slightly below the ``about_concept`` triples from classify
             because vision output is inherently less certain than textual
             classification).

        Returns ``{figures, triples, depicts_triples, part_of_triples}``.
        Never raises — graph failures never break ingest.

        No-op paths (each returns zero counts without touching the graph):
          - ``extract_result is None`` or lacks ``elements`` attr
          - No Image elements in ``extract_result.elements``
          - Image element has no linked description paragraph (emits
            ``part_of`` only — figure is registered but zero ``depicts``)
          - ``_graph`` isn't wired (degraded mode, common in tests)
        """
        counts = {
            "figures": 0, "triples": 0,
            "depicts_triples": 0, "part_of_triples": 0,
        }
        graph = getattr(self, "_graph", None)
        if graph is None or not hasattr(graph, "add_triple"):
            return counts
        elements = getattr(extract_result, "elements", None) or []
        if not elements:
            return counts

        # Collect image elements + their linked description paragraphs.
        # An image's description is the FIRST paragraph element with
        # ``parent_id == img.element_id``. If none, figure still gets
        # registered + ``part_of`` triple, but no ``depicts`` triples.
        images: list[tuple[Any, str, int, int]] = []
        desc_by_parent: dict[str, str] = {}
        for el in elements:
            kind = getattr(el, "kind", None)
            if kind == "paragraph":
                pid = getattr(el, "parent_id", None)
                if pid and pid not in desc_by_parent:
                    desc_by_parent[pid] = getattr(el, "text", "") or ""
        for el in elements:
            if getattr(el, "kind", None) != "image":
                continue
            img_id = getattr(el, "element_id", None)
            if not img_id:
                continue
            page = getattr(el, "page", None) or 0
            # Parse image_idx from element_id format "page{N}_img{J}"
            img_idx = 0
            try:
                if "_img" in img_id:
                    img_idx = int(img_id.rsplit("_img", 1)[1])
            except (ValueError, IndexError):
                img_idx = 0
            images.append((el, img_id, int(page or 0), img_idx))
        if not images:
            return counts

        try:
            source_date = datetime.now(UTC).strftime("%Y-%m-%d")
        except Exception:
            source_date = ""
        source_wiki_path = f"knowledge/{kb_slug}/wiki/sources/{source_file}"

        for el, img_id, page_no, img_idx in images:
            description = desc_by_parent.get(img_id, "").strip()
            # Stable figure slug — deterministic across re-ingests of the
            # same source so Phase 4.75 re-extract replaces rather than
            # accumulates figure entities.
            figure_slug = f"{source_slug}-page{page_no}-img{img_idx}"
            # Register the figure as a document sub-entity.
            try:
                display = f"Figure {img_idx} on page {page_no} of {source_slug}"
                graph.registry.add(
                    figure_slug,
                    entity_type="document",
                    authority="system",
                    aliases=[display],
                )
            except Exception:
                log.debug("kb: graph.registry.add(figure) failed", exc_info=True)
                continue
            counts["figures"] += 1

            # Provenance carried on every triple this helper emits so the
            # user can trace a concept back to the specific figure that
            # surfaced it.
            base_context = {
                "extracted_from_vision": True,
                "image_page": page_no,
                "image_idx": img_idx,
                "source_document": source_slug,
                "figure_slug": figure_slug,
            }

            # 1. part_of — link figure to its source document.
            try:
                graph.add_triple(
                    subject=figure_slug,
                    predicate="part_of",
                    obj=source_slug or source_file,
                    authority="system",
                    source_file=source_wiki_path,
                    source_date=source_date,
                    confidence=0.9,
                    subject_type="document",
                    object_type="document",
                    epistemic_status="believed",
                    situational_context=dict(base_context),
                )
                counts["part_of_triples"] += 1
                counts["triples"] += 1
            except Exception:
                log.debug(
                    "kb: add_triple(figure part_of source) failed for %s",
                    figure_slug, exc_info=True,
                )

            # 2. depicts — parse concept candidates from the description
            # and link the figure to each. Strip the "[Page N · Image J]"
            # header before parsing so it doesn't become an entity.
            if not description:
                continue
            # Drop the bracket-prefix line; keep the rest as description prose
            desc_body = description
            if desc_body.startswith("[") and "]" in desc_body:
                desc_body = desc_body.split("\n", 1)[-1] if "\n" in desc_body else ""
            if not desc_body.strip():
                continue

            try:
                from agntspace.text import extract_entity_candidates
            except Exception:
                log.debug("kb: extract_entity_candidates unavailable", exc_info=True)
                continue

            seen: set[str] = set()
            try:
                candidates = extract_entity_candidates(desc_body) or []
            except Exception:
                log.debug(
                    "kb: candidate extraction failed for figure %s",
                    figure_slug, exc_info=True,
                )
                candidates = []

            for raw_name in candidates:
                name = (raw_name or "").strip()
                if not name:
                    continue
                if self._is_junk_name(name):
                    continue
                nl = name.lower()
                if nl in seen:
                    continue
                seen.add(nl)
                try:
                    graph.add_triple(
                        subject=figure_slug,
                        predicate="depicts",
                        obj=name,
                        authority="system",
                        source_file=source_wiki_path,
                        source_date=source_date,
                        confidence=0.7,
                        subject_type="document",
                        object_type="concept",
                        epistemic_status="believed",
                        situational_context=dict(base_context),
                    )
                    counts["depicts_triples"] += 1
                    counts["triples"] += 1
                except Exception:
                    log.debug(
                        "kb: add_triple(depicts) failed for %s → %s",
                        figure_slug, name, exc_info=True,
                    )

        # Persist once after all figures for this source.
        try:
            if hasattr(graph, "save"):
                graph.save()
        except Exception:
            log.debug("kb: graph.save() after images failed", exc_info=True)
        return counts

    @property
    def _taxonomy_text(self) -> str:
        """Lazily load taxonomy and render as prompt text."""
        if self._skills_dir:
            from agntspace.kb.taxonomy import load_taxonomy, taxonomy_for_prompt
            tree = load_taxonomy(self._skills_dir)
            return taxonomy_for_prompt(tree)
        return "(no taxonomy configured)"

    def _classify(self, entity_type: str, category: str, title: str) -> str:
        """Classify an article into the taxonomy. Returns taxonomy_path."""
        if not self._skills_dir:
            return "general"
        from agntspace.kb.taxonomy import classify_article, flatten_taxonomy, load_taxonomy
        tree = load_taxonomy(self._skills_dir)
        flat_nodes = flatten_taxonomy(tree)
        return classify_article(entity_type=entity_type, category=category, title=title, flat_nodes=flat_nodes)

    # ── Helpers ──

    def _classify_source(self, filename: str) -> tuple[str, str]:
        """Classify source by file type. Returns (type_name, prompt_focus).

        Resolution order (first non-None wins):
          1. FileTypeRegistry (bundled defaults + Phase-2 skill YAMLs + plugins).
          2. ``kb-ingest/config/source-types.yaml`` via the skill loader.
          3. Hardcoded ``_SOURCE_TYPES`` fallback (degraded mode).

        The registry path intentionally returns
        ``(spec.default_source_type, spec.prompt_focus)`` so the written
        ``source_type`` frontmatter + prompt_focus stay IDENTICAL to the
        pre-registry world. The registry's own ``spec.type_name`` (more
        granular — ``article_text`` vs ``article_html``) flows into
        provenance instead, via ``ExtractResult.provenance['type_name']``.
        """
        ext = Path(filename).suffix.lower()
        # 1. Registry — new canonical path.
        spec = self._file_types.resolve_type(Path(filename))
        if spec is not None:
            return spec.default_source_type, spec.prompt_focus
        # 2. Skill-driven YAML (legacy compat until Phase 2 migrates to
        #    per-type skill dirs and the registry is fully authoritative).
        yaml_types = self._load_source_types_config()
        if yaml_types:
            for stype, info in yaml_types.items():
                if stype.startswith("__"):
                    continue
                exts = info.get("extensions", [])
                if ext in exts:
                    return stype, info.get("prompt_focus", "")
            return yaml_types.get("__default_type", "article"), yaml_types.get("__default_focus", "Extract main points, key facts, and context.")
        # 3. Hardcoded degraded-mode fallback.
        for stype, info in _SOURCE_TYPES.items():
            if ext in info["exts"]:
                return stype, info["prompt_focus"]
        return "article", "Extract main points, key facts, and context."

    async def _extract_source(
        self,
        raw_file: Path,
        fname: str,
    ) -> tuple[str, dict, str, str, ExtractResult | None]:
        """Extract source content via the FileTypeRegistry.

        Returns ``(source_text, source_meta, raw_source_url, content_hash, extract_result)``
        — exactly the 4-tuple the pre-registry ingest loop used, plus the
        raw ExtractResult so the caller can emit provenance triples + read
        richer structured elements in later phases.

        Parity guarantees vs the pre-registry world:

        - **Text files** (`.md` / `.txt` / `.rst` / `.html` / `.htm` / `.yaml`):
          frontmatter is peeled into ``source_meta``; body runs through
          ``_strip_web_noise`` for HTML-like content; ``source_url`` /
          ``source`` / ``url`` frontmatter keys become ``raw_source_url``.
        - **PDF / JSONL / JSON / VTT / SRT**: routed through their dedicated
          extractors (``pdf_pdfplumber`` / ``json_pretty`` / ``vtt_srt``)
          which internally call ``kb.parsers.parse_file`` for backward compat.
        - **Images**: the ``vision_llm`` extractor runs OCR + multimodal LLM
          with the same 120s timeout and the same error fallback string
          (``"[Image file: X — could not analyze]"``) the pre-registry code
          produced. ``source_meta`` gains ``has_ocr`` / ``has_description``
          flags instead of the empty dict it used to be — new richness,
          not a contract break.
        - **Empty / unreadable**: fallback string
          ``"[Binary or unreadable file: X]"`` preserved so the content_hash
          stays deterministic and the dedup map keeps working.

        Never raises. Extractor failures are absorbed by
        ``FileTypeRegistry.extract`` and returned as ExtractResults with
        ``provenance["error"]`` set so the caller can see what failed.
        """
        spec = self._file_types.resolve_type(raw_file)
        if spec is None:
            # Unknown extension — fall back to the text_utf8 extractor so
            # we still get a result (text + frontmatter peeled). The caller
            # relies on fall-through for skip patterns that run before us.
            spec = self._file_types.get_spec("article_text")
            if spec is None:
                # Registry was never populated — degraded-mode error.
                msg = f"[Binary or unreadable file: {fname}]"
                return msg, {}, "", hashlib.md5(fname.encode()).hexdigest()[:12], None

        # Extractor context — vision extractor needs the llm provider +
        # the service's describe_image method for its OCR-aware flow.
        context: dict[str, Any] = {
            "llm": self._llm,
            "describe_image": getattr(self, "_describe_image", None),
            "vision_timeout": 120,
        }

        result = await self._file_types.extract(raw_file, spec, context)

        # Text assembly.
        if result.is_empty and not result.provenance.get("error"):
            source_text = f"[Binary or unreadable file: {fname}]"
            content_hash = hashlib.md5(fname.encode()).hexdigest()[:12]
        else:
            source_text = result.as_text()
            if not source_text.strip():
                # Extractor returned only empty elements + an error marker —
                # keep the content_hash stable by hashing the filename.
                source_text = f"[Binary or unreadable file: {fname}]"
                content_hash = hashlib.md5(fname.encode()).hexdigest()[:12]
            else:
                # Post-process text-like extractions to strip web-clipper noise
                # (iframes, leading blank lines, HTML comments). Binary-origin
                # extractors — PDF, vision, CSV, transcript — don't need this.
                if spec.extractor in ("text_utf8", "html_parser"):
                    source_text = self._strip_web_noise(source_text)
                content_hash = hashlib.md5(source_text.encode()).hexdigest()[:12]

        # Frontmatter / doc_metadata carries source_url candidates.
        source_meta = dict(result.doc_metadata or {})
        raw_source_url = str(
            source_meta.get("source_url", "")
            or source_meta.get("source", "")
            or source_meta.get("url", "")
            or ""
        )
        if raw_source_url and not raw_source_url.startswith(("http://", "https://")):
            raw_source_url = ""

        return source_text, source_meta, raw_source_url, content_hash, result

    def _load_source_types_config(self) -> dict | None:
        """Load source-types.yaml from the kb-ingest skill."""
        if not self._skill_loader:
            return None
        try:
            cfg = self._skill_loader.load_skill_config_file("kb-ingest", "source-types.yaml")
            if not cfg or not isinstance(cfg.get("source_types"), dict):
                return None
            result = cfg["source_types"]
            result["__default_type"] = cfg.get("default_type", "article")
            result["__default_focus"] = cfg.get("default_prompt_focus", "Extract main points, key facts, and context.")
            return result
        except Exception:
            return None

    @staticmethod
    def _strip_web_noise(content: str) -> str:
        """Strip web-clipper noise — thin wrapper over agntspace.text.

        Kept as a method so existing call sites still work; the actual
        implementation is the shared ``strip_web_clipper_noise`` in
        ``agntspace.text.html`` so KB ingest and any future text-consuming
        subsystem share one source of truth.
        """
        from agntspace.text import strip_web_clipper_noise
        return strip_web_clipper_noise(content)

    def _get_wiki_subdirs(self, slug: str) -> list[str]:
        """Discover all wiki subdirectories dynamically (covers LLM-created dirs like events/)."""
        wiki_dir = self._resolve(f"knowledge/{slug}/wiki")
        if not wiki_dir.is_dir():
            return list(_WIKI_SUBDIRS)
        dirs = []
        for d in wiki_dir.iterdir():
            if d.is_dir() and not d.name.startswith(("_", ".")):
                dirs.append(d.name)
        # Ensure static subdirs are always included
        for sd in _WIKI_SUBDIRS:
            if sd not in dirs:
                dirs.append(sd)
        return dirs

    def _list_wiki_articles(self, base_path: str, pattern: str = "*.md") -> list:
        """List wiki article files, excluding .history/ version archives."""
        try:
            files = self._reader.list_files(base_path, pattern)
            return [
                f for f in files
                if "/.history/" not in str(f).replace("\\", "/")
                and "\\.history\\" not in str(f)
            ]
        except Exception:
            return []

    def _collect_image_inventory(self, base: str) -> list[dict]:
        """Scan wiki/sources/ for image source summaries.

        Returns a list of dicts with ``library_path`` (relative to the
        wiki article that will embed it) and ``description`` (the vision
        LLM's summary of what the image shows).  The compile LLM uses
        this to embed ``![caption](path)`` in the articles it generates.
        """
        images: list[dict] = []
        sources_dir = f"{base}/wiki/sources"
        try:
            files = self._list_wiki_articles(sources_dir)
        except Exception:
            return images

        for f in files:
            try:
                rel = self._to_logical(f)
                doc = self._reader.read(rel)
                meta = doc.metadata or {}

                # Only image source summaries
                if meta.get("category") != "image":
                    continue

                library_path = meta.get("library_path", "")
                if not library_path:
                    continue

                # Extract the summary or first paragraph as description
                content = doc.content or ""
                description = ""

                # Try the "## Summary" section first
                if "## Summary" in content:
                    after_summary = content.split("## Summary", 1)[1]
                    # Take until the next ## heading
                    next_heading = after_summary.find("\n## ")
                    if next_heading > 0:
                        description = after_summary[:next_heading].strip()
                    else:
                        description = after_summary[:500].strip()
                elif "## Visual Description" in content:
                    after_vis = content.split("## Visual Description", 1)[1]
                    next_heading = after_vis.find("\n## ")
                    if next_heading > 0:
                        description = after_vis[:next_heading].strip()
                    else:
                        description = after_vis[:500].strip()

                if not description:
                    # Fallback: first non-empty line after the title
                    for line in content.split("\n"):
                        line = line.strip()
                        if line and not line.startswith("#") and not line.startswith(">") and not line.startswith("!"):
                            description = line[:300]
                            break

                images.append({
                    "source_file": meta.get("source_file", f.name),
                    "library_path": library_path,
                    "description": description or "Image (no description available)",
                })
            except Exception:
                continue

        return images

    def _read_schema(self, slug: str) -> str:
        path = f"knowledge/{slug}/SCHEMA.md"
        if self._reader.exists(path):
            return self._reader.read(path).content
        return ""

    def _read_schema_meta(self, slug: str) -> dict:
        """Return SCHEMA.md frontmatter as a dict. Empty dict if missing."""
        path = f"knowledge/{slug}/SCHEMA.md"
        if not self._reader.exists(path):
            return {}
        try:
            return dict(self._reader.read(path).metadata or {})
        except Exception:
            return {}

    # Library layout modes — controls how originals are filed under library/.
    # ``type-date`` is the historical default and remains the fallback for
    # KBs whose SCHEMA.md predates the field. New KBs created after 2026-04
    # default to ``thematic`` via the SCHEMA template.
    _VALID_LIBRARY_LAYOUTS = frozenset({"flat", "type-date", "thematic"})  # arch:deterministic — layout vocabulary

    def _kb_library_layout(self, slug: str) -> str:
        """Resolve the effective library layout for a KB.

        Returns one of ``flat`` / ``type-date`` / ``thematic``. When the
        SCHEMA.md doesn't declare ``library_layout`` (existing KBs,
        corrupted frontmatter, etc.) falls back to ``type-date`` so the
        old on-disk arrangement stays honoured — the "no surprises" rule.
        """
        meta = self._read_schema_meta(slug)
        raw = str(meta.get("library_layout") or "").strip().lower()
        if raw in self._VALID_LIBRARY_LAYOUTS:
            return raw
        return "type-date"

    def _read_index(self, slug: str) -> str:
        path = f"knowledge/{slug}/wiki/.index.md"
        if self._reader.exists(path):
            return self._reader.read(path).content
        return ""

    def _append_log(self, slug: str, operation: str, details: str) -> None:
        now = datetime.now().strftime("%Y-%m-%d %H:%M")
        entry = f"\n## [{now}] {operation} | {details}\n"
        log_path = f"knowledge/{slug}/log.md"
        if not self._reader.exists(log_path):
            self._writer.write(log_path, (
                "---\ntitle: \"Operation Log\"\ntype: kb_log\nauthor: agent\nagent: librarian\n---\n\n"
                "# Knowledge Base Log\n"
            ), trust_reason="kb_log")
        self._writer.append(log_path, entry, trust_reason="kb_log")

    def _read_compile_state(self, slug: str) -> dict:
        # Read the JSON state file directly from the filesystem. Do NOT
        # use VaultReader: it runs every file through python-frontmatter,
        # which interprets a leading ``{`` as JSON frontmatter and returns
        # an empty ``content``. _write_compile_state already writes the
        # file directly (atomic rename), so the read path must match.
        physical = self._resolve(f"knowledge/{slug}/.compile-state.json")
        if physical.is_file():
            try:
                raw = physical.read_text(encoding="utf-8")
                if not raw.strip():
                    # Empty file (partial write, never populated) — just
                    # treat as fresh state, no need to back up.
                    return _fresh_compile_state()
                state = json.loads(raw)
                # Migrate old format: ensure "sources" dict exists
                if "sources" not in state:
                    state["sources"] = {}
                    for fname, h in state.get("processed_hashes", {}).items():
                        state["sources"][fname] = {
                            "hash": h,
                            "vault_source": "",
                            "ingested_at": state.get("last_ingest", ""),
                            "source_type": "",
                            "library_path": "",
                            "produced_pages": [],
                            "agent": "librarian",
                            "status": "ingested",
                        }
                return state
            except json.JSONDecodeError as exc:
                # Genuine corruption (not frontmatter-misread). Back it
                # up with a timestamped name so repeated corruptions
                # don't collide on the same backup target.
                log.warning(
                    "KB %s: compile-state corrupted (%s) — backing up and reconstructing",
                    slug, exc,
                )
                try:
                    stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
                    backup = physical.with_suffix(f".json.corrupted.{stamp}")
                    physical.replace(backup)
                except Exception:
                    log.exception("KB %s: could not back up corrupted state", slug)
            except Exception:
                log.exception("KB %s: unexpected error reading compile-state", slug)
        return _fresh_compile_state()

    def _write_compile_state(self, slug: str, state: dict) -> None:
        # Atomic write: write to temp file, then rename. Prevents corruption
        # if the process dies mid-write.
        path = self._resolve(f"knowledge/{slug}/.compile-state.json")
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".json.tmp")
        try:
            tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
            tmp.replace(path)
        except Exception:
            log.exception("KB %s: failed to write compile-state", slug)
            if tmp.exists():
                try:
                    tmp.unlink()
                except Exception:
                    pass

    def _record_source(
        self, state: dict, fname: str, *,
        content_hash: str, vault_source: str = "",
        source_type: str = "", library_path: str = "",
        produced_pages: list[str] | None = None,
        agent: str = "librarian",
    ) -> None:
        """Record full provenance for a processed source."""
        now = datetime.now(UTC).isoformat()
        sources = state.setdefault("sources", {})
        existing = sources.get(fname, {})
        sources[fname] = {
            "hash": content_hash,
            "vault_source": vault_source or existing.get("vault_source", ""),
            "ingested_at": existing.get("ingested_at", now),
            "updated_at": now,
            "source_type": source_type or existing.get("source_type", ""),
            "library_path": library_path or existing.get("library_path", ""),
            "produced_pages": (produced_pages or []) + existing.get("produced_pages", []),
            "agent": agent,
            "status": "ingested",
        }
        # Keep backward compat
        state.setdefault("processed_hashes", {})[fname] = content_hash

    def _check_cross_kb_duplicate(self, current_slug: str, content_hash: str) -> str | None:
        """Check if content_hash exists in any other KB's processed_hashes.
        Returns the other KB slug if found, else None.
        """
        for kb in self.list_kbs():
            other_slug = kb["slug"]
            if other_slug == current_slug:
                continue
            other_state = self._read_compile_state(other_slug)
            if content_hash in other_state.get("processed_hashes", {}).values():
                return other_slug
        return None

    def _find_similar_article(self, kb_slug: str, article_path: str, content: str) -> str | None:
        """Check if a near-duplicate article already exists.

        Checks:
        1. Slug similarity (e.g., 'ai-safety' vs 'artificial-intelligence-safety')
        2. Title match in frontmatter
        Returns the path of the duplicate if found, else None.
        """
        import re as _re
        from difflib import SequenceMatcher

        # Extract the proposed slug and subdir
        parts = article_path.split("/", 1)
        if len(parts) != 2:
            return None
        subdir, new_slug = parts

        # Extract proposed title from content frontmatter
        new_title = ""
        title_match = _re.search(r'^title:\s*"?(.+?)"?\s*$', content, _re.MULTILINE)
        if title_match:
            new_title = title_match.group(1).strip().lower()

        # Scan existing articles in the same subdir
        base = f"knowledge/{kb_slug}/wiki/{subdir}"
        try:
            existing_files = list(self._reader.list_files(base, "*.md"))
        except Exception:
            return None

        for ef in existing_files:
            # Skip version history files
            if "/.history/" in str(ef).replace("\\", "/") or "\\.history\\" in str(ef):
                continue
            existing_slug = ef.stem
            # 1. Slug similarity — one is a substring of the other, or SequenceMatcher > 0.75
            slug_ratio = SequenceMatcher(None, new_slug, existing_slug).ratio()
            if slug_ratio > 0.75 or new_slug in existing_slug or existing_slug in new_slug:
                return f"{subdir}/{existing_slug}"

            # 2. Title match
            if new_title:
                try:
                    doc = self._reader.read(f"{base}/{ef.name}")
                    existing_title = doc.metadata.get("title", "").strip().lower()
                    if existing_title and (
                        existing_title == new_title
                        or SequenceMatcher(None, new_title, existing_title).ratio() > 0.80
                    ):
                        return f"{subdir}/{existing_slug}"
                except Exception:
                    continue

        return None

    def _render_template(self, entity_type: str, **kwargs) -> str:
        templates = _load_article_templates(getattr(self, "_skill_loader", None))
        template = templates.get(entity_type, templates.get("concept", _CONCEPT_FALLBACK))
        kwargs.setdefault("agent", "editor")
        # Provenance — Layer 2 of the three-layer model (type × provenance × status).
        # Fixed-provenance templates (source_summary, synthesis, decision_record)
        # hardcode their own value and ignore this kwarg. Flexible templates
        # (person/org/concept/event/place/document/decision) interpolate {provenance}
        # from here. Default to "compiled" because that's the most common write
        # path (editor compiling entity pages from sources); callers override
        # for research/manual/reflected paths.
        kwargs.setdefault("provenance", "compiled")
        # Graceful fallback: when the loaded template has placeholders
        # the caller didn't provide (e.g. {sources} in the concept
        # fallback, {source_list} in the concept template), supply
        # empty strings so .format() never raises KeyError. The
        # template files evolve independently from the kwargs list
        # and this is the one place they meet at runtime.
        import string
        try:
            needed = {
                fname
                for _, fname, _, _ in string.Formatter().parse(template)
                if fname is not None
            }
        except Exception:
            needed = set()
        for key in needed:
            kwargs.setdefault(key, "")
        return template.format(**kwargs)

    def _enrich_index(self, slug: str) -> None:
        """Rebuild _index.md with per-article metadata.

        Pattern: each page listed with a link, a one-line summary, and
        optionally metadata like date or source count.

        Scans all wiki articles and rebuilds the Sources, Entities, and
        Concepts sections with: ``- [[Title]] — summary (N sources, date)``
        """
        base = f"knowledge/{slug}"
        idx_path = f"{base}/wiki/.index.md"
        if not self._reader.exists(idx_path):
            return

        doc = self._reader.read(idx_path)
        idx_raw = doc.raw

        # Collect articles by category
        sources_lines: list[str] = []
        entities_lines: list[str] = []
        concepts_lines: list[str] = []
        synthesis_lines: list[str] = []

        for subdir, target_list in [
            ("sources", sources_lines),
            ("entities", entities_lines),
            ("concepts", concepts_lines),
            ("synthesis", synthesis_lines),
        ]:
            try:
                for wf in sorted(
                    self._list_wiki_articles(f"{base}/wiki/{subdir}"),
                    key=lambda f: f.stem,
                ):
                    rel = self._to_logical(wf)
                    adoc = self._reader.read(rel)
                    meta = adoc.metadata or {}
                    title = meta.get("title", wf.stem)
                    # One-line summary: first non-empty, non-heading line
                    summary = ""
                    for line in (adoc.content or "").split("\n"):
                        line = line.strip()
                        if line and not line.startswith("#") and not line.startswith("!") and not line.startswith(">"):
                            summary = line[:120]
                            if len(line) > 120:
                                summary += "..."
                            break
                    src_count = meta.get("source_count", len(meta.get("sources", [])))
                    date = meta.get("date_created", meta.get("date_updated", ""))
                    if date:
                        date = date[:10]  # just the date part

                    parts = [f"- [[{title}]]"]
                    if summary:
                        parts.append(f"— {summary}")
                    meta_bits = []
                    if src_count:
                        meta_bits.append(f"{src_count} source{'s' if int(src_count) != 1 else ''}")
                    if date:
                        meta_bits.append(date)
                    if meta_bits:
                        parts.append(f"({', '.join(meta_bits)})")
                    target_list.append(" ".join(parts))
            except Exception:
                continue

        # Rebuild sections in the index
        def _replace_section(text: str, header: str, new_lines: list[str]) -> str:
            marker = f"## {header}"
            if marker not in text:
                return text
            before, rest = text.split(marker, 1)
            # Find the next ## heading
            next_h2 = rest.find("\n## ")
            if next_h2 > 0:
                after = rest[next_h2:]
            else:
                after = ""
            body = "\n".join(new_lines) if new_lines else "\n*No articles yet.*"
            return f"{before}{marker}\n\n{body}\n{after}"

        updated = idx_raw
        updated = _replace_section(updated, "Sources", sources_lines)
        updated = _replace_section(updated, "Entities", entities_lines)
        updated = _replace_section(updated, "Concepts", concepts_lines)

        # Add Synthesis section if we have synthesis articles and section doesn't exist
        if synthesis_lines:
            if "## Synthesis" not in updated:
                # Insert before Statistics
                if "## Statistics" in updated:
                    updated = updated.replace(
                        "## Statistics",
                        "## Synthesis\n\n" + "\n".join(synthesis_lines) + "\n\n## Statistics",
                    )
            else:
                updated = _replace_section(updated, "Synthesis", synthesis_lines)

        # Also update the stats
        src_total = len(sources_lines)
        ent_total = len(entities_lines)
        con_total = len(concepts_lines)
        date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
        import re as _re
        updated = _re.sub(r"- Sources ingested: \d+", f"- Sources ingested: {src_total}", updated)
        updated = _re.sub(r"- Entity pages: \d+", f"- Entity pages: {ent_total}", updated)
        updated = _re.sub(r"- Concept pages: \d+", f"- Concept pages: {con_total}", updated)
        updated = _re.sub(r"- Last updated: .+", f"- Last updated: {date_str}", updated)
        updated = self._update_frontmatter(updated, "source_count", str(src_total))
        updated = self._update_frontmatter(updated, "article_count", str(ent_total + con_total))

        with self._writer.trusted_scope("kb_index_enrich"):
            self._writer.write(idx_path, updated)

    def _update_index_stats(self, slug: str) -> None:
        """Recount articles on disk and update .index.md frontmatter + Statistics section."""
        import re as _re
        base = f"knowledge/{slug}"
        idx_path = f"{base}/wiki/.index.md"
        if not self._reader.exists(idx_path):
            return
        src_count = len([f for f in self._reader.list_files(f"{base}/library", "**/*") if f.name != ".gitkeep"])
        ent_count = len(self._list_wiki_articles(f"{base}/wiki/entities"))
        con_count = len(self._list_wiki_articles(f"{base}/wiki/concepts"))
        # Count source pages across both legacy (source_summary/) and current (sources/) dirs
        src_pages = set()
        for sd in ("sources", "source_summary"):
            src_pages.update(f.stem for f in self._list_wiki_articles(f"{base}/wiki/{sd}"))
        date_str = datetime.now().strftime("%Y-%m-%d %H:%M")

        doc = self._reader.read(idx_path)
        idx = doc.raw
        idx = self._update_frontmatter(idx, "source_count", str(src_count))
        idx = self._update_frontmatter(idx, "article_count", str(ent_count + con_count))
        # Update Statistics section
        idx = _re.sub(r"- Sources ingested: \d+", f"- Sources ingested: {src_count}", idx)
        idx = _re.sub(r"- Entity pages: \d+", f"- Entity pages: {ent_count}", idx)
        idx = _re.sub(r"- Concept pages: \d+", f"- Concept pages: {con_count}", idx)
        idx = _re.sub(r"- Last updated: .+", f"- Last updated: {date_str}", idx)
        self._writer.write(idx_path, idx)

    # ── KB Management ���─

    def create_kb(
        self,
        topic: str,
        *,
        description: str = "",
        focus_questions: list[str] | None = None,
        source_types: list[str] | None = None,
        notes: str = "",
    ) -> dict:
        slug = topic.lower().replace(" ", "-")
        base = f"knowledge/{slug}"
        now = datetime.now().strftime("%Y-%m-%d %H:%M")

        # KB creation is user-initiated — wrap all scaffold writes in a
        # trusted scope so the VaultWriter runtime guard passes them.
        with self._writer.trusted_scope("kb_create"):
            self._writer.write(f"{base}/inbox/.gitkeep", "")
            self._writer.write(f"{base}/library/.gitkeep", "")
            self._writer.write(f"{base}/wiki/.index.md", _INDEX_TEMPLATE.format(topic=topic, date=now))
            self._writer.write(f"{base}/wiki/entities/.gitkeep", "")
            self._writer.write(f"{base}/wiki/concepts/.gitkeep", "")
            self._writer.write(f"{base}/wiki/sources/.gitkeep", "")
            self._writer.write(f"{base}/wiki/synthesis/.gitkeep", "")
            self._writer.write(f"{base}/wiki/decisions/.gitkeep", "")
            self._writer.write(f"{base}/wiki/decisions/evolution/.gitkeep", "")
            self._writer.write(f"{base}/wiki/projects/.gitkeep", "")
            self._writer.write(f"{base}/output/.gitkeep", "")

            # Build enriched SCHEMA.md from wizard fields
            schema = _SCHEMA_TEMPLATE.format(topic=topic)
            if description:
                schema = schema.replace(
                    "<!-- What is this knowledge base about? What questions should it answer? -->",
                    description,
                )
            if focus_questions:
                fq_lines = "\n".join(f"- {q}" for q in focus_questions)
                schema = schema.replace(
                    "## Conventions",
                    f"## Focus Questions\n{fq_lines}\n\n## Conventions",
                )
            if source_types:
                st_lines = "\n".join(f"- **{t}**" for t in source_types)
                schema = schema.replace(
                    "## Notes\n<!-- Domain-specific instructions for the agent -->",
                    f"## Expected Source Types\n{st_lines}\n\n## Notes\n{notes if notes else '<!-- Domain-specific instructions for the agent -->'}",
                )
            elif notes:
                schema = schema.replace(
                    "<!-- Domain-specific instructions for the agent -->",
                    notes,
                )

            self._writer.write(f"{base}/SCHEMA.md", schema)
            self._write_compile_state(slug, {"last_ingest": None, "last_compile": None, "processed_hashes": {}})
            self._append_log(slug, "create", f"Knowledge base created: {topic}")

        return {"topic": topic, "slug": slug, "path": base}

    def delete_kb(self, slug: str) -> dict:
        """Delete a knowledge base and clean up all references."""
        import shutil

        kb_path = self._resolve(f"knowledge/{slug}")
        if not kb_path.is_dir():
            raise FileNotFoundError(f"KB not found: {slug}")

        # Count articles and sources before deleting
        article_count = 0
        for sd in _WIKI_SUBDIRS:
            article_count += len(self._list_wiki_articles(f"knowledge/{slug}/wiki/{sd}"))
        source_count = len([
            f for f in self._reader.list_files(f"knowledge/{slug}/library", "**/*")
            if f.name != ".gitkeep"
        ])

        # Delete the entire KB folder
        shutil.rmtree(kb_path)

        # Clean up bibliography entries
        bib_cleaned = 0
        bib_path = self._resolve("knowledge/bibliography.json")
        if bib_path.is_file():
            try:
                entries = json.loads(bib_path.read_text(encoding="utf-8"))
                before = len(entries)
                entries = [e for e in entries if e.get("kb_slug") != slug]
                bib_cleaned = before - len(entries)
                if bib_cleaned > 0:
                    bib_path.write_text(json.dumps(entries, indent=2, ensure_ascii=False), encoding="utf-8")
            except Exception:
                log.warning("Failed to clean bibliography for deleted KB %s", slug)

        # Clean up project references
        projects_updated = self._remove_kb_from_projects(slug)

        return {
            "slug": slug,
            "deleted_articles": article_count,
            "deleted_sources": source_count,
            "bibliography_cleaned": bib_cleaned,
            "projects_updated": projects_updated,
        }

    def rename_kb(self, slug: str, new_title: str) -> dict:
        """Rename a knowledge base (folder + all references)."""
        import re as _re
        import shutil

        old_path = self._resolve(f"knowledge/{slug}")
        if not old_path.is_dir():
            raise FileNotFoundError(f"KB not found: {slug}")

        new_slug = _re.sub(r"[^a-z0-9-]", "", new_title.lower().replace(" ", "-")).strip("-")
        if not new_slug:
            raise ValueError("Invalid title — produces empty slug")

        new_path = self._resolve(f"knowledge/{new_slug}")
        if new_path.exists():
            raise ValueError(f"KB already exists: {new_slug}")

        # Get old title from index
        idx_rel = f"knowledge/{slug}/wiki/.index.md"
        old_title = slug.replace("-", " ").title()
        if self._reader.exists(idx_rel):
            doc = self._reader.read(idx_rel)
            old_title = doc.metadata.get("title", old_title)

        # Rename the folder
        shutil.move(str(old_path), str(new_path))

        # Update _index.md title inside the KB
        new_idx_path = new_path / "wiki" / ".index.md"
        if new_idx_path.is_file():
            content = new_idx_path.read_text(encoding="utf-8")
            # Update frontmatter title
            content = _re.sub(
                r'^title:\s*".*?"',
                f'title: "{new_title} — Index"',
                content, count=1, flags=_re.MULTILINE,
            )
            # Update heading
            content = _re.sub(
                r"^# .+ — Knowledge Base",
                f"# {new_title} — Knowledge Base",
                content, count=1, flags=_re.MULTILINE,
            )
            new_idx_path.write_text(content, encoding="utf-8")

        # Update bibliography entries
        bib_updated = 0
        bib_path = self._resolve("knowledge/bibliography.json")
        if bib_path.is_file():
            try:
                entries = json.loads(bib_path.read_text(encoding="utf-8"))
                for e in entries:
                    if e.get("kb_slug") == slug:
                        e["kb_slug"] = new_slug
                        bib_updated += 1
                if bib_updated > 0:
                    bib_path.write_text(json.dumps(entries, indent=2, ensure_ascii=False), encoding="utf-8")
            except Exception:
                log.warning("Failed to update bibliography for renamed KB %s -> %s", slug, new_slug)

        # Update project references
        projects_updated = self._rename_kb_in_projects(slug, new_slug)

        return {
            "old_slug": slug,
            "new_slug": new_slug,
            "old_title": old_title,
            "new_title": new_title,
            "bibliography_updated": bib_updated,
            "projects_updated": projects_updated,
        }

    def _remove_kb_from_projects(self, kb_slug: str) -> list[str]:
        """Remove a KB slug from linked_kbs in all PROJECT.md files."""
        import frontmatter as fm

        updated = []
        projects_dir = self._resolve("projects")
        if not projects_dir.is_dir():
            return updated
        for child in projects_dir.iterdir():
            if not child.is_dir():
                continue
            project_file = child / "PROJECT.md"
            if not project_file.is_file():
                continue
            try:
                post = fm.load(str(project_file))
                linked = post.metadata.get("linked_kbs") or []
                if kb_slug in linked:
                    linked = [k for k in linked if k != kb_slug]
                    post.metadata["linked_kbs"] = linked
                    project_file.write_text(fm.dumps(post), encoding="utf-8")
                    updated.append(child.name)
            except Exception:
                log.warning("Failed to update project %s for KB cleanup", child.name)
        return updated

    def _rename_kb_in_projects(self, old_slug: str, new_slug: str) -> list[str]:
        """Replace old KB slug with new in linked_kbs across all PROJECT.md files."""
        import frontmatter as fm

        updated = []
        projects_dir = self._resolve("projects")
        if not projects_dir.is_dir():
            return updated
        for child in projects_dir.iterdir():
            if not child.is_dir():
                continue
            project_file = child / "PROJECT.md"
            if not project_file.is_file():
                continue
            try:
                post = fm.load(str(project_file))
                linked = post.metadata.get("linked_kbs") or []
                if old_slug in linked:
                    linked = [new_slug if k == old_slug else k for k in linked]
                    post.metadata["linked_kbs"] = linked
                    project_file.write_text(fm.dumps(post), encoding="utf-8")
                    updated.append(child.name)
            except Exception:
                log.warning("Failed to update project %s for KB rename", child.name)
        return updated

    def list_kbs(self) -> list[dict]:
        kbs = []
        seen_slugs: set[str] = set()
        try:
            files = self._reader.list_files("knowledge", "**/wiki/.index.md")
        except Exception:
            return []
        for f in files:
            # Skip archived copies
            f_str = str(f).replace("\\", "/")
            if "/.archives/" in f_str:
                continue
            try:
                parts = f_str.split("/")
                kb_idx = next(
                    i for i, p in enumerate(parts)
                    if p in ("knowledge", "agntspace-knowledge")
                )
                slug = parts[kb_idx + 1]
                if slug in seen_slugs:
                    continue
                seen_slugs.add(slug)
                doc = self._reader.read(f"knowledge/{slug}/wiki/.index.md")
                lib = self._reader.list_files(f"knowledge/{slug}/library", "**/*")
                wiki = self._reader.list_files(f"knowledge/{slug}/wiki", "**/*.md")
                kbs.append({
                    "slug": slug,
                    "title": doc.metadata.get("title", slug),
                    "sources": len([r for r in lib if r.name != ".gitkeep"]),
                    "articles": len([w for w in wiki if not w.name.startswith(("_", "."))
                                     and "/.history/" not in str(w).replace("\\", "/")
                                     and "/.archives/" not in str(w).replace("\\", "/")]),
                    "updated": doc.metadata.get("updated", ""),
                })
            except Exception:
                continue
        return kbs

    # ── Phase 4.5: source-page provenance walker ──────────────────────

    def list_source_records(self, slug: str | None = None) -> list[dict]:
        """Walk ``wiki/sources/*.md`` across KBs and return provenance records.

        Each record carries the fields ``propose_extractor_upgrades`` needs
        (``path``, ``type_name``, ``extractor_id``) plus extras the UI will
        want (``kb``, ``filename``, ``extractor_version``, ``source_type``,
        ``date_updated``). Sources without an ``extractor_id`` frontmatter
        field (ingested before Phase 4.5 wired provenance into the template)
        are INCLUDED with an empty ``extractor_id`` so the upgrade-proposals
        function skips them silently — they represent the "unknown history"
        bucket that a future reingest pass will fill in.

        ``slug=None`` walks every KB; passing a slug scopes to that KB.
        Excludes ``.history/``, ``.archives/``, ``.quarantine/`` by path.
        """
        records: list[dict] = []
        kb_slugs: list[str] = []
        if slug is not None:
            kb_slugs = [slug]
        else:
            for kb in self.list_kbs():
                if isinstance(kb, dict) and kb.get("slug"):
                    kb_slugs.append(kb["slug"])

        for kb_slug in kb_slugs:
            sources_dir = self._resolve(f"knowledge/{kb_slug}/wiki/sources")
            if not sources_dir.is_dir():
                continue
            for src in sources_dir.rglob("*.md"):
                # Filter out historical / archived copies so we only report
                # the LIVE source page for a given filename.
                posix = str(src).replace("\\", "/")
                if any(f"/{x}/" in posix for x in (".history", ".archives", ".quarantine")):
                    continue
                try:
                    logical = f"knowledge/{kb_slug}/wiki/sources/{src.relative_to(sources_dir).as_posix()}"
                    doc = self._reader.read(logical)
                except Exception:
                    continue
                meta = doc.metadata or {}
                records.append({
                    "kb": kb_slug,
                    "path": logical,
                    "filename": str(meta.get("source_file") or ""),
                    "type_name": str(meta.get("type_name") or ""),
                    "extractor_id": str(meta.get("extractor_id") or ""),
                    "extractor_version": str(meta.get("extractor_version") or ""),
                    "source_type": str(meta.get("category") or meta.get("source_type") or ""),
                    "date_updated": str(meta.get("date_updated") or ""),
                })
        return records

    async def reextract_source(
        self,
        kb_slug: str,
        source_slug: str,
        extractor_id: str = "",
    ) -> dict:
        """Re-run extraction on one source using a different tier.

        Phase 4.75 of decisions.md §2026-04-18. Scope is deliberately tight:
        only the raw-extraction text + provenance frontmatter update, NOT
        a full re-ingest (no re-classification, no entity/concept re-extraction,
        no wiki compile). The target use case: user clicks "re-ingest with
        OCR" on a scanned PDF, we run `pdf_pdfplumber_ocr`, append the new
        text to the source page, stamp new provenance. Full pipeline re-run
        remains an explicit ``POST /api/kb/{slug}/reingest`` action.

        Returns a result dict with keys: ``ok``, ``error``, ``kb``, ``source``,
        ``extractor_id``, ``extractor_version``, ``cost_tier``, ``fidelity``,
        ``element_count``, ``library_path``.

        ``extractor_id=""`` picks the best upgrade target for this source
        type via ``ExtractionSelector`` (highest fidelity within budget).
        An explicit id bypasses the selector — used when the UI knows
        which tier the user picked.
        """
        base = f"knowledge/{kb_slug}"
        source_path = f"{base}/wiki/sources/{source_slug}.md"
        if not self._reader.exists(source_path):
            return {"ok": False, "error": f"Source page not found: {source_path}"}

        doc = self._reader.read(source_path)
        meta = dict(doc.metadata or {})

        # Resolve the original file's physical path from the source page's
        # frontmatter. ``library_path`` is written as a relative path like
        # ``../../library/{taxonomy}/file.pdf`` (relative to the source
        # page's own location inside wiki/sources/). Rebuild the absolute
        # physical path by walking that back to the KB root.
        lib_rel = str(meta.get("library_path") or "").strip()
        if not lib_rel:
            return {"ok": False, "error": "Source page missing library_path frontmatter"}

        # Normalise — strip any leading ``../../`` that goes past the KB root.
        # The source page lives at knowledge/{kb}/wiki/sources/{slug}.md, so
        # two ``../`` segments point at the KB root. Beyond that we refuse.
        lib_clean = lib_rel.replace("\\", "/")
        while lib_clean.startswith("../"):
            lib_clean = lib_clean[3:]
        abs_file = self._resolve(f"{base}/{lib_clean}")
        if not abs_file.is_file():
            return {
                "ok": False,
                "error": f"Original file not found at {lib_clean}",
                "library_path": lib_rel,
            }

        # Resolve the spec for this file. Prefer the recorded type_name
        # (Phase 4.5+ ingest wrote it); fall back to extension-based
        # resolution so legacy sources still work.
        type_name = str(meta.get("type_name") or "").strip()
        spec = None
        if type_name:
            spec = self._file_types.get_spec(type_name)
        if spec is None:
            spec = self._file_types.resolve_type(abs_file)
        if spec is None:
            return {
                "ok": False,
                "error": f"No FileTypeSpec found for {abs_file.name}",
                "library_path": lib_rel,
            }

        # Pick the extractor. Explicit id wins; otherwise ExtractionSelector
        # returns the highest-fidelity binding that fits the budget.
        from agntspace.kb.file_types import ExtractionSelector
        selector = ExtractionSelector()
        if extractor_id:
            binding = spec.find_binding(extractor_id)
            if binding is None and extractor_id in self._file_types._extractors:
                # Caller asked for a registered extractor that isn't in the
                # ladder — honour via the registry's extract_id kwarg path.
                pass
            elif binding is None:
                return {
                    "ok": False,
                    "error": f"Unknown extractor '{extractor_id}' for type '{spec.type_name}'",
                }
        else:
            binding = selector.select(spec, max_cost_tier="reasoning")
            extractor_id = binding.extractor

        # Run the new extraction. The registry wrapper stamps provenance
        # with cost_tier + fidelity automatically.
        context = {
            "llm": self._llm,
            "describe_image": getattr(self, "_describe_image", None),
            "vision_timeout": 120,
        }
        result = await self._file_types.extract(
            abs_file, spec, context, extractor_id=extractor_id,
        )

        if result.provenance.get("error"):
            return {
                "ok": False,
                "error": f"Extraction failed: {result.provenance.get('error')}",
                "library_path": lib_rel,
                "extractor_id": extractor_id,
            }

        # Rewrite the source page: preserve the existing body, replace
        # provenance frontmatter with the new extractor metadata, and
        # APPEND a new "## Re-extracted Content" section with the fresh
        # element text. This keeps the original summary + concepts intact
        # (those came from the classify LLM prompt which we're NOT re-running)
        # while making the richer extraction available for the next compile.
        body = doc.content or ""

        # Update provenance fields on the frontmatter dict, then re-serialise.
        import frontmatter as _fm
        meta["type_name"] = str(result.provenance.get("type_name") or spec.type_name)
        meta["extractor_id"] = str(result.provenance.get("extractor_id") or extractor_id)
        meta["extractor_version"] = str(result.provenance.get("extractor_version") or "")
        meta["date_updated"] = datetime.now(UTC).date().isoformat()

        # Clip and tag the new body — drop any prior re-extraction section
        # so re-running the upgrade doesn't pile up duplicate sections.
        marker = "\n\n## Re-extracted Content"
        if marker in body:
            body = body.split(marker, 1)[0].rstrip() + "\n"

        cost_tier = result.provenance.get("cost_tier", "fast")
        fidelity = result.provenance.get("fidelity", "medium")
        new_text = result.as_text()
        body += (
            f"\n\n## Re-extracted Content\n\n"
            f"*Extracted via `{extractor_id}` (tier: {cost_tier}, fidelity: {fidelity}) "
            f"on {meta['date_updated']}.*\n\n"
            f"{new_text}\n"
        )

        post = _fm.Post(body, **meta)
        new_page = _fm.dumps(post) + "\n"
        self._writer.write(source_path, new_page, contract_action="modify_wiki")

        return {
            "ok": True,
            "kb": kb_slug,
            "source": source_slug,
            "library_path": lib_rel,
            "extractor_id": extractor_id,
            "extractor_version": meta["extractor_version"],
            "cost_tier": cost_tier,
            "fidelity": fidelity,
            "element_count": len(result.elements),
        }

    def get_kb_status(self, slug: str) -> dict:
        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        doc = self._reader.read(f"{base}/wiki/.index.md")
        raw = [r.name for r in self._reader.list_files(f"{base}/inbox", "**/*") if r.name != ".gitkeep"]
        lib = [s.name for s in self._reader.list_files(f"{base}/library", "**/*") if s.name != ".gitkeep"]
        entities = [w.name for w in self._reader.list_files(f"{base}/wiki/entities", "*.md")]
        concepts = [w.name for w in self._reader.list_files(f"{base}/wiki/concepts", "*.md")]
        sources = [w.name for w in self._reader.list_files(f"{base}/wiki/sources", "*.md")]
        source_summaries = [w.name for w in self._reader.list_files(f"{base}/wiki/source_summary", "*.md")]
        sources = list(set(sources + source_summaries))  # merge legacy + current
        state = self._read_compile_state(slug)

        return {
            "slug": slug, "title": doc.metadata.get("title", slug),
            "raw_pending": raw, "library": lib,
            "entity_pages": entities, "concept_pages": concepts, "source_pages": sources,
            "raw_count": len(raw), "library_count": len(lib),
            "entity_count": len(entities), "concept_count": len(concepts), "source_count": len(sources),
            "last_ingest": state.get("last_ingest"), "last_compile": state.get("last_compile"),
            "has_schema": self._reader.exists(f"{base}/SCHEMA.md"),
        }

    # ── Delete / Reset ──

    def delete_article(self, slug: str, article_slug: str) -> dict:
        """Delete a single wiki article from a KB. Returns info about what was deleted."""
        base = f"knowledge/{slug}/wiki"
        deleted = []
        for subdir in _WIKI_SUBDIRS:
            path = f"{base}/{subdir}/{article_slug}.md"
            full = self._resolve(path)
            log.debug("delete_article: checking %s (exists=%s)", full, full.exists())
            if full.exists():
                full.unlink()
                deleted.append(f"{subdir}/{article_slug}")
                log.info("Deleted wiki article: %s", full)
        if not deleted:
            log.warning("delete_article: no files found for %s in KB %s", article_slug, slug)

        # Also remove from .index.md and update stats
        if deleted:
            idx_path = f"knowledge/{slug}/wiki/.index.md"
            if self._reader.exists(idx_path):
                doc = self._reader.read(idx_path)
                idx = doc.raw
                for d in deleted:
                    idx = idx.replace(f"- [[{d}]]\n", "")
                    idx = idx.replace(f"- [[{article_slug}]]\n", "")
                self._writer.write(idx_path, idx)
            self._update_index_stats(slug)
            self._append_log(slug, "delete", f"Deleted article: {article_slug}")

            # Rebuild graph to remove orphaned nodes and stale triples
            _graph = getattr(self, "_graph", None)
            if _graph and hasattr(_graph, "build"):
                try:
                    _graph.build()
                except Exception:
                    log.debug("Graph rebuild after delete failed", exc_info=True)

        return {"slug": slug, "article": article_slug, "deleted": deleted}

    # ------------------------------------------------------------------
    # Article CRUD helpers
    # ------------------------------------------------------------------

    def _find_article_by_slug(self, slug: str) -> tuple[str, str, Path, dict, str] | None:
        """Find a wiki article by slug across all KBs.

        Returns (kb_slug, subdir, filepath, metadata, content) or None.
        Searches frontmatter ``slug`` field first, then filename stem match.
        """
        import re as _re

        try:
            files = self._reader.list_files("knowledge", "**/wiki/**/*.md")
        except Exception:
            return None

        for f in files:
            f_str = str(f).replace("\\", "/")
            if "/.archives/" in f_str or "/.history/" in f_str:
                continue
            if f.name.startswith(("_", ".")):
                continue

            # Quick filename stem check (slug-style)
            stem_slug = _re.sub(r"[^a-z0-9]+", "-", f.stem.lower()).strip("-")
            match_by_name = stem_slug == slug

            try:
                rel = self._to_logical(f)
                doc = self._reader.read(rel)
                meta = doc.metadata
                content = doc.content
            except Exception:
                continue

            fm_slug = meta.get("slug", "")
            if fm_slug == slug or match_by_name:
                # Extract kb_slug and subdir from path
                parts = rel.replace("\\", "/").split("/")
                try:
                    kb_idx = parts.index("knowledge")
                    kb_slug = parts[kb_idx + 1]
                except (ValueError, IndexError):
                    kb_slug = ""
                # subdir is the directory between wiki/ and the file
                try:
                    wiki_idx = parts.index("wiki")
                    if wiki_idx + 2 < len(parts):
                        subdir = parts[wiki_idx + 1]
                    else:
                        subdir = ""
                except (ValueError, IndexError):
                    subdir = ""
                return (kb_slug, subdir, f, meta, content)

        return None

    def create_article(
        self,
        kb_slug: str,
        title: str,
        content: str,
        entity_type: str = "concept",
        subdir: str | None = None,
    ) -> dict:
        """Create a new wiki article in a knowledge base.

        Returns {"slug", "title", "kb", "path"}.
        Raises ValueError on duplicate slug.
        """
        import re as _re
        from datetime import date

        from agntspace.vault.writer import title_to_filename

        # Determine subdir
        if not subdir:
            if entity_type in ("person", "organization"):
                subdir = "entities"
            else:
                subdir = "concepts"

        # Generate slug
        slug = _re.sub(r"[^a-z0-9-]", "", title.lower().replace(" ", "-")).strip("-")
        if not slug:
            slug = "untitled"

        # Check for duplicate slug across this KB's wiki

        wiki_base = self._resolve(f"knowledge/{kb_slug}/wiki")
        if wiki_base.exists():
            for sd in _WIKI_SUBDIRS:
                sd_path = wiki_base / sd
                if not sd_path.exists():
                    continue
                for f in sd_path.glob("*.md"):
                    if f.name.startswith(("_", ".")):
                        continue
                    stem_slug = _re.sub(r"[^a-z0-9]+", "-", f.stem.lower()).strip("-")
                    if stem_slug == slug:
                        raise ValueError(f"Article with slug '{slug}' already exists in KB '{kb_slug}'")
                    # Also check frontmatter slug
                    try:
                        rel = self._to_logical(f)
                        doc = self._reader.read(rel)
                        if doc.metadata.get("slug") == slug:
                            raise ValueError(f"Article with slug '{slug}' already exists in KB '{kb_slug}'")
                    except ValueError:
                        raise
                    except Exception:
                        pass
            # Re-check: the inner ValueError may have been caught by the bare except
            # Actually the except clause above only catches FileNotFoundError and ValueError
            # Let me restructure — check root wiki dir too
            for f in wiki_base.glob("*.md"):
                if f.name.startswith(("_", ".")):
                    continue
                stem_slug = _re.sub(r"[^a-z0-9]+", "-", f.stem.lower()).strip("-")
                if stem_slug == slug:
                    raise ValueError(f"Article with slug '{slug}' already exists in KB '{kb_slug}'")

        filename = title_to_filename(title)
        relative_path = f"knowledge/{kb_slug}/wiki/{subdir}/{filename}.md"

        metadata = {
            "title": title,
            "slug": slug,
            "entity_type": entity_type,
            "provenance": "manual",
            "author": "user",
            "date": date.today().isoformat(),
        }

        self._writer.write(relative_path, content, metadata, versioned=True)
        self._append_log(kb_slug, "create", f"Created article: {title} ({slug})")

        return {"slug": slug, "title": title, "kb": kb_slug, "path": relative_path}

    def update_article(
        self,
        slug: str,
        content: str | None = None,
        metadata_updates: dict | None = None,
    ) -> dict:
        """Update an existing wiki article (content and/or metadata).

        Finds the article by slug across all KBs.
        Returns {"slug", "title", "kb", "path", "updated_fields"}.
        Raises FileNotFoundError if not found.
        """
        from datetime import date

        found = self._find_article_by_slug(slug)
        if not found:
            raise FileNotFoundError(f"Article not found: {slug}")

        kb_slug, subdir, filepath, meta, existing_content = found
        rel_path = self._to_logical(filepath)

        updated_fields: list[str] = []

        # Update content
        if content is not None:
            existing_content = content
            updated_fields.append("content")

        # Merge metadata updates
        if metadata_updates:
            for k, v in metadata_updates.items():
                meta[k] = v
                updated_fields.append(k)

        # Always set date_updated
        meta["date_updated"] = date.today().isoformat()

        self._writer.write(rel_path, existing_content, meta, versioned=True)
        self._append_log(kb_slug, "update", f"Updated article: {meta.get('title', slug)} ({slug})")

        return {
            "slug": slug,
            "title": meta.get("title", slug),
            "kb": kb_slug,
            "path": rel_path,
            "updated_fields": updated_fields,
        }

    def rename_article(self, slug: str, new_title: str) -> dict:
        """Rename a wiki article: new file, backlink rewriting, compile state update.

        Returns {"old_slug", "new_slug", "old_title", "new_title", "kb", "backlinks_updated"}.
        Raises FileNotFoundError if not found, ValueError on slug collision.
        """
        import re as _re
        from datetime import date

        from agntspace.vault.writer import title_to_filename

        found = self._find_article_by_slug(slug)
        if not found:
            raise FileNotFoundError(f"Article not found: {slug}")

        kb_slug, subdir, filepath, meta, content = found

        old_rel = self._to_logical(filepath)
        old_title = meta.get("title", filepath.stem)

        # Generate new slug
        new_slug = _re.sub(r"[^a-z0-9-]", "", new_title.lower().replace(" ", "-")).strip("-")
        if not new_slug:
            new_slug = "untitled"

        # Check for collision (skip if renaming to same slug)
        if new_slug != slug:
            collision = self._find_article_by_slug(new_slug)
            if collision:
                raise ValueError(f"Article with slug '{new_slug}' already exists")

        # Build new path
        new_filename = title_to_filename(new_title)
        if subdir:
            new_rel = f"knowledge/{kb_slug}/wiki/{subdir}/{new_filename}.md"
        else:
            new_rel = f"knowledge/{kb_slug}/wiki/{new_filename}.md"

        # Update metadata
        meta["title"] = new_title
        meta["slug"] = new_slug
        meta["date_updated"] = date.today().isoformat()

        # Write new file
        self._writer.write(new_rel, content, meta, versioned=True)

        # Delete old file
        if filepath.exists() and old_rel != new_rel:
            filepath.unlink()

        # --- Backlink rewriting across ALL KBs ---
        backlinks_updated = 0
        try:
            all_files = self._reader.list_files("knowledge", "**/wiki/**/*.md")
        except Exception:
            all_files = []

        for f in all_files:
            f_str = str(f).replace("\\", "/")
            if "/.archives/" in f_str or "/.history/" in f_str:
                continue
            if f.name.startswith(("_", ".")):
                continue
            # Skip the article we just wrote
            f_rel = self._to_logical(f)
            if f_rel == new_rel:
                continue

            try:
                doc = self._reader.read(f_rel)
                raw = doc.raw
            except Exception:
                continue

            updated_raw = raw
            # Replace [[Old Title]] with [[New Title]]
            if old_title and old_title != new_title:
                updated_raw = updated_raw.replace(f"[[{old_title}]]", f"[[{new_title}]]")
            # Replace [[old-slug]] with [[new-slug]]
            if slug != new_slug:
                updated_raw = updated_raw.replace(f"[[{slug}]]", f"[[{new_slug}]]")

            if updated_raw != raw:
                # Write the updated file directly (mechanical rewrite, no versioning)
                full_path = self._resolve(f_rel)
                full_path.write_text(updated_raw, encoding="utf-8")
                backlinks_updated += 1

        # --- Update compile state ---
        try:
            state = self._read_compile_state(kb_slug)
            sources = state.get("sources", {})
            changed = False
            for fname, info in sources.items():
                pages = info.get("produced_pages", [])
                new_pages = []
                for p in pages:
                    if p == old_rel:
                        new_pages.append(new_rel)
                        changed = True
                    else:
                        new_pages.append(p)
                info["produced_pages"] = new_pages
            if changed:
                self._write_compile_state(kb_slug, state)
        except Exception:
            log.warning("Failed to update compile state during rename", exc_info=True)

        # --- Update _index.md ---
        idx_path = f"knowledge/{kb_slug}/wiki/_index.md"
        try:
            if self._reader.exists(idx_path):
                doc = self._reader.read(idx_path)
                idx_raw = doc.raw
                updated_idx = idx_raw.replace(f"[[{old_title}]]", f"[[{new_title}]]")
                if slug != new_slug:
                    updated_idx = updated_idx.replace(f"[[{slug}]]", f"[[{new_slug}]]")
                if updated_idx != idx_raw:
                    self._writer.write(idx_path, updated_idx, versioned=False)
        except Exception:
            log.warning("Failed to update _index.md during rename", exc_info=True)

        self._append_log(
            kb_slug, "rename",
            f"Renamed article: {old_title} → {new_title} ({slug} → {new_slug}), "
            f"{backlinks_updated} backlinks updated",
        )

        return {
            "old_slug": slug,
            "new_slug": new_slug,
            "old_title": old_title,
            "new_title": new_title,
            "kb": kb_slug,
            "backlinks_updated": backlinks_updated,
        }

    def find_backlinks(self, slug: str) -> list[dict]:
        """Find all articles that link to the given article via [[wikilinks]].

        Returns list of {"slug", "title", "kb"} dicts.
        """
        import re as _re

        found = self._find_article_by_slug(slug)
        if not found:
            return []

        _kb_slug, _subdir, _filepath, meta, _content = found
        title = meta.get("title", "")

        backlinks: list[dict] = []

        try:
            all_files = self._reader.list_files("knowledge", "**/wiki/**/*.md")
        except Exception:
            return []

        for f in all_files:
            f_str = str(f).replace("\\", "/")
            if "/.archives/" in f_str or "/.history/" in f_str:
                continue
            if f.name.startswith(("_", ".")):
                continue

            try:
                rel = self._to_logical(f)
                doc = self._reader.read(rel)
                raw_content = doc.content
            except Exception:
                continue

            # Check for [[Title]] or [[slug]] references
            has_link = False
            if title and f"[[{title}]]" in raw_content:
                has_link = True
            if f"[[{slug}]]" in raw_content:
                has_link = True

            if has_link:
                art_slug = doc.metadata.get("slug") or _re.sub(
                    r"[^a-z0-9]+", "-", f.stem.lower()
                ).strip("-")
                parts = rel.split("/")
                try:
                    kb_idx = parts.index("knowledge")
                    art_kb = parts[kb_idx + 1]
                except (ValueError, IndexError):
                    art_kb = ""
                backlinks.append({
                    "slug": art_slug,
                    "title": doc.metadata.get("title", f.stem),
                    "kb": art_kb,
                })

        return backlinks

    def reset_wiki(self, slug: str) -> dict:
        """Delete ALL wiki articles for a KB and reset compile state. Keeps library/inbox intact."""

        base_rel = f"knowledge/{slug}/wiki"
        wiki_dir = self._resolve(f"knowledge/{slug}/wiki")

        if not wiki_dir.exists():
            return {"slug": slug, "deleted": 0, "message": "Wiki directory not found"}

        # Count articles before deletion
        deleted_count = 0
        for subdir in _WIKI_SUBDIRS:
            d = wiki_dir / subdir
            if d.exists():
                for f in d.glob("*.md"):
                    f.unlink()
                    deleted_count += 1

        # Reset .index.md to clean state (matching _INDEX_TEMPLATE structure)
        idx_path = wiki_dir / ".index.md"
        if idx_path.exists():
            title = slug.replace("-", " ").title()
            date_str = datetime.now().strftime('%Y-%m-%d')
            self._writer.write(f"{base_rel}/.index.md", _INDEX_TEMPLATE.format(
                topic=title, date=date_str,
            ).strip())

        # Reset .concepts.md
        concepts_path = wiki_dir / ".concepts.md"
        if concepts_path.exists():
            concepts_path.unlink()

        # Reset compile state so next compile starts fresh
        self._write_compile_state(slug, {"last_ingest": None, "last_compile": None, "processed_hashes": {}})

        self._append_log(slug, "reset", f"Wiki reset — deleted {deleted_count} articles")
        return {"slug": slug, "deleted": deleted_count}

    def reset_all_wikis(self) -> dict:
        """Reset wikis across ALL knowledge bases."""
        kbs = self.list_kbs()
        total = 0
        results = []
        for kb in kbs:
            r = self.reset_wiki(kb["slug"])
            total += r["deleted"]
            results.append(r)
        return {"kbs_reset": len(results), "total_deleted": total, "details": results}

    # ── Ingest ──

    async def find_drifted_sources(self, slug: str | None = None) -> dict:
        """Return source pages whose recorded language policy no longer matches
        the current effective policy (spec §19 item 3).

        Walks every source_summary page in the requested KB (or all KBs),
        reads its `content_language`, `output_language`, `language_policy`,
        and re-resolves the policy using the current skill config + schema
        + global config. Pages where the newly-resolved output_language or
        effective_policy differs from what was recorded are returned.

        Result:
            {
                "drifted": [
                    {
                        "kb": "general",
                        "source": "article.md",
                        "recorded": {"content_lang": ..., "output_lang": ..., "policy": ..., "source": ...},
                        "current":  {"content_lang": ..., "output_lang": ..., "policy": ..., "source": ...},
                    },
                    ...
                ],
                "scanned": N,
                "drifted_count": M,
            }
        """
        import frontmatter

        from agntspace.infra.language import resolve_languages

        kbs_to_scan: list[str] = []
        base_knowledge = self._resolve("knowledge")
        if slug:
            kbs_to_scan.append(slug)
        else:
            if base_knowledge.is_dir():
                for kb_dir in base_knowledge.iterdir():
                    if kb_dir.is_dir():
                        kbs_to_scan.append(kb_dir.name)

        # Precompute per-KB config so we don't re-read it per file
        _skill_lang_cfg: dict = {}
        if self._skill_loader and hasattr(self._skill_loader, "get_skill_config"):
            _skill_lang_cfg = self._skill_loader.get_skill_config(
                "kb-ingest", "language",
            ) or {}
        _global_policy = getattr(self, "_global_language_policy", None)
        _user_locale = getattr(self, "_user_locale", "en")

        drifted: list[dict] = []
        scanned = 0

        for kb_slug in kbs_to_scan:
            sources_dir = self._resolve(f"knowledge/{kb_slug}/wiki/sources")
            if not sources_dir.is_dir():
                continue
            _schema_meta = self._read_schema_meta(kb_slug)

            for source_file in sources_dir.glob("*.md"):
                if source_file.name.startswith((".", "_")):
                    continue
                try:
                    post = frontmatter.load(source_file)
                except Exception:
                    continue
                meta = dict(post.metadata or {})
                # Only audit source_summary pages
                if meta.get("entity_type") != "source_summary":
                    continue
                scanned += 1

                recorded_content = str(meta.get("content_language") or "")
                recorded_output = str(meta.get("output_language") or "")
                recorded_policy = str(meta.get("language_policy") or "")

                # Re-resolve using current config. We use the summary body
                # itself for detection since the original source may be
                # gone; body length is usually sufficient.
                new_content, new_output, new_policy, new_source = resolve_languages(
                    post.content,
                    source_meta=None,
                    skill_config=_skill_lang_cfg,
                    kb_schema=_schema_meta,
                    global_policy=_global_policy,
                    user_locale=_user_locale,
                )

                # Prefer the recorded content_lang as ground truth — we don't
                # want detection drift here, only policy drift.
                if recorded_content:
                    new_content = recorded_content
                    from agntspace.infra.language import resolve_output_language
                    # Re-apply policy with the trusted content_lang
                    # Walk the current chain manually to find winning policy
                    eff_policy = "source"
                    eff_source = "default"
                    if _global_policy:
                        eff_policy, eff_source = _global_policy, "config"
                    if _skill_lang_cfg.get("output_policy"):
                        eff_policy, eff_source = _skill_lang_cfg["output_policy"], "skill"
                    if _schema_meta.get("language_policy"):
                        eff_policy, eff_source = _schema_meta["language_policy"], "schema"
                    new_output = resolve_output_language(
                        recorded_content, eff_policy, _user_locale,
                    )
                    new_policy, new_source = eff_policy, eff_source

                if new_output != recorded_output or new_policy != recorded_policy:
                    drifted.append({
                        "kb": kb_slug,
                        "source": source_file.name,
                        "recorded": {
                            "content_lang": recorded_content,
                            "output_lang": recorded_output,
                            "policy": recorded_policy,
                            "source": str(meta.get("language_policy_source") or ""),
                        },
                        "current": {
                            "content_lang": new_content,
                            "output_lang": new_output,
                            "policy": new_policy,
                            "source": new_source,
                        },
                    })

        return {
            "drifted": drifted,
            "scanned": scanned,
            "drifted_count": len(drifted),
        }

    async def reingest_drifted_sources(self, slug: str | None = None) -> dict:
        """Re-ingest all source pages whose recorded language policy no
        longer matches the current effective policy (spec §19 item 3).

        Finds drifted sources via `find_drifted_sources`, then for each:
          1. Locates the original file in the KB library (by source_file meta)
          2. Copies it back into the KB inbox so the ingest pipeline picks it up
          3. Triggers `ingest(kb_slug)` once per affected KB

        Returns:
            {
                "drifted_count": N,
                "requeued": M,
                "kbs_ingested": [slug, ...],
                "errors": [...],
            }
        """
        import shutil

        drift_report = await self.find_drifted_sources(slug)
        drifted = drift_report["drifted"]

        if not drifted:
            return {
                "drifted_count": 0,
                "requeued": 0,
                "kbs_ingested": [],
                "errors": [],
            }

        requeued = 0
        errors: list[str] = []
        affected_kbs: set[str] = set()

        for entry in drifted:
            kb_slug = entry["kb"]
            source_name = entry["source"]
            try:
                # Read the source page to find the original library file
                source_path = self._resolve(
                    f"knowledge/{kb_slug}/wiki/sources/{source_name}"
                )
                if not source_path.is_file():
                    continue
                import frontmatter
                post = frontmatter.load(source_path)
                meta = dict(post.metadata or {})
                library_file = meta.get("source_file") or ""
                if not library_file:
                    errors.append(f"{kb_slug}/{source_name}: no source_file in frontmatter")
                    continue
                library_path = self._resolve(f"knowledge/{kb_slug}/library/{library_file}")
                if not library_path.is_file():
                    errors.append(f"{kb_slug}/{source_name}: library file missing ({library_file})")
                    continue
                inbox_dir = self._resolve(f"knowledge/{kb_slug}/inbox")
                inbox_dir.mkdir(parents=True, exist_ok=True)
                dest = inbox_dir / library_file
                if not dest.exists():
                    shutil.copy2(library_path, dest)
                requeued += 1
                affected_kbs.add(kb_slug)
            except Exception as exc:
                errors.append(f"{kb_slug}/{source_name}: {exc}")

        kbs_ingested: list[str] = []
        for kb_slug in sorted(affected_kbs):
            try:
                await self.ingest(kb_slug)
                kbs_ingested.append(kb_slug)
            except Exception as exc:
                errors.append(f"ingest({kb_slug}): {exc}")

        return {
            "drifted_count": len(drifted),
            "requeued": requeued,
            "kbs_ingested": kbs_ingested,
            "errors": errors,
        }

    def is_pipeline_frozen(self) -> bool:
        """True when the user has frozen ALL ingestion via Settings or API.

        Reads ``[pipeline].frozen`` from config.toml on every call so toggling
        in the UI takes effect immediately without a restart. Defaults to
        False (ingestion runs normally) when the settings service isn't wired
        or the key is absent — preserves pre-freeze behavior on every existing
        vault. Mirrors the watcher's ``is_paused()`` pattern (raw_watcher.py).

        This is the GLOBAL emergency lever — distinct from
        ``watcher.paused`` which only stops auto-discovery. When this returns
        True, ``ingest()`` short-circuits with ``IngestFrozenError`` BEFORE
        any work happens. Every entry point (watcher, work queue, agent tool,
        HTTP endpoint, plugin) hits the same chokepoint.
        """
        svc = self._settings_service
        if svc is None:
            return False
        try:
            return bool(svc.get_value("pipeline", "frozen", False))  # type: ignore[attr-defined]
        except Exception:
            return False

    async def ingest(self, slug: str) -> dict:
        """Process raw/ files: classify, extract, create source summary + wiki updates, move to library/.

        Single entry point for every ingest path (watcher, REST, work
        queue, agent tool). Owns three observability responsibilities
        (Rule #13): open a correlation scope, emit start/end activity
        events, and record a root librarian decision that per-file
        decisions chain to.

        Raises ``IngestFrozenError`` immediately when the global pipeline
        freeze flag is set — short-circuits BEFORE any locks, scopes, or
        side effects. The freeze gate is the load-bearing invariant of the
        "global stop ingestion" feature: every path that wants to ingest
        eventually calls this method, so checking here covers them all.
        """
        # Global freeze gate — fires BEFORE the lock + scope so frozen calls
        # don't even queue behind in-flight ingests. Emit a low-importance
        # activity event so the user sees "the agent tried to ingest while
        # frozen" in the activity log without spamming higher tiers.
        if self.is_pipeline_frozen():
            self._log_activity(
                "ingest_blocked_frozen",
                f"KB {slug}: ingest blocked — pipeline is frozen",
                {"slug": slug, "triggered_by": "from_queue" if self._called_from_queue else "direct"},
                importance="low",
            )
            raise IngestFrozenError(
                f"Pipeline is frozen — ingest({slug}) refused. "
                "Thaw via Settings → Inbox watcher → 'Freeze all ingestion' "
                "or PUT /api/settings/pipeline-freeze body {\"frozen\": false}.",
            )

        lock = self._ingest_locks.setdefault(slug, asyncio.Lock())
        if self._orchestrator and hasattr(self._orchestrator, "_active_tasks"):
            self._orchestrator._active_tasks["librarian"] = f"Ingesting sources into {slug}"

        _started_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        self._log_activity(
            "ingest_started",
            f"KB {slug}: ingest batch started",
            {"slug": slug, "triggered_by": "from_queue" if self._called_from_queue else "direct"},
            importance="low",
        )

        try:
            async with self._scoped_pipeline("kb_ingest", "ingest"):
                async with lock:
                    result = await self._do_ingest(slug)
                _ingested_files = result.get("ingested", []) if isinstance(result, dict) else []
                _skipped_files = result.get("skipped", []) if isinstance(result, dict) else []
                self._log_activity(
                    "ingest_completed",
                    f"KB {slug}: ingested {len(_ingested_files)} file(s), skipped {len(_skipped_files)}",
                    {
                        "slug": slug,
                        "count": len(_ingested_files),
                        "files": _ingested_files[:20],
                        "skipped_count": len(_skipped_files),
                        "skipped": _skipped_files[:20],
                    },
                    importance="medium" if _ingested_files else "low",
                )
                await self._record_decision(
                    action_type="ingest_batch",
                    action_target=f"kb/{slug}",
                    rationale=f"Ingest batch produced {len(_ingested_files)} source summaries",
                    details={
                        "slug": slug,
                        "count": len(_ingested_files),
                        "files": _ingested_files[:20],
                        "started_at": _started_iso,
                    },
                )
                # Auto-refresh structural graph overlay so the new
                # source nodes + triples appear without requiring a
                # manual /api/graph/build call.
                graph = getattr(self, "_graph", None)
                if graph is not None and hasattr(graph, "refresh_structural_triples"):
                    try:
                        graph.refresh_structural_triples()
                    except Exception:
                        log.debug("kb: refresh_structural_triples failed", exc_info=True)
                return result
        except Exception as exc:
            self._log_activity(
                "ingest_failed",
                f"KB {slug}: ingest batch raised {type(exc).__name__}",
                {"slug": slug, "error": str(exc)[:500]},
                importance="high",
            )
            await self._record_decision(
                action_type="ingest_batch",
                action_target=f"kb/{slug}",
                contract_result="failed",
                rationale=f"Ingest batch aborted: {type(exc).__name__}",
                details={"slug": slug, "error": str(exc)[:500]},
            )
            raise
        finally:
            if self._orchestrator and hasattr(self._orchestrator, "_active_tasks"):
                self._orchestrator._active_tasks.pop("librarian", None)

    async def _do_ingest(self, slug: str) -> dict:
        """Internal ingest implementation (holds per-KB lock)."""

        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        # Pipeline mode: suppress versioning for automated writes.
        # Guaranteed reset in finally block below — never leak True across calls.
        self._writer._pipeline_mode = True
        try:
            return await self._do_ingest_body(slug, base)
        finally:
            self._writer._pipeline_mode = False

    async def _do_ingest_body(self, slug: str, base: str) -> dict:
        """Main ingest body. Pipeline mode is managed by the caller."""
        from agntspace.llm.base import Message  # noqa: F401 — used by LLM helpers below

        index_doc = self._reader.read(f"{base}/wiki/.index.md")
        index_content = index_doc.raw
        schema = self._read_schema(slug)
        state = self._read_compile_state(slug)

        all_inbox = [
            f for f in self._reader.list_files(f"{base}/inbox", "**/*")
            if f.name != ".gitkeep" and ".quarantine" not in str(f).replace("\\", "/")
        ]
        raw_files = [f for f in all_inbox if not f.name.endswith(".source") and f.is_file()]
        if not raw_files:
            return {"slug": slug, "ingested": [], "skipped": [], "count": 0, "message": "raw/ is empty."}

        # Cap files per ingest call to keep individual cycles short.
        # Sort alphabetically for stable order; remaining files picked
        # up next cycle. Phase 2 (Rule 12): cap value lives in
        # ``kb-ingest/config/throughput.yaml`` so users can tune their
        # ingest pipeline without touching code.
        _max_per_ingest = self._load_throughput_cap("max_per_ingest", default=10)
        raw_files.sort(key=lambda f: f.name)
        if len(raw_files) > _max_per_ingest:
            raw_files = raw_files[:_max_per_ingest]
            log.info("KB %s: capping ingest to %d files (more pending)", slug, _max_per_ingest)

        # Read sidecar .source files to get original vault paths
        vault_sources: dict[str, str] = {}
        for f in all_inbox:
            if f.name.endswith(".source"):
                try:
                    original_name = f.name[:-7]  # strip ".source"
                    vault_sources[original_name] = f.read_text(encoding="utf-8").strip()
                except Exception:
                    pass

        now = datetime.now(UTC)
        local_now = datetime.now()
        date_folder = now.strftime("%Y-%m")
        date_str = local_now.strftime("%Y-%m-%d %H:%M")
        ingested: list[str] = []
        skipped: list[dict] = []
        wiki_updates = []

        # Discover known projects for auto-routing
        known_projects: dict[str, str] = {}  # slug → title
        projects_dir = self._resolve(f"{base}/wiki/projects")
        if projects_dir.is_dir():
            for d in projects_dir.iterdir():
                if d.is_dir() and d.name != ".gitkeep":
                    known_projects[d.name] = d.name.replace("-", " ").title()
        # Also check vault projects (from ProjectService)
        vault_projects_dir = self._resolve("projects")
        if vault_projects_dir.is_dir():
            for d in vault_projects_dir.iterdir():
                if d.is_dir() and d.name not in known_projects:
                    known_projects[d.name] = d.name.replace("-", " ").title()

        # Skip patterns for files that don't contain useful knowledge.
        # Phase 2 (Rule 12): patterns live in
        # ``kb-ingest/config/skip-patterns.yaml`` (multilingual).
        # The loader flattens all language sections into one match set.
        _skip_patterns = self._load_skip_patterns()

        for raw_file in raw_files:
            try:
                fname = raw_file.name
                fname_lower = fname.lower()

                # Skip mundane files (time reports, templates, etc.) — delete from inbox
                # so the watcher doesn't re-discover them every cycle.
                if any(p in fname_lower for p in _skip_patterns):
                    log.info("KB %s: skipping mundane file %s", slug, fname)
                    try:
                        raw_file.unlink()
                    except OSError:
                        pass
                    continue

                # Classify first — determines how to read the file
                source_type, type_focus = self._classify_source(fname)
                is_image = source_type == "image"

                # Extract via the file-type registry. One call, one contract,
                # preserves every pre-registry behaviour (image vision + timeout,
                # parsers path for PDF/JSON/VTT, text path with frontmatter +
                # web-noise stripping, empty/unreadable fallback with stable
                # content_hash). See ``_extract_source`` docstring for parity
                # guarantees and decisions.md §2026-04-18 for the overall design.
                source_text, source_meta, raw_source_url, content_hash, _extract_result = \
                    await self._extract_source(raw_file, fname)

                # ── Resolve embedded image references ───────────────────
                # Markdown files (from Obsidian Clipper, web clippers, etc.)
                # often contain ![alt](image.png) or ![[Pasted image.png]]
                # references.  The watcher copies sibling images alongside
                # the .md file, so they should be in the same inbox dir.
                # Here we: (1) find local images, (2) download remote ones,
                # (3) run each through OCR + vision LLM, (4) append the
                # descriptions to source_text so the LLM sees them during
                # classification and the descriptions become searchable.
                if not is_image and source_text:
                    resolved_images = await self._resolve_embedded_images(
                        source_text, raw_file, base, slug,
                    )
                    if resolved_images:
                        image_section_parts: list[str] = []
                        for ri in resolved_images:
                            if ri.resolved:
                                desc = await self._describe_embedded_image(ri)
                                if desc:
                                    label = ri.alt_text or ri.local_path.name  # type: ignore[union-attr]
                                    image_section_parts.append(
                                        f"### Image: {label}\n{desc}"
                                    )
                        if image_section_parts:
                            source_text += "\n\n## Embedded Images\n\n" + "\n\n".join(image_section_parts)
                            # Recompute hash now that source_text includes image descriptions
                            content_hash = hashlib.md5(source_text.encode()).hexdigest()[:12]
                            log.info(
                                "KB %s: resolved %d embedded image(s) for %s (%d described)",
                                slug, len(resolved_images), fname,
                                len(image_section_parts),
                            )

                # Skip if already processed — check by filename AND by content hash.
                # Emit a visible activity event so re-clipping an unchanged file
                # doesn't look like silent failure in the UI.
                if state["processed_hashes"].get(fname) == content_hash:
                    log.info(
                        "KB %s: %s unchanged since last ingest (hash %s) — moving to library",
                        slug, fname, content_hash,
                    )
                    self._append_log(slug, "skip", f"Unchanged: {fname} (hash {content_hash})")
                    self._log_activity(
                        "ingest_skipped_unchanged",
                        f"{fname} unchanged since last ingest in KB {slug}",
                        {
                            "slug": slug,
                            "file": fname,
                            "content_hash": content_hash,
                            "reason": "identical_content",
                        },
                        importance="low",
                    )
                    skipped.append({"file": fname, "reason": "unchanged", "hash": content_hash})
                    self._move_to_library(raw_file, base, fname, date_folder, slug=slug, taxonomy_path=None)
                    continue

                # Check for duplicate content under a different filename (same KB)
                all_hashes = state["processed_hashes"]
                duplicate_of = next(
                    (existing_name for existing_name, h in all_hashes.items() if h == content_hash and existing_name != fname),
                    None,
                )
                if duplicate_of:
                    log.info("KB %s: skipping %s — duplicate of %s (same content hash)", slug, fname, duplicate_of)
                    self._append_log(slug, "skip", f"Duplicate: {fname} = {duplicate_of}")
                    self._log_activity(
                        "ingest_skipped_duplicate",
                        f"{fname} is a duplicate of {duplicate_of} in KB {slug}",
                        {"slug": slug, "file": fname, "duplicate_of": duplicate_of, "content_hash": content_hash},
                        importance="low",
                    )
                    skipped.append({"file": fname, "reason": "duplicate", "duplicate_of": duplicate_of})
                    state["processed_hashes"][fname] = content_hash
                    self._move_to_library(raw_file, base, fname, date_folder, slug=slug, taxonomy_path=None)
                    self._write_compile_state(slug, state)
                    continue

                # Cross-KB dedup: check if this content hash exists in any other KB
                cross_dup = self._check_cross_kb_duplicate(slug, content_hash)
                if cross_dup:
                    log.info("KB %s: skipping %s — already ingested in KB %s", slug, fname, cross_dup)
                    self._append_log(slug, "skip", f"Cross-KB duplicate: {fname} already in {cross_dup}")
                    self._log_activity(
                        "ingest_skipped_cross_kb",
                        f"{fname} already ingested in KB {cross_dup}",
                        {"slug": slug, "file": fname, "other_kb": cross_dup, "content_hash": content_hash},
                        importance="low",
                    )
                    skipped.append({"file": fname, "reason": "cross_kb_duplicate", "other_kb": cross_dup})
                    state["processed_hashes"][fname] = content_hash
                    self._move_to_library(raw_file, base, fname, date_folder, slug=slug, taxonomy_path=None)
                    self._write_compile_state(slug, state)
                    continue

                # Skip empty files — LLM will reject empty content
                if not source_text.strip():
                    log.info("KB %s: skipping %s — empty file", slug, fname)
                    state["processed_hashes"][fname] = content_hash
                    self._move_to_library(raw_file, base, fname, date_folder, slug=slug, taxonomy_path=None)
                    self._write_compile_state(slug, state)
                    continue

                # Check if this is an updated version of an existing file (same stem, different hash)
                existing_entry = next(
                    (existing_name for existing_name in all_hashes if Path(existing_name).stem == Path(fname).stem and existing_name != fname),
                    None,
                )
                if existing_entry:
                    log.info("KB %s: %s appears to be an update of %s — replacing", slug, fname, existing_entry)
                    # Clean up old source summary if the slug changed
                    old_source_slug = Path(existing_entry).stem.lower().replace(" ", "-")
                    new_source_slug = Path(fname).stem.lower().replace(" ", "-")
                    if old_source_slug != new_source_slug:
                        old_page = self._resolve(f"{base}/wiki/sources") / f"{old_source_slug}.md"
                        if old_page.exists():
                            old_page.unlink()
                            log.info("KB %s: cleaned up orphaned source page %s", slug, old_source_slug)
                    # Also clean up pages the old source produced
                    old_provenance = state.get("sources", {}).get(existing_entry, {})
                    for old_page_path in old_provenance.get("produced_pages", []):
                        if old_page_path.startswith("sources/"):
                            continue  # handled above
                        full = self._resolve(f"{base}/wiki") / f"{old_page_path}.md"
                        if full.exists():
                            full.unlink()
                            log.info("KB %s: cleaned up old produced page %s", slug, old_page_path)
                    # Remove old hash and provenance
                    del all_hashes[existing_entry]
                    state.get("sources", {}).pop(existing_entry, None)

                # type_focus already set from classification above

                # For large documents: chunked multi-pass extraction
                # Extracts knowledge from ALL sections via parallel LLM calls,
                # then merges into a single consolidated summary. No content dropped.
                from agntspace.kb.chunked_extract import chunked_extract, needs_chunking
                if not is_image and needs_chunking(source_text):
                    try:
                        # Large documents need "capable" tier — small models (fast) produce
                        # garbage on multi-page content (truncation, hallucination, empty output)
                        source_text = await chunked_extract(
                            source_text, fname, source_type, self._llm,
                            model=self._resolve_model("capable"),
                        )
                        log.info("KB %s: chunked extraction complete for %s (%d chars)",
                                 slug, fname, len(source_text))
                    except Exception:
                        log.exception("KB %s: chunked extraction failed for %s — using first 12K chars",
                                      slug, fname)
                        source_text = source_text[:12000]

                # LLM: type-specific extraction — wrapped in try/except so one failure
                # doesn't kill the entire batch
                try:
                    # Build classify prompt: skill-loaded or hardcoded fallback
                    _bib_block = (
                        "\n\nAlso extract bibliographic metadata into this block:\n"
                        "```bibliography\n"
                        "- title: (exact title of the work — not the filename)\n"
                        "- authors: (comma-separated, Last First or First Last format)\n"
                        "- date: (publication date, e.g. \"14 July 2025\" or \"2025\" or \"2025-07-14\")\n"
                        "- publisher: (journal name, newspaper, website name, or publisher)\n"
                        "- doi: (DOI if visible, or \"\")\n"
                        "- volume: (journal volume number, or \"\")\n"
                        "- issue: (journal issue number, or \"\")\n"
                        "- pages: (page range e.g. \"263-276\", or \"\")\n"
                        "- issn: (ISSN if visible, or \"\")\n"
                        "- isbn: (ISBN if visible, or \"\")\n"
                        "- language: (content language if not English, e.g. \"Hungarian\", or \"\")\n"
                        "- via: (hosting platform if different from publisher, e.g. \"Taylor & Francis\", or \"\")\n"
                        "```"
                    )

                    # Build frontmatter context block — gives the LLM authoritative
                    # metadata from the source (title, description, author, URL) so it
                    # doesn't have to guess the topic from potentially noisy body text.
                    _fm_parts: list[str] = []
                    if not is_image and source_meta:
                        fm_title = source_meta.get("title", "")
                        fm_desc = source_meta.get("description", "")
                        fm_author = source_meta.get("author", "")
                        fm_published = source_meta.get("published", source_meta.get("date", ""))
                        fm_tags = source_meta.get("tags", [])
                        if fm_title:
                            _fm_parts.append(f"Title: {fm_title}")
                        if fm_desc:
                            _fm_parts.append(f"Description: {fm_desc}")
                        if fm_author:
                            if isinstance(fm_author, list):
                                fm_author = ", ".join(str(a).strip("[]") for a in fm_author)
                            _fm_parts.append(f"Author: {fm_author}")
                        if fm_published:
                            _fm_parts.append(f"Published: {fm_published}")
                        if raw_source_url:
                            _fm_parts.append(f"Source URL: {raw_source_url}")
                        if fm_tags and isinstance(fm_tags, list):
                            _fm_parts.append(f"Tags: {', '.join(str(t) for t in fm_tags)}")
                    _fm_block = ""
                    if _fm_parts:
                        _fm_block = (
                            "\n\nSOURCE METADATA (from file frontmatter — this is authoritative, "
                            "use it to determine the article's true topic even if body text "
                            "starts with ads or unrelated content):\n"
                            + "\n".join(_fm_parts) + "\n"
                        )

                    # Extract only article TITLES from the index, not content.
                    # Small models copy content from the prompt, producing wrong
                    # summaries. Titles-only gives context without contamination.
                    _index_titles: list[str] = []
                    for _line in (index_doc.content or "").split("\n"):
                        _l = _line.strip()
                        if _l.startswith("### ") or _l.startswith("## "):
                            _index_titles.append(_l.lstrip("# ").strip())
                    _index_summary = (
                        ("Existing articles: " + ", ".join(_index_titles[:30]))
                        if _index_titles else "Existing articles: (none)"
                    )
                    # ── Safe defaults for all language variables ──────────
                    # These 8 variables are set inside the language block
                    # below and used later in the template rendering +
                    # observability logging. If the block raises for any
                    # reason, these defaults ensure the rest of the per-file
                    # loop doesn't crash with NameError. The defaults are
                    # conservative: English, source policy, no drift, no
                    # bilingual, no escalation. Fix for the 2026-04-11
                    # production crash where undefined _language_check
                    # cascaded into NameError downstream.
                    content_lang: str = "en"
                    output_lang: str = "en"
                    _policy: str = "source"
                    _policy_source: str = "default"
                    _is_bilingual: bool = False
                    _secondary: str = ""
                    _language_check: dict = {"status": "skipped", "detected": "", "expected": "en", "reason": "defaults", "confidence": 0.0}
                    _classify_tier: str = "fast"
                    entities_block: str = ""

                    # ── Three-language architecture ─────────────────────────
                    # Resolve content/output language via the policy override
                    # chain: frontmatter > SCHEMA > skill > config > default.
                    # Build an English directive that tells the LLM what
                    # language to write the summary in, then inject it into
                    # the system prompt.
                    from agntspace.infra.language import (
                        build_bilingual_directive,
                        build_language_directive,
                        resolve_languages,
                    )
                    _skill_lang_cfg: dict = {}
                    if self._skill_loader and hasattr(self._skill_loader, "get_skill_config"):
                        _skill_lang_cfg = self._skill_loader.get_skill_config(
                            "kb-ingest", "language",
                        ) or {}
                    _schema_meta = self._read_schema_meta(slug)
                    _global_policy = getattr(self, "_global_language_policy", None)
                    _user_locale = getattr(self, "_user_locale", "en")
                    content_lang, output_lang, _policy, _policy_source = resolve_languages(
                        source_text,
                        source_meta=source_meta,
                        skill_config=_skill_lang_cfg,
                        kb_schema=_schema_meta,
                        global_policy=_global_policy,
                        user_locale=_user_locale,
                    )

                    # Bilingual mode (spec §19 item 4): when policy is "both",
                    # emit a directive that asks for two parallel outputs —
                    # primary in content_lang, secondary in user_locale.
                    _is_bilingual = (_policy or "").strip().lower() == "both"
                    if _is_bilingual:
                        _secondary = _user_locale.replace("_", "-").split("-")[0].lower()
                        if _secondary == content_lang:
                            # Degenerate case — nothing to translate
                            _lang_directive = build_language_directive(content_lang, content_lang)
                            _is_bilingual = False
                        else:
                            _lang_directive = build_bilingual_directive(content_lang, _secondary)
                    else:
                        _lang_directive = build_language_directive(content_lang, output_lang)

                    dynamic_ctx = (
                        f"\nSource file: {fname}\nSource type: {source_type}\n"
                        f"Type-specific focus: {type_focus}\n"
                        f"{_fm_block}\n"
                        f"KB Schema:\n{schema[:1000]}\n\n"
                        f"{_index_summary}\n\n"
                        f"KNOWLEDGE TAXONOMY:\n{self._taxonomy_text}"
                        f"{_bib_block}"
                        f"{_lang_directive}"
                    )
                    skill_prompt = self._get_skill_prompt("kb-ingest")
                    if skill_prompt:
                        classify_system = skill_prompt + "\n\n" + dynamic_ctx
                    else:
                        classify_system = _CLASSIFY_PROMPT_FALLBACK.format(
                            filename=fname, source_type=source_type, type_focus=type_focus,
                            schema=schema[:1000], index=_index_summary,
                            taxonomy=self._taxonomy_text,
                        )
                        classify_system += _lang_directive
                    classify_system += "\n\n" + self._get_skill_rules("kb-ingest")
                    # Use "capable" for papers/long content, "fast" for short articles.
                    # In local mode, always use "fast" — "capable" is typically a 70b
                    # model that's too slow for classification under any reasonable timeout.
                    if self._is_local_mode():
                        _classify_tier = "fast"
                        _timeout = 180
                    else:
                        _classify_tier = "capable" if source_type == "paper" or len(source_text) > 5000 else "fast"
                        _timeout = 90

                    # Language-drift escalation (spec v1.1 follow-up):
                    # When the work queue retries an ingest because an earlier
                    # attempt produced wrong-language output, the payload carries
                    # an `escalate_tier` hint which bumps the model tier for this
                    # specific file. Cleared after first use by the handler.
                    _escalation = getattr(self, "_language_drift_escalation", None)
                    _forced_model: str | None = None
                    if (
                        _escalation
                        and isinstance(_escalation, dict)
                        and (not _escalation.get("file") or _escalation["file"] == fname)
                    ):
                        if _escalation.get("tier"):
                            _classify_tier = _escalation["tier"]
                            if isinstance(source_meta, dict):
                                source_meta["_language_drift_retry"] = True
                        # Provider escalation (gap #5): when tier-escalation has
                        # already been tried, swap to a concrete fallback model
                        # string (e.g. "openai/gpt-5.4") via the LiteLLM provider
                        # override. Different provider = different training data
                        # bias, so multilingual adherence may improve.
                        if _escalation.get("fallback_model"):
                            _forced_model = _escalation["fallback_model"]
                            if isinstance(source_meta, dict):
                                source_meta["_language_drift_provider_retry"] = True

                    # Empty-extraction escalation: same shape as the drift
                    # escalation, used when an earlier attempt produced no
                    # usable entities/concepts. The retry runs at a higher
                    # tier so the LLM is more likely to produce proper
                    # fenced blocks. Does not override an active drift
                    # escalation — both retry reasons can coexist, drift
                    # takes priority because language correctness is
                    # load-bearing for downstream semantic work.
                    _empty_esc = getattr(self, "_empty_extraction_escalation", None)
                    if (
                        _empty_esc
                        and isinstance(_empty_esc, dict)
                        and (not _empty_esc.get("file") or _empty_esc["file"] == fname)
                        and not _forced_model  # don't override drift provider retry
                    ):
                        if _empty_esc.get("tier"):
                            _classify_tier = _empty_esc["tier"]
                            if isinstance(source_meta, dict):
                                source_meta["_empty_extraction_retry"] = True

                    # Prompt injection defense: wrap the raw source text in
                    # structural boundary markers before it reaches the LLM.
                    # This is the primary defense against malicious web content
                    # that contains "ignore previous instructions" payloads.
                    # See docs/threat-model.md §9.1 and security/prompt_guard.py.
                    from agntspace.security.prompt_guard import sanitize_for_llm
                    _safe_source_text = sanitize_for_llm(
                        source_text,
                        source="ingested_file",
                        context=fname,
                    )

                    _classify_model = _forced_model or self._resolve_model(_classify_tier)
                    response = await asyncio.wait_for(
                        self._llm.complete(
                            messages=[Message(role="user", content=_safe_source_text)],
                            system=classify_system,
                            max_tokens=3500,
                            temperature=0.3,
                            model=_classify_model,  # librarian: classification
                        ),
                        timeout=_timeout,
                    )
                except TimeoutError:
                    log.warning("KB %s: LLM extraction timed out for %s — quarantining", slug, fname)
                    # Move the file to a quarantine subdir so it doesn't block the
                    # queue forever. User can review and re-try manually later.
                    try:
                        quarantine = self._resolve(f"{base}/inbox/.quarantine")
                        quarantine.mkdir(parents=True, exist_ok=True)
                        raw_file.rename(quarantine / fname)
                    except OSError:
                        log.exception("KB %s: failed to quarantine %s", slug, fname)
                    continue
                except Exception as exc:
                    log.warning("KB %s: LLM extraction failed for %s: %s", slug, fname, exc)
                    if self._called_from_queue:
                        raise  # Let work queue handle retry scheduling
                    if self._work_queue:
                        asyncio.create_task(self._work_queue.enqueue(
                            "ingest", {"slug": slug}, deduplicate=True,
                        ))
                        log.info("KB %s: queued ingest for retry (LLM unavailable)", slug)
                    continue

                summary = self._extract_block(response.content, "summary") or response.content[:300]

                # ── Language adherence gate (spec §19 items 1 + 5) ──
                # Detect the actual language of the summary and compare it to the
                # expected output_lang. Small local models often ignore language
                # directives. On mismatch, log the drift and record it in the
                # frontmatter so the user can re-ingest with a better model.
                from agntspace.infra.language import verify_output_language
                _language_check = verify_output_language(summary, output_lang)
                if _language_check["status"] == "mismatch":
                    log.warning(
                        "KB %s: LLM output language drift — expected %s, got %s (file=%s)",
                        slug, output_lang, _language_check["detected"], fname,
                    )
                    _activity = getattr(self, "_activity", None)
                    if _activity and hasattr(_activity, "log"):
                        try:
                            _activity.log(
                                "llm", "language_drift",
                                f"LLM produced {_language_check['detected']} "
                                f"instead of requested {output_lang} for {fname}",
                                {
                                    "slug": slug,
                                    "file": fname,
                                    "expected": output_lang,
                                    "detected": _language_check["detected"],
                                },
                                importance="medium",
                            )
                        except Exception:
                            pass
                    # Language-aware quality gate (spec §19 item 5 + tier escalation):
                    # Re-queue the ingest with an ESCALATED model tier when the
                    # LLM produces wrong-language output. The escalated tier is
                    # encoded in the job payload; `_do_ingest` reads it on retry.
                    # Default escalation path: fast → capable → reasoning. Stops
                    # after one escalation per file to avoid loops.
                    _drift_retry_enabled = _skill_lang_cfg.get("retry_on_language_drift")
                    # When retry is ON by default, allow opt-out via explicit False.
                    if _drift_retry_enabled is None:
                        _drift_retry_enabled = True
                    if (
                        _drift_retry_enabled
                        and self._work_queue
                    ):
                        _already_tier_escalated = (
                            isinstance(source_meta, dict)
                            and source_meta.get("_language_drift_retry")
                        )
                        _already_provider_escalated = (
                            isinstance(source_meta, dict)
                            and source_meta.get("_language_drift_provider_retry")
                        )

                        # Step 1: tier escalation (fast → capable → reasoning)
                        if not _already_tier_escalated and not self._called_from_queue:
                            _escalation_map = {"fast": "capable", "capable": "reasoning"}
                            _next_tier = _escalation_map.get(_classify_tier)
                            if _next_tier:
                                try:
                                    asyncio.create_task(self._work_queue.enqueue(
                                        "ingest",
                                        {
                                            "slug": slug,
                                            "reason": "language_drift",
                                            "escalate_tier": _next_tier,
                                            "target_file": fname,
                                        },
                                        deduplicate=True,
                                    ))
                                    log.info(
                                        "KB %s: queued ingest retry at tier %s due to "
                                        "language drift (%s → %s) for %s",
                                        slug, _next_tier, output_lang,
                                        _language_check["detected"], fname,
                                    )
                                except Exception:
                                    log.debug("Failed to enqueue language-drift tier retry", exc_info=True)

                        # Step 2: provider escalation — if tier-escalation was
                        # already tried (we're in a queue retry) and STILL drifts,
                        # swap to a concrete fallback model from skill config or
                        # global LLM settings.
                        elif not _already_provider_escalated:
                            _fallback_model = (
                                _skill_lang_cfg.get("fallback_model")
                                or getattr(self, "_language_drift_fallback_model", None)
                            )
                            if _fallback_model:
                                try:
                                    asyncio.create_task(self._work_queue.enqueue(
                                        "ingest",
                                        {
                                            "slug": slug,
                                            "reason": "language_drift_provider",
                                            "fallback_model": _fallback_model,
                                            "target_file": fname,
                                        },
                                        deduplicate=True,
                                    ))
                                    log.warning(
                                        "KB %s: tier escalation exhausted — queuing "
                                        "provider retry (%s) for %s",
                                        slug, _fallback_model, fname,
                                    )
                                except Exception:
                                    log.debug("Failed to enqueue language-drift provider retry", exc_info=True)
                            else:
                                log.warning(
                                    "KB %s: tier escalation exhausted and no "
                                    "fallback_model configured — drift persists for %s",
                                    slug, fname,
                                )

                # Quality gate: reject garbage extractions (truncated, empty, too short)
                _MIN_SUMMARY_CHARS = 80
                if len(summary.strip()) < _MIN_SUMMARY_CHARS:
                    log.warning(
                        "KB %s: extraction quality too low for %s — summary only %d chars: %s",
                        slug, fname, len(summary.strip()), summary.strip()[:100],
                    )
                    if self._called_from_queue:
                        raise RuntimeError(f"Extraction quality too low ({len(summary.strip())} chars)")
                    if self._work_queue:
                        asyncio.create_task(self._work_queue.enqueue(
                            "ingest", {"slug": slug}, deduplicate=True,
                        ))
                        log.info("KB %s: queued ingest retry due to poor extraction quality", slug)
                    continue

                # Echo-detection gate: small local models sometimes regurgitate a
                # previously-generated summary from the prompt context. Flag the
                # extraction if it's a near-duplicate of another source in the
                # same KB (by first 200 chars). The compile state's sources dict
                # tracks hashes; here we check against *existing summaries* on disk.
                if self._is_local_mode():
                    summary_head = " ".join(summary.strip().split())[:200].lower()
                    if summary_head:
                        try:
                            for other in self._reader.list_files(
                                f"{base}/wiki/sources", "*.md"
                            ):
                                if other.stem == Path(fname).stem:
                                    continue
                                try:
                                    other_doc = self._reader.read(
                                        f"{base}/wiki/sources/{other.name}"
                                    )
                                    other_summary = self._extract_block(
                                        other_doc.content, "summary"
                                    ) or ""
                                    other_head = " ".join(
                                        other_summary.strip().split()
                                    )[:200].lower()
                                    if (
                                        other_head
                                        and len(other_head) >= 80
                                        and other_head == summary_head
                                    ):
                                        log.warning(
                                            "KB %s: extraction for %s duplicates %s "
                                            "(local LLM echo) — quarantining",
                                            slug, fname, other.stem,
                                        )
                                        try:
                                            quarantine = self._resolve(
                                                f"{base}/inbox/.quarantine"
                                            )
                                            quarantine.mkdir(parents=True, exist_ok=True)
                                            raw_file.rename(quarantine / fname)
                                        except OSError:
                                            log.exception(
                                                "KB %s: failed to quarantine echo %s",
                                                slug, fname,
                                            )
                                        raise _EchoDetected()
                                except _EchoDetected:
                                    raise
                                except Exception:
                                    continue
                        except _EchoDetected:
                            continue
                        except Exception:
                            log.debug(
                                "echo detection failed for %s", fname, exc_info=True
                            )
                concepts_raw = self._extract_block(response.content, "concepts") or ""
                # Layer 2 anti-contamination: strip concept lines that came
                # from the existing-articles list in the system prompt rather
                # than from the actual source content. The classify LLM sees
                # KB article titles for dedup and small models parrot them.
                concepts = self._filter_concepts_by_source(concepts_raw, source_text)
                entities_block = self._extract_block(response.content, "entities") or ""
                contradictions = self._extract_block(response.content, "contradictions") or "none found"
                updates = self._extract_block(response.content, "wiki_updates") or ""
                bib_raw = self._extract_block(response.content, "bibliography") or ""

                # Create source summary page — use display name for filename
                from agntspace.vault.writer import title_to_filename
                source_slug = Path(fname).stem.lower().replace(" ", "-")
                source_display = title_to_filename(Path(fname).stem)
                file_category = source_type if source_type in ("article", "paper", "image", "data", "code") else self._classify_source(fname)[0]
                lib_path = f"library/{file_category}/{date_folder}/{fname}"

                # For images: embed the image in the wiki page
                if is_image:
                    image_section = f"\n![{fname}](../../{lib_path})\n\n> *Image analyzed by AI vision — see description below.*\n\n"
                else:
                    image_section = ""

                vault_src = vault_sources.get(fname, "")
                vault_source_line = f"- Original: `{vault_src}`\n" if vault_src else ""

                # Preserve date_created from existing source page (don't overwrite on re-ingest)
                source_path = f"{base}/wiki/sources/{source_display}.md"
                original_date = date_str
                if self._reader.exists(source_path):
                    try:
                        existing_doc = self._reader.read(source_path)
                        original_date = existing_doc.metadata.get("date_created", date_str)
                    except Exception:
                        pass

                # Register in global bibliography (before source page so we get cite_key)
                cite_key = ""
                if self._bibliography:
                    try:
                        bib_meta = self._parse_bib_metadata(bib_raw, fname, source_type, raw_source_url, date_str) if bib_raw else {
                            "title": Path(fname).stem.replace("-", " ").replace("_", " "),
                            "authors": [],
                            "date": date_str,
                            "source_type": source_type,
                        }
                        if raw_source_url:
                            bib_meta["url"] = raw_source_url
                        bib_entry = self._bibliography.register_from_ingest({
                            **bib_meta,
                            "kb_slug": slug,
                            "source_file": fname,
                            "library_path": lib_path,
                            "content_hash": content_hash,
                            "date_ingested": date_str,
                        })
                        cite_key = bib_entry.get("cite_key", "")
                    except Exception:
                        log.debug("KB %s: bibliography registration failed for %s", slug, fname, exc_info=True)

                # Capture once — reused by _move_to_library so the thematic
                # layout can route the original to the right taxonomy folder.
                source_taxonomy_path = self._classify("source_summary", source_type, fname)
                # Phase 4.5: surface extractor provenance in the source
                # page's frontmatter so the upgrade-proposals surface can
                # tell whether a higher-fidelity tier became available
                # after this source was ingested. Graceful empty fallback
                # when the extractor didn't set these (shouldn't happen
                # post-Phase 1 — registry.extract() always stamps them —
                # but the template renders safely with empty strings).
                _prov = getattr(_extract_result, "provenance", {}) or {}
                _ext_id = str(_prov.get("extractor_id") or "")
                _ext_ver = str(_prov.get("extractor_version") or "")
                _ext_type = str(_prov.get("type_name") or "")
                source_page = self._render_template("source_summary",
                    title=fname, filename=fname, category=source_type,
                    taxonomy_path=source_taxonomy_path,
                    summary=summary, concepts=concepts,
                    date_created=original_date, date_updated=date_str,
                    hash=content_hash,
                    library_path=f"../../{lib_path}",
                    source_url=raw_source_url,
                    image_section=image_section,
                    vault_source=vault_src,
                    vault_source_line=vault_source_line,
                    cite_key=cite_key,
                    content_language=content_lang,
                    output_language=output_lang,
                    language_policy=_policy,
                    language_policy_source=_policy_source,
                    language_adherence=_language_check.get("status", "skipped"),
                    secondary_output_language=(
                        _secondary if _is_bilingual else ""
                    ),
                    type_name=_ext_type,
                    extractor_id=_ext_id,
                    extractor_version=_ext_ver,
                )

                # For images: append raw OCR text and vision description to the source page
                if is_image and source_text:
                    source_page += "\n\n## Extracted Content\n" + source_text
                self._writer.write(source_path, source_page)
                wiki_updates.append(f"created sources/{source_display}")

                # Process CREATE/UPDATE wiki actions
                created_entities: list[str] = []
                created_concepts: list[str] = []
                if updates:
                    for line in updates.strip().split("\n"):
                        line = line.strip().lstrip("- ")
                        if line.startswith("CREATE "):
                            parts = line[7:].split(":", 1)
                            if len(parts) == 2:
                                page_path = parts[0].strip()
                                desc_and_reason = parts[1].strip()
                                # Parse optional REASON
                                created_reason = ""
                                if "| REASON:" in desc_and_reason:
                                    description, _, created_reason = desc_and_reason.partition("| REASON:")
                                    description = description.strip()
                                    created_reason = created_reason.strip()
                                else:
                                    description = desc_and_reason
                                # Normalize slug: strip type prefixes, remap invalid subdirs
                                subdir, raw_slug = page_path.rsplit("/", 1) if "/" in page_path else ("concepts", page_path)
                                _INGEST_REMAP = {"events": "concepts", "meetings": "concepts", "people": "entities",
                                                 "organizations": "entities", "orgs": "entities", "references": "sources"}
                                subdir = _INGEST_REMAP.get(subdir, subdir)
                                # Reject placeholder slugs from prompt examples
                                _PLACEHOLDER_SLUGS = {"person-slug", "topic-slug", "slug", "org-slug", "existing-slug"}
                                if raw_slug in _PLACEHOLDER_SLUGS or page_path.split("/")[-1] in _PLACEHOLDER_SLUGS:
                                    continue
                                for prefix in ("person-", "org-", "organization-", "company-", "concept-", "entity-"):
                                    if raw_slug.startswith(prefix):
                                        raw_slug = raw_slug[len(prefix):]
                                        break
                                page_path = f"{subdir}/{raw_slug}"

                                # Project auto-routing: if source or description mentions a known project,
                                # route to wiki/projects/{project}/{subdir}/ instead of global
                                matched_project = None
                                if known_projects:
                                    source_lower = source_text.lower()
                                    desc_lower = description.lower()
                                    for proj_slug, proj_title in known_projects.items():
                                        proj_words = proj_slug.replace("-", " ")
                                        if proj_words in source_lower or proj_words in desc_lower:
                                            matched_project = proj_slug
                                            break
                                if matched_project:
                                    page_path = f"projects/{matched_project}/{subdir}/{raw_slug}"
                                    # Ensure project subdir exists
                                    proj_subdir = self._resolve(f"{base}/wiki/projects") / matched_project / subdir
                                    proj_subdir.mkdir(parents=True, exist_ok=True)

                                # Clean the description before using it as a
                                # filename: LLMs bleed chain-of-thought, trailing
                                # periods, and full sentences into what's supposed
                                # to be a short title. Result in prior sessions:
                                # filenames like "A type of AI-powered rocket
                                # technology. REASON This concept is crucial..."
                                # Truncate at the first sentence boundary (period,
                                # colon, semicolon) and strip any leaked markers.
                                _clean = description
                                for _marker in ("REASON:", "REASON ", "TODO:", "NOTE:", "—"):
                                    if _marker in _clean:
                                        _clean = _clean.split(_marker, 1)[0].strip()
                                for _stop in (". ", "; ", ": "):
                                    if _stop in _clean:
                                        _clean = _clean.split(_stop, 1)[0].strip()
                                _clean = _clean.rstrip(".,;:").strip()
                                # Hard cap: 60 chars. Real titles fit well under this.
                                if len(_clean) > 60:
                                    _clean = _clean[:60].rsplit(" ", 1)[0]
                                # Reject the whole CREATE if it's LLM prose leakage
                                # (placeholder, leading article, chain-of-thought,
                                # too many words). This is the same junk filter used
                                # by the entity/concept parsers — one rule, one truth.
                                if not _clean or self._is_junk_name(_clean):
                                    log.info(
                                        "KB %s: compile rejecting junk CREATE description: %s",
                                        slug, description[:80],
                                    )
                                    self._log_activity(
                                        "compile_rejected_junk_title",
                                        f"Rejected junk article title in KB {slug}",
                                        {
                                            "slug": slug,
                                            "page_path": page_path,
                                            "raw_description": description[:200],
                                            "cleaned": _clean,
                                        },
                                        importance="low",
                                    )
                                    continue
                                description = _clean

                                # Determine entity type from path
                                etype = "person" if "entities/" in page_path else "concept"
                                if any(kw in description.lower() for kw in ("company", "organization", "institution", "firm")):
                                    etype = "organization"
                                # Use display-name filename, keep slug for dedup and API
                                display_name = title_to_filename(description)
                                full_path = f"{base}/wiki/{subdir}/{display_name}.md"
                                if not self._reader.exists(full_path):
                                    alt_exists = False
                                    try:
                                        # Words too generic for meaningful dedup
                                        _SLUG_STOPWORDS = {
                                            "analysis", "assessment", "management", "system", "systems",
                                            "process", "review", "report", "data", "control", "overview",
                                            "general", "safety", "security", "tracking", "standard",
                                        }
                                        new_words = {w for w in raw_slug.split("-") if len(w) >= 4 and w not in _SLUG_STOPWORDS}
                                        for existing_file in self._reader.list_files(f"{base}/wiki/{subdir}", "*.md"):
                                            if "/.history/" in str(existing_file).replace("\\", "/") or "\\.history\\" in str(existing_file):
                                                continue
                                            existing_slug = existing_file.stem
                                            # 1. Substring match (archer-futura ⊂ archer-futura-technologies)
                                            if raw_slug in existing_slug or existing_slug in raw_slug:
                                                alt_exists = True
                                                log.info("KB %s: dedup %s — substring of %s", slug, page_path, existing_slug)
                                                break
                                            # 2. Word overlap (bowtie-analysis ~ bowtie-methodology)
                                            if new_words:
                                                existing_words = {w for w in existing_slug.split("-") if len(w) >= 4 and w not in _SLUG_STOPWORDS}
                                                overlap = new_words & existing_words
                                                # If they share a significant word AND both are short slugs, likely same topic
                                                if overlap and (len(overlap) / max(len(new_words), 1) >= 0.5
                                                               or len(overlap) / max(len(existing_words), 1) >= 0.5):
                                                    alt_exists = True
                                                    log.info("KB %s: dedup %s — word overlap with %s (shared: %s)",
                                                             slug, page_path, existing_slug, ", ".join(overlap))
                                                    break
                                    except Exception:
                                        pass
                                    # Also check against pages created in THIS batch
                                    if not alt_exists:
                                        batch_slugs = [p.rsplit("/", 1)[-1] for p in created_entities + created_concepts]
                                        for bs in batch_slugs:
                                            if raw_slug in bs or bs in raw_slug:
                                                alt_exists = True
                                                log.info("KB %s: dedup %s — substring of batch page %s", slug, page_path, bs)
                                                break
                                            bs_words = {w for w in bs.split("-") if len(w) >= 4 and w not in _SLUG_STOPWORDS}
                                            if new_words and bs_words:
                                                overlap = new_words & bs_words
                                                if overlap and (len(overlap) / max(len(new_words), 1) >= 0.5
                                                               or len(overlap) / max(len(bs_words), 1) >= 0.5):
                                                    alt_exists = True
                                                    log.info("KB %s: dedup %s — word overlap with batch page %s (shared: %s)",
                                                             slug, page_path, bs, ", ".join(overlap))
                                                    break
                                    if alt_exists:
                                        continue
                                    page = self._render_template(etype,
                                        title=description, category=source_type,
                                        taxonomy_path=self._classify(etype, source_type, description),
                                        sources=json.dumps([fname]),
                                        source_list=f"- [[{source_display}|{fname}]]",
                                        date=date_str,
                                        provenance="compiled",
                                    )
                                    # Inject slug + created_reason into frontmatter
                                    if "---" in page:
                                        fm_parts = page.split("---", 2)
                                        if len(fm_parts) >= 3:
                                            fm_parts[1] = fm_parts[1].rstrip() + f'\nslug: "{raw_slug}"\n'
                                            if created_reason:
                                                fm_parts[1] = fm_parts[1].rstrip() + f'\ncreated_reason: "{created_reason}"\n'
                                            page = "---".join(fm_parts)
                                    self._writer.write(full_path, page)
                                    wiki_updates.append(f"created {page_path}")
                                    # Track for index update
                                    if page_path.startswith("entities/"):
                                        created_entities.append(page_path)
                                    elif page_path.startswith("concepts/"):
                                        created_concepts.append(page_path)

                        elif line.startswith("UPDATE "):
                            parts = line[7:].split(":", 1)
                            if len(parts) == 2:
                                page_path = parts[0].strip()
                                note = parts[1].strip()
                                full_path = f"{base}/wiki/{page_path}.md"
                                if self._reader.exists(full_path):
                                    # Insert the update marker BEFORE any existing
                                    # ## References block so _numberify_sources can
                                    # cleanly strip + rebuild references. Appending
                                    # at EOF would orphan the new [Source: ...]
                                    # behind the old reference list.
                                    marker = (
                                        f"\n\n> **Update from [[{fname}]]**: "
                                        f"{note} [Source: {fname}]\n"
                                    )
                                    try:
                                        existing_raw = self._reader.read(full_path).raw
                                    except Exception:
                                        existing_raw = ""
                                    ref_match = self._REFERENCES_BLOCK_RE.search(existing_raw) if existing_raw else None
                                    if ref_match:
                                        updated_raw = (
                                            existing_raw[: ref_match.start()].rstrip()
                                            + marker
                                            + existing_raw[ref_match.start():]
                                        )
                                        self._writer.write(full_path, updated_raw)
                                    else:
                                        self._writer.append(full_path, marker)
                                    # Re-run the numbering pass so [Source: fname] becomes [N]
                                    # and the ## References section stays consistent.
                                    self._renumberify_article(full_path, [fname], slug)
                                    wiki_updates.append(f"updated {page_path}")

                # Add created entity/concept pages to index
                if created_entities:
                    new = [p for p in created_entities if f"[[{p}]]" not in index_content]
                    if new:
                        entry = "\n".join(f"- [[{p}]]" for p in new)
                        index_content = index_content.replace(
                            "<!-- Entity pages:",
                            f"<!-- Entity pages:\n{entry}",
                        )
                if created_concepts:
                    new = [p for p in created_concepts if f"[[{p}]]" not in index_content]
                    if new:
                        entry = "\n".join(f"- [[{p}]]" for p in new)
                        index_content = index_content.replace(
                            "<!-- Concept pages:",
                            f"<!-- Concept pages:\n{entry}",
                        )

                # Log contradictions (source page may not exist yet)
                if contradictions and contradictions != "none found":
                    try:
                        self._writer.append(f"{base}/wiki/sources/{source_slug}.md",
                            f"\n## Contradictions Detected\n{contradictions}\n")
                    except FileNotFoundError:
                        log.debug("KB %s: source page %s not found for contradiction log", slug, source_slug)

                # Update index — skip if this source is already listed
                dest_path = f"library/{source_type}/{date_folder}/{fname}"
                if f"### [{fname}]" not in index_content:
                    entry = f"\n### [{fname}]({dest_path})\n- Type: {source_type}\n- Summary: {summary}\n- Hash: {content_hash}\n"
                    index_content = index_content.replace(
                        "<!-- Each source: link, one-line summary, date ingested -->",
                        f"<!-- Each source: link, one-line summary, date ingested -->\n{entry}",
                    )

                # Record full provenance — only pages created by THIS source
                lib_path = f"library/{source_type}/{date_folder}/{fname}"
                file_pages = [f"sources/{source_slug}"]
                file_pages.extend(created_entities)
                file_pages.extend(created_concepts)
                # Also count updates (not just creates)
                file_pages.extend(u.split(" ", 1)[-1] for u in wiki_updates if u.startswith("updated ") and fname in u)
                vault_src = vault_sources.get(fname, "")
                self._record_source(
                    state, fname,
                    content_hash=content_hash,
                    vault_source=vault_src,
                    source_type=source_type,
                    library_path=lib_path,
                    produced_pages=file_pages,
                    agent="librarian",
                )
                ingested.append(fname)
                self._move_to_library(
                    raw_file, base, fname, date_folder,
                    slug=slug, taxonomy_path=source_taxonomy_path,
                )
                # Atomic save: index FIRST (so it's never behind state), then state
                self._writer.write(f"{base}/wiki/.index.md", index_content)
                self._write_compile_state(slug, state)

                log.info("KB %s: ingested %s (hash %s, %d pages)", slug, fname, content_hash, len(file_pages))

                # ── Observability: per-file activity + decision + graph population ──
                # This is the single point where one source transitions from
                # "raw file in inbox" to "durable knowledge". Rule #13 requires
                # it to produce structured evidence that the agent can see.
                _graph_result = self._populate_graph_from_ingest(
                    source_file=fname,
                    source_slug=source_slug,
                    entities_block=entities_block,
                    concepts_block=concepts,
                    kb_slug=slug,
                    source_text=source_text,
                )

                # Phase 5 v2 — multi-modal graph: register figure entities
                # and emit `depicts` triples for every embedded image that
                # produced a vision description in this file's ExtractResult.
                # Piggybacks on the same kb_slug / source_slug identity as
                # the classify-LLM triples above. No-op when the opt-in
                # flag was disabled (no Image elements in the result) OR
                # when the vision call didn't produce a description (image
                # element stays, description paragraph absent → only the
                # structural ``part_of`` triple is emitted).
                try:
                    _image_graph = self._populate_graph_from_images(
                        source_slug=source_slug,
                        source_file=fname,
                        kb_slug=slug,
                        extract_result=_extract_result,
                    )
                    if _image_graph.get("triples"):
                        log.info(
                            "KB %s: image-graph %s → %d figures, %d depicts, %d part_of",
                            slug, fname,
                            _image_graph["figures"],
                            _image_graph["depicts_triples"],
                            _image_graph["part_of_triples"],
                        )
                except Exception:
                    log.debug(
                        "kb: _populate_graph_from_images failed for %s",
                        fname, exc_info=True,
                    )

                # Empty-extraction retry: when the LLM produced zero usable
                # entities/concepts AND the regex fallback also found nothing
                # (or produced a weak result), escalate the model tier and
                # re-enqueue via the persistent work queue. Same pattern as
                # language-drift retry: at most one escalation per file per
                # reason, guarded by `_empty_extraction_retry` on source_meta.
                # The work queue owns retry lifecycle — infinite retry with
                # exponential backoff, survives server restarts.
                _already_empty_retried = (
                    isinstance(source_meta, dict)
                    and source_meta.get("_empty_extraction_retry")
                )
                if (
                    _graph_result.get("empty_extraction")
                    and not _graph_result.get("used_fallback")
                    and not _already_empty_retried
                    and not self._called_from_queue
                    and self._work_queue
                ):
                    _escalation_map = {"fast": "capable", "capable": "reasoning"}
                    _next_tier = _escalation_map.get(_classify_tier)
                    if _next_tier:
                        try:
                            asyncio.create_task(self._work_queue.enqueue(
                                "ingest",
                                {
                                    "slug": slug,
                                    "reason": "empty_extraction",
                                    "empty_extraction_tier": _next_tier,
                                    "target_file": fname,
                                },
                                deduplicate=True,
                            ))
                            log.info(
                                "KB %s: queued ingest retry at tier %s due to "
                                "empty entity/concept extraction for %s",
                                slug, _next_tier, fname,
                            )
                            self._log_activity(
                                "ingest_empty_extraction_retry_queued",
                                f"{fname} had no usable entities — retrying at tier {_next_tier}",
                                {
                                    "slug": slug,
                                    "file": fname,
                                    "next_tier": _next_tier,
                                    "previous_tier": _classify_tier,
                                },
                                importance="medium",
                            )
                        except Exception:
                            log.debug(
                                "Failed to enqueue empty-extraction retry",
                                exc_info=True,
                            )
                    else:
                        # Already at highest tier — record defeat so the
                        # user sees it in the decisions log, but don't
                        # keep spinning. Manual re-ingest or model change
                        # is the only recourse.
                        log.warning(
                            "KB %s: empty extraction at top tier (%s) for %s — "
                            "giving up retry, file landed in graph with regex fallback only",
                            slug, _classify_tier, fname,
                        )
                        self._log_activity(
                            "ingest_empty_extraction_exhausted",
                            f"{fname} still produced no entities at tier {_classify_tier}",
                            {"slug": slug, "file": fname, "top_tier": _classify_tier},
                            importance="high",
                        )

                # All language variables are pre-initialized with safe
                # defaults at the top of the per-file block, so they're
                # always defined here — no need for locals().get().
                _drift_status = _language_check.get("status", "unknown")
                self._log_activity(
                    "ingested",
                    f"KB {slug}: ingested {fname}",
                    {
                        "slug": slug,
                        "file": fname,
                        "source_type": source_type,
                        "content_hash": content_hash,
                        "pages": len(file_pages),
                        "content_language": content_lang,
                        "output_language": output_lang,
                        "language_adherence": _drift_status,
                        "entities_added": _graph_result.get("entities", 0),
                        "concepts_added": _graph_result.get("concepts", 0),
                        "triples_added": _graph_result.get("triples", 0),
                    },
                    importance="medium",
                )
                await self._record_decision(
                    action_type="ingest_file",
                    action_target=f"kb/{slug}/{fname}",
                    rationale=(
                        f"Classified as {source_type}; policy={_policy} "
                        f"({_policy_source}); adherence={_drift_status}; "
                        f"extracted {_graph_result.get('entities', 0)} entities "
                        f"+ {_graph_result.get('concepts', 0)} concepts"
                    ),
                    details={
                        "slug": slug,
                        "file": fname,
                        "source_type": source_type,
                        "content_hash": content_hash,
                        "pages": len(file_pages),
                        "content_language": content_lang,
                        "output_language": output_lang,
                        "language_policy": _policy,
                        "language_policy_source": _policy_source,
                        "language_adherence": _drift_status,
                        "model_tier": _classify_tier,
                        "graph": _graph_result,
                    },
                )

            except Exception as _crash_exc:
                # Safety net: an unexpected failure processing ONE file
                # must never kill the entire batch. Log, record a failed
                # decision, continue to the next file.
                log.exception(
                    "KB %s: unexpected failure processing %s — skipping file",
                    slug, raw_file.name,
                )
                self._log_activity(
                    "ingest_file_crashed",
                    f"Ingest crashed on {raw_file.name} in KB {slug}: {type(_crash_exc).__name__}",
                    {
                        "slug": slug,
                        "file": raw_file.name,
                        "error": str(_crash_exc)[:500],
                        "error_type": type(_crash_exc).__name__,
                    },
                    importance="high",
                )
                await self._record_decision(
                    action_type="ingest_file",
                    action_target=f"kb/{slug}/{raw_file.name}",
                    contract_result="failed",
                    rationale=f"Unexpected {type(_crash_exc).__name__} during ingest",
                    details={
                        "slug": slug,
                        "file": raw_file.name,
                        "error": str(_crash_exc)[:500],
                    },
                )
                continue
        if ingested:
            index_content = self._update_frontmatter(index_content, "updated", date_str)
            self._writer.write(f"{base}/wiki/.index.md", index_content)
            self._update_index_stats(slug)
            state["last_ingest"] = now.isoformat()
            self._write_compile_state(slug, state)
            self._append_log(slug, "ingest", f"{len(ingested)} sources: {', '.join(ingested)}")

        return {
            "slug": slug,
            "ingested": ingested,
            "skipped": skipped,
            "wiki_updates": wiki_updates,
            "count": len(ingested),
        }

    async def _describe_image(self, image_path: Path, ocr_text: str = "") -> str:
        """Use vision LLM to describe an image. Returns text description."""
        import base64

        from agntspace.llm.base import ImageContent

        # Read and encode image
        raw_bytes = image_path.read_bytes()
        b64 = base64.b64encode(raw_bytes).decode()

        # Determine media type
        ext = image_path.suffix.lower()
        media_types = {
            ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
            ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
        }
        media_type = media_types.get(ext, "image/png")

        image = ImageContent(base64_data=b64, media_type=media_type)

        prompt_parts = [
            "Describe this image in detail for a knowledge base. Include:",
            "1. What the image shows (diagram, photo, screenshot, chart, etc.)",
            "2. Any text, labels, or annotations visible",
            "3. Key information that should be indexed",
            "4. People, organizations, or concepts depicted",
            "5. How this relates to a knowledge base context",
            "",
            "Be thorough — this description replaces the image for text-based search and wiki compilation.",
        ]
        if ocr_text:
            prompt_parts.append(
                f"\n\nOCR has already extracted the following text from this image. "
                f"Use it as ground truth for any text visible, and focus your description "
                f"on visual elements, layout, and context that OCR cannot capture:\n\n{ocr_text}"
            )

        response = await self._llm.complete_vision(
            prompt="\n".join(prompt_parts),
            images=[image],
            max_tokens=1024,
        )

        log.info("Image described via vision LLM: %s (%d chars)", image_path.name, len(response.content))
        return response.content

    async def _resolve_embedded_images(
        self,
        source_text: str,
        raw_file: Path,
        base: str,
        slug: str,
    ) -> list:
        """Find and resolve image references inside a markdown source file.

        Resolves both local sibling images (``![alt](photo.png)``,
        ``![[Pasted image.png]]``) and remote URLs (``![alt](https://...)``).
        Remote images are downloaded into the KB inbox so they travel
        through the normal ingest pipeline.

        Returns a list of ``ResolvedImage`` objects.
        """
        try:
            from agntspace.kb.image_resolver import resolve_all_images
        except ImportError:
            return []

        source_dir = raw_file.parent
        inbox_dir = self._resolve(f"{base}/inbox")

        # Extra search paths: the KB inbox itself (watcher may have copied
        # siblings there) and the vault root (in case the user's folder
        # structure places images at the root level).
        search_dirs = [inbox_dir]
        vault_dir = getattr(self._reader, "_vault_dir", None)
        if vault_dir and Path(vault_dir).is_dir():
            search_dirs.append(Path(vault_dir))

        try:
            return resolve_all_images(
                source_text,
                source_dir,
                dest_dir=inbox_dir,
                search_dirs=search_dirs,
                max_remote=10,
                download_timeout=15,
            )
        except Exception:
            log.debug("Image resolution failed for %s", raw_file.name, exc_info=True)
            return []

    async def _describe_embedded_image(self, resolved_image) -> str:
        """Run OCR + vision LLM on a resolved embedded image.

        Mirrors the direct-image-file pipeline (OCR → vision) but for
        images found via markdown references rather than as standalone
        inbox files.  Returns the description text, or empty string on
        failure.
        """
        image_path = resolved_image.local_path
        if not image_path or not image_path.exists():
            return ""

        try:
            # OCR pass (optional — graceful when Tesseract not installed)
            ocr_text = ""
            try:
                from agntspace.llm.ocr import extract_text as ocr_extract
                ocr_text = ocr_extract(image_path)
            except Exception:
                pass

            # Vision LLM pass
            description = await self._describe_image(image_path, ocr_text=ocr_text)
            return description
        except Exception:
            log.debug(
                "Could not describe embedded image %s",
                image_path.name, exc_info=True,
            )
            return ""

    # Max destination path length for thematic layout — Windows MAX_PATH is
    # 260, leave headroom for filename + separators. Long taxonomy paths fall
    # back to ``unsorted/`` with a high-importance activity event so the user
    # sees the degradation instead of silent truncation.
    _MAX_LIBRARY_PATH = 240  # arch:deterministic — Windows MAX_PATH safety margin

    def _sanitize_taxonomy_segment(self, segment: str) -> str:
        """Reduce one taxonomy slug to a safe folder name.

        The classifier returns lowercase-hyphenated slugs from taxonomy.yaml,
        but LLM paths sometimes leak stray whitespace, accented characters,
        or path separators. Keep only [a-z0-9_-], collapse everything else
        to ``-``, strip leading/trailing punctuation. Empty results are
        rejected by the caller (folds into ``unsorted/``).
        """
        import re
        s = str(segment or "").strip().lower()
        s = re.sub(r"[^a-z0-9_-]+", "-", s)
        return s.strip("-_")

    def _resolve_library_dir(
        self,
        base: str,
        filename: str,
        date_folder: str,
        layout: str,
        taxonomy_path: str | None,
        source_type: str,
    ) -> tuple[Path, str]:
        """Resolve the target library directory for one source file.

        Returns ``(dest_dir, fallback_reason)``. ``fallback_reason`` is an
        empty string on the happy path; otherwise it names why the thematic
        layout degraded (``no_path``, ``invalid_segments``, ``path_too_long``).
        Callers emit a medium/high activity event when fallback_reason is set.
        """
        library_root = self._resolve(f"{base}/library")

        if layout == "flat":
            return library_root, ""

        if layout == "type-date":
            return library_root / source_type / date_folder, ""

        # thematic
        if not taxonomy_path or taxonomy_path.strip().lower() in ("", "general", "unsorted"):
            return library_root / "unsorted", "no_path"

        segments = [
            self._sanitize_taxonomy_segment(p)
            for p in str(taxonomy_path).replace("\\", "/").split("/")
            if p.strip() and p.strip() != ".."
        ]
        segments = [s for s in segments if s]
        if not segments:
            return library_root / "unsorted", "invalid_segments"

        dest_dir = library_root.joinpath(*segments)
        # Windows MAX_PATH safety — full dest including filename must fit.
        full_len = len(str(dest_dir / filename))
        if full_len > self._MAX_LIBRARY_PATH:
            return library_root / "unsorted", "path_too_long"

        return dest_dir, ""

    def _move_to_library(
        self,
        raw_file: Path,
        base: str,
        filename: str,
        date_folder: str,
        *,
        slug: str | None = None,
        taxonomy_path: str | None = None,
        layout: str | None = None,
    ) -> None:
        """Move a source file out of inbox into the KB's library/.

        Layout resolution order: explicit ``layout`` param → SCHEMA.md via
        ``slug`` → hardcoded ``type-date`` fallback. Existing KBs without
        ``library_layout`` in their SCHEMA.md stay on ``type-date`` so
        their on-disk arrangement is untouched (no-surprises guarantee).
        """
        import shutil
        stype, _ = self._classify_source(filename)

        # Resolve effective layout mode
        effective_layout = (layout or "").strip().lower()
        if effective_layout not in self._VALID_LIBRARY_LAYOUTS:
            effective_layout = self._kb_library_layout(slug) if slug else "type-date"

        dest_dir, fallback_reason = self._resolve_library_dir(
            base=base,
            filename=filename,
            date_folder=date_folder,
            layout=effective_layout,
            taxonomy_path=taxonomy_path,
            source_type=stype,
        )

        if fallback_reason:
            # Emit a visible event so thematic-layout degradation is
            # never silent. ``path_too_long`` is worth escalating to
            # ``medium`` because the wiki UI can't surface it otherwise.
            importance = "medium" if fallback_reason == "path_too_long" else "low"
            self._log_activity(
                "library_layout_fallback",
                f"{filename}: thematic layout fell back to unsorted/ ({fallback_reason})",
                {
                    "slug": slug or "",
                    "file": filename,
                    "layout": effective_layout,
                    "taxonomy_path": taxonomy_path or "",
                    "reason": fallback_reason,
                },
                importance=importance,
            )

        dest_dir.mkdir(parents=True, exist_ok=True)
        src = raw_file if isinstance(raw_file, Path) else Path(str(raw_file))
        dest = dest_dir / filename
        try:
            # shutil.move fails on Windows if dest exists. Remove it first
            # (we've already processed this content, so overwriting is correct).
            if dest.exists():
                dest.unlink()
            shutil.move(str(src), str(dest))
        except Exception as exc:
            log.warning("KB: failed to move %s to library: %s", filename, exc)
            # Last resort: if the file is still in the inbox after a failed move,
            # delete it so it doesn't get re-ingested forever.
            if src.exists() and dest.exists():
                try:
                    src.unlink()
                    log.info("KB: removed stale inbox copy of %s (already in library)", filename)
                except Exception:
                    log.exception("KB: could not remove stale inbox copy of %s", filename)
        # Clean up sidecar .source file if it exists
        source_meta = src.parent / f"{filename}.source"
        if source_meta.exists():
            try:
                source_meta.unlink()
            except Exception:
                pass

    # ── Library layout management ──

    async def park_unsupported_file(self, slug: str, src_path: Path, reason: str) -> Path | None:
        """Move an unsupported/oversized inbox file to library/unsupported/.

        Writes a sidecar ``.reason.txt`` next to the file explaining WHY
        it was skipped, and emits a medium-importance activity event so
        the user sees it in /decisions. Collisions get a numeric suffix.

        Returns the parked destination path on success, None on failure.
        """
        import shutil
        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/SCHEMA.md"):
            if slug == "general":
                try:
                    self.create_kb("General")
                except Exception:
                    log.warning("park_unsupported_file: could not auto-create 'general' KB")
                    return None
            else:
                log.warning("park_unsupported_file: KB %s not found", slug)
                return None

        dest_dir = self._resolve(f"{base}/library/unsupported")
        dest_dir.mkdir(parents=True, exist_ok=True)
        dest = dest_dir / src_path.name
        if dest.exists() and dest.resolve() != src_path.resolve():
            stem, ext = src_path.stem, src_path.suffix
            for i in range(1, 100):
                alt = dest_dir / f"{stem}-{i}{ext}"
                if not alt.exists():
                    dest = alt
                    break

        try:
            with self._writer.trusted_scope("unsupported_file_parked"):
                shutil.move(str(src_path), str(dest))
        except Exception as exc:
            log.warning("park_unsupported_file: move failed for %s: %s", src_path.name, exc)
            return None

        sidecar = dest.with_suffix(dest.suffix + ".reason.txt")
        try:
            sidecar.write_text(self._format_unsupported_reason(reason, src_path.name), encoding="utf-8")
        except OSError:
            pass

        importance = "medium" if reason.startswith("oversized:") else "low"
        self._log_activity(
            "ingest_skipped_unsupported",
            f"Parked {src_path.name} to library/unsupported/ ({reason})",
            {
                "slug": slug,
                "file": src_path.name,
                "reason": reason,
                "parked_to": str(dest).replace("\\", "/"),
            },
            importance=importance,
        )
        return dest

    def _format_unsupported_reason(self, reason: str, filename: str) -> str:
        """Build the human-readable .reason.txt body for a parked file."""
        from datetime import datetime
        now = datetime.now().isoformat(timespec="seconds")
        lines = [
            "# File parked: not supported by the ingest pipeline",
            "",
            f"File: {filename}",
            f"Parked at: {now}",
            "",
        ]
        if reason.startswith("oversized:"):
            try:
                size = int(reason.split(":", 1)[1])
                mb = size / 1024 / 1024
                lines.append(
                    f"Reason: file size {mb:.1f} MB exceeds the current 10 MB ingest cap."
                )
                lines.append("")
                lines.append(
                    "Options: compress / chunk the file, "
                    "or raise the cap via the kb-ingest skill config."
                )
            except ValueError:
                lines.append(f"Reason: oversized ({reason})")
        elif reason.startswith("unsupported_type:"):
            ext = reason.split(":", 1)[1]
            lines.append(
                f"Reason: extension '{ext}' is not in the supported list."
            )
            lines.append("")
            lines.append(
                "Supported: .md .txt .html .pdf .png .jpg .jpeg .csv .json .jsonl .vtt .srt"
            )
            lines.append(
                f"Options: convert to a supported type, "
                f"or request plugin support for '{ext}' files."
            )
        else:
            lines.append(f"Reason: {reason}")
        lines.append("")
        return "\n".join(lines)

    def set_library_layout(self, slug: str, layout: str) -> dict:
        """Set the library_layout field in SCHEMA.md. Creates the field if missing.

        Does NOT reorganize existing files — call reorganize_library() for that.
        Returns {layout, previous, ok}.
        """
        layout = str(layout or "").strip().lower()
        if layout not in self._VALID_LIBRARY_LAYOUTS:
            return {"error": f"invalid layout: {layout}",
                    "valid": sorted(self._VALID_LIBRARY_LAYOUTS)}
        schema_path = f"knowledge/{slug}/SCHEMA.md"
        if not self._reader.exists(schema_path):
            return {"error": f"SCHEMA.md not found for KB {slug}"}
        previous = self._kb_library_layout(slug)
        doc = self._reader.read(schema_path)
        meta = dict(doc.metadata or {})
        meta["library_layout"] = layout
        # Reassemble frontmatter + body. Use python-frontmatter via VaultWriter
        # when available; fall back to manual assembly.
        import frontmatter as _fm
        post = _fm.Post(doc.content, **meta)
        new_text = _fm.dumps(post) + "\n"
        self._writer.write(schema_path, new_text, trust_reason="library_layout_change")
        self._log_activity(
            "library_layout_changed",
            f"KB {slug}: library_layout {previous} → {layout}",
            {"slug": slug, "previous": previous, "new": layout},
            importance="medium",
        )
        return {"layout": layout, "previous": previous, "ok": True}

    def _source_taxonomy_for_file(self, slug: str, filename: str) -> str | None:
        """Look up the taxonomy_path recorded on the source_summary page
        for a library file, by filename stem. Returns None when no source
        page references this file.
        """
        stem = Path(filename).stem
        sources_dir = self._resolve(f"knowledge/{slug}/wiki/sources")
        if not sources_dir.is_dir():
            return None
        # Try exact stem match first (source pages usually share the stem)
        for ext in (".md",):
            candidate = sources_dir / f"{stem}{ext}"
            if candidate.is_file():
                try:
                    doc = self._reader.read(f"knowledge/{slug}/wiki/sources/{candidate.name}")
                    tp = str(doc.metadata.get("taxonomy_path") or "").strip()
                    return tp or None
                except Exception:
                    pass
        # Fallback: scan source pages for one referencing this filename
        try:
            for src_page in sources_dir.rglob("*.md"):
                try:
                    doc = self._reader.read(f"knowledge/{slug}/wiki/sources/{src_page.name}")
                    if doc.metadata.get("filename") == filename:
                        tp = str(doc.metadata.get("taxonomy_path") or "").strip()
                        return tp or None
                except Exception:
                    continue
        except Exception:
            pass
        return None

    async def reorganize_library(self, slug: str, dry_run: bool = True) -> dict:
        """Reorganize existing library/ files to match the KB's current layout.

        Walks every file under library/ (skipping .archives, .quarantine,
        .history), computes the target path for each under the current
        layout + recorded taxonomy_path, and returns a list of moves.
        When ``dry_run`` is False, the moves execute and per-file activity
        events are emitted (medium importance).

        This is the "opt-in migration" path. The default layout on existing
        KBs stays ``type-date`` until the user changes it — see the
        ``no-surprises`` contract (CLAUDE.md §1).
        """
        import shutil
        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/SCHEMA.md"):
            return {"error": f"KB {slug} not found"}

        layout = self._kb_library_layout(slug)
        library_root = self._resolve(f"{base}/library")
        if not library_root.is_dir():
            return {"layout": layout, "moves": [], "executed": 0, "errors": []}

        _SKIP_DIRS = {".archives", ".quarantine", ".history", ".tmp"}
        moves: list[dict] = []
        seen_targets: set[str] = set()

        for current_path in library_root.rglob("*"):
            if not current_path.is_file():
                continue
            # Skip files inside protected subdirs
            rel_parts = current_path.relative_to(library_root).parts
            if any(part in _SKIP_DIRS for part in rel_parts):
                continue
            if current_path.name.startswith("."):
                continue

            filename = current_path.name
            source_type, _ = self._classify_source(filename)
            taxonomy_path = self._source_taxonomy_for_file(slug, filename)

            # Use a synthetic date_folder for type-date targets — the original
            # ingest date is unrecoverable without parsing .compile-state.json.
            # We pick the year-month of the file's current mtime as a reasonable
            # proxy; when migrating OUT of type-date to thematic, the path is
            # ignored anyway.
            from datetime import datetime as _dt
            date_folder = _dt.fromtimestamp(current_path.stat().st_mtime).strftime("%Y-%m")

            target_dir, fallback_reason = self._resolve_library_dir(
                base=base,
                filename=filename,
                date_folder=date_folder,
                layout=layout,
                taxonomy_path=taxonomy_path,
                source_type=source_type,
            )
            target = target_dir / filename

            if target.resolve() == current_path.resolve():
                continue  # already in the right place

            move_entry = {
                "from": str(current_path.relative_to(library_root)).replace("\\", "/"),
                "to": str(target.relative_to(library_root)).replace("\\", "/"),
                "taxonomy_path": taxonomy_path or "",
                "fallback_reason": fallback_reason,
                "collision": str(target) in seen_targets or (target.exists() and target != current_path),
            }
            seen_targets.add(str(target))
            moves.append(move_entry)

        if dry_run:
            return {
                "layout": layout,
                "dry_run": True,
                "moves": moves,
                "executed": 0,
                "errors": [],
            }

        # Execute moves. Use trusted_scope so every rename inside passes the
        # VaultWriter guard without individual contract actions.
        executed = 0
        errors: list[dict] = []
        with self._writer.trusted_scope("library_reorganize"):
            for m in moves:
                src = library_root / m["from"]
                dst = library_root / m["to"]
                try:
                    dst.parent.mkdir(parents=True, exist_ok=True)
                    if dst.exists() and dst.resolve() != src.resolve():
                        # Collision — skip to avoid destroying data
                        errors.append({"file": m["from"], "reason": "collision_with_existing"})
                        continue
                    shutil.move(str(src), str(dst))
                    executed += 1
                    self._log_activity(
                        "library_file_moved",
                        f"KB {slug}: {m['from']} → {m['to']}",
                        {"slug": slug, **m},
                        importance="low",
                    )
                except Exception as exc:
                    errors.append({"file": m["from"], "reason": str(exc)[:200]})

        # Clean up empty directories left behind
        for d in sorted(library_root.rglob("*"), key=lambda p: -len(str(p))):
            if d.is_dir() and not any(d.iterdir()):
                try:
                    d.rmdir()
                except OSError:
                    pass

        self._log_activity(
            "library_reorganize_completed",
            f"KB {slug}: reorganized {executed} file(s) to {layout} layout",
            {"slug": slug, "layout": layout, "executed": executed, "errors": len(errors)},
            importance="medium" if executed else "low",
        )
        await self._record_decision(
            action_type="library_reorganize",
            action_target=f"kb/{slug}",
            rationale=f"Reorganized {executed} file(s) to {layout} layout ({len(errors)} errors)",
            details={"slug": slug, "layout": layout, "executed": executed,
                     "errors": errors, "dry_run": False},
        )

        return {
            "layout": layout,
            "dry_run": False,
            "moves": moves,
            "executed": executed,
            "errors": errors,
        }

    # ── Compile ──

    async def compile_wiki(self, slug: str, full: bool = False) -> dict:
        """Compile sources into wiki articles. Incremental by default.

        Wrapped in ``_scoped_pipeline`` so every write issued during
        compile is either authorised (``write_vault``/``modify_wiki``)
        or trusted (``kb_compile``). Same observability contract as
        ``ingest``: activity start/end events, decision record, graph
        refresh at the end.
        """
        if self._orchestrator and hasattr(self._orchestrator, "_active_tasks"):
            self._orchestrator._active_tasks["editor"] = f"Compiling wiki for {slug}"
        self._log_activity(
            "compile_started",
            f"KB {slug}: compile started",
            {"slug": slug, "full": full},
            importance="low",
        )
        try:
            async with self._scoped_pipeline("kb_compile", "compile"):
                result = await self._do_compile_wiki(slug, full=full)
                _count = len(result.get("articles", [])) if isinstance(result, dict) else 0
                self._log_activity(
                    "compile_completed",
                    f"KB {slug}: compile produced {_count} article(s)",
                    {"slug": slug, "count": _count},
                    importance="medium" if _count else "low",
                )
                await self._record_decision(
                    action_type="compile_batch",
                    action_target=f"kb/{slug}",
                    rationale=f"Compile produced {_count} wiki articles",
                    details={"slug": slug, "count": _count, "full": full},
                )
                graph = getattr(self, "_graph", None)
                if graph is not None and hasattr(graph, "refresh_structural_triples"):
                    try:
                        graph.refresh_structural_triples()
                    except Exception:
                        log.debug("kb: refresh_structural_triples failed", exc_info=True)
                return result
        except Exception as exc:
            self._log_activity(
                "compile_failed",
                f"KB {slug}: compile raised {type(exc).__name__}",
                {"slug": slug, "error": str(exc)[:500]},
                importance="high",
            )
            await self._record_decision(
                action_type="compile_batch",
                action_target=f"kb/{slug}",
                contract_result="failed",
                rationale=f"Compile aborted: {type(exc).__name__}",
                details={"slug": slug, "error": str(exc)[:500]},
            )
            raise
        finally:
            if self._orchestrator and hasattr(self._orchestrator, "_active_tasks"):
                self._orchestrator._active_tasks.pop("editor", None)

    async def _do_compile_wiki(self, slug: str, full: bool = False) -> dict:
        """Internal compile implementation."""
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        self._writer._pipeline_mode = True

        index_doc = self._reader.read(f"{base}/wiki/.index.md")
        schema = self._read_schema(slug)
        state = self._read_compile_state(slug)
        now = datetime.now(UTC)
        date_str = datetime.now().strftime("%Y-%m-%d %H:%M")

        # Read existing articles (summaries only — tiered L1 loading)
        existing = {}
        for subdir in self._get_wiki_subdirs(slug):
            for wf in self._list_wiki_articles(f"{base}/wiki/{subdir}"):
                try:
                    doc = self._reader.read(f"{base}/wiki/{subdir}/{wf.name}")
                    # L1: title + first 200 chars only
                    title = doc.metadata.get("title", wf.stem)
                    existing[f"{subdir}/{wf.stem}"] = f"{title}: {doc.content[:200]}"
                except Exception:
                    continue

        existing_text = "\n".join(f"- {k}: {v}" for k, v in existing.items()) or "No existing articles."

        # Identify stub pages that need filling (3+ TODOs, mostly placeholder)
        stubs = []
        for subdir in self._get_wiki_subdirs(slug):
            for wf in self._list_wiki_articles(f"{base}/wiki/{subdir}"):
                try:
                    doc = self._reader.read(f"{base}/wiki/{subdir}/{wf.name}")
                    todo_count = doc.content.count("[TODO:")
                    if todo_count >= 3:
                        stubs.append(f"{subdir}/{wf.stem}")
                except Exception:
                    continue

        # Read sources — only new since last compile (unless full)
        # Sort by modification time (newest first) so recent ingests get compile priority
        source_files: list[tuple[float, Path]] = []
        last_compile = state.get("last_compile") if not full else None
        for search_dir in [f"{base}/library", f"{base}/inbox"]:
            try:
                for sf in self._reader.list_files(search_dir, "**/*"):
                    if sf.name == ".gitkeep":
                        continue
                    mtime = os.path.getmtime(sf)
                    # Incremental: skip if older than last compile
                    if last_compile:
                        file_time = datetime.fromtimestamp(mtime, tz=UTC).isoformat()
                        if file_time < last_compile:
                            continue
                    source_files.append((mtime, sf))
            except Exception:
                continue

        # Newest sources first — ensures recently ingested content gets compiled
        source_files.sort(key=lambda x: x[0], reverse=True)

        source_texts = []
        for _, sf in source_files:
            try:
                rel = self._to_logical(sf)
                doc = self._reader.read(rel)
                # Prompt injection defense: source summaries were derived
                # from untrusted inbox content. Neutralize injection
                # patterns before feeding them back to the compile LLM.
                # We use neutralize_injection_patterns (not the full
                # envelope) because the compile prompt already structures
                # sources inside a "NEW SOURCES TO PROCESS" section — a
                # second structural envelope would confuse the LLM.
                from agntspace.security.prompt_guard import neutralize_injection_patterns
                safe_content = neutralize_injection_patterns(doc.content[:2500])
                source_texts.append(f"### {sf.name}\n{safe_content}")
            except Exception:
                continue

        if not source_texts and not stubs:
            return {"slug": slug, "articles": [], "count": 0, "message": "No new sources and no stubs to fill."}

        # ── Collect available images for embedding in compiled articles ──
        # Scan wiki/sources/ for image source summaries (category=image).
        # Each one has a library_path and a vision-generated description.
        # We pass this inventory to the compile LLM so it can embed
        # relevant images in the articles it generates.
        image_inventory = self._collect_image_inventory(base)
        image_ctx = ""
        if image_inventory:
            image_lines = []
            for img in image_inventory[:20]:  # cap at 20 images
                image_lines.append(
                    f"- Path: `{img['library_path']}` | "
                    f"Description: {img['description'][:200]}"
                )
            image_ctx = (
                "\n\nAVAILABLE IMAGES (embed in articles where relevant):\n"
                "When an image is relevant to an article, include it with "
                "![descriptive caption](library_path). Place images near the "
                "top of the article or next to the section they illustrate.\n"
                + "\n".join(image_lines)
            )

        # Build user message with specific instructions
        stubs_instruction = ""
        if stubs:
            stubs_list = ", ".join(stubs[:15])
            stubs_instruction = (
                f"\n\nCRITICAL: These {len(stubs)} existing articles are STUBS with only [TODO] placeholders. "
                f"You MUST rewrite them with FULL content extracted from the sources above. "
                f"Output each as an article block. Stubs to fill: {stubs_list}"
            )

        # Build compile prompt: skill-loaded or hardcoded fallback
        dynamic_ctx = (
            f"\nKB SCHEMA:\n{schema[:1500]}\n\n"
            f"WIKI INDEX:\n{index_doc.content[:2000]}\n\n"
            f"KNOWLEDGE TAXONOMY:\n{self._taxonomy_text}\n\n"
            f"EXISTING ARTICLES:\n{existing_text[:5000]}\n\n"
            f"NEW SOURCES TO PROCESS:\n{chr(10).join(source_texts)[:30000]}\n\n"
            f"{image_ctx}\n\n"
            f"Date: {date_str}"
        )
        skill_prompt = self._get_skill_prompt("kb-compile")
        if skill_prompt:
            compile_system = skill_prompt + "\n\n" + dynamic_ctx
        else:
            compile_system = _COMPILE_PROMPT_FALLBACK.format(
                schema=schema[:1500], index=index_doc.content[:2000],
                taxonomy=self._taxonomy_text,
                existing_articles=existing_text[:3000], sources="\n\n".join(source_texts)[:8000],
                date=date_str,
            )
        compile_system += "\n\n" + self._get_skill_rules("kb-compile")
        # Build explicit skip list from existing articles
        existing_slugs = list(existing.keys())
        skip_note = ""
        if existing_slugs:
            skip_note = f" ALREADY EXISTS (do NOT recreate): {', '.join(existing_slugs[:50])}."

        user_msg = (
            "Compile the wiki from sources now. "
            "Output ONLY ```article: blocks — no preamble, no explanation, no markdown outside the blocks. "
            "Start your response immediately with ```article:subdir/slug. "
            "Create NEW articles for entities, concepts, and techniques NOT already covered."
            f"{skip_note}"
            f"{stubs_instruction}"
        )
        # Compile emits 8K tokens which is slow on local models. Timeout
        # aborts the cycle cleanly and enqueues for retry via work queue.
        try:
            response = await asyncio.wait_for(
                self._llm.complete(
                    messages=[Message(role="user", content=user_msg)],
                    system=compile_system,
                    max_tokens=8192,
                    temperature=0.0,
                    model=self._resolve_model("fast"),  # librarian: compilation
                ),
                timeout=self._llm_timeout("compile"),
            )
        except TimeoutError:
            log.warning("KB %s: compile LLM timed out", slug)
            self._writer._pipeline_mode = False
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("compile", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "error": "LLM timed out", "queued": True}
        except Exception as exc:
            log.warning("KB %s: compile LLM failed: %s", slug, exc)
            self._writer._pipeline_mode = False
            if self._called_from_queue:
                raise
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("compile", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "error": f"LLM unavailable: {exc}", "queued": True}

        articles_written = []
        log.info("KB %s compile: LLM response %d chars, contains 'article:': %s",
                 slug, len(response.content), "```article:" in response.content)
        if "```article:" not in response.content:
            # LLM returned text but no fenced article blocks. This is a
            # malformed response — same class as empty extraction during
            # ingest. Log visibly, emit activity event, and enqueue a
            # retry so the work queue can escalate the model tier.
            log.warning(
                "KB %s compile: LLM returned %d chars with no article blocks — enqueueing retry",
                slug, len(response.content),
            )
            log.info("KB %s compile: response preview: %s", slug, response.content[:500])
            self._log_activity(
                "compile_no_article_blocks",
                f"KB {slug} compile LLM returned no article blocks ({len(response.content)} chars)",
                {
                    "slug": slug,
                    "response_chars": len(response.content),
                    "preview": response.content[:300],
                },
                importance="medium",
            )
            if self._called_from_queue:
                raise RuntimeError(f"compile produced no article blocks ({len(response.content)} chars)")
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue(
                    "compile",
                    {"slug": slug, "reason": "no_article_blocks"},
                    deduplicate=True,
                ))
            return {
                "slug": slug,
                "articles": [],
                "count": 0,
                "error": "LLM returned no article blocks",
                "queued": True,
            }
        parts = response.content.split("```article:")
        for part in parts[1:]:
            lines = part.split("\n", 1)
            article_path = lines[0].strip()  # e.g., "entities/person-name" or "concepts/topic"
            rest = lines[1] if len(lines) > 1 else ""
            end = rest.find("```")
            content = rest[:end].strip() if end != -1 else rest.strip()

            if article_path and content:
                # ── Compile quality gate ──
                # Extract body (content after frontmatter) for validation
                _body = content
                if "---" in content:
                    _fm = content.split("---", 2)
                    if len(_fm) >= 3:
                        _body = _fm[2]
                _body_stripped = _body.strip()
                _body_len = len(_body_stripped)

                # Gate 1: minimum body length (200 chars of real content)
                if _body_len < 200:
                    log.info("KB %s: compile skipping %s — body too short (%d chars)", slug, article_path, _body_len)
                    continue

                # Gate 2: must have at least one [Source: ...] citation
                if "[Source:" not in content:
                    log.info("KB %s: compile skipping %s — no [Source:] citations", slug, article_path)
                    continue

                # Gate 3: must have at least one ## heading (structure check)
                if "## " not in _body_stripped:
                    log.info("KB %s: compile skipping %s — no section headings", slug, article_path)
                    continue

                # Remap unknown subdirectories to valid ones
                _VALID_SUBDIRS = {"entities", "concepts", "synthesis", "sources", "decisions", "projects"}
                _SUBDIR_REMAP = {
                    "events": "concepts", "meetings": "concepts", "people": "entities",
                    "organizations": "entities", "orgs": "entities", "references": "sources",
                    "skills": "concepts", "journal": "concepts",
                }
                if "/" in article_path:
                    subdir_part = article_path.split("/")[0]
                    if subdir_part not in _VALID_SUBDIRS:
                        remapped = _SUBDIR_REMAP.get(subdir_part, "concepts")
                        slug_part = article_path.split("/", 1)[1]
                        log.info("KB %s: remapping %s/ → %s/ for %s", slug, subdir_part, remapped, slug_part)
                        article_path = f"{remapped}/{slug_part}"

                # Extract title from article content frontmatter for display-name filename
                article_title = None
                if "---" in content:
                    _fm_parts = content.split("---", 2)
                    if len(_fm_parts) >= 3:
                        for _fm_line in _fm_parts[1].strip().split("\n"):
                            if _fm_line.startswith("title:"):
                                article_title = _fm_line.split(":", 1)[1].strip().strip('"').strip("'")
                                break

                # Use display-name filename if title available, otherwise fall back to slug
                from agntspace.vault.writer import title_to_filename
                if article_title:
                    article_subdir = article_path.rsplit("/", 1)[0] if "/" in article_path else "concepts"
                    display_filename = title_to_filename(article_title)
                    full_path = f"{base}/wiki/{article_subdir}/{display_filename}.md"
                    # Inject slug into frontmatter for API routing
                    article_slug = article_path.rsplit("/", 1)[-1] if "/" in article_path else article_path
                    if "---" in content:
                        _fm_parts = content.split("---", 2)
                        if len(_fm_parts) >= 3 and "slug:" not in _fm_parts[1]:
                            _fm_parts[1] = _fm_parts[1].rstrip() + f"\nslug: \"{article_slug}\"\n"
                            content = "---".join(_fm_parts)
                else:
                    full_path = f"{base}/wiki/{article_path}.md"

                is_stub_replace = False
                # If article exists: stub → replace, real content → enrich (append new info)
                if self._reader.exists(full_path):
                    existing_doc = self._reader.read(full_path)
                    todo_count = existing_doc.content.count("[TODO:")
                    real_content = len(existing_doc.content.replace("[TODO:", "").strip())
                    if todo_count >= 3 or real_content < 300:
                        is_stub_replace = True
                        log.info("KB %s: replacing stub %s (%d TODOs)", slug, article_path, todo_count)
                    else:
                        # Non-stub enrichment: append ONLY genuinely new content
                        new_body = content
                        if "---" in content:
                            fm_parts = content.split("---", 2)
                            if len(fm_parts) >= 3:
                                new_body = fm_parts[2].strip()
                        # Skip if new body is too similar to existing content (>60% overlap)
                        existing_text = existing_doc.content.strip()
                        if new_body and len(new_body) > 100:
                            from difflib import SequenceMatcher
                            similarity = SequenceMatcher(None, existing_text[:3000], new_body[:3000]).ratio()
                            if similarity > 0.6:
                                log.info("KB %s: skipping enrichment for %s — %.0f%% similar to existing", slug, article_path, similarity * 100)
                                continue
                            source_fnames = [sf.split("\n")[0].replace("### ", "").strip() for sf in source_texts]
                            # Use HTML comment separator (not ---) to avoid frontmatter ambiguity
                            update_note = f"\n\n<!-- compile update {date_str} -->\n\n> **Updated from compile ({date_str})** — new sources: {', '.join(source_fnames[:5])}\n\n"
                            self._writer.append(full_path, update_note + new_body)
                            articles_written.append(article_path)
                            log.info("KB %s: enriched %s with new source content (%.0f%% novel)", slug, article_path, (1 - similarity) * 100)

                            # Track enriched articles in compile state + graph (Fix #4)
                            compile_record = state.setdefault("compiled_articles", {})
                            compile_record[article_path] = {
                                "compiled_at": date_str,
                                "derived_from": source_fnames[:20],
                                "agent": "editor",
                                "enriched": True,
                            }
                        continue
                # Dedup: skip for stub replacements (they SHOULD match existing slugs)
                if not is_stub_replace:
                    dup = self._find_similar_article(slug, article_path, content)
                    if dup:
                        log.info("KB %s: compile skipping %s — similar to existing %s", slug, article_path, dup)
                        continue
                # Validate taxonomy_path — LLM may invent nonexistent paths
                if "---" in content and self._skills_dir:
                    _fm_check = content.split("---", 2)
                    if len(_fm_check) >= 3:
                        _tax_path = ""
                        _etype_fm = ""
                        _title_fm = article_title or ""
                        for _fl in _fm_check[1].strip().split("\n"):
                            if _fl.startswith("taxonomy_path:"):
                                _tax_path = _fl.split(":", 1)[1].strip().strip('"').strip("'")
                            elif _fl.startswith("entity_type:"):
                                _etype_fm = _fl.split(":", 1)[1].strip().strip('"').strip("'")
                        if _tax_path:
                            from agntspace.kb.taxonomy import flatten_taxonomy, load_taxonomy
                            _tree = load_taxonomy(self._skills_dir)
                            _known = {n["path"] for n in flatten_taxonomy(_tree)}
                            if _tax_path not in _known:
                                _fixed = self._classify(_etype_fm, "", _title_fm)
                                log.info("KB %s: compile taxonomy fix %s → %s for %s", slug, _tax_path, _fixed, article_path)
                                _fm_check[1] = _fm_check[1].replace(f"taxonomy_path: \"{_tax_path}\"", f"taxonomy_path: \"{_fixed}\"")
                                _fm_check[1] = _fm_check[1].replace(f"taxonomy_path: {_tax_path}", f"taxonomy_path: \"{_fixed}\"")
                                content = "---".join(_fm_check)

                # Inject derived_from with source files that contributed
                source_fnames = [sf.split("\n")[0].replace("### ", "").strip() for sf in source_texts]
                if "---" in content:
                    # Insert derived_from into existing frontmatter
                    parts_fm = content.split("---", 2)
                    if len(parts_fm) >= 3:
                        derived = json.dumps(source_fnames[:20])
                        parts_fm[1] = parts_fm[1].rstrip() + f"\nderived_from: {derived}\ncompiled_by: editor\ncompiled_at: \"{date_str}\"\n"
                        content = "---".join(parts_fm)
                # Post-process: replace [Source: filename] with [N] and append numbered references.
                # NEVER gate on `source_fnames` being non-empty — `_numberify_sources` scans the
                # body itself for `[Source: ...]` markers (kb/service.py:_SOURCE_REF_RE) and uses
                # `source_files` only as a tail for uncited sources. Stubs-only compile batches
                # arrive here with `source_fnames=[]` but the LLM still emits `[Source: X]` in the
                # rewritten stub body — those MUST still be numberified. See research path (line
                # 6336) and synthesize path (line 5761) for the same unconditional pattern.
                if self._bibliography:
                    content = self._numberify_sources(content, source_fnames, slug)

                self._writer.write(full_path, content)
                articles_written.append(article_path)

                # ── Register compiled article in the knowledge graph ──
                # Closes the gap where compile bypassed the epistemic layer.
                # Each article becomes a document node with derived_from triples.
                _graph = getattr(self, "_graph", None)
                if _graph and hasattr(_graph, "add_triple"):
                    try:
                        _article_slug = article_path.rsplit("/", 1)[-1] if "/" in article_path else article_path
                        _article_title = article_title or _article_slug.replace("-", " ").title()
                        # Determine entity type from subdir
                        _subdir = article_path.split("/")[0] if "/" in article_path else "concepts"
                        _etype = {"entities": "person", "concepts": "concept", "synthesis": "document",
                                  "decisions": "decision"}.get(_subdir, "document")
                        # Register the article as a graph entity
                        _graph.registry.add(_article_title, entity_type=_etype, authority="system")
                        # Link article to its source files
                        for _sf in source_fnames[:20]:
                            _graph.add_triple(
                                subject=_article_title,
                                predicate="created_by",
                                obj="editor",
                                authority="system",
                                confidence=0.9,
                                subject_type=_etype,
                                object_type="ai_agent",
                                epistemic_status="believed",
                                source_file=full_path,
                            )
                            _graph.add_triple(
                                subject=_article_title,
                                predicate="about_entity" if _etype in ("person", "organization") else "about_concept",
                                obj=_sf.replace(".md", "").replace("-", " ").title(),
                                authority="system",
                                confidence=0.7,
                                subject_type="document",
                                object_type="document",
                                epistemic_status="believed",
                                source_file=full_path,
                            )
                        _graph.save()
                    except Exception:
                        log.debug("KB %s: graph registration failed for %s", slug, article_path, exc_info=True)

                # Track in compile state: which sources produced this article
                compile_record = state.setdefault("compiled_articles", {})
                compile_record[article_path] = {
                    "compiled_at": date_str,
                    "derived_from": source_fnames[:20],
                    "agent": "editor",
                }

        # Concept map
        concepts = self._extract_block(response.content, "concepts")
        if concepts:
            self._writer.write(f"{base}/wiki/.concepts.md", (
                f"---\ntitle: \"Concept Map\"\ntype: kb_concepts\nauthor: agent\nagent: editor\n"
                f"date_updated: \"{date_str}\"\n---\n\n{concepts}"
            ))

        # Update index — deduplicate, separate entities from concepts
        if articles_written:
            idx = index_doc.raw
            new_entities = [a for a in articles_written if a.startswith("entities/") and f"[[{a}]]" not in idx]
            new_concepts = [a for a in articles_written if a.startswith("concepts/") and f"[[{a}]]" not in idx]
            if new_entities:
                entity_list = "\n".join(f"- [[{a}]]" for a in new_entities)
                marker = "<!-- Entity pages:"
                if marker in idx:
                    idx = idx.replace(marker, f"{marker}\n{entity_list}\n")
            if new_concepts:
                concept_list = "\n".join(f"- [[{a}]]" for a in new_concepts)
                marker = "<!-- Concept pages:"
                if marker in idx:
                    idx = idx.replace(marker, f"{marker}\n{concept_list}\n")
            idx = self._update_frontmatter(idx, "updated", date_str)
            self._writer.write(f"{base}/wiki/.index.md", idx)

        self._update_index_stats(slug)
        state["last_compile"] = now.isoformat()
        self._write_compile_state(slug, state)
        self._append_log(slug, "compile", f"{len(articles_written)} articles: {', '.join(articles_written)}")

        self._writer._pipeline_mode = False
        return {"slug": slug, "articles": articles_written, "count": len(articles_written)}

    # ── Cross-Link ──

    def cross_link(self, slug: str) -> dict:
        """Scan all wiki pages for unlinked mentions of existing articles. Add [[wikilinks]].

        Scans every article for plain-text mentions of other article titles/slugs
        that aren't already wrapped in [[links]]. Converts them in-place.
        Pipeline mode: no versioning for automated cross-link writes.
        """
        import re as _re
        self._writer._pipeline_mode = True

        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        # Build lookup: slug → title for all articles
        articles: dict[str, str] = {}  # slug → title
        for subdir in self._get_wiki_subdirs(slug):
            try:
                for wf in self._reader.list_files(f"{base}/wiki/{subdir}", "*.md"):
                    if wf.name.startswith(("_", ".")):
                        continue
                    try:
                        doc = self._reader.read(f"{base}/wiki/{subdir}/{wf.name}")
                        title = doc.metadata.get("title", wf.stem.replace("-", " ").title())
                        articles[wf.stem] = title
                    except Exception:
                        pass
            except Exception:
                continue

        if len(articles) < 2:
            return {"slug": slug, "links_added": 0, "pages_modified": 0}

        # Build regex patterns: match title or slug (case-insensitive) NOT inside [[...]]
        # Sort by title length descending so longer titles match first
        patterns: list[tuple[str, str, str]] = []  # (slug, title, regex pattern)
        for article_slug, title in sorted(articles.items(), key=lambda x: len(x[1]), reverse=True):
            # Skip very short titles (too many false positives)
            if len(title) < 8 or len(article_slug) < 4:  # arch:deterministic — minimum length to avoid false positive crosslinks
                continue
            # Match title or slug-as-words, NOT already inside [[ ]]
            escaped_title = _re.escape(title)
            escaped_slug_words = _re.escape(article_slug.replace("-", " "))
            # Combine: match either title or slug-as-words
            if escaped_title.lower() != escaped_slug_words.lower():
                pattern = f"(?:{escaped_title}|{escaped_slug_words})"
            else:
                pattern = escaped_title
            patterns.append((article_slug, title, pattern))

        links_added = 0
        pages_modified = 0

        # Scan each article
        for subdir in self._get_wiki_subdirs(slug):
            try:
                for wf in self._reader.list_files(f"{base}/wiki/{subdir}", "*.md"):
                    if wf.name.startswith(("_", ".")):
                        continue
                    current_slug = wf.stem
                    try:
                        rel_path = f"{base}/wiki/{subdir}/{wf.name}"
                        doc = self._reader.read(rel_path)
                        content = doc.raw
                    except Exception:
                        continue

                    # Split into frontmatter and body
                    body_start = 0
                    if content.startswith("---"):
                        fm_end = content.find("---", 3)
                        if fm_end != -1:
                            body_start = fm_end + 3

                    frontmatter = content[:body_start]
                    body = content[body_start:]
                    modified = False

                    for target_slug, target_title, pattern in patterns:
                        # Don't link to self
                        if target_slug == current_slug:
                            continue

                        # Find mentions NOT already inside [[ ]]
                        def replace_mention(m: _re.Match, _title=target_title, body=body) -> str:
                            # Check if already inside a wikilink
                            start = m.start()
                            prefix = body[:start]
                            if prefix.endswith("[[") or "[[" in prefix[max(0, start - 50):start]:
                                # Check more carefully
                                last_open = prefix.rfind("[[")
                                last_close = prefix.rfind("]]")
                                if last_open > last_close:
                                    return m.group(0)  # inside [[ ]], leave alone
                            # Use display-name wikilinks: [[Title]] resolves to Title.md
                            # If matched text differs from title, use pipe: [[Title|matched text]]
                            matched = m.group(0)
                            if matched.lower() == _title.lower():
                                return f"[[{_title}]]"
                            return f"[[{_title}|{matched}]]"

                        new_body = _re.sub(
                            rf"(?<!\[\[)\b({pattern})\b(?!\]\])",
                            replace_mention,
                            body,
                            flags=_re.IGNORECASE,
                            count=1,  # Only link first mention per article
                        )
                        if new_body != body:
                            body = new_body
                            modified = True
                            links_added += 1

                    if modified:
                        self._writer.write(rel_path, frontmatter + body)
                        pages_modified += 1
            except Exception:
                continue

        if links_added:
            self._append_log(slug, "cross-link", f"{links_added} links added across {pages_modified} pages")
            log.info("KB %s: cross-linked %d mentions across %d pages", slug, links_added, pages_modified)

        self._writer._pipeline_mode = False
        return {"slug": slug, "links_added": links_added, "pages_modified": pages_modified}

    # ── Synthesize ──

    async def synthesize(self, slug: str) -> dict:
        """Public entry point — wraps ``_body_synthesize`` in the scoped
        pipeline so every vault write is authorised or trusted."""
        self._log_activity(
            "synthesize_started",
            f"KB {slug}: synthesis started",
            {"slug": slug},
            importance="low",
        )
        try:
            async with self._scoped_pipeline("kb_synthesize", "synthesize"):
                result = await self._body_synthesize(slug)
                _count = len(result.get("pages", [])) if isinstance(result, dict) else 0
                self._log_activity(
                    "synthesize_completed",
                    f"KB {slug}: synthesis produced {_count} page(s)",
                    {"slug": slug, "count": _count},
                    importance="medium" if _count else "low",
                )
                await self._record_decision(
                    action_type="synthesize_batch",
                    action_target=f"kb/{slug}",
                    rationale=f"Synthesis produced {_count} pages",
                    details={"slug": slug, "count": _count},
                )
                return result
        except Exception as exc:
            self._log_activity(
                "synthesize_failed",
                f"KB {slug}: synthesis raised {type(exc).__name__}",
                {"slug": slug, "error": str(exc)[:500]},
                importance="high",
            )
            await self._record_decision(
                action_type="synthesize_batch",
                action_target=f"kb/{slug}",
                contract_result="failed",
                rationale=f"Synthesis aborted: {type(exc).__name__}",
                details={"slug": slug, "error": str(exc)[:500]},
            )
            raise

    async def _body_synthesize(self, slug: str) -> dict:
        """Generate cross-source synthesis pages — insights that no single source contains alone."""
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        # Read all source summaries
        source_texts = []
        try:
            for sf in self._reader.list_files(f"{base}/wiki/sources", "*.md"):
                doc = self._reader.read(f"{base}/wiki/sources/{sf.name}")
                source_texts.append(f"### {sf.stem}\n{doc.content[:2000]}")
        except Exception:
            pass

        if len(source_texts) < 2:
            return {"slug": slug, "articles": [], "count": 0, "message": "Need 2+ sources for synthesis."}

        # Read existing synthesis pages to avoid re-creating
        existing_synthesis = []
        try:
            for sf in self._reader.list_files(f"{base}/wiki/synthesis", "*.md"):
                existing_synthesis.append(sf.stem)
        except Exception:
            pass

        date_str = datetime.now().strftime("%Y-%m-%d %H:%M")

        try:
            response = await asyncio.wait_for(self._llm.complete(
                messages=[Message(role="user", content=(
                    f"Analyze these {len(source_texts)} source summaries and identify cross-source insights — "
                    f"connections, contradictions, patterns, or implications that no single source reveals alone.\n\n"
                    f"Existing synthesis pages (do NOT duplicate): {', '.join(existing_synthesis) or 'none'}\n\n"
                    f"For each genuine insight, create an article block. Only create synthesis pages for "
                    f"NON-OBVIOUS connections. If no real cross-source insights exist, output nothing."
                ))],
                system=(
                    "You are a knowledge synthesis specialist. Read source summaries and find connections across them.\n\n"
                    "SOURCES:\n" + "\n\n".join(source_texts) + "\n\n"
                    "Output format for each synthesis:\n"
                    "```article:synthesis/{slug}\n"
                    "---\n"
                    f'title: "Synthesis: descriptive title"\n'
                    "entity_type: synthesis\n"
                    "provenance: compiled\n"
                    "category: cross-source\n"
                    'status: draft\n'
                    f'sources: ["source1", "source2"]\n'
                    "author: agent\n"
                    "agent: editor\n"
                    f'date_created: "{date_str}"\n'
                    f'date_updated: "{date_str}"\n'
                    "---\n\n"
                    "(Full analysis with [[wikilinks]] and [Source: file] citations)\n\n"
                    "## Connections\n(how the sources relate)\n\n"
                    "## Implications\n(what this means)\n\n"
                    "## Open Questions\n(what this raises but doesn't answer)\n"
                    "```\n\n"
                    "If no genuine cross-source insights exist, respond with: no_synthesis"
                ),
                max_tokens=4096,
                temperature=0.1,
                model=self._resolve_model("fast" if self._is_local_mode() else "capable"),  # editor: synthesis
            ), timeout=self._llm_timeout("synthesize"))
        except TimeoutError:
            log.warning("KB %s: synthesize LLM timed out", slug)
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("synthesize", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "error": "LLM timed out", "queued": True}
        except Exception as exc:
            log.warning("KB %s: synthesize LLM failed: %s", slug, exc)
            if self._called_from_queue:
                raise
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("synthesize", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "error": f"LLM unavailable: {exc}", "queued": True}

        if "no_synthesis" in response.content.lower():
            return {"slug": slug, "articles": [], "count": 0, "message": "No cross-source insights found."}

        articles_written = []
        parts = response.content.split("```article:")
        for part in parts[1:]:
            lines = part.split("\n", 1)
            article_path = lines[0].strip()
            rest = lines[1] if len(lines) > 1 else ""
            end = rest.find("```")
            content = rest[:end].strip() if end != -1 else rest.strip()

            if article_path and content and article_path.startswith("synthesis/"):
                full_path = f"{base}/wiki/{article_path}.md"
                if not self._reader.exists(full_path):
                    # Numberify inline [Source: ...] markers before write so synthesis
                    # pages get the same [N] + ## References treatment as compile CREATE.
                    synth_source_fnames = [
                        sf.split("\n")[0].replace("### ", "").strip()
                        for sf in source_texts
                    ]
                    content = self._numberify_sources(content, synth_source_fnames, slug)
                    self._writer.write(full_path, content)
                    articles_written.append(article_path)

        if articles_written:
            self._append_log(slug, "synthesize", f"{len(articles_written)} synthesis pages: {', '.join(articles_written)}")

        return {"slug": slug, "articles": articles_written, "count": len(articles_written)}

    # ── Archive ──

    def snapshot(self, slug: str, reason: str = "manual") -> dict:
        """Create a timestamped snapshot of the wiki before destructive operations."""
        import shutil

        base = self._resolve(f"knowledge/{slug}")
        if not (base / "wiki" / ".index.md").exists():
            return {"error": f"KB not found: {slug}"}

        now = datetime.now(UTC)
        archive_name = now.strftime("%Y%m%dT%H%M%S")
        archive_dir = base / ".archives" / archive_name
        archive_dir.mkdir(parents=True, exist_ok=True)

        # Copy wiki/ and metadata
        wiki_dir = base / "wiki"
        if wiki_dir.is_dir():
            shutil.copytree(str(wiki_dir), str(archive_dir / "wiki"))

        # Copy state files
        for fname in (".compile-state.json", "SCHEMA.md", "log.md"):
            src = base / fname
            if src.exists():
                shutil.copy2(str(src), str(archive_dir / fname))

        # Write archive metadata
        import json as _json
        article_count = sum(1 for _ in wiki_dir.rglob("*.md")) if wiki_dir.is_dir() else 0
        meta = {
            "archived_at": now.isoformat(),
            "reason": reason,
            "article_count": article_count,
            "slug": slug,
        }
        (archive_dir / "archive-meta.json").write_text(_json.dumps(meta, indent=2), encoding="utf-8")

        self._append_log(slug, "snapshot", f"Archive created: {archive_name} (reason: {reason}, {article_count} articles)")
        log.info("KB %s: snapshot created at %s (%d articles)", slug, archive_name, article_count)
        return {"slug": slug, "archive": archive_name, "articles": article_count, "reason": reason}

    def list_snapshots(self, slug: str) -> list[dict]:
        """List available snapshots for a KB."""
        import json as _json

        archive_dir = self._resolve(f"knowledge/{slug}/.archives")
        if not archive_dir.is_dir():
            return []

        snapshots = []
        for d in sorted(archive_dir.iterdir(), reverse=True):
            if not d.is_dir():
                continue
            meta_file = d / "archive-meta.json"
            if meta_file.exists():
                try:
                    meta = _json.loads(meta_file.read_text(encoding="utf-8"))
                    meta["archive_id"] = d.name
                    snapshots.append(meta)
                except Exception:
                    snapshots.append({"archived_at": d.name, "archive_id": d.name, "reason": "unknown"})
        return snapshots

    def restore_snapshot(self, slug: str, archive_id: str) -> dict:
        """Restore a wiki from a previous snapshot.

        Safety: snapshots current state before restoring.
        """
        import shutil

        base = self._resolve(f"knowledge/{slug}")
        archive_dir = base / ".archives" / archive_id
        if not archive_dir.is_dir():
            return {"error": f"Snapshot not found: {archive_id}"}

        archived_wiki = archive_dir / "wiki"
        if not archived_wiki.is_dir():
            return {"error": f"Snapshot has no wiki directory: {archive_id}"}

        # Safety: snapshot current state before restoring
        pre_restore = self.snapshot(slug, reason="pre-restore")

        # Clear current wiki
        live_wiki = base / "wiki"
        if live_wiki.is_dir():
            shutil.rmtree(str(live_wiki))

        # Copy archived wiki back
        shutil.copytree(str(archived_wiki), str(live_wiki))

        # Restore state files if they exist in the archive
        for fname in (".compile-state.json", "SCHEMA.md", "log.md"):
            archived_file = archive_dir / fname
            if archived_file.exists():
                shutil.copy2(str(archived_file), str(base / fname))

        self._append_log(slug, "restore", f"Restored from snapshot {archive_id} (pre-restore saved as {pre_restore.get('archive', '?')})")
        log.info("KB %s: restored from snapshot %s", slug, archive_id)

        # Count restored articles
        article_count = sum(1 for _ in live_wiki.rglob("*.md"))

        return {
            "slug": slug,
            "restored_from": archive_id,
            "pre_restore_snapshot": pre_restore.get("archive", ""),
            "articles_restored": article_count,
        }

    # ── Project Scoping ──

    def create_project_wiki(
        self, kb_slug: str, project_slug: str, project_title: str,
        project_data: dict | None = None,
    ) -> str:
        """Create a project namespace within a KB wiki.

        If project_data is provided (from ProjectService), populates the overview
        with real description, goals, agents, milestones, and status.
        """
        base = f"knowledge/{kb_slug}/wiki/projects/{project_slug}"
        date_str = datetime.now().strftime("%Y-%m-%d %H:%M")

        # Pull real data from the projects module
        pd = project_data or {}
        description = pd.get("description", "")
        status = pd.get("status", "active")
        target_date = pd.get("target_date", "")
        goals = pd.get("related_goals", [])
        agents = pd.get("assigned_agents", [])
        milestones = pd.get("milestones", "")

        desc_text = description if description else "[TODO: project description]"
        target_text = f"\n**Target date:** {target_date}" if target_date else ""

        goals_text = "\n".join(f"- [[{g}]]" for g in goals) if goals else "[TODO: link to goals]"
        agents_text = "\n".join(f"- {a}" for a in agents) if agents else "[TODO: assigned agents]"

        milestones_text = ""
        if milestones:
            milestones_text = f"\n## Milestones\n{milestones}\n"

        overview = (
            f"---\ntitle: \"{project_title}\"\n"
            f"entity_type: project\ncategory: project\n"
            f"status: {status}\nauthor: agent\nagent: wiki-supervisor\n"
            f"related_goals: {json.dumps(goals)}\nassigned_agents: {json.dumps(agents)}\n"
            f"date_created: \"{date_str}\"\ndate_updated: \"{date_str}\"\n---\n\n"
            f"# {project_title}\n{target_text}\n\n"
            f"## Overview\n{desc_text}\n\n"
            f"## Goals\n{goals_text}\n\n"
            f"## Assigned Agents\n{agents_text}\n"
            f"{milestones_text}\n"
            f"## Key Concepts\n[TODO: link to project-specific concept pages]\n\n"
            f"## Key People\n[TODO: link to entity pages]\n\n"
            f"## Open Questions\n- [TODO: what don't we know yet?]\n\n"
            f"## Sources\n[TODO: source links]\n"
        )
        self._writer.write(f"{base}/{project_slug}.md", overview)
        self._writer.write(f"{base}/concepts/.gitkeep", "")
        self._writer.write(f"{base}/entities/.gitkeep", "")
        self._writer.write(f"{base}/notes/.gitkeep", "")

        self._append_log(kb_slug, "project", f"Project wiki created: {project_title} ({project_slug})")
        return f"{base}/{project_slug}.md"

    # ── Reflect ──

    def create_decision_record(
        self, slug: str, *,
        title: str, decision: str, context: str,
        alternatives: str = "None documented.",
        reasoning: str = "", consequences: str = "",
        affects: list[str] | None = None,
    ) -> str:
        """Create a decision record as a first-class wiki page."""
        date_str = datetime.now(UTC).strftime("%Y-%m-%d")
        from agntspace.vault.writer import title_to_filename
        record_display = title_to_filename(title)
        path = f"knowledge/{slug}/wiki/decisions/{record_display}.md"

        page = self._render_template("decision_record",
            title=title, date=date_str,
            decision=decision, context=context,
            alternatives=alternatives, reasoning=reasoning,
            consequences=consequences,
            affects=json.dumps(affects or []),
        )
        self._writer.write(path, page)
        self._append_log(slug, "decision", f"Decision recorded: {title}")
        return path

    async def reflect(
        self, slug: str, *,
        articles_created: list[str] | None = None,
        files_ingested: list[str] | None = None,
    ) -> dict:
        """Public entry — scoped pipeline wrapper around ``_body_reflect``."""
        self._log_activity(
            "reflect_started",
            f"KB {slug}: reflection started",
            {"slug": slug},
            importance="low",
        )
        try:
            async with self._scoped_pipeline("kb_reflect", "reflect"):
                result = await self._body_reflect(
                    slug,
                    articles_created=articles_created,
                    files_ingested=files_ingested,
                )
                _created = len(result.get("created", [])) if isinstance(result, dict) else 0
                self._log_activity(
                    "reflect_completed",
                    f"KB {slug}: reflection produced {_created} decision record(s)",
                    {"slug": slug, "count": _created},
                    importance="medium" if _created else "low",
                )
                await self._record_decision(
                    action_type="reflect_batch",
                    action_target=f"kb/{slug}",
                    rationale=f"Reflection produced {_created} decision records",
                    details={"slug": slug, "count": _created},
                )
                return result
        except Exception as exc:
            self._log_activity(
                "reflect_failed",
                f"KB {slug}: reflection raised {type(exc).__name__}",
                {"slug": slug, "error": str(exc)[:500]},
                importance="high",
            )
            await self._record_decision(
                action_type="reflect_batch",
                action_target=f"kb/{slug}",
                contract_result="failed",
                rationale=f"Reflection aborted: {type(exc).__name__}",
                details={"slug": slug, "error": str(exc)[:500]},
            )
            raise

    async def _body_reflect(
        self, slug: str, *,
        articles_created: list[str] | None = None,
        files_ingested: list[str] | None = None,
    ) -> dict:
        """Structural reflection after compile — creates decision records for significant changes.

        Part of the full pipeline: ingest → compile → **reflect** → lint
        """
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        articles_created = articles_created or []
        files_ingested = files_ingested or []

        # Nothing to reflect on
        if not articles_created and not files_ingested:
            return {"slug": slug, "decisions_created": 0, "message": "No changes to reflect on."}

        schema = self._read_schema(slug)

        # Read FULL content of recently created/modified articles
        recent_articles_text = []
        for art_path in articles_created[:10]:
            full = f"{base}/wiki/{art_path}.md"
            if self._reader.exists(full):
                try:
                    doc = self._reader.read(full)
                    recent_articles_text.append(f"### {art_path}\n{doc.content[:1000]}")
                except Exception:
                    pass

        # Also read source summaries for ingested files
        for fname in files_ingested[:5]:
            src_slug = fname.lower().replace(" ", "-").replace(".md", "")
            src_path = f"{base}/wiki/sources/{src_slug}.md"
            if self._reader.exists(src_path):
                try:
                    doc = self._reader.read(src_path)
                    recent_articles_text.append(f"### sources/{src_slug}\n{doc.content[:800]}")
                except Exception:
                    pass

        # Read existing article titles for context
        existing = []
        for subdir in self._get_wiki_subdirs(slug):
            try:
                for wf in self._reader.list_files(f"{base}/wiki/{subdir}", "*.md"):
                    if wf.name.startswith(("_", ".")):
                        continue
                    try:
                        doc = self._reader.read(f"{base}/wiki/{subdir}/{wf.name}")
                        title = doc.metadata.get("title", wf.stem)
                        etype = doc.metadata.get("entity_type", "")
                        existing.append(f"- {subdir}/{wf.stem} [{etype}]: {title}")
                    except Exception:
                        pass
            except Exception:
                continue

        # Build reflect prompt: skill-loaded or hardcoded fallback
        dynamic_ctx = (
            f"\nWHAT JUST CHANGED:\n"
            f"- Articles created: {', '.join(articles_created) or 'none'}\n"
            f"- Files ingested: {', '.join(files_ingested[:10]) or 'none'}\n\n"
            f"RECENTLY CREATED/MODIFIED ARTICLES:\n"
            f"{chr(10).join(recent_articles_text) or 'No recent articles to analyze.'}\n\n"
            f"FULL WIKI STATE:\n{chr(10).join(existing[:80]) or 'No existing articles.'}\n\n"
            f"KB SCHEMA:\n{schema[:1000]}"
        )
        skill_prompt = self._get_skill_prompt("kb-reflect")
        if skill_prompt:
            reflect_system = skill_prompt + "\n\n" + dynamic_ctx
        else:
            reflect_system = _REFLECT_PROMPT_FALLBACK.format(
                articles_created=", ".join(articles_created) or "none",
                files_ingested=", ".join(files_ingested[:10]) or "none",
                recent_articles="\n\n".join(recent_articles_text) or "No recent articles to analyze.",
                existing_articles="\n".join(existing[:80]) or "No existing articles.",
                schema=schema[:1000],
            )
        try:
            response = await asyncio.wait_for(self._llm.complete(
                messages=[Message(role="user", content="Analyze the structural impact of recent wiki changes.")],
                system=reflect_system,
                max_tokens=2000, temperature=0.3,
                model=self._resolve_model("fast" if self._is_local_mode() else "capable"),  # editor: reflection
            ), timeout=self._llm_timeout("reflect"))
        except TimeoutError:
            log.warning("KB %s: reflect LLM timed out", slug)
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("reflect", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "error": "LLM timed out", "queued": True}
        except Exception as exc:
            log.warning("KB %s: reflect LLM failed: %s", slug, exc)
            if self._called_from_queue:
                raise
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue(
                    "reflect", {"slug": slug, "articles_created": articles_created, "files_ingested": files_ingested},
                    deduplicate=True,
                ))
            return {"slug": slug, "error": f"LLM unavailable: {exc}", "queued": True}

        # Check if LLM found significant decisions
        if "```no_decisions" in response.content:
            self._append_log(slug, "reflect", "No significant structural decisions")
            return {"slug": slug, "decisions_created": 0, "message": "Routine update — no structural decisions."}

        # Parse decision blocks
        decisions_created = 0
        for block in response.content.split("```decision"):
            if not block.strip() or block.strip().startswith("```"):
                continue
            end = block.find("```")
            content = block[:end].strip() if end != -1 else block.strip()

            # Parse fields
            fields: dict[str, str] = {}
            for line in content.split("\n"):
                if ":" in line:
                    key, _, val = line.partition(":")
                    fields[key.strip().lower()] = val.strip()

            title = fields.get("title", "Unnamed decision")
            if not title or title == "Unnamed decision":
                continue

            affects = [a.strip() for a in fields.get("affects", "").split(",") if a.strip()]

            self.create_decision_record(
                slug,
                title=title,
                decision=fields.get("decision", ""),
                context=fields.get("context", ""),
                alternatives=fields.get("alternatives", "None documented."),
                reasoning=fields.get("reasoning", ""),
                consequences=fields.get("consequences", ""),
                affects=affects,
            )
            decisions_created += 1

        self._append_log(slug, "reflect", f"{decisions_created} decision records created")
        return {"slug": slug, "decisions_created": decisions_created}

    # ── Research ──

    async def research(
        self, slug: str, query: str, save_output: bool = False,
        file_to_wiki: bool = False, output_format: str = "markdown",
    ) -> dict:
        """Public entry — scoped pipeline wrapper around ``_body_research``.

        *output_format* controls the shape of the LLM response:
        - ``"markdown"`` (default) — wiki article with citations
        - ``"comparison"`` — markdown comparison table
        - ``"slides"`` — Marp slide deck
        - ``"briefing"`` — executive briefing (short, structured)
        """
        self._log_activity(
            "research_started",
            f"KB {slug}: research started — {query[:120]}",
            {"slug": slug, "query": query[:500], "output_format": output_format},
            importance="low",
        )
        try:
            async with self._scoped_pipeline("kb_research", "research"):
                result = await self._body_research(
                    slug, query, save_output=save_output,
                    file_to_wiki=file_to_wiki, output_format=output_format,
                )
                self._log_activity(
                    "research_completed",
                    f"KB {slug}: research completed",
                    {"slug": slug, "saved": save_output, "filed_to_wiki": file_to_wiki},
                    importance="medium",
                )
                await self._record_decision(
                    action_type="research",
                    action_target=f"kb/{slug}",
                    rationale=f"Research query: {query[:200]}",
                    details={"slug": slug, "query": query[:500], "saved": save_output},
                )
                return result
        except Exception as exc:
            self._log_activity(
                "research_failed",
                f"KB {slug}: research raised {type(exc).__name__}",
                {"slug": slug, "error": str(exc)[:500]},
                importance="high",
            )
            await self._record_decision(
                action_type="research",
                action_target=f"kb/{slug}",
                contract_result="failed",
                rationale=f"Research aborted: {type(exc).__name__}",
                details={"slug": slug, "error": str(exc)[:500]},
            )
            raise

    async def _body_research(
        self, slug: str, query: str, save_output: bool = False,
        file_to_wiki: bool = False, output_format: str = "markdown",
    ) -> dict:
        """Research with tiered context loading: index → search → targeted reads."""
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        schema = self._read_schema(slug)
        self._read_index(slug)  # warm cache

        # L1: Read index to find relevant pages
        query_words = set(query.lower().split())

        # L2: Score articles by relevance using index
        all_articles = {}
        for subdir in _WIKI_SUBDIRS:
            try:
                for wf in self._reader.list_files(f"{base}/wiki/{subdir}", "*.md"):
                    path = f"{base}/wiki/{subdir}/{wf.name}"
                    doc = self._reader.read(path)
                    title = doc.metadata.get("title", wf.stem)
                    # Score by query word overlap in title + first 200 chars
                    preview = f"{title} {doc.content[:200]}".lower()
                    score = sum(1 for w in query_words if w in preview)
                    all_articles[path] = {"title": title, "score": score, "content": doc.content}
            except Exception:
                continue

        if not all_articles:
            return {"answer": "No wiki articles. Run ingest + compile first.", "sources": []}

        # L3: Load full content of top-scoring articles only
        sorted_articles = sorted(all_articles.items(), key=lambda x: x[1]["score"], reverse=True)
        context_parts = []
        token_budget = 15000
        used = 0
        for path, info in sorted_articles[:15]:
            article_text = f"## {info['title']}\n\n{info['content']}"
            if used + len(article_text) > token_budget:
                break
            context_parts.append(article_text)
            used += len(article_text)

        output_instruction = _RESEARCH_OUTPUT_FORMATS.get(output_format, "")
        if file_to_wiki and not output_instruction:
            output_instruction = "Format as a wiki article with frontmatter. Use [[wiki-links]] and [Source: file] citations."

        # Build research prompt: skill-loaded or hardcoded fallback
        articles_ctx = "\n\n---\n\n".join(context_parts)
        dynamic_ctx = (
            f"\nKB SCHEMA:\n{schema[:1000]}\n\n"
            f"WIKI ARTICLES:\n{articles_ctx}\n\n"
            f"QUESTION:\n{query}\n\n{output_instruction}"
        )
        skill_prompt = self._get_skill_prompt("kb-research")
        if skill_prompt:
            research_system = skill_prompt + "\n\n" + dynamic_ctx
        else:
            research_system = _RESEARCH_PROMPT_FALLBACK.format(
                schema=schema[:1000], context="\n\n---\n\n".join(context_parts),
                query=query, output_instruction=output_instruction,
            )
        try:
            response = await asyncio.wait_for(self._llm.complete(
                messages=[Message(role="user", content=query)],
                system=research_system,
                max_tokens=2048, temperature=0.3,
                model=self._resolve_model("fast" if self._is_local_mode() else "reasoning"),  # researcher: research
            ), timeout=self._llm_timeout("research"))
        except TimeoutError:
            log.warning("KB %s: research LLM timed out", slug)
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("research", {"slug": slug, "query": query}, deduplicate=True))
            return {"slug": slug, "error": "LLM timed out", "queued": True}
        except Exception as exc:
            log.warning("KB %s: research LLM failed: %s", slug, exc)
            if self._called_from_queue:
                raise
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue(
                    "research", {"slug": slug, "query": query, "save_output": save_output},
                    deduplicate=True,
                ))
            return {"slug": slug, "error": f"LLM unavailable: {exc}", "queued": True, "answer": ""}

        # Auto-file to wiki if answer has good citation coverage (>= 3 source references)
        source_refs = response.content.count("[Source:") + response.content.count("[[sources/")
        if not file_to_wiki and source_refs >= 3:
            file_to_wiki = True
            log.info("KB %s: auto-filing research answer to wiki (%d source citations)", slug, source_refs)

        output_path = None
        if save_output or file_to_wiki:
            ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
            date_today = datetime.now(UTC).strftime("%Y-%m-%d")
            q_slug = query[:40].lower().replace(" ", "-").replace("/", "-")
            if file_to_wiki:
                output_path = f"{base}/wiki/concepts/research-{q_slug}.md"
                # Idempotent: update existing research article instead of duplicating
                if self._reader.exists(output_path):
                    existing = self._reader.read(output_path)
                    content = self._update_frontmatter(existing.raw, "date_updated", date_today)
                    # Replace body after frontmatter
                    fm_end = content.find("---", content.find("---") + 3)
                    if fm_end != -1:
                        content = content[:fm_end + 3] + f"\n\n{response.content}"
                    log.info("KB %s: updated existing research article %s", slug, output_path)
                else:
                    content = response.content if response.content.startswith("---") else (
                        f"---\ntitle: \"{query[:80]}\"\nentity_type: concept\nprovenance: researched\ncategory: research\n"
                        f"status: draft\nauthor: agent\nagent: researcher\ndate_created: \"{date_today}\"\n"
                        f"date_updated: \"{date_today}\"\n---\n\n{response.content}"
                    )
            else:
                output_path = f"{base}/output/research_{q_slug}_{ts}.md"
                content = (
                    f"---\ntitle: \"{query[:80]}\"\ntype: kb_research\nauthor: agent\nagent: researcher\n"
                    f"date: \"{date_today}\"\n---\n\n# Research: {query}\n\n{response.content}"
                )
            # Numberify inline [Source: ...] markers (collected from LLM output itself)
            # so research output gets [N] + ## References like every other wiki write path.
            content = self._numberify_sources(content, [], slug)
            self._writer.write(output_path, content)

        self._append_log(slug, "research", f"Q: {query[:80]}")
        return {"answer": response.content, "output_path": output_path, "filed_to_wiki": file_to_wiki}

    # ── Lint ──

    def _structural_analysis(self, slug: str) -> tuple[list[dict], dict[str, str]]:
        """Pre-compute structural wiki issues from file data (no LLM needed).

        Returns (issues_list, articles_dict) where articles_dict maps path → content.
        """
        import re as _re
        from difflib import SequenceMatcher

        base = f"knowledge/{slug}"
        issues: list[dict] = []

        # Load all articles: path → {content, title, metadata, links_out, links_in}
        articles: dict[str, dict] = {}
        for subdir in _WIKI_SUBDIRS:
            try:
                for wf in self._list_wiki_articles(f"{base}/wiki/{subdir}"):
                    rel = f"{subdir}/{wf.stem}"
                    doc = self._reader.read(f"{base}/wiki/{subdir}/{wf.name}")
                    # Extract outbound [[wiki-links]]
                    outbound = set(_re.findall(r'\[\[([^\]]+)\]\]', doc.content))
                    # Count TODOs
                    todos = len(_re.findall(r'\[TODO:', doc.content))
                    articles[rel] = {
                        "content": doc.content,
                        "title": doc.metadata.get("title", wf.stem),
                        "metadata": doc.metadata,
                        "outbound_links": outbound,
                        "inbound_count": 0,
                        "todo_count": todos,
                        "status": doc.metadata.get("status", ""),
                        "entity_type": doc.metadata.get("entity_type", ""),
                        "sources": doc.metadata.get("sources", []),
                        "date_created": doc.metadata.get("date_created", ""),
                    }
            except Exception:
                continue

        if not articles:
            return issues, {}

        # Build inbound link counts + find broken links
        all_slugs = set(articles.keys())
        # Also index by just the slug part (without subdir) for fuzzy matching
        slug_to_path = {}
        for path in all_slugs:
            parts = path.split("/", 1)
            if len(parts) == 2:
                slug_to_path[parts[1]] = path

        missing_pages: set[str] = set()
        for path, info in articles.items():
            for link in info["outbound_links"]:
                # Normalize: links might be "slug" or "subdir/slug"
                link_norm = link.lower().replace(" ", "-")
                target = None
                if link_norm in all_slugs:
                    target = link_norm
                elif link_norm in slug_to_path:
                    target = slug_to_path[link_norm]
                else:
                    # Try partial match
                    for s in all_slugs:
                        if s.endswith(f"/{link_norm}"):
                            target = s
                            break

                if target and target in articles:
                    articles[target]["inbound_count"] += 1
                elif link_norm not in missing_pages:
                    missing_pages.add(link_norm)

        # 1. Orphan pages (no inbound links, excluding source summaries and index)
        for path, info in articles.items():
            if info["inbound_count"] == 0 and not path.startswith("sources/") and not path.startswith("source_summary/"):
                issues.append({
                    "type": "orphan",
                    "severity": "warning",
                    "message": f'Article "{info["title"]}" ({path}) has no inbound links',
                })

        # 2. Broken/missing pages
        for link in missing_pages:
            issues.append({
                "type": "missing_page",
                "severity": "critical",
                "message": f'[[{link}]] is linked but no article exists',
            })

        # 3. High TODO density
        for path, info in articles.items():
            if info["todo_count"] >= 3:
                issues.append({
                    "type": "todo",
                    "severity": "warning",
                    "message": f'Article "{info["title"]}" ({path}) has {info["todo_count"]} [TODO] items',
                })

        # 4. Missing frontmatter
        required_fields = {"entity_type", "status"}
        for path, info in articles.items():
            missing = [f for f in required_fields if not info["metadata"].get(f)]
            if missing:
                issues.append({
                    "type": "frontmatter",
                    "severity": "warning",
                    "message": f'Article "{info["title"]}" ({path}) missing frontmatter: {", ".join(missing)}',
                })

        # 5. Slug-based duplicate detection
        titles_seen: dict[str, str] = {}  # normalized_title → path
        for path, info in articles.items():
            if path.startswith("sources/") or path.startswith("source_summary/"):
                continue
            norm_title = info["title"].lower().strip()
            if norm_title in titles_seen:
                issues.append({
                    "type": "duplicate",
                    "severity": "critical",
                    "message": f'Potential duplicate: "{info["title"]}" ({path}) and ({titles_seen[norm_title]}) have the same title',
                })
            else:
                titles_seen[norm_title] = path

        # Also check slug similarity — pairwise.
        #
        # Performance guard (added 2026-04-25, same audit pass that found
        # the EntityRegistry Tier 4 runaway): naive O(N²) SequenceMatcher
        # over all wiki slugs is fine on small KBs (<500 articles) but
        # pins one CPU core for ~30-60s on a 2700-article vault — and the
        # user can't tell the lint endpoint from a hung server.
        #
        # Two cheap guards keep this safe at any vault size without
        # losing detection quality:
        #   (a) length prefilter — skip pairs whose lengths differ by
        #       more than 30%; SequenceMatcher.ratio() can't exceed
        #       2*min(a,b)/(a+b), so a 5-char slug vs a 30-char slug
        #       maxes at 0.286 — well below the 0.80 threshold. This
        #       eliminates ~95% of pair comparisons on heterogeneous
        #       vaults.
        #   (b) hard cap — if even after the prefilter we'd be doing
        #       more than 500K comparisons, downgrade to slug-prefix
        #       grouping (catches "foo-bar"/"foo-bar-2" duplicates,
        #       misses semantic-only similarity which is rare).
        non_source_paths = [p for p in all_slugs if not p.startswith("sources/") and not p.startswith("source_summary/")]
        slugs = [(p, p.split("/")[-1]) for p in non_source_paths]
        n = len(slugs)
        # Length-prefilter pair count estimate: a slug s only needs to be
        # compared against slugs whose length is within 30% of len(s).
        slugs_sorted = sorted(slugs, key=lambda ps: len(ps[1]))
        seen_pairs: int = 0
        max_pairs = 500_000  # ~600ms wall on a typical machine
        emitted: set[tuple[str, str]] = set()
        for i, (p1, slug1) in enumerate(slugs_sorted):
            len1 = len(slug1)
            min_len = max(1, int(len1 * 0.7))
            max_len = max(len1, int(len1 * 1.43))
            for p2, slug2 in slugs_sorted[i + 1:]:
                len2 = len(slug2)
                if len2 > max_len:
                    break  # sorted ascending, all subsequent are too long
                if len2 < min_len:
                    continue
                seen_pairs += 1
                if seen_pairs > max_pairs:
                    # Hard cap: fall back to prefix grouping for the rest.
                    break
                if slug1 != slug2 and SequenceMatcher(None, slug1, slug2).ratio() > 0.80:
                    key = tuple(sorted((p1, p2)))
                    if key not in emitted:
                        emitted.add(key)
                        issues.append({
                            "type": "duplicate",
                            "severity": "warning",
                            "message": f'Similar slugs: {p1} and {p2} — may be duplicates',
                        })
            if seen_pairs > max_pairs:
                break
        if seen_pairs > max_pairs:
            issues.append({
                "type": "info",
                "severity": "info",
                "message": (
                    f"Slug-similarity check capped at {max_pairs} comparisons "
                    f"(vault has {n} non-source articles). "
                    "Re-run lint on a smaller subset to surface deeper duplicates."
                ),
            })

        # 6. Stale articles (source_summary pointing to deleted files)
        for path, info in articles.items():
            if info["status"] == "stale":
                issues.append({
                    "type": "stale",
                    "severity": "warning",
                    "message": f'Article "{info["title"]}" ({path}) is marked stale',
                })

        # Build articles_text dict for LLM prompt
        articles_text = {path: info["content"] for path, info in articles.items()}

        return issues, articles_text

    async def lint(self, slug: str) -> dict:
        """Public entry — scoped pipeline wrapper around ``_body_lint``."""
        self._log_activity(
            "lint_started",
            f"KB {slug}: lint started",
            {"slug": slug},
            importance="low",
        )
        try:
            async with self._scoped_pipeline("kb_lint", "lint"):
                result = await self._body_lint(slug)
                _issues = len(result.get("issues", [])) if isinstance(result, dict) else 0
                self._log_activity(
                    "lint_completed",
                    f"KB {slug}: lint found {_issues} issue(s)",
                    {"slug": slug, "issue_count": _issues},
                    importance="medium" if _issues else "low",
                )
                await self._record_decision(
                    action_type="lint_batch",
                    action_target=f"kb/{slug}",
                    rationale=f"Lint found {_issues} issues",
                    details={"slug": slug, "issue_count": _issues},
                )
                return result
        except Exception as exc:
            self._log_activity(
                "lint_failed",
                f"KB {slug}: lint raised {type(exc).__name__}",
                {"slug": slug, "error": str(exc)[:500]},
                importance="high",
            )
            await self._record_decision(
                action_type="lint_batch",
                action_target=f"kb/{slug}",
                contract_result="failed",
                rationale=f"Lint aborted: {type(exc).__name__}",
                details={"slug": slug, "error": str(exc)[:500]},
            )
            raise

    async def _body_lint(self, slug: str) -> dict:
        """Run comprehensive wiki health-check: structural analysis + LLM semantic review."""
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        schema = self._read_schema(slug)
        index_doc = self._reader.read(f"{base}/wiki/.index.md")

        # Phase 1: Structural analysis (fast, no LLM)
        structural_issues, articles_content = self._structural_analysis(slug)

        structural_text = "\n".join(
            f"- [{i['type']}] ({i['severity']}) {i['message']}" for i in structural_issues
        ) or "No structural issues found."

        # Phase 2: Semantic analysis (LLM)
        articles_text = []
        for path, content in articles_content.items():
            articles_text.append(f"### {path}\n{content[:1500]}")

        # Build lint prompt: skill-loaded or hardcoded fallback
        dynamic_ctx = (
            f"\nKB SCHEMA:\n{schema[:1000]}\n\n"
            f"WIKI INDEX:\n{index_doc.content[:3000]}\n\n"
            f"STRUCTURAL ISSUES:\n{structural_text[:3000]}\n\n"
            f"ARTICLES:\n{chr(10).join(articles_text)[:10000]}"
        )
        skill_prompt = self._get_skill_prompt("kb-lint")
        if skill_prompt:
            lint_system = skill_prompt + "\n\n" + dynamic_ctx
        else:
            lint_system = _LINT_PROMPT_FALLBACK.format(
                schema=schema[:1000], index=index_doc.content[:3000],
                structural_issues=structural_text[:3000],
                articles="\n\n".join(articles_text)[:10000],
            )
        try:
            response = await asyncio.wait_for(self._llm.complete(
                messages=[Message(role="user", content="Run deep wiki health check. Focus on semantic issues the structural analysis can't detect.")],
                system=lint_system,
                max_tokens=2048, temperature=0.3,
                model=self._resolve_model("fast" if self._is_local_mode() else "capable"),  # editor: lint
            ), timeout=self._llm_timeout("lint"))
        except TimeoutError:
            log.warning("KB %s: lint LLM timed out", slug)
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("lint", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "error": "LLM timed out", "queued": True}
        except Exception as exc:
            log.warning("KB %s: lint LLM failed: %s", slug, exc)
            if self._called_from_queue:
                raise
            if self._work_queue:
                asyncio.create_task(self._work_queue.enqueue("lint", {"slug": slug}, deduplicate=True))
            return {"slug": slug, "structural_issues": structural_issues, "llm_issues": [],
                    "suggestions": [], "error": f"LLM unavailable (structural results only): {exc}", "queued": True}

        llm_issues_text = self._extract_block(response.content, "issues") or ""
        suggestions_text = self._extract_block(response.content, "suggestions") or ""
        llm_issues = [l.strip() for l in llm_issues_text.split("\n") if l.strip().startswith("- ")]
        suggestion_list = [l.strip() for l in suggestions_text.split("\n") if l.strip().startswith("- ")]

        # Combine structural + semantic issues
        all_issues = [f"- [{i['type']}] {i['message']}" for i in structural_issues] + llm_issues

        self._append_log(slug, "lint",
            f"{len(structural_issues)} structural + {len(llm_issues)} semantic issues, {len(suggestion_list)} suggestions")
        return {
            "slug": slug,
            "issues": all_issues,
            "suggestions": suggestion_list,
            "issue_count": len(all_issues),
            "suggestion_count": len(suggestion_list),
            "structural_issues": len(structural_issues),
            "semantic_issues": len(llm_issues),
        }

    # ── Schema co-evolution ──

    async def evolve_schema(self, slug: str) -> dict:
        """Analyze the KB and propose SCHEMA.md updates.

        Detects patterns like:
        - Entity types used in articles but not listed in schema
        - Taxonomy categories that articles cluster around but aren't documented
        - Conventions that emerged organically (e.g., recurring frontmatter fields)
        - New focus questions suggested by the content

        Applies approved proposals directly to SCHEMA.md.
        """
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        schema = self._read_schema(slug)
        if not schema:
            return {"error": f"No SCHEMA.md found for KB {slug}"}

        # Gather evidence: what entity_types, categories, and patterns exist
        entity_types_used: dict[str, int] = {}
        categories_used: dict[str, int] = {}
        taxonomy_paths_used: dict[str, int] = {}
        frontmatter_fields: dict[str, int] = {}
        article_count = 0

        for subdir in _WIKI_SUBDIRS:
            try:
                for wf in self._list_wiki_articles(f"{base}/wiki/{subdir}"):
                    rel = self._to_logical(wf)
                    doc = self._reader.read(rel)
                    meta = doc.metadata or {}
                    article_count += 1

                    et = meta.get("entity_type", "")
                    if et:
                        entity_types_used[et] = entity_types_used.get(et, 0) + 1
                    cat = meta.get("category", "")
                    if cat:
                        categories_used[cat] = categories_used.get(cat, 0) + 1
                    tp = meta.get("taxonomy_path", "")
                    if tp:
                        taxonomy_paths_used[tp] = taxonomy_paths_used.get(tp, 0) + 1
                    for k in meta:
                        frontmatter_fields[k] = frontmatter_fields.get(k, 0) + 1
            except Exception:
                continue

        if article_count < 3:
            return {"slug": slug, "proposals": [], "message": "Too few articles to detect patterns"}

        # Check ontology fallback count
        fallback_count = 0
        graph = getattr(self, "_graph", None)
        if graph and hasattr(graph, "get_stats"):
            stats = graph.get_stats()
            fallback_count = stats.get("extraction_metrics", {}).get("related_to_fallbacks", 0)

        # Build evidence for the LLM
        evidence = (
            f"KB: {slug} ({article_count} articles)\n\n"
            f"Current SCHEMA.md:\n```\n{schema}\n```\n\n"
            f"Entity types actually used (type: count):\n"
            + "\n".join(f"  - {t}: {c}" for t, c in sorted(entity_types_used.items(), key=lambda x: -x[1]))
            + "\n\nCategories used:\n"
            + "\n".join(f"  - {c}: {n}" for c, n in sorted(categories_used.items(), key=lambda x: -x[1]))
            + "\n\nTaxonomy paths used:\n"
            + "\n".join(f"  - {p}: {n}" for p, n in sorted(taxonomy_paths_used.items(), key=lambda x: -x[1])[:20])
            + "\n\nFrontmatter fields across articles:\n"
            + "\n".join(f"  - {f}: {n}/{article_count}" for f, n in sorted(frontmatter_fields.items(), key=lambda x: -x[1]))
        )
        if fallback_count > 0:
            evidence += f"\n\nOntology `related_to` fallbacks (unknown predicates): {fallback_count}"

        prompt = (
            "You are a knowledge base architect. Analyze this KB's actual usage patterns "
            "and propose concrete updates to SCHEMA.md.\n\n"
            "Look for:\n"
            "1. Entity types used in articles but NOT listed in the schema's Entity Types section\n"
            "2. Categories or taxonomy clusters that suggest new schema sections\n"
            "3. Conventions that emerged (frontmatter fields used across many articles) not documented\n"
            "4. Focus questions the content suggests but aren't listed\n"
            "5. Missing subdirectory documentation\n\n"
            "Output a JSON array of proposals. Each proposal:\n"
            '```json\n[{"section": "Entity Types|Conventions|Focus Questions|Structure|Notes", '
            '"action": "add|update", "content": "the line(s) to add", '
            '"reason": "why this change"}]\n```\n'
            "Only propose changes NOT already in the schema. If the schema is complete, return `[]`.\n\n"
            + evidence
        )

        try:
            response = await asyncio.wait_for(
                self._llm.complete(
                    messages=[Message(role="user", content=prompt)],
                    system="You are a knowledge base schema architect. Return ONLY valid JSON.",
                    max_tokens=2048,
                    temperature=0.2,
                    model=self._resolve_model("fast"),
                ),
                timeout=self._llm_timeout("lint"),
            )
        except Exception:
            log.debug("Schema evolution LLM failed for %s", slug, exc_info=True)
            return {"slug": slug, "proposals": [], "error": "LLM unavailable"}

        # Parse proposals
        import json as _json
        proposals = []
        try:
            raw = response.content
            # Extract JSON from fenced block if present
            if "```json" in raw:
                raw = raw.split("```json", 1)[1].split("```", 1)[0]
            elif "```" in raw:
                raw = raw.split("```", 1)[1].split("```", 1)[0]
            proposals = _json.loads(raw.strip())
            if not isinstance(proposals, list):
                proposals = []
        except Exception:
            log.debug("Could not parse schema evolution proposals for %s", slug)

        # Apply proposals to SCHEMA.md
        applied = []
        if proposals:
            updated_schema = schema
            for p in proposals:
                section = p.get("section", "")
                content = p.get("content", "")
                reason = p.get("reason", "")
                if not section or not content:
                    continue

                # Find the section header and append content after it
                section_marker = f"## {section}"
                if section_marker in updated_schema:
                    # Insert after the section header and any existing content
                    parts = updated_schema.split(section_marker, 1)
                    # Find the next ## or end of file
                    rest = parts[1]
                    next_section = rest.find("\n## ")
                    if next_section > 0:
                        insert_point = next_section
                    else:
                        insert_point = len(rest)
                    # Append before the next section
                    updated_content = rest[:insert_point].rstrip() + f"\n{content}\n"
                    updated_schema = parts[0] + section_marker + updated_content + rest[insert_point:]
                    applied.append({"section": section, "content": content, "reason": reason})

            if applied and updated_schema != schema:
                with self._writer.trusted_scope("schema_evolution"):
                    self._writer.write(f"{base}/SCHEMA.md", updated_schema)
                self._append_log(slug, "schema-evolution",
                    f"Updated SCHEMA.md: {len(applied)} changes — "
                    + ", ".join(p["section"] for p in applied))
                self._log_activity(
                    "schema_evolved",
                    f"KB {slug}: SCHEMA.md updated with {len(applied)} proposals",
                    {"slug": slug, "applied": applied},
                    importance="medium",
                )

        return {
            "slug": slug,
            "proposals": proposals,
            "applied": applied,
            "article_count": article_count,
            "entity_types_found": entity_types_used,
        }

    # ── Repair ──

    async def repair(self, slug: str) -> dict:
        """Auto-fix wiki issues: delete orphans, remove duplicates, clean .history/ noise.

        Runs structural analysis, then acts on fixable issues:
        - Orphan articles (no inbound links, not sources): deleted
        - Duplicate titles: keeps the one with more content, deletes the other
        - .history/ files that leaked into wiki listings: cleaned up
        - Empty articles (< 50 chars of real content): deleted

        Does NOT require LLM. Fast, deterministic.
        """
        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        issues, articles_text = self._structural_analysis(slug)
        actions = {"deleted": [], "kept": [], "cleaned": []}

        # 1. Delete empty articles (< 50 chars of real content)
        for subdir in self._get_wiki_subdirs(slug):
            for wf in self._list_wiki_articles(f"{base}/wiki/{subdir}"):
                try:
                    doc = self._reader.read(f"{base}/wiki/{subdir}/{wf.name}")
                    # Strip frontmatter and whitespace
                    real = doc.content.strip()
                    if len(real) < 50:
                        full = self._resolve(f"{base}/wiki") / subdir / wf.name
                        if full.exists():
                            full.unlink()
                            actions["deleted"].append(f"{subdir}/{wf.stem} (empty)")
                except Exception:
                    continue

        # 2. Delete duplicate titles (keep the one with more content)
        title_map: dict[str, list[tuple[str, int]]] = {}  # norm_title → [(path, content_len)]
        for issue in issues:
            if issue["type"] == "duplicate" and "same title" in issue["message"]:
                # Extract both paths from the message
                import re as _re
                paths = _re.findall(r'\(([^)]+)\)', issue["message"])
                if len(paths) >= 2:
                    for p in paths:
                        content = articles_text.get(p, "")
                        title_map.setdefault(issue["message"], []).append((p, len(content)))

        for _msg, entries in title_map.items():
            if len(entries) < 2:
                continue
            # Keep the one with more content
            entries.sort(key=lambda x: x[1], reverse=True)
            keep = entries[0][0]
            for path, _size in entries[1:]:
                subdir, stem = path.split("/", 1) if "/" in path else ("", path)
                full = self._resolve(f"{base}/wiki") / subdir / f"{stem}.md"
                if full.exists():
                    full.unlink()
                    actions["deleted"].append(f"{path} (duplicate of {keep})")
            actions["kept"].append(keep)

        # 3. Clean up .history/ files that shouldn't exist (e.g., in non-wiki dirs)
        try:
            history_dirs = list((self._resolve(f"{base}/wiki")).rglob(".history"))
            for hd in history_dirs:
                if hd.is_dir():
                    # Count files
                    history_files = list(hd.glob("*.md"))
                    if len(history_files) > 20:
                        # Keep only 20 most recent per article stem
                        stems: dict[str, list] = {}
                        for hf in history_files:
                            stem = hf.stem.rsplit("_", 1)[0] if "_" in hf.stem else hf.stem
                            stems.setdefault(stem, []).append(hf)
                        for stem, files in stems.items():
                            files.sort(key=lambda f: f.name, reverse=True)
                            for old in files[20:]:
                                old.unlink()
                                actions["cleaned"].append(str(old.name))
        except Exception:
            pass

        self._append_log(slug, "repair",
            f"deleted {len(actions['deleted'])}, cleaned {len(actions['cleaned'])}")

        return {
            "slug": slug,
            "deleted": actions["deleted"],
            "kept": actions["kept"],
            "cleaned": actions["cleaned"],
            "total_actions": len(actions["deleted"]) + len(actions["cleaned"]),
        }

    async def repair_citations(
        self,
        slug: str | None = None,
        *,
        dry_run: bool = False,
    ) -> dict:
        """Public entry point — wraps the body in the canonical ``_scoped_pipeline``
        so writes pass the VaultWriter runtime guard. See ``_do_repair_citations``
        for the implementation.
        """
        if dry_run:
            # Dry-run performs zero writes — no need for a trusted_scope.
            return await self._do_repair_citations(slug, dry_run=True)
        async with self._scoped_pipeline(
            "kb_citations_repair", scope_prefix="citations-repair"
        ):
            return await self._do_repair_citations(slug, dry_run=False)

    async def _do_repair_citations(
        self,
        slug: str | None = None,
        *,
        dry_run: bool = False,
    ) -> dict:
        """Backfill numberification on wiki articles that still carry raw
        ``[Source: filename]`` markers without a matching ``## References``
        section or ``citations:`` frontmatter.

        Closes the historic gap where the compile CREATE path silently skipped
        ``_numberify_sources`` when the batch had no source summaries (e.g. a
        stubs-only rewrite pass). The LLM still emitted ``[Source: X]`` markers
        from the stub's existing references, but numberification was gated on
        ``source_fnames`` being non-empty — so the prose kept raw markers while
        the UI's References panel rendered ``1. X`` from the bibliography
        registry, producing the disagreement between inline ``[1]`` and list
        ``1.`` numbers the user saw.

        Idempotent: ``_renumberify_article`` reverses prior ``[N]`` markers
        back to filenames using the ``citations:`` frontmatter before the next
        pass, so running this method twice is a no-op.

        Args:
            slug: KB slug to repair. ``None`` scans every KB.
            dry_run: If True, return the list of candidate articles without
                mutating the vault.

        Returns:
            ``{scanned, needs_repair, repaired, errors, dry_run, articles, error_details}``
        """
        if slug is None:
            kbs = [kb["slug"] for kb in self.list_kbs()]
        else:
            if not self._reader.exists(f"knowledge/{slug}/wiki/.index.md"):
                return {"error": f"KB not found: {slug}"}
            kbs = [slug]

        scanned = 0
        repaired: list[dict] = []
        candidates: list[dict] = []
        errors: list[dict] = []

        for kb_slug in kbs:
            base = f"knowledge/{kb_slug}"
            try:
                wiki_files = self._reader.list_files(f"{base}/wiki", "**/*.md")
            except Exception as exc:
                errors.append({"kb": kb_slug, "error": f"list_files: {exc}"})
                continue

            for wf in wiki_files:
                name = wf.name
                path_str = str(wf).replace("\\", "/")
                # Skip: catalogs/index files, dotfiles, history, archives
                if name.startswith("_") or name.startswith("."):
                    continue
                if "/.history/" in path_str or "/.archives/" in path_str:
                    continue

                try:
                    rel = self._to_logical(wf)
                except Exception:
                    continue

                scanned += 1
                try:
                    doc = self._reader.read(rel)
                    content = doc.raw
                except Exception as exc:
                    errors.append({"path": rel, "error": f"read: {exc}"})
                    continue

                head, body = self._split_frontmatter(content)

                # Count ONLY well-formed `[Source: filename]` markers — skip
                # `[[Source: filename|Title]]` wikilink-style leakage from old
                # LLM output. Those can't be cleanly numberified without also
                # stripping the wikilink wrappers; treat them as pre-existing
                # data corruption and leave alone. The negative-lookbehind
                # ensures we don't count `[Source:` preceded by another `[`.
                has_raw_markers = bool(
                    re.search(r"(?<!\[)\[Source:\s*[^\]\[]+?\]", body)
                )
                has_refs = bool(self._REFERENCES_BLOCK_RE.search(body))
                has_citations_fm = bool(self._read_citations_frontmatter(head))

                # Detect old-format References section that uses "[N] citation"
                # as the list prefix instead of the canonical markdown ordered
                # list "N. citation". These articles were numberified before the
                # format switch and need a reformat pass. `_renumberify_article`
                # is idempotent — re-running on an already-numberified article
                # reads the citations frontmatter, reverses [N] back to
                # [Source: filename], strips the old References block, and
                # rebuilds it in the new "N. " prefix form.
                old_format_refs = False
                if has_refs:
                    refs_match = self._REFERENCES_BLOCK_RE.search(body)
                    if refs_match:
                        refs_text = refs_match.group(0)
                        has_old = bool(re.search(r"^\[\d+\]\s", refs_text, flags=re.MULTILINE))
                        has_new = bool(re.search(r"^\d+\.\s", refs_text, flags=re.MULTILINE))
                        old_format_refs = has_old and not has_new

                # Already clean: numberified inline + new-format refs + citations fm.
                if (
                    not has_raw_markers
                    and has_refs
                    and has_citations_fm
                    and not old_format_refs
                ):
                    continue

                # Nothing to repair: no markers, no refs section — pristine source
                # summaries, stub pages, or articles that never cited anything.
                if not has_raw_markers and not has_refs:
                    continue

                if has_raw_markers and not has_refs:
                    reason = "missing_references_section"
                elif has_raw_markers and not has_citations_fm:
                    reason = "missing_citations_frontmatter"
                elif old_format_refs:
                    reason = "old_bracket_format"
                elif has_raw_markers:
                    reason = "mixed_state_raw_markers"
                else:
                    reason = "mixed_state"
                entry = {"kb": kb_slug, "path": rel, "reason": reason}
                candidates.append(entry)

                if dry_run:
                    continue

                try:
                    self._renumberify_article(rel, [], kb_slug)
                    repaired.append(entry)
                    self._log_activity(
                        "wiki_citations_repaired",
                        f"Repaired citations in {rel}",
                        {"kb": kb_slug, "path": rel, "reason": reason},
                        importance="medium",
                    )
                except Exception as exc:
                    errors.append({"path": rel, "error": f"renumberify: {exc}"})

        if not dry_run and (repaired or errors):
            self._append_log(
                slug or "all",
                "repair_citations",
                f"repaired {len(repaired)} of {len(candidates)} candidate articles "
                f"across {len(kbs)} KBs, {len(errors)} errors",
            )

        return {
            "scanned": scanned,
            "needs_repair": len(candidates),
            "repaired": len(repaired),
            "errors": len(errors),
            "dry_run": dry_run,
            "articles": candidates if dry_run else repaired,
            "error_details": errors[:20],
            "kb_count": len(kbs),
        }

    async def sync_projects_to_wiki(self, slug: str) -> dict:
        """Ensure all vault projects have wiki pages in this KB.

        For each project that doesn't have a wiki namespace yet,
        creates a project wiki page with overview, status, and links.
        """
        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        # Get all projects from vault
        projects_dir = self._resolve("projects")
        if not projects_dir.is_dir():
            return {"slug": slug, "synced": 0, "message": "No projects directory"}

        synced = 0
        for proj_dir in projects_dir.iterdir():
            if not proj_dir.is_dir():
                continue
            project_md = proj_dir / "PROJECT.md"
            if not project_md.exists():
                continue

            proj_slug = proj_dir.name
            wiki_path = f"{base}/wiki/projects/{proj_slug}/{proj_slug}.md"

            # Skip if already exists
            if self._reader.exists(wiki_path):
                continue

            # Read project data
            try:
                import frontmatter as fm
                post = fm.load(str(project_md))
                title = post.metadata.get("title", proj_slug.replace("-", " ").title())
                status = post.metadata.get("status", "active")
                description = post.content[:500] if post.content else ""
                goals = post.metadata.get("related_goals", [])
                agents = post.metadata.get("assigned_agents", [])
                kbs = post.metadata.get("linked_kbs", [])
            except Exception:
                title = proj_slug.replace("-", " ").title()
                status = "active"
                description = ""
                goals = []
                agents = []
                kbs = []

            # Create project wiki page
            content = f"""---
title: "{title}"
entity_type: project
category: projects
status: {status}
author: agent
agent: wiki-supervisor
date_created: "{datetime.now(UTC).strftime('%Y-%m-%d %H:%M')}"
---

# {title}

{description}

## Status

{status}

## Links

"""
            if goals:
                content += "### Goals\n" + "\n".join(f"- [[{g}]]" for g in goals) + "\n\n"
            if agents:
                content += "### Agents\n" + "\n".join(f"- [[{a}]]" for a in agents) + "\n\n"
            if kbs:
                content += "### Knowledge Bases\n" + "\n".join(f"- {kb}" for kb in kbs) + "\n\n"

            # Write
            (self._resolve(f"{base}/wiki/projects") / proj_slug).mkdir(parents=True, exist_ok=True)
            self._writer.write(wiki_path, content)
            synced += 1
            log.info("Synced project to wiki: %s → %s", proj_slug, wiki_path)

        if synced:
            self._append_log(slug, "sync-projects", f"{synced} projects synced to wiki")

        return {"slug": slug, "synced": synced}

    # ── Article Consolidation ──

    async def consolidate_articles(self, slug: str, min_updates: int = 3) -> dict:
        """Find and rewrite append-only articles that have accumulated updates.

        Articles with `min_updates` or more "Updated from compile" markers get
        rewritten by the LLM into a single coherent article. Old version is
        automatically saved to .history/ by the vault writer.

        Returns count of articles consolidated.
        """
        from agntspace.llm.base import Message

        base = f"knowledge/{slug}"
        wiki_base = f"{base}/wiki"

        if not self._reader.exists(f"{wiki_base}/.index.md"):
            return {"error": f"KB not found: {slug}"}

        candidates = []
        for subdir in ("entities", "concepts", "sources", "synthesis", "decisions"):
            dir_path = f"{wiki_base}/{subdir}"
            try:
                files = self._reader.list_files(dir_path, "*.md")
            except Exception:
                continue
            for f in files:
                if f.name.startswith(("_", ".")):
                    continue
                try:
                    relative = f"{dir_path}/{f.name}"
                    doc = self._reader.read(relative)
                    update_count = doc.content.count("Updated from compile")
                    if update_count >= min_updates:
                        candidates.append({
                            "path": relative,
                            "title": doc.metadata.get("title", f.stem),
                            "update_count": update_count,
                            "size": len(doc.content),
                        })
                except Exception:
                    continue

        if not candidates:
            return {"slug": slug, "consolidated": 0, "message": "No articles need consolidation"}

        # Sort by most updates first
        candidates.sort(key=lambda x: x["update_count"], reverse=True)

        consolidated = 0
        for article in candidates[:10]:  # max 10 per run
            try:
                doc = self._reader.read(article["path"])
                frontmatter_raw = doc.raw[:doc.raw.index("---", 3) + 3] if "---" in doc.raw[3:] else ""

                consolidate_system = self._get_skill_prompt("kb-consolidate") or _CONSOLIDATE_PROMPT_FALLBACK
                try:
                    response = await asyncio.wait_for(
                        self._llm.complete(
                            messages=[Message(role="user", content=doc.content)],
                            system=consolidate_system,
                            max_tokens=4096,
                            temperature=0.3,
                            model=self._resolve_model("capable"),  # editor: consolidate
                        ),
                        timeout=self._llm_timeout("consolidate", default=120),
                    )
                except (TimeoutError, Exception) as llm_exc:
                    log.warning(
                        "KB %s: consolidate LLM failed for %s: %s",
                        slug, article["path"], llm_exc,
                    )
                    self._log_activity(
                        "consolidate_llm_failed",
                        f"Consolidation LLM failed for {article['path']}",
                        {"slug": slug, "path": article["path"], "error": str(llm_exc)[:200]},
                        importance="medium",
                    )
                    if self._called_from_queue:
                        # Let the work queue's backoff machinery handle it.
                        raise
                    if self._work_queue:
                        asyncio.create_task(self._work_queue.enqueue(
                            "consolidate", {"slug": slug}, deduplicate=True,
                        ))
                    continue

                new_content = response.content.strip()
                if not new_content or len(new_content) < 100:
                    log.warning(
                        "KB %s: consolidate produced empty/short output for %s (%d chars) — enqueueing retry",
                        slug, article["path"], len(new_content),
                    )
                    self._log_activity(
                        "consolidate_empty_output",
                        f"Consolidation LLM returned {len(new_content)} chars for {article['path']}",
                        {"slug": slug, "path": article["path"], "chars": len(new_content)},
                        importance="medium",
                    )
                    if not self._called_from_queue and self._work_queue:
                        asyncio.create_task(self._work_queue.enqueue(
                            "consolidate", {"slug": slug}, deduplicate=True,
                        ))
                    continue

                # Preserve frontmatter, replace body
                if frontmatter_raw:
                    full = f"{frontmatter_raw}\n\n{new_content}"
                else:
                    full = new_content

                # Write (versioning happens automatically in VaultWriter)
                self._writer.write(article["path"], full)
                consolidated += 1
                log.info("Consolidated article: %s (%d updates merged)",
                         article["path"], article["update_count"])
                self._log_activity(
                    "consolidate_success",
                    f"Consolidated {article['path']} ({article['update_count']} updates merged)",
                    {"slug": slug, "path": article["path"], "updates": article["update_count"]},
                    importance="low",
                )
            except Exception as e:
                log.warning("Failed to consolidate %s: %s", article["path"], e)
                self._log_activity(
                    "consolidate_crashed",
                    f"Consolidation crashed on {article['path']}: {type(e).__name__}",
                    {"slug": slug, "path": article["path"], "error": str(e)[:200]},
                    importance="high",
                )
                continue

        self._log(slug, "consolidate", f"{consolidated} articles consolidated")
        return {"slug": slug, "consolidated": consolidated, "candidates": len(candidates)}

    # ── Full Pipeline ──

    async def run_wiki_job(self, slug: str, *, skip_lint: bool = False, skip_reflect: bool = False) -> dict:
        """Run the full wiki pipeline: snapshot → ingest → compile → reflect → lint.

        This is the single entry point for a complete wiki processing cycle.
        Designed to be called from chat (agent tool) or API.
        """
        base = f"knowledge/{slug}"
        if not self._reader.exists(f"{base}/wiki/.index.md"):
            return {"error": f"KB not found: {slug}"}

        results: dict[str, Any] = {"slug": slug, "steps": []}

        # 1. Snapshot (safety net before processing)
        try:
            snap = self.snapshot(slug, reason="pre-wiki-job")
            results["snapshot"] = snap.get("path", "")
            results["steps"].append("snapshot")
        except Exception:
            log.warning("KB %s: snapshot failed, continuing anyway", slug)

        # 2. Ingest (process inbox files)
        try:
            ingest_result = await self.ingest(slug)
            results["ingested"] = ingest_result.get("count", 0)
            results["ingested_files"] = ingest_result.get("ingested", [])
            results["steps"].append("ingest")
        except Exception:
            log.exception("KB %s: ingest failed during wiki job", slug)
            results["ingested"] = 0
            results["ingest_error"] = True

        # 3. Compile (generate/update wiki articles)
        try:
            compile_result = await self.compile_wiki(slug)
            results["compiled"] = compile_result.get("count", 0)
            results["compiled_articles"] = compile_result.get("articles", [])
            results["steps"].append("compile")
        except Exception:
            log.exception("KB %s: compile failed during wiki job", slug)
            results["compiled"] = 0
            results["compile_error"] = True

        # 4. Synthesize (cross-source insights — only if 2+ sources exist)
        if results.get("ingested", 0) > 0 or results.get("compiled", 0) > 0:
            try:
                synth_result = await self.synthesize(slug)
                results["synthesized"] = synth_result.get("count", 0)
                if synth_result.get("count", 0) > 0:
                    results["steps"].append("synthesize")
            except Exception:
                log.exception("KB %s: synthesize failed during wiki job", slug)

        # 5. Cross-link (scan for unlinked mentions, add [[wikilinks]])
        if results.get("ingested", 0) > 0 or results.get("compiled", 0) > 0:
            try:
                xlink = self.cross_link(slug)
                results["cross_links_added"] = xlink.get("links_added", 0)
                if xlink.get("links_added", 0) > 0:
                    results["steps"].append("cross-link")
            except Exception:
                log.exception("KB %s: cross-link failed during wiki job", slug)

        # 6. Reflect (structural analysis — only if something changed)
        if not skip_reflect and (results.get("ingested", 0) > 0 or results.get("compiled", 0) > 0):
            try:
                reflect_result = await self.reflect(
                    slug,
                    articles_created=results.get("compiled_articles", []),
                    files_ingested=results.get("ingested_files", []),
                )
                results["decisions_created"] = reflect_result.get("decisions_created", 0)
                results["reflect_message"] = reflect_result.get("message", "")
                results["steps"].append("reflect")
            except Exception:
                log.exception("KB %s: reflect failed during wiki job", slug)
                results["reflect_error"] = True

        # 6. Lint (health check)
        if not skip_lint:
            try:
                lint_result = await self.lint(slug)
                results["issues"] = lint_result.get("issue_count", 0)
                results["suggestions"] = lint_result.get("suggestion_count", 0)
                results["lint_issues"] = lint_result.get("issues", [])[:10]  # top 10
                results["lint_suggestions"] = lint_result.get("suggestions", [])[:5]
                results["steps"].append("lint")
            except Exception:
                log.exception("KB %s: lint failed during wiki job", slug)
                results["lint_error"] = True

        # 7. Schema evolution (propose SCHEMA.md updates based on patterns)
        if results.get("compiled", 0) > 0 or results.get("ingested", 0) > 0:
            try:
                evo = await self.evolve_schema(slug)
                if evo.get("applied"):
                    results["schema_proposals"] = len(evo["applied"])
                    results["schema_changes"] = [p["section"] for p in evo["applied"]]
                    results["steps"].append("schema-evolution")
            except Exception:
                log.debug("KB %s: schema evolution failed", slug, exc_info=True)

        # 8. Enrich index with per-article metadata
        try:
            self._enrich_index(slug)
            results["steps"].append("index-enrich")
        except Exception:
            log.debug("KB %s: index enrichment failed", slug, exc_info=True)

        self._append_log(slug, "wiki-job",
            f"Pipeline complete: {results.get('ingested', 0)} ingested, "
            f"{results.get('compiled', 0)} compiled, "
            f"{results.get('synthesized', 0)} synthesized, "
            f"{results.get('cross_links_added', 0)} cross-links, "
            f"{results.get('decisions_created', 0)} decisions, "
            f"{results.get('issues', '?')} issues")

        return results

    # ── Reclassify ──

    def reclassify_articles(self, slug: str | None = None) -> dict:
        """Re-run taxonomy classification on all articles and update their frontmatter.

        Call after taxonomy YAML changes to migrate all articles to new paths.
        If slug is None, reclassifies across all KBs.
        """
        from agntspace.kb.taxonomy import classify_article, flatten_taxonomy, load_taxonomy

        if not self._skills_dir:
            return {"error": "No skills directory configured", "updated": 0}

        tree = load_taxonomy(self._skills_dir)
        flat_nodes = flatten_taxonomy(tree)
        slugs = [slug] if slug else [kb["slug"] for kb in self.list_kbs()]
        updated = 0
        total = 0

        for kb_slug in slugs:
            base = f"knowledge/{kb_slug}/wiki"
            for subdir in _WIKI_SUBDIRS:
                try:
                    files = self._reader.list_files(f"{base}/{subdir}", "*.md")
                except Exception:
                    continue
                for f in files:
                    total += 1
                    try:
                        rel = f"knowledge/{kb_slug}/wiki/{subdir}/{f.name}"
                        doc = self._reader.read(rel)
                        meta = doc.metadata
                        old_path = meta.get("taxonomy_path", "")

                        new_path = classify_article(
                            entity_type=meta.get("entity_type", ""),
                            category=meta.get("category", ""),
                            title=meta.get("title", f.stem.replace("-", " ").title()),
                            flat_nodes=flat_nodes,
                        )

                        if new_path != old_path:
                            content = self._update_frontmatter(doc.raw, "taxonomy_path", new_path)
                            self._writer.write(rel, content)
                            updated += 1
                    except Exception:
                        continue

        return {"updated": updated, "total": total, "kbs": slugs}

    # ── Utility ──

    @staticmethod
    def _extract_block(text: str, label: str, max_chars: int = 10_000) -> str | None:
        marker = f"```{label}"
        start = text.find(marker)
        if start == -1:
            return None
        start = text.index("\n", start) + 1
        end = text.find("```", start)
        if end == -1:
            # Unclosed fence — cap at max_chars to prevent runaway extraction
            result = text[start:start + max_chars].strip()
            if result:
                log.warning("_extract_block(%s): unclosed fence, capped at %d chars", label, max_chars)
            return result
        return text[start:end].strip()

    # Domains that indicate a placeholder/fabricated URL — never render these.
    _FAKE_URL_DOMAINS = {"example.com", "localhost", "placeholder", "fake.com", "test.test"}  # arch:deterministic — domain blocklist for URL validation, not content strategy

    @classmethod
    def _is_fake_url(cls, url: str) -> bool:
        """Return True when a URL looks fabricated (placeholder domain)."""
        if not url:
            return False
        low = url.lower()
        return any(d in low for d in cls._FAKE_URL_DOMAINS)

    # Regex matching [Source: filename.ext] markers in article prose.
    # Handles optional whitespace around the filename and both .md and other extensions.
    _SOURCE_REF_RE = re.compile(r"\[Source:\s*([^\]]+?)\s*\]")
    # Regex matching standalone [N] numeric citations (for reverse-mapping).
    _NUMERIC_REF_RE = re.compile(r"\[(\d+)\]")
    # Regex matching an existing ## References block at end of article.
    _REFERENCES_BLOCK_RE = re.compile(r"\n*## References\s*\n.*\Z", flags=re.DOTALL)

    def _numberify_sources(self, content: str, source_files: list[str], kb_slug: str) -> str:
        """Replace ``[Source: filename]`` markers with ``[N]`` + append numbered references.

        Idempotent: every call persists the ordered filename list in the article's
        frontmatter as ``citations:``. On re-run, the previous citations are used to
        reverse inline ``[N]`` markers back to ``[Source: filename]`` so new append
        operations can extend the list without orphaning old numbers. This is the
        single post-processor that turns LLM output into Wikipedia-style numbered
        inline citations, and it is called from every wiki write path (compile
        CREATE, compile UPDATE, synthesize, research).

        Numbers are assigned by order of first appearance in the prose.
        Sources that appear in ``source_files`` but are never cited in the text
        are appended at the end of the reference list so no source is lost.
        """
        # Split frontmatter off so the numeric-ref reversal never touches metadata.
        head, body = self._split_frontmatter(content)
        prev_citations = self._read_citations_frontmatter(head)

        # Step 0: if the article was previously numberified, reverse [N] → [Source: filename]
        # BEFORE stripping the old References block — so numbers still resolve.
        if prev_citations:
            def _reverse(m: re.Match) -> str:
                idx = int(m.group(1))
                if 1 <= idx <= len(prev_citations):
                    return f"[Source: {prev_citations[idx - 1]}]"
                return m.group(0)
            body = self._NUMERIC_REF_RE.sub(_reverse, body)

        # Strip any existing ## References section (will be rebuilt from scratch).
        body = self._REFERENCES_BLOCK_RE.sub("", body).rstrip()

        # Pass 1: collect all [Source: ...] markers in order of first appearance.
        seen_order: list[str] = []  # filenames in first-appearance order
        seen_set: set[str] = set()

        for m in self._SOURCE_REF_RE.finditer(body):
            fname = m.group(1).strip()
            if fname and fname not in seen_set:
                seen_order.append(fname)
                seen_set.add(fname)

        # Add source_files that were never cited (so no source is lost).
        for fname in source_files:
            if fname and fname not in seen_set:
                seen_order.append(fname)
                seen_set.add(fname)

        if not seen_order:
            # Nothing to cite — drop any stale citations frontmatter field.
            head = self._write_citations_frontmatter(head, [])
            return head + body

        # Build filename → number mapping.
        num_map: dict[str, int] = {fname: i + 1 for i, fname in enumerate(seen_order)}

        # Pass 2: replace [Source: filename] with [N].
        def _replace_source_ref(m: re.Match) -> str:
            fname = m.group(1).strip()
            n = num_map.get(fname)
            return f"[{n}]" if n else m.group(0)

        body = self._SOURCE_REF_RE.sub(_replace_source_ref, body)

        # Pass 3: build numbered reference list as a proper markdown ordered list.
        # Format is "N. citation" (Wikipedia-style footnotes). Tight list (single
        # newline between items) so markdown renders one list, not N 1-item lists.
        # Inline markers stay [N] per academic convention; list prefix is N. so the
        # References body reads like a normal numbered list when viewed in Obsidian
        # or any markdown editor that doesn't render the UI's separate <ol> panel.
        ref_lines: list[str] = []
        for fname in seen_order:
            n = num_map[fname]
            bib = self._bibliography.find_by_source(fname, kb_slug) if self._bibliography else None
            if bib:
                citation = self._format_citation(bib, fname, kb_slug)
                ref_lines.append(f"{n}. {citation}")
            else:
                ref_lines.append(f"{n}. {Path(fname).stem.replace('-', ' ').replace('_', ' ')}")

        body = body + "\n\n## References\n\n" + "\n".join(ref_lines) + "\n"

        # Persist the ordered citations list so the next call is idempotent.
        head = self._write_citations_frontmatter(head, seen_order)
        return head + body

    @staticmethod
    def _split_frontmatter(content: str) -> tuple[str, str]:
        """Return (head_including_closing_delim, body). Head is '' when no frontmatter."""
        if not content.startswith("---"):
            return "", content
        # Find the closing ---
        end = content.find("\n---", 3)
        if end == -1:
            return "", content
        # Include the closing --- and its trailing newline (if present)
        tail_start = end + 4  # past "\n---"
        if tail_start < len(content) and content[tail_start] == "\n":
            tail_start += 1
        return content[:tail_start], content[tail_start:]

    @staticmethod
    def _read_citations_frontmatter(head: str) -> list[str]:
        """Parse ``citations: ["a.md", "b.md"]`` from frontmatter head. Returns []."""
        if not head:
            return []
        m = re.search(r"^citations:\s*\[(.*?)\]\s*$", head, flags=re.MULTILINE | re.DOTALL)
        if not m:
            return []
        raw = m.group(1)
        items: list[str] = []
        for tok in raw.split(","):
            tok = tok.strip().strip('"').strip("'")
            if tok:
                items.append(tok)
        return items

    @staticmethod
    def _write_citations_frontmatter(head: str, citations: list[str]) -> str:
        """Upsert a ``citations: [...]`` line in the frontmatter head.

        Removes the field when the list is empty. No-op when there is no
        frontmatter (keeps behavior safe for article fragments that bypass
        the normal write pipeline — the next full compile will re-emit it).
        """
        if not head:
            return head
        if citations:
            # json.dumps produces valid YAML-flow-style list of strings
            value_str = json.dumps(citations, ensure_ascii=False)
            new_line = f"citations: {value_str}"
            if re.search(r"^citations:\s*\[", head, flags=re.MULTILINE):
                head = re.sub(
                    r"^citations:\s*\[.*?\]\s*$",
                    new_line,
                    head,
                    count=1,
                    flags=re.MULTILINE | re.DOTALL,
                )
            else:
                # Insert before the closing --- delimiter
                closing = head.rfind("\n---")
                if closing != -1:
                    head = head[:closing] + f"\n{new_line}" + head[closing:]
        else:
            head = re.sub(
                r"^citations:\s*\[.*?\]\s*\n",
                "",
                head,
                count=1,
                flags=re.MULTILINE | re.DOTALL,
            )
        return head

    def _renumberify_article(self, full_path: str, source_files: list[str], kb_slug: str) -> None:
        """Read an on-disk wiki article, run ``_numberify_sources``, write it back.

        Thin wrapper around ``_numberify_sources`` for callers that operate on
        files already written to the vault (UPDATE path, synthesize, research).
        No-op when the article doesn't exist or the post-processor is disabled.
        """
        if not self._bibliography:
            return
        if not self._reader.exists(full_path):
            return
        try:
            existing = self._reader.read(full_path)
            content = existing.raw
            new_content = self._numberify_sources(content, source_files, kb_slug)
            if new_content != content:
                self._writer.write(full_path, new_content)
        except Exception:
            log.debug("KB %s: renumberify failed for %s", kb_slug, full_path, exc_info=True)

    def _build_bibliography_section(self, source_files: list[str], kb_slug: str) -> str:
        """Build a standalone bibliography section (used when _numberify_sources is not called).

        Output follows Wikipedia/Chicago style:
          Author, First (Date). "Title". *Publisher*. Vol(Issue): Pages.
          doi:10.xxx. ISSN xxxx-xxxx – via Platform. Retrieved Date.
        """
        entries: list[str] = []
        for i, fname in enumerate(source_files, 1):
            bib = self._bibliography.find_by_source(fname, kb_slug)
            if bib:
                citation = self._format_citation(bib, fname, kb_slug)
                entries.append(f"[{i}] {citation}")
            else:
                entries.append(f"[{i}] {Path(fname).stem.replace('-', ' ').replace('_', ' ')}")
        if not entries:
            return ""
        return "## References\n\n" + "\n\n".join(entries) + "\n"

    def _format_citation(self, bib: dict, fname: str, kb_slug: str) -> str:
        """Format a single bibliography entry in Wikipedia/Chicago style."""
        parts: list[str] = []

        # ── Authors ──
        authors = bib.get("authors", [])
        author_str = "; ".join(authors) if authors else ""
        if author_str:
            parts.append(author_str)

        # ── Date (in parentheses) ──
        date = bib.get("date", "")
        if date:
            parts.append(f"({date})")

        # ── Title (quoted, optionally linked) ──
        title = bib.get("title", fname)
        url = bib.get("url", "")

        # Never output fabricated URLs — Rule 16: no fabricated data
        if url and self._is_fake_url(url):
            url = ""
        publisher = bib.get("publisher", "")
        if publisher and self._is_fake_url(publisher):
            publisher = ""

        # Resolve local file path for clickable links
        local_path = ""
        if not url:
            lib_path = bib.get("library_path", "")
            if lib_path:
                local_path = f"knowledge/{kb_slug}/{lib_path}"
            else:
                try:
                    found = self._vault.list_files(f"knowledge/{kb_slug}/library", f"**/{fname}")
                    if found:
                        p = found[0]
                        if self._vault.paths:
                            local_path = self._vault.paths.to_logical(p)
                        else:
                            local_path = str(p.relative_to(self._vault.vault_dir)).replace("\\", "/")
                except Exception:
                    pass

        if url:
            parts.append(f'["{title}"]({url})')
        elif local_path:
            parts.append(f'["{title}"]({local_path})')
        else:
            parts.append(f'"{title}"')

        # ── Publisher / journal (italicized) ──
        if publisher:
            lang = bib.get("language", "")
            pub_str = f"*{publisher}*"
            if lang:
                pub_str += f" (in {lang})"
            parts.append(pub_str)

        # ── Volume, issue, pages (journal-style) ──
        vol = bib.get("volume", "")
        issue = bib.get("issue", "")
        pages = bib.get("pages", "")
        if vol or issue or pages:
            loc_parts: list[str] = []
            if vol and issue:
                loc_parts.append(f"{vol} ({issue})")
            elif vol:
                loc_parts.append(vol)
            elif issue:
                loc_parts.append(f"({issue})")
            if pages:
                loc_parts.append(pages)
            parts.append(": ".join(loc_parts))

        # ── DOI ──
        doi = bib.get("doi", "")
        if doi:
            doi_clean = doi.strip()
            if doi_clean.startswith("10."):
                parts.append(f"[doi:{doi_clean}](https://doi.org/{doi_clean})")
            else:
                parts.append(f"doi:{doi_clean}")

        # ── ISSN / ISBN ──
        issn = bib.get("issn", "")
        if issn:
            parts.append(f"ISSN {issn}")
        isbn = bib.get("isbn", "")
        if isbn:
            parts.append(f"ISBN {isbn}")

        # ── Via (hosting platform) ──
        via = bib.get("via", "")
        if via:
            parts.append(f"– via {via}")

        # ── Access date ──
        access_date = bib.get("access_date", "")
        if access_date:
            parts.append(f"Retrieved {access_date}")

        return ". ".join(parts) + "."

    # Junk patterns in LLM-extracted bibliography values — prompt echoes,
    # TODO markers, and placeholder instructions that should never be stored.
    _BIB_JUNK_RE = re.compile(  # arch:deterministic — prompt-echo detection regex for bibliography parsing
        r"^\[?TODO[:\s]|"                  # [TODO: ...] or TODO: ...
        r"^\(.*or\s+\"unknown\"\)$|"        # (comma-separated list of authors, or "unknown")
        r"^\(.*or\s+\"\"\)$|"               # (DOI if visible, or "")
        r"^\(.*if visible|"                 # (DOI if visible, ...)
        r"^unknown$|"                       # bare "unknown"
        r"^\[?no\s|"                        # [no title specified] etc.
        r"^n/?a$",                          # n/a, N/A
        re.IGNORECASE,
    )

    @classmethod
    def _is_bib_junk(cls, val: str) -> bool:
        """Return True if a bibliography value is a prompt echo or placeholder."""
        return bool(cls._BIB_JUNK_RE.search(val.strip()))

    # All bibliography fields the LLM can extract.
    _BIB_SIMPLE_FIELDS = frozenset({  # arch:deterministic — bibliography field names for structured parsing
        "title", "date", "publisher", "doi",
        "volume", "issue", "pages", "issn", "isbn", "language", "via",
    })

    @classmethod
    def _parse_bib_metadata(
        cls, bib_raw: str, filename: str, source_type: str, source_url: str, date_str: str,
    ) -> dict:
        """Parse the LLM-produced bibliography block into structured metadata."""
        meta: dict = {"source_type": source_type}
        for line in bib_raw.strip().split("\n"):
            line = line.strip().lstrip("- ")
            if ":" not in line:
                continue
            key, _, val = line.partition(":")
            key = key.strip().lower()
            val = val.strip().strip('"').strip("'")
            if not val or cls._is_bib_junk(val):
                continue
            if key == "authors":
                authors = [a.strip() for a in val.split(",") if a.strip() and not cls._is_bib_junk(a.strip())]
                if authors:
                    meta["authors"] = authors
            elif key in cls._BIB_SIMPLE_FIELDS:
                meta[key] = val
        # ── Validation gates (Rule 16: no fabricated data) ──

        # Date: must look like a date (YYYY, YYYY-MM, YYYY-MM-DD, or with time)
        if "date" in meta:
            d = meta["date"]
            if not re.match(r"^\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?(?:\s+\d{1,2}:\d{2})?$", d):
                del meta["date"]  # Malformed date — drop it

        # Publisher: reject if it looks like a fake domain
        if "publisher" in meta and cls._is_fake_url(meta["publisher"]):
            del meta["publisher"]

        # URL from source: reject fake domains at storage time (not just display)
        url_to_store = source_url or ""
        if url_to_store and cls._is_fake_url(url_to_store):
            url_to_store = ""

        # DOI: must start with 10. (standard DOI prefix)
        if "doi" in meta:
            doi = meta["doi"].strip()
            if not doi.startswith("10."):
                del meta["doi"]

        # Fallbacks
        if "title" not in meta:
            meta["title"] = Path(filename).stem.replace("-", " ").replace("_", " ")
        if url_to_store:
            meta["url"] = url_to_store
        if "date" not in meta:
            meta["date"] = date_str
        return meta

    @staticmethod
    def _update_frontmatter(content: str, key: str, value: str) -> str:
        """Update or insert a frontmatter key-value pair.

        Matches both quoted (``key: "value"``) and unquoted (``key: value``)
        forms so numeric fields like ``source_count: 0`` don't accumulate
        duplicate entries on every update.
        """
        import re
        # Match quoted values ("...") OR unquoted values (rest of line)
        pattern = rf'^{key}:\s*(?:"[^"]*"|.+)$'
        replacement = f'{key}: "{value}"'
        result = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
        if result == content:
            # Key not found — insert after opening ---
            result = content.replace("---\n", f"---\n{key}: \"{value}\"\n", 1)
        return result
