diff --git a/plugins/graceful-handoff/hooks/scripts/session-start-load-handoff.mjs b/plugins/graceful-handoff/hooks/scripts/session-start-load-handoff.mjs new file mode 100644 index 0000000..1522f2a --- /dev/null +++ b/plugins/graceful-handoff/hooks/scripts/session-start-load-handoff.mjs @@ -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: `\n${content}\n`, + }, + }; + 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(); diff --git a/plugins/graceful-handoff/tests/hooks/session-start-load-handoff.test.mjs b/plugins/graceful-handoff/tests/hooks/session-start-load-handoff.test.mjs new file mode 100644 index 0000000..6d6d2bb --- /dev/null +++ b/plugins/graceful-handoff/tests/hooks/session-start-load-handoff.test.mjs @@ -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, / { + 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(), ''); +});