§4 Port 3 verifiseringskrav: «CT5 (sourcedness) ERSTATTER K8s rolle». K8 var LLM-judge (sample 5 ref-filer, ikke-deterministisk); CT5 er samme signal gjort deterministisk + hel-korpus fra Port 1-kontrakten. Per ref-kb-direction-note §45 MÅ de ikke leve parallelt (dobbelttelling + divergerende dashbord) — så K8 er fjernet, ikke duplisert. eval.mjs: checkCT5(refFiles) — blant filer som deklarerer type:reference, andel med **Source:** (template/methodology/regulatory ekskludert fra nevneren). Wiret som deterministic.CT5_sourcedness. parseTypeHeader/parseSourceHeader importert fra kb-update/lib/kb-headers (tillatt retning). skill-score.mjs: K8-kriteriet (source:judge) → CT5 (source:det, vekt 1). available:false når referenceFiles=0 → droppet fra vektet snitt → tanker IKKE scoren på umigrert korpus (alarm-tretthet unngått, direction-note §45). judge-prompt.md: K8 fjernet fra rubrikk/instruksjon/JSON (judgen gjør nå K1/K4/K7/K9). Ekte kjøring: alle 5 skills CT5 dormant (referenceFiles:0, umigrert), scorer uendret (91-96, 0 under mål). CT5 aktiveres deterministisk når Spor 1 migrerer. TDD (Iron Law): 5 checkCT5 + 3 CT5-integrasjon; død K8-fixture-data ryddet. Suite 620/620. Plugin-validering 239/0/0.
447 lines
18 KiB
JavaScript
447 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
// eval.mjs — Skill quality baseline (rubrikk K1–K9) for ms-ai-architect.
|
||
//
|
||
// READ-ONLY by default. Computes the deterministic criteria (K2/K3/K5/K6 +
|
||
// reference-count consistency) for every skill and extracts the inputs an LLM
|
||
// judge needs for the semantic criteria (K1/K4/K7/K9). The baseline file is
|
||
// written ONLY with --write (operator gate), per the redesign spec.
|
||
//
|
||
// Usage:
|
||
// node scripts/kb-eval/eval.mjs # human-readable summary
|
||
// node scripts/kb-eval/eval.mjs --json # machine output (pure JSON to stdout)
|
||
// node scripts/kb-eval/eval.mjs --write # also persist data/eval-baseline.json
|
||
//
|
||
// Zero dependencies. Reuses kb-update/lib/atomic-write.mjs for the gated write.
|
||
|
||
import { readdirSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||
import { join, dirname, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
|
||
import { parseTypeHeader, parseSourceHeader } from '../kb-update/lib/kb-headers.mjs';
|
||
import {
|
||
computeOverlapFromInputs,
|
||
perSkillSiblingOverlap,
|
||
K10_OVERLAP_THRESHOLD,
|
||
} from './lib/sibling-overlap.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
|
||
const OUT_DIR = join(__dirname, 'data');
|
||
const OUT_FILE = join(OUT_DIR, 'eval-baseline.json');
|
||
const PROMPTS_FILE = join(OUT_DIR, 'k1-trigger-prompts.json');
|
||
|
||
// Thresholds — see spec "Rubrikk K1–K10".
|
||
const K3_MAX_BODY_LINES = 500;
|
||
const K5_MIN_NAMED_RATIO = 0.2;
|
||
const CT5_SOURCED_RATIO_TARGET = 0.8; // pass at/above this share of sourced reference files (mirrors old K8)
|
||
|
||
/** Recursively list .md files under dir. */
|
||
function listMarkdown(dir) {
|
||
const out = [];
|
||
if (!existsSync(dir)) return out;
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const p = join(dir, e.name);
|
||
if (e.isDirectory()) out.push(...listMarkdown(p));
|
||
else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Count .md files per immediate subfolder of references/. */
|
||
function refCountsPerFolder(refDir) {
|
||
const counts = {};
|
||
if (!existsSync(refDir)) return counts;
|
||
for (const e of readdirSync(refDir, { withFileTypes: true })) {
|
||
if (e.isDirectory()) counts[e.name] = listMarkdown(join(refDir, e.name)).length;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
/** Split YAML frontmatter from body. */
|
||
export function splitFrontmatter(content) {
|
||
if (!content.startsWith('---')) return { frontmatter: '', body: content };
|
||
const close = content.indexOf('\n---', 3);
|
||
if (close === -1) return { frontmatter: '', body: content };
|
||
const bodyStart = content.indexOf('\n', close + 1);
|
||
return {
|
||
frontmatter: content.slice(0, bodyStart === -1 ? content.length : bodyStart + 1),
|
||
body: bodyStart === -1 ? '' : content.slice(bodyStart + 1),
|
||
};
|
||
}
|
||
|
||
/** Extract the `name:` scalar from frontmatter (single-line value). */
|
||
export function extractName(frontmatter) {
|
||
for (const line of frontmatter.split('\n')) {
|
||
const m = /^name:\s*(.+?)\s*$/.exec(line);
|
||
if (m) return m[1].replace(/^["']|["']$/g, '').trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** Extract the (possibly folded) description value from frontmatter. */
|
||
export function extractDescription(frontmatter) {
|
||
const lines = frontmatter.split('\n');
|
||
const out = [];
|
||
let collecting = false;
|
||
for (const line of lines) {
|
||
if (/^description:/.test(line)) {
|
||
collecting = true;
|
||
out.push(line.replace(/^description:\s*>?-?\s*/, ''));
|
||
continue;
|
||
}
|
||
if (collecting) {
|
||
if (/^[A-Za-z0-9_-]+:\s/.test(line) || line.trim() === '---' || line.trim() === '') {
|
||
if (line.trim() === '') continue; // folded blocks keep going across blanks
|
||
break;
|
||
}
|
||
out.push(line.trim());
|
||
}
|
||
}
|
||
return out.join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
/** K2 — description format: third-person/use-when OR >=3 quoted user phrases. */
|
||
export function checkK2(description) {
|
||
const quoted = (description.match(/"[^"]+"/g) || []).length;
|
||
const useWhenForm = /should be used when|use this skill|triggers on/i.test(description);
|
||
const imperativeStart = /^(use|bruk|run|kjør)\b/i.test(description);
|
||
const pass = useWhenForm || quoted >= 3;
|
||
return { quotedPhrases: quoted, useWhenForm, imperativeStart, pass };
|
||
}
|
||
|
||
/** K3 — SKILL.md body length. */
|
||
export function checkK3(body) {
|
||
const lines = body.replace(/\n+$/, '').split('\n').length;
|
||
return { bodyLines: lines, pass: lines <= K3_MAX_BODY_LINES };
|
||
}
|
||
|
||
/** K5/K6 — progressive disclosure: named .md links vs folder-only references. */
|
||
export function checkK5K6(body, totalRefFiles) {
|
||
const named = [...new Set((body.match(/references\/[A-Za-z0-9_\-/]+\.md/g) || []))];
|
||
// folder references like `references/foo/` not immediately followed by a filename
|
||
const folderRefs = [...new Set((body.match(/references\/[A-Za-z0-9_-]+\/(?![A-Za-z0-9_-]+\.md)/g) || []))];
|
||
const namedRatio = totalRefFiles > 0 ? named.length / totalRefFiles : 0;
|
||
return {
|
||
K5: {
|
||
namedFileLinks: named.length,
|
||
folderRefs: folderRefs.length,
|
||
totalRefFiles,
|
||
namedRatio: Number(namedRatio.toFixed(4)),
|
||
pass: namedRatio >= K5_MIN_NAMED_RATIO,
|
||
sampleNamed: named.slice(0, 5),
|
||
},
|
||
K6: {
|
||
// routing table = at least one named start file the model can Read directly
|
||
namedStartFiles: named.length,
|
||
pass: named.length >= 1,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Reference-count consistency: cited per-folder counts vs actual on disk. */
|
||
export function checkRefConsistency(body, actualPerFolder) {
|
||
const mismatches = [];
|
||
const seen = {};
|
||
// prose form: references/<folder>/ (N files) and `references/<folder>/` (N files)
|
||
const proseRe = /references\/([a-z0-9-]+)\/`?\s*\((\d+)\s*files?\)/gi;
|
||
// table form: `references/<folder>/` | N
|
||
const tableRe = /`references\/([a-z0-9-]+)\/`\s*\|\s*(\d+)/gi;
|
||
for (const re of [proseRe, tableRe]) {
|
||
let m;
|
||
while ((m = re.exec(body)) !== null) {
|
||
const folder = m[1];
|
||
const cited = Number(m[2]);
|
||
seen[folder] = seen[folder] || new Set();
|
||
seen[folder].add(cited);
|
||
const actual = actualPerFolder[folder];
|
||
if (actual !== undefined && cited !== actual) {
|
||
mismatches.push({ folder, cited, actual });
|
||
}
|
||
}
|
||
}
|
||
// also flag folders cited with two different numbers
|
||
for (const [folder, set] of Object.entries(seen)) {
|
||
if (set.size > 1) mismatches.push({ folder, citedMultiple: [...set], actual: actualPerFolder[folder] ?? null });
|
||
}
|
||
return { consistent: mismatches.length === 0, mismatches };
|
||
}
|
||
|
||
/** Light deterministic pre-scan for K9 (time-sensitive tokens in body). */
|
||
function scanK9Hints(body) {
|
||
const tokens = body.match(/\b(GA|preview|deprecat\w*|retir\w*|20\d\d|v\d+(?:\.\d+)?)\b/gi) || [];
|
||
return { timeSensitiveTokenHits: tokens.length, sample: [...new Set(tokens)].slice(0, 12) };
|
||
}
|
||
|
||
// --- N1-N5 — deterministic checks from Anthropic "Skill authoring best
|
||
// practices" that the K1-K10 rubric did not yet cover. See
|
||
// docs/skill-quality-scoring-plan.md §1 for exact rules + sources.
|
||
|
||
const N1_MAX_NAME_LEN = 64; // best-practices §Skill structure
|
||
const N1_RESERVED = ['claude', 'anthropic'];
|
||
const N2_MAX_DESC_LEN = 1024; // best-practices §Writing effective descriptions
|
||
const N4_LARGE_FILE_LINES = 100; // best-practices §Structure longer reference files
|
||
|
||
/** N1 — name validity: ≤64 chars, [a-z0-9-], no reserved words. Gerund is
|
||
* Anthropic-recommended but NOT required, so it is reported, never gated. */
|
||
export function checkN1(name) {
|
||
const n = (name || '').trim();
|
||
const withinLength = n.length > 0 && n.length <= N1_MAX_NAME_LEN;
|
||
const validChars = /^[a-z0-9-]+$/.test(n);
|
||
const lower = n.toLowerCase();
|
||
const noReserved = !N1_RESERVED.some((w) => lower.includes(w));
|
||
const gerund = /[a-z]ing(?:-|$)/.test(lower); // informational only
|
||
return { name: n, length: n.length, withinLength, validChars, noReserved, gerund, pass: withinLength && validChars && noReserved };
|
||
}
|
||
|
||
/** N2 — description present and within the 1024-char hard limit. */
|
||
export function checkN2(description) {
|
||
const d = description || '';
|
||
const nonEmpty = d.trim().length > 0;
|
||
const withinLimit = d.length <= N2_MAX_DESC_LEN;
|
||
return { length: d.length, nonEmpty, withinLimit, pass: nonEmpty && withinLimit };
|
||
}
|
||
|
||
/** Markdown links to a local .md file (nesting candidates). Skips external
|
||
* URLs and pure in-page anchors (#section). */
|
||
function localMdLinks(content) {
|
||
const out = [];
|
||
const re = /\]\(([^)\s]+?\.md)(?:#[^)]*)?\)/g;
|
||
let m;
|
||
while ((m = re.exec(content)) !== null) {
|
||
const target = m[1];
|
||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) continue; // external URL
|
||
out.push(target);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** N3 — references stay one level deep: a reference file must not link to
|
||
* another reference (.md) file. Input: [{ path, content }]. */
|
||
export function checkN3(refFiles) {
|
||
const nested = [];
|
||
for (const f of refFiles) {
|
||
const links = localMdLinks(f.content);
|
||
if (links.length > 0) nested.push({ file: f.path, links: [...new Set(links)].slice(0, 8) });
|
||
}
|
||
return { checked: refFiles.length, nested, pass: nested.length === 0 };
|
||
}
|
||
|
||
/** Does a reference file carry a table of contents? Either an explicit TOC
|
||
* heading or a cluster (≥3) of in-page anchor links near the top. */
|
||
function hasToc(content) {
|
||
if (/^#{1,3}\s*(table of contents|contents|innhold(?:sfortegnelse)?|toc)\b/im.test(content)) return true;
|
||
const head = content.split('\n').slice(0, 40).join('\n');
|
||
return (head.match(/\]\(#/g) || []).length >= 3;
|
||
}
|
||
|
||
/** N4 — reference files longer than 100 lines should have a table of contents.
|
||
* Sub-score = fraction of large files that have one (vacuous pass if none). */
|
||
export function checkN4(refFiles) {
|
||
const large = refFiles.filter((f) => f.content.replace(/\n+$/, '').split('\n').length > N4_LARGE_FILE_LINES);
|
||
const withToc = large.filter((f) => hasToc(f.content));
|
||
const ratio = large.length > 0 ? Number((withToc.length / large.length).toFixed(4)) : 1;
|
||
return {
|
||
largeFiles: large.length,
|
||
withToc: withToc.length,
|
||
ratio,
|
||
missing: large.filter((f) => !hasToc(f.content)).map((f) => f.path).slice(0, 12),
|
||
pass: large.length === 0 || withToc.length === large.length,
|
||
};
|
||
}
|
||
|
||
/** N5 — forward-slash paths only (no Windows-style backslash paths) in body. */
|
||
export function checkN5(body) {
|
||
const patterns = [
|
||
/[A-Za-z]:\\/g, // drive-letter path: C:\
|
||
/[\w-]+\\[\w-]+\.md/g, // backslash path ending in a .md file
|
||
/\\(references|skills|scripts|docs|assets)\b/gi, // backslash before a known dir
|
||
];
|
||
const hits = new Set();
|
||
for (const re of patterns) for (const m of body.match(re) || []) hits.add(m);
|
||
const windowsPaths = [...hits].slice(0, 12);
|
||
return { windowsPaths, pass: windowsPaths.length === 0 };
|
||
}
|
||
|
||
/**
|
||
* CT5 — sourcedness (deterministic, whole-corpus). REPLACES the LLM-sampled K8
|
||
* (same signal; per ref-kb-direction-note §45 the two must not run in parallel
|
||
* or they double-count and two dashboards diverge). Scope is the Port 1
|
||
* contract: only files declaring `type: reference` are in scope; CT5 is the
|
||
* fraction of those that cite a **Source:**. template/methodology/regulatory are
|
||
* exempt (excluded from the denominator). On an unmigrated corpus no file
|
||
* declares a type, so referenceFiles is 0 and the score layer drops CT5
|
||
* (available:false) — it never tanks the score before migration adopts the
|
||
* contract.
|
||
* @param {Array<{path: string, content: string}>} refFiles
|
||
* @returns {{referenceFiles: number, sourced: number, ratio: number, pass: boolean, unsourced: string[]}}
|
||
*/
|
||
export function checkCT5(refFiles) {
|
||
let referenceFiles = 0;
|
||
let sourced = 0;
|
||
const unsourced = [];
|
||
for (const f of refFiles) {
|
||
if (parseTypeHeader(f.content) !== 'reference') continue; // out of scope
|
||
referenceFiles++;
|
||
if (parseSourceHeader(f.content)) sourced++;
|
||
else unsourced.push(f.path);
|
||
}
|
||
const ratio = referenceFiles ? sourced / referenceFiles : 0;
|
||
const pass = referenceFiles === 0 ? true : ratio >= CT5_SOURCED_RATIO_TARGET;
|
||
return { referenceFiles, sourced, ratio, pass, unsourced: unsourced.slice(0, 12) };
|
||
}
|
||
|
||
export function evalSkill(skillName) {
|
||
const skillDir = join(SKILLS_DIR, skillName);
|
||
const skillMd = join(skillDir, 'SKILL.md');
|
||
if (!existsSync(skillMd)) return null;
|
||
const content = readFileSync(skillMd, 'utf8');
|
||
const { frontmatter, body } = splitFrontmatter(content);
|
||
const name = extractName(frontmatter);
|
||
const description = extractDescription(frontmatter);
|
||
const refDir = join(skillDir, 'references');
|
||
const refFiles = listMarkdown(refDir);
|
||
const refFileContents = refFiles.map((p) => ({ path: relative(PLUGIN_ROOT, p), content: readFileSync(p, 'utf8') }));
|
||
const perFolder = refCountsPerFolder(refDir);
|
||
|
||
const K2 = checkK2(description);
|
||
const K3 = checkK3(body);
|
||
const { K5, K6 } = checkK5K6(body, refFiles.length);
|
||
const refConsistency = checkRefConsistency(body, perFolder);
|
||
const K9hints = scanK9Hints(body);
|
||
const N1 = checkN1(name);
|
||
const N2 = checkN2(description);
|
||
const N3 = checkN3(refFileContents);
|
||
const N4 = checkN4(refFileContents);
|
||
const N5 = checkN5(body);
|
||
const CT5 = checkCT5(refFileContents);
|
||
|
||
return {
|
||
name: skillName,
|
||
skillMd: relative(PLUGIN_ROOT, skillMd),
|
||
refFilesActual: refFiles.length,
|
||
refCountsPerFolder: perFolder,
|
||
deterministic: {
|
||
K2_descriptionFormat: K2,
|
||
K3_bodyLength: K3,
|
||
K5_progressiveDisclosure: K5,
|
||
K6_routingTable: K6,
|
||
refCountConsistency: refConsistency,
|
||
K9_timeSensitiveHints: K9hints,
|
||
N1_nameValidity: N1,
|
||
N2_descriptionLength: N2,
|
||
N3_refNestingDepth: N3,
|
||
N4_refToc: N4,
|
||
N5_forwardSlashPaths: N5,
|
||
CT5_sourcedness: CT5,
|
||
},
|
||
// Pointers for the LLM-judge pass (K1/K4/K7/K9). The judge reads the files
|
||
// directly; we only carry the description + sampled ref files here. (K8
|
||
// sourcedness is now the deterministic CT5 above — no longer LLM-sampled.)
|
||
judgeInputs: {
|
||
description,
|
||
bodyLines: K3.bodyLines,
|
||
refFileSample: refFiles.slice(0, 8).map((f) => relative(PLUGIN_ROOT, f)),
|
||
},
|
||
// Filled in by the operator-gated LLM-judge merge step. Null = not yet judged.
|
||
judge: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* K10 — sibling-scope-non-overlap. Deterministic, computed ACROSS all skills
|
||
* (unlike K2/K3/K5/K6 which are per-skill), so it is attached after the
|
||
* per-skill pass. Reuses the B1 overlap core: combined = boundaryTension +
|
||
* df-weighted lexical overlap; each skill's K10 verdict is its worst sibling
|
||
* pair (perSkillSiblingOverlap). Mutates + returns the skills array.
|
||
*/
|
||
export function attachSiblingOverlap(skills, promptSet, { threshold = K10_OVERLAP_THRESHOLD } = {}) {
|
||
const descriptionsBySkill = {};
|
||
for (const s of skills) descriptionsBySkill[s.name] = s.judgeInputs.description;
|
||
const overlap = computeOverlapFromInputs(descriptionsBySkill, promptSet);
|
||
const k10 = perSkillSiblingOverlap(overlap.pairs, { threshold });
|
||
for (const s of skills) {
|
||
s.deterministic.K10_siblingScopeOverlap =
|
||
k10[s.name] || { maxCombined: 0, worstSibling: null, pass: true, threshold };
|
||
}
|
||
return skills;
|
||
}
|
||
|
||
/**
|
||
* Assemble the full corpus report: per-skill deterministic eval (incl. N1-N5) +
|
||
* cross-skill K10 sibling-overlap + merged operator-gated judge cache. This is
|
||
* the object the score-skill CLI consumes via scoreReport(). Reads disk.
|
||
*/
|
||
export function buildReport() {
|
||
const skillNames = readdirSync(SKILLS_DIR, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory() && existsSync(join(SKILLS_DIR, e.name, 'SKILL.md')))
|
||
.map((e) => e.name)
|
||
.sort();
|
||
|
||
const skills = skillNames.map(evalSkill).filter(Boolean);
|
||
|
||
// K10 — sibling-scope-non-overlap (deterministic, cross-skill). Attached after
|
||
// the per-skill pass since it needs every description + the curated prompt set.
|
||
if (existsSync(PROMPTS_FILE)) {
|
||
const promptSet = JSON.parse(readFileSync(PROMPTS_FILE, 'utf8'));
|
||
attachSiblingOverlap(skills, promptSet);
|
||
}
|
||
|
||
// Merge operator-gated LLM-judge results (K1/K4/K7/K9) if present.
|
||
const judgeFile = join(OUT_DIR, 'judge-results.json');
|
||
if (existsSync(judgeFile)) {
|
||
const jr = JSON.parse(readFileSync(judgeFile, 'utf8'));
|
||
for (const s of skills) if (jr[s.name]) s.judge = jr[s.name];
|
||
}
|
||
|
||
return {
|
||
rubric: 'K1-K10',
|
||
note: 'Deterministic: K2,K3,K5,K6,refCountConsistency,K10(siblingScopeOverlap),N1-N5,CT5(sourcedness). LLM-judge (operator-gated): K1,K4,K7,K9.',
|
||
skills,
|
||
};
|
||
}
|
||
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
const jsonOut = args.includes('--json');
|
||
const doWrite = args.includes('--write');
|
||
|
||
const report = buildReport();
|
||
const skills = report.skills;
|
||
|
||
if (doWrite) {
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
atomicWriteJson(OUT_FILE, report);
|
||
}
|
||
|
||
if (jsonOut) {
|
||
process.stdout.write(JSON.stringify(report) + '\n');
|
||
return;
|
||
}
|
||
|
||
// Human-readable summary
|
||
console.log(`\nKB skill eval — ${skills.length} skills (deterministic pass)\n`);
|
||
for (const s of skills) {
|
||
const d = s.deterministic;
|
||
const flag = (b) => (b ? 'PASS' : 'FAIL');
|
||
console.log(`■ ${s.name} (${s.refFilesActual} ref-filer)`);
|
||
console.log(` K2 description : ${flag(d.K2_descriptionFormat.pass)} (quoted=${d.K2_descriptionFormat.quotedPhrases}, useWhen=${d.K2_descriptionFormat.useWhenForm})`);
|
||
console.log(` K3 body ≤500 : ${flag(d.K3_bodyLength.pass)} (${d.K3_bodyLength.bodyLines} linjer)`);
|
||
console.log(` K5 prog.disc. : ${flag(d.K5_progressiveDisclosure.pass)} (navngitte=${d.K5_progressiveDisclosure.namedFileLinks}, mapper=${d.K5_progressiveDisclosure.folderRefs}, ratio=${d.K5_progressiveDisclosure.namedRatio})`);
|
||
console.log(` K6 routing-tab : ${flag(d.K6_routingTable.pass)} (navngitte startfiler=${d.K6_routingTable.namedStartFiles})`);
|
||
const rc = d.refCountConsistency;
|
||
console.log(` ref-tall : ${flag(rc.consistent)}${rc.consistent ? '' : ' — ' + JSON.stringify(rc.mismatches)}`);
|
||
const k10 = d.K10_siblingScopeOverlap;
|
||
if (k10) {
|
||
console.log(` K10 søsken-ovl : ${flag(k10.pass)} (maks ${k10.maxCombined} mot ${k10.worstSibling}, terskel ${k10.threshold})`);
|
||
}
|
||
console.log(` K9 hints : ${d.K9_timeSensitiveHints.timeSensitiveTokenHits} tid-sensitive tokens i body`);
|
||
console.log('');
|
||
}
|
||
console.log(`(LLM-judge K1/K4/K7/K9 kjøres som operatør-gated subagent-steg; baseline skrives med --write.)\n`);
|
||
}
|
||
|
||
// Run only when invoked directly (not when imported by tests).
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||
main();
|
||
}
|