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

#StageRuns inModelWrites
0Configuremainmainconfig.json
1Protocolmainmainoutputs/protocol.md
2Searchmain, scripts only—pool-seeded corpus.jsonl, then workspace/search/<query_id>.json
3Screensubagents, batchedsonnetworkspace/screening/<screener>/pmid-*.json
4Retrievemain + fulltext.py—corpus fulltext block, missing.md
5Extractsubagents, 1/paperopusworkspace/extractions/pmid-*.json, pool synced
6Appraisesubagents, 1/paperopusworkspace/appraisals/pmid-*.json, pool synced
7Synthesizemain onlymainoutputs/report.md
7bDigestsubagent, 1opusoutputs/digest.md
8Verify / publishmainmainoutputs/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:

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.

ProfileScopeRigorGatesmax_articlesExtras
fastnarrowfastnone10Report only
standard (default)mediumstandardprotocol+strategy25Report + OKF bundle when a wiki is selected
systematicwidesystematicboth60Dual screening, PRISMA log, full verifier, optional Quarto export
maxmaxsystematicboth100+ 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

TierSourcesNeeds a connector?
narrowPubMed MCP only (search_articles, get_article_metadata, find_related_articles)PubMed MCP
medium+ scripts/eutils.py — exact boolean/MeSH, hit counts, large result setsNo (plain HTTPS)
wide+ Europe PMC REST — indexes bioRxiv / medRxiv / Research Square; preprints tagged loudlyNo (plain HTTPS)
max+ web: guidelines, grey literature, Scholar Gateway / Consensus connectorsYes, 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.

PubMed MCP is mandatory at every scope. If 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

#AskedWhy it cannot be defaulted
1Question — restated in a framework (PICO/PECO/PEO/PCC/SPIDER/SPICE)Every downstream search axis derives from the framework fields. See Scientific questions.
2ProfileSets article cap, gates, dual screening, and whether the run is report-only.
3Target root — wiki or standalone repoThe run directory, PDF library, and shared paper pool all live under it. Needed before anything is written. See Repo architecture.
4Study selection criteria — designs, population bounds, comparator scope, required outcomes, hard excludesStage 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.
5Filters — years, authors, journals, article types, species/age, language, OA-onlyIncludes 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.
6Outputs — HTML artifact, Quarto PDF/docx, OKF promotion, and whether to stop after extractionreport.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
Reserved directories hold one record shape and nothing else. 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:

AxisValuesWhat varying it buys
A — concept axis droppedDrop one framework facet, drop setting, or drop nothingAn N-facet AND is massively over-specified; each dropped facet recovers records that facet's vocabulary failed on
B — vocabularyMeSH-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 filternone · RCT hedge · SR hedge · observational hedge · null-findings hedge · harms hedgeDifferent literatures index the same question under different designs
D — entry pointde novo boolean · forward chaining · backward chaining · related-articles · journal/author-targeted · registry & greyEscapes 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-patternWhy it failsFix
Same facets, synonyms permutedAutomatic term mapping already ORs most synonyms; J ≈ 0.95Change axis A or C, not the wordlist
Adding one more AND facet per queryEach query is a subset of the last — nested, not orthogonalDrop a facet instead of adding one
Eight design hedges over one facet blockHedges overlap heavily on [pt] termsMax two design hedges plus one no-hedge arm
Only [mh] armsMisses everything unindexed≥1 pure [tiab] arm restricted by [edat]
Only forward chainingInherits the seed set's biasPair citedin with refs and independent seeds
Date-slicing the same queryJ = 0 yet zero new concepts — pagination, not a queryVary 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.

#RungMethodSuccess =access_route
0Local librarylibrary.py lookup by DOI/PMID/PMCID/title, then pdftotext -layout≥100 charslibrary / inbox_manual
1PMC full textPubMed MCP get_full_text_article — coordinator handoff≥100 chars at result_pathpmc_mcp
2PMC PDFGET .../articles/<PMCID>/pdf/, polite UA + rate limit → pdftotextHTTP 200, PDF content type, ≥100 charspmc_pdf
3Europe PMCREST id conversion → fullTextXML → JATS→text; indexes bioRxiv/medRxiv/Research Square natively≥100 chars of bodyepmc_xml
4Unpaywallapi.unpaywall.org/v2/<doi>?email=… → best_oa_locationAn OA URL (no text yet — feeds rung 5)unpaywall
5OA PDF/HTMLFetch; PDF → pdftotext (+OCR fallback); HTML → stdlib parse + truncation detectorPDF ≥100 chars, or HTML body textunpaywall_pdf / oa_pdf / oa_html
6Preprint twinEurope PMC … AND SRC:PPR, title similarity ≥0.90 unless DOI-matched≥100 charspreprint_twin (sets is_preprint)
7Browser search/fetchclaude-in-chrome MCP — coordinator handoff, OA content only≥100 chars at result_pathbrowser_fetch
8QuarantineAppend a block to missing.md, alert the userAlways succeedsquarantine

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:

