feat(graceful-handoff): 2.0 — statusLine context-percent hint [skip-docs]
Step 4 of v2.0 plan. statusLine hook reads context_window.used_percentage from stdin payload and prints display-only hint at 60% / 70%. NEVER runs git (research/03 — statusLine scripts can be cancelled mid-flight, unsafe for side effects). 9 tests cover thresholds, null payload, malformed JSON. Includes hook-helper.mjs copied from llm-security as test infrastructure.
This commit is contained in:
parent
8d4e16bf8e
commit
1efb1b3176
3 changed files with 171 additions and 0 deletions
|
|
@ -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();
|
||||||
42
plugins/graceful-handoff/tests/hooks/hook-helper.mjs
Normal file
42
plugins/graceful-handoff/tests/hooks/hook-helper.mjs
Normal file
|
|
@ -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 <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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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(), '');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue