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, serve, 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

  • tasks kanban cards with a 10-state machine, priority, labels, source URLs, timestamps, tombstone/LWW columns, plus wake_at/pre_defer_status for the Deferred state
  • 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
  • wake_attempts (#487) FSM-tracked wake attempts: spawn time, PID, PTY EOF time, exit_observed_at, terminal status, and a nullable card_id linking an attempt to a Delegated card. 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, tasks, schedules, documents) carry deleted_at and updated_at for soft-delete replication and last-write-wins conflict resolution.

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.

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. The dashboard surfaces 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.

Kanban scheduler

The kanban board is a scheduler wearing a human-friendly UI.

Card statuses: Backlog, Pending, Accepted, Needs Input, In Review, Blocked, Delegated, Deferred, Done, Cancelled.

State machine enforces valid transitions. force_move bypasses the state machine for dashboard drag-and-drop.

Delegated (#778) and Deferred (#816) are both fail-closed, not self-set labels. Delegated entry requires a fresh watch-daemon heartbeat and a live linked wake_attempts row; Deferred entry requires a resolvable future --until. Both revert through the same watch health tick that drives the rest of watch (reap_deferred_cards alongside the existing delegated reaper), writing status, wake_at, and pre_defer_status atomically so a crash between steps can never strand a card invisible to the sweep. Deferred is excluded from CardScope::WorkingSet — and so from the Stop gate’s accepted-only check — but stays visible everywhere else via list --deferred and its wake markers.

Scheduler (legion work): atomically selects the highest-priority unblocked card and accepts it. Priority ordering: critical > high > med > low, then sort_order, then oldest.

Work sources: plugins that sync external issue trackers (GitHub, GitLab, Jira) into the kanban board. Cards link back to source issues via source_url. Reconciliation pulls live state with legion kanban reconcile.

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. They are structurally validated at insert (must have $schema, title, type:object, non-empty properties). Each dual-writes a pointer reflection on domain=schema so legion recall --domain schema surfaces every landed schema with its document id. legion document validate checks any JSON instance against a landed schema, one error per violation.

Spec-gen: legion spec-gen --repo <surface> reads non-archived service-design documents (types: persona, journey, blueprint, painmatrix, ecosystem), derives one requirement per moment_of_truth, validates each against the requirement schema, and inserts new requirement documents plus born-Backlog kanban cards. Idempotent on (traces_to, surface) pairs.

Card-spec binding: legion kanban bind --id <card-id> --document <doc-id> sets tasks.document_id. Once bound, legion verify reads acceptance criteria from the document’s verification.acceptance block. Status transitions that reach accepted, in-review, done, or cancelled also update the document’s meta.status transactionally (accepted, implemented, verified, cancelled). A dangling binding (document archived or missing) is a hard error on any transition.

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 and cards.

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, help, blocker) spawn an asleep recipient via watch. Informational verbs (announce, ack, info, answer, review) deliver to live sessions only.

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

Channel: MCP server providing real-time communication tools. Events arrive as JSON-RPC notifications with method notifications/claude/channel. No polling.

Delivery (#941): two lanes carry a post or signal into a live session, running concurrently for parity measurement. The MCP channel pushes it as a JSON-RPC notification, no polling. legion deliver drain --repo <repo>, wired into the plugin’s UserPromptSubmit, PostToolUse, and Stop hooks, reads the same undelivered set and returns it as additional context at the next hook turn-boundary — a code-side path independent of the MCP notifier subprocess, with no inference roundtrip. Because both lanes run at once, a post can arrive via both. Both write a DeliveryRecord row to delivery.jsonl, so which lane actually delivered it is measured rather than assumed. The MCP channel is not removed.

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.

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)
  • Auto-unblock: when an agent announces completed work, blocked cards referencing that repo are automatically unblocked

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

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, kanban cards, 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, 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

Dashboard

Axum web server with rust-embed for static assets. SSE for live updates. Tabs: Feed, Signals, Board (kanban), Stats, Chat, plus code-intelligence views fed by scip_indexes and observability views fed by rate_limit_samples and usage_samples.

Document endpoints (#702): GET /api/documents, GET /api/documents/{id}, and POST /api/documents/{id}/status land in channel::router, the surface the daemon mounts, so the dashboard can list, view, and publish documents. The status route backs legion document set-status: the dashboard’s Publish/Approve button flips a document’s lifecycle status directly, with the localhost operator session as the human gate rather than a status-machine.

The dashboard is the human control surface. Agents interact via CLI.

Project layout

src/
  main.rs              -- CLI entry point (clap); the only process::exit site
  cli/                 -- subcommand handlers (kanban, pr, document, verify, spec_gen, watch, etc.)
  db/                  -- SQLite init, migrations, domain CRUD modules (reflections, kanban, documents, audit, etc.)
  kanban/              -- CardStatus state machine and formatting
  uncertainty/         -- emit, witness, calibration, orphans
  mcp/                 -- MCP stdio server (notifier, tools, log)
  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
  spec_gen.rs          -- requirement derivation from service-design artifacts
  cluster.rs           -- cluster.toml, key management, `legion cluster`
  sync_actor.rs        -- background thread that drives delta sync
  serve.rs             -- web dashboard
  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 (legion-prime, legion-explore, legion-verify, dungeon-master)
  skills/              -- skill definitions (legion-memory, legion-simplify, legion-pr-write, legion-review, legion-verify)
  channel/             -- MCP client bridge
  worksources/         -- work source plugins