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,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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue