Skip to content

refactor(claude-code-hermit): consolidate 23 scripts into verb CLIs - #662

Merged
gtapps merged 9 commits into
mainfrom
chore/script-consolidation-phase-1
Jul 25, 2026
Merged

refactor(claude-code-hermit): consolidate 23 scripts into verb CLIs#662
gtapps merged 9 commits into
mainfrom
chore/script-consolidation-phase-1

Conversation

@gtapps

@gtapps gtapps commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Collapses the shallow-adapter surface in plugins/claude-code-hermit/scripts/: 23 top-level scripts become verbs on 7 CLIs, backed by modules under scripts/lib/. Top-level scripts drop 80 → 57, and shell entrypoints 5 → 4.

The one operator-visible win is the prompt path: seven UserPromptSubmit hooks became one process, which also made two latent precedence bugs expressible and fixed. The rest is maintainer-surface reduction — every other commit is behavior-preserving by construction.

Seven commits, one per consolidation, each independently green and revertible, plus a comment-cleanup commit at the end.

Changes

Prompt pathuser-prompt-pipeline.ts replaces 7 hooks. Reads stdin once, parses the channel envelope once, reads config.json/runtime.json at most once each. Stage precedence is explicit in code rather than implied by hooks.json ordering, and a blocked prompt emits the decision JSON alone (mixed context text alongside it doesn't parse as a decision, silently dropping the block). Two fixes this shape made possible:

  • A pending shutdown is now terminal. A failed shutdown send used to fall through, letting an exact status message send and block on its own — discarding the shutdown relay the model was supposed to act on.
  • pause/resume and channel harness commands no longer mutate session state mid-shutdown.

Permissions become script-ownedapply-settings.ts gains permissions-plan / permissions-sync over a sealed HERMIT_ALLOW plus a new sealed HERMIT_OBSOLETE registry. hatch and hermit-evolve call the verbs instead of restating the list; two copies of the list and a hand-maintained stale-entry list are gone. Removal is filtered by the sealed registry, never by shape — operator-authored rules are structurally untouchable.

Six more consolidationsproposal.ts (+7 verbs), artifact.ts, cost-report.ts, setup-token-mint.ts probe, heartbeat.ts, routines.ts. log-routine-event.sh was ported to TypeScript, removing the last shell script on the routine path; routines.ts due/precheck now log in-process instead of spawning a subprocess per stamp.

Fleet-scoped, and whybin/hermit-run resolves scripts by bare filesystem probe with no alias table, so a rename breaks in both version directions and fails into a misleading "plugin may predate this command" error. The three domain plugins that route through it therefore ship together with required_core_version raised to >=1.2.34 across all three canonical fields. Their two grants are verb-pinned (hermit-run proposal micro *, … proposal metrics *) — a bare hermit-run proposal * would also expose create, patch, shell-append, next-task and routine, i.e. arbitrary state-dir writes.

Authority changes worth namingproposal-metrics-report.ts, eval-success-signal.ts and the setup-token probe had no allow entry and were prompted for on every run. As verbs they're covered by existing grants and run unattended.

Test plan

Run from the repo root unless noted.

  • cd plugins/claude-code-hermit && bun test3179 pass / 0 fail (115 files)
  • bunx tsc --noEmit → exit 0
  • Sibling suites: HA bun test 686 tests / 0 fail · dev bash tests/run-all.sh 13 passed · fitness bash tests/run-all.sh 129 passed
  • Permissions round-trip against a scratch dir: bare settings → 29 missing / 0 obsolete; after permissions-sync → re-plan is 0/0 (idempotent); inject 3 retired grants + 1 operator custom rule → sync removes exactly the 3, custom rule preserved.
  • hermit-run route check from a hatched scratch dir: proposal micro … brief-cycle and proposal metrics … --source=… both dispatch at exit 0; the pre-rename names micro-proposal and proposal-metrics-report both hit the "may predate this command" branch — the failure required_core_version exists to prevent operators meeting.
  • Every consolidated verb exercised by hand for stdout grammar and exit code, including the load-bearing non-zero ones (success-signal --validate on a bad predicate → exit 1; queue-micro/micro on malformed input → exit 1; index with no state dir → SKIP|no state dir at exit 0).
  • Routine logger verified byte-identical to the shell original it replaces ({"ts":"…Z","routine_id":"…","event":"…","delivery":"…"} — same key order, second-precision UTC), and the fired-after-fired dedup guard still collapses two consecutive fires to one.
  • Monitor shells run clean on both the default path and with HEARTBEAT_PRECHECK / ROUTINE_DUE_SCRIPT overridden.

Reviewer notes

doctor-check warns until core is released. Verified, not assumed:

"id": "dependencies", "status": "warn",
"detail": "3 sibling(s) outside required_core_version range:
           claude-code-fitness-hermit requires >=1.2.34 (core is 1.2.33)"

This is the intended trade. Pinning the siblings to >=1.2.33 would silence the warn but let a hermit on 1.2.33 pass the check and then hit the route error at runtime. Since main only reaches operators on a version bump, the warn is confined to the pre-release window and clears when core ships.

Env-override contract deliberately unchanged. HEARTBEAT_PRECHECK / ROUTINE_DUE_SCRIPT still take a bare script path invoked with the same arguments — the monitors build the command as a bash array and prepend the verb only on the default branch. No test stub needed changing.

Two import.meta.main hazards the dispatchers surfaced, both fixed: registry.ts's CLI would have silently never run under a dynamic import (it's a named runCli() export now, and can't run at module scope because tests import its pure helpers); record-operator-action.ts needed its guard so an imported executable doesn't steal stdin.

Known follow-up, not in scope: lib/cost-report/today.ts, lib/cost-report/session.ts and lib/session-cost.ts each carry a separate cost-log accumulator loop. That triplication predates this branch, and it isn't a naive merge — the three have different predicates and different fail-open shapes, and lib/cost-log.ts is not a drop-in because it buckets by configured timezone where today deliberately uses a raw UTC slice.

gtapps added 9 commits July 25, 2026 17:03
…ermit permissions

The allow-list lived in three places — apply-settings.ts's sealed array, a copied
JSON block in hatch/SKILL.md, and a prose list in hermit-evolve/reference.md that
had already drifted to 15 of 33 entries. Nothing could remove a stale entry either:
scrubbing was hand-written prose the model executed, so a deleted script's
permission lingered in every hatched hermit forever.

apply-settings.ts gains permissions-plan (read-only diff) and permissions-sync
(apply it). Removal is filtered through a sealed HERMIT_OBSOLETE registry, so an
operator's own rules can never be caught by it. hatch and hermit-evolve now call
the verbs; both copies of the list are gone, and future script deletions reach
installed hermits by appending one registry line.

next-prop-id.ts goes with it — no production caller, since proposal.ts create
claims IDs atomically via exclusive create. Its prediction-only helpers
(nextPropId, resolveSuffix, PropIdParts) are deleted too; the slug and numbering
cases they covered move to direct lib unit tests.

A no-op sync deliberately skips the write so an upgrade never reformats a settings
file it had nothing to change.
…down terminal over it

Seven hooks fired on every submitted prompt, each re-reading stdin, re-parsing
the channel envelope and re-reading config. The operator paid all seven process
launches per message, and precedence between them was implied by their order in
hooks.json rather than stated anywhere.

user-prompt-pipeline.ts reads stdin once and runs the former hooks as stages;
six move to scripts/lib/prompt-stages/ and stop being independently invokable.
record-operator-action.ts stays a top-level executable — it is also a
SessionStart hook and takes --force — so its entry shell is now behind
import.meta.main, otherwise importing it would steal the pipeline's stdin.

Two behaviors the multi-process shape could not express:

  Shutdown is terminal. A failed shutdown send used to fall through, and an
  exact `status` could then send and block on its own, discarding the shutdown
  relay instruction the model was told to act on; pause/resume and harness
  commands could mutate session state mid-shutdown. Both were reachable by
  construction — no hook could see what another had already done.

  One disposition per prompt. Output is buffered and a block emits its JSON
  alone: mixed context text does not parse as a decision, so the block would
  be silently dropped and the prompt delivered anyway.

lib/cron-shift.ts is extracted now rather than in the heartbeat/routines commit
so the status stage never imports a file a later commit deletes, keeping the
commits revertible in any order.

Behavior note beyond the two fixes above: a payload carrying no `prompt` now
produces no output at all, where prompt-context.ts previously emitted its
`[Now: …]` line regardless. Claude Code always sends a prompt on this event, so
the path is unreachable outside tests.
…posal.ts verbs

Seven top-level scripts become seven verbs over scripts/lib/proposals/ modules:
resolve-id, gate, queue-micro, micro, index, metrics, success-signal. Top-level
scripts drop 74 -> 67.

Every stdout grammar and exit code is preserved deliberately, not incidentally.
These verbs are not forced into proposal.ts's ok()/fail() helpers, because
callers branch on what they return: success-signal --validate must still exit 1
on a bad predicate (proposal-create reads that exit code), queue-micro and micro
must still exit 1 rather than drop a candidate silently, and index stays
fail-open with SKIP|no state dir at exit 0. Three verbs are therefore carved out
of the dispatcher's !verb || !stateDir guard: metrics defaults its state dir,
success-signal --validate reads no state at all, and index answers SKIP instead
of a usage error.

The extracted module is named index-rebuild.ts rather than index.ts so that an
import of './lib/proposals' cannot silently resolve to it; both
generate-summary.ts and lib/dashboard.ts import it directly.

Permissions are verb-pinned, not widened. The two bin/hermit-run grants become
"proposal micro *" and "proposal metrics *", keeping the space-before-* word
boundary. A bare "hermit-run proposal *" would have handed domain plugins
create, patch, shell-append, next-task and routine - arbitrary state-dir writes
- where two narrow routes existed before. The five retired direct-bun grants and
the two pre-absorption hermit-run routes are appended to HERMIT_OBSOLETE so
permissions-sync scrubs them from already-hatched hermits.

Fleet-scoped by necessity. bin/hermit-run resolves a script by bare filesystem
probe with no alias table, so a rename breaks in both version directions and
fails into hermit-exec.sh's misleading "plugin may predate this command" branch.
The three domain plugins that route through it therefore ship together with
required_core_version raised to >=1.2.34 across all three canonical fields.
Verified in a scratch hatch dir: both new routes dispatch, and both old names
hit the predate branch.

Consequence worth stating: until core is released at 1.2.34, doctor-check's
dependency check warns "3 sibling(s) outside required_core_version range (core
is 1.2.33)". That is the correct trade - pinning to >=1.2.33 would silence the
warn but let a hermit on 1.2.33 pass the check and then hit the route error at
runtime. main only reaches operators on a version bump, so the warn is confined
to this pre-release window.

