fix(okr): skjerp separasjonsvakten og lukk tre defekter i D6-leveransen

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 07:09:35 +02:00
commit d2ac153a68
5 changed files with 100 additions and 14 deletions

View file

@ -146,9 +146,11 @@ Drop documents — tildelingsbrev PDFs, virksomhetsplan docx, meeting notes —
> /okr:rapport
```
Generates a tertial report straight from the KR numbers in the cycle's `okr-*.md` frontmatter — deterministic, so the same data yields byte-identical output. Committed and aspirational Key Results are reported **separately**, never aggregated into one figure: the two are measured against different standards, and a combined average is ambiguous by construction. The generator owns the arithmetic, the table structure and the formatting invariants (score is stated on its 01.0 scale, never as percent goal attainment; a committed KR below target is flagged as a deviation, not a middling score).
Generates a report straight from the KR numbers in the cycle's `okr-*.md` frontmatter — deterministic, so the same data yields byte-identical output. Three forms, one command argument: `tertial` for interim reporting, `arsrapport` for part III of the statutory annual report ("Årets aktiviteter og resultater"), and `etatsstyring` for the agency-governance meeting brief. Committed and aspirational Key Results are reported **separately** in all three, never aggregated into one figure: the two are measured against different standards, and a combined average is ambiguous by construction. The generator owns the arithmetic, the table structure and the formatting invariants (score is stated on its 01.0 scale, never as percent goal attainment; a committed KR below target is flagged as a deviation, not a middling score).
It does **not** own confidence. The Confidence column is left empty by design and filled in during `/okr:sporing`, because confidence is a probability judgement — the canon designates one source of truth for the On Track / At Risk / Off Track scale and explicitly rejects deriving it mechanically from score. Scores themselves are never written to disk; they are recomputed from `baseline`/`target`/`naa` each run so a second, drifting source of truth cannot arise.
An aspirational KR with a low score is **never** presented as a deviation — landing near 0.7 is expected attainment for that type, and letting the aspirational score carry consequences in reporting would re-bundle target and forecast, which is what makes sandbagging structurally rational in the next cycle.
It does **not** own confidence, nor the assessment fields it reserves — the per-Objective evaluation of goal attainment in the annual report, and the mapping from Objectives to the letter of allocation's governance parameters. Those are left as marked placeholders because they require judgement or sources the generator does not read. The Confidence column is likewise left empty by design and filled in during `/okr:sporing`, because confidence is a probability judgement — the canon designates one source of truth for the On Track / At Risk / Off Track scale and explicitly rejects deriving it mechanically from score. Scores themselves are never written to disk; they are recomputed from `baseline`/`target`/`naa` each run so a second, drifting source of truth cannot arise.
### Help and Maintenance

View file

@ -26,7 +26,7 @@ kommandoene for det temaet. Ellers vis full oversikt.
| `/okr:analyse` | Kryss-syklus-analyse med Mermaid-trendvisualisering |
| `/okr:oppsett` | Konfigurer plugin: onboarding (`full`/`mvp`), `arkiver`, `oppdater`, `vis` |
| `/okr:innboks` | Ingest dokumenter fra innboksen (`.claude/okr/innboks/`) til kunnskapstreet |
| `/okr:rapport` | Generer tertialrapport fra syklusdata — committed og aspirational hver for seg |
| `/okr:rapport` | Generer rapport fra syklusdata: `tertial`, `arsrapport` (del III) eller `etatsstyring` — committed og aspirational hver for seg |
| `/okr:export` | Eksporter OKR-dokumenter til print-klar PDF (ledelse/Riksrevisjon) |
| `/okr:freshen-references` | KB-selvevaluator + currency-polling av offentlige kilder |
| `/okr:help` | Denne oversikten — kommandoer, agenter, anbefalt arbeidsflyt |

View file

@ -127,14 +127,24 @@ function avvikListe(okrer) {
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. Aspirational',
'KR er holdt utenfor med vilje -- se forventningen til aspirational under.',
'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.']),
'',
@ -410,8 +420,11 @@ export function etatsstyringsunderlag(syklus, opts = {}) {
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.
ut.push('## Aspirational Key Results - forventning', '', ...ASPIRATIONAL_RAMME, '');
// 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,

View file

@ -175,12 +175,21 @@ test('(a2) F-c: ingen parallelle confidence-etikettsett utenfor kanon', () => {
// i kalkulatoren), og aa dra den inn her ville blandet to spoersmaal i en vakt.
//
// To former, fordi bruddet har to skrivemaater:
// ETIKETT -- "Samlet score: <verdi>" i en malblokk. De andre aksene gaar klar:
// rubrikkens 0-10-skala har ingen verdi paa linjen, og det arkiverte
// datafeltet `samlet_score` er snake_case, ikke "samlet score".
// ETIKETT -- "Samlet score" sammen med en VERDI paa linjen (tall, X-plassholder
// eller utfyllingsfelt). Verdien og etiketten bindes bevisst IKKE
// til hverandre posisjonelt: "Samlet score (committed +
// aspirational): 0.53" har noeyaktig det aggregatet vakten finnes
// for, og et moenster som krevde verdien rett etter etiketten slapp
// den gjennom (mutasjons-verifisert -- den gjorde det).
// INSTRUKS -- "samlet (vektet gjennomsnitt)", som ber leseren lage aggregatet.
// En linje som navngir typen er per definisjon differensiert og gaar klar.
const AGGREGAT_ETIKETT = /samlet\s+score[:*\s]*[[\]0-9X_]/i;
//
// De andre aksene gaar klar: rubrikkens 0-10-skala har ingen verdi paa linjen,
// og det arkiverte datafeltet `samlet_score` er snake_case, ikke "samlet score".
// Mermaids akselabel rendrer det samme datafeltet i Title Case og unntas
// eksplisitt -- arkiv-kontrakten er en annen sak enn maleneS rapportform.
const AGGREGAT_ORD = /samlet\s+score/i;
const VERDISLOT = /\d[.,]\d|\d\.XX|_{3,}|\bX{1,2}\b/;
const MERMAID_AKSE = /^\s*[xy]-axis\b/;
const AGGREGAT_INSTRUKS = /\bsamlet\b[^\n]{0,25}vektet\s+gjennomsnitt/i;
test('(k) separasjonsregelen: ingen mal emitterer et udifferensiert samlet-aggregat', () => {
@ -190,8 +199,14 @@ test('(k) separasjonsregelen: ingen mal emitterer et udifferensiert samlet-aggre
const violations = [];
for (const f of maler) {
readDoc(f).split('\n').forEach((line, i) => {
if (/committed|aspirational/i.test(line)) return;
if (AGGREGAT_ETIKETT.test(line) || AGGREGAT_INSTRUKS.test(line)) {
// Differensiert = linjen navngir NOEYAKTIG EN type. En linje som nevner
// begge ("Samlet score (committed + aspirational)") er nettopp det
// udifferensierte aggregatet vakten finnes for -- den skal ikke slippe
// gjennom paa aa ha uttalt ordene.
if (/committed/i.test(line) !== /aspirational/i.test(line)) return;
if (MERMAID_AKSE.test(line)) return;
const etikett = AGGREGAT_ORD.test(line) && VERDISLOT.test(line);
if (etikett || AGGREGAT_INSTRUKS.test(line)) {
violations.push(`${f}:${i + 1}: ${line.trim()}`);
}
});

View file

@ -383,6 +383,33 @@ for (const [navn, bygger] of FORMER) {
});
}
// Avviks-seksjonen viser til «forventningen til aspirational under». Den
// seksjonen finnes bare naar syklusen HAR aspirational-KR. En syklus med bare
// committed -- fullt lovlig, og typisk for en ren etterlevelses-syklus -- ville
// faatt et styringsdokument som peker paa en seksjon som ikke er der. Fixturen
// kan ikke fange det; den har begge typene.
const KUN_COMMITTED = {
id: 'T-kun-committed',
okrer: [{
fil: 'okr-etterlevelse.md',
tittel: 'Etterlevelse',
krer: [{ n: 1, navn: 'Palagte tilsyn gjennomfoert (antall)', baseline: 0, target: 12, naa: 9, type: 'committed' }],
}],
};
for (const [navn, bygger] of FORMER) {
test(`(10f/${navn}) ingen henvisning til en aspirational-seksjon som ikke finnes`, () => {
const md = bygger(KUN_COMMITTED, { naa: NAA });
const viser = /forventningen til aspirational under/i.test(md);
const finnes = /^##+\s+Aspirational/m.test(md);
assert.equal(
viser && !finnes,
false,
'avviks-seksjonen viser til en aspirational-seksjon rapporten ikke har',
);
});
}
// Generatoren handhever regelen; governance-fila baerer begrunnelsen. Staar
// begrunnelsen bare i koden, kan en leser overproeve vakten uten aa moete
// argumentet -- og det er nettopp en Riksrevisjon-vendt leser som vil sporre.
@ -586,6 +613,35 @@ test('(7i) CLI: etatsstyring skrives til sin egen fil', async (t) => {
assert.equal(lesSyklus(dir).okrer.length, 2, 'rapporten ble lest som en OKR');
});
// Fixturens historikk er FLAT prosa-filer. Den EKTE formen kommer fra
// /okr:oppsett arkiver (commands/oppsett.md:514-518), som lager BEGGE deler:
// katalogen `historikk/[id]/` med syklusens filer, OG den flate
// `historikk/[id]-resultater.md` med sammendraget. Sammendraget er riktig
// granularitet for et flerarig perspektiv; katalogen er raamaterialet under.
// Uten denne testen ville fixtur, kode og (7h) vaert tre artefakter som er enige
// med hverandre -- og ingen av dem den kommandoen som faktisk skriver treet.
test('(7j) historikk leses fra arkiveringens EKTE form: sammendragsfil, ikke katalog', async (t) => {
const { rmSync, mkdirSync, mkdtempSync, writeFileSync, copyFileSync, readdirSync, readFileSync: les } = await import('node:fs');
const { tmpdir } = await import('node:os');
const rot = mkdtempSync(join(tmpdir(), 'okr-arkiv-'));
const dir = join(rot, 'syklus', 'T1-2026');
const hist = join(rot, 'historikk');
mkdirSync(dir, { recursive: true });
mkdirSync(join(hist, 'T3-2025'), { recursive: true });
for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f));
// Kopien av syklusen ligger i katalogen; sammendraget flatt ved siden av.
writeFileSync(join(hist, 'T3-2025', 'okr-gammel.md'), ['---', 'type: OKR', 'title: Gammel OKR', '---', ''].join('\n'));
writeFileSync(join(hist, 'T3-2025-resultater.md'), ['---', 'type: Retrospektiv', 'title: Resultater T3-2025', '---', '# Resultater T3-2025', ''].join('\n'));
t.after(() => rmSync(rot, { recursive: true, force: true }));
assert.equal((await kjoerCli([dir, 'arsrapport'])).code, 0);
const kropp = les(join(dir, 'rapport-arsrapport.md'), 'utf8');
assert.match(kropp, /Resultater T3-2025/, 'sammendragsfila fra arkiveringen ble ikke funnet');
// Den arkiverte syklus-katalogen er raamateriale, ikke en post i det flerarige
// perspektivet -- og `[id]/` ender ikke paa .md, saa den filtreres bort som seg selv.
assert.doesNotMatch(kropp, /Gammel OKR/, 'generatoren gikk inn i den arkiverte syklus-katalogen');
});
// Idempotens: rapporten skal kunne kjoeres om igjen uten aa endre bundlen, og
// uten aa bli lest som en OKR neste gang (filnavnet starter ikke med okr-).
test('(7f) CLI: to kjoeringer gir byte-identisk fil', async (t) => {