Tier 1 (SessionStart-hook) kjører deteksjon kun når en Claude-sesjon starter.
Tier 2 installerer en lokal launchd LaunchAgent som fyrer SAMME Claude-frie
entrypoint daglig (kl. 03:00), uavhengig av sesjoner = ekte bakgrunn mellom
sesjoner. ToS-trygt: entrypointet kan strukturelt ikke invokere claude.
Søk-først-verifisert (2026-06): launchd > cron (Apple-anbefalt; cron krever
Full Disk Access); StartCalendarInterval fanger opp etter dvale; launchd har
minimalt PATH + ekspanderer ikke ~; process.execPath = Cellar-sti som dør ved
`brew upgrade node` → plistens PATH inkluderer /usr/local/bin.
Levert (TDD, test FØR kode):
- lib/launchd-plist.mjs (REN, SKRIVER ALDRI): renderPlist (XML-escape,
StartCalendarInterval, PATH-fix), defaultLabel, resolvePaths.
- scheduler.mjs (TYNN CLI): install|uninstall|status|run-now|print|run.
Strukturelt Claude-fri (kun process.execPath på run-detection.mjs +
launchctl; kilde-grep-testet). run gater på os_scheduler_cadence →
delegerer til run-detection.mjs. uid-0-nekt; idempotent bootout.
- detection-schedule.mjs (additiv, Tier-1 urørt): os_scheduler_cadence
('daily' default | 'interval') + daysSinceLastPoll ren helper.
- commands/kb-update.md: Tier-1/Tier-2-doc + caveats (uninstall=pause;
re-install etter node-upgrade).
Operatør-valg: daglig default, kadens egen innstilling (settes i onboarding/C2).
Verifisert: launchd-plist 13/13 · detection-schedule 21/21 · kb-update 173 ·
kb-eval 100 · validate 239/0 · test-hooks 6/6 · plutil -lint OK · live
install→status→idempotent→uninstall på maskinen (gui/501, rent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
10 KiB
JavaScript
225 lines
10 KiB
JavaScript
// 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 } 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,
|
|
loadScheduleConfig,
|
|
shouldRunDetection,
|
|
summarizeSkillLifecycle,
|
|
daysSinceLastPoll,
|
|
} from '../../scripts/kb-update/lib/detection-schedule.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');
|
|
});
|
|
|
|
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 — missing config file => OFF default', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
|
try {
|
|
assert.deepEqual(loadScheduleConfig(root), DEFAULT_SCHEDULE_CONFIG);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('loadScheduleConfig — reads ms-ai-architect.local.md when present', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
|
try {
|
|
writeFileSync(
|
|
join(root, 'ms-ai-architect.local.md'),
|
|
'---\nscheduled_detection:\n enabled: true\n interval_days: 14\n---\n',
|
|
);
|
|
const cfg = loadScheduleConfig(root);
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.interval_days, 14);
|
|
} finally {
|
|
rmSync(root, { 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');
|
|
});
|