git:20260905.74ec8ce to git:20260905.0c707c7
41 added, 117 removed. Audit A to A.
# claude-code-hermit
- A personal assistant that lives in your project — memory-driven learning, daily rhythm, idle agency, and operational hygiene for Claude Code.
-
- ## This Repo is a Plugin
-
- This repo is structured as a Claude Code plugin. It is NOT a standalone project — it gets installed into other projects via:
-
- ```
- claude plugin marketplace add gtapps/claude-code-hermit
- claude plugin install claude-code-hermit@claude-code-hermit --scope local
- ```
+ A personal assistant that lives in your project: memory-driven learning, daily rhythm, idle agency, and operational hygiene for Claude Code.
- After install, run `/claude-code-hermit:hatch` in the target project to create the state directory.
+ This directory is a Claude Code plugin, not a standalone project. Operators install it from the marketplace (commands in `README.md`) and run `/claude-code-hermit:hatch` in the target project to create the state directory.
- ## Plugin Structure
+ ## Plugin structure
Standard Claude Code plugin layout (`agents/`, `skills/`, `hooks/hooks.json`, `scripts/`, `.claude-plugin/plugin.json`). The non-obvious dir is `state-templates/`: what `hatch` copies into a target project and `hermit-evolve` refreshes. Skills are namespaced `/claude-code-hermit:*`.
- ## Constraints
-
- - Before implementing any new capability, check Claude Code docs (https://code.claude.com/docs)
- and plugins (https://claude.com/plugins) for native features that already cover it.
- If overlap exists, delegate — don't build.
-
## Subagent delegation
- The shipped hermit applies a **main-as-orchestrator** pattern: the long-lived main session delegates heavy sub-steps to isolated-context subagents and keeps only the verdict. Delegate a sub-step when its intermediate context dwarfs its conclusion, it needs no operator contact mid-flight, and main needs the result, not the artifact. Subagents inherit `CLAUDE.md`/`CLAUDE.local.md` (a fixed re-seed cost per dispatch), so it's a net win only above that noise threshold, not a blanket rule. Delegation *bookends* main rather than bypassing it: a dispatch costs the re-seed plus **≥2 main turns at full context** (the dispatch turn and the completion-ingestion turn) — batch dispatches, and for trivial sub-steps prefer a single inline main turn (measured live across production hermits). A delegated sub-step returns a verdict/`operator_message`; main owns `AskUserQuestion` and operator notification. Illustrative dispatchers (not exhaustive): `heartbeat`/`reflect`/`brief`/`weekly-review`/`hermit-evolution` → `skill-eval-runner`; `hermit-evolve` → `evolve-runner`; `proposal-act` → `general-purpose` for the accept-flow tail. The operator-facing version is in `state-templates/CLAUDE-APPEND.md` § Rules.
-
- ## Per-Project State
+ The shipped hermit is main-as-orchestrator: the long-lived main session delegates a sub-step to an isolated-context subagent when the step's intermediate context dwarfs its conclusion, it needs no operator contact mid-flight, and main needs only the verdict. A dispatch costs the `CLAUDE.md`/`CLAUDE.local.md` re-seed plus at least two main turns at full context (dispatch and completion ingestion), so batch dispatches and keep trivial sub-steps inline. A delegated step returns a verdict plus an optional `operator_message`; main owns `AskUserQuestion` and operator notification. The operator-facing version is in `state-templates/CLAUDE-APPEND.md` § Rules.
- When installed in a target project, state lives in `.claude-code-hermit/`:
+ ## Per-project state
- - `sessions/SHELL.md` — current session (with tags, monitoring)
- - `sessions/S-NNN-REPORT.md` — archived reports
- - `proposals/PROP-NNN-<slug>-HHMMSS.md` — improvement proposals
- - `templates/` — session and proposal templates
- - `state/` — runtime observations (alert-state.json, reflection-state.json, routine-metrics.jsonl, proposal-metrics.jsonl, usage-metrics.jsonl, usage-archived.json, micro-proposals.json, state-summary.md, monitors.runtime.json)
- - `raw/` — domain inputs (fetched content, snapshots, logs); flat layout only (no subdirectories). `raw/.archive/` holds expired artifacts. See [plugin-hermit-storage](docs/plugin-hermit-storage.md).
- - `compiled/` — durable domain outputs (briefings, decisions, assessments) injected at session start; flat layout only (no subdirectories)
- - `knowledge-schema.md` — per-hermit behavioral schema (what it produces and when)
- - `config.json` — project config (identity, channels, routines, idle agency, scheduled checks)
- - `OPERATOR.md` — human-curated context (draft changes, confirm before writing; hard-blocked in always-on mode)
+ Installed state lives in the target project's `.claude-code-hermit/`:
- `hatch` also seeds `bin/` (lifecycle scripts), `docker/` (container scaffolding), `HEARTBEAT.md`, and `SESSION-REPORT.md` — see `state-templates/` for the full set.
+ - `sessions/SHELL.md` (current session; the only plan surface) and `sessions/S-NNN-REPORT.md` archives
+ - `proposals/PROP-NNN-<slug>-HHMMSS.md`
+ - `state/` machine state: runtime, alert and reflection state, metrics JSONL, monitor registry
+ - `raw/` domain inputs and `compiled/` durable domain outputs (injected at session start). Both flat, no subdirectories; `raw/.archive/` holds expired artifacts. Contract: [plugin-hermit-storage](docs/plugin-hermit-storage.md).
+ - `knowledge-schema.md`, `config.json`, and `OPERATOR.md` (operator-curated: draft changes, confirm before writing; hard-blocked in always-on mode)
+ - `bin/` lifecycle scripts, `docker/`, `HEARTBEAT.md`, `SESSION-REPORT.md`, `templates/`; the full set is `state-templates/`
### State ownership under residency
- A hatched folder can hold more than one session, so every state file needs an owner. `startup-context.ts` decides resident-vs-guest once per session and records the verdict with `lib/guest-marker.ts`; hooks read it with `isGuest(stateDir, payload.session_id)` (they run per turn with no model in the loop, so the guest banner cannot reach them). Three buckets — a new state file belongs to exactly one:
+ A hatched folder can hold more than one session, so every state file has exactly one owner. `startup-context.ts` decides resident-vs-guest once per session and records the verdict with `lib/guest-marker.ts`; hooks read it with `isGuest(stateDir, payload.session_id)` (they run per turn with no model in the loop) and normalize payload IDs through `cc-compat.sessionId`.
- - **Resident-owned** — liveness signals, context-reset stamps, session-id caches, CC-payload snapshots. Guests never write: gate the write on `isGuest`. Gated so far: `state/.heartbeat` (stop-pipeline), `runtime.json.last_context_reset_at` (precompact-stamp). Cost rows are labelled rather than gated — every session writes one, carrying its own `cc_session_id` and a `guest` flag, so readers filter instead of losing the row. Still ungated and known: `state/cc-stop-snapshot.json`, `sessions/.status.json`, `state/last-operator-action.json` and `state/operator-turn-open.json` (record-operator-action writes both on a guest prompt, stop-pipeline clears the turn marker on a guest Stop). The operator-recency one is load-bearing: the watchdog's wedge path backs off while `last-operator-action.json` is fresh, so a guest actively working still defers the restart the `.heartbeat` gate is meant to unblock.
- - **Folder-shared** — `sessions/SHELL.md` Progress Log, the cost log. Any session writes; entries carry provenance instead of a gate (the PreCompact breadcrumb labels a guest's reset). `SHELL.md` stays one file: "the only plan surface" is a contract, and its read-modify-write is already serialized by `withProgressLogLock`.
- - **Session-keyed** — anything that must vary per session is keyed by session id (like the guest markers), never a singleton.
+ - **Resident-owned**: liveness signals, context-reset stamps, operator-activity and open-turn markers, CC-payload snapshots, the session-diff sidecar, channel-control queues. Gate hook writes on the guest verdict; guest prompts skip resident channel-control stages and guest Stops do not drain resident commands. Startup seeds activity only after residency classification. The cost cache `sessions/.status.json` also carries cumulative totals, so any ownership change there must preserve them.
+ - **Folder-shared**: the `SHELL.md` Progress Log and the cost log. Any session writes; entries carry provenance (`cc_session_id`, `guest`, the PreCompact breadcrumb) instead of a gate. Progress Log and lifecycle archive/open/reset writes go through the shared SHELL lock in `lib/md-write.ts`; hooks run concurrently, so an unlocked read-modify-write loses updates.
+ - **Session-keyed**: anything that must vary per session is keyed by session id, never a singleton.
## Hatch target routing
- `scripts/domain-hatch.ts` owns target resolution and stamping for every consumer — core `hatch`, `hermit-evolve`, `docker-setup`, and all five domain hatches. Routing is derived from the plugin's install scope (read from `claude plugin list --json`): `scope=local` → `CLAUDE.local.md` + `.claude/settings.local.json`; `scope=project` → `CLAUDE.md` + `.claude/settings.json`; `scope=user` or no detectable scope → `.local` files (safer default). Advanced mode lets the operator override the scope-derived default via the Visibility prompt.
-
- The resolved target is stamped into `.claude-code-hermit/state/hatch-options.json` by `domain-hatch.ts` alone; no consumer re-derives it. Domain hatches reach it through `.claude-code-hermit/bin/hermit-run domain-hatch <preflight|ensure-target|sync-block> <plugin-id>` — `preflight` returns the resolved `target`/`target_file`/`target_default`/`needs_target_question` plus the version verdict, `ensure-target` records an operator override, and `sync-block` writes the CLAUDE-APPEND block into the resolved file. `hermit-evolve` Steps 6, 7, 8 are target-aware and will not re-add committed files after a `.local` migration.
-
- ## Migrations
-
- When a change needs to be applied to existing hermits (not just the template for new ones), document it in `CHANGELOG.md` under the relevant version's `### Upgrade Instructions` section. The `hermit-evolve` skill reads and **executes** those instructions — write them as imperative steps the skill will follow, not passive notes.
-
- Example: removing a line from an operator-editable file (like `HEARTBEAT.md`) that `hermit-evolve` would otherwise skip.
-
- Upgrade Instructions write only to `.claude-code-hermit/`, never into the plugin tree; the seeded `Edit(//**/.claude/plugins/**)` deny enforces it. Plugin-side changes ship in the release, not the upgrade step.
-
- ## Feature defaults (research preview)
+ `scripts/domain-hatch.ts` owns target resolution and stamping for every consumer: core `hatch`, `hermit-evolve`, `docker-setup`, and every domain hatch. Routing derives from the plugin's install scope (`claude plugin list --json`): `local` → `CLAUDE.local.md` + `.claude/settings.local.json`; `project` → `CLAUDE.md` + `.claude/settings.json`; `user` or undetectable → the `.local` files. Advanced mode lets the operator override via the Visibility prompt. The result is stamped into `state/hatch-options.json` by `domain-hatch.ts` alone. Domain hatches reach it only through `.claude-code-hermit/bin/hermit-run domain-hatch <preflight|ensure-target|sync-block> <plugin-id>`: `preflight` returns `target`/`target_file`/`target_default`/`needs_target_question` plus the version verdict, `ensure-target` records an operator override, `sync-block` writes the CLAUDE-APPEND block into the resolved file. `hermit-evolve` steps 6 to 8 are target-aware and will not re-add committed files after a `.local` migration.
- Follow Claude Code's research-preview model: **new features ship enabled by default, opt-out not opt-in.** When you launch a new capability:
+ ## Migrations and feature defaults
- - **Template default is on.** Set the feature enabled in `state-templates/config.json.template` (or the relevant default) so new hermits get it from `hatch` without extra steps.
- - **Upgrade Instructions enable it for existing hermits.** Write the `### Upgrade Instructions` so `hermit-evolve` turns the feature on for already-installed operators by default, rather than leaving it off pending opt-in. Note how to disable it for operators who don't want it.
+ Changes that must reach existing hermits (not just the template for new ones) go in `CHANGELOG.md` under that version's `### Upgrade Instructions`. `hermit-evolve` executes those steps, so write them as imperative steps. They write only to `.claude-code-hermit/`, never into the plugin tree (the seeded `Edit(//**/.claude/plugins/**)` deny enforces it); plugin-side changes ship in the release.
- Reserve opt-in defaults for features that are genuinely risky, costly, or destructive: state why in the CHANGELOG when you choose opt-in over opt-out.
+ New features ship enabled: set the default in `state-templates/config.json.template` so `hatch` gives it to new hermits, and write the Upgrade Instructions so `hermit-evolve` turns it on for existing ones, noting how to disable it. When you choose opt-in instead (credential, per-invocation spend, destructive), say why in the CHANGELOG.
## Authorship layers
- An installed hermit belongs to its operator — they rewrite it, and the plugin's job is to keep working underneath that. Two layers, different rules:
+ An installed hermit belongs to its operator; they rewrite it and the plugin keeps working underneath. Two layers, different rules.
- **Contracts (plugin owns — stay strict).** State-file schemas, hook exit codes and fail-open, the `hermit-run` → `hermit-exec.sh` verb path, hatch target stamping, deny patterns, the token/script-mediation boundary. Customization works *because* these are rigid. `hermit-exec.sh` resolves a bare name to `$PLUGIN_ROOT/scripts/<name>.ts` only, rejecting `*/*` and `*..*` so a permission glob spanning `/` can't drive it outside `scripts/` — that guard is load-bearing for the allow-list, not incidental. There is deliberately no local-verb fallback; `bin/hermit-run` is a resolver, not an operator-extensible table (and it's a state-template copy that `hermit-evolve` refreshes, so local edits there don't survive).
+ **Contracts (plugin owns, stay strict).** State-file schemas, hook exit codes and fail-open, the `hermit-run` → `hermit-exec.sh` verb path, hatch target stamping, deny patterns, the token/script-mediation boundary. Customization works because these are rigid. `hermit-exec.sh` resolves a bare name to `$PLUGIN_ROOT/scripts/<name>.ts` only, rejecting `*/*` and `*..*`, so a permission glob spanning `/` cannot drive it outside `scripts/`; that guard is load-bearing for the allow-list. There is deliberately no local-verb fallback: `bin/hermit-run` is a resolver, not an operator-extensible table, and it is a state-template copy that `hermit-evolve` refreshes.
- **Core depends on nothing downstream.** Domain plugins depend on core; core never depends on them. Nothing in this plugin may import a sibling, hardcode a sibling slug in logic, or branch on one being installed — sibling discovery is generic (`resolve-siblings.ts` matches name-contains-`hermit`, not a list) and core consumes only what a sibling *declares* in its `hermit-meta.json` (`hermit.boot_skill`). Naming a plugin as an example in a comment, doc, or `docker.recommended_plugins` entry is fine; conditioning behavior on it is not. Every new cross-plugin capability goes through a declared field or a `hermit-run` verb behind a `required_core_version` floor.
+ **Core depends on nothing downstream.** Sibling discovery is generic (`resolve-siblings.ts` matches name-contains-`hermit`) and core consumes only what a sibling declares in its `hermit-meta.json` (`hermit.boot_skill`). Naming a plugin as an example in a comment, doc, or `docker.recommended_plugins` entry is fine; conditioning behavior on it is not.
- **Content (operator owns — stay loose).** Extension is declarative, not code injection into core's dispatch path:
+ **Content (operator owns, stay loose).** Extension is declarative:
| Surface | What the operator can put there |
|---|---|
| `routines[]`, `boot_skill`, `shutdown_skill`, `monitors[]` | any skill string, including their own local plugins' skills |
| `config.env`, `docker.packages`, `docker.recommended_plugins` | arbitrary values |
| `OPERATOR.md`, `HEARTBEAT.md`, `knowledge-schema.md`, the CLAUDE-APPEND block, `docker/` | free-form rewrite; on-disk beats template (see the `hermit-docker update` gotcha below) |
- When authoring above the contract line, give skills data + goal + voice and let the model compose — don't hardcode content, edge-case copy, checklists, or opinions about how the operator should work. Loose ≠ vague: a skill still states its goal and verdict shape precisely.
+ Above the contract line, give skills data + goal + voice and let the model compose; don't hardcode content, edge-case copy, checklists, or opinions about how the operator should work. Loose is not vague: a skill still states its goal and verdict shape precisely.
- **Skill text describes the present, in a colleague's runbook voice.** The model reading a skill never saw its earlier versions, so anything phrased as a diff against them is noise it must reconcile: no version pins, issue or proposal IDs, or migration history ("no longer", "now automatic", "used to", "backwards compatible") inside instruction text. That context belongs in the commit message, the CHANGELOG, or a code comment. State each rule once, in the file that owns it, with other files pointing at it rather than restating it, in plain declarative sentences: no shouting caps, no "think hard", no rationale attached to every rule (one clause of why, only where the model would otherwise be tempted). Name the audience instead of a length cap ("someone reading on a phone", not "one paragraph max"). When a step's inputs already determine its answer, move it into a script and delete the step instead of asking the model to judge it.
+ **Skill text describes the present, in a colleague's runbook voice.** The model reading a skill never saw earlier versions, so no version pins, issue or proposal IDs, or migration history ("no longer", "now automatic", "backwards compatible") in instruction text; that belongs in the commit, the CHANGELOG, or a code comment. State each rule once, in the file that owns it, and point at it from elsewhere. Plain declarative sentences: no shouting caps, no "think hard", one clause of why only where the model would otherwise be tempted. Name the audience instead of a length cap ("someone reading on a phone"). When a step's inputs already determine its answer, move it into a script and delete the step.
- **The upgrade rule.** Anything operator-editable must survive `hermit-evolve`. Adding a new operator-owned surface means saying in `### Upgrade Instructions` how existing edits are preserved, not just how the template changes.
+ **The upgrade rule.** Anything operator-editable must survive `hermit-evolve`. A new operator-owned surface says in `### Upgrade Instructions` how existing edits are preserved, not just how the template changes.
## Development
- To test locally against a target project:
-
- ```
- cd /path/to/target-project
- claude --plugin-dir /path/to/this-repo
- ```
-
- Then run `/claude-code-hermit:hatch` to set up the target project.
-
- Run tests:
-
- ```
- bun test
- ```
+ Test locally against a target project with `claude --plugin-dir /path/to/this-repo` from that project, then `/claude-code-hermit:hatch`. Unit tests: `bun test` from this directory; fixtures and hook checks in [docs/testing.md](docs/testing.md).
- **Development constraints (non-negotiable):**
+ Constraints:
- - **Runtime is Bun (TypeScript-native).** Hooks and scripts are `.ts` run directly by `bun` —
- no transpile, no build. The minimum version lives in `.claude-plugin/hermit-meta.json`
- (`required_bun_version`) — `doctor-check` and the `hermit-start` preflight both read it
- dynamically. The Docker template pins its own `BUN_VERSION`; bump it together with the meta.
- - No runtime dependencies — shipped code imports only the standard library and Bun built-ins
- (`Bun.*`, `bun:*`, `node:*` modules). The repo-root `package.json` is dev-only toolchain
- (typecheck via `bunx tsc`, test-only fuzzing); nothing under `plugins/` may import from
- `node_modules` outside `*.test.ts` files.
- - No build step — skills are plain markdown, hooks are standalone `.ts`/`.sh` scripts.
- - Avoid overengineering.
- - Hooks fail open — a hook must never block Claude Code. Catch all errors, `process.exit(0)`. Never exit non-zero on transient failures.
- - Consume stdin — every hook must read stdin to completion even if unused (avoids broken pipe errors).
- - Agent references in skill instructions must always use the full namespaced form `claude-code-hermit:<agent-name>` (e.g., `claude-code-hermit:proposal-triage`). Bare names are auto-namespaced by the harness on load, so bare-name invocations from skill text will fail with "Agent type not found".
- - **Hermit `state-templates/CLAUDE-APPEND.md` blocks must not restate `config.json` contents** (routine schedules, Discord/Telegram user IDs, morning-brief times, `permission_mode`, `agent_name`, `sign_off`, `escalation`, `idle_behavior`). Those are loaded structurally from `config.json` on every session start. CLAUDE-APPEND describes behaviors, conventions, and workflow shape — not the wiring. Restating config values leaks them into `CLAUDE.md`, which the hatch's OPERATOR.md scan reads, tempting the model to mirror them again into OPERATOR.md prose. Naming routines by `id` and referencing `enabled` state is fine — those are stable; schedules and flags drift.
- - **Default `config.json` source of truth is `state-templates/config.json.template`.** Skills (especially `hatch`) must overlay operator choices onto the template — never re-declare a parallel inline default object in SKILL.md text. The `tests/template-skill-sync.test.ts` contract test catches drift between the template's top-level keys and `hatch/SKILL.md` references; if you add a field to the template, also reference it by name in hatch.
- - **SKILL.md is what every invocation pays for; siblings are progressive disclosure.** Only `SKILL.md` auto-loads. Keep in it the path every run takes; move what a branch or subcommand needs only sometimes into a sibling (`reference.md`, `branches.md`) and have that branch `Read` it, naming the section. Trim before either: cut prose, collapse tables, delete duplicates.
- - **Token discipline: keep the script-mediation boundary.** State costs tokens only where
- it crosses into context — hook stdout, skill-driven `Read`s, and helper-script output.
- Hooks and helper scripts print verdict-sized digests, never raw logs or full state dumps
- (everything a hook prints on success is injected into context). Skills must not `Read`
- unbounded surfaces (`cost-log.jsonl`, `*.jsonl` event logs, the channel DB) directly —
- front them with a script that returns a bounded summary (the `cost-report.ts` /
- `heartbeat.ts precheck` / `lib/search.ts` pattern); a fixed-line tail of a log is the
- ceiling, not the norm, and it is the wrong shape whenever the question is per-period
- (reflect's cost-spike detector reads `state/cost-index.json` day totals for exactly
- that reason — a 20-line tail of `cost-log.jsonl` spans one date on a busy install).
- Session-start injection
- (`startup-context.ts`, `generate-summary.ts`) is the largest recurring cost — a new
- section there must justify its per-session tokens against how often it changes behavior.
- No numeric budgets: this is a boundary rule (where digestion happens), not a size quota.
- (SKILL.md size has its own rule above.) **The atom of cost is the API call**: every
- tool-call round trip re-reads the full accumulated context from cache, so
- cache_read+cache_write dominates an always-on hermit's bill (roughly two-thirds to
- nine-tenths depending on workload; measure the install), not per-prompt injection.
- The boundary rule above therefore also covers **native tool outputs** (`CronList`
- returning full prompt text per entry, etc.), not just file reads.
+ - **Runtime is Bun, no build step.** Hooks and scripts are `.ts` run directly by `bun`; skills are plain markdown. The minimum Bun version is `required_bun_version` in `.claude-plugin/hermit-meta.json`, read dynamically by `doctor-check` and the `hermit-start` preflight; the Docker template pins its own `BUN_VERSION`, bump both together.
+ - **No runtime dependencies.** Shipped code imports only the standard library and Bun built-ins (`Bun.*`, `bun:*`, `node:*`). Root `package.json` is dev-only toolchain (`bunx tsc`, test fuzzing); nothing under `plugins/` imports from `node_modules` outside `*.test.ts`.
+ - **Hooks fail open on transient errors** (catch, exit 0) and read stdin to completion even when unused. Intentional policy gates may block through the hook's documented denial contract; preserve those decisions.
+ - **`state-templates/CLAUDE-APPEND.md` describes behavior, never config values** (routine schedules, channel user IDs, brief times, `permission_mode`, `agent_name`, `sign_off`, `escalation`, `idle_behavior`). Those load structurally from `config.json` every session start; restated values leak into the operator's `CLAUDE.md` and from there into OPERATOR.md prose. Naming a routine by `id` or its `enabled` state is fine.
+ - **`state-templates/config.json.template` is the source of default config.** Skills, `hatch` especially, overlay operator choices onto it rather than declaring a parallel default object in prose. `tests/template-skill-sync.test.ts` checks that every top-level template key is referenced by name in `hatch/SKILL.md`.
+ - **`SKILL.md` is what every invocation pays for; siblings are progressive disclosure.** Keep the path every run takes in `SKILL.md`; move what a branch needs only sometimes into a sibling (`reference.md`, `branches.md`) that the branch `Read`s by section. Trim prose before either.
+ - **Keep the script-mediation boundary.** State costs tokens only where it crosses into context: hook stdout, skill-driven `Read`s, helper-script output, and native tool results (`CronList` returning full prompt text per entry counts). Hooks and helpers print verdict-sized digests, never raw logs or state dumps. Skills never `Read` unbounded surfaces (`cost-log.jsonl`, `*.jsonl` event logs, the channel DB); front them with a script that returns a bounded summary (`cost-report.ts`, `heartbeat.ts precheck`, `lib/search.ts`). A fixed-line log tail is the ceiling, not the norm, and the wrong shape for per-period questions (reflect's cost-spike detector reads `state/cost-index.json` day totals because a 20-line tail of `cost-log.jsonl` spans one date on a busy install). Session-start injection (`startup-context.ts`, `generate-summary.ts`) is the largest recurring cost; a new section there must justify its per-session tokens against how often it changes behavior. No numeric budgets: this is a rule about where digestion happens, not a size quota.
## Debugging gotchas
- - `read_only: true` on the hermit container is incompatible with Claude Code's credential-refresh write path. Hermits 401 with `Invalid authentication credentials` once the access token expires (~8h after `/login`) because the refresh write fails silently under the current tmpfs / named-volume layout. Do not reintroduce without verifying refresh writes survive whatever layout is added.
- - The hermit Ubuntu image has **no `strace`** and `apt install` is blocked when `read_only: true` is on the container. For fs/network tracing inside hermit, use `NODE_DEBUG=fs,http,https,net,tls claude ...` instead.
- - **Enforce mode is authoritative, not advisory** — verified live (dnsmasq 2.92, throwaway container): with `no-resolv` + `--conf-file`, unmatched queries NXDOMAIN and are never forwarded. The `forwarded <host> to 127.0.0.11` line some operators see in `docker logs <stack>-hermit-netguard-1` comes from **log-only** mode, which passes neither `--conf-file` nor `--no-resolv` and forwards everything to `/etc/resolv.conf`'s resolver by design (block-nothing is the point of that mode). Static `address=/host/ip` records in the allowlist are honored in both modes (see `dnsmasq.allowlist.template`).
- - `grep` returning no matches breaks `&&` chains in diagnostic one-liners — silently truncates the rest of the pipeline. Use `; grep ... || true` if continuation matters.
- - **`hermit-docker update` uses the on-disk Dockerfile, compose, and entrypoint.** Upgrade Instructions for any of those templates must name hermit-evolve as the refresh path, then require a second `hermit-docker update` to apply the merged file.
+ - `read_only: true` on the hermit container breaks Claude Code's credential-refresh write path: hermits 401 with `Invalid authentication credentials` once the access token expires (~8h after `/login`) because the refresh write fails silently under the tmpfs / named-volume layout. Do not reintroduce without verifying refresh writes survive the layout.
+ - The hermit Ubuntu image has no `strace`, and `apt install` is blocked while `read_only: true` is on. For fs/network tracing inside hermit use `NODE_DEBUG=fs,http,https,net,tls claude ...`.
+ - Netguard enforce mode is authoritative (verified live, dnsmasq 2.92): with `no-resolv` + `--conf-file`, unmatched queries NXDOMAIN and are never forwarded. The `forwarded <host> to 127.0.0.11` lines in `docker logs <stack>-hermit-netguard-1` come from log-only mode, which forwards everything by design. Static `address=/host/ip` allowlist records are honored in both modes (`dnsmasq.allowlist.template`).
+ - `grep` with no match breaks `&&` chains in diagnostic one-liners and silently truncates the rest; use `; grep ... || true` when continuation matters.
+ - `hermit-docker update` builds from the on-disk Dockerfile, compose file, and entrypoint. Upgrade Instructions for those templates must name `hermit-evolve` as the refresh path, then a second `hermit-docker update` to apply the merged file.