RuleThreshold
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

HostMin intervalNote
NCBI (pmc., eutils.)0.34 s (≈3 req/s)10 req/s with an NCBI_API_KEY in env
www.ebi.ac.uk0.25 sEurope PMC
api.unpaywall.org0.15 semail= 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:

BlockMeans
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.

The OA ladder is the whole ladder. No credentials, no saved sessions or cookie jars, no institutional/VPN/EZproxy/Shibboleth proxies, no captcha solving, no mirror sites, and no retrying a 401/402/403 by another route. Rung 7's browser exists to render a JS-gated page that carries no access control — a login wall, paywall, or captcha there means 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:

PrecisionRule
preciseInterval excludes the null and both bounds fall on the same side of the minimally important difference
impreciseInterval excludes the null but spans the MID, or the interval is very wide
null-compatibleInterval includes the null
unreportedNo 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

What the verifier checks

check_idPasses when
C-CITE-RESOLVEEvery footnote key resolves to a sources[].id / evidence_id in the corpus, and every cited item was retrieved this run
C-CORPUS-COMPLETEEvery included record has a screening verdict, an extraction, and an appraisal — or a recorded reason it has none
C-SEARCH-LOGEvery executed query has a search record with hit_count_logged, and all appear verbatim in the Methods section
C-RETRACTIONEvery record with retraction_status != "none" is flagged at every point of use; retracted records are excluded from synthesis
C-FULLTEXTmissing_fulltext matches missing.md; every abstract-only claim is labelled
C-HYPOTHESIS-WALLNo evidence-side verb forms inside §13; no unlabelled hypothesis outside it
C-PRISMAThe counter invariants hold and the flow's arithmetic closes
C-PREPRINTEvery is_preprint record is tagged at every point of use
C-ATTRIBUTIONEvery PubMed-sourced footnote carries a PubMed link plus the DOI where one exists
C-PROVISIONALIf a provisional trigger fired, PROVISIONAL appears in the title block and §14 lists the quarantined records
C-SECTIONSEvery skeleton section is present — empty-with-a-stated-reason is acceptable, missing is not
C-OKFBundle concepts validate, PubMed metadata preserved, sources[] present. skipped when no promotion was requested
C-SNAPSHOT C-SPAN C-FRESH-FETCH C-ASSEMBLERThe 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.

The kernel gate is off by default; tamper is never downgraded. Until a live end-to-end run has passed, the four kernel checks run and are reported but warn rather than fail. Turn the gate on with 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.

§SectionBuilt from
0Title blockconfig.json — question, profile, run slug, date of last search, PROVISIONAL marker
1Short answer + plain-language summarySynthesis; ≤200 words, GRADE wordings only, every sentence traceable to §8
2Evidence at a glanceOne row per outcome: studies/participants, direction, best estimate, certainty, main limitation
3Question and protocolprotocol.md — criteria with ids, timepoint bands, MIDs, protocol deviation table
4MethodsSearch records, taskboard, config — every query verbatim and reproducible; unreachable connectors and what they cost
5PRISMA flow + screening logcorpus.py prisma — exclusion reasons by criterion id, dual-screening agreement
6Characteristics of included studiesCorpus + extractions — the evidence table
7Risk of bias and appraisalAppraisals — tool named per study; tool: "none" records listed with the reason
8Results by outcomeSynthesis — effect-direction table, conflicts with attribution, heterogeneity, a required harms subsection
9Certainty of evidenceGRADE summary-of-findings table with the reason for every downgrade
10Conflicts and inconsistenciesEach conflict with its attribution, or an explicit "unexplained"
11Evidence gaps"No study reported X" statements only — observations, not explanations
12Limitations of this reviewQuarantine, abstract-only reliance, scope tier, language/database restrictions, no pooling
13New insights / hypothesesHard-walled, after limitations; every hypothesis paired with what would test it
14Unobtainable / quarantined evidencemissing.md — ids, links, rung reached, resume instructions
15ReferencesA rendering of the in-body footnotes; never the sole citation mechanism
16ProvenanceProfile, 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:

ConditionWhat you are told
Ladder rung 1 unreachableThe best source for paywalled records is unavailable this session; the shortfall is partly infrastructure, not only paywalls
quarantined + abstract_only > half of the selected setExtraction quality is materially limited — said before extracting, with the synthesis marked provisional
Any blocked recordOne consolidated table, both kinds of block, asked once
A connector the profile assumes is unauthorizedNamed, with the reduced-scope-or-stop choice put to you
A script crashesThe actual error, quoted. Not "some issues"
A budget is hitWhich budget, what it cut, and what the run would look like without it
A verifier check failsWhich 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.