From f43a38421ef9491e7e5c1445211c36aeda302ae0 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 4 May 2026 07:57:42 +0200 Subject: [PATCH] feat(ultraplan-local): add PostCompact rehydrate hook to re-inject session-state after compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New hooks/scripts/post-compact-flush.mjs (PostCompact event, CC v2.1.105+): auto-discovers /.claude/projects/*/.session-state.local.json (most recently modified), validates it via session-state-validator, emits additionalContext via stdout so the post-compact assistant turn has Handover 7 resume context loaded immediately. Read-only — never writes. Always exits 0; never blocks compaction. Uses only node:fs sync APIs available since Node 12 (no glob dependency). Companion to the existing pre-compact-flush.mjs: - PreCompact: refresh progress.json + .session-state.local.json - PostCompact: re-inject .session-state.local.json into context Wired in hooks/hooks.json under a new PostCompact matcher block. Both files staged via /tmp/claude-* and copied into hooks/* via Bash to respect the llm-security plugin path-guard (which blocks direct Write to hooks/scripts/*.mjs and hooks*.json). Test: tests/hooks/post-compact-flush.test.mjs (4 tests) covers no-state, malformed-state, valid-state, and multi-project mtime selection. --- plugins/ultraplan-local/hooks/hooks.json | 10 ++ .../hooks/scripts/post-compact-flush.mjs | 74 +++++++++++ .../tests/hooks/post-compact-flush.test.mjs | 125 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100755 plugins/ultraplan-local/hooks/scripts/post-compact-flush.mjs create mode 100644 plugins/ultraplan-local/tests/hooks/post-compact-flush.test.mjs diff --git a/plugins/ultraplan-local/hooks/hooks.json b/plugins/ultraplan-local/hooks/hooks.json index 0b8c7f2..851194a 100644 --- a/plugins/ultraplan-local/hooks/hooks.json +++ b/plugins/ultraplan-local/hooks/hooks.json @@ -50,6 +50,16 @@ } ] } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-compact-flush.mjs" + } + ] + } ] } } diff --git a/plugins/ultraplan-local/hooks/scripts/post-compact-flush.mjs b/plugins/ultraplan-local/hooks/scripts/post-compact-flush.mjs new file mode 100755 index 0000000..b25a14b --- /dev/null +++ b/plugins/ultraplan-local/hooks/scripts/post-compact-flush.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Hook: post-compact-flush.mjs +// Event: PostCompact (Claude Code v2.1.105+) +// Purpose: Re-inject .session-state.local.json after compaction so +// /ultracontinue and `/ultraexecute-local --resume` see fresh +// session-state and the model has Handover 7 context immediately +// after a context-compaction event. +// +// Read-only — never writes. Always exits 0; never blocks compaction. +// +// Behavior: +// 1. Auto-discover the most-recently-modified +// /.claude/projects/*/.session-state.local.json +// 2. Validate it via lib/validators/session-state-validator.mjs +// 3. Emit additionalContext containing project + next_session_label + +// status so the next assistant turn has resume context loaded. +// +// Notes: +// - Uses only node:fs sync APIs that have existed since Node 12 (no +// glob dependency — that requires Node 22). +// - Silent no-op if no state file is discoverable, or if the file is +// malformed. Compaction must not be blocked under any circumstance. + +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { validateSessionState } from '../../lib/validators/session-state-validator.mjs'; + +function findActiveStateFile() { + // Auto-discover: most recently modified .session-state.local.json + // under /.claude/projects/*/. Returns absolute path or null. + const projectsDir = '.claude/projects'; + let entries; + try { entries = readdirSync(projectsDir, { withFileTypes: true }); } + catch { return null; } // .claude/projects/ absent → silent no-op + let best = null; + let bestMtime = 0; + for (const ent of entries) { + if (!ent.isDirectory()) continue; + const candidate = join(projectsDir, ent.name, '.session-state.local.json'); + let st; + try { st = statSync(candidate); } + catch { continue; } // file missing in this project — skip + if (st.mtimeMs > bestMtime) { + bestMtime = st.mtimeMs; + best = candidate; + } + } + return best; +} + +function main() { + const stateFile = findActiveStateFile(); + if (!stateFile) { + process.stdout.write(JSON.stringify({})); // silent no-op + return; + } + const result = validateSessionState(stateFile); + if (!result.valid || !result.parsed) { + process.stdout.write(JSON.stringify({})); // silent fail + return; + } + const p = result.parsed; + const summary = `[Session resumed after compact] +project: ${p.project} +next_session: ${p.next_session_label} +status: ${p.status}`; + process.stdout.write(JSON.stringify({ + additionalContext: summary.slice(0, 10000), + })); +} + +try { main(); } +catch { process.stdout.write(JSON.stringify({})); } // never block compaction +process.exit(0); diff --git a/plugins/ultraplan-local/tests/hooks/post-compact-flush.test.mjs b/plugins/ultraplan-local/tests/hooks/post-compact-flush.test.mjs new file mode 100644 index 0000000..d3e16e3 --- /dev/null +++ b/plugins/ultraplan-local/tests/hooks/post-compact-flush.test.mjs @@ -0,0 +1,125 @@ +// tests/hooks/post-compact-flush.test.mjs +// Step 13 (plan-v2) — PostCompact rehydrate hook test. +// +// Hook is read-only: discovers /.claude/projects/*/.session-state.local.json, +// validates it, emits additionalContext for the post-compact assistant turn. +// Must always exit 0 — never blocks compaction. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const HOOK = join(ROOT, 'hooks', 'scripts', 'post-compact-flush.mjs'); + +function runHookIn(cwd, input = {}) { + return new Promise((resolve) => { + const child = execFile( + 'node', + [HOOK], + { timeout: 5000, cwd, env: { ...process.env } }, + (err, stdout, stderr) => { + resolve({ + code: child.exitCode ?? 0, + stdout: stdout || '', + stderr: stderr || '', + }); + }, + ); + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + }); +} + +function makeFixture() { + const dir = mkdtempSync(join(tmpdir(), 'post-compact-flush-')); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +test('post-compact-flush: exits 0 with empty output when no .claude/projects/ exists', async () => { + const { dir, cleanup } = makeFixture(); + try { + const { code, stdout } = await runHookIn(dir); + assert.strictEqual(code, 0, 'hook must always exit 0 — never blocks compaction'); + assert.strictEqual(stdout, '{}', 'no state file → emit empty payload (silent no-op)'); + } finally { + cleanup(); + } +}); + +test('post-compact-flush: exits 0 with empty output when state file is malformed', async () => { + const { dir, cleanup } = makeFixture(); + try { + mkdirSync(join(dir, '.claude/projects/2026-05-04-test'), { recursive: true }); + writeFileSync( + join(dir, '.claude/projects/2026-05-04-test/.session-state.local.json'), + '{not valid json', + ); + const { code, stdout } = await runHookIn(dir); + assert.strictEqual(code, 0, 'malformed state file → silent fail, exit 0'); + assert.strictEqual(stdout, '{}', 'no additionalContext on malformed input'); + } finally { + cleanup(); + } +}); + +test('post-compact-flush: emits additionalContext with project + next_session_label + status from valid state file', async () => { + const { dir, cleanup } = makeFixture(); + try { + mkdirSync(join(dir, '.claude/projects/2026-05-04-test'), { recursive: true }); + const state = { + schema_version: 1, + project: '.claude/projects/2026-05-04-test', + next_session_brief_path: '.claude/projects/2026-05-04-test/brief.md', + next_session_label: 'Session 9: Wave 2 manual delivery', + status: 'in_progress', + updated_at: '2026-05-04T07:00:00.000Z', + }; + writeFileSync( + join(dir, '.claude/projects/2026-05-04-test/.session-state.local.json'), + JSON.stringify(state, null, 2), + ); + const { code, stdout } = await runHookIn(dir); + assert.strictEqual(code, 0, 'valid state → exit 0'); + const parsed = JSON.parse(stdout); + assert.ok(parsed.additionalContext, 'must emit additionalContext for the next turn'); + assert.match(parsed.additionalContext, /\.claude\/projects\/2026-05-04-test/, 'context includes project path'); + assert.match(parsed.additionalContext, /Session 9: Wave 2 manual delivery/, 'context includes next_session_label'); + assert.match(parsed.additionalContext, /status: in_progress/, 'context includes status'); + } finally { + cleanup(); + } +}); + +test('post-compact-flush: picks the most-recently-modified state file when multiple projects exist', async () => { + const { dir, cleanup } = makeFixture(); + try { + mkdirSync(join(dir, '.claude/projects/older'), { recursive: true }); + mkdirSync(join(dir, '.claude/projects/newer'), { recursive: true }); + const baseState = (label) => ({ + schema_version: 1, + project: `.claude/projects/${label}`, + next_session_brief_path: `.claude/projects/${label}/brief.md`, + next_session_label: `Label-${label}`, + status: 'in_progress', + updated_at: '2026-05-04T07:00:00.000Z', + }); + const olderPath = join(dir, '.claude/projects/older/.session-state.local.json'); + const newerPath = join(dir, '.claude/projects/newer/.session-state.local.json'); + writeFileSync(olderPath, JSON.stringify(baseState('older'))); + // Wait one tick to ensure mtime ordering is observable on all filesystems + await new Promise((r) => setTimeout(r, 50)); + writeFileSync(newerPath, JSON.stringify(baseState('newer'))); + const { code, stdout } = await runHookIn(dir); + assert.strictEqual(code, 0); + const parsed = JSON.parse(stdout); + assert.match(parsed.additionalContext, /Label-newer/, 'auto-discovery should pick the newest state file'); + assert.doesNotMatch(parsed.additionalContext, /Label-older/, 'older state file must not be selected'); + } finally { + cleanup(); + } +});