hermit-doctor · git:20260902.349157d · 2026-09-02 · sha256 04d7eadc4f50e9b0
hermit-doctor git:20260902.349157dA
Immutable. This exact content is served forever at /api/v1/blob/04d7eadc4f50e9b0.
---
name: hermit-doctor
description: Runs the hermit's read-only health checks (runtime, config, hooks, state integrity, cost and spend, scheduling and watchdog, channels, credentials, permissions, docker, backup) and reports the summary. Use when diagnosing an install, before a release, or after suspicious behavior. Activates on messages like "/hermit-doctor", "health check", "diagnose the hermit", "what's wrong", "run diagnostic".
---
# Hermit Doctor
Runs read-only health checks against the current hermit install (`channel-liveness`
is the only one that performs outbound API calls — see Notes) and surfaces the summary. Safe
to run at any time. Produces no side effects beyond writing
`.claude-code-hermit/state/doctor-report.json` and `.claude-code-hermit/state/doctor-alerts.json`,
and appending a summary block to SHELL.md.
## Notification route
A finding gets one notification per unresolved episode: the check script records it, you send it
once, and it stays silent until it resolves. A send that never reached the operator is re-offered
on the next run rather than counted as delivered.
Every run sends the same two-leg notice and `channel-send.ts` resolves each leg against this
install's own config: the maintainer leg reaches the configured `maintainer_channel_id`, else the
primary chat on a `technical` profile (the client leg is dropped there, since both landed in one
chat), else `SHELL.md` Findings on a `non-technical` one. A configured maintainer destination that
is unreachable fails closed to Findings and never spills into the primary chat.
`--maintainer` is accepted and ignored (routine strings may still pass it): audience is decided by
the row's own tier and the operator's config, not by the flag.
## Steps
1. Run the check script:
```bash
bun ${CLAUDE_PLUGIN_ROOT}/scripts/doctor-check.ts .claude-code-hermit
```
The script writes `.claude-code-hermit/state/doctor-report.json` and prints the same
JSON to stdout. It exits 0 unconditionally — on any internal failure the failing
check reports `status: "fail"` in its own entry rather than crashing the report.
2. Parse the JSON. For each check in the report (one entry per id), emit one line using this format:
- `✓ <id> — <detail>` when `status: ok`
- `⚠ <id> — <detail>` when `status: warn`
- `✗ <id> — <detail>` when `status: fail`
3. Append a summary section to `.claude-code-hermit/sessions/SHELL.md` under a new
`## Doctor Report (<ts>)` heading. Use the same per-check lines from step 2. Place it
above the `## Monitoring` section so it sits with session-level context, not
with monitoring chatter.
4. Return the per-check lines to the caller and nothing else.
5. **Escalation.** The script already computed this — do not recompute it, and do not write alert
state yourself. Read the `escalation` object from the step-1 JSON:
- `escalation.new` — findings owed to the operator, each `{id, status, detail}` plus an
optional `tier`. Empty means everything currently failing has already been announced; say
nothing.
- `escalation.resolved` — check ids whose finding cleared. Recorded, never announced: there is
no "recovered" ping.
- `escalation.persisted: false` — the ledger could not be written. `prior_state_known: false` —
the ledger was unreadable and had to be rebuilt, so what was already announced is unknown.
**On either, send nothing** and record the findings under `## Findings` in SHELL.md instead;
a notification you cannot dedup would repeat every run.
**When `escalation.new` is non-empty.** Compose one complete, concise summary covering every
listed check, its detail, and a named next action, in the operator's configured language.
When the finding is `classifier-denials`, name what was blocked by kind: a `bun` block is
usually a hermit script, a call-shape/upstream matter the hermit reports; interpreter heredocs
(`python3`, `node`) are something the hermit stops doing itself; an operator's own host needs an
environment entry added from the terminal (`/auto-mode-setup` or user settings). Never offer to
add classifier context on a chat reply.
**Rows carrying `tier: "maintainer"` go on the maintainer leg only.** Their content is what the
`PermissionDenied` hook already keeps off a client chat, so the payload splits by audience and
`channel-send.ts` decides where each leg lands (§ Notification route).
Deliver it once through the canonical notice path:
```bash
bun ${CLAUDE_PLUGIN_ROOT}/scripts/channel-send.ts .claude-code-hermit --notice
```
One payload, whatever the invocation:
`{"client": "<plain headline for the rows without a tier, plus the one next step>", "maintainer": "<complete summary, every row>"}`.
The client leg is the only part of this notice that can land in a client chat, so write it to the
channel voice rule — no check ids, file paths, USD or token figures; what is wrong in plain words
and what the operator should do about it. The maintainer leg is the complete richer version of
the same notice, never a tiered-rows-only fragment, because it stands alone wherever both
audiences resolve to one chat. Omit `client` when every new row is tiered. Send no `fallback`
key: its default is what routes a maintainer leg to Findings on a `non-technical` install.
When doctor was invoked from a channel, do not quote a tiered row back into your reply; say a
maintainer diagnostic was recorded and leave it at that.
**Then confirm delivery**, so those findings stop being re-offered — only when the send actually
landed (exit 0):
```bash
bun ${CLAUDE_PLUGIN_ROOT}/scripts/doctor-check.ts .claude-code-hermit --mark-notified <id> [<id>…]
```
Pass every `escalation.new[].id` you just announced, tiered rows included. If the send failed
or degraded, skip this step: an id left unconfirmed keeps `escalation.new` non-empty, so the
next run retries it instead of dropping it, and the doctor routine keeps waking on it.
For exit-code handling and the Findings fallback, follow
`/claude-code-hermit:channel-responder` § Outbound notification protocol.
## Silence policy
- If every check is `ok`, return only: `All checks passed.` Do not notify via
channel (Tier 0). Still append to SHELL.md so the run is traceable. Clearing the stale
`doctor:*` entries is the script's job, not yours — it happens on every run.
- If any check is `warn` or `fail`, return the full per-check summary. Notification is
governed by `escalation.new` (step 5), not a blanket per-run ping: only findings not yet
confirmed delivered notify the selected route.
## What each check looks at
Per-check semantics and status rules: `${CLAUDE_SKILL_DIR}/reference.md`, read only when a row
needs interpreting.
No automatic fixes. Doctor reports; the operator acts.
## Notes
- The check logic lives in `scripts/doctor-check.ts` so it can be unit-tested without
invoking the model.
- Re-runs are cheap. No locking needed.
- `permission-rules` never writes. It reports which seeded `ask` entries are inert and names the
`apply-settings.ts <file> deny hardened` command; converting them to hard blocks is the operator's
call, from a terminal.
- `channel-liveness` is the only check that leaves the machine: one token-authed liveness
call per already-configured, enabled channel, 5s timeout, fail-soft. Disabling a channel
disables its probe. Every other check is a local filesystem read.