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:
parent
9033f37b63
commit
43a84d2326
5 changed files with 233 additions and 38 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue