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:
Kjell Tore Guttormsen 2026-06-22 13:34:05 +02:00
commit dc0b627e98
6 changed files with 459 additions and 31 deletions

View file

@ -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 });
}
});

View 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}`);
});

View file

@ -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 ""
# -------------------------------------------------------