Skip to content

Releases: jjanczur/tyran

0.1.48 — writes no em dash, and reads a rule whose dash a human fixed

Choose a tag to compare

@github-actions github-actions released this 25 Sep 08:37
53750d0

mistakes.mjs writes no em dash, and reads a rule whose dash a human fixed

Two bugs from one character, reported from a real install. add wrote every
entry heading as ## <date> — <title> and promote --law wrote every rule
line with — MISTAKES.md entries ..., so in a host repo whose gate rejects
U+2014 on added lines, every add and every promote --law turned the commit
red, and that repo's MISTAKES.md header had to tell people to fix the dash by
hand after each add.

The hand fix then caused the second bug. RULE_EVIDENCE_RE accepted only
U+2014, so a rule line whose dash had been turned into a hyphen no longer named
its signature: fenceCarriesSignature answered false and the next
promote --law wrote the same rule into CLAUDE.md a second time. Measured on
that repo: 4 of the 5 rules in its fence were invisible to the check, and
promote --law --dry-run offered a duplicate of one of them without the
"already carries this signature" notice.

Both writers now use a plain hyphen (-), and the evidence pattern accepts a
hyphen, an en dash or an em dash, because fences already in use hold rules with
the old character and with the hand-fixed one. Messages on stderr and in the
usage text keep their dashes; they never reach a file. templates/MISTAKES.md
and the rule-line example in docs/self-improvement.md (and its site copy)
show the new separator.

Four assertions that pinned U+2014 in written text now pin the hyphen; the
forged-heading test keeps its second em dash, which comes from the hostile
title, not from the template. Two new tests, each naming the mutant it kills:
M18 (U+2014 back in renderEntry or ruleLineFor) and M19 (RULE_EVIDENCE_RE
back to U+2014 only).

0.1.47 — The secrets gate scans 10 MiB, and buys the time to do it

Choose a tag to compare

@github-actions github-actions released this 19 Sep 07:55
d3b0575

The secrets gate scans 10 MiB, and buys the time to do it

MAX_PAYLOAD_BYTES goes from 4 MiB to 10 MiB, together with what the number
depends on: DEADLINE_MS 7 s → 15 s, the scanner's own budget 5 s → 11 s and
the registered timeout 15 s → 30 s, the most a hook here may declare. The
old ceiling was true to its 7 s budget, and it made a finished change on a
real project — two 2.4 MB translation catalogs plus the code that used them —
into three commits and a chunked push, because the gate could not scan them
in one go.

Measured end to end through the real hook before the number was written
(8 cores, load average 3): copies of a 2.4 MB JSON catalog, 4 MiB / 3.14 s,
8 MiB / 5.99 s, 9.5 MiB / 7.45 s; base64 folded at 76 columns,
9.9 MiB / 6.97 s. The ceiling counts every object git would write, so 9.9 MiB
of catalog files arrived as 10,703,651 bytes in nine objects and was refused
in 0.08 s.

One cost the size made visible, recorded in docs/hooks.md: gitleaks 8.30.1
stops reading a single line that carries no newline, deterministically per
payload and chunking, exits 0 and reports the smaller count — 3 of 18 runs at
8 MiB of unbroken base64, 0 of 6 at 4 MiB, never with newlines. The coverage
check refuses such a scan rather than trusting it, which is unchanged and is
the point of that check.

The pins moved with the numbers: tests/unit/hook-secrets-gate.test.mjs pins
10,485,760 and 17,000 and sizes its oversized-report fixture from the constant
instead of a literal.

0.1.46 — The wall is readable, and a question no longer costs a night

Choose a tag to compare

@github-actions github-actions released this 22 Aug 19:54
98e3eff

The wall is readable, and a question no longer costs a night

Two failures were losing whole nights on a real install, and neither was
visible while it happened.

The pause could never fire. A repo configured limits: mode: pause, pause_at_percent: 99 drove into the account wall at full speed on three
separate nights. Both telemetry channels are dark on Claude Code 2.1.197: the
statusline payload carries no rate_limits block — no usage.json had ever
been written in ANY repo on that machine — and cachedUsageUtilization is not
a key of ~/.claude.json at all. The gate fails open on absent telemetry, by
design, so nothing said so.

The signal exists, and it is the wall itself. Every rejected request lands in
the session transcript as an assistant record carrying quotaLimits: {status: "rejected", rateLimitType, resetsAt} — 45 of them across one machine's
projects, including in subagents/*.jsonl, which is the seam this project had
written off as needing a platform signal it did not have. usage-transcript.mjs
reads it as a third channel, after the two percentage sources, because it is
exact and LATE: it reports 100% of a window already spent, so it restores the
clean wind-down and the scheduled resume rather than the early stop.

The correctness rule is not "resetsAt is ahead of us". Measured: a weekly
rejection carrying a reset five days out is followed by 565 successful
assistant messages in the same file, because an allowance can be topped up.
Acting on the reset alone would have held a working machine until the
following Friday — a worse failure than the one being fixed, since a false
pause burns the night the pause was meant to save. So a rejection counts only
while nothing newer shows a named model answering, reconciled on one timeline
across a session and its agents.

Nothing resumed, because nothing was awake to. After a five-hour wall the
conductor's own request is the one rejected, so it cannot checkpoint, cannot
run the wind-down and cannot arm a resume. Measured on this machine: walled at
19:25 with the window resetting at 20:00, first real answer at 20:30 — half an
hour lost because the reset was nobody's job. overnight.mjs watch-limit and
the served board's own timer now do that job, handing off to the existing
schedule so the hold policy, the weekly deferral and the babysitting ladder
are the ones already there.

Unattended delivery — the default that finally ships itself

An open ask has no timeout, no expiry and no auto-apply anywhere in Tyran.
It sits WAITING_ON_OPERATOR forever; doctor turns info into warning after 72
hours of journal time and that is the whole consequence. The ask protocol has
said "record a default — what ships if nobody ever answers" since it was
written, and nothing ever took one: it fired when a human clicked Take the
default
, at which point somebody was awake and the promise was moot.

unattended: mode: on closes an ask with its own recommendation the moment it
is raised, and the run keeps going. Two refusals are mechanical: an ask with
no recommendation and no default is a real question, and an ask raised
--blocking is its author saying this one must wake you. .tyran/STOP still
outranks both. Everything taken is journalled as answer_mode: unattended
with its decision text prefixed (auto-accepted overnight) — an auto-ruling
that looked like the operator's own would be the failure this is judged by.

A recommendation now becomes the default at the mint when no other is given.
The two fields stay distinct, but an ask recording only an opinion rendered as
blocking · no safe default and stopped the queue on a question its own
author had answered.

Smaller, and each one a silence

  • Answering on the board never woke the conductor. The terminal path has
    offered to since answers existed; POST /answer re-rendered the page and
    returned, so a ruling made at 23:00 sat until a session happened to start.
  • limit-near — warn promised to surface and never did. The gate
    returns PASS ("surfacing is doctor's job") and doctor compared no percentage
    to any threshold, so warn differed from off in nothing visible.
  • limit-watcher-absent — a pause in force with no watcher beside it.
    limit-pause-stale fires only once resume_at has already passed.
  • limit-statusline-stale — a statusline pinned to a plugin-cache version
    that is no longer running. A cache is not an archive; when it is pruned the
    telemetry stops with no symptom but a pause that never fires.
  • Two findings recorded rather than papered over: SESSION_ID_RE admits
    --dangerous, so the watcher's filename-sourced id uses a stricter pattern;
    and both marker writers throw on resets_at: undefined, unreachable and
    left shared because a guard on one side is a silent divergence.

0.1.45 — The review reads data access and structure, and the self-review has criteria

Choose a tag to compare

@github-actions github-actions released this 22 Aug 17:48
96b35bf

The review reads data access and structure, and the self-review has criteria

code-review gained two dimensions. Data access and wasted work: the
round trips one request makes and how the count moves with the rows — a query
inside a loop or an awaited per-item map is 1 + N, awaits with no dependency
between them run together with the fan-out bounded, the same pure computation
runs once per request, and a cache comes AFTER those, never instead of them,
with the diff saying what invalidates it and why the TTL is that number. None
of it shows in the diff alone, so the rule is to follow the new call up to the
request that triggers it, and the finding is a count — "N+1 queries for N rows,
expected 1" — which is what lets it pin as a test. Structure: a
thousand-line file is built one reasonable addition at a time, so the question
is whether THIS addition gives a module a second reason to change; the
counterweight is deslop's own — a layer or helper introduced for one caller
is the opposite failure, not the cure — and a giant the story merely touched is
a NOTES.md debt, not an in-story refactor.

Reported from the operator's own repos: the implementer and reviewer shipped
N+1 queries, serial awaits over independent calls, repeated queries that could
have been one, and caches with no stated reason for their TTL, often enough
that the same eight-point checklist was being pasted into every handoff by
hand. The checklist now lives in one place — the skill the reviewer already
loads — and nowhere else.

The implementer's "self-review of your own diff" had no criteria: it named no
skill and listed no dimension. It is now the code-review skill run on
yourself, loaded rather than recalled, with data access called out as the
dimension an author misses most, because each call site looked fine where it
was written. For work that reads or writes data the short plan states the
round trips one request will cost. deslop says in one sentence that a slow
data path is not its business — it deletes, and the fix for a query in a loop
adds lines — so "the optimization pass" can no longer be read as the
performance pass. A test pins the pointer and the N+1 clause, because 0.1.40
is exactly the kind of edit that drops a cross-reference.

Not added: a performance-audit skill. The operator's four-phase audit — recon,
a false-positive pass, a report split by what is safe to fix without asking,
iterative implementation — is the shape of an initiative under /tyran:run:
scout, plan, implementer, reviewer, with the policy classes deciding what needs
a human. A skill nothing asks for by name is a library entry, and every
description is paid for by every session. Skill descriptions: 3868 → 3947 of
5000.

0.1.44 — The board follows your OS, and finally has a light mode

Choose a tag to compare

@github-actions github-actions released this 19 Aug 12:11
87fbfb2

The board follows your OS, and finally has a light mode

The board was dark only, by declaration in its own docstring. It now ships
two renderings of the same warm palette — light is the new default face,
dark keeps its near-black ground — and follows prefers-color-scheme until
you choose. A three-state switch (System / Light / Dark) sits in the
masthead; the choice is stored per browser and applied in the <head>,
before first paint, so a dark-choosing operator never sees a light flash.
Dark got its contrast fixed while it was open: --muted, which carries most
of the small mono text on the page, was measured at 5.4:1 and raised to
6.9:1, and every text role in both palettes now clears WCAG 4.5:1 — computed,
not eyeballed (the light --brass failed at 4.40 and was darkened until it
did not). Two tokens that had never existed (--ink, --line) stopped
silently resolving to nothing.

Readability borrowed from a board the operator reads faster than this one:
one emoji per tab and section label, green/amber/red freshness dots on agent
chips and stalled cards, a waiting-time chip on every open question that
turns clay after a day, and the recorded DEFAULT promoted from a dl row to a
bold sentence — "what happens if I never answer" is what the queue is
actually scanned by. The four headline tiles became buttons that go where
their number is explained.

Answering is a chat now

The reply box under each question is a composer: the two canned moves —
⭐ Use the recommendation, Take the default — as chips above, the
field and a round send on one line, and Enter sends (Shift+Enter keeps
the newline). The modifier-required rule is retired by operator decision;
the empty-input guard survives it, so a stray Enter on a blank box still
sends nothing. A ticket standing in waiting-operator embeds the same
composer in its own detail panel — answering never requires finding the
question a second tab over.

The ticket detail also grew a timeline — spawned, reported, reviewed,
merged, oldest first, merge sha one click from the clipboard — assembled
entirely from lifecycle facts the payload already carried. Initiative
documents open from tickets as well as initiatives now, and they render as
documents: headings, lists and code blocks through a renderer that builds
structure with createElement/textContent only. The rule the old <pre>
enforced survives restated — nothing out of an agent-written document
becomes a link, an image, raw HTML or anything that runs.

Every setting explains itself before you touch it

Each knob on the Settings tab folds open into "What does this change?":
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. The prose lives in the settings
catalogue and travels through settings.json, so the terminal and the page
read the same sentences.

The shipped policy template starts a step looser

Operator-decided: new installs got more refusal than they needed. Two dials
turn, in the template only — an installed policy is KERNEL and no update
touches it:

  • default: GATED → AUTO. The gate is silent outside .tyran/,
    .claude/ and the hooks directory, so the default never protected your
    source tree; it decided only the unnamed paths under those prefixes, where
    an unknown file was denied to every subagent — the exact incident class
    the legacy lease alias records.
  • .claude/agents/**: GATED → AUTO. GATED denies a subagent
    unconditionally, so the retro loop could only ever propose changes to the
    files it exists to maintain. The trade is stated in the rule's own reason.

boundaries: still ships strict, the KERNEL floors are as unloosenable as
ever, and tightening either dial back is one word in
.tyran/policies/autonomy.yaml.

And the board signs its work

The masthead links to janczura.com, LinkedIn and
the GitHub repo; the footer says what the docs site already said — From
Berlin with ♥ by two buddies — Jacek and Piotr.

0.1.43 — One switch that opens the gate, and four things it cannot reach

Choose a tag to compare

@github-actions github-actions released this 18 Aug 15:08

One switch that opens the gate, and four things it cannot reach

Tyran refuses things a less-technical operator cannot reason about: a .env
read, a path outside the repository, a GATED write from a subagent, a push
past the deployment class. Every one of those refusals is defensible and every
one of them is a wall, and until now there was no way to turn any of it down.
The knobs that existed — autonomy: and main_writable_paths: — are narrow,
and neither is the switch people were asking for.

boundaries: in .tyran/config.yaml is that switch. Five flags and a preset:

boundaries:
  preset: open

outside_repo stops refusing paths outside the repo, for every actor
rather than the main thread only — which is the difference between this and
main_writable_paths:, and it is deliberate: a flag that says agents may work
outside the repository is useless if it excludes the agent that does the work.
credentials lets .env and friends be read on Read, Grep and in shell
text. path_classes makes your own GATED/KERNEL rules advisory for writes.
push skips the deployment-class analysis whole — refspec and alias refusals
included, because with no destination left to judge, refusing a command for
being unreadable to a check that is switched off is noise, not safety.
prompts: skip is the one people mean by "skip permissions".

An explicit key beats the preset in both directions, so preset: open with
credentials: refuse is a repo that wants room to work but not its own keys
read, and preset: strict with outside_repo: allow is one door.

permissionDecision: "allow" became sayable, and stayed unreachable by accident

prompts: skip needed something the hook runtime deliberately did not have.
The old invariant was "there is no way to emit allow from this runtime, and
a test pins that"
— sound, because allow does not mean "this gate is
satisfied", it means "approve this call and skip the prompt", and a gate
emitting it for everything it did not object to would be approving the session.

The invariant is now narrower and strictly harder to hold: PASS never
becomes allow, and no failure mode does either.
Auto-approval requires a
handler to return {decision: 'allow'} on purpose. A throw, a timeout, an
overrun budget, a malformed input and an unrecognised verdict are all still
refusals, and the test that used to assert unreachability now asserts that
list — including the case that actually bites, a handler that overruns and
then returns an approval, whose verdict is discarded.

In the gate it is one line in one place. handle() rewrites a verdict only
when it was already PASS; a deny, an ask and a PolicyFailure are
returned untouched. A gate that could not complete its check must not
auto-approve, whatever the config says.

The floor, which is the part that makes the rest safe to ship

.tyran/config.yaml is class AUTO, so an agent can widen these itself. That
is the trade autonomy: already made and it is recorded as one declared limit
rather than two, because it is one fact. What makes it acceptable is that the
floor is mechanical:

  • Secret scanning is untouched. gitleaks runs from the secrets gate, a
    different hook that never learns the word boundaries, and deny from any
    hook beats allow from another. Pinned twice: a source assertion that the
    file never reads the block, and a real scan — a repo configured as
    permissively as the schema allows still refuses a commit carrying a private
    key, with coverage verified.
  • BOUNDARY_FLOOR_GLOBS is SHELL_PROTECTED_GLOBS plus .tyran/STOP, and
    path_classes: allow consults the floor before the flag. The enforcement
    hooks, the policy that classifies them, the hook registry, and the operator's
    brake. A flag the loop can use to clear its own stop signal is not a flag.
    This changes nothing about what a policy file may say — it closes a new door
    rather than reopening an old one.
  • Every unreadable answer is strict. A missing config, an unparseable one,
    an oversized one, and any value the schema rejects all resolve to every
    boundary ON. credentials: yes is not allow. This is limitsOf's doctrine
    with the sign flipped: an out-of-range limit falls back to a default, but a
    security flag must never relax on input nobody can read.

On the dashboard, with the confirmation it already knew how to ask for

The Settings tab has a "What agents may touch" section with all six controls.
Loosening any of them demands the same second, deliberate act that raising
autonomy does, and the token is the new value itself rather than a boolean,
so a caller cannot satisfy it by sending confirm: true alongside whatever
value it liked. Tightening is one click, always.

That took deleting something rather than adding it: applySetting had an
if (id === 'autonomy') special case, and every knob added beside it would
have had to remember to extend that line — which is exactly how a
boundary-loosening control ships with no confirmation at all. The catalogue
carries a widening descriptor now and the check reads it.

The block is written out in full by scan-repo.mjs and in
templates/config.yaml, at its strict values, for the same reason limits: is:
yaml-patch refuses to invent a key, so an absent block is a section of the
dashboard nobody can use. doctor reports boundaries-absent (info) for a
config written before this release, and boundaries-relaxed (warning) whenever
anything is loose — naming which flags. Relaxing a boundary removes refusals,
so nothing else in a session will ever mention it again.

Documented on both surfaces: configuration carries the reference and the
cost, policy-gate gains "Turning the gate down" and extends declared limit 6,
hooks restates the approval contract, board covers the new controls, and
faq answers it in the words somebody would actually search for.

0.1.42 — The board stopped throwing you off the page you were reading

Choose a tag to compare

@jjanczur jjanczur released this 18 Aug 14:04
625958a

The board stopped throwing you off the page you were reading

Six defects, all reported by an operator using the board rather than found by
a test, and every one of them a place where the page knew something and did
not act on it.

The tab lived in a local variable. The page reloads itself every 30
seconds; tab state did not survive that, so anyone reading Spend was returned
to Overview twice a minute. The open tab now travels in the URL fragment, which
also makes #questions a link you can send someone. replaceState, not an
assignment to location.hash — the latter pushes a history entry per reload,
and Back then walks backwards through half an hour of refreshes instead of
leaving the page. A hashchange listener answers the other half: changing only
the fragment is a same-document navigation that reloads nothing, so without it
a deep link into an already-open page moved the URL and nothing else. That half
is invisible to source reading and was found in a browser.

Spend now holds the refresh timer, as Settings already did. Nothing on it
is live: it is one scan of your transcripts, and reloading under a reader
comparing two rows re-runs the scan to redraw numbers that did not move. The
lane filter holds it too, and releases when cleared. Overview and Board are
never held — a board that quietly stopped updating at midnight is the failure
this page exists to prevent.

An initiative row is a button now. It was the one thing on the page you
could read and not open. Selecting one opens the ticket detail's own shape:
directory, state, tickets merged, findings, when it last moved — and the
documents beside its journal, which the board has listed as paths since it
learned to list them and could not show.

GET /doc.json?init=…&name=… serves those five files, and it takes no path.
name must equal a member of INITIATIVE_FILES; 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 someone has to keep ahead of. The
text is invisible-escaped like every other journal-side value that reaches a
human, capped at 200 000 codepoints, and rendered by nothing — these documents
are agent-written, out of reports about other repositories. Reading is not
behind --write: that flag draws its line at changing the repository.

Finished and abandoned stopped looking identical. A percentage cannot tell
them apart, and the board had nothing else — measured across 63 real journals,
43 sat on an unanswered question and 6 were genuinely done. A checkpoint
whose phase is the reserved word closed is the only event that ends an
initiative, and it already closes that initiative's open spawns; the row now
says FINISHED, carries the timestamp, sorts last and leaves the "waiting on
you" count. It is deliberately not a deployment claim: no event in the
closed set records a deploy, so the board does not imply one.

The answer box grew the keystroke everyone tried. ⌘/Ctrl+Enter sends, and
the placeholder says so — "Answer" was always the send button, but a labelled
verb beside a text box reads as a mode switch. Bare Enter still inserts a
newline, on purpose: answers are sentences, which is why the field is a
textarea, and what it sends is appended to a journal that can never take it
back.

And the three npx answer commands folded away. They are the only route on
a read-only board or one opened over file://, so they cannot be removed — but
above the questions on a writable board they were three commands you never need
in the place the eye lands first. They now sit behind "Answer from the terminal
instead", and open themselves on the boards where they are the answer, decided
by the writable flag the settings fetch already returned.

Documented on both surfaces, including the difference the page never explained:
a default is what happens if you say nothing (blank is recorded verbatim as
your decision), a recommendation is what the asking agent thinks you should
do (it fills the box and submits nothing).

0.1.29 — the video slate

Choose a tag to compare

@jjanczur jjanczur released this 15 Aug 22:40
1e29e58

Tyran has videos. Five cuts, and a page that says which one to send.

cut length answers
The explainer 3:20 What is this and why would I run it?
Your first session 3:53 I'm sold — what do I actually type?
One chat, one model 1:01 Why is my bill high and my output worse?
The board 0:59 What does delegation look like from outside?
It learns you 1:22 What does it do that a prompt pack cannot?

They are not five lengths of one video, which is the point of
the videos page: the shorts and the
tutorial fail at each other's jobs.

The written surfaces now say what the video says

The README and the landing hero were accurate and abstract where the explainer
is concrete. Both now open on the same sentence it does — your most expensive
model renaming a variable in the same chat it judged an authentication
boundary — and carry the manager framing through to the board, the bill and the
retro.

The claims in the explainer, checked against the code

Every spoken claim was audited. Nine are exact, and several turn out to be
near-verbatim quotes of the mechanism's own documentation: the four-question
interview batch, the reviewer that ships with no editing tools, the role floor
applied after both the risk shift and the override, and the three-to-knowledge
/ five-to-law ladder.

Two are not, and are recorded rather than quietly left:

  • "cheap tier, read only" — the scout is granted Bash. "You change
    nothing"
    is the first line of its instructions, which is the one form of
    guarantee this project refuses to accept anywhere else.
  • "this is not an estimate, it is the bill" — the figures are exactly what
    the sandbox board shows (404 requests, 4.53M tokens, 54.9% conductor) and the
    rollup computing them is the real one, but the usage feeding it is authored,
    and the board honestly labels those models sample-large / sample-small.

What is in the repository, and what is not

Video sources are tracked — compositions, the voiceover pipeline, the
scripts, the storyboards. The renders are not: roughly 430 MB, and this
repository is what /plugin marketplace add jjanczur/tyran clones, so
committing them would put marketing video into every install of the plugin. Git
LFS was considered and rejected on the same ground — a clone without git-lfs
gets pointer files, which breaks both the Pages build and the install.

No behaviour changes. This release is documentation, and the version bump
that carries it — a README merged to main without one reports "already at the
latest version"
and reaches nobody.

Full changelog: https://github.com/jjanczur/tyran/blob/main/CHANGELOG.md

0.1.28 — zero vulnerabilities, and three numbers that were wrong

Choose a tag to compare

@jjanczur jjanczur released this 15 Aug 20:05
f446638

A maintenance release: the documentation site's dependencies, and three numbers in the prose that had gone stale because nothing was watching them.

Five vulnerabilities, now zero

npm audit in site/ reported five, three of them high, all transitive: nanoid's infinite loop on a zero size, js-yaml, fast-uri, dompurify, and mermaid's prototype pollution. npm update and npm audit fix clear all five and carry astro 7.1.4 to 7.2.2, starlight 0.41.4 to 0.41.7 and playwright 1.62.0 to 1.62.1.

Lockfile only — no range in package.json moved, and the plugin itself still has zero runtime dependencies, which is the property that keeps this whole section confined to site/.

Verified against the built site rather than asserted: astro check reports 0 errors over 27 files, the build produces 18 pages, 488 of 488 root-absolute URLs carry the /tyran base prefix, the browser pass finds 0 broken links and 0 console errors, and the worst mermaid contrast is 5.68:1 against a floor of 3.

TypeScript stays on 6. @astrojs/check is at its newest release, 0.9.10, and still declares typescript: ^5.0.0 || ^6.0.0 — and astro check is what the Pages workflow runs, so accepting TS 7 breaks the deploy rather than a test. Dependabot now ignores that major with the reason written beside it.

The four Dependabot PRs this closes had been open for ten days. They were security updates, which arrive ungrouped and without the seven-day cooldown, so four one-line lockfile changes became four review cycles nobody ran. Both tracks are grouped now: one chore a month instead of a queue.

Three numbers that were wrong

The house rule is that a number in the prose is a claim, verified against the thing it describes. These three were claims nothing verified.

  • The README said 1314 unit tests; the suite has 1405. docs-claims.test.mjs pins every such claim on the docs surfaces, but each of those is about ONE test file and is counted by running it. The README's is about the whole suite, which no unit test can count without running the suite from inside itself. CI now compares it against the full run it already performs for the skipped-test check, so the guard costs nothing.
  • The README said the board has four tabs. It has had five since 0.1.24, and docs/board.md said five throughout. One surface moved, the other did not — the failure the two-surfaces rule exists to prevent, on the one surface that rule does not name.
  • templates/config.yaml keeps 63 comment lines out of 90, not 60.

assets/board-demo.gif was recorded at 0.1.22 and moves through four tabs, so it predates the Settings tab. Its alt text no longer claims to show the whole page; the re-record needs a live board and a screen recorder, so it is written down as an open item rather than quietly left wrong.

The browser check had been shrinking

site/scripts/check-browser.mjs carried a hand-written list of 14 slugs while the site had grown to 17 pages. The three it missed — board, overnight, cost — were the three newest, which is to say the likeliest to carry a relative link nobody had clicked. It reads the content directory now, so the list cannot fall behind again. All 17 pass, 18 internal links, 0 broken.

A check whose coverage silently shrinks as the thing it checks grows is worse than no check, because it still prints OK.

Astro 7's preview daemonizes and returns, so the npm run preview -- & in both check-script headers no longer described what happens: the server outlives the shell that started it and needs astro preview stop.


Update: /plugin update tyran inside Claude Code, or npx @jjanczur/tyran@0.1.28 for the CLI.

Full changelog: https://github.com/jjanczur/tyran/blob/main/CHANGELOG.md

0.1.27 — run state, blast radius, evidence, and tier escalation

Choose a tag to compare

@jjanczur jjanczur released this 15 Aug 16:38
52784b3

The last four items of the munder-difflin plan, and the half of the model
fallback that was still missing. NOTES-REQUESTS.md §7 is now closed.

The board says whether the run is supposed to be running

Three agent chips reading "6 HOURS since last signal — likely dead" covered
four unrelated situations, and 0.1.25 could only tell you about one of them:
an operator STOP. That one is committed repo state and travels in the
artefact. The rest is machine-local and gitignored — the pause marker, the
resume watcher's pid, the usage sidecar — so --serve answers it at
/run.json, on exactly the argument spend already 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 was due 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 must never be the reason nobody can
see the board.

The queue knows what each question is holding up

Nine open questions sorted by age alone 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}, walked transitively and skipping
merged tickets. The board sorts by it and so does the answer sheet, because
those are two renderings of one queue. Two deliberate limits:
no-recorded-default still sorts first (the only questions where saying nothing
has no safe outcome), and an initiative that declares no dependencies reports
null rather than 0 on every ask — an absence rendered as a measurement is
worse than saying nothing.

Signal is not evidence

The agent strip aged on progress, 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 stalest-first, sorted to the bottom.

Agents now carry both times: last_signal is what it said,
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 still outranks one that
produced something twenty minutes ago, which is the exact inversion this split
exists to correct.

No doctor finding for it, deliberately. The threshold separating "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
one people learn to scroll past.

A ticket that failed twice is re-tried a tier higher

The escalation rule lived in the conductor's memory, which iron rule 7 already
names the least reliable store in the system: after a compaction, the ticket is
re-spawned at the tier that failed both times.

node scripts/tiers.mjs --role implementer \
  --journal .tyran/state/<init>/journal.jsonl --ticket T-3 --field json
# tiers: ESCALATED work -> deep after 2 failed attempt(s) on T-3 (ceiling deep).

Capped twice over — two steps, and a ceiling of deep. A ticket that has
failed five times does not need the most expensive model in the table; it needs
a human, and the changes-requested lane already shows that. The same read
picks up any model an error event recorded as model-unavailable and feeds
it to the fallback, so the exclusion is durable in the ledger rather than
in a session that will be compacted. Escalation happens first and the fallback
second: a re-try asks for a stronger model, then discovers whether it is
available.

The weakness, stated rather than left to be found: "did this attempt fail" is
APPROVING_RE, written for lane assignment — "approved with nits" escalates
nothing and "looks fine" escalates. And a journal that cannot be read routes as
a first attempt, loudly, because routing must never depend on a readable
journal.

Detection is still not automatic. The limit surfaces inside a subagent's
API call where no hook can see it, so something still has to notice and write
the error event down. That seam is recorded in NOTES-REQUESTS.md rather
than papered over.

Left alone on purpose

budget: in the config schema is dead weight — validated, read by nothing —
and it stays. Removing a key from the known list makes every config that
carries it invalid, and an invalid config makes the policy gate refuse every
write in that repo. The cost lands on whoever set the key; the benefit is a
shorter array.