Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 43 additions & 12 deletions hooks/dist/nf-precompact.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
#!/usr/bin/env node
// hooks/nf-precompact.js
// PreCompact hook — injects nForma session state as additionalContext before context compaction.
// PreCompact hook — captures nForma session state before context compaction.
// Reads .planning/STATE.md "Current Position" section and any pending task files.
// Output survives compaction and appears in the first message of the compacted context.
// Fails open on all errors — never blocks compaction.
//
// IMPORTANT: the harness does NOT accept hookSpecificOutput/additionalContext for
// the PreCompact event (it validates output against a discriminated union that
// only includes PreToolUse/UserPromptSubmit/PostToolUse/PostToolBatch/Stop) — a
// PreCompact hook that emits it is rejected with "Invalid input" on every
// compaction. Instead we persist the continuation context to a sidecar file
// (.claude/precompact-continuation.txt) and emit NOTHING on stdout; the
// nf-session-start hook injects it via the valid SessionStart channel on the
// post-compaction SessionStart (source: "compact").
//
// Fails open on all errors — never blocks compaction, never writes to stdout.

'use strict';

Expand Down Expand Up @@ -208,7 +217,7 @@ process.stdin.on('end', () => {
} catch (e) {
process.stderr.write('[nf-precompact] Could not read STATE.md: ' + e.message + '\n');
additionalContext = 'nForma session resumed after compaction. Run `cat .planning/STATE.md` for project state.';
emitOutput(additionalContext);
emitOutput(cwd, additionalContext);
return;
}

Expand Down Expand Up @@ -302,7 +311,7 @@ process.stdin.on('end', () => {
additionalContext = lines.join('\n');
}

emitOutput(additionalContext);
emitOutput(cwd, additionalContext);

} catch (e) {
if (e instanceof SyntaxError) {
Expand All @@ -315,13 +324,35 @@ process.stdin.on('end', () => {
});
} // end require.main === module

function emitOutput(additionalContext) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreCompact',
additionalContext,
},
}));
// Persist the continuation context to a sidecar file for the post-compaction
// SessionStart hook to inject. We do NOT write to stdout: PreCompact output does
// not support hookSpecificOutput.additionalContext (the harness rejects it), so
// handing the context to nf-session-start via .claude/precompact-continuation.txt
// is the only valid channel. nf-session-start consumes-and-deletes the file on
// the next SessionStart (source: "compact"). Fail-open: any write error is
// swallowed so compaction is never blocked.
function emitOutput(cwd, additionalContext) {
try {
const claudeDir = path.join(cwd, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const target = path.join(claudeDir, 'precompact-continuation.txt');
// Symlink hardening: a pre-planted symlink at the target would make
// writeFileSync follow it and clobber an arbitrary file. Remove any existing
// non-regular file first, then write with O_NOFOLLOW so we never traverse a
// symlink that races in between (TOCTOU-safe). Falls back cleanly on error.
try {
const st = fs.lstatSync(target);
if (!st.isFile()) fs.rmSync(target, { force: true });
} catch (_) { /* target absent — nothing to clean up */ }
const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, 0o600);
try {
fs.writeFileSync(fd, additionalContext, 'utf8');
} finally {
fs.closeSync(fd);
}
} catch (e) {
process.stderr.write('[nf-precompact] Could not persist continuation context: ' + e.message + '\n');
}
process.exit(0);
}

