# Research

> Orchestrates multi-phase research pipeline: scoping, evidence collection, claim extraction, graph relationship enrichment, section brief synthesis, report composition, and publishing.

- Skill: `torpedod/research` (Agent Skill, multi-file: 56 files)
- Install (CLI): `npx skillmds@latest add torpedod/research`
- Raw SKILL.md: https://api.skillmd.com/api/skills/torpedod/research/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: torpedod (https://skillmd.com/u/torpedod)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/torpedod/research

---


# Research Orchestrator

Accepts a freeform research request, plans scope, manages a 7-phase claim-based pipeline with 4 human checkpoint gates, and produces a polished, citation-rich research document. Every claim is traceable to its source, every gap is detected and addressed, and every run is reproducible and auditable.

The orchestrator coordinates collection, claim extraction, graph relationship enrichment, section brief synthesis, report composition, and optional publishing. `output/report.md` is the canonical final report; HTML, PDF, and QMD are publishing products rendered from it.

## Claim Pipeline Contract

New runs use `manifest.pipeline_contract_version = "claim_pipeline_v1"` and these phases:

```text
planning
→ collection
→ claim_extraction
→ graph_relationships
→ section_brief_synthesis
→ formatting
→ publishing
```

`synthesis/claim_bank.json` is the canonical research state. `synthesis/raw_research.md` is deprecated; if a prose diagnostic is needed, write `synthesis/research_notes.md` and keep it out of the main handoff.

A global file is tiny only when it is under 20KB or under 300 lines. Downstream agents must not read full `claim_bank.json`, full `inventory.json`, full graph files, or all section briefs unless the file qualifies as tiny. Otherwise they consume per-section slices.

---

## Dependency install policy

The research pipeline must **NEVER** auto-install or auto-download these runtime dependencies:
- `crawl4ai` (and its Playwright/browser runtime)
- `playwright` / Playwright browsers
- `docling`

If any of these are missing when the pipeline needs them, the skill/agent **MUST**:
1. Detect the missing dependency.
2. Emit a clear message naming the missing tool and the install command the user can run themselves (e.g., `pipx install crawl4ai`, `pipx install docling`, `playwright install chromium`).
3. Stop the run — halt the phase with a non-zero exit. Do NOT proceed with a fallback collection mode.

Never execute `pip install`, `pipx install`, `npm install`, `playwright install`, `crawl4ai-setup`, `crawl4ai-doctor --install`, or any equivalent install command for these tools from within the pipeline.

**Exception (explicitly allowed):** The Quarto `quarto-ext/mermaid` extension is auto-installed by `scripts/publish.sh` when PDF output is selected. This is the ONLY auto-download permitted in this workflow.

## Run Mode Contract

Manifest mode fields are separate:

- `run_mode`: `normal`, `resume`, or `inspect`
- `collection_mode`: `web_and_docs`, `docs_only`, `web_only`, or `metadata_only`
- `validation_mode`: `normal` or `strict`

`source_channels = {"web": bool, "documents": bool}` is the source intent object.
`collection_mode=auto` is accepted by the initializer but is never persisted; it
resolves from `source_channels` before `manifest.json` is written. `metadata_only`
means the collection phase is skipped and no extraction tools are required.
Legacy manifests or CLI calls that say `none` are treated as `metadata_only`;
new manifests must persist `metadata_only`.

Hard requirements:

- `web_and_docs`: Crawl4AI, Playwright browser runtime, and Docling
- `docs_only`: Docling
- `web_only`: Crawl4AI and Playwright browser runtime
- `metadata_only`: no extraction tools; inventory/resume metadata only

`validation_mode=normal` blocks on required phase artifacts and warns on
nonessential audit artifacts. `validation_mode=strict` fails on missing audit
files, invalid schemas, or contract violations.

---

## Quick Start

1. User triggers with `/research "topic or question"`
2. Orchestrator checks for interrupted runs via `find_interrupted_runs()`
3. If interrupted runs exist: display them and offer to resume or start fresh
4. If new run: call `init_run.py` CLI to create run directory and manifest
5. Proceed through the 7 pipeline phases with 4 checkpoint gates

Budget shorthand: if the user starts a new request with `/research --50,10,2 topic`,
the leading shorthand means `max_pages=50`, `max_per_domain=10`, and
`max_depth=2`. Keep that leading token as a CLI budget override and do not include
it in the research request text.

---

## Scripts

- `scripts/init_run.py` -- Run initialization and resume detection
  - New run: `python3 ~/.claude/skills/research/scripts/init_run.py "research request" --max-pages 75 --max-per-domain 15 --max-depth 3 --collection-mode auto`
  - New run with budget shorthand: `python3 ~/.claude/skills/research/scripts/init_run.py --50,10,2 "research request"`
  - Resume list: `python3 ~/.claude/skills/research/scripts/init_run.py --list-interrupted`
  - Resume run: `python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID`
  - Machine-readable resume: `python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID --json`
  - Functions: `next_run_id()`, `create_manifest()`, `update_phase_status()`, `find_interrupted_runs()`, `resume_run()`, `resolve_collection_mode()`

Machine-readable resume output is the workflow control source of truth. The
orchestrator must read `next_phase`, validate `required_artifacts`, and dispatch
by this table:

| `next_phase` | Action |
|--------------|--------|
| `planning` | Resume planning and show Gate 1 only if planning artifacts are not complete |
| `collection` | Run collector unless `collection_mode=metadata_only`, then validate existing metadata and advance |
| `claim_extraction` | Run synthesizer claim extraction |
| `graph_relationships` | Run graph enrichment |
| `section_brief_synthesis` | Run section brief synthesis |
| `formatting` | Run formatter |
| `publishing` | Run publisher |

Do not treat resume output as a status report only. It determines the next phase
to execute.

- `scripts/validate_artifact.py` -- Runtime artifact validation
  - Usage: `python3 ~/.claude/skills/research/scripts/validate_artifact.py <artifact_path> <schema_path>`
  - Returns JSON: `{"status": "pass"|"warn"|"error", "errors": [], "warnings": []}`
  - Used at checkpoint gates to surface validation results as warnings, not hard stops

- `scripts/check_content_rules.py` -- Report content-rules scanner
  - Usage: `python3 ~/.claude/skills/research/scripts/check_content_rules.py --target=report <report_md_path>`
  - Returns JSON stdout: `{"status": "pass"|"warn"|"error", "violations": [...], "summary": {"total": N, "by_rule": {...}}}`
  - Exit codes: 0=pass, 1=warn (violations found), 2=error (file missing, path traversal, oversized)
  - Checks: RULE-02 (URL cited >3x/section), CONS-01 (empty headers), CONS-02 (<2 sentences or >800 words/section), HIER-04 (bare code fences)
  - Violations are advisory WARNINGS ONLY — never blocks synthesis or Gate 3 (D-21)

- `references/architecture_execution_plan.md` -- Maintainer checklist for dependency modes, gate boundaries, resume dispatch, depth taxonomy, and README promise discipline

---

## Run Logging

The orchestrator maintains `<run_dir>/logs/run_log.md` throughout the pipeline. Every significant action gets one row.

**Format (per D-10):**

```markdown
## Run Log

| Timestamp | Phase | Action | Status | Detail |
|---|---|---|---|---|
| 2026-04-11T14:23:01Z | planning | scope_written | ok | 8 subtopics, 3 source types |
```

**Writing pattern (inline Python helper):**

Define this helper once at the start of the run (after `init_run.py` creates the run directory). Do NOT create a separate script file -- use inline Python file I/O per D-12.

```python
import datetime
from pathlib import Path

def append_log(run_dir, phase, action, status, detail):
    log_path = Path(run_dir) / "logs" / "run_log.md"
    ts = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
    row = f"| {ts} | {phase} | {action} | {status} | {detail} |"
    if not log_path.exists():
        log_path.parent.mkdir(parents=True, exist_ok=True)
        header = "## Run Log\n\n| Timestamp | Phase | Action | Status | Detail |\n|---|---|---|---|---|\n"
        log_path.write_text(header + row + "\n")
    else:
        with open(log_path, 'a') as f:
            f.write(row + "\n")
```

---

## Pipeline Phases

### Phase 1: Planning

**Objective:** Transform a freeform research request into a structured scope and collection plan.

**Steps:**

1. **Accept research request.** Capture the user's freeform text. This becomes `user_request` in `manifest.json`.
   If the request starts with a budget shorthand token like `--50,10,2`, treat it as
   `max_pages,max_per_domain,max_depth` and exclude it from `user_request`. The
   shorthand is valid only at the start of a new `/research` request.

2. **Check for interrupted runs.** If the user invoked `/research` with no topic, call discovery mode:
   ```bash
   python3 ~/.claude/skills/research/scripts/init_run.py
   ```
   For an explicit list request, call:
   ```bash
   python3 ~/.claude/skills/research/scripts/init_run.py --list-interrupted
   ```
   If interrupted runs exist, display them with their problem phases and completed phases. Ask the user whether to resume an existing run or start fresh. To resume, call:
   ```bash
   python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID
   ```

3. **Initialize run directory.** For a new run, call init_run.py:
   ```bash
   python3 ~/.claude/skills/research/scripts/init_run.py "user request text" --max-pages 75 --max-per-domain 15 --max-depth 3
   ```
   If the user supplied leading budget shorthand, preserve it before the request:
   ```bash
   python3 ~/.claude/skills/research/scripts/init_run.py --50,10,2 "user request text"
   ```
   This creates `research/run-NNN-TIMESTAMP/` with `manifest.json`. Record the run directory path for all subsequent operations.

   Log: `append_log(run_dir, 'planning', 'run_initialized', 'ok', f'Run {run_id} created')`

4. **Inspect workspace context (local context — ALWAYS runs).** Local context search MUST scope to the project directory (git repo root or cwd). NEVER search `~/.claude/`, home directory, or any path outside the project. This step ALWAYS runs — on empty result, note "no local artifacts found" and proceed.

   Determine the project root:
   ```bash
   PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
   ```

   Scan `$PROJECT_ROOT` (only) for:
   - Existing `research/` runs (previous research on related topics)
   - PDF files, markdown documents, or other local sources relevant to the request
   - Any files the user explicitly referenced in their request

   Record findings for scope planning. If nothing found, record "no local artifacts found" and continue — do NOT skip remaining planning steps.

   Log: `append_log(run_dir, 'planning', 'workspace_scanned', 'ok', f'Found {N} prior runs, {M} local docs')`

5. **Determine task type.** Classify the request as one of:
   - `new` -- Fresh research on an unfamiliar topic
   - `update` -- Refresh stale sections of existing research
   - `expansion` -- Add depth or breadth to existing research
   - `re-audit` -- Re-verify sources and claims in existing research
   Record in manifest.json by adding a `task_type` field.

   Log: `append_log(run_dir, 'planning', 'task_type_set', 'ok', f'Type: {task_type}')`

6. **Plan scope (7-layer decomposition).** Break the research request into:
   - **Subtopics** -- Distinct areas to investigate (5-15 typical)
   - **Source types** -- Expected source categories (official docs, academic papers, blog posts, etc.)
   - **Key questions** -- Specific questions each subtopic should answer
   - **Coverage areas** -- What the final document should cover
   - **Priority ranking** -- Which subtopics are most critical

   Decompose the request using the 7-layer methodology (INV-01): `identity`, `purpose`, `mechanics`, `relations`, `comparison`, `evidence`, `open questions`. Every layer that has applicable sub-questions must populate at least one L1 question. The resulting question tree must populate **≥3 distinct layers** (D-16); a flat plan (all questions under one layer) will be rejected at Gate 1.

   For the `relations` layer, generate **bridge questions** using:
   ```python
   from scope_paths import ensure_scope_dir
   from question_tree import build_question_tree, select_bridge_entities, write_question_tree

   ensure_scope_dir(run_dir)
   entities, source = select_bridge_entities(run_dir, top_n=5)  # graph_centrality → subtopic_fallback (D-10/D-11)
   tree = build_question_tree(
       topic=user_request,
       subtopics=[s["name"] for s in subtopics],
       bridge_entities=entities,
       generation_method=source,
       top_n=5,
   )
   ```

   Bridge questions use the canonical phrasing `"What is the relationship between X and Y?"` for every pair among the top-N entities (REL-09). `top_n` is hard-clamped to ≤10 to prevent combinatorial blowup.

   Log: `append_log(run_dir, 'planning', 'scope_planned', 'ok', f'{N} subtopics, {M} source types')`

6b. **LaTeX/TinyTeX pre-flight (advisory, non-blocking).** Before displaying Gate 1, detect PDF rendering availability so the user can install TinyTeX before investing research effort. This is **advisory**, **not blocking** (D-07, D-08) — a missing TinyTeX must never stop Gate 1 from proceeding. The user retains agency; graceful render fallback (D-09) handles any PDF render failure at Phase 6.

   ```bash
   # Security note: DO NOT pass `-shell-escape` to `quarto render` anywhere in this pipeline.
   # Defaults-safe is required — `\write18`/shell-escape enables arbitrary code execution during PDF render.
   quarto check > /tmp/quarto_check_${run_id}.log 2>&1 || true
   if grep -iq "tinytex" /tmp/quarto_check_${run_id}.log; then
     tinytex_available=true
   else
     tinytex_available=false
   fi
   ```

   Record the result in `manifest.json` under `environment.tinytex_available` (boolean). Example:
   ```python
   import json
   manifest = json.loads(Path(manifest_path).read_text())
   manifest.setdefault("environment", {})["tinytex_available"] = tinytex_available
   Path(manifest_path).write_text(json.dumps(manifest, indent=2))
   ```

   If `tinytex_available` is `false`, surface this **advisory warning** in the Gate 1 presentation (prepended to the summary table, not as a blocker):

   ```
   ⚠️  TinyTeX not detected. PDF output will be unavailable at Gate 3 (or will render-fail gracefully).
       Install with: quarto install tinytex
   ```

   Gate 1 output-target review uses `manifest.environment.tinytex_available` to annotate PDF-inclusive targets with a `"(requires TinyTeX — not detected)"` caveat so the user retains agency. Phase 6 graceful render fallback handles any render failure.

