From 663f447aa7c6d4115f4b3ff566cc38d7f465d7e9 Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Sat, 23 May 2026 21:05:20 +0100 Subject: [PATCH 1/2] claude-code-hermit: migrate heartbeat from CronCreate to CC Monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OK/SKIP ticks now run as a silent bash subprocess — zero LLM wakes when quiet. EVALUATE/AUTO_CLOSE notifications interrupt mid-task (Monitor semantics, bounded by precheck suppression). Adds --peek mode to heartbeat-precheck.js to avoid double-incrementing total_ticks on noteworthy ticks, and fixes the self-eval-at-tick-20 off-by-one that would have caused peek to fire EVALUATE one tick too late. Deprecates heartbeat.show_ok. Gives heartbeat its own runtime state file (state/heartbeat-monitor.runtime.json) independent of /watch. --- plugins/claude-code-hermit/CHANGELOG.md | 13 +++ .../claude-code-hermit/docs/always-on-ops.md | 10 +- .../claude-code-hermit/docs/architecture.md | 3 +- .../docs/config-reference.md | 2 - plugins/claude-code-hermit/docs/skills.md | 2 +- .../scripts/heartbeat-monitor.sh | 23 +++++ .../scripts/heartbeat-precheck.js | 17 ++-- .../scripts/hermit-start.py | 1 - .../skills/heartbeat/SKILL.md | 54 ++++++----- .../skills/heartbeat/reference.md | 1 - .../skills/hermit-settings/SKILL.md | 3 +- .../state-templates/CLAUDE-APPEND.md | 1 + .../state-templates/config.json.template | 1 - .../claude-code-hermit/tests/run-scripts.sh | 91 +++++++++++++++++++ 14 files changed, 179 insertions(+), 43 deletions(-) create mode 100755 plugins/claude-code-hermit/scripts/heartbeat-monitor.sh diff --git a/plugins/claude-code-hermit/CHANGELOG.md b/plugins/claude-code-hermit/CHANGELOG.md index 30e182a8..5d722036 100644 --- a/plugins/claude-code-hermit/CHANGELOG.md +++ b/plugins/claude-code-hermit/CHANGELOG.md @@ -2,10 +2,23 @@ ## [Unreleased] +### Changed + +- **heartbeat: CronCreate → CC Monitor** — OK/SKIP ticks no longer wake the LLM. Trade-off: EVALUATE notifications now interrupt mid-task (Monitor semantics) instead of deferring until idle (CronCreate semantics). Bounded by precheck suppression. + +### Removed + +- **heartbeat.show_ok: deprecated** — OK ticks are silent by design; use `/heartbeat status` for liveness. + ### Fixed - **heartbeat: schedule via `CronCreate` instead of `/loop`** — Claude Code 2.1.150's new "Cloud schedule" prompt inside `/loop` was blocking always-on bootstrap. +### Upgrade Instructions + +1. Run `/claude-code-hermit:heartbeat start` — sweeps the prior CronCreate entry and registers the new Monitor in one shot. Otherwise the legacy cron keeps firing alongside the new Monitor until its 7-day expiry. +2. If upgrade is delayed, the daily `heartbeat-restart` routine will eventually re-register, but the legacy cron continues until its expiry — manual `start` is preferred. + ## [1.1.3] - 2026-05-23 ### Fixed diff --git a/plugins/claude-code-hermit/docs/always-on-ops.md b/plugins/claude-code-hermit/docs/always-on-ops.md index 9b94de28..74f0ac7e 100644 --- a/plugins/claude-code-hermit/docs/always-on-ops.md +++ b/plugins/claude-code-hermit/docs/always-on-ops.md @@ -183,7 +183,7 @@ Manage with `/claude-code-hermit:hermit-settings routines`. Changes take effect CronCreate fires only between REPL turns — never mid-task. There is no queue: if Claude is mid-task when the cron time hits, the fire is deferred (not dropped) until idle. `run_during_waiting: false` routines additionally check `runtime.json` and self-suppress with a `skipped-waiting` event when `session_state == "waiting"`. -**`heartbeat-restart`** fires at 4am daily and restarts both the heartbeat tick and the routine registrations (CronCreate auto-expires after 7 days; daily re-arm keeps everything fresh). +**`heartbeat-restart`** fires at 4am daily and re-registers the heartbeat Monitor and routine CronCreates (both expire after 7 days; daily re-arm keeps everything fresh). Inspect live registrations with `/claude-code-hermit:hermit-routines status` (calls `CronList` under the hood). Inspect fire history with `tail .claude-code-hermit/state/routine-metrics.jsonl`. @@ -192,10 +192,10 @@ Inspect live registrations with `/claude-code-hermit:hermit-routines status` (ca | | Routines | Heartbeat | Monitors | | -------------- | -------------------------------- | ------------------------------ | --------------------------------- | | Timing | Exact cron schedule | Every N minutes | Event-driven or interval | -| Engine | CronCreate (idle-gated) | LLM evaluation | Monitor tool (OS subprocess) | -| Cost | Zero tokens until fire | Checklist evaluation per tick | Zero tokens when quiet | -| Survives exit | No (re-registered on launch) | No (7-day CronCreate expiry) | No (session-scoped) | -| Mid-task fire | Deferred until idle | N/A | Yes (interrupts) | +| Engine | CronCreate (idle-gated) | CC Monitor (subprocess poll) | Monitor tool (OS subprocess) | +| Cost | Zero tokens until fire | Zero tokens when quiet | Zero tokens when quiet | +| Survives exit | No (re-registered on launch) | No (re-armed daily by heartbeat-restart) | No (session-scoped) | +| Mid-task fire | Deferred until idle | Yes (interrupts) | Yes (interrupts) | | Use for | Scheduled tasks (briefs, audits) | Continuous monitoring | Reactive watching / quiet polling | **Hybrid model:** Monitors handle reactive event streams (interrupt-OK). Routines handle scheduled work (idle-gated, never interrupt). Heartbeat handles continuous health checks. Neither replaces the others. diff --git a/plugins/claude-code-hermit/docs/architecture.md b/plugins/claude-code-hermit/docs/architecture.md index 5f9efa3d..a826ed5b 100644 --- a/plugins/claude-code-hermit/docs/architecture.md +++ b/plugins/claude-code-hermit/docs/architecture.md @@ -176,6 +176,7 @@ One writer per state file. No shared mutation bus. | `state/micro-proposals.json` | reflect (queue) + channel-responder/brief (resolve) | brief, generate-summary.js | | `state/state-summary.md` | generate-summary.js only | humans | | `state/monitors.runtime.json` | watch skill only | session-start (clear on start), session-close (stop all) | +| `state/heartbeat-monitor.runtime.json` | heartbeat skill only | heartbeat-start (write), heartbeat-stop (clear), heartbeat-restart (rewrite) | | `state/.heartbeat` | heartbeat-touch.js only | heartbeat (detect activity gaps) | | `state/.lifecycle.lock` | hermit-start.py only | hermit-stop.py (cleanup) | @@ -275,7 +276,7 @@ Hermit provides the **timing infrastructure** (when to reflect), the **proposal Morning routine (configurable time, default: active hours start + 30m): brief, proposal review, priority check, pending micro-proposals surfaced. Evening routine (configurable time, default: active hours end - 30m): daily journal archived as S-NNN, reflection, preparation for tomorrow. -Both are managed by `/claude-code-hermit:hermit-routines` (per-session CronCreate registrations). Fire at exact cron times. CronCreate is idle-gated: routines that come due during `in_progress` are deferred until the REPL is between turns — never dropped, never interrupting mid-task. A daily 4am `heartbeat-restart` routine re-runs `/claude-code-hermit:hermit-routines load` to re-arm both the heartbeat tick and the routine CronCreates (both 7-day expiry). +Both are managed by `/claude-code-hermit:hermit-routines` (per-session CronCreate registrations). Fire at exact cron times. CronCreate is idle-gated: routines that come due during `in_progress` are deferred until the REPL is between turns — never dropped, never interrupting mid-task. A daily 4am `heartbeat-restart` routine re-runs `/claude-code-hermit:hermit-routines load` to re-arm the routine CronCreates (7-day expiry) and re-register the heartbeat Monitor. --- diff --git a/plugins/claude-code-hermit/docs/config-reference.md b/plugins/claude-code-hermit/docs/config-reference.md index e9733615..73b8bd9b 100644 --- a/plugins/claude-code-hermit/docs/config-reference.md +++ b/plugins/claude-code-hermit/docs/config-reference.md @@ -86,7 +86,6 @@ Manage with `/hermit-settings channels` (subcommands include `primary ` an |-----|------|---------|-------------| | `enabled` | boolean | `true` | Enable heartbeat on idle transitions. | | `every` | string | `"2h"` | Heartbeat interval (e.g., `"15m"`, `"1h"`, `"2h"`). | -| `show_ok` | boolean | `false` | Log OK results (false = silence means healthy). | | `active_hours.start` | string | `"08:00"` | Start of active window (heartbeat pauses outside). | | `active_hours.end` | string | `"23:00"` | End of active window. | | `stale_threshold` | string | `"2h"` | Alert if active session has no progress for this duration. | @@ -302,7 +301,6 @@ A realistic `config.json` for an always-on Docker hermit with Discord: "heartbeat": { "enabled": true, "every": "2h", - "show_ok": false, "active_hours": { "start": "08:00", "end": "23:00" diff --git a/plugins/claude-code-hermit/docs/skills.md b/plugins/claude-code-hermit/docs/skills.md index f7149a2f..a3bfe3b5 100644 --- a/plugins/claude-code-hermit/docs/skills.md +++ b/plugins/claude-code-hermit/docs/skills.md @@ -35,7 +35,7 @@ Skills are Hermit's built-in workflows — invoke them with `/claude-code-hermit | Checklist | HEARTBEAT.md (you edit it) | Inline command or config `monitors`| | Quiet mode | Suppresses OK by default | Silent until stdout fires | | Active hours | Yes (default 08:00-23:00) | No | -| Token cost | LLM evaluation per tick | Zero when quiet | +| Token cost | Zero tokens when quiet | Zero when quiet | **Checklist weight:** Keep the heartbeat checklist under 10 items. Items that need checking on a schedule (daily, weekly) rather than every tick belong in routines. The self-evaluation will flag overgrown checklists. diff --git a/plugins/claude-code-hermit/scripts/heartbeat-monitor.sh b/plugins/claude-code-hermit/scripts/heartbeat-monitor.sh new file mode 100755 index 00000000..a73a51d9 --- /dev/null +++ b/plugins/claude-code-hermit/scripts/heartbeat-monitor.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Usage: heartbeat-monitor.sh +# Env: HEARTBEAT_MONITOR_ONCE=1 → run one iteration and exit (tests) +# HEARTBEAT_PRECHECK= → override precheck path (tests) +# Polls heartbeat-precheck.js --peek and emits a notification only when the +# LLM needs to wake up (EVALUATE or AUTO_CLOSE verdict). --peek means the +# polling itself is read-only; the mutating tick happens once when +# /heartbeat run re-runs precheck inside the EVALUATE handler. +set -u +INTERVAL="${1:?usage: heartbeat-monitor.sh }" +HB_DIR="${2:?usage: heartbeat-monitor.sh }" +PRECHECK="${HEARTBEAT_PRECHECK:-$(dirname "$0")/heartbeat-precheck.js}" +while true; do + verdict="$(node "$PRECHECK" --peek "$HB_DIR" 2>/dev/null || echo "ERROR")" + case "$verdict" in + EVALUATE*|AUTO_CLOSE*) echo "HEARTBEAT_EVALUATE" ;; + OK|SKIP\|*) : ;; # silent — designed no-op + ERROR*) echo "HEARTBEAT_ERROR: precheck failed" ;; + *) echo "HEARTBEAT_ERROR: unknown verdict: $verdict" ;; + esac + [[ -n "${HEARTBEAT_MONITOR_ONCE:-}" ]] && break + sleep "$INTERVAL" +done diff --git a/plugins/claude-code-hermit/scripts/heartbeat-precheck.js b/plugins/claude-code-hermit/scripts/heartbeat-precheck.js index 747100dc..a582102e 100644 --- a/plugins/claude-code-hermit/scripts/heartbeat-precheck.js +++ b/plugins/claude-code-hermit/scripts/heartbeat-precheck.js @@ -1,9 +1,10 @@ 'use strict'; // heartbeat-precheck.js — fast-path verdict before the LLM evaluates HEARTBEAT.md. -// Usage: node heartbeat-precheck.js +// Usage: node heartbeat-precheck.js [--peek] // Output (stdout, one line): SKIP| | OK | AUTO_CLOSE | EVALUATE -// Exit 0 always. Writes updated alert-state.json (increments total_ticks only). +// Exit 0 always. Without --peek: writes updated alert-state.json (increments total_ticks). +// With --peek: read-only — computes the same verdict without any state mutation. // // Owner contract (write-field split with SKILL.md): // This script owns: alert-state.json total_ticks @@ -18,7 +19,8 @@ function emit(verdict) { process.exit(0); } -const stateDir = process.argv[2]; +const peek = process.argv[2] === '--peek'; +const stateDir = peek ? process.argv[3] : process.argv[2]; if (!stateDir) emit('EVALUATE'); const readJSON = (p) => { @@ -70,10 +72,13 @@ const alertState = readJSON(alertStatePath) ?? { alerts: {}, last_digest_date: n if (typeof alertState.total_ticks !== 'number' || !Number.isFinite(alertState.total_ticks)) { alertState.total_ticks = 0; } -alertState.total_ticks += 1; -writeJSON(alertStatePath, alertState); +if (!peek) { + alertState.total_ticks += 1; + writeJSON(alertStatePath, alertState); +} -if (alertState.total_ticks % 20 === 0) emit('EVALUATE'); +// peek fires one tick early; the subsequent mutating call lands on the multiple-of-20 +if (peek ? (alertState.total_ticks + 1) % 20 === 0 : alertState.total_ticks % 20 === 0) emit('EVALUATE'); const microProposals = readJSON(path.join(stateDir, 'state', 'micro-proposals.json')) ?? { pending: [] }; const hasPendingMicro = Array.isArray(microProposals.pending) && diff --git a/plugins/claude-code-hermit/scripts/hermit-start.py b/plugins/claude-code-hermit/scripts/hermit-start.py index bcc16d88..375449fe 100755 --- a/plugins/claude-code-hermit/scripts/hermit-start.py +++ b/plugins/claude-code-hermit/scripts/hermit-start.py @@ -73,7 +73,6 @@ 'heartbeat': { 'enabled': True, 'every': '2h', - 'show_ok': False, 'active_hours': { 'start': '08:00', 'end': '23:00', diff --git a/plugins/claude-code-hermit/skills/heartbeat/SKILL.md b/plugins/claude-code-hermit/skills/heartbeat/SKILL.md index 042a9933..e34b2e0b 100644 --- a/plugins/claude-code-hermit/skills/heartbeat/SKILL.md +++ b/plugins/claude-code-hermit/skills/heartbeat/SKILL.md @@ -20,7 +20,7 @@ Background health checker that periodically evaluates a checklist and surfaces a ### run -Execute one heartbeat tick. +This subcommand is the handler for `HEARTBEAT_EVALUATE` notifications emitted by the heartbeat Monitor. It's also runnable manually for ad-hoc ticks. The Monitor uses `precheck --peek` for polling; this handler re-runs precheck without `--peek` so the mutating tick (`total_ticks` increment, alert-state write) happens exactly once per noteworthy tick. 1. Run the precheck: ``` @@ -28,7 +28,7 @@ Execute one heartbeat tick. ``` 2. Read the verdict (first line of output): - Starts with `SKIP|` → emit `HEARTBEAT_SKIP ()`. No channel notification. No SHELL.md write. Stop. - - `OK` → emit `HEARTBEAT_OK`. If `heartbeat.show_ok` is `true` in config, notify the operator. Stop. + - `OK` → emit `HEARTBEAT_OK`. Stop. - `AUTO_CLOSE` → SHELL.md mtime exceeded 12h. Run the auto-close sequence, then stop: 1. Append to SHELL.md `## Monitoring`: `[HH:MM] Heartbeat: auto-closed after 12h quiet.` (Step 2 replaces SHELL.md with a fresh template, so a later append would miss the archived report.) 2. Invoke `/claude-code-hermit:session-close --auto` (skips summary-gathering, reflect, heartbeat-stop; passes `Closed Via: auto` to session-mgr). @@ -42,38 +42,46 @@ Execute one heartbeat tick. - If elapsed > `heartbeat.stale_threshold` (default `"2h"`): generate alert with key `stale-session`. 6. **Waiting timeout check.** If `session_state` is `waiting` and `heartbeat.waiting_timeout` is set: - If elapsed > `waiting_timeout` with no channel activity: update `runtime.json` `session_state` to `idle`, update SHELL.md Status to `idle`, notify the operator. -7. **Resume check.** If the previous tick was a SKIP and this tick is not: append to SHELL.md `## Monitoring`: `[HH:MM] Heartbeat: resumed (was inactive)`. -8. Evaluate each checklist item against available information. Generate alerts with semantic keys (taxonomy in reference.md). -9. Determine if anything needs operator attention. -10. Apply alert deduplication and write `state/alert-state.json` (procedure in reference.md). - **Do NOT write `total_ticks` — it was already incremented by the precheck.** -11. If `total_ticks % 20 === 0` (read from updated `state/alert-state.json`): run self-evaluation (procedure in reference.md). +7. Evaluate each checklist item against available information. Generate alerts with semantic keys (taxonomy in reference.md). +8. Determine if anything needs operator attention. +9. Apply alert deduplication and write `state/alert-state.json` (procedure in reference.md). + **Do NOT write `total_ticks` — it was already incremented by the precheck.** +10. If `total_ticks % 20 === 0` (read from updated `state/alert-state.json`): run self-evaluation (procedure in reference.md). ### start -Start a recurring heartbeat tick using `CronCreate`. +Start the heartbeat as a persistent CC Monitor subprocess. -1. Read `heartbeat.every` from config (default: `"2h"`). -2. Convert the interval to a 5-field cron expression using an off-minute (never `:00`, never `:30`) so a fleet of hermits doesn't cluster on the same wall-clock moment: - - `30m` → `7,37 * * * *` - - `Nh` (N≥1) → `7 */N * * *` (e.g. `1h` → `7 * * * *`, `2h` → `7 */2 * * *`) - - `Nd` → `7 4 */N * *` - - Any other `Nm` value: use `*/N * * * *` and proceed — `CronCreate` accepts non-clean steps without error. -3. Call `CronList` and delete any existing task whose prompt is `/claude-code-hermit:heartbeat run` (via `CronDelete`). Idempotent: safe to re-run from `heartbeat-restart` to reset the 7-day expiry. -4. Call `CronCreate` with `cron` set to the expression from step 2, `prompt` set to `/claude-code-hermit:heartbeat run`, and `recurring: true`. -5. Append to SHELL.md Monitoring: `[HH:MM] Heartbeat: started (every , cron , task )`. +1. Read `heartbeat.every` from config (default: `"2h"`). Parse to seconds (`"30m"` → 1800, `"2h"` → 7200, etc). +2. Resolve the script path: `${CLAUDE_PLUGIN_ROOT}/scripts/heartbeat-monitor.sh` (resolve at skill execution time — not available inside the subprocess). +3. Sweep any pre-existing CronCreate entry for the old recurring-cron approach: `CronList` → if an entry's `prompt` matches `/claude-code-hermit:heartbeat run`, `CronDelete` it. Idempotent. +4. If a Monitor with description `heartbeat-monitor` exists (TaskList), TaskStop it. Also remove any prior entry from `state/heartbeat-monitor.runtime.json`. +5. Register a new Monitor: + - `description`: `heartbeat-monitor` (reserved slot — operators must not reuse this description for ad-hoc `/watch` entries) + - `command`: `bash $PWD/.claude-code-hermit` + - `timeout_ms`: 86400000 (24h; re-armed daily by `heartbeat-restart`) + - `persistent`: true +6. Write the new entry (description, task_id, command, interval, started_at) to `state/heartbeat-monitor.runtime.json`. Do NOT use `state/monitors.runtime.json` — that file is owned exclusively by /watch and is cleared on every session start. +7. Append to SHELL.md Monitoring: `[HH:MM] Heartbeat: monitor started (interval: )`. -We use `CronCreate` directly rather than `/loop` because Claude Code 2.1.150 added an operator-facing "Cloud schedule vs This session only" prompt inside `/loop` that blocks the always-on bootstrap. `CronCreate` is the same local in-session scheduler `/loop` wraps — same runtime semantics, no prompt. +Safe to call from a routine — idempotent (legacy cron swept + existing Monitor stopped + state file rewritten). ### stop -1. Call `CronList`. Delete every task whose prompt is `/claude-code-hermit:heartbeat run` via `CronDelete`. -2. Append to SHELL.md Monitoring: `[HH:MM] Heartbeat: stopped`. +1. Read `state/heartbeat-monitor.runtime.json`. If a `task_id` is present, TaskStop it. Fallback: TaskList → find by description `heartbeat-monitor` and TaskStop. +2. Clear `state/heartbeat-monitor.runtime.json` (write `{}`). +3. Sweep legacy CronCreate: `CronList` → `CronDelete` any entry whose `prompt` matches `/claude-code-hermit:heartbeat run`. Belt-and-suspenders. +4. Append to SHELL.md Monitoring: `[HH:MM] Heartbeat: stopped`. ### status -Report current heartbeat state: -- Call `CronList` and find the task whose prompt is `/claude-code-hermit:heartbeat run`. Report: running (yes/no), cron expression, task ID, configured interval, active hours window, last tick time and result, show_ok setting. +Report current heartbeat state by reading: +- `state/heartbeat-monitor.runtime.json` — running yes/no, registered interval, task_id, started_at +- `CronList` filtered for `/claude-code-hermit:heartbeat run` — should be empty post-migration; if present, surface as "legacy CronCreate still active — run /heartbeat start to clean up" +- `state/alert-state.json` for `total_ticks` and last-tick metadata +- `config.json` for active hours window + +Report: monitor running (yes/no), configured interval, active hours window, total ticks since last clear, legacy-cron warning if applicable. ### edit diff --git a/plugins/claude-code-hermit/skills/heartbeat/reference.md b/plugins/claude-code-hermit/skills/heartbeat/reference.md index 7f73a641..ed31a23a 100644 --- a/plugins/claude-code-hermit/skills/heartbeat/reference.md +++ b/plugins/claude-code-hermit/skills/heartbeat/reference.md @@ -37,7 +37,6 @@ Before appending any alert to SHELL.md Monitoring: ## If nothing actionable - Do NOT append to SHELL.md. -- Read config `heartbeat.show_ok`: if `true`, notify the operator "Heartbeat OK"; if `false` (default), no channel message. - Respond "HEARTBEAT_OK". ## If something found diff --git a/plugins/claude-code-hermit/skills/hermit-settings/SKILL.md b/plugins/claude-code-hermit/skills/hermit-settings/SKILL.md index e8b20696..5b40f904 100644 --- a/plugins/claude-code-hermit/skills/hermit-settings/SKILL.md +++ b/plugins/claude-code-hermit/skills/hermit-settings/SKILL.md @@ -63,7 +63,7 @@ Operational: Morning brief: disabled → run: /claude-code-hermit:hermit-settings brief Idle behavior: discover → discover | wait Idle budget: $0.50 → any dollar amount (e.g. $0.25, $1.00) - Heartbeat: disabled → yes | no (interval, show_ok, active hours, stale threshold) + Heartbeat: disabled → yes | no (interval, active hours, stale threshold) Routines: 2 configured → run: /claude-code-hermit:hermit-settings routines Quality gate: budget → budget | balanced | quality Permission mode: auto → default | acceptEdits | auto | plan | dontAsk | bypassPermissions @@ -200,7 +200,6 @@ Update `permission_mode` in config.json. ``` Heartbeat sub-fields (press Enter to keep current value): interval — how often to check (e.g. 5m, 15m, 30m) [current] - show_ok — post a message on healthy checks (yes / no) [current] active — active hours window (e.g. 08:00-23:00) [current] stale — alert if no session progress for (e.g. 2h, 30m) [current] ``` diff --git a/plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md b/plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md index b4a86d59..fec97a5e 100644 --- a/plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md +++ b/plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md @@ -45,6 +45,7 @@ Rules: - All 4 CC Monitor tool params are required: `description`, `command`, `timeout_ms`, `persistent`. Always pass `timeout_ms` even when `persistent: true` (required by schema; ignored when persistent). - `$CLAUDE_PLUGIN_ROOT` is **NOT available** in the watch subprocess. `$PWD` is project root. Resolve plugin paths at registration time (skill execution context has the var). - Watch dies with the session — for scheduled work, use `/claude-code-hermit:hermit-routines` (re-registered on every always-on launch by `hermit-start.py`) +- `HEARTBEAT_EVALUATE` notification → invoke `/claude-code-hermit:heartbeat run`. ## Quick Reference diff --git a/plugins/claude-code-hermit/state-templates/config.json.template b/plugins/claude-code-hermit/state-templates/config.json.template index 0dd4c134..20668a86 100644 --- a/plugins/claude-code-hermit/state-templates/config.json.template +++ b/plugins/claude-code-hermit/state-templates/config.json.template @@ -45,7 +45,6 @@ "heartbeat": { "enabled": true, "every": "2h", - "show_ok": false, "active_hours": { "start": "08:00", "end": "23:00" diff --git a/plugins/claude-code-hermit/tests/run-scripts.sh b/plugins/claude-code-hermit/tests/run-scripts.sh index a3051e40..4e60cdb6 100755 --- a/plugins/claude-code-hermit/tests/run-scripts.sh +++ b/plugins/claude-code-hermit/tests/run-scripts.sh @@ -343,6 +343,97 @@ run_test "heartbeat-precheck (self_eval{} not mutated by precheck)" bash -c \ "python3 -c \"import json; d=json.load(open('$workdir/.claude-code-hermit/state/alert-state.json')); assert d['self_eval']['mykey']['clean_ticks']==5\"" cleanup +# ------------------------------------------------------- +# heartbeat-precheck.js --peek (read-only mode) +# ------------------------------------------------------- + +# 20a. --peek returns verdict without mutating total_ticks +workdir="$(setup_workdir)" +echo '{"timezone":"UTC","heartbeat":{"active_hours":{"start":"00:00","end":"23:59"}}}' \ + > "$workdir/.claude-code-hermit/config.json" +echo '{"alerts":{},"last_digest_date":null,"self_eval":{},"total_ticks":5}' \ + > "$workdir/.claude-code-hermit/state/alert-state.json" +echo '{"session_state":"idle"}' > "$workdir/.claude-code-hermit/state/runtime.json" +echo '{"pending":[]}' > "$workdir/.claude-code-hermit/state/micro-proposals.json" +peek_out="$(node "$REPO_ROOT/scripts/heartbeat-precheck.js" --peek "$workdir/.claude-code-hermit")" +run_test "heartbeat-precheck --peek (returns verdict)" bash -c "[ -n '$peek_out' ]" +run_test "heartbeat-precheck --peek (total_ticks not mutated)" bash -c \ + "python3 -c \"import json; d=json.load(open('$workdir/.claude-code-hermit/state/alert-state.json')); assert d['total_ticks']==5\"" +cleanup + +# 20a-2. --peek fires self-eval EVALUATE one tick early (at total_ticks=19) +workdir="$(setup_workdir)" +echo '{"timezone":"UTC","heartbeat":{"active_hours":{"start":"00:00","end":"23:59"}}}' \ + > "$workdir/.claude-code-hermit/config.json" +echo '{"alerts":{},"last_digest_date":null,"self_eval":{},"total_ticks":19}' \ + > "$workdir/.claude-code-hermit/state/alert-state.json" +echo '{"session_state":"idle"}' > "$workdir/.claude-code-hermit/state/runtime.json" +echo '{"pending":[]}' > "$workdir/.claude-code-hermit/state/micro-proposals.json" +printf '# Heartbeat\n- Review proposals/ for any needing attention\n' \ + > "$workdir/.claude-code-hermit/HEARTBEAT.md" +run_test "heartbeat-precheck --peek (self-eval EVALUATE at tick 19)" bash -c \ + "[ \"$(node "$REPO_ROOT/scripts/heartbeat-precheck.js" --peek "$workdir/.claude-code-hermit")\" = 'EVALUATE' ]" +run_test "heartbeat-precheck --peek (self-eval: total_ticks still 19)" bash -c \ + "python3 -c \"import json; d=json.load(open('$workdir/.claude-code-hermit/state/alert-state.json')); assert d['total_ticks']==19\"" +cleanup + +# ------------------------------------------------------- +# heartbeat-monitor.sh — real-script tests (HEARTBEAT_MONITOR_ONCE=1) +# ------------------------------------------------------- + +MONITOR_SH="$REPO_ROOT/scripts/heartbeat-monitor.sh" + +# 20b. EVALUATE → HEARTBEAT_EVALUATE +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stdout.write("EVALUATE\\n");\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (EVALUATE → HEARTBEAT_EVALUATE)" bash -c "[ '$out' = 'HEARTBEAT_EVALUATE' ]" +rm -f "$stub" + +# 20c. EVALUATE with suffix (prefix match) → HEARTBEAT_EVALUATE +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stdout.write("EVALUATE|micro-pending\\n");\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (EVALUATE|micro-pending → HEARTBEAT_EVALUATE)" bash -c "[ '$out' = 'HEARTBEAT_EVALUATE' ]" +rm -f "$stub" + +# 20d. AUTO_CLOSE → HEARTBEAT_EVALUATE +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stdout.write("AUTO_CLOSE\\n");\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (AUTO_CLOSE → HEARTBEAT_EVALUATE)" bash -c "[ '$out' = 'HEARTBEAT_EVALUATE' ]" +rm -f "$stub" + +# 20e. OK → silent (no output) +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stdout.write("OK\\n");\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (OK → silent)" bash -c "[ -z '$out' ]" +rm -f "$stub" + +# 20f. SKIP|outside-hours → silent +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stdout.write("SKIP|outside-hours\\n");\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (SKIP|outside-hours → silent)" bash -c "[ -z '$out' ]" +rm -f "$stub" + +# 20g. precheck nonzero exit → HEARTBEAT_ERROR: precheck failed +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stderr.write("crash\\n"); process.exit(1);\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (nonzero exit → HEARTBEAT_ERROR: precheck failed)" bash -c \ + "echo '$out' | grep -q 'HEARTBEAT_ERROR: precheck failed'" +rm -f "$stub" + +# 20h. unknown verdict → HEARTBEAT_ERROR: unknown verdict +stub="$(mktemp /tmp/hb-stub-XXXXX.js)" +printf 'process.stdout.write("WHATEVER\\n");\n' > "$stub" +out="$(HEARTBEAT_MONITOR_ONCE=1 HEARTBEAT_PRECHECK="$stub" bash "$MONITOR_SH" 60 /tmp 2>/dev/null)" +run_test "heartbeat-monitor (unknown verdict → HEARTBEAT_ERROR: unknown verdict)" bash -c \ + "echo '$out' | grep -q 'HEARTBEAT_ERROR: unknown verdict'" +rm -f "$stub" + # ------------------------------------------------------- # reflect-precheck.js # ------------------------------------------------------- From 44618946a768815b42e8619a05928e409718461c Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Sat, 23 May 2026 21:24:04 +0100 Subject: [PATCH 2/2] claude-code-hermit: fix stale CronCreate ref in session-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #132 migrates heartbeat to CC Monitor, but session-close/SKILL.md line 15 was rewritten from "/loop" to "CronCreate" — stale on landing. Update to "Monitor" to match the new mechanism so --auto callers don't reach for CronDelete instead of TaskStop. --- plugins/claude-code-hermit/skills/session-close/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/claude-code-hermit/skills/session-close/SKILL.md b/plugins/claude-code-hermit/skills/session-close/SKILL.md index a8842edb..73da9cb3 100644 --- a/plugins/claude-code-hermit/skills/session-close/SKILL.md +++ b/plugins/claude-code-hermit/skills/session-close/SKILL.md @@ -12,7 +12,7 @@ When invoked with `--auto` by heartbeat (after 12h SHELL.md inactivity), the ope Idle transitions happen automatically at task boundaries (handled by the `session` skill). By the time the operator runs `/session-close`, they want out. -If heartbeat is running, stop it before archiving. **Skip on `--auto`** — heartbeat is the caller; stopping its CronCreate would prevent all future ticks. +If heartbeat is running, stop it before archiving. **Skip on `--auto`** — heartbeat is the caller; stopping its Monitor would prevent all future ticks. If watches are registered (`state/monitors.runtime.json` has entries), stop all watches before archiving — invoke `/claude-code-hermit:watch stop --all`. session-mgr handles updating both SHELL.md (cosmetic) and `state/runtime.json` (lifecycle truth) during archiving. For full shutdown, session-mgr sets `shutdown_completed_at` in runtime.json.