refactor(claude-code-hermit): consolidate 23 scripts into verb CLIs - #662
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Collapses the shallow-adapter surface in
plugins/claude-code-hermit/scripts/: 23 top-level scripts become verbs on 7 CLIs, backed by modules underscripts/lib/. Top-level scripts drop 80 → 57, and shell entrypoints 5 → 4.The one operator-visible win is the prompt path: seven
UserPromptSubmithooks 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 path —
user-prompt-pipeline.tsreplaces 7 hooks. Reads stdin once, parses the channel envelope once, readsconfig.json/runtime.jsonat most once each. Stage precedence is explicit in code rather than implied byhooks.jsonordering, 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:statusmessage send and block on its own — discarding the shutdown relay the model was supposed to act on.pause/resumeand channel harness commands no longer mutate session state mid-shutdown.Permissions become script-owned —
apply-settings.tsgainspermissions-plan/permissions-syncover a sealedHERMIT_ALLOWplus a new sealedHERMIT_OBSOLETEregistry.hatchandhermit-evolvecall 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 consolidations —
proposal.ts(+7 verbs),artifact.ts,cost-report.ts,setup-token-mint.ts probe,heartbeat.ts,routines.ts.log-routine-event.shwas ported to TypeScript, removing the last shell script on the routine path;routines.ts due/prechecknow log in-process instead of spawning a subprocess per stamp.Fleet-scoped, and why —
bin/hermit-runresolves 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 withrequired_core_versionraised to>=1.2.34across all three canonical fields. Their two grants are verb-pinned (hermit-run proposal micro *,… proposal metrics *) — a barehermit-run proposal *would also exposecreate,patch,shell-append,next-taskandroutine, i.e. arbitrary state-dir writes.Authority changes worth naming —
proposal-metrics-report.ts,eval-success-signal.tsand 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 test→ 3179 pass / 0 fail (115 files)bunx tsc --noEmit→ exit 0bun test686 tests / 0 fail · devbash tests/run-all.sh13 passed · fitnessbash tests/run-all.sh129 passedpermissions-sync→ re-plan is0/0(idempotent); inject 3 retired grants + 1 operator custom rule → sync removes exactly the 3, custom rule preserved.hermit-runroute check from a hatched scratch dir:proposal micro … brief-cycleandproposal metrics … --source=…both dispatch at exit 0; the pre-rename namesmicro-proposalandproposal-metrics-reportboth hit the "may predate this command" branch — the failurerequired_core_versionexists to prevent operators meeting.success-signal --validateon a bad predicate → exit 1;queue-micro/microon malformed input → exit 1;indexwith no state dir →SKIP|no state dirat exit 0).{"ts":"…Z","routine_id":"…","event":"…","delivery":"…"}— same key order, second-precision UTC), and thefired-after-fireddedup guard still collapses two consecutive fires to one.HEARTBEAT_PRECHECK/ROUTINE_DUE_SCRIPToverridden.Reviewer notes
doctor-checkwarns until core is released. Verified, not assumed:This is the intended trade. Pinning the siblings to
>=1.2.33would silence the warn but let a hermit on 1.2.33 pass the check and then hit the route error at runtime. Sincemainonly reaches operators on aversionbump, the warn is confined to the pre-release window and clears when core ships.Env-override contract deliberately unchanged.
HEARTBEAT_PRECHECK/ROUTINE_DUE_SCRIPTstill 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.mainhazards the dispatchers surfaced, both fixed:registry.ts's CLI would have silently never run under a dynamic import (it's a namedrunCli()export now, and can't run at module scope because tests import its pure helpers);record-operator-action.tsneeded 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.tsandlib/session-cost.tseach 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, andlib/cost-log.tsis not a drop-in because it buckets by configured timezone wheretodaydeliberately uses a raw UTC slice.