// tests/kb-update/test-detection-schedule.test.mjs // Spor C / C1 (Sesjon 21): opt-in session-anchored detection. // // The detection layer (poll/report/discover/skill-lifecycle) is PURE node and // never contacts Anthropic, so it is OUTSIDE the ToS surface (Consumer Terms // §3.7 targets automated access to "our Services"). This suite proves: // (A) the opt-in config defaults OFF and parses safely, // (B) shouldRunDetection gates the hook's background spawn (disabled => never), // (C) the skill-lifecycle one-liner surfaced at session start, // (D) the STRUCTURAL ToS guarantee: run-detection.mjs is Claude-free — it // only ever spawns `node` on the known detection scripts, never `claude`. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { DEFAULT_SCHEDULE_CONFIG, DETECTION_STEPS, parseScheduleConfig, serializeScheduleConfig, loadScheduleConfig, shouldRunDetection, summarizeSkillLifecycle, daysSinceLastPoll, } from '../../scripts/kb-update/lib/detection-schedule.mjs'; import { writeScheduleConfig } from '../../scripts/kb-update/write-schedule-config.mjs'; import { resolveConfigPath, resolveUserDataDir } from '../../scripts/kb-update/lib/user-data.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); // =========================================================================== // (A) parseScheduleConfig / loadScheduleConfig — opt-in, default OFF // =========================================================================== test('DEFAULT_SCHEDULE_CONFIG — opt-in: disabled by default', () => { assert.equal(DEFAULT_SCHEDULE_CONFIG.enabled, false, 'detection is OFF until the user opts in'); assert.equal(DEFAULT_SCHEDULE_CONFIG.interval_days, 7); assert.equal(DEFAULT_SCHEDULE_CONFIG.include_skill_lifecycle, true); }); test('parseScheduleConfig — empty/absent text returns the OFF default', () => { assert.deepEqual(parseScheduleConfig(''), DEFAULT_SCHEDULE_CONFIG); assert.deepEqual(parseScheduleConfig('# just some markdown, no frontmatter'), DEFAULT_SCHEDULE_CONFIG); }); test('parseScheduleConfig — reads an enabled block with overrides', () => { const text = [ '---', 'scheduled_detection:', ' enabled: true', ' interval_days: 3', ' include_skill_lifecycle: false', '---', '', '# notes', ].join('\n'); const cfg = parseScheduleConfig(text); assert.equal(cfg.enabled, true); assert.equal(cfg.interval_days, 3); assert.equal(cfg.include_skill_lifecycle, false); }); test('parseScheduleConfig — partial block keeps defaults for missing keys', () => { const cfg = parseScheduleConfig('scheduled_detection:\n enabled: true\n'); assert.equal(cfg.enabled, true); assert.equal(cfg.interval_days, 7, 'unspecified key falls back to default'); assert.equal(cfg.include_skill_lifecycle, true); }); test('parseScheduleConfig — malformed input never throws, falls back to OFF', () => { assert.equal(parseScheduleConfig('scheduled_detection: : : nonsense').enabled, false); assert.equal(parseScheduleConfig('scheduled_detection:\n enabled: maybe\n').enabled, false, 'a non-boolean enabled is treated as OFF'); }); // --- Tier 2 (launchd) cadence: os_scheduler_cadence (Sesjon 24) --- test('DEFAULT_SCHEDULE_CONFIG — os_scheduler_cadence defaults to daily', () => { assert.equal(DEFAULT_SCHEDULE_CONFIG.os_scheduler_cadence, 'daily'); }); test('parseScheduleConfig — reads os_scheduler_cadence: interval', () => { const cfg = parseScheduleConfig('scheduled_detection:\n os_scheduler_cadence: interval\n'); assert.equal(cfg.os_scheduler_cadence, 'interval'); }); test('parseScheduleConfig — unknown/garbage cadence falls back to daily', () => { assert.equal(parseScheduleConfig('scheduled_detection:\n os_scheduler_cadence: weekly\n').os_scheduler_cadence, 'daily'); assert.equal(parseScheduleConfig('scheduled_detection:\n enabled: true\n').os_scheduler_cadence, 'daily', 'unspecified cadence => daily default'); }); // =========================================================================== // (A2) C2.3 — serializeScheduleConfig + writeScheduleConfig (onboarding writes // the opt-in config; parseScheduleConfig round-trips it — acceptance K5) // =========================================================================== test('serializeScheduleConfig — produces a parseable scheduled_detection: block', () => { const text = serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'interval' }); assert.match(text, /^---/, 'frontmatter-fenced config file'); assert.match(text, /scheduled_detection:/); assert.match(text, /\n {2}enabled: true/); assert.match(text, /\n {2}os_scheduler_cadence: interval/); }); test('serializeScheduleConfig — round-trips through parseScheduleConfig (daily)', () => { const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'daily' })); assert.equal(cfg.enabled, true); assert.equal(cfg.os_scheduler_cadence, 'daily'); assert.equal(cfg.interval_days, 7, 'unspecified key serialized at its default'); assert.equal(cfg.include_skill_lifecycle, true); }); test('serializeScheduleConfig — round-trips interval cadence + disabled', () => { const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: false, os_scheduler_cadence: 'interval' })); assert.equal(cfg.enabled, false); assert.equal(cfg.os_scheduler_cadence, 'interval'); }); test('serializeScheduleConfig — preserves a non-default interval_days through the round-trip', () => { const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, interval_days: 14, os_scheduler_cadence: 'daily' })); assert.equal(cfg.interval_days, 14); }); test('serializeScheduleConfig — garbage cadence is normalized to daily', () => { const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'weekly' })); assert.equal(cfg.os_scheduler_cadence, 'daily'); }); test('writeScheduleConfig — writes user config that parseScheduleConfig reads back (K5)', () => { const home = mkdtempSync(join(tmpdir(), 'home-')); try { const { configPath } = writeScheduleConfig({ enabled: true, cadence: 'interval', home }); assert.equal(configPath, resolveConfigPath(home), 'writes to the user-owned config path'); const cfg = parseScheduleConfig(readFileSync(configPath, 'utf8')); assert.equal(cfg.enabled, true); assert.equal(cfg.os_scheduler_cadence, 'interval'); } finally { rmSync(home, { recursive: true, force: true }); } }); test('writeScheduleConfig — coerces string flags (CLI args arrive as strings)', () => { const home = mkdtempSync(join(tmpdir(), 'home-')); try { writeScheduleConfig({ enabled: 'true', cadence: 'daily', home }); const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8')); assert.equal(cfg.enabled, true, '"true" string => boolean true'); assert.equal(cfg.os_scheduler_cadence, 'daily'); } finally { rmSync(home, { recursive: true, force: true }); } }); test('writeScheduleConfig — merge preserves pre-existing keys (interval_days) it does not own', () => { const home = mkdtempSync(join(tmpdir(), 'home-')); try { mkdirSync(resolveUserDataDir(home), { recursive: true }); writeFileSync( resolveConfigPath(home), 'scheduled_detection:\n enabled: true\n interval_days: 21\n include_skill_lifecycle: false\n', ); writeScheduleConfig({ enabled: false, cadence: 'interval', home }); const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8')); assert.equal(cfg.enabled, false, 'onboarding-owned key updated'); assert.equal(cfg.os_scheduler_cadence, 'interval', 'onboarding-owned key updated'); assert.equal(cfg.interval_days, 21, 'non-owned key preserved across the rewrite'); assert.equal(cfg.include_skill_lifecycle, false, 'non-owned key preserved across the rewrite'); } finally { rmSync(home, { recursive: true, force: true }); } }); test('writeScheduleConfig — backs up an existing config before overwriting it', () => { const home = mkdtempSync(join(tmpdir(), 'home-')); try { writeScheduleConfig({ enabled: true, cadence: 'daily', home }); // first write: nothing to back up const backupRoot = join(resolveUserDataDir(home), '.backups'); assert.equal(existsSync(backupRoot), false, 'no backup created on first write (no prior file)'); writeScheduleConfig({ enabled: false, cadence: 'interval', home }); // second write: prior file exists assert.equal(existsSync(backupRoot), true, 'prior config backed up before the second write'); const stamps = readdirSync(backupRoot); assert.equal(stamps.length, 1, 'exactly one backup generation'); const backedUp = parseScheduleConfig( readFileSync(join(backupRoot, stamps[0], 'ms-ai-architect.local.md'), 'utf8'), ); assert.equal(backedUp.enabled, true, 'backup holds the PRIOR (pre-overwrite) config'); } finally { rmSync(home, { recursive: true, force: true }); } }); test('writeScheduleConfig — loadScheduleConfig picks up what onboarding wrote (end-to-end)', () => { const root = mkdtempSync(join(tmpdir(), 'sched-')); const home = mkdtempSync(join(tmpdir(), 'home-')); try { writeScheduleConfig({ enabled: true, cadence: 'interval', home }); const cfg = loadScheduleConfig(root, home); assert.equal(cfg.enabled, true, 'the scheduler loads the onboarding-written user config'); assert.equal(cfg.os_scheduler_cadence, 'interval'); } finally { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); } }); test('daysSinceLastPoll — Infinity when report missing or last_poll absent/invalid', () => { const now = Date.UTC(2026, 5, 21); assert.equal(daysSinceLastPoll(null, now), Infinity); assert.equal(daysSinceLastPoll({}, now), Infinity); assert.equal(daysSinceLastPoll({ last_poll: 'not-a-date' }, now), Infinity); }); test('daysSinceLastPoll — computes whole days since last_poll', () => { const now = Date.UTC(2026, 5, 21, 0, 0, 0); const tenDaysAgo = new Date(Date.UTC(2026, 5, 11, 0, 0, 0)).toISOString(); assert.equal(daysSinceLastPoll({ last_poll: tenDaysAgo }, now), 10); }); 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, home), DEFAULT_SCHEDULE_CONFIG); } finally { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); } }); 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, 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 }); } }); // =========================================================================== // (B) shouldRunDetection — the hook's background-spawn gate // =========================================================================== test('shouldRunDetection — DISABLED (default) never runs, even when stale', () => { const r = shouldRunDetection(DEFAULT_SCHEDULE_CONFIG, 999); assert.equal(r.run, false); assert.match(r.reason, /opt-in|disabled/i); }); test('shouldRunDetection — enabled + stale (days >= interval) runs', () => { const cfg = { enabled: true, interval_days: 7, include_skill_lifecycle: true }; assert.equal(shouldRunDetection(cfg, 7).run, true, 'boundary: exactly interval => run'); assert.equal(shouldRunDetection(cfg, 30).run, true); }); test('shouldRunDetection — enabled + fresh (days < interval) does NOT run', () => { const cfg = { enabled: true, interval_days: 7, include_skill_lifecycle: true }; const r = shouldRunDetection(cfg, 3); assert.equal(r.run, false); assert.match(r.reason, /fresh|fersk/i); }); test('shouldRunDetection — force overrides a disabled config', () => { assert.equal(shouldRunDetection(DEFAULT_SCHEDULE_CONFIG, 0, { force: true }).run, true); }); // =========================================================================== // (C) summarizeSkillLifecycle — one-liner surfaced at session start // =========================================================================== const REPORT_WITH_SIGNALS = { overlap: { pairs: [{ pair: ['ms-ai-engineering', 'ms-ai-infrastructure'], combined: 7.4167 }, { pair: ['a', 'b'], combined: 2.1 }] }, coverage: { summary: { gaps: 1, thin: 2 }, gaps: [{ category: 'x' }] }, bloat: { summary: { bloatCandidates: [], staleCandidates: ['ms-ai-governance'] } }, }; test('summarizeSkillLifecycle — surfaces overlap-over-threshold, gaps, thin, stale', () => { const s = summarizeSkillLifecycle(REPORT_WITH_SIGNALS, { threshold: 7.0 }); assert.match(s, /Skill-signaler:/); assert.match(s, /1 overlapp-par/, 'only the combined>=7 pair counts'); assert.match(s, /1 (gap|dekningshull)/); assert.match(s, /2 tynne/); assert.match(s, /1 stale/); }); test('summarizeSkillLifecycle — no signals returns null (nothing to surface)', () => { const empty = { overlap: { pairs: [{ pair: ['a', 'b'], combined: 1.0 }] }, coverage: { summary: { gaps: 0, thin: 0 } }, bloat: { summary: { bloatCandidates: [], staleCandidates: [] } }, }; assert.equal(summarizeSkillLifecycle(empty, { threshold: 7.0 }), null); }); test('summarizeSkillLifecycle — tolerates a missing/partial report shape', () => { assert.equal(summarizeSkillLifecycle({}, { threshold: 7.0 }), null); assert.equal(summarizeSkillLifecycle(null, { threshold: 7.0 }), null); }); // =========================================================================== // (D) STRUCTURAL ToS GUARANTEE — detection is Claude-free // =========================================================================== const KNOWN_DETECTION_SCRIPTS = new Set([ 'poll-sitemaps.mjs', 'report-changes.mjs', 'discover-new-urls.mjs', 'detect-skill-lifecycle.mjs', 'build-registry.mjs', ]); test('DETECTION_STEPS — only the known pure-node detection scripts, never claude', () => { assert.ok(DETECTION_STEPS.length >= 4); for (const step of DETECTION_STEPS) { assert.ok(step.script.endsWith('.mjs'), `${step.script} must be a node script`); assert.ok(KNOWN_DETECTION_SCRIPTS.has(step.script), `${step.script} must be an allow-listed detection script`); assert.ok(['kb-update', 'kb-eval'].includes(step.dir), `${step.dir} unexpected`); } // skill-lifecycle detection must be present and flagged so config can exclude it assert.ok(DETECTION_STEPS.some((s) => s.script === 'detect-skill-lifecycle.mjs' && s.skillLifecycle === true)); }); test('run-detection.mjs source — spawns node, NEVER claude (the ToS boundary made structural)', () => { const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs'), 'utf8'); // It must run node on local scripts... assert.match(src, /execFileSync\(\s*'node'|spawn\(\s*'node'/, 'detection runs `node`'); // ...and must NEVER invoke the claude binary. Strip comments first so the // Claude-free CONTRACT can be documented in prose without tripping the guard: // the guarantee is "no claude INVOCATION", not "the word never appears". const code = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); assert.doesNotMatch(code, /(execFileSync|spawn|exec|execSync)\(\s*['"`]claude['"`]/i, 'never spawns claude'); assert.doesNotMatch(code, /['"`]claude['"`]/i, 'no claude binary referenced in code'); assert.doesNotMatch(code, /anthropic/i, 'code must not call Anthropic'); });