feat(ms-ai-architect): Sesjon 24 — C1 Tier 2 lokal launchd-scheduler (deteksjon-only, ToS-trygt) [skip-docs]
Tier 1 (SessionStart-hook) kjører deteksjon kun når en Claude-sesjon starter.
Tier 2 installerer en lokal launchd LaunchAgent som fyrer SAMME Claude-frie
entrypoint daglig (kl. 03:00), uavhengig av sesjoner = ekte bakgrunn mellom
sesjoner. ToS-trygt: entrypointet kan strukturelt ikke invokere claude.
Søk-først-verifisert (2026-06): launchd > cron (Apple-anbefalt; cron krever
Full Disk Access); StartCalendarInterval fanger opp etter dvale; launchd har
minimalt PATH + ekspanderer ikke ~; process.execPath = Cellar-sti som dør ved
`brew upgrade node` → plistens PATH inkluderer /usr/local/bin.
Levert (TDD, test FØR kode):
- lib/launchd-plist.mjs (REN, SKRIVER ALDRI): renderPlist (XML-escape,
StartCalendarInterval, PATH-fix), defaultLabel, resolvePaths.
- scheduler.mjs (TYNN CLI): install|uninstall|status|run-now|print|run.
Strukturelt Claude-fri (kun process.execPath på run-detection.mjs +
launchctl; kilde-grep-testet). run gater på os_scheduler_cadence →
delegerer til run-detection.mjs. uid-0-nekt; idempotent bootout.
- detection-schedule.mjs (additiv, Tier-1 urørt): os_scheduler_cadence
('daily' default | 'interval') + daysSinceLastPoll ren helper.
- commands/kb-update.md: Tier-1/Tier-2-doc + caveats (uninstall=pause;
re-install etter node-upgrade).
Operatør-valg: daglig default, kadens egen innstilling (settes i onboarding/C2).
Verifisert: launchd-plist 13/13 · detection-schedule 21/21 · kb-update 173 ·
kb-eval 100 · validate 239/0 · test-hooks 6/6 · plutil -lint OK · live
install→status→idempotent→uninstall på maskinen (gui/501, rent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f8a160585f
commit
071601bb9e
6 changed files with 624 additions and 5 deletions
|
|
@ -19,6 +19,11 @@ export const DEFAULT_SCHEDULE_CONFIG = Object.freeze({
|
|||
enabled: false,
|
||||
interval_days: 7,
|
||||
include_skill_lifecycle: true,
|
||||
// Tier 2 (launchd) cadence. 'daily' = poll on every (daily) fire; 'interval'
|
||||
// = throttle to interval_days via the same staleness gate Tier 1 uses. Read
|
||||
// ONLY by the Tier-2 entrypoint (scheduler.mjs run); the Tier-1 hook ignores
|
||||
// it, so existing behavior is unchanged. Operator default: daily.
|
||||
os_scheduler_cadence: 'daily',
|
||||
});
|
||||
|
||||
// The ordered, Claude-FREE detection pipeline. Every step is a local node
|
||||
|
|
@ -45,6 +50,10 @@ function coerce(key, raw) {
|
|||
const n = Number.parseInt(v, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
if (key === 'os_scheduler_cadence') {
|
||||
// Only 'interval' opts out of daily; any other/garbage value => default daily.
|
||||
return v === 'interval' ? 'interval' : 'daily';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +121,22 @@ export function shouldRunDetection(config, lastPollDaysAgo, opts = {}) {
|
|||
return { run: false, reason: 'fresh (within interval)' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Days since the last sitemap poll, read from a parsed change-report.json.
|
||||
* Pure; mirrors the SessionStart hook's computation. Missing/invalid
|
||||
* last_poll => Infinity ("infinitely stale" => the 'interval' cadence runs).
|
||||
* @param {object|null} report parsed change-report.json ({ last_poll?: string })
|
||||
* @param {number} now Date.now()
|
||||
* @returns {number}
|
||||
*/
|
||||
export function daysSinceLastPoll(report, now) {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
if (!report || !report.last_poll) return Infinity;
|
||||
const t = new Date(report.last_poll).getTime();
|
||||
if (!Number.isFinite(t)) return Infinity;
|
||||
return (now - t) / DAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact one-liner summarizing skill-lifecycle signals for the
|
||||
* SessionStart hook. Pure; tolerant of partial/missing reports. Returns null
|
||||
|
|
|
|||
106
scripts/kb-update/lib/launchd-plist.mjs
Normal file
106
scripts/kb-update/lib/launchd-plist.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// launchd-plist.mjs — Spor C / C1 Tier 2: pure renderer for the detection
|
||||
// LaunchAgent. SKRIVER ALDRI / never writes — it only builds strings and
|
||||
// resolves paths. The thin imperative side (write the file, run launchctl)
|
||||
// lives in scheduler.mjs, mirroring the detection-schedule.mjs (pure core) +
|
||||
// run-detection.mjs (thin executor) split.
|
||||
//
|
||||
// Why these specific plist choices (search-first verified, 2026-06):
|
||||
// - StartCalendarInterval (NOT StartInterval): a calendar trigger runs the
|
||||
// job on wake if the fire time was missed during sleep; StartInterval just
|
||||
// skips it. Decisive for a laptop that sleeps.
|
||||
// - EnvironmentVariables.PATH: launchd starts jobs with a MINIMAL PATH, so a
|
||||
// bare `node` lookup (run-detection.mjs uses execFileSync('node', ...)) would
|
||||
// fail. We inject dirname(nodePath) PLUS the upgrade-stable /usr/local/bin
|
||||
// symlink dir, so a `brew upgrade node` (which moves the version-pinned
|
||||
// Cellar path) can't orphan the agent.
|
||||
// - Absolute paths only: launchd does not expand `~`.
|
||||
//
|
||||
// Pure: imports only node:path/node:url. No fs, no child_process.
|
||||
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const LABEL = 'com.ktg.ms-ai-architect.detection';
|
||||
|
||||
/** The system PATH floor launchd should always have, plus Homebrew's stable symlink dir. */
|
||||
const STABLE_PATH = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
||||
|
||||
/** Escape a value for inclusion in a plist <string> (a plist is XML). */
|
||||
export function xmlEscape(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** The stable reverse-DNS label for the detection LaunchAgent. */
|
||||
export function defaultLabel() {
|
||||
return LABEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a LaunchAgent plist that runs `<nodePath> <scriptPath> <...args>`
|
||||
* daily at hour:minute. Every interpolated value is XML-escaped; the schedule
|
||||
* is StartCalendarInterval (sleep-safe); RunAtLoad is false (first run at the
|
||||
* next scheduled fire, not at install).
|
||||
* @param {{label:string,nodePath:string,scriptPath:string,args:string[],hour:number,minute:number,logPath:string,workingDir:string}} o
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderPlist({ label, nodePath, scriptPath, args = [], hour, minute, logPath, workingDir }) {
|
||||
// PATH: dirname(node) first (so child `node` spawns resolve), then the stable floor.
|
||||
const pathValue = [dirname(nodePath), ...STABLE_PATH].join(':');
|
||||
const programArgs = [nodePath, scriptPath, ...args]
|
||||
.map((a) => ` <string>${xmlEscape(a)}</string>`)
|
||||
.join('\n');
|
||||
|
||||
return `<?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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${xmlEscape(label)}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${programArgs}
|
||||
</array>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>${Number(hour)}</integer>
|
||||
<key>Minute</key>
|
||||
<integer>${Number(minute)}</integer>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>${xmlEscape(pathValue)}</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${xmlEscape(workingDir)}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${xmlEscape(logPath)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${xmlEscape(logPath)}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute paths the installer needs. All absolute (no `~`).
|
||||
* The LaunchAgent lives in ~/Library/LaunchAgents; the log lives in the
|
||||
* gitignored scripts/kb-update/data/ dir; launchd runs scheduler.mjs (which
|
||||
* gates on cadence, then delegates to the Claude-free run-detection.mjs).
|
||||
* @param {{pluginRoot:string,homeDir:string,nodePath:string}} o
|
||||
*/
|
||||
export function resolvePaths({ pluginRoot, homeDir }) {
|
||||
const label = LABEL;
|
||||
return {
|
||||
label,
|
||||
agentPlistPath: `${homeDir}/Library/LaunchAgents/${label}.plist`,
|
||||
scriptPath: `${pluginRoot}/scripts/kb-update/scheduler.mjs`,
|
||||
logPath: `${pluginRoot}/scripts/kb-update/data/detection-scheduler.log`,
|
||||
workingDir: pluginRoot,
|
||||
};
|
||||
}
|
||||
188
scripts/kb-update/scheduler.mjs
Normal file
188
scripts/kb-update/scheduler.mjs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
#!/usr/bin/env node
|
||||
// scheduler.mjs — Spor C / C1 Tier 2: install/manage the local launchd
|
||||
// LaunchAgent that runs the Claude-FREE detection pipeline on a daily schedule,
|
||||
// independent of Claude sessions = real background BETWEEN sessions.
|
||||
//
|
||||
// STRUCTURAL ToS GUARANTEE: this script only ever spawns the running node
|
||||
// binary (process.execPath) on the allow-listed run-detection.mjs, and the
|
||||
// `launchctl` CLI for agent management. It can NEVER invoke `claude`. The
|
||||
// scheduled entrypoint (`run`) gates on cadence, then delegates to
|
||||
// run-detection.mjs, which is itself structurally Claude-free. Apply (the only
|
||||
// Claude step) stays manual + in-session + gated. See detection-schedule.mjs
|
||||
// for the ToS rationale (Consumer Terms §3.7 targets Anthropic's services, not
|
||||
// local scripts).
|
||||
//
|
||||
// node scripts/kb-update/scheduler.mjs install # write plist + load agent
|
||||
// node scripts/kb-update/scheduler.mjs uninstall # unload + remove plist
|
||||
// node scripts/kb-update/scheduler.mjs status # is it loaded?
|
||||
// node scripts/kb-update/scheduler.mjs run-now # trigger one run now
|
||||
// node scripts/kb-update/scheduler.mjs print # show the plist (alias --dry-run)
|
||||
// node scripts/kb-update/scheduler.mjs run # (the launchd entrypoint)
|
||||
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { renderPlist, resolvePaths, defaultLabel } from './lib/launchd-plist.mjs';
|
||||
import {
|
||||
loadScheduleConfig,
|
||||
daysSinceLastPoll,
|
||||
} from './lib/detection-schedule.mjs';
|
||||
import { atomicWriteSync } from './lib/atomic-write.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
const RUN_DETECTION = join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs');
|
||||
const CHANGE_REPORT = join(PLUGIN_ROOT, 'scripts', 'kb-update', 'data', 'change-report.json');
|
||||
|
||||
// Daily fire time. Not configurable yet (onboarding/C2 may expose it).
|
||||
const FIRE_HOUR = 3;
|
||||
const FIRE_MINUTE = 0;
|
||||
|
||||
/** Build renderPlist inputs from the resolved paths + the running node. */
|
||||
function planAgent() {
|
||||
const nodePath = process.execPath;
|
||||
const paths = resolvePaths({ pluginRoot: PLUGIN_ROOT, homeDir: homedir(), nodePath });
|
||||
const plist = renderPlist({
|
||||
label: paths.label,
|
||||
nodePath,
|
||||
scriptPath: paths.scriptPath,
|
||||
args: ['run'],
|
||||
hour: FIRE_HOUR,
|
||||
minute: FIRE_MINUTE,
|
||||
logPath: paths.logPath,
|
||||
workingDir: paths.workingDir,
|
||||
});
|
||||
return { ...paths, nodePath, plist };
|
||||
}
|
||||
|
||||
/** gui/<uid> domain + gui/<uid>/<label> service target. */
|
||||
function targets() {
|
||||
const uid = process.getuid();
|
||||
return { domain: `gui/${uid}`, service: `gui/${uid}/${defaultLabel()}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run launchctl. ignoreError swallows a non-zero exit (the idempotency
|
||||
* primitive — a pre-install bootout of a not-yet-loaded service is expected to
|
||||
* fail); silent hides its output so that expected failure isn't alarming.
|
||||
*/
|
||||
function launchctl(args, { ignoreError = false, silent = false } = {}) {
|
||||
try {
|
||||
execFileSync('launchctl', args, { stdio: silent ? 'ignore' : 'inherit' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (ignoreError) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subcommands -----------------------------------------------------------
|
||||
|
||||
function printPlist() {
|
||||
// Pure preview: render and print. Writes nothing, runs no launchctl.
|
||||
process.stdout.write(planAgent().plist);
|
||||
}
|
||||
|
||||
function install() {
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
console.error('Refusing to install as root — run as your logged-in user (no sudo).');
|
||||
console.error('A LaunchAgent in the gui/<uid> domain must target your own session.');
|
||||
process.exit(1);
|
||||
}
|
||||
const { agentPlistPath, plist, scriptPath, logPath } = planAgent();
|
||||
const { domain, service } = targets();
|
||||
|
||||
mkdirSync(dirname(agentPlistPath), { recursive: true });
|
||||
atomicWriteSync(agentPlistPath, plist);
|
||||
|
||||
// Idempotent: unload any previous instance (expected to fail+silent on first install).
|
||||
launchctl(['bootout', service], { ignoreError: true, silent: true });
|
||||
launchctl(['bootstrap', domain, agentPlistPath]);
|
||||
|
||||
console.log(`Installed LaunchAgent: ${agentPlistPath}`);
|
||||
console.log(` fires daily at ${String(FIRE_HOUR).padStart(2, '0')}:${String(FIRE_MINUTE).padStart(2, '0')} → ${scriptPath} run`);
|
||||
console.log(` log: ${logPath}`);
|
||||
console.log('');
|
||||
console.log('Verify now: node scripts/kb-update/scheduler.mjs run-now');
|
||||
console.log('Pause it: node scripts/kb-update/scheduler.mjs uninstall (the enabled: flag is Tier-1/hook only)');
|
||||
console.log('After a major `brew upgrade node`, re-run install (the node path is pinned at install time).');
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
const { agentPlistPath } = planAgent();
|
||||
const { service } = targets();
|
||||
launchctl(['bootout', service], { ignoreError: true, silent: true });
|
||||
if (existsSync(agentPlistPath)) {
|
||||
rmSync(agentPlistPath);
|
||||
console.log(`Removed LaunchAgent: ${agentPlistPath}`);
|
||||
} else {
|
||||
console.log('No LaunchAgent plist found — nothing to remove.');
|
||||
}
|
||||
}
|
||||
|
||||
function status() {
|
||||
const { agentPlistPath } = planAgent();
|
||||
const { service } = targets();
|
||||
console.log(`plist: ${agentPlistPath} ${existsSync(agentPlistPath) ? '(present)' : '(absent)'}`);
|
||||
// Non-fatal: prints the loaded job, or an error line if not loaded.
|
||||
launchctl(['print', service], { ignoreError: true });
|
||||
}
|
||||
|
||||
function runNow() {
|
||||
const { service } = targets();
|
||||
launchctl(['kickstart', '-k', service]);
|
||||
console.log(`Kicked off ${service} — see the log for output.`);
|
||||
}
|
||||
|
||||
/** The launchd entrypoint. Gate on cadence, then delegate to run-detection.mjs. */
|
||||
function run() {
|
||||
const config = loadScheduleConfig(PLUGIN_ROOT);
|
||||
if (config.os_scheduler_cadence === 'interval') {
|
||||
let report = null;
|
||||
if (existsSync(CHANGE_REPORT)) {
|
||||
try {
|
||||
report = JSON.parse(readFileSync(CHANGE_REPORT, 'utf8'));
|
||||
} catch {
|
||||
report = null; // unreadable => treat as infinitely stale => run
|
||||
}
|
||||
}
|
||||
const days = daysSinceLastPoll(report, Date.now());
|
||||
if (days < config.interval_days) {
|
||||
console.log(`fresh (${Math.floor(days)}d < ${config.interval_days}d) — skipping poll`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// daily cadence, or interval+stale: delegate to the Claude-free entrypoint
|
||||
// using the running node binary (no PATH dependency for this hop).
|
||||
execFileSync(process.execPath, [RUN_DETECTION], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// --- Dispatch --------------------------------------------------------------
|
||||
|
||||
const cmd = process.argv[2];
|
||||
switch (cmd) {
|
||||
case 'print':
|
||||
case '--dry-run':
|
||||
printPlist();
|
||||
break;
|
||||
case 'install':
|
||||
install();
|
||||
break;
|
||||
case 'uninstall':
|
||||
uninstall();
|
||||
break;
|
||||
case 'status':
|
||||
status();
|
||||
break;
|
||||
case 'run-now':
|
||||
runNow();
|
||||
break;
|
||||
case 'run':
|
||||
run();
|
||||
break;
|
||||
default:
|
||||
console.error('Usage: scheduler.mjs <install|uninstall|status|run-now|print|run>');
|
||||
process.exit(2);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue