143 lines
4.9 KiB
JavaScript
143 lines
4.9 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 {
|
|
const statusContent = readFileSync(statusPath, 'utf8');
|
|
const riskMatches = statusContent.match(/[Ii] fare|[Bb]lokkert|risk/gi);
|
|
if (riskMatches) atRiskCount = riskMatches.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 som i fare/blokkert 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 i fare — 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);
|
|
}
|