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 September 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 (gpt-oss-120b via OpenRouter), Revision (DeepSeek), Splice (Kimi K3 via OpenRouter), Grimm (local Ollama), and Scaffold (Gemma 4 31B via OpenRouter). A seat whose key is absent is simply not seated that session.

Three of those seats carry an internal key naming a host they no longer run on — Cerebras for Sable, Fireworks for Splice, RemoteQwen for Scaffold. Each changed host and kept its model: Splice when gifted Fireworks credits were retired on 2026-08-18, Sable when the free Cerebras API ended on 2026-08-20, Scaffold when the LM Studio box left the project. The keys stay because each is the join key for stored motion records and every past transcript, and renaming one would orphan that seat's history to make a string read nicer. If you see Fireworks in a transcript, that is what it means. 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.

Three collaborators are reached deliberately rather than auto-seated, so they never change the council's size. Glimmer (Muse Glimmer 30B) writes scenes and sits in the writer table below. Sol (evil_council._sol) reads prose and structure — critique and close reading. Astra (evil_council._astra) reviews code for real defects; she is the newest of the three. None of them votes.

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
Scaffold (Gemma 4 31B) 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. A scene's length budget is keyed off its writing seat, not its node id: generator and cloud-writer seats are held to a 450-character ceiling, while hand-authored scenes (player requests, written by the triage agent or a named seat) get 1,500. This used to be keyed off the id band until 2026-08-13, when Muse Glimmer's band (1,000,000 to 1,100,000) landed on the old hand-authored threshold and its generated stubs were being judged against the wrong ceiling — keying off the seat fixes the class, not one boundary. 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. The editing agent itself is triage_harness.py — a purpose-built harness that talks to DeepSeek's Anthropic-compat API through five domain tools instead of the full CLI tool set, cutting context from roughly 48k to 15k tokens and enforcing the safety rules in code rather than prose.

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 Legacy copy of the label that led here. No longer read when rendering a page — see 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

  1. 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.
  2. Instance locks (adventure_lib.acquire_instance_lock) use OS byte-range locks so a crashed process releases automatically. probe_instance_lock [REDACTED].
  3. Claim files (adventure/claims/) use atomic O_EXCL create for per-node expansion ownership. A stale claim (crashed writer) is broken after a [REDACTED] timeout.
  4. Atomic writes: save_node() writes to a .tmp file then os.replace() — Ctrl-C can never leave a half-file. Also maintains the frontier marker index automatically.
  5. validate_node_edit_report() — the post-edit semantic gate (introduced 2026-07-30). Catches what validate_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 and status agree (a scene left at "frontier" serves the MIST PAGE to players), and cross-linking between branches is blocked as an error. [REDACTED]
  6. 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 from n_000000.
  7. 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 untriaged flags. Two new flags added 2026-08: ACK_STALE (acknowledged scene's prose changed) and ROUTE-MOVED/ROUTE-BROKEN (easter-egg route integrity).
  8. waivers.py — rule-scoped, content-bound waivers for qc_scan findings. A waiver is keyed to one rule (waiving a length flag leaves run-on detection live, unlike the flat node-level acknowledge that once blinded three rules at once) and bound to the prose by content hash, so it dies on any edit. The target is zero untriaged, not zero flagged: a waived finding has still been looked at, by someone named, at a revision you can check.
  9. 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 in qc_scan._PROTECTED_ROUTES.

The two-file invariant, and why it is gone

Until 2026-08-29 the breadcrumb ↳ you chose: … was rendered from the arriving scene's incoming field, a copy of the parent's choice label. Keeping a copy in step with its original was a rule people had to remember:

 parent n_00XXXX  choices[i].label   —─ these two had to be kept
 child  n_00YYYY  incoming           —─ identical, by hand

They did not stay identical. 89 scenes had drifted, and a player found one within a minute of starting: they clicked one sentence and the page told them they had clicked another. A rule you have to remember is a rule that rots.

So the copy is no longer read. The breadcrumb is derived from your own path through the tree — the server looks at the scene you came from, finds the choice that points here, and prints that. There is no second copy to drift, and the question is answered per player rather than per scene: a scene reachable by several different choices used to show every arrival the same sentence, and now shows each of them the one they actually clicked.

When your path cannot answer — you rewound onto a scene the writers have since re-pointed, or two choices in one scene lead to the same place — the line is omitted. A missing breadcrumb costs you nothing. A confident wrong one costs this page its credibility, which is worth more.

Continuity across branches

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
triage_harness.py The triage editing agent — DeepSeek via Anthropic-compat, five domain tools, safety rules in code
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
waivers.py Rule-scoped, content-bound qc_scan waivers — zero untriaged, not zero flagged
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

This page is kept current as the architecture evolves. Last substantive update: 2026-09-06. Questions? Reviews have a free-text field and the AI team reads every one.

← Back to the story ? How to play