// 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. const formatAvvik = (kr, erCommitted) => { if (!erCommitted) return '-'; return kr.naa >= kr.target ? '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)} | |`; } // 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}`, '', ...TABELLHODE); for (const kr of krer) linjer.push(krRad(kr, erCommitted)); linjer.push(''); } 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}.`; } /** * 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 = {}) { if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) { throw new Error('tertialrapport: forventet en syklus fra lesSyklus()'); } if (syklus.okrer.length === 0) { throw new Error(`tertialrapport: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim()); } const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString(); 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}`, '', // 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.', '', ]; 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, ); } if (aspirational.antallKr > 0) { ut.push( '## Aspirational Key Results', '', // Governance-invariant 1: typen merkes, slik at 0.7 ikke leses som svikt. '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.', '', ...aspirational.linjer, ); const snitt = aspirationalSnitt(syklus.okrer); if (snitt) ut.push(snitt, ''); } return `${ut.join('\n').replace(/\n+$/, '')}\n`; }