Expand Down
31 changes: 31 additions & 0 deletions hooks/dist/nf-session-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,37 @@ if (require.main === module) (async () => {
// Collect all additionalContext pieces — write once at the end
const _contextPieces = [];

// Post-compaction continuation context (CONT-01).
// nf-precompact persists rich resume context to this sidecar before compaction,
// because the PreCompact event cannot inject additionalContext itself (the
// harness rejects PreCompact hookSpecificOutput). We consume-and-delete it here,
// injecting via the valid SessionStart channel. One-shot, fresh-only (6h), and
// source-tolerant so it works whether the post-compaction event reports
// source "compact"/"resume" or omits source entirely.
try {
const contPath = path.join(_hookCwd, '.claude', 'precompact-continuation.txt');
// Symlink hardening: lstat (not stat) so a planted symlink is NOT followed —
// a repo-controlled symlink here could otherwise leak an arbitrary file's
// contents into additionalContext. Only inject from a regular file, and cap
// the size so a huge file can't blow up the context budget. Always remove the
// path afterwards (one-shot), including the rejected-symlink case.
let _st = null;
try { _st = fs.lstatSync(contPath); } catch (_) { _st = null; }
if (_st) {
const MAX = 64 * 1024; // 64KB cap
const _ageMs = Date.now() - _st.mtimeMs;
const _fresh = _ageMs >= 0 && _ageMs < 6 * 60 * 60 * 1000;
const _src = _parsedInput && _parsedInput.source;
const _wantInject = _src === 'compact' || _src === 'resume' || _src == null;
if (_st.isFile() && _st.size <= MAX && _fresh && _wantInject) {
const cont = fs.readFileSync(contPath, 'utf8').trim();
if (cont) _contextPieces.push(cont);
}
// One-shot: delete regardless (stale / wrong-source / oversized / symlink).
try { fs.unlinkSync(contPath); } catch (_) {}
}
} catch (_) {}

// Telemetry surfacing — inject top unsurfaced issue as additionalContext
// Guard: only active when running inside the nForma dev repo itself
try {
Expand Down
55 changes: 43 additions & 12 deletions hooks/nf-precompact.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
#!/usr/bin/env node
// hooks/nf-precompact.js
// PreCompact hook — injects nForma session state as additionalContext before context compaction.
// PreCompact hook — captures nForma session state before context compaction.
// Reads .planning/STATE.md "Current Position" section and any pending task files.
// Output survives compaction and appears in the first message of the compacted context.
// Fails open on all errors — never blocks compaction.
//
// IMPORTANT: the harness does NOT accept hookSpecificOutput/additionalContext for
// the PreCompact event (it validates output against a discriminated union that
// only includes PreToolUse/UserPromptSubmit/PostToolUse/PostToolBatch/Stop) — a
// PreCompact hook that emits it is rejected with "Invalid input" on every
// compaction. Instead we persist the continuation context to a sidecar file
// (.claude/precompact-continuation.txt) and emit NOTHING on stdout; the
// nf-session-start hook injects it via the valid SessionStart channel on the
// post-compaction SessionStart (source: "compact").
//
// Fails open on all errors — never blocks compaction, never writes to stdout.

'use strict';

Expand Down Expand Up @@ -208,7 +217,7 @@ process.stdin.on('end', () => {
} catch (e) {
process.stderr.write('[nf-precompact] Could not read STATE.md: ' + e.message + '\n');
additionalContext = 'nForma session resumed after compaction. Run `cat .planning/STATE.md` for project state.';
emitOutput(additionalContext);
emitOutput(cwd, additionalContext);
return;
}

Expand Down Expand Up @@ -302,7 +311,7 @@ process.stdin.on('end', () => {
additionalContext = lines.join('\n');
}

emitOutput(additionalContext);
emitOutput(cwd, additionalContext);

} catch (e) {
if (e instanceof SyntaxError) {
Expand All @@ -315,13 +324,35 @@ process.stdin.on('end', () => {
});
} // end require.main === module

function emitOutput(additionalContext) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreCompact',
additionalContext,
},
}));
// Persist the continuation context to a sidecar file for the post-compaction
// SessionStart hook to inject. We do NOT write to stdout: PreCompact output does
// not support hookSpecificOutput.additionalContext (the harness rejects it), so
// handing the context to nf-session-start via .claude/precompact-continuation.txt
// is the only valid channel. nf-session-start consumes-and-deletes the file on
// the next SessionStart (source: "compact"). Fail-open: any write error is
// swallowed so compaction is never blocked.
function emitOutput(cwd, additionalContext) {
try {
const claudeDir = path.join(cwd, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const target = path.join(claudeDir, 'precompact-continuation.txt');
// Symlink hardening: a pre-planted symlink at the target would make
// writeFileSync follow it and clobber an arbitrary file. Remove any existing
// non-regular file first, then write with O_NOFOLLOW so we never traverse a
// symlink that races in between (TOCTOU-safe). Falls back cleanly on error.
try {
const st = fs.lstatSync(target);
if (!st.isFile()) fs.rmSync(target, { force: true });
} catch (_) { /* target absent — nothing to clean up */ }
const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, 0o600);
try {
fs.writeFileSync(fd, additionalContext, 'utf8');
} finally {
fs.closeSync(fd);
}
} catch (e) {
process.stderr.write('[nf-precompact] Could not persist continuation context: ' + e.message + '\n');
}
process.exit(0);
}

Expand Down
93 changes: 70 additions & 23 deletions hooks/nf-precompact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,35 @@ test('readPendingTasks: does NOT delete files (non-consuming)', () => {
assert.ok(fs.existsSync(filePath), 'File should still exist after read (non-consuming)');
});

// ─── Full subprocess (stdin→stdout) integration tests ───────────────────────
// ─── Full subprocess (stdin→sidecar) integration tests ──────────────────────
//
// Contract change: PreCompact may NOT emit hookSpecificOutput.additionalContext
// (the harness validates output against a union that excludes PreCompact and
// rejects it as "Invalid input" on every compaction). The hook now persists the
// continuation context to .claude/precompact-continuation.txt and emits nothing
// on stdout; nf-session-start injects it on the post-compaction SessionStart.

const SIDECAR = '.claude/precompact-continuation.txt';
function readSidecar(dir) {
const p = path.join(dir, SIDECAR);
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
}

test('subprocess: exits 0 and emits additionalContext when STATE.md has Current Position', () => {
test('subprocess: emits NO hookSpecificOutput on stdout (harness rejects PreCompact additionalContext)', () => {
// Red-prover for the "Invalid input" compaction bug: any hookSpecificOutput
// PreCompact output is rejected by the harness, so stdout must be empty.
const tmpDir = makeTmpDir();
writeStateFile(tmpDir, '## Current Position\n\nPhase: v0.19-05\n\n## Next\nmore');

const { exitCode, stdout, parsed } = runHook({ cwd: tmpDir });

assert.equal(exitCode, 0);
assert.equal(stdout.trim(), '', 'stdout must be empty (no JSON output)');
assert.equal(parsed, null, 'must not emit any JSON output');
assert.ok(!stdout.includes('hookSpecificOutput'), 'must not emit hookSpecificOutput');
});

test('subprocess: persists continuation sidecar when STATE.md has Current Position', () => {
const tmpDir = makeTmpDir();
writeStateFile(tmpDir, [
'# Project State',
Expand All @@ -166,38 +192,63 @@ test('subprocess: exits 0 and emits additionalContext when STATE.md has Current
'other stuff',
].join('\n'));

const { exitCode, parsed } = runHook({ cwd: tmpDir });
const { exitCode } = runHook({ cwd: tmpDir });

assert.equal(exitCode, 0);
assert.ok(parsed, 'stdout should be valid JSON');
assert.ok(parsed.hookSpecificOutput, 'should have hookSpecificOutput');
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreCompact');
const ctx = parsed.hookSpecificOutput.additionalContext;
const ctx = readSidecar(tmpDir);
assert.ok(ctx, 'sidecar file should be written');
assert.ok(ctx.includes('nForma CONTINUATION CONTEXT'), 'should include header');
assert.ok(ctx.includes('v0.19-05'), 'should include current position content');
assert.ok(ctx.includes('Resume Instructions'), 'should include resume instructions');
});

test('subprocess: includes pending task when pending-task.txt exists', () => {
test('subprocess: sidecar includes pending task when pending-task.txt exists', () => {
const tmpDir = makeTmpDir();
writeStateFile(tmpDir, '## Current Position\n\nPhase: v0.19-05\n\n## Other\nstuff');
writeClaudeFile(tmpDir, 'pending-task.txt', '/qnf:execute-phase v0.19-05');

const { exitCode, parsed } = runHook({ cwd: tmpDir });
const { exitCode } = runHook({ cwd: tmpDir });

assert.equal(exitCode, 0);
const ctx = parsed.hookSpecificOutput.additionalContext;
const ctx = readSidecar(tmpDir);
assert.ok(ctx, 'sidecar file should be written');
assert.ok(ctx.includes('Pending Task'), 'should include Pending Task section');
assert.ok(ctx.includes('/qnf:execute-phase v0.19-05'), 'should include task content');
});

test('subprocess: emits minimal fallback when STATE.md is absent', () => {
test('subprocess: persists minimal fallback when STATE.md is absent', () => {
const tmpDir = makeTmpDir(); // no STATE.md written

const { exitCode, parsed } = runHook({ cwd: tmpDir });
const { exitCode, stdout } = runHook({ cwd: tmpDir });

assert.equal(exitCode, 0);
assert.ok(parsed.hookSpecificOutput.additionalContext.includes('resumed after compaction'));
assert.equal(stdout.trim(), '', 'stdout must be empty');
const ctx = readSidecar(tmpDir);
assert.ok(ctx && ctx.includes('resumed after compaction'), 'minimal fallback persisted to sidecar');
});

test('subprocess: SYMLINK pre-planted at sidecar path → not followed, target untouched (security)', () => {
// A pre-planted symlink must NOT cause the write to clobber an arbitrary file.
const tmpDir = makeTmpDir();
writeStateFile(tmpDir, '## Current Position\n\nPhase: v0.44-01\n\n## Next\nx');
const victim = path.join(tmpDir, 'victim.txt');
fs.writeFileSync(victim, 'ORIGINAL victim contents', 'utf8');
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
fs.symlinkSync(victim, path.join(claudeDir, 'precompact-continuation.txt'));

const { exitCode } = runHook({ cwd: tmpDir });

assert.equal(exitCode, 0);
assert.equal(
fs.readFileSync(victim, 'utf8'),
'ORIGINAL victim contents',
'symlink target must NOT be overwritten by the sidecar write'
);
// The sidecar path is now a regular file holding the continuation (not a symlink).
const sc = path.join(claudeDir, 'precompact-continuation.txt');
assert.ok(!fs.lstatSync(sc).isSymbolicLink(), 'sidecar must be a regular file, not a symlink');
assert.ok(fs.readFileSync(sc, 'utf8').includes('CONTINUATION CONTEXT'), 'sidecar holds the continuation');
});

test('subprocess: fails open on invalid JSON stdin (exit 0)', () => {
Expand All @@ -210,17 +261,13 @@ test('subprocess: fails open on invalid JSON stdin (exit 0)', () => {
});

test('subprocess: falls back to process.cwd() when cwd field is absent', () => {
// Pass empty object — no cwd field. Hook should default to process.cwd() and not crash.
const { exitCode, parsed } = runHook({});

assert.equal(exitCode, 0);
assert.ok(parsed || true, 'Should produce valid output or minimal fallback');
});

test('subprocess: hookEventName is PreCompact', () => {
// Pass empty object — no cwd field. Hook should default to process.cwd().
// Set the child's cwd to a tmpdir so the sidecar is NOT written into the repo.
const tmpDir = makeTmpDir();
writeStateFile(tmpDir, '## Current Position\n\nsome state\n\n## Next\nmore');

const { parsed } = runHook({ cwd: tmpDir });
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreCompact');
const { exitCode } = runHook({}, { cwd: tmpDir });

assert.equal(exitCode, 0);
assert.ok(readSidecar(tmpDir), 'sidecar written into process.cwd() fallback dir');
});
31 changes: 31 additions & 0 deletions hooks/nf-session-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,37 @@ if (require.main === module) (async () => {
// Collect all additionalContext pieces — write once at the end
const _contextPieces = [];

// Post-compaction continuation context (CONT-01).
// nf-precompact persists rich resume context to this sidecar before compaction,
// because the PreCompact event cannot inject additionalContext itself (the
// harness rejects PreCompact hookSpecificOutput). We consume-and-delete it here,
// injecting via the valid SessionStart channel. One-shot, fresh-only (6h), and
// source-tolerant so it works whether the post-compaction event reports
// source "compact"/"resume" or omits source entirely.
try {
const contPath = path.join(_hookCwd, '.claude', 'precompact-continuation.txt');
// Symlink hardening: lstat (not stat) so a planted symlink is NOT followed —
// a repo-controlled symlink here could otherwise leak an arbitrary file's
// contents into additionalContext. Only inject from a regular file, and cap
// the size so a huge file can't blow up the context budget. Always remove the
// path afterwards (one-shot), including the rejected-symlink case.
let _st = null;
try { _st = fs.lstatSync(contPath); } catch (_) { _st = null; }
if (_st) {
const MAX = 64 * 1024; // 64KB cap
const _ageMs = Date.now() - _st.mtimeMs;
const _fresh = _ageMs >= 0 && _ageMs < 6 * 60 * 60 * 1000;
const _src = _parsedInput && _parsedInput.source;
const _wantInject = _src === 'compact' || _src === 'resume' || _src == null;
if (_st.isFile() && _st.size <= MAX && _fresh && _wantInject) {
const cont = fs.readFileSync(contPath, 'utf8').trim();
if (cont) _contextPieces.push(cont);
}
// One-shot: delete regardless (stale / wrong-source / oversized / symlink).
try { fs.unlinkSync(contPath); } catch (_) {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (_) {}

// Telemetry surfacing — inject top unsurfaced issue as additionalContext
// Guard: only active when running inside the nForma dev repo itself
try {
Expand Down
Loading
Loading