okr/hooks/scripts/coaching-hook.mjs
Kjell Tore Guttormsen 43a84d2326 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.
2026-08-10 21:20:51 +02:00

191 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
// coaching-hook.mjs
// Event: SessionStart
// Purpose: Proactive coaching based on cycle position and OKR status.
// Zero npm dependencies. Target execution: <200ms.
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');
if (!existsSync(configPath)) {
process.exit(0);
}
try {
const content = readFileSync(configPath, 'utf8');
const { raw, get } = parseFrontmatter(content);
if (raw === null) process.exit(0);
const cycleId = get('id');
const fase = get('fase');
if (!cycleId) process.exit(0);
// Parse cycle type and dates
const cycleMatch = cycleId.match(/^([TQ])(\d)-(\d{4})$/);
if (!cycleMatch) process.exit(0);
const [, type, num, year] = cycleMatch;
const cycleNum = parseInt(num, 10);
const cycleYear = parseInt(year, 10);
// Calculate cycle start/end month (0-indexed)
let startMonth, endMonth, totalWeeks;
if (type === 'T') {
startMonth = (cycleNum - 1) * 4; // T1=0(Jan), T2=4(May), T3=8(Sep)
endMonth = startMonth + 3; // T1=3(Apr), T2=7(Aug), T3=11(Dec)
totalWeeks = 16;
} else {
startMonth = (cycleNum - 1) * 3;
endMonth = startMonth + 2;
totalWeeks = 13;
}
// Testable clock seam: OKR_NOW (ISO date) overrides the wall clock so the
// phase branches (early/mid/late) can be asserted deterministically.
const now = process.env.OKR_NOW ? new Date(process.env.OKR_NOW) : new Date();
const cycleStart = new Date(cycleYear, startMonth, 1);
const cycleEnd = new Date(cycleYear, endMonth + 1, 0); // last day of end month
// Calculate current week in cycle
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
const elapsed = now - cycleStart;
const currentWeek = Math.max(1, Math.min(totalWeeks, Math.ceil(elapsed / msPerWeek)));
// Determine phase
let phase;
if (now < cycleStart || now > cycleEnd) {
phase = 'between';
} else if (currentWeek <= 4) {
phase = 'early';
} else if (currentWeek <= totalWeeks - 4) {
phase = 'mid';
} else {
phase = 'late';
}
// Check for at-risk KR in status
let atRiskCount = 0;
const okrDir = join(cwd, '.claude', 'okr');
const statusPath = join(okrDir, 'syklus', cycleId, 'status.md');
if (existsSync(statusPath)) {
try {
// M1/m1 (B2): tell status-MARKERTE tabellrader, ikke raaforekomster --
// markoer-ord i forklaringstekst/prosa skal ikke inflatere telleren.
//
// 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');
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 */ }
}
// Check last archived cycle for learnings
let lastLearning = '';
const histDir = join(okrDir, 'historikk');
if (existsSync(histDir)) {
try {
const dirs = readdirSync(histDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.sort()
.reverse();
if (dirs.length > 0) {
const retroPath = join(histDir, dirs[0], 'retrospektiv.md');
if (existsSync(retroPath)) {
const retro = readFileSync(retroPath, 'utf8');
const learningMatch = retro.match(/## (?:Laering|Læring) til neste syklus\n\n([\s\S]*?)(?:\n##|$)/);
if (learningMatch) {
const lines = learningMatch[1].trim().split('\n').filter(l => l.trim());
if (lines.length > 0) lastLearning = lines[0].replace(/^[-*]\s*/, '');
}
}
}
} catch { /* skip */ }
}
// Build coaching message
const parts = [];
parts.push(`OKR coaching: Uke ${currentWeek} av ${totalWeeks} i ${cycleId}.`);
if (phase === 'early') {
parts.push('Tidlig i syklusen — fokus på alignment og kvalitetssikring av OKR.');
parts.push('Anbefalt: /okr:gap (dekning mot tildelingsbrev), /okr:kvalitet (kvalitetssjekk).');
} else if (phase === 'mid') {
parts.push('Midtveis i syklusen — tid for fremdriftssjekk.');
parts.push('Anbefalt: /okr:sporing (statusoppdatering og scoring).');
if (atRiskCount > 0) {
parts.push(`OBS: ${atRiskCount} KR er merket At Risk/Off Track i siste status.`);
}
} else if (phase === 'late') {
parts.push('Syklusen nærmer seg slutt — fokus på sluttspurt og forberedelse.');
parts.push('Anbefalt: /okr:sporing (endelig scoring), /okr:møter (review-møte).');
if (currentWeek >= totalWeeks - 2) {
parts.push('Mindre enn 2 uker igjen. Vurder /okr:oppsett arkiver for retrospektiv.');
}
if (atRiskCount > 0) {
parts.push(`OBS: ${atRiskCount} KR er At Risk/Off Track — vurder tiltak eller juster forventninger.`);
}
} else {
// between cycles
parts.push('Mellom sykluser. Anbefalt: /okr:oppsett arkiver eller /okr:skriv for ny syklus.');
}
if (lastLearning) {
parts.push(`Læring fra forrige syklus: "${lastLearning}"`);
}
const msg = parts.join(' ');
process.stdout.write(JSON.stringify({ systemMessage: msg }));
} catch {
// Graceful exit on any error — never block the user
process.exit(0);
}