feat(okr): rapport-kommando og CLI for syklusgeneratorene
This commit is contained in:
parent
50933d839d
commit
7d1de8049d
6 changed files with 298 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapte
|
|||
| `/okr:analyse` | Cross-cycle analytics with Mermaid trend visualizations |
|
||||
| `/okr:oppsett` | Configure plugin: onboarding interview (full/mvp), cycle archival, profile update. Args: `full\|mvp\|arkiver\|oppdater\|vis` |
|
||||
| `/okr:innboks` | Ingest documents dropped in `.claude/okr/innboks/` into the OKF tree — deterministic convert/split/frontmatter/index, per-document security gate |
|
||||
| `/okr:rapport` | Generate a report from cycle data. Args: `tertial` (`arsrapport`/`etatsstyring` not yet implemented). Owns arithmetic and formatting invariants — never confidence |
|
||||
| `/okr:export` | Export OKR deliverables (quality review, gap matrix, status report, retrospective) to print-ready PDF via weasyprint |
|
||||
| `/okr:freshen-references` | KB self-evaluator: score 18 of the 19 domain reference files against an anchored rubric — the quality rubric itself is excluded, since scoring the scoring instrument is circular — + currency-poll public sources |
|
||||
| `/okr:help` | Full overview of all commands, agents, and recommended cycle workflow |
|
||||
|
|
@ -95,6 +96,7 @@ Retrieval is on-demand via the `okr-second-brain-search` skill. The UserPromptSu
|
|||
/okr:oppsett ──→ (inline wizard: full/mvp/arkiver/oppdater/vis)
|
||||
/okr:oppsett arkiver ──→ cycle archival + retrospektiv-generering
|
||||
/okr:innboks ──→ scripts/innboks-ingest.mjs (convert → split → frontmatter → per-doc gate → relations → index)
|
||||
/okr:rapport ──→ scripts/syklus-rapport.mjs ──→ lib/syklus-data.mjs (read + score) + lib/syklus-rapport.mjs (render)
|
||||
/okr:export ──→ scripts/export-pdf.py (weasyprint, documented prerequisite)
|
||||
/okr:freshen-references ──→ (inline KB-evaluator + currency-polling via WebSearch/Task)
|
||||
/okr:help ──→ (inline command/agent/workflow overview)
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -140,6 +140,16 @@ Renders any OKR deliverable — quality review, gap matrix, status report, or re
|
|||
|
||||
Drop documents — tildelingsbrev PDFs, virksomhetsplan docx, meeting notes — into `.claude/okr/innboks/` and ingest them into the knowledge tree in one sweep. The pipeline is deterministic (no LLM in the core): convert (txt/md/docx/eml/pdf) → split on headings → stamp OKF frontmatter with a `kilde: innboks` provenance marker → route by document type → validate each document against a strict security gate. A document that fails the gate is discarded alone with a per-document report; originals always stay untouched in the inbox, and re-running on unchanged input is a byte-identical no-op. Known v1 limitations (flat PDF extraction, table structure loss) are listed in the CHANGELOG.
|
||||
|
||||
### Cycle Reporting
|
||||
|
||||
```
|
||||
> /okr:rapport
|
||||
```
|
||||
|
||||
Generates a tertial report straight from the KR numbers in the cycle's `okr-*.md` frontmatter — deterministic, so the same data yields byte-identical output. Committed and aspirational Key Results are reported **separately**, never aggregated into one figure: the two are measured against different standards, and a combined average is ambiguous by construction. The generator owns the arithmetic, the table structure and the formatting invariants (score is stated on its 0–1.0 scale, never as percent goal attainment; a committed KR below target is flagged as a deviation, not a middling score).
|
||||
|
||||
It does **not** own confidence. The Confidence column is left empty by design and filled in during `/okr:sporing`, because confidence is a probability judgement — the canon designates one source of truth for the On Track / At Risk / Off Track scale and explicitly rejects deriving it mechanically from score. Scores themselves are never written to disk; they are recomputed from `baseline`/`target`/`naa` each run so a second, drifting source of truth cannot arise.
|
||||
|
||||
### Help and Maintenance
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ kommandoene for det temaet. Ellers vis full oversikt.
|
|||
| `/okr:analyse` | Kryss-syklus-analyse med Mermaid-trendvisualisering |
|
||||
| `/okr:oppsett` | Konfigurer plugin: onboarding (`full`/`mvp`), `arkiver`, `oppdater`, `vis` |
|
||||
| `/okr:innboks` | Ingest dokumenter fra innboksen (`.claude/okr/innboks/`) til kunnskapstreet |
|
||||
| `/okr:rapport` | Generer tertialrapport fra syklusdata — committed og aspirational hver for seg |
|
||||
| `/okr:export` | Eksporter OKR-dokumenter til print-klar PDF (ledelse/Riksrevisjon) |
|
||||
| `/okr:freshen-references` | KB-selvevaluator + currency-polling av offentlige kilder |
|
||||
| `/okr:help` | Denne oversikten — kommandoer, agenter, anbefalt arbeidsflyt |
|
||||
|
|
|
|||
73
commands/rapport.md
Normal file
73
commands/rapport.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
name: okr:rapport
|
||||
description: Generer rapport fra en OKR-syklus - tertialrapport med committed og aspirational holdt fra hverandre
|
||||
allowed-tools: Read, Bash, Glob
|
||||
argument-hint: "[tertial] (arsrapport og etatsstyring kommer senere)"
|
||||
---
|
||||
|
||||
# OKR Rapport - Generer styringsrapport fra syklusdata
|
||||
|
||||
Bygg en rapport direkte fra KR-tallene i syklusens `okr-*.md`-filer. Generatoren
|
||||
er deterministisk: samme data gir samme dokument, hver gang.
|
||||
|
||||
## Hva generatoren eier - og hva den ikke eier
|
||||
|
||||
Generatoren eier **aritmetikken, tabellstrukturen og formateringsinvariantene**:
|
||||
den beregner score, holder committed og aspirational fra hverandre, og skriver
|
||||
skalaforklaringen som hindrer at 0.7 leses som «70 % av maalet».
|
||||
|
||||
Den eier **aldri confidence**. Confidence-kolonnen staar tom med vilje.
|
||||
`okr-framework.md` er eneste sannhetskilde for On Track / At Risk / Off Track, og
|
||||
`okr-calculator.md` avviser mekanisk utledning eksplisitt - gapet mellom faktisk
|
||||
og forventet score er *ett innspill* til vurderingen, ikke en regel. En generator
|
||||
som satte trafikklyset selv ville oppfunnet en terskel doktrinen forbyr.
|
||||
Confidence fylles derfor ut i `/okr:sporing`, der skjoennet hoerer hjemme.
|
||||
|
||||
Score lagres heller aldri til fil - den beregnes fra `baseline`/`target`/`naa`
|
||||
hver gang, slik at det ikke oppstaar en andre sannhetskilde som kan drifte.
|
||||
|
||||
## Forutsetninger
|
||||
|
||||
- Node.js 22 eller nyere. Ingen npm-avhengigheter.
|
||||
- Syklusens OKR-filer maa baere KR-datakontrakten i frontmatter
|
||||
(`krN_navn`, `krN_baseline`, `krN_target`, `krN_naa`, `krN_type`) - se
|
||||
`/okr:skriv`. Mangler tallene, sier generatoren fra i stedet for aa gjette.
|
||||
|
||||
## Arbeidsflyt
|
||||
|
||||
1. **Finn syklusen** - list katalogene med Glob (`.claude/okr/syklus/*/`). Er det
|
||||
flere, spoer brukeren hvilken. Er det ingen, si det og stopp (`/okr:oppsett`
|
||||
setter opp treet).
|
||||
|
||||
2. **Kjoer generatoren** via Bash:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scripts/syklus-rapport.mjs .claude/okr/syklus/[syklus-id] tertial
|
||||
```
|
||||
|
||||
3. **Tolk exit-koden**:
|
||||
- `0` - rapporten er skrevet til `rapport-tertial.md` i syklus-katalogen.
|
||||
Oppsummer for brukeren: hvilken syklus, hvor mange OKR, og at
|
||||
Confidence-kolonnen skal fylles ut i `/okr:sporing`.
|
||||
- `1` - domenefeil: syklusdataene er ufullstendige. Meldingen navngir KR-en og
|
||||
feltet som mangler. Be brukeren fylle inn tallene i OKR-fila (formatet staar
|
||||
i `/okr:skriv`), og kjoer om igjen.
|
||||
- `2` - bruksfeil: feil antall argumenter, ukjent rapportform, eller
|
||||
syklus-katalogen finnes ikke. Sjekk stien mot Glob-resultatet fra steg 1.
|
||||
|
||||
4. **Les rapporten** og gjennomgaa den med brukeren. Vaer spesielt tydelig paa
|
||||
committed-avvik: et committed KR under target er et avvik som skal forklares,
|
||||
ikke et middels resultat.
|
||||
|
||||
## Rapportformer
|
||||
|
||||
| Form | Status |
|
||||
|------|--------|
|
||||
| `tertial` | Tilgjengelig |
|
||||
| `arsrapport` | Ikke implementert ennaa - kommandoen svarer med exit 2 |
|
||||
| `etatsstyring` | Ikke implementert ennaa - kommandoen svarer med exit 2 |
|
||||
|
||||
## Relatert
|
||||
|
||||
- `/okr:skriv` - KR-datakontrakten rapporten leser
|
||||
- `/okr:sporing` - check-in og confidence-vurdering
|
||||
- `/okr:analyse` - trender paa tvers av sykluser
|
||||
117
scripts/syklus-rapport.mjs
Normal file
117
scripts/syklus-rapport.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env node
|
||||
// syklus-rapport.mjs
|
||||
// D5 steg 10: orkestrator for syklus-generatorene. Leser en syklus-katalog,
|
||||
// bygger den forespurte rapportformen og persisterer den atomisk.
|
||||
//
|
||||
// Bruk:
|
||||
// node syklus-rapport.mjs <syklus-dir> <form>
|
||||
//
|
||||
// Former: tertial | arsrapport | etatsstyring
|
||||
// EN kommandoflate med argument, ikke tre kommandoer -- samme moenster som
|
||||
// /okr:oppsett full|mvp|arkiver|oppdater|vis. Bare `tertial` er implementert
|
||||
// i D5; arsrapport og etatsstyring kommer i steg 11-12 og svarer inntil da
|
||||
// med exit 2 og en forklarende melding, aldri stum feil.
|
||||
//
|
||||
// Exit-koder (kontrakten commands/rapport.md mapper til norsk brukertekst):
|
||||
// 0 rapporten er skrevet
|
||||
// 1 domenefeil -- syklusdataene er ufullstendige eller ikke rapporterbare
|
||||
// 2 bruksfeil -- feil aritet, ukjent/uimplementert form, katalog finnes ikke
|
||||
//
|
||||
// Klokke-soem: OKR_NOW (ISO-8601) overstyrer veggklokka, saa to kjoeringer over
|
||||
// samme data gir byte-identisk fil (moenster: scripts/compose-org-profile.mjs:61).
|
||||
//
|
||||
// Zero npm dependencies.
|
||||
|
||||
import { existsSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { writeFrontmatter } from '../lib/frontmatter.mjs';
|
||||
import { lesSyklus } from '../lib/syklus-data.mjs';
|
||||
import { tertialrapport } from '../lib/syklus-rapport.mjs';
|
||||
|
||||
const FORMER = {
|
||||
tertial: {
|
||||
filnavn: 'rapport-tertial.md',
|
||||
tittel: (id) => `Tertialrapport ${id}`,
|
||||
beskrivelse: 'Maskingenerert tertialrapport med committed og aspirational holdt fra hverandre.',
|
||||
bygg: tertialrapport,
|
||||
},
|
||||
// Steg 11-12. Oppfoert her, ikke skjult, saa flaten er synlig foer den virker.
|
||||
arsrapport: null,
|
||||
etatsstyring: null,
|
||||
};
|
||||
|
||||
class Bruksfeil extends Error {}
|
||||
|
||||
// Skrevet fil er OKF-konsept, ikke loes markdown: den lander INNE i bundlen, og
|
||||
// en fil uten `type:` ville felt okf-check for hele roten. `type: Status` er
|
||||
// dessuten riktig -- rapporten ER en statusflate, og blir retrievbar for
|
||||
// okr-second-brain-search paa kjoepet.
|
||||
// Filnavnet starter bevisst IKKE med `okr-`: ellers ville neste lesSyklus()
|
||||
// plukket rapporten opp som en OKR-definisjon.
|
||||
function komponer(form, syklus, naa) {
|
||||
const frontmatter = writeFrontmatter({
|
||||
type: 'Status',
|
||||
resource: 'local',
|
||||
title: form.tittel(syklus.id),
|
||||
description: form.beskrivelse,
|
||||
timestamp: naa,
|
||||
});
|
||||
return `${frontmatter}\n${form.bygg(syklus, { naa })}`;
|
||||
}
|
||||
|
||||
// Atomisk skriv: temp i SAMME katalog (rename er kun atomisk innen filsystem),
|
||||
// process.pid i navnet mot samtidige kjoeringer. Moenster: lib/innboks-write.mjs.
|
||||
function skrivAtomisk(maal, innhold) {
|
||||
const temp = `${maal}.${process.pid}.tmp`;
|
||||
try {
|
||||
writeFileSync(temp, innhold, 'utf8');
|
||||
renameSync(temp, maal);
|
||||
} catch (e) {
|
||||
if (existsSync(temp)) {
|
||||
try { unlinkSync(temp); } catch { /* opprydding er best-effort */ }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function genererRapport(syklusDir, formNavn, opts = {}) {
|
||||
if (!syklusDir || !formNavn) {
|
||||
throw new Bruksfeil('Bruk: node syklus-rapport.mjs <syklus-dir> <form>\n'
|
||||
+ `Former: ${Object.keys(FORMER).join(' | ')}`);
|
||||
}
|
||||
if (!(formNavn in FORMER)) {
|
||||
throw new Bruksfeil(`Ukjent form: ${formNavn}. Former: ${Object.keys(FORMER).join(' | ')}`);
|
||||
}
|
||||
const form = FORMER[formNavn];
|
||||
if (form === null) {
|
||||
throw new Bruksfeil(`Rapportformen «${formNavn}» er ikke implementert ennaa.`);
|
||||
}
|
||||
if (!existsSync(syklusDir) || !statSync(syklusDir).isDirectory()) {
|
||||
throw new Bruksfeil(`Syklus-katalogen finnes ikke: ${syklusDir}`);
|
||||
}
|
||||
|
||||
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
|
||||
const syklus = lesSyklus(syklusDir);
|
||||
const maal = join(syklusDir, form.filnavn);
|
||||
skrivAtomisk(maal, komponer(form, syklus, naa));
|
||||
return { fil: maal, syklus: syklus.id, okrer: syklus.okrer.length };
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
const isMain = process.argv[1]
|
||||
&& fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMain) {
|
||||
const [syklusDir, form] = process.argv.slice(2);
|
||||
try {
|
||||
const r = genererRapport(syklusDir, form);
|
||||
process.stdout.write(
|
||||
`Rapport skrevet: ${r.fil}\n Syklus: ${r.syklus}\n OKR rapportert: ${r.okrer}\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(`${basename(process.argv[1])}: ${e.message}\n`);
|
||||
process.exit(e instanceof Bruksfeil ? 2 : 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -146,3 +146,98 @@ test('(6) tertialrapport kaster ved tom eller ufullstendig syklus', () => {
|
|||
assert.throws(() => tertialrapport({ id: 'T1-2026', okrer: [] }), /ingen OKR/i);
|
||||
assert.throws(() => tertialrapport(undefined), /syklus/i);
|
||||
});
|
||||
|
||||
// --- (7) CLI-kontrakten (steg 10) ---
|
||||
//
|
||||
// Testet BEGGE VEIER i samme fil: direkte import for atferd, execFileSync for
|
||||
// exit-koden -- fordi exit-koden ER kontrakten kommandofila mapper til norsk
|
||||
// brukertekst. En atferdstest alene ville ikke fanget at 1 og 2 byttet plass.
|
||||
// Moenster: tests/innboks-ingest.test.mjs (execFileSync for exit-kode).
|
||||
|
||||
const CLI = join(ROOT, 'scripts/syklus-rapport.mjs');
|
||||
|
||||
async function kjoerCli(args, opts = {}) {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [CLI, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, OKR_NOW: NAA, ...(opts.env ?? {}) },
|
||||
});
|
||||
return { code: 0, stdout };
|
||||
} catch (e) {
|
||||
return { code: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
// Egen kopi av syklusen per test som skriver -- CLI-en persisterer, og en test
|
||||
// skal aldri skitne til fixturen den delte med de andre testene.
|
||||
async function midlertidigSyklus(filer) {
|
||||
const { mkdtempSync, writeFileSync, mkdirSync, copyFileSync, readdirSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const rot = mkdtempSync(join(tmpdir(), 'okr-rapport-'));
|
||||
const dir = join(rot, 'T1-2026');
|
||||
mkdirSync(dir);
|
||||
if (filer) {
|
||||
for (const [navn, innhold] of Object.entries(filer)) writeFileSync(join(dir, navn), innhold);
|
||||
} else {
|
||||
for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f));
|
||||
}
|
||||
return { rot, dir };
|
||||
}
|
||||
|
||||
test('(7a) CLI: exit 2 ved feil aritet', async () => {
|
||||
assert.equal((await kjoerCli([])).code, 2);
|
||||
assert.equal((await kjoerCli([SYKLUS_DIR])).code, 2);
|
||||
});
|
||||
|
||||
test('(7b) CLI: exit 2 ved ukjent og ved ennaa uimplementert form', async () => {
|
||||
assert.equal((await kjoerCli([SYKLUS_DIR, 'tullball'])).code, 2);
|
||||
// arsrapport/etatsstyring kommer i steg 11-12 og skal si det, ikke feile stumt.
|
||||
for (const form of ['arsrapport', 'etatsstyring']) {
|
||||
const r = await kjoerCli([SYKLUS_DIR, form]);
|
||||
assert.equal(r.code, 2, `${form} burde gi exit 2`);
|
||||
assert.match(r.stderr, /ikke implementert/i, `${form} mangler forklarende melding`);
|
||||
}
|
||||
});
|
||||
|
||||
test('(7c) CLI: exit 2 naar syklus-katalogen ikke finnes', async () => {
|
||||
assert.equal((await kjoerCli([join(ROOT, 'finnes-ikke'), 'tertial'])).code, 2);
|
||||
});
|
||||
|
||||
test('(7d) CLI: exit 1 ved ufullstendig syklusdata', async (t) => {
|
||||
const { rmSync } = await import('node:fs');
|
||||
const { rot, dir } = await midlertidigSyklus({
|
||||
'okr-ufullstendig.md': ['---', 'type: OKR', 'title: Ufullstendig', 'kr1_navn: Mangler tall', 'kr1_baseline: 3', '---', '# Ufullstendig', ''].join('\n'),
|
||||
});
|
||||
t.after(() => rmSync(rot, { recursive: true, force: true }));
|
||||
const r = await kjoerCli([dir, 'tertial']);
|
||||
assert.equal(r.code, 1, 'domenefeil skal gi exit 1, ikke 2');
|
||||
assert.match(r.stderr, /kr1/i);
|
||||
});
|
||||
|
||||
test('(7e) CLI: exit 0 og fil paa disk ved gyldig input', async (t) => {
|
||||
const { rmSync, existsSync, readFileSync: les } = await import('node:fs');
|
||||
const { rot, dir } = await midlertidigSyklus();
|
||||
t.after(() => rmSync(rot, { recursive: true, force: true }));
|
||||
const r = await kjoerCli([dir, 'tertial']);
|
||||
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}: ${r.stderr ?? ''}`);
|
||||
const ut = join(dir, 'rapport-tertial.md');
|
||||
assert.ok(existsSync(ut), 'rapporten ble ikke skrevet');
|
||||
const innhold = les(ut, 'utf8');
|
||||
assert.match(innhold, /^---\ntype: Status\n/, 'skrevet fil mangler OKF-frontmatter');
|
||||
assert.match(innhold, /## Committed Key Results/);
|
||||
});
|
||||
|
||||
// Idempotens: rapporten skal kunne kjoeres om igjen uten aa endre bundlen, og
|
||||
// uten aa bli lest som en OKR neste gang (filnavnet starter ikke med okr-).
|
||||
test('(7f) CLI: to kjoeringer gir byte-identisk fil', async (t) => {
|
||||
const { rmSync, readFileSync: les } = await import('node:fs');
|
||||
const { rot, dir } = await midlertidigSyklus();
|
||||
t.after(() => rmSync(rot, { recursive: true, force: true }));
|
||||
await kjoerCli([dir, 'tertial']);
|
||||
const foerste = les(join(dir, 'rapport-tertial.md'), 'utf8');
|
||||
await kjoerCli([dir, 'tertial']);
|
||||
assert.equal(les(join(dir, 'rapport-tertial.md'), 'utf8'), foerste);
|
||||
assert.equal(lesSyklus(dir).okrer.length, 2, 'rapporten ble lest som en OKR');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue