Skip to content

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:

FilePurpose
STATE.mdfull state: checkpoint, agents, ledger, gates, leases, decisions, errors, resume steps, journal integrity
PROGRESS.mdone-line progress banner, ticket checklist, open gates, milestones
BOARD.md · board.jsonthe 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.

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.

Terminal window
node scripts/project.mjs <journal.jsonl> [--out-dir <dir>] [--check]
  • --out-dir defaults to the journal’s own directory (.tyran/state/<initiative>/).
  • --check writes nothing. It re-renders from the journal and compares the result byte for byte with what is on disk: exit 0 when the projections are current, exit 1 with a per-file summary (how many lines differ and where) when they drifted or are missing.
  • Exit codes match journal.mjs: 0 ok · 1 drift (--check only) · 2 usage 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.

  • 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-2 before T-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.mjs twice 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 is doctor’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.mjs exits 2 and writes nothing: a mistyped path must not replace a good STATE.md with 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 exit 0.
  • 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 — a merge with no ticket, a lease with no resource — 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 data cannot break the document. In a plugin, data is 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 the GENERATED header;
    • [, ] — cannot form Markdown link or image syntax. Without this an ![](https://…) in 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).
SectionBuilt from
Checkpointthe last checkpoint event (+ plan.accepted, and the last event of any type)
Agentsspawn, 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 blockagesprogress with state: blocked — cleared by the same agent’s next movement signal and by report/review/merge
Findingsfinding events, in journal order — with the command and exit_code behind each claim, where one was recorded
Ledgerticket.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
Gatesgate — the latest event per kind, plus the last REFUSAL that kind ever recorded
Leaseslease.acquired / lease.released
Decisions · Retro · Errorsdecision · retro.entry · error
Resume stepscheckpoint.data.next_steps

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. A report closes the oldest still-open spawn of 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, pairSpawns paired oldest-first and excluded unusable names outright), so STATE.md and doctor could describe the same journal differently. ADR-21 settled it: one answer, not one function.
    • The pairing stays unambiguous because ADR-18 has journal.append enforce at most one open spawn per agent name — chosen over a spawn_id that 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.mjs prints spawn/report pairing for this name is ambiguous on 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 spawn whose data.agent is not a usable correlator (invisible characters, not NFC, empty) is not rendered as running and not dropped: it appears as unusable 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.
  • 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 to STATE.md, to PROGRESS.md and to the warnings project.mjs writes to stderr — stderr is an output channel, and it was the one that leaked: a journal whose init and ev carried 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 exactly deploy ok, with nothing anywhere reporting a removal. ADR-19 is explicit that an exclusion must never be silent, and inline() 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..1343F and 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 in renderContext, 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 \uXXXX instead, because that output is machine-readable and this repo parses it back; JSON.parse of the escaped form is deep-equal to the original.
  • A gate is open unless it says otherwise. data.result values pass, passed, ok, green, approved, closed and answered (case-insensitive; answered closes an operator ask) close a gate; anything else — including fail and open — keeps it in Open gates. The set has ONE spelling, exported from project.mjs; overnight.mjs imports it rather than keeping a copy.
  • Open gates carries the QUESTION. An operator ask is a gate whose kind is its id (Q-<n>), so the table’s Gate column 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: mergedreview: <verdict>reported: <verdict>in progress (a spawn exists) › open. The progress percentage is merged tickets over all tickets.