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
27 changes: 27 additions & 0 deletions plugins/claude-code-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## [Unreleased]

### Added

- **daily-auto-close routine** — fires `0 0 * * *` (local) and closes the current session via `/session-close --auto` once the operator has been idle ≥10 min. If the operator is currently active at midnight, the routine writes `state/pending-close.json`; the next heartbeat tick drains the flag as soon as the lull arrives, regardless of active-hours or other gates. Fixes the silent-no-archives failure mode on long-running daemons (sessions that never close → reflect / weekly-review / hermit-brain / hermit-evolution all degrade).
- **skills/daily-auto-close/SKILL.md** — new routine driver skill (queue / drain-direct / stale-flag-cleanup branches).
- **scripts/heartbeat-precheck.js** — new pending-close drain block at the top of the script, runs before active-hours / 20-tick / micro-proposal gates.

### Changed

- **Removed `closed_via: auto` skip** from `reflect-precheck.js`, `skills/reflect/SKILL.md` (Resolution Check + routine-effect scan), and `weekly-review.js` (`isAutoArchived` filter, autonomy-denominator exclusion, "auto-archived excluded" note). All archives now count as evidence regardless of close trigger. The skip was correct for genuinely-empty 12h-inactivity closes but is wrong for the new daily-midnight closes on chatty daemons (which carry real operator content); the `operator_turns == 0` semantic still excludes empty archives from the `self-directed` numerator.
- **Auto-close wording** — `skills/heartbeat/SKILL.md`, `skills/session-close/SKILL.md`, `docs/always-on.md`, `docs/always-on-ops.md`, `skills/channel-responder/SKILL.md` updated to reflect both AUTO_CLOSE triggers (12h-inactivity OR midnight + 10-min lull). Generic "auto-closed" wording replaces "auto-closed after 12h quiet".
- **Shared `isEmptyAutoArchive` helper** in `scripts/lib/frontmatter.js` consumed by both `reflect-precheck.js` and `weekly-review.js`. The `closed_via: auto && operator_turns: 0` predicate was duplicated at both sites; centralizing it avoids drift as the rule evolves (post-KAIROS the consumers will read daily logs instead, but the predicate is the survival point for both).

### Fixed

- **Pending-close drain now runs before HEARTBEAT.md SKIP gates** in `scripts/heartbeat-precheck.js`. Previously, a missing or empty `HEARTBEAT.md` would short-circuit the script with `SKIP|...` before the drain block could fire, leaving operators who curate or remove HEARTBEAT.md stuck on at-most-daily drain cadence instead of at-most-tick.
- **Fail-open drain now guarded by `queued_at` staleness** in `scripts/heartbeat-precheck.js`. When `last-operator-action.json` is absent or malformed, the drain still fail-opens to `AUTO_CLOSE` (per `daily-auto-close` SKILL.md step 5), but only when `pending-close.json` was queued within the last 24h. A leftover stale flag from a crashed prior session paired with a missing last-op clock on a fresh session no longer triggers a premature close.

### Upgrade Instructions

Run `/claude-code-hermit:hermit-evolve`. The evolve skill handles:

1. **Add `daily-auto-close` routine to `.claude-code-hermit/config.json`.** Read `config.routines`. If any entry has `id: "daily-auto-close"`, skip — already present. Otherwise append: `{"id": "daily-auto-close", "schedule": "0 0 * * *", "skill": "claude-code-hermit:daily-auto-close", "run_during_waiting": true, "enabled": true}`. Preserve operator ordering; insert at end of array.
2. **Re-arm routines.** Invoke `/claude-code-hermit:hermit-routines load` so the new entry registers via CronCreate this session — no restart needed.
3. **Report.** "Added `daily-auto-close` routine — long-running daemon sessions now archive at midnight when idle ≥10 min, restoring reflect / weekly-review / brain evidence on chatty hermits. As part of this change, the `closed_via: auto` filter in reflect-precheck, reflect's session scans, and weekly-review's autonomy calculation was removed: all archives now count as evidence, regardless of close trigger. **Note:** the weekly self-directed rate may shift for the next 1–2 reviews as chatty-daemon midnight archives (which now count toward the denominator) age into the window; the rate will stabilize once historical archives roll past 14 days."

## [1.1.4] - 2026-05-23

### Changed
Expand Down
24 changes: 12 additions & 12 deletions plugins/claude-code-hermit/docs/always-on-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,18 @@ hermit-start -> [in_progress] -> task done -> [idle] -> new task -> [in_progress

### Close modes

| | Idle Transition (task boundary) | Waiting (blocked on input) | Auto-Close (12h idle) | Full Shutdown (`/session-close`) |
| ------------------- | -------------------------------------------------------------------------- | ------------------------------ | -------------------------------------- | -------------------------------- |
| **When** | Work done — automatic | Blocked on operator input | SHELL.md mtime >12h, heartbeat-driven | You explicitly close |
| **Report archived** | Yes | No (session stays open) | Yes (frontmatter `closed_via: auto`) | Yes |
| **Reflection runs** | Yes | No | No (auto-archived reports are skipped) | Yes |
| **Heartbeat** | Keeps running (or starts) | Runs (skips stale checks) | Keeps running | Stopped |
| **Monitors** | Keep running | Keep running | Keep running | Stopped (TaskStop + registry cleared) |
| **Channels** | Keep running (always-on only) | Keep running | Keep running | Stopped |
| **SHELL.md** | Reset in-place to `idle`, Monitoring & Summary compacted if over threshold | Status set to `waiting` | Replaced with fresh template | Replaced with fresh template |
| **Applies to** | Both interactive and always-on | Both interactive and always-on | Both interactive and always-on | Both interactive and always-on |

Default: idle transition when work finishes. Waiting when blocked on operator input (configurable `waiting_timeout` auto-transitions to idle). Auto-close after 12h of SHELL.md idle (heartbeat-driven, no configuration). Full shutdown only via explicit `/session-close` or `hermit-stop`.
| | Idle Transition (task boundary) | Waiting (blocked on input) | Auto-Close (12h idle OR midnight + 10min lull) | Full Shutdown (`/session-close`) |
| ------------------- | -------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------- | -------------------------------- |
| **When** | Work done — automatic | Blocked on operator input | 12h since last operator action, OR midnight daily-routine fires and operator goes idle ≥10min | You explicitly close |
| **Report archived** | Yes | No (session stays open) | Yes (frontmatter `closed_via: auto`) | Yes |
| **Reflection runs** | Yes | No | No (deferred to next session's heartbeat cycle) | Yes |
| **Heartbeat** | Keeps running (or starts) | Runs (skips stale checks) | Keeps running | Stopped |
| **Monitors** | Keep running | Keep running | Keep running | Stopped (TaskStop + registry cleared) |
| **Channels** | Keep running (always-on only) | Keep running | Keep running | Stopped |
| **SHELL.md** | Reset in-place to `idle`, Monitoring & Summary compacted if over threshold | Status set to `waiting` | Replaced with fresh template | Replaced with fresh template |
| **Applies to** | Both interactive and always-on | Both interactive and always-on | Both interactive and always-on | Both interactive and always-on |

Default: idle transition when work finishes. Waiting when blocked on operator input (configurable `waiting_timeout` auto-transitions to idle). Auto-close on either 12h operator inactivity OR the daily midnight routine once the operator is idle ≥10 min (both heartbeat-driven, no configuration). Full shutdown only via explicit `/session-close` or `hermit-stop`.

### How sessions compound

Expand Down
18 changes: 11 additions & 7 deletions plugins/claude-code-hermit/docs/always-on.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,19 @@ This means even a raw `docker compose down` (without `hermit-docker down`) will

## Auto-Close

Heartbeat archives the session when SHELL.md has been idle for more than 12 hours:
Heartbeat archives the current session via two triggers:

1. `heartbeat-precheck.js` checks SHELL.md mtime on each tick
2. If idle >12h, returns the `AUTO_CLOSE` verdict
3. Heartbeat appends `[HH:MM] Heartbeat: auto-closed after 12h quiet.` to SHELL.md Monitoring (so the trace lands in the archived report, not the next session)
4. Invokes `/session-close --auto` — bypasses the operator-summary prompt and skips reflect; the heartbeat tick continues
5. The report is archived with frontmatter `closed_via: auto`
- **12h inactivity** — `heartbeat-precheck.js` checks `last-operator-action.json` on each tick. If the operator has not acted for >12h, it returns the `AUTO_CLOSE` verdict.
- **Daily midnight with lull** — the `daily-auto-close` routine fires at `0 0 * * *` (local). If the operator is currently active (last action ≤10 min), the routine writes `state/pending-close.json`. The next heartbeat tick where the operator has been idle >10 min drains the flag and emits `AUTO_CLOSE`. If the operator was already idle when the routine fired, it closes directly without queueing.

`weekly-review` includes auto-archived sessions in cost/session totals but excludes them from the autonomy-rate denominator (with an inline "(N auto-archived excluded)" note). Reflect skips them when scanning archive evidence, preventing mtime-triggered false compute-phase runs. No configuration is needed — the trigger is fixed at 12h.
On either trigger:

1. Heartbeat appends `[HH:MM] Heartbeat: auto-closed.` to SHELL.md Monitoring (so the trace lands in the archived report, not the next session).
2. Invokes `/session-close --auto` — bypasses the operator-summary prompt and skips reflect; the heartbeat tick continues.
3. The report is archived with frontmatter `closed_via: auto`.
4. `state/pending-close.json` (if present) is removed after archive success.

All auto-archived sessions count as evidence in `reflect`, `weekly-review`, `hermit-brain`, and `hermit-evolution` — the prior `closed_via: auto` skip filter was removed so daily-midnight archives (with real operator content) reach those surfaces. The 12h-inactivity trigger and the 10-min lull threshold are not configurable in v1.

## Crash Recovery

Expand Down
46 changes: 40 additions & 6 deletions plugins/claude-code-hermit/scripts/heartbeat-precheck.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,46 @@ function normalizeItemKey(itemText) {
return text ? `checklist:${text}` : null;
}

// Resolve "now" once: real wall-clock, overridable by HERMIT_NOW for deterministic
// tests. Shared by the pending-close drain and the in_progress 12h check below.
let now = Date.now();
if (process.env.HERMIT_NOW) {
const d = new Date(process.env.HERMIT_NOW).getTime();
if (!isNaN(d)) now = d;
}

// Pending-close drain: if the daily-auto-close routine queued a close because the
// operator was active at midnight, drain it as soon as a 10-min lull appears.
// Runs BEFORE every other gate (HEARTBEAT.md presence, active-hours, 20-tick,
// micro-proposal) — the close is the signal, not a notification, and must not
// depend on operator-editable HEARTBEAT.md being present.
{
const pendingClose = readJSON(path.join(stateDir, 'state', 'pending-close.json'));
if (pendingClose !== null) {
const runtime = readJSON(path.join(stateDir, 'state', 'runtime.json')) ?? {};
if (runtime.session_state === 'in_progress') {
const lastAction = readJSON(path.join(stateDir, 'state', 'last-operator-action.json'));
const tStr = lastAction && typeof lastAction.at === 'string' ? lastAction.at : null;
const t = tStr ? new Date(tStr).getTime() : NaN;
if (!isNaN(t)) {
// Valid last-operator-action → standard 10-min lull check.
if ((now - t) / (1000 * 60) > 10) emit('AUTO_CLOSE');
} else {
// Absent/malformed last-operator-action → fail-open per daily-auto-close
// SKILL.md step 5, BUT only when the flag itself is recent. A stale flag
// left over from a crashed prior session must not auto-close a fresh
// session whose last-op clock has not yet been seeded. The routine fires
// every 24h and overwrites or cleans up the flag, so a queued_at older
// than 24h means the routine has stopped firing and the flag cannot be
// trusted.
const qStr = typeof pendingClose.queued_at === 'string' ? pendingClose.queued_at : null;
const q = qStr ? new Date(qStr).getTime() : NaN;
if (!isNaN(q) && (now - q) / (1000 * 60 * 60) <= 24) emit('AUTO_CLOSE');
}
}
}
}

let heartbeatContent;
try { heartbeatContent = fs.readFileSync(path.join(stateDir, 'HEARTBEAT.md'), 'utf-8'); }
catch { emit('SKIP|HEARTBEAT.md missing'); }
Expand Down Expand Up @@ -89,12 +129,6 @@ const runtime = readJSON(path.join(stateDir, 'state', 'runtime.json')) ?? {};
const sessionState = runtime.session_state ?? 'idle';

if (sessionState === 'in_progress') {
let now = Date.now();
if (process.env.HERMIT_NOW) {
const d = new Date(process.env.HERMIT_NOW).getTime();
if (!isNaN(d)) now = d;
}

// Prefer last-operator-action.json: records genuine operator prompts only, unaffected
// by routine writes (reflect, scheduled-checks, heartbeat alerts) that bump SHELL.md mtime.
// Falls back to SHELL.md mtime for pre-upgrade installs that don't have the file yet.
Expand Down
1 change: 1 addition & 0 deletions plugins/claude-code-hermit/scripts/hermit-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
{'id': 'reflect', 'schedule': '0 9 * * *', 'skill': 'claude-code-hermit:reflect', 'enabled': True},
{'id': 'scheduled-checks', 'schedule': '5 9 * * *', 'skill': 'claude-code-hermit:reflect-scheduled-checks', 'run_during_waiting': True, 'enabled': True},
{'id': 'weekly-review', 'schedule': '0 23 * * 0', 'skill': 'claude-code-hermit:weekly-review', 'enabled': True},
{'id': 'daily-auto-close', 'schedule': '0 0 * * *', 'skill': 'claude-code-hermit:daily-auto-close', 'run_during_waiting': True, 'enabled': True},
],
'monitors': [],
'env': {
Expand Down
22 changes: 21 additions & 1 deletion plugins/claude-code-hermit/scripts/lib/frontmatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ function readFileWithFrontmatter(filePath) {
}
}

/**
* Returns true when a session-report frontmatter represents an empty auto-archive:
* `closed_via: auto` AND `operator_turns: 0`. These reports come from the
* 12h-inactivity AUTO_CLOSE path on quiet sessions and carry no operator content;
* the daily-lull AUTO_CLOSE path carries operator_turns > 0 and is NOT empty.
*
* Used by reflect-precheck (excluded from compute-phase mtime trigger) and
* weekly-review (excluded from the autonomy-rate denominator). Null frontmatter
* returns false (an unreadable report is never excluded from evidence); a missing
* or non-numeric operator_turns is read as 0, matching the inline behavior both
* call sites had before extraction. Post-KAIROS the predicate becomes moot —
* reflect and weekly-review will read KAIROS daily logs instead of S-NNN-REPORT.md
* archives.
*/
function isEmptyAutoArchive(fm) {
if (!fm) return false;
const ops = parseInt(fm.operator_turns, 10) || 0;
return fm.closed_via === 'auto' && ops === 0;
}

/**
* Given an array of artifacts with { fm } (each having fm.type and fm.created),
* return a Map<type, artifact> keeping only the newest artifact per type.
Expand Down Expand Up @@ -141,4 +161,4 @@ function resolveArtifactPath(baseDir, pathEntry) {
return globDir(dir, re);
}

module.exports = { parseFrontmatter, readFrontmatter, readFileWithFrontmatter, newestByType, globDir, globDirRecursive, resolveArtifactPath };
module.exports = { parseFrontmatter, readFrontmatter, readFileWithFrontmatter, isEmptyAutoArchive, newestByType, globDir, globDirRecursive, resolveArtifactPath };
13 changes: 5 additions & 8 deletions plugins/claude-code-hermit/scripts/reflect-precheck.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { currentHHMM } = require('./lib/time');
const { readFrontmatter } = require('./lib/frontmatter');
const { readFrontmatter, isEmptyAutoArchive } = require('./lib/frontmatter');

function emit(verdict) {
process.stdout.write(verdict + '\n');
Expand Down Expand Up @@ -111,15 +111,12 @@ function hasComputeActivity(stateDir, lastRunAt, sessionState) {

try {
const sessionsDir = path.join(stateDir, 'sessions');
// Exclude empty auto-archives: their auto-close mtime bump would trigger compute
// on a report with no operator content. Daily-lull closes carry operator_turns > 0
// and DO trigger compute. See isEmptyAutoArchive in lib/frontmatter.js.
const reports = fs.readdirSync(sessionsDir)
.filter(f => /^S-\d+-REPORT\.md$/.test(f))
.filter(f => {
// Exclude auto-closed reports — they have no operator-curated content and their
// mtime bump (from auto-close writing them) would falsely trigger compute phase.
// Fail-open: if frontmatter can't be parsed, include the report.
try { return readFrontmatter(path.join(sessionsDir, f)).closed_via !== 'auto'; }
catch { return true; }
});
.filter(f => !isEmptyAutoArchive(readFrontmatter(path.join(sessionsDir, f))));
return reports.some(f => {
try { return fs.statSync(path.join(sessionsDir, f)).mtime > lastRun; }
catch { return false; }
Expand Down
Loading
Loading