From 071601bb9ee1501a520ffb567cad81f6df9d82ab Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 21 Jun 2026 19:59:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20Sesjon=2024=20?= =?UTF-8?q?=E2=80=94=20C1=20Tier=202=20lokal=20launchd-scheduler=20(deteks?= =?UTF-8?q?jon-only,=20ToS-trygt)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- commands/kb-update.md | 31 ++- scripts/kb-update/lib/detection-schedule.mjs | 25 ++ scripts/kb-update/lib/launchd-plist.mjs | 106 ++++++++ scripts/kb-update/scheduler.mjs | 188 +++++++++++++ .../test-detection-schedule.test.mjs | 31 +++ tests/kb-update/test-launchd-plist.test.mjs | 248 ++++++++++++++++++ 6 files changed, 624 insertions(+), 5 deletions(-) create mode 100644 scripts/kb-update/lib/launchd-plist.mjs create mode 100644 scripts/kb-update/scheduler.mjs create mode 100644 tests/kb-update/test-launchd-plist.test.mjs diff --git a/commands/kb-update.md b/commands/kb-update.md index 09996b3..28a4595 100644 --- a/commands/kb-update.md +++ b/commands/kb-update.md @@ -42,17 +42,35 @@ Deteksjonen kan kjøre automatisk i bakgrunnen ved sesjonsstart — **av som def ```yaml --- scheduled_detection: - enabled: true # default false — ingenting kjører før dette er true + enabled: true # default false — ingenting kjører før dette er true (Tier 1) interval_days: 7 # kjør deteksjon på nytt når det er ≥ N dager siden sist poll include_skill_lifecycle: true # ta med skill-livssyklus-deteksjon (overlapp/gap/bloat) + os_scheduler_cadence: daily # Tier 2: daily (poll hver dag) | interval (throttle på interval_days) --- ``` -Når `enabled: true` og det er ≥ `interval_days` siden sist poll, spawner SessionStart-hooken `scripts/kb-update/run-detection.mjs` i bakgrunnen. Den kjører **kun deteksjon** (poll → rapport → discovery → skill-livssyklus) og skriver JSON-rapporter til `data/` — **aldri** til `skills/`, og kaller **aldri** Claude. Ferske signaler surfaces ved neste sesjonsstart («KB: …» + «Skill-signaler: …»), og du kjører `/architect:kb-update` manuelt for å gjennomgå + apply-e gjennom gaten. +### Tier 1 — sesjonsforankret (SessionStart-hook) -**ToS-garanti (strukturell):** `run-detection.mjs` spawner kun `node` på de allow-listede deteksjons-scriptene; den kan ikke invokere `claude`. Apply (det eneste Claude-steget) forblir manuelt og in-session. Kjør `node scripts/kb-update/run-detection.mjs --dry-run` for å se stegene uten å kjøre noe. +Når `enabled: true` og det er ≥ `interval_days` siden sist poll, spawner SessionStart-hooken `scripts/kb-update/run-detection.mjs` i bakgrunnen. Den kjører **kun deteksjon** (poll → rapport → discovery → skill-livssyklus) og skriver JSON-rapporter til `data/` — **aldri** til `skills/`, og kaller **aldri** Claude. Ferske signaler surfaces ved neste sesjonsstart («KB: …» + «Skill-signaler: …»), og du kjører `/architect:kb-update` manuelt for å gjennomgå + apply-e gjennom gaten. **Begrensning:** Tier 1 kjører bare når en Claude-sesjon *starter* — starter du aldri en sesjon, kjører deteksjonen aldri. -Vil du ha ekte bakgrunnskjøring mellom sesjoner (lokal launchd/cron som kjører samme `run-detection.mjs`), kommer det som Tier 2 — den er like ToS-trygg fordi entrypointet er Claude-fritt. +### Tier 2 — lokal launchd-scheduler (ekte bakgrunn mellom sesjoner) + +For deteksjon som kjører *uavhengig av sesjoner*, installer en lokal **launchd LaunchAgent** som fyrer samme Claude-frie entrypoint daglig (kl. 03:00): + +```bash +node scripts/kb-update/scheduler.mjs install # skriv plist til ~/Library/LaunchAgents + last agenten +node scripts/kb-update/scheduler.mjs status # er den lastet? +node scripts/kb-update/scheduler.mjs run-now # kjør én gang nå (verifiser uten å vente til 03:00) +node scripts/kb-update/scheduler.mjs print # vis plisten uten å skrive noe (alias --dry-run) +node scripts/kb-update/scheduler.mjs uninstall # avlast + fjern plist +``` + +- **Kadens (`os_scheduler_cadence`):** `daily` (default) poller hver dag; `interval` throttler til `interval_days` via samme staleness-gate som Tier 1. (Onboarding setter dette senere; daglig er fornuftig default — poll av Microsoft Learn-sitemaps er billig og read-only.) +- **Pause = uninstall.** LaunchAgenten kjører uansett `enabled:`-flagget (det styrer *kun* Tier 1/hooken). Vil du stoppe Tier 2, kjør `uninstall` — det er pause-knappen. +- **Etter en major `brew upgrade node`:** kjør `install` på nytt. Node-stien bakes inn ved install-tid (launchd har minimalt `PATH`); plistens `PATH` inkluderer `/usr/local/bin` så barneprosessenes `node` overlever, men det er ryddigst å re-installere. +- **launchd, ikke cron:** Apple anbefaler launchd; cron krever Full Disk Access og er «not recommended». Vil du heller bruke cron/systemd/CI, peker du den på samme `node scripts/kb-update/run-detection.mjs`. + +**ToS-garanti (strukturell, begge tiers):** entrypointet (`run-detection.mjs`, og `scheduler.mjs run` som delegerer til det) spawner kun `node` på de allow-listede deteksjons-scriptene + `launchctl` for agent-styring; det kan ikke invokere `claude`. Apply (det eneste Claude-steget) forblir manuelt og in-session. Kjør `node scripts/kb-update/run-detection.mjs --dry-run` (eller `scheduler.mjs print`) for å se hva som ville kjørt uten å kjøre noe. ## Instruksjoner til assistenten @@ -174,6 +192,9 @@ Rapporter: ## Schedulering -Pluginen schedulerer **ingenting**. Hvis du vil ha periodisk kjøring, sett opp en cron-jobb / launchd-jobb / systemd timer / GitHub Actions-workflow som kjører `node scripts/kb-update/run-weekly-update.mjs --force --discover` (uten apply-fasen) og varsler deg om å kjøre `/architect:kb-update` i en interaktiv Claude Code-sesjon. +Pluginen schedulerer **ingenting før du selv aktiverer det** (alt er opt-in). Du har tre nivåer, alle Claude-frie i deteksjonsfasen: +- **Tier 1 (sesjonsstart):** `scheduled_detection.enabled: true` i `ms-ai-architect.local.md` — se «Scheduled deteksjon» over. +- **Tier 2 (lokal launchd, ekte bakgrunn):** `node scripts/kb-update/scheduler.mjs install` — se «Scheduled deteksjon» over. +- **Egen scheduler:** vil du heller bruke cron / systemd timer / CI, pek den på `node scripts/kb-update/run-detection.mjs` (eller `run-weekly-update.mjs --force --discover` for den eldre poll-flyten) og la den varsle deg om å kjøre `/architect:kb-update` i en interaktiv sesjon. Apply-fasen (oppdatere filer + committe) kan ikke automatiseres innenfor denne pluginen — den krever LLM-resonnering på endringene og menneskelig vurdering, og er bevisst designet for kjøring fra en åpen Claude Code-sesjon. diff --git a/scripts/kb-update/lib/detection-schedule.mjs b/scripts/kb-update/lib/detection-schedule.mjs index 11bbc71..098e126 100644 --- a/scripts/kb-update/lib/detection-schedule.mjs +++ b/scripts/kb-update/lib/detection-schedule.mjs @@ -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 diff --git a/scripts/kb-update/lib/launchd-plist.mjs b/scripts/kb-update/lib/launchd-plist.mjs new file mode 100644 index 0000000..4046069 --- /dev/null +++ b/scripts/kb-update/lib/launchd-plist.mjs @@ -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 (a plist is XML). */ +export function xmlEscape(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** The stable reverse-DNS label for the detection LaunchAgent. */ +export function defaultLabel() { + return LABEL; +} + +/** + * Render a LaunchAgent plist that runs ` <...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) => ` ${xmlEscape(a)}`) + .join('\n'); + + return ` + + + + Label + ${xmlEscape(label)} + ProgramArguments + +${programArgs} + + StartCalendarInterval + + Hour + ${Number(hour)} + Minute + ${Number(minute)} + + RunAtLoad + + EnvironmentVariables + + PATH + ${xmlEscape(pathValue)} + + WorkingDirectory + ${xmlEscape(workingDir)} + StandardOutPath + ${xmlEscape(logPath)} + StandardErrorPath + ${xmlEscape(logPath)} + + +`; +} + +/** + * 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, + }; +} diff --git a/scripts/kb-update/scheduler.mjs b/scripts/kb-update/scheduler.mjs new file mode 100644 index 0000000..539c457 --- /dev/null +++ b/scripts/kb-update/scheduler.mjs @@ -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/ domain + gui//