Two test-quality fixes fell out of the move. A contract assertion checked that
hermit-evolve Step 8 does not name two now-deleted scripts inline; those names
now refer to nothing, so it passed while guarding nothing. It is replaced with a
structural check - at most one inline bun-script grant in Step 8, being the
apply-settings bootstrap caveat - that cannot go stale on a rename. And the
flag-lookup helper duplicated between proposal.ts and the extracted micro module
is now one flagValue() in lib/cli.ts.

bun test 3179 pass / 0 fail; bunx tsc --noEmit exit 0. HA 686 tests, dev 13,
fitness 129, all green.
render-dashboard.ts, render-proposals-page.ts, render-weekly-artifact.ts and
artifact-strings-scaffold.ts become `artifact.ts render <page>` and
`artifact.ts scaffold-strings`. The three render scripts were the same twelve
lines three times: resolve an out path, render, mkdir -p, write, print the
{path,bytes,hash} receipt, exit 1 on anything thrown. Only the default out path
and the render call actually differed, so those are the only two things the
PAGES table holds; the table also supplies the usage string and the
unknown-page check, so it is not a parallel list to keep in sync.

The weekly page's frontmatter-strip moved to lib/weekly-artifact.ts, beside the
dashboard and proposals-page renderer libs it now sits with. It throws instead
of calling process.exit itself, so the shared catch owns the exit path.

Receipt shape, default out paths, and exit-1-on-failure are unchanged - the
refresh protocols in docs/artifacts.md hash-gate on that receipt, and a
publish is skipped when the hash matches. Confirmed the weekly receipt hash is
byte-identical before and after. What did change is stderr text: failures now
read "artifact render weekly: failed: ..." rather than a per-script prefix, and
a missing arg prints the two-verb usage. Both are diagnostic only; the callers
treat any non-zero exit as "skip silently".

Three scripts with a superficially similar shape are deliberately NOT absorbed.
render-docker-templates.ts writes several files from stdin JSON and documents
that it hashes nothing (manifest-seed.ts owns that); render-security-overlay.ts
has two subcommands and does live docker inspection; hatch-scaffold.ts
scaffolds a tree and receipts as created/overwritten/preserved. None has a
single output path or a hash, so folding them in would either drop fields their
callers read or force a hash onto a script that disclaims one.

Also corrects docs/config-reference.md, which still named the deleted
render-weekly-artifact.ts in its renderer list.

bun test 3179 pass / 0 fail; bunx tsc --noEmit exit 0.
…readouts

today-cost.ts, session-cost.ts and cost-reflect.ts become `cost-report.ts
today|session|reflect` over lib/cost-report/{today,session,reflect}.ts.

