There is a question you ask constantly while writing a paper and can almost never answer cheaply: has someone already said this? Not “is there literature on this topic” — search engines answer that — but, for this particular sentence, who said exactly it, who said a weaker version, who said a stronger one, and who said the opposite. The honest answer usually requires reading forty papers you have already downloaded and never opened.

lit-tools is a small plugin of three Claude Code skills that automate the mechanical half of that and refuse to automate the other half. This post documents how they work and, more usefully, why they work that way: nearly every design decision in them is a scar from a specific failure.

The one design rule

The tools know nothing about any subject. The corpus knows everything.

Every subject-specific fact — which research traditions the literature spans, what words each of them uses for the same idea, which filenames lie — lives in the corpus directory, never in the plugin. Consequently one installation serves a corpus on relational biology, one on algebraic topology and one on chemistry at the same time, and none of them contaminates the others.

A corpus is just a directory:

<corpus>/lit-corpus.json         identity, layout, traditions, bibliography
<corpus>/texts/                  extracted full text, one file per source
<corpus>/texts-norm/             OCR-robust search mirror (generated)
<corpus>/index/bibmap.tsv        curated slug <-> bibkey <-> tradition
<corpus>/index/MANIFEST.tsv      generated: size, OCR quality, status
<corpus>/index/CORPUS-MAP.md     routing blocks — what to ask each source
<corpus>/index/glossary.md       the dialects each tradition uses for one idea
<corpus>/index/topic-terms.txt   weighted vocabulary for ranking candidates
<corpus>/index/TRAPS.md          filenames that lie, duplicates, bad scans
<corpus>/findings/               one file per claim searched

Scripts locate it by --corpus PATH, else $LIT_CORPUS, else by walking up from the working directory to a lit-corpus.json. Two candidates side by side is an error, never a guess.

lit-corpus-init — a folder is to a corpus what a set is to a category

The entry point takes a folder of PDFs, a .bib, or both:

python3 init_corpus.py ~/corpora/relbio --name relbio --pdfs ~/Downloads/relbio
python3 init_corpus.py ./dump --bib refs.bib --paper-bib ../refs.bib --cited-from ../main.bbl

Each PDF is identified by the DOI embedded in it, falling back to a first-page title lookup, so opaque publisher filenames (1-s2.0-S0022519383712345-main.pdf) are fine. The two inputs contribute different things and it is worth being blunt about it: PDFs contribute text, which is the thing that actually gets searched; a .bib contributes clean keys, titles and years and no text at all. A corpus seeded from a bibliography alone is mostly gaps, and the skill is instructed to say so out loud — “48 entries, 11 with text, 37 in WANTED.md” — rather than report a number that sounds like coverage.

The mechanical half is that one command. The judgement half is the point, and it is four artefacts written by hand:

  1. Traditions. Which communities does this corpus span, and what vocabulary marks each? The distinction that matters is dialect, not subject: a paper applying one field’s machinery to another field’s objects belongs to the tradition whose words it uses.
  2. A glossary, one row per idea, one column per tradition, filled with what each community calls it. This is the highest-value file in the corpus, because claim search decomposes a claim across those rows before it searches anything. A dash where a tradition has no word for an idea is information, not a gap.
  3. Topic terms, weighted, including anti-terms with negative weights for the adjacent literatures that would otherwise flood the expansion queue.
  4. Routing blocks, one per source: what it establishes, what it does not establish, its dialect, what to route to it for.

Skipping step 3 is survivable. Skipping steps 1, 2 and 4 degrades the sibling skills in ways that are invisible from the outside: routing silently falls back to hit counts, and cross-dialect claims silently return nothing. A corpus with texts and no domain knowledge is a folder.

lit-claim-search — the actual question

Given a claim, the skill answers: who in the corpus said it, said less, said more, or said the opposite. It runs in two phases.

Phase 1: routing assets, deliberately cheap

There is no up-front full read of the corpus. Summarising a million tokens against queries you have not yet seen compresses away the one sentence that will matter, and once routing trusts that summary the miss is undetectable. So Phase 1 builds only a manifest and a routing map from abstracts and section maps. Per-source cards, with a claim inventory and a “does not prove” section, are written lazily — whenever a deep read opens a source, that reading is written down before the context holding it is discarded. Over time the sources the work actually leans on acquire detailed cards and the rest cost nothing.

Phase 2: decompose, route, fan out, verify, record

Decompose. Restate the claim as its formal content, then list the phrasings each tradition would use for it. In a corpus worth searching this way the communities rarely share a word, so this step does most of the work.

Route. Read the corpus map, then search — with find.py, never with grep:

python3 find.py "closure to efficient causation"
python3 find.py -C3 "noncomputable" "non computable"     # OR over patterns
python3 find.py -l -f Arbib "entailment"                 # counts only

The reason is OCR. Scanned journals come out letter-spaced (a u t o m a t a) or word-joined, and notation is mangled beyond recognition — and those scans are precisely the old papers on which a priority claim turns. find.py matches against a whitespace- and punctuation-free mirror of every text, so (M,R)-system and mr system are the same pattern and a phrase broken across a line break still matches. On one real test query, grep found two of three files and missed the only relevant one.

Then rank the hits by the map, not by hit count. This is not fastidiousness. On a real query, the corpus’s foundational-but-irrelevant text scored 435 hits and the single relevant source scored 1. Lexical scoring over a multi-tradition corpus does not merely underperform; it inverts the truth. To the ranked list one adds two or three cold reads: sources the map shows are topically adjacent but whose dialect differs enough that no query of yours would ever reach them. That is where uncited priority hides, and lexical search structurally cannot go there.

