okf-ocr
Process scanned or text-less documents in OKF matter folders. Use when files appear in 98_Inbox_ToProcess, when OCR log rows are PENDING, when the user mentions OCR, scanning, digitising documents, extracting tables, or processing the inbox in an OKF matter. Also maps dense documents (expert reports, FRAEW, adjudications) into a verified section index + per-section map (tools/dense_doc_map.py) so the README/Chronology can point into the document instead of at it. Transcribes verbatim via the pipeline (tools/ocr_pipeline.py — PyMuPDF text layer + Gemini vision for scans with PaddleOCR offline fallback + pdfplumber tables + pandas), tags files _OCR/_SRC, updates 97_OCR_Log, the matter README, matter.yaml and Chronology. Self-learning: appends new lessons to learnings.md.
Source: .opencode/skills/okf-ocr/SKILL.md — site rebuilt 2026-09-05.
OKF OCR & Extraction¶
When to run¶
- Files sit in a matter's
98_Inbox_ToProcess(Contentious_Template or NonContentious_Template layout). - The user asks to OCR, digitise, or extract tables from documents.
97_OCR_Log\OCR_Log.csvcontains PENDING rows whose files are already filed in matter folders (inbox may be empty — process PENDING items in place).- A matter has a dense document (expert report, FRAEW, adjudication decision, long advice) that the README/Chronology must reference — map it so references point at sections, not the whole document.
Tooling¶
tools/ocr_pipeline.py in this skill folder does ALL transcription work — engine precedence:
- Fast classifier (v1.10, optional, diagnostic) — pdf-inspector (Firecrawl, Rust,
pip install pdf-inspector) classifies every PDFtext_based/scanned/image_based/mixedin milliseconds and cross-checks the PyMuPDF text layer. Divergence → warnings only; the deterministic text layer stays authoritative for routing. Result goes to_pipeline.json,_V1_OCR_meta.json(classify:), and OCR_Log notes (classify: <type>). Disable with--no-classify. -
Office files (v1.10, optional) — anydoc (Firecrawl, Rust,
pip install firecrawl-anydoc) converts.doc/.docx/.ppt/.xls/.rtf/.epub/...to a clean Markdown<base>_V1_OCR.txtsidecar in Phase 1 (free, local, ~10 ms). Skipped with a note when not installed. anydoc's hosted OCR fallback sends scans to Firecrawl — never route client scans through it; Gemini/PaddleOCR stay the scan engines. -
Text layer — PyMuPDF
get_text()per page (instant, exact for digital PDFs) - Scans (default) — Gemini vision API (
gemini-3.5-flash-lite), verbatim transcription with{..}-braced uncertainty spans →[LOW CONFIDENCE]/[FIGURE UNVERIFIED]markers. Page batching (v1.8): scan pages go to Gemini in ONE request per batch of--gemini-batch Npages (default 20; user-tested sweet spot 20-40) with--- PAGE n ---markers; a whole multi-page file costs ~1 request. Missing pages (truncation) are silently re-batched at half size; pages that still fail fall to the next engine.--gemini-batch 1restores the old one-page-per-request behaviour. Daily budget guard: Google free tier caps requests per Cloud project (flash-lite 15 RPM / 1,000 RPD, resets midnight PT) — every Gemini request bumps a shared counter (%TEMP%\okf_gemini_daily.json, all processes/matters); once the daily cap is spent, the chain skips Gemini (auto falls to Muse/Qwen/Mimo) instead of failing.--engine textis for text-layer-only runs and skips scans. - Scans (Muse XHigh — highest quality) — Muse Spark 1.2 XHigh via the opencode-go gateway (
--engine muse, OpenAI-compatiblehttps://opencode.ai/zen/go/v1, modelmuse-spark-1.2-contributoraliasmuse-xhigh, key from authopencode-go.key/MUSE_API_KEY). Same{..}-brace tagging as Gemini/Qwen/Mimo. Requires workspace opt-in for data collection athttps://opencode.ai/workspace/wrk_01KZ9PBDY4PH0VY9KR7K20X140/go(until opted in the engine returnsDataPolicyErrorand the chain falls through soft — checkocr_pipeline.py:1header). Theautochain is now gemini → muse → qwen → mimo → mistral → paddle (Muse sits directly after Gemini as the XHigh quality engine). - Scans (fast path) — Qwen3.8 Max via the opencode-go gateway (
--engine qwen, OpenAI-compatiblehttps://opencode.ai/zen/go/v1, modelqwen3.8-max, key from authopencode-go.key/QWEN_API_KEY). Much faster than Gemini on photo/scan batches; same{..}-brace tagging. - Scans (gateway fallback, preferred over Gemini free/Mistral) — Mimo v2.5 via the opencode-go gateway:
mimo-v2.5is FREE (flash; PNG images only — the pipeline auto-converts JPG/TIF via PIL). OpenAI-compatible on the same endpoint as qwen/muse (key from authopencode-go.key/MIMO_API_KEY), same{..}-brace tagging. When vision has issues (Gemini free tier 429ing/recitation, Mistral slow or delayed), fall to muse/qwen/mimo instead of waiting — do not rely on Gemini free or Mistral under delay. Theauto/vision/qwen/mimo/musechains put muse after gemini, mimo between qwen and mistral;--engine museforces Muse XHigh then falls to qwen/mimo/mistral/paddle. COST RULE: the paid tierkimi-k3is NOT in any chain and never escalates automatically — mass OCR must not land on a paid model. It is explicit opt-in only, via--mimo-model kimi-k3on a targeted single-file run. (mimo-v2.5-prois TEXT-ONLY on the gateway — 404 no image endpoints.) Gateway requests send a browser-like User-Agent (Cloudflare 403/error-1010 blocks urllib's default one). - API concurrency gate (v1.7) —
--max-concurrency Ncaps simultaneous API calls (Gemini/Muse/Qwen/Mimo/Mistral combined) ACROSS all pipeline processes, not just one: a token pool guarded by an OS file lock (%TEMP%\okf_ocr_api_gate.lock, msvcrt on Windows / fcntl elsewhere, tokens expire after 10 min so a crashed process can't drain the pool). Default 2 (OKF_MAX_API_CONCURRENCYenv overrides;0= unlimited). This is the backstop that makes parallel matter agents safe on the opencode-go quota — keep it at 2 unless the user raises the matter cap too. Single-process runs are unaffected (they were serial anyway). - Low-confidence auto-resolution (v1.4) — when a page has any flagged line, the pipeline automatically re-reads the WHOLE page at 1.5× DPI with the next available engine (prefers muse/mimo/qwen over Gemini-free/Mistral — see delay policy) and line-reconciles: flagged lines confirmed cleanly by the alt engine get
[RESOLVED: <engine>@<dpi>dpi]and stop flagging; lines the alt engine reads DIFFERENTLY get[ALT: ...]but stay flagged; lines BOTH engines doubt stay flagged. A second disagreement pass checks the model's confident lines against the alt read: content-bearing lines (≥6 chars) the engines read differently (0.65–0.99 similarity) get[CONFLICT: <engine> reads "..."]— this catches silent errors like3MTvsOMT,UG467vsUG487that the model never flagged.[CONFLICT]lines keep the page PARTIAL. Disable with--no-resolve; resolutions are reported in the page summary (mistral@300dpi (2 resolved, 0 ALT, 1 CONFLICT)) and in the OCR_Log notes. Engine output varies run-to-run (e.g. a bad Gemini pass that silently swapped product codes), so[CONFLICT]/[LOW CONFIDENCE]residuals are never auto-cleared — a human/agent decides them. - Scans (fallback 1) — Mistral OCR (
mistral-ocr-latest), ~2–5 s/page, no safety filter — reliably transcribes the boilerplate pages Gemini refuses;tools/mistral_ocr_pipeline.pyis a wrapper forcing--engine mistral. Last-resort-only when Gemini/Mimo/Qwen all fail or are delayed — do not default to Mistral. - Scans (fallback 2) — PaddleOCR (PP-OCRv6,
enable_mkldnn=False— the oneDNN flag avoids a runtime crash), ~50 s/page, per-line scores →[LOW CONFIDENCE n.nnn]markers - Tables — pdfplumber
extract_tables()(deterministic, structure-exact) - Table vision-QA (safety net, ON by default) —
table_qa_pass(): for every page with tables (cap--table-qa-max, default 10), renders the table bbox crop and re-transcribes with a vision model (gemini → qwen → mimo → mistral), then NUMBER-ANCHORED verification: every figure pdfplumber extracted must be confirmed by the SIDECAR page text (exact text layer on digital pages) or the vision transcription — substring match against digit-collapsed text (spaced glyphs0 0 0 2==0002). Verdicts: PASS (all figures confirmed) / PARTIAL (unconfirmed figures listed; confirmation-rate <60% on dense CAD grid-glyph tables — pdfplumber's layout-aware extraction is authoritative there) / UNVERIFIED (vision verifier failed but page has a text layer — extraction still exact) / FAIL (no text layer AND figures unconfirmable, or vision error on a scan page). Row-count mismatches are scope noise (vision reads headers/letterhead in the crop as rows), never a failure. Writes<base>_V1_OCR_table_qa.csvnext to the source +table QA:summary line in OCR_Log notes. Report-only — nothing auto-corrected. Disable with--no-table-qa. This is the automated half of the "tables always double-checked" rule; the model still spot-checks 1-2 invoice sums + bank balance chains per matter. - Table QA (vision, ON by default) — every page with tables (cap 10 pages/file,
--table-qa-max N) is bbox-cropped and re-transcribed by a vision model (gemini→qwen→mistral), then number-anchored compared against pdfplumber: every figure pdfplumber extracted must appear in the vision read. Verdict PASS / PARTIAL (<3 missing figures) / FAIL (≥3 missing or vision error); row-count mismatch is scope noise (vision reads letterhead/headers as rows) and is NOT a failure. Writes<base>_V1_OCR_table_qa.csvnext to the source + atable QA:summary line in the OCR_Log notes. Skips re-run when the CSV exists;--forceredoes. Report-only — nothing auto-corrected. Disable with--no-table-qa. This catches silent figure swaps/column shifts pandas never sees. - Financial tables cross-check —
tools/table_vision_check.py <matter_root>: for invoice/fee-note PDFs, vision reads line-item amounts + stated totals and cross-checks against the tables CSV (line-item count parity, sum-of-items vs stated totals, vision vs pdfplumber agreement). Verdict MATCH/FLAG/NO-TABLES →00_Admin\table_vision_check.csv. Run it when a matter has invoices/fee notes with arithmetic flags. - Arithmetic — pandas column-sum vs stated total, auto-flags
[ARITHMETIC ERROR: ...]/[TOTAL OK: ...]in the CSV - Outputs —
<name>_V1_OCR.txtsidecar next to source +<name>_V1_OCR_tables.csvin the matter's Extracted_Data folder +<name>_V1_OCR_meta.jsonprovenance sidecar (producer/creator, creation date, digital-vs-scan origin, text-layer coverage, image counts, DPI hints, portfolio embedded-file count; EXIF for images — GPS as presence only, never coordinates) +<name>_pipeline.jsonmachine-readable summary - OCR_Log auto-update — one row per processed file is upserted into the matter's
97_OCR_Log\OCR_Log.csvkeyed on (File, Destination): created if absent, replaced on re-runs (no duplicates). Quality = qa overall_flag, tables flag PASS/NO, notes list page methods + sidecar + ameta:provenance line (e.g.meta: Aspose.Words 23.6.0 | created 20241001 | pure scan (0/45 text pages)). Disable with--no-log. The log folder is found via--matteror by walking up from the target until97_OCR_Logis found.
tools/apply_schema.py applies the OKF folder schema to a flat matter dump:
- --skeleton — create the template tree (contentious/noncontentious, auto detects 02_Pleadings vs 02_Contract_Documents)
- --scan --client <kw> --opponent <kw> --third <kw> — classify every file → moves.csv (no changes); counterparty keywords route email stubs by subject; content keywords (invoice/agreement/plan/certificate/...) route documents; identical-size small image pairs → decorations; UNCLASSIFIED items printed for human review. Non-contentious construction rules added 2026-08-05: SOA/redline/risk-analysis → 02_Contract_Documents\04_Amendments_Variations, collateral warranties + licence agreements → same folder, bonds/guarantees/PCG/NBIT → 04_Pre_Contract\03_Bonds_Guarantees_Insurance, professional appointments/fee proposals → 03_Professional_Appointments, tender packs/schedule of works → 04_Pre_Contract\01_Tenders_Proposals, front-end info/annexure/site plan → 01_Main_Contract, communications logs + .html working files → 00_Admin. email from X to Y on DATE.pdf portfolio exports are treated as email (counterparty keywords match party names in the filename).
- --apply [--purge-decorations] — perform the moves (refuses to overwrite existing files)
- --dedup [--apply] — sha256 byte-identical captures → consolidated to one copy (dupes to _thread_parts\ on apply); reports version families (draft/final/signed/Vn/dated copies of the same document) to 97_OCR_Log\version_families.csv — name them as ONE version chain at filing time, but note a family may still contain DISTINCT documents after (N)-stripping (review before renaming). Run BEFORE --scan.
- --init --ref <no> --client <n> --opponent <n> — create 97_OCR_Log\OCR_Log.csv header, README.md, matter.yaml stubs if missing. Template-aware since 2026-08-05: non-contentious matters get the construction README header (parties, project, contract form, contract sum, key dates) + construction legend + matter.yaml with contract_form/contract_sum/professional_team — no longer inherits the contentious (02_Pleadings) legend.
- --report — missing-evidence warnings (zero-byte .gdoc stubs, decoration candidates, generic-named images)
- --email-threads [--apply] — email chains: clio exports the SAME thread many times (13+ captures of one subject is normal). Groups by normalised subject (strips Parsed Email -, export timestamps, Re_/Fw_/RE_/Fwd_/Automatic_reply, punctuation), merges single-member groups into multi-member groups by substring, keeps the latest stub capture + every real file (distinct correspondence), consolidates duplicate captures (and identical sha256 content) into <folder>\_thread_parts\ on --apply. Writes 97_OCR_Log\email_threads.csv thread map. Run on every new batch before filing.
- --check — warn if new/unprocessed files were added since the agent last ran (unfiled root files, files with no OCR_Log row and no sidecar, non-empty 98_Inbox_ToProcess); exit 1 if anything found. 00_Admin is never checked (working files). Run this whenever the user says "new files" / "files added" — then process them via the pipeline.
- tools/dense_doc_map.py — map dense documents (expert reports, FRAEW, adjudications): parses the document's own ToC into a canonical section list, locates each section's start page in the body text layer, and emits <base>_V1_OCR_index.md (mechanical, page numbers verified) + <base>_V1_OCR_map.md (AI-fill stub: per-section Substance / Key holdings / Chronology relevance, all [UNVERIFIED] until confirmed against the verbatim source). ToC-less docs fall back to single-line numbered titles (level 1-2 only). Run it on any dense document the README/Chronology must reference; README points to the map, map points to sections, sections point to verbatim pages.
- tools/commslog_archive.py — MANDATORY for every matter with a Master Communications Log: convert the clio log (plus any 00_Admin\*Daily Communications Log*.txt, same format — merged chronologically, overlap removed by subject+body dedup) into 00_Admin\Email_Archive\: per-thread folders of individual-message .md files. Nested email chains are unpacked into separate messages, boilerplate segments (Mimecast banners, signatures) merged into parents, duplicates removed (subject+body hash), threads named from 97_OCR_Log\email_threads.csv. Nomenclature: thread folder + NNN ordinal — every message has a stable ID thread/NNN (0001-style zero-padded chronological position; ties by body hash), filename NNN_YYYYMMDD_Sender_Subject.md, a plain-text _V1_OCR.txt sidecar per message (written automatically, same convention as OCR sidecars — 1:1 with the .md, so every archived message is uniformly searchable), per-thread index.md table, and stub_index.md mapping every .gdoc stub to thread folder + message range. Every sidecar ends with an Attachments: field — real name+size when the source carries a manifest (.eml/.msg/portfolio embeds), otherwise (no manifest in source log). A message quoted in other chains exists once, at its origin position. .gdoc stubs themselves are unreadable Drive pointers while GDFS isn't streaming — but their content lives in the Master/Daily Communications Logs, so the archive is complete without Drive access; the logs stay the verbatim source. Run: python tools/commslog_archive.py <matter_root> (re-runs clear the output folder first).
- tools/eml_split.py — message-level email decomposition for every .eml/.msg: splits each chain into its component messages → <source>_split\NNN_YYYYMMDD_Sender_Subject.md (one file per unique message, dedup by subject+body hash, per-source index.md), extracts attachments (sha-deduped, _N suffix on collisions) to 03_Productions\06_Originals\_eml_attachments\. Tags per message: ID, From, To, Cc, Sent, Subject, Attachments, Attachments extracted, Sensitivity, Source (+ Archive ID cross-ref when the subject matches an Email_Archive message). Sensitivity auto-detected from body keywords (without prejudice / privileged / confidential / legal advice). Run AFTER building the Email_Archive; then cross-source dedup: collapse (subject, body) dupes across ALL _split dirs (the same message is quoted in every chain capture that contains it) — keep the canonical copy (longest source-dir name), record deletions in 00_Admin\eml_split_dedup_manifest.csv, and REGENERATE the affected index.md files. Run: python tools/eml_split.py <matter_root>.
- tools/native_structure.py — append a [DOCUMENT OUTLINE] appendix to sidecars from native source structure: PDF fitz.get_toc() (fallback heading-scan), DOCX Heading 1-3 styles, XLSX sheet names + dimensions. Idempotent (skips sidecars already containing the marker); skips admin/working files and .gdoc pointers. Run in Phase 1 for born-digital docs so every sidecar carries its structural outline.
- tools/table_vision_check.py — vision cross-check of extracted financial tables (invoices, fee notes, outlays, bills). Renders the table pages and asks a vision model (Mistral by default — markdown tables natively; Gemini fallback) to read line items + labeled totals, then cross-checks against the pdfplumber extraction: line-item parity, totals agreement (per-invoice Total preferred over account-level Outstanding Balance/Total Amount Outstanding, subtotal lines excluded, adjacent label/amount lines handled). Verdict per file MATCH/FLAG → 00_Admin\table_vision_check.csv. Catches what the pandas column-sum auto-flag misses: pdfplumber empty-table extractions (McAuley Outlays: full ledger + £11,934.32 recovered; invoice_3497: true £54,836.28 vs artifact 295), and account-vs-invoice total confusion (invoice_3446 £3,481.20 balance vs £2,348.40 invoice total). Run in Phase 2b on all financial PDFs.
- README stubs from --init carry a NEW FILES banner reminding to run the agent when files are added.
The Gemini key is picked up automatically from opencode's auth store (~/.local/share/opencode/auth.json → google.key) or GEMINI_API_KEY; the Mistral key from the same file (mistral.key) or MISTRAL_API_KEY — no extra setup. Without keys the pipeline falls back to PaddleOCR (--engine paddle forces it; --engine mistral forces Mistral).
Key behaviours:
- Resume-safe — the sidecar is rewritten after EVERY page and re-runs skip pages already transcribed (--pages 1-7 chunked runs merge with existing sections instead of replacing them)
- Never destructive — an existing good page section survives even if a re-run fails on that page (--force re-does everything)
- RECITATION guard — if Gemini's safety filter blocks a page (it refuses verbatim copies of standard legal boilerplate), the pipeline retries a rephrased prompt, then Mistral OCR, then PaddleOCR for that page automatically
- Google Drive quirk — sidecars on H: are written in place, NOT via temp-file+rename (GDFS reverts os.replace())
Run it per file or per folder: python <skill>/tools/ocr_pipeline.py <target> --matter <matter_root> --json <out.json> [--engine auto|vision|muse|qwen|mimo|mistral|paddle|text] [--pages N-M] [--force] [--no-resolve] [--no-table-qa] [--table-qa-max N] [--max-concurrency N] [--muse-model muse-spark-1.2-contributor] [--mimo-model mimo-v2.5]. The paid kimi-k3 is explicit opt-in only (--mimo-model kimi-k3) for a targeted single-file run — never in the default chains. Muse requires workspace opt-in (see above).
The model's job is: triage, run the pipeline, QA the output against the source (vision spot-check if available), file, log, maintain knowledge links, learn.
Order matters: dedup → text-extract → vision → emails → file¶
Canonical order (see Workflow): Phase 0 dedup/decoration cleanup FIRST — run --dedup --apply (sha256 byte-identical captures → _thread_parts\; reports version families draft/final/signed/Vn/dated to 97_OCR_Log\version_families.csv, name as ONE version chain at filing; a family may still contain DISTINCT documents after (N)-stripping — review before renaming) so the scan plan is smaller and nothing downstream is wasted → Phase 1 born-digital text extraction + quality assessment (free, offline, exact — the bulk of any corpus) → Phase 2 vision OCR only for the no-text remainder (the expensive tail) → Phase 2b post-OCR quality check (batch-level verification: cross-engine no-text verdicts, CONFLICT/ALT artifact triage, residuals never auto-cleared, arithmetic-artifact recognition, case-ref discrepancy flagging) → Phase 3 email layer (.eml/.msg conversion, content dedup, --email-threads --apply BEFORE --scan; since 2026-08-05 --email-threads --apply is deliberately decoupled from the plain --apply branch — it will NOT apply moves.csv anymore, it used to, and an unreviewed plan applied 840 moves before review on 01403) → Phase 4 file/consolidate (--scan, review moves.csv, then --apply (or --scan --apply after review), sidecars follow sources, log/README/yaml/chronology once). The Email_Archive build (commslog_archive.py) is MANDATORY in Phase 3 for every matter with a Master Communications Log — one .md sidecar per unique message, thread/NNN IDs, merged with the Daily Communications Logs; the archive is the reviewable email evidence layer and the matter is incomplete without it.
Self-learning¶
The skill remembers what it learns. learnings.md sits next to this SKILL.md and is the skill's memory.
- Before a batch: read
learnings.mdand apply known quirks (tool behaviors, file-type gotchas, engine bugs). - After a batch: append any NEW lessons to
learnings.md— one dated bullet each. Never delete old entries. - What to record: file-type quirks (e.g.
.gdocstubs,.msgoutput layout, PDF portfolio wrappers), engine behaviors (PaddleOCR flags, pdfplumber table catches), model limitations, environment gotchas, efficiency techniques. - If a lesson saved real time or prevented an error, it MUST be recorded — that is how the skill improves.
Parallel multi-matter batches (the list workflow)¶
When the user hands a list of matters to work through (refs, folders, or inboxes), treat it as a queue processed by parallel workers — never one big sequential slog, never more than the quota allows.
Concurrency rules¶
- MAX_PARALLEL_MATTERS = 2 (default) — at most 2 matters in flight at once. Every API call (Gemini/Qwen/Mistral) shares one quota and the opencode-go gateway rate-limits (HTTP 429) are the binding constraint; the pipeline's
--max-concurrencygate (default 2) is the hard backstop. Tune both numbers together, and only if the user says the plan allows more. - One
okf-workersubagent per matter (Task tool — the agent is defined at.opencode/agent/okf-worker.md). Launch the first 2 workers in the SAME message (multiple Task calls in one response run in parallel; calling, waiting, then calling again is sequential and defeats the purpose). As each finishes, launch the next queued matter. - Never two agents on the same matter; one writer per file. Workers own their matter folder; the main agent owns the skill folder,
learnings.md, and any batch-level files. Workers must never edit the skill folder — tools/SKILL.md are read-only for them (parallel-writer hazard: a second writer silently overwrites edits; see learnings 2026-08-06).
Worker brief (per matter)¶
The okf-worker agent file carries the standing rules; the main agent adds per-matter specifics:
- Matter folder path + template type (contentious / non-contentious) + ref; any user scope limits (e.g. "structure + core OCR only", "skip the giant bundles").
- Worker must read
SKILL.md+learnings.mdFIRST and follow the canonical phases 0→4 in order. - Every pipeline call runs under the shared gate (
--max-concurrency 2or theOKF_MAX_API_CONCURRENCYenv default — do not pass a higher number). Prefer--engine muse(XHigh quality, requires opt-in) or--engine qwen/--engine mimofor scan-heavy matters (fast, free on the gateway; mimo-v2.5 free is PNG-only, auto-converted). If Gemini free or Mistral are delayed/429ing, do NOT wait on them — muse/qwen/mimo are the fallbacks. - Rate-limit protocol: on repeated HTTP 429s do not hammer — runs are resume-safe; stop that file/matter, note remaining pages (
resumed_pagesvsscan_pages_ocrin the pipeline JSON), and report. The gate should make this rare. - Writes: inside the assigned matter folder only. The README contract still applies to the matter README.
5b. NotebookLM staging sync — build or refresh the
NotebookLM_<ref>sibling staging folder via the okf-notebooklm skill:python .opencode/skills/okf-notebooklm/tools/okf_nblm_build.py <matter_root> --check; rebuild when dirty (exit 1), and BUILD it if no staging folder exists yet (every OKF matter gets one). Sources over 500k words fail validation — split the bundle (usually a raw comms-log/00_Admin mega-bundle; processing the matter's OCR backlog resolves it). Never write into the okf-notebooklm skill folder. - Return the fixed-format report below — the main agent merges.
Worker report format (return verbatim)¶
MATTER: <ref>
STATUS: <complete|partial|blocked>
FILES: <processed / deduped / OCR'd / skipped>
QA: <PASS|PARTIAL|FAIL> + residual lines needing human decision
EMAIL: <archive messages / threads / attachments>
STAGING: <synced|dirty|none> + rebuild output summary
PENDING: <remaining items + reason>
README: <updated Y/N>
NEW LESSONS: <one-liners for learnings.md, or "none">
Main-agent coordination¶
- Keep a batch status table (queued / in flight / done + verdict) and show progress to the user.
- Merge worker reports into ONE final summary; collect
NEW LESSONSand append tolearnings.mdonce (single-writer; dedupe overlaps across workers). - PARTIAL/FAIL residuals and BLOCKED matters follow the never-guess rule: stay flagged for human decision, or ask the user.
Workflow — canonical order of operations¶
Order matters: free & offline first, expensive & API-bound last. Dedup and born-digital text extraction are instant and local; vision OCR is the slow/paid tail and must only see what is actually left. Per-file pipeline runs happen once each; all writes (log, README, yaml, chronology) happen ONCE per batch, not per file.
Phase 0 Recon & dedup (cheap, prevents wasted work)
Phase 1 Born-digital extract (free, offline, exact)
Phase 2 Vision triage (expensive tail — only the remainder)
Phase 2b Post-OCR quality check (batch-level verification, after ALL OCR)
Phase 3 Email knowledge layer (independent of OCR)
Phase 4 File & consolidate (writes once)
Phase 0 — Recon & dedup (once per batch)¶
- Read learnings — load
learnings.md(same folder as this skill) and apply known quirks to the current batch. - List & census — list the target folder (or PENDING rows in
97_OCR_Log\OCR_Log.csv; parse with the Pythoncsvmodule — filenames contain commas, never split on,). Count by extension to find the shape: PDFs,.eml/.msg, images,.gdocstubs, signature files, oversized files. - sha256 dedup FIRST — hash all files (skip unreadable
.gdocstubs), group identical bytes, keep ONE copy per hash — prefer the most descriptive filename.(1)/(2)suffixed files are usually DIFFERENT versions (different sizes), NOT dupes — verify by hash before touching them. Record removals for the log. - Decoration cleanup — delete email-signature noise: base64-named files (
*==),Outlook-*,image00X.*, clio/branding logos (powered-byclio.png,clio-border.png,welcome_email_*,front-composer*,LogoM365.png,microsoft.png),~WRD*.jpg,(Optimized).txtDocument AI error stubs. Keep.gdocstubs as thread pointers (content lives in the communications logs). Note "SKIPPED: decoration" for the log. - Classify what remains: Document (PDFs, scans, images of letters/contracts/submissions) → process; Already-text (
.docx, real.txt, text-layer PDFs) → text extract (Phase 1); Outlook.msg→ convert (Phase 3); Broken stubs (PDF portfolio wrappers "open this PDF portfolio in Acrobat X") → strip; extract embedded members and file them in place of the wrapper.
Phase 1 — Born-digital text extraction (free, offline, exact)¶
- Text-layer extraction for ALL PDFs — every PDF gets
page.get_text()written to<base>_V1_OCR.txtsidecars immediately (instant, no vision). This is the bulk of any corpus and costs nothing. Office files (.docx/.xls/.ppt/.rtf/.epub/...) get the same sidecar via anydoc (fast local Markdown conversion, v1.10); the pdf-inspector classifier cross-checks PDF text layers (diagnostic warnings only). - Assess text-layer quality — per PDF count extractable chars, text pages, and "gibberish pages" (alpha-char ratio < 0.25). Classify: good (searchable, done) / garbled-mixed (a few image/photo pages among good text — usually covers/index tables/photos, not bad OCR; the text is already extracted) / no-text (scans — go to Phase 2). Record the assessment with the batch.
- Extract PDF metadata (title, author, subject, pages, first-page text) + image EXIF (Pillow
_getexif(): DateTimeOriginal, Make, Model, GPS) →00_Admin\Image_Metadata.csv+.md. Use it to judge filename descriptiveness (rename only if a name is genuinely non-descriptive AND the metadata is a better label; never for versioned email captures) and to capture evidence dates/geolocation. HEIC: EXIF unreadable withoutpillow_heif— derive date from iOS filename (YYYYMMDD_HHMMSS..._iOS.heic). 8b. Append native structure outlines — runpython <skill>/tools/native_structure.py <matter_root>so every born-digital sidecar carries a[DOCUMENT OUTLINE]appendix (PDF bookmarks/heading scan, DOCX headings, XLSX sheets).
Phase 2 — Vision triage (the expensive tail)¶
- Send ONLY the remainder to the vision pipeline — the no-text scans and (if worth it) the garbled pages:
python <skill>/tools/ocr_pipeline.py <target> --matter <matter_root> --json <batch.json>. Theautochain is gemini → muse (XHigh, opt-in) → qwen → mimo (free) → mistral → paddle with low-confidence auto-resolution (kimi-k3 paid is opt-in only, never automatic). Delay policy: if Gemini free or Mistral are 429ing/slow, prefer--engine muse/--engine qwen/--engine mimorather than waiting on them. Skip: photos (not documents), oversized bundles (>10MB — file as evidence, don't OCR), and already-text PDFs (extracted in Phase 1). Rotate sideways photos via PIL (Image.rotate(270, expand=True)) before re-running on those files. - QA the vision output — confidence-driven, not blind:
- PASS: vision pages clean (no
{..}flags); PaddleOCR pages ≥0.90 mean. Verify metadata + key figures only. - PARTIAL (any flagged lines): most were reconciled by the auto-resolver; review ONLY the residuals —
[LOW CONFIDENCE...]/[FIGURE UNVERIFIED...]/[CONFLICT: ...]lines against the source page; otherwise markQuality_Check = PARTIALand note the lines. - FAIL (any page flagged FAIL): stop and report — never guess figures.
- Tables: ALWAYS double-check tables against the source, EVEN with zero auto-flags — the pipeline only flags arithmetic mismatches (pandas column-sum vs stated total) and even those are often FALSE positives (cross-table parse artifacts: per-line mini-table headers like
£295.00summed across tables, item/Doc#/U-value columns summed that were never meant to total). Zero flags ≠ correct table. Minimum spot-check per matter: re-sum 1-2 invoice line-item columns against stated totals, verify bank-statement balance chains (prev ± paid/withdrawn = new), confirm key columns (dates, prices, U-values, item numbers) against the sidecar, and eyeball row/column structure on 2-3 tables. Annotate OCR_Log notes when flags are artifacts; never re-run for artifact flags. - Capture metadata WHILE checking: document_type, parties, date, key figures.
- PASS: vision pages clean (no
- Map dense documents (when present) — for reports/advice with a ToC (expert reports, FRAEW, adjudications), run
python <skill>/tools/dense_doc_map.py "<file.pdf>"→<base>_V1_OCR_index.md(mechanical section tree, pages verified from the text layer) +<base>_V1_OCR_map.md(per-section rows, all[UNVERIFIED]). Fill map rows for the sections that matter (key findings, opinions, conclusions, costs) from the verbatim text; drop[UNVERIFIED]only when the pinpoint (page/section/paragraph) is confirmed — same rule as the case-law skill. README and Chronology referencemap:§4.6 p44-46.
Phase 2b — Post-OCR quality check (batch-level verification, after ALL OCR)¶
- Batch QA pass — after every page of OCR (text-layer AND vision) is written, verify the whole batch against the pipeline JSON + sidecars, NOT per-page:
- Cross-engine no-text verdicts: a scan that produced no text must be confirmed by ALL engines — Gemini empty + Mistral placeholder/hallucination + PaddleOCR 0 lines. Mistral HALLUCINATES on blank/dark pages (repeated LaTeX like
\( \therefore m = \frac{3}{11} \), fake![img-0.jpeg]placeholders, fabricated "Source: ..." URLs). Only when every engine agrees "no text" is the page logged as such (blank page / dark photo / non-document image). Never trust one engine alone. - CONFLICT/ALT artifact triage —
[CONFLICT: ...]/[ALT: ...]lines are often FORMATTING variants, not content errors. Classify each: - Line-split artifacts: alt engine merges lines the base splits ("...within 10 days," + "supply and fit a new steel beam." → one line). Content identical → not a real conflict.
- Footnote/superscript formatting: legal scans (Scots law authorities:
$^{302}$,¹⁵⁴superscripts) differ only in footnote markup. Citations identical → formatting-only. - Dash/quote variants: en-dash vs hyphen, straight vs curly quotes → cosmetic.
- TRUE conflicts (real silent errors:
3MT/OMT,UG467/UG487,Page 1/Page 7header-vs-footer): keep flagged, page stays PARTIAL, resolve against the source page. - Residuals are never auto-cleared — engine reads are NON-DETERMINISTIC run-to-run (a later pass can produce DIFFERENT silent errors). Only the reconciled output (or 2-of-3 verdicts) is trustworthy;
[CONFLICT]/[LOW CONFIDENCE]/[HANDWRITTEN]lines stay for human/agent decision. - Signature/handwriting flags:
[LOW CONFIDENCE]on signature lines (msGA,We,em) = handwriting, not transcription error — log as PARTIAL, don't chase engines. - Arithmetic flags:
[ARITHMETIC ERROR]on invoice bundles is USUALLY cross-table parse artifacts (per-line mini-table headers like£295.00summed across tables) — verify source maths, annotate, don't re-run. Genuine errors only where the source truly doesn't balance. - Vision cross-check of financial tables — run
python <skill>/tools/table_vision_check.py <matter_root>(Mistral engine default). The pandas auto-flag misses pdfplumber empty-table extractions and merged-cell errors; vision independently reads the totals. Interpret FLAGs: per-invoiceTotalvs accountOutstanding Balance(both may be correct), and cross-check the vision grand against the sidecar text. Record the interpretation in QA_Verdict. - Table double-check EVEN with zero flags — auto-flags only catch column-sum mismatches; verify tables unconditionally: re-sum invoice line items vs stated totals, walk bank-statement balance chains (prev ± paid/withdrawn = new), confirm key columns (dates, prices, U-values, item/Doc# codes) against the sidecar, eyeball structure on 2-3 tables. Zero flags ≠ correct table.
- No-text photo pages inside documents (fire-strategy covers, furniture/stonework photos in email chains, bundle index tables) — not OCR failures; the text is already extracted. Log, don't re-OCR.
- Case-ref / content discrepancies across documents (e.g. GLW-A728-22 vs -23): file BOTH as-is, flag in README/Chronology — never "correct" OCR or document content to make refs agree.
- Re-run the cross-layer dupe check — 0 dupes across Email_Archive / _split / attachments before the matter is complete.
- Cross-engine no-text verdicts: a scan that produced no text must be confirmed by ALL engines — Gemini empty + Mistral placeholder/hallucination + PaddleOCR 0 lines. Mistral HALLUCINATES on blank/dark pages (repeated LaTeX like
Phase 3 — Email knowledge layer (independent of OCR)¶
- Convert
.msgfiles (all at once) —python -m extract_msg --out <staging_dir> "file.msg"→<timestamp> <subject>\message.txt+ attachments. Envelope (From/To/CC/Date/Subject/body) is a correspondence record; each attachment is a document. - Convert
.emlto sidecars — parse with the stdlibemailmodule (policy.default); write<basename>_eml.txtIN THE SAME DIR as the source (naming trap: splitext-base matching fails across dirs — generate sidecars next to sources, never in a separate admin folder). Attachments →03_Productions\06_Originals\_eml_attachments\. 18b. Split emails into individual messages (eml_split.py) —python <skill>/tools/eml_split.py <matter_root>decomposes every.eml/.msgchain into its component messages:<source>_split\NNN_YYYYMMDD_Sender_Subject.mdper unique message (dedup by subject+body hash) with tags From/To/Cc/Sent/Subject/Attachments/Attachments extracted/Sensitivity/Source (+ Archive ID when it matches an Email_Archive message). Extracts attachments (dedup by sha) to_eml_attachments\. Nested Outlook items are parsed recursively — a.msg/.emlattached inside a.msg/.emlis itself parsed (its messages →<att_dir>\<name>_split\, taggedNested in: <parent>), and its attachments recursed into, until exhaustion. Guards:--max-depth N(default 12) and a sha256 set of container bytes that both breaks cycles (a self-containing .msg is parsed once) and dedupes attachment extraction across all sources (13 thread captures extract once). This is the per-message view for LLM use; the raw_eml.txtsidecar remains for verbatim fidelity. 18c. Cross-source split dedup (REQUIRED after 18b) — the same message is quoted inside EVERY chain capture that contains it, so it appears once per capture: dedup (subject, body) across ALL_splitdirs — keep the canonical copy (longest source-dir name = most specific capture), delete the rest, record deletions in00_Admin\eml_split_dedup_manifest.csv(deleted → kept_as), then REGENERATE the affected_split\index.mdfiles from the remaining messages. Also sha-dedup_eml_attachments\(keep the longest filename — Outlook signature images recur under different names). Re-run the cross-layer dupe check until 0. 18d. Attachment coverage check (MANDATORY — the gate) —python <skill>/tools/email_attachment_check.py <matter_root>parses every.eml/.msgin the matter RAW (stdlib email / extract_msg;.msgfalls back to the sibling.md## Attachmentslisting when extract_msg is missing), compares every declared attachment against what exists in_eml_attachments\and the_splitdirs, writes97_OCR_Log\email_attachment_check.csv, and exits 1 when any declared attachment is missing or when no extraction has run at all. Fix by (re-)running eml_split before Phase 4; a matter is not complete with missing attachments. Failure mode this exists to catch (matter 01730, 2026-08-12): email conversions declared## Attachments:(66 JA certificate PDFs, a 258 KB Deduction Notice, subcontractor EOT claims, an earlier baseline programme) whileeml_split.pywas never run — the documents were locked inside the.eml/.msgbinaries, invisible to--check,missing_evidence.pyand every downstream tool. Declaring an attachment without extracting it is a FAIL, not a note. - Dedup
.emlcontent — by(normalized_subject, body_sha256)(body = text/plain+text/html whitespace-collapsed); keep the most descriptive filename. Separate from stub dedup. - Consolidate thread stubs —
--email-threads --applygroups the.gdocstub captures into threads (one kept pointer each), writes97_OCR_Log\email_threads.csv. Run BEFORE--scanso the scan plan is smaller (and since 2026-08-05--email-threads --applyis decoupled from plain--apply— it will NOT apply moves.csv). - Build the Email_Archive — MANDATORY for every matter with a Master Communications Log —
python <skill>/tools/commslog_archive.py <matter_root>. Splits the Master log + all00_Admin\*Daily Communications Log*.txt(same clio format, merged chronologically, overlap removed by subject+body dedup) into00_Admin\Email_Archive\<thread>\NNN_YYYYMMDD_Sender_Subject.md— one.mdper unique message (envelope + body, stablethread/NNNID, per-threadindex.md,stub_index.mdmapping every.gdocstub). This is the reviewable/searchable evidence layer for the WHOLE email corpus: real.eml/.txtfiles AND stub-only threads (their content comes from the logs, so the archive is complete without Drive access). Re-runs clear the output folder first. The archive is never optional — a matter is not complete without it; the README must link it.
Phase 4 — File & consolidate (writes once)¶
- File outputs — move/copy as needed:
- Filed copy:
<dest>\YYYYMMDD_Description_FromTo_V1_OCR.<ext>(increment V# on later versions). If the source already sits at its destination with a dated name, keep it — just ensure_OCRsidecar + tables CSV exist. - Raw scan: original unaltered as
_SRCin03_Productions\06_Originals(contentious) or02_Contract_Documents\05_Execution_Exchange(non-contentious) - Sidecars must follow their sources — when a PDF moves, move its
_V1_OCR.txtalongside (else--checkflags the PDF as new). Do this after apply.
- Filed copy:
- Log — append ALL staged rows to
97_OCR_Log\OCR_Log.csvin ONE write (Python csv module). Update any rows that were PENDING to PASS/FAIL/SKIPPED instead of duplicating. - Update README + matter.yaml + Chronology — on EVERY change, not only at batch end. The README is the living state of the matter: any change to the corpus (files processed, files moved/re-filed, quality flags changed, QA verdicts resolved, sidecar annotations added, dedup/thread results, missing-evidence findings, counts) MUST be reflected in the README before the session ends — see "README contract" below. Single pass: add all documents to README Document Index (ISO dates), sync
matter.yaml(documents,links.figures,links.case_law,links.expert), and append event rows to00_Admin\Chronology.md. Note in README: intentional skips (oversized bundles, drawings), mixed-matter folders left untouched, and the large-corpus--checkcaveat (filed emails/photos aren't in OCR_Log — expected at scale). 24b. Run the missing-evidence test —python <skill>/tools/missing_evidence.py <matter_root>scans all correspondence (sidecars, Email_Archive, Master/Daily Communications Logs,_eml.txt) for references the schema should satisfy — explicit attachments ("I attach X", "Attachments: Y"), bills/invoices ("bill 2553", "invoice 3463"), dated letters ("our letter of 16 September 2025", "letter from X dated ..."), production numbers ("production 6/1") — and checks each against the filed corpus (filenames, OCR_Log,_eml_attachments\, inventories). Output:97_OCR_Log\missing_evidence.csv+ aMISSING-HIGH/LOWreport. Every MISSING-HIGH finding becomes a README missing-evidence entry (per the README contract); LOW items are eyeball-checked. Re-run after every batch — a reference satisfied by a newly filed document drops out automatically. - Learn — append new lessons to
learnings.md(dated bullets). 25b. NotebookLM staging sync — build or refresh theNotebookLM_<ref>sibling staging folder via the okf-notebooklm skill:python .opencode/skills/okf-notebooklm/tools/okf_nblm_build.py <matter_root> --check; rebuild when dirty (exit 1), and BUILD it if no staging folder exists (every OKF matter gets one). Sources over 500k words fail validation — split the bundle (usually a raw comms-log/00_Admin mega-bundle; processing the matter's OCR backlog resolves it). Report the result in the STAGING line. - Summary — files processed / deduped / text-extracted / vision-OCR'd / skipped (reasons) / tables extracted + arithmetic errors / quality flags / remaining PENDING / post-OCR QA verdict (per-batch: PASS / PARTIAL-with-residuals / FAIL; list residual
[CONFLICT]/[LOW CONFIDENCE]lines needing human decision).
README contract (the matter's living state)¶
The matter README is a living document, not a batch-end artefact. Every change to the matter MUST be reflected in the README before the session ends — a user reading the README must see the current truth about the corpus. This is a standing rule across ALL phases (0-4), not just consolidation.
Triggers — any of these requires a README edit¶
- New files processed / OCR'd (counts in the Document Index line:
OCR_Log.csv (N rows)) - Documents moved, re-filed, or routing corrected (incl. counterparty reclassification, e.g. "X is our correspondent firm, not opponent")
- Quality flags changed (PASS ↔ PARTIAL ↔ FAIL) — keep the QA residuals section in sync
- QA verdicts resolved or new ambiguities flagged (e.g. vision-model resolutions, figure verifications, human-decision items)
- Sidecar annotations added (metadata headers,
# Inventory:,# Structure:,# Vision QA) - Email layer changes (threads, Email_Archive rebuild, attachment indexes)
- Missing / referenced-but-absent evidence discovered or resolved (incl.
missing_evidence.pyMISSING-HIGH findings) - Production/inventory mappings (which file = which production number)
- Zips/bulk exports handled or intentionally left
- Anything the previous run reported as "to do" (e.g. re-OCR after re-export)
Sections that must stay current¶
| README section | Invariant |
|---|---|
| Parties & Case | names/roles; opponent agents vs own correspondent firm clearly distinguished |
| Document Index | every substantive document with location + quality; the OCR_Log (N rows) count line |
| Email Archive | link + message count after every rebuild |
| Missing / referenced-but-absent | every known gap with rationale (stubs, corrupt files, bulk sources left in place) |
| QA residuals (PARTIAL) | one bullet per PARTIAL file; resolved items annotated RESOLVED = ... with basis; open human-decision items explicitly labelled HUMAN DECISION |
| OCR pipeline quality | current PASS/PARTIAL/FAIL counts; pipeline behaviour changes worth knowing |
| Production numbers / inventory | 6/x-style mappings with file names |
Discipline¶
- Update the README in the SAME session as the change — never "leave for later".
- When a change supersedes a previous README statement, EDIT the old line, don't append a contradicting one.
matter.yaml(documents, key_dates, links) andChronology.mdfollow the same rule for their scopes.- If a change is reverted (e.g. a move undone), the README must be reverted with it.
Rules¶
- Parallel batches (list workflow): max 2 matters in flight; one
okf-workersubagent per matter; workers never write the skill folder; one writer per file; every pipeline call runs under the shared--max-concurrencygate (default 2). The main agent is the single writer forlearnings.mdand any batch-level files, and merges all worker reports. - Every matter with a Master Communications Log MUST have
00_Admin\Email_Archivebuilt (tools/commslog_archive.py <matter_root>) — one.mdsidecar per unique email (envelope + body,thread/NNNIDs,index.md+stub_index.md). A matter is not complete without it; the README must link it. The archive covers stub-only threads too — their content comes from the Master + Daily Communications Logs. - The README is the living state of the matter — every change (processed files, re-filing/routing corrections, quality-flag changes, QA verdicts, sidecar annotations, email-layer results, missing-evidence, production mappings) must be reflected in the README in the same session (see "README contract"). Never leave a change un-documented; edit superseded statements rather than appending contradictions.
- The what's-missing test runs on every matter —
tools/missing_evidence.py <matter_root>after filing/consolidation: anything correspondence refers to (attachments, bills/invoices, dated letters, production numbers) that the schema cannot satisfy is aMISSING-HIGHfinding and MUST be listed in the README missing-evidence section (and chased: filed elsewhere? in the bulk export? truly absent?). A reference satisfied later drops off the list automatically on re-run. - Email dedup = unique individual MESSAGES, not threads (user directive) — every unique message stored separately with a stable ID; threads are only grouping folders, never the dedup unit. The Email_Archive (from logs), the
_splitdirs (from.eml), and the raw_eml.txtsidecars are three views of the same corpus: verbatim / per-message-tagged / archive. - Attachments ARE essential email content — ~30% of emails carry them (drawings, photos, fee letters, evidence). Every email sidecar/archive message MUST list
Attachments:(names) and the extraction path; extract them to_eml_attachments\via eml_split.py and VERIFY withemail_attachment_check.py(step 18d) — a declared-but-unextracted attachment is a FAIL (01730: certificates/deduction notices/sub claims were locked inside .eml/.msg because eml_split was skipped). - Tables are ALWAYS double-checked against the source, EVEN when the pipeline reports zero flags — auto-flags only catch column-sum mismatches (and those are usually false-positive parse artifacts); a quiet table can still have wrong cells. Minimum per matter: re-sum 1-2 invoice line-item columns vs stated totals, verify bank-statement balance chains, confirm key columns (dates/prices/codes) against the sidecar, eyeball structure on 2-3 tables. Zero flags ≠ correct table.
- Never edit, redact, or "fix" document content.
- If scan quality prevents verbatim transcription, stop and report — never guess figures.
- Confidentiality: default engine is vision API (Gemini) with the user's own key;
--engine paddleis the fully-offline mode (PaddleOCR models cached locally). Never paste document content outside this matter's context.
Example¶
User: "OCR the inbox" — run the canonical sequence: Phase 0 recon + --dedup + decoration cleanup; Phase 1 born-digital text extraction + quality assessment + metadata/EXIF; Phase 2 vision OCR only for the no-text remainder; Phase 2b post-OCR quality check (cross-engine no-text verdicts, CONFLICT/ALT artifact triage, residuals never auto-cleared, arithmetic artifacts, case-ref discrepancies); Phase 3 email layer (convert .eml/.msg → _eml.txt sidecars + eml_split.py per-message dirs + cross-source dedup with manifest + --email-threads + Email_Archive build via commslog_archive.py); Phase 4 --scan → review moves → --apply → sidecars follow sources → consolidate log/README/yaml/chronology once → append learnings → summarise: files processed / deduped / text-extracted / vision-OCR'd / unique messages archived / attachments extracted / tables extracted + arithmetic flags / quality flags + QA verdict / remaining PENDING. Every change made along the way updates the README in the same session (README contract). If a filed document is dense (expert report, FRAEW), map it (step 11) so its references resolve to sections.
User: "work through this list: 01234-A, 01235-B, 01236-C" — treat as a batch queue (see "Parallel multi-matter batches"): launch okf-worker subagents for the first 2 matters in the SAME message; each runs phases 0→4 on its own matter under the shared --max-concurrency 2 API gate; as each finishes, launch the next. Collect the fixed-format reports, merge into one summary, append lessons once.