Turn a corpus of papers into a literature review: documented search strategy, screening with counts, per-paper synthesis notes, a theme matrix, and a citable narrative with DOI-verified citations. Triggers when someone has read or gathered a pile of papers on one topic and needs to organize them into a review, not a reading list.
./install.sh lit-review-synthesisname: "lit_review_synthesis" description: "Turn a corpus of papers into a literature review: documented search strategy, screening with counts, per-paper synthesis notes, a theme matrix, and a citable narrative with DOI-verified citations. Triggers when someone has read or gathered a pile of papers on one topic and needs to organize them into a review, not a reading list."
Bridge the gap between reading papers and writing about them. This skill starts where single-paper reading ends: with a pile of papers on one topic and the job of turning them into a documented, citable literature review. For how to read each paper, use reading_papers. For general web research with source triangulation, use deep-research-workflows. This skill owns the corpus layer only: search, screen, matrix, narrative.
Write the review question in one sentence. Then fix the inclusion and exclusion criteria: date range, languages, study types or venues, and what counts as out of scope. Set a stopping rule too, for example saturation (two full searches surface no new core papers) or a fixed date cutoff. Write all of this down first. Criteria set after seeing the results are rationalizations, not criteria.
Query at least two sources so one index's blind spots do not become yours. Candidate sources, as of this writing:
Log every query string, source, and date in a search log. Then snowball: walk the citations and references of the core papers one or two hops out, because keyword search misses papers that use different words for the same idea.
Deduplicate on DOI first, then on normalized title. Screen in two passes: title and abstract first, full text for the maybes. Record include or exclude for every candidate, with a real reason on every exclude. Keep the counts at each stage (identified, after dedup, screened, included) for the review's methods section.
The tracker below (screen.py) is stdlib-only Python: init, identified-count, add, decide, note, matrix export, counts, list. It was run end to end on 2026-10-06 (add, DOI-dedup refusal, title-similarity warning, decide with enforced exclude reasons, notes with quote capture, matrix export to Markdown, counts) and is labeled verified. Save the code as screen.py, then run python3 screen.py init to start.
python
#!/usr/bin/env python3
"""screen.py - corpus tracker for literature reviews (stdlib only).
Save the code block below as screen.py, then run it:
python3 screen.py init
python3 screen.py identified 120
python3 screen.py add --title "..." --authors "A. Smith, B. Jones" \
--year 2024 --venue "NeurIPS" --doi "10.1000/xyz" --url "https://doi.org/10.1000/xyz" \
--abstract "..." --source "s2: agentic memory benchmarks"
python3 screen.py decide 3 exclude --reason "survey, no primary results"
python3 screen.py decide 4 include
python3 screen.py note 4 --question "Does X beat Y?" --method "RCT, n=200" \
--findings "X wins by 12 pct" --limitations "single dataset" \
--themes "benchmarks, methods" --quote "exact sentence" --quotepage "p. 4"
python3 screen.py matrix > matrix.md
python3 screen.py counts
"""
import argparse
import sqlite3
import sys
DB = "corpus.db"
STATUSES = ("pending", "included", "excluded")
def db():
c = sqlite3.connect(DB)
c.row_factory = sqlite3.Row
return c
def cmd_init(_):
c = db()
c.executescript(
"""
CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS papers(
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
authors TEXT, year INTEGER, venue TEXT,
doi TEXT UNIQUE, url TEXT, abstract TEXT, source_query TEXT,
status TEXT NOT NULL DEFAULT 'pending',
exclude_reason TEXT, added TEXT DEFAULT CURRENT_DATE);
CREATE TABLE IF NOT EXISTS notes(
paper_id INTEGER PRIMARY KEY REFERENCES papers(id),
question TEXT, method TEXT, findings TEXT,
limitations TEXT, themes TEXT, quote TEXT, quote_loc TEXT);
INSERT OR IGNORE INTO meta(k, v) VALUES('identified', '0');
"""
)
c.commit()
c.close()
print("initialized", DB)
def cmd_identified(a):
c = db()
c.execute("UPDATE meta SET v=? WHERE k='identified'", (str(a.n),))
c.commit()
c.close()
print("records identified:", a.n)
def cmd_add(a):
c = db()
if a.doi:
row = c.execute("SELECT id FROM papers WHERE doi=?", (a.doi,)).fetchone()
if row:
print(f"duplicate doi, not added (existing id {row['id']})", file=sys.stderr)
sys.exit(1)
norm = " ".join(a.title.lower().split())
dup = c.execute(
"SELECT id, title FROM papers WHERE lower(title)=? OR replace(lower(title),' ','')=?",
(norm, norm.replace(" ", "")),
).fetchone()
if dup:
print(f"warning: similar title already at id {dup['id']}: {dup['title'][:60]}")
cur = c.execute(
"""INSERT INTO papers(title,authors,year,venue,doi,url,abstract,source_query)
VALUES(?,?,?,?,?,?,?,?)""",
(a.title, a.authors, a.year, a.venue, a.doi, a.url, a.abstract, a.source),
)
c.commit()
c.close()
print("added id", cur.lastrowid)
def cmd_decide(a):
status = {"include": "included", "exclude": "excluded"}.get(a.status, a.status)
if status not in STATUSES:
sys.exit("status must be one of: " + ", ".join(STATUSES))
if status == "excluded" and not a.reason:
sys.exit("exclude decisions need --reason")
c = db()
cur = c.execute(
"UPDATE papers SET status=?, exclude_reason=? WHERE id=?",
(status, a.reason if status == "excluded" else None, a.id),
)
c.commit()
c.close()
if cur.rowcount == 0:
sys.exit(f"no paper with id {a.id}")
print(f"id {a.id} -> {status}" + (f" ({a.reason})" if a.reason else ""))
def cmd_note(a):
c = db()
row = c.execute("SELECT status FROM papers WHERE id=?", (a.id,)).fetchone()
if not row:
sys.exit(f"no paper with id {a.id}")
if row["status"] != "included":
print(f"warning: paper {a.id} is '{row['status']}', not 'included'")
c.execute(
"""INSERT INTO notes(paper_id,question,method,findings,limitations,themes,quote,quote_loc)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(paper_id) DO UPDATE SET
question=excluded.question, method=excluded.method,
findings=excluded.findings, limitations=excluded.limitations,
themes=excluded.themes, quote=excluded.quote, quote_loc=excluded.quote_loc""",
(a.id, a.question, a.method, a.findings, a.limitations, a.themes, a.quote, a.quotepage),
)
c.commit()
c.close()
print(f"note saved for id {a.id}")
def esc(s):
return (s or "").replace("|", "\\|").replace("\n", " ")
def cmd_matrix(_):
c = db()
rows = c.execute(
"""SELECT p.id, p.title, p.authors, p.year, p.venue, p.doi,
n.question, n.method, n.findings, n.limitations, n.themes
FROM papers p LEFT JOIN notes n ON n.paper_id = p.id
WHERE p.status='included' ORDER BY p.year, p.id"""
).fetchall()
c.close()
print("| # | Citation | Question | Method | Key findings | Limitations | Themes |")
print("| - | - | - | - | - | - | - |")
for r in rows:
cite = f"{r['authors']} ({r['year']}). {r['title']}. {r['venue'] or ''}"
if r["doi"]:
cite += f" doi:{r['doi']}"
print(
f"| {r['id']} | {esc(cite)} | {esc(r['question'])} | {esc(r['method'])} "
f"| {esc(r['findings'])} | {esc(r['limitations'])} | {esc(r['themes'])} |"
)
if not rows:
print("| | no included papers yet | | | | | |", file=sys.stderr)
def cmd_counts(_):
c = db()
identified = c.execute("SELECT v FROM meta WHERE k='identified'").fetchone()["v"]
n = lambda q: c.execute(q).fetchone()[0] # noqa: E731
print("records identified :", identified)
print("after dedup :", n("SELECT COUNT(*) FROM papers"))
print("screened (decided) :", n("SELECT COUNT(*) FROM papers WHERE status != 'pending'"))
print("included :", n("SELECT COUNT(*) FROM papers WHERE status='included'"))
print("excluded :", n("SELECT COUNT(*) FROM papers WHERE status='excluded'"))
print("pending :", n("SELECT COUNT(*) FROM papers WHERE status='pending'"))
print("with notes :", n("SELECT COUNT(*) FROM notes"))
c.close()
def cmd_list(a):
c = db()
q = "SELECT id, status, year, title FROM papers"
params = ()
if a.status:
q += " WHERE status=?"
params = (a.status,)
q += " ORDER BY id"
for r in c.execute(q, params):
print(f"{r['id']:>3} [{r['status']:<8}] {r['year'] or '----'} {r['title'][:80]}")
c.close()
def main():
p = argparse.ArgumentParser(description="lit-review corpus tracker")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("init")
pi = sub.add_parser("identified")
pi.add_argument("n", type=int)
pa = sub.add_parser("add")
pa.add_argument("--title", required=True)
pa.add_argument("--authors", default="")
pa.add_argument("--year", type=int, default=None)
pa.add_argument("--venue", default="")
pa.add_argument("--doi", default="")
pa.add_argument("--url", default="")
pa.add_argument("--abstract", default="")
pa.add_argument("--source", default="")
pd = sub.add_parser("decide")
pd.add_argument("id", type=int)
pd.add_argument("status")
pd.add_argument("--reason", default="")
pn = sub.add_parser("note")
pn.add_argument("id", type=int)
pn.add_argument("--question", default="")
pn.add_argument("--method", default="")
pn.add_argument("--findings", default="")
pn.add_argument("--limitations", default="")
pn.add_argument("--themes", default="")
pn.add_argument("--quote", default="")
pn.add_argument("--quotepage", default="")
sub.add_parser("matrix")
sub.add_parser("counts")
pl = sub.add_parser("list")
pl.add_argument("--status", default="")
args = p.parse_args()
{
"init": cmd_init,
"identified": cmd_identified,
"add": cmd_add,
"decide": cmd_decide,
"note": cmd_note,
"matrix": cmd_matrix,
"counts": cmd_counts,
"list": cmd_list,
}[args.cmd](args)
if __name__ == "__main__":
main()
One row per included paper, same fields every time: the question it answers, the method or design, the main findings, the limitations, and the themes it touches. Mark verbatim quotes with quotation marks and the page or location at capture time. A quote rebuilt from memory at write time is a misquote waiting to happen. (Use reading_papers for how to read each paper. This step is about what to record.)
Group the rows into three to six themes. Group by what the papers argue, not by who wrote them. A paper can sit under more than one theme. For each theme, write down what the papers agree on, where they contradict each other, and what nobody has studied. Export the matrix to Markdown with python3 screen.py matrix > matrix.md.
Structure: the review question; the method summary (queries, sources, dates, counts from the tracker); then the synthesis, theme by theme; then a close on open questions. Do not write a serial paper-by-paper walkthrough. Each theme paragraph should cite multiple papers. If a claim is not in the matrix, it does not go in the review.
Verify every in-text citation against the source: authors, year, title, DOI. Resolve DOIs through Crossref rather than trusting search-result snippets. Keep one master bibliography and check both directions: every in-text citation has an entry, and every entry is cited. Fix mismatches before delivery, not after.
Deliver:
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.