#!/usr/bin/env node // neutralize-cosmo-headings.mjs — R13 Cosmo del 1: apply the ratified heading // neutralisation across the reference corpus, rewriting each persona heading and the TOC // entries anchored to it in ONE per-file operation, so no intermediate state leaves a // dead fragment link. // // SCOPE (operator-ratified 2026-09-12, alternative A). In: the 401 persona headings in // 374 reference files (40 measured variants) plus the 327 TOC entries that anchor to // them. OUT: the 4 SKILL.md files, the 23 commands, CLAUDE.md / README.md / NOTICE.md, // the docs/ files (all R14), and the 132 persona occurrences in prose, tables, dialogue // and provenance lines — those need editorial judgement, not a scripted transform, and // are booked as an open decision for R13b/R14. // // INVARIANTS asserted per file BEFORE any write (a throw aborts the whole run and // writes nothing — the plan-then-write discipline of strip-stale-verified-pipe.mjs): // - zero persona occurrences remain on heading lines // - product occurrence count byte-for-byte unchanged (the 451 Azure Cosmos DB mentions) // - line count unchanged (headings are edited in place, never added or removed) // - no NEW dead fragment link // - only heading lines and TOC-entry lines differ from the original // Idempotent: a re-run finds no persona heading and is a no-op, so an interrupted run is // recovered by re-running. // // Usage: node scripts/kb-update/neutralize-cosmo-headings.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, findDeadAnchors, neutralizeContent } from './lib/cosmo-persona.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); /** * Assert every per-file invariant. Throws on the first violation so the run aborts * before a single byte is written. */ function assertInvariant(rel, before, after, changes) { const b = classifyCosmo(before); const a = classifyCosmo(after); if (a.persona.heading !== 0) { throw new Error(`${rel}: ${a.persona.heading} persona-forekomst(er) staar igjen paa heading-linjer`); } if (a.product !== b.product) { throw new Error(`${rel}: produkt-forekomster endret ${b.product} -> ${a.product}`); } const lb = before.split('\n').length; const la = after.split('\n').length; if (lb !== la) throw new Error(`${rel}: linjeantall endret ${lb} -> ${la}`); 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(', ')}`); // Only the lines the transform claims to have touched may differ. const touched = new Set(changes.map((c) => c.line)); const lbArr = before.split('\n'); const laArr = after.split('\n'); for (let i = 0; i < lbArr.length; i++) { if (lbArr[i] === laArr[i]) continue; if (!touched.has(i + 1)) { throw new Error(`${rel}:${i + 1}: linje endret uten aa staa i endringslista\n` + ` foer: ${lbArr[i]}\n etter: ${laArr[i]}`); } } } function main() { const dry = process.argv.includes('--dry'); const files = globSync('skills/*/references/**/*.md', { cwd: PLUGIN_ROOT }).sort(); const planned = []; const skipped = []; let headingChanges = 0; let tocChanges = 0; for (const rel of files) { const abs = join(PLUGIN_ROOT, rel); const before = readFileSync(abs, 'utf8'); const { content: after, changes } = neutralizeContent(before, { relPath: rel }); if (after === before) { skipped.push(rel); continue; } assertInvariant(rel, before, after, changes); headingChanges += changes.filter((c) => c.kind === 'heading').length; tocChanges += changes.filter((c) => c.kind === 'toc').length; planned.push({ rel, out: after, changes }); } console.log(`ref-filer: ${files.length} | aa endre: ${planned.length} | urørt: ${skipped.length}`); console.log(`heading-endringer: ${headingChanges} | TOC-endringer: ${tocChanges}`); if (dry) { console.log('\n(dry run — ingen skriving)'); const byTarget = new Map(); for (const p of planned) { for (const c of p.changes.filter((x) => x.kind === 'heading')) { const k = `${c.from} -> ${c.to}`; byTarget.set(k, (byTarget.get(k) || 0) + 1); } } console.log('\n--- heading-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();