diff --git a/hooks/scripts/inject-okr-context.mjs b/hooks/scripts/inject-okr-context.mjs index 20e2a5e..fadd85e 100644 --- a/hooks/scripts/inject-okr-context.mjs +++ b/hooks/scripts/inject-okr-context.mjs @@ -13,6 +13,34 @@ const cwd = process.cwd(); const projectConfigPath = join(cwd, '.claude', 'okr.local.md'); const homeConfigPath = join(homedir(), '.claude', 'okr', 'org', 'profil.md'); +// Topic guard (UserPromptSubmit): only inject OKR context when the user's +// prompt is plausibly OKR-related. The stdin read is non-blocking here -- under +// execFileSync (Claude Code's hook call and the tests) stdin is a closed pipe +// so EOF is immediate; the isTTY guard avoids blocking in an interactive shell. +// Default to PASS (inject) on any doubt -- empty/unparseable stdin, missing +// prompt field, or TTY -- so we never drop a legitimate OKR prompt. Only an +// explicit non-matching prompt is suppressed. No network. +let rawPrompt = ''; +try { + if (!process.stdin.isTTY) rawPrompt = readFileSync(0, 'utf8'); +} catch { + rawPrompt = ''; +} +if (rawPrompt) { + let promptText = null; + try { + promptText = JSON.parse(rawPrompt).prompt; + } catch { + promptText = null; + } + if (typeof promptText === 'string' && promptText.length > 0) { + const okrPattern = /\bokr\b|objective|key result|n[oø]kkelresultat|noekkelresultat|\bkr\b|\bm[aå]l\b|\bmaal\b|tildelingsbrev|kaskade|syklus|tertial|kvartal/i; + if (!okrPattern.test(promptText)) { + process.exit(0); + } + } +} + // Hybrid org-profile resolution (most-specific-wins): project-local overrides // the machine-global home profile. The home path is the Fase 3 migration target; // this read is forward-compatible and stays inert until that file exists. diff --git a/tests/inject-okr-context.test.mjs b/tests/inject-okr-context.test.mjs index e05cf39..ce7326b 100644 --- a/tests/inject-okr-context.test.mjs +++ b/tests/inject-okr-context.test.mjs @@ -31,15 +31,21 @@ function makeProjectConfig(workDir, navn) { writeFileSync(join(p, 'okr.local.md'), `---\nnavn: "${navn}"\n---\n`); } -function runHook(cwd, home) { +function runHook(cwd, home, input = '') { // execFileSync returns stdout; the hook always exits 0. + // input pipes a UserPromptSubmit payload through stdin so the emne-guard + // (topic guard) sees an explicit OKR-relevant prompt instead of suppressing. return execFileSync('node', [HOOK], { cwd, env: { ...process.env, HOME: home }, + input, encoding: 'utf8', }); } +// OKR-relevant prompt slik at emne-guarden injiserer i stedet for aa suppresse. +const OKR_PROMPT = JSON.stringify({ prompt: 'hjelp meg skrive OKR' }); + function withDirs(fn) { const home = mkdtempSync(join(tmpdir(), 'okrhome-')); const work = mkdtempSync(join(tmpdir(), 'okrwork-')); @@ -54,7 +60,7 @@ function withDirs(fn) { test('kun-hjem: leser org fra hjem-profil, ingen syklus-kontekst', () => { withDirs((home, work) => { makeHomeProfil(home, 'HjemOrg'); - const out = runHook(work, home); + const out = runHook(work, home, OKR_PROMPT); assert.match(out, /HjemOrg/, 'org skal komme fra hjem-profil naar prosjekt-config mangler'); assert.doesNotMatch(out, /Tilgjengelige kontekstfiler/, 'kun-hjem har ingen prosjekt-lokalt syklus-tre (okrDir er cwd-bundet)'); }); @@ -64,7 +70,7 @@ test('begge: prosjekt-lokal vinner over hjem (mest-spesifikk-vinner)', () => { withDirs((home, work) => { makeHomeProfil(home, 'HjemOrg'); makeProjectConfig(work, 'ProsjektOrg'); - const out = runHook(work, home); + const out = runHook(work, home, OKR_PROMPT); assert.match(out, /ProsjektOrg/, 'prosjekt-lokal skal vinne'); assert.doesNotMatch(out, /HjemOrg/, 'hjem skal ikke leses naar prosjekt-config finnes'); }); diff --git a/tests/topic-guard.test.mjs b/tests/topic-guard.test.mjs new file mode 100644 index 0000000..f937126 --- /dev/null +++ b/tests/topic-guard.test.mjs @@ -0,0 +1,79 @@ +// topic-guard.test.mjs +// Tester UserPromptSubmit emne-guard (SC6) i inject-okr-context: kun +// OKR-relevante prompter injiserer kontekst; en eksplisitt ikke-matchende +// prompt suppresses; tvil (tomt felt / ingen stdin) -> default-inject. +// Spawner hooken som subprosess med kontrollert cwd + stdin. Zero npm deps. +// Moenster: tests/inject-okr-context.test.mjs (begge-casen + input-opsjon). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'inject-okr-context.mjs', +); + +function makeProjectConfig(workDir, navn) { + const p = join(workDir, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'okr.local.md'), `---\nnavn: "${navn}"\n---\n`); +} + +function runHook(cwd, input = '') { + // execFileSync returnerer stdout; hooken avslutter alltid med exit 0. + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env }, + input: input, + encoding: 'utf8', + }); +} + +function withWork(fn) { + const work = mkdtempSync(join(tmpdir(), 'okrtopic-')); + try { + fn(work); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +test('irrelevant prompt: suppresses injeksjon (tom stdout)', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work, JSON.stringify({ prompt: 'hva er vaeret i dag' })); + assert.equal(out.trim(), '', 'ikke-OKR-prompt skal suppresses selv med gyldig config'); + }); +}); + +test('relevant prompt: injiserer OKR-kontekst', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work, JSON.stringify({ prompt: 'hjelp meg skrive en OKR for neste tertial' })); + assert.match(out, /OKR-kontekst/, 'OKR-relevant prompt skal injisere kontekst'); + }); +}); + +test('tomt prompt-felt: bevarer inject-default (tvil -> injiser)', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work, JSON.stringify({ prompt: '' })); + assert.match(out, /OKR-kontekst/, 'tomt prompt-felt -> tvil -> injiser'); + }); +}); + +test('ingen stdin: bevarer inject-default (regresjonsvakt)', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work); // input utelatt -> tom stdin + assert.match(out, /OKR-kontekst/, 'ingen stdin -> tvil -> injiser'); + }); +});