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.
93 lines
2.6 KiB
JavaScript
93 lines
2.6 KiB
JavaScript
#!/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();
|