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:
parent
e47fc9bd59
commit
150b20584e
6 changed files with 456 additions and 6 deletions
|
|
@ -8,7 +8,9 @@ model: opus
|
|||
|
||||
# /architect:kb-update — Manuell KB-oppdatering
|
||||
|
||||
Holder Microsoft AI-kunnskapsbasen i `skills/*/references/` ferskt ved å sammenligne lokale referansefiler mot Microsoft Learn-sitemaps. **All kjøring er manuell** — pluginen schedulerer ingenting, og brukere som ønsker periodisk kjøring sørger for det selv (cron, launchd, GitHub Actions, etc.).
|
||||
Holder Microsoft AI-kunnskapsbasen i `skills/*/references/` ferskt ved å sammenligne lokale referansefiler mot Microsoft Learn-sitemaps.
|
||||
|
||||
**Apply (oppdatering av KB-filer) er alltid manuell og kjøres in-session** — den bruker Claude og forblir under operatør-gate. **Deteksjon** (poll → rapport → discovery → skill-livssyklus) er ren node som aldri kontakter Claude/Anthropic, og kan **valgfritt schceduleres** (se «Scheduled deteksjon» under). Skillet er ToS-forankret: Consumer Terms §3.7 begrenser automatisert tilgang til Anthropics tjenester — ikke kjøring av lokale scripts som ikke rører Claude.
|
||||
|
||||
## Hva kommandoen gjør
|
||||
|
||||
|
|
@ -31,6 +33,27 @@ Holder Microsoft AI-kunnskapsbasen i `skills/*/references/` ferskt ved å sammen
|
|||
| `--dry-run` | Generer rapport, men ikke oppdater filer eller committ |
|
||||
| `--single-commit` | Samle alle filendringer i én commit i stedet for én per fil |
|
||||
|
||||
## Scheduled deteksjon (opt-in, Spor C / C1)
|
||||
|
||||
Deteksjonen kan kjøre automatisk i bakgrunnen ved sesjonsstart — **av som default**. Den er frivillig å sette opp, men virker når aktivert.
|
||||
|
||||
**Slik aktiverer du den:** opprett `ms-ai-architect.local.md` i plugin-roten (gitignored) med:
|
||||
|
||||
```yaml
|
||||
---
|
||||
scheduled_detection:
|
||||
enabled: true # default false — ingenting kjører før dette er true
|
||||
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)
|
||||
---
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## Instruksjoner til assistenten
|
||||
|
||||
### 1. Pre-flight
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@
|
|||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
loadScheduleConfig,
|
||||
shouldRunDetection,
|
||||
summarizeSkillLifecycle,
|
||||
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
|
||||
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || join(process.cwd());
|
||||
const cwd = process.cwd();
|
||||
|
|
@ -62,12 +67,17 @@ if (existsSync(changeReportPath)) {
|
|||
}
|
||||
}
|
||||
|
||||
// Trigger background poll if >7 days since last check
|
||||
if (lastPollDaysAgo > 7) {
|
||||
const updateScript = join(pluginRoot, 'scripts', 'kb-update', 'run-weekly-update.mjs');
|
||||
if (existsSync(updateScript)) {
|
||||
// Opt-in background detection (Spor C / C1). Default OFF: nothing spawns unless
|
||||
// the user enabled scheduled_detection in ms-ai-architect.local.md. When on +
|
||||
// stale, run the Claude-FREE detection entrypoint (poll → report → discover →
|
||||
// skill-lifecycle) in the background. This never invokes Claude — apply stays
|
||||
// manual + in-session. See detection-schedule.mjs for the ToS rationale.
|
||||
const scheduleConfig = loadScheduleConfig(pluginRoot);
|
||||
if (shouldRunDetection(scheduleConfig, lastPollDaysAgo).run) {
|
||||
const detectScript = join(pluginRoot, 'scripts', 'kb-update', 'run-detection.mjs');
|
||||
if (existsSync(detectScript)) {
|
||||
try {
|
||||
spawn('node', [updateScript], { detached: true, stdio: 'ignore' }).unref();
|
||||
spawn('node', [detectScript], { detached: true, stdio: 'ignore' }).unref();
|
||||
} catch {
|
||||
// Non-critical — silent fail
|
||||
}
|
||||
|
|
@ -140,6 +150,17 @@ if (staleEntries.length > 0) {
|
|||
parts.push('KB: poll overdue');
|
||||
}
|
||||
|
||||
// Skill-lifecycle signals (Spor B/C) — read-only one-liner; never spawns.
|
||||
const skillReportPath = join(pluginRoot, 'scripts', 'kb-eval', 'data', 'skill-lifecycle-report.json');
|
||||
if (existsSync(skillReportPath)) {
|
||||
try {
|
||||
const skillSummary = summarizeSkillLifecycle(JSON.parse(readFileSync(skillReportPath, 'utf8')));
|
||||
if (skillSummary) parts.push(skillSummary);
|
||||
} catch {
|
||||
// Ignore — advisory only
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestDeadline) {
|
||||
parts.push(`EU AI Act: ${nearestDeadline.daysLeft} dager til ${nearestDeadline.label}. Kjør /architect:classify`);
|
||||
}
|
||||
|
|
|
|||
20
ms-ai-architect.local.md.example
Normal file
20
ms-ai-architect.local.md.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
# Opt-in scheduled detection (Spor C / C1). Copy this file to
|
||||
# `ms-ai-architect.local.md` (gitignored) and flip enabled:true to turn it on.
|
||||
#
|
||||
# Detection (poll → report → discovery → skill-lifecycle) is pure node and NEVER
|
||||
# contacts Claude/Anthropic — it only writes JSON reports under data/. Apply
|
||||
# (the one Claude step) always stays manual + in-session + gated. The schedule
|
||||
# is therefore outside the Anthropic ToS surface (Consumer Terms §3.7 restricts
|
||||
# automated access to "our Services", not local non-Claude scripts).
|
||||
scheduled_detection:
|
||||
enabled: false # default OFF — nothing runs until this is true
|
||||
interval_days: 7 # re-run detection when >= N days since last poll
|
||||
include_skill_lifecycle: true # include skill-lifecycle detection (overlap/gap/bloat)
|
||||
---
|
||||
|
||||
# ms-ai-architect — local settings
|
||||
|
||||
This file holds per-checkout plugin settings. The real file (`ms-ai-architect.local.md`)
|
||||
is gitignored; this `.example` is the tracked template. See
|
||||
`commands/kb-update.md` → "Scheduled deteksjon (opt-in)" for details.
|
||||
139
scripts/kb-update/lib/detection-schedule.mjs
Normal file
139
scripts/kb-update/lib/detection-schedule.mjs
Normal 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;
|
||||
}
|
||||
53
scripts/kb-update/run-detection.mjs
Normal file
53
scripts/kb-update/run-detection.mjs
Normal 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/) ===');
|
||||
194
tests/kb-update/test-detection-schedule.test.mjs
Normal file
194
tests/kb-update/test-detection-schedule.test.mjs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// tests/kb-update/test-detection-schedule.test.mjs
|
||||
// Spor C / C1 (Sesjon 21): opt-in session-anchored detection.
|
||||
//
|
||||
// The detection layer (poll/report/discover/skill-lifecycle) is PURE node and
|
||||
// never contacts Anthropic, so it is OUTSIDE the ToS surface (Consumer Terms
|
||||
// §3.7 targets automated access to "our Services"). This suite proves:
|
||||
// (A) the opt-in config defaults OFF and parses safely,
|
||||
// (B) shouldRunDetection gates the hook's background spawn (disabled => never),
|
||||
// (C) the skill-lifecycle one-liner surfaced at session start,
|
||||
// (D) the STRUCTURAL ToS guarantee: run-detection.mjs is Claude-free — it
|
||||
// only ever spawns `node` on the known detection scripts, never `claude`.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
DETECTION_STEPS,
|
||||
parseScheduleConfig,
|
||||
loadScheduleConfig,
|
||||
shouldRunDetection,
|
||||
summarizeSkillLifecycle,
|
||||
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
|
||||
// ===========================================================================
|
||||
// (A) parseScheduleConfig / loadScheduleConfig — opt-in, default OFF
|
||||
// ===========================================================================
|
||||
|
||||
test('DEFAULT_SCHEDULE_CONFIG — opt-in: disabled by default', () => {
|
||||
assert.equal(DEFAULT_SCHEDULE_CONFIG.enabled, false, 'detection is OFF until the user opts in');
|
||||
assert.equal(DEFAULT_SCHEDULE_CONFIG.interval_days, 7);
|
||||
assert.equal(DEFAULT_SCHEDULE_CONFIG.include_skill_lifecycle, true);
|
||||
});
|
||||
|
||||
test('parseScheduleConfig — empty/absent text returns the OFF default', () => {
|
||||
assert.deepEqual(parseScheduleConfig(''), DEFAULT_SCHEDULE_CONFIG);
|
||||
assert.deepEqual(parseScheduleConfig('# just some markdown, no frontmatter'), DEFAULT_SCHEDULE_CONFIG);
|
||||
});
|
||||
|
||||
test('parseScheduleConfig — reads an enabled block with overrides', () => {
|
||||
const text = [
|
||||
'---',
|
||||
'scheduled_detection:',
|
||||
' enabled: true',
|
||||
' interval_days: 3',
|
||||
' include_skill_lifecycle: false',
|
||||
'---',
|
||||
'',
|
||||
'# notes',
|
||||
].join('\n');
|
||||
const cfg = parseScheduleConfig(text);
|
||||
assert.equal(cfg.enabled, true);
|
||||
assert.equal(cfg.interval_days, 3);
|
||||
assert.equal(cfg.include_skill_lifecycle, false);
|
||||
});
|
||||
|
||||
test('parseScheduleConfig — partial block keeps defaults for missing keys', () => {
|
||||
const cfg = parseScheduleConfig('scheduled_detection:\n enabled: true\n');
|
||||
assert.equal(cfg.enabled, true);
|
||||
assert.equal(cfg.interval_days, 7, 'unspecified key falls back to default');
|
||||
assert.equal(cfg.include_skill_lifecycle, true);
|
||||
});
|
||||
|
||||
test('parseScheduleConfig — malformed input never throws, falls back to OFF', () => {
|
||||
assert.equal(parseScheduleConfig('scheduled_detection: : : nonsense').enabled, false);
|
||||
assert.equal(parseScheduleConfig('scheduled_detection:\n enabled: maybe\n').enabled, false,
|
||||
'a non-boolean enabled is treated as OFF');
|
||||
});
|
||||
|
||||
test('loadScheduleConfig — missing config file => OFF default', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
||||
try {
|
||||
assert.deepEqual(loadScheduleConfig(root), DEFAULT_SCHEDULE_CONFIG);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('loadScheduleConfig — reads ms-ai-architect.local.md when present', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(root, 'ms-ai-architect.local.md'),
|
||||
'---\nscheduled_detection:\n enabled: true\n interval_days: 14\n---\n',
|
||||
);
|
||||
const cfg = loadScheduleConfig(root);
|
||||
assert.equal(cfg.enabled, true);
|
||||
assert.equal(cfg.interval_days, 14);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// (B) shouldRunDetection — the hook's background-spawn gate
|
||||
// ===========================================================================
|
||||
|
||||
test('shouldRunDetection — DISABLED (default) never runs, even when stale', () => {
|
||||
const r = shouldRunDetection(DEFAULT_SCHEDULE_CONFIG, 999);
|
||||
assert.equal(r.run, false);
|
||||
assert.match(r.reason, /opt-in|disabled/i);
|
||||
});
|
||||
|
||||
test('shouldRunDetection — enabled + stale (days >= interval) runs', () => {
|
||||
const cfg = { enabled: true, interval_days: 7, include_skill_lifecycle: true };
|
||||
assert.equal(shouldRunDetection(cfg, 7).run, true, 'boundary: exactly interval => run');
|
||||
assert.equal(shouldRunDetection(cfg, 30).run, true);
|
||||
});
|
||||
|
||||
test('shouldRunDetection — enabled + fresh (days < interval) does NOT run', () => {
|
||||
const cfg = { enabled: true, interval_days: 7, include_skill_lifecycle: true };
|
||||
const r = shouldRunDetection(cfg, 3);
|
||||
assert.equal(r.run, false);
|
||||
assert.match(r.reason, /fresh|fersk/i);
|
||||
});
|
||||
|
||||
test('shouldRunDetection — force overrides a disabled config', () => {
|
||||
assert.equal(shouldRunDetection(DEFAULT_SCHEDULE_CONFIG, 0, { force: true }).run, true);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// (C) summarizeSkillLifecycle — one-liner surfaced at session start
|
||||
// ===========================================================================
|
||||
|
||||
const REPORT_WITH_SIGNALS = {
|
||||
overlap: { pairs: [{ pair: ['ms-ai-engineering', 'ms-ai-infrastructure'], combined: 7.4167 }, { pair: ['a', 'b'], combined: 2.1 }] },
|
||||
coverage: { summary: { gaps: 1, thin: 2 }, gaps: [{ category: 'x' }] },
|
||||
bloat: { summary: { bloatCandidates: [], staleCandidates: ['ms-ai-governance'] } },
|
||||
};
|
||||
|
||||
test('summarizeSkillLifecycle — surfaces overlap-over-threshold, gaps, thin, stale', () => {
|
||||
const s = summarizeSkillLifecycle(REPORT_WITH_SIGNALS, { threshold: 7.0 });
|
||||
assert.match(s, /Skill-signaler:/);
|
||||
assert.match(s, /1 overlapp-par/, 'only the combined>=7 pair counts');
|
||||
assert.match(s, /1 (gap|dekningshull)/);
|
||||
assert.match(s, /2 tynne/);
|
||||
assert.match(s, /1 stale/);
|
||||
});
|
||||
|
||||
test('summarizeSkillLifecycle — no signals returns null (nothing to surface)', () => {
|
||||
const empty = {
|
||||
overlap: { pairs: [{ pair: ['a', 'b'], combined: 1.0 }] },
|
||||
coverage: { summary: { gaps: 0, thin: 0 } },
|
||||
bloat: { summary: { bloatCandidates: [], staleCandidates: [] } },
|
||||
};
|
||||
assert.equal(summarizeSkillLifecycle(empty, { threshold: 7.0 }), null);
|
||||
});
|
||||
|
||||
test('summarizeSkillLifecycle — tolerates a missing/partial report shape', () => {
|
||||
assert.equal(summarizeSkillLifecycle({}, { threshold: 7.0 }), null);
|
||||
assert.equal(summarizeSkillLifecycle(null, { threshold: 7.0 }), null);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// (D) STRUCTURAL ToS GUARANTEE — detection is Claude-free
|
||||
// ===========================================================================
|
||||
|
||||
const KNOWN_DETECTION_SCRIPTS = new Set([
|
||||
'poll-sitemaps.mjs',
|
||||
'report-changes.mjs',
|
||||
'discover-new-urls.mjs',
|
||||
'detect-skill-lifecycle.mjs',
|
||||
'build-registry.mjs',
|
||||
]);
|
||||
|
||||
test('DETECTION_STEPS — only the known pure-node detection scripts, never claude', () => {
|
||||
assert.ok(DETECTION_STEPS.length >= 4);
|
||||
for (const step of DETECTION_STEPS) {
|
||||
assert.ok(step.script.endsWith('.mjs'), `${step.script} must be a node script`);
|
||||
assert.ok(KNOWN_DETECTION_SCRIPTS.has(step.script), `${step.script} must be an allow-listed detection script`);
|
||||
assert.ok(['kb-update', 'kb-eval'].includes(step.dir), `${step.dir} unexpected`);
|
||||
}
|
||||
// skill-lifecycle detection must be present and flagged so config can exclude it
|
||||
assert.ok(DETECTION_STEPS.some((s) => s.script === 'detect-skill-lifecycle.mjs' && s.skillLifecycle === true));
|
||||
});
|
||||
|
||||
test('run-detection.mjs source — spawns node, NEVER claude (the ToS boundary made structural)', () => {
|
||||
const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs'), 'utf8');
|
||||
// It must run node on local scripts...
|
||||
assert.match(src, /execFileSync\(\s*'node'|spawn\(\s*'node'/, 'detection runs `node`');
|
||||
// ...and must NEVER invoke the claude binary. Strip comments first so the
|
||||
// Claude-free CONTRACT can be documented in prose without tripping the guard:
|
||||
// the guarantee is "no claude INVOCATION", not "the word never appears".
|
||||
const code = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
assert.doesNotMatch(code, /(execFileSync|spawn|exec|execSync)\(\s*['"`]claude['"`]/i, 'never spawns claude');
|
||||
assert.doesNotMatch(code, /['"`]claude['"`]/i, 'no claude binary referenced in code');
|
||||
assert.doesNotMatch(code, /anthropic/i, 'code must not call Anthropic');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue