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:
parent
97d1101e91
commit
a7a334c8d1
29 changed files with 238 additions and 2708 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 på 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue