feat(ms-ai-architect): Sesjon 21 — C1 Tier 1 opt-in session-forankret deteksjon (ToS-forankret) [skip-docs]

Spor C fase C1 Tier 1. Ny verifisert ToS-analyse avdekket falsk «enten/eller»:
Consumer Terms §3.7 begrenser automatisert tilgang til Claude/Anthropic, IKKE
kjøring av lokale node-scripts. Deteksjon kontakter aldri Claude → utenfor
ToS-flaten; apply (eneste Claude-steg) forblir manuelt/in-session/gated.

- lib/detection-schedule.mjs (ren): opt-in config (default AV) + shouldRunDetection
  gate + DETECTION_STEPS allow-liste + summarizeSkillLifecycle. Zero-dep parser.
- run-detection.mjs: Claude-FRITT entrypoint — kjører kun `node` på de
  allow-listede deteksjons-scriptene; kan ikke invokere claude (guard-testet).
- session-start-context.mjs: ubetinget bakgrunns-spawn → opt-in (default AV
  spawner ingenting) + surfacer skill-signaler read-only.
- commands/kb-update.md: opt-in-seksjon + ToS-note; ms-ai-architect.local.md.example.

TDD: ny test-detection-schedule.test.mjs (16). kb-update 139→155.
Suiter uendret: validate 239 · kb-eval 100 · kb-integrity 192/192.
0 skills/-mutasjon. Tier 2 (lokal OS-timer, deteksjon-only) = neste økt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 22:54:18 +02:00
commit 150b20584e
6 changed files with 456 additions and 6 deletions

View file

@ -0,0 +1,139 @@
// 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.
//
// Zero dependencies beyond node:fs/path. parseScheduleConfig is pure.
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// 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,
});
// 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 },
]);
const CONFIG_FILENAME = 'ms-ai-architect.local.md';
/** 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') {
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;
}
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;
}
/**
* Load the opt-in schedule config from <pluginRoot>/ms-ai-architect.local.md
* (gitignored per the plugin-settings pattern). Absent/unreadable => OFF default.
* @param {string} pluginRoot
* @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}}
*/
export function loadScheduleConfig(pluginRoot) {
const path = join(pluginRoot, CONFIG_FILENAME);
if (!existsSync(path)) return { ...DEFAULT_SCHEDULE_CONFIG };
try {
return parseScheduleConfig(readFileSync(path, 'utf8'));
} catch {
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)' };
}
/**
* 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;
}

View file

@ -0,0 +1,53 @@
#!/usr/bin/env node
// run-detection.mjs — Spor C / C1: the Claude-FREE detection entrypoint.
//
// Runs the pure-node detection pipeline (poll → report → discover →
// detect-skill-lifecycle), each a local script that produces a JSON report and
// NEVER contacts Anthropic. This is the structural ToS guarantee: this file
// only ever spawns `node` on the allow-listed DETECTION_STEPS — it can never
// invoke `claude`, and it never touches the apply path (which alone invokes
// Claude and stays manual + in-session + gated).
//
// The SessionStart hook background-spawns this when the opt-in schedule is
// enabled + stale (see detection-schedule.mjs). Standalone use is fine too:
// node scripts/kb-update/run-detection.mjs # run the pipeline
// node scripts/kb-update/run-detection.mjs --dry-run # list steps, run none
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { DETECTION_STEPS, loadScheduleConfig } from './lib/detection-schedule.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const dryRun = process.argv.includes('--dry-run');
// Honor the opt-in content choice: skill-lifecycle detection runs unless the
// config turns it off. (enabled-gating is the hook's job; invoking this script
// directly is itself the intent to run.)
const config = loadScheduleConfig(PLUGIN_ROOT);
const steps = DETECTION_STEPS.filter((s) => !s.skillLifecycle || config.include_skill_lifecycle);
function run(step) {
const fullPath = join(PLUGIN_ROOT, 'scripts', step.dir, step.script);
console.log(`\n--- detection: ${step.name} (${step.dir}/${step.script} ${step.args.join(' ')}) ---`);
try {
// Only ever `node` on a local detection script — never `claude`.
execFileSync('node', [fullPath, ...step.args], { stdio: 'inherit', timeout: 10 * 60 * 1000 });
} catch (err) {
// A failing step is non-fatal for the others — detection is advisory.
console.error(`detection step ${step.name} failed: ${err.message}`);
}
}
if (dryRun) {
console.log('DRY RUN — Claude-free detection pipeline would execute:');
steps.forEach((s, i) => console.log(` ${i + 1}. ${s.dir}/${s.script} ${s.args.join(' ')}`));
console.log('\n(All steps are pure node; none invoke Claude. Apply stays manual + in-session.)');
process.exit(0);
}
console.log('=== Claude-free detection run ===');
for (const step of steps) run(step);
console.log('\n=== detection complete (reports refreshed; 0 writes to skills/) ===');