feat(okr): deterministisk skriveledd for status.md (1.10.0)

Kjeden /okr:rapport -> /okr:sporing -> status.md var beskrevet i prosa, men
manglet et mekanisk ledd: ingenting foerte en generert rad inn i status.md --
fila coaching-hook teller at-risk-KR i, /okr:moeter bygger agenda fra, og
/okr:oppsett arkiver leser sluttscorer fra. Rot-aarsaken var at HELE
sporings-stien var lesende (ingen Write/Edit/Bash i allowed-tools).

scripts/syklus-status.mjs tar confidence som JSON paa stdin og bygger radene
gjennom statusrapport() i lib/syklus-rapport.mjs -- samme kode som
tertialrapporten. Aritmetikken kan derfor ikke drifte fra /okr:rapport, fordi
det ikke finnes en andre implementasjon av den. En LLM skriver ikke av et tall
paa veien, og en maskin utleder ikke et trafikklys: beslutning 9 staar, presist
formulert som at generatoren PLASSERER en etikett den faar inn som data og
UTLEDER aldri en.

Haandhevet i kode, ikke i prosa: lukket vokabular (On Track/At Risk/Off Track),
full dekning (en tom confidence-celle ville lest som groenn hos hver konsument),
typo-fangst paa ukjente KR-navn, og kuratert-fil-vern -- en status.md uten
provenans-noekkelen `kilde: sporing` er haandholdt og overskrives aldri (samme
mekanikk som innboks-write bruker med `kilde: innboks`).

Suite 329 -> 343. Fem vakter mutasjons-verifisert: rad-drift mot
tertialrapporten, vokab, dekning (baade alene og sammen med vokab-vakten som
overlapper den), og kuratert-vernet.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 20:56:26 +02:00
commit 3a0cbcfbf9
13 changed files with 618 additions and 50 deletions

View file

@ -13,6 +13,16 @@
// doktrinen forbyr. Confidence-kolonnen staar tom by design og fylles av
// /okr:sporing, der skjoennet hoerer hjemme.
//
// PLASSERE er ikke det samme som AA EIE (S60). `statusrapport` nederst i fila
// emitterer etiketter i Confidence-kolonnen -- men kun etiketter den faar inn
// som data fra /okr:sporing, og den kaster heller enn aa fylle en celle den
// ikke har faatt en verdi for. Skillet er hele poenget med beslutning 9:
// aritmetikken skal ikke re-typas av en LLM, og vurderingen skal ikke utledes
// av en maskin. Rapportformene (tertial/aarsrapport/etatsstyring) utleder
// aldri confidence, og vakt (5c) i tests/syklus-rapport.test.mjs holder dem
// til det. `statusrapport` staar bevisst UTENFOR den listen, fordi den ikke
// utleder noe -- den plasserer et skjoenn som allerede er utoevd.
//
// 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").
@ -50,27 +60,30 @@ const formatAvvik = (kr, erCommitted) => {
return score >= 1 ? 'Nei' : 'Ja';
};
function krRad(kr, erCommitted) {
function krRad(kr, erCommitted, confidence = '') {
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)} | |`;
// Siste celle (Confidence) staar tom for rapportformene -- se seem-notatet
// over. Er den fylt, kommer verdien fra /okr:sporing via `statusrapport`, og
// resten av raden er byte for byte den samme.
const siste = confidence === '' ? ' ' : ` ${confidence} `;
return `| ${kr.navn} | ${kr.baseline} | ${kr.target} | ${kr.naa} | ${score} | ${formatAvvik(kr, erCommitted)} |${siste}|`;
}
const krTabell = (krer, erCommitted) => [
const krTabell = (krer, erCommitted, hentConfidence = () => '') => [
...TABELLHODE,
...krer.map((kr) => krRad(kr, erCommitted)),
...krer.map((kr) => krRad(kr, erCommitted, hentConfidence(kr))),
];
// 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) {
function seksjon(okrer, type, erCommitted, hentConfidence = () => '') {
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), '');
linjer.push(`### ${okr.tittel}`, '', ...krTabell(krer, erCommitted, hentConfidence), '');
}
return { linjer, antallKr };
}
@ -178,19 +191,35 @@ function krevSyklus(syklus, hvor) {
// 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 = [
const SKALA_TALL = [
// 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}.`,
];
const SKALAFORKLARING = [
...SKALA_TALL,
'',
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
'',
];
// Samme tall-avsnitt, men i statusrapporten ER confidence fylt. Setningen maa
// da si hvor den kom fra, ellers leser mottakeren et trafikklys uten aa vite om
// det er vurdert eller regnet ut.
const SKALAFORKLARING_STATUS = [
...SKALA_TALL,
'',
'Confidence er satt av /okr:sporing og gjengitt her uendret. Den er en',
'sannsynlighetsvurdering mot den kanoniske skalaen i okr-framework.md, ikke',
'en funksjon av score, og er aldri utledet av tallene i tabellene.',
'',
];
const klokke = (opts) => opts.naa || process.env.OKR_NOW || new Date().toISOString();
/**
@ -451,3 +480,124 @@ export function etatsstyringsunderlag(syklus, opts = {}) {
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
}
// --- Statusrapport (S60): det uwirede leddet ---
//
// Kjeden er generator -> /okr:sporing -> status.md, og dette er siste ledd.
// `status.md` er fila coaching-hook.mjs teller at-risk-rader i, /okr:moeter
// bygger check-in-agenda fra, og /okr:oppsett arkiver leser sluttscorer fra.
// Foer S60 fantes det ingen mekanisk vei dit: malen sto i commands/sporing.md,
// og en LLM maatte skrive av tallene generatoren nettopp hadde beregnet.
//
// Derfor tar denne funksjonen confidence som DATA og bygger radene med samme
// krRad som rapportformene. Aritmetikken kan ikke drifte fra tertialrapporten,
// fordi det ikke finnes en andre implementasjon av den.
// Lukket vokabular. okr-framework.md:564 er eneste sannhetskilde for confidence
// og forbyr andre filer aa definere egne nivaaer; her betyr det at et
// nabo-uttrykk som "Groenn" eller "on-track" avvises i stedet for aa bli tolket.
// Etikettene holdes ASCII-rene som resten av den maskingenererte flaten --
// hooken matcher paa ordene, aldri paa emoji.
const CONFIDENCE_VOKAB = ['On Track', 'At Risk', 'Off Track'];
// Inputen er noekklet paa KR-navn. To KR med samme navn ville da delt etikett
// uten at noen kunne se det, saa kollisjonen avvises foer den blir en stille
// feilrapportering.
function confidenceOppslag(syklus, confidence, hvor) {
if (confidence === null || typeof confidence !== 'object' || Array.isArray(confidence)) {
throw new Error(`${hvor}: confidence mangler -- forventet et oppslag fra KR-navn til etikett`);
}
const krNavn = [];
for (const okr of syklus.okrer) {
for (const kr of okr.krer) krNavn.push(kr.navn);
}
const duplikat = krNavn.find((navn, i) => krNavn.indexOf(navn) !== i);
if (duplikat) {
throw new Error(`${hvor}: to KR deler navnet "${duplikat}" -- confidence kan ikke tilordnes entydig`);
}
const kjent = new Set(krNavn);
for (const navn of Object.keys(confidence)) {
if (!kjent.has(navn)) {
throw new Error(`${hvor}: ukjent KR-navn i confidence: "${navn}" finnes ikke i syklusen`);
}
}
const mangler = krNavn.filter((navn) => confidence[navn] === undefined);
if (mangler.length > 0) {
throw new Error(`${hvor}: mangler confidence for ${mangler.length} KR: ${mangler.map((n) => `"${n}"`).join(', ')}`);
}
for (const navn of krNavn) {
const etikett = confidence[navn];
if (!CONFIDENCE_VOKAB.includes(etikett)) {
throw new Error(
`${hvor}: ugyldig confidence-etikett ${JSON.stringify(etikett)} for "${navn}". `
+ `Lovlige verdier: ${CONFIDENCE_VOKAB.join(' | ')}`,
);
}
}
return (kr) => confidence[kr.navn];
}
/**
* Bygger statusrapporten for en syklus lest av lesSyklus(), med confidence satt
* av /okr:sporing.
*
* @param {object} syklus struktur fra lesSyklus()
* @param {{naa?: string, confidence?: Record<string, string>}} [opts]
* confidence er et oppslag fra KR-navn til en etikett i det lukkede
* vokabularet. Hvert KR i syklusen MAA ha en verdi -- en tom celle ville gitt
* en fil som ser vurdert ut uten aa vaere det, og som konsumentene teller som
* groenn.
* @returns {string} markdown
*/
export function statusrapport(syklus, opts = {}) {
krevSyklus(syklus, 'statusrapport');
const naa = klokke(opts);
const hentConfidence = confidenceOppslag(syklus, opts.confidence, 'statusrapport');
const committed = seksjon(syklus.okrer, 'committed', true, hentConfidence);
const aspirational = seksjon(syklus.okrer, 'aspirational', false, hentConfidence);
if (committed.antallKr + aspirational.antallKr === 0) {
throw new Error(`statusrapport: syklusen ${syklus.id} inneholder ingen KR aa rapportere`);
}
const ut = [
`# Statusrapport ${syklus.id}`,
'',
`Generert: ${naa}`,
'',
...SKALAFORKLARING_STATUS,
];
if (committed.antallKr > 0) {
ut.push(
'## Committed Key Results',
'',
'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',
'',
...ASPIRATIONAL_RAMME,
'',
...aspirational.linjer,
);
const snitt = aspirationalSnitt(syklus.okrer);
if (snitt) ut.push(snitt, '');
}
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
}