Fan out — and never read the texts yourself. One cheap subagent per candidate source, four to eight in a single parallel batch, each given the claim, the decomposition, its one file, and an output contract. The binding constraint on this work is not capability but context: a single deep read of an OCR’d paper can cost more context than the entire answer it feeds, and a full window degrades the session exactly when the synthesis matters. The orchestrator sees only routing assets and hit lists.

Verify. Every reported hit is re-checked by searching a distinctive fragment of the quoted text at the stated line, and any hit that fails is dropped. This output becomes citations in published work; an invented quote is the one unrecoverable failure mode.

Record. The claim, its decomposition, the hit table, and the negative results go to findings/<claim-slug>.md.

The output contract

Every hit is a relation to the claim, never a similarity score:

verdict meaning
same priority citation — someone got there first
weaker special case or extra hypotheses → cite as antecedent, say what you drop
stronger ⚠️ the claim may be subsumed by existing work
assumed asserted without proof → proving it is the contribution
contradicts must be addressed in the text
adjacent cite as context, not as priority

Each hit carries a bibkey, a slug:Lstart-Lend reference, a verbatim quote, a verdict, one line of rationale, and a paste-ready citation line. And every search closes with three sections that are worth as much as the hits: searched, nothing found, per source — which is the evidence for a novelty claim; not in corpus, listing stubs and failed extractions, so a null result can never imply coverage that does not exist; and a confidence note, lowered for sources with poor OCR.

lit-expand — growing along the citation graph

The third skill adds sources rather than searching them, in the same formats, so anything ingested is immediately searchable.

Discovery is structural, not lexical. Candidates come from the citation graph of the sources already present — what they cite, and what cites them — because a corpus worth building this way spans traditions with disjoint vocabulary for the same ideas. A keyword sweep both misses papers that word things differently and drowns in papers sharing only surface terms. A citation edge to a seed is far stronger evidence of belonging than any phrase. For a focused subject the sweep is near-exhaustive: seeds have citing sets of tens to a few hundred works, not thousands.

Four stages, each writing a durable artefact, each resumable:

python3 resolve_seeds.py       # corpus     -> index/SEEDS.tsv
python3 harvest.py             # SEEDS      -> index/candidates.jsonl
python3 rank.py                # candidates -> index/CANDIDATES.md   <- REVIEW GATE
python3 ingest.py --from-queue # marked rows -> texts/ + bibmap + bibliography
python3 wanted.py              # stubs      -> index/WANTED.md

Resolution is a ladder — DOI, then OpenAlex title search, then trimmed title, then Crossref’s fuzzy bibliographic query, then give up. Crossref is not a nicety: publisher digitisations of old journals mangle deposited titles (one 1966 paper is deposited with its notation replaced by lookalike characters), so no title query against OpenAlex can ever reach the papers such a corpus is built on. Nothing below a 0.80 title-similarity cutoff is accepted; an early version took the top hit and filed one author’s 1982 paper under another’s slug.

Ranking scores a candidate as 4 × (distinct seeds connected) + vocabulary weight. Degree dominates by construction, for the reason given above, and it still surfaces an OCR-mangled paper with no abstract at all — exactly the case keyword search cannot reach. Tier A is degree ≥ 3 (or ≥ 2 with strong vocabulary), B is degree ≥ 2, C is a single edge kept on vocabulary alone. The split matters at scale: on one full frontier of 1006 works, 343 had an open-access PDF but 312 of those were tier C — one citation edge plus a topical word. Ingesting them would have quintupled the corpus with material one weak link from the subject. Fetch A and B in bulk; treat C as a reading list, not a queue.

Then rank.py stops. Nothing is ingested until a human marks the queue: [x] full text, [s] stub with metadata only, [ ] skip. Reading a long queue can be handed to a batch of cheap subagents, but the marking cannot be delegated, because a bad screening call does not announce itself — it becomes a corpus entry that later gets cited in published work.

Acquisition is another ladder, because an open-access link is usually not a PDF: in one bulk run 36 of 51 “OA” URLs failed, fourteen of them bare doi.org links resolving to HTML landing pages. The ladder tries arXiv, then Europe PMC, then the given URL, then the citation_pdf_url meta tag on the HTML it got back; that recovered nine more. Elsevier and MDPI block programmatic fetches outright and are not recoverable at all — which is what WANTED.md is for. It lists paywalled papers ordered by citation degree, and when you obtain them through institutional access, one command ingests the lot, matching each PDF to its stub by the DOI inside it:

python3 ingest.py --promote-dir ~/Downloads

Four invariants

Stripped of implementation, the whole thing rests on four rules, each of which exists because violating it produced a wrong answer that looked right.

Never grep an OCR’d corpus. A scan can render a word letter-spaced or joined to its neighbour; the match has to happen against a normalised mirror.

Rank by citation degree, not keywords. Over a multi-tradition literature, lexical scoring inverts the truth — 435 hits for the irrelevant classic, 1 for the paper you needed.

A rate limit is not an absence. Every pipeline stage stops on a 429 and keeps what it has. Catching the exception and recording “not found” once demoted 25 correctly resolved seeds, silently.

A paper with no reachable text is a stub, never a silent drop. Claim search reports stubs as unsearched, so a null result cannot masquerade as novelty. This is the same principle as the review gate: the system is allowed to be ignorant, but never allowed to present ignorance as knowledge.

That last one is really the thesis of the whole plugin. The expensive part of a literature search is not finding things; it is knowing precisely what you failed to look at.


lit-tools needs python3 and pdftotext (poppler-utils), and no API keys. It is installed as a marketplace plugin — claude plugin marketplace add <path to the repository>, then claude plugin install lit-tools@lit-tools, or the same two steps from /plugin inside Claude Code.