Stage 0-8
One literature review, nine stages, and a stage pointer that makes any of them resumable.
The agent is the coordinator: it configures the run, designs the search, and writes the synthesis. Scripts do the network and state work. Bounded subagents do the per-paper work and hand back one-line receipts. This page walks the whole loop — what each stage runs, what it writes to disk, what stops it, and what you see while it happens.
The Shape Of A Run
| # | Stage | Runs in | Model | Writes |
|---|---|---|---|---|
| 0 | Configure | main | main | config.json |
| 1 | Protocol | main | main | outputs/protocol.md |
| 2 | Search | main, scripts only | — | pool-seeded corpus.jsonl, then workspace/search/<query_id>.json |
| 3 | Screen | subagents, batched | sonnet | workspace/screening/<screener>/pmid-*.json |
| 4 | Retrieve | main + fulltext.py | — | corpus fulltext block, missing.md |
| 5 | Extract | subagents, 1/paper | opus | workspace/extractions/pmid-*.json, pool synced |
| 6 | Appraise | subagents, 1/paper | opus | workspace/appraisals/pmid-*.json, pool synced |
| 7 | Synthesize | main only | main | outputs/report.md |
| 7b | Digest | subagent, 1 | opus | outputs/digest.md |
| 8 | Verify / publish | main | main | outputs/result.json, outputs/verification.json, bundle |
Three structural rules hold across all of them, and they are what keeps a hundred-paper run inside a single context window:
- A subagent receives bounded input — one paper, or one batch of PMIDs, plus the criteria and an output path. Never the corpus.
- A subagent writes its own result file and returns only the receipt
{schema_version, task_id, status, output_path, summary}. Never raw paper text, never the full JSON. - Subagents cannot spawn subagents, and the stage's output directory is shared — a subagent must not count, audit, or comment on files it did not write.
Choosing A Profile
The profile sets search breadth, appraisal depth, and which approval checkpoints show back to you. It is asked explicitly at Stage 0 — never inferred from how the question was phrased.
| Profile | Scope | Rigor | Gates | max_articles | Extras |
|---|---|---|---|---|---|
fast | narrow | fast | none | 10 | Report only |
standard (default) | medium | standard | protocol+strategy | 25 | Report + OKF bundle when a wiki is selected |
systematic | wide | systematic | both | 60 | Dual screening, PRISMA log, full verifier, optional Quarto export |
max | max | systematic | both | 100 | + guidelines / grey literature / preprints, connector auth checks |
fast is not a shortcut through retrieval. gates: none only means the protocol and search strategy are not shown back for approval before Stage 2. Every profile, fast included, runs Stage 4 through fulltext.py acquire and the whole ladder. There is no profile-specific path that fetches full text directly, and no profile that skips the Stage 0 questions — fast has no later checkpoint to catch a wrong assumption, so Stage 0 is its only chance to get scope, criteria, and filters right.
Scope tiers, and what each one adds
| Tier | Sources | Needs a connector? |
|---|---|---|
narrow | PubMed MCP only (search_articles, get_article_metadata, find_related_articles) | PubMed MCP |
medium | + scripts/eutils.py — exact boolean/MeSH, hit counts, large result sets | No (plain HTTPS) |
wide | + Europe PMC REST — indexes bioRxiv / medRxiv / Research Square; preprints tagged loudly | No (plain HTTPS) |
max | + web: guidelines, grey literature, Scholar Gateway / Consensus connectors | Yes, and checked |
Stage 0 — Configure
Stage 0 does two jobs: establish what is reachable this session, and settle the handful of choices that cannot be inferred.
Connector preflight, every run
Tool availability is a property of the session, not of the skill. MCP servers attach at session start, so a connector authorized ten minutes ago is still absent until a new session starts. The agent checks its own tool list once, at Stage 0, and records what it found in config.json:
"connectors": {
"pubmed_mcp": true,
"scholar_gateway": false,
"consensus": false,
"checked_at": "2026-09-12T09:14:02Z"
}
That block is carried into the report's Methods section. A run that could not reach a source its profile assumes must say which source, and what it means for coverage.
mcp__claude_ai_PubMed__* is missing, the run does not proceed at a reduced scope — it stops. The connector is authorized in claude.ai → Settings → Connectors and picked up by a new session. There is no "proceed anyway", no alternate fetch route, and no authorization code to paste: it is a browser OAuth flow the agent cannot drive. Scholar Gateway and Consensus (both max-only) stay optional, but if they are missing you are told by name and asked whether to reduce scope now or resume connected — max without them is wide with extra steps, and should be described that way rather than run quietly.
The six questions
| # | Asked | Why it cannot be defaulted |
|---|---|---|
| 1 | Question — restated in a framework (PICO/PECO/PEO/PCC/SPIDER/SPICE) | Every downstream search axis derives from the framework fields. See Scientific questions. |
| 2 | Profile | Sets article cap, gates, dual screening, and whether the run is report-only. |
| 3 | Target root — wiki or standalone repo | The run directory, PDF library, and shared paper pool all live under it. Needed before anything is written. See Repo architecture. |
| 4 | Study selection criteria — designs, population bounds, comparator scope, required outcomes, hard excludes | Stage 1 turns these into numbered criteria that screening cites by id. The agent's own reading of the PICO is not a substitute for your answer. |
| 5 | Filters — years, authors, journals, article types, species/age, language, OA-only | Includes an explicit question about meta-analyses and systematic reviews: in the corpus or filtered out, and if in, synthesized alongside primary studies or appraised separately with AMSTAR-2. |
| 6 | Outputs — HTML artifact, Quarto PDF/docx, OKF promotion, and whether to stop after extraction | report.md is always produced; everything else is opt-in. |
Stop after extraction
Orthogonal to the profile: pipeline.stop_after_stage: 5 in config.json runs search → screen → retrieve → extract and halts. No appraisal, no synthesis, no report, no promotion. It still runs the Stage 5 exit sync, so the extractions are immediately reusable by any later run that hits the same papers — including a different question.
This is a pause point, not a different pipeline. Clearing stop_after_stage and resuming continues the same run into Stage 6.
The run directory
<wiki>/outputs/deep-research/<slug>/ # wiki mode
<repo>/runs/<slug>/ # standalone repo mode
config.json profile, filters, budgets, connectors, stage pointer
engine.log diagnostics, script failures, guard output
taskboard.jsonl one record per unit of work — never hand-edited
events.jsonl append-only retrieval log (evidence kernel)
corpus.jsonl one record per paper, all stages
sources/ immutable text snapshots
inputs/ run inputs
workspace/ per-stage working records
outputs/ protocol.md, report.md, digest.md, verification.json, result.json
missing.md quarantine + abstract-only blocks
inbox/ drop PDFs here to resolve blocks
corpus.py reads every *.json under workspace/search/, workspace/screening/<screener>/, workspace/screening/adjudication/, workspace/extractions/, and workspace/appraisals/, and treats each file as a record of that stage's type. Batch inputs, efetch payloads, and rankings go somewhere else — workspace/batches/, workspace/fetch/. A stray file of the wrong shape is now skipped with a warning rather than crashing prisma, but the directory contract is still the rule.
The stage pointer is the only thing a resume trusts
config.json's stage is bumped as each stage completes, in the same turn. A coordinator that carries the stage in its head leaves the pointer stale, and the next session restarts a stage that already finished. On resume, the agent reads config.json + taskboard.jsonl, continues incomplete and failed tasks, and does not re-prompt.
Stage 1 — Protocol
Question framing in the chosen framework, numbered inclusion/exclusion criteria (I1, E2, … — screening cites these ids), limits, and the planned search. Built from the Stage 0 selection-criteria answers, never invented fresh. Template: templates/protocol.md. At standard and above, the protocol and the search strategy are shown back and the run waits for approval.
Stage 2 — Search
Seed from the shared pool first, then design queries.
python3 scripts/pool.py seed --run-dir <run_dir> --wiki <root> # or --repo <path>
Seeding scores the existing pool against this run's question, framework fields, and filters, and upserts likely papers into corpus.jsonl with source: pool and first_seen_query: pool-seed. They are candidates only — Stage 3 still screens them against the current protocol.
Orthogonality is a property of hit sets, not of strings
The failure this guards against: eight queries that are one query with the synonyms shuffled, producing one hit set, near-zero marginal yield, and a search log that looks thorough. Queries vary along four axes:
| Axis | Values | What varying it buys |
|---|---|---|
| A — concept axis dropped | Drop one framework facet, drop setting, or drop nothing | An N-facet AND is massively over-specified; each dropped facet recovers records that facet's vocabulary failed on |
| B — vocabulary | MeSH-only [mh]/[majr] · text-word [tiab]/[ti] · author keyword [ot]/[nm] · single broad [tw] | Recovers records lost to indexing lag, descriptor drift, non-standard terminology |
| C — design filter | none · RCT hedge · SR hedge · observational hedge · null-findings hedge · harms hedge | Different literatures index the same question under different designs |
| D — entry point | de novo boolean · forward chaining · backward chaining · related-articles · journal/author-targeted · registry & grey | Escapes the vocabulary entirely |
Two queries count as orthogonal when they differ on ≥2 axes and their hit sets satisfy Jaccard J < 0.6 with unique yield ≥ 15% each. A 4–8 query set must cover at least three values of axis A, three of B, two of C, and two of D — plus at least one pure text-word arm bounded by [edat] (the only arm that sees the last ~12 months), at least one null/negative-findings arm, and at least one citation-chaining arm with ≥3 independent seeds.
Anti-patterns the checklist rejects
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Same facets, synonyms permuted | Automatic term mapping already ORs most synonyms; J ≈ 0.95 | Change axis A or C, not the wordlist |
Adding one more AND facet per query | Each query is a subset of the last — nested, not orthogonal | Drop a facet instead of adding one |
| Eight design hedges over one facet block | Hedges overlap heavily on [pt] terms | Max two design hedges plus one no-hedge arm |
Only [mh] arms | Misses everything unindexed | ≥1 pure [tiab] arm restricted by [edat] |
| Only forward chaining | Inherits the seed set's bias | Pair citedin with refs and independent seeds |
| Date-slicing the same query | J = 0 yet zero new concepts — pagination, not a query | Vary the axis profile, not the date window |
Every query string, its NCBI-translated form, and its hit count are logged to workspace/search/<query_id>.json and reproduced verbatim in the report's Methods. Duplicate queries are detected and skipped before execution by corpus.py query-check. A failed orthogonality pair is replaced, never deleted — deleting shrinks the recorded strategy without improving it.
Stage 3 — Screen
Dedupe first (PMID → DOI → normalized title), then title/abstract triage against the numbered criteria. Each verdict records include/exclude/unclear plus the first failing criterion id, and flags retractions and expressions of concern. PMIDs are batched across subagents.
At systematic and max, two independent screeners run, with a third adjudicating every disagreement. The PRISMA log gets raw agreement plus counts:
GOOD "Two screeners independently assessed 412 records; they disagreed on 37
(raw agreement 91.0%). All 37 were adjudicated by a third worker;
12 were adjudicated to include."
BAD "Inter-rater reliability was good."
A kappa is reported only if it was actually computed from the full contingency table — named as Cohen's kappa, with the table shown. Raw agreement is never presented as kappa.
The included set is capped at max_articles, chosen by protocol relevance, and the report says how the cap was applied.
Stage 4 — Retrieve
Goal: full text, not abstracts. fulltext.py acquire walks a nine-rung ladder per paper until text is in hand, recording which rung succeeded. Records screened out are never acquired.
| # | Rung | Method | Success = | access_route |
|---|---|---|---|---|
| 0 | Local library | library.py lookup by DOI/PMID/PMCID/title, then pdftotext -layout | ≥100 chars | library / inbox_manual |
| 1 | PMC full text | PubMed MCP get_full_text_article — coordinator handoff | ≥100 chars at result_path | pmc_mcp |
| 2 | PMC PDF | GET .../articles/<PMCID>/pdf/, polite UA + rate limit → pdftotext | HTTP 200, PDF content type, ≥100 chars | pmc_pdf |
| 3 | Europe PMC | REST id conversion → fullTextXML → JATS→text; indexes bioRxiv/medRxiv/Research Square natively | ≥100 chars of body | epmc_xml |
| 4 | Unpaywall | api.unpaywall.org/v2/<doi>?email=… → best_oa_location | An OA URL (no text yet — feeds rung 5) | unpaywall |
| 5 | OA PDF/HTML | Fetch; PDF → pdftotext (+OCR fallback); HTML → stdlib parse + truncation detector | PDF ≥100 chars, or HTML body text | unpaywall_pdf / oa_pdf / oa_html |
| 6 | Preprint twin | Europe PMC … AND SRC:PPR, title similarity ≥0.90 unless DOI-matched | ≥100 chars | preprint_twin (sets is_preprint) |
| 7 | Browser search/fetch | claude-in-chrome MCP — coordinator handoff, OA content only | ≥100 chars at result_path | browser_fetch |
| 8 | Quarantine | Append a block to missing.md, alert the user | Always succeeds | quarantine |
Why two rungs need the agent
A script cannot call an MCP tool. Rungs 1 and 7 therefore append a handoff task and keep walking the ladder — the run never blocks on either. The agent later reads fulltext.py status, calls the tool, writes the returned text verbatim to the task's result_path, and resolves it:
python3 scripts/fulltext.py status --run-dir <run>
# → mcp_tasks_pending: [...], browser_tasks_pending: [...]
python3 scripts/fulltext.py resolve-mcp --run-dir <run> --evidence-id pmid:33333333 --text-file <path>
python3 scripts/fulltext.py resolve-browser --run-dir <run> --evidence-id pmid:33333333 --status unavailable
Re-running acquire picks up a dropped result file and is equivalent to calling resolve-* — they are the same code path, not two. If the tool reports no full text, resolving with --status unavailable marks the rung terminal so it is never retried.
The truncation detector
Rungs 1, 5, and 7 can all hand back an abstract wrapped in a page of teaser text. The same two checks apply to raw text as to stripped HTML, and either one firing is enough:
| Rule | Threshold |
|---|---|
| Body word count (after tag stripping) | < 1500 words |
| Paywall marker present (case-insensitive substring) | Any of: Access options, Purchase, Sign in to view, Get access, Subscribe |
On fire: truncation_detected: true, status downgraded to abstract_only, and the reason written to the rung detail and engine.log (body_words=303<1500, paywall_marker=Access options). The text is still stored — it is a legitimate abstract — but every downstream claim carries the abstract-only label. The detector is deliberately trigger-happy: a false abstract_only costs a label; a false fulltext corrupts the appraisal.
A truncated success no longer stops the ladder. The first one is kept as a fallback while every remaining rung is tried for genuine full text. A later rung with real full text wins outright and clears any missing.md block; a later truncated result does not replace the fallback; rung 8 is preempted so quarantine never overwrites degraded-but-real text with status: missing.
OCR, and what is not installed
1. pdftotext -layout <pdf> - # always first
2. pdfminer.high_level.extract_text # if pdftotext absent or empty
3. pdftoppm -r 300 -gray -png -f N -l N | tesseract # only if 1-2 yield < 100 chars
ocrmypdf is not installed and there is no whole-file OCR path. Per-page OCR is capped at 30 pages and disabled with --no-ocr; its use is recorded in the rung detail. pdfinfo metadata is not trusted for DOI recovery — page-1 text plus a 10\.\d{4,}/\S+ regex is.
Politeness is enforced, not aspirational
| Host | Min interval | Note |
|---|---|---|
NCBI (pmc., eutils.) | 0.34 s (≈3 req/s) | 10 req/s with an NCBI_API_KEY in env |
www.ebi.ac.uk | 0.25 s | Europe PMC |
api.unpaywall.org | 0.15 s | email= is a required parameter, never guessed — without it rung 4 is skipped |
Two retries on network errors and HTTP 429/500/502/503/504 with linear backoff. Everything else fails the rung and moves on. Email comes from --email or $DEEP_RESEARCH_EMAIL.
Quarantine → inbox → resume
Stage 4 itself never stalls: it walks the ladder for every remaining record regardless of any single failure, and never asks mid-ladder whether to keep going. What stalls, deliberately, is the pipeline as a whole. Once acquisition has been attempted for every selected record, a non-empty missing.md halts the run before Stage 5 — regardless of which kind of block it holds:
| Block | Means |
|---|---|
| Quarantined (rung 8) | Zero text at all. Every rung failed outright. |
| Abstract-only (fallback finalized) | Some rung returned text, the truncation detector flagged it, and the ladder tried every remaining rung before giving up. The real degraded text is stored; a full PDF would upgrade the record. |
An abstract-only block is not a lesser case to be waved through. You get one consolidated table of every blocked record with links, the rung reached, and the ask:
| Title | PMID | DOI | Rung | Status | Links |
|-------|----------|-------------------|------|---------------|------------------|
| ... | 33333333 | 10.1000/paywalled | t8 | quarantined | PubMed · DOI |
| ... | 42119772 | — | t1 | abstract-only | PubMed |
Action: place the PDF in <run-dir>/inbox/ (any filename) and re-run.
systematic and max gate hard on this — extraction does not start until missing.md is empty. fast and standard may proceed on your say-so, with quarantined records tagged missing, abstract-only records used as-is, and the synthesis marked PROVISIONAL either way.
python3 scripts/library.py ingest-inbox --run-dir <run> --wiki <root>
python3 scripts/fulltext.py acquire --corpus <corpus.jsonl> --run-dir <run>
Ingestion matches each dropped PDF by page-1 DOI regex first, then by fuzzy title against quarantined records (normalized title slid across page-1 text, best ratio ≥ 0.85; titles under 25 normalized characters are never fuzzy-matched). Matched PDFs are filed into the library with sha256 dedupe, the corpus updated to source_tier: 0 / access_route: inbox_manual, and the block removed from missing.md. Unmatched PDFs stay in inbox/ and are listed with the reason — never guessed onto a record.
unavailable. The single carve-out is the opt-in institutional_access setting: even then the agent never handles the credential, it stops at the SSO wall and hands the open tab to you, and the result is recorded as browser_fetch_institutional so the Methods section can disclose which full texts came through your own licence. A missing paper is an honest gap in the report; a circumvented paywall is a policy breach.
Stage 5 — Extract
Before dispatching a subagent for a paper, try the pool:
python3 scripts/pool.py reuse --run-dir <run> --wiki <root> --pmid <pmid>
matched: true with a non-null extraction means another run already extracted this exact paper. reuse has already copied that file into this run's workspace/extractions/ tagged "reused_from", and (wiki mode) re-registered every snapshot its spans cite into this run's own store — same source_id, content-addressed, so quotes verify locally exactly like a freshly extracted paper's. Skip the subagent, log the reuse.
Otherwise: one subagent per paper, opus, references/prompts/extract.md. Design, N, population, I/C, outcomes with effect + CI + direction, funding/COI, limitations, and spans with character offsets. evidence_basis is abstract_only whenever no full text was obtained.
A reused snapshot carries a register event, never fresh — the text was not retrieved in this run. C-FRESH-FETCH reports it as non-fresh accordingly; C-SNAPSHOT and C-SPAN pass normally, because the content is locally present and hash-verified. See Evidence kernel.
On stage completion, sync the pool and show the table:
python3 scripts/pool.py sync --run-dir <run> # wiki mode
python3 scripts/registry.py promote --repo <path> --run-dir <run> # repo mode
python3 scripts/status.py <run> --table
The table shows which papers were downloaded, in what format, and whether extraction ran — so you see corpus coverage before appraisal, including which rows were reused rather than freshly extracted. Stage 6 does not start while any row is still pending extraction.
Stage 6 — Appraise
Same reuse gate, called per paper (idempotent — this time it checks for a resolving appraisal_path). In repo mode pass --project <slug>: appraisal there is project-scoped, because risk of bias and certainty depend on the question being asked, not on the paper alone.
Otherwise one subagent per paper, opus. Tool selected by design — RoB 2 / ROBINS-I / Newcastle-Ottawa / AMSTAR-2 / QUADAS-2 / PROBAST / CASP / JBI / none — then GRADE domains. Abstract-only records are never appraised as if full text. Full routing and domain detail: Appraisal frameworks.
python3 scripts/pool.py sync --run-dir <run>
python3 scripts/registry.py appraise-promote --repo <path> --run-dir <run> --project <slug>
Stage 7 — Synthesize
Main thread only. Effect-direction tabulation, agreement and conflict with an explanation of why, certainty, gaps, and a hard-walled hypotheses section. One row per study-outcome, with precision classified rather than asserted:
| Precision | Rule |
|---|---|
precise | Interval excludes the null and both bounds fall on the same side of the minimally important difference |
imprecise | Interval excludes the null but spans the MID, or the interval is very wide |
null-compatible | Interval includes the null |
unreported | No interval reported — the row still appears; it is not dropped |
Nulls are not estimated, conflicts are not averaged away, and appraisal constrains the verbs the report is allowed to use (see Downgrading claim strength).
Stage 7b — Digest
One subagent compresses the finished report (§1, §8, §9, §10, §11, §13, §15 only) into outputs/digest.md: an answer-first paragraph, a categorical outcomes table (outcome / direction / certainty / studies), and an ## Open Questions section merging gaps and hypotheses, each paired with what would resolve it. No new research, no re-derived judgement, the same GRADE wordings and the same hard wall as the source report.
This is the body that okf.py promote writes into research/reviews/<slug>.md. The full report stays linked from it as the audit trail — the digest does not replace §0–§16 for methods, PRISMA, or provenance detail.
Stage 8 — Assemble, Verify, Publish
Fixed order. Each step's output is the next step's input:
assemble.py run → verify.py run → render.py / html_report.py → okf.py promote --check → okf.py promote
assemble.pydecides which artifacts are admissible and writesoutputs/result.json— the evidence ledger.verify.pyruns the report checks plus the four kernel checks and writesoutputs/verification.json.okf.py promote --checkre-verifies integrity and writes nothing. It is mandatory before the real promote.- Only then does promotion write concepts — and it fails closed: any failure aborts the whole promotion, never a partial bundle.
What the verifier checks
check_id | Passes when |
|---|---|
C-CITE-RESOLVE | Every footnote key resolves to a sources[].id / evidence_id in the corpus, and every cited item was retrieved this run |
C-CORPUS-COMPLETE | Every included record has a screening verdict, an extraction, and an appraisal — or a recorded reason it has none |
C-SEARCH-LOG | Every executed query has a search record with hit_count_logged, and all appear verbatim in the Methods section |
C-RETRACTION | Every record with retraction_status != "none" is flagged at every point of use; retracted records are excluded from synthesis |
C-FULLTEXT | missing_fulltext matches missing.md; every abstract-only claim is labelled |
C-HYPOTHESIS-WALL | No evidence-side verb forms inside §13; no unlabelled hypothesis outside it |
C-PRISMA | The counter invariants hold and the flow's arithmetic closes |
C-PREPRINT | Every is_preprint record is tagged at every point of use |
C-ATTRIBUTION | Every PubMed-sourced footnote carries a PubMed link plus the DOI where one exists |
C-PROVISIONAL | If a provisional trigger fired, PROVISIONAL appears in the title block and §14 lists the quarantined records |
C-SECTIONS | Every skeleton section is present — empty-with-a-stated-reason is acceptable, missing is not |
C-OKF | Bundle concepts validate, PubMed metadata preserved, sources[] present. skipped when no promotion was requested |
C-SNAPSHOT C-SPAN C-FRESH-FETCH C-ASSEMBLER | The four evidence-kernel checks — see Evidence kernel |
A fail makes the report provisional and blocks OKF promotion. The report is still delivered — outputs/report.md is preserved even when rendering, export, or promotion fails, and the failure is recorded in engine.log.
verify.py --gate / assemble.py --strict, or gates.evidence_kernel: true. Two things are never affected by that flag: a snapshot hash mismatch and an excerpt that does not match its re-slice are hard failures that block promotion regardless. Legacy span-less records are unverified — a different thing from tampered, and reported as such.
Report skeleton
Sixteen sections, each naming its data source. verify.py matches headings by regex, not by number, so renumbering or adding subsections is safe.
| § | Section | Built from |
|---|---|---|
| 0 | Title block | config.json — question, profile, run slug, date of last search, PROVISIONAL marker |
| 1 | Short answer + plain-language summary | Synthesis; ≤200 words, GRADE wordings only, every sentence traceable to §8 |
| 2 | Evidence at a glance | One row per outcome: studies/participants, direction, best estimate, certainty, main limitation |
| 3 | Question and protocol | protocol.md — criteria with ids, timepoint bands, MIDs, protocol deviation table |
| 4 | Methods | Search records, taskboard, config — every query verbatim and reproducible; unreachable connectors and what they cost |
| 5 | PRISMA flow + screening log | corpus.py prisma — exclusion reasons by criterion id, dual-screening agreement |
| 6 | Characteristics of included studies | Corpus + extractions — the evidence table |
| 7 | Risk of bias and appraisal | Appraisals — tool named per study; tool: "none" records listed with the reason |
| 8 | Results by outcome | Synthesis — effect-direction table, conflicts with attribution, heterogeneity, a required harms subsection |
| 9 | Certainty of evidence | GRADE summary-of-findings table with the reason for every downgrade |
| 10 | Conflicts and inconsistencies | Each conflict with its attribution, or an explicit "unexplained" |
| 11 | Evidence gaps | "No study reported X" statements only — observations, not explanations |
| 12 | Limitations of this review | Quarantine, abstract-only reliance, scope tier, language/database restrictions, no pooling |
| 13 | New insights / hypotheses | Hard-walled, after limitations; every hypothesis paired with what would test it |
| 14 | Unobtainable / quarantined evidence | missing.md — ids, links, rung reached, resume instructions |
| 15 | References | A rendering of the in-body footnotes; never the sole citation mechanism |
| 16 | Provenance | Profile, budgets, models per stage, verification summary, links to every artifact |
Citation is per claim, by markdown footnote keyed to a sources[].id. A claim resting on three studies carries three footnote references, not one covering footnote, and each footnote carries the appraisal verdict and evidence basis inline — which is what makes an abstract-only label survive copy-paste:
Group CBT reduced depressive symptoms at 12 weeks relative to waitlist
(SMD -0.41, 95% CI -0.68 to -0.14).[^pubmed-12345678]
[^pubmed-12345678]: Smith JA, Doe R, Muller K. Cognitive behavioral therapy for
adolescent depression: a randomized trial. *J Example Med.* 2024;12(3):101-115.
PMID [12345678](https://pubmed.ncbi.nlm.nih.gov/12345678/).
DOI [10.1000/example](https://doi.org/10.1000/example).
RoB 2: some concerns. Full text.
Guardrails And Progress
Budgets are enforced, not aspirational
max_subagents, max_parallel, max_wall_time, max_articles, max_fulltext_failures, and max_ocr_pages live under config.json's budgets. max_wall_time is seconds as a bare number, or a suffixed string (90m, 3h, 1d) — never a bare number meaning minutes.
The no-progress guard
Repeated identical tool calls, or repeated task assignment with no newly completed or blocked work, write diagnostics to engine.log, stop dispatch, and move to verification with a provisional report.
python3 scripts/corpus.py guard --run-dir <run> # records a probe
python3 scripts/corpus.py guard --run-dir <run> --no-record # status check only
Use --no-record for polling. Every recording call appends a probe, so checking the guard while a run is legitimately parked manufactures the very flat window it looks for. A board with nothing pending and nothing active reports idle: true — idle is not stuck.
Health alerts you should expect to see
A degraded run looks exactly like a healthy one unless someone says otherwise. Each of these is surfaced in the turn it is discovered, with the numbers:
| Condition | What you are told |
|---|---|
| Ladder rung 1 unreachable | The best source for paywalled records is unavailable this session; the shortfall is partly infrastructure, not only paywalls |
quarantined + abstract_only > half of the selected set | Extraction quality is materially limited — said before extracting, with the synthesis marked provisional |
| Any blocked record | One consolidated table, both kinds of block, asked once |
| A connector the profile assumes is unauthorized | Named, with the reduced-scope-or-stop choice put to you |
| A script crashes | The actual error, quoted. Not "some issues" |
| A budget is hit | Which budget, what it cut, and what the run would look like without it |
| A verifier check fails | Which check id, and what it blocks. A failing kernel check is never rounded down to "passed with warnings" |
Two rules override any instinct to keep a run looking clean. Never report a stage complete when part of it silently did not run — "10 of 25 full text" is the result, "acquisition complete" is not. And an empty result is a finding: if a query yields 7 records where hundreds were expected, the literature is thin, and the query is not quietly widened until the number looks respectable.
Progress updates
After every stage transition and at least once per batch of dispatched subagents, the agent runs status.py and posts the output. This applies to fast too — "report only" affects which artifacts are produced, not whether you can see what stage the run is in.
python3 scripts/status.py <run> # stage, progress bar, counts
python3 scripts/status.py <run> --table # per-paper coverage
python3 scripts/status.py <run> --missing # blocked records only
python3 scripts/watch.py --run-dir <run> # read-only TUI, attach to a live or finished run
The task board
Every unit of work is a record in taskboard.jsonl, and it is never hand-edited. All transitions go through the CLI, which computes inputs_hash, stamps timestamps, and appends:
python3 scripts/corpus.py task claim|complete|fail|block|list|next --stage <stage> [...]
Task ids follow <stage>:<key-kind>:<key> — extract:pmid:12345678. Completed outputs are immutable unless inputs_hash changes; failed tasks retry independently. The subagent receipt's summary is passed to task complete --summary so the one-line result lives on the board rather than only in the coordinator's context.