---
name: "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."
---

# Lit Review Synthesis

## Purpose

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.

## Workflow

### 1. Scope the review before searching

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.

### 2. Run a documented search strategy

Query at least two sources so one index's blind spots do not become yours. Candidate sources, as of this writing:

- Semantic Scholar: free API; anonymous calls draw from a shared pool and get throttled (an unauthenticated test request on 2026-10-06 returned HTTP 429); a free API key raises the limit. Confirm current terms before quoting them.
- Crossref: free, no key; include a contact email with requests (the documented polite-pool practice). Strong for verifying metadata and DOIs.
- arXiv API: free, no key; respect its published request-rate terms.
- DBLP: free search API, no key; strong for CS conference papers.
- OpenAlex: access terms moved during 2026 toward keyed, credit-based access. Confirm the current terms on its docs before quoting anything about keys, credits, or limits.

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.

### 3. Screen with the tracker

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()
```

### 4. Take synthesis-ready notes per paper

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

### 5. Build the synthesis matrix

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

### 6. Write the narrative from the matrix

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.

### 7. Check citation hygiene

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.

## Output Contract

Deliver:

- the search log: each query string, source, and date
- the screening log with counts: identified, after dedup, screened, included, excluded, with a reason on every exclude
- matrix.md: one row per included paper with theme tags, exported from the tracker
- the review narrative in Markdown: question, method summary, theme-by-theme synthesis, open questions
- the bibliography file, every entry DOI-verified
- the gap list: questions the corpus does not answer

## Operating Rules

1. Stay at the corpus layer. Single-paper reading technique belongs to reading_papers; general web research belongs to deep-research-workflows.
2. Set inclusion criteria before the first search. If they change mid-screening, log the change and its date.
3. Every exclude gets a real reason. "Not relevant" is not a reason.
4. Mark verbatim quotes with their location at capture time. Never reconstruct a quote from memory.
5. Write the narrative from the matrix only. Not in the matrix, not in the review.
6. Verify every citation (authors, year, DOI) against the source. Resolve DOIs through Crossref.
7. Check time-sensitive source claims (API access, rate limits, terms) against current docs before quoting. This skill does not carry those numbers.
8. No em dashes in any generated text.
