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,501 +0,0 @@
#!/usr/bin/env node
// install-kb-cron.mjs — Standalone cross-OS install helper for the weekly
// KB-update cron job. Reads the appropriate template from
// scripts/kb-update/templates/, fills {{NODE_BIN}}, {{PLUGIN_ROOT}},
// {{LOG_FILE}}, {{SCHEDULE_HOUR/MINUTE/DAY_OF_WEEK}} placeholders, writes
// to the platform-specific scheduler dir, and registers the job with the
// host scheduler.
//
// macOS → ~/Library/LaunchAgents/com.fromaitochitta.ms-ai-architect.kb-update.plist
// launchctl bootstrap gui/<uid> <path> (EIO fallback to load -w)
// Linux → ~/.config/systemd/user/{ms-ai-architect-kb-update.service, .timer}
// systemctl --user daemon-reload && enable --now ms-ai-architect-kb-update.timer
// Windows → invoke ms-ai-architect-kb-update.ps1 via
// powershell -ExecutionPolicy Bypass -File <path>
// (template registers via Register-ScheduledTask itself).
// Marked beta — not validated against a real Windows machine.
//
// Usage:
// install-kb-cron.mjs [--print-only] [--target macos|linux|windows]
// [--uninstall [--purge]]
// [--node-bin <path>] [--claude-bin <path>]
// [--schedule "M H * * D"] default: "23 4 * * 3" (Wed 04:23)
//
// --print-only renders the filled template to stdout and exits without
// touching the filesystem (no scheduler dirs, no log dirs, nothing under
// HOME). Use it for inspection or for cross-target rendering on a
// developer machine.
import { spawnSync } from 'node:child_process';
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
unlinkSync,
rmSync,
} from 'node:fs';
import { homedir, 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 PLUGIN_ROOT = join(__dirname, '..');
const TEMPLATES_DIR = join(__dirname, 'kb-update', 'templates');
const APP = 'ms-ai-architect';
const LAUNCHD_LABEL = 'com.fromaitochitta.ms-ai-architect.kb-update';
const SYSTEMD_UNIT = 'ms-ai-architect-kb-update';
const WIN_TASK_NAME = 'ms-ai-architect-kb-update';
// ---------- CLI parsing ----------
function printUsage() {
console.log(`Usage: install-kb-cron.mjs [options]
Installs (or removes) the weekly Microsoft Learn KB-update job for the
ms-ai-architect plugin on the host scheduler.
Options:
--print-only Print filled template to stdout, no file writes
--target <os> Target OS: macos|linux|windows (default: auto-detect)
--uninstall Reverse the registration (idempotent)
--purge With --uninstall: also delete logs/status/.kb-backup
--node-bin <path> Path to node binary (default: process.execPath)
--claude-bin <path> Path to claude binary (default: 'claude' on PATH)
--schedule "M H * * D" Cron expression (default: "23 4 * * 3" = Wed 04:23)
--help, -h Show this message and exit
`);
}
function parseArgs(argv) {
const args = {
printOnly: false,
target: null,
uninstall: false,
purge: false,
nodeBin: null,
claudeBin: null,
schedule: '23 4 * * 3',
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const eq = (name) => a.startsWith(`${name}=`) ? a.slice(name.length + 1) : null;
if (a === '--print-only') args.printOnly = true;
else if (a === '--uninstall') args.uninstall = true;
else if (a === '--purge') args.purge = true;
else if (a === '--help' || a === '-h') { printUsage(); process.exit(0); }
else if (a === '--target') args.target = argv[++i];
else if (eq('--target') !== null) args.target = eq('--target');
else if (a === '--node-bin') args.nodeBin = argv[++i];
else if (eq('--node-bin') !== null) args.nodeBin = eq('--node-bin');
else if (a === '--claude-bin') args.claudeBin = argv[++i];
else if (eq('--claude-bin') !== null) args.claudeBin = eq('--claude-bin');
else if (a === '--schedule') args.schedule = argv[++i];
else if (eq('--schedule') !== null) args.schedule = eq('--schedule');
else {
console.error(`Unknown argument: ${a}`);
console.error('Run with --help to see usage.');
process.exit(2);
}
}
return args;
}
// ---------- Target detection ----------
function detectHostTarget() {
switch (process.platform) {
case 'darwin': return 'macos';
case 'linux': return 'linux';
case 'win32': return 'windows';
default: return null;
}
}
const VALID_TARGETS = new Set(['macos', 'linux', 'windows']);
// ---------- Schedule parsing ----------
function parseSchedule(expr) {
if (typeof expr !== 'string') {
throw new Error(`invalid schedule: not a string`);
}
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
`invalid schedule "${expr}" — expected 5 cron fields "M H * * D"`,
);
}
const [m, h, dom, mon, dow] = parts;
if (dom !== '*' || mon !== '*') {
throw new Error(
`invalid schedule "${expr}" — day-of-month and month must be "*"`,
);
}
const minute = Number(m);
const hour = Number(h);
const dayOfWeek = Number(dow);
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
throw new Error(`invalid schedule minute "${m}" (expected 0-59)`);
}
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new Error(`invalid schedule hour "${h}" (expected 0-23)`);
}
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
throw new Error(`invalid schedule day-of-week "${dow}" (expected 0-6)`);
}
return { minute, hour, dayOfWeek };
}
// ---------- Path resolution (no side effects) ----------
function resolveAppPaths(target) {
const h = homedir();
if (target === 'macos') {
return {
logFile: join(h, 'Library', 'Logs', APP, 'kb-update.log'),
logDir: join(h, 'Library', 'Logs', APP),
cacheDir: join(h, 'Library', 'Caches', APP),
};
}
if (target === 'windows') {
const lad = process.env.LOCALAPPDATA || join(h, 'AppData', 'Local');
return {
logFile: join(lad, APP, 'Logs', 'kb-update.log'),
logDir: join(lad, APP, 'Logs'),
cacheDir: join(lad, APP, 'Cache'),
};
}
// linux
const xdgState = process.env.XDG_STATE_HOME || join(h, '.local', 'state');
const xdgCache = process.env.XDG_CACHE_HOME || join(h, '.cache');
return {
logFile: join(xdgState, APP, 'logs', 'kb-update.log'),
logDir: join(xdgState, APP, 'logs'),
cacheDir: join(xdgCache, APP),
};
}
// ---------- Template substitution ----------
function fillTemplate(content, vars) {
return content
.replace(/\{\{NODE_BIN\}\}/g, vars.nodeBin)
.replace(/\{\{PLUGIN_ROOT\}\}/g, vars.pluginRoot)
.replace(/\{\{LOG_FILE\}\}/g, vars.logFile)
.replace(/\{\{SCHEDULE_HOUR\}\}/g, String(vars.hour))
.replace(/\{\{SCHEDULE_MINUTE\}\}/g, String(vars.minute))
.replace(/\{\{SCHEDULE_DAY_OF_WEEK\}\}/g, String(vars.dayOfWeek));
}
function readTemplate(name) {
return readFileSync(join(TEMPLATES_DIR, name), 'utf8');
}
// ---------- MCP / WSL detection ----------
function checkMcpServer() {
const claudeJson = join(homedir(), '.claude.json');
if (!existsSync(claudeJson)) return null;
try {
const data = JSON.parse(readFileSync(claudeJson, 'utf8'));
const mcp = data && data.mcpServers ? data.mcpServers : {};
return Boolean(mcp['microsoft-learn']);
} catch {
return null;
}
}
function isWsl() {
if (process.platform !== 'linux') return false;
try {
const v = readFileSync('/proc/version', 'utf8');
return /Microsoft|WSL/i.test(v);
} catch {
return false;
}
}
// ---------- Per-target install ----------
function installMacos(args, vars) {
const filled = fillTemplate(
readTemplate(`${LAUNCHD_LABEL}.plist`),
vars,
);
if (args.printOnly) {
process.stdout.write(filled);
return;
}
const dest = join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
mkdirSync(dirname(dest), { recursive: true });
mkdirSync(vars.logDir, { recursive: true });
writeFileSync(dest, filled, 'utf8');
// launchctl bootstrap gui/<uid> <path> (with load -w fallback on error).
if (typeof process.getuid === 'function') {
const uid = process.getuid();
const r = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, dest], {
encoding: 'utf8',
});
if (r.status !== 0) {
console.error(`launchctl bootstrap returned ${r.status}: ${r.stderr.trim()}`);
console.error('Falling back to: launchctl load -w');
const r2 = spawnSync('launchctl', ['load', '-w', dest], { encoding: 'utf8' });
if (r2.status !== 0) {
throw new Error(`launchctl load -w failed: ${r2.stderr.trim()}`);
}
}
}
console.log(`✓ Installed ${dest}`);
console.log('Next steps:');
console.log(` - Verify: launchctl list | grep ${LAUNCHD_LABEL}`);
console.log(` - Logs: ${vars.logFile}`);
}
function installLinux(args, vars) {
const serviceFilled = fillTemplate(readTemplate(`${SYSTEMD_UNIT}.service`), vars);
const timerFilled = fillTemplate(readTemplate(`${SYSTEMD_UNIT}.timer`), vars);
if (args.printOnly) {
process.stdout.write(`# === ${SYSTEMD_UNIT}.service ===\n`);
process.stdout.write(serviceFilled);
process.stdout.write(`\n# === ${SYSTEMD_UNIT}.timer ===\n`);
process.stdout.write(timerFilled);
return;
}
// Pre-check: is systemd present? (skip if --target linux on non-linux host —
// user is likely cross-rendering by mistake; refuse to actually install.)
if (process.platform !== 'linux') {
throw new Error(
`cannot install systemd units on host platform "${process.platform}" — ` +
`use --print-only to render templates for cross-OS inspection`,
);
}
const sys = spawnSync('systemctl', ['is-system-running'], { encoding: 'utf8' });
if (sys.status === null || sys.error) {
throw new Error('systemctl not available — systemd is not running on this system');
}
if (isWsl()) {
console.warn('WARNING: detected WSL — systemd --user may need manual setup.');
}
const userDir = join(homedir(), '.config', 'systemd', 'user');
mkdirSync(userDir, { recursive: true });
mkdirSync(vars.logDir, { recursive: true });
const servicePath = join(userDir, `${SYSTEMD_UNIT}.service`);
const timerPath = join(userDir, `${SYSTEMD_UNIT}.timer`);
writeFileSync(servicePath, serviceFilled, 'utf8');
writeFileSync(timerPath, timerFilled, 'utf8');
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
const en = spawnSync(
'systemctl',
['--user', 'enable', '--now', `${SYSTEMD_UNIT}.timer`],
{ stdio: 'inherit' },
);
if (en.status !== 0) {
throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT}.timer failed`);
}
console.log(`✓ Installed ${servicePath}`);
console.log(`✓ Installed ${timerPath}`);
console.log('Next steps:');
console.log(` - Verify: systemctl --user list-timers | grep ${SYSTEMD_UNIT}`);
console.log(` - Optional: sudo loginctl enable-linger $USER (autostart without login)`);
console.log(` - Logs: ${vars.logFile}`);
}
function installWindows(args, vars) {
const filled = fillTemplate(readTemplate(`${SYSTEMD_UNIT}.ps1`), vars);
if (args.printOnly) {
process.stdout.write(filled);
return;
}
if (process.platform !== 'win32') {
throw new Error(
`cannot register Windows scheduled task on host platform "${process.platform}" — ` +
`use --print-only to render the .ps1 for cross-OS inspection`,
);
}
console.warn(
'NOTE: Windows install path is BETA — not validated against a real Windows machine.',
);
// Materialize the .ps1 in the cache dir, then invoke it.
mkdirSync(vars.cacheDir, { recursive: true });
mkdirSync(vars.logDir, { recursive: true });
const ps1Path = join(vars.cacheDir, 'install-kb-cron.ps1');
writeFileSync(ps1Path, filled, 'utf8');
const r = spawnSync(
'powershell',
['-ExecutionPolicy', 'Bypass', '-File', ps1Path],
{ stdio: 'inherit' },
);
if (r.status !== 0) {
throw new Error(`powershell install failed (status=${r.status})`);
}
console.log(`✓ Registered Windows task '${WIN_TASK_NAME}'`);
console.log('Next steps:');
console.log(` - Verify: schtasks /Query /TN ${WIN_TASK_NAME}`);
console.log(` - Logs: ${vars.logFile}`);
}
// ---------- Per-target uninstall ----------
function uninstallMacos(host) {
const dest = join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
if (!existsSync(dest)) {
console.log(`(nothing to remove at ${dest})`);
return;
}
if (host === 'macos' && typeof process.getuid === 'function') {
const uid = process.getuid();
spawnSync('launchctl', ['bootout', `gui/${uid}`, dest], { encoding: 'utf8' });
spawnSync('launchctl', ['unload', '-w', dest], { encoding: 'utf8' });
}
unlinkSync(dest);
console.log(`✓ Removed ${dest}`);
}
function uninstallLinux(host) {
const userDir = join(homedir(), '.config', 'systemd', 'user');
const servicePath = join(userDir, `${SYSTEMD_UNIT}.service`);
const timerPath = join(userDir, `${SYSTEMD_UNIT}.timer`);
const anyExists = existsSync(servicePath) || existsSync(timerPath);
if (!anyExists) {
console.log(`(nothing to remove at ${userDir})`);
return;
}
if (host === 'linux') {
spawnSync(
'systemctl',
['--user', 'disable', '--now', `${SYSTEMD_UNIT}.timer`],
{ encoding: 'utf8' },
);
}
for (const p of [servicePath, timerPath]) {
if (existsSync(p)) {
unlinkSync(p);
console.log(`✓ Removed ${p}`);
}
}
if (host === 'linux') {
spawnSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf8' });
}
}
function uninstallWindows(host) {
if (host === 'windows') {
const r = spawnSync(
'schtasks',
['/Delete', '/TN', WIN_TASK_NAME, '/F'],
{ encoding: 'utf8' },
);
if (r.status === 0) {
console.log(`✓ Removed Windows task '${WIN_TASK_NAME}'`);
} else {
console.log(`(nothing to remove — task '${WIN_TASK_NAME}' not registered)`);
}
} else {
console.log(`(nothing to remove — schtasks unavailable on host '${host}')`);
}
}
function purgeAppFiles(target) {
const paths = resolveAppPaths(target);
const backupDir = join(PLUGIN_ROOT, '.kb-backup');
let removed = 0;
for (const d of [paths.logDir, paths.cacheDir, backupDir]) {
if (existsSync(d)) {
rmSync(d, { recursive: true, force: true });
console.log(`✓ Purged ${d}`);
removed++;
}
}
if (removed === 0) {
console.log('(nothing to purge)');
}
}
// ---------- Main ----------
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.target) args.target = detectHostTarget();
if (!args.target || !VALID_TARGETS.has(args.target)) {
console.error(
`unsupported or invalid --target "${args.target}" — must be one of: macos, linux, windows`,
);
process.exit(2);
}
// Resolve binaries.
const nodeBin = args.nodeBin || process.execPath;
// claudeBin is only consumed by the templates that reference it; current
// template set assumes 'claude' is on PATH inside the cron environment, so
// the override is currently a no-op pass-through. Accept the flag so future
// template revisions can pick it up without a CLI break.
void args.claudeBin;
// Schedule parsing.
let sched;
try {
sched = parseSchedule(args.schedule);
} catch (err) {
console.error(`ERROR: ${err.message}`);
process.exit(2);
}
const paths = resolveAppPaths(args.target);
const vars = {
nodeBin,
pluginRoot: PLUGIN_ROOT,
logFile: paths.logFile,
logDir: paths.logDir,
cacheDir: paths.cacheDir,
minute: sched.minute,
hour: sched.hour,
dayOfWeek: sched.dayOfWeek,
};
// Uninstall path.
if (args.uninstall) {
const host = detectHostTarget();
if (args.target === 'macos') uninstallMacos(host);
if (args.target === 'linux') uninstallLinux(host);
if (args.target === 'windows') uninstallWindows(host);
if (args.purge) purgeAppFiles(args.target);
return;
}
// MCP soft-warn (install path only).
if (!args.printOnly) {
const mcp = checkMcpServer();
if (mcp === false) {
console.warn(
'WARNING: ~/.claude.json has no `microsoft-learn` MCP server entry. ' +
'KB-updates will run but the agent will lack live Microsoft Learn access.',
);
} else if (mcp === null) {
console.warn('WARNING: could not read ~/.claude.json — skipping MCP server check.');
}
}
if (args.target === 'macos') installMacos(args, vars);
if (args.target === 'linux') installLinux(args, vars);
if (args.target === 'windows') installWindows(args, vars);
}
try {
main();
} catch (err) {
console.error(`ERROR: ${err.message}`);
process.exit(1);
}

View file

@ -1,99 +0,0 @@
// auth-mode.mjs — Detect and validate Claude auth mode for cron-safe runs.
// Zero dependencies. The detector and validator are pure-testable: both
// `runner` (claude CLI invoker) and `claudeJsonPath` (~/.claude.json) are
// dependency-injected so tests stub them rather than spawning real subprocess
// or touching the user's home directory.
//
// Subscription browser-OAuth tokens expire ~15h and are architecturally
// incompatible with cron. This lib surfaces that case as a hard fail so the
// installer/cron-runner can refuse to proceed.
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
/**
* Default subprocess runner invokes a command and returns its exit code.
* Returns 0 on success, the actual exit code on failure, 127 on spawn error.
*/
function defaultRunner(cmd, args) {
try {
execFileSync(cmd, args, { stdio: 'ignore' });
return 0;
} catch (err) {
if (typeof err.status === 'number') return err.status;
return 127;
}
}
/**
* Safely read and parse a Claude config JSON file. Returns null on any error.
* @param {string} path
* @returns {object|null}
*/
export function readClaudeJson(path) {
try {
const text = readFileSync(path, 'utf8');
const obj = JSON.parse(text);
return obj && typeof obj === 'object' ? obj : null;
} catch {
return null;
}
}
/**
* Detect the active Claude authentication mode.
*
* Resolution order:
* 1. ANTHROPIC_API_KEY env-var 'api-key'
* 2. CLAUDE_CODE_OAUTH_TOKEN env-var 'long-oauth'
* 3. ~/.claude.json onboarded + `claude auth status` exits 0 'subscription-browser-only'
* 4. otherwise 'unauthenticated'
*
* @param {object} [opts]
* @param {(cmd: string, args: string[]) => number} [opts.runner]
* @param {string} [opts.claudeJsonPath]
* @param {object} [opts.env] defaults to process.env
* @returns {'api-key'|'long-oauth'|'subscription-browser-only'|'unauthenticated'}
*/
export function detectAuthMode(opts = {}) {
const env = opts.env ?? process.env;
const runner = opts.runner ?? defaultRunner;
const claudeJsonPath = opts.claudeJsonPath ?? join(homedir(), '.claude.json');
if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_API_KEY.trim() !== '') {
return 'api-key';
}
if (env.CLAUDE_CODE_OAUTH_TOKEN && env.CLAUDE_CODE_OAUTH_TOKEN.trim() !== '') {
return 'long-oauth';
}
const claudeJson = readClaudeJson(claudeJsonPath);
if (!claudeJson || claudeJson.hasCompletedOnboarding !== true) {
return 'unauthenticated';
}
const exitCode = runner('claude', ['auth', 'status']);
return exitCode === 0 ? 'subscription-browser-only' : 'unauthenticated';
}
/**
* Throw a clear error if the detected mode is incompatible with cron.
* Subscription-browser-only OAuth dies after ~15h; unauthenticated has no
* credential. Both must be rejected before headless cron runs.
*
* @param {string} mode
* @throws {Error} with code 'EAUTHCRON' if mode is not safe for cron
*/
export function validateAuthForCron(mode) {
if (mode === 'api-key' || mode === 'long-oauth') return;
const e = new Error(
`Auth mode "${mode}" is not safe for cron. ` +
'Run `claude setup-token` to generate a long-lived OAuth, ' +
'or set ANTHROPIC_API_KEY in the cron environment.'
);
e.code = 'EAUTHCRON';
e.detectedMode = mode;
throw e;
}

View file

@ -1,36 +0,0 @@
// cost-estimat.mjs — Heuristic cost-estimate for KB-update runs.
// Pure function. Auth-mode-aware: api-key returns numeric USD,
// subscription modes return null USD + kvote_warn flag.
// Zero dependencies.
const AVG_INPUT_TOKENS_PER_FILE = 3000;
const AVG_OUTPUT_TOKENS_PER_FILE = 1500;
const SONNET_INPUT_USD_PER_M = 3.0;
const SONNET_OUTPUT_USD_PER_M = 15.0;
const SUBSCRIPTION_MODES = new Set(['long-oauth', 'subscription-browser-only']);
/**
* Estimate cost (and quota-warn flag) for a run of N files at given priorities.
* Filters to critical + high only (medium/low excluded per brief).
*
* @param {object} priorities { critical, high, medium, low } file counts
* @param {object} [opts]
* @param {string} [opts.authMode] 'api-key' | 'long-oauth' | 'subscription-browser-only' | 'unauthenticated'
* @returns {{tokens_input: number, tokens_output: number, usd: number|null, kvote_warn: boolean}}
*/
export function estimateCost(priorities = {}, opts = {}) {
const authMode = opts.authMode ?? 'api-key';
const fileCount = (priorities.critical ?? 0) + (priorities.high ?? 0);
const tokens_input = fileCount * AVG_INPUT_TOKENS_PER_FILE;
const tokens_output = fileCount * AVG_OUTPUT_TOKENS_PER_FILE;
if (SUBSCRIPTION_MODES.has(authMode)) {
return { tokens_input, tokens_output, usd: null, kvote_warn: true };
}
const usd =
(tokens_input / 1_000_000) * SONNET_INPUT_USD_PER_M +
(tokens_output / 1_000_000) * SONNET_OUTPUT_USD_PER_M;
return { tokens_input, tokens_output, usd, kvote_warn: false };
}

View file

@ -1,166 +0,0 @@
// lock-file.mjs — Exclusive lock with PID + mtime stale-detection.
// Zero dependencies. Uses fs.writeFileSync('wx') for atomic exclusive create.
// Stale-detection is OR-based: stale if PID is dead OR mtime exceeds threshold.
// Either condition alone is enough to break the lock — handles SIGKILL orphans
// (mtime alone) and PID-reuse races (mtime alone) and crashed-then-replaced
// runs (PID alone). Long runs may opt-in to mtime refresh via refreshIntervalMs.
import { writeFileSync, readFileSync, statSync, unlinkSync, utimesSync } from 'node:fs';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { getCacheDir } from './cross-platform-paths.mjs';
const DEFAULT_STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
const DEFAULT_LOCK_NAME = 'kb-update.lock';
/**
* Check whether a PID identifies a live process.
* @param {number} pid POSIX process id
* @returns {boolean}
*/
export function isPidAlive(pid) {
if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but we lack signal permission — still alive.
return err && err.code === 'EPERM';
}
}
function safeReadLock(lockPath) {
try {
return JSON.parse(readFileSync(lockPath, 'utf8'));
} catch {
return null;
}
}
function lockMtimeMs(lockPath) {
try {
return statSync(lockPath).mtimeMs;
} catch {
return null;
}
}
function writeLockFile(lockPath) {
writeFileSync(
lockPath,
JSON.stringify({
pid: process.pid,
started: Date.now(),
host: hostname(),
version: 1,
}),
{ flag: 'wx', encoding: 'utf8' }
);
}
/**
* Acquire an exclusive lock. Throws ELOCKED if held by a live, fresh holder.
* Cleans up stale locks (dead PID OR mtime older than staleThresholdMs).
*
* @param {string} [lockPath] absolute lock-file path; defaults to <cache>/kb-update.lock
* @param {object} [opts]
* @param {number} [opts.staleThresholdMs] default 3600000 (1h)
* @param {number} [opts.refreshIntervalMs] if > 0, periodically utimes the lock
* @param {boolean} [opts.registerCleanup] default true; install exit/signal handlers
* @returns {{lockPath: string, release: () => void}}
*/
export function acquireLock(lockPath, opts = {}) {
const staleThresholdMs = opts.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
const refreshIntervalMs = opts.refreshIntervalMs ?? 0;
const registerCleanup = opts.registerCleanup ?? true;
const path = lockPath || join(getCacheDir('ms-ai-architect'), DEFAULT_LOCK_NAME);
try {
writeLockFile(path);
} catch (err) {
if (!err || err.code !== 'EEXIST') throw err;
const data = safeReadLock(path);
const mtime = lockMtimeMs(path);
const holderPid = typeof data?.pid === 'number' ? data.pid : null;
const pidAlive = holderPid != null ? isPidAlive(holderPid) : false;
const ageMs = mtime != null ? Date.now() - mtime : Infinity;
const stale = !pidAlive || ageMs > staleThresholdMs;
if (!stale) {
const e = new Error(
`Lock held by PID ${holderPid} (started ${data?.started ?? 'unknown'})`
);
e.code = 'ELOCKED';
e.holderPid = holderPid;
throw e;
}
try {
unlinkSync(path);
} catch {
// best-effort
}
writeLockFile(path); // retry once
}
let refreshTimer = null;
let released = false;
const release = () => {
if (released) return;
released = true;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
try {
const data = safeReadLock(path);
if (!data || data.pid === process.pid) {
unlinkSync(path);
}
} catch {
// best-effort
}
};
if (refreshIntervalMs > 0) {
refreshTimer = setInterval(() => {
try {
const now = new Date();
utimesSync(path, now, now);
} catch {
// best-effort
}
}, refreshIntervalMs);
if (typeof refreshTimer.unref === 'function') {
refreshTimer.unref();
}
}
if (registerCleanup) {
const onExit = () => release();
process.once('exit', onExit);
process.once('SIGINT', () => {
release();
process.exit(130);
});
process.once('SIGTERM', () => {
release();
process.exit(143);
});
process.once('SIGHUP', () => {
release();
process.exit(129);
});
process.once('uncaughtException', (err) => {
release();
console.error(err);
process.exit(1);
});
}
return { lockPath: path, release };
}

View file

@ -1,62 +0,0 @@
# ms-ai-architect KB-update scheduling templates
These templates are consumed by `scripts/install-kb-cron.mjs` (added in
Wave 4 / Step 11) which substitutes the documented placeholders and
hands off to the platform's native scheduler. Do not edit a generated
file directly — re-run the installer instead so the source-of-truth
stays in this directory.
## Files
| File | Platform | Scheduler |
|------|----------|-----------|
| `com.fromaitochitta.ms-ai-architect.kb-update.plist` | macOS (Intel + Apple Silicon) | `launchctl` (per-user LaunchAgent) |
| `ms-ai-architect-kb-update.service` | Linux | `systemctl --user` |
| `ms-ai-architect-kb-update.timer` | Linux | `systemctl --user` (paired with the .service) |
| `ms-ai-architect-kb-update.ps1` | Windows 10/11 | Task Scheduler via `Register-ScheduledTask` |
## Placeholders
All four templates share the same canonical placeholder set. The
installer fills them in at install-time and writes the rendered file
under the platform's scheduler directory.
| Placeholder | Filled with | Source |
|-------------|-------------|--------|
| `{{NODE_BIN}}` | Absolute path to the `node` binary that should run the cron | `which node` (POSIX) / `where node` (Windows) at install-time |
| `{{PLUGIN_ROOT}}` | Absolute path to the `plugins/ms-ai-architect/` directory | Resolved by the installer relative to itself |
| `{{LOG_FILE}}` | Absolute path to the rotated log file | `getLogDir('ms-ai-architect') + '/kb-update.log'` (per `lib/cross-platform-paths.mjs`) |
| `{{SCHEDULE_HOUR}}` | Cron-hour, 0-23 | Default `4`; overridable via `--schedule-hour` |
| `{{SCHEDULE_MINUTE}}` | Cron-minute, 0-59 | Default `23`; overridable via `--schedule-minute` |
| `{{SCHEDULE_DAY_OF_WEEK}}` | launchd Weekday integer (0=Sunday … 3=Wednesday) | Default `3` (Wednesday) |
The systemd `.timer` and Windows `.ps1` use a literal `Wed`/`Wednesday`
day name rather than `{{SCHEDULE_DAY_OF_WEEK}}` because their respective
schedulers expect day-name strings, and the installer currently locks
the day to Wednesday (per the brief's "weekly Wed" cadence). Changing
the day requires editing the template — the installer does not yet
expose a `--schedule-day` flag.
## Install / uninstall
The full install/uninstall flow is implemented by
`scripts/install-kb-cron.mjs` (Wave 4 / Step 11). Run with `--help` for
the current option set. The contract for all three platforms is "fires
while the user is logged in" — there is no system-wide / sudo install
path because Claude Code's keychain-bound auth dies in unattended
contexts.
## Why these specific schedulers
- **launchd** is the only first-class scheduler on macOS; cron is a
thin user-facing alias. `RunAtLoad` is `false` so loading the job at
boot does not trigger an immediate Claude Code session.
- **systemd `--user` units** keep the symmetry of "user-context only"
with launchd's LoginItem and Windows' `InteractiveToken`. The
`Persistent=true` setting on the timer ensures a missed run (laptop
asleep on Wednesday) fires on next boot rather than being skipped.
- **Windows Task Scheduler** with `InteractiveToken` is the only logon
type that keeps the keychain unlocked, which is required for
subscription-auth Claude Code sessions.
See `research/01-cross-os-scheduling.md` for the full background.

View file

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
launchd job for weekly ms-ai-architect KB-update.
Placeholders ({{NODE_BIN}}, {{PLUGIN_ROOT}}, {{LOG_FILE}}, {{SCHEDULE_HOUR}},
{{SCHEDULE_MINUTE}}, {{SCHEDULE_DAY_OF_WEEK}}) are filled in by
scripts/install-kb-cron.mjs at install-time.
RunAtLoad is intentionally false so loading the job at boot does not
immediately spawn a Claude Code session. Weekday=3 is Wednesday in
launchd's StartCalendarInterval semantics.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fromaitochitta.ms-ai-architect.kb-update</string>
<key>ProgramArguments</key>
<array>
<string>{{NODE_BIN}}</string>
<string>{{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs</string>
</array>
<key>WorkingDirectory</key>
<string>{{PLUGIN_ROOT}}</string>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>{{SCHEDULE_DAY_OF_WEEK}}</integer>
<key>Hour</key>
<integer>{{SCHEDULE_HOUR}}</integer>
<key>Minute</key>
<integer>{{SCHEDULE_MINUTE}}</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>{{LOG_FILE}}</string>
<key>StandardErrorPath</key>
<string>{{LOG_FILE}}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>

View file

@ -1,48 +0,0 @@
# ms-ai-architect-kb-update.ps1
# PowerShell installer fragment for Windows Task Scheduler. Filled in
# by scripts/install-kb-cron.mjs at install-time and run elevated only
# if the user requested system-wide install (default is per-user with
# InteractiveToken so the task fires while the user is logged in).
$TaskName = 'ms-ai-architect-kb-update'
$NodeBin = '{{NODE_BIN}}'
$PluginRoot = '{{PLUGIN_ROOT}}'
$LogFile = '{{LOG_FILE}}'
$ScheduleAt = '{{SCHEDULE_HOUR}}:{{SCHEDULE_MINUTE}}'
$Trigger = New-ScheduledTaskTrigger `
-Weekly `
-DaysOfWeek Wednesday `
-At $ScheduleAt
$Action = New-ScheduledTaskAction `
-Execute $NodeBin `
-Argument "$PluginRoot\scripts\kb-update\weekly-kb-cron.mjs" `
-WorkingDirectory $PluginRoot
# InteractiveToken is the contract: the task only runs while the user is
# logged in. This avoids the "OAuth dies in cron" failure-mode (claude
# subscription auth is bound to the keychain, which is unlocked only when
# the user is logged in). RunLevel Limited keeps the task at non-elevated
# privileges; admin elevation is unnecessary for per-user scheduling.
$Principal = New-ScheduledTaskPrincipal `
-UserId $env:USERNAME `
-LogonType InteractiveToken `
-RunLevel Limited
$Settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask `
-TaskName $TaskName `
-Trigger $Trigger `
-Action $Action `
-Principal $Principal `
-Settings $Settings `
-Description 'Weekly Microsoft Learn KB freshness update for ms-ai-architect plugin' `
-Force | Out-Null
Write-Host "Registered Windows scheduled task '$TaskName' (weekly Wed $ScheduleAt, log: $LogFile)"

View file

@ -1,19 +0,0 @@
[Unit]
Description=ms-ai-architect weekly KB-update (Microsoft Learn freshness)
Documentation=file://{{PLUGIN_ROOT}}/scripts/kb-update/templates/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart={{NODE_BIN}} {{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs
WorkingDirectory={{PLUGIN_ROOT}}
StandardOutput=append:{{LOG_FILE}}
StandardError=append:{{LOG_FILE}}
Environment=PATH=/usr/local/bin:/usr/bin:/bin
# No User= here; the unit is installed under `systemctl --user` so it
# inherits the invoking user's identity. Running under the user manager
# keeps the contract "fires while user is logged in" symmetric across
# the three platforms (launchd LoginItem, systemd --user, Windows
# InteractiveToken). Switching to system-wide service+sudo would
# diverge from that contract — do not do that here.

View file

@ -1,13 +0,0 @@
[Unit]
Description=Weekly trigger for ms-ai-architect KB-update
[Timer]
# Default cadence per the brief is Wednesday 04:23 local time. Editing
# this file directly is fine for one-off schedule tweaks; for
# reproducible installs prefer re-running scripts/install-kb-cron.mjs.
OnCalendar=Wed *-*-* 04:23:00
Persistent=true
Unit=ms-ai-architect-kb-update.service
[Install]
WantedBy=timers.target

View file

@ -1,559 +0,0 @@
#!/usr/bin/env node
// weekly-kb-cron.mjs — Cross-OS scheduler entrypoint for weekly KB-update.
//
// Pipeline:
// 1. Parse flags (--dry-run, --force, --discover, --budget-usd=N).
// 2. Resolve cross-platform log/cache/state/backup dirs via lib/cross-platform-paths.mjs.
// 3. Rotate the log file before first write (lib/log-rotate.mjs, 10 MB default).
// 4. If --dry-run: print plan, write status (last_run_status: dry-run), exit 0.
// 5. Pre-flight: git --version, which claude, detectAuthMode + validateAuthForCron,
// ~/.claude.json onboarding flags, soft-warn on missing microsoft-learn MCP,
// git status --porcelain clean check.
// 6. Acquire lock (lib/lock-file.mjs). Capture runStartTs (Unix ms).
// 7. Run scripts/kb-update/run-weekly-update.mjs (existing pattern).
// 8. Read change-report.json. updateFiles = critical+high only.
// 9. Pre-flight cost-estimate (lib/cost-estimat.mjs). Abort with budget_exceeded
// if api-key auth and usd > budget. Subscription auth: kvote_warn, proceed.
// 10. Backup skills/ via lib/backup.mjs#backupDir.
// 11. Spawn Claude with NEW flag stack: dontAsk + scoped allowedTools +
// --output-format json + --model claude-sonnet-4-6.
// 12. Parse stdout JSON for total_cost_usd, session_id, max_turns_hit.
// 13. Post-run verification: git log --since=@<unixSeconds> commit count vs
// updateFiles.length. Branch: success / partial / failure.
// 14. On failure: rollback via backup#restore. On partial: keep commits.
// On success: optionally git push (auto_push_eligible).
// 15. Cleanup: release lock, cleanupOldBackups.
// 16. Exit 0 on success / dry-run / partial; 1 on failure / budget_exceeded.
//
// Status file: <getCacheDir('ms-ai-architect')>/kb-update-status.json
// (rewritten atomically per Status File Schema in plan.md L122-153).
//
// Crontab one-liner is still supported for direct cron use, but the recommended
// install path is `node ../install-kb-cron.mjs` which generates a launchd plist
// (macOS), systemd .timer + .service (Linux), or Windows Task Scheduler entry.
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir, platform as osPlatform } from 'node:os';
import { getCacheDir, getLogDir, getBackupDir } from './lib/cross-platform-paths.mjs';
import { atomicWriteJson } from './lib/atomic-write.mjs';
import { rotateLog } from './lib/log-rotate.mjs';
import { detectAuthMode, validateAuthForCron, readClaudeJson } from './lib/auth-mode.mjs';
import { acquireLock } from './lib/lock-file.mjs';
import { estimateCost } from './lib/cost-estimat.mjs';
import { backupDir, cleanupOldBackups } from './lib/backup.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const APP = 'ms-ai-architect';
const PLUGIN_ROOT = join(__dirname, '..', '..');
const DATA_DIR = join(__dirname, 'data');
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
const DEFAULT_BUDGET_USD = 5;
const KB_BACKUP_DAYS = 7;
// ---------- Arg parsing ----------
function parseArgs(argv) {
const args = {
dryRun: false,
force: false,
discover: true, // run-weekly-update default
budgetUsd: Number(process.env.KB_UPDATE_BUDGET_USD) || DEFAULT_BUDGET_USD,
};
for (const a of argv) {
if (a === '--dry-run') args.dryRun = true;
else if (a === '--force') args.force = true;
else if (a === '--no-discover') args.discover = false;
else if (a.startsWith('--budget-usd=')) {
const n = Number(a.slice('--budget-usd='.length));
if (Number.isFinite(n) && n > 0) args.budgetUsd = n;
}
}
return args;
}
const ARGS = parseArgs(process.argv.slice(2));
// ---------- Logging ----------
function fsTimestamp(date = new Date()) {
// ISO timestamp made filesystem-safe (colons → dashes; macOS+Windows reject ':' in filenames).
return date.toISOString().replace(/:/g, '-');
}
const FS_TS = fsTimestamp();
const LOG_DIR = getLogDir(APP);
const LOG_FILE = join(LOG_DIR, `kb-update-${FS_TS}.log`);
// Rotate the *active* log if it exists and exceeds the size cap, BEFORE the
// first write of this run. Per-run log files (timestamped) won't actually
// overflow during a single run, but rotateLog also tolerates missing files.
rotateLog(LOG_FILE, { maxSizeBytes: 10 * 1024 * 1024, maxGenerations: 5 });
function log(msg) {
const ts = new Date().toISOString();
console.log(`[${ts}] ${msg}`);
}
// ---------- Status file ----------
const CACHE_DIR = getCacheDir(APP);
const STATUS_FILE = join(CACHE_DIR, 'kb-update-status.json');
function writeStatus(partial) {
const base = {
schema_version: 1,
last_run_status: 'unknown',
last_run_ts: new Date().toISOString(),
duration_seconds: null,
auth_mode: 'unauthenticated',
log_file: LOG_FILE,
files_planned: null,
files_committed: null,
session_id: null,
total_cost_usd: null,
tokens_input: null,
tokens_output: null,
max_turns_hit: false,
diagnostic: null,
};
atomicWriteJson(STATUS_FILE, { ...base, ...partial });
}
// ---------- Dry-run early exit ----------
if (ARGS.dryRun) {
log('=== DRY RUN — Weekly KB Cron ===');
log(`Plugin root: ${PLUGIN_ROOT}`);
log(`Log file: ${LOG_FILE}`);
log(`Status file: ${STATUS_FILE}`);
log(`Budget cap: $${ARGS.budgetUsd.toFixed(2)} USD (api-key auth only)`);
log('Pipeline plan (would execute):');
log(' 1. run-weekly-update.mjs --force' + (ARGS.discover ? ' --discover' : ''));
log(' 2. read change-report.json → critical + high files');
log(' 3. cost-estimate via lib/cost-estimat.mjs');
log(' 4. backup skills/ → .kb-backup/<ts>/');
log(' 5. spawn claude -p with --permission-mode dontAsk + scoped allowedTools');
log(' 6. post-run verify: git log --since=@<runStart> commit count');
log(' 7. branch on status: success / partial / failure / budget_exceeded');
if (existsSync(join(DATA_DIR, 'change-report.json'))) {
try {
const rp = JSON.parse(readFileSync(join(DATA_DIR, 'change-report.json'), 'utf8'));
const c = rp?.by_priority?.critical ?? 0;
const h = rp?.by_priority?.high ?? 0;
log(`Current change-report: ${c} critical + ${h} high (would be planned)`);
} catch {
log('Current change-report: (unreadable)');
}
} else {
log('Current change-report: (none — would be generated by run-weekly-update.mjs)');
}
// Auth-mode is lazy in dry-run: detect but never validate so a dev can
// sanity-check the plan without having a cron-safe credential set up yet.
let mode = 'unauthenticated';
try {
mode = detectAuthMode();
} catch {
// detectAuthMode shouldn't throw, but be defensive.
}
writeStatus({
last_run_status: 'dry-run',
auth_mode: mode,
diagnostic: null,
});
log('=== DRY RUN COMPLETE ===');
process.exit(0);
}
// ---------- Pre-flight ----------
function which(cmd) {
const finder = osPlatform() === 'win32' ? 'where' : 'which';
try {
const out = execFileSync(finder, [cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
return out.split(/\r?\n/)[0].trim() || null;
} catch {
return null;
}
}
function preflight() {
// git --version
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
} catch {
const e = new Error('git not found in PATH');
e.code = 'ENOGIT';
throw e;
}
// which claude
const claudeBin = process.env.CLAUDE_BIN || which('claude');
if (!claudeBin) {
const e = new Error('claude CLI not found in PATH (set CLAUDE_BIN to override)');
e.code = 'ENOCLAUDE';
throw e;
}
// auth-mode detection + validation
const authMode = detectAuthMode();
validateAuthForCron(authMode); // throws EAUTHCRON if not safe
// ~/.claude.json onboarding flag (informational)
const claudeJson = readClaudeJson(join(homedir(), '.claude.json'));
const onboarded = claudeJson?.hasCompletedOnboarding === true;
if (!onboarded) {
log('WARN: ~/.claude.json missing or onboarding incomplete — cron may prompt');
}
// microsoft-learn MCP soft-warn
const mcpJsonPath = join(PLUGIN_ROOT, '.mcp.json');
if (!existsSync(mcpJsonPath)) {
log('WARN: plugin .mcp.json missing — Claude session may lack microsoft-learn');
}
// git status --porcelain clean check
let porcelain = '';
try {
porcelain = execFileSync('git', ['status', '--porcelain'], {
cwd: PLUGIN_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (err) {
const e = new Error(`git status failed: ${err.message}`);
e.code = 'EGITSTATUS';
throw e;
}
if (porcelain) {
const e = new Error(`Working tree not clean:\n${porcelain}`);
e.code = 'EDIRTY';
throw e;
}
return { claudeBin, authMode };
}
// ---------- Main ----------
const runStartTs = Date.now();
let lockHandle = null;
let backupHandle = null;
let authMode = 'unauthenticated';
let claudeBin = null;
let updateFiles = [];
function bail(status, diagnostic, extra = {}) {
const duration = Math.round((Date.now() - runStartTs) / 1000);
writeStatus({
last_run_status: status,
auth_mode: authMode,
duration_seconds: duration,
diagnostic,
...extra,
});
if (backupHandle && status === 'failure') {
try {
log('Rolling back skills/ from backup...');
backupHandle.restore();
log('Rollback complete.');
} catch (err) {
log(`Rollback failed: ${err.message}`);
}
}
if (lockHandle) {
try { lockHandle.release(); } catch { /* best-effort */ }
}
process.exit(status === 'success' || status === 'partial' ? 0 : 1);
}
try {
log('=== Weekly KB Cron Start ===');
log(`Plugin root: ${PLUGIN_ROOT}`);
log(`Log file: ${LOG_FILE}`);
// Pre-flight
const pf = preflight();
claudeBin = pf.claudeBin;
authMode = pf.authMode;
log(`Auth mode: ${authMode}`);
log(`Claude bin: ${claudeBin}`);
// Lock
lockHandle = acquireLock(undefined, { staleThresholdMs: 2 * 60 * 60 * 1000 });
log(`Lock acquired: ${lockHandle.lockPath}`);
// Pipeline step 1: poll + report (+ optional discover)
const updateScript = join(__dirname, 'run-weekly-update.mjs');
const updateArgs = ['--force'];
if (ARGS.discover) updateArgs.push('--discover');
log(`Running ${updateScript} ${updateArgs.join(' ')}`);
try {
execFileSync('node', [updateScript, ...updateArgs], {
stdio: 'inherit',
timeout: 10 * 60 * 1000,
cwd: PLUGIN_ROOT,
});
} catch (err) {
bail('failure', `run-weekly-update.mjs failed: ${err.message}`);
}
// Read change report
const reportPath = join(DATA_DIR, 'change-report.json');
if (!existsSync(reportPath)) {
log('No change report produced. Treating as success (nothing to do).');
bail('success', null, { files_planned: 0, files_committed: 0 });
}
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
const counts = report.by_priority || {};
log(`Change report: ${counts.critical || 0} critical, ${counts.high || 0} high, ${counts.medium || 0} medium`);
// Build updateFiles = critical + high (medium/low excluded per brief)
updateFiles = (report.files || []).filter(
(f) => f.priority === 'critical' || f.priority === 'high'
);
log(`Files to update: ${updateFiles.length} (critical + high)`);
if (updateFiles.length === 0) {
log('Nothing critical/high to update. Exiting clean.');
bail('success', null, { files_planned: 0, files_committed: 0 });
}
// Cost estimate + budget check
const cost = estimateCost(counts, { authMode });
log(`Estimated cost: ${cost.usd === null ? '(quota; subscription)' : `$${cost.usd.toFixed(2)}`} ` +
`(${cost.tokens_input} in / ${cost.tokens_output} out)`);
if (cost.kvote_warn) {
log('NOTE: Subscription auth — quota-bound, no $-cap applied.');
}
if (authMode === 'api-key' && cost.usd !== null && cost.usd > ARGS.budgetUsd) {
log(`Cost $${cost.usd.toFixed(2)} exceeds budget $${ARGS.budgetUsd.toFixed(2)} — aborting.`);
bail('budget_exceeded',
`Estimated $${cost.usd.toFixed(2)} > budget $${ARGS.budgetUsd.toFixed(2)}`,
{
files_planned: updateFiles.length,
files_committed: 0,
tokens_input: cost.tokens_input,
tokens_output: cost.tokens_output,
total_cost_usd: cost.usd,
});
}
// Backup skills/
const backupRoot = getBackupDir(PLUGIN_ROOT);
log(`Backing up ${SKILLS_DIR}${backupRoot}/<ts>/...`);
backupHandle = backupDir(SKILLS_DIR, backupRoot, { retentionDays: KB_BACKUP_DAYS });
log(`Backup: ${backupHandle.backupPath}`);
// Build prompt
const fileList = updateFiles.map((f) => {
const urls = (f.changed_urls || []).slice(0, 5).join('\n ');
return `- ${f.path} [${f.priority}]\n Changed URLs:\n ${urls}`;
}).join('\n');
const yyyymm = new Date().toISOString().slice(0, 7);
const prompt = `Du er Cosmo Skyberg. Oppdater ${updateFiles.length} stale kunnskapsreferanser i ms-ai-architect pluginen.
Arbeidsmappe: ${PLUGIN_ROOT}
## Filer å oppdatere
${fileList}
## For HVER fil
1. Les filen med Read
2. Bruk microsoft_docs_fetch de endrede kilde-URLene listet over
3. Bruk microsoft_docs_search for supplerende info
4. Oppdater filen med Edit:
- Oppdater "Last updated" til ${yyyymm}
- Oppdater utdaterte fakta, priser, datoer
- Bevar eksisterende struktur og seksjoner
- Marker oppdatert innhold med "Verified (MCP ${yyyymm})"
## Etter alle oppdateringer
1. Kjør: node scripts/kb-update/build-registry.mjs --merge
2. Kjør: node scripts/kb-update/report-changes.mjs
3. git add skills/ scripts/kb-update/data/
4. git commit -m "docs(architect): weekly KB update ${updateFiles.length} files refreshed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
## Regler
- Aldri slett filer, kun oppdater
- Bruk Edit, ikke Write
- Bevar all eksisterende struktur
- Commit én gang ved slutt ikke per fil`;
// Spawn Claude (NEW flag stack)
const allowedTools = [
'Read', 'Edit', 'Write',
'Bash(git add:*)', 'Bash(git commit:*)', 'Bash(git push:*)',
'Bash(git status:*)', 'Bash(git diff:*)', 'Bash(git log:*)',
'mcp__microsoft-learn__microsoft_docs_search',
'mcp__microsoft-learn__microsoft_docs_fetch',
].join(',');
log(`Spawning Claude (model claude-sonnet-4-6, max-turns 200) with ${allowedTools.split(',').length} allowed tools...`);
const claudeResult = spawnSync(claudeBin, [
'-p', prompt,
'--permission-mode', 'dontAsk',
'--allowedTools', allowedTools,
'--max-turns', '200',
'--output-format', 'json',
'--model', 'claude-sonnet-4-6',
], {
cwd: PLUGIN_ROOT,
encoding: 'utf8',
timeout: 60 * 60 * 1000,
maxBuffer: 32 * 1024 * 1024,
});
// Parse output
let sessionMeta = {};
let maxTurnsHit = false;
if (claudeResult.stdout) {
try {
// --output-format json yields a single JSON object on stdout.
sessionMeta = JSON.parse(claudeResult.stdout);
const resultStr = String(sessionMeta.result ?? sessionMeta.stop_reason ?? '');
if (resultStr.includes('max_turns')) maxTurnsHit = true;
} catch (err) {
log(`WARN: could not parse Claude JSON output: ${err.message}`);
}
}
if (claudeResult.stderr) {
process.stderr.write(claudeResult.stderr);
}
// Post-run verification: count git commits since runStart
const runStartUnixSec = Math.floor(runStartTs / 1000);
let commitCount = 0;
try {
const log_out = execFileSync('git', ['log', `--since=@${runStartUnixSec}`, '--oneline'], {
cwd: PLUGIN_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
commitCount = log_out.split('\n').filter((l) => l.trim().length > 0).length;
} catch (err) {
log(`WARN: git log post-run failed: ${err.message}`);
}
log(`Post-run: ${commitCount} commit(s) since runStart, planned ${updateFiles.length}.`);
// Branching
const claudeOk = claudeResult.status === 0;
let status = 'success';
let diagnostic = null;
if (!claudeOk) {
status = 'failure';
diagnostic = `claude exited ${claudeResult.status}` +
(claudeResult.signal ? ` (signal ${claudeResult.signal})` : '');
} else if (commitCount === 0 && updateFiles.length > 0) {
status = 'failure';
diagnostic = 'No commits produced despite expected files';
} else if (commitCount > 0 && commitCount < updateFiles.length && maxTurnsHit) {
status = 'partial';
diagnostic = `Hit max_turns: ${commitCount}/${updateFiles.length} files committed; rest will retry next week`;
} else if (commitCount > 0 && commitCount < updateFiles.length) {
// Partial without max_turns hit — treat as partial (Claude completed but
// some files weren't actionable). Conservative: don't roll back.
status = 'partial';
diagnostic = `Claude completed but only ${commitCount}/${updateFiles.length} files committed`;
} else {
status = 'success';
}
const totalCostUsd = typeof sessionMeta.total_cost_usd === 'number'
? sessionMeta.total_cost_usd
: null;
const sessionId = typeof sessionMeta.session_id === 'string'
? sessionMeta.session_id
: null;
const tokensIn = typeof sessionMeta?.usage?.input_tokens === 'number'
? sessionMeta.usage.input_tokens
: null;
const tokensOut = typeof sessionMeta?.usage?.output_tokens === 'number'
? sessionMeta.usage.output_tokens
: null;
const statusExtra = {
files_planned: updateFiles.length,
files_committed: commitCount,
session_id: sessionId,
total_cost_usd: totalCostUsd,
tokens_input: tokensIn,
tokens_output: tokensOut,
max_turns_hit: maxTurnsHit,
};
if (status === 'failure') {
bail('failure', diagnostic, statusExtra);
}
// success or partial: keep commits + optionally push
if (status === 'success' && autoPushEligible()) {
try {
execFileSync('git', ['push', 'origin', 'main'], {
cwd: PLUGIN_ROOT,
stdio: 'inherit',
});
log('Pushed origin/main.');
} catch (err) {
log(`WARN: git push failed (commits remain local): ${err.message}`);
}
}
// Cleanup old backups (best-effort, post-success)
try {
const cleanup = cleanupOldBackups(backupRoot, KB_BACKUP_DAYS);
if (cleanup.deleted.length > 0) {
log(`Cleaned up ${cleanup.deleted.length} old backup(s).`);
}
} catch (err) {
log(`WARN: cleanupOldBackups failed: ${err.message}`);
}
log(`=== Weekly KB Cron Done (${status}) ===`);
bail(status, diagnostic, statusExtra);
} catch (err) {
// Pre-flight or unexpected error before pipeline started.
const code = err && err.code ? err.code : 'EUNKNOWN';
log(`Pre-flight/error: [${code}] ${err.message}`);
if (err.stack) {
log(err.stack.split('\n').slice(1, 4).join('\n'));
}
bail('failure', `[${code}] ${err.message}`);
}
// ---------- Helpers ----------
function autoPushEligible() {
// Two gates: a configured user.email + a reachable origin.
try {
const email = execFileSync('git', ['config', '--get', 'user.email'], {
cwd: PLUGIN_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
if (!email) return false;
} catch {
return false;
}
try {
execFileSync('git', ['ls-remote', 'origin', '--exit-code', 'HEAD'], {
cwd: PLUGIN_ROOT,
stdio: 'ignore',
timeout: 10_000,
});
return true;
} catch {
return false;
}
}