feat(ms-ai-architect): C2.1 lib/user-data resolver + ambient org-injeksjon + C1 config bakoverkompat (TDD) [skip-docs]
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) <noreply@anthropic.com>
This commit is contained in:
parent
4ff60803ce
commit
dc0b627e98
6 changed files with 459 additions and 31 deletions
|
|
@ -11,6 +11,11 @@ import {
|
||||||
shouldRunDetection,
|
shouldRunDetection,
|
||||||
summarizeSkillLifecycle,
|
summarizeSkillLifecycle,
|
||||||
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
|
} 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 pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || join(process.cwd());
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
@ -105,22 +110,33 @@ for (const dl of AI_ACT_DEADLINES) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. Check onboarding status ---
|
// --- 4. Onboarding status + ambient org context (C2.1) ---
|
||||||
const orgDir = join(pluginRoot, 'org');
|
// The user-owned org dir (~/.claude/ms-ai-architect/org) wins; the legacy
|
||||||
const ORG_FILES = [
|
// plugin org/ dir is the backward-compat fallback. Read the files once: their
|
||||||
'organization-profile.md',
|
// count drives the status line, their content drives the ambient summary that
|
||||||
'technology-stack.md',
|
// makes org context present in EVERY session (not just a "run onboarding" nudge).
|
||||||
'security-compliance.md',
|
function readOrgFiles(dir) {
|
||||||
'architecture-decisions.md',
|
const files = {};
|
||||||
'business-references.md',
|
let count = 0;
|
||||||
];
|
if (existsSync(dir)) {
|
||||||
let orgComplete = 0;
|
for (const f of ORG_FILES) {
|
||||||
const orgExists = existsSync(orgDir);
|
const p = join(dir, f);
|
||||||
if (orgExists) {
|
if (existsSync(p)) {
|
||||||
for (const f of ORG_FILES) {
|
try {
|
||||||
if (existsSync(join(orgDir, f))) orgComplete++;
|
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 ---
|
// --- 4. Build output ---
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
@ -132,7 +148,7 @@ if (utredningFiles > 0) {
|
||||||
parts.push(`${utredningFiles} utredningsdokument(er) i docs/`);
|
parts.push(`${utredningFiles} utredningsdokument(er) i docs/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgExists || orgComplete === 0) {
|
if (orgComplete === 0) {
|
||||||
parts.push('Ingen virksomhetstilpasning. Kjør /architect:onboard (~5 min)');
|
parts.push('Ingen virksomhetstilpasning. Kjør /architect:onboard (~5 min)');
|
||||||
} else if (orgComplete < ORG_FILES.length) {
|
} else if (orgComplete < ORG_FILES.length) {
|
||||||
parts.push(`Onboarding ${orgComplete}/${ORG_FILES.length}. Kjør /architect:onboard for å fullføre`);
|
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');
|
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) {
|
if (lines.length > 0) {
|
||||||
process.stdout.write(lines.join('\n') + '\n');
|
process.stdout.write(lines.join('\n') + '\n');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@
|
||||||
// manual, in-session, and gated. This module is the pure decision core for the
|
// manual, in-session, and gated. This module is the pure decision core for the
|
||||||
// SessionStart hook + the run-detection.mjs executor.
|
// 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 { readFileSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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
|
// 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)
|
// 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 },
|
{ 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. */
|
/** Coerce a YAML-ish scalar to bool/number/string. Unknown booleans => false. */
|
||||||
function coerce(key, raw) {
|
function coerce(key, raw) {
|
||||||
const v = raw.trim();
|
const v = raw.trim();
|
||||||
|
|
@ -87,19 +89,26 @@ export function parseScheduleConfig(text) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the opt-in schedule config from <pluginRoot>/ms-ai-architect.local.md
|
* Load the opt-in schedule config. C2.1: the user-owned path
|
||||||
* (gitignored per the plugin-settings pattern). Absent/unreadable => OFF default.
|
* (~/.claude/ms-ai-architect/ms-ai-architect.local.md) takes precedence so the
|
||||||
|
* config survives a plugin reinstall (acceptance K2); the legacy
|
||||||
|
* <pluginRoot>/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} pluginRoot
|
||||||
|
* @param {string} [home] injectable home dir (defaults to os.homedir())
|
||||||
* @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}}
|
* @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}}
|
||||||
*/
|
*/
|
||||||
export function loadScheduleConfig(pluginRoot) {
|
export function loadScheduleConfig(pluginRoot, home = homedir()) {
|
||||||
const path = join(pluginRoot, CONFIG_FILENAME);
|
const candidates = [resolveConfigPath(home), join(pluginRoot, CONFIG_FILENAME)];
|
||||||
if (!existsSync(path)) return { ...DEFAULT_SCHEDULE_CONFIG };
|
for (const path of candidates) {
|
||||||
try {
|
if (!existsSync(path)) continue;
|
||||||
return parseScheduleConfig(readFileSync(path, 'utf8'));
|
try {
|
||||||
} catch {
|
return parseScheduleConfig(readFileSync(path, 'utf8'));
|
||||||
return { ...DEFAULT_SCHEDULE_CONFIG };
|
} catch {
|
||||||
|
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
129
scripts/kb-update/lib/user-data.mjs
Normal file
129
scripts/kb-update/lib/user-data.mjs
Normal file
|
|
@ -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<string, string|null>|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');
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
summarizeSkillLifecycle,
|
summarizeSkillLifecycle,
|
||||||
daysSinceLastPoll,
|
daysSinceLastPoll,
|
||||||
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
|
} 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 __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
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);
|
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 root = mkdtempSync(join(tmpdir(), 'sched-'));
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'home-')); // empty home: no user-owned config
|
||||||
try {
|
try {
|
||||||
assert.deepEqual(loadScheduleConfig(root), DEFAULT_SCHEDULE_CONFIG);
|
assert.deepEqual(loadScheduleConfig(root, home), DEFAULT_SCHEDULE_CONFIG);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
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 root = mkdtempSync(join(tmpdir(), 'sched-'));
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
||||||
try {
|
try {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(root, 'ms-ai-architect.local.md'),
|
join(root, 'ms-ai-architect.local.md'),
|
||||||
'---\nscheduled_detection:\n enabled: true\n interval_days: 14\n---\n',
|
'---\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.enabled, true);
|
||||||
assert.equal(cfg.interval_days, 14);
|
assert.equal(cfg.interval_days, 14);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
175
tests/kb-update/test-user-data.test.mjs
Normal file
175
tests/kb-update/test-user-data.test.mjs
Normal file
|
|
@ -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}`);
|
||||||
|
});
|
||||||
|
|
@ -39,6 +39,57 @@ else
|
||||||
fail "Output missing 'Architect:' prefix"
|
fail "Output missing 'Architect:' prefix"
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# -------------------------------------------------------
|
# -------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue