#!/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
// // 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 /../../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 \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); } }