All three stay fail-open readers, which is the property that matters: an
unreadable log still prints "cost data unavailable" rather than a misleading
$0.00, session still prints {"cost_usd":0,"tokens":0}, and reflect still turns
a throw into a one-line error at exit 0. Their callers are morning briefs and
channel replies that have to compose even when there is no cost data.

Two threading points were the risk in this move and both were checked against
the pre-move files: session's --opened-at/--closed-at overrides were module
scope closure variables and are now explicit parameters to readWindow(), with
the same ?? precedence; and reflect's argv indices still line up, since the
dispatcher passes argv.slice(3) where the old script read argv.slice(2) and the
verb now occupies the slot that shifted.

Fixes a cost the consolidation itself introduced: session.ts and today.ts each
resolved hermitDir() at MODULE scope, and cost-report.ts imports every verb
module unconditionally, so hermitDir()'s fs.existsSync walk (up to 8 levels)
ran on every invocation regardless of verb. Both now resolve inside run(),
matching what reflect.ts already did.

Not addressed here, and worth its own change: today, session and
lib/session-cost.ts each carry a separate "read cost-log.jsonl, JSON.parse each
line, accumulate" loop. That triplication predates this move. It is not a naive
merge either - the three have different predicates (today-only, exact-match,
window-range) and different fail-open shapes - and lib/cost-log.ts is not a
drop-in, because it buckets by configured timezone where today deliberately
uses a raw UTC slice.

bun test 3179 pass / 0 fail; bunx tsc --noEmit exit 0.
…mint.ts

setup-token-probe.ts becomes `setup-token-mint.ts probe`, and hermit-meta.json's
expiry_probe command moves with it. The doctor protocol is unchanged: exactly
one line of stdout, OK | EXPIRED | EXPIRES:<iso8601>, exit 0 on every path
including the catch, because a probe failure must never read as a credential
problem. Verified by hand across all four paths (no record, valid record,
unreadable record, unknown verb) and through doctor's own arg-free bash -c
invocation.

The verb takes the hermit dir positionally so the declared probe command can
stay argument-free while tests point it at a fixture. Its no-arg fallback is the
module-level HERMIT_DIR const rather than the old script's inline literal; that
const is process.env.HERMIT_DIR || '.claude-code-hermit', and HERMIT_DIR is set
nowhere in production paths, so the two resolve identically today. Every other
verb in the file already defaults through it.

One new cost on the probe path, small and read-only: setup-token-mint.ts reads
config.json at module scope (MINT_CONFIG), which the standalone probe never did.
Measured 50-run batches show no regression regardless - ~38ms/run vs the old
script's ~41ms, both dominated by bun startup and both ~1% of doctor's 5s probe
timeout. Nothing else runs at import: every mutating operation (mint start,
marker write, capture file, tmux teardown) sits inside functions unreachable
from this verb.

This is an authority change worth naming: the probe had no permission entry of
its own and was prompted for on every run; it is now covered by the existing
setup-token-mint.ts grant, so doctor's credential-expiry check runs unattended.

bun test 3179 pass / 0 fail; bunx tsc --noEmit exit 0.
…odules

The last seven scripts of the consolidation become two CLIs:
  heartbeat.ts precheck|alert-state          over lib/heartbeat/
  routines.ts due|precheck|cron-registry|tz-shift|log-event   over lib/routines/

Every verdict token and emission line is unchanged, which is the property that
matters here: the monitors grep them and record-operator-action.ts's
isRoutinePrompt() matches them. That covers SKIP|... / EVALUATE / AUTO_CLOSE /
ALERT, PROCEED, and the ROUTINE_DUE [hermit-routine:<id>] line.

Dispatch is a dynamic import per verb, with the verb spliced out of process.argv
first so each module keeps the argv indices it used standing alone. That is
deliberate: ~1200 lines of scheduling, alert derivation and registry
reconciliation stay byte-for-byte rather than being rethreaded through a
parameter, and the poll verbs don't load the graphs they never touch. Measured
against the old standalone precheck over 40 alternating runs in a controlled
tree: -1.0%, inside one standard deviation. No per-poll cost.

log-routine-event.sh is now TypeScript, the last shell script on the routine
path. The appended line is byte-identical - same key order, same
second-precision UTC stamp - and the fired-after-fired dedup guard is preserved.
due and precheck call it in-process instead of spawning a subprocess per stamp
(3.43ms -> 0.04ms measured). The event name is still unvalidated, matching bash;
rejecting names it used to record would be a behavior change.

Two things this shape forced, both bugs if missed:
  - registry.ts's CLI sat behind `import.meta.main`, which is FALSE under a
    dynamic import, so it would have silently never run. It is a named runCli()
    export now. It cannot run at module scope either, because
    cron-registry.test.ts imports the module for its pure helpers.
  - due.ts and precheck.ts still resolved the deleted shell script by path.

The env-override contract is unchanged, and deliberately so: HEARTBEAT_PRECHECK
and ROUTINE_DUE_SCRIPT still take a BARE script path invoked with the same
arguments. The monitors build the command as a bash array and prepend the verb
only on the default branch, so no test stub needed changing and the documented
contract stays literally true.

One knowing deviation from the bash original: its walk-up loop skipped the
filesystem root, so a hermit at exactly /.claude-code-hermit was never found.
The port checks it. Reproducing that limitation would mean adding a bug on an
unreachable path.

Top-level scripts 80 -> 57 across the branch; shell entrypoints 5 -> 4.
bun test 3179 pass / 0 fail; bunx tsc --noEmit exit 0.
… names

Comment-only sweep. The seven consolidation commits renamed 23 scripts; these
are the cross-references left pointing at filenames that no longer exist -
monitor shell headers, lib/ comments explaining who calls what, and test-file
headers naming the script under test. A reader following any of them found
nothing.

Two references are deliberately left naming the old spelling, and both are
load-bearing: lib/trigger-source.ts matches `log-routine-event.sh <id>` as well
as the new form, because cost attribution replays historic transcripts; and the
cost-tracker/subagent-cost test fixtures embed that old string on purpose for
the same reason.

No behavior change. bun test 3179 pass / 0 fail; bunx tsc --noEmit exit 0.
…er consolidation

The verb-suffixed strings in hermit-routines' pluginRoot guard aren't paths, so
`test -f` failed on every one and `load` aborted unconditionally — no hermit
would have registered the routine monitor or the heartbeat-restart anchor again.
The shared execution template also invoked routines.ts without `bun`, which is
Permission denied now that log-routine-event.sh's shebang and exec bit are gone,
losing every `fired` event that cost attribution and the dedup guard read. The
anchor's mirror commit still pointed at the deleted cron-registry.ts.

user-prompt-pipeline's new 1 MiB stdin cap cuts mid-JSON, and the null-prompt
return then skipped every stage — an over-cap paste got no timestamp and no
recorded turn, so heartbeat's AUTO_CLOSE gate could read the operator as silent.
It now falls through with an empty prompt; the stages that match on prompt text
fail closed on it.
@gtapps
gtapps merged commit 755f630 into main Jul 25, 2026
8 checks passed
@gtapps
gtapps deleted the chore/script-consolidation-phase-1 branch July 29, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant