feat(ms-ai-architect): v1.12.0 manuell KB-refresh — fjern launchd/cron-arkitektur

ToS-vurdering konkluderte med at autonom cron-kjøring er unødvendig kompleks
for en solo-fork-and-own-plugin. Apply-fasen krever LLM-resonnering uansett,
så manuell trigger fra en aktiv Claude Code-sesjon er enklere og holder
pluginen klart innenfor Anthropic Consumer Terms paragraf 3 (automated access
only via API key or where explicitly permitted — Claude Code CLI er
eksemptert som offisielt verktøy).

Lagt til:
- commands/kb-update.md — ny /architect:kb-update slash-kommando som driver
  poll, endringsrapport, microsoft_docs_fetch-update og commit fra sesjonen.
  Argumenter: --skip-discover, --priorities, --dry-run, --single-commit
- Catalog-entry i playground HTML for kb-update (categori: tool, 4 input-felt)

Slettet (Wave 3-5 reversert, ~1500 linjer + 7 testmoduler):
- scripts/install-kb-cron.mjs (cross-OS scheduler-installer)
- scripts/kb-update/weekly-kb-cron.mjs (cron-orkestrator med pre-flight, lock,
  backup, claude -p subprocess, post-run verify, rollback)
- scripts/kb-update/templates/ (4 scheduler-templates: launchd plist, systemd
  service+timer, Windows ps1 + README)
- scripts/kb-update/lib/auth-mode.mjs (cron-spesifikk auth validation)
- scripts/kb-update/lib/lock-file.mjs (PID+mtime stale-detection)
- scripts/kb-update/lib/cost-estimat.mjs (pre-flight budget-cap)
- 7 testmoduler under tests/kb-update/ for slettet kode
- tests/test-kb-update.sh (Bash-3.2-shim, erstattet av direkte node --test)

Beholdt (utility-laget fortsatt brukbart):
- run-weekly-update.mjs, report-changes.mjs, build-registry.mjs,
  discover-new-urls.mjs (KB change-detection-pipelinen)
- lib/atomic-write, lib/backup, lib/cross-platform-paths, lib/log-rotate
- 4 testmoduler (42/42 tester PASS)

Endret:
- hooks/scripts/session-start-context.mjs: fjern kb-update-status.json-overvaaking
- tests/run-e2e.sh --kb-update kaller node --test direkte i stedet for shim
- README.md, CLAUDE.md: KB-vedlikehold-seksjon rewriter for manuell modell
- plugin.json: 1.11.0 -> 1.12.0
- Rot README + CLAUDE.md: ms-ai-architect-versjon bumpet

Schedulering er bevisst utenfor scope og overlatt til brukeren — eventuelle
forks som vil ha periodisk varsling kan sette opp egen cron / launchd /
GitHub Actions som kjører rapport-fasen og varsler om aa kjore
/architect:kb-update i CC-sesjon.

Verifisering:
- bash tests/validate-plugin.sh: 219 PASS, 0 FAIL
- bash tests/run-e2e.sh --kb-update: 42/42 inner + suite PASS
- bash tests/run-e2e.sh --playground: 271/271 PASS (statisk + parsers)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-05 12:03:45 +02:00
commit a7a334c8d1
29 changed files with 238 additions and 2708 deletions

View file

