Skip to content

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.

Terminal window
node scripts/doctor.mjs --state [--dir <.tyran>] [--json]
[--now <iso>] [--stale-hours <n>]
ExitMeaning
0healthy — info findings are allowed and expected
1findings: at least one error or warning
2usage or I/O error (unknown flag, flag given twice, an explicitly named --dir that does not exist)
  • --state is required. It is the only mode today; --env and --config land with the setup epic, and a bare doctor.mjs that silently meant one of them now would silently mean something else then.
  • --dir defaults to .tyran. A missing default is a healthy repo that has not run /tyran:setup yet (exit 0). A missing explicit --dir is a typo, and a clean bill of health for a path nobody looked at is the one output a diagnostic must never produce (exit 2).
  • --json prints the same result as a machine-readable object — the shape the future SessionStart hook and the dashboard consume.
  • A flag given twice is refused rather than silently resolved.

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.

CodeSeverityFinding
journal-invaliderrorper-event schema failure, timestamp regression, unknown event type, corruption mid-file
journal-truncatedwarningthe final line is truncated — a crash mid-write; readers discard it
journal-warningwarninga validateJournal warning doctor does not have a more specific code for
journal-key-near-misswarninga 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-unreadinfodata 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-commandinfofinding 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-mismatcherrorevents carry an init that is not the directory name
journal-cross-init-pairingerrora report from one initiative closed a spawn from another
journal-mixed-initiativeswarningmore than one init in one file (the contract is one initiative, one file)
journal-missingwarningthe initiative directory exists (ENOENT on the journal), but nothing records what happened
journal-not-a-fileerrorjournal.jsonl is a directory
journal-unreadableerrorthe journal could not be stat’ed or read — the errno is printed, and nothing about this initiative was checked
journal-lock-presentwarninga leftover write-lock directory: a writer is running, or died inside its critical section
check-failederrorone check threw on this journal — the other checks still ran
spawn-openinfothe journal still believes this agent is working
spawn-stalewarning…and the initiative moved on without it (see the clock below)
spawn-blockedwarningthe 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-duplicatewarningtwo open spawns for one agent name — pairing is ambiguous (ADR-18)
spawn-orphan-reportwarninga report that closes nothing
agent-name-unusablewarningan agent name that cannot act as a correlator; those events are excluded from pairing
ask-openinfoa question is waiting on the operator and has not been answered
ask-stalewarning…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-openinfoan open lease whose holder is still working
lease-orphanwarningan open lease whose holder already reported — the resource is blocked by nobody
lease-expiredwarningthe acquiring event carried expires / expires_at / until, and it has passed
lease-release-by-non-holderwarninga release that did not free the lease
worktree-accumulatingwarningmore than eight git worktrees beside the main checkout — Tyran creates one per parallel agent and removes none
projection-driftwarningSTATE.md / PROGRESS.md no longer match the journal, byte for byte
projection-absentinfoneither projection generated yet — a repo that has not run the projector
projection-missingwarningone of the pair is gone while the other is there: a run stopped half way
projection-blockedwarningthe journal cannot be projected at all, so drift is not a meaningful question
projection-unreadableerrora projection exists but could not be read (errno printed)
projection-failederrorrendering the projection threw
board-absentinfono BOARD.md / board.json yet — every install older than the board feature; regenerating the projections creates them
config-missinginfothe repo has not been set up (yet)
boundaries-absentinfono boundaries: block, so the Settings tab cannot edit one (strict still applies)
boundaries-relaxedwarninga boundaries: flag is turned down, so the gate no longer refuses what it covers
tyran-dir-untrackedwarningnothing under .tyran/ is tracked by git — worktrees get no config and no policy, so agents run there with no autonomy class at all
policy-missingerror.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-invaliderrora schema validator rejected the file, with its exact field path
config-unreadable · knowledge-unreadable · policy-unreadableerrorthe file could not be read at all (errno printed)
policies-unreadableerrorpolicies/ could not be listed (errno printed) — not “zero policies”
knowledge-not-a-directory · policies-not-a-directorywarningthe path exists but is not a directory, so nothing in it was checked
knowledge-duplicate-iderrorthe 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-oversizedwarningan 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-unreachableinfohow 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-downgradeerrora rule that tries to lower a protected kernel path
policy-rule-deadwarninga rule glob that can never match any path
policy-rule-overruledwarninga rule that quietly fails to cover part of what it looks like it covers
no-state-dirinfono .tyran/ here — a repo that has not run /tyran:setup
state-not-a-directoryerrorstate/ exists but is not a directory
state-unreadableerrorstate/ could not be listed (errno printed)
state-stray-filewarningsomething under state/ is not an initiative directory
state-legacy-initiatives-dirwarninga .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-trackedwarninglease files are committed to git — a lease records who holds a resource right now, so a committed one conflicts on every parallel merge
initiative-untrackedwarninggit 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-ignoredwarninga .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-uncommittedinfothe ledger has uncommitted changes — ordinary mid-initiative, a gap at a merge boundary
limit-pause-activeinfoa usage-limit pause marker is present and its resume time has not passed — autonomous work is deliberately wound down
limit-pause-stalewarningthe pause marker’s resume time has passed and the marker remains — the watcher died (reboot) or was never scheduled
limit-resume-watcher-deadwarningresume.json says a watcher is waiting but no such process is alive, or the last scheduled resume failed
limit-telemetry-missingwarninglimits.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-absentwarninga 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-nearwarninga 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-stalewarningthe 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-unreadablewarningMISTAKES.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-unpromotedinfoone 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-missinginfothere 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-missinginfoentries 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.

  • 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 is journal.tail(), projection freshness is project.checkFile(), path classification is schema.classifyPath(), file schemas are schema.validateFile(), and “is this codepoint invisible” is invisible.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 exit 0. 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, mv or 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 because data.agent is guarded by agentNameProblem while init is notvalidateEvent only 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, so JSON.parse and 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 (argv does 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 POSIX sh (dash on Debian, Ubuntu and most containers) it does not expand — dash reads 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 --json alike. In a plugin, data is 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.

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:

Terminal window
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.

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:

RuleVerdict
./hooks/**, /hooks/**, hooks\x, a/./b, foo/../bar, ., ..dead — the report suggests the normalized spelling
*, **, .*, src/**, (hooks)/**, */policy-gate.mjslive — 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.mjsclassifyPath 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.

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.

  • policy-rule-overruled misses a wildcard inside a segment. The candidate paths give * a whole segment, so a rule spelled h*/x.mjs or hooks*/x passes validatePolicy clean, really does reach hooks/x.mjs, and doctor stays silent. Closing it needs the real matcher — globMatches in schema.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.
  • globMatches implements only * and **. ?, [abc] and {a,b} are matched literally, so a rule like hook?/x is dead in practice while policy-rule-dead reports nothing — its witness hook?/x survives 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 reports info and exit 0; passing the real clock makes the same state a warning and exit 1. That trade-off is right for a CLI, where determinism is worth more, but a SessionStart hook must pass --now or the staleness check never fires in production.
  • lease-expired only sees an expiry the acquiring event recorded. lease.acquired requires resource and holder; 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-present is how you find out that this happened.
Terminal window
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.

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.

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 with shell: 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 timeout is 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-implementer also matches evil-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 implementer can never match tyran:implementer — the check names the value it cannot match rather than saying only that something is wrong.
  • 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__tool and a project may define its own agents, so “matches nothing I know about” is not the same as “matches nothing”.
  • Only command hooks are checked. prompt, http, agent and callback hooks 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.