refactor(ms-ai-architect): R13 del 1 — nøytraliser Cosmo-personaen i ref-korpusets headinger, etter å ha rettet en gate som var målt usann to ganger

Ordre 20260912T193441Z-7358817909. Steg 1 var ikke transformen, men å rette
roadmapens R13-gate og få den ratifisert. Gaten `grep -rl "Cosmo"
skills/*/references -> 0` var usann på to uavhengige måter:

1. Ordren fanget den første: 451 av forekomstene er Azure Cosmos DB, ekte
   produktinnhold. Diskriminatoren er ikke bokstaven «s» — `Cosmos <norsk
   substantiv>` er genitiv av personaen (`### Cosmos tonalitet`), mens
   `Cosmos DB`/`CosmosClient`/`cosmos_ru` er produkt.
2. Denne økten fant den andre: 132 persona-forekomster ligger i prosa,
   tabeller, dialog-replikker og proveniens-linjer. Heading-nøytralisering
   kan ikke nå dem, så «0 persona» er uoppnåelig også under den ratifiserte
   formen. Operatøren ratifiserte alternativ A: gaten speiler formen, og de
   132 bokføres til R13b/R14.

Tre korreksjoner av premisser som sto i ordren og STATE:
  «ca 320 produkt»   -> 451 (case-sensitivt nett manglet 327 lowercase
                        TOC-ankre + 99 identifikatorer; sann nevner 1 638)
  «169 headinger»    -> 401. 169 var `^## For Cosmo`-prefikset (168) og var
                        internt inkonsistent med sin egen topp-variant (204)
  «417 matcher ingen
   populasjon»       -> 417 er cosmo-headinger utenfor kodefences; briefens
                        nevner var reell hele tiden

Fence-bevissthet er målt skadelig, ikke nødvendig: begge toggle-regler er
gale på dette korpuset (naiv toggle skjuler en ekte heading i
chain-of-thought-prompting.md, CommonMark-regelen ubalanserer
service-level-documentation-dr.md). Fence-agnostisk deteksjon finner 401
heading-linjer i nøyaktig de samme 40 variantene som fence-bevisst finner
400 i — ingen kodeblokk-linje er byte-identisk til en persona-heading. Derfor
nøkles transformen på 40 enumererte heading-tekster og ignorerer fences. En
ukjent variant kaster; en slug-kollisjon kaster. Ingenting auto-fikses.

TOC-en regenereres ikke, den rettes kirurgisk: alle 327 persona-lenker hadde
lenketekst lik én av de 40 heading-tekstene og anker lik slugify av den
(327/327, 0 avvik), så heading og TOC-entry skrives i samme operasjon og
ingen mellomtilstand etterlater en død lenke.

Ratifisert målform: `For Cosmo`, `For Cosmo Skyberg` og `For arkitekten
(Cosmo)` konvergerer på `For arkitekten`. To filer kolliderte og er adjudisert
ved å lese dem, ikke ved regel.

Verifisering (alle 7 kriterier fra ordren):
  G1 persona på heading-linjer   401 -> 0
  G2 døde fragmentlenker         1 -> 1 (pre-eksisterende, unntatt)
  G3 produkt-forekomster         451 -> 451; `Cosmos DB|Azure Cosmos` 308 = 308
  de 3 kun-produkt-filene        byte-identiske
  nettet validert begge veier    injisert persona feller G1; genitiv feller G1;
                                 produkt-heading og de 3 filene passerer
  hele diffen                    802 heading-linjer + 654 TOC-linjer, ANNET = 0
  linjeantall                    728 lagt til = 728 slettet
  suite                          1120/1120 (1097 + 23 nye)
  validate-plugin                250 PASS / 0 FAIL
  stikkprøve                     10 filer, alle 5 skills, inkl. de 3 mest
                                 produkt-tunge (26/20/19) — kun heading+TOC

Utenfor scope, urørt: de 4 SKILL.md, de 23 commands, CLAUDE.md, README.md,
NOTICE.md, docs/ (alt R14).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-12 22:12:28 +02:00
commit 3a73eeafdc
380 changed files with 1711 additions and 729 deletions

