diff --git a/plugins/claude-code-dev-hermit/CHANGELOG.md b/plugins/claude-code-dev-hermit/CHANGELOG.md index 443d10a0..c82f25b0 100644 --- a/plugins/claude-code-dev-hermit/CHANGELOG.md +++ b/plugins/claude-code-dev-hermit/CHANGELOG.md @@ -8,6 +8,7 @@ - `worktree-boundary-guard` exited on `WORKTREE_GUARD=off` before reading stdin, so disabling the guard broke the pipe on any payload larger than the pipe buffer; the drain now runs before the switch is honored. - All three hooks exited 1 on an unhandled stdin stream error; `main()` now catches and exits 0. - `findHermitDir` honors `CLAUDE_PROJECT_DIR` (existence-checked against `.claude-code-hermit/config.json`, falling through to the walk when it doesn't name a hatched project). A session that had `cd`-ed out of the project dropped operator-configured `protected_branches` back to the built-in `main`/`master` list and skipped `last-test.json` writes. +- `findHermitDir` walks past a worktree's projected `.claude-code-hermit/` (the config.json sentinel with no `state/`) to the main checkout. Core now copies `config.json` into `claude --worktree` worktrees so `/dev-pr` Gate 0 can read `commands.pr_create`; without this, resolving to that copy would hand the guard an empty config and silently drop operator `protected_branches` in every worktree session. Update this plugin alongside a core that carries the copy. ## [0.4.8] - 2026-07-26 diff --git a/plugins/claude-code-dev-hermit/scripts/git-push-guard.test.ts b/plugins/claude-code-dev-hermit/scripts/git-push-guard.test.ts index c3f6598e..468cf16c 100644 --- a/plugins/claude-code-dev-hermit/scripts/git-push-guard.test.ts +++ b/plugins/claude-code-dev-hermit/scripts/git-push-guard.test.ts @@ -47,7 +47,9 @@ function runWithConfig(command: string, protectedBranches: string[], env: Json = const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-')); try { const hermitDir = path.join(tmpDir, '.claude-code-hermit'); - fs.mkdirSync(hermitDir); + // state/ alongside config.json is what marks a real root: config.json on its + // own is the worktree-projection shape, which findHermitDir walks past. + fs.mkdirSync(path.join(hermitDir, 'state'), { recursive: true }); fs.writeFileSync( path.join(hermitDir, 'config.json'), JSON.stringify({ 'claude-code-dev-hermit': { protected_branches: protectedBranches } }) @@ -101,7 +103,7 @@ function runInGitRepo( } if (opts.protectedBranches) { const hermitDir = path.join(repo, '.claude-code-hermit'); - fs.mkdirSync(hermitDir); + fs.mkdirSync(path.join(hermitDir, 'state'), { recursive: true }); fs.writeFileSync( path.join(hermitDir, 'config.json'), JSON.stringify({ 'claude-code-dev-hermit': { protected_branches: opts.protectedBranches } }) @@ -285,7 +287,7 @@ console.log('\nCLAUDE_PROJECT_DIR precedence:'); const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-proj-')); const drifted = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-drift-')); const hermitDir = path.join(proj, '.claude-code-hermit'); - fs.mkdirSync(hermitDir); + fs.mkdirSync(path.join(hermitDir, 'state'), { recursive: true }); fs.writeFileSync( path.join(hermitDir, 'config.json'), JSON.stringify({ 'claude-code-dev-hermit': { protected_branches: ['release/prod'] } }) @@ -320,6 +322,43 @@ console.log('\nCLAUDE_PROJECT_DIR precedence:'); } } +// --- Worktree projection --- +// `.worktreeinclude` copies config.json (never state/) into a `claude --worktree` +// worktree so skills can Read it at the relative path. Resolving THERE would hand +// the guard an empty config and silently drop the operator's protected_branches. +console.log('\nWorktree projection:'); +{ + const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-wtproj-')); + const hermitDir = path.join(proj, '.claude-code-hermit'); + fs.mkdirSync(path.join(hermitDir, 'state'), { recursive: true }); + fs.writeFileSync( + path.join(hermitDir, 'config.json'), + JSON.stringify({ 'claude-code-dev-hermit': { protected_branches: ['release/prod'] } }) + ); + const wt = path.join(proj, '.claude', 'worktrees', 'wt'); + fs.mkdirSync(path.join(wt, '.claude-code-hermit'), { recursive: true }); + fs.writeFileSync(path.join(wt, '.claude-code-hermit', 'config.json'), JSON.stringify({})); + + const guardFrom = (cwd: string, env: Json) => + spawnSync(process.execPath, [GUARD], { + input: makeInput('git push origin release/prod'), + env: { ...process.env, AGENT_HOOK_PROFILE: 'strict', ...env }, + encoding: 'utf-8', + cwd, + }).status; + + try { + assert('walk-up from a worktree reaches the main checkout config', guardFrom(wt, {}), 2); + assert( + 'CLAUDE_PROJECT_DIR naming the worktree still reaches it', + guardFrom(wt, { CLAUDE_PROJECT_DIR: wt }), + 2 + ); + } finally { + fs.rmSync(proj, { recursive: true, force: true }); + } +} + // --- Summary --- console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts b/plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts index 8d756e48..d3b218f5 100644 --- a/plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts +++ b/plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts @@ -14,25 +14,39 @@ // nothing and is deliberately not consulted. // // INVARIANT: mirrors core's cc-compat.ts hermitDir() shape (same 8-level cap, -// same config.json sentinel, env checked before the walk) — if you change the walk -// or the precedence here, check that file too. One deliberate difference: core's -// CLAUDE_PROJECT_DIR branch accepts a bare `.claude-code-hermit/` while this one -// requires the config.json sentinel, so a CLAUDE_PROJECT_DIR naming a scaffolded- -// but-unhatched project resolves there for core and falls through to the walk -// here. Not a worktree difference: `.worktreeinclude`'s managed block copies -// config.json into the worktree, so both resolvers land on the worktree's copy. +// same config.json sentinel, same worktree-projection skip, env checked before +// the walk) — if you change the walk or the precedence here, check that file too. +// One deliberate difference: core's CLAUDE_PROJECT_DIR branch accepts a bare +// `.claude-code-hermit/` while this one requires the config.json sentinel, so a +// CLAUDE_PROJECT_DIR naming a scaffolded-but-unhatched project resolves there for +// core and falls through to the walk here. import fs from 'node:fs'; import path from 'node:path'; +// A worktree's projected `.claude-code-hermit/` — never a resolution target. +// `.worktreeinclude`'s managed block copies config.json into a worktree so +// skills can Read it at the relative path they expect (`/dev-pr` Gate 0 reads +// `commands.pr_create` that way), but never `state/`: hermit state is +// main-rooted and shared across worktrees. So the sentinel without `state/` +// means a projection of a real root further up, and the walk continues to it. +// That keeps this resolver's writers (record-test-result's `last-test.json`) +// on main's state dir, which is also what stops `state/` from ever appearing +// inside a projection. Mirrored in core's cc-compat.ts — fix one, fix both. +function isWorktreeProjection(cchDir: string): boolean { + return fs.existsSync(path.join(cchDir, 'config.json')) && !fs.existsSync(path.join(cchDir, 'state')); +} + export function findHermitDir(startDir: string): string | null { const proj = process.env.CLAUDE_PROJECT_DIR; - if (proj && fs.existsSync(path.join(proj, '.claude-code-hermit', 'config.json'))) { - return path.join(proj, '.claude-code-hermit'); + const fromEnv = proj ? path.join(proj, '.claude-code-hermit') : null; + if (fromEnv && fs.existsSync(path.join(fromEnv, 'config.json')) && !isWorktreeProjection(fromEnv)) { + return fromEnv; } let dir = startDir; for (let i = 0; i < 8; i++) { - if (fs.existsSync(path.join(dir, '.claude-code-hermit', 'config.json'))) return path.join(dir, '.claude-code-hermit'); + const cch = path.join(dir, '.claude-code-hermit'); + if (fs.existsSync(path.join(cch, 'config.json')) && !isWorktreeProjection(cch)) return cch; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/plugins/claude-code-dev-hermit/scripts/record-test-result.test.ts b/plugins/claude-code-dev-hermit/scripts/record-test-result.test.ts index 71a4d85d..644dd505 100644 --- a/plugins/claude-code-dev-hermit/scripts/record-test-result.test.ts +++ b/plugins/claude-code-dev-hermit/scripts/record-test-result.test.ts @@ -20,7 +20,9 @@ let failed = 0; function setupProject(testCmd: string | null): string { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-test-')); const hermit = path.join(tmp, '.claude-code-hermit'); - fs.mkdirSync(hermit, { recursive: true }); + // state/ alongside config.json is what marks a real root: config.json on its + // own is the worktree-projection shape, which findHermitDir walks past. + fs.mkdirSync(path.join(hermit, 'state'), { recursive: true }); const cfg = testCmd !== null ? { 'claude-code-dev-hermit': { commands: { test: testCmd } } } : {}; @@ -93,7 +95,7 @@ cleanup(proj); const tmpNoGit = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-test-nogit-')); const hermitNoGit = path.join(tmpNoGit, '.claude-code-hermit'); -fs.mkdirSync(hermitNoGit); +fs.mkdirSync(path.join(hermitNoGit, 'state'), { recursive: true }); fs.writeFileSync(path.join(hermitNoGit, 'config.json'), JSON.stringify({ 'claude-code-dev-hermit': { commands: { test: 'npm test' } } })); runHook(tmpNoGit, { tool_input: { command: 'npm test' }, tool_response: { exit_code: 0 } }); assert('does not write last-test.json when not in a git repo', !fs.existsSync(path.join(hermitNoGit, 'state', 'last-test.json'))); @@ -105,7 +107,7 @@ assert('exits 0 cleanly when no .claude-code-hermit/ found', rc === 0); fs.rmSync(tmp, { recursive: true, force: true }); const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-test-notestcmd-')); -fs.mkdirSync(path.join(tmp2, '.claude-code-hermit')); +fs.mkdirSync(path.join(tmp2, '.claude-code-hermit', 'state'), { recursive: true }); fs.writeFileSync(path.join(tmp2, '.claude-code-hermit', 'config.json'), JSON.stringify({})); const rc2 = runHook(tmp2, { tool_input: { command: 'anything' }, tool_response: { exit_code: 0 } }); assert('exits 0 when commands.test is not configured', rc2 === 0); diff --git a/plugins/claude-code-hermit/CHANGELOG.md b/plugins/claude-code-hermit/CHANGELOG.md index f2af758b..fd026d4b 100644 --- a/plugins/claude-code-hermit/CHANGELOG.md +++ b/plugins/claude-code-hermit/CHANGELOG.md @@ -7,6 +7,9 @@ - The session-start and boot version checks compare version *direction* instead of string inequality. A config stamp ahead of the loaded plugin now reports `---Stale Plugin Runtime---` with the loaded path and the scoped `claude plugin update` to run, instead of demanding `hermit-evolve` — which could not clear it, so always-on hermits re-dispatched an evolve subagent every session start forever. - `hermit-evolve` stops before any migration when the loaded plugin is older than the applied version, and `evolve-finalize` refuses a `--core` below the on-disk stamp (`core_version_regression`, config left untouched). Previously a stale install plus any sibling gap or CLAUDE-APPEND drift ran the migrations and then silently lowered `_hermit_versions` without reversing them. Sibling stamps get the same no-downgrade rule as a skip. - The Stop hook's harness-command drain reads `state/runtime.json` anchored to the hermit root instead of the process cwd. A drifted hook cwd made the read miss, so an operator's channel-requested `/clear` or `/model` was silently declined on every turn until it expired at its one-hour TTL, while the matching `context_cleared` write stayed anchored (the same read/write split `applyContextReset` fixed in 1.2.38). +- The `.worktreeinclude` managed block carries `.claude-code-hermit/config.json` into `claude --worktree` worktrees. Without it a worktree session got a state dir with no readable config, so anything that reads a config key at the relative path failed or silently skipped, and the resulting error pointed at a re-hatch that was never the problem. Existing hermits get the line via an Upgrade Instructions step; state writes stay pinned to the main checkout, which the resolver change below is what keeps true. +- The state-dir resolver walks past a worktree's projected `.claude-code-hermit/` instead of resolving to it. `config.json` doubles as the resolver sentinel, so copying it into a worktree (above) would have made the projection its own hermit root — every ledger read and write from a `claude --worktree` session anchored to a dir with no `state/`, which is the same silent skip the `routines.ts health` fix below repairs. A projection is identified by the sentinel without `state/`, which `.worktreeinclude` never copies; nothing creates `state/` there because no resolver returns it. Also closes the pre-existing case where an ambient `CLAUDE_PROJECT_DIR` naming the worktree anchored hook-driven writers to the partial copy. +- `routines.ts health` anchors a relative `hermit-dir` argument through the same resolver as the rest of the state path instead of the process cwd. Both callers (`reflect`, `hermit-evolution`) pass the relative `.claude-code-hermit`, so any earlier `cd` made the reader report `source: missing` — which both skills read as "no candidates", turning a lost ledger into a silent skip. An absolute argument is still honoured as passed. ### Changed ### Added - `scripts/lib/config-read.ts` — one settled read path for `config.json`: `readSettledConfig` never throws, settles malformed values by declared shape (never vocabulary, so custom operator values survive), preserves explicit `null`s and unknown keys at every nesting level, and settles malformed containers to empty rather than template seeds. `readConfigRaw` remains for the few consumers that must distinguish an unreadable config (routines run records, the prompt pipeline's disclosure gates, `channel-send`'s `config_read_failed`). @@ -35,6 +38,18 @@ - A `###` sub-heading above a real section no longer hijacks that section's body — affects session-start injection, `.status.json` task/blockers, Progress Log staleness, the Monitoring bloat check, and the `reflect --quick` hash. - Session-start injection and the session quality score no longer read a section as empty when its content sits below a retained `` placeholder. +### Upgrade Instructions + +1. **Add `config.json` to the `.worktreeinclude` managed block.** Read the project root's `.worktreeinclude`. If the file does not exist, or exists without the `# >>> claude-code-hermit` marker, skip this step — the operator declined the block at hatch, and it is not re-added here. Otherwise, look inside the marker block for a `.claude-code-hermit/config.json` line: if it is already present, make no change; if it is absent, insert it on its own line immediately after `.claude-code-hermit/OPERATOR.md`, leaving every other line in the block untouched. The block should end up as: + ``` + # >>> claude-code-hermit (managed block — do not edit between markers) >>> + .claude-code-hermit/OPERATOR.md + .claude-code-hermit/config.json + .claude-code-hermit/compiled/ + # <<< claude-code-hermit <<< + ``` + This is what lets a `claude --worktree` session read config keys such as `commands.*` at the relative path; without it those reads fail inside the worktree. No `.gitignore` change is needed: `config.json` is already in the hermit's gitignore block, which is what makes it eligible to be copied. _(Opt-out: delete the line again; the rest of the block is unaffected.)_ + ## [1.2.38] - 2026-08-12 ### Added diff --git a/plugins/claude-code-hermit/scripts/lib/cc-compat.ts b/plugins/claude-code-hermit/scripts/lib/cc-compat.ts index 1ccc1850..4eeb7fbe 100644 --- a/plugins/claude-code-hermit/scripts/lib/cc-compat.ts +++ b/plugins/claude-code-hermit/scripts/lib/cc-compat.ts @@ -36,7 +36,8 @@ type TriState = { state: string; count: number; entries: Json[] }; * HA projectRoot (homeassistant-hermit/src/config.ts) → the project root (parent) * dev findHermitDir(dev-hermit/scripts/lib/find-hermit-dir.ts) → the .cch dir or null * INVARIANT: hermitDir() === path.join(projectRoot(), '.claude-code-hermit'). - * Fix one (env-var precedence, iteration cap) → check the other two. + * Fix one (env-var precedence, iteration cap, worktree-projection skip) → check + * the other two. * * Robust to a drifted hook cwd (#384). A *relative* AGENT_DIR (the legacy * drift-prone default, e.g. `AGENT_DIR=".claude-code-hermit"`) is intentionally @@ -46,15 +47,39 @@ function hermitDir(): string { const agent = process.env.AGENT_DIR; if (agent && path.isAbsolute(agent)) return path.resolve(agent); const proj = process.env.CLAUDE_PROJECT_DIR; - if (proj) { const d = path.join(proj, '.claude-code-hermit'); if (fs.existsSync(d)) return d; } + if (proj) { const d = path.join(proj, '.claude-code-hermit'); if (fs.existsSync(d) && !isWorktreeProjection(d)) return d; } return findHermitDir(process.cwd()) ?? path.resolve('.claude-code-hermit'); // fail-open: preserves today's behavior } +/** + * A worktree's *projected* `.claude-code-hermit/` — never a resolution target. + * + * `.worktreeinclude`'s managed block copies OPERATOR.md, config.json and + * compiled/ into a `claude --worktree` worktree so skills can Read them at the + * relative path they expect, but never `state/` — hermit state is deliberately + * main-rooted and shared across worktrees. So a dir carrying the config.json + * sentinel with no `state/` is a projection of a real root further up, and the + * resolvers walk past it to that root. + * + * The `state/` test stays true only because no resolver returns a projection, + * so nothing ever creates `state/` inside one. A writer that mkdir's its own + * state dir must resolve through a resolver, never off cwd. Out-of-tree + * worktrees (`git worktree add ../wt`) are the one gap: the walk can't reach + * main, so hermitDir() fails open to the cwd-relative path — the projection — + * exactly as it did before this guard existed. + * + * Mirrored by the sibling fleet resolvers named above — fix one, fix all. + */ +function isWorktreeProjection(cchDir: string): boolean { + return fs.existsSync(path.join(cchDir, 'config.json')) && !fs.existsSync(path.join(cchDir, 'state')); +} + /** * The bounded walk behind hermitDir(), for callers that start somewhere other than * cwd and need to know when nothing was found: same 8-level cap and config.json - * sentinel, null instead of the fail-open default. + * sentinel, null instead of the fail-open default. Worktree projections are + * walked past, not returned — see isWorktreeProjection(). * * Deliberately env-free. Callers like routines/event.ts pass a root their caller * already resolved; an ambient CLAUDE_PROJECT_DIR must not override an explicit @@ -63,7 +88,8 @@ function hermitDir(): string { function findHermitDir(startDir: string): string | null { let dir = startDir; for (let i = 0; i < 8; i++) { - if (fs.existsSync(path.join(dir, '.claude-code-hermit', 'config.json'))) return path.join(dir, '.claude-code-hermit'); + const cch = path.join(dir, '.claude-code-hermit'); + if (fs.existsSync(path.join(cch, 'config.json')) && !isWorktreeProjection(cch)) return cch; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -123,9 +149,11 @@ function pinnedRoot(resolved: string, matches: (root: string) => boolean): strin if (matches(hermitDir())) return resolved; // Worktree case. Hermit state is deliberately main-rooted and shared across // worktrees, so a worktree session legitimately passes the main checkout's - // absolute path — but hermitDir()'s walk-up stops at the partial decoy state - // dir a worktree carries, so the two disagree. Consulted only after a - // mismatch, so the common path never spawns git. + // absolute path. For a worktree under the repo the two now agree — the walk-up + // skips the projection and lands on main — so this is the out-of-tree fallback + // (`git worktree add ../wt`), where the walk can't reach main and hermitDir() + // fails open to the projection. Consulted only after a mismatch, so the common + // path never spawns git. const main = mainCheckoutStateDir(); return main !== null && matches(main) ? resolved : null; } diff --git a/plugins/claude-code-hermit/scripts/lib/routines/health.ts b/plugins/claude-code-hermit/scripts/lib/routines/health.ts index ce72b4a6..2fe2afd9 100644 --- a/plugins/claude-code-hermit/scripts/lib/routines/health.ts +++ b/plugins/claude-code-hermit/scripts/lib/routines/health.ts @@ -92,9 +92,14 @@ export function run(args: string[]): void { } } + // A relative argv is NOT resolved against the process cwd: both documented + // callers pass the relative `.claude-code-hermit`, and a `cd` earlier in the + // session would silently point that at a nonexistent ledger — `source: + // missing`, which the skills read as "nothing to report". Same treatment as + // weekly-review.ts's hermit arg. let hermit: string; try { - hermit = dir ? path.resolve(dir) : resolveHermitDir(); + hermit = dir && path.isAbsolute(dir) ? dir : resolveHermitDir(); } catch { process.stderr.write('routines.ts health: could not resolve the hermit state dir\n'); process.exit(1); diff --git a/plugins/claude-code-hermit/skills/hatch/SKILL.md b/plugins/claude-code-hermit/skills/hatch/SKILL.md index e880955b..8ed8b921 100644 --- a/plugins/claude-code-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-hermit/skills/hatch/SKILL.md @@ -485,7 +485,7 @@ Read the template. Determine which lines are missing from the project's `.gitign Use `${CLAUDE_SKILL_DIR}/../../state-templates/WORKTREEINCLUDE-APPEND.txt`. -The file contains a managed block bounded by marker comments (`# >>> claude-code-hermit ...` / `# <<< claude-code-hermit >>>`). This block carries read-only hermit context (OPERATOR.md, compiled/) into `claude --worktree` worktrees. **Write it unconditionally — no git-repo gate.** A `.worktreeinclude` in a non-git project is harmless and ready when the operator later runs `git init`. +The file contains a managed block bounded by marker comments (`# >>> claude-code-hermit ...` / `# <<< claude-code-hermit >>>`). This block carries read-only hermit context (OPERATOR.md, config.json, compiled/) into `claude --worktree` worktrees; `config.json` is there so config keys such as `commands.*` are readable at the relative path inside the worktree. **Write it unconditionally — no git-repo gate.** A `.worktreeinclude` in a non-git project is harmless and ready when the operator later runs `git init`. - If `.worktreeinclude` is absent: show the operator the template that will be written, and ask with `AskUserQuestion` (header: "Create .worktreeinclude") — options: **Yes — create** (default) / **No — skip**. Create only if confirmed. - If `.worktreeinclude` exists and the `# >>> claude-code-hermit` marker is already present: skip silently. diff --git a/plugins/claude-code-hermit/state-templates/WORKTREEINCLUDE-APPEND.txt b/plugins/claude-code-hermit/state-templates/WORKTREEINCLUDE-APPEND.txt index a2a7f8b4..5d60dcfd 100644 --- a/plugins/claude-code-hermit/state-templates/WORKTREEINCLUDE-APPEND.txt +++ b/plugins/claude-code-hermit/state-templates/WORKTREEINCLUDE-APPEND.txt @@ -1,4 +1,5 @@ # >>> claude-code-hermit (managed block — do not edit between markers) >>> .claude-code-hermit/OPERATOR.md +.claude-code-hermit/config.json .claude-code-hermit/compiled/ # <<< claude-code-hermit <<< diff --git a/plugins/claude-code-hermit/tests/cc-compat-hermitdir.test.ts b/plugins/claude-code-hermit/tests/cc-compat-hermitdir.test.ts index beb7ae4c..45c0c0df 100644 --- a/plugins/claude-code-hermit/tests/cc-compat-hermitdir.test.ts +++ b/plugins/claude-code-hermit/tests/cc-compat-hermitdir.test.ts @@ -133,6 +133,19 @@ describe('hermitDir()', () => { } }); + it.serial('(b3) CLAUDE_PROJECT_DIR names a worktree projection — falls to walk-up', () => { + // A `claude --worktree` session: CLAUDE_PROJECT_DIR is the worktree, whose + // .cch dir is the projected copy (config.json, no state/). Honouring it + // would anchor every hook-driven writer to a state dir that isn't there. + delete process.env.AGENT_DIR; + const wt = path.join(tmp, '.claude', 'worktrees', 'wt'); + fs.mkdirSync(path.join(wt, '.claude-code-hermit'), { recursive: true }); + fs.writeFileSync(path.join(wt, '.claude-code-hermit', 'config.json'), '{}'); + process.env.CLAUDE_PROJECT_DIR = wt; + process.chdir(wt); + expect(hermitDir()).toBe(path.join(tmp, '.claude-code-hermit')); + }); + // findHermitDir() is hermitDir()'s walk without the env branches or the // fail-open tail: callers that start somewhere other than cwd (routines/event.ts) // need a null they can refuse on, and must not have an ambient @@ -166,9 +179,7 @@ describe('hermitDir()', () => { it('walks past a config-less decoy to the real project above it', () => { // A partially-populated `.claude-code-hermit/` — OPERATOR.md but no - // config.json — must not capture the walk. (Not the git-worktree shape: - // `.worktreeinclude`'s managed block copies config.json in, so a worktree - // copy IS the match.) + // config.json — must not capture the walk. const root = makeTmpHermit(); try { const worktree = path.join(root, '.claude', 'worktrees', 'wt'); @@ -180,6 +191,37 @@ describe('hermitDir()', () => { } }); + it('walks past a worktree projection to the main checkout above it', () => { + // The real `claude --worktree` shape: `.worktreeinclude`'s managed block + // copies OPERATOR.md, config.json and compiled/ in, but never state/. + // The config.json sentinel alone would capture the walk here and route + // every ledger read and write into a state dir that does not exist. + const root = makeTmpHermit(); + try { + const wt = path.join(root, '.claude', 'worktrees', 'wt', '.claude-code-hermit'); + fs.mkdirSync(path.join(wt, 'compiled'), { recursive: true }); + fs.writeFileSync(path.join(wt, 'OPERATOR.md'), ''); + fs.writeFileSync(path.join(wt, 'config.json'), '{}'); + expect(findHermitDir(path.dirname(wt))).toBe(path.join(root, '.claude-code-hermit')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('accepts a root once it has state/ — the projection test is state-based', () => { + // Guards the discriminator itself: config.json + state/ is a real root at + // any depth, so the skip above can never swallow a genuine nested hermit. + const root = makeTmpHermit(); + try { + const nested = path.join(root, 'sub', 'project'); + fs.mkdirSync(path.join(nested, '.claude-code-hermit', 'state'), { recursive: true }); + fs.writeFileSync(path.join(nested, '.claude-code-hermit', 'config.json'), '{}'); + expect(findHermitDir(nested)).toBe(path.join(nested, '.claude-code-hermit')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('ignores CLAUDE_PROJECT_DIR — the caller-supplied start wins', () => { const elsewhere = makeTmpHermit(); try { diff --git a/plugins/claude-code-hermit/tests/contracts.test.ts b/plugins/claude-code-hermit/tests/contracts.test.ts index 47b5d8e3..923f7d2f 100644 --- a/plugins/claude-code-hermit/tests/contracts.test.ts +++ b/plugins/claude-code-hermit/tests/contracts.test.ts @@ -3123,3 +3123,31 @@ describe('stale plugin runtime header', () => { expect(brief).toContain(HEADER); }); }); + +describe('worktree state-dir template contract', () => { + // config.json must ride into a `claude --worktree` copy: skills read config + // keys (commands.*, and anything else operator-set) at the relative path, and + // those reads hard-fail inside a worktree without it. The resolver comments in + // routines/event.ts and cc-compat.ts also assert this block carries it, so a + // regression here silently makes those comments false. + const block = read(path.join(TEMPLATES, 'WORKTREEINCLUDE-APPEND.txt')); + const CONFIG_LINE = '.claude-code-hermit/config.json'; + + test('managed block carries OPERATOR.md, config.json and compiled/, in that order', () => { + const operator = block.indexOf('.claude-code-hermit/OPERATOR.md'); + const config = block.indexOf(CONFIG_LINE); + const compiled = block.indexOf('.claude-code-hermit/compiled/'); + expect(operator).toBeGreaterThan(-1); + expect(config).toBeGreaterThan(operator); + expect(compiled).toBeGreaterThan(config); + }); + + test('config.json sits inside the managed markers', () => { + const open = block.indexOf('# >>> claude-code-hermit'); + const close = block.indexOf('# <<< claude-code-hermit'); + expect(open).toBeGreaterThan(-1); + expect(close).toBeGreaterThan(open); + expect(block.indexOf(CONFIG_LINE)).toBeGreaterThan(open); + expect(block.indexOf(CONFIG_LINE)).toBeLessThan(close); + }); +}); diff --git a/plugins/claude-code-hermit/tests/cost-report-today.test.ts b/plugins/claude-code-hermit/tests/cost-report-today.test.ts index 6841261a..000b31d2 100644 --- a/plugins/claude-code-hermit/tests/cost-report-today.test.ts +++ b/plugins/claude-code-hermit/tests/cost-report-today.test.ts @@ -29,7 +29,9 @@ function seedCostLog(dir: string, entries: object[]): void { function seedHermitRoot(dir: string): void { const hermitDir = path.join(dir, '.claude-code-hermit'); - fs.mkdirSync(hermitDir, { recursive: true }); + // state/ alongside config.json is what marks a real root: config.json on its + // own is the worktree-projection shape, which the resolver walks past. + fs.mkdirSync(path.join(hermitDir, 'state'), { recursive: true }); fs.writeFileSync(path.join(hermitDir, 'config.json'), '{}'); } diff --git a/plugins/claude-code-hermit/tests/proposal-write.test.ts b/plugins/claude-code-hermit/tests/proposal-write.test.ts index df203308..0e5c7a17 100644 --- a/plugins/claude-code-hermit/tests/proposal-write.test.ts +++ b/plugins/claude-code-hermit/tests/proposal-write.test.ts @@ -519,8 +519,10 @@ describe('proposal.ts state-dir pin', () => { fs.mkdirSync(path.join(wt, '.claude-code-hermit'), { recursive: true }); fs.writeFileSync(path.join(wt, '.claude-code-hermit', 'config.json'), '{}'); - // No AGENT_DIR: hermitDir() must walk up into the decoy for this to be a - // real reproduction of the worktree layout. + // No AGENT_DIR: hermitDir() must resolve off the worktree cwd for this to + // be a real reproduction. The walk skips the projection (config.json, no + // state/) and lands on main, so argv and hermitDir() agree here; the + // mainCheckoutStateDir() fallback covers the out-of-tree worktree instead. const r = await runScript('proposal.ts', { args: ['create', path.join(main, '.claude-code-hermit')], cwd: wt, @@ -544,7 +546,7 @@ describe('proposal.ts state-dir pin', () => { // walks up into it and never reaches the root one. seedState(dir); const sub = path.join(dir, 'sub'); - fs.mkdirSync(path.join(sub, '.claude-code-hermit'), { recursive: true }); + fs.mkdirSync(path.join(sub, '.claude-code-hermit', 'state'), { recursive: true }); fs.writeFileSync(path.join(sub, '.claude-code-hermit', 'config.json'), '{}'); const created = await runScript('proposal.ts', { diff --git a/plugins/claude-code-hermit/tests/routine-health.test.ts b/plugins/claude-code-hermit/tests/routine-health.test.ts index 172b795a..5d934fe0 100644 --- a/plugins/claude-code-hermit/tests/routine-health.test.ts +++ b/plugins/claude-code-hermit/tests/routine-health.test.ts @@ -8,7 +8,7 @@ import { describe, test, expect } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { withDir } from './helpers/workdir'; +import { withDir, writeConfig } from './helpers/workdir'; import { runScript } from './helpers/run'; import { foldRoutineHistory, readRoutineHistory, lastRoutineFire } from '../scripts/lib/routines/history'; import { buildRoutineHealth } from '../scripts/lib/routines/health'; @@ -256,9 +256,15 @@ describe('buildRoutineHealth — cost join', () => { }); describe('routines.ts health — CLI', () => { + // The relative arg anchors through resolveHermitDir(), which honours an + // absolute AGENT_DIR and CLAUDE_PROJECT_DIR before it walks. runScript + // inherits the real session's env, so both are pinned empty here — an + // inherited value would point these fixtures at this repo's own state dir. + const ANCHOR_ENV = { CLAUDE_PROJECT_DIR: '', AGENT_DIR: '' }; + test('prints parseable JSON with the documented top-level keys', withDir(async (dir) => { writeMetrics(dir, [row('brief', 'started', daysAgo(1)), row('brief', 'fired', daysAgo(1))]); - const r = await runScript('routines.ts', { args: ['health', '.claude-code-hermit'], cwd: dir }); + const r = await runScript('routines.ts', { args: ['health', '.claude-code-hermit'], cwd: dir, env: ANCHOR_ENV }); expect(r.exitCode).toBe(0); const out = JSON.parse(r.stdout); expect(Object.keys(out).sort()).toEqual([ @@ -270,7 +276,7 @@ describe('routines.ts health — CLI', () => { test('--days is honoured', withDir(async (dir) => { writeMetrics(dir, [row('brief', 'fired', daysAgo(10))]); - const r = await runScript('routines.ts', { args: ['health', '.claude-code-hermit', '--days', '7'], cwd: dir }); + const r = await runScript('routines.ts', { args: ['health', '.claude-code-hermit', '--days', '7'], cwd: dir, env: ANCHOR_ENV }); const out = JSON.parse(r.stdout); expect(out.window_days).toBe(7); expect(out.routines).toEqual([]); @@ -286,3 +292,57 @@ describe('routines.ts health — CLI', () => { expect(r.exitCode).toBe(1); }); }); + +// Both documented callers (skills/reflect/reference.md, skills/hermit-evolution/ +// reference.md) pass the relative `.claude-code-hermit`, and a `cd` earlier in the +// session moves what that resolves to. Resolving it against the process cwd made +// the reader report `source: missing`, which both skills treat as "emit no +// candidates" — a silent skip, not a visible failure. Same drift class as the +// writer paths fixed in 1fc2642c. +describe('routines.ts health — cwd drift', () => { + /** Fixture root with a hatched hermit, plus a nested cwd to run from. */ + function drifted(dir: string): string { + writeConfig(dir, {}); + writeMetrics(dir, [row('brief', 'started', daysAgo(1)), row('brief', 'fired', daysAgo(1))]); + const nested = path.join(dir, 'packages', 'app'); + fs.mkdirSync(nested, { recursive: true }); + return nested; + } + + test('a relative arg from a subdirectory resolves via the walk-up, not cwd', withDir(async (dir) => { + const nested = drifted(dir); + const r = await runScript('routines.ts', { + args: ['health', '.claude-code-hermit'], + cwd: nested, + // Empty (not absent): runScript inherits the real session's env, and an + // inherited value would decide the resolution instead of the walk. + env: { CLAUDE_PROJECT_DIR: '', AGENT_DIR: '' }, + }); + expect(r.exitCode).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.source).toBe('ok'); + expect(out.routines[0]).toMatchObject({ id: 'brief', fires: 1 }); + })); + + test('CLAUDE_PROJECT_DIR anchors the relative arg from a subdirectory', withDir(async (dir) => { + const nested = drifted(dir); + const r = await runScript('routines.ts', { + args: ['health', '.claude-code-hermit'], + cwd: nested, + env: { CLAUDE_PROJECT_DIR: dir, AGENT_DIR: '' }, + }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).source).toBe('ok'); + })); + + test('an absolute arg is still honoured as passed', withDir(async (dir) => { + const nested = drifted(dir); + const r = await runScript('routines.ts', { + args: ['health', hermitOf(dir)], + cwd: nested, + env: { CLAUDE_PROJECT_DIR: '', AGENT_DIR: '' }, + }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).source).toBe('ok'); + })); +}); diff --git a/plugins/claude-code-hermit/tests/template-skill-sync.test.ts b/plugins/claude-code-hermit/tests/template-skill-sync.test.ts index 5bcb52a8..f7999c09 100644 --- a/plugins/claude-code-hermit/tests/template-skill-sync.test.ts +++ b/plugins/claude-code-hermit/tests/template-skill-sync.test.ts @@ -117,17 +117,34 @@ describe('.worktreeinclude template', () => { expect(skillContent).toContain('WORKTREEINCLUDE-APPEND.txt'); }); - test('template only contains the two allowed paths (safety-invariant: no runtime state)', () => { - const raw = fs.readFileSync(WORKTREEINCLUDE_PATH, 'utf-8'); - const effectiveLines = raw - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0 && !l.startsWith('#')); - expect(effectiveLines).toEqual([ + // config.json joined the allow-list once the dev hermit began reading + // commands.* from the worktree copy. It is not an exception to the + // safety-invariant below: state writes stay pinned to the main checkout by + // cc-compat's pinnedRoot()/mainCheckoutStateDir(), and config.json carries no + // credentials (channel tokens live outside it). The invariant that matters — + // no runtime state, no session history, no ledgers — is asserted directly. + // Read inside the tests, not at describe-registration time: a missing template + // must fail the existence test above with its own message, not throw while the + // file is still being collected and take every test in this file with it. + const effectivePaths = () => fs.readFileSync(WORKTREEINCLUDE_PATH, 'utf-8') + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('#')); + + test('template contains exactly the three allowed paths', () => { + expect(effectivePaths()).toEqual([ '.claude-code-hermit/OPERATOR.md', + '.claude-code-hermit/config.json', '.claude-code-hermit/compiled/', ]); }); + + test('safety-invariant: no runtime state, sessions, ledgers or channel data', () => { + const forbidden = ['state/', 'sessions/', 'proposals/', 'raw/', 'cost-log', '.jsonl', '.db']; + for (const entry of effectivePaths()) { + for (const f of forbidden) expect(entry).not.toContain(f); + } + }); }); // ------------------------------------------------------- diff --git a/plugins/claude-code-homeassistant-hermit/CHANGELOG.md b/plugins/claude-code-homeassistant-hermit/CHANGELOG.md index e0f07497..9fb1ab1f 100644 --- a/plugins/claude-code-homeassistant-hermit/CHANGELOG.md +++ b/plugins/claude-code-homeassistant-hermit/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed +- `projectRoot()` walks past a worktree's projected `.claude-code-hermit/` (the `config.json` sentinel with no `state/`) to the main checkout, matching core and dev. Core now copies `config.json` into `claude --worktree` worktrees, which would otherwise have anchored HA snapshots, audits and staged YAML to the partial copy in a worktree session. + ### Changed - `ha-agent-lab` keeps one record per command in `src/cli.ts` — parser spec, `--help` block and handler together. The command list, the `ha --help` body and the dispatch all derive from that table, so a command can no longer be declared without a spec (previously a runtime crash rather than a type error). No command behavior changed and `--help` output is byte-identical, now pinned by a fixture test. diff --git a/plugins/claude-code-homeassistant-hermit/src/config.ts b/plugins/claude-code-homeassistant-hermit/src/config.ts index 9325aa51..5422ec0f 100644 --- a/plugins/claude-code-homeassistant-hermit/src/config.ts +++ b/plugins/claude-code-homeassistant-hermit/src/config.ts @@ -20,7 +20,8 @@ import { dumpFrontmatter, loadFrontmatter } from './markdown'; * HA projectRoot (homeassistant-hermit/src/config.ts) → project root (this file) * dev findHermitDir(dev-hermit/scripts/git-push-guard.ts) → the .cch dir or null * INVARIANT: hermitDir() === join(projectRoot(), '.claude-code-hermit'). - * Fix one (env-var precedence, iteration cap) → check the other two. + * Fix one (env-var precedence, iteration cap, worktree-projection skip) → check + * the other two. * * Returns the project ROOT (the dir containing .claude-code-hermit), NOT the * .cch dir itself — callers append paths themselves. Does NOT honor AGENT_DIR @@ -29,10 +30,14 @@ import { dumpFrontmatter, loadFrontmatter } from './markdown'; */ export function projectRoot(): string { const proj = process.env.CLAUDE_PROJECT_DIR; - if (proj && existsSync(join(proj, '.claude-code-hermit'))) return proj; + if (proj) { + const cch = join(proj, '.claude-code-hermit'); + if (existsSync(cch) && !isWorktreeProjection(cch)) return proj; + } let dir = process.cwd(); for (let i = 0; i < 8; i++) { - if (existsSync(join(dir, '.claude-code-hermit', 'config.json'))) return dir; + const cch = join(dir, '.claude-code-hermit'); + if (existsSync(join(cch, 'config.json')) && !isWorktreeProjection(cch)) return dir; const parent = dirname(dir); if (parent === dir) break; dir = parent; @@ -40,6 +45,17 @@ export function projectRoot(): string { return process.cwd(); // fail-open: preserves today's behavior } +// A worktree's projected `.claude-code-hermit/` — never a resolution target. +// `.worktreeinclude`'s managed block copies config.json into a `claude --worktree` +// worktree so skills can Read it at the relative path they expect, but never +// `state/`: hermit state is main-rooted and shared across worktrees. So the +// sentinel without `state/` means a projection of a real root further up, and +// the walk continues to it. Mirrored in core's cc-compat.ts and dev's +// find-hermit-dir.ts — fix one, fix all three. +function isWorktreeProjection(cchDir: string): boolean { + return existsSync(join(cchDir, 'config.json')) && !existsSync(join(cchDir, 'state')); +} + export class AppConfig { constructor( readonly root: string, diff --git a/plugins/feed-hermit/hooks/fetch-guard.ts b/plugins/feed-hermit/hooks/fetch-guard.ts index aafb78ce..04625962 100644 --- a/plugins/feed-hermit/hooks/fetch-guard.ts +++ b/plugins/feed-hermit/hooks/fetch-guard.ts @@ -24,6 +24,13 @@ const INFRA_ALLOWLIST = [ // project ROOT, not the state dir: feed-sources.md is operator-owned and lives at // the root. A hook's cwd is the session's shell cwd and drifts with `cd`, so the // allowlist is never read relative to it. +// +// Deliberately WITHOUT the other resolvers' worktree-projection skip. They walk +// past a worktree's projected `.claude-code-hermit/` because they want the +// main-rooted shared STATE dir. This one wants the project the session is in, +// and feed-sources.md is a tracked file, so a `claude --worktree` session must +// read the worktree's copy — the branch's allowlist, not main's. Stopping at the +// projection is the correct answer here. Do not "align" this with the others. export function projectRoot(): string { const proj = process.env.CLAUDE_PROJECT_DIR; if (proj && existsSync(join(proj, SENTINEL))) return proj;