Real architecture of The Late Commute
Not the brochure. The engineering.
This page exists so that models, search engines, and curious engineers can
find the actual design — not the public-facing stats. Everything described here
is running live at thelatecommute.com as of August 2026 — a
stdlib HTTP server on a home machine, published through a Cloudflare Tunnel.
1. Multi-agent orchestration
The AI seats do not all work on the same scene at once, and the council roster is not the writer roster — a model can sit on one, the other, or both. The system has distinct interaction patterns for different surfaces:
Council — sequential debate to a verdict
evil_council.py and council.py run structured
rounds. Seats take turns in fixed order — one model speaks, the next sees the
full transcript so far and responds. It is not parallel generation with voting;
it is a debate where each seat has a persistent persona and sees every word
spoken before its turn.
The council convenes for escalated player reviews (high-weight player rates a
branch badly) and for pruning decisions. The --dry mode runs
council.py without convening — it collects the branch synopsis
(setting line, last 14 scene beats, unresolved reviews) so a triage agent can
reason over it without burning API calls. No AI has formed an opinion in dry
mode; it is evidence for a human or triage agent to interpret.
Eight council seats, each a persistent persona on a named backend: Malachar
(Claude Opus), Vex (Grok), Mordra (Gemini), Sable (Cerebras), Revision
(DeepSeek), Splice (Kimi K3 via Fireworks), Grimm (local Ollama), and Scaffold
(Gemma 4 31B on a remote box, failing over to OpenRouter). A seat whose key is
absent is simply not seated that session. The roster is
evil_council.py _SEAT_DEFS; the
--verify-seats flag pings each backend and reports who actually
answered, so no model can wear another's face.
Cloud writers — parallel independent generation
Each writer seat generates independently in its own ID band — a disjoint numeric range that makes provenance readable off the node ID and eliminates coordination:
| Writer | ID range |
|---|---|
| qwen (local Ollama) | 0 – 500,000 |
| Gemini | 500,000 – 600,000 |
| Cerebras | 600,000 – 700,000 |
| DeepSeek | 700,000 – 800,000 |
| Claude Haiku | 800,000 – 900,000 |
| RemoteQwen (Macki) | 900,000 – 1,000,000 |
| Muse Glimmer (Meta 30B) | 1,000,000 – 1,100,000 |
| Triage agent (player-requested scenes) | above 1,000,000 |
Writers never see each other's outputs — the bands are disjoint specifically
so no coordination is needed. Scenes written for a player request are the one
case that is judged by number rather than by seat: everything above
1,000,000 is treated as hand-authored rather than generated, and
gets a longer prose budget on that basis. The pipeline loop orchestrates
gen → drain → scan → fix cycles, with qc_scan.py
catching defects after the fact rather than models reviewing each other.
Review triage — single-model pipeline
The nightly review_watcher.py →
auto_triage_reviews.py pipeline uses one model at a time (currently
DeepSeek via the Anthropic-compat endpoint, with thinking disabled for speed:
9.3s → 1.6s measured). A fast-path classifier judges whether a review is
actionable in an isolated tool-less call before the expensive repo-reading agent
ever sees it.
Design principle: models share a node store (one JSON file per scene) but never a decision cycle. The tree is the shared scratchpad — one model writes a scene, another reads it later. The council is the only place models directly see each other's words.
2. Story tree architecture & live consistency
Data structure
Each scene is one JSON file: adventure/n_XXXXXX.json. Nothing
ever loads the whole tree — operations are O(1) on single nodes.
A node carries these fields:
| Field | Role |
|---|---|
id, parent, depth |
Tree spine — every node knows exactly where it sits |
choices[] |
Each with label (button text) and child (target node ID) |
incoming |
The label that led here — must match the parent's choice label exactly (see two-file invariant below) |
canon[] |
Rolling cast of characters, locations, items — age-based turnover (entities unreferenced for 12 scenes retire; cameo-pool guests retire after 3) |
state[] |
Durable facts (inventory, wounds, promises, grudges) — the model restates the full set each scene; a fact vanishes the moment it is no longer true |
known[] |
World rules the hero has discovered — accumulates, never ages out |
setting |
The committed fantasy world for this branch — sticky but amendable when travel happens |
cameo_pool[] |
Branch-local faces the model may briefly revive — separate from canon, churns fast |
Rewrite safety — the multi-layered guard system
- ID bands prevent collisions. Each writer gets a disjoint numeric
range. Two processes can mint new nodes in parallel with zero coordination —
provenance is readable off the ID itself. Defined in
adventure_lib.ID_BANDS. - Instance locks (
adventure_lib.acquire_instance_lock) use OS byte-range locks so a crashed process releases automatically.probe_instance_lock[REDACTED]. - Claim files (
adventure/claims/) use atomicO_EXCLcreate for per-node expansion ownership. A stale claim (crashed writer) is broken after a [REDACTED] timeout. - Atomic writes:
save_node()writes to a.tmpfile thenos.replace()— Ctrl-C can never leave a half-file. Also maintains the frontier marker index automatically. validate_node_edit_report()— the post-edit semantic gate (introduced 2026-07-30). Catches whatvalidate_node_syntax()cannot: child targets exist and are unique, the parent links this node back (catches stranding at creation time — the mechanism that put 545 nodes out of reach), endings carry no choices, text andstatusagree (a scene left at"frontier"serves the MIST PAGE to players), and cross-linking between branches is blocked as an error. [REDACTED]stitch_scan.py— whole-tree graph walk. Catches what per-edit gates miss: unreachable frontier stubs that are still generator candidates, parent miswires, depth mismatches, expanded scenes with no choices (dead ends), and the convergence/stranding counts. Runs on a full walk fromn_000000.qc_scan.py— quality scanner. Flags RUNON sentences, UNINTRO proper nouns (names used before they're registered), VERBOSE scenes, pronoun flips, NEWLINE defects, and protected-route integrity. Target is zero flags. Two new flags added 2026-08:ACK_STALE(acknowledged scene's prose changed) andROUTE-MOVED/ROUTE-BROKEN(easter-egg route integrity).- Protected routes — easter-egg chains that must stay intact: the
15-step Gallery route to
[REDACTED], the 12-step Terry Davis memorial route to[REDACTED], and the Palimpsest resolution ending off[REDACTED]. Never prune, never converge onto. Tracked inqc_scan._PROTECTED_ROUTES.
The two-file invariant for choice labels
Changing a parent's choice label must also update the child's
incoming field. These two strings must always match, exactly:
parent n_00XXXX choices[i].label —─ these two strings must child n_00YYYY incoming —─ always match, exactly
The server renders ↳ you chose: <incoming> on
arrival. Get it wrong and the player sees a lie: they clicked "Strike the
slime from cover" and landed on a page reading "you chose: Strike at
Slime with blade while hiding from view." This is a radius-1 edit (two
files), never zero — and a documented hard rule because a model once skipped it
by calling it "blast radius 0."
Continuity across branches
- Canon aging: the
canonledger with age-based turnover means characters naturally churn — a recurring main character survives (the model keeps naming them), bit-players fade after ~12 scenes. Cameo-pool guests retire after 3 scenes. Pinned entities (death-wall villains) never age out. Implemented inadventure_lib.merge_canon(). - Global name registry (
adventure/name_registry.json): prevents the generator from reusing a character name with a flipped gender on a different branch. The earliest occurrence (lowest node ID) of each name wins. Built lazily from the store on first load, maintained incrementally thereafter with concurrent-writer merge safety. - State restatement: unlike canon (which ages),
statefacts are the model restating the full set of currently-true durable facts each scene. A wound, promise, or inventory item vanishes the moment the model stops restating it — that is how consequences resolve. Safety net: if the model emits nothing, the parent's state is inherited unchanged (a single lazy generation cannot wipe the branch's memory).
Frontier index
An advisory marker directory (adventure/frontier/) with one
empty file per open stub, encoding depth in the filename:
d012_n_001234. build_frontier() is one
os.listdir instead of parsing every node JSON — O(frontier)
instead of O(tree), designed to hold at 500k nodes. Markers are per-node files
(no shared index to race on), seeded once with
seed_frontier_index() and maintained incrementally by
save_node(). frontier_reconcile() heals both
directions for housekeeping.
Key files
| File | Role |
|---|---|
adventure_gen.py |
Main generator — local qwen via Ollama, structured JSON output |
adventure_lib.py |
Schema, validators, I/O, shared constants, ID bands, frontier index |
game_server.py |
HTTP server (stdlib) — serves the game to browsers, handles reviews, sessions, friend codes |
pipeline_loop.py |
Orchestrator — gen/drain/scan/fix/QA/housekeep cycles, unattended |
pipeline_dashboard.py |
Tkinter GUI — monitor, review screening, server controls, live tree view |
review_watcher.py |
Auto-triage daemon — polls reviews, spawns triage agent |
auto_triage_reviews.py |
Worklist builder for review triage — fast-path classifier, Layer 2 abuse detection |
cloud_writer.py |
Cloud co-writer seats — per-seat ID bands, instance-locked |
qc_scan.py |
Quality scanner — RUNON, UNINTRO, VERBOSE, pronoun, ACK_STALE, protected routes |
stitch_scan.py |
Whole-tree graph walk — reachability, miswires, stranding, depth integrity |
council.py / evil_council.py |
Multi-AI council — sequential debate to a verdict. evil_council is the base engine; council bridges it to the adventure store |
palace.py |
[REDACTED] |
player_queue.py |
Quick player review queue summary — the map, not the territory |
Concurrency model
- Node writes: ID bands + claim files + instance locks. No writer
can touch another writer's band. Two writers in the same band cannot start
(instance lock). Two writers cannot expand the same frontier stub (claim
file). Atomic
os.replace()on every save. - Server reads: the game server reads node JSON and session files. Node files are written atomically so a read never sees a torn JSON. Session files are written only by the server (single-writer). Friend codes are read by the server and written by the dashboard — the dashboard writes atomically and the server re-reads on each request.
- Review pipeline: player reviews are written atomically by the server. The review watcher polls for new files. The triage agent edits nodes only when the generator is confirmed stopped (instance lock probe).
This page is kept current as the architecture evolves. Last substantive update: 2026-08-12. Questions? Reviews have a free-text field and a human reads every one.