Skip to content

The board

One screen that answers “what is going on”: every ticket in a lane, every running agent with its own last signal, and the questions waiting on you — the first tile on the page, and an open count in the tab label from wherever you are looking. Measured before it existed: answering that question on a real install meant opening 31 files.

Moving a ticket IS appending an event. The board is a projection of the journal, exactly like STATE.md: hand-editing it is drift, --check catches drift byte-for-byte, and the one hand-set state that exists — ticket.status — is itself an event, restricted to the three lanes no lifecycle event can derive and cleared automatically by the next stronger one. There is no second source of truth to disagree with the first.

filescopewritten by
.tyran/state/<init>/BOARD.md · board.jsonone initiativescripts/project.mjs, with the other projections
.tyran/state/BOARD.md · board.json · board.htmlevery initiativescripts/board.mjs

All are GENERATED; all live under .tyran/state/**, which the shipped policy already classes AUTO — no policy change on any install. Freshness has three layers: the conductor regenerates at every merge (iron rule 1), a SubagentStop probe re-renders after every agent (a probe — a render failure can never refuse a report), and doctor --state reports drift and absence (board-absent is info: every pre-board install has neither file, and a check that fails on upgrade day gets deleted).

A ticket is in exactly one lane:

lanederived from
donemerge
parked · waiting-operator · blockeda ticket.status override (the closed three-lane set)
waiting-operatoran open gate whose result matches waiting[-_]on[-_](operator|owner|human), carrying question, recommendation, default
blockedan open blockage (progress state: blocked) or an error naming the ticket
paused-limitan open usage-limit gate while the ticket has running agents (overnight mode)
changes-requestedthe latest review verdict is not an approval
in-reviewa report with no review yet; an approving review awaiting merge is annotated
in-progressa running agent on the ticket
readydeclared, every deps[] entry merged — an UNKNOWN dep counts unmet, because a typo must refuse to schedule
backlogeverything else declared

An ask is raised by one command, which mints its id under the journal’s write lock: node scripts/journal.mjs ask <journal> <init> --question '...' --recommendation '...' --default '...' [--ticket T-n]. The id IS the gate kind, Q-<n>. Do not park an asked ticket — the override is checked before the ask, so it overrules the lane and hides the question’s own ticket.

Terminal window
npx @jjanczur/tyran answer render --dir .tyran # writes .tyran/state/ANSWERS.md
$EDITOR .tyran/state/ANSWERS.md # fill the `answer:` lines
npx @jjanczur/tyran answer apply --dir .tyran # closes what you answered, re-renders everything

Spelled with npx because that is what an operator can actually paste: installing the Claude Code plugin puts no tyran on your PATH, and a repo that adopted the plugin has no scripts/ of its own for node to point at.

Three words do all the work. Blank takes the recorded default, verbatim, and still records it as a decision — a default accepted is a decision, and the ledger says which it was. - leaves the question open for next time. Anything else is your answer, in your words, down to the next ## .

Questions with no recorded default come first, because those are the only ones where saying nothing has no safe outcome.

apply reads every value it writes — the question, the default, the ticket, the id — back out of the journal, so nothing you type can change what was asked. One line in each block is structure rather than content: the ## Q-<n> · <initiative> heading PICKS which question your answer closes. Leave it alone — retype it to name a different ask and your answer is filed against that ask instead. It is all-or-nothing: one unparseable block and nothing at all is appended, with the line number.

Two refusals you may meet, both exit 2 and both deliberate. render will not overwrite a sheet that already holds answers you have not applied — apply them first, or pass --force to discard them and start the sitting again. And an ask raised before this feature existed has a gate kind that is not Q-<n>, so no heading can select it: those are listed read-only above the blocks, with the journal.mjs append … gate command that closes each one, so one un-answerable question can never block the rest of the sitting.

Each answer becomes two events, decision first: a decision in your words, then the closing gate with result: answered. Decision first is deliberate — a crash between them leaves a visible orphan decision, never a closed question whose answer was never written down.

ANSWERS.md is generated and then hand-edited, so it is deliberately NOT in the byte-exact --check set: byte-equality is not a property a file you type into can have. Re-render it at the start of each sitting rather than reusing the last one — every block in an applied sheet names an ask that is now closed, and apply refuses the whole file rather than appending a duplicate.

With the board served --write, every question in the Waiting on you tab carries a chat-style composer: the two canned moves — ⭐ Use the recommendation and Take the default — as chips above, the field and a round send button on one line, and Enter sends (Shift+Enter for a new line). The modifier rule this box used to enforce was retired by the operator on 2026-08-19; what survives of it is the empty-input guard, so a stray Enter on a blank box still sends nothing. Every question also wears a wait chip — how long it has been standing, turning red after a day — and its recorded DEFAULT line in bold, because “what happens if I never answer” is the sentence the queue is scanned by. A ticket sitting in the waiting-operator lane embeds the same composer in its own detail panel, so answering never requires switching tabs.

The three commands above still work, and the tab still carries them — folded into “Answer from the terminal instead”, and opened automatically on a board that cannot answer in the page (read-only, or file://), where they are not a footnote but the only route there is.

The request carries exactly two things: which ask, and your words. Everything else — the question, the recorded default, the ticket, the gate id — is read back out of the journal by the server, which is the same guarantee the sheet makes and for the same reason. The append itself is the function answer apply calls, unwrapped, so both routes produce byte-identical events: the gate is re-checked INSIDE the journal’s write lock (a question you closed in a terminal thirty seconds ago cannot be closed twice) and the decision is written before the gate.

Each card also says which KIND of question it is, and that costs no new event type to know:

decision · a default is recordedsaying nothing has a safe outcome. You may take the default and move on.
blocking · no safe defaultnobody recorded a fallback, so this one waits for you.

It is the same distinction that already makes the answer sheet sort no-default-first.

A default and a recommendation are not the same field, and the buttons behave differently because of it. The default is what happens if you say nothing: Take the default submits blank, and the server writes the recorded value verbatim as your decision, marked (default accepted). The recommendation is what the asking agent thinks you should do: Use the recommendation fills the box and submits nothing, so the wording stays yours to edit. When the two read alike on a card, that is the asking agent having written one sentence into both fields.

Try the sandbox board — the real page, rendered by this same code. Nothing to install: click the tabs, filter the lanes, select a card. Its three initiatives are the ones that built the board itself — the lanes, overnight mode and the Settings tab — so every finding, logged error and open question on it is one that actually happened while the thing was made.

Most of the time you type none of this. /tyran:setup writes a board: block with autostart: true, and from then on every session starts the board if it is not already up and prints the URL in its opening summary. The rest of this section is for turning that off, moving it, or driving it by hand.

Two ways, and the difference between them is spend.

Serve it — the whole page, spend included:

Terminal window
npx @jjanczur/tyran board --dir .tyran --detach --write # returns; prints the URL
npx @jjanczur/tyran board --dir .tyran --serve # holds the terminal instead

Both start the same server. --detach waits only until the board answers, prints board: serving http://127.0.0.1:4173/, and gives you your shell back; --serve holds the terminal until Ctrl-C. Anything that is not a person at a prompt wants --detach — a script or an agent given --serve sits on it until its timeout, which is how the setup step that starts the dashboard used to silently eat every step after it.

Running it twice is safe: --detach finds a board already serving this directory and reports it rather than starting a second one. If 4173 is taken it walks forward to 4174 and up, so several repositories can each have their own board without being told about each other. --status says where yours is, --stop ends it, and --port <n> picks a different starting point.

The board re-renders on every request, so a reload is always current, and the page reloads itself every 30 seconds anyway. It binds loopback only and pins the Host header, so nothing outside your machine can reach it, and it derives a filesystem path from no URL at all. It is read-only unless you add --write, which turns on the Settings tab and nothing else.

Open the file — no server, no spend:

Terminal window
open .tyran/state/board.html # macOS
xdg-open .tyran/state/board.html # Linux
start .tyran\state\board.html # Windows

That file is regenerated after every agent and at every merge, so it is a real answer, not a stale one. It carries everything except Spend and Settings, which are fetched rather than embedded — see Spend for why, and the same reasoning covers config. Over file:// both tabs say where their data comes from rather than rendering an empty panel; over the server, a failure says which failure it was.

Inside a session you never type either command: /tyran:status regenerates the board and tells you where it is. The three commands are the same board from three directions — --serve for a browser you keep open, the file for a quick look, BOARD.md for reading in the terminal or in a diff.

The page an operator leaves open overnight: self-contained (inline CSS/JS, zero external hosts and zero CDNs, complete over file://; the one same-origin request to loopback is amended in Spend below) and reloading itself every 30 seconds — on a timer the page can stop, not a meta http-equiv="refresh", because a browser schedules that the moment it parses the tag and removing the tag afterwards does not cancel it. A reload nothing can stop would destroy a half-typed answer or model name, so the timer is held while you are editing and re-armed when you leave.

What the reload must not cost you. The open tab lives in the URL fragment (#spend, #board), so a refresh returns to the tab you were reading rather than to Overview — which is what it used to do, twice a minute, to anyone who left the page on Spend. It is replaceState, not an assignment to location.hash: the latter pushes a history entry per reload, and Back then walks through half an hour of refreshes instead of leaving the page. The fragment is also a link — #questions opens the queue directly, on a page already open as well as on a cold load. Three things hold the timer outright: typing in an answer box, the Settings tab, and the Spend tab, whose numbers are one scan of your transcripts and do not move on their own. The lane filter holds it while it has text and releases it when cleared. Overview and Board are never held — a board that silently stopped updating at midnight is the exact failure this page exists to prevent.

The artefact itself never reads a clock — “as of” is the newest event timestamp, which keeps --check valid for the HTML too; the only clock is the viewer’s own browser, which ages the agent strip.

One scroll was answering four different questions at once, so each has a tab (each label leads with one emoji, which is what the eye finds first; the word stays the identifier). With no fragment in the URL — or one no tab answers to — the page opens on Overview. The masthead links to the people behind the project and carries a three-state theme switch — System / Light / Dark — light and dark renderings of one warm palette, following the OS until you choose, the choice stored per browser and applied before first paint:

tabwhat it answers
Overviewis anything on fire — four tiles (waiting on you · agents running · progress · needs a human), any STOPPED or PAUSED banner, the agent strip with its signal-freshness colours, a spend headline once spend has loaded, the errors agents logged, and the UNREADABLE list
Boardwhere every ticket is — the lanes, and the detail of whichever card you select
Waiting on youwhat is blocked on a decision, whether it has a safe default, and — with --write — a box to answer it in
Spendwhat the work has cost
Settingswhat Tyran is configured to do, and — with --write — how to change it

The queue’s tab label carries its open count, because the operator is usually looking at one of the other three, and a question they cannot see is a question they do not answer.

Every lane card is a <button> carrying aria-pressed, and selecting one opens a detail panel under the lanes: the lane, the initiative, the agents on the ticket, the board’s own annotation, and — once the spend fetch has landed — that ticket’s tokens and cost. The panel also tells the ticket’s own story: a timeline of its lifecycle facts (spawned, reported, reviewed, merged — oldest first, with the merge sha one click from the clipboard), the initiative’s documents openable in place, and jump buttons to the blocking question on the Waiting tab and to Spend. The card stays a summary. What does not fit goes into the panel rather than into a wider card, which is what a grid of lanes cannot afford.

An initiative row is a button too, and selecting one opens the same kind of panel under the list: its directory, its state, tickets merged, findings, when it last moved, and the documents it actually has. Selecting a document reads it from the server and renders it in the panel — headings, lists and code blocks, built with createElement/textContent only. These files are written by agents out of reports about other repositories, so structure is all they are allowed to become: nothing in a document turns into a link, an image, raw HTML or anything that runs — a URL stays a string you can read and copy.

The route behind that is GET /doc.json?init=…&name=…, and it takes no path. name must equal a member of a fixed five (PLAN.md, NOTES.md, RETRO.md, STATE.md, PROGRESS.md) and init must equal a directory state/ actually contains — nothing from the request is joined into a path until it has matched one of those two lists, so traversal is refused by never being a candidate rather than by a filter. Content is invisible-escaped like every other journal-side value that reaches a human, and capped at 200 000 codepoints with the cut declared. Reading is not behind --write: that flag draws its line at changing the repository.

Finished is a fact, not a percentage. A row says FINISHED when a checkpoint declared the initiative’s phase closed — the same event that closes its still-open spawns, and the only one in the closed set that ends an initiative. Finished rows sort last and drop out of the “waiting on you” count. Note what it does not claim: no journal event records a deployment, so the board never says one happened. An initiative sitting at 100% with no closing checkpoint is work that stopped, not work that ended — and telling those two apart is the whole point of the flag.

The panel also carries the two facts the lane could only imply and an Execution table:

rowwhat it answers
reviewthe verdict, who wrote it, and when. changes-requested used to name a lane and never a reviewer
mergedthe commit, the merge mode, and when. done said a ticket shipped without saying what shipped
Executionone row per run: agent, model, when it started, how it ended

All three come from journal events that already existed — review carries ticket/verdict/by, merge carries ticket/sha — and reached no surface at all. Measured across 387 real journals and 1799 cards: 1243 carried a merge sha and 1074 a review verdict that nothing displayed.

Execution is the half of “what did this cost me” that Spend cannot answer: Spend gives dollars per ticket, this gives what was actually spawned to earn them — three implementers on one ticket, or the same story reopened on a bigger model, read down a column.

The Waiting on you tab prints the three commands of Answering above the queue, and states the three answer words with them. It is the one thing on this page an operator cannot derive from what is on the screen: the questions are visible, the way to close them is not.

The styling is the landing page’s system in a muted key. The roles are unchanged — --brass marks the one call to action (your queue), --steel lights the agent strip, --clay is refusal (the PAUSED banner, the blocked lane), --sage is the ledger’s + (done) — but the tones are pulled back from the first version, which filled whole bars and whole cards at full saturation and so read as an alarm rather than as information. An alarm colour on every surface leaves nothing louder for the one card that IS an alarm. Saturation is now spent on text, edges and 3px rails, and every large fill is a muted tone of the same hue.

Hostile journal values cannot break the page: board.json is invisible-escaped, the embedded copy additionally escapes <, and the client builds DOM with createElement/textContent only.

Terminal window
node scripts/board.mjs --dir .tyran # render all three artefacts
node scripts/board.mjs --dir .tyran --check # byte-exact drift check, exit 1
node scripts/board.mjs --dir .tyran --serve # localhost viewer, holds the terminal
node scripts/board.mjs --dir .tyran --detach # ... same server, returns instead
node scripts/board.mjs --dir .tyran --status # where is it? exit 1 if nowhere
node scripts/board.mjs --dir .tyran --stop # end the running one
node scripts/board.mjs --dir .tyran --serve --write # ... and Settings can edit
node scripts/board.mjs --dir .tyran --serve --transcripts <dir> # ... and Spend reads THAT dir

--serve binds 127.0.0.1 only, re-renders per request, and never derives a filesystem path from a URL — including /doc.json, which takes an initiative and a file NAME and matches both against fixed lists; --write adds the Settings routes and nothing else, and refuses on its own (--write without --serve or --detach is a usage error, not a silent no-op). --transcripts <dir> is the same refusal, for the same reason: it means something only to /cost.json, which only exists under a server. Repeatable, and it overrides spend.transcript_dirs in .tyran/config.yaml — see Spend. The initiative ceiling is 64, refused loudly — archive closed initiatives rather than boarding them. An unreadable journal renders as a visible UNREADABLE entry: a board that omits a broken initiative would read as “all is well” exactly when it is not.

Starting itself, and how it knows it already has

Section titled “Starting itself, and how it knows it already has”

The board: block in .tyran/config.yaml — written at setup, editable on the Settings tab — decides this. It defaults ON:

board:
autostart: true # start with the session if not already up
port: 4173 # first port tried; the next 9 are used if it is taken
write: true # the Settings tab can edit config.yaml and the policy
open: false # true also launches a browser on every session

TYRAN_NO_BOARD=1 in the environment outranks all of it, for the case the per-repo setting cannot cover: CI. An automated run clones a repository whose committed config says autostart: true, has no browser and no operator, and a detached server started by a build outlives the build.

“Already up” is an HTTP question, never a recorded pid. The running server answers /health.json with the .tyran directory it serves, and a caller believes it only if that directory is its own. Both halves are load-bearing: a pid recorded by one process and read by another is dead before it is read and, once the OS recycles the number, names a stranger — while a check that only probed the port would see a different repository’s board (same program, same route) and conclude this repo’s board was already running, so it would never start. The port is recorded at state/board-server.json as a hint for --stop; it is gitignored, because a pid and a port name a process on one machine and a stranger on every other.

The Spend tab shows what the work cost: total tokens and requests, the amount under the named rate card, the conductor’s share of tokens, how many agents carry a ticket id, a composition bar across input / cache write / cache read / output, and three ranked charts — by model, by agent type, by ticket — with a toggle between tokens and cost. A row whose models have no rate draws no bar at all, because “not priced” and “cost nothing” must not look the same. What the numbers mean, and what they do not, is in the spend ledger.

Spend is fetched, not embedded, and that amends the description above. board.html requests cost.json from board.mjs --serve and builds the tab in the browser. Spend is derived from transcripts under the operator’s home directory — machine-local, different in every clone — so writing it into board.json would break the byte-exact --check contract and make two people with one journal disagree. Opened over file:// there is no server, the request fails, and the tab says where spend comes from rather than rendering an empty panel that reads as broken. The two places spend appears outside that tab — the Overview headline, and a selected card’s own tokens and cost — come from the same fetch and simply never appear; the board is complete without them.

The page is therefore still zero external hosts and zero CDNs. What it makes is one same-origin request to loopback, and the same defences cover it: the /cost.json route sits behind the same Host pin as everything else on --serve (a foreign Host gets 403, verified), and a cost read that fails returns 503 rather than taking the board down — the board answers “what is going on”, and that answer does not depend on knowing what it cost.

When the resolved directory is the wrong one. Both of cost.mjs’s heuristics for finding a repo’s transcripts assume the conductor session ran from the repo it operates on. A conductor started from another working directory — Desktop opened in a sibling folder, working the repo through absolute paths and worktrees — breaks that assumption, and the tab still renders: honest numbers, about the wrong session. The tell is zero agent transcripts while the board itself lists running agents, and the tab shows a hint for it, naming the two overrides: start this server with --serve --transcripts <dir>, or set spend.transcript_dirs in .tyran/config.yaml. Details and the measured failure are in the spend ledger.

The Settings tab is the only thing on this page that writes. Everything else is a projection of the journal, where hand-editing is drift; config and the autonomy policy are the opposite — operator-owned files that nothing generates, and until this tab existed the only way to change them was an editor and a memory of what each key meant.

Terminal window
npx @jjanczur/tyran board --dir .tyran --serve --write

Without --write the tab still renders, every control disabled, with the command that turns it on. Writes are off by default and per-invocation: a board left running is reachable by anything on the machine, and the difference between reading that and editing the autonomy policy through it is the difference this flag exists to make.

What it edits, with a sentence of prose on every knob — and, folded under each row, a “What does this change?” explainer: what the setting is, what changing it does, how it lands on Tyran, and (where loosening is possible) what loosening costs, stated before the confirm dialog restates it as a question:

.tyran/config.yamlcost profile · deployment autonomy · the four model tiers · validation commands · shared zones · the whole boundaries: block · the whole limits: block
.tyran/policies/autonomy.yamlthe default class, and the class of any existing rule

One line moves. The file is not round-tripped through a serializer — scripts/yaml-patch.mjs finds the line that owns the value and rewrites that line, keeping its trailing comment, the blank lines, the key order and your own spacing. templates/config.yaml is 63 comment lines out of 90, and those comments are the only place anyone is told that bare off is the YAML boolean false; a serializer round-trip would delete all of it silently the first time someone moved a control.

The edit is proved, not trusted. Every patch is applied and then parsed back and compared to the document that was intended: the target holds the new value, and every other path holds exactly what it held before. Then the result goes through the same validateConfig / validatePolicy that node scripts/schema.mjs validate runs. If either check fails, nothing is written and the page says why. A refused change leaves the file byte-identical.

The boundary still protects itself. hooks/** and .tyran/policies/** are rendered as locked KERNEL rows with no control at all, and the write route refuses them twice over — once by name, and once because validatePolicy rejects any policy in which a kernel path resolves below KERNEL, however the rule is spelled. A screen that could lower the class of its own enforcement would be the way around the gate rather than the way to configure it.

Loosening takes a second, deliberate act. Tightening does not. Those two protected globs are the only ones the validator defends, and the shipped policy carries several more whose own stated reason is that they must not move — .claude/settings.json (“anything that can edit it can switch every gate off”), .tyran/STOP (“a loop that can clear its own stop signal has none”). A dropdown treats those like any other choice, so:

  • moving a class toward AUTO, raising autonomy (P1 → P2 → P3), or turning any boundaries: flag toward its loose value (including preset: open and prompts: skip) is refused on the first request. The refusal quotes the rule’s own reason: and names a confirmation token; the page turns that into one extra Yes — loosen it press, and the second request carries the token.
  • the token is the new value itself, not a boolean, so nothing can widen a boundary by sending a truthy flag beside whatever value it liked.
  • tightening applies on the first click, exactly like every other setting. Friction on making a boundary stricter is how you teach someone to stop making boundaries stricter.

validation: is not in that set, deliberately. It is a list of shell commands and it looks like it belongs — but .tyran/config.yaml is class AUTO in the shipped policy, so an agent can already edit it directly. A confirmation on the page would add friction without adding a boundary; tighten that rule instead, on the same screen, if the trade is wrong for your repo.

What it deliberately will not do: create a key that is not already in the file (absent knobs render as “add it by hand once”), author or delete a policy rule (a boundary’s written reason is load-bearing, and a one-line reason typed into a form is how a policy turns into a list of unexplained globs), or edit pricing: (a rate card is a table copied from a vendor’s price list).

Three routes write, and one flag turns all of them on. POST /settings/config, POST /settings/policy and POST /answer — answering a question and editing the autonomy policy are different acts, but they are the same decision for the operator: whether this board may change the repository at all.

What guards the routes, stated exactly. The Host pin that covers the whole server, plus an Origin check and a required application/json content type — together those close the browser paths: a page on another origin cannot read the board, and its POST is preflighted and refused. What they do not do is stop another process on your machine that can already reach loopback; nothing served on localhost can. The flag is the control that matters there, and the KERNEL invariant is what holds even if the flag is on. Every write also prints a line to the terminal running the server, and lands in git diff — those two are the audit trail.

Three agent chips reading “6 HOURS since last signal — likely dead” cover four completely different situations, and for a long time the board could tell them apart in none of them: an operator wrote .tyran/STOP, overnight mode paused on a usage limit, the resume watcher died, or the agents really are gone.

A STOP is now on the board, above everything else — **STOPPED** in BOARD.md, a banner on the page, carrying the first line of the file as its reason. It is committed repo state, identical in every clone, so it travels in the artefact rather than behind a route. Note what it does and does not mean: nothing new starts while it exists, and agents already running are not killed by it.

And whether a watcher is still breathing. STOP is committed, so it travels in the artefact. The rest of “is this supposed to be running” is machine-local and gitignored — the pause marker, the resume watcher’s pid, the usage sidecar — so --serve answers it at /run.json instead, on exactly the argument Spend makes. The page turns it into one banner above the tiles when any of three things is true: a usage-limit pause is live, a pause is overdue to resume and has not, or the resume watcher is not running. A damaged or missing file reads as absent: this answers a question about the run, and it must never be the reason nobody can see the board.

Cards say how long they have stood still. The board would tell you an agent had been silent for three hours and refuse to tell you a ticket had been blocked for four days — the timestamp was in board.json all along and nothing rendered it. Now every card in a lane where standing still IS the defect (blocked, changes-requested, in-review, waiting-operator, in-progress, paused-limit) carries its age, in the same four colours the agent strip uses. The other four do not, for the same reason in four forms: backlog and ready hold tickets waiting their turn rather than stalling, parked holds one you deliberately set aside, and done holds one that is supposed to have stopped moving. Marking all of those stale makes the mark worthless where it means something.

Both are worded as observations rather than verdicts. The board reports the last event on a ticket; only you know whether that is a stall or a long test run.

Nine open questions sorted by age put the one gating six tickets wherever it happened to fall. deps is resolved FORWARD everywhere else — a ticket is ready when its dependencies are merged — and the reverse direction was never computed at all.

Each ask now carries blocks: {count, ids}: the tickets that cannot proceed until it is answered, walked transitively, skipping tickets already merged (nothing holds those up). The queue sorts by it, and so does the answer sheet, because those are two renderings of ONE queue and an operator should not meet a different order depending on which surface they opened.

Two deliberate limits. No-recorded-default still sorts first, above any amount of downstream work: those are the only questions where saying nothing has no safe outcome. And an initiative that declares no dependencies at all reports blocks: null rather than 0 on every ask — an absence rendered as a measurement is worse than saying nothing.

The agent strip aged on progress events, which an agent emits at will. An agent looping without achieving anything therefore had the freshest chip on the board, and — because the strip is sorted stalest-first — sorted to the bottom.

Stale is a third thing, and it does not share their vocabulary. The four colours above are wall-clock: “how long since it spoke, right now, where you are sitting”, computed in the reader’s own browser because the artefact may not read a clock. Stale is journal time, decided on the server by the same predicate doctor uses — “the initiative moved on without it” — and it reads the same tomorrow, and in six months, for every reader of that journal. An agent can be quiet for hours without being stale, and stale while chattering every minute. So it marks the whole chip in brass rather than joining the age scale: two questions sharing one vocabulary is how an operator ends up with two contradictory answers on one strip.

The header tile counts the live ones and names the rest. It used to read “N agents running” for every open spawn, which made a fleet that died before you went to bed indistinguishable from one working through the night. The stale agents are still counted, still in the strip, and now said out loud. The same correction reaches BOARD.md, whose headline says open rather than running, with the stale count beside it.

Every agent carries three times. since is when it was spawned. last_signal is what it SAID, from its own progress events — and it is null until it says something. last_evidence is what it SHOWED: a report with its evidence[], a finding with its proof, a review verdict. The strip sorts on evidence, and an agent that has shown nothing ages from its SPAWN rather than from the last thing it said — otherwise the chatty agent outranks one that produced something twenty minutes ago, which is the exact inversion this split exists to correct.

last_signal fell back to the spawn time until 0.1.36, and that was a wrong number rather than a missing one. Measured over 420 real journals: 72 running agents on boards, last_signal === since for 72 of 72 — never once an actual signal — with detail, next and state: "blocked" at zero across the same set. The chip therefore printed “N min since last signal” about agents that had never signalled, and the golden fixture missed it because its one agent does signal: the case that is universal in the wild was the case no fixture covered. The age is still shown, because an agent open for six hours is worth flagging — it now says “since it started”, which is what it measures. agents/implementer.md asked for four progress emissions per story and got one across 388 journals; it now asks for one, at a blockage, which is the only one of the four that carries something no other event can reconstruct.

There is deliberately no doctor finding for it. The threshold that separates “quiet because the work is hard” from “quiet because nothing is happening” is not one this project can pick for every repo, and a warning that fires on healthy agents is a warning people learn to scroll past.

Fixed key order (schema, as_of, totals, stop, paused, asks, agents (each with last_signal, last_evidence, since, and stale / open_hours in journal time), files, lanes, errors_logged, errors_logged_total, errors, warned); timestamps copied verbatim from events; invisibles escaped as \uXXXX, never removed; byte-identical reruns. Consumers check schema === 1 — the HTML shows “regenerate with a newer Tyran” on a mismatch instead of rendering garbage.

Two of those keys are easy to confuse, so they are named apart on purpose. errors is the list of initiatives whose journal could not be read at all. totals.agents counts OPEN SPAWNS, and totals.agents_stale says how many of those the journal has moved on without. The page shows the difference as its headline and names the remainder; adding the two back together is the reading the split exists to prevent.

errors_logged is what agents recorded as failures with an error event — newest first, capped at 20 with the true count in errors_logged_total, because this artefact is committed and compared byte for byte. One key with two meanings is the defect ADR-21 is named after.

Nothing in the payload is machine-local. stop carries {stopped, reason} and deliberately not the path checkStop also returns: an absolute path would make two clones of one journal disagree on a machine whose home directory is spelled differently, which is the same rule that keeps spend served rather than embedded.