// syklus-rapport.mjs // D5 steg 9: tertialrapport fra en lest syklus. REN modul (ingen shebang, ingen // isMain-CLI) -- orkestratoren bor i scripts/syklus-rapport.mjs. // Zero npm dependencies. // // SEEMEN, og hvorfor den ligger der den ligger (beslutning B-3): // generatoren eier ARITMETIKK, TABELLSTRUKTUR og FORMATERINGSINVARIANTER. // Den eier ALDRI confidence. okr-framework.md:563 gjoer confidence-tabellen til // eneste sannhetskilde og forbyr andre filer aa definere egne terskler; // okr-calculator.md:249 avviser mekanisk utledning ordrett ("bruk gapet ... som // ETT innspill til confidence-vurderingen, ikke som en mekanisk regel"). En // generator som satte et trafikklys fra score ville derfor oppfunnet en terskel // doktrinen forbyr. Confidence-kolonnen staar tom by design og fylles av // /okr:sporing, der skjoennet hoerer hjemme. // // Committed-kolonnen "Avvik" er IKKE en terskel: den er sammenligningen // naa >= target, som okr-offentlig-governance.md:150-152 krever ("et lovkrav er // naadd eller ikke; score 0.9 paa et lovpaalagt krav er et avvik"). // // Rapporten holdes ASCII-ren, som resten av den maskingenererte flaten. import { beregnScore } from './syklus-data.mjs'; // Literal for score som ikke er definert som ratio (target == baseline, B-2). // Et tomt felt ville lest som manglende data; "0" ville loeyet om at KR-en // ikke har beveget seg. const UDEFINERT = 'udefinert'; const TABELLHODE = [ '| KR | Baseline | Target | Naa | Score | Avvik | Confidence |', '|----|----------|--------|-----|-------|-------|------------|', ]; const formatScore = (score) => (score === undefined ? UDEFINERT : score.toFixed(2)); // Governance-invariant 2: committed maales binaert mot kravet, ikke paa score. // Aspirational har ingen Avvik-kolonneverdi -- 0.7 er forventet, ikke svikt. // // Predikatet gaar gjennom beregnScore, ALDRI gjennom `naa >= target` direkte: // den sammenligningen er retningsavhengig og gir feil svar begge veier for // nedadgaaende krav (krav 5 dager, naa 11 -> "ingen avvik"; krav 5, naa 4 -> // "avvik"). beregnScore haandterer retningen allerede, fordi teller og nevner // begge blir negative. Bonus: kolonnen kan da ikke motsi rapportens egen // setning om at en score under 1.0 er et avvik. const formatAvvik = (kr, erCommitted) => { if (!erCommitted) return '-'; const score = beregnScore(kr); // target == baseline: ingen ratio aa maale mot, men kravet er fortsatt et tall. if (score === undefined) return kr.naa === kr.target ? 'Nei' : 'Ja'; return score >= 1 ? 'Nei' : 'Ja'; }; function krRad(kr, erCommitted) { const score = formatScore(beregnScore(kr)); // Siste celle (Confidence) staar bevisst tom -- se seem-notatet over. return `| ${kr.navn} | ${kr.baseline} | ${kr.target} | ${kr.naa} | ${score} | ${formatAvvik(kr, erCommitted)} | |`; } const krTabell = (krer, erCommitted) => [ ...TABELLHODE, ...krer.map((kr) => krRad(kr, erCommitted)), ]; // Ett avsnitt per OKR, med KR-ene som tabellrader. Rekkefolgen arves fra // lesSyklus (sortert filnavn) og er del av determinisme-kontrakten. function seksjon(okrer, type, erCommitted) { const linjer = []; let antallKr = 0; for (const okr of okrer) { const krer = okr.krer.filter((kr) => kr.type === type); if (krer.length === 0) continue; antallKr += krer.length; linjer.push(`### ${okr.tittel}`, '', ...krTabell(krer, erCommitted), ''); } return { linjer, antallKr }; } // Aspirational vurderes paa SNITTET paa tvers av alle aspirational-OKR, ikke // paa ett enkelt KR (okr-framework.md:557). Snittet er derfor kanonisk for // denne typen -- og finnes bevisst ikke som et felles tall paa tvers av typene. // KR uten definert score kan ikke inngaa; antallet oppgis i stedet for aa // forsvinne stille. function aspirationalSnitt(okrer) { const scorer = []; let udefinerte = 0; for (const okr of okrer) { for (const kr of okr.krer) { if (kr.type !== 'aspirational') continue; const s = beregnScore(kr); if (s === undefined) udefinerte += 1; else scorer.push(s); } } if (scorer.length === 0) return null; const snitt = scorer.reduce((a, b) => a + b, 0) / scorer.length; const hale = udefinerte > 0 ? ` (${udefinerte} KR uten definert score er holdt utenfor)` : ''; return `**Snitt aspirational: ${snitt.toFixed(2)}** over ${scorer.length} KR${hale}.`; } // --- Sandbagging-vakten (steg 13): felles for alle tre rapportformene --- // // Regelen: en aspirational-OKR med lav score presenteres ALDRI som avvik // (okr-offentlig-governance.md:148-156). Den er kodet ETT sted og brukes av alle // formene, fordi en regel som maa gjentas per form er en regel som glipper i den // fjerde. // // Begrunnelsen er kausal, ikke normativ (okr-framework.md:158-161, Bogsnes): // faar aspirational-scoren konsekvenser i rapporteringen, er target og forecast // re-bundlet -- og da kommer sandbaggingen tilbake av strukturell noedvendighet, // uansett hvor disiplinert den som setter maalet er. Vakten beskytter altsaa // ikke aspirational-KR-et; den beskytter maalsettingen i neste syklus. // Avvik er committed-only. Predikatet er det samme som Avvik-kolonnen bruker -- // via beregnScore, aldri naa >= target -- saa listen og kolonnen ikke kan gi // ulikt svar om samme KR. function avvikListe(okrer) { const rader = []; for (const okr of okrer) { for (const kr of okr.krer) { if (kr.type !== 'committed' || formatAvvik(kr, true) !== 'Ja') continue; rader.push( `- ${okr.tittel}: ${kr.navn} -- naa ${kr.naa} mot krav ${kr.target} ` + `(score ${formatScore(beregnScore(kr))})`, ); } } return rader; } const harAspirational = (okrer) => okrer.some((okr) => okr.krer.some((kr) => kr.type === 'aspirational')); // Overskriften varierer med mottakeren; filteret gjoer det ikke. // // Kryssreferansen til aspirational-seksjonen settes bare naar den seksjonen // faktisk kommer. En syklus med bare committed -- fullt lovlig, og typisk for en // ren etterlevelses-syklus -- ville ellers faatt et styringsdokument som peker // paa en seksjon som ikke er der. function avvikSeksjon(okrer, overskrift) { const rader = avvikListe(okrer); return [ `## ${overskrift}`, '', 'Kun committed KR staar her: et committed krav er naadd eller ikke.', ...(harAspirational(okrer) ? ['Aspirational KR er holdt utenfor med vilje -- se forventningen til', 'aspirational under.'] : []), '', ...(rader.length > 0 ? rader : ['Ingen committed KR ligger under kravet i denne syklusen.']), '', ]; } // Forventningsteksten hoerer i rapporten, ikke bare i koden: mottakeren i // departementet leser ikke 0.7 som suksess med mindre det staar eksplisitt // (governance-regel 1). const ASPIRATIONAL_RAMME = [ 'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes', 'paa snittet paa tvers av alle aspirational-OKR -- ikke paa ett enkelt KR.', 'En lav score er derfor maaloppnaaelse, ikke svikt, og et aspirational KR staar', 'aldri i avviks-seksjonen. Fikk lav aspirational-score konsekvenser i', 'rapporteringen, ville maal og prognose vaert bundlet sammen igjen -- og', 'sandbagging fulgt strukturelt, ikke som et disiplinproblem.', ]; // Felles inngangsvakt for alle rapportformene. En form som stille rapporterte // en tom syklus ville produsert et styringsdokument uten innhold, som ser // komplett ut. function krevSyklus(syklus, hvor) { if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) { throw new Error(`${hvor}: forventet en syklus fra lesSyklus()`); } if (syklus.okrer.length === 0) { throw new Error(`${hvor}: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim()); } } // Skala- og confidence-avsnittet er felles for alle formene, ikke fordi det // sparer linjer, men fordi de tre governance-invariantene ikke kan gjelde bare // den ene rapporten mottakeren tilfeldigvis leser. const SKALAFORKLARING = [ // Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som // prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet" // trekker feil konklusjon om baade ambisjonsniva og resultat. 'Score er en andel paa skalaen 0 til 1.0, beregnet som', '(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.', `Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`, '', 'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --', 'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.', '', ]; const klokke = (opts) => opts.naa || process.env.OKR_NOW || new Date().toISOString(); /** * Bygger tertialrapporten for en syklus lest av lesSyklus(). * * @param {object} syklus struktur fra lesSyklus() * @param {{naa?: string}} [opts] naa overstyrer klokka; ellers OKR_NOW, ellers veggklokke * (klokke-soem etter moenster fra scripts/compose-org-profile.mjs:61) * @returns {string} markdown */ export function tertialrapport(syklus, opts = {}) { krevSyklus(syklus, 'tertialrapport'); const naa = klokke(opts); const committed = seksjon(syklus.okrer, 'committed', true); const aspirational = seksjon(syklus.okrer, 'aspirational', false); if (committed.antallKr + aspirational.antallKr === 0) { throw new Error(`tertialrapport: syklusen ${syklus.id} inneholder ingen KR aa rapportere`); } const ut = [ `# Tertialrapport ${syklus.id}`, '', `Generert: ${naa}`, '', ...SKALAFORKLARING, ]; if (committed.antallKr > 0) { ut.push( '## Committed Key Results', '', // Governance-invariant 1 + 2, uttalt der mottakeren leser tallene. 'Committed KR maales binaert mot kravet: kravet er naadd eller ikke. En score', 'under 1.0 er et avvik som skal forklares, ikke et godt resultat.', '', ...committed.linjer, ); } ut.push(...avvikSeksjon(syklus.okrer, 'Avvik som skal forklares')); if (aspirational.antallKr > 0) { ut.push( '## Aspirational Key Results', '', // Governance-invariant 1: typen merkes, slik at 0.7 ikke leses som svikt. ...ASPIRATIONAL_RAMME, '', ...aspirational.linjer, ); const snitt = aspirationalSnitt(syklus.okrer); if (snitt) ut.push(snitt, ''); } return `${ut.join('\n').replace(/\n+$/, '')}\n`; } // --- Aarsrapport del III (steg 11) --- // // Del III «Aarets aktiviteter og resultater» er hovedplassen for OKR i den // statlige aarsrapporten (okr-offentlig-governance.md:125-133). // // ANTIPATTERNET generatoren maa unngaa, ordrett fra primaerkilden -- // Riksrevisjonen (2020), «Undersoekelse av etats- og virksomhetsstyringen av // Norsk institutt for biooekonomi (NIBIO)», del av Dokument 1 (2020-2021) s. 107: // framstillingen «synliggjoer i mindre grad NIBIOs analyser av maaloppnaaelsen og // framstaar som et oeyeblikksbilde av NIBIOs aktiviteter og resultater». Saken ble // senere avsluttet etter forbedringer i maal- og resultatstyringen (gjengitt i // DFOe-notat 2026:2 s. 48) -- funnet er en historisk dokumentert svakhet // forvaltningen har rettet, ikke gjeldende kritikk av NIBIO. // // Konsekvensen for koden er konkret: en generator som bare dumper KR-tabeller // PRODUSERER nettopp det oeyeblikksbildet. Den reserverer derfor plass til // vurderingen av maaloppnaaelse per Objective -- og fyller den aldri selv, av // samme grunn som den ikke setter confidence: vurderingen krever skjoenn. const VURDERINGSFELT = [ '[Fylles ut av virksomheten: analysen av maaloppnaaelsen for dette Objectivet --', 'hva tallene over betyr, hva som forklarer avvikene, og hva som er laert.', 'Generatoren fyller ikke feltet; en vurdering av maaloppnaaelse krever skjoenn.', 'Uten analysen staar del III igjen som et oeyeblikksbilde av aktiviteter og', 'resultater, som er nettopp det revisjonen har paapekt. Slett klammene naar', 'feltet er fylt ut.]', ]; // DFOe-notat 2026:2 kap. 5.3.3 (s. 61): virksomhetene «skal planlegge med baade // ettaarig og flerarig perspektiv», men «i tildelingsbrevene og aarsrapportene vi // har sett paa omtales imidlertid i liten grad det ettaarige i et flerarig // perspektiv». Generatoren PEKER derfor paa materialet fra tidligere sykluser -- // den regner ikke trend paa tvers av dem. Historikk-filene baerer retrospektiv- // prosa, ikke KR-tall; en beregnet utvikling ville vaert oppdiktet. function flerarigSeksjon(historikk) { if (historikk.length === 0) return []; return [ '## Flerarig perspektiv', '', 'Virksomheten skal planlegge med baade ettaarig og flerarig perspektiv.', 'Materialet fra tidligere sykluser ligger i historikk-katalogen:', '', ...historikk.map((h) => `- ${h.tittel} (${h.fil})`), '', '[Fylles ut av virksomheten: utviklingen sett over tid. Generatoren viser', 'hvilket materiale som finnes, og sammenligner ikke sykluser den ikke har', 'tall fra.]', '', ]; } /** * Bygger aarsrapportens del III for en syklus lest av lesSyklus(). * * @param {object} syklus struktur fra lesSyklus() * @param {{naa?: string, historikk?: Array<{tittel: string, fil: string}>}} [opts] * historikk leses av kalleren (scripts/syklus-rapport.mjs), ikke her -- modulen * holdes fri for disk, saa formen er testbar uten et tre paa filsystemet. * @returns {string} markdown */ export function arsrapportDelIII(syklus, opts = {}) { krevSyklus(syklus, 'arsrapportDelIII'); const naa = klokke(opts); const historikk = Array.isArray(opts.historikk) ? opts.historikk : []; const antallKr = syklus.okrer.reduce((n, okr) => n + okr.krer.length, 0); if (antallKr === 0) { throw new Error(`arsrapportDelIII: syklusen ${syklus.id} inneholder ingen KR aa rapportere`); } const ut = [ `# Aarsrapport del III - Aarets aktiviteter og resultater (${syklus.id})`, '', `Generert: ${naa}`, '', ...SKALAFORKLARING, ]; // Per Objective, fordi del III dokumenterer maaloppnaaelse mot tildelingsbrevets // krav -- og kravene henger paa Objectives, ikke paa rapportformen. for (const okr of syklus.okrer) { ut.push(`## ${okr.tittel}`, ''); for (const [type, erCommitted, overskrift] of [ ['committed', true, '### Committed Key Results'], ['aspirational', false, '### Aspirational Key Results'], ]) { const krer = okr.krer.filter((kr) => kr.type === type); if (krer.length === 0) continue; ut.push(overskrift, '', ...krTabell(krer, erCommitted), ''); } ut.push('### Vurdering av maaloppnaaelse', '', ...VURDERINGSFELT, ''); } ut.push(...avvikSeksjon(syklus.okrer, 'Avvik som skal forklares')); // Snittet hoerer PAA TVERS av Objectives (okr-framework.md:557) og staar derfor // ikke i noen enkelt Objective-seksjon. Committed har bevisst ingen motpart: // et snitt av binaere krav er ikke en stoerrelse. const snitt = aspirationalSnitt(syklus.okrer); if (snitt) { ut.push( '## Aspirational maaloppnaaelse paa tvers av Objectives', '', ...ASPIRATIONAL_RAMME, '', snitt, '', ); } ut.push(...flerarigSeksjon(historikk)); return `${ut.join('\n').replace(/\n+$/, '')}\n`; } // --- Etatsstyringsmoete-underlag (steg 12) --- // // Bygget paa styringsdialog-kapittelet i okr-offentlig-governance.md:96-114. // Etatsstyringsmoetet er det sentrale moetepunktet mellom departement og // virksomhet, og OKR-settet gir det en fast struktur: hva flyttet seg, hva // stoppet opp, hva ber vi om. // // Generatoren tar IKKE stilling til kadens. F-j fjernet paastanden om «2-4 // etatsstyringsmoeter per aar» nettopp fordi den ikke lot seg verifisere; antall // og form fastsettes i departementets hovedinstruks. Et underlag som foregrep // kadensen ville gjeninnfoert den samme uverifiserte paastanden i maskinform. /** * Bygger underlaget til et etatsstyringsmoete for en syklus lest av lesSyklus(). * * @param {object} syklus struktur fra lesSyklus() * @param {{naa?: string}} [opts] * @returns {string} markdown */ export function etatsstyringsunderlag(syklus, opts = {}) { krevSyklus(syklus, 'etatsstyringsunderlag'); const naa = klokke(opts); const antallKr = syklus.okrer.reduce((n, okr) => n + okr.krer.length, 0); if (antallKr === 0) { throw new Error(`etatsstyringsunderlag: syklusen ${syklus.id} inneholder ingen KR aa rapportere`); } const ut = [ `# Etatsstyringsmoete - underlag (${syklus.id})`, '', `Generert: ${naa}`, '', 'Antall og form paa etatsstyringsmoetene fastsettes i departementets', 'hovedinstruks. Dette underlaget tar ikke stilling til kadensen; det gir', 'moetet en struktur bygget paa syklusens egne tall.', '', ...SKALAFORKLARING, ]; for (const okr of syklus.okrer) { ut.push(`## ${okr.tittel}`, ''); for (const [type, erCommitted, overskrift] of [ ['committed', true, '### Committed Key Results'], ['aspirational', false, '### Aspirational Key Results'], ]) { const krer = okr.krer.filter((kr) => kr.type === type); if (krer.length === 0) continue; ut.push(overskrift, '', ...krTabell(krer, erCommitted), ''); } } ut.push(...avvikSeksjon(syklus.okrer, 'Avvik som krever departementets oppmerksomhet')); // Forventningen staar rett under avvikslisten, der spoersmaalet «hvorfor er // ikke DETTE et avvik?» faktisk oppstaar hos mottakeren -- og bare naar // syklusen har aspirational-KR aa forklare. if (harAspirational(syklus.okrer)) { ut.push('## Aspirational Key Results - forventning', '', ...ASPIRATIONAL_RAMME, ''); } // Hjemmelen for at tildelingsbrevet SKAL inneholde styringsparametere er // bestemmelser om oekonomistyring i staten («bestemmelsene») punkt 1.5, // gjengitt i DFOe-notat 2026:2 s. 49 (fn. 228). Generatoren leser ikke // tildelingsbrevet -- den har bare syklusdataene -- saa koblingen er et felt // den RESERVERER. En generator som gjettet hvilket krav et Objective svarer // paa, ville laget styringsinformasjon ingen har vedtatt. ut.push( '## Styringsparametere mot tildelingsbrevets krav', '', 'Tildelingsbrevet skal sette styringsparametere for aa kunne vurdere', 'maaloppnaaelse og resultater (bestemmelsene punkt 1.5). Koblingen mellom', 'syklusens Objectives og disse parameterne staar her:', '', '| Objective | Styringsparameter i tildelingsbrevet | Krav |', '|-----------|--------------------------------------|------|', ...syklus.okrer.map((okr) => `| ${okr.tittel} | | |`), '', '[Fylles ut av virksomheten: styringsparameter og krav hentes fra', 'tildelingsbrevet. Generatoren leser bare syklusdataene og gjetter ikke', 'hvilket krav et Objective svarer paa.]', '', ); return `${ut.join('\n').replace(/\n+$/, '')}\n`; }