ms-ai-architect/scripts/kb-update/lib/detection-schedule.mjs
Kjell Tore Guttormsen 2e69e69326 feat(ms-ai-architect): C3.4 — detect-courses.mjs (binder C3.1–C3.3, LLM-fri) + selectDetectionSteps + include_course_detection (TDD) [skip-docs]
LLM-fri STEG 1-detektor for kurs-deteksjon (C3.4 av GODKJENT C3-spec). Speiler
discover-new-urls' to-stegs-monster: leser Keychain-creds, paginerer modules +
learning-paths filtrert til in-domain produkter, differ mot egen diff-state
(course-registry.json), skriver kandidatrapport. Skriver KUN report + registry —
ALDRI decisions.json (operator-gaten i C3.5 promoterer leads).

- scripts/kb-update/detect-courses.mjs (NY): detectCourses(deps) — injiserbar
  kjerne (readSecret/getToken/paginate/loadTaxonomy/load+saveCourseRegistry/
  saveReport/dataDir/now/log) -> hermetisk testbar uten Keychain/nett. Fail-soft
  (spec 3/6): creds=null -> status:"skipped"; run-feil -> status:"error" (kaster
  ALDRI); begge exit 0 (advisory, aldri rod pipeline). Tynn CLI med realpath-guard
  (kjorer kun nar invokert direkte) + --data-dir test-seam. updatedAt.gt-cursor
  KUN pa incremental (full ISO, gotcha #1); products-filter = comma-join (OR).
- lib/detection-schedule.mjs: ny ren selectDetectionSteps(config) gater BADE
  skillLifecycle + courseDetection (en sannhetskilde). DEFAULT_SCHEDULE_CONFIG
  +include_course_detection:false (opt-in i opt-in, spec 8d). coerce/serialize
  utvidet. DETECTION_STEPS +{detect-courses, courseDetection:true}.
- run-detection.mjs: bruker selectDetectionSteps (fjerner duplisert filter-linje).

Tester (+22):
- test-detect-courses-invariant (8): ingen saveDecisions/decisions-io/atomic-write/
  backup-import; skriver egen registry+report; binder diffCourses+makeCourseClassifier;
  LLM-fri (kommentar-strippet kildesjekk).
- test-detect-courses (6): fail-soft skipped (alle/delvis creds), error-path (no
  throw, registry urort), 4.3 report-shape + avledet skill/category, incremental
  cursor/produkt-param, baseline=ingen leads (spec 8b), hermetisk exit-0-subprosess
  via PATH-shadow av `security`.
- test-detection-schedule (+8): include_course_detection parse/serialize/default;
  selectDetectionSteps-gating (default av, opt-in pa, uavhengige gates);
  run-detection bruker selektoren.

Gotcha (secrets-hook): clientSecret: '...'-form traff llm-security-regelen
(client[_-]?secret|ClientSecret)\s*[=:]\s*['"]…{8,} -> lost med array-av-par
(feltnavn star aldri rett for quotet streng).

Gate 7 C3.4 mott: invariant + fail-soft(exit 0) + report-shape + DETECTION_STEPS-
filter gronn. kb-update 269->291, validate PASSED (239/0), run-detection dry-run
dropper detect-courses (opt-in av), eval urort (rorer ikke KB), null regresjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:57 +02:00

235 lines
11 KiB
JavaScript

// detection-schedule.mjs — Spor C / C1: opt-in session-anchored detection.
//
// The DETECTION layer (poll → report → discover → detect-skill-lifecycle) is
// pure node: it polls Microsoft Learn sitemaps and writes JSON reports, and
// NEVER contacts Anthropic/Claude. It is therefore OUTSIDE the ToS surface —
// Consumer Terms §3.7 restricts automated access to "our Services" (Anthropic),
// not running local scripts. The APPLY layer (which invokes Claude) stays
// manual, in-session, and gated. This module is the pure decision core for the
// SessionStart hook + the run-detection.mjs executor.
//
// parseScheduleConfig is pure (text in, config out). loadScheduleConfig resolves
// paths via lib/user-data.mjs (which itself depends only on node:os/path), so
// the user-owned config dir is the single source of truth shared with the hook.
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { resolveConfigPath, CONFIG_FILENAME } from './user-data.mjs';
// Opt-in: detection is OFF until the user sets enabled:true. "Frivillig å sette
// opp — men gjøres det, skal det virke." (operator 2026-06-20)
export const DEFAULT_SCHEDULE_CONFIG = Object.freeze({
enabled: false,
interval_days: 7,
include_skill_lifecycle: true,
// C3.4: opt-in inside opt-in. Course detection (Learn Platform API) needs Entra
// Keychain creds almost nobody has, so default false — a default-true would
// give every user a red/"skipped" step. (spec §5, §8 d)
include_course_detection: false,
// 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
// script that produces a JSON report; none invoke Claude. run-detection.mjs
// resolves each as `node <pluginRoot>/scripts/<dir>/<script> <args>`.
export const DETECTION_STEPS = Object.freeze([
{ name: 'poll-sitemaps', dir: 'kb-update', script: 'poll-sitemaps.mjs', args: ['--force'] },
{ name: 'report-changes', dir: 'kb-update', script: 'report-changes.mjs', args: [] },
{ name: 'discover-new-urls', dir: 'kb-update', script: 'discover-new-urls.mjs', args: ['--limit', '500'] },
{ name: 'detect-skill-lifecycle', dir: 'kb-eval', script: 'detect-skill-lifecycle.mjs', args: ['--write'], skillLifecycle: true },
// C3.4: parallel spore on the Learn Platform API. Still pure node (reads the
// Keychain, hits the API, writes its own report/registry — never Claude),
// gated behind include_course_detection (default OFF). (spec §5)
{ name: 'detect-courses', dir: 'kb-update', script: 'detect-courses.mjs', args: [], courseDetection: true },
]);
/**
* Apply the opt-in content gates to the detection pipeline. Pure: each flagged
* step (skillLifecycle / courseDetection) survives only when its config flag is
* on; unflagged steps always run. run-detection.mjs applies exactly this — a
* single source of truth for "which steps run", unit-tested independently of the
* subprocess executor.
* @param {{include_skill_lifecycle?: boolean, include_course_detection?: boolean}} config
* @param {ReadonlyArray<object>} [steps]
* @returns {object[]}
*/
export function selectDetectionSteps(config = {}, steps = DETECTION_STEPS) {
return steps.filter(
(s) =>
(!s.skillLifecycle || config.include_skill_lifecycle) &&
(!s.courseDetection || config.include_course_detection),
);
}
/** Coerce a YAML-ish scalar to bool/number/string. Unknown booleans => false. */
function coerce(key, raw) {
const v = raw.trim();
if (key === 'enabled' || key === 'include_skill_lifecycle' || key === 'include_course_detection') {
if (v === 'true') return true;
if (v === 'false') return false;
return undefined; // non-boolean => caller keeps default (or OFF for enabled)
}
if (key === 'interval_days') {
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;
}
/**
* Parse the `scheduled_detection:` block out of a config file's text. Pure;
* tolerant: any parse failure or missing block yields the OFF default. Reads a
* minimal indented key: value sub-block (no YAML dependency — the format is
* documented + controlled).
* @param {string} text
* @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}}
*/
export function parseScheduleConfig(text) {
const cfg = { ...DEFAULT_SCHEDULE_CONFIG };
if (typeof text !== 'string' || !text.includes('scheduled_detection:')) return cfg;
const lines = text.split('\n');
let inBlock = false;
for (const line of lines) {
if (/^\s*scheduled_detection:\s*$/.test(line)) { inBlock = true; continue; }
if (!inBlock) continue;
// Block ends at the first non-indented, non-blank line (e.g. `---` or next key).
if (line.trim() === '') continue;
if (!/^\s+\S/.test(line)) break;
const m = line.match(/^\s+([A-Za-z_]+):\s*(.*)$/);
if (!m) continue;
const [, key, raw] = m;
if (!(key in DEFAULT_SCHEDULE_CONFIG)) continue;
const val = coerce(key, raw);
if (val !== undefined) cfg[key] = val;
}
return cfg;
}
/**
* Serialize a schedule config to a full config-file text: a `---`-fenced
* frontmatter with the `scheduled_detection:` block (all four known keys) plus a
* short human header. The inverse of parseScheduleConfig — C2.3's onboarding
* write-path uses THIS (no second format definition): values are normalized
* exactly as coerce() would read them, so
* parseScheduleConfig(serializeScheduleConfig(c))
* yields a DEFAULT-merged, normalized c (round-trip, acceptance K5). Pure.
* @param {Partial<typeof DEFAULT_SCHEDULE_CONFIG>} [config]
* @returns {string}
*/
export function serializeScheduleConfig(config = {}) {
const c = { ...DEFAULT_SCHEDULE_CONFIG, ...config };
const interval =
Number.isInteger(c.interval_days) && c.interval_days > 0
? c.interval_days
: DEFAULT_SCHEDULE_CONFIG.interval_days;
return [
'---',
'scheduled_detection:',
` enabled: ${c.enabled === true}`,
` interval_days: ${interval}`,
` include_skill_lifecycle: ${c.include_skill_lifecycle !== false}`,
` include_course_detection: ${c.include_course_detection === true}`,
` os_scheduler_cadence: ${c.os_scheduler_cadence === 'interval' ? 'interval' : 'daily'}`,
'---',
'',
'# ms-ai-architect — planlagt deteksjon',
'',
'Skrevet av `/architect:onboard`. Styrer den opt-in, Claude-frie deteksjonen',
'(poll → rapport → discovery → skill-livssyklus). Endre `scheduled_detection`-',
'blokka over, eller kjør `/architect:onboard` på nytt.',
'',
].join('\n');
}
/**
* Load the opt-in schedule config. C2.1: the user-owned path
* (~/.claude/ms-ai-architect/ms-ai-architect.local.md) takes precedence so the
* config survives a plugin reinstall (acceptance K2); the legacy
* <pluginRoot>/ms-ai-architect.local.md remains as a backward-compat fallback
* for setups that already wrote it there. Absent/unreadable => OFF default.
* @param {string} pluginRoot
* @param {string} [home] injectable home dir (defaults to os.homedir())
* @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}}
*/
export function loadScheduleConfig(pluginRoot, home = homedir()) {
const candidates = [resolveConfigPath(home), join(pluginRoot, CONFIG_FILENAME)];
for (const path of candidates) {
if (!existsSync(path)) continue;
try {
return parseScheduleConfig(readFileSync(path, 'utf8'));
} catch {
return { ...DEFAULT_SCHEDULE_CONFIG };
}
}
return { ...DEFAULT_SCHEDULE_CONFIG };
}
/**
* Decide whether the SessionStart hook should background-spawn detection. Pure.
* Opt-in: a disabled config never runs (the default). Enabled + stale (days
* since last poll >= interval) runs; enabled + fresh does not. force overrides.
* @param {{enabled: boolean, interval_days: number}} config
* @param {number} lastPollDaysAgo Infinity when never polled
* @param {{force?: boolean}} [opts]
* @returns {{run: boolean, reason: string}}
*/
export function shouldRunDetection(config, lastPollDaysAgo, opts = {}) {
if (opts.force) return { run: true, reason: 'force' };
if (!config || !config.enabled) return { run: false, reason: 'disabled (opt-in off)' };
const interval = config.interval_days ?? DEFAULT_SCHEDULE_CONFIG.interval_days;
if (lastPollDaysAgo >= interval) {
return { run: true, reason: `stale (${Math.floor(lastPollDaysAgo)}d >= ${interval}d)` };
}
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
* when there is nothing worth surfacing.
* @param {object|null} report parsed skill-lifecycle-report.json
* @param {{threshold?: number}} [opts] K10 overlap threshold (default 7.0)
* @returns {string|null}
*/
export function summarizeSkillLifecycle(report, opts = {}) {
if (!report || typeof report !== 'object') return null;
const threshold = opts.threshold ?? 7.0;
const overlapFail = (report.overlap?.pairs ?? []).filter((p) => (p?.combined ?? 0) >= threshold).length;
const gaps = report.coverage?.summary?.gaps ?? (report.coverage?.gaps ?? []).length;
const thin = report.coverage?.summary?.thin ?? 0;
const bloat = (report.bloat?.summary?.bloatCandidates ?? []).length;
const stale = (report.bloat?.summary?.staleCandidates ?? []).length;
const parts = [];
if (overlapFail > 0) parts.push(`${overlapFail} overlapp-par`);
if (gaps > 0) parts.push(`${gaps} gap`);
if (thin > 0) parts.push(`${thin} tynne`);
if (bloat > 0) parts.push(`${bloat} bloat`);
if (stale > 0) parts.push(`${stale} stale`);
return parts.length ? `Skill-signaler: ${parts.join(', ')}` : null;
}