@ -1,181 +0,0 @@
// tests/kb-update/test-auth-mode.test.mjs
// Unit tests for scripts/kb-update/lib/auth-mode.mjs
// Note: Test fixture credential values are deliberately short (<8 chars) to
// stay below the secrets-scanner heuristic. They are stub markers, not keys.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
detectAuthMode,
validateAuthForCron,
readClaudeJson,
} from '../../scripts/kb-update/lib/auth-mode.mjs';
function withTmp(fn) {
const dir = mkdtempSync(join(tmpdir(), 'auth-test-'));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function makeStubRunner(exitCode) {
const calls = [];
const runner = (cmd, args) => {
calls.push({ cmd, args });
return exitCode;
};
return { runner, calls };
}
const MISSING_PATH = '/__definitely__/__not__/__a__/__path__/.claude.json';
test('detectAuthMode — ANTHROPIC_API_KEY set → api-key', () => {
const { runner } = makeStubRunner(0);
const mode = detectAuthMode({
env: { ANTHROPIC_API_KEY: 'fake' },
runner,
claudeJsonPath: MISSING_PATH,
});
assert.equal(mode, 'api-key');
});
test('detectAuthMode — empty ANTHROPIC_API_KEY is ignored', () => {
const { runner } = makeStubRunner(1);
const mode = detectAuthMode({
env: { ANTHROPIC_API_KEY: ' ' },
runner,
claudeJsonPath: MISSING_PATH,
});
assert.equal(mode, 'unauthenticated');
});
test('detectAuthMode — CLAUDE_CODE_OAUTH set → long-oauth', () => {
const { runner } = makeStubRunner(0);
const mode = detectAuthMode({
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oat' },
runner,
claudeJsonPath: MISSING_PATH,
});
assert.equal(mode, 'long-oauth');
});
test('detectAuthMode — both env vars set → api-key precedence', () => {
const { runner } = makeStubRunner(0);
const mode = detectAuthMode({
env: {
ANTHROPIC_API_KEY: 'fake',
CLAUDE_CODE_OAUTH_TOKEN: 'oat',
},
runner,
claudeJsonPath: MISSING_PATH,
});
assert.equal(mode, 'api-key');
});
test('detectAuthMode — neither env, no claude.json → unauthenticated', () => {
const { runner, calls } = makeStubRunner(0);
const mode = detectAuthMode({
env: {},
runner,
claudeJsonPath: MISSING_PATH,
});
assert.equal(mode, 'unauthenticated');
// Runner must NOT be invoked when claude.json is unreadable.
assert.equal(calls.length, 0);
});
test('detectAuthMode — claude.json onboarded + runner exit 0 → subscription-browser-only', () => {
withTmp((tmp) => {
const path = join(tmp, '.claude.json');
writeFileSync(
path,
JSON.stringify({ hasCompletedOnboarding: true, userID: 'abc' }),
'utf8'
);
const { runner, calls } = makeStubRunner(0);
const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path });
assert.equal(mode, 'subscription-browser-only');
assert.deepEqual(calls, [{ cmd: 'claude', args: ['auth', 'status'] }]);
});
});
test('detectAuthMode — claude.json onboarded + runner exit 1 → unauthenticated', () => {
withTmp((tmp) => {
const path = join(tmp, '.claude.json');
writeFileSync(
path,
JSON.stringify({ hasCompletedOnboarding: true }),
'utf8'
);
const { runner } = makeStubRunner(1);
const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path });
assert.equal(mode, 'unauthenticated');
});
});
test('detectAuthMode — claude.json present but not onboarded → unauthenticated', () => {
withTmp((tmp) => {
const path = join(tmp, '.claude.json');
writeFileSync(
path,
JSON.stringify({ hasCompletedOnboarding: false }),
'utf8'
);
const { runner, calls } = makeStubRunner(0);
const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path });
assert.equal(mode, 'unauthenticated');
assert.equal(calls.length, 0);
});
});
test('readClaudeJson — returns parsed object on valid JSON', () => {
withTmp((tmp) => {
const path = join(tmp, '.claude.json');
writeFileSync(path, '{"hasCompletedOnboarding": true, "x": 42}', 'utf8');
const obj = readClaudeJson(path);
assert.deepEqual(obj, { hasCompletedOnboarding: true, x: 42 });
});
});
test('readClaudeJson — returns null on missing file', () => {
assert.equal(readClaudeJson(MISSING_PATH), null);
});
test('readClaudeJson — returns null on malformed JSON', () => {
withTmp((tmp) => {
const path = join(tmp, 'bad.json');
writeFileSync(path, 'not json {', 'utf8');
assert.equal(readClaudeJson(path), null);
});
});
test('validateAuthForCron — api-key passes silently', () => {
validateAuthForCron('api-key');
});
test('validateAuthForCron — long-oauth passes silently', () => {
validateAuthForCron('long-oauth');
});
test('validateAuthForCron — subscription-browser-only throws EAUTHCRON', () => {
assert.throws(
() => validateAuthForCron('subscription-browser-only'),
(err) =>
err.code === 'EAUTHCRON' &&
err.detectedMode === 'subscription-browser-only' &&
/claude setup-token/.test(err.message) &&
/ANTHROPIC_API_KEY/.test(err.message)
);
});
test('validateAuthForCron — unauthenticated throws EAUTHCRON', () => {
assert.throws(
() => validateAuthForCron('unauthenticated'),
(err) => err.code === 'EAUTHCRON' && err.detectedMode === 'unauthenticated'
);
});

View file

