Skip to content

fix(claude-code-hermit): route hatch through Claude Code's built-in output styles - #810

Open
gtapps wants to merge 2 commits into
mainfrom
fix/hatch-builtin-output-styles
Open

fix(claude-code-hermit): route hatch through Claude Code's built-in output styles#810
gtapps wants to merge 2 commits into
mainfrom
fix/hatch-builtin-output-styles

Conversation

@gtapps

@gtapps gtapps commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

A fresh hatch could end up with the operator's communication-style answer in both OPERATOR.md prose and the voice mechanism — the template's stale "four questions" header invited a fourth item, and nothing excluded the answer from the final OPERATOR.md write. Separately, hatch was hand-writing a paraphrase of Claude Code's own built-in output styles (Default/Concise/Explanatory) whenever the operator's answer matched one, dropping the built-in's guarantee that error reports, security warnings, and destructive-action confirmations keep their full content.

Changes

  • hatch now asks about communication style in a dedicated step after OPERATOR.md is already written, so the answer is never in play while that file is drafted — closes the leak structurally rather than by scrubbing prose.
  • That step offers Claude Code's built-in styles first; a built-in creates no file at all. Only "something else" renders the custom .claude/output-styles/hermit-voice.md, in the operator's own words.
  • hermit-settings voice offers the same built-in-first choice and can now switch an already-set style (previously only-if-absent seeding, which couldn't switch anything).
  • apply-settings.ts's output-style op now checks every persisted scope (local, project, and user via CLAUDE_CONFIG_DIR) before seeding, not just its target file — a style set at user scope was previously invisible and could get silently outranked. A new output-style-set op does the explicit replacement; it's deliberately outside the auto-mode classifier's preauthorized op list, reachable only from a live terminal choice.
  • hermit-doctor's voice-carrier check reports a recognized built-in with no voice file as a deliberate persisted style, not "using Claude Code defaults".

Existing hermits with a custom voice file are untouched — this only changes what a fresh hatch offers and what hermit-settings voice can switch to.

Test plan

  • bun test — 4520 pass, 0 fail
  • bunx tsc --noEmit — clean
  • Live style literals (Concise/Explanatory/Learning/Proactive, and default lowercase for Default) verified against the installed CLI's own binary (claude --version 2.1.243), not just docs — the docs never state the persisted value for Default
  • Not yet run: a live re-hatch on Sonnet confirming the operator-facing flow end to end (flagged in the implementation plan as the one claim that needs an empirical probe rather than a code read)

…utput styles

A fresh hatch could end up with the operator's tone in both OPERATOR.md
prose and the voice mechanism, because the style question was answered
before OPERATOR.md was finalized. Moving it to a dedicated question after
that write closes the leak structurally, and along the way stops hatch
from hand-writing a strictly weaker paraphrase of Claude Code's own
built-in output styles when one already covers the operator's answer.
apply-settings.ts's output-style op now also resolves the winning style
across every persisted scope (local, project, user) instead of just its
target file, and a new output-style-set op lets hermit-settings voice
switch an already-set style instead of only seeding an absent one.
@gtapps

gtapps commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Follow-up work for this PR

Review of the current diff plus a design discussion produced the list below. All of it lands in this PR, in a later session. Nothing here is shipped yet.

Decisions taken

  1. Built-in style switches become channel-reachable. Switching between default / Concise / Explanatory is a sealed five-value enum with no injection surface, so it belongs at the gate's allowed tier (the operator's own chat), alongside name, language, and heartbeat cadence. Operators run these hermits from Discord and Telegram, and the current terminal-only rule (which predates this PR) means they can never change how the hermit talks.

  2. Custom voice prose goes to the nonce tier. Rendering .claude/output-styles/hermit-voice.md writes arbitrary text into every future session's system prompt, which is the class channel-settings-gate.ts already reserves for permission_mode, env.*, and monitors. The echoed-token flow in scripts/lib/settings-confirm.ts is the existing mechanism: draft the prose, show it back, emit a code, operator echoes it. Content the hermit merely read (a web page, an issue body) never sees the code, which is the vector this closes. The write has to go through a script rather than Edit / Write, or the strict-profile deny patterns block it and the gate has nothing to match; settings-edit.ts is the shape to follow.

  3. Restore the communication-style question to the Phase 3 batch. Moving it to its own AskUserQuestion in Phase 4b adds a third blocking dialog at the end of hatch. Put it back as Q4 in Call 1, keep Phase 4b as resolve-and-apply with no dialog, and state the constraint in Phase 4 instead: draft OPERATOR.md from Q1 to Q3 and the Phase 2 signals only, with Q4 feeding the style step and not the file. The leak this traded away is cosmetic (tone wording duplicated into an operator-editable file), which does not justify the extra interruption.

