diff --git a/.agents/skills/stale-proposals/SKILL.md b/.agents/skills/stale-proposals/SKILL.md index 9f3679ff..6a8d382d 100644 --- a/.agents/skills/stale-proposals/SKILL.md +++ b/.agents/skills/stale-proposals/SKILL.md @@ -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 "" +bun plugins/claude-code-hermit/scripts/proposal.ts resolve-id .claude-code-hermit "" ``` 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":"","type":"resolved","proposal_id":""}' +bun plugins/claude-code-hermit/scripts/proposal.ts event .claude-code-hermit resolved --id="" bun plugins/claude-code-hermit/scripts/proposal.ts patch .claude-code-hermit \ --set status=resolved --set resolved_date=@now --request-compact <<'HERMIT_PATCH' @@ -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|`: nothing was patched; report the failure and continue. ## Step 4 — Ask about weak and aged proposals diff --git a/.claude/skills/stale-proposals/SKILL.md b/.claude/skills/stale-proposals/SKILL.md index aa32bc79..062709da 100644 --- a/.claude/skills/stale-proposals/SKILL.md +++ b/.claude/skills/stale-proposals/SKILL.md @@ -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 "" -bun plugins/claude-code-hermit/scripts/append-metrics.ts \ - .claude-code-hermit/state/proposal-metrics.jsonl \ - '{"ts":"","type":"resolved","proposal_id":""}' +bun plugins/claude-code-hermit/scripts/proposal.ts event .claude-code-hermit resolved --id="" bun plugins/claude-code-hermit/scripts/proposal.ts patch .claude-code-hermit \ --set status=resolved --set resolved_date=@now --request-compact <<'HERMIT_PATCH' diff --git a/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts b/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts index f2c78a38..30bae295 100644 --- a/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts +++ b/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts @@ -75,7 +75,7 @@ for (const { name, gates } of SKILLS) { // Every ${CLAUDE_PLUGIN_ROOT}/scripts/ 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:'); diff --git a/plugins/claude-code-hermit/CHANGELOG.md b/plugins/claude-code-hermit/CHANGELOG.md index 133c90c9..ea4897e9 100644 --- a/plugins/claude-code-hermit/CHANGELOG.md +++ b/plugins/claude-code-hermit/CHANGELOG.md @@ -16,8 +16,13 @@ - 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. +- `observations.ts observe ` — 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 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:`) 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. @@ -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 ` ` 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 `` 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 /scripts/apply-settings.ts 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 /scripts/apply-settings.ts 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 ``, 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 `` or `` line it introduced through the end of that appended region) — the correctly-rendered `` 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. diff --git a/plugins/claude-code-hermit/docs/architecture.md b/plugins/claude-code-hermit/docs/architecture.md index a41bdfb7..31b2c7f3 100644 --- a/plugins/claude-code-hermit/docs/architecture.md +++ b/plugins/claude-code-hermit/docs/architecture.md @@ -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 | diff --git a/plugins/claude-code-hermit/scripts/append-metrics.ts b/plugins/claude-code-hermit/scripts/append-metrics.ts deleted file mode 100644 index a8af6a4d..00000000 --- a/plugins/claude-code-hermit/scripts/append-metrics.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Append-only JSONL helper — appends one line and exits. -// Zero npm dependencies, Node stdlib only. -// -// Usage (argv): bun append-metrics.ts '' -// — argv[3] is reserved for enum/id/count/slug/numeric values only. -// Apostrophes cannot appear in those payloads, so single-quoting is safe. -// -// Usage (stdin): bun append-metrics.ts <<'HERMIT_METRICS_JSON' -// -// HERMIT_METRICS_JSON -// — required for free-text payloads (question, pattern labels, prose values) -// where apostrophes in single-quoted argv would corrupt the shell command. - -import { appendJsonlLine } from './lib/append-jsonl'; - -const filePath = process.argv[2]; - -if (!filePath) { - console.error("Usage: bun append-metrics.ts ''"); - process.exit(1); -} - -function append(eventJson: string): void { - const err = appendJsonlLine(filePath!, eventJson); - if (err) { - console.error(err); - process.exit(1); - } -} - -if (process.argv[3] !== undefined) { - // Argv mode — synchronous, for enum/id/count/slug/numeric payloads. - append(process.argv[3]); -} else { - // Stdin mode — for free-text payloads that may contain apostrophes. - // Deliver via quoted heredoc: <<'HERMIT_METRICS_JSON' ... HERMIT_METRICS_JSON - let buf = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { buf += chunk; }); - process.stdin.on('error', () => {}); - process.stdin.on('end', () => { append(buf.trim()); }); -} diff --git a/plugins/claude-code-hermit/scripts/apply-reflection-actions.ts b/plugins/claude-code-hermit/scripts/apply-reflection-actions.ts index 535a88ba..d8e20b0b 100644 --- a/plugins/claude-code-hermit/scripts/apply-reflection-actions.ts +++ b/plugins/claude-code-hermit/scripts/apply-reflection-actions.ts @@ -5,7 +5,8 @@ // // Usage: bun apply-reflection-actions.ts (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. @@ -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; @@ -44,13 +45,13 @@ function apply(stateDir: string, stdin: string): Json { return { ok: false, reason: `${label}: proposal_id must match PROP-` }; } 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` }; @@ -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 { diff --git a/plugins/claude-code-hermit/scripts/apply-settings.ts b/plugins/claude-code-hermit/scripts/apply-settings.ts index 3d284fb7..d345a3a2 100644 --- a/plugins/claude-code-hermit/scripts/apply-settings.ts +++ b/plugins/claude-code-hermit/scripts/apply-settings.ts @@ -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*)', @@ -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*)', diff --git a/plugins/claude-code-hermit/scripts/lib/append-jsonl.ts b/plugins/claude-code-hermit/scripts/lib/append-jsonl.ts index 03820809..24a484da 100644 --- a/plugins/claude-code-hermit/scripts/lib/append-jsonl.ts +++ b/plugins/claude-code-hermit/scripts/lib/append-jsonl.ts @@ -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 `` in a caller's `ERROR|`. */ function appendJsonlLine(filePath: string, eventJson: string): string | null { if (!eventJson) return 'Error: event payload is empty'; diff --git a/plugins/claude-code-hermit/scripts/lib/cli.ts b/plugins/claude-code-hermit/scripts/lib/cli.ts index 9c6b4ddf..6a8fc01e 100644 --- a/plugins/claude-code-hermit/scripts/lib/cli.ts +++ b/plugins/claude-code-hermit/scripts/lib/cli.ts @@ -33,4 +33,12 @@ function flagValue(argv: string[], flag: string): string | undefined { return i === -1 ? undefined : argv[i + 1]; } -export { emit, readStdin, readJson, flagValue }; +// `--flag=value` (equals form), as distinct from flagValue's `--flag value`. +// Both forms are in use: the equals form reads better in skill prose, where the +// call is written by hand. +function flagEq(argv: string[], name: string): string | undefined { + const hit = argv.find(a => a.startsWith(`--${name}=`)); + return hit === undefined ? undefined : hit.slice(name.length + 3); +} + +export { emit, readStdin, readJson, flagValue, flagEq }; diff --git a/plugins/claude-code-hermit/scripts/lib/observations.ts b/plugins/claude-code-hermit/scripts/lib/observations.ts new file mode 100644 index 00000000..37a48e39 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/lib/observations.ts @@ -0,0 +1,111 @@ +// Source-owned constructors for `state/observations.jsonl`. +// +// Every row is { ts, pattern, session_id, source } plus source-specific extras. +// `ts` is stamped here and `session_id` resolved here, so nothing — script or +// skill prose — hand-assembles either. Sources split two ways: +// +// CLI_SOURCES model-authored labels, written through observations.ts +// DETERMINISTIC_SOURCES computed facts, written by the script that computes them +// +// The split is enforced rather than documented: observations.ts rejects a +// deterministic source, so a computed row cannot be forged from prose, and the +// script that owns a fact is the only thing that can record it. + +import path from 'node:path'; +import { appendJsonlLine } from './append-jsonl'; +import { readJson } from './cli'; +import { utcISOStamp } from './time'; + +type Origin = 'own-work' | 'external-content'; + +const CLI_SOURCES = ['quick-deferral', 'reflect-noticed', 'skill-correction'] as const; +const DETERMINISTIC_SOURCES = ['cost-spike', 'behavior-digest', 'startup-drift'] as const; + +type CliSource = (typeof CLI_SOURCES)[number]; +type Source = CliSource | (typeof DETERMINISTIC_SOURCES)[number]; + +// Sources whose rows carry an `origin`. The rest omit the key entirely — readers +// treat a missing origin as own-work (skills/reflect/SKILL.md § observations). +const ORIGIN_SOURCES = new Set(['reflect-noticed', 'skill-correction', 'startup-drift']); + +const ORIGINS: string[] = ['own-work', 'external-content']; + +// `pattern` is the grouping key reflect matches on by exact string equality, so a +// runaway label would never group with anything and would bloat every read of the +// ledger. Bounded at the boundary where model text enters. +const MAX_PATTERN = 200; + +function observationsPath(stateDir: string): string { + return path.join(stateDir, 'state', 'observations.jsonl'); +} + +// runtime.json is optional and carries a null session_id between sessions — +// 'unknown' is what reflect-precheck has always written in that case. +function resolveSessionId(stateDir: string): string { + const id = readJson(path.join(stateDir, 'state', 'runtime.json'))?.session_id; + return typeof id === 'string' && id ? id : 'unknown'; +} + +type RowInput = { + source: Source; + pattern: string; + sessionId: string; + origin?: Origin; + extra?: Record; +}; + +// Returns the row or an error token. Field insertion order matches the rows this +// ledger has always carried — JSON.stringify preserves it, so historical and new +// rows are indistinguishable to every reader. +function observationRow(input: RowInput): { row: Record } | { error: string } { + const pattern = input.pattern.trim(); + if (!pattern) return { error: 'empty-pattern' }; + if (pattern.includes('\n')) return { error: 'multiline-pattern' }; + if (pattern.length > MAX_PATTERN) return { error: `pattern-too-long:${pattern.length}` }; + + if (input.origin !== undefined) { + if (!ORIGIN_SOURCES.has(input.source)) return { error: `origin-not-allowed:${input.source}` }; + if (!ORIGINS.includes(input.origin)) return { error: `invalid-origin:${input.origin}` }; + } + + const row: Record = { + ts: utcISOStamp(), + pattern, + session_id: input.sessionId, + source: input.source, + }; + if (input.origin !== undefined) row.origin = input.origin; + // `extra` may only widen a row, never rewrite its identity — an extra key named + // `source` or `session_id` would otherwise silently forge the very fields this + // module exists to own. + for (const [k, v] of Object.entries(input.extra ?? {})) { + if (k in row) return { error: `reserved-extra-key:${k}` }; + row[k] = v; + } + return { row }; +} + +// Serializes one row without writing — for callers that batch several appends into +// a single write (reflect-precheck collects its drift rows this way). +function observationLine(input: RowInput): { line: string } | { error: string } { + const built = observationRow(input); + return 'error' in built ? built : { line: JSON.stringify(built.row) }; +} + +function appendObservation(stateDir: string, input: RowInput): string | null { + const built = observationRow(input); + if ('error' in built) return built.error; + return appendJsonlLine(observationsPath(stateDir), JSON.stringify(built.row)); +} + +export { + CLI_SOURCES, + DETERMINISTIC_SOURCES, + MAX_PATTERN, + observationsPath, + resolveSessionId, + observationRow, + observationLine, + appendObservation, +}; +export type { Origin, Source, CliSource, RowInput }; diff --git a/plugins/claude-code-hermit/scripts/lib/proposals/event.ts b/plugins/claude-code-hermit/scripts/lib/proposals/event.ts new file mode 100644 index 00000000..5e635124 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/lib/proposals/event.ts @@ -0,0 +1,71 @@ +// Typed writers for the two proposal-lifecycle rows that skill prose used to +// hand-assemble as JSON: `responded` and `resolved`. +// +// The other five proposal-metrics shapes (created, triage-verdict, gate-failed, +// micro-queued, micro-resolved) were already written from code in this directory; +// these two were the last ones whose schema lived in markdown. Both carry only an +// id and an enum, so argv is safe here — no stdin mode is needed, unlike the +// observation writer where the payload is free text. +// +// `ts` is stamped here rather than passed in: the `` placeholder callers +// used to fill produced at least three different formats in live ledgers +// (`…Z` and `…+01:00` both appear), and no caller has a reason to choose. + +import path from 'node:path'; +import { appendJsonlLine } from '../append-jsonl'; +import { flagEq as flag } from '../cli'; +import { utcISOStamp } from '../time'; + +const EVENT_TYPES = ['responded', 'resolved'] as const; +const RESPONDED_ACTIONS = ['accept', 'defer', 'dismiss'] as const; + +type EventType = (typeof EVENT_TYPES)[number]; + +function metricsPath(stateDir: string): string { + return path.join(stateDir, 'state', 'proposal-metrics.jsonl'); +} + +// Field order matches the rows this ledger already carries. +function respondedEvent(proposalId: string, action: string): Record { + return { ts: utcISOStamp(), type: 'responded', proposal_id: proposalId, action }; +} + +function resolvedEvent(proposalId: string): Record { + return { ts: utcISOStamp(), type: 'resolved', proposal_id: proposalId }; +} + +function appendEvent(stateDir: string, event: Record): string | null { + return appendJsonlLine(metricsPath(stateDir), JSON.stringify(event)); +} + +// `rest` is argv from the event type onward. Returns a verdict token for +// proposal.ts to emit — `OK` or `ERROR|`, never a thrown error. +function run(stateDir: string, rest: string[]): string { + const type = rest[0]; + if (!type || !(EVENT_TYPES as readonly string[]).includes(type)) { + return `ERROR|unknown-event-type:${type ?? ''}`; + } + + const id = flag(rest, 'id'); + if (!id) return 'ERROR|missing-id'; + if (!/^PROP-[A-Za-z0-9._-]+$/.test(id)) return `ERROR|invalid-id:${id}`; + + const action = flag(rest, 'action'); + let event: Record; + + if ((type as EventType) === 'responded') { + if (!action) return 'ERROR|missing-action'; + if (!(RESPONDED_ACTIONS as readonly string[]).includes(action)) { + return `ERROR|invalid-action:${action}`; + } + event = respondedEvent(id, action); + } else { + if (action) return 'ERROR|action-not-allowed:resolved'; + event = resolvedEvent(id); + } + + const err = appendEvent(stateDir, event); + return err ? `ERROR|${err}` : 'OK'; +} + +export { EVENT_TYPES, RESPONDED_ACTIONS, metricsPath, respondedEvent, resolvedEvent, appendEvent, run }; diff --git a/plugins/claude-code-hermit/scripts/observations.ts b/plugins/claude-code-hermit/scripts/observations.ts new file mode 100644 index 00000000..f474f4c6 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/observations.ts @@ -0,0 +1,58 @@ +// observations.ts — the one model-facing writer for state/observations.jsonl. +// +// Usage: bun observations.ts observe [--origin=] +// +// +// Replaces append-metrics.ts, which took an arbitrary path and arbitrary JSON and +// so granted every holder of its unattended permission strictly more authority +// than any caller needed. +// +// Stdin-only by design. The label is the only free-text field, and a free-text +// value in single-quoted argv breaks the moment it contains an apostrophe — the +// hazard the old script's dual argv/stdin mode existed to work around. Once the +// computed sources moved to the scripts that compute them (reflect-precheck, +// transcript-digest), every remaining caller passes model-authored prose, so +// there is no argv case left to serve and no quoting rule to document. +// +// Exit 0 always; the verdict is the stdout line (OK | ERROR|), matching +// proposal.ts. A metrics row is telemetry — it must never abort a skill step. + +import { emit, flagEq as flag, readStdin } from './lib/cli'; +import { CLI_SOURCES, appendObservation, resolveSessionId, type CliSource, type Origin } from './lib/observations'; + +const USAGE = `Usage: bun observations.ts observe <${CLI_SOURCES.join('|')}> [--origin=own-work|external-content] +