fix: anchor hermit state-dir resolution against a drifted session cwd - #721
Merged
Conversation
A `cd` earlier in a session silently changed what three resolvers found. The worst case is feed's WebFetch allowlist: it reads feed-sources.md relative to cwd and fails open, so drift stopped the domain guard enforcing at all. Its table validator had the same shape — it gated on the hook's file_path, then read a same-named file under cwd instead. Dev's hooks never consulted CLAUDE_PROJECT_DIR, so drift dropped operator protected_branches back to the built-in list, and core's routine ledger accepted the config-less decoy `.claude-code-hermit/` a worktree carries, writing rows nobody reads. Each resolver now anchors on an existence-checked CLAUDE_PROJECT_DIR or a bounded walk to the config.json sentinel, with the fallbacks chosen so no hermit loses data: dev keeps its load-bearing null, and core still records into a scaffolded-but-unhatched dir rather than dropping the row.
Review of the drift fix found the hatched-project preference inert whenever a
caller passes a relative root. findHermitDir does not resolve its start, so
path.dirname('.') ends the walk after one check, pass 1 returns null, and the
nearest-.claude-code-hermit fallback wins — exactly the behavior the anchor
replaced. routine-due.ts derives its root from argv, whose absoluteness is
documented in the skill but not enforced.
The bare-dir fallback also promised an error string it could not deliver:
appendFileSync throws when the resolved dir has no state/, and run() does not
catch, so a scaffolded-but-unfinished hatch produced a stack trace instead of
one stderr line.
The dev resolver's INVARIANT comment claimed env-precedence parity with core
that does not exist — core's CLAUDE_PROJECT_DIR branch accepts a bare
.claude-code-hermit while dev requires the config.json sentinel. The comment
exists to keep the resolvers in sync, so it now names the one case where they
disagree.
…-deriving it precheck, finish and due each resolved a hermit dir, took its parent, and handed that to logRoutineEvent so it could walk back down. The round trip could only lose information. Once the walk gained a config.json sentinel the two resolvers could disagree and append to a different project's ledger, and in finish.ts it already split the run record and the ledger row across two roots — markOutcome used the resolved dir while stamp() used whatever the re-walk found. logRoutineEvent now takes the hermit dir directly. The walk survives only in the log-event CLI verb, which fires from a CronCreate prompt with no caller-supplied anchor. Also corrects what the walk's comment claims about worktrees. A worktree's partial state dir carries config.json — the dev hermit's /dev-quality and /dev-pr read commands.test and commands.pr_create from it — so the config.json sentinel does not identify a worktree copy, and the changelog entry no longer promises that it does. Discriminating that case needs a sentinel the copy does not carry, in hermitDir() rather than here.
Two comments asserted that a git worktree's partial `.claude-code-hermit/` carries no config.json. It does — `.worktreeinclude`'s managed block copies it in, because the dev hermit's /dev-quality and /dev-pr read commands.test and commands.pr_create from it there. So the dev resolver's INVARIANT comment described a core-vs-dev divergence that does not occur: both land on the worktree's copy. It now names the divergence that is real, a CLAUDE_PROJECT_DIR pointing at a scaffolded-but-unhatched project, which core accepts and dev walks past. The cc-compat test's decoy fixture is a valid walk-past-a-config-less-dir case; only its justification claimed to be the worktree shape. Also drops a stray blank line that had made the core changelog's Fixed list render loose.
pull Bot
pushed a commit
to bryanwills/claude-code-hermit
that referenced
this pull request
Aug 14, 2026
…eader A `claude --worktree` session got a state dir the dev hermit could not read: /dev-pr failed Gate 0 pointing at a re-hatch, which was the wrong diagnosis — the hatch was fine, commands.pr_create simply was not in the copy. The resolver comments merged in gtapps#721 already assert the block carries config.json; only this repo's hand-fixed .worktreeinclude made that true. Writes stay pinned to the main checkout, so the single-writer invariant the original two-path allow-list protected is unaffected; the template-sync test now asserts the no-runtime-state half directly instead of by counting lines. routines.ts health resolved a relative argv against the process cwd while both documented callers pass the relative `.claude-code-hermit`. Any earlier `cd` turned a real ledger into `source: missing`, which reflect and hermit-evolution both read as "no candidates" — a silent skip. Now anchored like weekly-review's hermit arg, with an absolute argv still honoured as passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A
cdearlier in a session silently changed what three separate resolvers found on disk. The sharpest case is feed-hermit's WebFetch allowlist: it readfeed-sources.mdrelative to the process cwd and fails open, so once the shell drifted the domain guard stopped enforcing entirely — with no signal. This PR anchors each resolver on an existence-checkedCLAUDE_PROJECT_DIRor a bounded walk to the.claude-code-hermit/config.jsonsentinel.Each fallback is chosen so no hermit loses data: dev keeps its deliberately load-bearing
null, and core still records into a scaffolded-but-unhatched dir rather than dropping a row.Changes
feed-hermit (security-relevant)
fetch-guard.tsresolves the project root (env → 8-level walk → cwd) instead of reading a bare relative path. Drift no longer disables the allowlist.validate-sources.tsvalidates the file the hook actually reported. It previously gated on the payload'sfile_pathand then read a same-namedfeed-sources.mdunder cwd — so it could pass a bad table and fail a good one.CLAUDE.mdsection that describedfetch-guard.tsas slated for deletion pending a shared core hook; that hook was dropped in July, so the plugin-local guard is permanent.claude-code-dev-hermit
findHermitDirhonorsCLAUDE_PROJECT_DIR, existence-checked against theconfig.jsonsentinel and falling through to the walk on a miss — so a stale value degrades to today's behavior rather than tonullor a foreign project's store. Under drift the guard had been dropping operatorprotected_branchesback to the built-inmain/masterlist and skippinglast-test.jsonwrites.git-push-guard'sbaseCwdbranch resolution andrecord-test-result'srun/write --cwdsemantics are untouched.dev-pr-transforms' parent-rooted lookup is intentionally left alone (single-store design) and was verified unaffected.claude-code-hermit (core)
hermitDir()asfindHermitDir(startDir)— same 8-check cap and sentinel,nullinstead of the fail-open tail.hermitDir()behavior is unchanged for all ~39 importers.routines/event.tsstops re-deriving where the ledger lives.precheck,finishanddueeach already hold a resolved hermit dir, and used to hand over its parent sologRoutineEventcould walk back down to it. That round trip could only lose information: with the two resolvers disagreeing it could append to a different project'sroutine-metrics.jsonl, and infinishit split the run record from the ledger row across two roots —markOutcomeused the resolved dir whilestamp()used whatever the re-walk found.logRoutineEventnow takes the hermit dir directly. The walk survives only in thelog-eventCLI verb, which fires from a CronCreate prompt with no caller-supplied anchor, and is capped at 8 levels like every other resolver.state/now returns the function's documented error string instead of throwing a stack trace out of that CLI verb.Scope note — worktrees
An earlier revision of this branch claimed the resolution change also fixed git worktrees, on the premise that
.worktreeincludeships a config-less.claude-code-hermit/there. That premise is wrong and the claim has been removed from the code comments, the changelog, and this description.The managed block copies
config.jsoninto the worktree deliberately, because dev-hermit's/dev-qualityand/dev-prreadcommands.testandcommands.pr_createfrom it in a worktree session. Soconfig.jsoncannot discriminate a worktree copy from a real project — for any operator, not just in this repo. Doing that needs a sentinel the copy does not carry (state/is the candidate), applied inhermitDir()rather than in the routine ledger. That is tracked separately and is out of scope here.What this PR does deliver for the ledger is agreement by construction: one resolved root per invocation, no second resolver to disagree with it.
A bare config-less dir still counts as a fallback in the CLI walk, because hatch scaffolds the tree before the config wizard runs and an aborted hatch can leave it that way. Refusing there would drop the row silently, and a short ledger still reads
source: 'ok'to routine health.Test plan
All run in this worktree, all exit 0:
cd plugins/feed-hermit && bun test→ 60 passcd plugins/claude-code-dev-hermit && bash tests/run-all.sh→ 150 pass (27 + 73 + 37 + 13)cd plugins/claude-code-hermit && bun test→ 3781 passbunx tsc --noEmit→ cleanNew regressions, written red-first where the fix was behavioral:
feed-sources.mdfiles — the payload-named one is validated, in both directions (good payload/bad cwd and bad payload/good cwd).CLAUDE_PROJECT_DIR— notespawnSyncsnapshots the parent env, so an in-processdeletedoes not reach children that omit an explicitenv. Suite re-run withCLAUDE_PROJECT_DIRexported to prove the scrub holds.findHermitDirignoring ambient env so an explicit caller-supplied start wins.logRoutineEventwrites under the hermit dir it is given and does not walk. Confirmed red-first by replaying the old resolution against the same fixture — given<root>/child/.claude-code-hermit, it resolved to<root>/.claude-code-hermitinstead.Notes for review
cwdfield was probed live (CC v2.1.232) rather than assumed: it is present on every PreToolUse/PostToolUse payload but tracks the drifted shell cwd and always equals the hook process's ownprocess.cwd(), so it anchors nothing. That is why no fix here consults it. The probe also confirmedtool_input.file_pathis normalized to absolute before hook dispatch, which is what makes the one-linevalidate-sourcesfix safe.findHermitDiris deliberately env-free: thelog-eventCLI verb passes an explicit start, and ambient env must not override an explicit argument.INVARIANTcomment documents one intended divergence from core'shermitDir(): core'sCLAUDE_PROJECT_DIRbranch accepts a bare.claude-code-hermit/while dev's requires theconfig.jsonsentinel. That matters for a scaffolded-but-unhatched project, not for worktrees.