#!/usr/bin/env node // 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. import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; 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. // NOTE: only the org PROFILE resolves to home. The cycle/work tree scan // (okrDir, below) stays cwd-bound -- syklus data is always project-local. const configPath = existsSync(projectConfigPath) ? projectConfigPath : (existsSync(homeConfigPath) ? homeConfigPath : null); if (!configPath) { process.exit(0); } try { const content = readFileSync(configPath, 'utf8'); const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) process.exit(0); const fm = match[1]; const get = (key) => { const m = fm.match(new RegExp(`${key}:\\s*["']?([^"'\\n]+)["']?`)); return m ? m[1].trim() : null; }; // Core fields (backwards-compatible with old 4-field format) const org = get('navn') || get('name'); const syklus = get('gjeldende') || get('id') || get('current_cycle'); const sektor = get('sektor') || get('sector') || get('domene'); const linear = fm.includes('aktivert: true') || fm.includes('enabled: true'); // New v1.1 fields (silently skipped if absent) const modenhet = get('modenhetsnivaa'); const fase = get('fase'); const frikoblet = get('okr_frikoblet_fra_loenn'); const trygghet = get('psykologisk_trygghet'); const kortform = get('kortform'); if (!org) process.exit(0); // Build message parts const parts = [`Organisasjon: ${org}${kortform ? ` (${kortform})` : ''}`]; if (syklus) parts.push(`Syklus: ${syklus}${fase ? ` [${fase}]` : ''}`); if (sektor) parts.push(`Sektor: ${sektor}`); if (modenhet) parts.push(`Modenhet: ${modenhet}`); if (frikoblet !== null) parts.push(`OKR frikoblet fra lonn: ${frikoblet}`); 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 let msg = `OKR-kontekst (fra .claude/okr.local.md): ${parts.join(', ')}.`; if (dirParts.length > 0) { msg += `\nTilgjengelige kontekstfiler: ${dirParts.join('; ')}.`; } 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.'; } process.stdout.write(JSON.stringify({ systemMessage: msg })); } catch { // Graceful exit on any error — never block the user process.exit(0); }