How Hermes Coach works

The hooks, worker thread, bounded ledger, findings and diff evidence behind Hermes Coach — how a review is scheduled, what the reviewer sees, and why every failure path is fail-open.

Registration and hooks

coach/plugin.py is the orchestrator: it owns the tools, every hook, the worker, and the decision of when to spend. The root __init__.py is the thin seam the host calls.

The import, the build, and each registration are wrapped independently. A host without register_tool, a duplicate name, or a renamed handler logs and continues with the rest. Cleanup is still registered on a host with no hook surface, so the worker thread never outlives the plugin.

HookJobFailure posture
post_tool_callBuffer a compact tool card for the current turnIgnore
post_llm_callSnapshot the turn, check cadence, budget and digest, queue a reviewReturn immediately
pre_llm_callHand over ambient notes once, drop expired ones, snapshot the turn’s base commit, and add pre-turn guidance when active_timing is pre or bothReturn without advice
pre_verifyReview the turn’s diff, card the finding, queue it for the next turn, optionally hold completionFail open
subagent_start / subagent_stopTrack child sessions so their tool calls are refusedIgnore
on_session_endPrune old session filesLog and continue

A turn, end to end

The turn path never waits on a model except at the completion gate, and even there the wait is bounded.

  1. The turn starts

    With active_timing set to pre or both, pre_llm_call asks for short guidance on the coming turn and injects it as context. Budget, the pause flag and the cadence are checked first, and the call is bounded and fail-open.

  2. The turn runs and tool calls are recorded

    post_tool_call stores a one-line card per call: tool name, main argument, status and a truncated preview.

  3. The turn ends

    post_llm_call snapshots it, checks cadence, budget and duplicate digest, then hands the session and turn to the worker and returns immediately.

  4. The worker reviews

    One background thread, one review in flight, newest queued wins. An Active review has a 45-second budget.

  5. Advice is routed and delivered

    A card is posted, and the finding is stored as a pending note or injected as a message, depending on policy and the corrective cap.

  6. The next turn starts with the note

    pre_llm_call returns pending notes as <coach-note> context and snapshots HEAD once per turn.

  7. A completion claim is gated

    pre_verify collects base..HEAD, the worktree and new files, asks the coach for a verdict within 20 seconds, and either continues with one blocker or gets out of the way.

A ledger, not a transcript

The coach never sees the session transcript. It sees a compiled ledger: at most 60 turns, a user message capped at 4000 characters, an assistant answer capped at 600, and one-line tool cards. Both caps apply at write time, so oversized model output never accumulates in memory or on disk.

When the prompt is over budget, content is evicted in a deliberate order: oldest tool detail first, then assistant text, then turn headings. User messages are dropped only as an absolute last resort, oldest first, and never the last remaining one — so the newest context always survives.

The budget is ledger_max_tokens × 4 characters minus the system prompt, guidance, session note and question, so the whole prompt fits the ceiling rather than just the ledger.

Findings and their bounds

Findings are structured output, not prose. A JSON schema is sent with the request, and parsing accepts a dict or a fenced JSON object inside text. Anything malformed returns nothing — the plugin never invents a finding from a paragraph.

  • Title is capped at 80 characters and body at 600, bounded at the boundary rather than trusted from the model.
  • A digest of severity, title and evidence is normalised for case and whitespace, so the same issue twice is recognised as the same issue.
  • Notes are deduplicated per turn and title: Active and the completion gate can flag the same turn, and the model should read it once.

What the completion review can see

The evidence block is assembled in a fixed order, and its header always survives truncation. Only the patch is clipped when the budget runs out, so a clipped diff can never be mistaken for the whole change set.

--- stat (commits since turn start (<base>..HEAD)) ---
--- files changed (<N>) ---
--- coverage ---
--- committed during this turn ---
--- uncommitted ---
--- new files ---
The shape of the review evidence. The coverage line states counts, the patch budget and any truncation.
  • git status -z --untracked-files=all keeps paths verbatim and lists new files individually instead of collapsing a directory.
  • Every git call runs with -c core.fsmonitor=false, --no-color, --no-ext-diff and --no-textconv, so repository config cannot execute code or rewrite the patch inside the gate.
  • Paths are project-relative and resolved under the session directory; absolute paths and any .. component are refused.
  • When the commit range is unusable — a rebase, amended history, no recorded base — the review falls back to the worktree and says so.

The evidence block is capped by review_evidence_max_chars (128 000 characters by default; adjustable on the Completion review card or with /coach set review_evidence_max_chars <n>). Past that, the stat list and file list survive and the patch is clipped.

Guardrails

Fail open
A timeout, provider error or malformed answer becomes a recorded error, never a blocked turn.
Same-model refusal
A coach on the primary model would review its own work. Model and provider are compared case-insensitively; skip_same_model: false is the only escape.
Per-turn call caps
Consult and Decide each count their own calls. The dispatcher’s session_id overrides anything the model puts in tool arguments, so a call cannot rotate ids to dodge a cap or read another session.
Child sessions refused
Subagent sessions cannot call the coach unless allow_in_subagents is on, so a fan-out cannot multiply spend silently.
One hold per request
The gate may hold a completion once, only for a parsed blocker with review_authority="hold_once". Everything else is a card.
Serialised state
One lock guards every load-mutate-save, and paths that cross a model call reload afterwards, so a review worker cannot overwrite spend, holds or pending notes.