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
8 changes: 3 additions & 5 deletions .agents/skills/stale-proposals/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,13 @@ Skip this step in report-only mode.
For each `SHIPPED-STRONG` verdict, resolve the proposal ID to a filename. The filename lookup happens before mutation; an ambiguous or missing ID moves into Step 4.

```bash
bun plugins/claude-code-hermit/scripts/resolve-prop.ts .claude-code-hermit "<PROP-ID>"
bun plugins/claude-code-hermit/scripts/proposal.ts resolve-id .claude-code-hermit "<PROP-ID>"
```

For an unambiguous filename, append the metrics event before patching so regenerated summaries include it:

```bash
bun plugins/claude-code-hermit/scripts/append-metrics.ts \
.claude-code-hermit/state/proposal-metrics.jsonl \
'{"ts":"<now ISO>","type":"resolved","proposal_id":"<PROP-ID>"}'
bun plugins/claude-code-hermit/scripts/proposal.ts event .claude-code-hermit resolved --id="<PROP-ID>"

bun plugins/claude-code-hermit/scripts/proposal.ts patch .claude-code-hermit <filename> \
--set status=resolved --set resolved_date=@now --request-compact <<'HERMIT_PATCH'
Expand All @@ -84,7 +82,7 @@ HERMIT_PATCH

The evidence in the Decision line makes the automatic resolution auditable.

- `resolve-prop.ts` returning `AMBIGUOUS` or `NONE`: do not guess; move the item to Step 4.
- `proposal.ts resolve-id` returning `AMBIGUOUS` or `NONE`: do not guess; move the item to Step 4.
- `proposal.ts` returning `ERROR|<reason>`: nothing was patched; report the failure and continue.

## Step 4 — Ask about weak and aged proposals
Expand Down
4 changes: 1 addition & 3 deletions .claude/skills/stale-proposals/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,7 @@ For each `SHIPPED-STRONG` verdict, resolve the ID to a filename, then close it t
```bash
bun plugins/claude-code-hermit/scripts/proposal.ts resolve-id .claude-code-hermit "<PROP-ID>"

bun plugins/claude-code-hermit/scripts/append-metrics.ts \
.claude-code-hermit/state/proposal-metrics.jsonl \
'{"ts":"<now ISO>","type":"resolved","proposal_id":"<PROP-ID>"}'
bun plugins/claude-code-hermit/scripts/proposal.ts event .claude-code-hermit resolved --id="<PROP-ID>"

