From dc0b627e983659b81bb3f0ff274f5c71bc72c440 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 22 Jun 2026 13:34:05 +0200 Subject: [PATCH] feat(ms-ai-architect): C2.1 lib/user-data resolver + ambient org-injeksjon + C1 config bakoverkompat (TDD) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bæreren i C2 onboarding-redesign (#26). TDD: test FØR kode. - lib/user-data.mjs (NY, ren, SKRIVER ALDRI; kun node:os/path): resolveUserDataDir/resolveOrgDir/resolveConfigPath peker ~/.claude/ms-ai-architect/ (uavhengig av pluginRoot -> overlever reinstall, K2). CONFIG_FILENAME delt med C1 (en sannhetskilde). buildOrgSummary: deterministisk, lengde-kappet H2-ekstraksjon. - detection-schedule.mjs: loadScheduleConfig(pluginRoot, home=homedir()) leser bruker-sti FORST, fallback plugin-rot. Lokal CONFIG_FILENAME -> importeres fra resolver. Alle 3 kallere bruker ett-arg-kall (uendret oppforsel). - session-start-context.mjs: injiserer buildOrgSummary ambient (K1) som egen Virksomhetskontekst-blokk; status-nudge beholdt nar org tom. Verifisert: user-data 16/16 · detection-schedule 23/23 · test-hooks 10/10 · kb-update 191 · kb-eval 100 · validate PASSED. Co-Authored-By: Claude Opus 4.8 (1M context) --- hooks/scripts/session-start-context.mjs | 54 ++++-- scripts/kb-update/lib/detection-schedule.mjs | 33 ++-- scripts/kb-update/lib/user-data.mjs | 129 +++++++++++++ .../test-detection-schedule.test.mjs | 48 ++++- tests/kb-update/test-user-data.test.mjs | 175 ++++++++++++++++++ tests/test-hooks.sh | 51 +++++ 6 files changed, 459 insertions(+), 31 deletions(-) create mode 100644 scripts/kb-update/lib/user-data.mjs create mode 100644 tests/kb-update/test-user-data.test.mjs diff --git a/hooks/scripts/session-start-context.mjs b/hooks/scripts/session-start-context.mjs index c8cfe2e..841d6d7 100644 --- a/hooks/scripts/session-start-context.mjs +++ b/hooks/scripts/session-start-context.mjs @@ -11,6 +11,11 @@ import { shouldRunDetection, summarizeSkillLifecycle, } from '../../scripts/kb-update/lib/detection-schedule.mjs'; +import { + resolveOrgDir, + ORG_FILES, + buildOrgSummary, +} from '../../scripts/kb-update/lib/user-data.mjs'; const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || join(process.cwd()); const cwd = process.cwd(); @@ -105,22 +110,33 @@ for (const dl of AI_ACT_DEADLINES) { } } -// --- 4. Check onboarding status --- -const orgDir = join(pluginRoot, 'org'); -const ORG_FILES = [ - 'organization-profile.md', - 'technology-stack.md', - 'security-compliance.md', - 'architecture-decisions.md', - 'business-references.md', -]; -let orgComplete = 0; -const orgExists = existsSync(orgDir); -if (orgExists) { - for (const f of ORG_FILES) { - if (existsSync(join(orgDir, f))) orgComplete++; +// --- 4. Onboarding status + ambient org context (C2.1) --- +// The user-owned org dir (~/.claude/ms-ai-architect/org) wins; the legacy +// plugin org/ dir is the backward-compat fallback. Read the files once: their +// count drives the status line, their content drives the ambient summary that +// makes org context present in EVERY session (not just a "run onboarding" nudge). +function readOrgFiles(dir) { + const files = {}; + let count = 0; + if (existsSync(dir)) { + for (const f of ORG_FILES) { + const p = join(dir, f); + if (existsSync(p)) { + try { + files[f] = readFileSync(p, 'utf8'); + count++; + } catch { + // Unreadable file — skip; advisory only + } + } + } } + return { files, count }; } +const userOrg = readOrgFiles(resolveOrgDir()); +const orgData = userOrg.count > 0 ? userOrg : readOrgFiles(join(pluginRoot, 'org')); +const orgComplete = orgData.count; +const orgSummary = buildOrgSummary(orgData.files); // --- 4. Build output --- const parts = []; @@ -132,7 +148,7 @@ if (utredningFiles > 0) { parts.push(`${utredningFiles} utredningsdokument(er) i docs/`); } -if (!orgExists || orgComplete === 0) { +if (orgComplete === 0) { parts.push('Ingen virksomhetstilpasning. Kjør /architect:onboard (~5 min)'); } else if (orgComplete < ORG_FILES.length) { parts.push(`Onboarding ${orgComplete}/${ORG_FILES.length}. Kjør /architect:onboard for å fullføre`); @@ -171,6 +187,14 @@ if (parts.length > 0) { lines.push('Architect: Ingen aktive sesjoner. KB oppdatert. /architect:help'); } +// Ambient org context (C2.1): inject the compact summary so org facts are +// PRESENT in the session, not merely discoverable. Length-capped by +// buildOrgSummary. Only emitted when onboarding has produced content. +if (orgSummary) { + lines.push('Virksomhetskontekst (auto-injisert fra ~/.claude/ms-ai-architect/org):'); + for (const l of orgSummary.split('\n')) lines.push(`- ${l}`); +} + if (lines.length > 0) { process.stdout.write(lines.join('\n') + '\n'); } diff --git a/scripts/kb-update/lib/detection-schedule.mjs b/scripts/kb-update/lib/detection-schedule.mjs index 098e126..f6cfe95 100644 --- a/scripts/kb-update/lib/detection-schedule.mjs +++ b/scripts/kb-update/lib/detection-schedule.mjs @@ -8,10 +8,14 @@ // manual, in-session, and gated. This module is the pure decision core for the // SessionStart hook + the run-detection.mjs executor. // -// Zero dependencies beyond node:fs/path. parseScheduleConfig is pure. +// parseScheduleConfig is pure (text in, config out). loadScheduleConfig resolves +// paths via lib/user-data.mjs (which itself depends only on node:os/path), so +// the user-owned config dir is the single source of truth shared with the hook. import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { resolveConfigPath, CONFIG_FILENAME } from './user-data.mjs'; // Opt-in: detection is OFF until the user sets enabled:true. "Frivillig å sette // opp — men gjøres det, skal det virke." (operator 2026-06-20) @@ -36,8 +40,6 @@ export const DETECTION_STEPS = Object.freeze([ { name: 'detect-skill-lifecycle', dir: 'kb-eval', script: 'detect-skill-lifecycle.mjs', args: ['--write'], skillLifecycle: true }, ]); -const CONFIG_FILENAME = 'ms-ai-architect.local.md'; - /** Coerce a YAML-ish scalar to bool/number/string. Unknown booleans => false. */ function coerce(key, raw) { const v = raw.trim(); @@ -87,19 +89,26 @@ export function parseScheduleConfig(text) { } /** - * Load the opt-in schedule config from /ms-ai-architect.local.md - * (gitignored per the plugin-settings pattern). Absent/unreadable => OFF default. + * Load the opt-in schedule config. C2.1: the user-owned path + * (~/.claude/ms-ai-architect/ms-ai-architect.local.md) takes precedence so the + * config survives a plugin reinstall (acceptance K2); the legacy + * /ms-ai-architect.local.md remains as a backward-compat fallback + * for setups that already wrote it there. Absent/unreadable => OFF default. * @param {string} pluginRoot + * @param {string} [home] injectable home dir (defaults to os.homedir()) * @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}} */ -export function loadScheduleConfig(pluginRoot) { - const path = join(pluginRoot, CONFIG_FILENAME); - if (!existsSync(path)) return { ...DEFAULT_SCHEDULE_CONFIG }; - try { - return parseScheduleConfig(readFileSync(path, 'utf8')); - } catch { - return { ...DEFAULT_SCHEDULE_CONFIG }; +export function loadScheduleConfig(pluginRoot, home = homedir()) { + const candidates = [resolveConfigPath(home), join(pluginRoot, CONFIG_FILENAME)]; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + return parseScheduleConfig(readFileSync(path, 'utf8')); + } catch { + return { ...DEFAULT_SCHEDULE_CONFIG }; + } } + return { ...DEFAULT_SCHEDULE_CONFIG }; } /** diff --git a/scripts/kb-update/lib/user-data.mjs b/scripts/kb-update/lib/user-data.mjs new file mode 100644 index 0000000..c8f6d50 --- /dev/null +++ b/scripts/kb-update/lib/user-data.mjs @@ -0,0 +1,129 @@ +// user-data.mjs — Spor C fase C2.1: user-owned storage + ambient org context. +// +// This module is a PURE RESOLVER. It NEVER writes and never spawns anything — +// it only resolves user-owned paths and builds a compact, deterministic +// summary from file contents it is HANDED (no fs read of its own). All org-/ +// config writing happens elsewhere, gated, via lib/atomic-write + lib/backup. +// +// Why a user-owned dir? Org context + the C1 scheduler config previously lived +// inside the plugin directory (gitignored), so a plugin reinstall / marketplace +// move blew them away. Resolving them under ~/.claude/ms-ai-architect/ — a +// sibling of ~/.claude/plugins/, independent of pluginRoot — makes them survive +// reinstall (acceptance K2). Privacy is preserved: this path is in no git repo. +// +// Zero dependencies beyond node:os/path. Every function is pure. + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// The plugin's folder under ~/.claude/ (sibling of ~/.claude/plugins/). +export const USER_DATA_DIRNAME = 'ms-ai-architect'; + +// The scheduler config filename. Single source of truth shared with +// detection-schedule.mjs (C1) so loadScheduleConfig stays backward-compatible. +export const CONFIG_FILENAME = 'ms-ai-architect.local.md'; + +// The five structured onboarding files, in the order they are surfaced. +export const ORG_FILES = Object.freeze([ + 'organization-profile.md', + 'technology-stack.md', + 'security-compliance.md', + 'architecture-decisions.md', + 'business-references.md', +]); + +const DEFAULT_SUMMARY_CAP = 25; // max summary lines (hook-injection budget) +const DEFAULT_MAX_VALUE_LEN = 160; // per-field char cap (free-text guard) + +/** The user-owned data root: ~/.claude/ms-ai-architect/ (survives reinstall). */ +export function resolveUserDataDir(home = homedir()) { + return join(home, '.claude', USER_DATA_DIRNAME); +} + +/** The user-owned org/ directory (the five structured onboarding files). */ +export function resolveOrgDir(home = homedir()) { + return join(resolveUserDataDir(home), 'org'); +} + +/** The user-owned scheduler config path (same filename as the C1 plugin-root one). */ +export function resolveConfigPath(home = homedir()) { + return join(resolveUserDataDir(home), CONFIG_FILENAME); +} + +/** Drop a leading `---`…`---` YAML frontmatter block, if present. */ +function stripFrontmatter(text) { + if (!text.startsWith('---')) return text; + const m = text.match(/^---\n[\s\S]*?\n---\n?/); + return m ? text.slice(m[0].length) : text; +} + +/** + * Extract `## Header` → collapsed-body pairs from one org file's markdown. + * Frontmatter and the leading H1 are ignored; H2 sections with an empty body + * are skipped. Body lines are collapsed to a single spaced line. Pure. + * @param {string} content + * @returns {Array<[string, string]>} + */ +function extractSections(content) { + const lines = stripFrontmatter(content).split('\n'); + const out = []; + let header = null; + let body = []; + const flush = () => { + if (header !== null) { + const value = body.join(' ').replace(/\s+/g, ' ').trim(); + if (value) out.push([header, value]); + } + }; + for (const line of lines) { + const h2 = line.match(/^##\s+(.+?)\s*$/); + if (h2) { + flush(); + header = h2[1]; + body = []; + } else if (header !== null && !/^#\s/.test(line)) { + body.push(line); + } + } + flush(); + return out; +} + +/** + * Build a compact, deterministic org-context summary from org file contents. + * PURE — takes a `{ filename: contentString }` map (the caller reads the files) + * and returns a length-capped `Header: value` block, one field per line, in + * ORG_FILES order then header order. Empty/garbage input => "". + * + * Robust by design: it extracts whatever H2 sections the onboarding agent + * wrote rather than hard-coding field names, so header wording can drift + * without silently emptying the summary. + * + * @param {Record|null|undefined} orgFiles + * @param {{cap?: number, maxValueLen?: number}} [opts] + * @returns {string} + */ +export function buildOrgSummary(orgFiles, opts = {}) { + if (!orgFiles || typeof orgFiles !== 'object') return ''; + const cap = opts.cap ?? DEFAULT_SUMMARY_CAP; + const maxValueLen = opts.maxValueLen ?? DEFAULT_MAX_VALUE_LEN; + + const fields = []; + for (const name of ORG_FILES) { + const content = orgFiles[name]; + if (typeof content !== 'string' || !content.trim()) continue; + for (const [header, value] of extractSections(content)) { + const trimmed = + value.length > maxValueLen ? `${value.slice(0, maxValueLen - 1).trimEnd()}…` : value; + fields.push(`${header}: ${trimmed}`); + } + } + + if (fields.length === 0) return ''; + if (fields.length <= cap) return fields.join('\n'); + + // Over budget: keep (cap-1) fields, then a marker line for the omitted rest. + const kept = fields.slice(0, cap - 1); + kept.push(`… (+${fields.length - kept.length} flere felt)`); + return kept.join('\n'); +} diff --git a/tests/kb-update/test-detection-schedule.test.mjs b/tests/kb-update/test-detection-schedule.test.mjs index 855c867..43251e6 100644 --- a/tests/kb-update/test-detection-schedule.test.mjs +++ b/tests/kb-update/test-detection-schedule.test.mjs @@ -25,6 +25,7 @@ import { summarizeSkillLifecycle, daysSinceLastPoll, } from '../../scripts/kb-update/lib/detection-schedule.mjs'; +import { resolveConfigPath, resolveUserDataDir } from '../../scripts/kb-update/lib/user-data.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); @@ -104,27 +105,66 @@ test('daysSinceLastPoll — computes whole days since last_poll', () => { assert.equal(daysSinceLastPoll({ last_poll: tenDaysAgo }, now), 10); }); -test('loadScheduleConfig — missing config file => OFF default', () => { +test('loadScheduleConfig — no config (neither user nor plugin-root) => OFF default', () => { const root = mkdtempSync(join(tmpdir(), 'sched-')); + const home = mkdtempSync(join(tmpdir(), 'home-')); // empty home: no user-owned config try { - assert.deepEqual(loadScheduleConfig(root), DEFAULT_SCHEDULE_CONFIG); + assert.deepEqual(loadScheduleConfig(root, home), DEFAULT_SCHEDULE_CONFIG); } finally { rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); } }); -test('loadScheduleConfig — reads ms-ai-architect.local.md when present', () => { +test('loadScheduleConfig — reads plugin-root ms-ai-architect.local.md when no user config (fallback)', () => { const root = mkdtempSync(join(tmpdir(), 'sched-')); + const home = mkdtempSync(join(tmpdir(), 'home-')); try { writeFileSync( join(root, 'ms-ai-architect.local.md'), '---\nscheduled_detection:\n enabled: true\n interval_days: 14\n---\n', ); - const cfg = loadScheduleConfig(root); + const cfg = loadScheduleConfig(root, home); assert.equal(cfg.enabled, true); assert.equal(cfg.interval_days, 14); } finally { rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +// --- C2.1: user-owned config wins; plugin-root is the backward-compat fallback --- + +function writeUserConfig(home, text) { + mkdirSync(resolveUserDataDir(home), { recursive: true }); + writeFileSync(resolveConfigPath(home), text); +} + +test('loadScheduleConfig — user-owned config is read even when pluginRoot has none (K2 survival)', () => { + const root = mkdtempSync(join(tmpdir(), 'sched-')); // fresh reinstall: plugin root has no config + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + writeUserConfig(home, '---\nscheduled_detection:\n enabled: true\n interval_days: 5\n---\n'); + const cfg = loadScheduleConfig(root, home); + assert.equal(cfg.enabled, true, 'config survives in the user dir, independent of pluginRoot'); + assert.equal(cfg.interval_days, 5); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +test('loadScheduleConfig — user config wins over a plugin-root config', () => { + const root = mkdtempSync(join(tmpdir(), 'sched-')); + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + writeFileSync(join(root, 'ms-ai-architect.local.md'), 'scheduled_detection:\n enabled: true\n interval_days: 99\n'); + writeUserConfig(home, 'scheduled_detection:\n enabled: true\n interval_days: 3\n'); + const cfg = loadScheduleConfig(root, home); + assert.equal(cfg.interval_days, 3, 'user path takes precedence over plugin-root'); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); } }); diff --git a/tests/kb-update/test-user-data.test.mjs b/tests/kb-update/test-user-data.test.mjs new file mode 100644 index 0000000..35e0313 --- /dev/null +++ b/tests/kb-update/test-user-data.test.mjs @@ -0,0 +1,175 @@ +// tests/kb-update/test-user-data.test.mjs +// Spor C fase C2.1: user-owned storage + ambient org-context injection (#1 + #2). +// +// lib/user-data.mjs is the PURE resolver every later C2 sub-session builds on. +// It NEVER writes — it only resolves user-owned paths and builds a compact, +// deterministic org-context summary from file contents handed to it. This suite +// proves: +// (A) the resolvers point at ~/.claude/ms-ai-architect/ (a USER-owned dir, +// independent of pluginRoot => survives plugin reinstall — acceptance K2), +// and the config filename matches C1's so loadScheduleConfig stays compat, +// (B) buildOrgSummary is deterministic, length-capped, tolerant, and pure +// (no fs) so the SessionStart hook can inject it ambiently (K1). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { + USER_DATA_DIRNAME, + CONFIG_FILENAME, + ORG_FILES, + resolveUserDataDir, + resolveOrgDir, + resolveConfigPath, + buildOrgSummary, +} from '../../scripts/kb-update/lib/user-data.mjs'; + +// =========================================================================== +// (A) Resolvers — pure, homedir-injectable, user-owned (survives reinstall) +// =========================================================================== + +test('USER_DATA_DIRNAME — the plugin folder under ~/.claude', () => { + assert.equal(USER_DATA_DIRNAME, 'ms-ai-architect'); +}); + +test('resolveUserDataDir — user-owned dir under ~/.claude, independent of pluginRoot (K2)', () => { + assert.equal(resolveUserDataDir('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect')); +}); + +test('resolveUserDataDir — defaults to os.homedir()', () => { + assert.equal(resolveUserDataDir(), join(homedir(), '.claude', 'ms-ai-architect')); +}); + +test('resolveOrgDir — org/ under the user data dir', () => { + assert.equal(resolveOrgDir('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect', 'org')); +}); + +test('resolveConfigPath — config under user data dir; filename matches C1 (backward-compat)', () => { + assert.equal( + resolveConfigPath('/Users/x'), + join('/Users/x', '.claude', 'ms-ai-architect', CONFIG_FILENAME), + ); + assert.equal(CONFIG_FILENAME, 'ms-ai-architect.local.md', 'must match detection-schedule CONFIG_FILENAME'); +}); + +test('ORG_FILES — the five canonical onboarding files in deterministic order', () => { + assert.deepEqual([...ORG_FILES], [ + 'organization-profile.md', + 'technology-stack.md', + 'security-compliance.md', + 'architecture-decisions.md', + 'business-references.md', + ]); +}); + +// =========================================================================== +// (B) buildOrgSummary — deterministic, capped, pure +// =========================================================================== + +const PROFILE = [ + '---', + 'category: organization-profile', + 'completed: true', + 'last_updated: 2026-06-22', + '---', + '', + '# Virksomhetsprofil', + '', + '## Sektortype', + 'Offentlig sektor', + '', + '## Sektor', + 'Statlig', + '', + '## Virksomhet', + 'Statens vegvesen — nasjonal veiforvaltning', + '', + '## Størrelse', + '2000-10000', + '', + '## Regulatoriske krav', + 'Personopplysningsloven/GDPR, Arkivloven, Forvaltningsloven', + '', +].join('\n'); + +test('buildOrgSummary — no files / null content => empty string', () => { + assert.equal(buildOrgSummary({}), ''); + assert.equal(buildOrgSummary({ 'organization-profile.md': null }), ''); +}); + +test('buildOrgSummary — null/undefined/garbage input is tolerated (returns "")', () => { + assert.equal(buildOrgSummary(null), ''); + assert.equal(buildOrgSummary(undefined), ''); + assert.equal(buildOrgSummary({ 'organization-profile.md': 12345 }), ''); +}); + +test('buildOrgSummary — strips frontmatter + H1, extracts H2 section => value lines', () => { + const s = buildOrgSummary({ 'organization-profile.md': PROFILE }); + assert.doesNotMatch(s, /category:/, 'frontmatter stripped'); + assert.doesNotMatch(s, /completed:/, 'frontmatter stripped'); + assert.doesNotMatch(s, /Virksomhetsprofil/, 'H1 not included'); + assert.match(s, /Sektortype: Offentlig sektor/); + assert.match(s, /Sektor: Statlig/); + assert.match(s, /Virksomhet: Statens vegvesen/); + assert.match(s, /Størrelse: 2000-10000/); + assert.match(s, /Regulatoriske krav: .*Arkivloven/); +}); + +test('buildOrgSummary — deterministic order follows ORG_FILES regardless of input key order', () => { + const TECH = '---\ncategory: technology-stack\n---\n## Lisenstype\nE5\n'; + const out = buildOrgSummary({ 'technology-stack.md': TECH, 'organization-profile.md': PROFILE }); + assert.ok( + out.indexOf('Sektortype') < out.indexOf('Lisenstype'), + 'organization-profile sections precede technology-stack sections', + ); +}); + +test('buildOrgSummary — unknown filenames are ignored (only ORG_FILES contribute)', () => { + const s = buildOrgSummary({ 'random.md': '## Hemmelig\nverdi' }); + assert.equal(s, ''); +}); + +test('buildOrgSummary — skips empty sections (header with no body)', () => { + const c = '## Sektor\n\n## Størrelse\n500-2000\n'; + const s = buildOrgSummary({ 'organization-profile.md': c }); + assert.doesNotMatch(s, /Sektor:/, 'empty section omitted'); + assert.match(s, /Størrelse: 500-2000/); +}); + +test('buildOrgSummary — collapses a multi-line section body to one line', () => { + const c = '## Virksomhet\nLinje en\nLinje to\n'; + const s = buildOrgSummary({ 'organization-profile.md': c }); + assert.match(s, /Virksomhet: Linje en Linje to/); +}); + +test('buildOrgSummary — caps a long free-text value with an ellipsis (budget guard)', () => { + const long = 'x'.repeat(500); + const s = buildOrgSummary( + { 'business-references.md': `## Referansearkitektur\n${long}\n` }, + { maxValueLen: 80 }, + ); + const line = s.split('\n').find((l) => l.includes('Referansearkitektur')); + assert.ok(line, 'value line present'); + assert.match(line, /…$/, 'truncation marker appended'); + // label "Referansearkitektur: " + value(<=80) + assert.ok(line.length <= 'Referansearkitektur: '.length + 80, `line too long: ${line.length}`); +}); + +test('buildOrgSummary — caps total line count and marks omitted fields', () => { + const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n'); + const s = buildOrgSummary({ 'organization-profile.md': many }, { cap: 10 }); + const lines = s.split('\n').filter(Boolean); + assert.ok(lines.length <= 10, `capped to 10, got ${lines.length}`); + assert.match(s, /flere/, 'truncation marker indicates omitted fields'); +}); + +test('buildOrgSummary — default cap keeps the summary compact (<= 25 lines)', () => { + const all = {}; + for (const f of ORG_FILES) { + all[f] = Array.from({ length: 8 }, (_, i) => `## ${f}-Felt${i}\nverdi${i}`).join('\n\n'); + } + const s = buildOrgSummary(all); // default cap + const lines = s.split('\n').filter(Boolean); + assert.ok(lines.length <= 25, `default cap should keep <=25 lines, got ${lines.length}`); +}); diff --git a/tests/test-hooks.sh b/tests/test-hooks.sh index f302790..db771b8 100644 --- a/tests/test-hooks.sh +++ b/tests/test-hooks.sh @@ -39,6 +39,57 @@ else fail "Output missing 'Architect:' prefix" fi +# C2.1: ambient org-context injection from the USER-owned org dir (HOME override). +# Proves K1 — the hook injects org CONTENT, not just a status nudge. +TMPHOME=$(mktemp -d) +mkdir -p "$TMPHOME/.claude/ms-ai-architect/org" +cat > "$TMPHOME/.claude/ms-ai-architect/org/organization-profile.md" <<'ORGEOF' +--- +category: organization-profile +completed: true +--- + +# Virksomhetsprofil + +## Sektor +Statlig + +## Virksomhet +Testetaten +ORGEOF + +OUTPUT=$(CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" HOME="$TMPHOME" node "$SCRIPTS_DIR/session-start-context.mjs" 2>/dev/null) + +if echo "$OUTPUT" | grep -q "Virksomhetskontekst (auto-injisert"; then + pass "Injects ambient org-context block when user org/ has content (K1)" +else + fail "Missing ambient org-context block when user org/ has content" +fi + +if echo "$OUTPUT" | grep -q "Sektor: Statlig"; then + pass "Ambient block reflects an org fact (Sektor: Statlig)" +else + fail "Ambient block missing org fact" +fi + +# Empty home (no user org, no plugin org) => onboarding nudge, NO ambient block. +TMPHOME2=$(mktemp -d) +OUTPUT=$(CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" HOME="$TMPHOME2" node "$SCRIPTS_DIR/session-start-context.mjs" 2>/dev/null) + +if echo "$OUTPUT" | grep -q "Ingen virksomhetstilpasning"; then + pass "No org content => onboarding nudge" +else + fail "Expected onboarding nudge when no org content" +fi + +if echo "$OUTPUT" | grep -q "Virksomhetskontekst (auto-injisert"; then + fail "Should NOT inject ambient block when org is empty" +else + pass "No ambient block when org is empty" +fi + +rm -rf "$TMPHOME" "$TMPHOME2" + echo "" # -------------------------------------------------------