Doctor reference
scripts/doctor.mjs --state · 117 unit tests
Doctor diagnoses, it never repairs. Every finding carries a severity, a location and a command you can paste.
The journal is append-only and the projections are generated, so most of
Tyran’s state is self-consistent by construction. The gaps are the places
where a human, a crash or a second process can get in between: a hand-edited
journal, an agent that died without reporting, a STATE.md somebody
“fixed”, a policy rule with a typo that silently protects nothing.
node scripts/doctor.mjs --state [--dir <.tyran>] [--json] [--now <iso>] [--stale-hours <n>]| Exit | Meaning |
|---|---|
0 | healthy — info findings are allowed and expected |
1 | findings: at least one error or warning |
2 | usage or I/O error (unknown flag, flag given twice, an explicitly named --dir that does not exist) |
--stateis required. It is the only mode today;--envand--configland with the setup epic, and a baredoctor.mjsthat silently meant one of them now would silently mean something else then.--dirdefaults to.tyran. A missing default is a healthy repo that has not run/tyran:setupyet (exit0). A missing explicit--diris a typo, and a clean bill of health for a path nobody looked at is the one output a diagnostic must never produce (exit2).--jsonprints the same result as a machine-readable object — the shape the futureSessionStarthook and the dashboard consume.- A flag given twice is refused rather than silently resolved.
What it checks
Section titled “What it checks”Severity is a property of the code, not of the call site. It is declared
once, in SEVERITY_BY_CODE in scripts/doctor.mjs, and a unit test pins
this table against it — including the rows that are hard to reach at
runtime. A severity is a promise about the exit code, so it must not be
possible to change one and keep the suite green.
| Code | Severity | Finding |
|---|---|---|
journal-invalid | error | per-event schema failure, timestamp regression, unknown event type, corruption mid-file |
journal-truncated | warning | the final line is truncated — a crash mid-write; readers discard it |
journal-warning | warning | a validateJournal warning doctor does not have a more specific code for |
journal-key-near-miss | warning | a data key one edit from the key consumers actually read (next_step for next_steps). Accepted at append, never read, never reported — the writer believes it recorded something and nothing did. A healthy journal has none, which is why this is a warning |
journal-key-unread | info | data keys no consumer reads, counted and named. NOT a defect: data may always carry extra keys, and the tail of this is deliberate annotation. Stated so that “recorded” and “recorded AND read” stay distinguishable |
finding-no-command | info | finding events whose proof is prose alone — no command to re-run. A finding produced by reading code legitimately has none, which is why this is stated rather than refused. Info and not warning because the keys are newer than every finding already written, and a check that goes red on upgrade day is one people learn to skip |
journal-init-mismatch | error | events carry an init that is not the directory name |
journal-cross-init-pairing | error | a report from one initiative closed a spawn from another |
journal-mixed-initiatives | warning | more than one init in one file (the contract is one initiative, one file) |
journal-missing | warning | the initiative directory exists (ENOENT on the journal), but nothing records what happened |
journal-not-a-file | error | journal.jsonl is a directory |
journal-unreadable | error | the journal could not be stat’ed or read — the errno is printed, and nothing about this initiative was checked |
journal-lock-present | warning | a leftover write-lock directory: a writer is running, or died inside its critical section |
check-failed | error | one check threw on this journal — the other checks still ran |
spawn-open | info | the journal still believes this agent is working |
spawn-stale | warning | …and the initiative moved on without it (see the clock below) |
spawn-blocked | warning | the agent’s own last progress signal says blocked and it has stood past the threshold (see the clock below) — the conductor should unblock or close it |
spawn-duplicate | warning | two open spawns for one agent name — pairing is ambiguous (ADR-18) |
spawn-orphan-report | warning | a report that closes nothing |
agent-name-unusable | warning | an agent name that cannot act as a correlator; those events are excluded from pairing |
ask-open | info | a question is waiting on the operator and has not been answered |
ask-stale | warning | …and it has stood past 72 h of journal time — the initiative is waiting on a human who has not been told (see the clock below) |
lease-open | info | an open lease whose holder is still working |
lease-orphan | warning | an open lease whose holder already reported — the resource is blocked by nobody |
lease-expired | warning | the acquiring event carried expires / expires_at / until, and it has passed |
lease-release-by-non-holder | warning | a release that did not free the lease |
worktree-accumulating | warning | more than eight git worktrees beside the main checkout — Tyran creates one per parallel agent and removes none |
projection-drift | warning | STATE.md / PROGRESS.md no longer match the journal, byte for byte |
projection-absent | info | neither projection generated yet — a repo that has not run the projector |
projection-missing | warning | one of the pair is gone while the other is there: a run stopped half way |
projection-blocked | warning | the journal cannot be projected at all, so drift is not a meaningful question |
projection-unreadable | error | a projection exists but could not be read (errno printed) |
projection-failed | error | rendering the projection threw |
board-absent | info | no BOARD.md / board.json yet — every install older than the board feature; regenerating the projections creates them |
config-missing | info | the repo has not been set up (yet) |
boundaries-absent | info | no boundaries: block, so the Settings tab cannot edit one (strict still applies) |
boundaries-relaxed | warning | a boundaries: flag is turned down, so the gate no longer refuses what it covers |
tyran-dir-untracked | warning | nothing under .tyran/ is tracked by git — worktrees get no config and no policy, so agents run there with no autonomy class at all |
policy-missing | error | .tyran/ exists with no policies/autonomy.yaml under it — the policy gate fails closed on this state, so every write in the repo is refused until the file is there |
config-invalid · knowledge-invalid · policy-invalid | error | a schema validator rejected the file, with its exact field path |
config-unreadable · knowledge-unreadable · policy-unreadable | error | the file could not be read at all (errno printed) |
policies-unreadable | error | policies/ could not be listed (errno printed) — not “zero policies” |
knowledge-not-a-directory · policies-not-a-directory | warning | the path exists but is not a directory, so nothing in it was checked |
knowledge-duplicate-id | error | the same entry id appears in two files of the store. validateKnowledge allocates its id set per document, so no per-file check can see it — and knowledge.mjs brief refuses a store that does not validate, so every handoff asking for a brief gets nothing. It also makes a supersedes naming that id ambiguous |
knowledge-entry-oversized | warning | an entry’s text exceeds the size a budgeted brief can carry — it validates, but it crowds out every other entry knowledge.mjs brief would select |
knowledge-store-unreachable | info | how many LIVE entries can reach ONE budgeted brief, and how many cannot (superseded entries are counted separately, so the total still reconciles with the files on disk). The aggregate of the line above: measured on a real install the per-entry warning fired five times while brief was returning 1 of 31 entries and 104,178 codepoints reached nobody. Info, because a store outgrows the budget in the ordinary course of being useful — but the ratio is the number that gets acted on |
policy-kernel-downgrade | error | a rule that tries to lower a protected kernel path |
policy-rule-dead | warning | a rule glob that can never match any path |
policy-rule-overruled | warning | a rule that quietly fails to cover part of what it looks like it covers |
no-state-dir | info | no .tyran/ here — a repo that has not run /tyran:setup |
state-not-a-directory | error | state/ exists but is not a directory |
state-unreadable | error | state/ could not be listed (errno printed) |
state-stray-file | warning | something under state/ is not an initiative directory |
state-legacy-initiatives-dir | warning | a .tyran/initiatives/ directory from before 0.1.9 — initiative files live under state/, and nothing mechanical reads the old location. The fix is scripts/migrate.mjs, which previews by default and never overwrites |
lease-file-tracked | warning | lease files are committed to git — a lease records who holds a resource right now, so a committed one conflicts on every parallel merge |
initiative-untracked | warning | git has never seen this initiative’s ledger — journal.mjs append writes the working tree and nothing else, so an initiative nobody committed is one git clean -fd from having never happened |
initiative-ignored | warning | a .gitignore rule covers the ledger, so nothing under it can be committed at all — and git add on an ignored path is a silent no-op |
initiative-uncommitted | info | the ledger has uncommitted changes — ordinary mid-initiative, a gap at a merge boundary |
limit-pause-active | info | a usage-limit pause marker is present and its resume time has not passed — autonomous work is deliberately wound down |
limit-pause-stale | warning | the pause marker’s resume time has passed and the marker remains — the watcher died (reboot) or was never scheduled |
limit-resume-watcher-dead | warning | resume.json says a watcher is waiting but no such process is alive, or the last scheduled resume failed |
limit-telemetry-missing | warning | limits.mode is warn or pause but no usage PERCENTAGE is reachable — nothing can stop the run early, though the wall itself is still detected from the transcript once it is hit |
limit-watcher-absent | warning | a pause marker is in force with no resume.json beside it — the wind-down stopped before it armed the resume, so nothing is waiting for the reset |
limit-near | warning | a usage window has crossed its configured threshold. Under mode: warn this is the whole of what warn does; under pause it is the wind-down announcing itself |
limit-statusline-stale | warning | the statusLine command in ~/.claude/settings.json points into the plugin cache for a version that is no longer running — a cache is not an archive, and when it is pruned the telemetry stops silently |
mistakes-unreadable | warning | MISTAKES.md is present but could not be read or parsed, so nothing in it was counted. Warning rather than error: nothing mechanical consumes the ledger at write time, so an unreadable one degrades learning without stopping work |
mistakes-repeat-unpromoted | info | one signature’s open entries have reached the knowledge threshold (self-improvement) — a failure that recurred that often is evidence a rule is missing, and /tyran:retro promotes it into .tyran/knowledge/. Info, because a healthy repo sits here between a breakage and its next retrospective |
mistakes-file-missing | info | there is no MISTAKES.md and git has never seen one — the repository predates the ledger rather than having opted out of it. A deletion is the documented opt-out and produces NO finding, and where git cannot answer at all this says nothing rather than guessing. Info, because nothing is broken: it is an offer, not a defect (self-improvement) |
claude-md-fence-missing | info | entries claim status law while CLAUDE.md carries no tyran:rules fence — the earned rule is not in force in any session — or carries a malformed one, where the next promotion cannot land at all |
An absent MISTAKES.md produces no finding at all: deleting the file is
the documented opt-out (self-improvement), and a tool
that nags about a file you removed on purpose is a tool you disable. The
checked line still names it, so “nothing was said about it” and “it is not
there” stay distinguishable.
Guarantees
Section titled “Guarantees”- Deterministic. The same state renders the same bytes. Nothing reads the wall clock, findings are sorted explicitly (severity, then code, then location, then message, ties keeping the order the checks produced), and the report contains no timestamp that is not copied from an event.
- One implementation per rule. Spawn/report pairing is
journal.pairSpawns(), lease ownership isjournal.tail(), projection freshness isproject.checkFile(), path classification isschema.classifyPath(), file schemas areschema.validateFile(), and “is this codepoint invisible” isinvisible.invisibleProblem(). Doctor asks those modules; it never re-derives their answers. Two implementations of one rule diverge at the first “optimization” — this repo has the scar twice over (ADR-18, and ADR-21 after the invisibility rule was found in three spellings that disagreed on 456 codepoints). - No false alarm on a healthy repo. No
.tyran/, an empty journal, no projections yet, no config: all exit0. A tool that cries wolf on a fresh checkout is uninstalled before it ever finds anything. - Nothing passes silently. An unreadable file, a directory where a file
belongs, a
knowledge/that is not a directory, a journal shape that throws inside a reader — each becomes a finding naming the errno. A check that cannot run says so; it never reports “clean” for something it skipped. - Every fix command runs, and none of them destroys anything. Doctor
diagnoses; it is allowed to be wrong, so it never prints
rm,mvor a redirection. Arguments are shell-quoted, and anything outside printable ASCII falls back to ANSI-C quoting ($'demo\x1b[2K'): the value stays byte-exact and runnable, but a control or bidi codepoint can no longer reach the terminal. That matters becausedata.agentis guarded byagentNameProblemwhileinitis not —validateEventonly requires a non-empty string — and neither are lease resource names. Values that travel as JSON (--data) are additionally written with every non-ASCII codepoint as\uXXXX; both are escapes, not sanitizations, soJSON.parseand the shell both return the exact original. Two values cannot be byte-exact and are called out here rather than left implied: a NUL cannot survive at all (argvdoes not carry it, so no quoting fixes it), and a lone surrogate becomes U+FFFD on the way to UTF-8 — the command stays safe, but it quietly looks for a different string than the journal holds. Both need the journal repaired, not the command pasted. One more limit, for the same reason of stating it rather than implying it:$'...'is a bash/zsh construct. Pasted into a POSIXsh(dash on Debian, Ubuntu and most containers) it does not expand —dashreads it as$followed by an ordinary quoted string, so the command runs and looks for the literal text$'demo\x1b[2K'. Safe, and wrong, which is the failure mode this section exists to prevent. Run printed commands in bash or zsh. - Untrusted journal values cannot rewrite the report. Every value read
out of a journal is passed through
project.inline()before it is printed, in the text report and in--jsonalike. In a plugin,datais written by agents processing someone else’s repository; without this an agent name carrying an unterminated right-to-left override would mirror every following line of the diagnosis a human is reading.
The clock
Section titled “The clock”Staleness needs a “now”, and a wall clock would make the output
non-deterministic. Doctor uses the journal’s own last event as the
reference time, so spawn-stale means:
this agent has been open for N hours of journal time — the initiative kept moving and left it behind.
That is the signal worth acting on, and it has a useful property: when the spawn is the last event, its age is zero, so an agent that is simply working long never trips the check. It also means a completely idle journal cannot report staleness — pass the real clock when you want that:
node scripts/doctor.mjs --state --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)"--stale-hours moves the threshold (default 4).
spawn-blocked reads the same journal clock against a threshold of its own: a
self-reported blocked signal that has stood for 1 hour of journal time.
That hour is fixed — --stale-hours moves staleness only. A signal counts
only for the spawn it was emitted during, so a re-spawned agent name does not
inherit the blockage its previous incarnation’s report already cleared.
ask-stale reads the same journal clock against 72 hours, also fixed. The
fix it prints is the sitting: node scripts/answer.mjs render, fill the
answer: lines, then apply. An ask that has been answered is a closed gate
and produces no finding at all.
Dead policy rules
Section titled “Dead policy rules”A rule that matches nothing is worse than a missing rule: the file looks
like the boundary is defined, schema.mjs validate policy passes, and
nobody is protected.
Doctor does not enumerate possible repo paths — that set is unbounded and
would only ever prove a rule live, never dead. It uses the one thing that
is decidable: every path a rule ever meets has been normalized first
(repo-relative, POSIX separators, no . or .. segments, no leading
slash). Each glob is instantiated into witness paths (** becomes real
segments, * becomes one segment) and each witness is pushed through
normalizePath. If normalization rejects or rewrites every witness, the
literal parts of the glob contain exactly the characters normalization
removes — and those parts must appear verbatim in any match. The rule is
dead, and the corrected glob is printed:
| Rule | Verdict |
|---|---|
./hooks/**, /hooks/**, hooks\x, a/./b, foo/../bar, ., .. | dead — the report suggests the normalized spelling |
*, **, .*, src/**, (hooks)/**, */policy-gate.mjs | live — they do match real paths |
One witness surviving unchanged is enough to call a rule live, so the check is conservative by construction: a false “dead” alarm on a working security rule would be worse than the miss it exists to prevent.
policy-rule-overruled narrows a gap from the other side. validatePolicy
already rejects rules that downgrade hooks/** or .tyran/policies/**, but
its heuristic fills the rule’s wildcards with filler segments, so a rule
like */policy-gate.mjs validates clean while quietly failing to cover
hooks/policy-gate.mjs — classifyPath returns KERNEL there before any
rule is consulted. Doctor instantiates the rule’s wildcards with segments of
the protected path instead, which means the rule matches the candidate by
construction and no second glob matcher is involved.
It covers the whole-segment wildcard shapes only, and that limit is real rather than theoretical — see Known limits below.
Rule analysis runs only on a policy that validates. Findings derived from a document the schema already rejected are noise stacked on top of the real problem.
Module API
Section titled “Module API”import { runStateChecks, renderText, renderJson } from './scripts/doctor.mjs';
const result = runStateChecks({ dir: '.tyran', now: null, staleHours: 4 });// { ok, dir, checked: string[], counts: {error, warning, info}, findings }// finding: { severity, code, where, message, fix }process.stdout.write(renderText(result));severityFor(code) and SEVERITY_BY_CODE are exported for the same reason:
the SessionStart hook and any dashboard need to know a finding’s weight
without re-deriving it from the report. severityFor throws on an
unregistered code rather than defaulting — that is part of the contract, not
an implementation detail, because a finding with no severity is how a check
silently stops failing.
deadRules(policy, repoRoot) and overruledRules(policy, repoRoot) are
exported separately so the future policy gate can reuse them without
scanning a state directory.
Known limits
Section titled “Known limits”policy-rule-overruledmisses a wildcard inside a segment. The candidate paths give*a whole segment, so a rule spelledh*/x.mjsorhooks*/xpassesvalidatePolicyclean, really does reachhooks/x.mjs, and doctor stays silent. Closing it needs the real matcher —globMatchesinschema.mjs, which is private. Writing a second copy is exactly what ADR-18 forbids, so the fix is to export the first one, which belongs with a change to that module.globMatchesimplements only*and**.?,[abc]and{a,b}are matched literally, so a rule likehook?/xis dead in practice whilepolicy-rule-deadreports nothing — its witnesshook?/xsurvives normalization unchanged, so the rule looks live. The dead-rule method is declaratively conservative; this is one of the shapes it declines to judge.- The clock has a dead zone. With no
--now, an agent that hangs while the journal goes completely idle reportsinfoand exit0; passing the real clock makes the same state awarningand exit1. That trade-off is right for a CLI, where determinism is worth more, but aSessionStarthook must pass--nowor the staleness check never fires in production. lease-expiredonly sees an expiry the acquiring event recorded.lease.acquiredrequiresresourceandholder; an expiry field is a convention, not a schema rule.- Doctor reads; it does not take the journal write lock. On a journal
being appended to right now, a finding can describe a state one event old.
journal-lock-presentis how you find out that this happened.
--hooks: can the gates fire at all?
Section titled “--hooks: can the gates fire at all?”node scripts/doctor.mjs --hooks [--plugin-root <dir>] [--json]A separate mode from --state, because the two answer different questions.
--state asks whether the RECORD of the work is consistent. --hooks asks
whether the gates that produce that record can run — a question with no answer
inside the state directory, because a plugin whose hooks are dead writes no
state to be inconsistent with.
This is DETECTION, not ENFORCEMENT. Nothing in this mode can make a gate
run. doctor --hooks reports and the SessionStart probe warns; neither can
refuse anything, because SessionStart has no refusal channel at all. Any
claim that the plugin “guarantees” its gates fire would be a false guarantee.
Why it exists
Section titled “Why it exists”Measured on the shipped binary: a hook file that is missing, or present
without the execute bit, is spawned through a shell, exits 127/126 with empty
stdout, and lands in the platform’s non-blocking-error branch — the action
proceeds and nothing is printed. hooks.json still lists the gate. The
manifest still validates. There is a state in which the plugin is installed,
every document says it guards, and it guards nothing.
What it checks
Section titled “What it checks”For every command hook declared in hooks.json:
- the event key is one this platform build dispatches (a one-character typo removes every gate under it, silently);
- the file exists, is a file, is executable, has a shebang, and the interpreter that shebang names is reachable;
${CLAUDE_PLUGIN_ROOT}is quoted — hooks are spawned withshell: true, so an unquoted path with a space in it word-splits and every gate under it is dead on that machine and alive everywhere else;- a
timeoutis declared (the default is 600 seconds); - the command is not a shell program whose real target this check would have to guess at;
- no two entries on one event share a command (the platform deduplicates by
(pluginRoot, command), so the second never runs).
And for every matcher, using a transcription of the platform’s own predicate:
- an invalid regex matches nothing, forever, with only a debug log line;
- a comma-separated matcher (
Edit, Write) is an error: the exact-match branch is guarded by/^[a-zA-Z0-9_|]+$/, which a comma fails, so it becomes an unanchored regex matching a tool name that will never exist; - an unanchored regex matches anywhere inside the subject
(
tyran-implementeralso matchesevil-tyran-implementer-nope); - a matcher matching none of the values the event can carry is an error
where that set is closed by the platform’s input schema (
SessionStart,PreCompact) and a warning where it is open (tool names, agent types); - the alphanumeric branch is EQUALITY, not a substring test, so
implementercan never matchtyran:implementer— the check names the value it cannot match rather than saying only that something is wrong.
Known limits, stated rather than implied
Section titled “Known limits, stated rather than implied”- The matcher analysis duplicates platform logic on purpose. There is no
other way to answer “will this matcher ever fire?”. The transcription is
pinned to one build, printed in the report as
platform modelled: <version>, and a platform upgrade can make it stale without anything noticing. - Open subject sets can only ever produce warnings. MCP tools arrive as
mcp__server__tooland a project may define its own agents, so “matches nothing I know about” is not the same as “matches nothing”. - Only
commandhooks are checked.prompt,http,agentandcallbackhooks are reported as unchecked rather than skipped quietly. - The declared-event cross-check is textual. It greps the hook script for
event: 'X'; a script that computes its event would trip it wrongly, and the finding says so.
Keys on the hook ENTRY (the fifth failure variant)
Section titled “Keys on the hook ENTRY (the fifth failure variant)”The checks above all pass while a gate is decoration, if the entry carries one
of four keys. Measured live, same payload, one key changed at a time: a bare
entry refused and the file was never written; "async": true PASSED and wrote
raw TAG characters to disk; "if": "Bash(git *)" PASSED; "shell": "powershell" PASSED. In every case a logger on the same matcher fired
normally, so dispatch and matching were both working.
They are errors, on blocking events only:
async/asyncRewake— “runs in background without blocking”. A backgrounded hook has no channel to return a decision through, so the gate cannot refuse at all.once— “runs once and is removed after execution”. The gate guards the first occurrence, which is the one anybody testing an installation uses.if— evaluated by the platform in a language this check does not interpret, so the gate’s coverage is unknown. Unknown coverage on a control is treated as failure here, the same call the secrets gate makes when it refuses rather than scanning a prefix.shell— the command goes to an interpreter it was not written for, and fails in the silent 127 way.
On a non-blocking event none of these is an error: a probe that runs in the background, once, or conditionally is a legitimate design. The severity is a property of the pair (key, event).
A timeout is also checked for plausibility, not only for presence: the field
is documented in seconds, so a value above the platform’s own 600 s default is
almost always a millisecond figure pasted into a seconds field, and until the
hook exits the tool call it guards is held.