bun plugins/claude-code-hermit/scripts/proposal.ts patch .claude-code-hermit <filename> \
--set status=resolved --set resolved_date=@now --request-compact <<'HERMIT_PATCH'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ for (const { name, gates } of SKILLS) {
// Every ${CLAUDE_PLUGIN_ROOT}/scripts/<file> reference must resolve to a script
// this plugin actually ships. Installed plugins cannot reach outside their own
// root, so a reference to a sibling plugin's script (e.g. core's
// append-metrics.ts) fails silently at runtime — see #648.
// observations.ts) fails silently at runtime — see #648.
// Walks the skills dir rather than the SKILLS list above: that list covers only
// the gate-shaped skills, while hatch/dev-test/dev-quality also carry refs.
console.log('\nscript references:');
Expand Down
10 changes: 9 additions & 1 deletion plugins/claude-code-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
- Three `Bash(.claude-code-hermit/bin/hermit-run domain-hatch <verb> *)` 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.
- `observations.ts observe <dir> <source>` — the typed, stdin-only writer for `observations.jsonl`. Sources are an enum, `ts` is stamped by the script, and `session_id` is resolved from `runtime.json`, so no caller hand-assembles a row. Only the three model-authored sources are invocable; `cost-spike`, `behavior-digest` and `startup-drift` are rejected because the scripts that compute them own them.
- `proposal.ts event <dir> responded|resolved --id=` — the same treatment for the two proposal-metrics rows skill prose used to write as JSON. Named `event`, not `metric`, so it cannot be confused with the existing `metrics` reader one character away.
- `transcript-digest.ts --record-observation` — opt-in flag that lets the digest write its own `behavior-digest` row. Without it the digest stays a pure read, so an ad-hoc run leaves nothing behind.

### Fixed
- Cost-spike and defer-loop observations are now written by the scripts that detect them, instead of being recomputed and formatted in skill prose. Both rows had been unwritable in practice: across the live fleet `observations.jsonl` contained only script-written `startup-drift` rows, and not one prose-authored `cost-spike` or `behavior-digest` row had ever been recorded.
- The cost-spike label is date-scoped (`cost-spike:<YYYY-MM-DD>`) with the figures carried as `today_total`/`median_7d` fields. The old label embedded the running total, which climbs through the day, so it would have defeated the ledger's exact-pattern dedup and written a fresh row on every precheck run of a spike day.
- The post-implementation cleanup gate gave different answers depending on which path ran it. The rubric was prose in two places and the dispatched-subagent copy had no session-bookkeeping filter, so an implementation whose only diff was `sessions/SHELL.md` ran `/simplify` (~$0.25) on the dispatched path and skipped on the in-main one. Both now call one verb, so they cannot disagree.
- A pending shutdown is now terminal for the whole prompt path. When a shutdown send failed, the prompt used to fall through and an exact `status` message could still send and block on its own — discarding the shutdown relay instruction the model was supposed to act on. `pause`/`resume`/`snooze` and channel harness commands could likewise mutate session state mid-shutdown and get only a shutdown reply. Both were possible by construction in the seven-process shape, where no hook could see what another had done; the pipeline now answers the shutdown and stops.
- A `/clear` reaching the pane outside the watchdog no longer skips the hermit's own reset bookkeeping. The runtime stamp, `SHELL.md` breadcrumb, and status-cache clear moved to `lib/context-reset.ts`; skipping the cache clear previously let the watchdog fire a spurious `/compact` against a freshly-cleared context.
Expand Down Expand Up @@ -65,10 +70,13 @@
- Three top-level cost scripts, absorbed into `cost-report.ts` verbs: `today-cost.ts`, `session-cost.ts`, `cost-reflect.ts`. (`lib/session-cost.ts`, the shared window-cost algorithm, is unrelated and stays.)
- `setup-token-probe.ts`, absorbed into `setup-token-mint.ts probe`.
- Seven top-level heartbeat/routine scripts, absorbed into `heartbeat.ts` and `routines.ts` verbs: `heartbeat-precheck.ts`, `update-alert-state.ts`, `routine-due.ts`, `routine-precheck.ts`, `cron-registry.ts`, `cron-tz-shift.ts`, `log-routine-event.sh`.
- `append-metrics.ts`, whose `<any-path> <any-json>` signature was an unattended grant to write arbitrary JSON to an arbitrary path. Replaced by `observations.ts observe` and `proposal.ts event`.
- `metrics_event` from the reflect eval-runner's `resolution_actions` schema. It was a model-authored JSON string appended after only a `JSON.parse` check — the last unschema'd write into `proposal-metrics.jsonl` — and always derivable from `proposal_id`, which the apply script already has. A stale runner that still sends one is now rejected rather than silently trusted.
- The argv free-text lint over skill prose. It policed a hazard that only existed because `append-metrics.ts` had an argv mode; `observations.ts` takes the label on stdin and nothing else.

### Upgrade Instructions
1. The CLAUDE-APPEND change reaches installed hermits through the standard `<!-- claude-code-hermit: Session Discipline -->` marker-block resync `hermit-evolve` already performs. Skills and scripts ship in the plugin, so no per-operator migration is needed for those.
2. Run `bun <plugin_root>/scripts/apply-settings.ts <settings-file> permissions-sync` against the install's settings file (`.claude/settings.local.json` for a `local` hatch target, `.claude/settings.json` for `committed`). This is now the one step that reconciles permissions: it adds every sealed entry the install is missing — including `channel-send.ts`, new this version — and scrubs entries from retired plugin versions: `next-prop-id.ts`, the five proposal satellites absorbed into `proposal.ts` verbs, and the two pre-absorption `hermit-run` routes. It replaces the per-entry "re-run hatch so X lands in the allow-list" instructions.
2. Run `bun <plugin_root>/scripts/apply-settings.ts <settings-file> permissions-sync` against the install's settings file (`.claude/settings.local.json` for a `local` hatch target, `.claude/settings.json` for `committed`). This is now the one step that reconciles permissions: it adds every sealed entry the install is missing — including `channel-send.ts`, new this version — and scrubs entries from retired plugin versions: `next-prop-id.ts`, `append-metrics.ts` (deleted this version; replaced by the narrower `observations.ts observe *` grant), the five proposal satellites absorbed into `proposal.ts` verbs, and the two pre-absorption `hermit-run` routes. It replaces the per-entry "re-run hatch so X lands in the allow-list" instructions.
3. If `claude-code-dev-hermit` is installed and its CLAUDE.md/CLAUDE.local.md contains the literal string `<!-- mode:standard-only -->`, a prior version of `hermit-evolve` hit the marker-discovery bug above and appended the raw, un-rendered dev-hermit template. Remove the entire stray block (from the first `<!-- mode:standard-only -->` or `<!-- mode:safety-only -->` line it introduced through the end of that appended region) — the correctly-rendered `<!-- claude-code-dev-hermit: Development Workflow -->` block elsewhere in the file is unaffected and should be left in place.
4. If an operator customized their CLAUDE-APPEND block in place (core, `hermit-scribe`, or `claude-code-dev-hermit`), note that the next version-gap sync for that plugin will replace it with the shipped template now that closing markers make the block bounds authoritative — re-apply any customization after that sync.

Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code-hermit/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ One writer per state file. No shared mutation bus. (Exception: `state/micro-prop
| `state/channel-replies.jsonl` | channel-hook.ts (append only) | reflect (routine-ROI engagement join) |
| `state/channel-log.sqlite` | channel-reply-reminder stage + channel-hook.ts (append, via `lib/channel-log.ts`); weekly-review marks/prunes | search.ts (recall, fourth source); weekly-review consolidation |
| `state/session-diff.json` | session-diff.ts only | session-close (display) |
| `state/observations.jsonl` | reflect + reflect-precheck + session-close + channel-responder (append only; `source` values: `cost-spike`, `quick-deferral`, `reflect-noticed`, `startup-drift`, `skill-correction`) | reflect (step 3b graduation), reflection-judge (§1.4 ledger verification) |
| `state/observations.jsonl` | reflect-precheck (`cost-spike`, `startup-drift`) + transcript-digest `--record-observation` (`behavior-digest`) + session-close and channel-responder via `observations.ts` (`skill-correction`) + reflect (`quick-deferral`, `reflect-noticed`); append only | reflect (step 3b graduation), reflection-judge (§1.4 ledger verification) |
| `state/proposal-metrics.jsonl` | proposal-create + proposal-act (append only) | generate-summary.ts, proposal.ts metrics (read-only) |
| `state/usage-metrics.jsonl` | usage-track.ts (Skill/Read PostToolUse) + record-operator-action.ts (operator-typed `/skill`, append only; compacted >180d by weekly-review) | weekly-review (untouched-knowledge/dormant-skill suggestions) |
| `state/micro-proposals.json` | reflect + channel-bridged skills (queue, schema owned by reflect § Queuing procedure) + channel-responder/brief (resolve) | brief, generate-summary.ts |
Expand Down
42 changes: 0 additions & 42 deletions plugins/claude-code-hermit/scripts/append-metrics.ts

This file was deleted.

29 changes: 14 additions & 15 deletions plugins/claude-code-hermit/scripts/apply-reflection-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
//
// Usage: bun apply-reflection-actions.ts <hermit-state-dir> (stdin: JSON)
// Stdin: {"resolution_actions":[{proposal_id, action, frontmatter_patch,
// metrics_event, shell_findings_line}, ...]}
// shell_findings_line}, ...]} — a non-null `metrics_event` is rejected;
// the `resolved` row is derived here from proposal_id.
// Output: one JSON line — {"ok":true,"applied":{...}} (+ "errors" when any
// post-validation write failed) or {"ok":false,"reason":...} with zero writes.
// Exit 0 always (lib/heartbeat/alert-update.ts pattern); only missing argv exits 1.
Expand All @@ -17,7 +18,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { listProposalFiles, readFileWithFrontmatter } from './lib/frontmatter';
import { appendJsonlLine } from './lib/append-jsonl';
import { appendEvent, resolvedEvent } from './lib/proposals/event';
import { writeFileAtomic, patchFrontmatter, appendShellLine, PATCH_KEY_RE } from './lib/md-write';

type Json = any;
Expand All @@ -44,13 +45,13 @@ function apply(stateDir: string, stdin: string): Json {
return { ok: false, reason: `${label}: proposal_id must match PROP-<digits>` };
}
if (!ACTIONS.has(a.action)) return { ok: false, reason: `${label}: unknown action "${a.action}"` };
// `metrics_event` was a model-authored JSON string appended verbatim after only a
// JSON.parse check — the last unschema'd write into proposal-metrics.jsonl. It is
// gone: the row is always a `resolved` for a proposal_id this function already has,
// so it is now built from that id below. Reject it if a stale runner still sends one,
// rather than ignoring it and leaving the caller believing it was recorded.
if (a.metrics_event != null) {
if (typeof a.metrics_event !== 'string') return { ok: false, reason: `${label}: metrics_event must be a JSON string` };
// Re-serialize rather than just validating: the ledger is line-delimited,
// and pretty-printed model output would otherwise be appended verbatim as
// several physical lines that every JSONL reader silently drops.
try { a.metrics_event = JSON.stringify(JSON.parse(a.metrics_event)); }
catch { return { ok: false, reason: `${label}: metrics_event is not valid JSON` }; }
return { ok: false, reason: `${label}: metrics_event is no longer accepted — the resolved row is derived from proposal_id` };
}
if (a.shell_findings_line != null && typeof a.shell_findings_line !== 'string') {
return { ok: false, reason: `${label}: shell_findings_line must be a string or null` };
Expand Down Expand Up @@ -92,13 +93,11 @@ function apply(stateDir: string, stdin: string): Json {
errors.push(`${a.proposal_id}: frontmatter patch failed: ${e.message}`);
continue; // don't record metrics/findings for a proposal that wasn't patched
}
if (a.metrics_event) {
try {
const err = appendJsonlLine(path.join(stateDir, 'state', 'proposal-metrics.jsonl'), a.metrics_event);
if (err) errors.push(`${a.proposal_id}: metrics append: ${err}`);
} catch (e: any) {
errors.push(`${a.proposal_id}: metrics append failed: ${e.message}`);
}
try {
const err = appendEvent(stateDir, resolvedEvent(a.proposal_id));
if (err) errors.push(`${a.proposal_id}: metrics append: ${err}`);
} catch (e: any) {
errors.push(`${a.proposal_id}: metrics append failed: ${e.message}`);
}
applied.auto_resolve++;
} else {
Expand Down
9 changes: 8 additions & 1 deletion plugins/claude-code-hermit/scripts/apply-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const HERMIT_ALLOW = [
'Bash(bun */scripts/reflect-precheck.ts*)',
'Bash(bun */scripts/archive-shell.ts*)',
'Bash(bun */scripts/evaluate-session.ts*)',
'Bash(bun */scripts/append-metrics.ts*)',
// Verb-scoped: `observe` is the only thing this script does, and pinning it keeps
// the grant from widening if it ever grows a second verb. Replaces the old
// append-metrics.ts entry, which granted "write any JSON to any path".
'Bash(bun */scripts/observations.ts observe *)',
'Bash(bun */scripts/proposal.ts*)',
'Bash(bun */scripts/generate-summary.ts*)',
'Bash(bun */scripts/update-reflection-state.ts*)',
Expand Down Expand Up @@ -102,6 +105,10 @@ const HERMIT_OBSOLETE = [
'Bash(bun */scripts/run-with-profile.ts*)',
'Bash(bun */scripts/suggest-compact.ts*)',
'Bash(bun */scripts/next-prop-id.ts*)',
// Retired with the script: an arbitrary-path, arbitrary-JSON writer is a wider
// grant than any caller needed. Replaced by `observations.ts observe *` above and
// by proposal.ts's existing grant, which now covers the `event` verb.
'Bash(bun */scripts/append-metrics.ts*)',
// Proposal satellites absorbed into proposal.ts verbs — the scripts are gone,
// so these grants now name nothing.
'Bash(bun */scripts/resolve-prop.ts*)',
Expand Down
10 changes: 5 additions & 5 deletions plugins/claude-code-hermit/scripts/lib/append-jsonl.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// Shared validated-append primitive for JSONL event ledgers (proposal-metrics.jsonl,
// observations.jsonl, micro-proposals.json's metrics companion, etc). Extracted from
// append-metrics.ts so proposal.ts's gate and queue-micro verbs can append
// pre-built events through the same validate-then-append contract instead of
// re-implementing it.
// observations.jsonl, micro-proposals.json's metrics companion, etc). Every writer
// goes through here so the validate-then-append contract is defined once: the typed
// constructors in lib/observations.ts and lib/proposals/event.ts, and the gate and
// queue-micro verbs that build their own events.

import fs from 'node:fs';

/**
* Validates `eventJson` is non-empty parseable JSON, then appends it (+ newline)
* to `filePath`. Returns null on success, or an error message on failure (no write).
* Error strings match append-metrics.ts's original CLI-facing messages verbatim.
* Error strings are surfaced verbatim as the `<reason>` in a caller's `ERROR|<reason>`.
*/
function appendJsonlLine(filePath: string, eventJson: string): string | null {
if (!eventJson) return 'Error: event payload is empty';
Expand Down
Loading
Loading