#!/usr/bin/env node // neutralize-cosmo-labels.mjs — R13b: apply the ratified neutralisation to the MECHANICAL // persona sites that sit OUTSIDE headings — bold labels and table cells — in one per-file // operation, behind the same plan-then-write discipline as R13's heading driver. // // SCOPE (operator-ratified 2026-09-15, alternative A; measured, not inherited). IN: the 62 // mechanical occurrences — 46 bold labels in 16 ratified variants, 16 table cells in 4. // OUT, and deliberately so: the 70 editorial ones, which R14 owns. The dispatching order // carried "MEKANISK 85 / REDAKSJONELT 47" and asked for that premise to be re-measured before // anything was built; re-measured, it is wrong in the CLASSIFIER, not the count — 14 bold // labels are dialogue speaker attributions standing in front of quoted speech, 10 table cells // are provenance claims in the source-quality column, and one list-item label hidden behind a // `- ` marker leaks the other way INTO scope. Full derivation: lib/cosmo-persona.mjs. // // Also out, unchanged from R13: the 4 SKILL.md, the 23 commands, CLAUDE.md / README / NOTICE, // and docs/ — all R14 — and R13's own headings and TOC, which are green and are not regraded. // // INVARIANTS asserted per file BEFORE any write (a throw aborts the whole run and writes // nothing): // - zero MECHANICAL label/cell sites remain // - the editorial counts are untouched — if one moved, the scope has been breached // - product occurrences unchanged (the 451 Azure Cosmos DB mentions) // - persona on headings and in TOC entries still zero (R13 not regraded) // - line count unchanged; no NEW dead fragment link // - only lines the transform claims to have touched differ from the original // Idempotent: a re-run matches no key and is a no-op, so an interrupted run is re-runnable. // // Usage: node scripts/kb-update/neutralize-cosmo-labels.mjs [--dry] import { readFileSync, realpathSync, globSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { atomicWriteSync } from './lib/atomic-write.mjs'; import { classifyCosmo, classifyPersonaSites, findDeadAnchors, neutralizeLabels } from './lib/cosmo-persona.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); /** Throws on the first violation, so the run aborts before a single byte is written. */ function assertInvariant(rel, before, after, changes) { const sb = classifyPersonaSites(before); const sa = classifyPersonaSites(after); const left = sa.label.mechanical + sa.table.mechanical; if (left !== 0) throw new Error(`${rel}: ${left} mekanisk(e) persona-site(r) staar igjen`); if (sa.unmapped.length) throw new Error(`${rel}: ukjent persona-site: ${sa.unmapped.join(', ')}`); if (sa.label.editorial !== sb.label.editorial || sa.table.editorial !== sb.table.editorial) { throw new Error(`${rel}: REDAKSJONELT scope roert — etiketter ` + `${sb.label.editorial} -> ${sa.label.editorial}, celler ` + `${sb.table.editorial} -> ${sa.table.editorial}`); } const b = classifyCosmo(before); const a = classifyCosmo(after); if (a.product !== b.product) throw new Error(`${rel}: produkt endret ${b.product} -> ${a.product}`); if (a.persona.heading !== 0 || a.persona.toc !== 0) { throw new Error(`${rel}: R13 regradert — heading ${a.persona.heading}, toc ${a.persona.toc}`); } const lb = before.split('\n'); const la = after.split('\n'); if (lb.length !== la.length) throw new Error(`${rel}: linjeantall endret ${lb.length} -> ${la.length}`); const deadBefore = new Set(findDeadAnchors(before).map((d) => d.anchor)); const newDead = findDeadAnchors(after).map((d) => d.anchor).filter((x) => !deadBefore.has(x)); if (newDead.length) throw new Error(`${rel}: nye doede ankre: ${newDead.join(', ')}`); const touched = new Set(changes.map((c) => c.line)); for (let i = 0; i < lb.length; i++) { if (lb[i] === la[i]) continue; if (!touched.has(i + 1)) { throw new Error(`${rel}:${i + 1}: linje endret uten aa staa i endringslista\n` + ` foer: ${lb[i]}\n etter: ${la[i]}`); } } } function main() { const dry = process.argv.includes('--dry'); const files = globSync('skills/*/references/**/*.md', { cwd: PLUGIN_ROOT }).sort(); const planned = []; let labelChanges = 0; let cellChanges = 0; let heldBack = 0; for (const rel of files) { const before = readFileSync(join(PLUGIN_ROOT, rel), 'utf8'); heldBack += (() => { const s = classifyPersonaSites(before); return s.label.editorial + s.table.editorial; })(); const { content: after, changes } = neutralizeLabels(before, { relPath: rel }); if (after === before) continue; assertInvariant(rel, before, after, changes); labelChanges += changes.filter((c) => c.kind === 'label').length; cellChanges += changes.filter((c) => c.kind === 'cell').length; planned.push({ rel, out: after, changes }); } console.log(`ref-filer: ${files.length} | aa endre: ${planned.length} | urørt: ${files.length - planned.length}`); console.log(`etikett-endringer: ${labelChanges} | tabellcelle-endringer: ${cellChanges} ` + `| SUM ${labelChanges + cellChanges}`); console.log(`redaksjonelle sites holdt tilbake til R14: ${heldBack}`); if (dry) { const byTarget = new Map(); for (const p of planned) { for (const c of p.changes) { const k = `${c.kind.padEnd(5)} ${c.from} -> ${c.to}`; byTarget.set(k, (byTarget.get(k) || 0) + 1); } } console.log('\n(dry run — ingen skriving)\n--- mapping som vil brukes ---'); for (const [k, v] of [...byTarget.entries()].sort((x, y) => y[1] - x[1])) { console.log(`${String(v).padStart(4)} ${k}`); } return; } for (const p of planned) atomicWriteSync(join(PLUGIN_ROOT, p.rel), p.out); console.log(`\nSkrev ${planned.length} filer.`); } const isMain = (() => { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })(); if (isMain) main();