okr/scripts/syklus-rapport.mjs

156 lines
6.3 KiB
JavaScript

#!/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. `etatsstyring` kommer i steg 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, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFrontmatter, writeFrontmatter } from '../lib/frontmatter.mjs';
import { lesSyklus } from '../lib/syklus-data.mjs';
import { arsrapportDelIII, etatsstyringsunderlag, tertialrapport } from '../lib/syklus-rapport.mjs';
// historikk/ er SOESKEN av syklus/, altsaa <syklusDir>/../../historikk. Den
// avledningen er en determinisme-risiko: regnet ut blindt ville en syklus-katalog
// utenfor et OKR-tre pekt paa en vilkaarlig katalog paa maskinen, og rapporten
// variert med hva som tilfeldigvis laa der. Derfor avledes stien KUN naar
// foreldrekatalogen faktisk heter `syklus`; ellers finnes det ingen historikk.
//
// Katalogen leses direkte -- ALDRI via index.md. Indeksen er navigasjon og kan
// peke paa filer som ikke finnes (fixturen har en slik dangling-lenke med vilje);
// et styringsdokument som lister materiale som ikke eksisterer er verre enn et
// som utelater seksjonen. readdir sorteres, fordi byte-identiske kjoeringer er
// kontrakt og lesrekkefolgen fra filsystemet ikke er garantert.
function lesHistorikk(syklusDir) {
const syklusRot = dirname(resolve(syklusDir));
if (basename(syklusRot) !== 'syklus') return [];
const dir = join(dirname(syklusRot), 'historikk');
let filer;
try {
filer = readdirSync(dir);
} catch {
return [];
}
return filer
.filter((n) => n.endsWith('.md') && n !== 'index.md')
.sort()
.map((fil) => {
const fm = parseFrontmatter(readFileSync(join(dir, fil), 'utf8'));
return { tittel: fm.get('title') ?? fil.replace(/\.md$/, ''), fil };
});
}
const FORMER = {
tertial: {
filnavn: 'rapport-tertial.md',
tittel: (id) => `Tertialrapport ${id}`,
beskrivelse: 'Maskingenerert tertialrapport med committed og aspirational holdt fra hverandre.',
bygg: tertialrapport,
},
arsrapport: {
filnavn: 'rapport-arsrapport.md',
tittel: (id) => `Aarsrapport del III ${id}`,
beskrivelse: 'Maskingenerert underlag til aarsrapportens del III. Vurderingen av maaloppnaaelse er reservert til virksomheten.',
bygg: arsrapportDelIII,
// Disklesing hoerer til orkestratoren, ikke til lib/: generatorformen skal
// kunne testes uten et tre paa filsystemet.
kontekst: (syklusDir) => ({ historikk: lesHistorikk(syklusDir) }),
},
etatsstyring: {
filnavn: 'rapport-etatsstyring.md',
tittel: (id) => `Etatsstyringsmoete - underlag ${id}`,
beskrivelse: 'Maskingenerert underlag til etatsstyringsmoetet. Koblingen mot tildelingsbrevets styringsparametere er reservert til virksomheten.',
bygg: etatsstyringsunderlag,
},
};
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, kontekst) {
const frontmatter = writeFrontmatter({
type: 'Status',
resource: 'local',
title: form.tittel(syklus.id),
description: form.beskrivelse,
timestamp: naa,
});
return `${frontmatter}\n${form.bygg(syklus, { naa, ...kontekst })}`;
}
// 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 (!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 kontekst = form.kontekst ? form.kontekst(syklusDir) : {};
const maal = join(syklusDir, form.filnavn);
skrivAtomisk(maal, komponer(form, syklus, naa, kontekst));
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);
}
}