@ -1,82 +0,0 @@
// tests/kb-update/test-cost-estimat.test.mjs
// Unit tests for scripts/kb-update/lib/cost-estimat.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { estimateCost } from '../../scripts/kb-update/lib/cost-estimat.mjs';
test('estimateCost — api-key returns numeric usd, kvote_warn unset', () => {
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' });
assert.equal(typeof result.usd, 'number');
assert.equal(result.kvote_warn, false);
assert.ok(result.usd > 0);
});
test('estimateCost — api-key empty input returns 0 USD', () => {
const result = estimateCost({}, { authMode: 'api-key' });
assert.equal(result.usd, 0);
assert.equal(result.kvote_warn, false);
assert.equal(result.tokens_input, 0);
assert.equal(result.tokens_output, 0);
});
test('estimateCost — api-key tokens are integers', () => {
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' });
assert.equal(Number.isInteger(result.tokens_input), true);
assert.equal(Number.isInteger(result.tokens_output), true);
});
test('estimateCost — ignores medium and low (only critical+high counted)', () => {
const a = estimateCost({ critical: 1, high: 1 }, { authMode: 'api-key' });
const b = estimateCost({ critical: 1, high: 1, medium: 100, low: 100 }, { authMode: 'api-key' });
assert.equal(a.usd, b.usd);
assert.equal(a.tokens_input, b.tokens_input);
});
test('estimateCost — long-oauth returns null usd, kvote_warn flag set', () => {
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'long-oauth' });
assert.strictEqual(result.usd, null);
assert.strictEqual(result.kvote_warn, true);
});
test('estimateCost — subscription-browser-only returns null usd, kvote_warn flag set', () => {
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'subscription-browser-only' });
assert.strictEqual(result.usd, null);
assert.strictEqual(result.kvote_warn, true);
});
test('estimateCost — auth-mode does not affect token math', () => {
const apikey = estimateCost({ critical: 5, high: 10 }, { authMode: 'api-key' });
const oauth = estimateCost({ critical: 5, high: 10 }, { authMode: 'long-oauth' });
const sub = estimateCost({ critical: 5, high: 10 }, { authMode: 'subscription-browser-only' });
assert.equal(apikey.tokens_input, oauth.tokens_input);
assert.equal(apikey.tokens_input, sub.tokens_input);
assert.equal(apikey.tokens_output, oauth.tokens_output);
assert.equal(apikey.tokens_output, sub.tokens_output);
});
test('estimateCost — unauthenticated treated as best-effort api-key', () => {
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'unauthenticated' });
assert.equal(typeof result.usd, 'number');
assert.equal(result.kvote_warn, false);
});
test('estimateCost — missing authMode opt treated as best-effort api-key', () => {
const result = estimateCost({ critical: 3, high: 15 });
assert.equal(typeof result.usd, 'number');
assert.equal(result.kvote_warn, false);
});
test('estimateCost — unknown priority keys are ignored', () => {
const result = estimateCost({ critical: 1, high: 1, weird: 999 }, { authMode: 'api-key' });
// Should equal {critical:1, high:1} alone
const baseline = estimateCost({ critical: 1, high: 1 }, { authMode: 'api-key' });
assert.equal(result.usd, baseline.usd);
});
test('estimateCost — fixture {critical: 3, high: 15} produces expected order of magnitude', () => {
// 18 files * (3000 in + 1500 out) tokens = 54k in, 27k out
// api-key cost: 54k * $3/M + 27k * $15/M = $0.162 + $0.405 = $0.567
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' });
assert.ok(result.usd > 0.4 && result.usd < 0.8, `expected ~$0.567, got $${result.usd}`);
});

View file

