fix(okr): seksjons-bevisst confidence-telling i coaching-hook (patch-lane #1)

Hooken talte alle tabellrader merket At Risk/Off Track under ett. okr-framework.md:605
sier samtidig at de fleste stretch-KR BOER staa At Risk, og at permanent groent er selve
sandbagging-signalet. For en sunn aspirational-portefoelje pekte varselet dermed feil vei:
eneste maate aa dempe det paa var aa merke stretch-KR groenne -- noeyaktig det kanon
advarer mot. Fra 1.10.0 fylles kolonnen deterministisk, saa kollisjonen inntraff alltid.

Tellingen foelger naa seksjonen raden staar under:
  committed     -> At Risk OG Off Track teller
  aspirational  -> KUN Off Track ("progresjonen har stoppet opp" er reelt ogsaa for stretch)
  uplasserbar   -> telles som committed

Fallbacken er en operatoerbeslutning, ikke en default: haandskrevne status.md fra foer
1.10.0 har ingen seksjonsstruktur, og en fil som varsler for lite er farligere enn en som
varsler for mye. Samme verdi som dekningsvakten i statusrapport() haandhever paa skrivesiden.

Rad-predikatene er navngitte konstanter, og de to testfilene som leser dem ut av hookens
kilde henter dem naa ved NAVN i stedet for ved posisjon -- hooken har flere predikater enn
to, saa "det foerste og det andre literalet" ville vaert en vakt som stille plukket feil regex.

Test (7) i syklus-status.test.mjs kunne ikke bare endres fra 3 til 2: den SIMULERTE hookens
aritmetikk med skrapte rad-predikater, og en seksjons-bevisst regel er ikke uttrykkbar per
rad. Splittet i (7) rad-form og (7b) ekte ende-til-ende-telling som kjoerer hooken mot
generatorens fil paa disk.

Suite 343 -> 348. Fire mutasjoner verifisert: seksjons-bevissthet fjernet -> ROED (4),
aspirational teller aldri -> ROED (1), fallback snudd -> ROED (3), navne-forankring brutt
-> ROED (6).

Ingen versjonsbump: bugfix paa releaset kode (presedens 8a7bc08). CHANGELOGs "Kjent avvik"
under 1.10.0 er ikke slettet -- 1.10.0 BLE utgitt med avviket -- men lukket med hva som
gjelder naa.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 21:20:51 +02:00
commit 43a84d2326
5 changed files with 233 additions and 38 deletions

View file

@ -21,6 +21,8 @@ Minor-release: **det uwirede leddet**. Kjeden `/okr:rapport` → `/okr:sporing`
### Kjent avvik
- **`coaching-hook` teller committed og aspirational KR under ett når den varsler om confidence.** Hooken teller alle tabellrader merket `At Risk`/`Off Track` og sier «OBS: N KR er At Risk/Off Track — vurder tiltak eller juster forventninger». `okr-framework.md` sier samtidig at *de fleste stretch-KR bør stå At Risk*, og at permanent grønt er selve sandbagging-signalet. For en velfungerende aspirational-portefølje peker varselet derfor feil vei: eneste måte å dempe det på er å merke stretch-KR grønne. Kollisjonen er **ikke ny i art** — en håndskrevet `status.md` etter den gamle malen ble talt likt — men fra og med 1.10.0 fylles kolonnen deterministisk, så den inntreffer alltid i stedet for av og til. Fikset hører hjemme i hooken, ikke i skriveleddet, og er ført som egen post for en senere patch. Selve rapporteringen er ikke berørt: aspirational-KR står fortsatt aldri i avviks-seksjonen (låst beslutning 15).
**Lukket etter release** (patch-lane, egen økt): hooken teller nå seksjons-bevisst. Committed KR teller på både `At Risk` og `Off Track`; aspirational KR teller **kun**`Off Track`, siden «blokkere oppstår, progresjon stopper opp» er et reelt signal også for et stretch-KR, mens `At Risk` er den forventede tilstanden kanon beskriver. En rad hooken ikke kan plassere i en seksjon telles som committed — håndskrevne `status.md` fra før 1.10.0 har ingen seksjonsstruktur, og en fil som varsler for lite er farligere enn en som varsler for mye. Avviket over gjaldt altså 1.10.0 slik den ble utgitt, og gjelder ikke lenger.
## [1.9.0] - 2026-08-09
Minor-release: **«Styringssløyfa lukkes»** — kjeden fra tildelingsbrev til årsrapport dekkes nå i én sammenhengende form, i stedet for å stoppe der OKR-ene var skrevet. Åtte arbeidsstrømmer over fem execute-sesjoner (fase D, D4D8). Ingen nye npm-avhengigheter; de fire eksisterende er fortsatt exact-pinnet. Suite 197 → 315 cases.

View file

@ -9,6 +9,27 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { parseFrontmatter } from '../../lib/frontmatter.mjs';
// Rad-predikatene er NAVNGITTE konstanter fordi to testfiler leser dem ut av
// denne kilden i stedet for aa kopiere dem (tests/syklus-rapport.test.mjs,
// tests/syklus-status.test.mjs). En kopiert regex ville bare bevist at testen er
// enig med seg selv. Navne-forankring, ikke posisjon: predikatene er ikke lenger
// to, saa «det foerste og det andre literalet» ville vaert en vakt som stille
// plukket feil regex ved neste endring.
// En KR-rad i statusrapporten er en markdown-tabellrad.
const ER_KR_RAD = /^\s*\|.*\|\s*$/;
// R1 (1.8.0): den kanoniske skalaen fra okr-framework.md -- On Track / At Risk /
// Off Track. De to norske etikettene beholdes som bakover-kompatibilitet for
// status-filer skrevet foer 1.8.0. On Track matcher ingen av dem.
const FARE_ORD = /at risk|off track|i fare|blokkert/i;
// Delmengden som betyr «progresjonen har stoppet opp» (okr-framework.md:610).
// Den eneste som ogsaa gjelder aspirational KR -- se seksjons-regelen under.
const STOPPET_ORD = /off track|blokkert/i;
// Toppnivaa-overskrift. `### Undertittel` matcher IKKE: etter `##` staar `#`,
// som ikke er [ \t]. Verdi-grenen er [ \t], aldri \s (laast beslutning 14).
const ER_TOPPSEKSJON = /^[ \t]*##[ \t]+(.*)$/;
const ER_ASPIRATIONAL = /^aspirational\b/i;
const cwd = process.cwd();
const configPath = join(cwd, '.claude', 'okr.local.md');
@ -76,20 +97,34 @@ try {
try {
// M1/m1 (B2): tell status-MARKERTE tabellrader, ikke raaforekomster --
// markoer-ord i forklaringstekst/prosa skal ikke inflatere telleren.
// En KR-rad i statusrapporten er en markdown-tabellrad (`| ... |`).
//
// R1 (1.8.0): status-malen bruker den kanoniske confidence-skalaen fra
// okr-framework.md -- On Track / At Risk / Off Track. De to norske
// etikettene beholdes som bakover-kompatibilitet for status-filer skrevet
// foer 1.8.0. On Track matcher ingen av alternativene og telles ikke.
// Patch-lane #1 (S61): tellingen er SEKSJONS-BEVISST. okr-framework.md:605
// sier at de fleste stretch-KR BOER staa At Risk, og at permanent groent er
// sandbagging-signalet. Talte vi alle ikke-groenne rader under ett, ba vi
// operatoeren om tiltak for at aspirational-portefoeljen oppfoerer seg
// slik doktrinen krever -- og eneste maate aa dempe nagget paa ville vaert
// aa merke stretch-KR groenne, noeyaktig det kanon advarer mot.
//
// Regelen, som foelger av det:
// committed -> At Risk OG Off Track teller (kravet er naadd eller ikke)
// aspirational -> KUN Off Track teller; At Risk er forventet tilstand
//
// Fallback (operatoerbeslutning S61): en rad hooken ikke kan plassere
// telles som COMMITTED. Haandskrevne status.md fra foer 1.10.0 har ingen
// seksjonsstruktur, og en stille fil er den farlige feilen -- den ser
// vurdert ut uten aa vaere det. Samme verdi som dekningsvakten i
// statusrapport() haandhever paa skrivesiden.
const statusContent = readFileSync(statusPath, 'utf8');
atRiskCount = statusContent
.split('\n')
.filter(
(line) => /^\s*\|.*\|\s*$/.test(line)
&& /at risk|off track|i fare|blokkert/i.test(line),
)
.length;
let iAspirational = false;
for (const line of statusContent.split('\n')) {
const overskrift = ER_TOPPSEKSJON.exec(line);
if (overskrift) {
iAspirational = ER_ASPIRATIONAL.test(overskrift[1].trim());
continue;
}
if (!ER_KR_RAD.test(line)) continue;
if ((iAspirational ? STOPPET_ORD : FARE_ORD).test(line)) atRiskCount += 1;
}
} catch { /* skip */ }
}

View file

@ -148,6 +148,133 @@ test('at-risk telles paa kanonisk 1.8.0-vokabular (At Risk + Off Track, ikke On
});
});
// --- Seksjons-bevisst telling (patch-lane #1) ---
//
// okr-framework.md:605: «De fleste stretch-KR boer derfor starte At Risk -- ikke
// groent», og permanent groent ER sandbagging-signalet. En hook som teller ALLE
// ikke-groenne rader under ett ber derfor operatoeren om «tiltak» for at
// aspirational-portefoeljen oppfoerer seg slik doktrinen krever, og eneste maate
// aa dempe nagget paa er aa merke stretch-KR groenne -- noeyaktig det kanon
// advarer mot. Fra 1.10.0 fylles Confidence-kolonnen deterministisk av
// /okr:sporing, saa kollisjonen inntreffer alltid, ikke bare av og til.
//
// Operatoerbeslutning (S61), to ledd:
// (a) aspirational At Risk er FORVENTET og nagger ikke; aspirational Off Track
// teller, fordi «blokkere oppstaar, progresjon stopper opp» (framework:610)
// er et reelt signal ogsaa for et stretch-KR.
// (b) en rad hooken ikke kan plassere i en seksjon telles som COMMITTED.
// Haandskrevne status.md fra foer 1.10.0 har ingen seksjonsstruktur, og en
// stille fil er den farlige feilen: den ser vurdert ut uten aa vaere det.
// Samme verdi som dekningsvakten i statusrapport() haandhever.
const GENERERT_HODE = [
'# Statusrapport T2-2026',
'',
'| KR | Baseline | Target | Naa | Score | Avvik | Confidence |',
'|----|----------|--------|-----|-------|-------|------------|',
];
// Bygger en fil med generatorens seksjonsstruktur. `## Avvik som skal forklares`
// ligger MELLOM de to seksjonene i ekte output og er med her med vilje: den
// inneholder kun punktlister, og skal verken telles eller oedelegge
// seksjons-tilhoerigheten for radene under.
function generertStatus({ committed = [], aspirational = [] }) {
const ut = ['# Statusrapport T2-2026', ''];
if (committed.length > 0) {
ut.push('## Committed Key Results', '', '### Trafikksikkerhet', '', ...GENERERT_HODE.slice(2), ...committed, '');
}
ut.push(
'## Avvik som skal forklares',
'',
'- Trafikksikkerhet: KR1 -- naa 17 mot krav 20 (score 0.63)',
'',
);
if (aspirational.length > 0) {
ut.push('## Aspirational Key Results', '', '### Digitalisering', '', ...GENERERT_HODE.slice(2), ...aspirational, '');
}
return ut.join('\n');
}
function writeStatus(work, innhold) {
const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026');
mkdirSync(statusDir, { recursive: true });
writeFileSync(join(statusDir, 'status.md'), innhold);
}
test('aspirational At Risk nagger IKKE -- kanon sier stretch-KR boer staa der', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');
writeStatus(work, generertStatus({
committed: ['| KR1: Alvorlige hendelser | 0 | 0 | 0 | udefinert | Nei | On Track |'],
aspirational: [
'| KR2: Andel digitale soeknader | 60 | 85 | 63 | 0.12 | - | At Risk |',
'| KR3: Saksbehandlingstid | 14 | 5 | 11 | 0.33 | - | At Risk |',
],
}));
const out = runHook(work, '2026-06-20');
assert.doesNotMatch(
out,
/OBS:/,
'en sunn aspirational-portefoelje (At Risk) skal ikke utloese tiltaks-nagget',
);
});
});
test('aspirational Off Track teller -- progresjonen har stoppet opp', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');
writeStatus(work, generertStatus({
aspirational: [
'| KR2: Andel digitale soeknader | 60 | 85 | 63 | 0.12 | - | At Risk |',
'| KR3: Saksbehandlingstid | 14 | 5 | 11 | 0.33 | - | Off Track |',
],
}));
const out = runHook(work, '2026-06-20');
assert.match(out, /OBS: 1 KR er merket/, 'kun Off Track-raden skal telle (1), ikke At Risk-raden');
});
});
test('committed teller baade At Risk og Off Track, aspirational At Risk holdes utenfor', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');
writeStatus(work, generertStatus({
committed: [
'| KR1: Alvorlige hendelser | 0 | 0 | 0 | udefinert | Nei | Off Track |',
'| KR2: Oppgradert utrustning | 12 | 20 | 17 | 0.63 | Ja | At Risk |',
],
aspirational: ['| KR3: Andel digitale soeknader | 60 | 85 | 63 | 0.12 | - | At Risk |'],
}));
const out = runHook(work, '2026-06-20');
assert.match(out, /OBS: 2 KR er merket/, 'de to committed-radene teller; aspirational At Risk gjoer ikke');
});
});
// Fallback-beslutningen (b), som EGEN vakt. De to eldre at-risk-testene over
// treffer den ogsaa, men de handler om noe annet (rad-form og vokabular), saa en
// fremtidig endring kunne snudd fallback-semantikken uten at noen test NAVNGA
// den. Moenster: S60s M3 -- en overlappet vakt er en udekket vakt.
test('fallback: rader uten seksjonsoverskrift telles som committed, ikke bortfiltrert', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');
// Haandskrevet form fra foer 1.10.0: ingen ## Committed / ## Aspirational.
writeStatus(work, [
'# Status T2-2026',
'',
...GENERERT_HODE.slice(2),
'| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | Ja | At Risk |',
'| KR2: Oppetid | 10 | 25 | 22 | 0.80 | Nei | On Track |',
'| KR3: Tilfredshet | 60 | 90 | 65 | 0.17 | Ja | At Risk |',
'',
].join('\n'));
const out = runHook(work, '2026-06-20');
assert.match(
out,
/OBS: 2 KR er merket/,
'uplasserbare At Risk-rader maa fortsatt telle -- en stille fil er den farlige feilen',
);
});
});
test('OKR_NOW sen fase: late-coaching', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');

View file

@ -44,21 +44,19 @@ const byggFor = (bygger, opts = {}) => bygger(lesSyklus(SYKLUS_DIR), { naa: NAA,
// literalene ut av coaching-hook.mjs' egen kilde blir en fremtidig endring i
// hooken en ROED suite her, i stedet for en stille inkompatibilitet.
// Hooken er i forbidden_paths for dette steget -- forbudt aa ENDRE, ikke aa LESE.
// Hentes ved NAVN, ikke ved posisjon. Hooken ble seksjons-bevisst i S61 og har
// flere predikater enn de to rad-predikatene; «det foerste og det andre
// literalet i fila» ville da vaert en vakt som stille plukket feil regex.
function hookPredikater() {
const src = readFileSync(join(ROOT, 'hooks/scripts/coaching-hook.mjs'), 'utf8');
const funnet = [...src.matchAll(/(\/(?:[^/\\\n]|\\.)+\/[gimsuy]*)\.test\(line\)/g)].map(
(m) => m[1],
);
assert.equal(
funnet.length,
2,
`parser-sanity: forventet 2 rad-predikater i coaching-hook.mjs, fant ${funnet.length}`,
);
const kompiler = (lit) => {
const hent = (navn) => {
const m = src.match(new RegExp(`^const ${navn} = (/(?:[^/\\\\\\n]|\\\\.)+/[gimsuy]*);`, 'm'));
assert.ok(m, `parser-sanity: fant ikke konstanten ${navn} i coaching-hook.mjs`);
const lit = m[1];
const siste = lit.lastIndexOf('/');
return new RegExp(lit.slice(1, siste), lit.slice(siste + 1));
};
return { radForm: kompiler(funnet[0]), fareOrd: kompiler(funnet[1]) };
return { radForm: hent('ER_KR_RAD'), fareOrd: hent('FARE_ORD') };
}
const erKrRad = (linje) => /^\s*\|/.test(linje) && !/^\s*\|[\s|:-]+\|\s*$/.test(linje)

View file

@ -20,7 +20,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -71,15 +71,17 @@ const utenConfidence = (rad) => rad.replace(/\|[^|]*\|\s*$/, '|');
// Hookens EGNE predikater, lest fra kilden. En kopiert regex ville bevist at
// testen er enig med seg selv (moenster: syklus-rapport.test.mjs).
// Hentes ved NAVN, ikke ved posisjon (se samme helper i syklus-rapport.test.mjs).
function hookPredikater() {
const src = readFileSync(join(ROOT, 'hooks/scripts/coaching-hook.mjs'), 'utf8');
const funnet = [...src.matchAll(/(\/(?:[^/\\\n]|\\.)+\/[gimsuy]*)\.test\(line\)/g)].map((m) => m[1]);
assert.equal(funnet.length, 2, `parser-sanity: forventet 2 rad-predikater, fant ${funnet.length}`);
const kompiler = (lit) => {
const hent = (navn) => {
const m = src.match(new RegExp(`^const ${navn} = (/(?:[^/\\\\\\n]|\\\\.)+/[gimsuy]*);`, 'm'));
assert.ok(m, `parser-sanity: fant ikke konstanten ${navn} i coaching-hook.mjs`);
const lit = m[1];
const siste = lit.lastIndexOf('/');
return new RegExp(lit.slice(1, siste), lit.slice(siste + 1));
};
return { radForm: kompiler(funnet[0]), fareOrd: kompiler(funnet[1]) };
return { radForm: hent('ER_KR_RAD'), fareOrd: hent('FARE_ORD') };
}
// --- (1) LOAD-BEARING: aritmetikken kommer fra generatoren, aldri fra input ---
@ -149,21 +151,52 @@ test('(6b) de tre kanoniske etikettene godtas', () => {
});
// --- (7) Hook-kontrakten: fila MAA vaere tellbar for coaching-hook.mjs ---
// MERK (S60, kjent avvik): tallet 3 under koder hookens NAAVAERENDE oppfoersel,
// ikke den oenskede. En av de tre radene er et aspirational KR, og
// okr-framework.md:605 sier at de fleste stretch-KR BOER staa At Risk -- saa
// hookens «OBS: N KR er At Risk/Off Track, vurder tiltak» peker feil vei for en
// sunn aspirational-portefoelje. Fikset hoerer hjemme i hooken (patch-lane), og
// naar det kommer skal denne assertionen endres MED den. Testen her vokter at
// fila er TELLBAR, ikke at 3 er riktig svar.
test('(7) hooken kan telle radene, og teller kun de ikke-groenne', () => {
// Rad-FORMEN er det generatoren skylder hooken: hver KR-rad maa matche hookens
// eget rad-predikat. Hva hooken saa velger aa telle er hookens sak, og maales i
// (7b) ved aa kjoere den.
test('(7) hver KR-rad matcher hookens rad-predikat', () => {
const { radForm, fareOrd } = hookPredikater();
const rader = krRader(bygg());
const utellbare = rader.filter((l) => !radForm.test(l));
assert.deepEqual(utellbare, [], `rader hooken ikke kan telle:\n${utellbare.join('\n')}`);
// FULL har 1 Off Track + 2 At Risk = 3 ikke-groenne, 1 On Track.
const talt = rader.filter((l) => radForm.test(l) && fareOrd.test(l));
assert.equal(talt.length, 3, 'hooken ville talt feil antall KR i fare');
// Fare-ordene traeffer faktisk fila: uten dette kunne (7b) talt 0 av feil grunn.
assert.ok(rader.some((l) => fareOrd.test(l)), 'ingen rad baerer et fare-ord i det hele tatt');
});
// --- (7b) Patch-lane #1: hookens telling er seksjons-bevisst ---
//
// S60 lot denne testen assertere 3 og skrev i klartekst at tallet kodet hookens
// NAAVAERENDE oppfoersel, ikke den oenskede: en av de tre radene er et
// aspirational KR, og okr-framework.md:605 sier at de fleste stretch-KR BOER
// staa At Risk. Nagget pekte dermed feil vei for en sunn portefoelje.
//
// Assertionen kunne ikke bare endres fra 3 til 2: den SIMULERTE hookens
// aritmetikk med to skrapte rad-predikater, og en seksjons-bevisst regel er
// ikke uttrykkbar per rad. Den ekte kontrakten maales derfor ende-til-ende --
// generatorens fil, paa disk, lest av hooken selv.
//
// FULL gir: committed Off Track + committed At Risk (teller) og aspirational
// At Risk + aspirational On Track (teller ikke) = 2.
test('(7b) hooken teller committed i fare, men ikke et aspirational KR paa At Risk', (t) => {
const work = mkdtempSync(join(tmpdir(), 'okr-status-hook-'));
t.after(() => rmSync(work, { recursive: true, force: true }));
const syklusDir = join(work, '.claude', 'okr', 'syklus', 'T1-2026');
mkdirSync(syklusDir, { recursive: true });
writeFileSync(join(work, '.claude', 'okr.local.md'), '---\nid: "T1-2026"\n---\n');
writeFileSync(join(syklusDir, 'status.md'), bygg());
// Uke ~8 av T1 (jan-apr) -> midtveis-fasen, den ene som rapporterer telleren.
const r = spawnSync(process.execPath, [join(ROOT, 'hooks/scripts/coaching-hook.mjs')], {
cwd: work,
encoding: 'utf8',
env: { ...process.env, OKR_NOW: '2026-02-20' },
});
assert.equal(r.status, 0, `hooken feilet: ${r.stderr}`);
assert.match(
r.stdout,
/OBS: 2 KR er merket/,
'aspirational At Risk skal ikke telle -- kanon sier stretch-KR boer staa der',
);
});
// --- (8-12) CLI-kontrakten ---