feat(okr): splitt inject-hook ved 91/92 - kjerne + resolvert index-peker (SC5) [skip-docs]
This commit is contained in:
parent
bf0e463b4a
commit
ea2bd444ea
2 changed files with 161 additions and 102 deletions
|
|
@ -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 }));
|
||||
|
|
|
|||
138
tests/inject-core-cap.test.mjs
Normal file
138
tests/inject-core-cap.test.mjs
Normal file
|
|
@ -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 "#"');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue