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 @@ -7,6 +7,7 @@
- `record-test-result` crashed with exit 1 on a valid-JSON payload that is not an object (`null`), instead of failing open.
- `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.

## [0.4.8] - 2026-07-26

Expand Down
51 changes: 51 additions & 0 deletions plugins/claude-code-dev-hermit/scripts/git-push-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import fs from 'node:fs';

type Json = any;

// findHermitDir() honors CLAUDE_PROJECT_DIR. These tests run inside a Claude Code
// session, where it names the real repo — every fixture below would resolve to the
// repo's own hermit state instead of its temp project. Scrub it for the whole file;
// the two cases that need it set pass it explicitly.
delete process.env.CLAUDE_PROJECT_DIR;

const GUARD = path.join(import.meta.dir, 'git-push-guard.ts');

let passed = 0;
Expand Down Expand Up @@ -269,6 +275,51 @@ console.log('\nInactive-profile notice:');
assertEmpty('non-strict non-push emits no notice', r.stderr || '');
}

// --- CLAUDE_PROJECT_DIR precedence (drifted cwd) ---
// The guard's config lookup used to start at process.cwd(); a session that had
// `cd`-ed anywhere outside the project silently lost operator protected_branches
// and fell back to the built-in main/master list.
console.log('\nCLAUDE_PROJECT_DIR precedence:');
{
// A hatched project whose config protects release/*, and a drifted cwd outside it.
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.writeFileSync(
path.join(hermitDir, 'config.json'),
JSON.stringify({ 'claude-code-dev-hermit': { protected_branches: ['release/prod'] } })
);
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(
'env-named project wins over a drifted cwd',
guardFrom(drifted, { CLAUDE_PROJECT_DIR: proj }),
2
);
assert(
'stale env falls through to the walk-up',
guardFrom(proj, { CLAUDE_PROJECT_DIR: path.join(os.tmpdir(), 'guard-no-such-dir') }),
2
);
assert(
'no env and a drifted cwd still degrades to the built-in list',
guardFrom(drifted, {}),
0
);
} finally {
fs.rmSync(proj, { recursive: true, force: true });
fs.rmSync(drifted, { recursive: true, force: true });
}
}

// --- Summary ---
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
23 changes: 20 additions & 3 deletions plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
// Walk up from startDir (max 8 levels) to the nearest .claude-code-hermit dir
// that has a config.json; return that dir, or null when none is found.
// Resolve the nearest .claude-code-hermit dir that has a config.json:
// CLAUDE_PROJECT_DIR when it names one, else a walk up from startDir (max 8
// levels). Returns that dir, or null when none is found.
//
// Returning null (rather than a fail-open default path) is load-bearing:
// git-push-guard falls back to the built-in protected-branch list on null instead
// of blocking, and record-test-result / dev-pr-transforms skip their hermit-state
// writes. Do NOT change this to core's fail-open hermitDir() default.
//
// The env check is sentinel-gated and falls through on a miss, so a stale
// CLAUDE_PROJECT_DIR degrades to the walk (today's behavior) rather than to null
// or to some unrelated project's store. Hook stdin carries a `cwd`, but it is the
// session's drifted shell cwd — identical to this process's own — so it anchors
// nothing and is deliberately not consulted.
//
// INVARIANT: mirrors core's cc-compat.ts hermitDir() shape (same 8-level cap,
// same config.json sentinel) — if you change the walk here, check that file too.
// 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.

import fs from 'node:fs';
import path from 'node:path';

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');
}
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');
Expand Down
40 changes: 24 additions & 16 deletions plugins/claude-code-dev-hermit/scripts/record-test-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import path from 'node:path';

type Json = any;

// findHermitDir() honors CLAUDE_PROJECT_DIR; scrub the inherited value so these
// fixtures resolve to their own temp projects, not the repo this suite runs in.
// Deleting it from process.env is not enough — spawnSync snapshots the parent env
// and only an explicit `env` reaches the child, so every spawn below passes CLEAN_ENV.
delete process.env.CLAUDE_PROJECT_DIR;
const CLEAN_ENV = { ...process.env };

const HOOK = path.join(import.meta.dir, 'record-test-result.ts');

let passed = 0;
Expand All @@ -28,6 +35,7 @@ function runHook(cwd: string, payload: Json) {
input: JSON.stringify(payload),
cwd,
encoding: 'utf-8',
env: CLEAN_ENV,
});
return result.status;
}
Expand Down Expand Up @@ -113,78 +121,78 @@ cleanup(proj);

// write subcommand — pass
proj = setupProject('npm test');
spawnSync(process.execPath, [HOOK, 'write', '0', '1234'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'write', '0', '1234'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
const expectedSha = execSync('git rev-parse HEAD', { cwd: proj, encoding: 'utf-8' }).trim();
const expectedSha = execSync('git rev-parse HEAD', { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV }).trim();
assert('write: records pass with exit_code 0', s !== null && s.exit_code === 0 && s.status === 'pass');
assert('write: records duration_ms', s !== null && s.duration_ms === 1234);
assert('write: records git HEAD sha', s !== null && s.sha === expectedSha);
cleanup(proj);

// write subcommand — fail
proj = setupProject('npm test');
spawnSync(process.execPath, [HOOK, 'write', '1', '500'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'write', '1', '500'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
assert('write: records fail with exit_code 1', s !== null && s.exit_code === 1 && s.status === 'fail');
cleanup(proj);

// write subcommand — invalid args
proj = setupProject('npm test');
spawnSync(process.execPath, [HOOK, 'write', '0abc', '1234'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'write', '0abc', '1234'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
assert('write: does not write on invalid exit_code arg', s === null);
cleanup(proj);

// run subcommand — pass
proj = setupProject('exit 0');
const runPass = spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, encoding: 'utf-8' });
const runPass = spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV });
s = readState(proj);
const runSha = execSync('git rev-parse HEAD', { cwd: proj, encoding: 'utf-8' }).trim();
const runSha = execSync('git rev-parse HEAD', { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV }).trim();
assert('run: exits 0 on passing test command', runPass.status === 0);
assert('run: records pass', s !== null && s.status === 'pass');
assert('run: records git HEAD sha', s !== null && s.sha === runSha);
cleanup(proj);

// run subcommand — fail
proj = setupProject('exit 1');
const runFail = spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, encoding: 'utf-8' });
const runFail = spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV });
s = readState(proj);
assert('run: exits 1 on failing test command', runFail.status === 1);
assert('run: records fail', s !== null && s.status === 'fail');
cleanup(proj);

// run subcommand — no commands.test
proj = setupProject(null);
const runNoCmd = spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, encoding: 'utf-8' });
const runNoCmd = spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV });
s = readState(proj);
assert('run: exits 1 when commands.test not configured', runNoCmd.status === 1);
assert('run: does not write when commands.test not configured', s === null);
cleanup(proj);

// likely_cause — oom (137)
proj = setupProject('exit 137');
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
assert('likely_cause: oom on exit 137', s !== null && s.likely_cause === 'oom');
cleanup(proj);

// likely_cause — timeout (124)
proj = setupProject('exit 124');
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
assert('likely_cause: timeout on exit 124', s !== null && s.likely_cause === 'timeout');
cleanup(proj);

// likely_cause — user-interrupt (130)
proj = setupProject('exit 130');
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
assert('likely_cause: user-interrupt on exit 130', s !== null && s.likely_cause === 'user-interrupt');
cleanup(proj);

// likely_cause — absent on generic non-zero
proj = setupProject('exit 1');
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj });
spawnSync(process.execPath, [HOOK, 'run'], { cwd: proj, env: CLEAN_ENV });
s = readState(proj);
assert('likely_cause: absent on generic exit 1', s !== null && !('likely_cause' in s));
cleanup(proj);
Expand All @@ -196,26 +204,26 @@ const child = path.join(proj, 'packages', 'foo');
fs.mkdirSync(child, { recursive: true });
const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
execSync('git init -q && git commit -q --allow-empty -m child-init', { cwd: child, env: gitEnv, stdio: 'ignore' });
const parentSha = execSync('git rev-parse HEAD', { cwd: proj, encoding: 'utf-8' }).trim();
const parentSha = execSync('git rev-parse HEAD', { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV }).trim();
const childSha = execSync('git rev-parse HEAD', { cwd: child, encoding: 'utf-8' }).trim();
assert('--cwd test setup: parent and child SHAs differ', parentSha !== childSha);
const cwdRun = spawnSync(process.execPath, [HOOK, 'run', '--cwd', child], { cwd: proj, encoding: 'utf-8' });
const cwdRun = spawnSync(process.execPath, [HOOK, 'run', '--cwd', child], { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV });
s = readState(proj);
assert('run --cwd: exits 0 on passing test command', cwdRun.status === 0);
assert('run --cwd: captures child HEAD sha (not parent)', s !== null && s.sha === childSha);
cleanup(proj);

// --cwd: bogus path fails fast
proj = setupProject('exit 0');
const cwdBogus = spawnSync(process.execPath, [HOOK, 'run', '--cwd', '/nonexistent/path/xyz'], { cwd: proj, encoding: 'utf-8' });
const cwdBogus = spawnSync(process.execPath, [HOOK, 'run', '--cwd', '/nonexistent/path/xyz'], { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV });
assert('run --cwd: exits non-zero on missing path', cwdBogus.status !== 0);
assert('run --cwd: does not write last-test.json on missing path', readState(proj) === null);
cleanup(proj);

// `git rev-parse --git-dir` walks up, so the dir must be outside any git repo.
proj = setupProject('exit 0');
const notGit = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-test-notgit-'));
const cwdNotGit = spawnSync(process.execPath, [HOOK, 'run', '--cwd', notGit], { cwd: proj, encoding: 'utf-8' });
const cwdNotGit = spawnSync(process.execPath, [HOOK, 'run', '--cwd', notGit], { cwd: proj, encoding: 'utf-8', env: CLEAN_ENV });
assert('run --cwd: exits non-zero on non-git dir', cwdNotGit.status !== 0);
assert('run --cwd: does not write last-test.json on non-git dir', readState(proj) === null);
fs.rmSync(notGit, { recursive: true, force: true });
Expand Down
1 change: 1 addition & 0 deletions plugins/claude-code-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

### Fixed
- Routine events are recorded under the hermit dir the caller already resolved instead of a second one re-derived from its parent. The round trip could only lose information: with the two resolvers disagreeing it could append to a *different* project's `routine-metrics.jsonl`, and in `routines.ts finish` it split the run record and the ledger row across two roots. The walk-up survives only in the `log-event` CLI verb, which has no caller-supplied anchor, capped at 8 levels like every other resolver. An append into a hermit dir with no `state/` now returns its documented error string instead of throwing a stack trace.
- 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).
Expand Down
19 changes: 17 additions & 2 deletions plugins/claude-code-hermit/scripts/lib/cc-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,28 @@ function hermitDir(): string {
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; }
let dir = process.cwd();
return findHermitDir(process.cwd())
?? path.resolve('.claude-code-hermit'); // fail-open: preserves today's behavior
}

/**
* 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.
*
* 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
* argument. Env precedence belongs in hermitDir(), which owns the cwd default.
*/
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 parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve('.claude-code-hermit'); // fail-open: preserves today's behavior
return null;
}

// The main checkout's state dir, or null when we are not in a LINKED worktree
Expand Down Expand Up @@ -522,6 +536,7 @@ function ccVersion(payload?: Json): string | null {
export {
// Project-root resolution
hermitDir,
findHermitDir,
assertStateDir,
assertUnderStateDir,
pinStateDirOrExit,
Expand Down
3 changes: 1 addition & 2 deletions plugins/claude-code-hermit/scripts/lib/routines/due.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ const hermitDir = process.argv[2];
if (!hermitDir) process.exit(0);

const stateDir = path.join(hermitDir, 'state');
const projectRoot = path.dirname(hermitDir);
const schedulePath = path.join(stateDir, 'routine-schedule.json');
const livenessPath = path.join(stateDir, 'routine-monitor-liveness.json');

Expand Down Expand Up @@ -80,7 +79,7 @@ function writeLiveness(): void {

function stamp(id: string, event: string): void {
try {
logRoutineEvent(id, event, 'monitor', projectRoot);
logRoutineEvent(id, event, hermitDir, 'monitor');
} catch { /* fail-open — a stamp failure must not block the routine */ }
}

Expand Down
Loading
Loading