diff --git a/hooks/scripts/inject-okr-context.mjs b/hooks/scripts/inject-okr-context.mjs index faeb3fe..f97e771 100644 --- a/hooks/scripts/inject-okr-context.mjs +++ b/hooks/scripts/inject-okr-context.mjs @@ -2,10 +2,12 @@ // inject-okr-context.mjs // Event: UserPromptSubmit -// Purpose: Inject OKR organization context from .claude/okr.local.md and .claude/okr/ tree. -// Zero npm dependencies. Target execution: <50ms. +// Purpose: Inject the core OKR organization context from .claude/okr.local.md +// (or the home org profile) plus ONE pointer to the second-brain index.md. +// Cycle/history/context retrieval is on-demand via the okr-second-brain-search +// skill — no longer pre-injected here. Zero npm dependencies. Target: <50ms. -import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { parseFrontmatter } from '../../lib/frontmatter.mjs'; @@ -45,8 +47,9 @@ if (rawPrompt) { // 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. -// NOTE: only the org PROFILE resolves to home. The cycle/work tree scan -// (okrDir, below) stays cwd-bound -- syklus data is always project-local. +// NOTE: only the org PROFILE resolves to home. Cycle/work data stays cwd-bound +// and is no longer pre-injected -- it is retrieved on-demand via the +// okr-second-brain-search skill. const configPath = existsSync(projectConfigPath) ? projectConfigPath : (existsSync(homeConfigPath) ? homeConfigPath : null); @@ -84,105 +87,23 @@ try { if (trygghet) parts.push(`Psykologisk trygghet: ${trygghet}`); if (linear) parts.push('Linear: aktivert'); - // Scan .claude/okr/ directory tree (cap at 50 files) - const okrDir = join(cwd, '.claude', 'okr'); - const dirParts = []; - let totalFiles = 0; - - if (existsSync(okrDir)) { - try { - const topEntries = readdirSync(okrDir, { withFileTypes: true }); - for (const entry of topEntries) { - if (!entry.isDirectory()) continue; - if (totalFiles >= 50) break; - try { - const subEntries = readdirSync(join(okrDir, entry.name), { withFileTypes: true }); - const mdFiles = []; - const subDirs = []; - for (const sub of subEntries) { - if (totalFiles >= 50) break; - if (sub.isFile() && sub.name.endsWith('.md')) { - mdFiles.push(sub.name); - totalFiles++; - } else if (sub.isDirectory()) { - subDirs.push(sub.name); - } - } - // Enumerate nested subdirectories (e.g. syklus/T1-2026/) - for (const sd of subDirs) { - if (totalFiles >= 50) break; - try { - const nested = readdirSync(join(okrDir, entry.name, sd), { withFileTypes: true }); - const nestedMd = []; - for (const n of nested) { - if (totalFiles >= 50) break; - if (n.isFile() && n.name.endsWith('.md')) { - nestedMd.push(n.name); - totalFiles++; - } - } - if (nestedMd.length > 0) { - dirParts.push(`${entry.name}/${sd}/ (${nestedMd.length} fil${nestedMd.length > 1 ? 'er' : ''}: ${nestedMd.join(', ')})`); - } - } catch { /* skip unreadable nested dirs */ } - } - if (mdFiles.length > 0) { - dirParts.push(`${entry.name}/ (${mdFiles.length} fil${mdFiles.length > 1 ? 'er' : ''}: ${mdFiles.join(', ')})`); - } - } catch { /* skip unreadable dirs */ } - } - } catch { /* .claude/okr/ scan failed — continue without */ } - } - - // Scan historikk/ for archived cycle count - const histDir = join(okrDir, 'historikk'); - const archivedCycles = []; - if (existsSync(histDir)) { - try { - const histEntries = readdirSync(histDir, { withFileTypes: true }); - for (const entry of histEntries) { - if (entry.isDirectory()) { - archivedCycles.push(entry.name); - } - } - } catch { /* skip */ } - } - - // List active cycle files - const cycleId = syklus; - const cycleParts = []; - if (cycleId) { - const cyclePath = join(okrDir, 'syklus', cycleId); - if (existsSync(cyclePath)) { - try { - const cycleEntries = readdirSync(cyclePath, { withFileTypes: true }); - for (const e of cycleEntries) { - if (e.isFile() && e.name.endsWith('.md')) { - cycleParts.push(e.name); - } - } - } catch { /* skip */ } - } - } - - // Build rich systemMessage + // Base context line from the core profile fields only. The directory-tree + // enumeration that used to live here (the :92-191 MOVE block) is removed: + // retrieval is now on-demand via the okr-second-brain-search skill, not + // pre-injected. The payload is therefore independent of file count. (SC5) let msg = `OKR-kontekst (fra .claude/okr.local.md): ${parts.join(', ')}.`; - if (dirParts.length > 0) { - msg += `\nTilgjengelige kontekstfiler: ${dirParts.join('; ')}.`; - } + // Resolve ONE pointer to the second-brain index.md, reusing most-specific-wins + // (project bundle preferred, else home bundle). Project root is cwd-bound + // (.claude/okr/); the home bundle root is ~/.claude/okr/org/ (two-root model). + const projectIndex = join(cwd, '.claude', 'okr', 'index.md'); + const homeIndex = join(homedir(), '.claude', 'okr', 'org', 'index.md'); + const indexPath = existsSync(projectIndex) + ? projectIndex + : (existsSync(homeIndex) ? homeIndex : null); - if (cycleParts.length > 0) { - msg += `\nAktive OKR-filer i syklus ${cycleId}: ${cycleParts.join(', ')}.`; - } - - if (archivedCycles.length > 0) { - msg += `\nArkiverte sykluser (${archivedCycles.length}): ${archivedCycles.sort().join(', ')}.`; - msg += ' Bruk /okr:analyse for trendanalyse.'; - } - - if (dirParts.length > 0 || cycleParts.length > 0) { - msg += '\nBruk disse filene automatisk nar relevant — ikke be brukeren om a lime inn innhold som allerede finnes.'; + if (indexPath) { + msg += `\nPersonlig OKF-kontekst finnes - sok wikien (skill: okr-second-brain-search) eller se ${indexPath}.`; } process.stdout.write(JSON.stringify({ systemMessage: msg })); diff --git a/tests/inject-core-cap.test.mjs b/tests/inject-core-cap.test.mjs new file mode 100644 index 0000000..c84e98e --- /dev/null +++ b/tests/inject-core-cap.test.mjs @@ -0,0 +1,138 @@ +// inject-core-cap.test.mjs +// Verifiserer hook-splitten (SC5): inject-okr-context emitterer kun kjerne- +// kontekst + EN resolvert index.md-peker, ALDRI tre-enumerering. Beviser: +// (a) byte-cap < 512; (b) payload uavhengig av filantall (lite vs stort tre); +// (c) ingen konsept-filnavn lekker; (d) home-only -> home-bundle-sti i peker; +// (e) ingen '#'. +// Spawner hooken som subprosess m/ kontrollert cwd + HOME. Zero npm deps. +// Monster: tests/inject-okr-context.test.mjs + tre-bygger-helper. + +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', +); + +const OKR_PROMPT = JSON.stringify({ prompt: 'hjelp meg skrive OKR' }); + +function makeProjectConfig(work, navn = 'CapTestOrg') { + const p = join(work, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync( + join(p, 'okr.local.md'), + `---\nnavn: "${navn}"\ngjeldende: "T1-2026"\nsektor: "transport"\n---\n`, + ); +} + +// Idempotent paa index.md, additiv paa konsept-filer (0..count-1). Distinkte +// filnavn (konseptfil-N) for lekkasje-testen. +function makeProjectTree(work, count) { + const lvl = join(work, '.claude', 'okr', 'strategisk-kontekst'); + mkdirSync(lvl, { recursive: true }); + writeFileSync( + join(work, '.claude', 'okr', 'index.md'), + '# OKR-rot\n\nokf_version: kb-layout-2026-06\n', + ); + for (let i = 0; i < count; i++) { + writeFileSync(join(lvl, `konseptfil-${i}.md`), `---\ntype: OKR\n---\n# K${i}\n`); + } +} + +function makeHomeBundle(home, navn = 'HomeCapOrg') { + const org = join(home, '.claude', 'okr', 'org'); + mkdirSync(org, { recursive: true }); + writeFileSync(join(org, 'profil.md'), `---\nnavn: "${navn}"\n---\n`); + writeFileSync(join(org, 'index.md'), '# Org-rot\n\nokf_version: kb-layout-2026-06\n'); +} + +function runHook(cwd, home, input = OKR_PROMPT) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function systemMessage(out) { + return JSON.parse(out).systemMessage; +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +// (a) byte-cap < 512 +test('cap: systemMessage < 512 byte (utf8) selv med 50 konsept-filer', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 50); + const msg = systemMessage(runHook(work, home)); + const bytes = Buffer.byteLength(msg, 'utf8'); + assert.ok(bytes < 512, `payload skal vaere < 512 B, var ${bytes}`); + }); +}); + +// (b) invariant: lite (index+3) vs stort (index+50) tre -> byte-identisk payload. +// SAMME work-dir i begge maalinger -> peker-stien er konstant; eneste variabel +// er filantallet, som splitten skal gjore irrelevant. +test('invariant: payload byte-identisk for lite og stort tre (ingen enumerering)', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 3); + const small = systemMessage(runHook(work, home)); + makeProjectTree(work, 50); + const large = systemMessage(runHook(work, home)); + assert.equal(small, large, 'filantall skal ikke paavirke payload'); + }); +}); + +// (c) ingen konsept-filnavn enumerert +test('ingen lekkasje: konsept-filnavn opptrer ikke i payload; peker bruker index.md', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 5); + const msg = systemMessage(runHook(work, home)); + assert.doesNotMatch(msg, /konseptfil-/, 'konsept-filnavn skal ikke enumereres'); + assert.match(msg, /index\.md/, 'peker skal referere index.md'); + }); +}); + +// (d) home-only -> home-bundle index.md i peker +test('home-only: peker bruker home-bundle index.md (~/.claude/okr/org/index.md)', () => { + withDirs((home, work) => { + makeHomeBundle(home); // tomt prosjekt, kun home + const msg = systemMessage(runHook(work, home)); + assert.match(msg, /HomeCapOrg/, 'org leses fra home-profil'); + assert.match(msg, /okr-second-brain-search/, 'peker nevner skillen'); + const expected = join(home, '.claude', 'okr', 'org', 'index.md'); + assert.ok(msg.includes(expected), `peker skal inneholde home-index-sti: ${expected}`); + }); +}); + +// (e) ingen '#' +test('ingen "#" i payload (peker + sti ASCII-rent)', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 3); + const msg = systemMessage(runHook(work, home)); + assert.doesNotMatch(msg, /#/, 'payload skal ikke inneholde "#"'); + }); +});