diff --git a/plugins/claude-code-hermit/CHANGELOG.md b/plugins/claude-code-hermit/CHANGELOG.md index c1fcd573..98fae3f9 100644 --- a/plugins/claude-code-hermit/CHANGELOG.md +++ b/plugins/claude-code-hermit/CHANGELOG.md @@ -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 diff --git a/plugins/claude-code-hermit/docs/always-on-ops.md b/plugins/claude-code-hermit/docs/always-on-ops.md index 74f0ac7e..9c5376e3 100644 --- a/plugins/claude-code-hermit/docs/always-on-ops.md +++ b/plugins/claude-code-hermit/docs/always-on-ops.md @@ -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 diff --git a/plugins/claude-code-hermit/docs/always-on.md b/plugins/claude-code-hermit/docs/always-on.md index c54ccce8..bdccee6f 100644 --- a/plugins/claude-code-hermit/docs/always-on.md +++ b/plugins/claude-code-hermit/docs/always-on.md @@ -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 diff --git a/plugins/claude-code-hermit/scripts/heartbeat-precheck.js b/plugins/claude-code-hermit/scripts/heartbeat-precheck.js index a582102e..0cfae80b 100644 --- a/plugins/claude-code-hermit/scripts/heartbeat-precheck.js +++ b/plugins/claude-code-hermit/scripts/heartbeat-precheck.js @@ -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'); } @@ -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. diff --git a/plugins/claude-code-hermit/scripts/hermit-start.py b/plugins/claude-code-hermit/scripts/hermit-start.py index 2e202b9f..644c8871 100755 --- a/plugins/claude-code-hermit/scripts/hermit-start.py +++ b/plugins/claude-code-hermit/scripts/hermit-start.py @@ -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': { diff --git a/plugins/claude-code-hermit/scripts/lib/frontmatter.js b/plugins/claude-code-hermit/scripts/lib/frontmatter.js index 2ec8d6a2..c8b88073 100644 --- a/plugins/claude-code-hermit/scripts/lib/frontmatter.js +++ b/plugins/claude-code-hermit/scripts/lib/frontmatter.js @@ -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 keeping only the newest artifact per type. @@ -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 }; diff --git a/plugins/claude-code-hermit/scripts/reflect-precheck.js b/plugins/claude-code-hermit/scripts/reflect-precheck.js index 9d25923f..27c7ad15 100644 --- a/plugins/claude-code-hermit/scripts/reflect-precheck.js +++ b/plugins/claude-code-hermit/scripts/reflect-precheck.js @@ -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'); @@ -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; } diff --git a/plugins/claude-code-hermit/scripts/weekly-review.js b/plugins/claude-code-hermit/scripts/weekly-review.js index 8c9acb2f..4e81e172 100644 --- a/plugins/claude-code-hermit/scripts/weekly-review.js +++ b/plugins/claude-code-hermit/scripts/weekly-review.js @@ -8,7 +8,7 @@ const fs = require('fs'); const path = require('path'); -const { readFrontmatter, parseFrontmatter, newestByType, globDir } = require('./lib/frontmatter'); +const { readFrontmatter, parseFrontmatter, isEmptyAutoArchive, newestByType, globDir } = require('./lib/frontmatter'); const { lint: knowledgeLint } = require('./knowledge-lint'); // --- Args --- @@ -50,10 +50,6 @@ function isSelfDirected(s) { return s.fm.escalation === 'autonomous'; } -function isAutoArchived(s) { - return s.fm.closed_via === 'auto'; -} - // --- Determine current week --- const now = new Date(); const { year: currentYear, week: currentWeek } = getISOWeek(now); @@ -133,13 +129,14 @@ if (allHaveTokens) { } const avgTokens = sessionsCount > 0 ? Math.round(totalTokens / sessionsCount) : 0; -// Self-directed rate excludes auto-archived sessions from the denominator — -// closed_via: auto + status: completed would otherwise inflate the apparent completion/autonomy rate. -const autoArchivedSessions = weekSessions.filter(isAutoArchived); -const operatorSessions = weekSessions.filter(s => !isAutoArchived(s)); -const selfDirectedCount = operatorSessions.filter(isSelfDirected).length; -const assistedSessions = operatorSessions.filter(s => !isSelfDirected(s)); -const autonomousRate = operatorSessions.length > 0 ? selfDirectedCount / operatorSessions.length : 0; +// Exclude empty auto-archives from the autonomy calc: they have no content to +// attribute either way and would inflate the self-directed numerator via the +// operator_turns === 0 branch of isSelfDirected. See isEmptyAutoArchive in +// lib/frontmatter.js for the shared predicate (also used by reflect-precheck). +const contentfulSessions = weekSessions.filter(s => !isEmptyAutoArchive(s.fm)); +const selfDirectedCount = contentfulSessions.filter(isSelfDirected).length; +const assistedSessions = contentfulSessions.filter(s => !isSelfDirected(s)); +const autonomousRate = contentfulSessions.length > 0 ? selfDirectedCount / contentfulSessions.length : 0; // --- Operator dependence --- const assistedTags = {}; @@ -227,8 +224,7 @@ let body = `## Week of ${dateRange}\n\n`; if (sessionsCount > 0) { body += `### Sessions\n`; body += `${sessionsCount} session${sessionsCount !== 1 ? 's' : ''}, $${totalCost.toFixed(2)} (${formatTokens(totalTokens)}) total ($${avgCost.toFixed(2)} avg).\n`; - const autoArchivedNote = autoArchivedSessions.length > 0 ? `, ${autoArchivedSessions.length} auto-archived excluded` : ''; - body += `${selfDirectedCount} self-directed (operator_turns = 0), ${assistedSessions.length} operator-assisted${autoArchivedNote}.\n\n`; + body += `${selfDirectedCount} self-directed (operator_turns = 0), ${assistedSessions.length} operator-assisted.\n\n`; } else { body += `### Sessions\nNo sessions this week.\n\n`; } diff --git a/plugins/claude-code-hermit/skills/channel-responder/SKILL.md b/plugins/claude-code-hermit/skills/channel-responder/SKILL.md index ca33e9eb..328d7f65 100644 --- a/plugins/claude-code-hermit/skills/channel-responder/SKILL.md +++ b/plugins/claude-code-hermit/skills/channel-responder/SKILL.md @@ -75,7 +75,7 @@ After authorization passes, run: node ${CLAUDE_PLUGIN_ROOT}/scripts/record-operator-action.js --force ``` -This writes `state/last-operator-action.json` with the current timestamp, resetting the 12h AUTO_CLOSE quiet window. The `UserPromptSubmit` hook deliberately skips ` 10min`** — safe lull; close directly. + - Invoke `/claude-code-hermit:session-close --auto`. The auto-close path archives the session and clears `pending-close.json` itself on archive success. + - Stop. + + **c. `session_state == "in_progress"` AND `now - last_operator_action ≤ 10min`** — operator is currently active; queue. + - Write `.claude-code-hermit/state/pending-close.json` with `{"queued_at":"","queued_by":"daily-auto-close"}` (singleton; overwrite unconditionally). Use the Write tool. + - Stop. The heartbeat-precheck drain block will emit `AUTO_CLOSE` on the next tick where the operator has been idle >10 minutes. + +5. If `last-operator-action.json` is absent, unreadable, or has no valid `at` timestamp: treat as "operator has been idle indefinitely" → take branch (b) (close directly). Fail-open: it's better to close an arguably-still-active session than to leak the routine into perpetual noop. + +## Notes + +- The skill is intentionally silent. No operator notification on queue or drain — the existing `Auto-closed S-NNN` notification from `/session-close --auto` is the only operator-facing signal. +- The 10-minute lull threshold is hardcoded. If operators report mid-conversation closes, raise it; the threshold is a single constant in this skill and in `scripts/heartbeat-precheck.js`. +- When heartbeat is disabled and the operator stays active past midnight, this routine itself acts as the slow-path drain on the next day's tick: it re-evaluates and closes directly if branch (b)'s conditions are met. diff --git a/plugins/claude-code-hermit/skills/hatch/SKILL.md b/plugins/claude-code-hermit/skills/hatch/SKILL.md index 068f292b..50f6fa99 100644 --- a/plugins/claude-code-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-hermit/skills/hatch/SKILL.md @@ -124,6 +124,7 @@ Initialize state files (inline — shape-insensitive or append-only): - `.claude-code-hermit/state/proposal-metrics.jsonl`: empty file — append-only, not schema-sensitive JSON state - `.claude-code-hermit/state/routine-metrics.jsonl`: empty file — append-only routine fire log (`fired` events written by `scripts/log-routine-event.sh` from CronCreate prompts) - `.claude-code-hermit/state/update-history.jsonl`: empty file — append-only log of `hermit-docker update` runs +- `.claude-code-hermit/state/pending-close.json`: do NOT initialize — created lazily by the `daily-auto-close` skill when the midnight routine fires while the operator is currently active. Deleted by `session-close --auto` after the archive succeeds. - Read the template files from `${CLAUDE_SKILL_DIR}/../../state-templates/` - Copy `alert-state.json.template` → `.claude-code-hermit/state/alert-state.json` diff --git a/plugins/claude-code-hermit/skills/heartbeat/SKILL.md b/plugins/claude-code-hermit/skills/heartbeat/SKILL.md index e34b2e0b..508b4ee5 100644 --- a/plugins/claude-code-hermit/skills/heartbeat/SKILL.md +++ b/plugins/claude-code-hermit/skills/heartbeat/SKILL.md @@ -29,10 +29,10 @@ This subcommand is the handler for `HEARTBEAT_EVALUATE` notifications emitted by 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`. 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). - 3. Notify the operator per CLAUDE-APPEND.md § Operator Notification: "Auto-closed S-NNN after 12h quiet." + - `AUTO_CLOSE` → operator inactivity exceeded the threshold (12h of no operator action, or 10-min lull after a `daily-auto-close` queued at midnight). Run the auto-close sequence, then stop: + 1. Append to SHELL.md `## Monitoring`: `[HH:MM] Heartbeat: auto-closed.` (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; clears `state/pending-close.json` after archive succeeds). + 3. Notify the operator per CLAUDE-APPEND.md § Operator Notification: "Auto-closed S-NNN." 4. Emit `HEARTBEAT_AUTO_CLOSED`. Stop. Do NOT run the EVALUATE flow — the session is being archived; generating stale-session alerts for a closing session would create phantom dedup entries. - `EVALUATE` → continue to step 3. 3. Read `${CLAUDE_PLUGIN_ROOT}/skills/heartbeat/reference.md` for the semantic key taxonomy, alert deduplication procedure, self-evaluation steps, and output format. diff --git a/plugins/claude-code-hermit/skills/reflect/SKILL.md b/plugins/claude-code-hermit/skills/reflect/SKILL.md index 94addb4a..6aabbc74 100644 --- a/plugins/claude-code-hermit/skills/reflect/SKILL.md +++ b/plugins/claude-code-hermit/skills/reflect/SKILL.md @@ -34,7 +34,7 @@ This skill is **silent by default**. Only notify the operator (per the channel p b. Read all proposals with `status: accepted`. Sort by `accepted_date` ascending. Resume from the proposal after `last_resolution_check`, wrapping around. Take up to 5. c. If the accepted list from step b is empty, skip to step f. d. For each proposal: read its `title` and Evidence section to understand the original pattern. - Delegate the session fetch to the built-in `Explore` subagent. Prompt: `Glob .claude-code-hermit/sessions/S-*-REPORT.md. Sort descending by filename. Read the 3 most recent and return: filename, date from frontmatter, and the full body verbatim — skip any report whose frontmatter has closed_via: auto and grab the next-most-recent in its place (auto-closed reports have no operator-curated content and must not count as evidence of pattern absence or presence) — do not truncate, summarize, or excerpt (full body is required for pattern presence/absence detection). If a body exceeds your read window, say so explicitly per file rather than silently trimming.` If Explore returns truncated bodies for any of the 3 files, fall back to reading those files inline with the Read tool before evaluating step e. + Delegate the session fetch to the built-in `Explore` subagent. Prompt: `Glob .claude-code-hermit/sessions/S-*-REPORT.md. Sort descending by filename. Read the 3 most recent and return: filename, date from frontmatter, and the full body verbatim — do not truncate, summarize, or excerpt (full body is required for pattern presence/absence detection). If a body exceeds your read window, say so explicitly per file rather than silently trimming.` If Explore returns truncated bodies for any of the 3 files, fall back to reading those files inline with the Read tool before evaluating step e. e. If the pattern is **absent** from all 3 checked sessions — apply the cadence-aware resolution rule: **Compute original cadence:** @@ -98,7 +98,7 @@ If SHELL.md status is `idle` — think broader: ``` When accepted via `proposal-act`, this JSON is parsed and added to `config.json` routines automatically. -- Is a routine firing repeatedly with no visible downstream effect? Read the last 200 lines of `state/routine-metrics.jsonl` inline — count `fired` events per `routine_id` where `ts` falls within the last 14 days. Then delegate the session citation check to the built-in `Explore` subagent. Prompt: `Glob .claude-code-hermit/sessions/S-*-REPORT.md. Read the 3 most recent, skipping any whose frontmatter has closed_via: auto (auto-closed sessions have no curated content and would bias toward "routine had no effect"). Return which routine_ids appear in any session body.` If a routine has ≥5 fires in the last 14 days and no session report cites its `routine_id` or skill output as producing findings, decisions, or follow-ups — apply the Three-Condition Rule. If all three conditions hold: +- Is a routine firing repeatedly with no visible downstream effect? Read the last 200 lines of `state/routine-metrics.jsonl` inline — count `fired` events per `routine_id` where `ts` falls within the last 14 days. Then delegate the session citation check to the built-in `Explore` subagent. Prompt: `Glob .claude-code-hermit/sessions/S-*-REPORT.md. Read the 3 most recent. Return which routine_ids appear in any session body.` If a routine has ≥5 fires in the last 14 days and no session report cites its `routine_id` or skill output as producing findings, decisions, or follow-ups — apply the Three-Condition Rule. If all three conditions hold: - Propose `enabled: false` (disable) via a `Type: routine` proposal reusing the existing `id`. `proposal-act` upserts by `id`, so no delete path is needed. - Or propose a changed `schedule` if the routine is valuable but mis-timed. - Include the fire count + window in the proposal's Evidence section. diff --git a/plugins/claude-code-hermit/skills/session-close/SKILL.md b/plugins/claude-code-hermit/skills/session-close/SKILL.md index 73da9cb3..f0b1989f 100644 --- a/plugins/claude-code-hermit/skills/session-close/SKILL.md +++ b/plugins/claude-code-hermit/skills/session-close/SKILL.md @@ -8,7 +8,7 @@ description: Closes the current work session with a structured handoff. Archives `/session-close` is always a **Full Shutdown**. The operator explicitly invoked it — that's the confirmation. No close mode decision, no prompting. -When invoked with `--auto` by heartbeat (after 12h SHELL.md inactivity), the operator did not invoke it. The auto-close path bypasses summary-gathering, skips reflect (step 5), skips the heartbeat-stop step (step below), and stamps `closed_via: auto` in the archive frontmatter via the session-mgr payload. +When invoked with `--auto` by heartbeat (either after 12h SHELL.md inactivity, or via the `daily-auto-close` pending-flag drain after a 10-min lull), the operator did not invoke it. The auto-close path bypasses summary-gathering, skips reflect (step 5), skips the heartbeat-stop step (step below), stamps `closed_via: auto` in the archive frontmatter via the session-mgr payload, and clears `state/pending-close.json` after the archive succeeds. Idle transitions happen automatically at task boundaries (handled by the `session` skill). By the time the operator runs `/session-close`, they want out. @@ -25,7 +25,7 @@ Use this when the operator wants to end everything (via `hermit-stop` or explici ### Auto-close path (`--auto`) -When invoked with `--auto` by heartbeat, skip steps 1–5 and jump directly to step 6 (Tasks cleanup) then step 7 (session-mgr archive). Pass this templated payload to session-mgr: +When invoked with `--auto` by heartbeat, skip steps 1–5 and jump directly to step 6 (Tasks cleanup), step 7 (session-mgr archive), and step 8 (pending-close cleanup). Pass this templated payload to session-mgr: ``` Status: completed @@ -37,7 +37,9 @@ Closed Via: auto Next Start Point: Fresh start. ``` -Write `Auto-closed after 12h quiet.` as the first line of `## Overview` in the session report. +Write `Auto-closed by heartbeat.` as the first line of `## Overview` in the session report. + +If the archive in step 7 fails, leave `pending-close.json` in place so the next heartbeat tick retries the drain — skip step 8. --- @@ -63,6 +65,7 @@ Write `Auto-closed after 12h quiet.` as the first line of `## Overview` in the s Next Start Point: ``` Also include the task table (if native Tasks were created). +8. **Pending-close cleanup (both paths).** After the session-mgr archive returns success, delete `.claude-code-hermit/state/pending-close.json` if it exists (`rm -f` — ignore if absent). Any pending midnight-drain flag is invalidated by a successful close, regardless of trigger; without this step a flag queued before an operator-invoked close would survive and the next session's first heartbeat tick could fire `AUTO_CLOSE` against it. --- diff --git a/plugins/claude-code-hermit/state-templates/config.json.template b/plugins/claude-code-hermit/state-templates/config.json.template index 6ba12f95..85e7d708 100644 --- a/plugins/claude-code-hermit/state-templates/config.json.template +++ b/plugins/claude-code-hermit/state-templates/config.json.template @@ -21,7 +21,8 @@ {"id": "heartbeat-restart", "schedule": "0 4 * * *", "skill": "claude-code-hermit:heartbeat start", "run_during_waiting": true, "enabled": true}, {"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": "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": { diff --git a/plugins/claude-code-hermit/tests/test-auto-close.sh b/plugins/claude-code-hermit/tests/test-auto-close.sh index 57b7e731..2f7b66ee 100644 --- a/plugins/claude-code-hermit/tests/test-auto-close.sh +++ b/plugins/claude-code-hermit/tests/test-auto-close.sh @@ -48,7 +48,8 @@ run_test "heartbeat-precheck: fresh SHELL.md → EVALUATE (not AUTO_CLOSE)" bash cleanup # ------------------------------------------------------- -# 2. reflect-precheck: only auto-archived session report → EMPTY (not compute) +# 2. reflect-precheck: auto-archived session report DOES trigger compute phase +# (the prior closed_via: auto skip was removed so daily-midnight archives reach reflect) # ------------------------------------------------------- workdir="$(mktemp -d)" mkdir -p "$workdir/.claude-code-hermit/sessions" @@ -68,11 +69,11 @@ tags: [] proposals_created: [] task: "test" escalation: balanced -operator_turns: 0 +operator_turns: 3 closed_via: auto --- ## Overview -Auto-closed after 12h quiet. +Auto-closed by heartbeat. EOF # reflection-state.json: old lastRunAt so the report mtime appears newer, # old since so phase is 'adult' (prevents newborn/digest from firing). @@ -82,8 +83,8 @@ EOF # session_state: idle so the in_progress short-circuit in reflect-precheck doesn't apply echo '{"session_state":"idle"}' > "$workdir/.claude-code-hermit/state/runtime.json" out="$(cd "$workdir" && node "$REFLECT_PRECHECK" .claude-code-hermit "$REPO_ROOT" 2>/dev/null)" -run_test "reflect-precheck: only auto-archived report → EMPTY (not compute)" \ - bash -c "[ '$out' = 'EMPTY' ]" +run_test "reflect-precheck: auto-archived report newer than last_reflection → triggers compute phase (skip removed)" \ + bash -c "[ '$out' != 'EMPTY' ]" cleanup # ------------------------------------------------------- @@ -144,11 +145,11 @@ run_test "weekly-review: total_cost_usd: 2.30 (both summed)" bash -c \ "grep -q 'total_cost_usd: 2.30' '$review_file'" run_test "weekly-review: body shows 2 sessions headline" bash -c \ "grep -q '2 sessions' '$review_file'" -run_test "weekly-review: auto-archived excluded note appears" bash -c \ - "grep -q '1 auto-archived excluded' '$review_file'" -# S-001 has operator_turns=5 → not self-directed; S-002 excluded from denominator -# autonomousRate = 0/1 = 0.00 -run_test "weekly-review: self_directed_rate: 0.00 (auto-archived excluded from denominator)" bash -c \ +run_test "weekly-review: auto-archived excluded note GONE (filter removed)" bash -c \ + "! grep -q 'auto-archived excluded' '$review_file'" +# Both sessions have operator_turns > 0 → neither self-directed. +# Denominator is now all sessions (no auto exclusion). autonomousRate = 0/2 = 0.00. +run_test "weekly-review: self_directed_rate: 0.00 (both in denominator, neither self-directed)" bash -c \ "grep -q 'self_directed_rate: 0.00' '$review_file'" cleanup @@ -338,4 +339,315 @@ run_test "--force overwrites existing file (channel inbound bumps clock)" bash - cd "$ORIG_DIR" cleanup +echo "" +echo "=== daily-auto-close lull + pending-close drain ===" +echo "" + +# Reusable fixture for the drain cases: HEARTBEAT.md with one item + alert-state.json scaffold. +hb_setup() { + local dir="$1" + mkdir -p "$dir/.claude-code-hermit/sessions" + mkdir -p "$dir/.claude-code-hermit/state" + printf '# Heartbeat\n\n- [ ] Check system\n' > "$dir/.claude-code-hermit/HEARTBEAT.md" + touch "$dir/.claude-code-hermit/sessions/SHELL.md" + echo '{"alerts":{},"last_digest_date":null,"self_eval":{},"total_ticks":0}' \ + > "$dir/.claude-code-hermit/state/alert-state.json" +} + +# ------------------------------------------------------- +# drain.1. pending-close.json + last_op > 10min + in_progress → AUTO_CLOSE +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-20T22:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: pending-close + last_op > 10min + in_progress → AUTO_CLOSE" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.2. pending-close.json + last_op < 10min + in_progress → not AUTO_CLOSE +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-20T22:40:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: pending-close + last_op < 10min + in_progress → does NOT emit AUTO_CLOSE" bash -c "[ '$out' != 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.3. pending-close.json + session_state == idle → not AUTO_CLOSE (stale flag) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-20T22:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"idle"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: pending-close + session_state idle → does NOT emit AUTO_CLOSE" bash -c "[ '$out' != 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.4. drain bypasses active-hours skip (load-bearing invariant) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close","heartbeat":{"active_hours":{"start":"08:00","end":"23:00"}}}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +# config has active_hours that EXCLUDE 03:00 (we'll send HERMIT_NOW at 03:00) +echo '{"timezone":"UTC","heartbeat":{"active_hours":{"start":"08:00","end":"23:00"}}}' > "$workdir/.claude-code-hermit/config.json" +echo '{"at":"2026-05-21T02:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-21T03:00:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: outside active hours + pending-close + lull → AUTO_CLOSE (bypasses active-hours skip)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.5. pending-close.json absent → existing 12h fallback path unchanged +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"at":"2026-05-20T09:00:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:00:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: no pending flag + last_op 13h ago → AUTO_CLOSE via existing 12h fallback (regression guard)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.6. malformed pending-close.json → no drain, no crash, falls through +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +printf '{not valid' > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-20T22:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: malformed pending-close.json → no drain, no crash, falls through" bash -c "[ '$out' != 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.7. hook smoke: [hermit-routine:daily-auto-close ...] → file NOT written +# (proves the routine fire doesn't poison the very clock it reads) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +mkdir -p "$workdir/.claude-code-hermit/state" +out="$(cd "$workdir" && echo '{"prompt":"[hermit-routine:daily-auto-close] Invoke /claude-code-hermit:daily-auto-close."}' | node "$RECORD_HOOK")" +run_test "hook smoke: [hermit-routine:daily-auto-close prefix → file NOT written" bash -c "[ ! -f '$workdir/.claude-code-hermit/state/last-operator-action.json' ]" +cleanup + +# ------------------------------------------------------- +# drain.8. pending-close.json + in_progress + last-operator-action.json ABSENT → AUTO_CLOSE +# (per daily-auto-close SKILL.md step 5: absent clock = idle indefinitely = fail-open close) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +# NO last-operator-action.json written — fresh install scenario. +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: pending-close + in_progress + absent last-op → AUTO_CLOSE (fail-open)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.9. pending-close.json + in_progress + last-operator-action.json malformed → AUTO_CLOSE +# (malformed at-field treated the same as absent — fail-open) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"not-a-date"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: pending-close + in_progress + malformed last-op → AUTO_CLOSE (fail-open)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.10. HEARTBEAT.md missing + pending-close + lull → AUTO_CLOSE +# (drain runs before the HEARTBEAT.md SKIP gate — the close is the signal, +# not a notification, and must not depend on operator-editable HEARTBEAT.md) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +mkdir -p "$workdir/.claude-code-hermit/sessions" +mkdir -p "$workdir/.claude-code-hermit/state" +# Deliberately NO HEARTBEAT.md +touch "$workdir/.claude-code-hermit/sessions/SHELL.md" +echo '{"alerts":{},"last_digest_date":null,"self_eval":{},"total_ticks":0}' \ + > "$workdir/.claude-code-hermit/state/alert-state.json" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-20T22:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: HEARTBEAT.md missing + pending-close + lull → AUTO_CLOSE (drain bypasses SKIP gate)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.11. HEARTBEAT.md empty + pending-close + lull → AUTO_CLOSE +# (drain runs before the empty-checklist SKIP gate too) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +# Overwrite HEARTBEAT.md with no checklist items +printf '# Heartbeat\n\nNo items today.\n' > "$workdir/.claude-code-hermit/HEARTBEAT.md" +echo '{"queued_at":"2026-05-20T22:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-20T22:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-001"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-20T22:45:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: HEARTBEAT.md no-checklist + pending-close + lull → AUTO_CLOSE (drain bypasses SKIP gate)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.12. stale queued_at (>24h) + absent last-op + in_progress → NO AUTO_CLOSE +# (defends fresh sessions against premature close when a leftover flag from a +# crashed prior session coincides with a missing last-op clock) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-19T00:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +# NO last-operator-action.json — fresh-session scenario after prior crash +echo '{"session_state":"in_progress","session_id":"S-002"}' > "$workdir/.claude-code-hermit/state/runtime.json" +# HERMIT_NOW is 2026-05-21 → queued_at is 48h+ old → stale flag +out="$(cd "$workdir" && HERMIT_NOW="2026-05-21T01:00:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: stale queued_at (>24h) + absent last-op → NO AUTO_CLOSE (stale-flag guard)" bash -c "[ '$out' != 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.13. pending-close.json missing queued_at + absent last-op → NO AUTO_CLOSE +# (defensive: if queued_at can't be parsed we can't tell the flag's age, so +# don't fail-open close — wait for either a valid last-op or the next routine fire) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +# NO last-operator-action.json +echo '{"session_state":"in_progress","session_id":"S-003"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-21T01:00:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: pending-close missing queued_at + absent last-op → NO AUTO_CLOSE (defensive)" bash -c "[ '$out' != 'AUTO_CLOSE' ]" +cleanup + +# ------------------------------------------------------- +# drain.14. stale queued_at + VALID old last-op (>10min) → AUTO_CLOSE +# (a stale flag is still actionable when last-op proves a real lull; the staleness +# guard only suppresses the fail-open path, not the standard lull-check path) +# ------------------------------------------------------- +workdir="$(mktemp -d)" +hb_setup "$workdir" +echo '{"queued_at":"2026-05-19T00:00:00+00:00","queued_by":"daily-auto-close"}' \ + > "$workdir/.claude-code-hermit/state/pending-close.json" +echo '{"at":"2026-05-21T00:30:00+00:00"}' > "$workdir/.claude-code-hermit/state/last-operator-action.json" +echo '{"session_state":"in_progress","session_id":"S-004"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && HERMIT_NOW="2026-05-21T01:00:00+00:00" node "$HEARTBEAT_PRECHECK" .claude-code-hermit)" +run_test "drain: stale queued_at + valid >10min last-op → AUTO_CLOSE (lull-check path unaffected by guard)" bash -c "[ '$out' = 'AUTO_CLOSE' ]" +cleanup + +echo "" +echo "=== empty-12h-archive exclusion in weekly-review / reflect-precheck ===" +echo "" + +# ------------------------------------------------------- +# exclude.1. weekly-review: closed_via:auto + operator_turns:0 EXCLUDED from autonomy denominator +# ------------------------------------------------------- +workdir="$(mktemp -d)" +mkdir -p "$workdir/.claude-code-hermit/sessions" +mkdir -p "$workdir/.claude-code-hermit/state" +echo '{"timezone":"UTC"}' > "$workdir/.claude-code-hermit/config.json" +TODAY="$(date -u +%Y-%m-%dT12:00:00+00:00)" +cat > "$workdir/.claude-code-hermit/sessions/S-001-REPORT.md" << EOF +--- +id: S-001 +status: completed +date: ${TODAY} +duration: 2h +cost_usd: 1.50 +tokens: 50000 +tags: [] +proposals_created: [] +task: "real work" +escalation: balanced +operator_turns: 5 +closed_via: operator +--- +## Overview +Real work session. +EOF +cat > "$workdir/.claude-code-hermit/sessions/S-002-REPORT.md" << EOF +--- +id: S-002 +status: completed +date: ${TODAY} +duration: 12h +cost_usd: 0.00 +tokens: 0 +tags: [] +proposals_created: [] +task: "" +escalation: balanced +operator_turns: 0 +closed_via: auto +--- +## Overview +Auto-closed by heartbeat. +EOF +cd "$workdir" && node "$WEEKLY_REVIEW" .claude-code-hermit /nonexistent 2>/dev/null +review_file="$(find "$workdir/.claude-code-hermit/compiled" -name 'review-weekly-*.md' | head -1)" +cd "$ORIG_DIR" +# Both sessions count in sessions_count and total_cost (raw aggregates). +run_test "weekly-review: sessions_count: 2 (raw count includes empty 12h archive)" bash -c \ + "grep -q 'sessions_count: 2' '$review_file'" +# S-002 (empty 12h auto) excluded from autonomy calc. Denominator = 1 (S-001 only). +# S-001 has operator_turns=5 → not self-directed → numerator=0. autonomousRate = 0/1 = 0.00. +run_test "weekly-review: self_directed_rate: 0.00 (S-001 in denominator, S-002 empty-12h excluded)" bash -c \ + "grep -q 'self_directed_rate: 0.00' '$review_file'" +# Header text: 1 operator-assisted (S-001 with operator_turns=5), 0 self-directed. +run_test "weekly-review: '0 self-directed' (empty 12h archive NOT counted as self-directed)" bash -c \ + "grep -q '0 self-directed' '$review_file'" +cleanup + +# ------------------------------------------------------- +# exclude.2. reflect-precheck: closed_via:auto + operator_turns:0 does NOT trigger compute phase +# ------------------------------------------------------- +workdir="$(mktemp -d)" +mkdir -p "$workdir/.claude-code-hermit/sessions" +mkdir -p "$workdir/.claude-code-hermit/state" +mkdir -p "$workdir/.claude-code-hermit/proposals" +cp "$FIXTURES/shell-session.md" "$workdir/.claude-code-hermit/sessions/SHELL.md" +echo '{"timezone":"UTC"}' > "$workdir/.claude-code-hermit/config.json" +cat > "$workdir/.claude-code-hermit/sessions/S-001-REPORT.md" << 'EOF' +--- +id: S-001 +status: completed +date: 2026-01-15T10:00:00+00:00 +duration: 12h +cost_usd: 0.00 +tokens: 0 +tags: [] +proposals_created: [] +task: "" +escalation: balanced +operator_turns: 0 +closed_via: auto +--- +## Overview +Auto-closed by heartbeat. +EOF +cat > "$workdir/.claude-code-hermit/state/reflection-state.json" << 'EOF' +{"counters":{"last_run_at":"2020-01-01T00:00:00Z","since":"2020-01-01T00:00:00Z"}} +EOF +echo '{"session_state":"idle"}' > "$workdir/.claude-code-hermit/state/runtime.json" +out="$(cd "$workdir" && node "$REFLECT_PRECHECK" .claude-code-hermit "$REPO_ROOT" 2>/dev/null)" +run_test "reflect-precheck: only empty 12h archive (operator_turns:0, closed_via:auto) → EMPTY (skipped)" \ + bash -c "[ '$out' = 'EMPTY' ]" +cleanup + print_results