agentleFS
MarkusNeusingerMarkusNeusinger/cite-citadel/CLAUDE.md

cite-citadel

CLAUDE.md2 starsChanged 2 months agoMITView on GitHub

cite-citadel (CLI: citadel, PyPI package: cite-citadel) is an LLM-maintained, fully-cited personal wiki in Google's Open Knowledge Format, with an MCP server so an AI can search and read it. It implements Karpathy's LLM-Wiki pattern: drop arbitrary text-bearing files into raw/, and one agentic CLI session per source folds each into a cross-linked OKF wiki under wiki/. Pure Python 3.12, KISS. Runtime deps are only mcp, pyyaml, pypdf, and rich (all pure-Python, no native weight) — there is no LLM SDK…

  • Reads credentials
  • Commits and pushes

We split this CLAUDE.md into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use the portable invocation everywhere — it works identically on Linux/macOS/Windows and needs no .exe (the uv run citadel … shorthand …

    line 20
  2. Very specific:

    After merge, tag: git tag vX.Y.Z && git push origin vX.Y.Z.

    line 165
  3. Very specific:

    The wiki/ directory _is_ the database: No SQLite, no vector store, no second source of truth.

    line 174
  4. Distinctive:

    Only *structural* problems (missing type, broken links, bad sources, wikilinks) flip its non-zero exit (code 3 — lint's own, so CI can tell …

    line 369
  5. Distinctive:

    BEFORE and AFTER the load and must match, a snapshot whose newest stamp is younger than the settle window is never stored (coarse-mtime …

    line 474

≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

cite-citadel (CLI: citadel, PyPI package: cite-citadel) is an LLM-maintained, fully-cited personal wiki in Google's Open Knowledge Format, with an MCP server so an AI can search and read it. It implements Karpathy's LLM-Wiki pattern: drop arbitrary text-bearing files into raw/, and one agentic CLI session per source folds each into a cross-linked OKF wiki under wiki/. Pure Python 3.12, KISS. Runtime deps are only mcp, pyyaml, pypdf, and rich (all pure-Python, no native weight) — there is no LLM SDK and no API key: ingest shells out to a coding-agent CLI you already have logged in (claude/copilot/agy).

Commands

Setup: uv sync (creates .venv, installs deps + the dev group + the citadel script).

Use the portable invocation everywhere — it works identically on Linux/macOS/Windows and needs no .exe (the uv run citadel … shorthand often breaks on Windows because AV quarantines uv's generated citadel.exe):

uv run python -m citadel <subcommand>

Subcommands: init [DIR] (scaffold a workspace: citadel.toml marker, .env, raw/, wiki/; idempotent), ingest [paths…] (fold raw/ into the wiki; --verbose/-v streams the agent session, --log-dir DIR writes a transcript per source, --quiet drops the live progress display, --jobs N/-j folds N sources in CONCURRENTLY (default 1 = serial; CITADEL_JOBS), --full-rescan distrusts the manifest's stat cache and re-hashes every tracked source, --force <paths> deliberately re-reads already-ingested sources as a reconcile — it requires explicit paths and is refused without them, --reingest <paths> goes one step further: each named tracked source is re-imported FRESH — a kind="delete" cleanup session strips its previous facts (manifest entry dropped), then the same run ingests it as a brand-new source under the current model + rules — the full re-think reconcile's keep-the-existing-treatment rule deliberately avoids (requires explicit paths like --force; the two flags and --retry refuse to combine; a failed cleanup blocks that source's fresh session), --retry re-runs everything STUCK without naming paths: every failed source still on disk plus every ingested source no wiki page cites — the zero-entry sources the run report flags as no_pages and status marks NO PAGES — as forced reconciles; it refuses paths/--force and exits 0 when nothing is stuck, --guidance TEXT hands this ONE run a free-text steer ("create one machine registry from the maintenance lists") appended to each source-reading session's prompt — within the rules, never overriding citations; max 2000 chars, CITADEL_INGEST_GUIDANCE is the env twin, the MCP wiki_ingest takes it as a per-call argument, and the steer joins the resume checkpoint identity), refresh [--limit N] [--min-age-days D] [--dry-run] [--jobs N] (the THIRD lifecycle: re-verify the least-recently-checked sources — ordered by the manifest's ingested_at stamp, oldest/stampless first — through forced reconcile sessions on an explicit per-run budget of N sources; the sustainable alternative to regenerating the wiki after a model upgrade), curate [--dry-run] [--limit N] [--stale-rules] [--diff PATH] [--retry] [--guidance TEXT] [paths…] (the SECOND lifecycle: improve EXISTING pages — re-sort/split/re-ground/resolve contradictions/fix locators — against a recomputed findings checklist; --guidance is the curate twin of ingest's steer — STRUCTURAL only, carried in the cluster's findings file, and the paths it names (a page, a folder, a glob) are curated even when no detector flagged them), status (read-only per-source state table: ingested / failed / skipped-duplicate / ignored / oversized / pending; MCP twin wiki_status), doctor (read-only setup health check — OK/WARN/FAIL lines for workspace / rules / config-parse fallbacks / agent CLI / the configured ingest model / raw roots / wiki placement (the wiki nested inside a raw root) / child paths (the UNC-vs-drive-letter cwd) / manifest / billing / the MCP SDK (mcp.server.fastmcp imports, version inside the mcp>=1.10,<2 pin) / the HTTP-serve posture / wiki-git state / a best-effort PyPI update check / workspace coherence; every check runs isolated, so one that raises is its own FAIL line, never a traceback; needs no workspace, exits 1 only on a FAIL), serve [--http [--host H] [--port P] [--path /mcp] [--read-only]] (the MCP server — stdio by default; --http serves the SAME surface over MCP's Streamable HTTP transport for a client that is not on this machine, mandatory bearer token, loopback default, optional read-only), capture <text> [--from WHO] [--topic T] (append one attributed note from a conversation to the raw/ capture log raw/captures/YYYY-MM.md; - reads stdin; the next ingest folds it in — the conversational-capture bridge, MCP twin wiki_capture), search <query> [--tag T] [--limit N], define <term> / read <rel_path> / raw <key> [--locator L] / neighbors <rel_path> / index / sources (CLI twins of the wiki_define/wiki_read/wiki_raw/wiki_neighbors/wiki_index/wiki_sources MCP tools — full CLI↔MCP parity), tags [tag], lint [--stale-days N] (exit 3 when the report is not clean — its own code, distinct from the usage/no-workspace exit 2), check [paths…], view [--out PATH] [--no-open] [--obsidian], rules list|show|eject. citadel --version prints the version and (like --help) needs no workspace.

Tests (pytest, all offline — no LLM CLI and no network is ever spawned; only test_wikigit shells out, to local git):

uv run pytest -q                                    # whole offline suite, a few seconds
uv run pytest tests/test_ingest_core.py -q          # one file
uv run pytest tests/test_ingest_core.py::test_ingest_creates_pages   # one test

New tests build on the shared fixtures in tests/conftest.py — that layer is THE pattern: tmp_citadel (a temp repo/wiki/raw/docs layout wired into config.*; tmp_citadel_external for the out-of-repo mounted-drive layout, make_citadel for custom ones), seed_page (write a canonical OKF page into the configured wiki), and fake_agent (a recording FakeAgent installed over llm.run_ingest_session — pages to write, an error to raise, or a side_effect). Don't re-create per-file _wire*/fake-session copies.

Lint and format with ruff (config in pyproject.toml; CI gates both, alongside pytest):

uv run ruff check .       # lint
uv run ruff format .      # auto-format (CI runs `ruff format --check .`)

Python 3.12+ is required. There is no separate build step — pytest and ruff are the checks.

Test corpora

Ten synthetic corpora live under corpora/ (corpora/<name>/{raw/, stages/?, README.md}), runnable individually or together: beverages (the coffee+tea showcase), kelvarra (a coherent fictional world stated wrong about reality — graded that facts appear as stated, cited, never corrected), leuchtfeuer (a 3-year programme ingested in dated stages/ waves that drives reconcile/delete/force, temporal supersession, German→English, and attributed opinions — its committed raw/ is the FINAL post-wave state and stages/initial/ holds the wave-1 originals), pemberley (the whole of Pride and Prejudice as one ~730k-char source — large-source multi-segment chunking, relationship extraction, in-novel misinformation, narrative supersession), injection-resistance (three mundane documents with adversarial instructions embedded — the agent must treat them as content, never execute them), clockwork (a whole git repository folded in as ONE digest via CITADEL_REPO_SUPPORT, with a second commit driving repo-reconcile — its committed inputs are the repo-src/ + repo-src-wave2/ trees materialized into a checkout, since a git repo cannot be committed inside this repo), flurfunk (seven informal-genre sources — a chat export, a tweet thread, an interview, a job application, a forum thread, an announcement — grading attribution, "X said Y" ≠ "Y is true", in-thread reversal, and CV timelines), gazette (five PDF/markdown sources whose stdlib-generated PDFs grade CITADEL_PDF_MODE text-vs-images — a figure-only number and an image-only page absent in text mode, present in images mode — plus the academic-publications genre and references-are-not-sources), and kontor (binary Office documents — OOXML .pptx/.docx/.xlsx and legacy OLE .doc/.ppt/.xls, generated stdlib-only via make_office.py — the sole test of the Office text-extraction path (extract.py + extract_ole.py), an embedded-chart image delta (CITADEL_IMAGE_SUPPORT), dedup-by-basename, and ignore-patterns, with the same discriminative judgment traps as the hardened corpora — all fictional, Aldervik Kontor), and werkhof (uniform enumerable sources — a 28-machine register, a 20-code fault catalogue, a 15-customer CSV, plus a service report — grading Registry-page completeness (no entry compressed away), per-row citations with locators, promotion past the granularity floor, a dated status supersession, and near-miss key discipline — all fictional, Werkhof Anlagenservice Brandt). Each corpus carries its own committed, graded showcase wiki at corpora/<name>/wiki/ (its own nested citadel.toml marker, meta.workspace neutralized to "", no viewer artifact); CI lints every one and the GitHub Pages site (.github/workflows/pages.yml) builds a gallery with one offline viewer per corpus. Each carries a hidden answer key at .claude/skills/verify-corpus/<name>/ground-truth.md — outside the corpus so the ingest agent never sees it (Mode A also points CITADEL_RAW_DIR at the corpus raw/ only). The parameterized verify-corpus skill (verify-corpus <name>|all [--grade-only]) ingests a corpus into a throwaway sandbox workspace (never a live wiki) and grades it the way a user consumes the wiki: citadel check + lint exit 0 (structural eligibility), then a retrieval-first content grade — driving citadel's own read tools (search/read/index/tags) to prove each answer-key guarantee is both correct+cited and easily findable, dropping to a file-level grep only to separate a wiki-creation defect from a retrieval one (its misses feed two optimization lanes: the ingest/rules generator and the search tools — persisted per run in the committed ledger docs/verify-corpus-backlog.md). Corpora live outside citadel/, so they never ship in the wheel. The repo-root raw/ + wiki/ are a gitignored developer workspace (the checkout's citadel.toml marker still makes it a workspace).

Self-verification (feedback loops)

Two .claude/skills/ skills close the loop between a change and its proof:

  • verify-corpus (verify-corpus <beverages|kelvarra|leuchtfeuer|pemberley|injection-resistance|clockwork|flurfunk|gazette|kontor|werkhof|all> [--grade-only]) — the end-to-end corpus grader: ingests a corpus into a throwaway sandbox and grades the result against its hidden ground-truth.md by querying the wiki through citadel's own read tools like a user (retrieval-first), falling back to file greps only to tell a wiki-creation defect from a retrieval one. Run it after any change to ingest.py, llm.py, or the rules tree (citadel/rules/).
  • open-pr — the ship path: runs the hard local gates (pytest, ruff check, ruff format --check, the beverages-workspace lint), routes ingest/llm/rules diffs through verify-corpus, branches claude/<topic>-<slug> off main, opens a ready PR + Copilot review, watches CI, and stops at green — never merges.

Routing is mandatory, not advisory: any commit/push/PR request goes through /open-pr.

Release process

Trunk-based, no develop branch by design: every change lands on main via PR (through /open-pr with its gates + the Copilot round), so main is always releasable. A release is a deliberate act, never automatic on merge:

  1. A small release PR bumps __version__ in citadel/__init__.py and re-dates CHANGELOG.md's ## [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD (keep the flip-gate blockquote under a fresh ## [Unreleased] heading while it still applies).
  2. After merge, tag: git tag vX.Y.Z && git push origin vX.Y.Z.
  3. .github/workflows/release.yml then builds and publishes automatically — PyPI via Trusted Publishing (no token, no manual upload) + a GitHub Release.

SemVer: patch for fixes/docs, minor for features. PyPI versions are immutable — never re-tag or re-release a number; a mistake costs it, so bump again.

Architecture

The wiki/ directory is the database. No SQLite, no vector store, no second source of truth. Pages are markdown files with YAML frontmatter; everything (search, index, graph, provenance) is recomputed from them in memory.

Three layers (the README and citadel/rules/schema.md are authoritative):

  1. raw/ — immutable sources the agent reads but never edits.
  2. wiki/ — the LLM-owned OKF bundle: pages routed by kind into concepts/, objects/, systems/, persons/, organizations/, projects/, abbreviations/, registries/, misc/ (see okf.folder_for_type), cross-linked with relative markdown links, each fact carrying a footnote citation.
  3. citadel/rules/ — the schema/rules tree, packaged with the wheel (index: citadel/rules/README.md): schema.md (format contract) + core.md (agent behavior) are read every session, plus one lifecycle brief from tasks/, any file-type brief from formats/, and the agent-judged genres/ briefs. These are read by the ingest agent at run time (referenced by path in the prompt), so editing them changes how the wiki is built with no code change. Treat them as part of the program.

Everything operates on a WORKSPACE, not the repo checkout: a directory holding a citadel.toml marker (a pure marker, never config — scaffold one with citadel init [DIR]). Discovery order: CITADEL_WORKSPACE env var > nearest marker walking up from the CWD (nested markers shadow outer ones) > an env-dirs workspace (CITADEL_WIKI_DIR+CITADEL_RAW_DIR both set) > otherwise none: config.WORKSPACE_FOUND is False, WORKSPACE_ROOT falls back to the bare CWD, and every subcommand except init fails loud. The dev checkout carries a marker, so it is itself a workspace.

Ingest is the heart of the system (ingest.py → llm.py). Like store.py, ingest.py is an orchestrator + facade over three sibling modules split by responsibility — ingest_scan.py (the discovery walk, the guarded deletion sweep, source classification, and the file/repo partitions), ingest_staging.py (staging copies, snapshot/diff-by-hash, validate-and-restamp, rename-link repair, the robust file primitives, and the base-aware promote), and ingest_sessions.py (the all-or-nothing session runner, resume-checkpoint glue, and large-source pass planning) — with every ingest._* seam re-exported, so tests/status/curate keep addressing them through ingest. The flow per source:

  • ingest.ingest() partitions candidates into pending / already-ingested (sha match) / reorganized (moved-or-duplicate) / unreadable (binary; an all-NUL read is flagged as a cloud-only placeholder — Dropbox/OneDrive online-only — and never stat-cached as done, so it ingests once hydrated) / deleted (vanished from disk, full runs only) / same-basename document duplicates (skipped in favor of one preferred format). A pending Office source is extracted to text first; a pending image is read visually; a pending audio/video recording (CITADEL_AUDIO_SUPPORT, opt-in) is transcribed through a local whisper-class CLI (transcribe.py, content-addressed cache .citadel_transcripts/ beside the wiki) and the agent reads the [HH:MM:SS]-stamped transcript; a pending PDF (pypdf is a bundled dep — CITADEL_PDF_TEXT, default auto) gets its text layer extracted (pdftext.py, content-addressed cache .citadel_pdftext/) and the agent reads the [p. N]-marked extraction, falling back to the direct agent read when there is no usable text layer; a pending source larger than the effective chunk budget (config.source_chunk_chars() — the CITADEL_MAX_SOURCE_CHARS ceiling, tightened to a fraction of a stated CITADEL_MODEL_CONTEXT_TOKENS and floored at MIN_CHUNK_CHARS, so a small-context local model segments finer without the char threshold being dialed down globally) is folded in over several passes — each a contiguous LINE WINDOW of the ONE unchanged text (ingest_sessions._line_windows: the source file itself for plain text, the single full-text temp for an Office/PDF/transcript extraction), never a rebased slice, so every lines A-B locator is the file's own line number in every pass — all against one staging copy (see the promote bullet below). ingest --force <paths> bypasses the sha short-circuit: the named sources land in pending as reconciles (a repo re-digests in full), and the manifest is re-stamped with the current model + rules version. ingest --reingest <paths> rides force's partitioning but re-imports fresh instead of reconciling: a delete-cleanup job per named tracked source runs in the deletions group (always first), dropping the manifest key so the pending session plans the plain ingest/image/audio/pdf kind of a new key (repo for a tracked repo — safe only because the cleanup already stripped the pages a first-time brief would duplicate); a failed cleanup refuses that source's fresh session, and the cleanup never prunes the transcript/pdftext caches (the bytes are unchanged and about to be re-read).
  • Discovery is incremental and deletion-safe: one iterative os.scandir walk over every CITADEL_RAW_DIRS root keeps each file's stat; the manifest doubles as the scan cache (an entry's size/mtime_ns/ctime_ns/hashed_at_ns are a skip-hint — sha256 stays the sole arbiter of "changed"; --full-rescan distrusts the cache). Two things are pruned from the walk before any of that: the wiki dir itself (_is_wiki_internal, lexical — a raw root ABOVE the wiki, e.g. the whole of T:\, would otherwise fold the generated wiki back in as sources run after run; a root that IS the wiki is refused without arming the sweep, an explicit path inside it is refused too, and the run-start migration sweep clears any already-self-ingested key), and files over CITADEL_MAX_SOURCE_BYTES (the size complement to the name-matching ignore globs — skipped from the walk's own stat, so a 10 GB sensor dump is never opened, let alone hashed; reported, never silent, and off by default). A third filter applies to FILES only, when configured: the CITADEL_INCLUDE_PATTERNS allowlist (_is_included_name — "read only .pdf and .txt"), checked after the ignore globs (deny beats allow) and before the stat, so a filtered file is neither hashed nor classified; directories and repo dirs are never matched (an allowlist of *.pdf matching directories would prune every sub-folder). Deletion candidates come from the walked-seen-set diff and each is positively confirmed with .exists(); any walk error aborts the whole sweep, an unreachable root contributes no candidates, keys under no configured root are logged and never swept, and a workspace-identity mismatch whose keys do not resolve refuses the sweep. A flaky share or unmounted root must NEVER read as mass deletion — don't weaken these guards.
  • For each pending source it runs the agent against a per-source staging copy of the wiki (a sibling dir, never the live wiki), then snapshots before/after and diffs by content hash to learn what the agent created/updated/deleted — the agent's file edits are the result (the session seam's return value is only passive cost/usage telemetry, never consulted for what changed).
  • It then re-imposes invariants on every changed page (validate.validate_page + store.write_page to canonicalize YAML and stamp the timestamp) after every agent pass, repairs renamed-page links, and only on a fully clean source promotes staging onto the live wiki — exactly once per source — with a non-destructive copy-over-then-prune. A chunked large source folds ALL its segments into that one staging copy before the single promote (the live wiki never holds a partially imported source; a failure at segment N still discards the whole staging copy — but no longer the MONEY: each completed segment banks its delta as a resume checkpoint (resume.py, CITADEL_RESUME, .citadel_resume/ beside the wiki) that the next run replays into its fresh staging copy to continue at segment N. Every reuse is guarded — identity (source sha, model, rules version, segment content, prompt knobs), blob integrity, per-page base state in the live wiki, plus re-validation and a no-new-broken-links check of the replayed delta — and every guard failure falls back to a full restart at segment 1 in the same run, so the pre-resume behavior is the floor). Any failure/timeout/Ctrl+C leaves the live wiki exactly as it was; the source is retried next run. A session that deletes its own STAGING COPY (a weak model inventing a "publish" step — copying its pages into the live wiki and removing staging as "done") is caught right after the session (an identity sentinel, so a deleted-then-recreated directory cannot impersonate the copy — checked on the raising path too): the source fails with staging wiki copy vanished, naming any files that appeared in the live wiki outside the staging discipline (left in place, flagged as never validated), and the prompt tells the agent outright that its work stays in the wiki dir it was given — no publish step. Deletion cleanups, then pending files, then repos all drive this through ONE shared per-source loop (_SourceJob + _run_source_jobs) — deletions run first so a delete cleanup strips a vanished source's stale provenance before any pending session touches a page that still cites it (else that pre-existing bad citation would fail the pending session's validation and roll it back). That ordering is a mitigation, not the guarantee: when the cleanup itself FAILS the bad citation survives, so the all-or-nothing gate is blame-scoped — _validate_and_restamp takes the wiki as the source FOUND it (inherited) and an error whose exact (category, detail) the page already carried does not fail that source. Without this one broken page takes down every source whose session touches it, forever, on every run (money spent, nothing kept, and no way out but a hand edit). Every OTHER error still fails the source, so nothing a session actually breaks reaches the live wiki; the inherited ones ride out on report.inherited_issues as a run warning (and the same carve-out gates the resume replay, so a poisoned page cannot cost a chunked source its checkpoint). A failed cleanup is also the ONE failure that leaves a DEFECT rather than just undone work — every other failed source is rolled back untouched — so it carries a _SourceJob.failure_hint (surfaced once per run on report.failure_hints, under the errors footer) naming the offline tools that see and repair the dangling [^sN]: citadel lint and citadel curate. This all-or-nothing + network-share-hardened machinery (_robust_*, robust_mkdir) is load-bearing — don't simplify it away. A dead agent stops the run (_StallGuard, CITADEL_STALL_LIMIT, default 3, 0 = off): a CLI that self-updates mid-run into a build that cannot launch its own tools — or loses a permission — keeps exiting 0, reporting tokens, and returning an empty diff, so every guard passes and the run bills the whole corpus for nothing. N consecutive sources that were EXPECTED to change something (_SourceJob.expects_changes — a fresh source, or a delete cleanup planned because something still cites the source; a reconcile's "nothing changed" is a real verdict and never counts) and did not stops dispatching, in both the serial and the --jobs N driver; un-attempted sources stay pending for a plain re-run. Bounded parallelism (--jobs N / CITADEL_JOBS, default 1 = serial): the per-source staging copy IS the isolation primitive, so N sources run at once through the same _SourceJob loop — worker threads only plan/stage/run/promote, while every SHARED write (report, manifest, failures) stays on the main thread. One lock (_LIVE_WIKI_LOCK) guards the only two moments that touch the live wiki: the clone (taken together with a hash snapshot of what was cloned) and the promote. Under it the promote is base-aware — it prunes only what its own clone had and its staging lacks (else it would delete a concurrent source's new pages), and it REFUSES, before writing a byte, if a path it would touch has moved since the clone; that source is then re-run SERIALLY at the end of its group, where it merges into the winner's page (reported as raced). What parallelism costs is cross-linking, not safety — concurrent sessions cannot see each other's new pages — which is why the default is 1. The redirect this rides on is context-local (config.wiki_redirect → a ContextVar behind config.wiki_dir(); children get their wiki via config.child_env()), never a config.WIKI_DIR/os.environ assignment. One mutating run per workspace: ingest and curate take an exclusive run lock (runlock.py, a dotfile sibling of the wiki; stale locks reclaimed via dead-pid/mtime, refreshed per source) so a second concurrent run fails loud instead of silently destroying the first one's staging/promotes; manifest + failures saves are atomic (config.atomic_write_text, temp-sibling + os.replace), and the stale-staging sweep runs once at run start under the lock, never per source.

llm.py is the ONLY place that talks to an LLM, and it does so by shelling out to a CLI in agentic mode (cwd = config.child_cwd() — the workspace root in its child-friendly spelling — autonomous file tools). The prompt is paths-only — it references the source and rules by path, never embeds file content — which keeps argv tiny (the Windows WinError 206 fix). One per-kind spec table (_KIND_SPECS) maps each kind to its task-rule file, whether it reads a source, and its format policy; an unknown kind fails loud. kind selects the propagation: ingest (new), reconcile (changed source — update/remove stale facts, don't just append), delete (source removed — strip its provenance), repo/repo-reconcile (a whole git repo folded as one digest), image/image-reconcile (an image source read visually), audio/audio-reconcile (an audio/video source read via its whisper transcript), pdf/pdf-reconcile (a PDF read via its pypdf text-layer extraction), and curate (improve an existing page cluster against a findings file — reads that file by path, not a raw source). A large source is split into segments and folded in over several passes (segment=(part, total) on run_ingest_session, telling later passes to MERGE into earlier ones). run_ingest_session is the single seam tests monkeypatch; it returns the session's best-effort SessionUsage (the backend's OWN report of what it spent AND which model actually served the session: claude's result envelope - total_cost_usd plus the modelUsage map, whose PRIMARY entry is the one carrying the token volume, since claude routes cheap side work to a smaller model; copilot's --output-format json JSONL - no dollars, so its own billing unit is recorded instead: totalNanoAiu, the counter behind the N AIC used session footer (1 AIC = 1e9 nanoAiu), converted to cost_usd at GitHub's fixed published $0.01/credit so a mixed corpus keeps ONE comparable total while the un-derived credits stay stamped beside it (the retired totalPremiumRequests is deliberately ignored); agy's --output-format stream-json - the opening init.model plus the closing result.usage token totals; None when nothing was reported - accounting is strictly passive and can never fail a session), which ingest sums per source into the manifest stamp and per run onto the reports. That passive accounting layer — SessionUsage, combine_usage, the cost/AIC formatters, and the per-backend envelope parsers — lives in llm_usage.py, fully re-exported through llm (so llm.SessionUsage etc. stay the addressable surface). The REPORTED model wins: config.model_label_for(reported) stamps the backend plus what actually ran, and falls back to the configured label only when the backend named nothing (an Ollama/proxy backend, or agy left on its own default), so the manifest never claims a model that never ran.

Two checking layers, one implementation (validate.py):

  • citadel check / wiki_validate — the strict per-page gate (required fields, honest/defined citations, relative non-broken links, no [[wikilinks]]). The ingest agent self-runs it; ingest re-runs it and fails the source on any error.
  • citadel lint (lint.py) — a pure offline health check (contradictions, orphans, missing cites, broken links, stale, fabricated sources, undefined abbreviations, near-duplicate/malformed open points, and locator issues — a lines A-B range past a text source's end or a § Heading naming a heading the source lacks, via lint.check_locators, shared with curate). Only structural problems (missing type, broken links, bad sources, wikilinks) flip its non-zero exit (code 3 — lint's own, so CI can tell "wiki has problems" from the usage/no-workspace exit 2); the rest — locator issues included — are advisory. Both layers parse citations/links/fences through grammar.py, so lint and citadel check agree by construction: a citation into raw/ or docs/ is legal provenance (never a broken link), and a link inside a ``` code fence is literal text.

Curate is the second wiki lifecycle (curate.py, citadel curate). It has no persisted queue — the plan is recomputed from offline detectors every run (the wiki IS the database): rules_version_drift, page_length_hard, contradiction, orphan, llm_drift, resort (type↔folder mismatch via okf.folder_for_type), bad_source (a [^sN] citation that does not verify, via validate.source_issues — e.g. the dangling citations a failed deletion cleanup leaves, closing ingest's failure-hint loop), and locator (from lint.check_locators); fact re-verification is pre-filtered offline through manifest shas (reverify_candidates — changed = reconcile's job, gone = delete's job). Each planned page CLUSTER (page + cited raw files + link neighbors) runs ONE staged kind="curate" session over ingest's existing staging machinery, its findings written to a temp file referenced by path. The staging diff-by-hash is the single result arbiter (empty = NOOP, clean promoted = applied, exception/check-fail = failed → revert-and-stop). A failed cluster lands in the failures catalog keyed by page rel_path with an additive attempts counter (default cap 2, never auto-retried until an explicit retry). --dry-run prints the plan with zero sessions; --limit/--stale-rules shape it; --diff PATH writes a per-page change report; --retry re-includes attempt-capped clusters (the explicit retry that bypasses the cap); curate sessions run under CITADEL_CURATE_MODEL (falling back to the ingest model). --guidance TEXT (env CITADEL_CURATE_GUIDANCE, the shared 2000-char cap) is the lifecycle's own operator steer, and it rides a DIFFERENT channel than ingest's prompt bullet: the text is rendered into every cluster's findings file — curate's designated instruction channel — so the improve-or-NOOP rule reads it as a listed finding instead of a competing instruction. The positional paths (a page rel_path, a FOLDER, or a glob — _select_pages, applied inside build_plan BEFORE --limit, unmatched args surfaced on report.unmatched) are, under a steer, the ask itself: each named page is planned under the operator_guidance reason (ordered FIRST — a human ask outranks a detection) even when every detector considers it clean, which is the whole point (a badly named page trips no offline detector). A steer with NO paths only rides along on the detector plan — fanning one out over a clean wiki would be one paid session per page. A guided cluster's findings also name the run's OTHER guided anchors (elided past _SIBLINGS_SHOWN) and say they run in sequence: a structural ask is one instruction over many pages, but curate runs one session per cluster, so without it each session answers the same instruction blind to the others (one re-pivot becoming N near-duplicate pages). Because a steer makes RENAMES common and a rename is the one edit that reliably strands inbound links — the mechanical ingest._repair_renames net fires only when the page KEEPS its title, which a retitle by definition does not — every cluster session runs under a no-new-broken-links post-condition (_no_new_broken_links, riding ingest's existing extra_check seam, so it is checked against STAGING before the promote): a cluster whose edit leaves a cross-link pointing at a page that does not exist is failed and rolled back whole. Compared by dangling TARGET against a per-cluster baseline of the live wiki, never by (page, target) pair — a cluster MOVES pages, so a pre-existing dangling link would otherwise look brand new the moment its page is renamed (the same blame-scoping _validate_and_restamp's inherited carve-out exists for).

Refresh is the third lifecycle (refresh.py, citadel refresh): budget-controlled re-verification of existing SOURCES, so an aging wiki is brought up to the current model + rules a slice at a time instead of ever being regenerated. Every successful session stamps its source's manifest entry with an ingested_at last-checked time (manifest.now_iso; stamped ONLY in mark_done/the repo done-hook — moves and cache re-stamps CARRY the old stamp, so "last checked" never lies). refresh.plan() orders the manifest by that stamp (oldest first, a stampless pre-refresh entry counting as oldest; only model-imported, still-on-disk sources qualify; --min-age-days drops fresh ones so scheduled runs self-limit) and refresh.refresh(limit=N) hands the queue head to ingest.ingest(paths, force=True) — one kind="reconcile" session per source through the existing staging machinery, the success re-stamp rotating it to the back, so repeated runs walk the corpus round-robin with NO persisted queue (the manifest IS the queue). The budget is always explicit (limit >= 1 enforced, default 1), mirroring --force's no-accidental-corpus-wide-run refusal. CLI-only, like curate.

An auth-shaped session failure under hermetic isolation is retried once without the isolation flags: on a machine whose CLI credentials live in exactly the personal config --bare skips, every session used to die on authentication until the user found CITADEL_HERMETIC=0. The retry is scoped to that signature (flags actually passed + an auth-shaped message), so a real credential problem still fails instead of looping. A retry that WORKS is remembered for the rest of the RUN (llm._HERMETIC_REFUSED, keyed by the resolved binary, cleared by llm.reset_hermetic_refusals() when each ingest / curate run starts, so a long-lived citadel serve tries the flag afresh per wiki_ingest): the rest of the run skips the flag up front, so a corpus pays one failed launch and one notice, not one per source or curate cluster.

Status is the read-only corpus view (status.py, citadel status): the manifest + failures catalog + one stat-only walk (never re-hashes) + one wiki traversal rendered as a per-source state table — ingested (model + rules_version, (stale) when it predates the current rulebook, checked YYYY-MM-DD from the ingested_at stamp, the last session's cost when recorded, with copilot's AI credits shown beside the dollars they converted into — with Recorded LLM cost / Recorded AI credits corpus totals above the table, and a NO PAGES (nothing cites this source) marker on every ingested source the wiki does not cite — a paid session that produced zero entries), failed (reason, attempts), skipped-duplicate, ignored (pattern), oversized (over CITADEL_MAX_SOURCE_BYTES, with the size), not-included (outside CITADEL_INCLUDE_PATTERNS — the row is hidden entirely when no allowlist is configured, but the JSON key is always there), pending — closing with a citadel ingest --retry hint whenever anything is failed or uncited.

Other modules: okf.py is the OKF format core (parse/dump, type→folder routing, link math, and the non-negotiable safe_join path guard — reuse it for any wiki-relative path). grammar.py is the single home of the markdown grammar (link/footnote/fence/Sources-heading parsing, the source-citation predicates, and the [^sN] locator parser — parse_locator/source_headings, which lint.check_locators and curate consume) that store, validate, lint, and the viewer all parse through; never re-define any of it locally. store.py is a thin facade re-exporting the "database" API, split by responsibility into four sibling modules (import them through store, not directly): store_core.py (load(), the single swappable search() seam, read/write/delete_page — both mutators share the reserved-name guard that refuses index.md/*/index.md/log.md/dotfiles — plus the CLI/MCP text providers and the log.md writer); linkgraph.py (the deterministic link-rewrite safety nets rewrite_links, rewrite_raw_references, find_raw_references, find_broken_links, and the inbound_map backlink graph, all fence-aware via grammar.py); catalogs.py (rebuild_indexes(), which regenerates index.md, per-folder index.md, sources/index.md, and open-points/index.md mechanically from frontmatter + manifest); and open_points.py (parsing ## Open Points threads and deriving each point's status). pagecache.py is the read-path snapshot cache behind store_core.load() (CITADEL_PAGE_CACHE, auto = on only in citadel serve, which opts in): a long-lived MCP server otherwise re-walks and re-parses the WHOLE wiki on every tool call, so the last load is kept in memory and re-validated per call by a stat-only scandir fingerprint ((rel_path, size, mtime_ns, ctime_ns) over exactly the files load() parses) — ~4 ms vs ~700 ms at 1000 pages, with search's per-page term-frequency tables memoized on the same snapshot (a 1000-page wiki_search: ~1.4 s → ~50 ms). Nothing is persisted (the wiki stays the database) and staleness is designed out: the fingerprint is taken BEFORE and AFTER the load and must match, a snapshot whose newest stamp is younger than the settle window is never stored (coarse-mtime filesystems), the single slot is keyed by wiki dir (ingest's staging redirect can never be served the live wiki), write_page/delete_page invalidate, and ingest()/curate() wear @pagecache.bypass so the diff-by-hash machinery always reads disk. manifest.py tracks idempotency in wiki/.citadel_ingested.json (per source: sha256 or git commit + importing model + the last session's backend-reported cost_usd/tokens_in/tokens_out, carried across moves/re-stamps like ingested_at; meta.workspaces accumulates every workspace root that ever saved the manifest, so a SHARED workspace — one share mounted at different paths by different machines — is recognized instead of warned about once each mount has run one mutating command). failures.py persists the sources that could NOT be ingested (wiki/.citadel_failures.json: unreadable / errored / timed-out, with a reason), surfaced by store under a "Could not ingest" section of sources/index.md. repo.py builds the digest for git-repo sources. extract.py pulls text from Office files (stdlib-only): OOXML .pptx/.docx/.xlsx (+ macro-enabled) via zipfile+ElementTree, and legacy OLE .ppt/.doc/.xls via the CFBF reader + best-effort text salvage in extract_ole.py (imported lazily, only when a legacy OLE file is dispatched); its extract_media also pulls embedded raster images out of OOXML files so the agent can view them. transcribe.py is the whisper-CLI seam for audio/video sources (CITADEL_AUDIO_SUPPORT, opt-in): detection by extension+magic, one shell-out per content (openai-whisper flag convention), the [HH:MM:SS]-per-line transcript cached content-addressed in .citadel_transcripts/ beside the wiki — the same cached text lint/wiki_raw/the viewer verify and serve audio citations against; transcript_for is the ingest seam tests monkeypatch (whisper itself is never an LLM concern, so this lives beside extract.py, not in llm.py). pdftext.py is the same idea for PDFs (the audio pattern applied to the PDF class): pypdf is a bundled runtime dep (PDFs are a common raw/ class), so with CITADEL_PDF_TEXT (default auto = on when pypdf imports, which it always does unless deliberately uninstalled) a genuine PDF's (%PDF- magic) text layer is extracted once per content into a [p. N]-page-marked line-stable text, cached content-addressed in .citadel_pdftext/ beside the wiki — the agent reads it under the pdf/pdf-reconcile kinds while citing the original .pdf with lines A-B locators the same cache lets lint/wiki_raw/the viewer verify offline; text_for is the ingest seam, strictly best-effort (scanned / encrypted / corrupt / pypdf force-removed → None → the agent-native read with agent-verified p. N locators — never a failed source). resume.py is the chunked-source resume-checkpoint store (CITADEL_RESUME): after each completed segment ingest banks the promote-shaped delta in .citadel_resume/ beside the wiki, guarded by identity (source sha, model, rules version, segment content, prompt knobs), blob integrity, per-page live base state, re-validation of the replay and an attempt cap — so an interrupted large source continues at its failed segment instead of re-buying the earlier ones, and every guard failure degrades to a full restart. curate.py is the second lifecycle (offline detectors + staged cluster sessions; see above). status.py is the read-only per-source state view; doctor.py (citadel doctor) is the read-only setup health check (OK/WARN/FAIL lines over workspace resolution, the rules tree, the agent CLI on PATH, raw-root reachability, manifest parse + stamp, failures summary, the API-key/PDF/audio advisories, the wiki-git state, a best-effort PyPI update check naming the right upgrade command per install method, and workspace coherence). progress.py is the CLI's live console reporter (rich), wired in only by cmd_ingest/cmd_refresh — the MCP server passes no progress, so its stdio stays clean. On a terminal it renders a live region: one spinner row per IN-FLIGHT source (which is what makes --jobs N legible) plus an overall bar, while finished sources scroll away above it as permanent one-line verdicts carrying what the session actually spent ([2/3] OK raw/notes.md 18.4s 2 created $0.0123 1.2k in / 456 out claude-opus-5 — only fields the backend genuinely reported). A CHUNKED source names the pass it is on (part 3/8, from the source_segment event ingest._attempt_source fires before each of a multi-pass source's sessions — never for a single-pass one), because that is the one row that can legitimately stand still for hours and it would otherwise be indistinguishable from a hung run; its verdict then carries 8 parts, and a failed one part 7/8 — where it died, and where CITADEL_RESUME continues. Off a TTY or under --verbose it degrades to a START line plus a verdict line per source (plus one line per pass). Two invariants are load-bearing: every composed string is ASCII-only and the spinner is pinned to rich's ASCII line frames (a cp1252 Windows console must be able to encode it), and every write goes through a swallow-everything guard — console output must never be able to fail a run that already spent money. Source keys are shortened by config.display_key, which drops the whole prefix before the raw folder in three tiers: an exact prefix match, a cut at the last path segment NAMED like a configured root (this is what rescues a Windows drive letter mapped to a share, where T:\proj\raw and the key's //fileserver.../proj/raw/... are one folder but share no text), and finally a .../-marked tail clip, so no absolute key can ever flood the console. Its twin config.display_path does the same for the paths citadel itself announces — today the per-session transcript CITADEL_LLM_LOG_DIR writes, printed workspace-RELATIVE (a network-drive workspace makes it a ~200-char UNC string, and one is echoed per session, so a chunked source alone used to wrap two dozen rows through the live display). wikigit.py is the best-effort wiki-HISTORY layer: after every run that changed the wiki (ingest or curate) it commits the whole wiki dir as ONE commit (and pushes to CITADEL_WIKI_GIT_REMOTE when set), so every change is a reviewable diff; auto (default) only acts when the wiki dir is already its own git repo, CITADEL_WIKI_GIT=1 also git inits it on first use (refusing an embedded repo inside another working tree), and any git problem is a report note, never a failed run. server.py is the FastMCP stdio server (13 tools — 11 read-only incl. wiki_raw (the cited-source reader, backed by rawsource.py), wiki_neighbors (a page's links-out/backlinks/cited-sources graph), wiki_lint (with a tunable stale_days) and wiki_status (the per-source state view), plus two mutating: wiki_capture (append-only conversational note capture into raw/captures/, backed by capture.py — it never touches the wiki) and wiki_ingest (the only wiki-writer); every tool carries MCP behavior annotations — readOnlyHint/destructiveHint/idempotentHint/openWorldHint — never raises, returning error strings instead, and hands the recommended tool flow up through initialize.instructions; the server also publishes four workflow prompts — wiki_answer/wiki_verify/wiki_capture_note/wiki_health, slash-command-like packaged flows — and the wiki's documents as wiki:// resources (wiki://index/wiki://sources/ wiki://tags + the wiki://page/{folder}/{name} per-page template), byte-identical to their tool twins and sharing the tools' never-raise contract). httpserve.py is the OPT-IN Streamable HTTP transport for that same surface (citadel serve --http) — the only network surface citadel has, so it is strict by construction: a mandatory bearer token (CITADEL_HTTP_TOKEN, ≥16 chars — serve refuses to start without one) checked in a ~30-line ASGI wrapper BEFORE the MCP session layer (constant-time compare, 401 + WWW-Authenticate, no "unauthenticated for a minute" mode), a loopback bind by default (a public bind warns — the transport is plain HTTP, the intended remote path is a TLS-terminating tunnel), a Host/Origin admission policy owned by that same wrapper (the SDK's own middleware couples the two checks to one flag and can express neither "any host" nor "one browser origin") — Host derived from the bind address or named in CITADEL_HTTP_ALLOWED_HOSTS (a tunnel forwards its own hostname; a wildcard 0.0.0.0 bind derives nothing, so it REFUSES to start instead of 421-ing every request), browser Origins refused unless CITADEL_HTTP_ALLOWED_ORIGINS admits them — and an optional read-only mode (--read-only/CITADEL_HTTP_READ_ONLY) that has the two mutating tools refuse while leaving the 11 readers and the advertised tool list untouched (server.set_read_only). No new dependency — starlette/uvicorn already ship with mcp. The viewer/ subpackage builds the self-contained offline HTML viewer (build logic in __init__.py; template.html/app.css/app.js are real package-data assets loaded via importlib.resources). config.py resolves all paths/settings — including the native-form registry (NATIVE_FORMS/native_form/child_cwd): every configured path stays resolve()-d, which is what makes path identity unambiguous, but on Windows that rewrites a mapped drive (T:\team-wiki) into its UNC form, and a UNC cwd is refused outright by some agent CLIs (and read as a different repository by git). So the NON-resolved spelling citadel already holds (the .env value, the launching drive) is remembered alongside the resolved one and handed to CHILD processes only — the agent CLI's cwd + directory grants, git's -C — and every path UNDER an aliased directory inherits it (a default wiki/ has no .env value of its own to record, yet it is what git gets as -C). An alias is recorded only when both spellings of one directory are known AND resolution turned a non-UNC path into a UNC one, so POSIX and ordinary Windows paths keep exactly one spelling. cli.py mirrors the MCP tools as subcommands (full parity: define/read/raw/neighbors/index/sources/capture twin their tools; view stays CLI-only and wiki_lint/wiki_status close the lint/status gaps from the MCP side). capture.py is the conversational-capture bridge behind wiki_capture/citadel capture: an append-only, dated, attributed note into the monthly raw/captures/YYYY-MM.md log under the primary raw root — an ordinary raw source the normal lifecycle ingests/reconciles, so captured statements get real [^sN] line locators and the wiki is never written directly (docs/capture.md also documents the save-the-transcript-as-a-file lane for whole conversations). rawsource.py backs wiki_raw/citadel raw: the provenance-gated, locator-aware reader for the raw source behind a [^sN] citation (verify-only — the wiki stays the synthesized layer for retrieval).

Conventions specific to this codebase

  • config.* is read at call time (from . import config then config.RAW_DIR), never imported by value — so tests can monkeypatch the whole filesystem layout. Honor this when adding code. The WIKI path is read through the ACCESSORS (config.wiki_dir(), index_path(), sources_index_path(), log_path(), manifest_path(), failures_path()), never as config.WIKI_DIR: ingest redirects it per source through a ContextVar, so only the accessors see the staging copy. The module attributes stay the process-wide base tests monkeypatch.
  • Tests redirect everything to tmp_path by monkeypatching config.* (including WORKSPACE_ROOT, which the agent's cwd reads) and replace llm.run_ingest_session with a fake that writes files into the temp wiki. No test spawns a real LLM CLI. Follow that pattern; keep tests offline.
  • Never hand-edit generated files — index.md, log.md, any */index.md, sources/index.md, .citadel_viewer.html, and .citadel_ingested.json are regenerated. The ingest agent prompt and store.delete_page both refuse to touch them. In the REPO, .github/copilot-instructions.md is generated too: it is THIS file with a swapped header (both agents get one instruction set, so a feature can't be documented for one and not the other). Edit CLAUDE.md, then regenerate with CITADEL_WRITE_COPILOT_DOC=1 uv run pytest tests/test_packaging.py -k copilot -q; the drift guard in tests/test_packaging.py fails the suite when the two disagree.
  • Provenance grammar is load-bearing: raw facts cite [^sN] → a real raw/ file; model-supplied facts use [^llmN] (source: LLM) and must never be disguised as raw citations. A [^sN] to a missing file fails lint/check.
  • wiki/, raw/, docs/ can live outside the workspace (e.g. a mounted network drive) via CITADEL_*_DIR. Path handling distinguishes workspace-relative keys from absolute out-of-workspace keys (config.rel_or_abs_posix / source_path_for_key) — preserve that when touching path logic.
  • Cross-platform robustness is intentional, not over-engineering: UTF-8 forcing, BOM stripping, ASCII-only progress output, read-only-bit clearing, and network-share retry loops all fix real Windows/SMB failures.
  • Config knobs live in the workspace-root .env (auto-loaded, gitignored; template: citadel/templates/env.example). The format has no multi-line values, so ONE line is one setting and a list knob takes ONE comma-separated line; a key assigned twice keeps only its FIRST line (first-wins, the same rule that lets the real environment override the file) and the repeat is recorded in config.CONFIG_WARNINGS, which citadel doctor's config check shows. Knobs: CITADEL_LLM_CLI, CITADEL_INGEST_MODEL (passed as --model to EVERY backend - claude, copilot and agy all honor it; unset (the default) means "run the CLI's own default model"), CITADEL_CURATE_MODEL (model for citadel curate sessions; falls back to CITADEL_INGEST_MODEL), CITADEL_LLM_TIMEOUT, CITADEL_HERMETIC (session isolation — append claude's --bare when the installed binary advertises it, so personal ~/.claude config never leaks into ingest; default on, probe-gated), CITADEL_PAGE_CACHE (the serve-side page snapshot cache: auto = on in citadel serve only, 1 = everywhere, 0 = never), the CITADEL_HTTP_* serving knobs (TOKEN — mandatory for serve --http, no default; HOST/PORT/PATH — loopback:8765/mcp; READ_ONLY), CITADEL_LLM_VERBOSE, CITADEL_LLM_LOG_DIR, CITADEL_REPO_SUPPORT, CITADEL_IMAGE_SUPPORT (read images visually), CITADEL_AUDIO_SUPPORT (opt-in whisper transcript ingest for audio/video, with CITADEL_WHISPER_CLI/ CITADEL_WHISPER_MODEL/CITADEL_WHISPER_TIMEOUT tuning the seam), CITADEL_JOBS (how many sources ingest folds in concurrently; 1 = serial), CITADEL_STALL_LIMIT (the broken-agent circuit breaker: stop the run after N consecutive sources come back with no wiki changes; 0 = off), CITADEL_MAX_SOURCE_CHARS (large-source chunking threshold — the absolute ceiling), CITADEL_MODEL_CONTEXT_TOKENS (the ingest model's usable context in TOKENS, unset by default; when stated, the per-pass source window is capped at 10% of it at ~4 chars/token and floored at 8000 chars, so a small-context local model segments finer — and, because only chunked sources checkpoint, becomes resumable — while the two compose by min() and a MAX_SOURCE_CHARS=0 still means "never chunk"), CITADEL_RESUME (resume checkpoints for those chunked sources: continue at the segment an interrupted run died on instead of re-paying for the earlier ones; default on), CITADEL_DEDUP_BY_BASENAME (skip same-basename document duplicates), CITADEL_IGNORE_PATTERNS (the BLOCKLIST: OS/junk-file globs skipped at discovery — Thumbs.db, desktop.ini, ~$ locks, …; a + marker extends the built-in defaults, written once in front of the value or per entry — either way it never survives into the glob), CITADEL_INCLUDE_PATTERNS (its ALLOWLIST twin: when set, only files whose NAME matches are read ("in raw/, read only .pdf and .txt") — extensions (.pdf), globs (*.pdf) or exact names, case-insensitive; the blocklist still wins, an explicit path still wins over both, directories and repo sources are never matched, and an already-ingested source that falls outside a narrowed allowlist keeps its pages instead of reading as deleted — the sweep's .exists() confirm. What it filtered out is reported: ingest's Not included section, citadel status' bucket, and a citadel doctor line that WARNs when the allowlist admits nothing), CITADEL_MAX_SOURCE_BYTES (the SIZE complement to those globs: a raw file bigger than this many bytes is skipped at discovery — never hashed, never tracked, but reported; 0 = no limit, the default; an explicitly named path always wins), CITADEL_WIKI_LANG (target language of all wiki prose, default en; verbatim quotes stay original), CITADEL_INGEST_GUIDANCE (free-text steer appended to each source-reading session's prompt — usually passed per run as ingest --guidance instead; permanent house rules belong in rules/local.md), CITADEL_CURATE_GUIDANCE (its curate twin — STRUCTURAL only, carried in the cluster findings file, usually passed as curate --guidance "…" <paths>), CITADEL_RELATED_SOURCES (how many OTHER raw sources one source-reading session may consult to resolve a term/code/reference its own source never explains — found through the wiki, cited to the file it came from, core.md § Related sources; a per-session ceiling named in the run instruction so an ingest can never sweep the raw tree, default 3, 0 = never look), CITADEL_PDF_MODE (text | images — whether the agent also reads a PDF's figures), CITADEL_PDF_TEXT (auto | 1 | 0 — the pypdf text-layer pre-pass; auto = on when pypdf imports, which it does by default; 0 forces agent-native reading), CITADEL_STYLE_PROFILES (opt-in persona/style capture on persons/ pages, default 0), CITADEL_WIKI_GIT (wiki-history auto-commit after ingest/curate: auto acts only when the wiki dir is its own git repo, 1 also git inits it, 0 off) + CITADEL_WIKI_GIT_REMOTE (optional push target — remote name or URL), the CITADEL_*_DIR path overrides, CITADEL_RAW_DIRS (multi-root: a comma/newline-separated list of raw roots discovery walks; replaces the walk list when set, CITADEL_RAW_DIR stays the primary root), and *_CLI_PATH binary overrides.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.