Det rapporterende gulvet (§4 Port 3, P3b): SessionStart-hooken surfacer en KB-tillit-énlinjer fra cachet verified-staleness-report.json. Et GULV, ikke en gate (§4c — unngå alarm-tretthet på 84 %-signal). summarizeTrustFreshness (detection-schedule.mjs, ren, speiler summarizeSkillQuality): surfacer KUN drift (stale-since-verified) + kontraktbrudd (unverified) — det handlingsbare «bekreft mot kilde»-settet. unmigrated (Spor-1-backlog, 306 i dag) ekskluderes bevisst: ved det volumet ville det fyre hver økt og bli støy; migrerings-censusen hører i CT5 (P3c). Tolererer manglende/malformed cache → null (gitignored, fraværende i fersk klon, krasjer aldri hooken). session-start-context.mjs: leser cachen read-only (samme mønster som skill-quality-blokken), dytter linjen når den finnes. Ende-til-ende verifisert: injisert stale-rapport → «KB-tillit: 3 filer driftet siden verifisering, 1 uverifisert — bekreft mot kilde (aldri auto-fiks)»; ekte korpus (306 unmigrated) → taus (ingen falsk alarm). TDD (Iron Law): 8 nye tester. Suite 612/612. Plugin-validering PASS.
337 lines
16 KiB
JavaScript
337 lines
16 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;
|
|
}
|
|
|
|
/**
|
|
* Build a compact one-liner summarizing course-detection leads (Spor C / C3.6)
|
|
* for the SessionStart hook. Pure; tolerant of partial/missing reports. Mirrors
|
|
* summarizeSkillLifecycle. Surfaces only NEW + UPDATED leads in covered
|
|
* products; `removed` is an informational rapport-signal (spec §4.2), never a
|
|
* surfaced lead. Returns null when there is nothing worth surfacing (0 new + 0
|
|
* updated, a skipped/error report, or a malformed shape).
|
|
* @param {object|null} report parsed course-detection-report.json
|
|
* @returns {string|null}
|
|
*/
|
|
export function summarizeCourses(report) {
|
|
if (!report || typeof report !== 'object') return null;
|
|
if (report.status && report.status !== 'ok') return null;
|
|
const counts = report.counts ?? {};
|
|
const fresh = Number(counts.new) || 0;
|
|
const changed = Number(counts.updated) || 0;
|
|
|
|
const parts = [];
|
|
if (fresh > 0) parts.push(`${fresh} nye`);
|
|
if (changed > 0) parts.push(`${changed} endrede`);
|
|
return parts.length ? `Kurs-signaler: ${parts.join(' / ')} kurs i dekkede produkter` : null;
|
|
}
|
|
|
|
// Default skill-quality target (%). The score cache always carries its own
|
|
// `target` (buildScoreCache); this literal is only a defensive fallback for an
|
|
// older/partial cache. Mirrors TARGET in scripts/kb-eval/lib/skill-score.mjs —
|
|
// kept local so this Claude-free kb-update module never imports the kb-eval lib.
|
|
const SKILL_QUALITY_TARGET_DEFAULT = 90;
|
|
|
|
/**
|
|
* Build a compact one-liner summarizing skill-quality scores (Spor D / Steg C)
|
|
* for the SessionStart hook. Pure; tolerant of partial/missing caches. Mirrors
|
|
* summarizeSkillLifecycle / summarizeCourses.
|
|
*
|
|
* Consumes the CACHED score report (scripts/kb-eval/data/skill-score-report.json,
|
|
* produced by `score-skill.mjs --write`) — it NEVER scores live: buildReport
|
|
* reads ~400 ref-files, far too expensive for a SessionStart hook. That cache is
|
|
* gitignored (derived/regenerable), so it is ABSENT in a fresh clone — this MUST
|
|
* tolerate a missing/malformed cache → null and never crash the hook. In
|
|
* practice the one-liner therefore surfaces only for a maintainer who has run
|
|
* `--write` locally, never for an end-user of a fresh checkout.
|
|
*
|
|
* Surfaces ONLY skills below target. Returns null when every skill meets target,
|
|
* when the cache is missing/non-object, or when it carries no usable skill data.
|
|
* @param {object|null} cache parsed skill-score-report.json
|
|
* ({target, scored:[{name, meetsTarget}], below:[name]})
|
|
* @returns {string|null}
|
|
*/
|
|
export function summarizeSkillQuality(cache) {
|
|
if (!cache || typeof cache !== 'object') return null;
|
|
const target = Number.isFinite(cache.target) ? cache.target : SKILL_QUALITY_TARGET_DEFAULT;
|
|
// Trust the precomputed `below` name-list; fall back to deriving it from
|
|
// `scored` (tolerant of an older/partial shape). Neither present => unusable.
|
|
let below;
|
|
if (Array.isArray(cache.below)) {
|
|
below = cache.below;
|
|
} else if (Array.isArray(cache.scored)) {
|
|
below = cache.scored.filter((s) => s && s.meetsTarget === false).map((s) => s.name);
|
|
} else {
|
|
return null;
|
|
}
|
|
below = below.filter(Boolean);
|
|
if (below.length === 0) return null;
|
|
const noun = below.length === 1 ? 'skill' : 'skills';
|
|
return `Skill-kvalitet: ${below.length} ${noun} < ${target} % (${below.join(', ')})`;
|
|
}
|
|
|
|
/**
|
|
* Build the KB-trust reporting floor one-liner (Spor 3 Port 3, P3b) for the
|
|
* SessionStart hook. Pure; tolerant of partial/missing reports. Mirrors
|
|
* summarizeSkillQuality.
|
|
*
|
|
* Consumes the CACHED verified-staleness-report.json (produced by
|
|
* report-verified-staleness.mjs on the KB-refresh cadence) — it NEVER judges
|
|
* live. That cache is gitignored (derived/regenerable), so it is ABSENT in a
|
|
* fresh clone — this MUST tolerate a missing/malformed report → null and never
|
|
* crash the hook.
|
|
*
|
|
* A REPORTING FLOOR, not a gate (§4c — avoid alarm fatigue on an 84 % signal):
|
|
* surfaces ONLY drift (stale-since-verified) + contract breaches (unverified),
|
|
* the actionable "confirm against source" set. `unmigrated` (the Spor-1 backlog,
|
|
* 306 today) is DELIBERATELY excluded: at that volume it would fire every
|
|
* session and become noise; the migration census belongs to CT5 (P3c).
|
|
*
|
|
* @param {object|null} report parsed verified-staleness-report.json
|
|
* ({counts: {stale, unverified, ...}})
|
|
* @returns {string|null}
|
|
*/
|
|
export function summarizeTrustFreshness(report) {
|
|
if (!report || typeof report !== 'object') return null;
|
|
const counts = report.counts;
|
|
if (!counts || typeof counts !== 'object') return null;
|
|
const stale = Number(counts.stale) || 0;
|
|
const unverified = Number(counts.unverified) || 0;
|
|
if (stale === 0 && unverified === 0) return null;
|
|
|
|
const parts = [];
|
|
if (stale > 0) parts.push(`${stale} ${stale === 1 ? 'fil' : 'filer'} driftet siden verifisering`);
|
|
if (unverified > 0) parts.push(`${unverified} uverifisert`);
|
|
return `KB-tillit: ${parts.join(', ')} — bekreft mot kilde (aldri auto-fiks)`;
|
|
}
|