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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue