Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/claude-code-dev-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 42 additions & 3 deletions plugins/claude-code-dev-hermit/scripts/git-push-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
Expand Down Expand Up @@ -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 } })
Expand Down Expand Up @@ -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'] } })
Expand Down Expand Up @@ -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);
34 changes: 24 additions & 10 deletions plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } }
: {};
Expand Down Expand Up @@ -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')));
Expand All @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions plugins/claude-code-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
42 changes: 35 additions & 7 deletions plugins/claude-code-hermit/scripts/lib/cc-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion plugins/claude-code-hermit/scripts/lib/routines/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading