// tests/hooks/pre-compact-flush.test.mjs // The PreCompact hook rebuilds progress.json from git and refreshes the // session state before compaction. An independent review (2026-10-08) found // that the checkpoint form `git add … && git commit -m "…"` (plan template, // lekerepo trial 2 point A7) matched nothing, and that an empty match skipped // the session-state refresh too. No test covered the hook until now. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from '../helpers/child-env.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const HOOK = join(HERE, '..', '..', 'hooks', 'scripts', 'pre-compact-flush.mjs'); function git(cwd, ...args) { const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); return r.stdout.trim(); } function project(checkpoint, { stateStatus = 'in_progress' } = {}) { const repo = mkdtempSync(join(tmpdir(), 'voyage-precompact-')); git(repo, 'init', '-q'); git(repo, 'config', 'user.email', 't@example.invalid'); git(repo, 'config', 'user.name', 'test'); writeFileSync(join(repo, 'a.txt'), 'a\n'); git(repo, 'add', 'a.txt'); git(repo, 'commit', '-q', '-m', 'base'); const base = git(repo, 'rev-parse', 'HEAD'); writeFileSync(join(repo, 'a.txt'), 'b\n'); git(repo, 'add', 'a.txt'); git(repo, 'commit', '-q', '-m', 'feat(a): change a'); const dir = join(repo, '.claude', 'projects', 'p'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'plan.md'), ['# Plan', '', '### Step 1: Change a', '', `- **Checkpoint:** ${checkpoint}`, ''].join('\n')); writeFileSync(join(dir, 'progress.json'), JSON.stringify({ status: 'in_progress', current_step: 0, steps: {}, session_start_sha: base })); writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ status: stateStatus, updated_at: '2000-01-01T00:00:00.000Z' })); return { repo, dir }; } function run(repo) { return spawnSync(process.execPath, [HOOK], { input: '{}', encoding: 'utf8', env: childEnv({ CLAUDE_PROJECT_DIR: repo }) }); } const progress = (dir) => JSON.parse(readFileSync(join(dir, 'progress.json'), 'utf8')); const state = (dir) => JSON.parse(readFileSync(join(dir, '.session-state.local.json'), 'utf8')); for (const [name, cp] of [ ['the old form', '`git commit -m "feat(a): change a"`'], ['the template form with git add', '`git add a.txt && git commit -m "feat(a): change a"`'], ]) { test(`pre-compact: ${name} -> progress.json is rebuilt from git`, () => { const { repo, dir } = project(cp); try { assert.equal(run(repo).status, 0); assert.equal(progress(dir).current_step, 1); } finally { rmSync(repo, { recursive: true, force: true }); } }); } test('pre-compact: a plan with no recognisable checkpoint still refreshes the session state', () => { const { repo, dir } = project('`make commit`'); try { assert.equal(run(repo).status, 0); assert.equal(progress(dir).current_step, 0); assert.notEqual(state(dir).updated_at, '2000-01-01T00:00:00.000Z'); } finally { rmSync(repo, { recursive: true, force: true }); } });