View file

@ -0,0 +1,188 @@
#!/usr/bin/env node
// check-cosmo-gate.mjs — the RATIFIED R13 persona gate (operator 2026-09-12, alt. A).
//
// Replaces the roadmap's `grep -rl "Cosmo" skills/*/references | wc -l -> 0`, which was
// measured false twice over (it would delete 451 Azure Cosmos DB occurrences, and it is
// unreachable under a heading-only scope because 132 persona occurrences live in prose,
// tables and dialogue). Full derivation: lib/cosmo-persona.mjs header.
//
// THREE CLAUSES
// G1 persona occurrences on markdown heading lines in the reference corpus = 0
// G2 every fragment link resolves to a real heading slug, except the anchors on the
// committed exemption list (pre-existing, not caused by R13)
// G3 product occurrences unchanged from the committed baseline, and each product-only
// file still carries zero persona
//
// USAGE
// node scripts/kb-update/check-cosmo-gate.mjs # check (exit 1 on fail)
// node scripts/kb-update/check-cosmo-gate.mjs --emit-baseline # write the baseline JSON
// node scripts/kb-update/check-cosmo-gate.mjs --validate-net # prove the net both ways
//
// --validate-net is not decoration. A gate whose null result has never been forced to
// the other answer is not a measurement (memory decision-gate-must-be-validated-both-
// ways): it injects a known-positive persona heading into a copy of a real corpus file
// and asserts G1 FAILS, then asserts the known-negative product-only files PASS.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { globSync } from 'node:fs';
import { classifyCosmo, findDeadAnchors } from './lib/cosmo-persona.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const BASELINE_PATH = join(__dirname, 'data', 'cosmo-gate-baseline.json');
/** Every reference file in the corpus, as repo-relative paths, sorted. */
function refFiles() {
return globSync('skills/*/references/**/*.md', { cwd: PLUGIN_ROOT }).sort();
}
/** Measure the whole corpus: persona buckets, product total, per-file detail. */
function measure() {
const files = refFiles();
const totals = { heading: 0, toc: 0, prose: 0, product: 0 };
const headingFiles = new Set();
const perFile = {};
const deadAnchors = [];
for (const rel of files) {
const src = readFileSync(join(PLUGIN_ROOT, rel), 'utf8');
const c = classifyCosmo(src);
totals.heading += c.persona.heading;
totals.toc += c.persona.toc;
totals.prose += c.persona.prose;
totals.product += c.product;
if (c.persona.heading > 0) headingFiles.add(rel);
perFile[rel] = { persona: c.personaTotal, product: c.product };
for (const d of findDeadAnchors(src)) deadAnchors.push(`${rel}#${d.anchor}`);
}
return { files, totals, headingFiles: [...headingFiles].sort(), perFile, deadAnchors };
}
/** Files carrying Azure Cosmos DB but no persona at all — must stay untouched. */
function productOnlyFiles(m) {
return m.files.filter((f) => m.perFile[f].product > 0 && m.perFile[f].persona === 0);
}
function emitBaseline() {
const m = measure();
const pof = productOnlyFiles(m);
const baseline = {
_comment: 'Ratified R13 persona-gate baseline. Measured before the heading transform; '
+ 'G1 drives personaHeadings to 0, G2 and G3 must not move. Regenerate ONLY with a '
+ 'ratified scope change — a silently moved baseline is a gate that proves nothing.',
measured: new Date().toISOString().slice(0, 10),
refFiles: m.files.length,
personaHeadings: m.totals.heading,
personaHeadingFiles: m.headingFiles.length,
personaTocOccurrences: m.totals.toc,
personaProseOccurrences: m.totals.prose,
productOccurrences: m.totals.product,
productOnlyFiles: pof,
productOnlyFileCounts: Object.fromEntries(pof.map((f) => [f, m.perFile[f].product])),
deadAnchorsExempt: m.deadAnchors,
};
writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
console.log(`baseline skrevet: ${relative(PLUGIN_ROOT, BASELINE_PATH)}`);
console.log(JSON.stringify(baseline, null, 2));
}
function check() {
if (!existsSync(BASELINE_PATH)) {
console.error('FEIL: ingen baseline. Kjoer --emit-baseline foer transformen.');
process.exit(2);
}
const b = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
const m = measure();
const fails = [];
// --- G1: no persona left on any heading line ---
if (m.totals.heading !== 0) {
fails.push(`G1: ${m.totals.heading} persona-forekomster staar fortsatt paa heading-linjer `
+ `i ${m.headingFiles.length} filer (baseline var ${b.personaHeadings})`);
}
// --- G2: no dead fragment links beyond the committed exemptions ---
const exempt = new Set(b.deadAnchorsExempt);
const newDead = m.deadAnchors.filter((d) => !exempt.has(d));
if (newDead.length) {
fails.push(`G2: ${newDead.length} NYE doede fragmentlenker: ${newDead.slice(0, 10).join(', ')}`);
}
const healed = b.deadAnchorsExempt.filter((d) => !m.deadAnchors.includes(d));
// --- G3: product untouched ---
if (m.totals.product !== b.productOccurrences) {
fails.push(`G3: produkt-forekomster ${m.totals.product} != baseline ${b.productOccurrences} `
+ `(differanse ${m.totals.product - b.productOccurrences})`);
}
for (const [f, n] of Object.entries(b.productOnlyFileCounts)) {
const now = m.perFile[f];
if (!now) { fails.push(`G3: kun-produkt-fila ${f} finnes ikke lenger`); continue; }
if (now.product !== n) fails.push(`G3: ${f} produkt ${now.product} != baseline ${n}`);
if (now.persona !== 0) fails.push(`G3: ${f} har faatt ${now.persona} persona-forekomst(er)`);
}
console.log('=== R13 persona-gate (ratifisert 2026-09-12, alt. A) ===');
console.log(`ref-filer : ${m.files.length} (baseline ${b.refFiles})`);
console.log(`G1 persona paa heading-linjer : ${m.totals.heading} (baseline ${b.personaHeadings})`);
console.log(` persona i TOC-linjer : ${m.totals.toc} (baseline ${b.personaTocOccurrences})`);
console.log(` persona i proesa/tabell : ${m.totals.prose} (baseline ${b.personaProseOccurrences}) `
+ '— UTENFOR R13-scope, bokfoert til R13b/R14');
console.log(`G2 doede fragmentlenker : ${m.deadAnchors.length} `
+ `(${exempt.size} unntatt, ${newDead.length} nye, ${healed.length} forsvunnet)`);
console.log(`G3 produkt-forekomster : ${m.totals.product} (baseline ${b.productOccurrences})`);
console.log(` kun-produkt-filer urørt : ${Object.keys(b.productOnlyFileCounts).length}`);
if (fails.length) {
console.log('\nGATE FAILED');
for (const f of fails) console.log(` x ${f}`);
process.exit(1);
}
console.log('\nGATE PASSED');
}
/** Force the gate to the other answer — a null result nobody could falsify is not one. */
function validateNet() {
const m = measure();
let bad = 0;
// Known-positive: inject a persona heading into a real corpus file's content and
// assert the classifier sees it. If this cannot fail the gate, the gate is blind.
const victim = m.files[0];
const injected = `${readFileSync(join(PLUGIN_ROOT, victim), 'utf8')}\n## For Cosmo\n`;
const ic = classifyCosmo(injected);
const base = classifyCosmo(readFileSync(join(PLUGIN_ROOT, victim), 'utf8'));
if (ic.persona.heading !== base.persona.heading + 1) {
console.log(`x KJENT-POSITIV: injisert "## For Cosmo" ble IKKE sett `
+ `(${base.persona.heading} -> ${ic.persona.heading})`); bad++;
} else {
console.log(`ok KJENT-POSITIV: injisert "## For Cosmo" felte G1 `
+ `(${base.persona.heading} -> ${ic.persona.heading}) i ${victim}`);
}
// Known-positive #2: the Norwegian genitive must also be caught, since it is the
// case the letter-s heuristic gets wrong in BOTH directions.
const gen = classifyCosmo('### Cosmos tonalitet\n');
if (gen.persona.heading !== 1) { console.log('x KJENT-POSITIV: genitiv "### Cosmos tonalitet" ikke sett'); bad++; }
else console.log('ok KJENT-POSITIV: genitiv "### Cosmos tonalitet" felte G1');
// Known-negative: a product heading must NOT trip the gate.
const prod = classifyCosmo('### Azure Cosmos DB for GraphRAG\n');
if (prod.personaTotal !== 0 || prod.product !== 1) {
console.log(`x KJENT-NEGATIV: produkt-heading feilklassifisert `
+ `(persona ${prod.personaTotal}, produkt ${prod.product})`); bad++;
} else console.log('ok KJENT-NEGATIV: "### Azure Cosmos DB for GraphRAG" gir 0 persona, 1 produkt');
// Known-negative #2: the real product-only files must be clean of persona.
for (const f of productOnlyFiles(m)) {
const c = classifyCosmo(readFileSync(join(PLUGIN_ROOT, f), 'utf8'));
if (c.personaTotal !== 0) { console.log(`x KJENT-NEGATIV: ${f} klassifisert med persona`); bad++; }
else console.log(`ok KJENT-NEGATIV: ${f} — 0 persona, ${c.product} produkt`);
}
console.log(bad === 0 ? '\nNETTET VALIDERT BEGGE VEIER' : `\nNETT-VALIDERING FEILET (${bad})`);
process.exit(bad === 0 ? 0 : 1);
}
const arg = process.argv[2];
if (arg === '--emit-baseline') emitBaseline();
else if (arg === '--validate-net') validateNet();
else check();

