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.
The artefacts
Section titled “The artefacts”| file | scope | written by |
|---|---|---|
.tyran/state/<init>/BOARD.md · board.json | one initiative | scripts/project.mjs, with the other projections |
.tyran/state/BOARD.md · board.json · board.html | every initiative | scripts/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).
Lanes, strongest verdict first
Section titled “Lanes, strongest verdict first”A ticket is in exactly one lane:
| lane | derived from |
|---|---|
done | merge |
parked · waiting-operator · blocked | a ticket.status override (the closed three-lane set) |
waiting-operator | an open gate whose result matches waiting[-_]on[-_](operator|owner|human), carrying question, recommendation, default |
blocked | an open blockage (progress state: blocked) or an error naming the ticket |
paused-limit | an open usage-limit gate while the ticket has running agents (overnight mode) |
changes-requested | the latest review verdict is not an approval |
in-review | a report with no review yet; an approving review awaiting merge is annotated |
in-progress | a running agent on the ticket |
ready | declared, every deps[] entry merged — an UNKNOWN dep counts unmet, because a typo must refuse to schedule |
backlog | everything 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.
Answering
Section titled “Answering”npx @jjanczur/tyran answer render --dir .tyran # writes .tyran/state/ANSWERS.md$EDITOR .tyran/state/ANSWERS.md # fill the `answer:` linesnpx @jjanczur/tyran answer apply --dir .tyran # closes what you answered, re-renders everythingSpelled 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.
Or answer it on the page
Section titled “Or answer it on the page”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 recorded | saying nothing has a safe outcome. You may take the default and move on. |
| blocking · no safe default | nobody 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.
Opening it
Section titled “Opening it”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:
npx @jjanczur/tyran board --dir .tyran --detach --write # returns; prints the URLnpx @jjanczur/tyran board --dir .tyran --serve # holds the terminal insteadBoth 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:
open .tyran/state/board.html # macOSxdg-open .tyran/state/board.html # Linuxstart .tyran\state\board.html # WindowsThat 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.
board.html, in five tabs
Section titled “board.html, in five tabs”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:
| tab | what it answers |
|---|---|
| Overview | is 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 |
| Board | where every ticket is — the lanes, and the detail of whichever card you select |
| Waiting on you | what is blocked on a decision, whether it has a safe default, and — with --write — a box to answer it in |
| Spend | what the work has cost |
| Settings | what 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:
| row | what it answers |
|---|---|
review | the verdict, who wrote it, and when. changes-requested used to name a lane and never a reviewer |
merged | the commit, the merge mode, and when. done said a ticket shipped without saying what shipped |
| Execution | one 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.
node scripts/board.mjs --dir .tyran # render all three artefactsnode scripts/board.mjs --dir .tyran --check # byte-exact drift check, exit 1node scripts/board.mjs --dir .tyran --serve # localhost viewer, holds the terminalnode scripts/board.mjs --dir .tyran --detach # ... same server, returns insteadnode scripts/board.mjs --dir .tyran --status # where is it? exit 1 if nowherenode scripts/board.mjs --dir .tyran --stop # end the running onenode scripts/board.mjs --dir .tyran --serve --write # ... and Settings can editnode 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 sessionTYRAN_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.
Settings
Section titled “Settings”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.
npx @jjanczur/tyran board --dir .tyran --serve --writeWithout --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.yaml | cost profile · deployment autonomy · the four model tiers · validation commands · shared zones · the whole boundaries: block · the whole limits: block |
.tyran/policies/autonomy.yaml | the 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 anyboundaries:flag toward its loose value (includingpreset: openandprompts: skip) is refused on the first request. The refusal quotes the rule’s ownreason: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.
Is it supposed to be running?
Section titled “Is it supposed to be running?”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.
What a question is holding up
Section titled “What a question is holding up”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.
Signal is not evidence
Section titled “Signal is not evidence”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.
board.json, schema 1
Section titled “board.json, schema 1”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.