Projections reference
scripts/project.mjs · 85 unit tests, including byte-exact golden files
The journal stays the only source of truth; everything on this page is a disposable view of it.
Humans do not read JSONL. scripts/project.mjs folds a journal into two
Markdown documents that are generated, never hand-written:
| File | Purpose |
|---|---|
STATE.md | full state: checkpoint, agents, ledger, gates, leases, decisions, errors, resume steps, journal integrity |
PROGRESS.md | one-line progress banner, ticket checklist, open gates, milestones |
BOARD.md · board.json | the kanban view — lanes, the waiting-on-operator queue, the agent strip; semantics in the board page |
Both start with
<!-- GENERATED by tyran scripts/project.mjs - DO NOT EDIT. Source of truth: journal.jsonl --><!-- Regenerate: node scripts/project.mjs <journal.jsonl> --out-dir <dir> -->Edits to these files are lost on the next projection — and --check will
report them as drift before that happens.
Spend is deliberately not a projection
Section titled “Spend is deliberately not a projection”The spend ledger is the one report on this state layer that is not
rendered from the journal and never will be. It reads the transcripts Claude
Code writes under the operator’s home directory: machine-local inputs that
differ per clone, so two people with the same journal legitimately get
different numbers. A projection promises the opposite — same bytes in, same
bytes out — so adding cost.json to the byte-exact --check set would turn a
correct answer into reported drift. It is therefore never committed, never
byte-compared, and never merged into board.json.
node scripts/project.mjs <journal.jsonl> [--out-dir <dir>] [--check]--out-dirdefaults to the journal’s own directory (.tyran/state/<initiative>/).--checkwrites nothing. It re-renders from the journal and compares the result byte for byte with what is on disk: exit0when the projections are current, exit1with a per-file summary (how many lines differ and where) when they drifted or are missing.- Exit codes match
journal.mjs:0ok ·1drift (--checkonly) ·2usage or I/O error (unknown flag, missing journal file, a journal path that is a directory, a file that is not a journal at all). - A flag given twice (
--out-dir a --out-dir b) is refused rather than silently resolved — that is how a projection ends up in the wrong place.
Because the comparison is byte-exact, trailing whitespace, CRLF endings, a
byte-order mark and a missing final newline all count as drift. That is
deliberate: a hand-edited projection is exactly what --check exists to
catch.
Guarantees
Section titled “Guarantees”- Deterministic. The same journal bytes always render the same output
bytes. Nothing reads the clock: every timestamp in the output is copied
verbatim from an event. Tables are sorted explicitly — tickets and
decisions in natural order (
T-2beforeT-10), agents by name then spawn time, gates and leases by key — with ties keeping journal order. - Idempotent. Re-running on an unchanged journal rewrites identical
files, so
project.mjstwice in a row is a no-op for git. - Atomic. Both documents are staged as temporary files first and only then renamed into place, so a concurrent reader (or a second projector) never observes a half-written document, and a failure part-way through leaves the previous projections completely untouched. The pair is still not one transaction: the exposure window is the gap between two renames.
- Crash-tolerant. A truncated final line (crash mid-write), corrupt
lines mid-file, lines that parse to something other than an object, and
unknown event types are all skipped, counted and reported on stderr —
never fatal. A projection over a partially damaged journal still exits
0; judging journal health isdoctor’s job, not the renderer’s. - A non-journal is refused, not projected. When not one event could be
read and the file showed any damage — corrupt lines, non-object lines,
or a truncated final line —
project.mjsexits2and writes nothing: a mistyped path must not replace a goodSTATE.mdwith an empty one under a success code. A one-line file with no trailing newline counts here too (that is how a token file or minified JSON arrives), and so does a journal whose very first append died mid-write. An empty journal — no events and no damage at all — stays legal and projects empty documents with exit0. - Nothing vanishes silently. Every type in the closed event set folds
into a rendered section (a unit test asserts this against
EVENT_TYPES). Events that arrive without their required keys — amergewith noticket, a lease with noresource— land under a visible(no ticket)/(no resource)placeholder instead of being dropped. A section with no data renders as_none_; sections never disappear, because a missing section is indistinguishable from a missing feature. - Untrusted
datacannot break the document. In a plugin,datais written by agents processing someone else’s repository, so every value rendered inline is collapsed to a single line, length-capped and escaped:|,`,\— cannot break a table or its columns;<,>— cannot inject HTML or forge theGENERATEDheader;[,]— cannot form Markdown link or image syntax. Without this anin a journal value renders as a remote<img>in GitHub or VS Code: a read beacon plus an exfiltration channel carrying journal text in the URL;- C0/C1 controls, DEL, zero-width characters, direction marks, bidi embeddings, overrides and isolates, and the BOM — all collapse to a space. An unterminated right-to-left override would otherwise mirror every following column and let a journal value rewrite the document a human is reading (Trojan Source).
How state is derived
Section titled “How state is derived”| Section | Built from |
|---|---|
| Checkpoint | the last checkpoint event (+ plan.accepted, and the last event of any type) |
| Agents | spawn, closed by the next report for the same agent name — or by a checkpoint whose phase is closed; the Last signal column is the agent’s latest progress event, shown on running rows only |
| Open blockages | progress with state: blocked — cleared by the same agent’s next movement signal and by report/review/merge |
| Findings | finding events, in journal order — with the command and exit_code behind each claim, where one was recorded |
| Ledger | ticket.created, ticket.status (a lane override that never moves the percent, and never invents a ticket — one naming an id no other event created is ignored, loudly), spawn, report, review, merge |
| Gates | gate — the latest event per kind, plus the last REFUSAL that kind ever recorded |
| Leases | lease.acquired / lease.released |
| Decisions · Retro · Errors | decision · retro.entry · error |
| Resume steps | checkpoint.data.next_steps |
What the fold no longer throws away
Section titled “What the fold no longer throws away”Four things the journal recorded and the projection dropped. Each was a question an operator asked the board, got a confident answer to, and the answer was wrong in the same direction: everything looked fine.
An agent the initiative moved on without is stale, not running.
Nothing here ever downgraded a spawn, so an agent that never reported stayed
running in every artefact for as long as the journal survived — and the
board’s header counted week-old ghosts as live work. Doctor had been calling
them abandoned the whole time, from the same events, which is two answers to
one question. There is now one predicate, journal.spawnStaleness, and both
call it.
The threshold is measured in journal time — against the initiative’s own
latest event, not the wall clock. That is what spawn-stale has always meant
(“the initiative moved on without it”) and it is the only version that keeps
board.json byte-exact under --check: a wall-clock verdict would make two
clones of one journal disagree, and would call every agent in a finished
initiative stale the moment you walked away from it. An agent carries
stale and open_hours alongside its state; blocked still outranks
stale, because that is the agent’s own account of why it stopped.
The age-fresh/warm/cold/dead colours on board.html are a different
question and keep their own vocabulary — see
the board.
A closing checkpoint closes the spawns it leaves open. No event type
closes an initiative, so one could be explicitly wound up with three agents
that never reported still running forever. A checkpoint whose phase is
closed — the one reserved value in an otherwise free-text field, compared
trimmed and case-insensitively — now closes that initiative’s still-open
spawns at fold time. Only the ones folded before it: an agent spawned after
the checkpoint is a new incarnation and keeps its own lifecycle. They are
closed, never reported: no verdict is invented for an agent that never filed
one, and each is named in the warnings rather than tidied away.
A gate that passes after refusing does not erase the refusal. Gate results
are keyed by kind, so a re-run won the slot — and “security denied this,
then someone re-ran it green” rendered identically to “security has only ever
passed”. The event count always survived, so the volume was never lost, only
the verdict. The last refusal is now kept beside the current result and shown
in its own column. A refusal is a named set of results (deny, fail,
rejected, …), not “anything that is not a pass” — open and
WAITING_ON_OPERATOR are gates that have not answered yet, and a mark that
fires on every pending gate says nothing at all.
A report carries what the agent said, not only its verdict. decision
folds its text and gate folds its evidence; a report was the one
carrier of description that wrote to nowhere, so a changes-needed card
reached the board with the reason it came back silently discarded. One field
reads text, then note, then evidence — agents improvise the key, and
three near-synonyms rendered as three rows would be the same answer in three
places.
Two details worth knowing:
- Agent pairing is
journal.pairSpawns, and nothing else. Areportcloses the oldest still-openspawnof the same agent name, and the projection does not compute that answer itself — it renders the one the journal gives. This used to be two implementations with two different rules (this file preferred a spawn on the same ticket,pairSpawnspaired oldest-first and excluded unusable names outright), soSTATE.mdanddoctorcould describe the same journal differently. ADR-21 settled it: one answer, not one function.- The pairing stays unambiguous because ADR-18 has
journal.appendenforce at most one open spawn per agent name — chosen over aspawn_idthat callers would have to carry across a process boundary. - When a journal nonetheless holds two open spawns of one name (only a
hand-edited file can), the pairing is oldest-first and it says so:
project.mjsprintsspawn/report pairing for this name is ambiguouson stderr. The old ticket-first rule guessed silently instead, and it existed for journals written before ADR-18 — a population measured at zero before it was removed. - A
spawnwhosedata.agentis not a usable correlator (invisible characters, not NFC, empty) is not rendered as running and not dropped: it appears asunusable agent name (excluded from pairing), with a warning. Silently omitting it would hide an agent that may still be working; showing it as running would repeat the false state picture ADR-18 calls worse than no picture. - A spawn with no matching report stays visible as running (no report
yet) — an agent that died mid-story must not quietly disappear from
STATE.md.
- The pairing stays unambiguous because ADR-18 has
- Invisible characters are SHOWN, never dropped — in every channel.
A journal value carrying a bidi override, a zero-width mark or a TAG
character is rendered as its escape notation (
<U+202E>), not deleted. That applies toSTATE.md, toPROGRESS.mdand to the warningsproject.mjswrites to stderr — stderr is an output channel, and it was the one that leaked: a journal whoseinitandevcarried an override plus 18 TAG characters put 37 invisible codepoints on the operator’s terminal, spelling text nobody could see, while the documents were clean.- Why shown and not removed. Silent removal made a poisoned value and a
clean one render identically:
inline("deploy ok" + <29 TAG characters>)returned exactlydeploy ok, with nothing anywhere reporting a removal. ADR-19 is explicit that an exclusion must never be silent, andinline()already signals its other losses — truncation prints an ellipsis — so this was the one lossy step leaving no trace. - The cost, stated rather than discovered. The rule’s boundary is the
Unicode default-ignorable property, which includes legitimate
formatting characters of Arabic, Syriac, Kaithi and Egyptian
(
U+0600..0605,U+070F,U+110BD,U+13430..1343Fand others). A report about a repository written in those scripts will show them as<U+0600>instead of applying them. This is a real loss of meaning for those texts, accepted because the same codepoints are a working smuggling channel into a document an agent acts on, and because the projection is a summary rather than a faithful reproduction of source text. It costs zero false findings on ordinary content — measured on 66 MB of multilingual text, 392 766 non-ASCII characters, no new hits. - Escaping is a content-suppression lever, and a cheap one. One invisible codepoint becomes eight visible characters, so a prefix of a dozen hostile characters expands ~17x and pushes the rest of a cell past the 160-codepoint cap and behind the ellipsis. A journal value can therefore hide legitimate text from a reader without hiding anything invisibly. This is a real trade against the blanking it replaced, taken because the failure is now visible — the reader sees escapes and an ellipsis and knows the value was tampered with — where silent deletion left a value that looked ordinary and complete.
- The session-start budget is measured AFTER escaping. Expansion used to
happen downstream of
fitBudget, so a hostile journal shipped 9 880 characters of injected context against a 2 000-character budget. The escaping now happens inrenderContext, before the budget is applied, so the length the budget sees is the length that ships. journal.mjs’s own CLI escapes the same characters as JSON\uXXXXinstead, because that output is machine-readable and this repo parses it back;JSON.parseof the escaped form is deep-equal to the original.
- Why shown and not removed. Silent removal made a poisoned value and a
clean one render identically:
- A gate is open unless it says otherwise.
data.resultvaluespass,passed,ok,green,approved,closedandanswered(case-insensitive;answeredcloses an operator ask) close a gate; anything else — includingfailandopen— keeps it in Open gates. The set has ONE spelling, exported fromproject.mjs;overnight.mjsimports it rather than keeping a copy. - Open gates carries the QUESTION. An operator ask is a gate whose
kindis its id (Q-<n>), so the table’sGatecolumn would otherwise tell a resumed conductor that it is waiting without telling it what it asked. Ordinary gates render an em dash there.
Ticket status is the strongest signal seen: merged › review: <verdict> ›
reported: <verdict> › in progress (a spawn exists) › open. The
progress percentage is merged tickets over all tickets.