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
11 changes: 11 additions & 0 deletions plugins/claude-code-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

### 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.

- **env defaults: `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` bumped 50 → 65** — auto-compact was firing well before the quality-degradation zone (~73%); 65 reduces premature context loss while staying conservative.
- **env defaults: `COMPACT_THRESHOLD` bumped 50 → 75** — the tool-call-based nudge was firing mid-session for any non-trivial work; 75 quiets the fallback path while `context_usage > 60%` continues to drive real nudges in `suggest-compact.js`.
- **docs: `COMPACT_THRESHOLD` description corrected to tool-call-count fallback** — config-reference previously called it a "context % threshold," which contradicted `suggest-compact.js`. Now matches the code: tool-call counter consulted only when `context_usage` is unavailable.
Expand All @@ -27,6 +33,11 @@

- **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
Expand Down
10 changes: 5 additions & 5 deletions plugins/claude-code-hermit/docs/always-on-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion plugins/claude-code-hermit/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down Expand Up @@ -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.

---

Expand Down
2 changes: 0 additions & 2 deletions plugins/claude-code-hermit/docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ Manage with `/hermit-settings channels` (subcommands include `primary <name>` 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. |
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code-hermit/docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions plugins/claude-code-hermit/scripts/heartbeat-monitor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Usage: heartbeat-monitor.sh <interval_seconds> <hermit_state_dir>
# Env: HEARTBEAT_MONITOR_ONCE=1 → run one iteration and exit (tests)
# HEARTBEAT_PRECHECK=<path> → 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 <interval_seconds> <hermit_state_dir>}"
HB_DIR="${2:?usage: heartbeat-monitor.sh <interval_seconds> <hermit_state_dir>}"
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
17 changes: 11 additions & 6 deletions plugins/claude-code-hermit/scripts/heartbeat-precheck.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use strict';

// heartbeat-precheck.js — fast-path verdict before the LLM evaluates HEARTBEAT.md.
// Usage: node heartbeat-precheck.js <hermit-state-dir>
// Usage: node heartbeat-precheck.js [--peek] <hermit-state-dir>
// Output (stdout, one line): SKIP|<reason> | 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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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) &&
Expand Down
1 change: 0 additions & 1 deletion plugins/claude-code-hermit/scripts/hermit-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@
'heartbeat': {
'enabled': True,
'every': '2h',
'show_ok': False,
'active_hours': {
'start': '08:00',
'end': '23:00',
Expand Down
54 changes: 31 additions & 23 deletions plugins/claude-code-hermit/skills/heartbeat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ 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:
```
node ${CLAUDE_PLUGIN_ROOT}/scripts/heartbeat-precheck.js .claude-code-hermit
```
2. Read the verdict (first line of output):
- Starts with `SKIP|` → emit `HEARTBEAT_SKIP (<reason>)`. 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).
Expand All @@ -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 <interval>, cron <expr>, task <id>)`.
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 <abs_script_path> <interval_seconds> $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: <every>)`.

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

Expand Down
1 change: 0 additions & 1 deletion plugins/claude-code-hermit/skills/heartbeat/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading