feat(okr): sync to v1.3.0 from ktg-privat
Syncs all changes from v1.0.0 through v1.3.0: v1.1 (quick fixes): - Fix deprecated Viva Goals references - Add DFO-OKR terminology mapping - Add tillitsvalgt/fagforening perspective - Update Objectives recommendation from 3-5 to 2-3 v1.1 (persistent context): - Deep onboarding interview (full/mvp) - Persistent .claude/okr/ directory tree - Context-aware commands - Cycle archival with retrospective v1.3 (AI-first differentiators): - /okr:gap — tildelingsbrev gap analysis with coverage matrix - /okr:analyse — cross-cycle Mermaid analytics - SessionStart coaching hook (proactive, phase-aware) - gapanalytiker + trendanalytiker agents - inject-okr-context.mjs extended for historikk/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4b5e19e7b7
commit
ac95cd6a30
30 changed files with 1900 additions and 275 deletions
146
plugins/okr/hooks/scripts/coaching-hook.mjs
Normal file
146
plugins/okr/hooks/scripts/coaching-hook.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
#!/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';
|
||||
|
||||
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 match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) process.exit(0);
|
||||
|
||||
const fm = match[1];
|
||||
const get = (key) => {
|
||||
const m = fm.match(new RegExp(`${key}:\\s*["']?([^"'\\n]+)["']?`));
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const 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(/## [Ll]aering 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 pa 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 naermer seg slutt — fokus pa sluttspurt og forberedelse.');
|
||||
parts.push('Anbefalt: /okr:sporing (endelig scoring), /okr:moter (review-mote).');
|
||||
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(`Laering 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);
|
||||
}
|
||||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
// inject-okr-context.mjs
|
||||
// Event: UserPromptSubmit
|
||||
// Purpose: Inject OKR organization context from .claude/okr.local.md when configured.
|
||||
// Zero npm dependencies. Target execution: <20ms.
|
||||
// Purpose: Inject OKR organization context from .claude/okr.local.md and .claude/okr/ tree.
|
||||
// Zero npm dependencies. Target execution: <50ms.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const configPath = join(process.cwd(), '.claude', 'okr.local.md');
|
||||
const cwd = process.cwd();
|
||||
const configPath = join(cwd, '.claude', 'okr.local.md');
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
process.exit(0);
|
||||
|
|
@ -25,20 +26,132 @@ try {
|
|||
return m ? m[1].trim() : null;
|
||||
};
|
||||
|
||||
// Core fields (backwards-compatible with old 4-field format)
|
||||
const org = get('navn') || get('name');
|
||||
const syklus = get('gjeldende') || get('current_cycle');
|
||||
const sektor = get('sektor') || get('sector');
|
||||
const syklus = get('gjeldende') || get('id') || get('current_cycle');
|
||||
const sektor = get('sektor') || get('sector') || get('domene');
|
||||
const linear = fm.includes('aktivert: true') || fm.includes('enabled: true');
|
||||
|
||||
// New v1.1 fields (silently skipped if absent)
|
||||
const modenhet = get('modenhetsnivaa');
|
||||
const fase = get('fase');
|
||||
const frikoblet = get('okr_frikoblet_fra_loenn');
|
||||
const trygghet = get('psykologisk_trygghet');
|
||||
const kortform = get('kortform');
|
||||
|
||||
if (!org) process.exit(0);
|
||||
|
||||
const parts = [`Organisasjon: ${org}`];
|
||||
if (syklus) parts.push(`Gjeldende syklus: ${syklus}`);
|
||||
// Build message parts
|
||||
const parts = [`Organisasjon: ${org}${kortform ? ` (${kortform})` : ''}`];
|
||||
if (syklus) parts.push(`Syklus: ${syklus}${fase ? ` [${fase}]` : ''}`);
|
||||
if (sektor) parts.push(`Sektor: ${sektor}`);
|
||||
if (modenhet) parts.push(`Modenhet: ${modenhet}`);
|
||||
if (frikoblet !== null) parts.push(`OKR frikoblet fra lonn: ${frikoblet}`);
|
||||
if (trygghet) parts.push(`Psykologisk trygghet: ${trygghet}`);
|
||||
if (linear) parts.push('Linear: aktivert');
|
||||
|
||||
const systemMessage = `OKR-kontekst (fra .claude/okr.local.md): ${parts.join(', ')}.`;
|
||||
process.stdout.write(JSON.stringify({ systemMessage }));
|
||||
// Scan .claude/okr/ directory tree (cap at 50 files)
|
||||
const okrDir = join(cwd, '.claude', 'okr');
|
||||
const dirParts = [];
|
||||
let totalFiles = 0;
|
||||
|
||||
if (existsSync(okrDir)) {
|
||||
try {
|
||||
const topEntries = readdirSync(okrDir, { withFileTypes: true });
|
||||
for (const entry of topEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (totalFiles >= 50) break;
|
||||
try {
|
||||
const subEntries = readdirSync(join(okrDir, entry.name), { withFileTypes: true });
|
||||
const mdFiles = [];
|
||||
const subDirs = [];
|
||||
for (const sub of subEntries) {
|
||||
if (totalFiles >= 50) break;
|
||||
if (sub.isFile() && sub.name.endsWith('.md')) {
|
||||
mdFiles.push(sub.name);
|
||||
totalFiles++;
|
||||
} else if (sub.isDirectory()) {
|
||||
subDirs.push(sub.name);
|
||||
}
|
||||
}
|
||||
// Enumerate nested subdirectories (e.g. syklus/T1-2026/)
|
||||
for (const sd of subDirs) {
|
||||
if (totalFiles >= 50) break;
|
||||
try {
|
||||
const nested = readdirSync(join(okrDir, entry.name, sd), { withFileTypes: true });
|
||||
const nestedMd = [];
|
||||
for (const n of nested) {
|
||||
if (totalFiles >= 50) break;
|
||||
if (n.isFile() && n.name.endsWith('.md')) {
|
||||
nestedMd.push(n.name);
|
||||
totalFiles++;
|
||||
}
|
||||
}
|
||||
if (nestedMd.length > 0) {
|
||||
dirParts.push(`${entry.name}/${sd}/ (${nestedMd.length} fil${nestedMd.length > 1 ? 'er' : ''}: ${nestedMd.join(', ')})`);
|
||||
}
|
||||
} catch { /* skip unreadable nested dirs */ }
|
||||
}
|
||||
if (mdFiles.length > 0) {
|
||||
dirParts.push(`${entry.name}/ (${mdFiles.length} fil${mdFiles.length > 1 ? 'er' : ''}: ${mdFiles.join(', ')})`);
|
||||
}
|
||||
} catch { /* skip unreadable dirs */ }
|
||||
}
|
||||
} catch { /* .claude/okr/ scan failed — continue without */ }
|
||||
}
|
||||
|
||||
// Scan historikk/ for archived cycle count
|
||||
const histDir = join(okrDir, 'historikk');
|
||||
const archivedCycles = [];
|
||||
if (existsSync(histDir)) {
|
||||
try {
|
||||
const histEntries = readdirSync(histDir, { withFileTypes: true });
|
||||
for (const entry of histEntries) {
|
||||
if (entry.isDirectory()) {
|
||||
archivedCycles.push(entry.name);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// List active cycle files
|
||||
const cycleId = syklus;
|
||||
const cycleParts = [];
|
||||
if (cycleId) {
|
||||
const cyclePath = join(okrDir, 'syklus', cycleId);
|
||||
if (existsSync(cyclePath)) {
|
||||
try {
|
||||
const cycleEntries = readdirSync(cyclePath, { withFileTypes: true });
|
||||
for (const e of cycleEntries) {
|
||||
if (e.isFile() && e.name.endsWith('.md')) {
|
||||
cycleParts.push(e.name);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Build rich systemMessage
|
||||
let msg = `OKR-kontekst (fra .claude/okr.local.md): ${parts.join(', ')}.`;
|
||||
|
||||
if (dirParts.length > 0) {
|
||||
msg += `\nTilgjengelige kontekstfiler: ${dirParts.join('; ')}.`;
|
||||
}
|
||||
|
||||
if (cycleParts.length > 0) {
|
||||
msg += `\nAktive OKR-filer i syklus ${cycleId}: ${cycleParts.join(', ')}.`;
|
||||
}
|
||||
|
||||
if (archivedCycles.length > 0) {
|
||||
msg += `\nArkiverte sykluser (${archivedCycles.length}): ${archivedCycles.sort().join(', ')}.`;
|
||||
msg += ' Bruk /okr:analyse for trendanalyse.';
|
||||
}
|
||||
|
||||
if (dirParts.length > 0 || cycleParts.length > 0) {
|
||||
msg += '\nBruk disse filene automatisk nar relevant — ikke be brukeren om a lime inn innhold som allerede finnes.';
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ systemMessage: msg }));
|
||||
} catch {
|
||||
// Graceful exit on any error — never block the user
|
||||
process.exit(0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue