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:
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:
- Detect the missing dependency.
- 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).
- 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
- User triggers with
/research "topic or question"
- Orchestrator checks for interrupted runs via
find_interrupted_runs()
- If interrupted runs exist: display them and offer to resume or start fresh
- If new run: call
init_run.py CLI to create run directory and manifest
- 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):
## 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.
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:
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.
Check for interrupted runs. If the user invoked /research with no topic, call discovery mode:
python3 ~/.claude/skills/research/scripts/init_run.py
For an explicit list request, call:
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:
python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID
Initialize run directory. For a new run, call init_run.py:
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:
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')
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:
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')
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}')
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:
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.
# 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:
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.
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:
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}')
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: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.
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.
Update manifest. Mark planning complete:
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:
Update manifest:
update_phase_status(manifest_path, "collection", "running")
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')
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)
Validate inventory. On collector completion:
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}')
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:
- Proceed -- Continue to graph and synthesis
- Flag issues -- Note concerns (logged to run_log.md), then proceed
- 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}')
Update manifest:
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:
Update manifest:
update_phase_status(manifest_path, "claim_extraction", "running")
Initialize stable IDs. Run the claim helper before spawning extraction:
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.
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.
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')
Validate claim artifacts.
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:
Update manifest:
update_phase_status(manifest_path, "graph_relationships", "running")
Build compact graph artifacts from claim/entity state.
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.
Update manifest:
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:
Update manifest:
update_phase_status(manifest_path, "section_brief_synthesis", "running")
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')
Normalize section artifacts.
python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-section-artifacts --run-dir "$run_dir"
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)
Validate all Slice 2 artifacts.
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}')
Run Gate 3 readiness check.
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.
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.
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:
Read gap analysis. Parse <run_dir>/synthesis/gap_analysis.md for threshold checks.
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
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.
If gap-fill NOT triggered: Continue to Gate 3 and mark section_brief_synthesis complete after approval.
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:
- Proceed to format -- Continue to formatting with the normalized output settings approved at Gate 1
- 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.
- 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:
{
"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}')
Update manifest:
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:
Update manifest:
update_phase_status(manifest_path, "formatting", "running")
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:
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)
1---2name: research3description: Orchestrates multi-phase research pipeline: scoping, evidence collection, claim extraction, graph relationship enrichment, section brief synthesis, report composition, and publishing.4---56# Research Orchestrator78Accepts 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.910The 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.1112## Claim Pipeline Contract1314New runs use `manifest.pipeline_contract_version = "claim_pipeline_v1"` and these phases:1516```text17planning18→ collection19→ claim_extraction20→ graph_relationships21→ section_brief_synthesis22→ formatting23→ publishing24```2526`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.2728A 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.2930---3132## Dependency install policy3334The research pipeline must **NEVER** auto-install or auto-download these runtime dependencies:35- `crawl4ai` (and its Playwright/browser runtime)36- `playwright` / Playwright browsers37- `docling`3839If any of these are missing when the pipeline needs them, the skill/agent **MUST**:401. Detect the missing dependency.412. 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`).423. Stop the run — halt the phase with a non-zero exit. Do NOT proceed with a fallback collection mode.4344Never 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.4546**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.4748## Run Mode Contract4950Manifest mode fields are separate:5152- `run_mode`: `normal`, `resume`, or `inspect`53- `collection_mode`: `web_and_docs`, `docs_only`, `web_only`, or `metadata_only`54- `validation_mode`: `normal` or `strict`5556`source_channels = {"web": bool, "documents": bool}` is the source intent object.57`collection_mode=auto` is accepted by the initializer but is never persisted; it58resolves from `source_channels` before `manifest.json` is written. `metadata_only`59means the collection phase is skipped and no extraction tools are required.60Legacy manifests or CLI calls that say `none` are treated as `metadata_only`;61new manifests must persist `metadata_only`.6263Hard requirements:6465- `web_and_docs`: Crawl4AI, Playwright browser runtime, and Docling66- `docs_only`: Docling67- `web_only`: Crawl4AI and Playwright browser runtime68- `metadata_only`: no extraction tools; inventory/resume metadata only6970`validation_mode=normal` blocks on required phase artifacts and warns on71nonessential audit artifacts. `validation_mode=strict` fails on missing audit72files, invalid schemas, or contract violations.7374---7576## Quick Start77781. User triggers with `/research "topic or question"`792. Orchestrator checks for interrupted runs via `find_interrupted_runs()`803. If interrupted runs exist: display them and offer to resume or start fresh814. If new run: call `init_run.py` CLI to create run directory and manifest825. Proceed through the 7 pipeline phases with 4 checkpoint gates8384Budget shorthand: if the user starts a new request with `/research --50,10,2 topic`,85the leading shorthand means `max_pages=50`, `max_per_domain=10`, and86`max_depth=2`. Keep that leading token as a CLI budget override and do not include87it in the research request text.8889---9091## Scripts9293- `scripts/init_run.py` -- Run initialization and resume detection94 - 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`95 - New run with budget shorthand: `python3 ~/.claude/skills/research/scripts/init_run.py --50,10,2 "research request"`96 - Resume list: `python3 ~/.claude/skills/research/scripts/init_run.py --list-interrupted`97 - Resume run: `python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID`98 - Machine-readable resume: `python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID --json`99 - Functions: `next_run_id()`, `create_manifest()`, `update_phase_status()`, `find_interrupted_runs()`, `resume_run()`, `resolve_collection_mode()`100101Machine-readable resume output is the workflow control source of truth. The102orchestrator must read `next_phase`, validate `required_artifacts`, and dispatch103by this table:104105| `next_phase` | Action |106|--------------|--------|107| `planning` | Resume planning and show Gate 1 only if planning artifacts are not complete |108| `collection` | Run collector unless `collection_mode=metadata_only`, then validate existing metadata and advance |109| `claim_extraction` | Run synthesizer claim extraction |110| `graph_relationships` | Run graph enrichment |111| `section_brief_synthesis` | Run section brief synthesis |112| `formatting` | Run formatter |113| `publishing` | Run publisher |114115Do not treat resume output as a status report only. It determines the next phase116to execute.117118- `scripts/validate_artifact.py` -- Runtime artifact validation119 - Usage: `python3 ~/.claude/skills/research/scripts/validate_artifact.py <artifact_path> <schema_path>`120 - Returns JSON: `{"status": "pass"|"warn"|"error", "errors": [], "warnings": []}`121 - Used at checkpoint gates to surface validation results as warnings, not hard stops122123- `scripts/check_content_rules.py` -- Report content-rules scanner124 - Usage: `python3 ~/.claude/skills/research/scripts/check_content_rules.py --target=report <report_md_path>`125 - Returns JSON stdout: `{"status": "pass"|"warn"|"error", "violations": [...], "summary": {"total": N, "by_rule": {...}}}`126 - Exit codes: 0=pass, 1=warn (violations found), 2=error (file missing, path traversal, oversized)127 - Checks: RULE-02 (URL cited >3x/section), CONS-01 (empty headers), CONS-02 (<2 sentences or >800 words/section), HIER-04 (bare code fences)128 - Violations are advisory WARNINGS ONLY — never blocks synthesis or Gate 3 (D-21)129130- `references/architecture_execution_plan.md` -- Maintainer checklist for dependency modes, gate boundaries, resume dispatch, depth taxonomy, and README promise discipline131132---133134## Run Logging135136The orchestrator maintains `<run_dir>/logs/run_log.md` throughout the pipeline. Every significant action gets one row.137138**Format (per D-10):**139140```markdown141## Run Log142143| Timestamp | Phase | Action | Status | Detail |144|---|---|---|---|---|145| 2026-04-11T14:23:01Z | planning | scope_written | ok | 8 subtopics, 3 source types |146```147148**Writing pattern (inline Python helper):**149150Define 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.151152```python153import datetime154from pathlib import Path155156def append_log(run_dir, phase, action, status, detail):157 log_path = Path(run_dir) / "logs" / "run_log.md"158 ts = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')159 row = f"| {ts} | {phase} | {action} | {status} | {detail} |"160 if not log_path.exists():161 log_path.parent.mkdir(parents=True, exist_ok=True)162 header = "## Run Log\n\n| Timestamp | Phase | Action | Status | Detail |\n|---|---|---|---|---|\n"163 log_path.write_text(header + row + "\n")164 else:165 with open(log_path, 'a') as f:166 f.write(row + "\n")167```168169---170171## Pipeline Phases172173### Phase 1: Planning174175**Objective:** Transform a freeform research request into a structured scope and collection plan.176177**Steps:**1781791. **Accept research request.** Capture the user's freeform text. This becomes `user_request` in `manifest.json`.180 If the request starts with a budget shorthand token like `--50,10,2`, treat it as181 `max_pages,max_per_domain,max_depth` and exclude it from `user_request`. The182 shorthand is valid only at the start of a new `/research` request.1831842. **Check for interrupted runs.** If the user invoked `/research` with no topic, call discovery mode:185 ```bash186 python3 ~/.claude/skills/research/scripts/init_run.py187 ```188 For an explicit list request, call:189 ```bash190 python3 ~/.claude/skills/research/scripts/init_run.py --list-interrupted191 ```192 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:193 ```bash194 python3 ~/.claude/skills/research/scripts/init_run.py --resume RUN_ID195 ```1961973. **Initialize run directory.** For a new run, call init_run.py:198 ```bash199 python3 ~/.claude/skills/research/scripts/init_run.py "user request text" --max-pages 75 --max-per-domain 15 --max-depth 3200 ```201 If the user supplied leading budget shorthand, preserve it before the request:202 ```bash203 python3 ~/.claude/skills/research/scripts/init_run.py --50,10,2 "user request text"204 ```205 This creates `research/run-NNN-TIMESTAMP/` with `manifest.json`. Record the run directory path for all subsequent operations.206207 Log: `append_log(run_dir, 'planning', 'run_initialized', 'ok', f'Run {run_id} created')`2082094. **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.210211 Determine the project root:212 ```bash213 PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)214 ```215216 Scan `$PROJECT_ROOT` (only) for:217 - Existing `research/` runs (previous research on related topics)218 - PDF files, markdown documents, or other local sources relevant to the request219 - Any files the user explicitly referenced in their request220221 Record findings for scope planning. If nothing found, record "no local artifacts found" and continue — do NOT skip remaining planning steps.222223 Log: `append_log(run_dir, 'planning', 'workspace_scanned', 'ok', f'Found {N} prior runs, {M} local docs')`2242255. **Determine task type.** Classify the request as one of:226 - `new` -- Fresh research on an unfamiliar topic227 - `update` -- Refresh stale sections of existing research228 - `expansion` -- Add depth or breadth to existing research229 - `re-audit` -- Re-verify sources and claims in existing research230 Record in manifest.json by adding a `task_type` field.231232 Log: `append_log(run_dir, 'planning', 'task_type_set', 'ok', f'Type: {task_type}')`2332346. **Plan scope (7-layer decomposition).** Break the research request into:235 - **Subtopics** -- Distinct areas to investigate (5-15 typical)236 - **Source types** -- Expected source categories (official docs, academic papers, blog posts, etc.)237 - **Key questions** -- Specific questions each subtopic should answer238 - **Coverage areas** -- What the final document should cover239 - **Priority ranking** -- Which subtopics are most critical240241 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.242243 For the `relations` layer, generate **bridge questions** using:244 ```python245 from scope_paths import ensure_scope_dir246 from question_tree import build_question_tree, select_bridge_entities, write_question_tree247248 ensure_scope_dir(run_dir)249 entities, source = select_bridge_entities(run_dir, top_n=5) # graph_centrality → subtopic_fallback (D-10/D-11)250 tree = build_question_tree(251 topic=user_request,252 subtopics=[s["name"] for s in subtopics],253 bridge_entities=entities,254 generation_method=source,255 top_n=5,256 )257 ```258259 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.260261 Log: `append_log(run_dir, 'planning', 'scope_planned', 'ok', f'{N} subtopics, {M} source types')`2622636b. **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.264265 ```bash266 # Security note: DO NOT pass `-shell-escape` to `quarto render` anywhere in this pipeline.267 # Defaults-safe is required — `\write18`/shell-escape enables arbitrary code execution during PDF render.268 quarto check > /tmp/quarto_check_${run_id}.log 2>&1 || true269 if grep -iq "tinytex" /tmp/quarto_check_${run_id}.log; then270 tinytex_available=true271 else272 tinytex_available=false273 fi274 ```275276 Record the result in `manifest.json` under `environment.tinytex_available` (boolean). Example:277 ```python278 import json279 manifest = json.loads(Path(manifest_path).read_text())280 manifest.setdefault("environment", {})["tinytex_available"] = tinytex_available281 Path(manifest_path).write_text(json.dumps(manifest, indent=2))282 ```283284 If `tinytex_available` is `false`, surface this **advisory warning** in the Gate 1 presentation (prepended to the summary table, not as a blocker):285286 ```287 ⚠️ TinyTeX not detected. PDF output will be unavailable at Gate 3 (or will render-fail gracefully).288 Install with: quarto install tinytex289 ```290291 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.2922936c. **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:294 - `depth = "standard"`295 - `audience = "external"`296 - `tone = "professional"`297 - `render_targets = ["md", "html"]`298 - `section_depth_overrides = {}`299 - `performance_mode = "auto"`300 - `validation_mode = "normal"`301302 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.3033047. **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.305306 **Step A — Print tables to chat (normal output, NOT inside AskUserQuestion):**307308 Print the following as plain markdown tables in regular chat output:309310 | Field | Value |311 |-------|-------|312 | Research question | {user_request} |313 | Task type | {task_type} |314 | Subtopics | {count} (see table below) |315 | Source channels | web={true/false}, documents={true/false} |316 | Source types | {comma-separated list} |317 | Collection mode | {web_and_docs / docs_only / web_only / metadata_only} |318 | Depth | {summary / standard / comprehensive / audit} |319 | Audience | {internal / external / technical / executive} |320 | Tone | {concise / professional / explanatory} |321 | Render targets | {md/qmd/html/pdf list} |322 | Validation mode | {normal / strict} |323 | Performance mode | {auto or resolved override} |324 | Coverage areas | {comma-separated list} |325 | Budget | max_pages={N}, max_per_domain={N}, max_depth={N} |326 | TinyTeX | {available / not detected — see advisory above if false} |327328 | # | Subtopic | Priority |329 |---|----------|----------|330 | 1 | {name} | {priority number} |331 | … | … | … |332333 (If `tinytex_available` is `false`, also print the advisory warning text here before the tables.)334335 **Step B — ONE combined AskUserQuestion call:**336337 ```python338 scope_choice = AskUserQuestion(339 question="Review the scope above. How would you like to proceed?",340 options=[341 {"label": "Approve plan — proceed with current scope, depth, and output", "value": "confirm"},342 {"label": "Edit scope/depth/output — revise plan settings", "value": "adjust"},343 {"label": "Abort — cancel this run", "value": "abort"},344 ],345 multiSelect=False,346 )347 ```348349 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.350351 See `references/checkpoint_protocol.md` Gate 1 for full specification.352353 **Tool resolution check**: After init_run.py runs, verify `manifest.collection_mode` and `manifest.environment.tools`.354 Stop if the resolved mode's required tools are missing. `metadata_only` means collection is skipped and no extraction tools are required.355356 Log: `append_log(run_dir, 'gate_1', 'checkpoint_shown', 'ok', 'Gate 1 displayed')`357 Log (after user responds): `append_log(run_dir, 'gate_1', 'checkpoint_response', 'ok', f'User chose: {choice}')`3583598. **Write scope and plan artifacts** (all under `scope/`, D-05/D-06/D-07). On confirmation:360 - Create the `scope/` subdirectory via `ensure_scope_dir(run_dir)`361 - Write `scope/scope.md` (format per `references/scope.md.contract.md`)362 - Write `scope/plan.json` (format per `references/plan.json.contract.md`)363 - Write `scope/question_tree.json` via `write_question_tree(run_dir, tree)` (format per `references/question_tree.json.contract.md`)364 - Validate `plan.json`:365 ```bash366 python3 ~/.claude/skills/research/scripts/validate_artifact.py research/run-NNN/scope/plan.json ~/.claude/skills/research/references/plan.schema.json367 ```368 - **Gate 1 layered-plan validator** (D-16..D-19): run the question tree validator with auto-regenerate loop.369 ```python370 from gate1_validator import run_gate1_validator371 from scope_paths import question_tree_path372373 result = run_gate1_validator(374 tree_path=question_tree_path(run_dir),375 run_dir=run_dir,376 regenerate=regenerate_layered_plan, # rewrites scope/question_tree.json with a layered plan377 max_attempts=2, # D-19 cap; prevents infinite loops378 )379 if result["status"] == "warn":380 # D-17/D-19: flag the checkpoint banner so the user sees the downgrade.381 banner = "⚠️ Question tree validation downgraded to manual review after 2 auto-regenerate attempts"382 ```383 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).384385 Log: `append_log(run_dir, 'planning', 'scope_written', 'ok', 'scope/scope.md + scope/plan.json + scope/question_tree.json written')`386 Log: `append_log(run_dir, 'planning', 'plan_validated', 'ok', f'plan.json: {validation_status}')`387 Log (by `run_gate1_validator` internally): `question_tree_validated` (ok|warn|error) and `question_tree_regenerated` per attempt.3883899. **Update manifest.** Mark planning complete:390 ```python391 update_phase_status(manifest_path, "planning", "running")392 # ... after scope/plan written ...393 update_phase_status(manifest_path, "planning", "complete")394 ```395396---397398### Phase 2: Collection399400**Objective:** Gather evidence from web and document sources according to the plan.401402**Steps:**4034041. **Update manifest:**405 ```python406 update_phase_status(manifest_path, "collection", "running")407 ```4084092. **Spawn collector agent.** Use the Agent tool to spawn the collection subagent:410 ```411 Agent(412 prompt="Collect evidence for the research run at <run_dir_path>.413 Read scope/scope.md and scope/plan.json from the run directory for collection targets.414 Use scripts/parallel_crawl.py (Crawl4AI arun_many + MemoryAdaptiveDispatcher) for concurrent web crawling415 and scripts/parallel_docling.py for parallel document parsing.416 Write outputs to <run_dir_path>/collect/.417 Budget: max_pages=<N>, max_per_domain=<N>, max_depth=<N>.418 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.419 Follow the research-collect skill instructions for all collection procedures.",420 subagent_type="research-collector",421 model="sonnet",422 description="Collect evidence for: <user_request summary>"423 )424 ```425426 Log: `append_log(run_dir, 'collection', 'agent_spawned', 'ok', 'research-collector dispatched')`4274283. **Collector outputs.** The collector produces these artifacts in `<run_dir>/collect/`:429 - `evidence/*.md` -- Individual evidence files with YAML provenance headers430 - `inventory.json` -- Full source catalog with metadata, tiers, and quality scores431 - `collection_log.md` -- Operations log with budget usage and decisions432 - `coverage_matrix.md` -- Topic-to-source coverage assessment433 - `quarantine/*.md` -- Quarantined items (suspicious, low-quality, or potentially harmful)4344354. **Validate inventory.** On collector completion:436 ```bash437 python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/collect/inventory.json ~/.claude/skills/research-collect/references/inventory.schema.json438 ```439440 Log: `append_log(run_dir, 'collection', 'inventory_validated', 'ok', f'inventory.json: {validation_status}')`4414425. **CHECKPOINT GATE 2 (Post-Collection).** Present coverage summary via AskUserQuestion:443 - Total sources collected444 - Sources by tier (1-5)445 - Topic coverage (Strong/Moderate/Weak/None per topic)446 - Quarantined item count447 - Budget usage (pages_used / max_pages)448 - Weak areas flagged449450 **User options:**451 1. **Proceed** -- Continue to graph and synthesis452 2. **Flag issues** -- Note concerns (logged to run_log.md), then proceed453 3. **Abort** -- Cancel the run454455 See `references/checkpoint_protocol.md` Gate 2 for full specification.456457 **Collection quality warnings**: Check `manifest.collection_warnings` and stderr logs for:458 - `BACKOFF_LOCK`: concurrency frozen due to excessive rate-limit backoff459 - `DOMAIN_CONCENTRATION`: one domain > 40% top-1 share460 - `DEVICE_FALLBACK`: Docling fell back from MPS/CUDA to CPU for > 10% of docs461 - `DOCLING_THIN_OUTPUT`: one or more Docling docs returned `thin_success` class462 - `DOCLING_PARTIAL`: one or more Docling docs routed to quarantine as `partial`463 - `DOCLING_CACHE_HIT_RATE`: logged by parallel_docling.py (informational)464 - `BACKOFF_THROTTLE_APPLIED`: active backoff mutated dispatcher concurrency mid-run465466 Surface any warnings before proceeding. Then present the quality summary table:467468 | Metric | Value | Flag |469 |--------|-------|------|470 | Top-domain share | `<N>%` | ⚠️ if > 40% |471 | Challenge / soft-fail pages | `<N>` | ⚠️ if > 0 |472 | thin_success (crawl) | `<N>` | info |473 | thin_success (Docling) | `<N>` | info |474 | Per-domain success-rate delta | worst: `<domain> −<N>%` | ⚠️ if any domain > 20% worse than expected |475476 Populate from `collection_log.md` domain stats and `docling_out.jsonl` quality_class counts.477478 Log: `append_log(run_dir, 'gate_2', 'checkpoint_shown', 'ok', f'Gate 2: {N} sources, {M} quarantined')`479 Log (after user responds): `append_log(run_dir, 'gate_2', 'checkpoint_response', 'ok', f'User chose: {choice}')`4804816. **Update manifest:**482 ```python483 update_phase_status(manifest_path, "collection", "complete")484 ```485486---487488### Phase 3: Claim Extraction489490**Objective:** Extract stable, atomic claims from collected evidence and write canonical claim state.491492**Canonical outputs:**493- `synthesis/global_id_registry.json`494- `synthesis/claim_bank.json`495- `synthesis/entity_index.json`496497**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`.498499**Steps:**5005011. **Update manifest:**502 ```python503 update_phase_status(manifest_path, "claim_extraction", "running")504 ```5055062. **Initialize stable IDs.** Run the claim helper before spawning extraction:507 ```bash508 python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py init-registry --run-dir "$run_dir"509 ```510 This creates or preserves `synthesis/global_id_registry.json`. IDs are generated once and never regenerated on resume.5115123. **Determine extraction granularity.** Count non-quarantined evidence files and record `manifest.evidence_count`.513 - Small runs may use one `mode=full` synthesizer call if evidence fits context.514 - Medium or large runs must use `mode=claim_batch` calls per source, per planned section, or per fixed evidence batch.515 - No extraction agent may read all evidence when the corpus exceeds the tiny-file rule or the orchestrator batch threshold.5165174. **Spawn synthesizer for claim extraction.** The synthesizer writes claim deltas to `synthesis/claim_deltas/*.json`, then merges them into `claim_bank.json`.518 ```519 Agent(520 subagent_type="research-synthesizer",521 model="sonnet",522 description="Extract claim state for: <user_request summary>",523 prompt="""524 mode: full525 run_dir: <run_dir_path>526527 Execute claim extraction only.528 Read scope/plan.json, scope/question_tree.json, collect/inventory.json, and selected collect/evidence/*.md batches.529 For large runs, write one synthesis/claim_deltas/*.json file per source, section, or evidence batch, then merge.530 Do not write raw_research.md.531 Do not create planner sections.532 Produce synthesis/global_id_registry.json, synthesis/claim_bank.json, and synthesis/entity_index.json.533 Follow research-synthesize/SKILL.md Stage 1.534 """535 )536 ```537538 Log: `append_log(run_dir, 'claim_extraction', 'agent_spawned', 'ok', 'research-synthesizer dispatched for claim extraction')`5395405. **Validate claim artifacts.**541 ```bash542 python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/global_id_registry.json" ~/.claude/skills/research-synthesize/references/global_id_registry.schema.json543 python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/claim_bank.json" ~/.claude/skills/research-synthesize/references/claim_bank.schema.json544 python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/entity_index.json" ~/.claude/skills/research-synthesize/references/entity_index.schema.json545 ```546547 Log validation results. Validation warnings surface at Gate 3.5485496. **Update manifest:**550 ```python551 update_phase_status(manifest_path, "claim_extraction", "complete")552 ```553554### Phase 4: Graph Relationships555556**Objective:** Build relationship metadata from extracted claims and entities.557558**Canonical outputs:**559- `synthesis/claim_graph_map.json`560- `synthesis/section_graph_hints.json`561562**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.563564**Steps:**5655661. **Update manifest:**567 ```python568 update_phase_status(manifest_path, "graph_relationships", "running")569 ```5705712. **Build compact graph artifacts from claim/entity state.**572 ```bash573 python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-entity-index --run-dir "$run_dir"574 python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-graph-artifacts --run-dir "$run_dir"575 python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/entity_index.json" ~/.claude/skills/research-synthesize/references/entity_index.schema.json576 python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/claim_graph_map.json" ~/.claude/skills/research-synthesize/references/claim_graph_map.schema.json577 python3 ~/.claude/skills/research/scripts/validate_artifact.py "$run_dir/synthesis/section_graph_hints.json" ~/.claude/skills/research-synthesize/references/section_graph_hints.schema.json578 ```579580 `section_graph_hints.json` must list only planner-defined section IDs. Graph centrality is advisory and must not create or reorder sections.5815823. **Update manifest:**583 ```python584 update_phase_status(manifest_path, "graph_relationships", "complete")585 ```586587---588589### Phase 5: Section Brief Synthesis590591**Objective:** Produce compact per-section memory and slices for report composition.592593**Canonical outputs:**594- `synthesis/section_briefs/<section_id>.json`595- `synthesis/claim_slices/<section_id>.json`596- `synthesis/citation_audit.md`597- `synthesis/gap_analysis.md`598- Optional diagnostics: `synthesis/research_notes.md`599600**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.601602**Steps:**6036041. **Update manifest:**605 ```python606 update_phase_status(manifest_path, "section_brief_synthesis", "running")607 ```6086092. **Spawn synthesizer for section briefs and audits.**610611 ```612 Agent(613 subagent_type="research-synthesizer",614 model="sonnet",615 description="Build section briefs for: <user_request summary>",616 prompt="""617 mode: section_briefs618 run_dir: <run_dir_path>619620 Build compact section briefs, per-section claim slices, citation_audit.md, and gap_analysis.md.621 Read claim_bank.json, section_graph_hints.json, scope/plan.json, and source metadata only as needed.622 Do not write raw_research.md.623 Do not read all evidence.624 Every planned section must have claims or an explicit missing-evidence reason.625 Follow research-synthesize/SKILL.md Stage 3 and Gate 3 readiness rules.626 """627 )628 ```629630 Log: `append_log(run_dir, 'section_brief_synthesis', 'agent_spawned', 'ok', 'research-synthesizer dispatched for section briefs')`6316323. **Normalize section artifacts.**633 ```bash634 python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py build-section-artifacts --run-dir "$run_dir"635 ```6366374. **Synthesizer outputs.** Written to `<run_dir>/synthesis/`:638 - `claim_bank.json` -- Canonical claim state (format per `references/claim_bank.contract.md` in research-synthesize)639 - `section_briefs/*.json` -- Compact per-section briefs640 - `claim_slices/*.json` -- Per-section claim/source slices641 - `claim_graph_map.json` -- Compact claim relationship map642 - `section_graph_hints.json` -- Compact advisory section graph hints643 - `citation_audit.md` -- Citation verification results (format per `references/citation_audit.contract.md`)644 - `gap_analysis.md` -- Coverage gaps and weak areas (format per `references/gap_analysis.contract.md`)6456465. **Validate all Slice 2 artifacts.**647 ```bash648 python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/synthesis/claim_bank.json ~/.claude/skills/research-synthesize/references/claim_bank.schema.json649 python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/synthesis/claim_graph_map.json ~/.claude/skills/research-synthesize/references/claim_graph_map.schema.json650 python3 ~/.claude/skills/research/scripts/validate_artifact.py <run_dir>/synthesis/section_graph_hints.json ~/.claude/skills/research-synthesize/references/section_graph_hints.schema.json651 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; done652 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; done653 ```654655 Log: `append_log(run_dir, 'section_brief_synthesis', 'slice2_artifacts_validated', 'ok', f'Slice 2 validation: {validation_status}')`6566576. **Run Gate 3 readiness check.**658 ```bash659 python3 ~/.claude/skills/research-synthesize/scripts/claim_pipeline.py validate-readiness --run-dir "$run_dir"660 ```661662 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.6636647. **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.665666 ```python667 import subprocess, json668 script = Path.home() / ".claude/skills/research/scripts/check_content_rules.py"669 target = run_dir / "synthesis/research_notes.md"670 result = subprocess.run(["python3", str(script), "--target=raw", str(target)], capture_output=True, text=True)671 try:672 payload = json.loads(result.stdout)673 except json.JSONDecodeError:674 payload = {"status": "error", "violations": [], "summary": {"total": 0}, "detail": result.stderr[:500]}675 status = payload.get("status", "error") # 'pass' | 'warn' | 'error'676 violations = payload.get("violations", [])677 total = payload.get("summary", {}).get("total", len(violations))678 log_status = 'ok' if status == 'pass' else 'warn' # D-21: warnings never escalate to 'fail'679 detail = f"violations={total} status={status} rules=" + ",".join(sorted({v.get('rule','?') for v in violations}))680 append_log(run_dir, 'synthesis', 'content_rules_check', log_status, detail)681 # Store for Gate 3 presentation — do NOT block, do NOT raise, do NOT exit the orchestrator on violations.682 content_rules_summary = {"status": status, "total": total, "violations": violations}683 ```684685 **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.686687 **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.688689---690691### Gate 3: Claim State Review692693**Objective:** Detect coverage gaps and fill them with targeted collection and re-synthesis.694695**Steps:**6966971. **Read gap analysis.** Parse `<run_dir>/synthesis/gap_analysis.md` for threshold checks.6986992. **Evaluate gap-fill triggers.** Gap-fill is triggered when ANY of these thresholds are exceeded:700 - Uncovered topics > 25% of planned subtopics701 - Isolated nodes > 20% of total graph nodes702 - Low-confidence claims (tier 4-5 sources only) > 30% of total claims7037043. **If gap-fill triggered:**705 a. Keep `section_brief_synthesis` running while the gap-fill loop executes.706 b. Defer to the synthesizer — the canonical gap-fill execution path lives in the synthesizer skill, not the orchestrator.707708 > **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.709710 c. Maximum 1 gap-fill iteration (no infinite loops) — enforced inside the synthesizer loop.7117124. **If gap-fill NOT triggered:** Continue to Gate 3 and mark `section_brief_synthesis` complete after approval.7137145. **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.715716 **Part A — Synthesis review (summary table):**717 - Strongest areas (sections with most tier-1/2 citations)718 - Weakest areas (sections with fewest citations or only tier-4/5)719 - Gap-fill status ("Not triggered" or "Triggered: N additional pages, M new claims")720 - Total claims from claim_bank.json721 - Citation coverage percentage722 - Average sources per claim723 - Citation audit pass/fail summary724 - Validation warnings from validate_artifact.py725 - **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)726727 **Part A — User options:**728 1. **Proceed to format** -- Continue to formatting with the normalized output settings approved at Gate 1729 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.730 3. **Abort** -- Cancel the run731732 **Output settings:** Verify these normalized fields exist in `manifest.json` before Phase 6. For legacy or resumed runs where they are absent, write defaults:733 ```json734 {735 "depth": "standard",736 "audience": "external",737 "tone": "professional",738 "render_targets": ["md", "html"]739 }740 ```741742 `section_depth_overrides` is optional and defaults to `{}`. Per-section depth must use the same enum as global `depth`: `summary`, `standard`, `comprehensive`, or `audit`.743744 See `references/checkpoint_protocol.md` Gate 3 for full specification.745746 Log: `append_log(run_dir, 'gate_3', 'checkpoint_shown', 'ok', f'Gate 3: {N} claims, {coverage}% coverage')`747 Log (after user responds): `append_log(run_dir, 'gate_3', 'checkpoint_response', 'ok', f'User chose: {choice}')`7487496. **Update manifest:**750 ```python751 update_phase_status(manifest_path, "section_brief_synthesis", "complete")752 ```753754---755756### Phase 6: Formatting / Report Composition757758**Objective:** Compose the canonical Markdown report from section briefs, claim slices, and formatter-owned presentation rules.759760**Canonical outputs:**761- `output/assembly_plan.json`762- `output/sections/<section_id>.md`763- `output/sections/<section_id>.meta.json`764- `output/report.md`765- `output/formatter_audit.json`766767`output/report.md` must be useful by itself and must exist before publishing starts.768769**Steps:**7707711. **Update manifest:**772 ```python773 update_phase_status(manifest_path, "formatting", "running")774 ```7757762. **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:777778 ```python779 import json780 m = json.loads(Path(manifest_path).read_text())781 depth = m.get("depth", "standard")782 audience = m.get("audience", "external")783 tone = m.get("tone", "professional")784 render_targets = m.get("render_targets", ["md", "html"])785 produce_qmd = any(target in render_targets for target in ("qmd", "html", "pdf"))786 quarto_output = (787 "both" if "html" in render_targets and "pdf" in render_targets788 789790…(truncated)