View file

@ -0,0 +1,23 @@
{
"_comment": "Ratified R13 persona-gate baseline. Measured before the heading transform; G1 drives personaHeadings to 0, G2 and G3 must not move. Regenerate ONLY with a ratified scope change — a silently moved baseline is a gate that proves nothing.",
"measured": "2026-09-12",
"refFiles": 389,
"personaHeadings": 401,
"personaHeadingFiles": 374,
"personaTocOccurrences": 654,
"personaProseOccurrences": 132,
"productOccurrences": 451,
"productOnlyFiles": [
"skills/ms-ai-advisor/references/architecture/decision-trees.md",
"skills/ms-ai-advisor/references/architecture/poc-template.md",
"skills/ms-ai-advisor/references/development/agent-framework.md"
],
"productOnlyFileCounts": {
"skills/ms-ai-advisor/references/architecture/decision-trees.md": 3,
"skills/ms-ai-advisor/references/architecture/poc-template.md": 1,
"skills/ms-ai-advisor/references/development/agent-framework.md": 1
},
"deadAnchorsExempt": [
"skills/ms-ai-advisor/references/development/agent-framework.md#pattern-3-human-in-the-loop"
]
}

View file

@ -0,0 +1,324 @@
// cosmo-persona.mjs — R13 Cosmo del 1: classify a `cosmo` occurrence as PERSONA or
// PRODUCT, and neutralise the persona out of reference-corpus headings (and the TOC
// entries that anchor to them) without touching a single Azure Cosmos DB mention.
//
// WHY THIS LIB EXISTS. The roadmap's R13 acceptance gate
// (.claude/projects/2026-07-02-helhetlig-roadmap § R13) reads
// `grep -rl "Cosmo" skills/*/references | wc -l -> 0`. Measured 2026-09-12 over the
// 389 reference files, that gate is false twice over:
//
// 1. Of 1 638 case-insensitive `cosmo` occurrences, 451 are AZURE COSMOS DB — a real
// Microsoft product, legitimate content in the BCDR / engineering / cost files.
// A gate demanding zero `Cosmo` demands they be deleted. The discriminator is NOT
// the letter s: `Cosmos DB` / `CosmosClient` / `cosmos_ru` are product, while
// `Cosmos <norsk substantiv>` is the persona's GENITIVE (`### Cosmos tonalitet`).
// 2. Of the 1 187 persona occurrences, 132 live in prose, tables, dialogue and
// provenance lines that heading neutralisation cannot reach. "Zero persona" is
// therefore unreachable under the ratified heading-only scope either.
//
// THE RATIFIED GATE (operator 2026-09-12, alternative A) — three clauses, baselines
// measured over skills/*/references:
// G1 persona occurrences on markdown heading lines 401 -> 0 (374 files, 40 variants)
// G2 every fragment link resolves to a real heading unchanged (1 pre-existing dead)
// G3 product occurrences unchanged 451 -> 451
// The 132 residual persona occurrences are booked as an open decision for R13b/R14.
//
// WHY EXACT-STRING KEYING AND NOT FENCE-AWARENESS. Measured: BOTH fence rules are
// wrong on this corpus. A naive ``` toggle (what transform.mjs buildToc uses) leaves
// chain-of-thought-prompting.md unbalanced — 19 markers — and hides the real heading at
// line 403; the CommonMark "a closing fence carries no info string" rule instead
// unbalances service-level-documentation-dr.md, whose ```markdown blocks contain
// ```mermaid / ```bash examples as literal content. Measured resolution: fence-agnostic
// detection finds 401 heading-like persona lines in EXACTLY the same 40 variants that
// fence-aware detection finds 400 in — i.e. no code-block line in the corpus is
// byte-identical to a persona heading, so fence state cannot change the verdict. The
// transform therefore keys on the 40 enumerated heading texts and ignores fences. That
// is both correct here and auditable: every rewrite is a table entry a human ratified.
//
// ARCHITECTURE INVARIANT: this lib NEVER writes and never guesses. An unmapped persona
// heading throws; an in-file slug collision throws. Applying content is the driver's
// job (neutralize-cosmo-headings.mjs), behind the operator gate.
// Function words that may follow a bare product mention of "Cosmos" in prose, e.g.
// "Redis for hot, Cosmos for warm data" / "autoscale for Cosmos i DR-region". A
// genitive persona use is always "Cosmos <substantiv>", never "Cosmos <funksjonsord>".
// Enumerated rather than inferred: the net is validated by running it over all 1 638
// occurrences and hand-checking every bare-Cosmos line (25 of them).
const PRODUCT_FOLLOWERS = [
'for', 'og', 'eller', 'i', 'med', 'til', 'som', 'er', 'ellers', 'via',
'på', 'pa', 'av', 'fra', 'ved', 'kan', 'skal', 'hvis', 'men', 'the', 'to',
];
/**
* Is the `cosmo` occurrence at the START of `text` the persona (not the product)?
*
* PERSONA /cosmo(?!s)/i 1 178 measured (851 `Cosmo`,
* 327 lowercase TOC anchors)
* `Cosmos <lowercase substantiv>` 9 measured (Norwegian genitive)
* PRODUCT everything else 451 measured
*
* @param {string} text the document text from the occurrence's first character on
* @returns {boolean}
*/
export function isPersonaOccurrence(text) {
const s = String(text ?? '');
// `Cosmo`, `Cosmo's`, `Cosmo-tilnærming`, and the lowercase `#...-cosmo)` anchors.
if (/^cosmo(?!s)/i.test(s)) return true;
// Norwegian genitive: capital `Cosmos` + space + a lowercase noun. A capital follower
// (DB, PITR, L2, Kusto, Snowflake) or no space at all (`Cosmos;`, `Cosmos-DB`,
// `CosmosClient`) is product, as is a lowercase function word.
const gen = /^Cosmos[ \t]+(\S+)/.exec(s);
if (!gen) return false;
const follower = gen[1];
if (!/^[a-zæøå]/.test(follower)) return false;
const word = follower.replace(/[^\p{L}]/gu, '').toLowerCase();
return word !== '' && !PRODUCT_FOLLOWERS.includes(word);
}
/** Does this line carry at least one persona occurrence? */
function hasPersona(line) {
for (const m of String(line).matchAll(/cosmo/gi)) {
if (isPersonaOccurrence(String(line).slice(m.index))) return true;
}
return false;
}
/**
* GitHub-style heading slug. MIRRORS transform.mjs slugify (lowercase, drop all
* punctuation, spaces hyphens, Unicode-aware so æ/ø/å survive) kept local rather
* than imported so this lib stays dependency-free, and pinned to parity by
* test-cosmo-persona's slug cases against known corpus anchors.
* @param {string} heading
* @returns {string}
*/
export function slugifyHeading(heading) {
return String(heading ?? '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
.trim()
.replace(/\s/g, '-');
}
/**
* Count every `cosmo` occurrence, bucketing the persona ones by where they sit.
* The buckets are what make the ratified scope legible: `heading` + `toc` is what R13
* neutralises, `prose` is the 132 booked for R13b/R14, `product` is what G3 protects.
*
* @param {string} content
* @returns {{persona: {heading: number, toc: number, prose: number},
* personaTotal: number, product: number, total: number}}
*/
export function classifyCosmo(content) {
const persona = { heading: 0, toc: 0, prose: 0 };
let product = 0;
for (const line of String(content ?? '').split('\n')) {
const isHeading = /^#{1,6}\s+/.test(line);
// Spans covered by a markdown fragment link `[text](#anchor)` — both the visible
// link text and the anchor sit inside one span, which is why a TOC entry for a
// persona heading contributes two occurrences.
const linkSpans = [...line.matchAll(/\[[^\]]*\]\(#[^)]*\)/g)]
.map((m) => [m.index, m.index + m[0].length]);
for (const m of line.matchAll(/cosmo/gi)) {
if (!isPersonaOccurrence(line.slice(m.index))) { product++; continue; }
if (isHeading) persona.heading++;
else if (linkSpans.some(([a, b]) => m.index >= a && m.index < b)) persona.toc++;
else persona.prose++;
}
}
const personaTotal = persona.heading + persona.toc + persona.prose;
return { persona, personaTotal, product, total: personaTotal + product };
}
/**
* The ratified rewrite table: all 40 persona heading variants measured in
* the reference corpus on 2026-09-12, keyed by heading text (the line without its `#`
* prefix) neutral text. Operator-ratified 2026-09-12: `For Cosmo`,
* `For Cosmo Skyberg` and `For arkitekten (Cosmo)` all converge on `For arkitekten`,
* giving the corpus ONE heading vocabulary where it had three.
*
* No target introduces a word its source did not carry, except the two sanctioned
* substitutions for the persona token `arkitekten` (the role Cosmo played) and `å`
* (where the name was the clause's subject). Pinned by test-cosmo-persona.
*/
export const HEADING_MAP = {
// --- the four bulk variants (363 of 401 occurrences) ---
'For arkitekten (Cosmo)': 'For arkitekten',
'For Cosmo': 'For arkitekten',
'For Cosmo Skyberg': 'For arkitekten',
'For Cosmo: Beslutningsveiledning': 'Beslutningsveiledning',
// --- the tail: 36 variants, 1-2 occurrences each ---
'Oppsummering for Cosmo': 'Oppsummering',
'10. For Cosmo: Modellvalgveiledning': '10. Modellvalgveiledning',
'Key insights for Cosmo': 'Key insights',
'Architecture decision prompts for Cosmo': 'Architecture decision prompts',
'Røde flagg (når skal Cosmo advare?)': 'Røde flagg (når skal arkitekten advare?)',
'Fallgruver (Cosmo Har Sett Før)': 'Fallgruver',
"Desicion Matrix (Cosmo's Cheat Sheet)": 'Desicion Matrix',
"Shortcut Design Patterns (Cosmo's Checklist)": 'Shortcut Design Patterns',
'For Cosmo — Beslutningsveiledning': 'Beslutningsveiledning',
'Praktiske anbefalinger for arkitekten (Cosmo)': 'Praktiske anbefalinger for arkitekten',
'For arkitekten (Cosmo) — spørsmål, fallgruver og anbefalinger':
'For arkitekten — spørsmål, fallgruver og anbefalinger',
'For Cosmo — når bruker denne kunnskapen?': 'For arkitekten — når bruker denne kunnskapen?',
'For Cosmo: Quick Reference Card': 'Quick Reference Card',
"Cosmo's Talking Points": 'Talking Points',
'Når skal Cosmo foreslå Impact Assessment?': 'Når skal arkitekten foreslå Impact Assessment?',
'Cosmos veiledningsstrategi': 'Veiledningsstrategi',
'Cosmos spørsmål for å utdype': 'Spørsmål for å utdype',
'Red flags Cosmo skal varsle om': 'Red flags arkitekten skal varsle om',
'Cosmos tonalitet': 'Tonalitet',
'Cosmos sjekkliste før avslutning': 'Sjekkliste før avslutning',
"Cosmo's quick decision tree": 'Quick decision tree',
"Cosmo's Stakeholder Communication Checklist": 'Stakeholder Communication Checklist',
'Cosmo-tilnærming': 'Tilnærming',
'Decision trees for Cosmo': 'Decision trees',
'Red flags for Cosmo å se etter': 'Red flags å se etter',
'For Cosmo Skyberg: Application Insights for LLM Monitoring':
'Application Insights for LLM Monitoring',
'For Cosmo: Anvendelse i Arkitekturrådgivning': 'Anvendelse i Arkitekturrådgivning',
'For Arkitekten (Cosmo)': 'For Arkitekten',
"Cosmo's Quick Decision Matrix": 'Quick Decision Matrix',
'Cosmos anbefalinger': 'Anbefalinger',
'For Cosmo: Practical Implementation': 'Practical Implementation',
'For Cosmo: Veiledning i Arkitekturdialog': 'Veiledning i Arkitekturdialog',
'10. Anbefalinger for Cosmo Skyberg': '10. Anbefalinger',
'For Cosmo: Anvendelse i Microsoft AI-arkitektur': 'Anvendelse i Microsoft AI-arkitektur',
'Cosmo-oppsummering': 'Oppsummering',
'Spørsmål Cosmo bør stille kunden': 'Spørsmål å stille kunden',
};
/**
* Per-file overrides where the shared table would collide inside one document. Measured
* by sweeping the ratified mapping over all 389 files and collecting every slug that
* would end up shared: exactly TWO, both adjudicated by reading the file.
*
* 1. feedback-loops-continuous-improvement.md carries both `## For arkitekten (Cosmo)`
* (L674) and `## For Cosmo` (L745), which the ratified mapping would fold onto one
* slug. The second section's own body names the alternative the brief already
* sanctions "Nøkkelpunkter å fremheve i konsultasjon"
* (docs/cosmo-removal-brief-2026-06.md:24) so the target is read off the content
* rather than invented.
* 2. application-insights-llm-monitoring.md's `## For Cosmo Skyberg: Application
* Insights for LLM Monitoring` (L717) would collide with the document's own H1 title
* (L1), because the heading's suffix merely restated that title. Dropping the suffix
* loses nothing and lands on the ratified `For arkitekten` vocabulary.
*/
export const FILE_HEADING_OVERRIDES = {
'skills/ms-ai-engineering/references/mlops-genaiops/feedback-loops-continuous-improvement.md': {
'For Cosmo': 'Nøkkelpunkter for rådgivning',
},
'skills/ms-ai-governance/references/monitoring-observability/application-insights-llm-monitoring.md': {
'For Cosmo Skyberg: Application Insights for LLM Monitoring': 'For arkitekten',
},
};
/**
* Every fragment link whose anchor matches no heading slug in the same document.
* Heading collection is fence-agnostic, matching the transform see the header note.
*
* @param {string} content
* @returns {Array<{text: string, anchor: string}>}
*/
export function findDeadAnchors(content) {
const src = String(content ?? '');
const slugs = new Set();
for (const line of src.split('\n')) {
const m = /^#{1,6}\s+(.+?)\s*$/.exec(line);
if (m) slugs.add(slugifyHeading(m[1].trim()));
}
const dead = [];
for (const m of src.matchAll(/\[([^\]]*)\]\(#([^)]*)\)/g)) {
if (!slugs.has(m[2])) dead.push({ text: m[1], anchor: m[2] });
}
return dead;
}
/**
* Neutralise the persona out of this document's headings, and rewrite the TOC entries
* that anchor to them in the SAME operation so no step of the run ever leaves a dead
* fragment link. Pure: returns new content, writes nothing.
*
* Surgical by design. The TOC is NOT regenerated (buildToc would reorder entries,
* re-derive every anchor and drop `###` entries); only the persona entries are
* rewritten, in place, leaving all 2 709 non-persona fragment links byte-identical.
* Measured precondition that makes this exact: all 327 persona TOC links carry a link
* text that is one of the 40 heading texts AND an anchor equal to slugify of it
* (327/327, zero deviations).
*
* Throws never writes a guess when:
* - a heading carries persona but is not in HEADING_MAP (an unmeasured variant)
* - a rewrite would make two headings in this file share one slug
* - a persona fragment link's anchor is not slugify of its own link text
*
* Idempotent: neutral content has no persona headings left to match.
*
* @param {string} content
* @param {{relPath?: string}} [opts] relPath selects FILE_HEADING_OVERRIDES
* @returns {{content: string, changes: Array<{kind: string, line: number, from: string, to: string}>}}
*/
export function neutralizeContent(content, { relPath = '' } = {}) {
const src = String(content ?? '');
const overrides = FILE_HEADING_OVERRIDES[relPath] || {};
const target = (text) => (Object.prototype.hasOwnProperty.call(overrides, text)
? overrides[text]
: HEADING_MAP[text]);
const lines = src.split('\n');
const changes = [];
// --- pass 1: headings ---
const rewritten = lines.map((line, i) => {
const m = /^(#{1,6})(\s+)(.+?)(\s*)$/.exec(line);
if (!m || !hasPersona(line)) return line;
const from = m[3];
const to = target(from);
if (to === undefined) {
throw new Error(`ukjent persona-heading i ${relPath || '<ukjent fil>'}:${i + 1}: "${from}"`);
}
changes.push({ kind: 'heading', line: i + 1, from, to });
return `${m[1]}${m[2]}${to}${m[4]}`;
});
// --- collision check, BEFORE any TOC work: would two headings share one slug? ---
const slugCount = new Map();
for (const line of rewritten) {
const m = /^#{1,6}\s+(.+?)\s*$/.exec(line);
if (!m) continue;
const s = slugifyHeading(m[1].trim());
slugCount.set(s, (slugCount.get(s) || 0) + 1);
}
for (const c of changes) {
const s = slugifyHeading(c.to);
if (slugCount.get(s) > 1) {
throw new Error(
`slug-kollisjon i ${relPath || '<ukjent fil>'}:${c.line}: "${c.from}" -> "${c.to}" `
+ `gir slug "#${s}" som alt finnes i fila. Krever en FILE_HEADING_OVERRIDES-entry.`,
);
}
}
// --- pass 2: the TOC entries anchoring to the headings we just renamed ---
const out = rewritten.map((line, i) => line.replace(
/\[([^\]]*)\]\(#([^)]*)\)/g,
(whole, text, anchor) => {
if (!hasPersona(whole)) return whole;
const to = target(text);
if (to === undefined) {
throw new Error(
`persona-fragmentlenke som ikke peker paa en kjent heading, `
+ `${relPath || '<ukjent fil>'}:${i + 1}: "${whole}"`,
);
}
if (anchor !== slugifyHeading(text)) {
throw new Error(
`anker matcher ikke slugify(lenketekst) i ${relPath || '<ukjent fil>'}:${i + 1}: `
+ `"${whole}" — forventet "#${slugifyHeading(text)}"`,
);
}
changes.push({ kind: 'toc', line: i + 1, from: text, to });
return `[${to}](#${slugifyHeading(to)})`;
},
));
return { content: out.join('\n'), changes };
}

View file

@ -0,0 +1,117 @@
#!/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();