From fdff8519a86396fceaef970366dce8b6b2d50fa2 Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Sat, 25 Jul 2026 16:20:04 +0100 Subject: [PATCH 1/2] feat(fleet): route domain plugins to core scripts via bin/hermit-run Installed domain plugins can't reach core's versioned cache dir from their own ${CLAUDE_PLUGIN_ROOT}, so HA's ha-morning-brief re-implemented core's micro-proposal writer inline and the domain-brainstorm skills told the model to hunt for core's root. Route them through the project-resident bin/hermit-run instead: harden its resolver (validate the env root by manifest, fail loud on marketplace ambiguity) and add a hermit-exec.sh name guard so the new permission globs can't reach a script outside core's scripts/. Add a micro-proposal.ts brief-cycle verb so both briefs age the queue in one call, and move the per-entry lifecycle into core's writer. Also: hermit-evolve Step 8 now delegates the permission merge to apply-settings allow (the hand-list had drifted and was denied under the strict hook profile); evolve sibling resolution applies local > project precedence; dev's triplicated findHermitDir collapses to one lib; and a root behavior test plus a path-scoped workflow guard the three automode-env copies. --- .github/workflows/test-cross-plugin.yml | 35 +++++ plugins/claude-code-dev-hermit/CHANGELOG.md | 1 + .../scripts/dev-pr-transforms.ts | 14 +- .../scripts/git-push-guard.ts | 12 +- .../scripts/lib/find-hermit-dir.ts | 24 +++ .../scripts/record-test-result.ts | 12 +- .../skills/domain-brainstorm/SKILL.md | 2 +- .../skills/hatch/SKILL.md | 2 +- .../claude-code-fitness-hermit/CHANGELOG.md | 1 + .../skills/domain-brainstorm/SKILL.md | 2 +- .../skills/hatch/SKILL.md | 2 +- plugins/claude-code-hermit/CHANGELOG.md | 6 + .../scripts/apply-settings.ts | 22 ++- .../claude-code-hermit/scripts/evolve-plan.ts | 6 +- .../claude-code-hermit/scripts/hermit-exec.sh | 9 ++ .../scripts/lib/cc-compat.ts | 6 +- .../scripts/micro-proposal.ts | 71 ++++++++- .../claude-code-hermit/skills/brief/SKILL.md | 10 +- .../claude-code-hermit/skills/hatch/SKILL.md | 5 +- .../skills/hermit-evolve/reference.md | 8 +- .../state-templates/bin/hermit-run | 38 ++++- .../tests/contracts.test.ts | 23 +++ .../tests/evolve-plan.test.ts | 9 +- .../tests/hermit-run.test.ts | 137 ++++++++++++++++++ .../claude-code-hermit/tests/scripts.test.ts | 49 +++++++ .../CHANGELOG.md | 5 +- .../skills/domain-brainstorm/SKILL.md | 2 +- .../skills/ha-morning-brief/SKILL.md | 18 +-- .../automode-env.behavior.test.ts | 127 ++++++++++++++++ 29 files changed, 577 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/test-cross-plugin.yml create mode 100644 plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts create mode 100644 plugins/claude-code-hermit/tests/hermit-run.test.ts create mode 100644 tests/cross-plugin/automode-env.behavior.test.ts diff --git a/.github/workflows/test-cross-plugin.yml b/.github/workflows/test-cross-plugin.yml new file mode 100644 index 00000000..663316b5 --- /dev/null +++ b/.github/workflows/test-cross-plugin.yml @@ -0,0 +1,35 @@ +name: Cross-Plugin Guards +# Runs repo-root behavior guards that span more than one plugin, so they can't +# live in any single plugin's release-blocking suite. Scoped tightly to the +# files each guard covers — a core-only or single-plugin PR must not trigger it. +# NOTE: the automode-env glob matches the three copies under scripts/ today; +# a future copy placed elsewhere would need its path added here. +on: + push: + branches: [main] + paths: + - 'plugins/*/scripts/automode-env.ts' + - 'tests/cross-plugin/**' + - '.github/workflows/test-cross-plugin.yml' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + pull_request: + paths: + - 'plugins/*/scripts/automode-env.ts' + - 'tests/cross-plugin/**' + - '.github/workflows/test-cross-plugin.yml' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + +jobs: + cross-plugin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' + - run: bun install --frozen-lockfile + - run: bun test tests/cross-plugin/ diff --git a/plugins/claude-code-dev-hermit/CHANGELOG.md b/plugins/claude-code-dev-hermit/CHANGELOG.md index dd183e61..51464d6d 100644 --- a/plugins/claude-code-dev-hermit/CHANGELOG.md +++ b/plugins/claude-code-dev-hermit/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - The CLAUDE-APPEND template gained a closing marker (``), placed outside both mode regions so it survives both renderings. Lets core's `hermit-evolve` bound the block exactly instead of a heuristic that used to mistake the template's own `` annotation for the block marker. +- `domain-brainstorm` reads core's proposal-metrics report via `.claude-code-hermit/bin/hermit-run` (a path relative to this plugin can't reach core's install), and a kill-criteria breach now escalates to the operator as a class-level signal instead of instructing the skill to self-retire (the shared segment can't attribute noise to one skill). ## [0.4.7] - 2026-07-03 diff --git a/plugins/claude-code-dev-hermit/scripts/dev-pr-transforms.ts b/plugins/claude-code-dev-hermit/scripts/dev-pr-transforms.ts index 159f7a54..45870538 100644 --- a/plugins/claude-code-dev-hermit/scripts/dev-pr-transforms.ts +++ b/plugins/claude-code-dev-hermit/scripts/dev-pr-transforms.ts @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execSync } from 'node:child_process'; +import { findHermitDir } from './lib/find-hermit-dir'; type Json = any; @@ -102,19 +103,6 @@ function git(args: string): string { } catch (_) { return ''; } } -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 null; -} - function loadBinding(branch: string): { id?: string; title?: string } | null { const hermitDir = findHermitDir(process.cwd()); if (!hermitDir) return null; diff --git a/plugins/claude-code-dev-hermit/scripts/git-push-guard.ts b/plugins/claude-code-dev-hermit/scripts/git-push-guard.ts index 4611f209..309519c9 100644 --- a/plugins/claude-code-dev-hermit/scripts/git-push-guard.ts +++ b/plugins/claude-code-dev-hermit/scripts/git-push-guard.ts @@ -9,20 +9,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { findHermitDir } from './lib/find-hermit-dir'; const MAX_STDIN = 1024 * 1024; -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 null; -} - function loadProtectedBranches(): string[] { try { const hermitDir = findHermitDir(process.cwd()); 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 new file mode 100644 index 00000000..27173bc9 --- /dev/null +++ b/plugins/claude-code-dev-hermit/scripts/lib/find-hermit-dir.ts @@ -0,0 +1,24 @@ +// 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. +// +// 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. +// +// 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. + +import fs from 'node:fs'; +import path from 'node:path'; + +export 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 null; +} diff --git a/plugins/claude-code-dev-hermit/scripts/record-test-result.ts b/plugins/claude-code-dev-hermit/scripts/record-test-result.ts index 57175c45..8a25775f 100644 --- a/plugins/claude-code-dev-hermit/scripts/record-test-result.ts +++ b/plugins/claude-code-dev-hermit/scripts/record-test-result.ts @@ -13,22 +13,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { execSync, spawnSync } from 'node:child_process'; +import { findHermitDir } from './lib/find-hermit-dir'; type Json = any; const MAX_STDIN = 1024 * 1024; -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 null; -} - function loadTestCommand(hermitDir: string): string | null { try { const cfg = JSON.parse(fs.readFileSync(path.join(hermitDir, 'config.json'), 'utf-8')); diff --git a/plugins/claude-code-dev-hermit/skills/domain-brainstorm/SKILL.md b/plugins/claude-code-dev-hermit/skills/domain-brainstorm/SKILL.md index 0e0f3ca8..0e344a20 100644 --- a/plugins/claude-code-dev-hermit/skills/domain-brainstorm/SKILL.md +++ b/plugins/claude-code-dev-hermit/skills/domain-brainstorm/SKILL.md @@ -7,7 +7,7 @@ description: On-demand dev-voice brainstorm — reads codebase friction signals ## Kill criteria (read before running) -After ≥8 invocations, check the `capability-brainstorm` segment of core's proposal metrics report — run `bun scripts/proposal-metrics-report.ts .claude-code-hermit --source=capability-brainstorm` from the `claude-code-hermit` plugin root (not this plugin's; `${CLAUDE_PLUGIN_ROOT}` cannot reach it), or read the `capability-brainstorm` row of the table `/claude-code-hermit:hermit-evolution` prints. If triage-survival < 25% or PROP-acceptance < 30%, cut this skill rather than tune it — signal-to-noise isn't there. That segment is shared with core's `capability-brainstorm` and the other domain brainstorm skills, so a breach means brainstorm-generated proposals are noisy in general; it does not by itself say which skill to cut. +After ≥8 invocations, check the `capability-brainstorm` segment of core's proposal metrics report — run `.claude-code-hermit/bin/hermit-run proposal-metrics-report .claude-code-hermit --source=capability-brainstorm` (the project-resident `bin/hermit-run` resolves core's plugin root, which a path relative to this plugin can't reach), or read the `capability-brainstorm` row of the table `/claude-code-hermit:hermit-evolution` prints. If triage-survival < 25% or PROP-acceptance < 30%, treat it as a class-level signal that brainstorm output is noisy and raise it with the operator — the segment is shared with core's `capability-brainstorm` and the other domain brainstorm skills, so it cannot by itself say which skill to cut. ### Gate 0 — Gather inputs diff --git a/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md b/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md index 8dc7b5a3..9d1285ca 100644 --- a/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md @@ -77,7 +77,7 @@ Read the plugin version from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. **Resolve target file:** Read `.claude-code-hermit/state/hatch-options.json`. Use the `"target"` field: - `"local"` → `target_file = CLAUDE.local.md` - `"committed"` or absent → `target_file = CLAUDE.md` -- If the file doesn't exist (no `hatch-options.json` yet — operator's core hermit predates 1.1.1): detect `core_install_scope` from `claude plugin list --json` using the same precedence rules core hatch's Step 1.5 item 2 uses (filter to entries where plugin name is `claude-code-hermit` and `enabled == true`; apply precedence `local` > `project` (both require `projectPath == project root`) > `user` (any `projectPath`) > `null`; map `project` → `committed`, `local`/`user`/`null` → `local` as the scope-derived default). Ask with `AskUserQuestion` (header: "Visibility") — present the scope-derived default at position 0 with `(recommended)` in the label: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Record the choice and write `.claude-code-hermit/state/hatch-options.json` with the full schema: +- If the file doesn't exist (no `hatch-options.json` yet — operator's core hermit predates 1.1.1): detect `core_install_scope` from `claude plugin list --json` using the same precedence core hatch resolves via `resolve-siblings.ts --role core-scope` (filter to entries where plugin name is `claude-code-hermit` and `enabled == true`; apply precedence `local` > `project` (both require `projectPath == project root`) > `user` (any `projectPath`) > `null`; map `project` → `committed`, `local`/`user`/`null` → `local` as the scope-derived default). Ask with `AskUserQuestion` (header: "Visibility") — present the scope-derived default at position 0 with `(recommended)` in the label: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Record the choice and write `.claude-code-hermit/state/hatch-options.json` with the full schema: ```json { diff --git a/plugins/claude-code-fitness-hermit/CHANGELOG.md b/plugins/claude-code-fitness-hermit/CHANGELOG.md index 82775b1d..b10cda54 100644 --- a/plugins/claude-code-fitness-hermit/CHANGELOG.md +++ b/plugins/claude-code-fitness-hermit/CHANGELOG.md @@ -11,6 +11,7 @@ - `hatch` no longer tells the operator to `cp .env.example .env`; a `.env.example` does not ship with the plugin. ### Changed +- `domain-brainstorm` reads core's proposal-metrics report via `.claude-code-hermit/bin/hermit-run` (a path relative to this plugin can't reach core's install), and a kill-criteria breach now escalates to the operator as a class-level signal instead of instructing the skill to self-retire (the shared segment can't attribute noise to one skill). - `strava-sync` and `strava-health-check` routines removed — `fitness-brief` (morning + evening) now owns Strava connectivity, activity sync, RPE binding, and Run deep-dive as the plugin's two daily beats. - Activity notes carry `cardiac_drift_bpm` in frontmatter; `weekly-patterns` reads it there and falls back to the rendered line for older notes. diff --git a/plugins/claude-code-fitness-hermit/skills/domain-brainstorm/SKILL.md b/plugins/claude-code-fitness-hermit/skills/domain-brainstorm/SKILL.md index 23bed876..c39c843d 100644 --- a/plugins/claude-code-fitness-hermit/skills/domain-brainstorm/SKILL.md +++ b/plugins/claude-code-fitness-hermit/skills/domain-brainstorm/SKILL.md @@ -7,7 +7,7 @@ description: On-demand fitness-voice brainstorm — reads Strava history and tra ## Kill criteria (read before running) -After ≥8 invocations, check the `capability-brainstorm` segment of core's proposal metrics report — run `bun scripts/proposal-metrics-report.ts .claude-code-hermit --source=capability-brainstorm` from the `claude-code-hermit` plugin root (not this plugin's; `${CLAUDE_PLUGIN_ROOT}` cannot reach it), or read the `capability-brainstorm` row of the table `/claude-code-hermit:hermit-evolution` prints. If triage-survival < 25% or PROP-acceptance < 30%, cut this skill rather than tune it — signal-to-noise isn't there. That segment is shared with core's `capability-brainstorm` and the other domain brainstorm skills, so a breach means brainstorm-generated proposals are noisy in general; it does not by itself say which skill to cut. +After ≥8 invocations, check the `capability-brainstorm` segment of core's proposal metrics report — run `.claude-code-hermit/bin/hermit-run proposal-metrics-report .claude-code-hermit --source=capability-brainstorm` (the project-resident `bin/hermit-run` resolves core's plugin root, which a path relative to this plugin can't reach), or read the `capability-brainstorm` row of the table `/claude-code-hermit:hermit-evolution` prints. If triage-survival < 25% or PROP-acceptance < 30%, treat it as a class-level signal that brainstorm output is noisy and raise it with the operator — the segment is shared with core's `capability-brainstorm` and the other domain brainstorm skills, so it cannot by itself say which skill to cut. ### Gate 0 — Gather inputs diff --git a/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md b/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md index a2c0e840..0c6db78f 100644 --- a/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md @@ -152,7 +152,7 @@ Read the source file (using Read tool), then check if the destination exists (`. **Resolve target file:** Read `.claude-code-hermit/state/hatch-options.json`. Use the `"target"` field: - `"local"` → `target_file = CLAUDE.local.md` - `"committed"` or absent → `target_file = CLAUDE.md` -- If the file doesn't exist (no `hatch-options.json` yet — operator's core hermit predates 1.1.1): detect `core_install_scope` from `claude plugin list --json` using the same precedence rules as core hatch Step 1.5 item 2 (filter entries where plugin name is `claude-code-hermit` and `enabled == true`; precedence `local` > `project` (both require `projectPath == project root`) > `user` (any `projectPath`) > `null`; map `project` → `committed`, `local`/`user`/`null` → `local`). Ask with `AskUserQuestion` (header: "Visibility") — scope-derived default at position 0 with `(recommended)`: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Write the canonical 5-field schema to `.claude-code-hermit/state/hatch-options.json`: +- If the file doesn't exist (no `hatch-options.json` yet — operator's core hermit predates 1.1.1): detect `core_install_scope` from `claude plugin list --json` using the same precedence core hatch resolves via `resolve-siblings.ts --role core-scope` (filter entries where plugin name is `claude-code-hermit` and `enabled == true`; precedence `local` > `project` (both require `projectPath == project root`) > `user` (any `projectPath`) > `null`; map `project` → `committed`, `local`/`user`/`null` → `local`). Ask with `AskUserQuestion` (header: "Visibility") — scope-derived default at position 0 with `(recommended)`: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Write the canonical 5-field schema to `.claude-code-hermit/state/hatch-options.json`: ```json { diff --git a/plugins/claude-code-hermit/CHANGELOG.md b/plugins/claude-code-hermit/CHANGELOG.md index c2e28654..c495dfda 100644 --- a/plugins/claude-code-hermit/CHANGELOG.md +++ b/plugins/claude-code-hermit/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Channel-originated harness commands: an exact `/model `, `/effort `, `/compact`, or `/clear` from a trusted channel sender is recorded by a new `UserPromptSubmit` hook and typed into the session's pane when the turn ends. Args are passed through to Claude Code rather than checked against a fixed list, so new models and effort levels work without a plugin change. - `config.effort` — passed as `--effort` on every `hermit-start`, mirroring `config.model`, so a channel effort change reverts on restart instead of persisting. Ships `null` (no flag, model default); set it to opt into the revert-on-restart guarantee. Not the same lever as `config.env.CLAUDE_CODE_EFFORT_LEVEL`, which pins the session and makes a runtime `/effort` a no-op. +- `micro-proposal.ts brief-cycle` — ages the whole micro-approval queue in one call (re-nudges `follow_up_count` 1 entries, expires 2+ entries, records each expiry) and returns a JSON verdict, replacing the per-entry `nudge`/`resolve` calls both briefs used to issue one at a time. +- Two `Bash(.claude-code-hermit/bin/hermit-run