#!/usr/bin/env node // Hook: pre-compact-flush.mjs // Event: PreCompact (Claude Code v2.1.105+) // Purpose: Flush progress.json drift before context compaction so // /trekexecute --resume works after long conversations. // Direct fix for the documented P0 in // docs/trekexecute-v2-observations-from-config-audit-v4.md. // // v3.3.0: also refreshes sibling .session-state.local.json // (Handover 7) so /trekcontinue can detect a resumable session // even after a compaction event mid-run. // // Behavior: // 1. Locate {cwd}/.claude/projects/* / progress.json (any nested project) // 2. Read progress.json + sibling plan.md // 3. Run `git log --oneline {session_start_sha}..HEAD` // 4. For each commit, match against plan steps' commit_message_pattern // 5. If derived current_step > stored current_step → write fresh checkpoint // atomically (tmp + rename), monotonic only (current_step never decreases). // 6. Refresh sibling .session-state.local.json if present and status is // resumable (in_progress | partial) — bumps updated_at only. Never // creates the state file; creation is the writer's job at session-end. // Skips if status is completed/failed/stopped (non-resumable or terminal). // 7. Always exit 0 — NEVER blocks compaction. // // v3.3.0: // - atomicWrite extracted to lib/util/atomic-write.mjs for reuse // - File reformatted (removed pre-existing leading-whitespace syntax error // that silently broke the hook since v3.1.0; PreCompact swallowed it) // - Added Handover 7 sibling-state refresh import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { atomicWriteJson } from '../../lib/util/atomic-write.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(HERE, '..', '..'); function findProgressFiles(cwd) { const projectsDir = join(cwd, '.claude', 'projects'); if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return []; const out = []; for (const entry of readdirSync(projectsDir)) { const projDir = join(projectsDir, entry); if (!statSync(projDir).isDirectory()) continue; const progPath = join(projDir, 'progress.json'); if (existsSync(progPath) && statSync(progPath).isFile()) { out.push({ projDir, progPath, planPath: join(projDir, 'plan.md') }); } } return out; } function readJson(path) { try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; } } function readPlanCheckpointPatterns(planPath) { if (!existsSync(planPath)) return new Map(); const text = readFileSync(planPath, 'utf-8'); const map = new Map(); const stepRe = /^### Step (\d+):/gm; const checkpointRe = /\*\*Checkpoint:\*\*\s+`git commit -m "([^"]+)"`/; const headings = []; let m; while ((m = stepRe.exec(text)) !== null) { headings.push({ n: Number.parseInt(m[1], 10), idx: m.index }); } for (let i = 0; i < headings.length; i++) { const start = headings[i].idx; const end = i + 1 < headings.length ? headings[i + 1].idx : text.length; const body = text.slice(start, end); const cp = body.match(checkpointRe); if (cp) { const msg = cp[1]; const conventionalPrefix = (msg.match(/^([a-z]+)\(([^)]+)\):/) || [])[0]; if (conventionalPrefix) map.set(headings[i].n, conventionalPrefix); } } return map; } function gitLog(repoDir, baseSha) { if (!baseSha) return []; try { const out = execSync(`git -C "${repoDir}" log --pretty=format:'%H %s' ${baseSha}..HEAD 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, }); return out.trim().split('\n').filter(Boolean).map(line => { const sp = line.indexOf(' '); return { sha: line.slice(0, sp), subject: line.slice(sp + 1) }; }); } catch { return []; } } function deriveCurrentStep(progress, plan, gitCommits) { if (!progress || !progress.steps || gitCommits.length === 0) return null; const stored = progress.current_step || 0; let highestMatched = stored; for (const [stepN, prefix] of plan.entries()) { const matchedCommit = gitCommits.find(c => c.subject.startsWith(prefix.replace(/\\/g, ''))); if (matchedCommit && stepN > highestMatched) highestMatched = stepN; } return highestMatched; } function repoRootOf(dir) { try { return execSync(`git -C "${dir}" rev-parse --show-toplevel 2>/dev/null`, { encoding: 'utf-8', timeout: 2000 }).trim(); } catch { return null; } } // Resumable statuses for .session-state.local.json. `completed` is terminal; // `failed`/`stopped` are operator-action-required and should NOT be silently // refreshed by a background hook (would mask the alert). We only bump // updated_at for in_progress | partial — the active-work statuses. const SESSION_STATE_REFRESHABLE = new Set(['in_progress', 'partial']); function refreshSessionState(projDir) { const statePath = join(projDir, '.session-state.local.json'); if (!existsSync(statePath)) return false; const state = readJson(statePath); if (!state || typeof state !== 'object') return false; if (!SESSION_STATE_REFRESHABLE.has(state.status)) return false; // Monotonic guard: only mutate updated_at. Never touch status, project, // next_session_*. The writer (Phase 8 / helper) owns those fields. state.updated_at = new Date().toISOString(); atomicWriteJson(statePath, state); return true; } let stdinPayload = ''; try { stdinPayload = readFileSync(0, 'utf-8'); } catch { /* fine */ } const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd(); const progressFiles = findProgressFiles(cwd); if (progressFiles.length === 0) { process.exit(0); } let mutationsMade = 0; for (const { projDir, progPath, planPath } of progressFiles) { const progress = readJson(progPath); if (!progress || progress.status === 'completed') continue; const repoRoot = repoRootOf(projDir); if (!repoRoot) continue; const plan = readPlanCheckpointPatterns(planPath); if (plan.size === 0) continue; const sessionStart = progress.session_start_sha; if (!sessionStart) continue; const commits = gitLog(repoRoot, sessionStart); const derivedStep = deriveCurrentStep(progress, plan, commits); if (derivedStep !== null && derivedStep > (progress.current_step || 0)) { progress.current_step = derivedStep; progress.updated_at = new Date().toISOString(); if (!progress.steps[String(derivedStep)]) { progress.steps[String(derivedStep)] = { status: 'completed', attempts: 1, error: null, completed_at: progress.updated_at, commit: null, manifest_audit: 'n/a', note: 'reconstructed by pre-compact-flush from git log', }; } atomicWriteJson(progPath, progress); process.stderr.write(`[voyage] pre-compact flush: ${progPath} -> current_step=${derivedStep}\n`); mutationsMade++; } // Sibling .session-state.local.json refresh (Handover 7). Independent of // progress.json mutation — the state file may exist for a session that // hasn't advanced step yet, and we still want updated_at to track liveness. if (refreshSessionState(projDir)) { process.stderr.write(`[voyage] pre-compact refresh: ${projDir}/.session-state.local.json\n`); mutationsMade++; } } process.exit(0);