Review findings

Medium

  • scripts/lib/voice.ts:80 — adding user scope to the resolver inverts Claude Code's precedence. Project and local settings outrank ~/.claude/settings.json, so a user-scope value can never silently outrank a project write, yet output-style and hermit-start.ts:924 now refuse to seed when one exists. An operator with {"outputStyle":"Concise"} at user scope who hatches and asks for a custom voice gets hermit-voice.md rendered, then kept:Concise, and the file is inert forever. Treat user scope as reportable in the doctor, not as a seed veto, and fix the docstring premise.
  • skills/hatch/SKILL.md:455 — Phase 4b applies an answer the operator just gave through the only-if-absent seed op, so any pre-existing key silently discards it. Phase 5 line 463 then reports "Communication style is set to <style>" unconditionally, which is false when step 4 printed kept:. Use output-style-set for an interactively-given answer, or branch Phase 5 on the result.
  • scripts/apply-settings.ts:369output-style-set is terminal-only by prose alone. channel-settings-gate.ts matches settings-edit <word> <word> and config.json Edit / Write; the strict-profile deny patterns cover Edit / Write on hermit-voice.md. Neither sees this Bash call, so a channel-tagged turn can already run it. docs/security.md:96 states the guarantee that breaks. Decisions 1 and 2 above are the fix: a deterministic matcher on apply-settings.ts … output-style-set <style>, tiered by the style value.

Low

  • scripts/lib/settings/automode-entries.ts:73 — the sealed classifier entry still asserts that channel-env's state-directory argument is the one caller-supplied value. output-style now takes a caller-supplied style argument too, so the grant text describes an op it preauthorizes inaccurately. Mention the validated style argument or restore the value-sealed form.
  • CHANGELOG.md:12 — "could get silently outranked by a hermit seeding the key into a lower-precedence project file" states the precedence backwards. The project file is the higher-precedence one, which is why it wins.
  • skills/hermit-doctor/SKILL.md:123 — the voice-carrier row still documents "Resolution is local-over-project" and "warn when another style is active". Both are stale after this PR adds user scope and the built-in ok branch.
  • skills/hermit-settings/SKILL.md:140 — the voice flow reads only the hatch target and its sibling to display the current style, so an operator whose style lives at user scope is shown "none set" and then gets a project-scope write on top. Use the same resolver the rest of the PR standardizes on.
  • scripts/doctor-check.ts:2036hermit-settings voice deliberately leaves hermit-voice.md on disk when switching to a built-in, but checkVoiceCarrier returns warn for exactly that state. Every doctor run after an operator-sanctioned switch carries a permanent warning. The built-in early return at line 2025 only fires when no voice file exists.

Noted, not filed

  • apply-settings.ts:361 derives projectRoot by stripping two path segments from the target, correct only for a <root>/.claude/settings*.json shape. All current callers conform.
  • output-style-set hermit-voice does not verify the style file exists. Both skill paths render it first, so this is prose-guarded only.

Open question

Whether hatch should always render hermit-voice.md, even when a built-in is chosen, so later tweaks have a file ready. The argument against: with a built-in selected, the file's content would be a paraphrase of that built-in, which is the bug this PR set out to remove, and hermit-settings voice already renders it on demand from the shipped template in one step. The adjacent problem worth fixing either way is the permanent doctor warning above, which is what makes a kept-around file behave correctly when it does exist.

Verification already done

All 159 tests across the four touched suites pass. The BUILTIN_OUTPUT_STYLES literals were confirmed against the installed CLI binary at 2.1.243 (outputStyle??"default", name:"Explanatory",source:"built-in", "Learning", "Proactive").

…e seed

Claude Code ranks both project scopes above user scope, so a value in
~/.claude/settings.json can never shadow a write to .claude/settings.json
or its .local sibling. Treating one as ownership made the seed refuse a
write that would have won: an operator who ever picked a style in /config
at user scope would hatch, get hermit-voice.md rendered, and then watch
apply-settings print kept:<style> and leave the file permanently inert.
Boot repair had the same hole.

The seed and the boot repair now stop at project scope via a dedicated
resolveProjectStyle; resolvePersistedStyle keeps the full local -> project
-> user walk for hermit-doctor, which is reporting what an install will
actually use rather than deciding whether to write. Two tests asserted the
old veto and are inverted to assert the seed lands.

Also corrects the surfaces that stated the precedence backwards or went
stale with it: the classifier grant text still claimed channel-env's was
the only caller-supplied argument after output-style grew a style arg,
hatch Phase 5 reported the operator's answer as applied even when step 4
printed kept:, and the doctor/settings skill rows described the old
two-scope resolution.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant