feat(graceful-handoff): 2.0 — SessionStart auto-load handoff on resume/compact [skip-docs]
Step 6 of v2.0 plan. SessionStart hook fires on source: resume or source: compact, walks up to 3 levels searching for NEXT-SESSION-*.local.md, injects content via additionalContext, and archives the file (rename to *.archived.local.md) to prevent stale-load in later sessions. 9 tests cover sources, multi-level search, topic-slug variants, archive filtering, malformed payload.
This commit is contained in:
parent
81aba9a5f5
commit
4076bf904a
2 changed files with 201 additions and 0 deletions
|
|
@ -0,0 +1,93 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// session-start-load-handoff.mjs — graceful-handoff v2.0
|
||||||
|
// SessionStart hook: on `source: resume` or `source: compact`, find
|
||||||
|
// NEXT-SESSION-PROMPT.local.md (or NEXT-SESSION-*.local.md) in cwd and up
|
||||||
|
// to 3 levels above, inject contents into the new session's context, then
|
||||||
|
// archive the file to prevent stale-load in subsequent sessions.
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, readdirSync, renameSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
try {
|
||||||
|
return readFileSync(0, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find NEXT-SESSION-*.local.md in dir; returns path or null
|
||||||
|
function findHandoffIn(dir) {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir);
|
||||||
|
// Prefer NEXT-SESSION-PROMPT.local.md, then any NEXT-SESSION-*.local.md
|
||||||
|
const exact = entries.find(e => e === 'NEXT-SESSION-PROMPT.local.md');
|
||||||
|
if (exact) return join(dir, exact);
|
||||||
|
const match = entries.find(e => /^NEXT-SESSION-.*\.local\.md$/.test(e) && !/\.archived\./.test(e));
|
||||||
|
if (match) return join(dir, match);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk up from start, max 3 levels, looking for handoff
|
||||||
|
function findHandoffUpwards(start) {
|
||||||
|
let cur = start;
|
||||||
|
for (let i = 0; i < 4; i++) { // 0,1,2,3 — start + 3 ancestors
|
||||||
|
const found = findHandoffIn(cur);
|
||||||
|
if (found) return found;
|
||||||
|
const parent = dirname(cur);
|
||||||
|
if (parent === cur) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const raw = readStdin();
|
||||||
|
if (!raw.trim()) process.exit(0);
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = payload?.source;
|
||||||
|
if (source !== 'resume' && source !== 'compact') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = payload?.cwd || process.cwd();
|
||||||
|
const handoffPath = findHandoffUpwards(cwd);
|
||||||
|
if (!handoffPath) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = readFileSync(handoffPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject via additionalContext for clean structured output
|
||||||
|
const output = {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'SessionStart',
|
||||||
|
additionalContext: `<session-handoff source="${handoffPath}" loaded-by="graceful-handoff">\n${content}\n</session-handoff>`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(output));
|
||||||
|
|
||||||
|
// Archive to prevent stale-load in subsequent sessions
|
||||||
|
try {
|
||||||
|
const archived = handoffPath.replace(/\.local\.md$/, '.archived.local.md');
|
||||||
|
if (!existsSync(archived)) {
|
||||||
|
renameSync(handoffPath, archived);
|
||||||
|
}
|
||||||
|
} catch { /* archival is best-effort, never block injection */ }
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// session-start-load-handoff.test.mjs
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { runHook } from './hook-helper.mjs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'session-start-load-handoff.mjs');
|
||||||
|
|
||||||
|
function makeFixture() {
|
||||||
|
return mkdtempSync(join(tmpdir(), 'sessionstart-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('source: startup → silent (no injection)', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'should not load\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'startup', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
assert.equal(res.stdout.trim(), '', 'startup source should not inject');
|
||||||
|
assert.ok(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), 'file should not be archived');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source: clear → silent (no injection)', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'should not load\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'clear', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
assert.equal(res.stdout.trim(), '');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source: resume + handoff in cwd → injected and archived', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), '# my handoff\n\nimportant content\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'resume', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
// Stdout should be JSON with additionalContext containing the file
|
||||||
|
const json = JSON.parse(res.stdout);
|
||||||
|
assert.equal(json.hookSpecificOutput.hookEventName, 'SessionStart');
|
||||||
|
assert.match(json.hookSpecificOutput.additionalContext, /important content/);
|
||||||
|
assert.match(json.hookSpecificOutput.additionalContext, /<session-handoff/);
|
||||||
|
// File should be archived
|
||||||
|
assert.ok(!existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), 'original should be renamed');
|
||||||
|
assert.ok(existsSync(join(dir, 'NEXT-SESSION-PROMPT.archived.local.md')), 'archive should exist');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source: compact + handoff in cwd → injected and archived', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), '# compact handoff\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'compact', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
const json = JSON.parse(res.stdout);
|
||||||
|
assert.match(json.hookSpecificOutput.additionalContext, /compact handoff/);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source: resume + handoff 2 levels above cwd → found and injected', async () => {
|
||||||
|
const root = makeFixture();
|
||||||
|
const sub = join(root, 'a', 'b');
|
||||||
|
mkdirSync(sub, { recursive: true });
|
||||||
|
writeFileSync(join(root, 'NEXT-SESSION-PROMPT.local.md'), '# parent handoff\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'resume', cwd: sub });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
const json = JSON.parse(res.stdout);
|
||||||
|
assert.match(json.hookSpecificOutput.additionalContext, /parent handoff/);
|
||||||
|
// Archived in the original parent location
|
||||||
|
assert.ok(existsSync(join(root, 'NEXT-SESSION-PROMPT.archived.local.md')));
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source: resume + no handoff anywhere → silent', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const res = await runHook(HOOK, { source: 'resume', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
assert.equal(res.stdout.trim(), '');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source: resume + topic-slug variant NEXT-SESSION-foo.local.md → found', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
writeFileSync(join(dir, 'NEXT-SESSION-feature-x.local.md'), '# topic handoff\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'resume', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
const json = JSON.parse(res.stdout);
|
||||||
|
assert.match(json.hookSpecificOutput.additionalContext, /topic handoff/);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archived files are not re-loaded on subsequent runs', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.archived.local.md'), 'stale - should not load\n');
|
||||||
|
const res = await runHook(HOOK, { source: 'resume', cwd: dir });
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
assert.equal(res.stdout.trim(), '', 'archived files must be ignored');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed JSON payload: silent exit 0', async () => {
|
||||||
|
const res = await runHook(HOOK, '{not valid');
|
||||||
|
assert.equal(res.code, 0);
|
||||||
|
assert.equal(res.stdout.trim(), '');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue