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(), '');
+});