feat(okr): UserPromptSubmit emne-guard + test (SC6) [skip-docs]
This commit is contained in:
parent
b594eeb88c
commit
c7a0323a81
3 changed files with 116 additions and 3 deletions
|
|
@ -13,6 +13,34 @@ const cwd = process.cwd();
|
||||||
const projectConfigPath = join(cwd, '.claude', 'okr.local.md');
|
const projectConfigPath = join(cwd, '.claude', 'okr.local.md');
|
||||||
const homeConfigPath = join(homedir(), '.claude', 'okr', 'org', 'profil.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
|
// Hybrid org-profile resolution (most-specific-wins): project-local overrides
|
||||||
// the machine-global home profile. The home path is the Fase 3 migration target;
|
// 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.
|
// this read is forward-compatible and stays inert until that file exists.
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,21 @@ function makeProjectConfig(workDir, navn) {
|
||||||
writeFileSync(join(p, 'okr.local.md'), `---\nnavn: "${navn}"\n---\n`);
|
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.
|
// 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], {
|
return execFileSync('node', [HOOK], {
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env, HOME: home },
|
env: { ...process.env, HOME: home },
|
||||||
|
input,
|
||||||
encoding: 'utf8',
|
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) {
|
function withDirs(fn) {
|
||||||
const home = mkdtempSync(join(tmpdir(), 'okrhome-'));
|
const home = mkdtempSync(join(tmpdir(), 'okrhome-'));
|
||||||
const work = mkdtempSync(join(tmpdir(), 'okrwork-'));
|
const work = mkdtempSync(join(tmpdir(), 'okrwork-'));
|
||||||
|
|
@ -54,7 +60,7 @@ function withDirs(fn) {
|
||||||
test('kun-hjem: leser org fra hjem-profil, ingen syklus-kontekst', () => {
|
test('kun-hjem: leser org fra hjem-profil, ingen syklus-kontekst', () => {
|
||||||
withDirs((home, work) => {
|
withDirs((home, work) => {
|
||||||
makeHomeProfil(home, 'HjemOrg');
|
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.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)');
|
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) => {
|
withDirs((home, work) => {
|
||||||
makeHomeProfil(home, 'HjemOrg');
|
makeHomeProfil(home, 'HjemOrg');
|
||||||
makeProjectConfig(work, 'ProsjektOrg');
|
makeProjectConfig(work, 'ProsjektOrg');
|
||||||
const out = runHook(work, home);
|
const out = runHook(work, home, OKR_PROMPT);
|
||||||
assert.match(out, /ProsjektOrg/, 'prosjekt-lokal skal vinne');
|
assert.match(out, /ProsjektOrg/, 'prosjekt-lokal skal vinne');
|
||||||
assert.doesNotMatch(out, /HjemOrg/, 'hjem skal ikke leses naar prosjekt-config finnes');
|
assert.doesNotMatch(out, /HjemOrg/, 'hjem skal ikke leses naar prosjekt-config finnes');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
79
tests/topic-guard.test.mjs
Normal file
79
tests/topic-guard.test.mjs
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue