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.
284 lines
11 KiB
JavaScript
284 lines
11 KiB
JavaScript
// coaching-hook.test.mjs
|
|
// Tester coaching-hook (SC4): graceful-exit-grener + gyldig-syklus-emisjon +
|
|
// deterministisk fase via OKR_NOW klokke-seam. Spawner hooken som subprosess
|
|
// med kontrollert cwd + env. Zero npm deps. Coaching leser kun cwd, saa én
|
|
// temp-dir per test holder. Moenster: tests/inject-okr-context.test.mjs.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HOOK = join(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'hooks',
|
|
'scripts',
|
|
'coaching-hook.mjs',
|
|
);
|
|
|
|
function writeConfig(workDir, body) {
|
|
const p = join(workDir, '.claude');
|
|
mkdirSync(p, { recursive: true });
|
|
writeFileSync(join(p, 'okr.local.md'), body);
|
|
}
|
|
|
|
function runHook(cwd, okrNow) {
|
|
// execFileSync returnerer stdout; hooken avslutter alltid med exit 0.
|
|
const env = { ...process.env };
|
|
if (okrNow) env.OKR_NOW = okrNow;
|
|
else delete env.OKR_NOW;
|
|
return execFileSync('node', [HOOK], { cwd, env, encoding: 'utf8' });
|
|
}
|
|
|
|
function withWork(fn) {
|
|
const work = mkdtempSync(join(tmpdir(), 'okrcoach-'));
|
|
try {
|
|
fn(work);
|
|
} finally {
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('ingen config: exit 0, tom stdout (graceful)', () => {
|
|
withWork((work) => {
|
|
const out = runHook(work);
|
|
assert.equal(out.trim(), '', 'ingen config -> tom stdout, ingen blokkering');
|
|
});
|
|
});
|
|
|
|
test('config uten id: exit 0, tom stdout', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nnavn: "Org"\n---\n');
|
|
const out = runHook(work);
|
|
assert.equal(out.trim(), '', 'manglende id -> tom stdout');
|
|
});
|
|
});
|
|
|
|
test('ugyldig id: exit 0, tom stdout', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nid: "ugyldig"\n---\n');
|
|
const out = runHook(work);
|
|
assert.equal(out.trim(), '', 'id som ikke matcher T/Q-moenster -> tom stdout');
|
|
});
|
|
});
|
|
|
|
test('gyldig syklus: emitterer systemMessage', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nid: "T2-2026"\n---\n');
|
|
const out = runHook(work);
|
|
assert.match(out, /OKR coaching/, 'gyldig syklus skal emittere coaching-melding');
|
|
});
|
|
});
|
|
|
|
test('OKR_NOW tidlig fase: early-coaching', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nid: "T2-2026"\n---\n');
|
|
const out = runHook(work, '2026-05-05');
|
|
assert.match(out, /Tidlig i syklusen/, 'uke 1 av T2 -> tidlig fase');
|
|
});
|
|
});
|
|
|
|
test('OKR_NOW midtveis fase: mid-coaching', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nid: "T2-2026"\n---\n');
|
|
const out = runHook(work, '2026-06-20');
|
|
assert.match(out, /Midtveis i syklusen/, 'uke ~8 av T2 -> midtveis fase');
|
|
});
|
|
});
|
|
|
|
test('at-risk telles per status-RAD (tabell), ikke raaforekomster i prosa (B2/M1)', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nid: "T2-2026"\n---\n');
|
|
const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026');
|
|
mkdirSync(statusDir, { recursive: true });
|
|
// 2 markerte rader + markoer-ord i forklaring OG prosa: kun radene skal telle.
|
|
writeFileSync(join(statusDir, 'status.md'), [
|
|
'# Status T2-2026',
|
|
'',
|
|
'Merk: "I fare" betyr flat trend; "Blokkert" betyr ingen fremgang.',
|
|
'',
|
|
'| KR | Baseline | Maal | Naa | Score | Status |',
|
|
'|----|----------|------|-----|-------|--------|',
|
|
'| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | I fare |',
|
|
'| KR2: Oppetid | 10 | 25 | 22 | 0.80 | Paa sporet |',
|
|
'| KR3: Tilfredshet | 60% | 90% | 65% | 0.17 | Blokkert |',
|
|
'',
|
|
'KR1 er i fare fordi trenden er flat. KR3 er blokkert av leverandoer.',
|
|
'',
|
|
].join('\n'));
|
|
const out = runHook(work, '2026-06-20'); // midtveis -> at-risk rapporteres
|
|
assert.match(out, /OBS: 2 KR er merket/, '2 markerte rader -> teller noeyaktig 2 (ikke 6)');
|
|
});
|
|
});
|
|
|
|
// R1 (review.md af16d5e4): 1.8.0 skrev om status-malen (commands/sporing.md:86-88) til den
|
|
// KANONISKE confidence-skalaen (okr-framework.md:389-392) — On Track / At Risk / Off Track.
|
|
// Hooken talte fortsatt kun det gamle norske vokabularet, saa nudgen doede stille under 1.8.0.
|
|
// Denne casen mater malen slik den faktisk genereres i dag; casen over beholder det gamle
|
|
// vokabularet og daekker dermed bakover-kompatibilitet for arkiverte status-filer.
|
|
test('at-risk telles paa kanonisk 1.8.0-vokabular (At Risk + Off Track, ikke On Track)', () => {
|
|
withWork((work) => {
|
|
writeConfig(work, '---\nid: "T2-2026"\n---\n');
|
|
const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026');
|
|
mkdirSync(statusDir, { recursive: true });
|
|
// Emoji som \u-escapes: test-kilden holdes ASCII-ren (bash 3.2 set -u multibyte).
|
|
const GUL = '\u{1F7E1}';
|
|
const GROENN = '\u{1F7E2}';
|
|
const ROED = '\u{1F534}';
|
|
writeFileSync(join(statusDir, 'status.md'), [
|
|
'# Status T2-2026',
|
|
'',
|
|
'| KR | Baseline | Target | Naa | Score | Status |',
|
|
'|----|----------|--------|-----|-------|--------|',
|
|
`| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | At Risk ${GUL} |`,
|
|
`| KR2: Fartshumper installert | 0% | 100% | 60% | 0.60 | On Track ${GROENN} |`,
|
|
`| KR3: Foreldre-tilfredshet | 60% | 90% | 65% | 0.17 | Off Track ${ROED} |`,
|
|
'',
|
|
].join('\n'));
|
|
const out = runHook(work, '2026-06-20');
|
|
assert.match(
|
|
out,
|
|
/OBS: 2 KR er merket/,
|
|
'At Risk + Off Track teller (2); On Track skal IKKE telle',
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- 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');
|
|
const out = runHook(work, '2026-08-25');
|
|
assert.match(out, /sluttspurt/, 'uke ~16 av T2 -> sen fase');
|
|
});
|
|
});
|