Architecture

Legion is a single Rust binary that stores data in SQLite, indexes prose with Tantivy, indexes code with SCIP, and computes embeddings with model2vec. No runtime dependencies. No background services required (watch and the daemon are optional).

Storage

SQLite via rusqlite with WAL mode. Default location: ~/.local/share/legion/ (XDG on Linux, Application Support on macOS). Overridable via LEGION_DATA_DIR.

Tables (grouped by concern):

Memory and search

  • reflections agent memories with BM25-searchable text, domain tags, learning chains, boost/decay scoring, archive/tombstone/LWW columns
  • embeddings model2vec vectors stored as nullable BLOB on reflections (per-node, not replicated)

Code intelligence

  • scip_indexes per (repo, lang) SCIP protobuf blobs that legion sym reads in-process. Built by legion index and refreshed by the PostToolUse hook
  • file_inventory one row per non-ignored file in every watched repo, built by a gitignore-aware walk on every legion index run. The substrate under sym tree and sym etc find-file; a file gets a row whether or not SCIP indexes its language
  • module_edges one row per resolved (or unresolved) JS/TS import, export-from, or re-export, keyed on (repo, from_path, specifier). Built by parsing every js/ts/jsx/tsx file with oxc_parser/oxc_resolver, independent of whether scip-typescript is installed. Queried via sym imports/sym importers
  • css_symbols one row per class-selector or --custom-property definition, keyed on (repo, path, kind, name, line). Built with lightningcss, including Tailwind v4 @theme blocks. Queried via sym list/sym def --lang css

