AGENTS.md¶
Guidance for coding agents (and anyone else) using this pipeline to draft content.
Changing chitragupta's own code, rather than drafting with it? Read DEVELOPER-AGENTS.md first -- it governs. Test policy, the local check suite, environment constraints, code standards, and commit/PR/release conventions all live there.
CLAUDE.md is the one-screen router between the two, for an agent that has not yet worked out which task it is on.
SOUL.md is the one-page why behind everything below. When this file and that one seem to disagree, that one is the tie-breaker.
The hard invariant: never fabricate a citekey¶
Fabricated placeholder references have made it into real papers before -- that is the failure mode this pipeline is built to prevent, and it is why this is the one rule that cannot bend. SOUL.md states the invariant itself.
Rule: a citekey may only be used if it appears in papers/bibliography.bib
(source of truth -- see below) and was picked up into content/ledger.sqlite
by python -m src.corpus sync.
All five genre skills (survey-writer, thesis-chapter-writer,
textbook-chapter-writer, tutorial-writer, deep-research in
.claude/skills/) must run python -m src.draft gate <file> on its
own output and only present the draft once it exits 0. So must
book-assembler, which writes no prose but does write a document: the
LaTeX book it composes is a new file, and this layer has one exit
whatever produced the file. This is a gate,
not a lint suggestion -- treat a FAIL the same way you'd treat a
failing test. It binds the two teaching genres too, where citations are
optional: a draft that cites nothing passes trivially, but a draft that
cites anything must pass on merit.
A PostToolUse hook (.claude/hooks/citation_gate_hook.py, wired up in
.claude/settings.json) also enforces this mechanically: any Write/Edit
under content/drafts/*.md or *.tex runs the gate automatically and blocks
the write with a FAIL reason on the offending citekey(s) if it doesn't
pass. Treat the instruction above as belt-and-suspenders, not the only line
of defense -- but still run the gate by hand before calling a draft done,
since the hook only fires on the tool call that wrote the file, not on
demand.
The bib file is the source of truth (not this pipeline)¶
papers/bibliography.bib (path configurable via config.toml's
[bib].path or the BIB_FILE env var; gitignored, per-host data -- see
docs/CONFIG.md) is a manual export from your reference manager's BibTeX
export feature -- no auto-sync plugin is installed, so it is not
continuously auto-synced. Whatever citekey BibTeX assigns there
(e.g. talasila_composable_2025, or noauthor_digital_nodate for an item
with no discoverable author) is the citekey everywhere downstream.
src/bib_reader.py parses it and is the only place that reads it; nothing
else should ever generate or guess a citekey.
One constraint follows from that, enforced in bib_reader.citekey_problem():
a citekey is also a filename stem (content/parsed/<citekey>.txt, its
.passages.json sidecar, the enrichment layer's content/docling/<citekey>.md),
so it has to be usable as one. A citekey containing a path separator, a
character Windows forbids, or a reserved device name is skipped with a
warning naming it, rather than sanitised -- this project never rewrites a
citekey, so the only fix is to rename it in the reference manager and
re-export. Skipping loses one paper and says so; letting it through would
write outside content/.
That rule is why a module needing bibliographic detail reads it back out of the ledger rather than re-opening the bib file.
Adding or removing a paper is the user's job, not a skill's: change it in
the reference manager, re-export, re-run python -m src.corpus sync. There is no
watch/auto-export step. Removal is deliberately opt-in -- sync only
reports a citekey that has dropped out of the bib file until it is
re-run with --remove-stale, because a short export is more often a
botched one than an intentional deletion. README.md and docs/ZOTERO.md
have the full semantics.
The four layers¶
The numbers below are the order these are introduced, and the order you meet them: you need a corpus before a draft, and there is nothing to review until a draft exists. They are not a dependency rank -- the enrichment layer is optional and nothing above it needs it.
- Layer 1, the corpus layer -- deterministic (
python -m src.corpus sync): bib file read -> ledger update -> PDF text extraction -> duplicate-citekey check -> stale-citekey report. No LLM calls, no judgment calls, idempotent; safe to run unattended. docs/ARCHITECTURE.md has the stage detail. - Layer 2, the drafting layer -- generative (the
.claude/skills/): invoked on demand, reviewed by the user. Read-only over the corpus layer: they never write tocontent/ledger.sqlite, and they never runpython -m src.corpus syncor the enrichment layer on the user's behalf. On an empty ledger a skill says so and stops rather than regenerating anything -- except the two teaching genres, where citations are optional:textbook-chapter-writerandtutorial-writerinstead ask the user whether to proceed uncited, and wait. Each run writes a dossier beside its draft (content/dossiers/<the draft's path minus its suffix>/, Markdown, owned bysrc/dossier/) holding the reader, scope, glossary, kept evidence, rejected candidates and why, and the steering the user gave in chat. That is what makes a draft revisable weeks later:draft-reviserreads the dossier and edits the affected sections instead of re-running the genre skill over the whole topic -- including when the change comes from the corpus rather than from you, re-grounding a draft whose cited papers asyncremoved. If you do want the whole corpus re-searched, ask for it and you getcorpus-reviser, which is the same edit discipline over a full retrieval pass -- it still keeps the dossier. And if what you want repaired is the verbatim overlap a scan reported, that isoverlap-reviser: it works the findings one at a time, reasks you before deciding paraphrase-or-quote on a long run, and keeps no repair thatpython -m src.draft gateandpython -m src.review verbatim recheckdo not both accept. Never re-run a genre skill to change an existing draft -- see docs/DRAFT-ITERATION.md. - Layer 3, the enrichment layer -- optional (
python -m src.enrich): Docling, embeddings and topic modelling over the same corpus. It extends the corpus layer rather than the drafting one -- nothing in it is generative, everything it writes is a corpus artefact, and it takes the same write lock assyncfor that reason. Run by a human, never by a skill. It imports nothing from the drafting or review layers, which is what keeps this picture free of a cycle -- a per-draft stage wrapping either one would reintroduce it. - Layer 4, the review layer -- advisory (
src/review/citation_provenance.py,src/review/verbatim_check.py,src/review/citation_coverage.py): run by hand on a finished draft, never invoked automatically. Each reads a draft plus the corpus and produces evidence for a human judgement, never a verdict -- every one exits 0 whether it finds something or not, and none may block a draft. Don't promote one to a gate -- SOUL.md has why. It takes no lock: read-only over the corpus, so it keeps working during async, likepython -m src.corpus ledgerand retrieval. Input is a draft undercontent/; output iscontent/review/, mirroring the draft's path undercontent/drafts/the waycontent/rendered/andcontent/dossiers/do, withsrc/review/__init__.pyowning that contract.
Review, not verification: src.draft gate is verification, it lives
in the drafting layer, and it is that layer's only exit. The gate
answers a question with one correct answer and may block; these three
answer questions of judgement and may not.
One correct answer is what a gate needs, not what earns it. The
deciding question is what the check is measured against. The gate is
measured against the ledger, which is ground truth -- the human's own
.bib export plus a real parse of a real PDF -- and no state of the
world makes a citekey absent from it legitimately present. A check
measured against a recorded preference -- a target someone typed,
which can be wrong, stale, or deliberately overridden -- reports and
never blocks however mechanical its answer, because blocking on it
would refuse a correct draft on a bad target. That holds whichever
layer the check lives in, and it is why DEVELOPER-AGENTS.md bars
promoting any new check into a gate beside src/citation_gate.py.
docs/ARCHITECTURE.md's "Layer 4" has the argument.
verbatim_check's scan mode is the whole-draft × whole-corpus one,
and the complement of the citation gate: the gate proves every citekey
is real, the scan reports what wording came along with them. It runs
three detection tiers, and the one that sees a genuine restatement
needs an optional stack a checkout may not have -- so a clean run is
not a clean bill of health, and the scan names any tier that did not
run. docs/PLAGIARISM.md. Its recheck mode compares a
re-scan against a payload scan --write filed earlier, so "is this
finding gone, and did fixing it break anything else" is arithmetic
rather than two reports read side by side. Advisory like the rest: it
exits 0 on a draft that got worse.
Retrieval¶
src/retrieval.py (BM25 over a cached term-frequency index, stdlib-only,
no venv or model download needed) is what the genre skills use by
default. src/enrich/embed_index.py (sentence-transformers + Chroma) is
a working upgrade path with a matching search(query, k) shape, to swap
in when BM25 stops being enough -- a judgement call, not a corpus-size
threshold. docs/RETRIEVAL.md has the caching mechanics and the
choose-between-them guidance.
Retrieval finds a document; src/passages.py decides which part of it
may be shown. Anything that needs to point at a span of a source rather
than the whole of it -- citation_provenance, verbatim_check, the
enrichment layer -- goes through that one ladder rather than re-deriving
passages, so a caller cannot accidentally quote from a rung that isn't
quotable. See docs/LADDERS.md.
Config lives in config.toml¶
Every setting lives in config.toml at the repo root, and every one is
overridable by an env var of the same name (e.g.
BIB_FILE=/other/path.bib python -m src.corpus sync). docs/CONFIG.md is the
reference.
python -m src.draft gate needs no venv -- it only reads
content/ledger.sqlite through stdlib sqlite3 and runs with bare
python. python -m src.corpus sync does need the venv, and must be run
through the installed one rather than the bare system interpreter.