okr/hooks/scripts/coaching-hook.mjs
Kjell Tore Guttormsen 44ceec7205 fix(okr): C7b remediering R1-R4 (release-relevante review-funn for 1.8.0)
Lukker de fire release-relevante MAJOR-funnene fra /trekreview (S33). Alle TDD
roed -> groenn, ingen fiks landet uten en test som feilet foerst. Suite 163 -> 167.

R1 (af16d5e4) hooks/scripts/coaching-hook.mjs:83 -- LEVENDE REGRESJON innfoert av
1.8.0. Denne releasen skrev status-malen (commands/sporing.md:86-88) om til den
kanoniske skalaen On Track/At Risk/Off Track, mens hooken fortsatt talte kun
/i fare|blokkert/i over tabellrader. En status generert under 1.8.0 ga derfor
atRiskCount = 0 og SessionStart-nudgen sluttet stille aa utloese seg. Hooken
teller naa de kanoniske etikettene; de to norske er BEHOLDT som bakover-
kompatibilitet for status-filer skrevet foer 1.8.0. Nudge-teksten bruker samme
kanoniske vokabular. Ny testcase mates av malen slik den faktisk genereres i dag;
den eksisterende casen beholder gammelt vokabular og daekker legacy-stien.

R2 (7ec575be) F-i-omskrivingen ga hver kommando en Kontekstbevissthet-blokk som
INSTRUERER Glob, men allowed-tools ble kun utvidet i kaskade.md. Glob lagt til i
export, gap, governance, innfoering, kvalitet, moeter, skriv, sporing. Ny vakt-
case (k): nevner BODY verktoeyet, maa frontmatter deklarere det (13 kommandoer
instruerer Glob; alle 13 dekket). Case (i) grepper kun etter fjernede fraser og
kunne ikke fange dette.

R3 (5e61ae0d) tests/package-shape.test.mjs asserterte KUN package.json -- som er
private:true og dermed den ene flaten som aldri shipper. En delvis bump ville
shippet groenn. Ny versjonssync-case dekker .claude-plugin/plugin.json, README-
badgen og begge SKILL.md, med forventet verdi UTLEDET fra package.json (ett sted
aa endre ved neste bump). Mutasjonsbevist: hver av de fire flatene tilbakestilt
til 1.7.1 en om gangen -> casen roed i alle fire tilfeller.

R4 (ccff16e1 + 231c53fc) Tre parallelle confidence-etikettsett overlevde F-c:
fremdriftssporer-agent.md:68 ("Paa sporet / I fare / Blokkert"), :98
("Confidence: [Hoey/Medium/Lav]" -- en annen akse: stoerrelse, ikke sannsynlighet)
og SKILL.md:48 ("blocked"; kanonisk er "off track"). Alle tre erstattet med
referanse til kanon (okr-framework.md:389-392). sporing.md og agenten den
delegerer til svarer naa i samme vokabular. Vakt-case (a)/(b) skanner naa samme
sett som (d) allerede brukte (+ agents/ + SKILL.md), samlet i canonScan(). Ny
case (a2) fanger etikettsett skrevet som bullet eller mal-linje -- tabell-
signaturen alene fanget dem ikke, og det var nettopp formen driften overlevde i.
Divergens gjenkjennes STRUKTURELT (skraastrek-enumerasjon av >= 2 etiketter, med
>= 1 ikke-kanonisk), saa loepende prosa som "For KR i fare" og "Blokkert av
eksterne faktorer" ikke gir falske positive. Verifisert: roed-listen var noeyaktig
de 3 kjente linjene, ingen andre.

CHANGELOG/README: vakt-antall 13 -> 15 cases, suite 149 -> 167, og de fire
fiksene lagt inn under [1.8.0] (Added + Fixed). Ingen versjonsbump.

GJENSTAAR fra reviewet (3 MAJOR, IKKE i denne bolgen): 607313e3 F-d-kadens-
moensteret kan ikke feile paa den kanoniske kadens-tabellen; 029ef814
freshen-references.md:24 baerer fortsatt en foreldet kontekstblokk og vaktens
exclude-kommentar kaller fila feilaktig "allerede-fikset" (den er utenfor
1.8.0-scope by design, jf. ba91fc2 -- kommentaren er usann, ikke ekskluderingen);
7579d59c "Outcome" er utestbar i 10-dims-casen pga. substring-containment.

Verify: node --test --test-reporter=tap tests/*.test.mjs -> 167 pass / 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pUfkQ1YyH75z9y6RBaBHG
2026-07-25 12:33:55 +02:00

156 lines
5.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';
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.
// 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.
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;
} 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);
}