Coordination

  • schedules cron-like scheduled posts with tombstone/LWW columns
  • documents coordination substrate documents (#455/#456): specs, NFRs, blueprints, personas, journeys, etc. Type-agnostic at the storage layer. Payload is a validated JSON blob
  • board_reads per-agent read cursors for the bullpen (local, does not replicate)

Watch and wake

  • watch_handled per-repo signal handling tracking
  • watch_redelivery (#948) per-(signal_id, repo_name) retry counter for signals stranded by a failed wake attempt. An attempt that settles to a failure terminal state unmarks the watch_handled rows it carried, and the next poll re-surfaces them, capped at max_redelivery_attempts (default 3); exhaustion posts loudly to the bullpen and stderr rather than failing silently again. Host-local, pruned on the same retention cutoff as watch_handled
  • wake_attempts (#487) FSM-tracked wake attempts: spawn time, PID, PTY EOF time, exit_observed_at, terminal status. The reaper is the only writer for terminal transitions

Observability

  • health_samples system telemetry for watch pressure monitoring
  • rate_limit_samples Claude Code rate-limit chips ingested by legion statusline
  • usage_samples Claude Code session token usage ingested alongside the rate-limit chip
  • audit_log work source actions (issue/PR creation, merge, review) for traceability
  • quality_gates skill-run results enforced by legion pr create and the ->Done gate, carrying provenance (VALIDATED via a check validator, ASSERTED via record), a void/supersede tombstone, and the resolved base ref a check run diffed against
  • quality_gate_findings structured findings (file, line, severity, status, disposition reason, resolving commit) keyed to the gate row that raised them, resolved by git-log detection or retired via finding-disposition/finding-ack

Uncertainty (Pillar 2)

  • predictions claims with confidence, surface, cohort, orphan window (#355/#356)
  • calibration_snapshot reliability buckets: claimed vs actual, count, Brier score (#357/#358)

All IDs are UUIDv7 (time-ordered). Migrations are idempotent and run on every DB open. Syncable tables (reflections, schedules, documents) carry deleted_at and updated_at for soft-delete replication and last-write-wins conflict resolution. The tasks table carries the same columns but does not replicate — its row type left the wire, and the table stays inert rather than syncing.

Identity-root guard (#785): a repo holds at most one live, unparented domain=identity reflection. insert_reflection_with_meta refuses a second one unconditionally — bootstrap and --follows chaining are unaffected, only a second orphan root is blocked. The one sanctioned replace path is swap_identity_root, consumed by legion whoami --generate --apply: a single transaction that deletes every live identity root for the repo, including any previously leaked duplicates, and inserts the new root plus optional chained children, rolling back atomically on failure. reflect retag carries the same guard from the UPDATE side: it refuses to retag the last live root of a protected domain (identity or workflow) off that domain.

Tantivy BM25 full-text search with English stemming. Index lives alongside the database. Queries filter by repo or search across all repos (consult).

Incremental index sync detects reflections in SQLite missing from Tantivy and adds them automatically. No manual reindex needed after sync.

Documents share the index. A kind field (reflection or document) partitions the two corpora on every read, so a document with text identical to a reflection’s never surfaces in legion recall — recall always passes the reflection kind. Every document write path re-indexes after its DB write: CLI create, revise, set-status, and archive, plus the daemon’s own write endpoints. The daemon serves GET /api/search?q=&repo=, document-scoped, returning full Document rows in rank order (repo required, limit defaults to 20 and caps at 50, q caps at 512 characters, parenthesis nesting bounded at 8); it registers directly on the daemon’s channel::router. Adding kind changed the index’s on-disk schema, so the first open after upgrading from a pre-0.37.0 index wipes it and starts empty — legion reindex, which now rebuilds reflections and documents together, is the one-time fix.

Embeddings

model2vec-rs for semantic similarity. Stored as a nullable BLOB column on reflections. Each node computes its own embeddings. They do not replicate.

Code intelligence

SCIP (Sourcegraph Code Intelligence Protocol) protobuf blobs in scip_indexes, keyed on (repo, lang). legion index detects languages from the workdir, runs the corresponding indexer per language (scip-typescript, rust-analyzer’s SCIP mode, scip-python, etc.), and writes the blobs.

legion sym def | refs | impl | hover | impact reads these blobs in-process. No file scan. No language server runtime.

Background indexer: triggered after legion watch add, after PostToolUse on indexed repos, and on demand. Logs land under ~/.local/state/legion/index-logs/.

Sym etc (#704): the non-symbol answer surface, for files and query shapes SCIP was never going to cover. sym etc find-content (src/etc.rs) runs an in-process ripgrep engine (grep-regex + grep-searcher over an ignore walk) against the working tree at query time, deliberately not a corpus: a tokenized index returns nothing on the punctuation-heavy literals agents actually search for, and any content index goes stale on checkout or pull, neither of which fires an edit hook. --no-ignore disables gitignore/.ignore/parent-ignore/git-exclude checks (independent of --hidden, which admits dotfiles — reaching a gitignored dot-directory needs both). sym tree, sym etc find-file, and the sym imports/sym importers module-graph readers answer from file_inventory/module_edges instead, with no filesystem walk at query time, and wrap --json output in a {snapshots, entries} freshness envelope (per-repo indexed_at, head_at_index, current_head, head_drift). sym list/sym def --lang css and sym etc extract round out the surface: CSS reads a separate lightningcss-backed store, and extract reads a JSON/TOML/YAML file or a .md/.mdx/.astro file’s YAML frontmatter and walks a dotted field path. Every invocation, success or failure, lands one row in etc-usage.jsonl; legion telemetry etc-summary reads it back.

Enforcement hooks (#438/#439, #713): on indexed repos, raw Grep and Read are blocked at PreToolUse. The redirect names the exact sym etc command for a non-symbol query shape, not just sym/recall: a literal string routes to find-content, a listing to tree, a config value to extract, “which repo has X” to find-file. Bypass via env var (LEGION_BYPASS_GREP=1, LEGION_BYPASS_READ=1) or # legion-bypass: <reason> sentinel in Bash. Every bypass writes one row to bypass.jsonl. This wording lives in the plugin (plugin/hooks/), not the daemon, so a session running an older plugin against a newer daemon can still show stale redirect text until the plugin itself is updated.

The feedback loop: bypass.jsonl is the signal that sym or recall missed an answer agents expected. The uncertainty engine reads it, and legion telemetry summary surfaces the top under-served (tool, repo, pattern) tuples (#440). Doctrine becomes mechanism: the rule is enforced, the violations are counted, the count drives what gets fixed next.

Work sources

GitHub issues are the work source, through legion’s own verbs: legion issue create | view | list | edit | close | reopen, legion pr create | merge | review | .... Every action lands in the audit log. Direct gh is blocked for agents, so nothing routes around it.

Work sources are plugins: executables that speak a documented protocol (list, close, detect, create-issue, sub-issue, pr-create, pr-merge, etc.). GitHub ships first, wrapping the gh CLI and a few GraphQL mutations. The interface is generic enough for GitLab or Jira.

Sub-issues (#462): create child issues linked to a parent via GitHub’s native sub-issue relationship. The plugin looks up the parent node id, errors if missing, then links via the addSubIssue mutation.

Coordination substrate

documents is the type-agnostic storage layer for shared coordination artifacts: specs, NFRs, blueprints, personas, journey maps, schemas. Payload is a validated JSON blob; meta columns (type, surface, status, priority, owner) are hoisted into indexed SQL columns.

legion document create | view | list | validate | archive is the operator surface.

Schema registry: schemas are documents with doc_type=schema, structurally validated at insert ($schema, title, type:object, non-empty properties) and self-describing via a top-level x-doc-type keyword naming the type each one governs. Every write to the documents table — Database::insert_document, revise_document, and update_document_body, so CLI and daemon alike — validates its payload against the schema landed for that doc_type through the jsonschema crate (draft-07 and 2020-12, $ref/definitions honored); a doc_type with no schema, or with two schemas claiming it, is refused, no warn-and-allow. A violation raises LegionError::SchemaViolation, one <pointer>: <message> line per problem — the CLI prints the lines and exits 1, the daemon answers 422 with a violations array. The crate builds with default-features = false: its defaults resolve a remote $ref over HTTP or file://, which would turn a caller-supplied schema on the daemon’s create endpoint into an SSRF and file-disclosure primitive and panic a blocking fetch inside an axum handler, so an external $ref now fails to compile instead. A second bound counts every reference keyword ($ref, $dynamicRef, $recursiveRef) anywhere in a schema and refuses past 64, alongside a matching nesting cap, closing a stack-overflow path a chained-ref schema could otherwise trigger. Each schema write dual-writes a pointer reflection on domain=schema so legion recall --domain schema surfaces every landed schema with its document id. legion document validate --schema <id> runs the same compiled validator by hand against an arbitrary instance.

Spec-gen: retired. The Rust legion spec-gen command — and src/spec_gen.rs with it — was removed in 0.34.0 (#821). Deriving a requirement from a service-design document’s moment_of_truth is judgment work legion carries through the uncertainty engine and the audit log, not a deterministic field-concatenation, so it now runs as an instrumented skill rather than compiled Rust. /legion:sd-write-spec is the current path: two input modes (intent alone, or a landed service design), every traces_to naming what earned it from an exhaustive list of grounds, and every gap escalated rather than resolved in the body. The requirement JSON schema in the registry is unchanged, so a derived requirement still validates the same way either path produced it.

Issue-requirement tracing (#933): an issue can point at a requirement without a card in between. Its body carries a ## Traces to section: a requirement id, an optional set of criteria ids, and prose, or an explicit None with a reason. legion issue create validates the trace at filing time, refusing a line that names a requirement that does not exist, is cancelled, or is malformed — a bad trace fails at the point it is written, not the point verify runs. legion verify --issue then judges the work against the traced requirement’s own verification.criteria rather than the issue’s restatement of them, and the recorded verdict pins the requirement document’s id and revision. legion document view surfaces which of a requirement’s criteria currently carry a clean verdict, and pr write-check renders the traced criteria beside the PR body’s mapping, flagging any the PR re-authors instead of citing. Untraced issues verify exactly as before, straight from the issue body.

Documents replicate across the cluster like reflections.

Communication

Bullpen: shared message board. Posts are reflections with audience = 'team'. Discoverable via consult.

Signals: structured bullpen posts for coordination. Format: @recipient verb:status {details}. Filtered separately from natural-language posts. Wake-worthy verbs (question, request, handoff, correction, proposal, decision, routing) spawn an asleep recipient via watch; rfc is also wake-worthy but additionally requires a budget: entry in --details. Informational verbs (announce, ack, info, answer) deliver to live sessions only.

Resolve (#362): legion resolve --id <post-id> marks a converged thread so it stops resurfacing in the bullpen and the wake-loop signal feed. Optional --reflection <id> links the team’s decision so future recall surfaces them together.

MCP server: retired, in two passes. 0.32.0 (#947) retired only the push half — the notification channel that pushed notifications/claude/channel frames — and left legion mcp and its four write tools (legion_post, legion_reply, legion_signal, legion_task_respond) standing. 0.35.0 (#952, PR #982) finished the job: the MCP server itself, legion mcp and legion mcp-logs, src/mcp/ in full, and plugin.json’s mcpServers registration are all gone. The CLI is now the single write surface for board and signal writes: legion post and legion signal are the direct replacements, each running the embed backfill, self-signal refusal, and the resolves-stamp the MCP tools never inherited. legion_task_respond has no replacement — legion task and the card/task system it wrote to were themselves removed in 0.39.0 (see Storage above, on the inert tasks table).

Delivery: legion inbox --repo <repo> is the sole live-session delivery lane, wired into the plugin’s UserPromptSubmit, PostToolUse, and Stop hooks. It reads the undelivered set and returns it as additional context at the next hook turn-boundary — real-time relative to your prompt, a tool call, or the session ending, whichever fires first. Its output is a delimited result block (#1024, #1020): [Legion] Inbox: … [Legion] End inbox., musings first, then directed signals last in the same REQUIRES A REPLY shape boot and legion pending-replies use — Claude Code runs matching hooks in parallel with no documented ordering of injected context, so the delimiter is the mechanism, not hook placement in hooks.json. legion inbox --split does the sorting in the binary so the hook never parses posts.

What the retired push covered that the inbox did not: a live session sitting idle mid-turn, with no hook firing, heard nothing until its own next prompt, tool call, or a watch wake. The idle-session courier described under Watch below closes that gap for a watched repo with unread mail, without adding a second delivery lane — the inbox, unchanged, is still what delivers. Every delivery writes a DeliveryRecord row to delivery.jsonl. The watch daemon’s independent wake path for asleep agents is unaffected; it never depended on the MCP channel.

Stop gate on an open directed ask (#1024, #1020): the Stop hook runs legion pending-replies --repo <repo> --directed and blocks the turn from ending while the result is non-empty, naming each open ask and telling the agent to reply --to <author> (a reply --to all retires nothing). It honors LEGION_SKIP_STOP_BLOCK=1 (bypass audited), sits below the existing watch-pty exemption, and is guarded by legion_hook_covered so a repo with no legion footprint is never blocked.

Watch

Long-lived daemon that polls SQLite for unhandled wake-worthy signals. When one arrives for a configured repo, watch spawns a headless Claude Code session in that repo’s working directory.

Spawn modes (#485, env WATCH_SPAWN_MODE):

  • print legacy claude --print subprocess
  • pty (default since v0.16) portable-pty wrapper with a ring-buffered reader (#486, #498). Provides a real TTY so prompts that require interactive features behave correctly

Wake tracking (#487, #499): every wake attempt writes a wake_attempts row and transitions through an FSM. The reaper is the only writer for terminal states (exited, panic-stopped, abandoned). The Stop hook can hand off an exit_observed_at hint via legion watch session-end (#493). PTY EOF + PID poll remain authoritative.

Idle-session courier (#1013, #999): watch already skipped a repo with a live session, correctly — but a live session that had gone idle got silence, the same as an empty repo got a spawn. poll_cycle’s active_pid branch now matches a live session to a watched repo by canonicalized cwd against the registered workdir (via claude agents --json) and, when that repo has unread mail and is outside its nudge cooldown, starts a short-lived PTY courier that SendMessages the target “you have undelivered mail in {repo}; take a turn so your inbox delivers it,” restricted to the SendMessage/ListAgents tools so the call never stalls on a permission prompt, and run from a scratch dir with LEGION_REPO=courier-scratch so its own hooks can never claim the nudged repo’s lock. The prompt carries only the repo name and the target’s name and pid, never post or signal text — the nudge is not a second delivery lane — and the courier identifies as legion-watch, not as the repo it nudges for. The idle agent wakes on the message, and its own unchanged hook inbox delivers. The nudge cooldown is independent of the wake cooldown in both directions. The target is the first roster session matching the workdir, not cross-checked against the pid holding the session lock, so two terminals open on one workdir could route the nudge to the wrong one. A session running under bypassed permissions cannot be nudged or even detected: inbound SendMessage is held there for human approval, and the roster does not expose permission mode.

Persona leases: cluster-wide wake coordination so two nodes do not wake the same persona at the same time. legion watch leases inspects.

Safeguards:

  • Per-repo cooldown prevents wake storms
  • Stagger between spawns prevents I/O storms
  • System health monitoring pauses spawns under pressure
  • Panic-stop on subscription-quota exhaustion (#484)
  • Failed-wake re-arm (#948): an attempt that dies before its prompt submits unmarks the signals it carried instead of leaving them permanently marked handled, so the next poll re-surfaces them — subject to that poll’s own lookback and expiry windows. Capped at max_redelivery_attempts (default 3) per (signal_id, repo_name); exhaustion posts loudly to the bullpen and stderr rather than failing silently a second time

Uncertainty engine (Pillar 2)

Agents emit predictions with claimed confidence. Outcomes are witnessed later. Calibration is measured per cohort and surface.

emit  -> prediction row, orphan window starts
witness -> outcome recorded, calibration_snapshot updated
sweep -> past-deadline emitted predictions marked orphaned (hourly, in-process)
roll  -> calibration_snapshot recomputed per cohort, 0.1-point Brier buckets (nightly, in-process)
orphans -> predictions still unresolved past their window
calibration -> claimed vs actual per reliability bucket, Brier score

legion uncertainty emit is non-blocking so a downstream auto-emit hook (#358) can never break the agent. witness is idempotent-failure: re-witnessing is an error so the calibration table cannot drift.

Calibration roller and orphan sweep (#1004, #359): sweep and roll run inside WatchLoop::tick_poll — the sweep at most hourly, the roll at most nightly — each gated on an in-process last-run timestamp the way the heartbeat throttle is, and fail-open, so an error in either never stops the poll cycle. legion uncertainty roll (sweep then roll), sweep-now, and calibrate-now expose the same jobs as manual verbs, each with --json, for a one-off backfill outside the daemon’s own schedule. legion daemon status does not report the jobs; they log to stderr like the sibling reapers.

Bypass telemetry (bypass.jsonl) feeds the same engine. A high bypass rate on a (repo, pattern) is a signal that the system claims it can answer the query (sym/recall) but agents are voting with their feet.

Rate-limit and mesh placement

legion statusline is wired into Claude Code’s statusLine.command. Each tick reads the rate-limit JSON on stdin, persists a rate_limit_samples row (and a usage_samples row when the transcript is readable), and prints a one-line chip.

legion mesh headroom ranks every host in the cluster by remaining rate-limit headroom and recent burn rate. legion mesh pick prints the best host for placing a new task. legion usage surfaces session cost analysis for the operator.

Multi-node

Each machine runs its own legion with its own SQLite database. Nodes on the same LAN discover each other via UDP broadcast and exchange encrypted delta packets. No coordinator, no central server, no cloud dependency.

Transport library: the broadcast and encryption primitives come from smugglr, a reusable Rust crate. Legion owns the schema, the cluster.toml configuration, the CLI, and the sync actor wiring on top. The split lets legion stay focused on the memory model while smugglr handles the edge-of-the-network concerns (packet framing, encryption, peer discovery).

Encryption: XChaCha20-Poly1305 with a pre-shared 256-bit key. The key is membership: anyone with the key can read and write to the cluster. cluster.toml is written with 0o600 on Unix.

Discovery: UDP broadcast on port 31337 (configurable). Each node announces itself and listens for peers.

Cross-network: bring your own tunnel (Tailscale, WireGuard, ZeroTier). Legion broadcasts to whatever subnet it’s on.

What replicates: reflections, schedules, documents. Each row carries updated_at (LWW) and deleted_at (tombstones), so deletes replicate as rows rather than vanishing from peers that have not yet seen them.

What stays local: Tantivy search index, model2vec embeddings, SCIP indexes, board_reads, health_samples, rate_limit_samples, usage_samples, watch_handled, watch_redelivery, wake_attempts, bypass.jsonl. Each node computes or tracks its own.

Tombstone housekeeper: legion watch runs a weekly job that hard-deletes rows whose deleted_at exceeds the retention window (7 days by default). Late-joining peers still see recent deletes. The database stays bounded.

cluster.toml

Stored in the data directory alongside legion.db. Managed by legion cluster * commands. Hand-editing is supported but unusual.

enabled = true
secret = "64-hex-chars"          # 256-bit key, generated by `legion cluster init`
port = 31337                     # UDP broadcast port
instance_id = "hostname"         # optional, defaults to system hostname

Cluster commands

legion cluster init                # generate key, write cluster.toml
legion cluster init --key <hex>    # enroll using another node's key
legion cluster key                 # print the current key
legion cluster enable              # start broadcasting
legion cluster disable             # stop broadcasting (key preserved)
legion cluster status              # peers, sync state, last sync time

Daemon HTTP API

The daemon (legion daemon) mounts an Axum router, channel::router, over /health, /sse, /api/feed, /api/post, /api/search, and the /api/documents routes. SSE gives live updates to any connected client; agents themselves interact via CLI, not this surface.

Document endpoints (#702): GET /api/documents, GET /api/documents/{id}, and POST /api/documents/{id}/status list, view, and change a document’s lifecycle status. The status route backs legion document set-status: a caller flips a document’s status directly, with the human issuing the request as the gate rather than a status-machine.

Document editor endpoints (#1038, #1036): POST /api/documents mirrors legion document create field for field, including the schema-write gate above. PUT /api/documents/{id}/body is a debounced working-copy save — it merges new text under the payload’s top-level body key through a single read-then-json_set update and never touches revision, so a caller typing does not spin the counter; a revise landing between two body saves is kept. POST /api/documents/{id}/revise is the only one of the three that actually bumps a revision: it replaces the whole payload and increments revision by exactly one, the same as the CLI verb. All three validate against the doc_type’s schema before writing and share one load_editable_document preamble — 404 for an unknown id, 400 for an archived one. owner and a caller-supplied id are constrained to [A-Za-z0-9._-] and 128 characters, since here they arrive as network input rather than argv; request bodies cap at 4 MiB. There is still no authentication on any of this — what a LAN peer can do with these endpoints is tracked separately (#1034).

Project layout

src/
  main.rs              -- CLI entry point (clap); the only process::exit site
  cli/                 -- subcommand handlers (pr, document, verify, watch, push, commit, etc.)
  db/                  -- SQLite init, migrations, domain CRUD modules (reflections, documents, audit, wake, etc.; kanban.rs is an inert legacy remnant)
  uncertainty/         -- emit, witness, calibration, orphans
  search.rs            -- Tantivy index management
  scip.rs              -- SCIP index build, store, query (sym engine)
  sym.rs               -- sym subcommand: def, refs, impl, hover, impact, list, tree, etc
  etc.rs               -- sym etc: find-content (ripgrep engine), extract, find-file
  inventory.rs         -- file_inventory walk: the substrate under sym tree and find-file
  graph.rs             -- module_edges: JS/TS import graph via oxc_parser/oxc_resolver
  css.rs               -- css_symbols: class/custom-property extraction via lightningcss
  recall.rs            -- query and rank reflections
  board.rs             -- bullpen posts and filtering
  signal.rs            -- signal parsing, formatting, wake-worthy verbs
  surface.rs           -- cross-repo highlights
  status.rs            -- agent work status
  documents.rs         -- coordination substrate documents, schema registry, validation
  cluster.rs           -- cluster.toml, key management, `legion cluster`
  sync_actor.rs        -- background thread that drives delta sync
  health.rs            -- system health sampling
  statusline.rs        -- statusLine ingest + chip render
  mesh.rs              -- mesh-aware task placement
  telemetry.rs         -- bypass.jsonl and etc-usage.jsonl writers and readers
  embed.rs             -- model2vec embeddings
  init.rs              -- hook script generation and settings.json management
  error.rs             -- error types
plugin/
  .claude-plugin/      -- plugin manifest
  bin/legion           -- wrapper script
  hooks/               -- Claude Code hooks
  commands/            -- slash commands
  agents/              -- agent definitions (changelog, dungeon-master, issue-writer, legion-explore, legion-review, legion-verify)
  skills/              -- skill definitions (legion-memory, legion-simplify, legion-pr-write, legion-review, legion-verify, legion-distill, and the sd-* service-design/spec skills)
  worksources/         -- work source plugins