6c. **Gate 1 defaulted controls.** Do not ask separate Gate 1 questions for implementation knobs. The initializer and planner set these defaults before the scope review:
   - `depth = "standard"`
   - `audience = "external"`
   - `tone = "professional"`
   - `render_targets = ["md", "html"]`
   - `section_depth_overrides = {}`
   - `performance_mode = "auto"`
   - `validation_mode = "normal"`

   If the user wants changes, they use the single Gate 1 "Edit scope/depth/output" option. Per-section depth inherits from global `depth` unless `section_depth_overrides` explicitly names a section.

7. **CHECKPOINT GATE 1 (Post-Planning).** Present the proposed scope to the user. Tables MUST be printed to chat via normal output BEFORE AskUserQuestion. Do NOT embed tables inside the AskUserQuestion question/header/options. Never ask two questions at Gate 1 — one combined confirm/adjust/abort only.

   **Step A — Print tables to chat (normal output, NOT inside AskUserQuestion):**

   Print the following as plain markdown tables in regular chat output:

   | Field | Value |
   |-------|-------|
   | Research question | {user_request} |
   | Task type | {task_type} |
   | Subtopics | {count} (see table below) |
   | Source channels | web={true/false}, documents={true/false} |
   | Source types | {comma-separated list} |
   | Collection mode | {web_and_docs / docs_only / web_only / metadata_only} |
   | Depth | {summary / standard / comprehensive / audit} |
   | Audience | {internal / external / technical / executive} |
   | Tone | {concise / professional / explanatory} |
   | Render targets | {md/qmd/html/pdf list} |
   | Validation mode | {normal / strict} |
   | Performance mode | {auto or resolved override} |
   | Coverage areas | {comma-separated list} |
   | Budget | max_pages={N}, max_per_domain={N}, max_depth={N} |
   | TinyTeX | {available / not detected — see advisory above if false} |

   | # | Subtopic | Priority |
   |---|----------|----------|
   | 1 | {name}   | {priority number} |
   | … | …        | … |

   (If `tinytex_available` is `false`, also print the advisory warning text here before the tables.)

   **Step B — ONE combined AskUserQuestion call:**

   ```python
   scope_choice = AskUserQuestion(
       question="Review the scope above. How would you like to proceed?",
       options=[
           {"label": "Approve plan — proceed with current scope, depth, and output", "value": "confirm"},
           {"label": "Edit scope/depth/output — revise plan settings", "value": "adjust"},
           {"label": "Abort — cancel this run", "value": "abort"},
       ],
       multiSelect=False,
   )
   ```

   This is the ONLY AskUserQuestion call for scope confirmation at Gate 1. Do NOT follow it with a second "any adjustments?" question. If user selects "adjust", process changes and re-print tables + re-ask this same single question.

   See `references/checkpoint_protocol.md` Gate 1 for full specification.

   **Tool resolution check**: After init_run.py runs, verify `manifest.collection_mode` and `manifest.environment.tools`.
   Stop if the resolved mode's required tools are missing. `metadata_only` means collection is skipped and no extraction tools are required.

   Log: `append_log(run_dir, 'gate_1', 'checkpoint_shown', 'ok', 'Gate 1 displayed')`
   Log (after user responds): `append_log(run_dir, 'gate_1', 'checkpoint_response', 'ok', f'User chose: {choice}')`

8. **Write scope and plan artifacts** (all under `scope/`, D-05/D-06/D-07). On confirmation:
   - Create the `scope/` subdirectory via `ensure_scope_dir(run_dir)`
   - Write `scope/scope.md` (format per `references/scope.md.contract.md`)
   - Write `scope/plan.json` (format per `references/plan.json.contract.md`)
   - Write `scope/question_tree.json` via `write_question_tree(run_dir, tree)` (format per `references/question_tree.json.contract.md`)
   - Validate `plan.json`:
     ```bash
     python3 ~/.claude/skills/research/scripts/validate_artifact.py research/run-NNN/scope/plan.json ~/.claude/skills/research/references/plan.schema.json
     ```
   - **Gate 1 layered-plan validator** (D-16..D-19): run the question tree validator with auto-regenerate loop.
     ```python
     from gate1_validator import run_gate1_validator
     from scope_paths import question_tree_path

     result = run_gate1_validator(
         tree_path=question_tree_path(run_dir),
         run_dir=run_dir,
         regenerate=regenerate_layered_plan,   # rewrites scope/question_tree.json with a layered plan
         max_attempts=2,                       # D-19 cap; prevents infinite loops
     )
     if result["status"] == "warn":
         # D-17/D-19: flag the checkpoint banner so the user sees the downgrade.
         banner = "⚠️ Question tree validation downgraded to manual review after 2 auto-regenerate attempts"
     ```
     Display only per-layer question **counts** to the user at Gate 1 (not the full tree) so the checkpoint stays legible (RESEARCH Open Question 2).

   Log: `append_log(run_dir, 'planning', 'scope_written', 'ok', 'scope/scope.md + scope/plan.json + scope/question_tree.json written')`
   Log: `append_log(run_dir, 'planning', 'plan_validated', 'ok', f'plan.json: {validation_status}')`
   Log (by `run_gate1_validator` internally): `question_tree_validated` (ok|warn|error) and `question_tree_regenerated` per attempt.

