diff --git a/plugins/graceful-handoff/hooks/scripts/statusline-monitor.mjs b/plugins/graceful-handoff/hooks/scripts/statusline-monitor.mjs new file mode 100644 index 0000000..7e2f37c --- /dev/null +++ b/plugins/graceful-handoff/hooks/scripts/statusline-monitor.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// statusline-monitor.mjs — graceful-handoff v2.0 +// statusLine hook: prints a context-percent hint, never runs git. +// +// Reads JSON from stdin (Claude Code statusLine payload). If +// context_window.used_percentage is available: +// < 60% → no output +// 60-69% → "kontekst NN% — vurder /graceful-handoff" +// ≥ 70% → "kontekst NN% — kjør /graceful-handoff NÅ" +// +// Exit 0 always. statusLine is display-only — never run git here +// (research/03 — statusLine scripts can be cancelled mid-flight). + +import { readFileSync } from 'node:fs'; + +function readStdin() { + try { + return readFileSync(0, 'utf-8'); + } catch { + return ''; + } +} + +function main() { + const raw = readStdin(); + if (!raw.trim()) { + process.exit(0); + } + let payload; + try { + payload = JSON.parse(raw); + } catch { + process.exit(0); + } + + const ctx = payload?.context_window; + const pct = ctx?.used_percentage; + if (typeof pct !== 'number' || isNaN(pct)) { + process.exit(0); + } + + if (pct >= 70) { + process.stdout.write(`kontekst ${Math.round(pct)}% — kjør /graceful-handoff NÅ`); + } else if (pct >= 60) { + process.stdout.write(`kontekst ${Math.round(pct)}% — vurder /graceful-handoff`); + } + // < 60 → no output (silent) + process.exit(0); +} + +main(); diff --git a/plugins/graceful-handoff/tests/hooks/hook-helper.mjs b/plugins/graceful-handoff/tests/hooks/hook-helper.mjs new file mode 100644 index 0000000..8c22a8c --- /dev/null +++ b/plugins/graceful-handoff/tests/hooks/hook-helper.mjs @@ -0,0 +1,42 @@ +// 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 ` 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} 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)); + }); +} diff --git a/plugins/graceful-handoff/tests/hooks/statusline-monitor.test.mjs b/plugins/graceful-handoff/tests/hooks/statusline-monitor.test.mjs new file mode 100644 index 0000000..533a878 --- /dev/null +++ b/plugins/graceful-handoff/tests/hooks/statusline-monitor.test.mjs @@ -0,0 +1,78 @@ +// 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(), ''); +});