feat(graceful-handoff)!: integrate with STATE.md continuity system (v3.0.0)

BREAKING: replace the NEXT-SESSION artifact + 3 hooks with a STATE.md-centric,
skill-only design. /graceful-handoff now overwrites the nearest STATE.md (with the
mandatory 👉 NESTE block) instead of writing a separate handover file.

- Remove hooks/ entirely: Stop auto-trigger (operator choice), SessionStart loader
  (redundant with global session-start.sh), statusLine hint (dead — user settings win).
- Invert the pipeline: the model writes STATE.md (only it has the context for 👉 NESTE);
  handoff-pipeline.mjs becomes a slim deterministic helper (--plan / --commit / --dry-run).
- Remote-aware policy: STATE.md tracked on private remotes, local-only (gitignored) on
  public/open mirrors. Authoritative signal: git check-ignore STATE.md.
- SKILL.md rewritten as the Session-Slutt ritual; dropped the Sonnet model pin.
- Docs (README, CLAUDE.md, CHANGELOG), plugin.json 2.1.0→3.0.0, .gitignore cleanup.
- Tests rewritten for --plan/--commit; no-`git add -A` regression preserved. 30/30 green.

Release-cut (tag v3.0.0 + catalog ref bump) pending — separate gated action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SiKr4c6GAzQH5n6E6f5NA
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 21:09:44 +02:00
commit 2c4e5e425b
18 changed files with 694 additions and 1656 deletions

View file

