ktg-plugin-marketplace/plugins/ultraplan-local/hooks/scripts/pre-compact-flush.mjs
Kjell Tore Guttormsen 14ecda886c feat(voyage)!: bulk content rewrite ultra -> voyage/trek prose [skip-docs]
Sed-pipeline (16 patterns, longest-match-first) sweeper residuelle ultra*-treff
i prose, command-narrativ, agent-prompts, hook-kommentarer, doc-prosa.

Pipeline-utvidelser fra V4-prompten:
- BSD-syntax [[:<:]]ultra[[:>:]] istedenfor \bultra\b (BSD sed mangler \b)
- 6 compound-patterns for ultraplan/ultraexecute/ultraresearch/ultrabrief/
  ultrareview/ultracontinue uten -local-suffiks
- ultra*-stats glob -> trek*-stats glob
- Linje-eksklusjon redusert til ultra-cc-architect (Q8); session-state-
  eksklusjonen var over-protektiv
- File-eksklusjon utvidet til settings.json, package.json, plugin.json,
  hele .claude/-treet (gitignored + V5-territorium)

Q8-undantak holdt: architecture-discovery.mjs + project-discovery.mjs urort.
Filnavn-konvensjon holdt: .session-state.local.json + *.local.* preservert.

Manuell narrative-fix: tests/lib/agent-frontmatter.test.mjs linje 10
mangled "/ultra*-local" til "/voyage*-local" (ingen slik kommando finnes);
korrigert til "/trek*".

Residualer utenfor scope (V5 handterer): package.json + .claude-plugin/
plugin.json (Step 12-14 versjons-bump). .claude/* er gitignored
spec-historikk med tilsiktet BEFORE/AFTER-narrativ.

Part of voyage-rebrand session 3 (Wave 4 / Step 10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 15:08:20 +02:00

186 lines
7.2 KiB
JavaScript

#!/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);