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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/test-cross-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions plugins/claude-code-dev-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<!-- /claude-code-dev-hermit: Development Workflow -->`), 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 `<!-- mode:standard-only -->` 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).
Expand Down
4 changes: 2 additions & 2 deletions plugins/claude-code-dev-hermit/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <choice>`, 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

Expand Down
44 changes: 18 additions & 26 deletions plugins/claude-code-dev-hermit/skills/hatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ 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`):
1. Write `.claude-code-hermit/state/hatch-resume.json` with `{ "skill": "claude-code-dev-hermit:hatch" }`.
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

Expand Down Expand Up @@ -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": "<choice>",
"core_install_scope": "<project|local|user|null>",
"stamped_at": "<current ISO 8601 timestamp with timezone offset>",
"stamped_by": "claude-code-dev-hermit:hatch",
"version": "<current dev-hermit plugin version from ${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json>"
}
```
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 <choice>
```

Read `target_file` (treat a missing file as marker-absent — Edit will create the file in the append branch). Look for the marker `<!-- claude-code-dev-hermit: Development Workflow -->`. 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 <mode> | .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 <mode>` (`<mode>` 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.
`<mode>` 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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (<value>)` 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.
Expand Down
68 changes: 41 additions & 27 deletions plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode>[\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));
Expand Down
Loading
Loading