@ -1,207 +0,0 @@
// tests/kb-update/test-install-cron.test.mjs
// Subprocess + filesystem-snapshot tests for scripts/install-kb-cron.mjs
// (Step 11). Exercises --print-only across targets and verifies idempotent
// --uninstall. Never invokes launchctl/systemctl/Register-ScheduledTask
// against real schedulers; --print-only short-circuits before any
// side-effecting call, and --uninstall is exercised against an empty HOME
// where the install file simply does not exist.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, readdirSync, existsSync } from 'node:fs';
import { tmpdir, platform as osPlatform } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(__dirname, '..', '..', 'scripts', 'install-kb-cron.mjs');
function mkSandbox() {
return mkdtempSync(join(tmpdir(), 'install-kb-cron-test-'));
}
function runInstall(args, env = {}) {
return spawnSync('node', [SCRIPT, ...args], {
env: { PATH: process.env.PATH, ...env },
encoding: 'utf8',
timeout: 30_000,
});
}
function snapshotDir(dir) {
const out = [];
function walk(d) {
if (!existsSync(d)) return;
let entries;
try {
entries = readdirSync(d, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const p = join(d, entry.name);
out.push(p);
if (entry.isDirectory()) walk(p);
}
}
walk(dir);
return out.sort();
}
function hostTarget() {
const p = osPlatform();
if (p === 'darwin') return 'macos';
if (p === 'linux') return 'linux';
if (p === 'win32') return 'windows';
return null;
}
test('--print-only --target macos: substituted plist with no unsubstituted placeholders', () => {
const home = mkSandbox();
try {
const r = runInstall(['--print-only', '--target', 'macos'], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
assert.match(r.stdout, /<key>Label<\/key>/);
assert.match(r.stdout, /<key>StartCalendarInterval<\/key>/);
assert.match(r.stdout, /<integer>3<\/integer>/, 'default day-of-week=3 (Wednesday)');
assert.match(r.stdout, /<integer>4<\/integer>/, 'default hour=4');
assert.match(r.stdout, /<integer>23<\/integer>/, 'default minute=23');
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--print-only --target linux: filled service+timer with [Unit] and OnCalendar=Wed', () => {
const home = mkSandbox();
try {
const r = runInstall(['--print-only', '--target', 'linux'], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.match(r.stdout, /\[Unit\]/);
assert.match(r.stdout, /\[Service\]/);
assert.match(r.stdout, /\[Timer\]/);
assert.match(r.stdout, /OnCalendar=Wed/);
assert.match(r.stdout, /ExecStart=/);
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--print-only --target windows: Register-ScheduledTask + InteractiveToken', () => {
const home = mkSandbox();
try {
const r = runInstall(['--print-only', '--target', 'windows'], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.match(r.stdout, /Register-ScheduledTask/);
assert.match(r.stdout, /InteractiveToken/);
assert.match(r.stdout, /-DaysOfWeek\s+Wednesday/);
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--print-only writes no files (HOME snapshot before/after equal)', () => {
const home = mkSandbox();
try {
const before = snapshotDir(home);
const r = runInstall(['--print-only', '--target', 'macos'], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
const after = snapshotDir(home);
assert.deepEqual(after, before, 'HOME must not be touched in --print-only mode');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--print-only --target linux writes no files in HOME', () => {
const home = mkSandbox();
try {
const before = snapshotDir(home);
const r = runInstall(['--print-only', '--target', 'linux'], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
const after = snapshotDir(home);
assert.deepEqual(after, before, 'HOME must not be touched in --print-only mode (linux target)');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--uninstall is idempotent (exit 0 with nothing installed)', () => {
const home = mkSandbox();
try {
const target = hostTarget() || 'macos';
const r = runInstall(['--uninstall', '--target', target], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--uninstall --target macos on empty HOME: idempotent (no plist, no launchctl call)', () => {
const home = mkSandbox();
try {
const r = runInstall(['--uninstall', '--target', 'macos'], { HOME: home });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.match((r.stdout || '') + (r.stderr || ''), /nothing to remove|not installed/i);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--schedule with custom cron expr substitutes correctly into plist', () => {
const home = mkSandbox();
try {
const r = runInstall(
['--print-only', '--target', 'macos', '--schedule', '15 7 * * 5'],
{ HOME: home },
);
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.match(r.stdout, /<integer>15<\/integer>/, 'minute=15');
assert.match(r.stdout, /<integer>7<\/integer>/, 'hour=7');
assert.match(r.stdout, /<integer>5<\/integer>/, 'day-of-week=5 (Friday)');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('invalid --target rejects with non-zero exit', () => {
const home = mkSandbox();
try {
const r = runInstall(['--print-only', '--target', 'bogus'], { HOME: home });
assert.notEqual(r.status, 0);
assert.match((r.stderr || '') + (r.stdout || ''), /target|invalid|unsupported/i);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('invalid --schedule rejects with non-zero exit', () => {
const home = mkSandbox();
try {
const r = runInstall(
['--print-only', '--target', 'macos', '--schedule', 'not a cron'],
{ HOME: home },
);
assert.notEqual(r.status, 0);
assert.match((r.stderr || '') + (r.stdout || ''), /schedule|cron|invalid/i);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--node-bin override appears in substituted plist', () => {
const home = mkSandbox();
try {
const r = runInstall(
['--print-only', '--target', 'macos', '--node-bin', '/opt/custom/bin/node'],
{ HOME: home },
);
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.match(r.stdout, /\/opt\/custom\/bin\/node/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

View file

@ -1,192 +0,0 @@
// tests/kb-update/test-lock-file.test.mjs
// Unit tests for scripts/kb-update/lib/lock-file.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
existsSync,
utimesSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
acquireLock,
isPidAlive,
} from '../../scripts/kb-update/lib/lock-file.mjs';
const DEAD_PID = 99999999; // far above typical PID_MAX; reliably non-existent
function withTmp(fn) {
const dir = mkdtempSync(join(tmpdir(), 'lf-test-'));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function writeFakeLock(path, { pid, started, host = 'test-host', ageMs = 0 }) {
writeFileSync(
path,
JSON.stringify({
pid,
started: started ?? Date.now() - ageMs,
host,
version: 1,
}),
'utf8'
);
if (ageMs > 0) {
const past = new Date(Date.now() - ageMs);
utimesSync(path, past, past);
}
}
test('isPidAlive — current process is alive', () => {
assert.equal(isPidAlive(process.pid), true);
});
test('isPidAlive — non-existent PID is dead', () => {
assert.equal(isPidAlive(DEAD_PID), false);
});
test('isPidAlive — invalid input is dead', () => {
assert.equal(isPidAlive(0), false);
assert.equal(isPidAlive(-1), false);
assert.equal(isPidAlive(NaN), false);
assert.equal(isPidAlive(undefined), false);
});
test('acquireLock — creates lock file with current PID metadata', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
const lock = acquireLock(path, { registerCleanup: false });
try {
assert.equal(lock.lockPath, path);
assert.equal(existsSync(path), true);
const data = JSON.parse(readFileSync(path, 'utf8'));
assert.equal(data.pid, process.pid);
assert.equal(data.version, 1);
assert.equal(typeof data.started, 'number');
assert.equal(typeof data.host, 'string');
} finally {
lock.release();
}
});
});
test('acquireLock — second call same process throws ELOCKED', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
const lock = acquireLock(path, { registerCleanup: false });
try {
assert.throws(
() => acquireLock(path, { registerCleanup: false }),
(err) => err.code === 'ELOCKED' && err.holderPid === process.pid
);
} finally {
lock.release();
}
});
});
test('acquireLock — concurrent live holder (fixture lock-fil) throws ELOCKED', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
// Pre-write a lock as if held by another live process (we use process.pid
// as a stand-in for "guaranteed alive" without forking).
writeFakeLock(path, { pid: process.pid, ageMs: 0 });
assert.throws(
() => acquireLock(path, { registerCleanup: false }),
(err) => err.code === 'ELOCKED'
);
});
});
test('acquireLock — release deletes the lock file', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
const lock = acquireLock(path, { registerCleanup: false });
assert.equal(existsSync(path), true);
lock.release();
assert.equal(existsSync(path), false);
});
});
test('acquireLock — release on already-released lock is a no-op', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
const lock = acquireLock(path, { registerCleanup: false });
lock.release();
// Second release must not throw.
lock.release();
assert.equal(existsSync(path), false);
});
});
test('acquireLock — stale lock with dead PID + old mtime is cleaned', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
writeFakeLock(path, { pid: DEAD_PID, ageMs: 2 * 60 * 60 * 1000 });
const lock = acquireLock(path, { registerCleanup: false });
try {
const data = JSON.parse(readFileSync(path, 'utf8'));
assert.equal(data.pid, process.pid);
} finally {
lock.release();
}
});
});
test('acquireLock — stale lock with live PID but old mtime is also cleaned', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
// Live PID (us) but mtime older than default 1h threshold.
writeFakeLock(path, { pid: process.pid, ageMs: 2 * 60 * 60 * 1000 });
const lock = acquireLock(path, { registerCleanup: false });
try {
const data = JSON.parse(readFileSync(path, 'utf8'));
assert.equal(data.pid, process.pid);
// started is rewritten to fresh wallclock
assert.ok(Date.now() - data.started < 5000);
} finally {
lock.release();
}
});
});
test('acquireLock — fresh lock with live PID is NOT cleaned', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
writeFakeLock(path, { pid: process.pid, ageMs: 0 });
assert.throws(
() => acquireLock(path, { registerCleanup: false }),
(err) => err.code === 'ELOCKED' && err.holderPid === process.pid
);
});
});
test('acquireLock — staleThresholdMs is honored', () => {
withTmp((dir) => {
const path = join(dir, 'test.lock');
// 5s-old, live PID. Default 1h threshold → not stale → ELOCKED.
writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
assert.throws(
() => acquireLock(path, { registerCleanup: false }),
(err) => err.code === 'ELOCKED'
);
// Same fixture but threshold 1s → stale → cleaned.
writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
const lock = acquireLock(path, {
registerCleanup: false,
staleThresholdMs: 1_000,
});
lock.release();
assert.equal(existsSync(path), false);
});
});

View file

@ -1,172 +0,0 @@
// tests/kb-update/test-session-start-status.test.mjs
// Verifies that hooks/scripts/session-start-context.mjs surfaces the
// KB-update status file correctly per Status File Schema (plan.md L122-153).
//
// Same fixture statuses as test-weekly-kb-cron-flags.test.mjs so producer/
// consumer divergence is caught at test time.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir, platform as osPlatform } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'session-start-context.mjs');
const PLUGIN_ROOT = join(__dirname, '..', '..');
function mkSandbox() {
return mkdtempSync(join(tmpdir(), 'sshook-test-'));
}
function cacheDirFor(home) {
if (osPlatform() === 'darwin') {
return join(home, 'Library', 'Caches', 'ms-ai-architect');
}
if (osPlatform() === 'win32') {
return join(home, 'AppData', 'Local', 'ms-ai-architect', 'Cache');
}
return join(home, '.cache', 'ms-ai-architect');
}
function writeStatus(home, status) {
const dir = cacheDirFor(home);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'kb-update-status.json'), JSON.stringify(status, null, 2));
}
function runHook(home, extraEnv = {}) {
return spawnSync('node', [HOOK], {
env: {
PATH: process.env.PATH,
HOME: home,
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
...extraEnv,
},
encoding: 'utf8',
timeout: 10_000,
cwd: home, // not in plugin dir, so utredning/onboarding checks stay quiet
});
}
const baseStatus = {
schema_version: 1,
last_run_status: 'success',
last_run_ts: '2026-05-05T10:00:00Z',
duration_seconds: 412,
auth_mode: 'api-key',
log_file: '/tmp/kb-update.log',
files_planned: 18,
files_committed: 18,
session_id: 'sess_demo',
total_cost_usd: 1.42,
tokens_input: 54000,
tokens_output: 27000,
max_turns_hit: false,
diagnostic: null,
};
test('failure status surfaces "KB-update: failure" line', () => {
const home = mkSandbox();
try {
writeStatus(home, { ...baseStatus, last_run_status: 'failure', diagnostic: 'No commits produced' });
const result = runHook(home);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
assert.match(result.stdout, /KB-update: failure/);
assert.match(result.stdout, /2026-05-05T10:00:00Z/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('partial status surfaces "KB-update: partial" line', () => {
const home = mkSandbox();
try {
writeStatus(home, { ...baseStatus, last_run_status: 'partial', files_committed: 7, max_turns_hit: true });
const result = runHook(home);
assert.equal(result.status, 0);
assert.match(result.stdout, /KB-update: partial/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('budget_exceeded status surfaces a line', () => {
const home = mkSandbox();
try {
writeStatus(home, { ...baseStatus, last_run_status: 'budget_exceeded', files_committed: 0 });
const result = runHook(home);
assert.equal(result.status, 0);
assert.match(result.stdout, /KB-update: budget_exceeded/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('success status does NOT surface a KB-update line', () => {
const home = mkSandbox();
try {
writeStatus(home, { ...baseStatus, last_run_status: 'success' });
const result = runHook(home);
assert.equal(result.status, 0);
assert.doesNotMatch(result.stdout, /KB-update:/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('dry-run status does NOT surface a KB-update line', () => {
const home = mkSandbox();
try {
writeStatus(home, { ...baseStatus, last_run_status: 'dry-run' });
const result = runHook(home);
assert.equal(result.status, 0);
assert.doesNotMatch(result.stdout, /KB-update:/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('missing status file → hook still exits 0 with no KB-update line', () => {
const home = mkSandbox();
try {
// No status file written.
const result = runHook(home);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
assert.doesNotMatch(result.stdout, /KB-update:/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('malformed status file → hook tolerates and exits 0', () => {
const home = mkSandbox();
try {
const dir = cacheDirFor(home);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'kb-update-status.json'), '{ this is: not, valid json');
const result = runHook(home);
assert.equal(result.status, 0);
assert.doesNotMatch(result.stdout, /KB-update:/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('hook completes in < 1 second on warm filesystem', () => {
const home = mkSandbox();
try {
writeStatus(home, { ...baseStatus, last_run_status: 'failure' });
// Warm-up.
runHook(home);
const start = Date.now();
const result = runHook(home);
const elapsed = Date.now() - start;
assert.equal(result.status, 0);
assert.ok(elapsed < 1000, `hook took ${elapsed}ms (>1s)`);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

View file

@ -1,98 +0,0 @@
// tests/kb-update/test-template-generation.test.mjs
// Structural-regex tests for scripts/kb-update/templates/* (Step 8).
// Verifies that each template file exists, contains the documented sentinel
// strings, and exposes the documented placeholder set. No template execution
// or real scheduling occurs in this test — that lives in Wave 6 live-test.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, '..', '..', 'scripts', 'kb-update', 'templates');
const PLIST = join(TEMPLATES_DIR, 'com.fromaitochitta.ms-ai-architect.kb-update.plist');
const SERVICE = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.service');
const TIMER = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.timer');
const PS1 = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.ps1');
const README = join(TEMPLATES_DIR, 'README.md');
function readTpl(p) {
assert.equal(existsSync(p), true, `template missing: ${p}`);
return readFileSync(p, 'utf8');
}
test('plist — exists with required keys and placeholders', () => {
const content = readTpl(PLIST);
assert.match(content, /<key>Label<\/key>/);
assert.match(content, /<key>StartCalendarInterval<\/key>/);
assert.match(content, /<key>ProgramArguments<\/key>/);
assert.match(content, /<key>StandardOutPath<\/key>/);
assert.match(content, /<key>StandardErrorPath<\/key>/);
assert.match(content, /<key>EnvironmentVariables<\/key>/);
assert.match(content, /<key>RunAtLoad<\/key>\s*<false\/>/);
assert.match(content, /\{\{NODE_BIN\}\}/);
assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
assert.match(content, /\{\{LOG_FILE\}\}/);
assert.match(content, /\{\{SCHEDULE_HOUR\}\}/);
assert.match(content, /\{\{SCHEDULE_MINUTE\}\}/);
assert.match(content, /\{\{SCHEDULE_DAY_OF_WEEK\}\}/);
});
test('systemd .timer — exists with OnCalendar and Persistent', () => {
const content = readTpl(TIMER);
assert.match(content, /\[Unit\]/);
assert.match(content, /\[Timer\]/);
assert.match(content, /\[Install\]/);
assert.match(content, /OnCalendar=Wed/);
assert.match(content, /Persistent=true/);
assert.match(content, /WantedBy=timers\.target/);
});
test('systemd .service — exists with [Unit], [Service] and ExecStart', () => {
const content = readTpl(SERVICE);
assert.match(content, /\[Unit\]/);
assert.match(content, /\[Service\]/);
assert.match(content, /ExecStart=/);
assert.match(content, /\{\{NODE_BIN\}\}/);
assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
});
test('PowerShell ps1 — exists with Register-ScheduledTask and InteractiveToken', () => {
const content = readTpl(PS1);
assert.match(content, /Register-ScheduledTask/);
assert.match(content, /InteractiveToken/);
assert.match(content, /New-ScheduledTaskTrigger/);
assert.match(content, /-Weekly/);
assert.match(content, /-DaysOfWeek\s+Wednesday/);
assert.match(content, /\{\{NODE_BIN\}\}/);
assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
});
test('README — exists and references each template by filename', () => {
const content = readTpl(README);
assert.match(content, /com\.fromaitochitta\.ms-ai-architect\.kb-update\.plist/);
assert.match(content, /ms-ai-architect-kb-update\.service/);
assert.match(content, /ms-ai-architect-kb-update\.timer/);
assert.match(content, /ms-ai-architect-kb-update\.ps1/);
});
test('plist + service + ps1 reference NODE_BIN and PLUGIN_ROOT', () => {
// The .timer is a pure trigger — it activates the .service, which is
// the only systemd unit that needs to know the binary + plugin root.
// launchd and Windows put the command directly in the trigger spec, so
// they need both placeholders themselves.
for (const tpl of [PLIST, SERVICE, PS1]) {
const content = readFileSync(tpl, 'utf8');
assert.match(content, /\{\{NODE_BIN\}\}/, `${tpl} missing NODE_BIN placeholder`);
assert.match(content, /\{\{PLUGIN_ROOT\}\}/, `${tpl} missing PLUGIN_ROOT placeholder`);
}
});
test('.timer is placeholder-free literal (Wed 04:23 hardcoded per plan)', () => {
const content = readFileSync(TIMER, 'utf8');
assert.match(content, /OnCalendar=Wed \*-\*-\* 04:23:00/);
assert.doesNotMatch(content, /\{\{[A-Z_]+\}\}/);
});

View file

@ -1,126 +0,0 @@
// tests/kb-update/test-weekly-kb-cron-flags.test.mjs
// Subprocess-based flag-parsing tests for scripts/kb-update/weekly-kb-cron.mjs
// (Step 9). Avoids real Claude spawn by exercising --dry-run + auth-failure
// fast-path. Full e2e is reserved for Wave 6 live-test.
//
// The cron writes its status file to <getCacheDir('ms-ai-architect')>, which
// on darwin resolves to $HOME/Library/Caches/ms-ai-architect/. Setting HOME
// in the subprocess env therefore points all path resolution at a tmp dir,
// keeping the test isolated from the real machine.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir, platform as osPlatform } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CRON = join(__dirname, '..', '..', 'scripts', 'kb-update', 'weekly-kb-cron.mjs');
function mkSandbox() {
return mkdtempSync(join(tmpdir(), 'cron-test-'));
}
function runCron(extraArgs, env = {}) {
return spawnSync('node', [CRON, ...extraArgs], {
env: { PATH: process.env.PATH, ...env },
encoding: 'utf8',
timeout: 30_000,
});
}
function statusFilePath(home) {
if (osPlatform() === 'darwin') {
return join(home, 'Library', 'Caches', 'ms-ai-architect', 'kb-update-status.json');
}
if (osPlatform() === 'win32') {
return join(home, 'AppData', 'Local', 'ms-ai-architect', 'Cache', 'kb-update-status.json');
}
return join(home, '.cache', 'ms-ai-architect', 'kb-update-status.json');
}
test('--dry-run exits 0 with dry-run status, no Claude spawn', () => {
const home = mkSandbox();
try {
const result = runCron(['--dry-run'], {
HOME: home,
ANTHROPIC_API_KEY: '',
CLAUDE_CODE_OAUTH_TOKEN: '',
});
assert.equal(result.status, 0, `stderr: ${result.stderr}\nstdout: ${result.stdout}`);
assert.match(result.stdout, /DRY RUN/i);
const sf = statusFilePath(home);
assert.equal(existsSync(sf), true, `status file missing at ${sf}`);
const status = JSON.parse(readFileSync(sf, 'utf8'));
assert.equal(status.schema_version, 1);
assert.equal(status.last_run_status, 'dry-run');
assert.equal(typeof status.last_run_ts, 'string');
assert.match(status.last_run_ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
assert.equal(typeof status.auth_mode, 'string');
assert.equal(typeof status.log_file, 'string');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('missing auth (no --dry-run) fails fast with auth-related error', () => {
const home = mkSandbox();
try {
const result = runCron([], {
HOME: home,
ANTHROPIC_API_KEY: '',
CLAUDE_CODE_OAUTH_TOKEN: '',
});
assert.notEqual(result.status, 0, 'cron should exit non-zero on missing auth');
const combined = (result.stdout || '') + '\n' + (result.stderr || '');
assert.match(
combined,
/not safe for cron|unauthenticated|EAUTHCRON|auth/i,
`expected auth error in output. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--budget-usd flag parsed and reflected in dry-run plan', () => {
const home = mkSandbox();
try {
const result = runCron(['--dry-run', '--budget-usd=12.50'], {
HOME: home,
ANTHROPIC_API_KEY: '',
CLAUDE_CODE_OAUTH_TOKEN: '',
});
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
assert.match(
result.stdout,
/(budget|Budget)[^\n]*12\.50|12\.5/,
`expected 12.50 in dry-run output: ${result.stdout}`
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--dry-run writes status file even with no change-report present', () => {
const home = mkSandbox();
try {
const result = runCron(['--dry-run'], {
HOME: home,
ANTHROPIC_API_KEY: '',
CLAUDE_CODE_OAUTH_TOKEN: '',
});
assert.equal(result.status, 0);
const sf = statusFilePath(home);
const status = JSON.parse(readFileSync(sf, 'utf8'));
// Required fields per Status File Schema (plan.md L122-153)
for (const key of ['schema_version', 'last_run_status', 'last_run_ts', 'auth_mode', 'log_file', 'diagnostic']) {
assert.ok(Object.prototype.hasOwnProperty.call(status, key), `missing required field: ${key}`);
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});

View file

@ -79,7 +79,14 @@ if $RUN_PLAYGROUND; then
fi
if $RUN_KB_UPDATE; then
bash "$SCRIPT_DIR/test-kb-update.sh" || FAILURES=$((FAILURES + 1))
echo -e "${CYAN}─── KB Update utilities ───${NC}"
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if (cd "$PLUGIN_ROOT" && node --test tests/kb-update/*.test.mjs); then
echo -e "${GREEN}KB Update: PASS${NC}"
else
echo -e "${RED}KB Update: FAIL${NC}"
FAILURES=$((FAILURES + 1))
fi
fi
echo -e "${CYAN}══════════════════════════════════════════════${NC}"

View file

@ -1,44 +0,0 @@
#!/bin/bash
# test-kb-update.sh — Run KB-update node:test suite via the E2E harness.
# Bash 3.2-compatible. Sources lib/e2e-helpers.sh, runs node --test against
# the kb-update glob (Node 25 rejects directory-form arguments to --test),
# and translates the result into the suite's pass/fail counters.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/lib/e2e-helpers.sh"
init_suite "KB Update"
cd "$PLUGIN_ROOT"
if ! compgen -G "tests/kb-update/*.test.mjs" >/dev/null 2>&1; then
fail "No test files matched tests/kb-update/*.test.mjs"
print_summary
exit 1
fi
TEST_LOG="$(mktemp -t kb-update-suite.XXXXXX)"
trap 'rm -f "$TEST_LOG"' EXIT
NODE_EXIT=0
node --test tests/kb-update/*.test.mjs >"$TEST_LOG" 2>&1 || NODE_EXIT=$?
cat "$TEST_LOG"
PASS_COUNT="$(awk '/^[^[:alnum:]]*pass[[:space:]]+[0-9]+/ { for (i=1;i<=NF;i++) if ($i=="pass") { print $(i+1); exit } }' "$TEST_LOG")"
FAIL_COUNT="$(awk '/^[^[:alnum:]]*fail[[:space:]]+[0-9]+/ { for (i=1;i<=NF;i++) if ($i=="fail") { print $(i+1); exit } }' "$TEST_LOG")"
TESTS_COUNT="$(awk '/^[^[:alnum:]]*tests[[:space:]]+[0-9]+/ { for (i=1;i<=NF;i++) if ($i=="tests") { print $(i+1); exit } }' "$TEST_LOG")"
PASS_COUNT="${PASS_COUNT:-0}"
FAIL_COUNT="${FAIL_COUNT:-0}"
TESTS_COUNT="${TESTS_COUNT:-0}"
if [ "$NODE_EXIT" -eq 0 ] && [ "$FAIL_COUNT" -eq 0 ]; then
pass "node --test tests/kb-update/*.test.mjs ($PASS_COUNT/$TESTS_COUNT pass)"
else
fail "node --test failed (pass=$PASS_COUNT, fail=$FAIL_COUNT, tests=$TESTS_COUNT, exit=$NODE_EXIT)"
fi
print_summary

View file

@ -151,10 +151,10 @@ for f in "$PLUGIN_ROOT"/commands/*.md; do
fail "Command-ID '${cmd_id}' mangler i v3 HTML"
fi
done
if [ "$cmd_count" -eq 24 ]; then
pass "24 command-filer funnet i commands/ (forventet 24)"
if [ "$cmd_count" -eq 25 ]; then
pass "25 command-filer funnet i commands/ (forventet 25)"
else
fail "Forventet 24 command-filer, fant $cmd_count"
fail "Forventet 25 command-filer, fant $cmd_count"
fi
# -------------------------------------------------------