9. **Update manifest.** Mark planning complete:
   ```python
   update_phase_status(manifest_path, "planning", "running")
   # ... after scope/plan written ...
   update_phase_status(manifest_path, "planning", "complete")
   ```

---

### Phase 2: Collection

**Objective:** Gather evidence from web and document sources according to the plan.

**Steps:**

1. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "collection", "running")
   ```

2. **Spawn collector agent.** Use the Agent tool to spawn the collection subagent:
   ```
   Agent(
     prompt="Collect evidence for the research run at <run_dir_path>.
       Read scope/scope.md and scope/plan.json from the run directory for collection targets.
       Use scripts/parallel_crawl.py (Crawl4AI arun_many + MemoryAdaptiveDispatcher) for concurrent web crawling
       and scripts/parallel_docling.py for parallel document parsing.
       Write outputs to <run_dir_path>/collect/.
       Budget: max_pages=<N>, max_per_domain=<N>, max_depth=<N>.
       Use `manifest.runtime_profile.resolved.max_concurrent` and `manifest.runtime_profile.resolved.per_domain_cap` for crawl concurrency knobs, and `manifest.runtime_profile.resolved.docling_parallelism`, `docling_device`, and `docling_threads` for Docling SDK flags.
       Follow the research-collect skill instructions for all collection procedures.",
     subagent_type="research-collector",
     model="sonnet",
     description="Collect evidence for: <user_request summary>"
   )
   ```

   Log: `append_log(run_dir, 'collection', 'agent_spawned', 'ok', 'research-collector dispatched')`

3. **Collector outputs.** The collector produces these artifacts in `<run_dir>/collect/`:
   - `evidence/*.md` -- Individual evidence files with YAML provenance headers
   - `inventory.json` -- Full source catalog with metadata, tiers, and quality scores
   - `collection_log.md` -- Operations log with budget usage and decisions
   - `coverage_matrix.md` -- Topic-to-source coverage assessment
   - `quarantine/*.md` -- Quarantined items (suspicious, low-quality, or potentially harmful)

4. **Validate inventory.** On collector completion:
   ```bash
   python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/collect/inventory.json ~/.claude/skills/research-collect/references/inventory.schema.json
   ```

   Log: `append_log(run_dir, 'collection', 'inventory_validated', 'ok', f'inventory.json: {validation_status}')`

5. **CHECKPOINT GATE 2 (Post-Collection).** Present coverage summary via AskUserQuestion:
   - Total sources collected
   - Sources by tier (1-5)
   - Topic coverage (Strong/Moderate/Weak/None per topic)
   - Quarantined item count
   - Budget usage (pages_used / max_pages)
   - Weak areas flagged

   **User options:**
   1. **Proceed** -- Continue to graph and synthesis
   2. **Flag issues** -- Note concerns (logged to run_log.md), then proceed
   3. **Abort** -- Cancel the run

   See `references/checkpoint_protocol.md` Gate 2 for full specification.

   **Collection quality warnings**: Check `manifest.collection_warnings` and stderr logs for:
   - `BACKOFF_LOCK`: concurrency frozen due to excessive rate-limit backoff
   - `DOMAIN_CONCENTRATION`: one domain > 40% top-1 share
   - `DEVICE_FALLBACK`: Docling fell back from MPS/CUDA to CPU for > 10% of docs
   - `DOCLING_THIN_OUTPUT`: one or more Docling docs returned `thin_success` class
   - `DOCLING_PARTIAL`: one or more Docling docs routed to quarantine as `partial`
   - `DOCLING_CACHE_HIT_RATE`: logged by parallel_docling.py (informational)
   - `BACKOFF_THROTTLE_APPLIED`: active backoff mutated dispatcher concurrency mid-run

   Surface any warnings before proceeding. Then present the quality summary table:

   | Metric | Value | Flag |
   |--------|-------|------|
   | Top-domain share | `<N>%` | ⚠️ if > 40% |
   | Challenge / soft-fail pages | `<N>` | ⚠️ if > 0 |
   | thin_success (crawl) | `<N>` | info |
   | thin_success (Docling) | `<N>` | info |
   | Per-domain success-rate delta | worst: `<domain> −<N>%` | ⚠️ if any domain > 20% worse than expected |

   Populate from `collection_log.md` domain stats and `docling_out.jsonl` quality_class counts.

   Log: `append_log(run_dir, 'gate_2', 'checkpoint_shown', 'ok', f'Gate 2: {N} sources, {M} quarantined')`
   Log (after user responds): `append_log(run_dir, 'gate_2', 'checkpoint_response', 'ok', f'User chose: {choice}')`

6. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "collection", "complete")
   ```

---

### Phase 3: Claim Extraction

**Objective:** Extract stable, atomic claims from collected evidence and write canonical claim state.

**Canonical outputs:**
- `synthesis/global_id_registry.json`
- `synthesis/claim_bank.json`
- `synthesis/entity_index.json`

**Contract notes:** Claims are the primary unit. `categorized_evidence.json` is not canonical. Every claim has exactly one `primary_section_id`, stable `id`, normalized `content_hash`, `source_ids`, `confidence`, `salience`, and `include_in_report`.

**Steps:**

1. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "claim_extraction", "running")
   ```

2. **Initialize stable IDs.** Run the claim helper before spawning extraction:
   ```bash
   python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py init-registry --run-dir "$run_dir"
   ```
   This creates or preserves `synthesis/global_id_registry.json`. IDs are generated once and never regenerated on resume.

3. **Determine extraction granularity.** Count non-quarantined evidence files and record `manifest.evidence_count`.
   - Small runs may use one `mode=full` synthesizer call if evidence fits context.
   - Medium or large runs must use `mode=claim_batch` calls per source, per planned section, or per fixed evidence batch.
   - No extraction agent may read all evidence when the corpus exceeds the tiny-file rule or the orchestrator batch threshold.

4. **Spawn synthesizer for claim extraction.** The synthesizer writes claim deltas to `synthesis/claim_deltas/*.json`, then merges them into `claim_bank.json`.
   ```
   Agent(
     subagent_type="research-synthesizer",
     model="sonnet",
     description="Extract claim state for: <user_request summary>",
     prompt="""
     mode: full
     run_dir: <run_dir_path>

     Execute claim extraction only.
     Read scope/plan.json, scope/question_tree.json, collect/inventory.json, and selected collect/evidence/*.md batches.
     For large runs, write one synthesis/claim_deltas/*.json file per source, section, or evidence batch, then merge.
     Do not write raw_research.md.
     Do not create planner sections.
     Produce synthesis/global_id_registry.json, synthesis/claim_bank.json, and synthesis/entity_index.json.
     Follow research-synthesize/SKILL.md Stage 1.
     """
   )
   ```

   Log: `append_log(run_dir, 'claim_extraction', 'agent_spawned', 'ok', 'research-synthesizer dispatched for claim extraction')`

5. **Validate claim artifacts.**
   ```bash
  python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/global_id_registry.json" ~/.claude/skills/research-synthesize/references/global_id_registry.schema.json
  python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/claim_bank.json" ~/.claude/skills/research-synthesize/references/claim_bank.schema.json
  python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/entity_index.json" ~/.claude/skills/research-synthesize/references/entity_index.schema.json
   ```

   Log validation results. Validation warnings surface at Gate 3.

6. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "claim_extraction", "complete")
   ```

### Phase 4: Graph Relationships

**Objective:** Build relationship metadata from extracted claims and entities.

**Canonical outputs:**
- `synthesis/claim_graph_map.json`
- `synthesis/section_graph_hints.json`

**Graph rules:** Graph hints are advisory. They may enrich relationships inside planned sections, but may not create sections, reorder sections, override source quality, or force claim inclusion by centrality.

**Steps:**

1. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "graph_relationships", "running")
   ```

2. **Build compact graph artifacts from claim/entity state.**
   ```bash
   python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-entity-index --run-dir "$run_dir"
   python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-graph-artifacts --run-dir "$run_dir"
   python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/entity_index.json" ~/.claude/skills/research-synthesize/references/entity_index.schema.json
   python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/claim_graph_map.json" ~/.claude/skills/research-synthesize/references/claim_graph_map.schema.json
   python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/section_graph_hints.json" ~/.claude/skills/research-synthesize/references/section_graph_hints.schema.json
   ```

   `section_graph_hints.json` must list only planner-defined section IDs. Graph centrality is advisory and must not create or reorder sections.

3. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "graph_relationships", "complete")
   ```

---

### Phase 5: Section Brief Synthesis

**Objective:** Produce compact per-section memory and slices for report composition.

**Canonical outputs:**
- `synthesis/section_briefs/<section_id>.json`
- `synthesis/claim_slices/<section_id>.json`
- `synthesis/citation_audit.md`
- `synthesis/gap_analysis.md`
- Optional diagnostics: `synthesis/research_notes.md`

**Slicing rules:** The report composer parent reads only section indexes, claim IDs per section, source IDs per section, graph hint summaries, and normalized output preferences. Section agents receive one brief, referenced claims, referenced sources, relevant graph hints, and boundary rules.

**Steps:**

1. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "section_brief_synthesis", "running")
   ```

2. **Spawn synthesizer for section briefs and audits.**

   ```
   Agent(
     subagent_type="research-synthesizer",
     model="sonnet",
     description="Build section briefs for: <user_request summary>",
     prompt="""
     mode: section_briefs
     run_dir: <run_dir_path>

     Build compact section briefs, per-section claim slices, citation_audit.md, and gap_analysis.md.
     Read claim_bank.json, section_graph_hints.json, scope/plan.json, and source metadata only as needed.
     Do not write raw_research.md.
     Do not read all evidence.
     Every planned section must have claims or an explicit missing-evidence reason.
     Follow research-synthesize/SKILL.md Stage 3 and Gate 3 readiness rules.
     """
   )
   ```

   Log: `append_log(run_dir, 'section_brief_synthesis', 'agent_spawned', 'ok', 'research-synthesizer dispatched for section briefs')`

3. **Normalize section artifacts.**
   ```bash
   python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-section-artifacts --run-dir "$run_dir"
   ```

4. **Synthesizer outputs.** Written to `<run_dir>/synthesis/`:
   - `claim_bank.json` -- Canonical claim state (format per `references/claim_bank.contract.md` in research-synthesize)
   - `section_briefs/*.json` -- Compact per-section briefs
   - `claim_slices/*.json` -- Per-section claim/source slices
   - `claim_graph_map.json` -- Compact claim relationship map
   - `section_graph_hints.json` -- Compact advisory section graph hints
   - `citation_audit.md` -- Citation verification results (format per `references/citation_audit.contract.md`)
   - `gap_analysis.md` -- Coverage gaps and weak areas (format per `references/gap_analysis.contract.md`)

5. **Validate all Slice 2 artifacts.**
   ```bash
   python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/synthesis/claim_bank.json ~/.claude/skills/research-synthesize/references/claim_bank.schema.json
   python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/synthesis/claim_graph_map.json ~/.claude/skills/research-synthesize/references/claim_graph_map.schema.json
   python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/synthesis/section_graph_hints.json ~/.claude/skills/research-synthesize/references/section_graph_hints.schema.json
   for f in <run_dir>/synthesis/section_briefs/*.json; do python3 ~/.claude/skills/research/scripts/validate_artifact.py "$f" ~/.claude/skills/research-synthesize/references/section_brief.schema.json; done
   for f in <run_dir>/synthesis/claim_slices/*.json; do python3 ~/.claude/skills/research/scripts/validate_artifact.py "$f" ~/.claude/skills/research-synthesize/references/claim_slice.schema.json; done
   ```

   Log: `append_log(run_dir, 'section_brief_synthesis', 'slice2_artifacts_validated', 'ok', f'Slice 2 validation: {validation_status}')`

6. **Run Gate 3 readiness check.**
   ```bash
   python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py validate-readiness --run-dir "$run_dir"
   ```

   Gate 3 is blocked if readiness returns `status=fail`, including the failed-slice condition: any planned section has no claims and no explicit missing-evidence reason.

7. **Optional diagnostics content-rules check.** If diagnostics write `synthesis/research_notes.md`, the scanner may be run in raw mode for advisory warnings. This is not part of the canonical handoff.

   ```python
   import subprocess, json
   script = Path.home() / ".claude/skills/research/scripts/check_content_rules.py"
   target = run_dir / "synthesis/research_notes.md"
   result = subprocess.run(["python3", str(script), "--target=raw", str(target)], capture_output=True, text=True)
   try:
       payload = json.loads(result.stdout)
   except json.JSONDecodeError:
       payload = {"status": "error", "violations": [], "summary": {"total": 0}, "detail": result.stderr[:500]}
   status = payload.get("status", "error")          # 'pass' | 'warn' | 'error'
   violations = payload.get("violations", [])
   total = payload.get("summary", {}).get("total", len(violations))
   log_status = 'ok' if status == 'pass' else 'warn'  # D-21: warnings never escalate to 'fail'
   detail = f"violations={total} status={status} rules=" + ",".join(sorted({v.get('rule','?') for v in violations}))
   append_log(run_dir, 'synthesis', 'content_rules_check', log_status, detail)
   # Store for Gate 3 presentation — do NOT block, do NOT raise, do NOT exit the orchestrator on violations.
   content_rules_summary = {"status": status, "total": total, "violations": violations}
   ```

   **Scanner error handling:** If the scanner exits 2 (error — file missing, path traversal, oversized), log with `status='warn'` (not `'fail'`), record detail, and proceed. Missing `research_notes.md` is acceptable because diagnostics are optional.

   **Non-goal (QA-04, Phase 16):** QA-04 will later BLOCK Gate 3 on certain error-severity issues. Phase 11 is warn-only. Do NOT add blocking logic here.

---

### Gate 3: Claim State Review

**Objective:** Detect coverage gaps and fill them with targeted collection and re-synthesis.

**Steps:**

1. **Read gap analysis.** Parse `<run_dir>/synthesis/gap_analysis.md` for threshold checks.

2. **Evaluate gap-fill triggers.** Gap-fill is triggered when ANY of these thresholds are exceeded:
   - Uncovered topics > 25% of planned subtopics
   - Isolated nodes > 20% of total graph nodes
   - Low-confidence claims (tier 4-5 sources only) > 30% of total claims

3. **If gap-fill triggered:**
   a. Keep `section_brief_synthesis` running while the gap-fill loop executes.
   b. Defer to the synthesizer — the canonical gap-fill execution path lives in the synthesizer skill, not the orchestrator.

   > **Note (SYNTH-11 canonical path):** Gap-fill is orchestrated by the synthesizer — see research-synthesize SKILL.md § Step: Gap-Fill Loop (SYNTH-11) for the canonical execution path. The orchestrator does NOT spawn the collector directly for gap-fill; it only evaluates thresholds and updates manifest state.

   c. Maximum 1 gap-fill iteration (no infinite loops) — enforced inside the synthesizer loop.

4. **If gap-fill NOT triggered:** Continue to Gate 3 and mark `section_brief_synthesis` complete after approval.

5. **CHECKPOINT GATE 3 (Post-Synthesis).** Present synthesis results via one AskUserQuestion. Gate 3 reviews claim state only; it does not repeat Gate 1's output settings interview.

   **Part A — Synthesis review (summary table):**
   - Strongest areas (sections with most tier-1/2 citations)
   - Weakest areas (sections with fewest citations or only tier-4/5)
   - Gap-fill status ("Not triggered" or "Triggered: N additional pages, M new claims")
   - Total claims from claim_bank.json
   - Citation coverage percentage
   - Average sources per claim
   - Citation audit pass/fail summary
   - Validation warnings from validate_artifact.py
   - **Content-rules violations: {total} ({status}). Rules: {comma-separated rule codes}. Advisory only — see logs/run_log.md for full detail.** (from `content_rules_summary` computed in Phase 4 Step 4b; D-21: Gate 3 approval is NOT blocked by any violation count)

   **Part A — User options:**
   1. **Proceed to format** -- Continue to formatting with the normalized output settings approved at Gate 1
   2. **Request gap-fill** — Defer to the synthesizer's gap-fill loop (see research-synthesize SKILL.md § Step: Gap-Fill Loop (SYNTH-11)). The synthesizer re-invokes collection internally against gap_analysis.md targets, capped at 20 additional pages and max 1 iteration.
   3. **Abort** -- Cancel the run

   **Output settings:** Verify these normalized fields exist in `manifest.json` before Phase 6. For legacy or resumed runs where they are absent, write defaults:
   ```json
   {
     "depth": "standard",
     "audience": "external",
     "tone": "professional",
     "render_targets": ["md", "html"]
   }
   ```

   `section_depth_overrides` is optional and defaults to `{}`. Per-section depth must use the same enum as global `depth`: `summary`, `standard`, `comprehensive`, or `audit`.

   See `references/checkpoint_protocol.md` Gate 3 for full specification.

   Log: `append_log(run_dir, 'gate_3', 'checkpoint_shown', 'ok', f'Gate 3: {N} claims, {coverage}% coverage')`
   Log (after user responds): `append_log(run_dir, 'gate_3', 'checkpoint_response', 'ok', f'User chose: {choice}')`

6. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "section_brief_synthesis", "complete")
   ```

---

### Phase 6: Formatting / Report Composition

**Objective:** Compose the canonical Markdown report from section briefs, claim slices, and formatter-owned presentation rules.

**Canonical outputs:**
- `output/assembly_plan.json`
- `output/sections/<section_id>.md`
- `output/sections/<section_id>.meta.json`
- `output/report.md`
- `output/formatter_audit.json`

`output/report.md` must be useful by itself and must exist before publishing starts.

**Steps:**

1. **Update manifest:**
   ```python
   update_phase_status(manifest_path, "formatting", "running")
   ```

2. **Read output preferences & derive Quarto conditional (D-04, D-05).** Read normalized output fields from `manifest.json`. If fields are absent (e.g., resumed run), apply defaults: `depth=standard`, `audience=external`, `tone=professional`, `render_targets=["md", "html"]`. Derive the conditional flag:

   ```python
   import json
   m = json.loads(Path(manifest_path).read_text())
   depth = m.get("depth", "standard")
   audience = m.get("audience", "external")
   tone = m.get("tone", "professional")
   render_targets = m.get("render_targets", ["md", "html"])
   produce_qmd = any(target in render_targets for target in ("qmd", "html", "pdf"))
   quarto_output = (
       "both" if "html" in render_targets and "pdf" in render_targets
       

…(truncated)