@ -1,42 +0,0 @@
// hook-helper.mjs — Shared test helper for hook scripts.
// Spawns a hook as a child process and feeds it JSON via stdin.
import { execFile } from 'node:child_process';
/**
* Run a hook script by spawning `node <scriptPath>` and piping `input` to stdin.
*
* @param {string} scriptPath - Absolute path to the hook .mjs file
* @param {object|string} input - JSON payload (object will be stringified)
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
export function runHook(scriptPath, input) {
return runHookWithEnv(scriptPath, input, {});
}
/**
* Run a hook script with custom environment variables.
*
* @param {string} scriptPath - Absolute path to the hook .mjs file
* @param {object|string} input - JSON payload (object will be stringified)
* @param {Record<string, string>} envOverrides - Extra env vars to set
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
export function runHookWithEnv(scriptPath, input, envOverrides) {
return new Promise((resolve) => {
const env = { ...process.env, ...envOverrides };
const child = execFile(
'node',
[scriptPath],
{ timeout: 5000, env },
(err, stdout, stderr) => {
resolve({
code: child.exitCode ?? (err && err.code === 'ERR_CHILD_PROCESS_STDIO_FINAL' ? 0 : 1),
stdout: stdout || '',
stderr: stderr || '',
});
}
);
child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input));
});
}

View file

@ -1,108 +0,0 @@
// 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(), '');
});

View file

@ -1,78 +0,0 @@
// statusline-monitor.test.mjs — Tests statusLine hook display thresholds.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from './hook-helper.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'statusline-monitor.mjs');
function payload(usedPercentage) {
return {
context_window: {
used_percentage: usedPercentage,
remaining_percentage: usedPercentage == null ? null : 100 - usedPercentage,
context_window_size: 200000,
},
model: { id: 'claude-opus-4-7', display_name: 'Opus' },
session_id: 'test-session',
};
}
test('< 60%: silent, no output', async () => {
const res = await runHook(HOOK, payload(45));
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected empty stdout, got: "${res.stdout}"`);
});
test('60-69%: prints "vurder /graceful-handoff" hint with "60" or "kontekst" substring', async () => {
const res = await runHook(HOOK, payload(63));
assert.equal(res.code, 0);
assert.match(res.stdout, /kontekst/);
assert.match(res.stdout, /vurder.*graceful-handoff/);
});
test('≥ 70%: prints stronger hint with "kjør NÅ"', async () => {
const res = await runHook(HOOK, payload(75));
assert.equal(res.code, 0);
assert.match(res.stdout, /kontekst/);
assert.match(res.stdout, /kjør.*graceful-handoff.*NÅ/i);
});
test('exact threshold 60%: shows hint (not silent)', async () => {
const res = await runHook(HOOK, payload(60));
assert.equal(res.code, 0);
assert.match(res.stdout, /60/);
});
test('exact threshold 70%: shows urgent hint', async () => {
const res = await runHook(HOOK, payload(70));
assert.equal(res.code, 0);
assert.match(res.stdout, /NÅ/);
});
test('null used_percentage: silent (early session before first API call)', async () => {
const res = await runHook(HOOK, payload(null));
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('missing context_window field: silent', async () => {
const res = await runHook(HOOK, { model: { id: 'foo' }, session_id: 'x' });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('empty stdin: silent', async () => {
const res = await runHook(HOOK, '');
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('malformed JSON: silent (no crash)', async () => {
const res = await runHook(HOOK, '{not json');
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});

View file

@ -1,236 +0,0 @@
// stop-context-monitor.test.mjs — Tests for Stop hook auto-execute logic.
// Uses runHook to spawn the script as a subprocess and inspect its behavior
// via temporary fixture files (real fs) — simpler than mocking imports.
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, writeFileSync, existsSync, rmSync, statSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { runHookWithEnv } from './hook-helper.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'stop-context-monitor.mjs');
const PLUGIN_ROOT = join(__dirname, '..', '..');
function setup(transcriptSize) {
const dir = mkdtempSync(join(tmpdir(), 'stop-hook-'));
const transcriptPath = join(dir, 'transcript.jsonl');
// Generate transcript content of exact size (chars)
writeFileSync(transcriptPath, 'a'.repeat(transcriptSize), 'utf-8');
return { dir, transcriptPath };
}
// Build a stub plugin root with a fake handoff-pipeline.mjs that returns
// canned JSON. This prevents tests from invoking the real pipeline (which
// does git operations against whatever repo the test process happens to be in).
function makeStubPluginRoot() {
const dir = mkdtempSync(join(tmpdir(), 'stub-plugin-root-'));
const scriptsDir = join(dir, 'scripts');
mkdirSync(scriptsDir);
const stub = `#!/usr/bin/env node
process.stdout.write(JSON.stringify({
handoff_type: 'plugin-arbeid',
write_dir: '/tmp/stub',
artifact_path: '/tmp/stub/NEXT-SESSION-PROMPT.local.md',
next_steps: [],
git_status: { branch: 'main', dirty: false, ahead: 0 },
commit_message: '',
actions_taken: ['stub-no-op'],
errors: [],
}));
process.exit(0);
`;
writeFileSync(join(scriptsDir, 'handoff-pipeline.mjs'), stub, 'utf-8');
return dir;
}
function cleanup(dir) {
rmSync(dir, { recursive: true, force: true });
}
test('estimated < 70%: no spawn, no lock file', async () => {
// 200k window × 70% threshold = 140k tokens × 3.5 chars = 490k chars
// Use 400k chars (~57%) — well under threshold
const { dir, transcriptPath } = setup(400_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-1',
context_window: { context_window_size: 200_000 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent, got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-1')), 'no lock should be written below threshold');
cleanup(dir);
});
test('estimated ≥ 70% + no lock: lock created, stub pipeline spawned', async () => {
// 600k chars / 3.5 = 171k tokens / 200k = 86% — well above threshold
const { dir, transcriptPath } = setup(600_000);
const stubRoot = makeStubPluginRoot();
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-2',
context_window: { context_window_size: 200_000 },
}, { CLAUDE_PLUGIN_ROOT: stubRoot });
assert.equal(res.code, 0);
// Lock file must exist
assert.ok(existsSync(join(dir, '.handoff-lock-test-2')), 'lock file should be created');
// additionalContext should mention auto-handoff (stub returns no errors → success path)
assert.match(res.stdout, /Auto-handoff utført/i);
cleanup(dir);
cleanup(stubRoot);
});
test('estimated ≥ 70% + lock exists: no spawn, no output', async () => {
const { dir, transcriptPath } = setup(600_000);
// Pre-create the lock file
writeFileSync(join(dir, '.handoff-lock-test-3'), 'pre-existing', 'utf-8');
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-3',
context_window: { context_window_size: 200_000 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent (lock exists), got: ${res.stdout}`);
cleanup(dir);
});
test('missing transcript_path: silent exit 0', async () => {
const res = await runHookWithEnv(HOOK, { session_id: 'test-4' }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('non-existent transcript file: silent exit 0', async () => {
const res = await runHookWithEnv(HOOK, {
transcript_path: '/tmp/does-not-exist-' + Date.now() + '.jsonl',
session_id: 'test-5',
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('uses context_window_size from payload (not hardcoded 200k)', async () => {
// 1M context window × 70% = 700k tokens × 3.5 = 2.45M chars to trigger
// 600k chars on a 1M window is only ~17% — should NOT trigger
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-6',
context_window: { context_window_size: 1_000_000 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent on 1M window, got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-6')));
cleanup(dir);
});
test('CLAUDE_PLUGIN_ROOT missing: graceful error message', async () => {
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-7',
context_window: { context_window_size: 200_000 },
}, {}); // no CLAUDE_PLUGIN_ROOT
assert.equal(res.code, 0);
assert.match(res.stdout, /CLAUDE_PLUGIN_ROOT not set/);
cleanup(dir);
});
// --- v2.1: 4-step context resolution -----------------------------------
test('prefers used_percentage from payload over transcript estimate', async () => {
// Big transcript that would trigger via size-estimate (600k chars / 200k window ≈ 86%),
// but used_percentage says 25% — direct path must win.
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-8',
context_window: { context_window_size: 200_000, used_percentage: 25 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent (used_percentage=25), got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-8')), 'no lock should be written when used_percentage is below threshold');
cleanup(dir);
});
test('used_percentage triggers above threshold even with tiny transcript', async () => {
// Tiny transcript would never trigger via size-estimate, but used_percentage=75 must.
const { dir, transcriptPath } = setup(1_000);
const stubRoot = makeStubPluginRoot();
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-9',
context_window: { context_window_size: 200_000, used_percentage: 75 },
}, { CLAUDE_PLUGIN_ROOT: stubRoot });
assert.equal(res.code, 0);
assert.ok(existsSync(join(dir, '.handoff-lock-test-9')), 'lock file should be created when used_percentage ≥ 70%');
assert.match(res.stdout, /Auto-handoff utført/i);
assert.match(res.stdout, /kilde: direct/, 'message should label source as direct');
cleanup(dir);
cleanup(stubRoot);
});
test('model-mapping: Opus 4.7 resolves to 1M window (no trigger at 17%)', async () => {
// 600k chars / 3.5 = 171k tokens / 1M = 17% — well under threshold.
// No context_window in payload — must fall through to model-map.
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-10',
model: { id: 'claude-opus-4-7' },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent on Opus 4.7 1M window at 17%, got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-10')));
cleanup(dir);
});
test('model-mapping: Haiku resolves to 200k window (triggers at 86%)', async () => {
// 600k chars / 3.5 = 171k tokens / 200k = 86% — above threshold.
const { dir, transcriptPath } = setup(600_000);
const stubRoot = makeStubPluginRoot();
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-11',
model: { id: 'claude-haiku-4-5-20251001' },
}, { CLAUDE_PLUGIN_ROOT: stubRoot });
assert.equal(res.code, 0);
assert.ok(existsSync(join(dir, '.handoff-lock-test-11')), 'lock should fire on Haiku 200k window at 86%');
assert.match(res.stdout, /kilde: model-map/, 'message should label source as model-map');
cleanup(dir);
cleanup(stubRoot);
});
test('default fallback (1M) when neither used_percentage nor model is in payload', async () => {
// 600k chars / 3.5 = 171k tokens / 1M = 17% — must NOT trigger with new 1M default.
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-12',
// intentionally no context_window, no model
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent on default 1M fallback at 17%, got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-12')));
cleanup(dir);
});
test('null used_percentage falls through to size-based path', async () => {
// Early-session payloads may have used_percentage: null. We must NOT treat that
// as 0 and skip the size-estimate. With size=200k and 600k chars (~86%) we trigger.
const { dir, transcriptPath } = setup(600_000);
const stubRoot = makeStubPluginRoot();
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-13',
context_window: { context_window_size: 200_000, used_percentage: null },
}, { CLAUDE_PLUGIN_ROOT: stubRoot });
assert.equal(res.code, 0);
assert.ok(existsSync(join(dir, '.handoff-lock-test-13')), 'lock should fire via size-fallback when used_percentage is null');
assert.match(res.stdout, /kilde: payload-size/, 'message should label source as payload-size');
cleanup(dir);
cleanup(stubRoot);
});

View file

@ -1,4 +1,4 @@
// plugin-manifest.test.mjs — verify plugin.json schema for v2.1
// plugin-manifest.test.mjs — verify plugin.json schema + CHANGELOG for v3.0.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -10,17 +10,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const MANIFEST = join(__dirname, '..', '.claude-plugin', 'plugin.json');
const CHANGELOG = join(__dirname, '..', 'CHANGELOG.md');
test('plugin.json version is 2.1.0', () => {
test('plugin.json version is 3.0.0', () => {
const m = JSON.parse(readFileSync(MANIFEST, 'utf-8'));
assert.equal(m.version, '2.1.0');
assert.equal(m.version, '3.0.0');
});
test('CHANGELOG has [2.1.0] entry mentioning model-aware fix', () => {
const c = readFileSync(CHANGELOG, 'utf-8');
assert.match(c, /## \[2\.1\.0\]/);
const match = c.match(/## \[2\.1\.0\][\s\S]*?(?=## \[2\.0\.0\]|$)/);
assert.ok(match, '[2.1.0] section missing');
assert.match(match[0], /modell-bevisst|model-aware|resolveContextSource/i);
test('plugin.json description mentions STATE.md', () => {
const m = JSON.parse(readFileSync(MANIFEST, 'utf-8'));
assert.match(m.description, /STATE\.md/);
});
test('plugin.json does NOT include auto_discover (not in documented schema)', () => {
@ -28,27 +25,23 @@ test('plugin.json does NOT include auto_discover (not in documented schema)', ()
assert.ok(!('auto_discover' in m), 'auto_discover field should be removed');
});
test('plugin.json description mentions auto-trigger or context-threshold', () => {
const m = JSON.parse(readFileSync(MANIFEST, 'utf-8'));
assert.match(m.description, /auto-trigger|context-threshold/i);
});
test('CHANGELOG has [2.0.0] entry', () => {
test('CHANGELOG has [3.0.0] entry with BREAKING section mentioning STATE.md', () => {
const c = readFileSync(CHANGELOG, 'utf-8');
assert.match(c, /## \[2\.0\.0\]/);
});
test('CHANGELOG [2.0.0] entry has BREAKING section', () => {
const c = readFileSync(CHANGELOG, 'utf-8');
// Get content from [2.0.0] until next ## or end
const match = c.match(/## \[2\.0\.0\][\s\S]*?(?=## \[1\.0\.0\]|$)/);
assert.ok(match, '[2.0.0] section missing');
const match = c.match(/## \[3\.0\.0\][\s\S]*?(?=## \[2\.1\.0\]|$)/);
assert.ok(match, '[3.0.0] section missing');
assert.match(match[0], /### BREAKING/);
assert.match(match[0], /STATE\.md/);
});
test('No source files reference version 1.0.0', () => {
const m = JSON.parse(readFileSync(MANIFEST, 'utf-8'));
// Manifest is the canonical source — check it doesn't accidentally still say 1.0.0
const raw = readFileSync(MANIFEST, 'utf-8');
assert.doesNotMatch(raw, /"version":\s*"1\.0\.0"/);
test('CHANGELOG preserves [2.1.0] and [2.0.0] history', () => {
const c = readFileSync(CHANGELOG, 'utf-8');
assert.match(c, /## \[2\.1\.0\]/);
assert.match(c, /## \[2\.0\.0\]/);
const v20 = c.match(/## \[2\.0\.0\][\s\S]*?(?=## \[1\.0\.0\]|$)/);
assert.ok(v20 && /### BREAKING/.test(v20[0]), '[2.0.0] BREAKING section should remain');
});
test('No source files reference version 1.0.0 / 2.x as current', () => {
const raw = readFileSync(MANIFEST, 'utf-8');
assert.doesNotMatch(raw, /"version":\s*"[12]\./);
});

View file

@ -1,184 +1,202 @@
// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs.
// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs (v3.0 STATE helper).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
import { existsSync, writeFileSync, rmSync, mkdtempSync, realpathSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(__dirname, '..', '..', 'scripts', 'handoff-pipeline.mjs');
function makeTempRepo() {
const dir = mkdtempSync(join(tmpdir(), 'gh-pipeline-'));
function makeTempRepo({ remote = null, gitignoreState = false } = {}) {
// realpath: on macOS tmpdir() is a /var → /private/var symlink, but
// `git rev-parse --show-toplevel` returns the canonical path. Canonicalise
// here so derived expected paths match the script's git-resolved output.
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'gh-pipeline-')));
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
// Initial commit so HEAD exists
if (remote) execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: dir });
writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8');
if (gitignoreState) writeFileSync(join(dir, '.gitignore'), 'STATE.md\n', 'utf-8');
execFileSync('git', ['add', '.'], { cwd: dir });
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
return dir;
}
function runPipeline(repo, args = [], { stdin = '' } = {}) {
function runPipeline(cwd, args = []) {
return new Promise((resolveP) => {
const child = spawn('node', [SCRIPT, ...args], { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'] });
const child = spawn('node', [SCRIPT, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => (stdout += d.toString()));
child.stderr.on('data', (d) => (stderr += d.toString()));
child.on('close', (code) => resolveP({ code, stdout, stderr }));
if (stdin) child.stdin.write(stdin);
child.stdin.end();
});
}
test('--dry-run returns valid JSON with required keys', async () => {
// ---------- classifyRemote (unit) ----------
test('classifyRemote: none / public / private', async () => {
const { classifyRemote } = await import(pathToFileURL(SCRIPT).href);
assert.equal(classifyRemote(null), 'none');
assert.equal(classifyRemote(''), 'none');
assert.equal(classifyRemote('https://github.com/foo/bar.git'), 'public');
assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/open/graceful-handoff.git'), 'public');
assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/ktg/secret.git'), 'private');
});
// ---------- --plan ----------
test('--plan returns JSON with required keys', async () => {
const repo = makeTempRepo();
const result = await runPipeline(repo, ['--dry-run']);
const result = await runPipeline(repo, ['--plan']);
assert.equal(result.code, 0, `non-zero exit: ${result.stderr}`);
const json = JSON.parse(result.stdout);
assert.ok(json.handoff_type, 'handoff_type missing');
assert.ok(json.write_dir, 'write_dir missing');
assert.ok(Array.isArray(json.next_steps), 'next_steps missing');
assert.ok(Array.isArray(json.actions_taken), 'actions_taken missing');
assert.ok(Array.isArray(json.errors), 'errors missing');
assert.ok(json.git_status, 'git_status missing');
for (const k of ['state_path', 'state_exists', 'state_gitignored', 'remote_class',
'should_be_local_only', 'should_commit_state', 'git_status', 'dirty_files',
'recent_commits', 'line_budget', 'errors']) {
assert.ok(k in json, `missing key: ${k}`);
}
assert.ok(json.git_status.branch, 'branch missing');
rmSync(repo, { recursive: true, force: true });
});
test('--dry-run is idempotent (two runs produce same JSON shape)', async () => {
test('--plan (default mode) targets <repoRoot>/STATE.md when none exists', async () => {
const repo = makeTempRepo();
const a = await runPipeline(repo, ['--dry-run']);
const b = await runPipeline(repo, ['--dry-run']);
const aJson = JSON.parse(a.stdout);
const bJson = JSON.parse(b.stdout);
assert.equal(aJson.handoff_type, bJson.handoff_type);
assert.equal(aJson.write_dir, bJson.write_dir);
assert.deepEqual(aJson.next_steps, bJson.next_steps);
rmSync(repo, { recursive: true, force: true });
});
test('--non-interactive without --auto is invalid', async () => {
const repo = makeTempRepo();
// Add dirty state so commit phase would activate
writeFileSync(join(repo, 'foo.txt'), 'change\n');
const result = await runPipeline(repo, ['--non-interactive']);
assert.equal(result.code, 0); // pipeline always exits 0 on logical errors
const result = await runPipeline(repo, []); // default = plan
const json = JSON.parse(result.stdout);
assert.ok(json.errors.some(e => /non-interactive/i.test(e)), `expected non-interactive error, got: ${JSON.stringify(json.errors)}`);
assert.equal(json.state_exists, false);
assert.equal(json.state_path, join(repo, 'STATE.md'));
rmSync(repo, { recursive: true, force: true });
});
test('--auto on dirty repo writes artifact and commits without prompting', async () => {
test('--plan resolves nearest existing STATE.md (subdir wins over root)', async () => {
const repo = makeTempRepo();
writeFileSync(join(repo, 'foo.txt'), 'change\n');
// No upstream — push will be skipped via no-upstream error, but commit should succeed
const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
assert.equal(result.code, 0);
const sub = join(repo, 'plugins', 'x');
execFileSync('mkdir', ['-p', sub]);
writeFileSync(join(repo, 'STATE.md'), '# root\n');
writeFileSync(join(sub, 'STATE.md'), '# sub\n');
const result = await runPipeline(sub, ['--plan']);
const json = JSON.parse(result.stdout);
assert.ok(json.actions_taken.some(a => a.startsWith('wrote-artifact')), `expected wrote-artifact, got: ${JSON.stringify(json.actions_taken)}`);
assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`);
// Verify artifact file actually exists on disk
assert.ok(existsSync(json.artifact_path), `artifact path ${json.artifact_path} should exist`);
assert.equal(json.state_path, join(sub, 'STATE.md'));
assert.equal(json.state_exists, true);
rmSync(repo, { recursive: true, force: true });
});
test('--no-commit skips git operations even when dirty', async () => {
const repo = makeTempRepo();
writeFileSync(join(repo, 'foo.txt'), 'change\n');
const result = await runPipeline(repo, ['--no-commit', '--auto']);
test('--plan: public open/ remote → should_be_local_only, leak_warning when STATE not gitignored', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' });
const result = await runPipeline(repo, ['--plan']);
const json = JSON.parse(result.stdout);
assert.ok(!json.actions_taken.includes('committed'), 'should not commit with --no-commit');
assert.ok(!json.actions_taken.includes('pushed'), 'should not push without commit');
assert.equal(json.remote_class, 'public');
assert.equal(json.should_be_local_only, true);
assert.ok(json.leak_warning, 'expected leak_warning when public remote and STATE not gitignored');
rmSync(repo, { recursive: true, force: true });
});
test('idempotency: second --auto run on clean tree with recent artifact is no-op', async () => {
const repo = makeTempRepo();
writeFileSync(join(repo, 'foo.txt'), 'change\n');
// First run: dirty, writes artifact and commits ONLY the artifact (not foo.txt)
await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
// Clean up the unrelated dirty file so second run sees a CLEAN tree.
// The pipeline must NEVER auto-stage user's other dirty files (CLAUDE.md
// anti-pattern) — the test explicitly removes it to isolate idempotency.
rmSync(join(repo, 'foo.txt'));
// Second run: clean tree, recent artifact exists → idempotent no-op
const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
test('--plan: public remote + STATE gitignored → no leak_warning, should_commit_state false', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
const result = await runPipeline(repo, ['--plan']);
const json = JSON.parse(result.stdout);
assert.ok(
json.actions_taken.some(a => a.includes('idempotent')),
`expected idempotent no-op, got: ${JSON.stringify(json.actions_taken)}`
);
assert.equal(json.remote_class, 'public');
assert.equal(json.state_gitignored, true);
assert.equal(json.should_commit_state, false);
assert.equal(json.leak_warning, null);
rmSync(repo, { recursive: true, force: true });
});
test('pipeline never stages unrelated dirty files (no git add -A regression)', async () => {
const repo = makeTempRepo();
// Two unrelated dirty files — pipeline should NOT commit them
test('--plan: private remote → should_commit_state true, not local-only', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
const result = await runPipeline(repo, ['--plan']);
const json = JSON.parse(result.stdout);
assert.equal(json.remote_class, 'private');
assert.equal(json.should_be_local_only, false);
assert.equal(json.should_commit_state, true);
rmSync(repo, { recursive: true, force: true });
});
// ---------- --commit ----------
test('--commit on private repo stages and commits ONLY STATE.md', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
writeFileSync(join(repo, 'unrelated.txt'), 'user work\n');
const result = await runPipeline(repo, ['--commit']);
const json = JSON.parse(result.stdout);
assert.ok(json.actions_taken.includes('committed'), `expected committed, got ${JSON.stringify(json.actions_taken)}`);
const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' })
.trim().split('\n').filter(Boolean);
assert.deepEqual(head, ['STATE.md'], `HEAD should contain only STATE.md, got ${JSON.stringify(head)}`);
rmSync(repo, { recursive: true, force: true });
});
test('--commit never stages unrelated dirty files (no git add -A regression)', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
writeFileSync(join(repo, 'unrelated-1.txt'), 'user work\n');
writeFileSync(join(repo, 'unrelated-2.md'), '# user notes\n');
await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
// After commit, unrelated files must STILL be in working tree (not committed)
const { execFileSync } = await import('node:child_process');
const lastCommit = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], {
cwd: repo, encoding: 'utf-8',
}).trim().split('\n').filter(Boolean);
assert.ok(!lastCommit.includes('unrelated-1.txt'), `unrelated-1.txt should NOT be in HEAD commit, got: ${lastCommit}`);
assert.ok(!lastCommit.includes('unrelated-2.md'), `unrelated-2.md should NOT be in HEAD commit, got: ${lastCommit}`);
// The artifact SHOULD be in HEAD
assert.ok(lastCommit.some(f => f.includes('NEXT-SESSION')), `artifact should be in HEAD, got: ${lastCommit}`);
// unrelated files still untracked
writeFileSync(join(repo, 'unrelated-2.md'), '# notes\n');
await runPipeline(repo, ['--commit']);
const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' });
assert.match(status, /unrelated-1\.txt/);
assert.match(status, /unrelated-2\.md/);
rmSync(repo, { recursive: true, force: true });
});
test('detached HEAD is detected and reported (no commit attempted)', async () => {
const repo = makeTempRepo();
// Detach HEAD
test('--commit with --also includes the explicit related path', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
writeFileSync(join(repo, 'code.mjs'), 'export const x = 1;\n');
writeFileSync(join(repo, 'untouched.txt'), 'leave me\n');
await runPipeline(repo, ['--commit', '--also', 'code.mjs']);
const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' })
.trim().split('\n').filter(Boolean).sort();
assert.deepEqual(head, ['STATE.md', 'code.mjs']);
const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' });
assert.match(status, /untouched\.txt/);
rmSync(repo, { recursive: true, force: true });
});
test('--commit on public repo does NOT commit gitignored STATE.md', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
writeFileSync(join(repo, 'STATE.md'), '# STATE local-only\n');
const result = await runPipeline(repo, ['--commit']);
const json = JSON.parse(result.stdout);
assert.ok(
json.actions_taken.some(a => /local-only-skipped|intet-å-committe/.test(a)),
`expected local-only skip, got ${JSON.stringify(json.actions_taken)}`
);
assert.ok(!json.actions_taken.includes('committed'), 'should not commit gitignored STATE.md');
// STATE.md stays untracked/ignored, working tree otherwise clean
const tracked = execFileSync('git', ['ls-files', 'STATE.md'], { cwd: repo, encoding: 'utf-8' }).trim();
assert.equal(tracked, '', 'STATE.md must not become tracked on a public repo');
rmSync(repo, { recursive: true, force: true });
});
test('--commit on detached HEAD is detected (no commit attempted)', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
execFileSync('git', ['checkout', '-q', sha], { cwd: repo });
writeFileSync(join(repo, 'foo.txt'), 'change\n');
const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
const result = await runPipeline(repo, ['--commit']);
const json = JSON.parse(result.stdout);
assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got: ${JSON.stringify(json.errors)}`);
assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got ${JSON.stringify(json.errors)}`);
assert.ok(!json.actions_taken.includes('committed'), 'should not commit on detached HEAD');
rmSync(repo, { recursive: true, force: true });
});
test('no-upstream branch is detected on push attempt', async () => {
const repo = makeTempRepo();
writeFileSync(join(repo, 'foo.txt'), 'change\n');
// No remote/upstream — pipeline tries to push, gets no-upstream error
const result = await runPipeline(repo, ['--auto', '--non-interactive']);
const json = JSON.parse(result.stdout);
assert.ok(json.errors.some(e => /upstream/i.test(e)), `expected upstream error, got: ${JSON.stringify(json.errors)}`);
rmSync(repo, { recursive: true, force: true });
});
// ---------- --dry-run ----------
test('interactive: stdin "n" cancels commit', async () => {
const repo = makeTempRepo();
writeFileSync(join(repo, 'foo.txt'), 'change\n');
const result = await runPipeline(repo, [], { stdin: 'n\n' });
test('--dry-run writes nothing and creates no commit', async () => {
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
const result = await runPipeline(repo, ['--dry-run']);
const json = JSON.parse(result.stdout);
assert.ok(
json.actions_taken.some(a => /cancelled/i.test(a)),
`expected commit-cancelled-by-user, got: ${JSON.stringify(json.actions_taken)}`
);
assert.ok(!json.actions_taken.includes('committed'), 'should not commit when user says n');
rmSync(repo, { recursive: true, force: true });
});
test('interactive: stdin "y" confirms commit', async () => {
const repo = makeTempRepo();
writeFileSync(join(repo, 'foo.txt'), 'change\n');
const result = await runPipeline(repo, ['--no-push'], { stdin: 'y\n' });
const json = JSON.parse(result.stdout);
assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`);
assert.equal(json.mode, 'dry-run');
assert.ok(!existsSync(join(repo, 'STATE.md')), 'dry-run must not write STATE.md');
const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
assert.equal(before, after, 'dry-run must not create a commit');
rmSync(repo, { recursive: true, force: true });
});

View file

@ -1,4 +1,4 @@
// skill-structure.test.mjs — Verifies SKILL.md frontmatter and commands/ deletion.
// skill-structure.test.mjs — Verifies SKILL.md frontmatter (v3.0) and commands/ deletion.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -8,54 +8,62 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..');
const SKILL = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
function skill() {
return readFileSync(SKILL, 'utf-8');
}
test('SKILL.md exists at expected path', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
assert.ok(existsSync(skillPath), `SKILL.md missing at ${skillPath}`);
assert.ok(existsSync(SKILL), `SKILL.md missing at ${SKILL}`);
});
test('commands/ directory is deleted (hard cut to skills/)', () => {
const commandsDir = join(PLUGIN_ROOT, 'commands');
assert.ok(!existsSync(commandsDir), 'commands/ directory still exists — should be deleted in v2.0');
assert.ok(!existsSync(join(PLUGIN_ROOT, 'commands')), 'commands/ should be deleted');
});
test('hooks/ directory is deleted (v3.0 removed all hooks)', () => {
assert.ok(!existsSync(join(PLUGIN_ROOT, 'hooks')), 'hooks/ should be deleted in v3.0');
});
test('SKILL.md has disable-model-invocation: true', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
const content = readFileSync(skillPath, 'utf-8');
assert.match(content, /^disable-model-invocation: true$/m);
assert.match(skill(), /^disable-model-invocation: true$/m);
});
test('SKILL.md has model: claude-sonnet-4-6', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
const content = readFileSync(skillPath, 'utf-8');
assert.match(content, /^model: claude-sonnet-4-6$/m);
test('SKILL.md has NO model: pin (inherits session model for quality synthesis)', () => {
const fm = skill().match(/^---\n[\s\S]*?\n---/)[0];
assert.doesNotMatch(fm, /^model:/m);
});
test('SKILL.md has Bash sub-scoped allowed-tools', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
const content = readFileSync(skillPath, 'utf-8');
assert.match(content, /Bash\(git:\*\)/);
assert.match(content, /Bash\(node:\*\)/);
test('SKILL.md allowed-tools is Bash sub-scoped and includes Write', () => {
const line = skill().match(/^allowed-tools:.*$/m);
assert.ok(line, 'allowed-tools line missing');
assert.match(line[0], /Bash\(git:\*\)/);
assert.match(line[0], /Bash\(node:\*\)/);
assert.match(line[0], /\bWrite\b/);
});
test('SKILL.md does not pre-approve curl or wget', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
const content = readFileSync(skillPath, 'utf-8');
// Frontmatter only — find the allowed-tools line
const allowedToolsLine = content.match(/^allowed-tools:.*$/m);
assert.ok(allowedToolsLine, 'allowed-tools line missing');
assert.doesNotMatch(allowedToolsLine[0], /\bcurl\b/);
assert.doesNotMatch(allowedToolsLine[0], /\bwget\b/);
const line = skill().match(/^allowed-tools:.*$/m);
assert.ok(line, 'allowed-tools line missing');
assert.doesNotMatch(line[0], /\bcurl\b/);
assert.doesNotMatch(line[0], /\bwget\b/);
});
test('SKILL.md body references handoff-pipeline.mjs', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
const content = readFileSync(skillPath, 'utf-8');
assert.match(content, /handoff-pipeline\.mjs/);
assert.match(skill(), /handoff-pipeline\.mjs/);
});
test('SKILL.md body mandates the 👉 NESTE — START HER block', () => {
assert.match(skill(), /👉 NESTE — START HER/);
});
test('SKILL.md body has Tidsbudsjett (time budget) note', () => {
const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
const content = readFileSync(skillPath, 'utf-8');
assert.match(content, /Tidsbudsjett/);
assert.match(skill(), /Tidsbudsjett/);
});
test('SKILL.md is STATE.md-centric (overwrites the nearest STATE.md)', () => {
const s = skill();
assert.match(s, /STATE\.md/);
assert.match(s, /overskriv/i);
});