From e26399c6da1d880271733ee5101c949440c078e7 Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Sat, 25 Jul 2026 20:36:22 +0100 Subject: [PATCH 1/2] feat(fleet): own the domain-hatch protocol in one core script Four of the five domain hatches checked a core version floor written into skill prose (HA 1.0.16, fitness 1.0.26, feed 1.2.22, forge 1.1.1) while every manifest declared >=1.2.30, so they proceeded against a core too old to run them. The floor now comes from the manifest, via a core-owned script the hatches call through bin/hermit-run. The same extraction closes three more defects the prose copies carried: a hatch-options.json present without a `target` key was never repaired and silently routed to the committed CLAUDE.md; feed's precedence copy had lost the projectPath == project root qualifier and resolved a project-scoped install differently from resolve-siblings.ts; and fitness, feed and forge wrote a config.json they had read before the wizard ran, discarding anything changed in between. Stale-core advice is now split by cause: an old installed package needs `claude plugin update`, a stale applied migration needs hermit-evolve, and evolve can never fix the first. Marker-block bounds and the duplicate-marker refusal are imported from evolve-plan.ts rather than reimplemented, so hatch and evolve cannot disagree about where a block ends. A cross-plugin contract test derives its plugin list from the filesystem instead of a hardcoded array. The two guards it replaces had both gone stale: hatch-resume-contract omitted feed-hermit, hatch-options-contract checked only dev-hermit. --- .github/workflows/test-cross-plugin.yml | 10 + plugins/claude-code-dev-hermit/CHANGELOG.md | 2 + plugins/claude-code-dev-hermit/CLAUDE.md | 4 +- .../skills/hatch/SKILL.md | 44 ++- .../tests/hatch-mode.test.ts | 68 ++-- .../claude-code-fitness-hermit/CHANGELOG.md | 3 + plugins/claude-code-fitness-hermit/CLAUDE.md | 2 +- .../skills/hatch/SKILL.md | 93 +++--- .../tests/hatch-skill.test.ts | 74 +++++ .../tests/run-all.sh | 1 + plugins/claude-code-hermit/CHANGELOG.md | 4 + plugins/claude-code-hermit/CLAUDE.md | 4 +- .../scripts/apply-settings.ts | 7 + .../scripts/domain-hatch.ts | 149 +++++++++ .../claude-code-hermit/scripts/evolve-plan.ts | 23 +- .../scripts/lib/domain-hatch/block.ts | 128 ++++++++ .../scripts/lib/domain-hatch/preflight.ts | 167 ++++++++++ .../scripts/lib/domain-hatch/resolve.ts | 94 ++++++ .../scripts/lib/domain-hatch/target.ts | 170 ++++++++++ .../scripts/validate-config.ts | 51 +++ .../claude-code-hermit/skills/hatch/SKILL.md | 31 +- .../skills/hermit-evolve/reference.md | 8 +- .../tests/domain-hatch.test.ts | 305 ++++++++++++++++++ .../tests/hatch-options-contract.test.ts | 70 ++-- .../tests/hatch-resume-contract.test.ts | 33 +- .../CHANGELOG.md | 3 + .../CLAUDE.md | 4 +- .../skills/hatch/SKILL.md | 72 ++--- .../tests/hatch-skill.test.ts | 106 +++--- .../.claude-plugin/hermit-meta.json | 4 +- .../feed-hermit/.claude-plugin/plugin.json | 2 +- plugins/feed-hermit/CHANGELOG.md | 4 + plugins/feed-hermit/CLAUDE.md | 4 +- plugins/feed-hermit/docs/schema.md | 2 +- plugins/feed-hermit/skills/hatch/SKILL.md | 88 +++-- .../.claude-plugin/hermit-meta.json | 4 +- .../.claude-plugin/plugin.json | 2 +- plugins/laravel-forge-hermit/CHANGELOG.md | 4 + plugins/laravel-forge-hermit/CLAUDE.md | 2 +- .../skills/forge-failed-deploys/SKILL.md | 2 +- .../skills/hatch/SKILL.md | 84 +++-- .../tests/hatch-skill.test.ts | 74 +++++ plugins/laravel-forge-hermit/tests/run-all.sh | 16 +- .../domain-hatch.contract.test.ts | 142 ++++++++ 44 files changed, 1776 insertions(+), 388 deletions(-) create mode 100644 plugins/claude-code-fitness-hermit/tests/hatch-skill.test.ts create mode 100644 plugins/claude-code-hermit/scripts/domain-hatch.ts create mode 100644 plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts create mode 100644 plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts create mode 100644 plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts create mode 100644 plugins/claude-code-hermit/scripts/lib/domain-hatch/target.ts create mode 100644 plugins/claude-code-hermit/tests/domain-hatch.test.ts create mode 100644 plugins/laravel-forge-hermit/tests/hatch-skill.test.ts create mode 100644 tests/cross-plugin/domain-hatch.contract.test.ts diff --git a/.github/workflows/test-cross-plugin.yml b/.github/workflows/test-cross-plugin.yml index 663316b5..a5669b0f 100644 --- a/.github/workflows/test-cross-plugin.yml +++ b/.github/workflows/test-cross-plugin.yml @@ -4,11 +4,18 @@ name: Cross-Plugin Guards # 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. +# The hatch/hermit-meta/core-scripts globs cover the domain-hatch contract: a +# hatch rewrite, a changed core floor declaration, and the shared script itself +# all have to re-run that guard, and none of them would trigger any single +# plugin's own workflow. on: push: branches: [main] paths: - 'plugins/*/scripts/automode-env.ts' + - 'plugins/*/skills/hatch/**' + - 'plugins/*/.claude-plugin/hermit-meta.json' + - 'plugins/claude-code-hermit/scripts/**' - 'tests/cross-plugin/**' - '.github/workflows/test-cross-plugin.yml' - 'package.json' @@ -17,6 +24,9 @@ on: pull_request: paths: - 'plugins/*/scripts/automode-env.ts' + - 'plugins/*/skills/hatch/**' + - 'plugins/*/.claude-plugin/hermit-meta.json' + - 'plugins/claude-code-hermit/scripts/**' - 'tests/cross-plugin/**' - '.github/workflows/test-cross-plugin.yml' - 'package.json' diff --git a/plugins/claude-code-dev-hermit/CHANGELOG.md b/plugins/claude-code-dev-hermit/CHANGELOG.md index 9693f69f..adbbde9b 100644 --- a/plugins/claude-code-dev-hermit/CHANGELOG.md +++ b/plugins/claude-code-dev-hermit/CHANGELOG.md @@ -8,6 +8,8 @@ - `hatch`'s CLAUDE-APPEND version gate said to "extract the stamped version from the existing block", but no template or renderer ever wrote a version into the block — the gate degenerated to marker-present-only, so a plugin version bump alone never refreshed an already-hatched install. It now reads the stamp from `_hermit_versions["claude-code-dev-hermit"]` in `config.json`, which `hatch` itself already writes on every run. ### Changed +- `hatch` reads the required core version from `.claude-plugin/hermit-meta.json` at runtime via `domain-hatch preflight`, instead of the `>=1.0.22` floor its prose carried as the worked example. The manifest is the only place the requirement is stated now, so it cannot drift from what the plugin actually needs. +- Target resolution and CLAUDE-APPEND writing are delegated to core: `domain-hatch preflight claude-code-dev-hermit` resolves the target, `ensure-target` records an operator override, and the mode-specific `render-append.ts` output is piped into `sync-block ... --rendered-stdin`. The skill no longer detects install scope, stamps `hatch-options.json`, or tracks `prior_hatch_mode` to decide on a replacement. - Requires core `>=1.2.34`. Core absorbed its proposal satellites into `proposal.ts` verbs, so the shared route this plugin calls through `bin/hermit-run` is now `proposal metrics …`. `bin/hermit-run` resolves a script by bare filesystem probe, so pairing this version with an older core fails with a misleading "plugin may predate this command" error. - 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). diff --git a/plugins/claude-code-dev-hermit/CLAUDE.md b/plugins/claude-code-dev-hermit/CLAUDE.md index 75cdc5cc..26086cc8 100644 --- a/plugins/claude-code-dev-hermit/CLAUDE.md +++ b/plugins/claude-code-dev-hermit/CLAUDE.md @@ -31,13 +31,13 @@ Language-agnostic safety layer for any agent doing dev work in a hermit project. ## Hatch target routing -`/hatch` Step 3 reads `.claude-code-hermit/state/hatch-options.json` (written by core hatch) to determine where to write the CLAUDE-APPEND block: `target = "local"` → `CLAUDE.local.md`; `target = "committed"` → `CLAUDE.md`. If core hatch hasn't run yet, the skill detects `core_install_scope` from `claude plugin list --json`, presents the scope-derived default at position 0 of the Visibility prompt, and stamps the full canonical schema (`target`, `core_install_scope`, `stamped_at`, `stamped_by`, `version`) into `hatch-options.json`. Applies to both renderings of the single-source `CLAUDE-APPEND.md` (standard and safety, emitted by `scripts/render-append.ts`). +`/hatch` Step 1 runs `.claude-code-hermit/bin/hermit-run domain-hatch preflight claude-code-dev-hermit`; core's `scripts/domain-hatch.ts` owns install-scope detection, target resolution, and stamping `hatch-options.json`. The preflight verdict hands back `target`, `target_file`, `target_default`, and `needs_target_question` — the skill only surfaces the Visibility prompt when asked to, records the answer with `domain-hatch ensure-target claude-code-dev-hermit --target `, and never reads or writes `hatch-options.json` itself. Step 3 pipes the mode-specific rendering into `domain-hatch sync-block claude-code-dev-hermit --rendered-stdin`, so both renderings of the single-source `CLAUDE-APPEND.md` (standard and safety, emitted by `scripts/render-append.ts`) land in the resolved file and a mode change becomes a block replacement. **Migration on target change.** When the operator flips `hatch_target` (e.g. via core 1.1.1's `hermit-evolve` Upgrade Instructions), the dev block can end up stranded in the old file. The most recent CHANGELOG entry's `### Upgrade Instructions` run a one-shot migration via `hermit-evolve` Step 7's sibling upgrade flow to strip the stranded block. ## Depends On -- `claude-code-hermit` v1.1.2+ (core). Authoritative source: `.claude-plugin/hermit-meta.json` (`required_core_version` field). +- `claude-code-hermit` (core). Authoritative source: `.claude-plugin/hermit-meta.json` (`required_core_version` field) — read at runtime by the `domain-hatch preflight` verb, never restated in skill prose. ## Core Contracts diff --git a/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md b/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md index 4288135d..e96deabf 100644 --- a/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-dev-hermit/skills/hatch/SKILL.md @@ -13,7 +13,7 @@ The plugin's identity in v0.3.0+: a thin wrapper around (a) `git-push-guard` str ### 1. Check prerequisites -Check if `.claude-code-hermit/` exists in the current project. +Check if `.claude-code-hermit/config.json` exists in the current project. - Missing: ask the operator (`AskUserQuestion`) "Core hermit isn't set up yet. Run `/claude-code-hermit:hatch` now?" with options `Yes — run now` / `No — I'll do it later`. - If yes, follow the domain hatch continuation protocol (documented in `claude-code-hermit:hatch`): @@ -21,9 +21,11 @@ Check if `.claude-code-hermit/` exists in the current project. 2. Print: "(If setup doesn't continue automatically when core finishes, re-run `/claude-code-dev-hermit:hatch`.)" 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. - If no, stop. -- Present: read `.claude-code-hermit/config.json` and the plugin's `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hermit-meta.json`. Verify `_hermit_versions["claude-code-hermit"]` from config satisfies `required_core_version` from hermit-meta (e.g. `">=1.0.22"`). If absent or below the floor, ask whether to run `/claude-code-hermit:hermit-evolve` first; allow opt-out with a warning. (Reading the floor from hermit-meta — never hardcoding it in skill prose — keeps this skill in sync with the plugin's declared requirement.) - -**Capture `prior_hatch_mode`.** While reading `config.json`, also record `claude-code-dev-hermit.hatch_mode` as `prior_hatch_mode` (or `null` if unset). Step 3's skip-vs-replace decision compares against this value, and Step 5 overwrites `hatch_mode` with Step 2's answer — capturing the prior value here keeps it intact across the wizard. +- Present: run `.claude-code-hermit/bin/hermit-run domain-hatch preflight claude-code-dev-hermit` and parse the JSON verdict. Branch on `action`: + - `upgrade-core-package` / `upgrade-core-applied` → relay the `remedy` string verbatim to the operator and stop. + - `verify` → this version (`self_version`) is already stamped; continue through the wizard, Step 3's block sync is the idempotency guard. + - `full` → continue through the wizard. + - `ok: false` → relay `message` and stop. ### 2. Capability scan + choose mode @@ -72,31 +74,21 @@ When building the options array at runtime: ### 3. Update CLAUDE.md / CLAUDE.local.md dev block -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 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: +**Resolve target file:** Step 1's preflight already returned `target`, `target_file`, `target_default` and `needs_target_question`. - ```json - { - "target": "", - "core_install_scope": "", - "stamped_at": "", - "stamped_by": "claude-code-dev-hermit:hatch", - "version": "" - } - ``` +If `needs_target_question` is true, ask with `AskUserQuestion` (header: "Visibility") — `target_default` at position 0 with `(recommended)` in the label: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Then record the choice: - This matches the canonical schema core hatch Step 9b writes, so when core hatch later runs its 1.1.1 preservation logic keeps `stamped_at`/`stamped_by` intact and adds `last_updated_at`/`last_updated_by`. +```bash +.claude-code-hermit/bin/hermit-run domain-hatch ensure-target claude-code-dev-hermit --target +``` -Read `target_file` (treat a missing file as marker-absent — Edit will create the file in the append branch). Look for the marker ``. Read the stamped version from `.claude-code-hermit/config.json` at `_hermit_versions["claude-code-dev-hermit"]` (treat absent as `null`) — Step 5 of this skill stamps that field at the end of every run, so on re-runs it reflects the version that last wrote the block. +**Write the block.** The dev block is rendered per mode, so pipe the rendering in — the rendered content is what `sync-block` compares against, which is how a mode change becomes a replacement: -Compare against the run's chosen mode (from Step 2's answer this run) and `prior_hatch_mode` (captured in Step 1, before Step 5 overwrites `hatch_mode`): +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/render-append.ts | .claude-code-hermit/bin/hermit-run domain-hatch sync-block claude-code-dev-hermit --rendered-stdin +``` -- **Marker present, stamped version matches plugin version, AND Step 2's mode equals `prior_hatch_mode`**: skip — block is current. Do not read the template. -- **All other cases** (marker absent, stamped version stale, OR mode changed): render the mode block from the single source — capture the stdout of `bun ${CLAUDE_PLUGIN_ROOT}/scripts/render-append.ts ` (`` is `safety` or `standard`), which emits the mode-specific rendering of `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md`. Write that stdout into the target as either an append (marker absent) or a replacement of the marked block (marker present). The rendered output is the source of truth; no operator prompt is needed. +`` is Step 2's answer (`safety` or `standard`). The script appends when the marker is absent, replaces when the rendering differs, and skips when it is already current. The rendered output is the source of truth; no operator prompt is needed. Stray-block migration (block stranded in the non-target file after a target flip) is handled one-shot by the Upgrade Instructions in this version's CHANGELOG entry, executed by `hermit-evolve` Step 7. Hatch itself stays focused on target-aware setup and steady-state refresh. @@ -238,7 +230,7 @@ Single atomic config.json write: - If the operator accepted strict in Round 2 → write `"strict"`. - Else if the existing value is already `"strict"` → preserve it (never silently downgrade). - Else → write `"standard"` explicitly. Do not leave the key unset; an explicit value makes the operator's choice durable across `hermit-evolve` runs and prevents silent re-prompting. -- `_hermit_versions["claude-code-dev-hermit"]` — set to the plugin version cached in step 3. +- `_hermit_versions["claude-code-dev-hermit"]` — set to `self_version` from Step 1's preflight. In `standard` mode only, also write: - `claude-code-dev-hermit.commands.test` — required, from Round 1. @@ -313,7 +305,7 @@ Read by `/claude-code-hermit:docker-security` when the operator enables LAN cont - **Strict-by-default.** The wizard defaults to installing `git-push-guard` at strict. Do not ask "which profile?" — ask "yes or opt out?". - **Idempotent.** Re-running detects existing `config.json` values and offers `Keep current ()` as the first option per key, so operators can fast-confirm with Enter presses. -- **Single source of truth.** `CLAUDE-APPEND.md` rendered for the chosen mode by `scripts/render-append.ts` is the source for the project's dev conventions. Step 3 always overwrites the marked block when versions differ or mode changes; do not preserve operator edits to that block (operators who want overrides put them elsewhere in their CLAUDE.md). +- **Single source of truth.** `CLAUDE-APPEND.md` rendered for the chosen mode by `scripts/render-append.ts` is the source for the project's dev conventions. Step 3 pipes that rendering into `sync-block`, which overwrites the marked block whenever it differs; do not preserve operator edits to that block (operators who want overrides put them elsewhere in their CLAUDE.md). - **Never downgrade hook profile.** If the operator chooses "No — leave at standard" but `env.AGENT_HOOK_PROFILE` is already `strict`, preserve `strict`. The opt-out only applies on first install. - **No stack detection magic.** Detection seeds defaults for prompts; operators always confirm. Never write `commands.test` from detection alone — it must be operator-confirmed. - **Safety mode skips workflow prompts.** In `safety` mode, do not prompt for `commands.test`, `commands.lint`, `commands.format`, `commands.pr_create`, `pr_template_path`, or `pr_base_branch`. These keys feed workflow sections that safety mode does not inject. diff --git a/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts b/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts index e11571c0..fb11b672 100644 --- a/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts +++ b/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts @@ -111,33 +111,47 @@ if (fs.existsSync(HATCH_SKILL)) { ok('renders block via render-append.ts', text.includes('render-append.ts')); ok('references capability scan slugs', text.includes('create-pr') && text.includes('release')); - console.log('\nskills/hatch/SKILL.md target routing + schema stamping:'); - - ok('references hatch-options.json', text.includes('hatch-options.json')); - ok('reads "target" field from hatch-options.json', - /hatch-options\.json[\s\S]{0,200}["`]target["`]/.test(text)); - ok('local target routes to CLAUDE.local.md', - /["`]local["`][\s\S]{0,80}target_file = CLAUDE\.local\.md/.test(text)); - ok('committed target routes to CLAUDE.md', - /["`]committed["`][\s\S]{0,120}target_file = CLAUDE\.md/.test(text)); - - ok('schema stamps "target" field', /"target":\s*"/.test(text)); - ok('schema stamps "core_install_scope" field', /"core_install_scope":\s*"/.test(text)); - ok('schema stamps "stamped_at" field', /"stamped_at":\s*"/.test(text)); - ok('schema stamps "stamped_by" field', /"stamped_by":\s*"claude-code-dev-hermit:hatch"/.test(text)); - ok('schema stamps "version" field', /"version":\s*"/.test(text)); - - ok('detects core_install_scope from `claude plugin list --json`', - /core_install_scope[\s\S]{0,120}claude plugin list --json/.test(text)); - ok('documents `project` → `committed` scope mapping', - /`project`[^\n]{0,20}`committed`/.test(text)); - ok('documents `local`/`user`/`null` → `local` scope mapping', - /`local`\/`user`\/`null`[^\n]{0,40}`local`/.test(text)); - - ok('Step 1 captures `prior_hatch_mode`', - /Capture `prior_hatch_mode`/.test(text)); - ok('Step 3 compares against `prior_hatch_mode`', - /Step 2's mode equals `prior_hatch_mode`/.test(text)); + console.log('\nskills/hatch/SKILL.md shared domain-hatch protocol:'); + + // Target resolution, install-scope detection, and the hatch-options stamp + // schema all moved into core's `domain-hatch.ts`. What the dev hatch owes the + // protocol is: call the three verbs with its own plugin id, and restate none + // of the resolution rules it no longer owns. + + ok('runs preflight through core, keyed to its own plugin id', + text.includes('domain-hatch preflight claude-code-dev-hermit')); + ok('reaches core via bin/hermit-run, not a relative path', + text.includes('.claude-code-hermit/bin/hermit-run domain-hatch') + && !text.includes('../claude-code-hermit/scripts')); + ok('consumes the preflight verdict fields instead of re-deriving them', + /`target`[\s\S]{0,60}`target_file`[\s\S]{0,60}`target_default`[\s\S]{0,60}`needs_target_question`/.test(text)); + ok('branches on every preflight `action` value', + ['upgrade-core-package', 'upgrade-core-applied', '`verify`', '`full`'].every(a => text.includes(a))); + + ok('records the operator\'s choice via ensure-target', + text.includes('domain-hatch ensure-target claude-code-dev-hermit --target')); + ok('Visibility prompt still offers .local vs committed', + /Visibility[\s\S]{0,240}`\.local` files[\s\S]{0,120}Committed files/.test(text)); + + // Regression: the dev block is mode-dependent, so it is the one hatch that + // must hand core the *rendered* bytes — otherwise core would sync the raw + // marker-annotated template and a mode flip would never take effect. + ok('pipes the rendered block into sync-block', + /render-append\.ts [\s\S]{0,200}sync-block claude-code-dev-hermit --rendered-stdin/.test(text)); + ok('renders block via render-append.ts', text.includes('render-append.ts')); + ok('states that a differing rendering replaces the block (mode-change path)', + /replaces when the rendering differs/.test(text)); + + // These are the prose surfaces that drifted from the manifest and from core's + // resolver before the protocol was centralised. None of them may come back. + ok('does not restate install-scope detection', !text.includes('claude plugin list --json')); + ok('does not restate the hatch-options stamp schema', + !/"stamped_by":\s*"/.test(text) && !/"core_install_scope":\s*"/.test(text)); + ok('does not read hatch-options.json directly', !text.includes('hatch-options.json')); + ok('states no hardcoded core version floor', + text.split('\n') + .filter(l => /(?:base hermit|core hermit|claude-code-hermit|_hermit_versions)/i.test(l)) + .every(l => !/(?:requires|earlier than|less than|below)\s+`?≥?>?=?\s*\d+\.\d+\.\d+/i.test(l))); ok('delegates stray-block migration to hermit-evolve Step 7', /hermit-evolve[\s\S]{0,20}Step 7/.test(text)); diff --git a/plugins/claude-code-fitness-hermit/CHANGELOG.md b/plugins/claude-code-fitness-hermit/CHANGELOG.md index 1b78a53b..c185b7ba 100644 --- a/plugins/claude-code-fitness-hermit/CHANGELOG.md +++ b/plugins/claude-code-fitness-hermit/CHANGELOG.md @@ -11,6 +11,9 @@ - `hatch` no longer tells the operator to `cp .env.example .env`; a `.env.example` does not ship with the plugin. ### Changed +- `hatch` reads the required core version from `.claude-plugin/hermit-meta.json` at runtime via `domain-hatch preflight`, instead of the hardcoded `1.0.26` floor its prose carried. That floor sat many minor versions below what the manifest declared, so the wizard proceeded against a core too old for it. +- Target resolution and CLAUDE-APPEND writing are delegated to core: `domain-hatch preflight claude-code-fitness-hermit` resolves the target, `ensure-target` records an operator override, `sync-block` writes the block. The skill no longer detects install scope from `claude plugin list --json` or stamps `hatch-options.json`. +- `hatch` re-reads `config.json` immediately before merging its routines, scheduled checks and version stamp, instead of reusing the copy it loaded before the wizard ran. Anything written to the file during the wizard is no longer clobbered. - Requires core `>=1.2.34`. Core absorbed its proposal satellites into `proposal.ts` verbs, so the shared route this plugin calls through `bin/hermit-run` is now `proposal metrics …`. `bin/hermit-run` resolves a script by bare filesystem probe, so pairing this version with an older core fails with a misleading "plugin may predate this command" error. - `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. diff --git a/plugins/claude-code-fitness-hermit/CLAUDE.md b/plugins/claude-code-fitness-hermit/CLAUDE.md index 4d561dc6..09b00da5 100644 --- a/plugins/claude-code-fitness-hermit/CLAUDE.md +++ b/plugins/claude-code-fitness-hermit/CLAUDE.md @@ -31,7 +31,7 @@ After install, run `/claude-code-fitness-hermit:hatch` in the target project. Th ## Hatch target routing -`/hatch` Step 6 reads `.claude-code-hermit/state/hatch-options.json` (written by core hatch) to determine where to write the CLAUDE-APPEND block: `target = "local"` → `CLAUDE.local.md`; `target = "committed"` → `CLAUDE.md`. If core hatch hasn't run yet, the skill detects `core_install_scope` from `claude plugin list --json`, presents the scope-derived default at position 0 of the Visibility prompt, and stamps the full canonical schema (`target`, `core_install_scope`, `stamped_at`, `stamped_by`, `version`) into `hatch-options.json`. +`/hatch` Step 1 runs `.claude-code-hermit/bin/hermit-run domain-hatch preflight claude-code-fitness-hermit`; core's `scripts/domain-hatch.ts` owns install-scope detection, target resolution, and stamping `hatch-options.json`. The preflight verdict hands back `target`, `target_file`, `target_default`, and `needs_target_question` — Step 5 only surfaces the Visibility prompt when asked to, records the answer with `domain-hatch ensure-target claude-code-fitness-hermit --target `, then writes the block with `domain-hatch sync-block claude-code-fitness-hermit`. The skill never reads or writes `hatch-options.json` itself. **Migration on target change.** When the operator flips `hatch_target` (e.g. via core 1.1.1's `hermit-evolve` Upgrade Instructions), the Fitness block can end up stranded in the old file. The most recent CHANGELOG entry's `### Upgrade Instructions` run a one-shot migration via `hermit-evolve` Step 7's sibling upgrade flow to strip the stranded block. diff --git a/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md b/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md index 0c6db78f..b6436730 100644 --- a/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-fitness-hermit/skills/hatch/SKILL.md @@ -11,9 +11,9 @@ Idempotent setup wizard for the fitness plugin. Run **after** `/claude-code-herm ## Step 1 — Prerequisite check -Read `.claude-code-hermit/config.json`. +Check whether `.claude-code-hermit/config.json` exists. -If the file does not exist or `_hermit_versions["claude-code-hermit"]` is absent or empty: +If it does not: > "The base hermit is not set up in this project yet. Run `/claude-code-hermit:hatch` first, then return here." @@ -25,34 +25,20 @@ Use `AskUserQuestion`: "Would you like to run `/claude-code-hermit:hatch` now? ( 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. - **no** → stop. -If `_hermit_versions["claude-code-hermit"]` is present but the version string is earlier than `1.0.26` (compare major.minor.patch numerically), warn: +If it does exist, run `.claude-code-hermit/bin/hermit-run domain-hatch preflight claude-code-fitness-hermit` and parse the JSON verdict. Branch on `action`: -> "Base hermit version is {version}; this plugin requires ≥1.0.26. Run `/claude-code-hermit:hermit-evolve` to upgrade, then re-run this hatch." +- **`upgrade-core-package` / `upgrade-core-applied`** → relay the `remedy` string verbatim to the operator and stop. +- **`verify`** → say: -Stop. + > "claude-code-fitness-hermit {self_version} is already installed. Skip to Step 7 to re-verify the installation, or reply 'full' to re-run the full wizard." ---- - -## Step 2 — Idempotency check - -Read `_hermit_versions["claude-code-fitness-hermit"]` from `.claude-code-hermit/config.json`. - -Read `version` from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. - -If the versions match, say: - -> "claude-code-fitness-hermit {version} is already installed. Skip to Step 8 to re-verify the installation, or reply 'full' to re-run the full wizard." - -Use `AskUserQuestion`: "(verify / full)" - -- **verify** → skip to Step 8. -- **full** → continue from Step 3. - -If absent or stale: continue from Step 3. + Use `AskUserQuestion`: "(verify / full)" — **verify** → skip to Step 7; **full** → continue from Step 2. +- **`full`** → continue from Step 2. +- **`ok: false`** → relay `message` and stop. --- -## Step 3 — .env verification +## Step 2 — .env verification **IMPORTANT: Do NOT use `grep`, `cat`, `echo`, or any Bash command to read `.env`. Three of the four required variables contain the literal string `TOKEN` in their name, which triggers the base hermit's deny-patterns hook on any Bash command argument. Use the `Read` tool only.** @@ -86,18 +72,18 @@ If any value is missing or still set to `replace_me`, report which ones and loop --- -## Step 4 — MCP registration +## Step 3 — MCP registration -**Step 4.0 — Detect an ambient Strava MCP server.** The operator may already run a Strava MCP server (user-scoped, or configured by another tool). Run `claude mcp list` (Bash) and scan for a server that talks to Strava — a name containing `strava`, or an entry whose command/args reference a Strava MCP package (e.g. `strava-mcp-server`). +**Step 3.0 — Detect an ambient Strava MCP server.** The operator may already run a Strava MCP server (user-scoped, or configured by another tool). Run `claude mcp list` (Bash) and scan for a server that talks to Strava — a name containing `strava`, or an entry whose command/args reference a Strava MCP package (e.g. `strava-mcp-server`). - **None found** → proceed to install the bundled server below (the default path). - **One found** → ask with `AskUserQuestion` (header: "Strava MCP"): **Reuse the existing `` server** (recommended — no duplicate) / **Install the bundled `@r-huijts/strava-mcp-server`**. - - **Reuse** → skip the `.mcp.json` write entirely. Record which server key the skills should target (if it is not `strava`, note in the final report that skill/settings matchers assume the key `strava`, so the operator should either rename their server to `strava` or accept that the fitness skills call `mcp__strava__*`). Continue to Step 5. + - **Reuse** → skip the `.mcp.json` write entirely. Record which server key the skills should target (if it is not `strava`, note in the final report that skill/settings matchers assume the key `strava`, so the operator should either rename their server to `strava` or accept that the fitness skills call `mcp__strava__*`). Continue to Step 4. - **Install bundled** → proceed below. This is a local reuse-vs-install choice only — do not attempt to reconcile or edit the operator's other MCP configs. -Using the four values you parsed from `.env` in Step 3 (held in working context — do not re-read .env), write the Strava MCP server entry into the project's `.mcp.json`. +Using the four values you parsed from `.env` in Step 2 (held in working context — do not re-read .env), write the Strava MCP server entry into the project's `.mcp.json`. **Do not embed `${VAR}` placeholders — substitute literal values.** Claude Code passes MCP `env` blocks as literal environment variables to the child process; it does not expand shell variable syntax. @@ -131,7 +117,7 @@ Write the updated `.mcp.json` using the Write tool. --- -## Step 5 — Drop routine prompt files +## Step 4 — Drop routine prompt files Copy the four routine prompt templates from the plugin's `state-templates/compiled/` into the consumer's `.claude-code-hermit/compiled/`. @@ -147,34 +133,29 @@ Read the source file (using Read tool), then check if the destination exists (`. --- -## Step 6 — CLAUDE.md / CLAUDE.local.md inject +## Step 5 — CLAUDE.md / CLAUDE.local.md inject -**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 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`: +**Resolve target file:** Step 1's preflight already returned `target`, `target_file`, `target_default` and `needs_target_question`. - ```json - { - "target": "", - "core_install_scope": "", - "stamped_at": "", - "stamped_by": "claude-code-fitness-hermit:hatch", - "version": "" - } - ``` +If `needs_target_question` is true, ask with `AskUserQuestion` (header: "Visibility") — `target_default` at position 0 with `(recommended)`: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Then record it: -Read `target_file`. Search for the opening marker ``. The matching closing marker is ``. +```bash +.claude-code-hermit/bin/hermit-run domain-hatch ensure-target claude-code-fitness-hermit --target +``` + +Then write the block: + +```bash +.claude-code-hermit/bin/hermit-run domain-hatch sync-block claude-code-fitness-hermit +``` -- **`target_file` does not exist** (greenfield `CLAUDE.local.md` is common) → treat as marker-absent and proceed to the append branch; Edit will create the file. -- **Marker absent** → append the full contents of `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md` to `target_file` using Edit. -- **Marker present** → skip (up-to-date; `hermit-evolve` handles block replacement on upgrade). +It appends the `` block when the marker is absent (creating `target_file` if needed) and skips when it is already present; `hermit-evolve` handles block replacement on upgrade. Stray-block migration (block stranded in the non-target file after a target flip) is handled one-shot by the Upgrade Instructions in this version's CHANGELOG entry, executed by `hermit-evolve` Step 7. Hatch itself stays focused on target-aware setup. --- -## Step 7 — Knowledge-schema extension +## Step 6 — Knowledge-schema extension Read `.claude-code-hermit/knowledge-schema.md`. @@ -203,17 +184,17 @@ Use Edit to make the changes. --- -## Step 8 — Stamp and register in config.json +## Step 7 — Stamp and register in config.json -Use the `config.json` content already loaded in Step 1. (Do not re-read the file.) +Re-read `.claude-code-hermit/config.json` now — the wizard has been running since Step 1 and the on-disk file may have changed. Apply the merges below to that fresh copy. -### 8a — Stamp version +### 7a — Stamp version -Set `_hermit_versions["claude-code-fitness-hermit"]` to the plugin version retrieved in Step 2. +Set `_hermit_versions["claude-code-fitness-hermit"]` to `self_version` from Step 1's preflight. If the key already exists: update the value. If absent: add it alongside the existing `_hermit_versions["claude-code-hermit"]` entry. -### 8b — Merge routines +### 7b — Merge routines In the `routines` array, check for each of these four IDs. For any that are **absent**, add the entry. For any that are **present** (by `id`), skip (do not clobber existing operator edits). @@ -252,7 +233,7 @@ In the `routines` array, check for each of these four IDs. For any that are **ab } ``` -### 8c — Merge scheduled_checks +### 7c — Merge scheduled_checks In `config.scheduled_checks`, check for an entry with `id: "weekly-coaching-patterns"`. If absent, append it. If present (by `id`), skip — do not clobber existing operator edits. @@ -264,13 +245,13 @@ No prompt needed — this is a read-only analysis. The core daily `scheduled-che Write the updated `config.json` using Write tool (full file replacement to ensure valid JSON). -### 8d — Auto-mode environment seed +### 7d — Auto-mode environment seed Run `bun ${CLAUDE_PLUGIN_ROOT}/scripts/automode-env.ts .claude/settings.local.json` — **always `.claude/settings.local.json`, regardless of `hatch_target`**: Claude Code's auto-mode classifier reads `autoMode` config only from local/user scope, never a committed project `.claude/settings.json`. This names `www.strava.com` as a trusted external service, so the classifier stops treating the nightly `evening-brief` routine's read-only fetches as unrecognized outbound calls. Additive and idempotent; safe to re-run on every hatch. No prompt needed. --- -## Step 9 — Final report +## Step 8 — Final report Print a structured summary: diff --git a/plugins/claude-code-fitness-hermit/tests/hatch-skill.test.ts b/plugins/claude-code-fitness-hermit/tests/hatch-skill.test.ts new file mode 100644 index 00000000..b1c61cc1 --- /dev/null +++ b/plugins/claude-code-fitness-hermit/tests/hatch-skill.test.ts @@ -0,0 +1,74 @@ +// Structural lint for skills/hatch/SKILL.md: that it runs core's shared +// domain-hatch protocol and carries no second copy of it. +// Run with: bun tests/hatch-skill.test.ts +// +// Grep-level checks only — no runtime skill execution. + +import fs from 'node:fs'; +import path from 'node:path'; +import { makeReporter } from './test-utils'; + +const PLUGIN_ROOT = path.join(import.meta.dir, '..'); +const SKILL = path.join(PLUGIN_ROOT, 'skills', 'hatch', 'SKILL.md'); +const TEMPLATE = path.join(PLUGIN_ROOT, 'state-templates', 'CLAUDE-APPEND.md'); + +const { ok, summary } = makeReporter(); + +console.log('\nskills/hatch/SKILL.md shared domain-hatch protocol:'); + +ok('file exists', fs.existsSync(SKILL), SKILL); + +if (fs.existsSync(SKILL)) { + const text = fs.readFileSync(SKILL, 'utf-8'); + + ok('runs preflight through core, keyed to its own plugin id', + text.includes('domain-hatch preflight claude-code-fitness-hermit')); + ok('reaches core via bin/hermit-run, not a relative path', + text.includes('.claude-code-hermit/bin/hermit-run domain-hatch') + && !text.includes('../claude-code-hermit/scripts')); + ok('branches on every preflight `action` value', + ['upgrade-core-package', 'upgrade-core-applied', '`verify`', '`full`'].every(a => text.includes(a))); + ok('consumes the preflight verdict fields instead of re-deriving them', + /`target`[\s\S]{0,60}`target_file`[\s\S]{0,60}`target_default`[\s\S]{0,60}`needs_target_question`/.test(text)); + + ok('records the operator\'s choice via ensure-target', + text.includes('domain-hatch ensure-target claude-code-fitness-hermit --target')); + ok('Visibility prompt still offers .local vs committed', + /Visibility[\s\S]{0,240}`\.local` files[\s\S]{0,120}Committed files/.test(text)); + ok('writes the block via sync-block', + text.includes('domain-hatch sync-block claude-code-fitness-hermit')); + + // Prose surfaces that drifted from the manifest and from core's resolver + // before the protocol was centralised. None of them may come back. + ok('does not read hatch-options.json directly', !text.includes('hatch-options.json')); + ok('does not restate install-scope detection', !text.includes('claude plugin list --json')); + ok('does not restate the hatch-options stamp schema', + !/"stamped_by":\s*"/.test(text) && !/"core_install_scope":\s*"/.test(text)); + ok('states no hardcoded core version floor', + text.split('\n') + .filter(l => /(?:base hermit|core hermit|claude-code-hermit|_hermit_versions)/i.test(l)) + .every(l => !/(?:requires|earlier than|less than|below)\s+`?≥?>?=?\s*\d+\.\d+\.\d+/i.test(l))); + + ok('stamps its own version into _hermit_versions', + text.includes('_hermit_versions["claude-code-fitness-hermit"]')); + ok('the stamped value comes from the preflight verdict, not a literal', + /_hermit_versions\["claude-code-fitness-hermit"\][\s\S]{0,60}self_version/.test(text)); + + ok('names the block marker it hands to sync-block', + text.includes('')); +} + +console.log('\nstate-templates/CLAUDE-APPEND.md:'); + +ok('file exists', fs.existsSync(TEMPLATE), TEMPLATE); + +if (fs.existsSync(TEMPLATE)) { + const tpl = fs.readFileSync(TEMPLATE, 'utf-8'); + // sync-block replaces between the markers, so the template must carry both. + ok('opening marker present', + tpl.includes('')); + ok('closing marker present', + tpl.includes('')); +} + +process.exit(summary() === 0 ? 0 : 1); diff --git a/plugins/claude-code-fitness-hermit/tests/run-all.sh b/plugins/claude-code-fitness-hermit/tests/run-all.sh index 9e3e733b..7ac7b243 100755 --- a/plugins/claude-code-fitness-hermit/tests/run-all.sh +++ b/plugins/claude-code-fitness-hermit/tests/run-all.sh @@ -7,6 +7,7 @@ PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" rc=0 bun "$SCRIPT_DIR/skill-structure.test.ts" || rc=$? +bun "$SCRIPT_DIR/hatch-skill.test.ts" || rc=$? if [ -d "$PLUGIN_ROOT/scripts" ]; then while IFS= read -r f; do diff --git a/plugins/claude-code-hermit/CHANGELOG.md b/plugins/claude-code-hermit/CHANGELOG.md index d0f57716..7bd64b63 100644 --- a/plugins/claude-code-hermit/CHANGELOG.md +++ b/plugins/claude-code-hermit/CHANGELOG.md @@ -7,6 +7,9 @@ - `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. - `proposal.ts micro 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 proposal *)` allow entries so domain plugins can reach core's shared scripts through the project-resident `bin/hermit-run` (their own `${CLAUDE_PLUGIN_ROOT}` can't resolve core's versioned cache dir). Each is pinned to the one verb that plugin needs — a bare `hermit-run proposal *` would also expose `create`, `patch`, `shell-append`, `next-task` and `routine`. The word-boundary space and `hermit-exec.sh`'s new `/`/`..` rejection keep the route from reaching a script outside core's `scripts/`. +- `scripts/domain-hatch.ts`, the shared protocol every domain hatch now runs, reachable as `.claude-code-hermit/bin/hermit-run domain-hatch `. `preflight` returns the core-version verdict plus the resolved `target`, `target_file`, `target_default` and `needs_target_question`; `ensure-target` records an operator override; `sync-block` writes the plugin's CLAUDE-APPEND block into the resolved file (`--rendered-stdin` for blocks the plugin renders itself). +- Three `Bash(.claude-code-hermit/bin/hermit-run domain-hatch *)` allow entries, one per verb. A bare `domain-hatch *` would hand every caller `ensure-target` and `sync-block`, which write core state and the operator's `CLAUDE.md`, when most of a hatch run only needs to read `preflight`. +- `validate-config.ts` now validates `scheduled_checks[]` (array shape, object entries, `id` grammar, `skill` present and a string, `plugin`/`enabled` types, duplicate ids) and `_hermit_versions` value types. Domain hatches write both and neither had any validation, so a typo'd entry stayed structurally valid and silently dead. - `apply-settings.ts permissions-plan` and `permissions-sync` — the script now owns hermit's permission list end to end. `permissions-plan` prints `{"missing":[],"obsolete":[]}` without writing; `permissions-sync` applies it, adding sealed entries and removing only entries a previous plugin version shipped and has since retired. Operator-authored rules are never removed — deletion is filtered by a sealed registry, not by shape. ### Fixed @@ -20,6 +23,7 @@ - Block bounds used to stop at the first standalone `---` line after the marker, silently truncating any template with an internal `---` (e.g. `laravel-forge-hermit`'s, to 6 of 77 lines) and leaving drift past that point undetected. Bounds now prefer a template's closing marker when present, falling back to the `---` heuristic only for legacy blocks that predate it. ### Changed +- `hatch` and `hermit-evolve` resolve the CLAUDE-APPEND target through `domain-hatch preflight` instead of each restating the stamped-file, marker-probe and install-scope chain, so core and all five domain hatches now derive the same target from one implementation. - The CLAUDE-APPEND block dropped content already owned elsewhere: the watch authoring rules (the `watch` skill), the `channel-send.ts` exit-code walkthrough (`channel-responder` § Outbound notification protocol), the delegation break-even math (plugin `CLAUDE.md`), and the suggestion-path/exemption list behind the memory-first rule (the `proposal-triage` and `reflection-judge` gates that execute it). 7,877 B → ~6,199 B on a surface re-paid every session load and every subagent dispatch; no safety rule changed. - `≤200 chars` for push messages is stated once, in § Operator Notification. The notification skills point at it and keep their own condensation priorities, ending nine prose copies of one constant across four plugins. - A CLAUDE-APPEND marker that appears more than once in the target file is now refused (reported `block-ambiguous`, no Edit applied) instead of risking a replace that hits the wrong instance. diff --git a/plugins/claude-code-hermit/CLAUDE.md b/plugins/claude-code-hermit/CLAUDE.md index af89e043..c9dd190e 100644 --- a/plugins/claude-code-hermit/CLAUDE.md +++ b/plugins/claude-code-hermit/CLAUDE.md @@ -51,7 +51,9 @@ When installed in a target project, state lives in `.claude-code-hermit/`: ## Hatch target routing -`hatch` routes operator-personal outputs based on the plugin's install scope (read from `claude plugin list --json`): `scope=local` → `CLAUDE.local.md` + `.claude/settings.local.json`; `scope=project` → `CLAUDE.md` + `.claude/settings.json`; `scope=user` or no detectable scope → `.local` files (safer default). Advanced mode lets the operator override the scope-derived default via the Visibility prompt. The chosen target is persisted to `.claude-code-hermit/state/hatch-options.json` and read by `hermit-evolve`, `docker-setup`, and `claude-code-dev-hermit:hatch`. `hermit-evolve` Steps 6, 7, 8 are target-aware and will not re-add committed files after a `.local` migration. +`scripts/domain-hatch.ts` owns target resolution and stamping for every consumer — core `hatch`, `hermit-evolve`, `docker-setup`, and all five domain hatches. Routing is derived from the plugin's install scope (read from `claude plugin list --json`): `scope=local` → `CLAUDE.local.md` + `.claude/settings.local.json`; `scope=project` → `CLAUDE.md` + `.claude/settings.json`; `scope=user` or no detectable scope → `.local` files (safer default). Advanced mode lets the operator override the scope-derived default via the Visibility prompt. + +The resolved target is stamped into `.claude-code-hermit/state/hatch-options.json` by `domain-hatch.ts` alone; no consumer re-derives it. Domain hatches reach it through `.claude-code-hermit/bin/hermit-run domain-hatch ` — `preflight` returns the resolved `target`/`target_file`/`target_default`/`needs_target_question` plus the version verdict, `ensure-target` records an operator override, and `sync-block` writes the CLAUDE-APPEND block into the resolved file. `hermit-evolve` Steps 6, 7, 8 are target-aware and will not re-add committed files after a `.local` migration. ## Migrations diff --git a/plugins/claude-code-hermit/scripts/apply-settings.ts b/plugins/claude-code-hermit/scripts/apply-settings.ts index 40dc4924..3d284fb7 100644 --- a/plugins/claude-code-hermit/scripts/apply-settings.ts +++ b/plugins/claude-code-hermit/scripts/apply-settings.ts @@ -78,6 +78,13 @@ const HERMIT_ALLOW = [ // `micro…`-prefixed verb. 'Bash(.claude-code-hermit/bin/hermit-run proposal micro *)', 'Bash(.claude-code-hermit/bin/hermit-run proposal metrics *)', + // The shared domain-hatch protocol, pinned per verb for the same reason: + // `domain-hatch *` would hand every caller `ensure-target` and `sync-block` + // — writes to core state and to the operator's CLAUDE.md — when most of a + // hatch run only needs to read `preflight`. + 'Bash(.claude-code-hermit/bin/hermit-run domain-hatch preflight *)', + 'Bash(.claude-code-hermit/bin/hermit-run domain-hatch ensure-target *)', + 'Bash(.claude-code-hermit/bin/hermit-run domain-hatch sync-block *)', "Bash(bash -c 'AGENT_DIR=\".claude-code-hermit\"*)", 'Edit(.claude-code-hermit/**)', ]; diff --git a/plugins/claude-code-hermit/scripts/domain-hatch.ts b/plugins/claude-code-hermit/scripts/domain-hatch.ts new file mode 100644 index 00000000..32f66793 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/domain-hatch.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env bun +// domain-hatch.ts — the shared protocol every domain hatch runs, over +// lib/domain-hatch/. +// +// Reached from a domain plugin as: +// .claude-code-hermit/bin/hermit-run domain-hatch [args] +// A domain plugin's own ${CLAUDE_PLUGIN_ROOT} is +// //// and cannot resolve core's, so the +// project-resident bin/hermit-run dispatcher is the route in. +// +// Usage: +// bun domain-hatch.ts preflight [--project-root

] [--state-dir ] +// Read-only. Prints one JSON verdict: which of the two stale-core cases +// applies (if any), whether this is a full run or a re-verify, the resolved +// CLAUDE target, and what the CLAUDE-APPEND block needs. Always exits 0 — +// callers read the fields, not the code. +// +// bun domain-hatch.ts ensure-target --target +// Creates or repairs state/hatch-options.json. Core owns this file; the +// verb exists so domain hatches stop carrying their own copy of the +// detection and stamping rules. +// +// bun domain-hatch.ts sync-block [--rendered-stdin] +// Appends the plugin's CLAUDE-APPEND block when absent, replaces it only +// when rendered content is piped in and differs, refuses on a duplicated +// marker. Version-driven refresh stays hermit-evolve's. +// +// Verb dispatch is lazy: preflight is the hot path (every hatch run, including +// the re-verify that changes nothing) and has no business loading the block +// writer to do its job. +// +// Mutating verbs exit 1 on failure, matching micro-proposal.ts / +// apply-settings.ts / hatch-config.ts. preflight is inspection and exits 0. + +import path from 'node:path'; +import { hermitDir } from './lib/cc-compat'; +import { flagValue } from './lib/cli'; + +const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(import.meta.dir, '..'); + +function out(o: unknown): void { + process.stdout.write(JSON.stringify(o) + '\n'); +} + +function die(code: string, message: string): never { + out({ ok: false, error: code, message }); + process.exit(1); +} + +const argv = process.argv.slice(2); +const verb = argv[0]; +const pluginId = argv[1]; + +if (!verb || !pluginId) { + process.stderr.write('Usage: domain-hatch.ts [args]\n'); + process.exit(1); +} + +// A plugin id is a bare plugin name — reject anything that could be read as a +// path. hermit-exec.sh checks the script name but forwards the rest of argv +// untouched, so this is the only place the identity argument is validated. +if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pluginId)) { + die('invalid_plugin_id', `not a plugin id: ${pluginId}`); +} + +const stateDir = flagValue(argv, '--state-dir') ?? hermitDir(); +const projectRoot = flagValue(argv, '--project-root') ?? path.resolve(stateDir, '..'); +const stdinJsonFile = flagValue(argv, '--plugin-list-file'); + +async function readStdinIfFlagged(flag: string): Promise { + if (!argv.includes(flag)) return undefined; + let buf = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) buf += chunk; + return buf; +} + +// Test seam shared by all three verbs: read the plugin list from a file so +// tests never shell out to a live `claude`. +async function readPluginListFile(): Promise { + if (!stdinJsonFile) return undefined; + const fs = await import('node:fs'); + try { return fs.readFileSync(stdinJsonFile, 'utf8'); } catch { return undefined; } +} + +if (verb === 'preflight') { + const { preflight } = await import('./lib/domain-hatch/preflight'); + const stdinJson = await readPluginListFile(); + out(preflight({ pluginId, hermitDir: stateDir, projectRoot, corePluginRoot: PLUGIN_ROOT, stdinJson })); + process.exit(0); +} + +if (verb === 'ensure-target') { + const target = flagValue(argv, '--target'); + if (target !== 'local' && target !== 'committed') { + die('bad_target', '--target must be local or committed'); + } + const [{ ensureHatchTarget }, { resolvePlugin, isResolveError, pluginList }, { coreScope }] = await Promise.all([ + import('./lib/domain-hatch/target'), + import('./lib/domain-hatch/resolve'), + import('./resolve-siblings'), + ]); + const stdinJson = await readPluginListFile(); + const list = pluginList(stdinJson); + const resolved = resolvePlugin(list, pluginId, projectRoot); + if (isResolveError(resolved)) die(resolved.error, resolved.message); + const scope = coreScope(list as any, projectRoot); + const res = ensureHatchTarget(stateDir, { + target, + core_scope: scope.core_scope, + stampedBy: `${pluginId}:hatch`, + version: resolved.version ?? '0.0.0', + }); + out(res); + process.exit(res.ok ? 0 : 1); +} + +if (verb === 'sync-block') { + const [{ planBlock, applyBlock }, { readTargetState, targetFile }, { resolvePlugin, isResolveError, pluginList }, { coreScope }] = + await Promise.all([ + import('./lib/domain-hatch/block'), + import('./lib/domain-hatch/target'), + import('./lib/domain-hatch/resolve'), + import('./resolve-siblings'), + ]); + const stdinJson = await readPluginListFile(); + const list = pluginList(stdinJson); + const resolved = resolvePlugin(list, pluginId, projectRoot); + if (isResolveError(resolved)) die(resolved.error, resolved.message); + + const state = readTargetState(stateDir, coreScope(list as any, projectRoot), projectRoot); + if (!state.target) { + die('no_target', 'hatch-options.json has no usable target; run ensure-target first'); + } + + const rendered = await readStdinIfFlagged('--rendered-stdin'); + const foreign = list + .map((e: any) => String(e?.id ?? '').split('@')[0]) + .filter((n: string) => n && n !== pluginId); + + const result = applyBlock( + planBlock(resolved.installPath, pluginId, path.join(projectRoot, targetFile(state.target)), foreign, rendered), + ); + out(result); + process.exit(result.ok ? 0 : 1); +} + +process.stderr.write(`domain-hatch.ts: unknown verb "${verb}"\n`); +process.exit(1); diff --git a/plugins/claude-code-hermit/scripts/evolve-plan.ts b/plugins/claude-code-hermit/scripts/evolve-plan.ts index 7558d0a2..da13c1e2 100644 --- a/plugins/claude-code-hermit/scripts/evolve-plan.ts +++ b/plugins/claude-code-hermit/scripts/evolve-plan.ts @@ -373,6 +373,19 @@ function markerOnward(text: string, marker?: string, foreignNames: string[] = [] return lines.slice(start, end).join('\n'); } +// Ambiguity guard for a marker block: the marker line must appear at most once, +// and the resolved block must occur exactly once in the target — otherwise a +// replace Edit's old_string wouldn't be unique (could hit the wrong instance), +// and the caller must NOT fall through to append, which would add a third copy. +// Exported because domain-hatch's sync-block applies the same guard: two +// writers touching one block have to agree on when it is safe to replace, or a +// block gets rewritten by one and refused by the other. +function isAmbiguousBlock(targetText: string, marker: string, targetBlock: string): boolean { + const markerLineCount = targetText.split('\n').filter(l => l.trim() === marker).length; + const occurrences = targetText.split(targetBlock).length - 1; + return markerLineCount > 1 || occurrences > 1; +} + // Find the plugin's own CLAUDE-APPEND opening marker: "". // Name-anchored so an unrelated leading comment (e.g. dev-hermit's // "") can never be mistaken for the block marker. @@ -459,13 +472,7 @@ function _diffClaudeAppendByText( return opts.sibling ? { changed: true, missing: true } : { changed: true }; } - // Ambiguity guard: the marker line must appear at most once, and old_block - // must occur exactly once in the target — otherwise a replace Edit's - // old_string wouldn't be unique (could hit the wrong instance), and this - // must NOT fall through to append, which would add a third copy. - const markerLineCount = targetText!.split('\n').filter(l => l.trim() === marker).length; - const occurrences = targetText!.split(targetBlock).length - 1; - if (markerLineCount > 1 || occurrences > 1) { + if (isAmbiguousBlock(targetText!, marker, targetBlock)) { return { changed: true, ambiguous: true }; } @@ -852,7 +859,7 @@ function parseArgs(argv: string[]) { return { hermitDir: hermitDir || '.claude-code-hermit', hatchTarget, pluginListJsonPath }; } -export { buildPlan, cmpSemver, changelogSlice, newConfigKeys, markerOnward, extractSiblingMarker, closingMarkerFor, classifyFiles, classifyDockerEntrypoint, classifyDockerTemplates }; +export { buildPlan, cmpSemver, changelogSlice, newConfigKeys, markerOnward, extractSiblingMarker, closingMarkerFor, isAmbiguousBlock, classifyFiles, classifyDockerEntrypoint, classifyDockerTemplates }; export type { ClassifiedFile, FileClass }; if (import.meta.main) { diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts new file mode 100644 index 00000000..8141d636 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts @@ -0,0 +1,128 @@ +// CLAUDE-APPEND block sync for domain hatches. +// +// Boundary detection, the closing-marker/`---` fallback, foreign-block fencing +// and the duplicate-marker refusal all come from evolve-plan.ts. Nothing here +// re-derives them: hermit-evolve and hatch write the same marked block, so a +// second parser would eventually disagree with the first about where a block +// ends and one of them would corrupt it. +// +// The write rule, unified across the five domain hatches: +// marker absent -> append the template +// marker present -> skip; version-driven refresh is hermit-evolve's +// rendered input differs -> replace (dev, whose block is rendered per mode +// from an input hermit-evolve cannot observe) +// ambiguous -> refuse, never append a third copy +// +// Before this, dev and HA also refreshed on a stale stamped version while +// fitness, feed and forge skipped — two owners for one block, with only one of +// them tracking evolve's bounds handling. + +import fs from 'node:fs'; +import path from 'node:path'; +import { markerOnward, extractSiblingMarker, isAmbiguousBlock } from '../../evolve-plan'; +import { writeFileAtomic } from '../md-write'; + +export type BlockAction = 'append' | 'replace' | 'skip' | 'ambiguous' | 'no-template' | 'no-marker'; + +export interface BlockPlan { + action: BlockAction; + marker: string | null; + targetFile: string; + /** The block currently in the target, when one was found. */ + old_block?: string; + /** What would be written. */ + new_block?: string; +} + +export function templatePath(installPath: string): string { + return path.join(installPath, 'state-templates', 'CLAUDE-APPEND.md'); +} + +function read(p: string): string | null { + try { return fs.readFileSync(p, 'utf8'); } catch { return null; } +} + +// Other registered plugin names, so a block can never swallow a sibling's when +// it has no closing marker. +export function planBlock( + installPath: string, + pluginName: string, + targetPath: string, + foreignNames: string[], + rendered?: string, +): BlockPlan { + const tmplText = rendered ?? read(templatePath(installPath)); + if (tmplText === null) { + return { action: 'no-template', marker: null, targetFile: targetPath }; + } + + const marker = extractSiblingMarker(tmplText, pluginName); + if (marker === null) { + return { action: 'no-marker', marker: null, targetFile: targetPath }; + } + + const targetText = read(targetPath); + if (targetText === null) { + // Missing target file is the append case — the caller's Edit creates it. + return { action: 'append', marker, targetFile: targetPath, new_block: tmplText }; + } + + const targetBlock = markerOnward(targetText, marker, foreignNames); + if (targetBlock === null) { + return { action: 'append', marker, targetFile: targetPath, new_block: tmplText }; + } + + if (isAmbiguousBlock(targetText, marker, targetBlock)) { + return { action: 'ambiguous', marker, targetFile: targetPath, old_block: targetBlock }; + } + + // Only a caller that supplied rendered content can ask for a replace: a + // static template that is already present is hermit-evolve's to refresh. + if (rendered === undefined) { + return { action: 'skip', marker, targetFile: targetPath, old_block: targetBlock }; + } + + const tmplBlock = markerOnward(tmplText, marker, foreignNames) ?? tmplText; + if (norm(targetBlock) === norm(tmplBlock)) { + return { action: 'skip', marker, targetFile: targetPath, old_block: targetBlock }; + } + return { action: 'replace', marker, targetFile: targetPath, old_block: targetBlock, new_block: tmplBlock }; +} + +function norm(s: string): string { + return s.replace(/\s+$/, ''); +} + +export interface BlockResult extends BlockPlan { + ok: boolean; + written: boolean; + message?: string; +} + +// Apply the plan. Refuses on ambiguity and on a missing template rather than +// guessing — both are states where a wrong write is worse than no write. +export function applyBlock(plan: BlockPlan): BlockResult { + if (plan.action === 'ambiguous') { + return { ...plan, ok: false, written: false, message: `marker ${plan.marker} appears more than once in ${plan.targetFile}; refusing to replace` }; + } + if (plan.action === 'no-template') { + return { ...plan, ok: false, written: false, message: 'plugin ships no state-templates/CLAUDE-APPEND.md' }; + } + if (plan.action === 'no-marker') { + return { ...plan, ok: false, written: false, message: 'template carries no opening marker for this plugin' }; + } + if (plan.action === 'skip') { + return { ...plan, ok: true, written: false }; + } + + const existing = read(plan.targetFile) ?? ''; + let next: string; + if (plan.action === 'append') { + const sep = existing === '' || existing.endsWith('\n') ? '' : '\n'; + next = existing + sep + plan.new_block; + } else { + next = existing.replace(plan.old_block!, plan.new_block!); + } + writeFileAtomic(plan.targetFile, next.endsWith('\n') ? next : next + '\n'); + return { ...plan, ok: true, written: true }; +} diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts new file mode 100644 index 00000000..0d5b3be4 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts @@ -0,0 +1,167 @@ +// The read-only verdict a domain hatch acts on. +// +// Replaces five prose copies of the same three steps (core-prereq check, +// idempotency gate, target routing). Four of those copies hardcoded a core +// version floor in skill text — HA 1.0.16, fitness 1.0.26, feed 1.2.22, forge +// 1.1.1 — while every one of those plugins declared `>=1.2.30` in its +// hermit-meta.json, so four hatches would proceed against a core too old for +// them. The floor is read from the manifest here and nowhere else. +// +// Writes nothing. `ensure-target` and `sync-block` own the mutations, so a +// hatch can always ask this what it is looking at before changing anything. + +import path from 'node:path'; +import { coreScope } from '../../resolve-siblings'; +import { readJson } from '../cli'; +import { resolvePlugin, isResolveError, pluginList, type ResolvedPlugin } from './resolve'; +import { readTargetState, targetFile, type Target, type CoreScope } from './target'; +import { planBlock } from './block'; + +type Json = any; + +export type Action = + | 'bootstrap-core' + | 'upgrade-core-package' + | 'upgrade-core-applied' + | 'full' + | 'verify'; + +export interface Preflight { + ok: boolean; + error?: string; + message?: string; + plugin?: string; + self_version?: string | null; + stamped_version?: string | null; + core_floor?: string | null; + core_installed?: string | null; + core_applied?: string | null; + action?: Action; + remedy?: string; + target?: Target | null; + target_default?: Target; + target_file?: string; + core_scope?: CoreScope; + needs_target_question?: boolean; + marker?: string | null; + append_action?: string; +} + +// `>=1.2.30` and `^1.2.30` both appear in the fleet's manifests. Bun's semver +// handles either; the manual fallback covers only the `>=` form the +// hermit-meta files actually use, and is deliberately conservative — an +// unparseable range fails the check rather than waving it through. +export function satisfiesFloor(version: string | null, range: string | null): boolean { + if (!range) return true; // nothing declared -> nothing to enforce + if (!version) return false; + const anyBun = (globalThis as any).Bun; + if (anyBun?.semver?.satisfies) { + try { return anyBun.semver.satisfies(version, range); } catch { /* fall through */ } + } + const m = range.match(/^>=\s*(\d+)\.(\d+)\.(\d+)/); + if (!m) return false; + const want = [Number(m[1]), Number(m[2]), Number(m[3])]; + const got = version.split('.').map((n) => Number(n.replace(/[^\d].*$/, ''))); + for (let i = 0; i < 3; i++) { + const a = got[i] ?? 0; + const b = want[i] ?? 0; + if (a !== b) return a > b; + } + return true; +} + +export interface PreflightInput { + pluginId: string; + hermitDir: string; + projectRoot: string; + corePluginRoot: string; + /** Test seam: the plugin-list JSON, so tests never shell out to `claude`. */ + stdinJson?: string; +} + +export function preflight(input: PreflightInput): Preflight { + const { pluginId, hermitDir, projectRoot, corePluginRoot } = input; + + const config = readJson(path.join(hermitDir, 'config.json')); + if (config === null) { + return { + ok: true, + plugin: pluginId, + action: 'bootstrap-core', + remedy: 'Core hermit is not initialized in this project. Follow the domain hatch continuation protocol: write state/hatch-resume.json, then invoke /claude-code-hermit:hatch.', + }; + } + + const list = pluginList(input.stdinJson); + const resolved = resolvePlugin(list, pluginId, projectRoot); + if (isResolveError(resolved)) { + return { ok: false, error: resolved.error, message: resolved.message, plugin: pluginId }; + } + const self: ResolvedPlugin = resolved; + + const coreInstalled: string | null = + readJson(path.join(corePluginRoot, '.claude-plugin', 'plugin.json'))?.version ?? null; + const versions = (config._hermit_versions && typeof config._hermit_versions === 'object') + ? config._hermit_versions + : {}; + const coreApplied: string | null = versions['claude-code-hermit'] ?? null; + const stamped: string | null = versions[pluginId] ?? null; + + // Two distinct staleness cases with two distinct remedies. `_hermit_versions` + // is migration state that hermit-evolve advances; the resolved manifest is + // installed code that only a package update can advance. Telling an operator + // to run hermit-evolve when the package itself is old sends them to a command + // that will report up-to-date and change nothing. + const floor = self.required_core_version; + let action: Action; + let remedy: string | undefined; + if (!satisfiesFloor(coreInstalled, floor)) { + action = 'upgrade-core-package'; + remedy = `Installed core is ${coreInstalled ?? 'unknown'} but ${pluginId} requires ${floor}. Update the plugin first (Docker: .claude-code-hermit/bin/hermit-docker update; host: claude plugin update claude-code-hermit), then run /claude-code-hermit:hermit-evolve, then re-run this hatch.`; + } else if (!satisfiesFloor(coreApplied, floor)) { + action = 'upgrade-core-applied'; + remedy = `Core code is current (${coreInstalled}) but this project is still migrated to ${coreApplied ?? 'none'}, below the required ${floor}. Run /claude-code-hermit:hermit-evolve, then re-run this hatch.`; + } else { + action = stamped !== null && self.version !== null && stamped === self.version ? 'verify' : 'full'; + } + + const scope = coreScope(list as any, projectRoot); + const state = readTargetState(hermitDir, scope, projectRoot); + + let marker: string | null = null; + let appendAction: string | undefined; + if (state.target) { + const foreign = list + .map((e: Json) => String(e?.id ?? '').split('@')[0]) + .filter((n: string) => n && n !== pluginId); + // planBlock already reads the template and derives the marker; taking both + // off its result avoids a second read and parse of the same file. + const plan = planBlock( + self.installPath, + pluginId, + path.join(projectRoot, targetFile(state.target)), + foreign, + ); + marker = plan.marker; + appendAction = plan.action; + } + + return { + ok: true, + plugin: pluginId, + self_version: self.version, + stamped_version: stamped, + core_floor: floor, + core_installed: coreInstalled, + core_applied: coreApplied, + action, + ...(remedy ? { remedy } : {}), + target: state.target, + target_default: state.target_default, + ...(state.target ? { target_file: targetFile(state.target) } : {}), + core_scope: state.core_scope, + needs_target_question: state.needs_target_question, + marker, + ...(appendAction ? { append_action: appendAction } : {}), + }; +} diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts new file mode 100644 index 00000000..f4de3334 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts @@ -0,0 +1,94 @@ +// Plugin-identity resolution for the domain-hatch verbs. +// +// Every verb takes a plugin ID, never a filesystem root. `hermit-exec.sh` +// validates only the script name and forwards the rest of argv verbatim, so a +// path supplied through skill prose would be an unchecked trust boundary — an +// ID is checked here against the installed, enabled plugin list instead. + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { readJson } from '../cli'; + +type Json = any; + +export interface ResolvedPlugin { + plugin: string; + installPath: string; + version: string | null; + required_core_version: string | null; +} + +export interface ResolveError { + error: string; + message: string; +} + +// The plugin list, or [] when `claude` is unavailable. Callers treat an empty +// list as "cannot resolve" rather than "plugin absent" — the two produce +// different operator advice. +export function pluginList(stdinJson?: string): Json[] { + if (stdinJson !== undefined) { + const parsed = (() => { try { return JSON.parse(stdinJson); } catch { return null; } })(); + return Array.isArray(parsed) ? parsed : []; + } + try { + const r = spawnSync('claude', ['plugin', 'list', '--json'], { timeout: 15000, encoding: 'utf8' }); + const parsed = r.stdout ? JSON.parse(r.stdout) : null; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function splitId(id: string): string { + const at = id.indexOf('@'); + return at < 0 ? id : id.slice(0, at); +} + +// Resolve one plugin ID to the install that would actually load in this +// project. Precedence mirrors resolve-siblings' dedupe rule (local over +// project) so hatch and the sibling probe never disagree about which copy is +// live. Ambiguity across marketplaces is an error, not a silent first-match: +// picking wrong here writes a hermit block sourced from the wrong template. +export function resolvePlugin( + list: Json[], + pluginId: string, + projectRoot: string, +): ResolvedPlugin | ResolveError { + const here = list.filter( + (e) => splitId(e?.id ?? '') === pluginId && e?.enabled === true && e?.projectPath === projectRoot, + ); + const byScope = (s: string) => here.filter((e) => e?.scope === s); + const candidates = byScope('local').length ? byScope('local') : byScope('project'); + + if (!candidates.length) { + return list.length + ? { error: 'plugin_not_installed', message: `${pluginId} is not installed and enabled for this project` } + : { error: 'plugin_list_unavailable', message: 'could not read `claude plugin list --json`' }; + } + if (candidates.length > 1) { + return { + error: 'plugin_ambiguous', + message: `${pluginId} is provided by more than one marketplace at the same scope: ${candidates.map((e) => e.id).join(', ')}`, + }; + } + + const installPath = candidates[0]?.installPath ?? ''; + if (!installPath || !fs.existsSync(installPath)) { + return { error: 'plugin_path_missing', message: `${pluginId} resolved to a path that does not exist: ${installPath || '(empty)'}` }; + } + + const manifest = readJson(path.join(installPath, '.claude-plugin', 'plugin.json')); + const meta = readJson(path.join(installPath, '.claude-plugin', 'hermit-meta.json')); + return { + plugin: pluginId, + installPath, + version: manifest?.version ?? null, + required_core_version: meta?.required_core_version ?? null, + }; +} + +export function isResolveError(r: ResolvedPlugin | ResolveError): r is ResolveError { + return (r as ResolveError).error !== undefined; +} diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/target.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/target.ts new file mode 100644 index 00000000..1bb54910 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/target.ts @@ -0,0 +1,170 @@ +// Core-owned read/repair/write of state/hatch-options.json. +// +// This file records the operator's global choice of where hermit blocks go +// (committed CLAUDE.md vs gitignored CLAUDE.local.md) plus the core install +// scope that choice was derived from. It is core state: before this module, +// five domain hatches each carried their own prose copy of the detection and +// stamping rules, one of which (feed) had already dropped the +// `projectPath == project root` qualifier and therefore resolved a +// project-scoped install to the wrong file. +// +// Two behaviours the prose copies did not have: +// * repair — the write used to be gated on the FILE being absent while the +// read fell back on the KEY being absent, so a file present without a +// `target` key silently resolved to committed CLAUDE.md forever. Here a +// missing/invalid key is a repair case, surfaced to the caller so it can +// ask rather than assume. +// * first-stamp preservation for every writer, not just core hatch. + +import fs from 'node:fs'; +import path from 'node:path'; +import { writeFileAtomic } from '../md-write'; +import { localISOStamp } from '../time'; +import { readJson } from '../cli'; + +type Json = any; + +export type Target = 'local' | 'committed'; +export type CoreScope = 'local' | 'project' | 'user' | null; + +export interface HatchOptions { + target: Target; + core_install_scope: CoreScope; + stamped_at: string; + stamped_by: string; + version: string; + last_updated_at?: string; + last_updated_by?: string; +} + +export interface TargetState { + /** Resolved target, or null when the file is absent or its key unusable. */ + target: Target | null; + /** Scope-derived default to offer at position 0 when asking the operator. */ + target_default: Target; + core_scope: CoreScope; + /** True when the caller must ask the operator before a write can happen. */ + needs_target_question: boolean; + /** Present and usable file that merely needs no change. */ + present: boolean; +} + +export function optionsPath(hermitDir: string): string { + return path.join(hermitDir, 'state', 'hatch-options.json'); +} + +function readOptions(hermitDir: string): Json { + return readJson(optionsPath(hermitDir)); +} + +function isTarget(v: unknown): v is Target { + return v === 'local' || v === 'committed'; +} + +// Core's own CLAUDE-APPEND marker. Where core already put its block is a +// stronger signal of the operator's intent than re-deriving install scope, so +// it outranks the scope default — this preserves hermit-evolve's fallback +// chain, which checked both files before falling back to detection. +const CORE_MARKER = 'claude-code-hermit: Session Discipline'; + +function fileHasCoreMarker(projectRoot: string, name: string): boolean { + try { return fs.readFileSync(path.join(projectRoot, name), 'utf8').includes(CORE_MARKER); } catch { return false; } +} + +// Full precedence chain, in one place for core hatch, hermit-evolve and every +// domain hatch: +// 1. hatch-options.json `target` +// 2. core's block already in CLAUDE.local.md -> local +// 3. core's block already in CLAUDE.md -> committed +// 4. scope-derived default from coreScope() +// Only (1) counts as answered; (2) and (3) inform the default a caller offers +// but still leave the file unstamped, so it gets repaired on the next write. +export function readTargetState( + hermitDir: string, + scopeDefault: { core_scope: CoreScope; target: Target }, + projectRoot?: string, +): TargetState { + const existing = readOptions(hermitDir); + const usable = existing && isTarget(existing.target); + + let fallback: Target = scopeDefault.target; + if (!usable && projectRoot) { + if (fileHasCoreMarker(projectRoot, 'CLAUDE.local.md')) fallback = 'local'; + else if (fileHasCoreMarker(projectRoot, 'CLAUDE.md')) fallback = 'committed'; + } + + return { + target: usable ? existing.target : null, + target_default: fallback, + core_scope: scopeDefault.core_scope, + needs_target_question: !usable, + present: !!usable, + }; +} + +export interface EnsureResult { + ok: boolean; + action: 'created' | 'repaired' | 'updated' | 'unchanged'; + target: Target; + path: string; +} + +// Create, repair, or update the file. `stampedBy` is the caller's skill id +// (e.g. "feed-hermit:hatch"); `version` is that caller's plugin version. +// +// An existing file keeps its original stamped_at/stamped_by — the first writer +// owns provenance — and records the later writer in last_updated_*. That is +// core hatch's own rule, now applied to every caller rather than restated per +// plugin. +export function ensureHatchTarget( + hermitDir: string, + opts: { target: Target; core_scope: CoreScope; stampedBy: string; version: string }, +): EnsureResult { + const p = optionsPath(hermitDir); + const existing = readOptions(hermitDir); + const hadFile = existing !== null; + const hadTarget = hadFile && isTarget(existing.target); + const now = localISOStamp(); + + let next: HatchOptions; + if (hadFile && typeof existing.stamped_by === 'string' && typeof existing.stamped_at === 'string') { + next = { + target: opts.target, + core_install_scope: opts.core_scope, + stamped_at: existing.stamped_at, + stamped_by: existing.stamped_by, + version: opts.version, + last_updated_at: now, + last_updated_by: opts.stampedBy, + }; + } else { + next = { + target: opts.target, + core_install_scope: opts.core_scope, + stamped_at: now, + stamped_by: opts.stampedBy, + version: opts.version, + }; + } + + const unchanged = + hadTarget && + existing.target === next.target && + existing.core_install_scope === next.core_install_scope && + existing.version === next.version; + + let action: EnsureResult['action']; + if (!hadFile) action = 'created'; + else if (!hadTarget) action = 'repaired'; + else action = unchanged ? 'unchanged' : 'updated'; + + if (action !== 'unchanged') { + fs.mkdirSync(path.dirname(p), { recursive: true }); + writeFileAtomic(p, JSON.stringify(next, null, 2) + '\n'); + } + return { ok: true, action, target: next.target, path: p }; +} + +export function targetFile(target: Target): string { + return target === 'local' ? 'CLAUDE.local.md' : 'CLAUDE.md'; +} diff --git a/plugins/claude-code-hermit/scripts/validate-config.ts b/plugins/claude-code-hermit/scripts/validate-config.ts index 241e5f51..2f134298 100644 --- a/plugins/claude-code-hermit/scripts/validate-config.ts +++ b/plugins/claude-code-hermit/scripts/validate-config.ts @@ -192,6 +192,57 @@ function validate(config: Json): { errors: string[]; warnings: string[] } { }); } + // scheduled_checks are written by domain hatches, which until now had no + // validation at all here — a typo'd skill name (issue #651's failure, one + // array over) produced a structurally valid config with a dead entry that + // nothing caught. Same id grammar as routines: ids travel in the same + // markers and JSONL. + if (config.scheduled_checks !== undefined && !Array.isArray(config.scheduled_checks)) { + errors.push(`scheduled_checks: expected array, got ${config.scheduled_checks === null ? 'null' : typeof config.scheduled_checks}`); + } else if (Array.isArray(config.scheduled_checks)) { + const checkIds = new Set(); + config.scheduled_checks.forEach((c: Json, i: number) => { + if (!c || typeof c !== 'object' || Array.isArray(c)) { + errors.push(`scheduled_checks[${i}]: must be an object`); + return; + } + if (!c.id) errors.push(`scheduled_checks[${i}]: missing id`); + else if (typeof c.id !== 'string' || !ROUTINE_ID_RE.test(c.id)) { + errors.push(`scheduled_checks[${i}]: id "${c.id}" must match ^[A-Za-z0-9._-]{1,64}$`); + } + if (!c.skill) errors.push(`scheduled_checks[${i}]: missing skill`); + else if (typeof c.skill !== 'string') { + errors.push(`scheduled_checks[${i}]: skill must be a string, got ${typeof c.skill}`); + } + if (c.plugin !== undefined && typeof c.plugin !== 'string') { + errors.push(`scheduled_checks[${i}]: plugin must be a string, got ${typeof c.plugin}`); + } + if (c.enabled !== undefined && typeof c.enabled !== 'boolean') { + warnings.push(`scheduled_checks[${i}]: "enabled" should be boolean`); + } + if (c.id && checkIds.has(c.id)) { + warnings.push(`scheduled_checks[${i}]: duplicate id "${c.id}"`); + } + if (c.id) checkIds.add(c.id); + }); + } + + // _hermit_versions is the applied-migration record hermit-evolve reads to + // compute the upgrade gap. A non-string value there makes that comparison + // meaningless, and the failure would only surface at upgrade time. + if (config._hermit_versions !== undefined) { + const hv = config._hermit_versions; + if (!hv || typeof hv !== 'object' || Array.isArray(hv)) { + errors.push(`_hermit_versions: expected object, got ${hv === null ? 'null' : Array.isArray(hv) ? 'array' : typeof hv}`); + } else { + for (const [plugin, v] of Object.entries(hv)) { + if (typeof v !== 'string') { + errors.push(`_hermit_versions.${plugin}: expected string version, got ${v === null ? 'null' : typeof v}`); + } + } + } + } + if (config.channels && typeof config.channels === 'object') { for (const [name, ch] of Object.entries(config.channels)) { // channels.primary is a magic string key (preferred-channel pointer), not a diff --git a/plugins/claude-code-hermit/skills/hatch/SKILL.md b/plugins/claude-code-hermit/skills/hatch/SKILL.md index 24fe39d9..71939ef4 100644 --- a/plugins/claude-code-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-hermit/skills/hatch/SKILL.md @@ -554,6 +554,7 @@ entries the target lacks, and any entries from retired plugin versions it still - `git diff`, `git status`, `git log` — session-diff.ts hook auto-populates `## Changed` in SHELL.md - `bun */scripts/.ts` — Stop hooks (cost-tracker, session-diff, evaluate-session) and precheck scripts (`heartbeat.ts precheck`, reflect-precheck), scoped to plugin scripts only. Includes `manifest-seed.ts`, which the seeding sub-step below runs to write the template-manifest baseline (deferred from Step 2 so the permission is in place first). Includes `channel-log.ts`, which weekly-review's consolidation step runs unattended to list/mark/prune the episodic channel log (PROP-010). Includes `session-archive.ts`, the deterministic session-lifecycle writer (idle/close/auto-close/open/recover) that replaced the session-mgr subagent — without this permission a hatched hermit would be asked (functionally denied headlessly) on its first idle transition. Includes `proposal.ts` — the single proposal CLI. Its create/patch/shell-append/next-task/routine verbs perform every `.claude-code-hermit/` state-dir write proposal-create and proposal-act make; without it, proposal creation and every accept/defer/dismiss/resolve mutation would be functionally denied in background/worktree sessions (the harness's isolation guard blocks the Write/Edit tools there, not Bash). Its resolve-id/gate/queue-micro/micro/index/metrics/success-signal verbs are the proposal-act/proposal-create/reflect mechanics; without them, ID resolution, gate-verdict routing, and micro-approval queuing would all be functionally denied headlessly. Includes `apply-reflection-actions.ts` and `transcript-digest.ts` — reflect's transactional resolution-action apply and its behavioral-telemetry digest; without these a scheduled reflect silently degrades to introspection-only and never resolves a proposal. Includes `setup-token-mint.ts` — the login-token renewal driver `/relogin` runs; that skill exists to be driven from chat when the hermit's login is about to lapse, so a permission prompt there is an outright denial and the renewal it was meant to perform never happens - `.claude-code-hermit/bin/hermit-run proposal micro *` / `proposal metrics *` — domain plugins (HA's `ha-morning-brief`, the `domain-brainstorm` skills) reach core's shared scripts through the project-resident `bin/hermit-run`, since their own `${CLAUDE_PLUGIN_ROOT}` can't resolve core's versioned cache dir. Each grant is pinned to the one verb that plugin needs, never a bare `hermit-run proposal *` — that would also expose `create`, `patch`, `shell-append`, `next-task` and `routine`, i.e. arbitrary state-dir writes. The space before `*` is a word boundary — matches `proposal micro .claude-code-hermit brief-cycle`, not a `micro…`-prefixed verb — and `hermit-exec.sh` additionally rejects `/`/`..` in the script name, so the route can't reach a script outside core's `scripts/`. Without these, headless domain briefs and brainstorm metrics checks are functionally denied +- `.claude-code-hermit/bin/hermit-run domain-hatch preflight *` / `ensure-target *` / `sync-block *` — the shared domain-hatch protocol every domain plugin's `/hatch` runs: the core-version floor check, the CLAUDE target resolution, and the CLAUDE-APPEND block write. Pinned per verb for the same reason as above — a bare `domain-hatch *` would hand every caller `ensure-target` and `sync-block`, which write core state and the operator's `CLAUDE.md`, when most of a hatch run only reads `preflight`. Without these, a domain hatch cannot check whether core is new enough for it and would proceed against a core it declares it cannot run on - `bash -c 'AGENT_DIR=...` — SessionStart hook that loads session context on every startup - `Edit` on `.claude-code-hermit/**` — heartbeat appends to SHELL.md, increments config.json tick counter, and skills update session state without prompting (Edit rules cover all file-editing tools, including Write) @@ -631,35 +632,15 @@ No `AskUserQuestion` — enabling or disabling `sandbox.*` is entirely the opera ### 9b. Persist hatch options -After Steps 6–9 complete, write `.claude-code-hermit/state/hatch-options.json`. +After Steps 6–9 complete, run: -**If the file does not exist**, write: - -```json -{ - "target": "", - "core_install_scope": "", - "stamped_at": "", - "stamped_by": "claude-code-hermit:hatch", - "version": "" -} +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/domain-hatch.ts ensure-target claude-code-hermit --target ``` -**If the file already exists** (e.g. `claude-code-dev-hermit:hatch` stamped it first), preserve the original `stamped_by` and `stamped_at` and update the rest: - -```json -{ - "target": "", - "core_install_scope": "", - "stamped_at": "", - "stamped_by": "", - "last_updated_at": "", - "last_updated_by": "claude-code-hermit:hatch", - "version": "" -} -``` +with the target chosen in Step 6. The script owns the whole schema: it stamps the five canonical fields on a fresh file, and on an existing one (a domain hatch may have stamped it first) it preserves the original `stamped_at`/`stamped_by` and records this run in `last_updated_at`/`last_updated_by`. It also repairs a file that exists without a usable `target`, which the previous file-existence gate left unfixable. Exits non-zero on a failed write; it prints `{ok, action, target, path}` where `action` is `created`, `repaired`, `updated` or `unchanged`. -This file is read by `hermit-evolve`, `docker-setup`, and `claude-code-dev-hermit:hatch` to inherit the operator's target choice without re-running scope detection. +This file is read by `hermit-evolve`, `docker-setup`, and every domain hatch to inherit the operator's target choice without re-running scope detection. ### 9c. Artifact publish permission diff --git a/plugins/claude-code-hermit/skills/hermit-evolve/reference.md b/plugins/claude-code-hermit/skills/hermit-evolve/reference.md index f3b30dff..685e2bb1 100644 --- a/plugins/claude-code-hermit/skills/hermit-evolve/reference.md +++ b/plugins/claude-code-hermit/skills/hermit-evolve/reference.md @@ -51,11 +51,9 @@ The dispatch prompt supplies the resolved absolute plugin root — substitute it First determine `hatch_target` (the pre-pass needs it, and so do Steps 6, 7, 8): -1. Read `.claude-code-hermit/state/hatch-options.json` if it exists. Use the `"target"` field as `hatch_target`. -2. If the file is absent or has no `"target"` field: - - Check if `CLAUDE.local.md` contains the marker `claude-code-hermit: Session Discipline` → `hatch_target = "local"`. - - Else if `CLAUDE.md` contains the marker → `hatch_target = "committed"`. - - Else → re-run scope detection: read `claude plugin list --json`. From the output, find all entries where plugin name (substring of `id` left of `@`) is `claude-code-hermit` and `enabled == true`. Apply precedence: if any has `scope == "local"` and `projectPath` equals current project root → `local`; else if any has `scope == "project"` and `projectPath` equals current project root → `project`; else if any has `scope == "user"` (any `projectPath`) → `user`; else → `null`. Map: `project` → `committed`; `local`/`user`/`null` → `local`. +Run `bun /scripts/domain-hatch.ts preflight claude-code-hermit` and take `target` as `hatch_target`. It owns the whole chain — the stamped file first, then core's own block in `CLAUDE.local.md` or `CLAUDE.md`, then install-scope detection — so hatch, evolve and every domain hatch resolve the target identically. + +If it returns `needs_target_question: true` the project has no stamped target. Use the returned `target_default` and stamp it with `bun /scripts/domain-hatch.ts ensure-target claude-code-hermit --target `, so the next run of anything reads an answered file instead of re-deriving. Then run the deterministic pre-pass — a single read-only analyzer that computes the version gap, the bounded CHANGELOG slice, new config keys, changed templates/bin, and the CLAUDE-APPEND block diff, so the steps below act on its output instead of reading and diffing whole files: diff --git a/plugins/claude-code-hermit/tests/domain-hatch.test.ts b/plugins/claude-code-hermit/tests/domain-hatch.test.ts new file mode 100644 index 00000000..472dc44c --- /dev/null +++ b/plugins/claude-code-hermit/tests/domain-hatch.test.ts @@ -0,0 +1,305 @@ +// Behavioural coverage for the shared domain-hatch protocol. +// +// Everything the five domain hatches used to do in prose is asserted here +// against real files: the version floor read from the manifest (four hatches +// hardcoded a stale one), the two distinct stale-core remedies, the +// missing-`target`-key repair, and the marker rules delegated to evolve-plan. + +import { describe, test, expect, afterAll } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { freshDirFactory } from './helpers/workdir'; +import { runScript, SCRIPTS_DIR } from './helpers/run'; +import { satisfiesFloor, preflight } from '../scripts/lib/domain-hatch/preflight'; +import { ensureHatchTarget, readTargetState, optionsPath } from '../scripts/lib/domain-hatch/target'; +import { planBlock, applyBlock } from '../scripts/lib/domain-hatch/block'; + +const { freshDir, cleanup } = freshDirFactory('hermit-domain-hatch-'); +afterAll(cleanup); + +const PLUGIN = 'feed-hermit'; +const MARKER = ''; +const CLOSING = ''; +const TEMPLATE = `---\n\n${MARKER}\n\n## Feed\n\nSome rules.\n\n${CLOSING}\n`; + +// A project with core hatched plus a fake installed domain plugin, wired the +// way `claude plugin list --json` would report it. +function scaffold(opts: { + coreApplied?: string | null; + selfStamped?: string | null; + selfVersion?: string; + floor?: string; + coreInstalled?: string; + hatchOptions?: unknown; +} = {}) { + const root = freshDir(); + const hermit = path.join(root, '.claude-code-hermit'); + fs.mkdirSync(path.join(hermit, 'state'), { recursive: true }); + + const versions: Record = {}; + if (opts.coreApplied !== null) versions['claude-code-hermit'] = opts.coreApplied ?? '1.2.30'; + if (opts.selfStamped) versions[PLUGIN] = opts.selfStamped; + fs.writeFileSync(path.join(hermit, 'config.json'), JSON.stringify({ _hermit_versions: versions }, null, 2)); + + if (opts.hatchOptions !== undefined) { + fs.writeFileSync(optionsPath(hermit), JSON.stringify(opts.hatchOptions, null, 2)); + } + + // The domain plugin's install tree. + const install = path.join(root, 'installed', PLUGIN); + fs.mkdirSync(path.join(install, '.claude-plugin'), { recursive: true }); + fs.mkdirSync(path.join(install, 'state-templates'), { recursive: true }); + fs.writeFileSync( + path.join(install, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: PLUGIN, version: opts.selfVersion ?? '1.0.4' }), + ); + fs.writeFileSync( + path.join(install, '.claude-plugin', 'hermit-meta.json'), + JSON.stringify({ required_core_version: opts.floor ?? '>=1.2.30' }), + ); + fs.writeFileSync(path.join(install, 'state-templates', 'CLAUDE-APPEND.md'), TEMPLATE); + + // A fake core plugin root, so core's own installed version is controllable. + const coreRoot = path.join(root, 'installed', 'claude-code-hermit'); + fs.mkdirSync(path.join(coreRoot, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(coreRoot, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'claude-code-hermit', version: opts.coreInstalled ?? '1.2.33' }), + ); + + const list = [ + { id: `${PLUGIN}@mp`, scope: 'local', enabled: true, projectPath: root, installPath: install }, + { id: 'claude-code-hermit@mp', scope: 'local', enabled: true, projectPath: root, installPath: coreRoot }, + ]; + + return { root, hermit, install, coreRoot, stdinJson: JSON.stringify(list) }; +} + +function run(s: ReturnType) { + return preflight({ + pluginId: PLUGIN, + hermitDir: s.hermit, + projectRoot: s.root, + corePluginRoot: s.coreRoot, + stdinJson: s.stdinJson, + }); +} + +describe('satisfiesFloor', () => { + test('accepts an equal and a higher version', () => { + expect(satisfiesFloor('1.2.30', '>=1.2.30')).toBe(true); + expect(satisfiesFloor('1.3.0', '>=1.2.30')).toBe(true); + expect(satisfiesFloor('2.0.0', '>=1.2.30')).toBe(true); + }); + + test('rejects a lower version, including a higher patch on a lower minor', () => { + expect(satisfiesFloor('1.2.29', '>=1.2.30')).toBe(false); + expect(satisfiesFloor('1.1.99', '>=1.2.30')).toBe(false); + expect(satisfiesFloor('1.0.16', '>=1.2.30')).toBe(false); + }); + + test('an undeclared floor enforces nothing; an unknown version fails a declared one', () => { + expect(satisfiesFloor('1.0.0', null)).toBe(true); + expect(satisfiesFloor(null, '>=1.2.30')).toBe(false); + }); +}); + +describe('preflight', () => { + test('no config.json means core was never hatched', () => { + const s = scaffold(); + fs.rmSync(path.join(s.hermit, 'config.json')); + expect(run(s).action).toBe('bootstrap-core'); + }); + + // The bug the five hatches shipped: HA checked 1.0.16, fitness 1.0.26, feed + // 1.2.22, forge 1.1.1, while every manifest declared >=1.2.30. + test('a stale installed package is reported as a package problem', () => { + const r = run(scaffold({ coreInstalled: '1.2.28', coreApplied: '1.2.28' })); + expect(r.action).toBe('upgrade-core-package'); + expect(r.core_floor).toBe('>=1.2.30'); + expect(r.remedy).toContain('claude plugin update'); + expect(r.remedy).not.toContain('Run /claude-code-hermit:hermit-evolve, then re-run'); + }); + + // The distinction that matters: hermit-evolve advances applied state and can + // never advance installed code, so the two cases need different advice. + test('current package with stale applied state is an evolve problem', () => { + const r = run(scaffold({ coreInstalled: '1.2.33', coreApplied: '1.2.28' })); + expect(r.action).toBe('upgrade-core-applied'); + expect(r.remedy).toContain('hermit-evolve'); + expect(r.remedy).not.toContain('claude plugin update'); + }); + + test('full on a first run, verify when the stamped version already matches', () => { + expect(run(scaffold({ selfVersion: '1.0.4' })).action).toBe('full'); + expect(run(scaffold({ selfVersion: '1.0.4', selfStamped: '1.0.3' })).action).toBe('full'); + expect(run(scaffold({ selfVersion: '1.0.4', selfStamped: '1.0.4' })).action).toBe('verify'); + }); + + test('an uninstalled plugin fails loud rather than resolving to a path', () => { + const s = scaffold(); + const r = preflight({ + pluginId: 'not-a-plugin', + hermitDir: s.hermit, + projectRoot: s.root, + corePluginRoot: s.coreRoot, + stdinJson: s.stdinJson, + }); + expect(r.ok).toBe(false); + expect(r.error).toBe('plugin_not_installed'); + }); + + test('reports the target and the block action once a target exists', () => { + const s = scaffold({ + hatchOptions: { target: 'committed', core_install_scope: 'project', stamped_at: 'x', stamped_by: 'y', version: '1.0.0' }, + }); + const r = run(s); + expect(r.target).toBe('committed'); + expect(r.target_file).toBe('CLAUDE.md'); + expect(r.needs_target_question).toBe(false); + expect(r.marker).toBe(MARKER); + expect(r.append_action).toBe('append'); // no CLAUDE.md in the scratch project yet + }); +}); + +describe('ensureHatchTarget', () => { + test('creates the file with a first stamp', () => { + const s = scaffold(); + const res = ensureHatchTarget(s.hermit, { target: 'local', core_scope: 'local', stampedBy: `${PLUGIN}:hatch`, version: '1.0.4' }); + expect(res.action).toBe('created'); + const written = JSON.parse(fs.readFileSync(optionsPath(s.hermit), 'utf8')); + expect(written.target).toBe('local'); + expect(written.stamped_by).toBe(`${PLUGIN}:hatch`); + expect(written.last_updated_by).toBeUndefined(); + }); + + test('a later writer preserves the first stamp and records itself separately', () => { + const s = scaffold(); + ensureHatchTarget(s.hermit, { target: 'local', core_scope: 'local', stampedBy: 'first:hatch', version: '1.0.0' }); + const before = JSON.parse(fs.readFileSync(optionsPath(s.hermit), 'utf8')); + const res = ensureHatchTarget(s.hermit, { target: 'committed', core_scope: 'project', stampedBy: 'second:hatch', version: '2.0.0' }); + expect(res.action).toBe('updated'); + const after = JSON.parse(fs.readFileSync(optionsPath(s.hermit), 'utf8')); + expect(after.stamped_by).toBe('first:hatch'); + expect(after.stamped_at).toBe(before.stamped_at); + expect(after.last_updated_by).toBe('second:hatch'); + expect(after.target).toBe('committed'); + }); + + // The hole the prose had: the write was gated on the FILE being absent while + // the read fell back on the KEY being absent, so a file without `target` + // silently resolved to committed CLAUDE.md forever. + test('a file present without a usable target is a repair, and is asked about first', () => { + const s = scaffold({ hatchOptions: { core_install_scope: 'user', version: '1.0.0' } }); + const state = readTargetState(s.hermit, { core_scope: 'user', target: 'local' }); + expect(state.target).toBeNull(); + expect(state.needs_target_question).toBe(true); + + const res = ensureHatchTarget(s.hermit, { target: 'local', core_scope: 'user', stampedBy: `${PLUGIN}:hatch`, version: '1.0.4' }); + expect(res.action).toBe('repaired'); + expect(JSON.parse(fs.readFileSync(optionsPath(s.hermit), 'utf8')).target).toBe('local'); + }); + + // hermit-evolve's fallback chain checked both CLAUDE files before falling + // back to scope detection. Losing that would flip the offered default for + // any project whose block is already placed. + test('an existing core block outranks the scope-derived default', () => { + const s = scaffold(); + const scope = { core_scope: 'project' as const, target: 'committed' as const }; + expect(readTargetState(s.hermit, scope, s.root).target_default).toBe('committed'); + + fs.writeFileSync(path.join(s.root, 'CLAUDE.local.md'), '\n'); + expect(readTargetState(s.hermit, scope, s.root).target_default).toBe('local'); + }); + + test('rewriting identical content is a no-op', () => { + const s = scaffold(); + const args = { target: 'local' as const, core_scope: 'local' as const, stampedBy: `${PLUGIN}:hatch`, version: '1.0.4' }; + ensureHatchTarget(s.hermit, args); + expect(ensureHatchTarget(s.hermit, args).action).toBe('unchanged'); + }); +}); + +describe('sync-block', () => { + test('appends when the marker is absent, then skips once present', () => { + const s = scaffold(); + const target = path.join(s.root, 'CLAUDE.md'); + fs.writeFileSync(target, '# Project\n'); + + const first = applyBlock(planBlock(s.install, PLUGIN, target, [])); + expect(first.action).toBe('append'); + expect(first.written).toBe(true); + expect(fs.readFileSync(target, 'utf8')).toContain(MARKER); + + const second = applyBlock(planBlock(s.install, PLUGIN, target, [])); + expect(second.action).toBe('skip'); + expect(second.written).toBe(false); + }); + + test('replaces only when rendered content is piped in and differs', () => { + const s = scaffold(); + const target = path.join(s.root, 'CLAUDE.md'); + fs.writeFileSync(target, '# Project\n'); + applyBlock(planBlock(s.install, PLUGIN, target, [])); + + const same = planBlock(s.install, PLUGIN, target, [], TEMPLATE); + expect(same.action).toBe('skip'); + + const changed = TEMPLATE.replace('Some rules.', 'Different rules.'); + const res = applyBlock(planBlock(s.install, PLUGIN, target, [], changed)); + expect(res.action).toBe('replace'); + const text = fs.readFileSync(target, 'utf8'); + expect(text).toContain('Different rules.'); + expect(text.split(MARKER).length - 1).toBe(1); + }); + + // Never add a third copy: with the marker duplicated, a replace could hit the + // wrong instance and an append would compound it. + test('refuses a duplicated marker instead of appending again', () => { + const s = scaffold(); + const target = path.join(s.root, 'CLAUDE.md'); + fs.writeFileSync(target, `# Project\n\n${MARKER}\na\n${CLOSING}\n\n${MARKER}\nb\n${CLOSING}\n`); + const res = applyBlock(planBlock(s.install, PLUGIN, target, [])); + expect(res.action).toBe('ambiguous'); + expect(res.ok).toBe(false); + expect(res.written).toBe(false); + }); +}); + +describe('CLI contract', () => { + test('preflight prints JSON and exits 0 even when resolution fails', async () => { + const s = scaffold(); + const listFile = path.join(s.root, 'list.json'); + fs.writeFileSync(listFile, s.stdinJson); + const r = await runScript('domain-hatch.ts', { + args: ['preflight', 'not-a-plugin', '--state-dir', s.hermit, '--project-root', s.root, '--plugin-list-file', listFile], + env: { CLAUDE_PLUGIN_ROOT: s.coreRoot }, + }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).error).toBe('plugin_not_installed'); + }); + + test('a plugin id that looks like a path is rejected', async () => { + const s = scaffold(); + const r = await runScript('domain-hatch.ts', { + args: ['preflight', '../../etc/passwd', '--state-dir', s.hermit, '--project-root', s.root], + env: { CLAUDE_PLUGIN_ROOT: s.coreRoot }, + }); + expect(r.exitCode).toBe(1); + expect(JSON.parse(r.stdout).error).toBe('invalid_plugin_id'); + }); + + test('a mutating verb exits non-zero on failure', async () => { + const s = scaffold(); + const r = await runScript('domain-hatch.ts', { + args: ['ensure-target', PLUGIN, '--target', 'sideways', '--state-dir', s.hermit, '--project-root', s.root], + env: { CLAUDE_PLUGIN_ROOT: s.coreRoot }, + }); + expect(r.exitCode).toBe(1); + expect(JSON.parse(r.stdout).error).toBe('bad_target'); + }); + + test('the script is on disk where hermit-exec.sh would dispatch it', () => { + expect(fs.existsSync(path.join(SCRIPTS_DIR, 'domain-hatch.ts'))).toBe(true); + }); +}); diff --git a/plugins/claude-code-hermit/tests/hatch-options-contract.test.ts b/plugins/claude-code-hermit/tests/hatch-options-contract.test.ts index 12b210e6..04839613 100644 --- a/plugins/claude-code-hermit/tests/hatch-options-contract.test.ts +++ b/plugins/claude-code-hermit/tests/hatch-options-contract.test.ts @@ -2,13 +2,17 @@ // // Asserts: // 1. state-templates/GITIGNORE-APPEND.txt contains the new local-file entries. -// 2. Every consumer of hatch-options.json references the same canonical path -// AND the "target" field name. Catches regressions like a typo in a path -// (.claude-code-hermit/state/hatch-options.json) or renaming the field -// in one consumer without updating the others. +// 2. Skills that still open hatch-options.json themselves reference the same +// canonical path AND the "target" field name — catching a path typo or a +// field rename in one reader but not the others. +// 3. Skills that delegate to scripts/domain-hatch.ts route through it and do +// NOT carry a second copy of the precedence rules or the stamp schema. // -// Scope: monorepo-internal. Reads two of OUR shipping files and the -// sibling dev-hermit skill. +// Domain-plugin hatches are covered by tests/cross-plugin/, not here: that +// suite discovers them from the filesystem instead of a hardcoded list, which +// is how feed-hermit went untested in the sibling checks this file used to do. +// +// Scope: monorepo-internal. Reads OUR shipping files only. // // Usage: bun test tests/hatch-options-contract.test.ts (from the plugin root) @@ -39,17 +43,14 @@ describe('GITIGNORE-APPEND.txt', () => { }); }); -// Producer + readers in core. -const CORE_CONSUMERS = [ - 'skills/hatch/SKILL.md', - // hermit-evolve's hatch-options read lives in reference.md (step 1), read only - // by the evolve-runner subagent — SKILL.md is a thin routing stub. - 'skills/hermit-evolve/reference.md', +// Plain readers: these skills still open the file themselves, so they must keep +// naming the canonical path and the field. +const DIRECT_READERS = [ 'skills/docker-setup/SKILL.md', 'skills/migrate/SKILL.md', ]; -for (const rel of CORE_CONSUMERS) { +for (const rel of DIRECT_READERS) { describe(rel, () => { const file = path.join(PLUGIN_ROOT, rel); @@ -69,21 +70,38 @@ for (const rel of CORE_CONSUMERS) { }); } -// Sibling plugin: dev-hermit's hatch reads the same state file. -describe('dev-hermit:hatch', () => { - const devHatch = path.join(PLUGIN_ROOT, '..', 'claude-code-dev-hermit', 'skills', 'hatch', 'SKILL.md'); +// Delegating consumers: hatch and hermit-evolve no longer resolve or stamp the +// target themselves — scripts/domain-hatch.ts owns it, so both must route +// through it and neither may carry a second copy of the rules. A skill that +// re-inlines the precedence prose here is the drift this test exists to catch. +const DELEGATORS: Array<{ rel: string; verb: string }> = [ + { rel: 'skills/hatch/SKILL.md', verb: 'ensure-target' }, + // hermit-evolve's hatch-options read lives in reference.md (step 1), read only + // by the evolve-runner subagent — SKILL.md is a thin routing stub. + { rel: 'skills/hermit-evolve/reference.md', verb: 'preflight' }, +]; - test('dev-hermit:hatch skill exists', () => { - expect(fs.existsSync(devHatch)).toBe(true); - }); +for (const { rel, verb } of DELEGATORS) { + describe(rel, () => { + const file = path.join(PLUGIN_ROOT, rel); - const content = fs.readFileSync(devHatch, 'utf-8'); + test(`${rel} exists`, () => { + expect(fs.existsSync(file)).toBe(true); + }); - test(`dev-hermit:hatch references ${CANONICAL_PATH}`, () => { - expect(content).toContain(CANONICAL_PATH); - }); + const content = fs.readFileSync(file, 'utf-8'); + + test(`${rel} invokes domain-hatch.ts ${verb}`, () => { + expect(content).toContain('domain-hatch.ts'); + expect(content).toContain(verb); + }); - test(`dev-hermit:hatch references ${TARGET_KEY} field`, () => { - expect(content).toContain(TARGET_KEY); + test(`${rel} does not restate the scope-precedence rules`, () => { + expect(content).not.toMatch(/precedence[\s\S]{0,80}`?local`?[\s\S]{0,40}`?project`?[\s\S]{0,40}`?user`?/); + }); + + test(`${rel} does not restate the five-field stamp schema`, () => { + expect(content).not.toMatch(/"stamped_by":\s*"/); + }); }); -}); +} diff --git a/plugins/claude-code-hermit/tests/hatch-resume-contract.test.ts b/plugins/claude-code-hermit/tests/hatch-resume-contract.test.ts index 5d033d1c..283a1e8d 100644 --- a/plugins/claude-code-hermit/tests/hatch-resume-contract.test.ts +++ b/plugins/claude-code-hermit/tests/hatch-resume-contract.test.ts @@ -28,27 +28,28 @@ const SKILL_KEY = '"skill"'; const CORE_HATCH = path.join(PLUGIN_ROOT, 'skills', 'hatch', 'SKILL.md'); -const DOMAIN_SLUGS = [ - 'claude-code-dev-hermit', - 'claude-code-fitness-hermit', - 'claude-code-homeassistant-hermit', - 'laravel-forge-hermit', -]; - -function domainHatch(slug: string): string { - return path.join(PLUGIN_ROOT, '..', slug, 'skills', 'hatch', 'SKILL.md'); -} - -for (const slug of DOMAIN_SLUGS) { +// Derived, never enumerated. The hardcoded list this replaced named four slugs +// and silently omitted feed-hermit, which implements the protocol — the guard +// against "a change will miss a copy" had itself missed a copy. A plugin joins +// the set the moment it ships a hatch that writes the resume marker. +const PLUGINS_DIR = path.join(PLUGIN_ROOT, '..'); + +const DOMAIN_HATCHES = fs + .readdirSync(PLUGINS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name !== 'claude-code-hermit') + .map((d) => d.name) + .map((slug) => ({ slug, file: path.join(PLUGINS_DIR, slug, 'skills', 'hatch', 'SKILL.md') })) + .filter((p) => fs.existsSync(p.file)) + .map((p) => ({ ...p, text: fs.readFileSync(p.file, 'utf-8') })) + .filter((p) => p.text.includes('hatch-resume.json')) + .sort((a, b) => a.slug.localeCompare(b.slug)); + +for (const { slug, file, text: content } of DOMAIN_HATCHES) { describe(`${slug}:hatch (writer)`, () => { - const file = domainHatch(slug); - test(`${slug}:hatch skill exists`, () => { expect(fs.existsSync(file)).toBe(true); }); - const content = fs.readFileSync(file, 'utf-8'); - test(`references ${CANONICAL_PATH}`, () => { expect(content).toContain(CANONICAL_PATH); }); diff --git a/plugins/claude-code-homeassistant-hermit/CHANGELOG.md b/plugins/claude-code-homeassistant-hermit/CHANGELOG.md index 820ed425..53f027b3 100644 --- a/plugins/claude-code-homeassistant-hermit/CHANGELOG.md +++ b/plugins/claude-code-homeassistant-hermit/CHANGELOG.md @@ -11,6 +11,9 @@ - `ha-morning-brief`'s micro-proposal lifecycle now runs in one `.claude-code-hermit/bin/hermit-run proposal micro .claude-code-hermit brief-cycle` call through core's writer (one atomic round-trip that re-nudges, expires, and records expiries), instead of two inline `bun -e` re-implementations of that writer — so a nudge, an expiry, or a crash mid-write can no longer corrupt the queue. ### Changed +- `hatch` reads the required core version from `.claude-plugin/hermit-meta.json` at runtime via `domain-hatch preflight`, instead of the hardcoded `1.0.16` floor its prose carried. That floor sat many minor versions below what the manifest declared, so the wizard proceeded against a core too old for it. +- Target resolution and CLAUDE-APPEND writing are delegated to core: `domain-hatch preflight claude-code-homeassistant-hermit` resolves the target, `ensure-target` records an operator override, `sync-block` writes the block. The skill no longer detects install scope from `claude plugin list --json` or stamps `hatch-options.json`. +- `hatch` no longer re-renders the CLAUDE-APPEND block when the stamped version changes; it appends when the marker is absent and skips otherwise. `hermit-evolve` owns version-driven block refresh, so the two writers no longer race on the same block. - Requires core `>=1.2.34`. Core absorbed its proposal satellites into `proposal.ts` verbs, so the shared route this plugin calls through `bin/hermit-run` is now `proposal micro …` / `proposal metrics …`. `bin/hermit-run` resolves a script by bare filesystem probe, so pairing this version with an older core fails with a misleading "plugin may predate this command" error. - `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). diff --git a/plugins/claude-code-homeassistant-hermit/CLAUDE.md b/plugins/claude-code-homeassistant-hermit/CLAUDE.md index f37d47c5..e12bab07 100644 --- a/plugins/claude-code-homeassistant-hermit/CLAUDE.md +++ b/plugins/claude-code-homeassistant-hermit/CLAUDE.md @@ -16,7 +16,7 @@ A Home Assistant domain layer for `claude-code-hermit`: skills, subagents, a saf ## Hatch target routing -`/hatch` Step 7 reads `.claude-code-hermit/state/hatch-options.json` (written by core hatch) to determine where to write the CLAUDE-APPEND block: `target = "local"` → `CLAUDE.local.md`; `target = "committed"` → `CLAUDE.md`. If core hatch hasn't run yet, the skill detects `core_install_scope` from `claude plugin list --json`, presents the scope-derived default at position 0 of the Visibility prompt, and stamps the full canonical schema (`target`, `core_install_scope`, `stamped_at`, `stamped_by`, `version`) into `hatch-options.json`. +`/hatch` Step 1 runs `.claude-code-hermit/bin/hermit-run domain-hatch preflight claude-code-homeassistant-hermit`; core's `scripts/domain-hatch.ts` owns install-scope detection, target resolution, and stamping `hatch-options.json`. The preflight verdict hands back `target`, `target_file`, `target_default`, and `needs_target_question` — Step 6 only surfaces the Visibility prompt when asked to, records the answer with `domain-hatch ensure-target claude-code-homeassistant-hermit --target `, then writes the block with `domain-hatch sync-block claude-code-homeassistant-hermit`. Hatch appends when the marker is absent and skips otherwise; refreshing the block on a version bump is `hermit-evolve`'s job. **Migration on target change.** When the operator flips `hatch_target` (e.g. via core 1.1.1's `hermit-evolve` Upgrade Instructions), the HA block can end up stranded in the old file. The most recent CHANGELOG entry's `### Upgrade Instructions` run a one-shot migration via `hermit-evolve` Step 7's sibling upgrade flow to strip the stranded block. @@ -42,7 +42,7 @@ A Home Assistant domain layer for `claude-code-hermit`: skills, subagents, a saf ## MCP vs CLI -- **Home Assistant MCP Server** (`homeassistant`): read-only live ops by default — `GetLiveContext`, `GetDateTime`. `Hass*` intent tools (`HassTurnOn`, `HassLightSet`, `HassSetPosition`, `HassFanSetSpeed`, etc.) are hard-blocked unless `ha_assist_control_enabled: true` is set in `config.json` (set during hatch Step 7.55). When enabled, HA's own expose-to-Assist gate is the control boundary — the gate defers to it rather than blocking. +- **Home Assistant MCP Server** (`homeassistant`): read-only live ops by default — `GetLiveContext`, `GetDateTime`. `Hass*` intent tools (`HassTurnOn`, `HassLightSet`, `HassSetPosition`, `HassFanSetSpeed`, etc.) are hard-blocked unless `ha_assist_control_enabled: true` is set in `config.json` (set during hatch Step 6.55). When enabled, HA's own expose-to-Assist gate is the control boundary — the gate defers to it rather than blocking. - **CLI** (`bin/ha-agent-lab`): build and analysis operations — context refresh, YAML simulation, policy checks, apply, audits, structural writes (helpers/areas/registries), and `ha trigger-automation`. MCP tool IDs follow the pattern `mcp__homeassistant__*`. The `homeassistant` name is required — the safety hook matches on it. diff --git a/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md b/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md index bde3f627..751226c3 100644 --- a/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md @@ -11,24 +11,22 @@ Set up the Home Assistant layer for this project. Idempotent — safe to re-run; ### 1. Prereq check -Read `.claude-code-hermit/config.json`. +Check whether `.claude-code-hermit/config.json` exists. -- If the file is missing or `_hermit_versions["claude-code-hermit"]` is absent or less than `1.0.16`: +- If it is missing: - `AskUserQuestion`: "Core hermit is not initialized. Run `/claude-code-hermit:hatch` now?" - Yes → Follow the domain hatch continuation protocol (documented in `claude-code-hermit:hatch`): 1. Write `.claude-code-hermit/state/hatch-resume.json` with `{ "skill": "claude-code-homeassistant-hermit:hatch" }`. 2. Print: "(If setup doesn't continue automatically when core finishes, re-run `/claude-code-homeassistant-hermit:hatch`.)" 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. - No → stop and explain what is required. +- If it is present: run `.claude-code-hermit/bin/hermit-run domain-hatch preflight claude-code-homeassistant-hermit` and parse the JSON verdict. Branch on `action`: + - `upgrade-core-package` / `upgrade-core-applied` → relay the `remedy` string verbatim to the operator and stop. + - `verify` → `AskUserQuestion`: "Already set up. Re-verify HA access only (skip setup wizard)?". Yes → skip to §5. No → continue. + - `full` → continue with setup. + - `ok: false` → relay `message` and stop. -### 2. Idempotency check - -Read `_hermit_versions["claude-code-homeassistant-hermit"]` from `config.json`. Read the `version` field from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. - -- If versions match → `AskUserQuestion`: "Already set up. Re-verify HA access only (skip setup wizard)?". Yes → skip to §6. No → continue. -- If stale or absent → continue with setup. - -### 3. Verify .env +### 2. Verify .env Run `${CLAUDE_PLUGIN_ROOT}/bin/ha-agent-lab boot status` and inspect the JSON output. @@ -57,13 +55,13 @@ Also check locale: Do not collect or store the token — it stays in `.env` only. -### 4. CLI check +### 3. CLI check The CLI runs on bun, which the core hermit requirement guarantees — no runtime deps to install. Run `${CLAUDE_PLUGIN_ROOT}/bin/ha-agent-lab boot status` (read-only, no `--probe`) to confirm the launcher resolves correctly. If it fails with "bun not found", stop and tell the user to install bun (https://bun.sh) — it is required by `claude-code-hermit` core. -### 5. Home Assistant MCP Server setup +### 4. Home Assistant MCP Server setup **Step A — Enable the integration in Home Assistant** @@ -73,7 +71,7 @@ Reference: https://www.home-assistant.io/integrations/mcp_server/ **Step B — Write `.mcp.json`** -Read the HA URL from the `boot status` JSON (`active_url` field, already fetched in §3). Read the token from `.env` using: +Read the HA URL from the `boot status` JSON (`active_url` field, already fetched in §2). Read the token from `.env` using: ```bash ${CLAUDE_PLUGIN_ROOT}/bin/ha-agent-lab boot status @@ -114,7 +112,7 @@ After writing `.mcp.json`, check the project's `.gitignore`: Tell the user: **restart Claude Code** in this project directory. On first use, Claude Code will prompt you to trust the `homeassistant` server — approve it. Then run `/mcp` to confirm `homeassistant` appears as connected. The next `ha-boot` will verify live HA connectivity. -### 6. Verify CLI (full probe) +### 5. Verify CLI (full probe) Run `${CLAUDE_PLUGIN_ROOT}/bin/ha-agent-lab boot status --probe` and present the result. If it fails: @@ -122,31 +120,27 @@ Run `${CLAUDE_PLUGIN_ROOT}/bin/ha-agent-lab boot status --probe` and present the - Connection refused → check `HOMEASSISTANT_LOCAL_URL` in `.env`. - Auth error → check `HOMEASSISTANT_TOKEN`. -### 7. Append to CLAUDE.md / CLAUDE.local.md +### 6. Append to CLAUDE.md / CLAUDE.local.md -**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`: +**Resolve target file:** Step 1's preflight already returned `target`, `target_file`, `target_default` and `needs_target_question`. - ```json - { - "target": "", - "core_install_scope": "", - "stamped_at": "", - "stamped_by": "claude-code-homeassistant-hermit:hatch", - "version": "" - } - ``` +If `needs_target_question` is true, ask with `AskUserQuestion` (header: "Visibility") — `target_default` at position 0 with `(recommended)`: **`.local` files** (gitignored — operator-personal) / **Committed files** (shared with teammates). Then record it: + +```bash +.claude-code-hermit/bin/hermit-run domain-hatch ensure-target claude-code-homeassistant-hermit --target +``` + +Then write the block: -Read the plugin version from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json` and the stamped version from `.claude-code-hermit/config.json` at `_hermit_versions["claude-code-homeassistant-hermit"]` (treat absent as `null`). Step 8 of this skill stamps that field at the end of every run, so on re-runs it reflects the version that last wrote the block. Look for the marker `` in `target_file`: +```bash +.claude-code-hermit/bin/hermit-run domain-hatch sync-block claude-code-homeassistant-hermit +``` -- **Marker present AND stamped version equals plugin version:** skip — block is current. Do not read the template. -- **All other cases** (marker absent, stamped version null, OR stamped version stale): read `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md` and either append it to `target_file` (marker absent — the Edit tool creates `target_file` if missing) or replace the marked block (marker present) — everything from the opening `` through the matching closing ``, inclusive. The template is the source of truth; no operator prompt is needed. +It appends the `` block when the marker is absent and skips when it is already present. Refreshing an existing block on a version bump is `hermit-evolve`'s job, not hatch's. Stray-block migration (block stranded in the non-target file after a target flip) is handled one-shot by the Upgrade Instructions in this version's CHANGELOG entry, executed by `hermit-evolve` Step 7. Hatch itself stays focused on target-aware setup and steady-state refresh. -### 7.5 Safety mode +### 6.5 Safety mode Read `ha_safety_mode` from `.claude-code-hermit/config.json`. @@ -157,7 +151,7 @@ Read `ha_safety_mode` from `.claude-code-hermit/config.json`. Write the chosen value to `config.json` as `ha_safety_mode`. Default to `strict` if the operator skips or is unsure. -### 7.55 HA Assist control (optional) +### 6.55 HA Assist control (optional) Read `ha_assist_control_enabled` from `.claude-code-hermit/config.json`. @@ -166,7 +160,7 @@ Read `ha_assist_control_enabled` from `.claude-code-hermit/config.json`. - **Yes** → write `ha_assist_control_enabled: true` to `config.json`. - **No / skip** → leave the key absent (fail-closed default; CLI remains available for automation triggering). -### 7.56 HA update one-tap apply (optional) +### 6.56 HA update one-tap apply (optional) Read `ha_update_auto_apply` from `.claude-code-hermit/config.json`. @@ -175,7 +169,7 @@ Read `ha_update_auto_apply` from `.claude-code-hermit/config.json`. - **Yes** → write `ha_update_auto_apply: true` to `config.json`. - **No / skip** → leave the key absent (fail-closed default; every pending update stays a proposal you apply yourself in the HA UI). -### 7.6 Knowledge-schema extension +### 6.6 Knowledge-schema extension Read `.claude-code-hermit/knowledge-schema.md`. @@ -202,13 +196,13 @@ If already present: skip (idempotent). Use Edit to make the changes. -### 7.7 Auto-mode environment seed +### 6.7 Auto-mode environment seed -Run `bun ${CLAUDE_PLUGIN_ROOT}/scripts/automode-env.ts .claude/settings.local.json` — **always `.claude/settings.local.json`, regardless of `hatch_target`**: Claude Code's auto-mode classifier reads `autoMode` config only from local/user scope, never a committed project `.claude/settings.json`. This names the operator's Home Assistant instance (read from `.env`'s `HOMEASSISTANT_URL`/`HOMEASSISTANT_LOCAL_URL`/`HOMEASSISTANT_REMOTE_URL` — the same set `curl-host-gate.ts` already trusts) as a trusted internal domain, so the classifier stops treating the hermit's nightly unattended reads (briefs, audits, context refresh) as unrecognized outbound calls. If the script prints `SKIP|...` (no HA URL configured yet), note it and move on — Step 3 already required a working `.env` before reaching here, so this should only skip on an unusual re-run. Additive and idempotent; safe to re-run on every hatch. +Run `bun ${CLAUDE_PLUGIN_ROOT}/scripts/automode-env.ts .claude/settings.local.json` — **always `.claude/settings.local.json`, regardless of `hatch_target`**: Claude Code's auto-mode classifier reads `autoMode` config only from local/user scope, never a committed project `.claude/settings.json`. This names the operator's Home Assistant instance (read from `.env`'s `HOMEASSISTANT_URL`/`HOMEASSISTANT_LOCAL_URL`/`HOMEASSISTANT_REMOTE_URL` — the same set `curl-host-gate.ts` already trusts) as a trusted internal domain, so the classifier stops treating the hermit's nightly unattended reads (briefs, audits, context refresh) as unrecognized outbound calls. If the script prints `SKIP|...` (no HA URL configured yet), note it and move on — Step 2 already required a working `.env` before reaching here, so this should only skip on an unusual re-run. Additive and idempotent; safe to re-run on every hatch. --- -### 8. Stamp version and register routines +### 7. Stamp version and register routines Write `_hermit_versions["claude-code-homeassistant-hermit"]` into `.claude-code-hermit/config.json` with the current plugin version. @@ -262,7 +256,7 @@ After adding or updating any entries, remind the operator: "Run `/claude-code-he These replace any need for CronCreate routines around analysis/observability — the `scheduled-checks` routine picks up whichever check is due, runs it, and any findings surface as proposals automatically. -### 9. Final report +### 8. Final report Summarize: diff --git a/plugins/claude-code-homeassistant-hermit/tests/hatch-skill.test.ts b/plugins/claude-code-homeassistant-hermit/tests/hatch-skill.test.ts index bb6ef6c1..64d68acb 100644 --- a/plugins/claude-code-homeassistant-hermit/tests/hatch-skill.test.ts +++ b/plugins/claude-code-homeassistant-hermit/tests/hatch-skill.test.ts @@ -1,5 +1,5 @@ -// Structural lint for the /hatch skill (target-aware routing + schema -// stamping) — 1:1 port of tests/test_hatch_skill.py (27 cases). +// Structural lint for the /hatch skill: that it runs the shared domain-hatch +// protocol rather than carrying its own copy of target resolution and stamping. // Grep-level checks against the skill markdown. No runtime skill execution. import { expect, test } from 'bun:test'; @@ -8,53 +8,84 @@ import { join, resolve } from 'node:path'; const PLUGIN_ROOT = resolve(import.meta.dir, '..'); const skillText = readFileSync(join(PLUGIN_ROOT, 'skills', 'hatch', 'SKILL.md'), 'utf8'); +const templateText = readFileSync( + join(PLUGIN_ROOT, 'state-templates', 'CLAUDE-APPEND.md'), + 'utf8', +); -test('references hatch-options.json', () => { - expect(skillText).toContain('hatch-options.json'); +// --- Shared domain-hatch protocol --- +// Target resolution, install-scope detection, and the hatch-options stamp +// schema live in core's `domain-hatch.ts`. This hatch's obligation is to call +// the verbs with its own plugin id and restate none of those rules. + +test('runs preflight through core, keyed to its own plugin id', () => { + expect(skillText).toContain('domain-hatch preflight claude-code-homeassistant-hermit'); }); -test('reads target field from hatch-options.json', () => { - expect(/hatch-options\.json[\s\S]{0,80}["`]target["`]/.test(skillText)).toBe(true); +test('reaches core via bin/hermit-run, not a relative path', () => { + expect(skillText).toContain('.claude-code-hermit/bin/hermit-run domain-hatch'); + expect(skillText).not.toContain('../claude-code-hermit/scripts'); }); -test('local target routes to CLAUDE.local.md', () => { - expect(/["`]local["`][\s\S]{0,80}target_file = CLAUDE\.local\.md/.test(skillText)).toBe(true); +test('branches on every preflight action value', () => { + for (const action of ['upgrade-core-package', 'upgrade-core-applied', '`verify`', '`full`']) { + expect(skillText).toContain(action); + } }); -test('committed target routes to CLAUDE.md', () => { - expect(/["`]committed["`][\s\S]{0,120}target_file = CLAUDE\.md/.test(skillText)).toBe(true); +test('consumes the preflight verdict fields instead of re-deriving them', () => { + expect( + /`target`[\s\S]{0,60}`target_file`[\s\S]{0,60}`target_default`[\s\S]{0,60}`needs_target_question`/.test( + skillText, + ), + ).toBe(true); }); -test('schema stamps target field', () => { - expect(/"target":\s*"/.test(skillText)).toBe(true); +test('records the operator choice via ensure-target', () => { + expect(skillText).toContain( + 'domain-hatch ensure-target claude-code-homeassistant-hermit --target', + ); }); -test('schema stamps core_install_scope field', () => { - expect(/"core_install_scope":\s*"/.test(skillText)).toBe(true); +test('Visibility prompt still offers .local vs committed', () => { + expect(/Visibility[\s\S]{0,240}`\.local` files[\s\S]{0,120}Committed files/.test(skillText)).toBe( + true, + ); }); -test('schema stamps stamped_at field', () => { - expect(/"stamped_at":\s*"/.test(skillText)).toBe(true); +test('writes the block via sync-block', () => { + expect(skillText).toContain('domain-hatch sync-block claude-code-homeassistant-hermit'); }); -test('schema stamps stamped_by field', () => { - expect(/"stamped_by":\s*"claude-code-homeassistant-hermit:hatch"/.test(skillText)).toBe(true); +test('defers version-driven block refresh to hermit-evolve', () => { + // The old hatch re-rendered the block whenever the stamped version differed. + // hermit-evolve owns that now; hatch appends when absent and skips otherwise. + expect(/Refreshing an existing block on a version bump is `hermit-evolve`'s job/.test(skillText)).toBe( + true, + ); }); -test('schema stamps version field', () => { - expect(/"version":\s*"/.test(skillText)).toBe(true); +test('does not read hatch-options.json directly', () => { + expect(skillText).not.toContain('hatch-options.json'); }); -test('detects core_install_scope from plugin list', () => { - expect(/core_install_scope[\s\S]{0,120}claude plugin list --json/.test(skillText)).toBe(true); +test('does not restate install-scope detection', () => { + expect(skillText).not.toContain('claude plugin list --json'); }); -test('documents project-to-committed scope mapping', () => { - expect(/`project`[^\n]{0,20}`committed`/.test(skillText)).toBe(true); +test('does not restate the hatch-options stamp schema', () => { + expect(/"stamped_by":\s*"/.test(skillText)).toBe(false); + expect(/"core_install_scope":\s*"/.test(skillText)).toBe(false); }); -test('documents local/user/null-to-local scope mapping', () => { - expect(/`local`\/`user`\/`null`[^\n]{0,40}`local`/.test(skillText)).toBe(true); +test('states no hardcoded core version floor', () => { + // The floor lives in .claude-plugin/hermit-meta.json; prose copies drifted. + const lines = skillText + .split('\n') + .filter((l) => /(?:base hermit|core hermit|claude-code-hermit|_hermit_versions)/i.test(l)); + for (const line of lines) { + expect(line).not.toMatch(/(?:requires|earlier than|less than|below)\s+`?≥?>?=?\s*\d+\.\d+\.\d+/i); + } }); test('stamped version source is _hermit_versions', () => { @@ -63,28 +94,19 @@ test('stamped version source is _hermit_versions', () => { expect(skillText).toContain('_hermit_versions["claude-code-homeassistant-hermit"]'); }); -test('skips on stamped version match', () => { - expect(/stamped version equals plugin version[\s\S]{0,40}skip/.test(skillText)).toBe(true); -}); - -test('handles absent stamped version', () => { - // Realistic upgrade case: block exists but was appended before stamping - // was reliable. Must NOT fall into an undefined branch. - expect( - /stamped version null[\s\S]{0,80}stale/.test(skillText) || - /stamped version (absent|null)/.test(skillText), - ).toBe(true); -}); - -test('marker replacement specifies closing marker', () => { - expect(skillText).toContain(''); +test('the synced block is marker-delimited on both ends', () => { + // sync-block replaces between the markers, so the template must carry both. + // The opening marker is named in the skill; the closing one is the template's. + expect(skillText).toContain(''); + expect(templateText).toContain(''); + expect(templateText).toContain(''); }); test('delegates stray-block migration to hermit-evolve', () => { expect(/hermit-evolve[\s\S]{0,20}Step 7/.test(skillText)).toBe(true); }); -// --- Knowledge-schema extension (Step 7.6) --- +// --- Knowledge-schema extension (Step 6.6) --- test('has knowledge-schema extension step', () => { expect(skillText).toContain('Knowledge-schema extension'); diff --git a/plugins/feed-hermit/.claude-plugin/hermit-meta.json b/plugins/feed-hermit/.claude-plugin/hermit-meta.json index 77e21845..a91e590c 100644 --- a/plugins/feed-hermit/.claude-plugin/hermit-meta.json +++ b/plugins/feed-hermit/.claude-plugin/hermit-meta.json @@ -1,6 +1,6 @@ { - "required_core_version": ">=1.2.30", + "required_core_version": ">=1.2.34", "requires": { - "claude-code-hermit": ">=1.2.30" + "claude-code-hermit": ">=1.2.34" } } diff --git a/plugins/feed-hermit/.claude-plugin/plugin.json b/plugins/feed-hermit/.claude-plugin/plugin.json index 05a14365..81baf445 100644 --- a/plugins/feed-hermit/.claude-plugin/plugin.json +++ b/plugins/feed-hermit/.claude-plugin/plugin.json @@ -4,7 +4,7 @@ "dependencies": [ { "name": "claude-code-hermit", - "version": "^1.2.30" + "version": "^1.2.34" } ], "description": "Feed-to-brief pipeline for claude-code-hermit — curated source registry, fetch/score/write/deliver/archive pipeline, weekly synthesis, and source-health analytics for an autonomous feed-reading assistant", diff --git a/plugins/feed-hermit/CHANGELOG.md b/plugins/feed-hermit/CHANGELOG.md index e970c074..05e47291 100644 --- a/plugins/feed-hermit/CHANGELOG.md +++ b/plugins/feed-hermit/CHANGELOG.md @@ -6,6 +6,10 @@ - No-op `Write(path)` settings rules no longer trigger a boot warning; `Write(tmp/**)` is now `Edit(tmp/**)` so tmp fetch-scratch writes are auto-approved. ### Changed +- `hatch` reads the required core version from `.claude-plugin/hermit-meta.json` at runtime via `domain-hatch preflight`, instead of the hardcoded `1.2.22` floor its prose carried. That floor sat below what the manifest declared, so the wizard proceeded against a core too old for it. +- Target resolution and CLAUDE-APPEND writing are delegated to core: `domain-hatch preflight feed-hermit` resolves the target, `ensure-target` records an operator override, `sync-block` writes the block. The skill no longer detects install scope from `claude plugin list --json` or stamps `hatch-options.json`. +- `hatch` re-reads `config.json` immediately before writing the feed block, routines, scheduled check and archive registration, instead of reusing the copy it loaded before the wizard ran. Anything written to the file during the wizard is no longer clobbered. +- Requires core `>=1.2.34` for the shared `domain-hatch` protocol. `bin/hermit-run` resolves a script by bare filesystem probe, so pairing this version with an older core fails with a misleading "plugin may predate this command" error. - The CLAUDE-APPEND block dropped the per-type fetch dispatch detail and the routine/scheduled-check tables, and no longer carries fetch-cost numbers — `docs/schema.md` owns them as the `tokens_approx` defaults, so the two copies can no longer drift. 3,203 B → ~2,384 B. The untrusted-content rule stays verbatim; the allowlist line now states that `fetch-guard` fails open when `feed-sources.md` is unreadable. - `feed-brief` § Security points at the CLAUDE-APPEND rule instead of restating it in different words. diff --git a/plugins/feed-hermit/CLAUDE.md b/plugins/feed-hermit/CLAUDE.md index 9b445eb9..7f6c157e 100644 --- a/plugins/feed-hermit/CLAUDE.md +++ b/plugins/feed-hermit/CLAUDE.md @@ -11,7 +11,7 @@ claude plugin marketplace add gtapps/claude-code-hermit claude plugin install feed-hermit@claude-code-hermit --scope local ``` -After install, run `/feed-hermit:hatch` in the target project. The core hermit (`claude-code-hermit` ≥1.2.30) must be installed and hatched first — `hatch` will prompt if it isn't. +After install, run `/feed-hermit:hatch` in the target project. The core hermit (`claude-code-hermit` ≥1.2.34) must be installed and hatched first — `hatch` will prompt if it isn't. ## Plugin Structure @@ -33,7 +33,7 @@ After install, run `/feed-hermit:hatch` in the target project. The core hermit ( ## Hatch target routing -`/hatch` reads `.claude-code-hermit/state/hatch-options.json` (written by core hatch) to determine where to append the CLAUDE-APPEND block: `target = "local"` → `CLAUDE.local.md`; `target = "committed"`/absent → `CLAUDE.md`. If core hatch hasn't run, the skill offers to run it first via the domain-hatch continuation protocol (writes `state/hatch-resume.json`, invokes `/claude-code-hermit:hatch`, which returns here). +`/hatch` Step 1 runs `.claude-code-hermit/bin/hermit-run domain-hatch preflight feed-hermit`; core's `scripts/domain-hatch.ts` resolves the target and stamps `hatch-options.json`. Step 5 records any operator override with `domain-hatch ensure-target feed-hermit --target ` and appends the block with `domain-hatch sync-block feed-hermit`. If core hatch hasn't run, the skill offers to run it first via the domain-hatch continuation protocol (writes `state/hatch-resume.json`, invokes `/claude-code-hermit:hatch`, which returns here). ## Data ownership diff --git a/plugins/feed-hermit/docs/schema.md b/plugins/feed-hermit/docs/schema.md index dd558bb0..4a50b138 100644 --- a/plugins/feed-hermit/docs/schema.md +++ b/plugins/feed-hermit/docs/schema.md @@ -278,7 +278,7 @@ injection. Body: <=250 chars, one line. - **All other compiled artifacts**: default 14-day retention (core `knowledge.raw_retention_days` in `config.json`). Living pages are exempt from rotation per core knowledge rules. - **`briefs/`**: never rotated. It is a plugin-owned archive registered in - `config.storage_drift.ignore` (by hatch step 7e), so core neither injects nor rotates it — + `config.storage_drift.ignore` (by hatch step 6e), so core neither injects nor rotates it — feed-hermit owns its retention, and the retention policy is "keep everything". Its session-facing projection is `compiled/brief-summary-last-*.md`; its readers are `weekly-digest`, `story-arcs`, and `source-health`. diff --git a/plugins/feed-hermit/skills/hatch/SKILL.md b/plugins/feed-hermit/skills/hatch/SKILL.md index c7acfcad..ee7d08dd 100644 --- a/plugins/feed-hermit/skills/hatch/SKILL.md +++ b/plugins/feed-hermit/skills/hatch/SKILL.md @@ -11,9 +11,9 @@ Idempotent setup wizard for the feed plugin. Run **after** `/claude-code-hermit: ## Step 1 — Prerequisite check -Read `.claude-code-hermit/config.json`. +Check whether `.claude-code-hermit/config.json` exists. -If the file does not exist or `_hermit_versions["claude-code-hermit"]` is absent or empty: +If it does not: > "The base hermit is not set up in this project yet. Run `/claude-code-hermit:hatch` first, then return here." @@ -25,32 +25,20 @@ Use `AskUserQuestion`: "Would you like to run `/claude-code-hermit:hatch` now? ( 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. - **no** → stop. -If `_hermit_versions["claude-code-hermit"]` is present but the version string is earlier than `1.2.22` (compare major.minor.patch numerically), warn: +If it does exist, run `.claude-code-hermit/bin/hermit-run domain-hatch preflight feed-hermit` and parse the JSON verdict. Branch on `action`: -> "Base hermit version is {version}; this plugin requires ≥1.2.22. Run `/claude-code-hermit:hermit-evolve` to upgrade, then re-run this hatch." +- **`upgrade-core-package` / `upgrade-core-applied`** → relay the `remedy` string verbatim to the operator and stop. +- **`verify`** → say: -Stop. + > "feed-hermit {self_version} is already installed. Skip to Step 6 to re-verify, or reply 'full' to re-run the full wizard." ---- - -## Step 2 — Idempotency check - -Read `_hermit_versions["feed-hermit"]` from `.claude-code-hermit/config.json`, and `version` from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. - -If they match: - -> "feed-hermit {version} is already installed. Skip to Step 7 to re-verify, or reply 'full' to re-run the full wizard." - -Use `AskUserQuestion`: "(verify / full)" - -- **verify** → skip to Step 7. -- **full** → continue from Step 3. - -If absent or stale: continue from Step 3. + Use `AskUserQuestion`: "(verify / full)" — **verify** → skip to Step 6; **full** → continue from Step 2. +- **`full`** → continue from Step 2. +- **`ok: false`** → relay `message` and stop. --- -## Step 3 — Seed the registries and tone spec +## Step 2 — Seed the registries and tone spec The operator owns three files at the **project root**: `feed-sources.md`, `feed-categories.md`, `FEEDS.md`. Seed each from the plugin template **only if it does not already exist** (never overwrite operator content). @@ -67,7 +55,7 @@ Read the destination first: if it exists, skip (report `⊘ skipped (alre --- -## Step 4 — Brief configuration wizard +## Step 3 — Brief configuration wizard Ask the operator (use `AskUserQuestion`, one prompt per decision or batched): @@ -77,11 +65,11 @@ Ask the operator (use `AskUserQuestion`, one prompt per decision or batched): 4. **Enrichments** — `story_arcs` (cross-reference developing stories into briefs) on/off; `follow_up_cta` (append a `/deep-dive` reply prompt to top-tier items) on/off. Defaults: both off. 5. **Reaction feedback** — track 👍/👎 reactions on delivered briefs for the weekly source signal, on/off. Default off. (Note: the reaction→feedback-line producer is a channel-layer concern; enabling this only turns on the message-registry write and weekly aggregation — see `docs/schema.md`.) -Convert each `HH:MM` to a cron expression for Step 7 (`M H * * *` for daily slots; `M H * * 0` for a Sunday weekly). Hold the answers in context. +Convert each `HH:MM` to a cron expression for Step 6 (`M H * * *` for daily slots; `M H * * 0` for a Sunday weekly). Hold the answers in context. --- -## Step 5 — Drop routine prompt files +## Step 4 — Drop routine prompt files Copy the three routine prompt templates from `${CLAUDE_PLUGIN_ROOT}/state-templates/compiled/` into the consumer's `.claude-code-hermit/compiled/`: @@ -93,39 +81,37 @@ For each: Read the source, check the destination (`.claude-code-hermit/compiled/ --- -## Step 6 — CLAUDE.md / CLAUDE.local.md inject +## Step 5 — CLAUDE.md / CLAUDE.local.md inject -**Resolve target file:** Read `.claude-code-hermit/state/hatch-options.json`. Use the `"target"` field: `"local"` → `CLAUDE.local.md`; `"committed"` or absent → `CLAUDE.md`. If `hatch-options.json` doesn't exist (operator's core hermit predates the field): detect `core_install_scope` from `claude plugin list --json` (filter entries where plugin name is `claude-code-hermit` and `enabled == true`; precedence `local` > `project` > `user` > `null`; map `project` → `committed`, else → `local`). Ask with `AskUserQuestion` (header: "Visibility") — scope-derived default at position 0 with `(recommended)`: **`.local` files** / **Committed files**. Write the canonical 5-field schema to `.claude-code-hermit/state/hatch-options.json`: +**Resolve target file:** Step 1's preflight already returned `target`, `target_file`, `target_default` and `needs_target_question`. -```json -{ - "target": "", - "core_install_scope": "", - "stamped_at": "", - "stamped_by": "feed-hermit:hatch", - "version": "" -} +If `needs_target_question` is true, ask with `AskUserQuestion` (header: "Visibility") — `target_default` at position 0 with `(recommended)`: **`.local` files** (gitignored, operator-personal) / **Committed files** (shared with teammates). Then record it: + +```bash +.claude-code-hermit/bin/hermit-run domain-hatch ensure-target feed-hermit --target ``` -Read `target_file`. Search for the opening marker `` (closing ``). +Then write the block: + +```bash +.claude-code-hermit/bin/hermit-run domain-hatch sync-block feed-hermit +``` -- **`target_file` does not exist** → treat as marker-absent; the append (via Edit) creates it. -- **Marker absent** → append the full contents of `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md` to `target_file` using Edit. -- **Marker present** → skip (`hermit-evolve` handles block replacement on upgrade). +It appends the `` block when the marker is absent (creating `target_file` if needed) and skips when it is already present; `hermit-evolve` handles block replacement on upgrade. --- -## Step 7 — Stamp and register in config.json +## Step 6 — Stamp and register in config.json -Use the `config.json` content already loaded in Step 1 (do not re-read). +Re-read `.claude-code-hermit/config.json` now — the wizard has been running since Step 1 and the on-disk file may have changed. Apply the merges below to that fresh copy. -### 7a — Stamp version +### 6a — Stamp version -Set `_hermit_versions["feed-hermit"]` to the plugin version from Step 2. +Set `_hermit_versions["feed-hermit"]` to `self_version` from Step 1's preflight. -### 7b — Write the feed config block +### 6b — Write the feed config block -Set `config.feed` from the Step 4 answers: +Set `config.feed` from the Step 3 answers: ```json { @@ -142,9 +128,9 @@ Set `config.feed` from the Step 4 answers: If `config.feed` already exists, merge (keep operator edits; only fill absent keys). -### 7c — Merge routines +### 6c — Merge routines -In the `routines` array, for each of these IDs that is **absent** (by `id`), add it using the crons from Step 4; skip any already present. Set `enabled` from the slot/weekly enable answers. +In the `routines` array, for each of these IDs that is **absent** (by `id`), add it using the crons from Step 3; skip any already present. Set `enabled` from the slot/weekly enable answers. ```json { @@ -173,7 +159,7 @@ In the `routines` array, for each of these IDs that is **absent** (by `id`), add } ``` -### 7d — Merge scheduled_checks +### 6d — Merge scheduled_checks In `config.scheduled_checks`, check for `id: "source-scout"`. If absent, append; if present, skip. @@ -181,7 +167,7 @@ In `config.scheduled_checks`, check for `id: "source-scout"`. If absent, append; {"id": "source-scout", "plugin": "feed-hermit", "skill": "feed-hermit:source-scout", "enabled": true, "trigger": "interval", "interval_days": 30} ``` -### 7e — Register the brief archive +### 6e — Register the brief archive Ensure `config.storage_drift` is an object and `config.storage_drift.ignore` is an array. If either is absent or malformed, normalize it while preserving any valid sibling keys and existing array entries. @@ -193,7 +179,7 @@ Write the updated `config.json` using the Write tool (full-file replacement to k --- -## Step 8 — Knowledge-schema extension +## Step 7 — Knowledge-schema extension Read `.claude-code-hermit/knowledge-schema.md`. If the string `brief-summary:` is absent, append under `## Work Products` (create the header if only a stub exists): @@ -215,7 +201,7 @@ If already present: skip. Use Edit. --- -## Step 9 — Final report +## Step 8 — Final report Print a structured summary: diff --git a/plugins/laravel-forge-hermit/.claude-plugin/hermit-meta.json b/plugins/laravel-forge-hermit/.claude-plugin/hermit-meta.json index 77e21845..a91e590c 100644 --- a/plugins/laravel-forge-hermit/.claude-plugin/hermit-meta.json +++ b/plugins/laravel-forge-hermit/.claude-plugin/hermit-meta.json @@ -1,6 +1,6 @@ { - "required_core_version": ">=1.2.30", + "required_core_version": ">=1.2.34", "requires": { - "claude-code-hermit": ">=1.2.30" + "claude-code-hermit": ">=1.2.34" } } diff --git a/plugins/laravel-forge-hermit/.claude-plugin/plugin.json b/plugins/laravel-forge-hermit/.claude-plugin/plugin.json index 61b85823..9efabd6c 100644 --- a/plugins/laravel-forge-hermit/.claude-plugin/plugin.json +++ b/plugins/laravel-forge-hermit/.claude-plugin/plugin.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "claude-code-hermit", - "version": "^1.2.30" + "version": "^1.2.34" } ] } diff --git a/plugins/laravel-forge-hermit/CHANGELOG.md b/plugins/laravel-forge-hermit/CHANGELOG.md index 5f3d1663..be6d56e6 100644 --- a/plugins/laravel-forge-hermit/CHANGELOG.md +++ b/plugins/laravel-forge-hermit/CHANGELOG.md @@ -6,6 +6,10 @@ - `forge.php deploy-watch ` replaces the hand-transcribed watch loop in `forge-deploy`; terminal statuses now come from the shared `STATUS_*` constants. `deploy` points at it in its `Watch with:` hint, and a failing poll now emits the exception class as a watch event instead of surfacing only as a `status=timeout` 15 minutes on. ### Changed +- `hatch` reads the required core version from `.claude-plugin/hermit-meta.json` at runtime via `domain-hatch preflight`, instead of the hardcoded `1.1.1` floor its prose carried. That floor sat many minor versions below what the manifest declared, so the wizard proceeded against a core too old for it. The PHP 8.5 floor is unaffected and still checked in Step 2. +- Target resolution and CLAUDE-APPEND writing are delegated to core: `domain-hatch preflight laravel-forge-hermit` resolves the target, `ensure-target` records an operator override, `sync-block` writes the block. The skill no longer detects install scope from `claude plugin list --json` or stamps `hatch-options.json`. +- `hatch` re-reads `config.json` immediately before merging its scheduled check, runtime-dir registration and version stamp, instead of reusing the copy it loaded before the wizard ran. Anything written to the file during the wizard is no longer clobbered. +- Requires core `>=1.2.34` for the shared `domain-hatch` protocol. `bin/hermit-run` resolves a script by bare filesystem probe, so pairing this version with an older core fails with a misleading "plugin may predate this command" error. - The CLAUDE-APPEND block keeps the surface-then-approve rule and the outage warning but drops the restated 4-step walk (`forge-deploy` and `forge-servers` own it), two of three `call` examples, and the `forge-failed-deploys` contract. 2,993 B → ~2,265 B. Enforcement is now stated accurately: the hook and the in-PHP gate are two layers with the PHP gate authoritative, replacing "neither can be bypassed". - `[hygiene]` and `[deploy-safety]` proposal prefixes removed — no skill produces either, so both were vocabulary paid for in every session. `[reliability]` remains. diff --git a/plugins/laravel-forge-hermit/CLAUDE.md b/plugins/laravel-forge-hermit/CLAUDE.md index 2c1ab82e..5a20ba8b 100644 --- a/plugins/laravel-forge-hermit/CLAUDE.md +++ b/plugins/laravel-forge-hermit/CLAUDE.md @@ -27,7 +27,7 @@ The vendor tree is **not committed to this repo** — hatch installs `laravel/fo ## Hatch target routing -`/hatch` Step 6 reads `.claude-code-hermit/state/hatch-options.json` (`target` field) to route the CLAUDE-APPEND block: `"local"` → `CLAUDE.local.md`, `"committed"` → `CLAUDE.md`. +`/hatch` Step 1 runs `.claude-code-hermit/bin/hermit-run domain-hatch preflight laravel-forge-hermit`; core's `scripts/domain-hatch.ts` resolves the target and stamps `hatch-options.json`. Step 5 records any operator override with `domain-hatch ensure-target laravel-forge-hermit --target ` and writes the block with `domain-hatch sync-block laravel-forge-hermit`. ## Core Rules diff --git a/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md b/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md index 6d58d34b..d5fac53d 100644 --- a/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md +++ b/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md @@ -48,5 +48,5 @@ Scheduled-check skill: scans the org-wide site list and flags any sites whose la - **This skill writes no artifact.** All output goes to stdout for `reflect --scheduled-checks`. - **Registered by `/laravel-forge-hermit:hatch`** step 8 via a `scheduled_checks` config entry (`interval_days: 1`). The core daily `scheduled-checks` routine fires `reflect --scheduled-checks`, which picks it up once 1+ day has elapsed since `last_run`. - **No channel notifications** — this is analysis-only. Findings surface as `[reliability]` proposals via the normal pipeline. -- **scope**: if `organizationSites()` does not carry `deployment_status` in your Forge plan/API version, the scan will report zero findings (not an error). Scope to `watched_sites` (set at hatch step 6) if org-wide scan is unavailable. +- **scope**: if `organizationSites()` does not carry `deployment_status` in your Forge plan/API version, the scan will report zero findings (not an error). Scope to `watched_sites` in `.claude-code-hermit/config.json` if org-wide scan is unavailable. - **Rate limit**: Forge allows ~60 req/min. The scan paces conservatively; if a 429 is returned mid-scan it waits 30 seconds and the scan exits with an error — the check will retry on the next scheduled run. diff --git a/plugins/laravel-forge-hermit/skills/hatch/SKILL.md b/plugins/laravel-forge-hermit/skills/hatch/SKILL.md index c5d04a29..8ac4c8e7 100644 --- a/plugins/laravel-forge-hermit/skills/hatch/SKILL.md +++ b/plugins/laravel-forge-hermit/skills/hatch/SKILL.md @@ -11,9 +11,9 @@ Idempotent setup wizard for the Laravel Forge plugin. Run **after** `/claude-cod ## Step 1 — Prerequisite check -Read `.claude-code-hermit/config.json`. +Check whether `.claude-code-hermit/config.json` exists. -If the file does not exist or `_hermit_versions["claude-code-hermit"]` is absent or empty: +If it does not: > "The base hermit is not set up yet. Run `/claude-code-hermit:hatch` first, then return here." @@ -25,50 +25,32 @@ Use `AskUserQuestion`: "Would you like to run `/claude-code-hermit:hatch` now? ( 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. - **no** → stop. -If present but below `1.1.1` (compare major.minor.patch numerically): +If it does exist, run `.claude-code-hermit/bin/hermit-run domain-hatch preflight laravel-forge-hermit` and parse the JSON verdict. Branch on `action`: -> "Base hermit version is {version}; this plugin requires ≥1.1.1 for scope-aware hatch routing. Run `/claude-code-hermit:hermit-evolve` to upgrade, then re-run this hatch." +- **`upgrade-core-package` / `upgrade-core-applied`** → relay the `remedy` string verbatim to the operator and stop. +- **`verify`** → say: -Stop. + > "laravel-forge-hermit {self_version} is already installed. Reply 'verify' to re-run checks only, or 'full' to re-run the full wizard." ---- - -## Step 2 — Idempotency check - -Read `_hermit_versions["laravel-forge-hermit"]` from `.claude-code-hermit/config.json`. - -Read `version` from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. - -If the versions match: - -> "laravel-forge-hermit {version} is already installed. Reply 'verify' to re-run checks only, or 'full' to re-run the full wizard." - -Use `AskUserQuestion`: "(verify / full)" - -- **verify** → skip to Step 5. -- **full** → continue from Step 3. - -If absent or stale: continue from Step 3. + Use `AskUserQuestion`: "(verify / full)" — **verify** → skip to Step 4; **full** → continue from Step 2. +- **`full`** → continue from Step 2. +- **`ok: false`** → relay `message` and stop. --- -## Step 3 — PHP/Composer preflight + SDK install +## Step 2 — PHP/Composer preflight + SDK install -**Check PHP version.** Run `php -r 'echo PHP_VERSION;'` via Bash. Parse the output. If `php` is not found or the version is below 8.5.0: +**Check PHP version.** Run `php -r 'echo PHP_VERSION;'` via Bash. Parse the output. If `php` is not found or the version is below 8.5.0, relay this and stop: > "PHP 8.5+ is required but not found (got: {version or 'not found'}). > > - **Docker**: re-run `/docker-setup` after the core base image is updated to Ubuntu 26.04 (which ships PHP 8.5 natively). If the core base is still 24.04, the Docker path is blocked pending that upgrade. > - **Bare-metal**: install `php8.5-cli` and `php8.5-curl` (or your distro's equivalent)." -Stop. - -**Check Composer.** Run `composer --version`. If not found: +**Check Composer.** Run `composer --version`. If not found, relay this and stop: > "Composer is not found. Install it from https://getcomposer.org/." -Stop. - **Install the Forge SDK into project space.** The SDK goes into `.claude-code-hermit/forge-runtime/` (hermit-owned, isolated from your app's own `composer.json`/`vendor/`). Run these Bash commands: @@ -89,7 +71,7 @@ If composer exits non-zero, surface the error. Common cause: egress blocked — --- -## Step 4 — Verify .env + consumer .gitignore +## Step 3 — Verify .env + consumer .gitignore **Do NOT use `cat`, `grep`, `echo`, or any Bash command to read `.env`.** The `FORGE_API_TOKEN` key name contains `TOKEN`, which triggers the base hermit's deny-pattern hook on Bash args. Use the **Read tool** only — and only to check the file exists / has the key, never to relay the value. @@ -102,7 +84,7 @@ Tell the operator: > FORGE_ORG=your-org-slug # optional if you have exactly one org > ``` > -> Get your token at https://forge.laravel.com/profile/api. Reply 'done' when set, or 'skip' to continue (credential check happens in Step 5)." +> Get your token at https://forge.laravel.com/profile/api. Reply 'done' when set, or 'skip' to continue (credential check happens in Step 4)." Use `AskUserQuestion`: "(done / skip)" @@ -117,34 +99,40 @@ Append any missing patterns via Edit. --- -## Step 5 — CLI probe (credential check) +## Step 4 — CLI probe (credential check) Run: `php ${CLAUDE_PLUGIN_ROOT}/php/forge.php check` -- **`missing`** → tell the operator to add `FORGE_API_TOKEN` to `.env` and re-run Step 4. +- **`missing`** → tell the operator to add `FORGE_API_TOKEN` to `.env` and re-run Step 3. - **`invalid`** → token found but API rejected it; tell the operator to check the token at https://forge.laravel.com/profile/api. - **`unreachable`** → token present but the API could not be reached (network/egress blocked). In Docker, verify the DNS allowlist in DOCKER.md (`forge.laravel.com`). Re-run once connectivity is confirmed. - **`ok`** → continue. -If `php` is not found at this point: re-run Step 3. +If `php` is not found at this point: re-run Step 2. --- -## Step 6 — CLAUDE.md / CLAUDE.local.md inject +## Step 5 — CLAUDE.md / CLAUDE.local.md inject + +**Resolve target file**: Step 1's preflight already returned `target`, `target_file`, `target_default` and `needs_target_question`. -**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` -- File doesn't exist: detect `core_install_scope` from `claude plugin list --json` (same logic as core hatch), ask with `AskUserQuestion` (header: "Visibility"): **`.local` files** (gitignored) / **Committed files** (shared). Write the 5-field canonical schema to `hatch-options.json`. +If `needs_target_question` is true, ask with `AskUserQuestion` (header: "Visibility") — `target_default` at position 0 with `(recommended)`: **`.local` files** (gitignored, operator-personal) / **Committed files** (shared with teammates). Then record it: -Read `target_file`. Search for ``. +```bash +.claude-code-hermit/bin/hermit-run domain-hatch ensure-target laravel-forge-hermit --target +``` + +Then write the block: + +```bash +.claude-code-hermit/bin/hermit-run domain-hatch sync-block laravel-forge-hermit +``` -- **Absent** → append the full contents of `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md` using Edit. -- **Present** → skip (already injected; `hermit-evolve` handles replacement on upgrade). +It appends the `` block when the marker is absent and skips when it is already present; `hermit-evolve` handles replacement on upgrade. --- -## Step 7 — Knowledge-schema extension +## Step 6 — Knowledge-schema extension Read `.claude-code-hermit/knowledge-schema.md`. @@ -158,11 +146,11 @@ Use Edit. Skip if already present (idempotent). --- -## Step 8 — Stamp + register in config.json +## Step 7 — Stamp + register in config.json -Use the `config.json` content loaded in Step 1. +Re-read `.claude-code-hermit/config.json` now — the wizard has been running since Step 1 and the on-disk file may have changed. Apply the merges below to that fresh copy. -**Stamp version**: set `_hermit_versions["laravel-forge-hermit"]` to the plugin version from Step 2. +**Stamp version**: set `_hermit_versions["laravel-forge-hermit"]` to `self_version` from Step 1's preflight. **Merge scheduled check**: check `config.scheduled_checks` for `id: "forge-failed-deploys"`. If absent, append: @@ -178,7 +166,7 @@ Write the updated `config.json` via Write tool (full file replacement for valid --- -## Step 9 — Final report +## Step 8 — Final report ``` laravel-forge-hermit {version} setup complete. diff --git a/plugins/laravel-forge-hermit/tests/hatch-skill.test.ts b/plugins/laravel-forge-hermit/tests/hatch-skill.test.ts new file mode 100644 index 00000000..108c190a --- /dev/null +++ b/plugins/laravel-forge-hermit/tests/hatch-skill.test.ts @@ -0,0 +1,74 @@ +// Structural lint for skills/hatch/SKILL.md: that it runs core's shared +// domain-hatch protocol and carries no second copy of it. +// Run with: bun test tests/hatch-skill.test.ts +// +// Grep-level checks only — no runtime skill execution. + +import fs from 'node:fs'; +import path from 'node:path'; +import { makeReporter } from './test-utils'; + +const PLUGIN_ROOT = path.join(import.meta.dir, '..'); +const SKILL = path.join(PLUGIN_ROOT, 'skills', 'hatch', 'SKILL.md'); +const TEMPLATE = path.join(PLUGIN_ROOT, 'state-templates', 'CLAUDE-APPEND.md'); + +const { ok, summary } = makeReporter(); + +console.log('\nskills/hatch/SKILL.md shared domain-hatch protocol:'); + +ok('file exists', fs.existsSync(SKILL), SKILL); + +if (fs.existsSync(SKILL)) { + const text = fs.readFileSync(SKILL, 'utf-8'); + + ok('runs preflight through core, keyed to its own plugin id', + text.includes('domain-hatch preflight laravel-forge-hermit')); + ok('reaches core via bin/hermit-run, not a relative path', + text.includes('.claude-code-hermit/bin/hermit-run domain-hatch') + && !text.includes('../claude-code-hermit/scripts')); + ok('branches on every preflight `action` value', + ['upgrade-core-package', 'upgrade-core-applied', '`verify`', '`full`'].every(a => text.includes(a))); + ok('consumes the preflight verdict fields instead of re-deriving them', + /`target`[\s\S]{0,60}`target_file`[\s\S]{0,60}`target_default`[\s\S]{0,60}`needs_target_question`/.test(text)); + + ok('records the operator\'s choice via ensure-target', + text.includes('domain-hatch ensure-target laravel-forge-hermit --target')); + ok('Visibility prompt still offers .local vs committed', + /Visibility[\s\S]{0,240}`\.local` files[\s\S]{0,120}Committed files/.test(text)); + ok('writes the block via sync-block', + text.includes('domain-hatch sync-block laravel-forge-hermit')); + + // Prose surfaces that drifted from the manifest and from core's resolver + // before the protocol was centralised. None of them may come back. + ok('does not read hatch-options.json directly', !text.includes('hatch-options.json')); + ok('does not restate install-scope detection', !text.includes('claude plugin list --json')); + ok('does not restate the hatch-options stamp schema', + !/"stamped_by":\s*"/.test(text) && !/"core_install_scope":\s*"/.test(text)); + // Scoped to the CORE floor — this hatch legitimately requires PHP >= 8.5.0. + ok('states no hardcoded core version floor', + text.split('\n') + .filter(l => /(?:base hermit|core hermit|claude-code-hermit|_hermit_versions)/i.test(l)) + .every(l => !/(?:requires|earlier than|less than|below)\s+`?≥?>?=?\s*\d+\.\d+\.\d+/i.test(l))); + ok('still states its own PHP floor', /PHP 8\.5\+? is required/.test(text)); + + ok('stamps its own version into _hermit_versions', + text.includes('_hermit_versions["laravel-forge-hermit"]')); + ok('the stamped value comes from the preflight verdict, not a literal', + /_hermit_versions\["laravel-forge-hermit"\][\s\S]{0,60}self_version/.test(text)); + + ok('names the block marker it hands to sync-block', + text.includes('')); +} + +console.log('\nstate-templates/CLAUDE-APPEND.md:'); + +ok('file exists', fs.existsSync(TEMPLATE), TEMPLATE); + +if (fs.existsSync(TEMPLATE)) { + const tpl = fs.readFileSync(TEMPLATE, 'utf-8'); + // sync-block replaces between the markers, so the template must carry both. + ok('opening marker present', tpl.includes('')); + ok('closing marker present', tpl.includes('')); +} + +process.exit(summary() === 0 ? 0 : 1); diff --git a/plugins/laravel-forge-hermit/tests/run-all.sh b/plugins/laravel-forge-hermit/tests/run-all.sh index a42bb8ae..d9ca4b4e 100755 --- a/plugins/laravel-forge-hermit/tests/run-all.sh +++ b/plugins/laravel-forge-hermit/tests/run-all.sh @@ -17,8 +17,20 @@ if ! php php/tests/run.php; then fi echo "" -echo "--- bun tests (hook + skill-structure) ---" -if ! bun test tests/hook.test.ts tests/skill-structure.test.ts; then +# The structural lints call process.exit(), which tears down a shared `bun test` +# runner before the remaining files load. Run each of those directly and keep +# `bun test` for the bun:test-based hook suite. +echo "--- bun tests (hook) ---" +if ! bun test tests/hook.test.ts; then + EXIT=1 +fi + +echo "" +echo "--- structural lints (skill-structure + hatch-skill) ---" +if ! bun tests/skill-structure.test.ts; then + EXIT=1 +fi +if ! bun tests/hatch-skill.test.ts; then EXIT=1 fi diff --git a/tests/cross-plugin/domain-hatch.contract.test.ts b/tests/cross-plugin/domain-hatch.contract.test.ts new file mode 100644 index 00000000..698af0a9 --- /dev/null +++ b/tests/cross-plugin/domain-hatch.contract.test.ts @@ -0,0 +1,142 @@ +// Cross-plugin guard: every domain hatch runs the shared protocol, and none of +// them carries a second copy of it. +// +// Lives at the repo root rather than in any plugin's suite because it spans +// plugins — a core-owned copy would never run on a domain-only PR under the +// per-plugin path filters, and a per-plugin copy would have to be remembered +// five times. +// +// Discovery is derived from the filesystem. The two hardcoded lists this +// replaces (hatch-resume-contract's DOMAIN_SLUGS, hatch-options-contract's +// single dev-hermit check) both went stale when a fifth plugin shipped, so a +// sixth must be covered the day it lands, without anyone updating a list. + +import { describe, test, expect } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(import.meta.dir, '../..'); +const PLUGINS_DIR = path.join(REPO_ROOT, 'plugins'); + +interface DomainHatch { + slug: string; + file: string; + text: string; +} + +// A plugin is in scope when it has a hatch, declares a core dependency, and +// that hatch actually does target routing. The first two conditions alone would +// pull in hermit-scribe, which declares the dependency but carries none of the +// protocol prose (it only reads the target, never resolves or stamps it), so a +// rewrite loop over that set would try to edit a file with nothing to edit. +function discover(): DomainHatch[] { + return fs + .readdirSync(PLUGINS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name !== 'claude-code-hermit') + .map((d) => d.name) + .map((slug) => ({ + slug, + file: path.join(PLUGINS_DIR, slug, 'skills', 'hatch', 'SKILL.md'), + meta: path.join(PLUGINS_DIR, slug, '.claude-plugin', 'hermit-meta.json'), + })) + .filter((p) => fs.existsSync(p.file) && fs.existsSync(p.meta)) + .map((p) => { + const meta = (() => { try { return JSON.parse(fs.readFileSync(p.meta, 'utf-8')); } catch { return null; } })(); + return { slug: p.slug, file: p.file, meta, text: fs.readFileSync(p.file, 'utf-8') }; + }) + .filter((p) => Boolean(p.meta?.required_core_version) && p.text.includes('domain-hatch')) + .map(({ slug, file, text }) => ({ slug, file, text })) + .sort((a, b) => a.slug.localeCompare(b.slug)); +} + +const HATCHES = discover(); + +describe('discovery', () => { + test('finds the domain hatches that run the shared protocol', () => { + expect(HATCHES.length).toBeGreaterThanOrEqual(5); + }); + + test('never includes core, which is not a consumer of its own protocol', () => { + expect(HATCHES.map((h) => h.slug)).not.toContain('claude-code-hermit'); + }); +}); + +for (const { slug, text } of HATCHES) { + describe(`${slug}:hatch`, () => { + test('reaches core through bin/hermit-run, not a relative path', () => { + expect(text).toContain('.claude-code-hermit/bin/hermit-run domain-hatch'); + // A domain plugin's ${CLAUDE_PLUGIN_ROOT} is ////, + // so no static ../claude-code-hermit/... path resolves from one. + expect(text).not.toContain('../claude-code-hermit/scripts'); + }); + + test('runs preflight rather than deciding prerequisites itself', () => { + expect(text).toContain('domain-hatch preflight'); + }); + + // The live bug this whole change exists for: four hatches checked a floor + // in prose that was 1 to 14 minor versions below what their own manifest + // declared, so they proceeded against a core too old for them. + // Scoped to the CORE floor: a hatch may legitimately state a version + // requirement for something else (forge checks PHP >= 8.5.0). What must + // never come back is a core-hermit floor written into skill prose, where + // it drifts from the manifest that actually declares it. + test('states no hardcoded core version floor', () => { + const lines = text.split('\n').filter((l) => + /(?:base hermit|core hermit|claude-code-hermit|_hermit_versions)/i.test(l), + ); + for (const line of lines) { + expect(line).not.toMatch(/(?:requires|earlier than|less than|below)\s+`?≥?>?=?\s*\d+\.\d+\.\d+/i); + } + }); + + test('does not restate the install-scope precedence rules', () => { + expect(text).not.toContain('claude plugin list --json'); + expect(text).not.toMatch(/precedence\s+`?local`?\s*>/); + }); + + test('does not restate the hatch-options stamp schema', () => { + expect(text).not.toMatch(/"stamped_by":\s*"/); + expect(text).not.toMatch(/"core_install_scope":\s*"/); + }); + + // Top-level `Stop.` sat outside the version-check branch in three hatches; + // read literally it ends the skill unconditionally after step 1. + test('has no bare top-level Stop. line', () => { + expect(text.split('\n').some((l) => l.trim() === 'Stop.')).toBe(false); + }); + }); +} + +describe('core side of the contract', () => { + const coreScripts = path.join(PLUGINS_DIR, 'claude-code-hermit', 'scripts'); + + test('the script the hatches invoke exists', () => { + expect(fs.existsSync(path.join(coreScripts, 'domain-hatch.ts'))).toBe(true); + }); + + test('each verb is granted separately, never as one wildcard', () => { + const applySettings = fs.readFileSync(path.join(coreScripts, 'apply-settings.ts'), 'utf-8'); + for (const verb of ['preflight', 'ensure-target', 'sync-block']) { + expect(applySettings).toContain(`bin/hermit-run domain-hatch ${verb} *`); + } + // A bare `domain-hatch *` would hand every caller the two mutating verbs. + expect(applySettings).not.toContain('bin/hermit-run domain-hatch *'); + }); + + // apply-settings.ts is the single owner of the literal entries; hatch carries + // only the rationale for why they exist. Asserting the entries twice would + // reintroduce the duplication the permissions single-owner change removed. + test('hatch SKILL.md explains the domain-hatch route without re-listing it', () => { + const hatch = fs.readFileSync(path.join(PLUGINS_DIR, 'claude-code-hermit', 'skills', 'hatch', 'SKILL.md'), 'utf-8'); + expect(hatch).toContain('hermit-run domain-hatch'); + expect(hatch).not.toContain('"Bash(.claude-code-hermit/bin/hermit-run domain-hatch'); + }); + + test('the marker parser stays single-sourced in evolve-plan', () => { + const block = fs.readFileSync(path.join(coreScripts, 'lib', 'domain-hatch', 'block.ts'), 'utf-8'); + expect(block).toContain("from '../../evolve-plan'"); + const evolvePlan = fs.readFileSync(path.join(coreScripts, 'evolve-plan.ts'), 'utf-8'); + expect(evolvePlan).toMatch(/export \{[^}]*isAmbiguousBlock/); + }); +}); From 55effc64f986e491def31e01727044b6df805e9d Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Sat, 25 Jul 2026 20:56:35 +0100 Subject: [PATCH 2/2] fix(fleet): resolve user-scope installs and refuse unrendered hatch templates The domain-hatch extraction dropped three behaviors its callers relied on. resolvePlugin only considered the local and project scope tiers, so every user-scope install became unresolvable; that stranded hermit-evolve (which now takes hatch_target solely from preflight), core hatch Step 9b, and all five domain hatches, each of which read the plugin root directly before. preflight returned early on a plugin-list failure and withheld target state that needs no plugin list at all, turning a transient probe failure into a hard stop. And planBlock inherited evolve-plan's rules minus the mode-marker refusal, so sync-block without --rendered-stdin could append an unrendered dev template, fence comments and both mode regions included, into the operator's CLAUDE.md. Also: ensure-target degrades to a 0.0.0 version stamp plus a resolve_warning instead of exiting 1, since the stamp was the only thing it took from the resolution; block replacement uses a function replacer so $-patterns in a CLAUDE-APPEND line survive verbatim; and the HA and forge hatch docs are repointed at the renumbered steps. No CHANGELOG bullet: [Unreleased] already describes the corrected behavior, and this fixes unreleased work no operator has seen. --- .../scripts/domain-hatch.ts | 12 +++- .../claude-code-hermit/scripts/evolve-plan.ts | 2 +- .../scripts/lib/domain-hatch/block.ts | 21 +++++- .../scripts/lib/domain-hatch/preflight.ts | 27 ++++--- .../scripts/lib/domain-hatch/resolve.ts | 22 +++--- .../tests/domain-hatch.test.ts | 72 +++++++++++++++++++ .../skills/hatch/SKILL.md | 2 +- .../skills/forge-failed-deploys/SKILL.md | 2 +- 8 files changed, 134 insertions(+), 26 deletions(-) diff --git a/plugins/claude-code-hermit/scripts/domain-hatch.ts b/plugins/claude-code-hermit/scripts/domain-hatch.ts index 32f66793..a17336de 100644 --- a/plugins/claude-code-hermit/scripts/domain-hatch.ts +++ b/plugins/claude-code-hermit/scripts/domain-hatch.ts @@ -102,16 +102,22 @@ if (verb === 'ensure-target') { ]); const stdinJson = await readPluginListFile(); const list = pluginList(stdinJson); + // The only thing this verb takes from the resolution is a version string for + // the stamp, and that already has a fallback. Failing the whole write when + // `claude plugin list` cannot be read would leave hatch-options.json + // unwritten and every later consumer re-asking the Visibility question — a + // far worse outcome than a `0.0.0` stamp. The plugin id is regex-validated + // above and only ever lands in core's own state dir as a string field. const resolved = resolvePlugin(list, pluginId, projectRoot); - if (isResolveError(resolved)) die(resolved.error, resolved.message); + const unresolved = isResolveError(resolved); const scope = coreScope(list as any, projectRoot); const res = ensureHatchTarget(stateDir, { target, core_scope: scope.core_scope, stampedBy: `${pluginId}:hatch`, - version: resolved.version ?? '0.0.0', + version: unresolved ? '0.0.0' : (resolved.version ?? '0.0.0'), }); - out(res); + out(unresolved ? { ...res, resolve_warning: resolved.message } : res); process.exit(res.ok ? 0 : 1); } diff --git a/plugins/claude-code-hermit/scripts/evolve-plan.ts b/plugins/claude-code-hermit/scripts/evolve-plan.ts index da13c1e2..b3c75350 100644 --- a/plugins/claude-code-hermit/scripts/evolve-plan.ts +++ b/plugins/claude-code-hermit/scripts/evolve-plan.ts @@ -859,7 +859,7 @@ function parseArgs(argv: string[]) { return { hermitDir: hermitDir || '.claude-code-hermit', hatchTarget, pluginListJsonPath }; } -export { buildPlan, cmpSemver, changelogSlice, newConfigKeys, markerOnward, extractSiblingMarker, closingMarkerFor, isAmbiguousBlock, classifyFiles, classifyDockerEntrypoint, classifyDockerTemplates }; +export { buildPlan, cmpSemver, changelogSlice, newConfigKeys, markerOnward, extractSiblingMarker, closingMarkerFor, isAmbiguousBlock, requiresRendering, classifyFiles, classifyDockerEntrypoint, classifyDockerTemplates }; export type { ClassifiedFile, FileClass }; if (import.meta.main) { diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts index 8141d636..e576cee6 100644 --- a/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/block.ts @@ -19,10 +19,10 @@ import fs from 'node:fs'; import path from 'node:path'; -import { markerOnward, extractSiblingMarker, isAmbiguousBlock } from '../../evolve-plan'; +import { markerOnward, extractSiblingMarker, isAmbiguousBlock, requiresRendering } from '../../evolve-plan'; import { writeFileAtomic } from '../md-write'; -export type BlockAction = 'append' | 'replace' | 'skip' | 'ambiguous' | 'no-template' | 'no-marker'; +export type BlockAction = 'append' | 'replace' | 'skip' | 'ambiguous' | 'no-template' | 'no-marker' | 'needs-rendering'; export interface BlockPlan { action: BlockAction; @@ -61,6 +61,16 @@ export function planBlock( return { action: 'no-marker', marker: null, targetFile: targetPath }; } + // A template carrying `mode:` markers is rendered by its own plugin before + // install (dev-hermit's render-append.ts). Core cannot render it, so the raw + // text is neither a valid comparison base nor a valid payload — the same + // refusal evolve-plan makes. Without this, `sync-block ` with no + // --rendered-stdin appends both mode regions and their fence comments + // verbatim into the operator's CLAUDE.md. + if (rendered === undefined && requiresRendering(tmplText)) { + return { action: 'needs-rendering', marker, targetFile: targetPath }; + } + const targetText = read(targetPath); if (targetText === null) { // Missing target file is the append case — the caller's Edit creates it. @@ -111,6 +121,9 @@ export function applyBlock(plan: BlockPlan): BlockResult { if (plan.action === 'no-marker') { return { ...plan, ok: false, written: false, message: 'template carries no opening marker for this plugin' }; } + if (plan.action === 'needs-rendering') { + return { ...plan, ok: false, written: false, message: 'template carries mode: markers and must be rendered by its own plugin; pipe the rendering in with --rendered-stdin' }; + } if (plan.action === 'skip') { return { ...plan, ok: true, written: false }; } @@ -121,7 +134,9 @@ export function applyBlock(plan: BlockPlan): BlockResult { const sep = existing === '' || existing.endsWith('\n') ? '' : '\n'; next = existing + sep + plan.new_block; } else { - next = existing.replace(plan.old_block!, plan.new_block!); + // Function replacement: a plain string would let `$&`, `` $` ``, `$'` and + // `$$` inside the block be expanded as substitution patterns. + next = existing.replace(plan.old_block!, () => plan.new_block!); } writeFileAtomic(plan.targetFile, next.endsWith('\n') ? next : next + '\n'); return { ...plan, ok: true, written: true }; diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts index 0d5b3be4..d8858d75 100644 --- a/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/preflight.ts @@ -93,9 +93,25 @@ export function preflight(input: PreflightInput): Preflight { } const list = pluginList(input.stdinJson); + + // Target resolution is computed first and reported unconditionally: none of + // it depends on resolving the plugin (the stamped file and the CLAUDE-marker + // probe are pure filesystem reads). hermit-evolve takes hatch_target from + // this verb and has no fallback of its own, so returning early on a plugin + // list that could not be read would strand it with no target at all. + const scope = coreScope(list as any, projectRoot); + const state = readTargetState(hermitDir, scope, projectRoot); + const targetFields = { + target: state.target, + target_default: state.target_default, + ...(state.target ? { target_file: targetFile(state.target) } : {}), + core_scope: state.core_scope, + needs_target_question: state.needs_target_question, + }; + const resolved = resolvePlugin(list, pluginId, projectRoot); if (isResolveError(resolved)) { - return { ok: false, error: resolved.error, message: resolved.message, plugin: pluginId }; + return { ok: false, error: resolved.error, message: resolved.message, plugin: pluginId, ...targetFields }; } const self: ResolvedPlugin = resolved; @@ -125,9 +141,6 @@ export function preflight(input: PreflightInput): Preflight { action = stamped !== null && self.version !== null && stamped === self.version ? 'verify' : 'full'; } - const scope = coreScope(list as any, projectRoot); - const state = readTargetState(hermitDir, scope, projectRoot); - let marker: string | null = null; let appendAction: string | undefined; if (state.target) { @@ -156,11 +169,7 @@ export function preflight(input: PreflightInput): Preflight { core_applied: coreApplied, action, ...(remedy ? { remedy } : {}), - target: state.target, - target_default: state.target_default, - ...(state.target ? { target_file: targetFile(state.target) } : {}), - core_scope: state.core_scope, - needs_target_question: state.needs_target_question, + ...targetFields, marker, ...(appendAction ? { append_action: appendAction } : {}), }; diff --git a/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts b/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts index f4de3334..63a572e1 100644 --- a/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts +++ b/plugins/claude-code-hermit/scripts/lib/domain-hatch/resolve.ts @@ -47,20 +47,26 @@ function splitId(id: string): string { } // Resolve one plugin ID to the install that would actually load in this -// project. Precedence mirrors resolve-siblings' dedupe rule (local over -// project) so hatch and the sibling probe never disagree about which copy is -// live. Ambiguity across marketplaces is an error, not a silent first-match: +// project. Precedence mirrors coreScope()'s: `local` > `project` (both require +// `projectPath == project root`) > `user` (any `projectPath` — a user-scope +// install is live in every project, which is why coreScope's user branch +// ignores projectPath too). Dropping the user tier here would make every +// user-scope install unresolvable, and `core_install_scope: "user"` is a value +// hatch-options.json is explicitly allowed to carry. +// Ambiguity across marketplaces is an error, not a silent first-match: // picking wrong here writes a hermit block sourced from the wrong template. export function resolvePlugin( list: Json[], pluginId: string, projectRoot: string, ): ResolvedPlugin | ResolveError { - const here = list.filter( - (e) => splitId(e?.id ?? '') === pluginId && e?.enabled === true && e?.projectPath === projectRoot, - ); - const byScope = (s: string) => here.filter((e) => e?.scope === s); - const candidates = byScope('local').length ? byScope('local') : byScope('project'); + const enabled = list.filter((e) => splitId(e?.id ?? '') === pluginId && e?.enabled === true); + const here = enabled.filter((e) => e?.projectPath === projectRoot); + const byScope = (pool: Json[], s: string) => pool.filter((e) => e?.scope === s); + const local = byScope(here, 'local'); + const project = byScope(here, 'project'); + const user = byScope(enabled, 'user'); + const candidates = local.length ? local : project.length ? project : user; if (!candidates.length) { return list.length diff --git a/plugins/claude-code-hermit/tests/domain-hatch.test.ts b/plugins/claude-code-hermit/tests/domain-hatch.test.ts index 472dc44c..e6acafa2 100644 --- a/plugins/claude-code-hermit/tests/domain-hatch.test.ts +++ b/plugins/claude-code-hermit/tests/domain-hatch.test.ts @@ -149,6 +149,47 @@ describe('preflight', () => { expect(r.error).toBe('plugin_not_installed'); }); + // coreScope() resolves a user-scope core (its user branch ignores + // projectPath on purpose), and hatch-options.json is allowed to record + // core_install_scope: "user" — so resolution must reach that tier too. + test('a user-scope install still resolves', () => { + const s = scaffold(); + const list = [ + { id: `${PLUGIN}@mp`, scope: 'user', enabled: true, projectPath: '/elsewhere', installPath: s.install }, + { id: 'claude-code-hermit@mp', scope: 'user', enabled: true, projectPath: '/elsewhere', installPath: s.coreRoot }, + ]; + const r = preflight({ + pluginId: PLUGIN, + hermitDir: s.hermit, + projectRoot: s.root, + corePluginRoot: s.coreRoot, + stdinJson: JSON.stringify(list), + }); + expect(r.ok).toBe(true); + expect(r.action).toBe('full'); + }); + + // hermit-evolve takes hatch_target from this verb and has no fallback of its + // own; the stamped file and the marker probe need no plugin list, so an + // unreadable list must not strand it without a target. + test('an unresolvable plugin still reports the target', () => { + const s = scaffold({ + hatchOptions: { target: 'local', core_install_scope: 'local', stamped_at: 'x', stamped_by: 'y', version: '1.0.0' }, + }); + const r = preflight({ + pluginId: PLUGIN, + hermitDir: s.hermit, + projectRoot: s.root, + corePluginRoot: s.coreRoot, + stdinJson: '[]', + }); + expect(r.ok).toBe(false); + expect(r.error).toBe('plugin_list_unavailable'); + expect(r.target).toBe('local'); + expect(r.target_file).toBe('CLAUDE.local.md'); + expect(r.needs_target_question).toBe(false); + }); + test('reports the target and the block action once a target exists', () => { const s = scaffold({ hatchOptions: { target: 'committed', core_install_scope: 'project', stamped_at: 'x', stamped_by: 'y', version: '1.0.0' }, @@ -253,6 +294,37 @@ describe('sync-block', () => { expect(text.split(MARKER).length - 1).toBe(1); }); + // A mode-annotated template is rendered by its own plugin; appending the raw + // text would drop both mode regions and their fence comments into CLAUDE.md. + test('refuses a template that must be rendered when nothing is piped in', () => { + const s = scaffold(); + const modeTemplate = `${MARKER}\n\n\nstandard\n\n\n${CLOSING}\n`; + fs.writeFileSync(path.join(s.install, 'state-templates', 'CLAUDE-APPEND.md'), modeTemplate); + const target = path.join(s.root, 'CLAUDE.md'); + + const raw = applyBlock(planBlock(s.install, PLUGIN, target, [])); + expect(raw.action).toBe('needs-rendering'); + expect(raw.ok).toBe(false); + expect(fs.existsSync(target)).toBe(false); + + const rendered = applyBlock(planBlock(s.install, PLUGIN, target, [], `${MARKER}\n\nstandard\n\n${CLOSING}\n`)); + expect(rendered.action).toBe('append'); + expect(rendered.written).toBe(true); + }); + + // `$&` in a replacement string is a substitution pattern, not a literal. + test('a replacement block containing $-patterns is written verbatim', () => { + const s = scaffold(); + const target = path.join(s.root, 'CLAUDE.md'); + fs.writeFileSync(target, '# Project\n'); + applyBlock(planBlock(s.install, PLUGIN, target, [])); + + const line = "Use `sed 's/x/$&/'` and `$'y'`."; + const dollar = TEMPLATE.replace('Some rules.', () => line); + expect(applyBlock(planBlock(s.install, PLUGIN, target, [], dollar)).action).toBe('replace'); + expect(fs.readFileSync(target, 'utf8')).toContain(line); + }); + // Never add a third copy: with the marker duplicated, a replace could hit the // wrong instance and an append would compound it. test('refuses a duplicated marker instead of appending again', () => { diff --git a/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md b/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md index 751226c3..cfd23726 100644 --- a/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-homeassistant-hermit/skills/hatch/SKILL.md @@ -204,7 +204,7 @@ Run `bun ${CLAUDE_PLUGIN_ROOT}/scripts/automode-env.ts .claude/settings.local.js ### 7. Stamp version and register routines -Write `_hermit_versions["claude-code-homeassistant-hermit"]` into `.claude-code-hermit/config.json` with the current plugin version. +Write `_hermit_versions["claude-code-homeassistant-hermit"]` into `.claude-code-hermit/config.json`, set to `self_version` from Step 1's preflight. **Compiled templates**: Copy `${CLAUDE_PLUGIN_ROOT}/state-templates/compiled/acknowledged-violations.md` to `.claude-code-hermit/compiled/acknowledged-violations.md` if that file does not already exist. Set `created` in the frontmatter to today's ISO date. This gives the operator a ready-to-use suppression list for the safety audit. diff --git a/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md b/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md index d5fac53d..47d76c27 100644 --- a/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md +++ b/plugins/laravel-forge-hermit/skills/forge-failed-deploys/SKILL.md @@ -46,7 +46,7 @@ Scheduled-check skill: scans the org-wide site list and flags any sites whose la ## Notes - **This skill writes no artifact.** All output goes to stdout for `reflect --scheduled-checks`. -- **Registered by `/laravel-forge-hermit:hatch`** step 8 via a `scheduled_checks` config entry (`interval_days: 1`). The core daily `scheduled-checks` routine fires `reflect --scheduled-checks`, which picks it up once 1+ day has elapsed since `last_run`. +- **Registered by `/laravel-forge-hermit:hatch`** step 7 via a `scheduled_checks` config entry (`interval_days: 1`). The core daily `scheduled-checks` routine fires `reflect --scheduled-checks`, which picks it up once 1+ day has elapsed since `last_run`. - **No channel notifications** — this is analysis-only. Findings surface as `[reliability]` proposals via the normal pipeline. - **scope**: if `organizationSites()` does not carry `deployment_status` in your Forge plan/API version, the scan will report zero findings (not an error). Scope to `watched_sites` in `.claude-code-hermit/config.json` if org-wide scan is unavailable. - **Rate limit**: Forge allows ~60 req/min. The scan paces conservatively; if a 429 is returned mid-scan it waits 30 seconds and the scan exits with an error — the check will retry on the next scheduled run.