okr/scripts/syklus-status.mjs
Kjell Tore Guttormsen 3a0cbcfbf9 feat(okr): deterministisk skriveledd for status.md (1.10.0)
Kjeden /okr:rapport -> /okr:sporing -> status.md var beskrevet i prosa, men
manglet et mekanisk ledd: ingenting foerte en generert rad inn i status.md --
fila coaching-hook teller at-risk-KR i, /okr:moeter bygger agenda fra, og
/okr:oppsett arkiver leser sluttscorer fra. Rot-aarsaken var at HELE
sporings-stien var lesende (ingen Write/Edit/Bash i allowed-tools).

scripts/syklus-status.mjs tar confidence som JSON paa stdin og bygger radene
gjennom statusrapport() i lib/syklus-rapport.mjs -- samme kode som
tertialrapporten. Aritmetikken kan derfor ikke drifte fra /okr:rapport, fordi
det ikke finnes en andre implementasjon av den. En LLM skriver ikke av et tall
paa veien, og en maskin utleder ikke et trafikklys: beslutning 9 staar, presist
formulert som at generatoren PLASSERER en etikett den faar inn som data og
UTLEDER aldri en.

Haandhevet i kode, ikke i prosa: lukket vokabular (On Track/At Risk/Off Track),
full dekning (en tom confidence-celle ville lest som groenn hos hver konsument),
typo-fangst paa ukjente KR-navn, og kuratert-fil-vern -- en status.md uten
provenans-noekkelen `kilde: sporing` er haandholdt og overskrives aldri (samme
mekanikk som innboks-write bruker med `kilde: innboks`).

Suite 329 -> 343. Fem vakter mutasjons-verifisert: rad-drift mot
tertialrapporten, vokab, dekning (baade alene og sammen med vokab-vakten som
overlapper den), og kuratert-vernet.
2026-08-10 20:56:26 +02:00

160 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
// syklus-status.mjs
// S60: siste ledd i kjeden generator -> /okr:sporing -> status.md.
//
// Bruk:
// node syklus-status.mjs <syklus-dir> < confidence.json
//
// Confidence leses fra STDIN som JSON: et oppslag fra KR-navn til etikett i det
// lukkede vokabularet (On Track | At Risk | Off Track).
//
// node scripts/syklus-status.mjs .claude/okr/syklus/T1-2026 <<'JSON'
// { "Andel digitale soeknader (prosent)": "At Risk" }
// JSON
//
// HVORFOR STDIN, og ikke flagg: KR-navn er norsk fritekst med mellomrom,
// parenteser og prosenttegn. Som `--confidence "<navn>=<etikett>"` ville hvert
// navn maattet siteres riktig gjennom et shell-lag, og en feilsitering ville
// gitt feil etikett paa riktig KR -- en stille feilrapportering. JSON har
// entydig avgrensning og feiler hoeylytt.
//
// ARBEIDSDELINGEN (laast beslutning 9): /okr:sporing eier VURDERINGEN og leverer
// den som data hit. Dette scriptet eier ARITMETIKKEN og FORMATERINGEN, og henter
// begge fra lib/syklus-rapport.mjs -- de samme radene tertialrapporten bygger.
// Ingen LLM skriver av et tall paa veien, og ingen maskin utleder et trafikklys.
//
// Exit-koder (kontrakten commands/sporing.md mapper til norsk brukertekst):
// 0 status.md er skrevet
// 1 domenefeil -- ugyldig/manglende confidence, eller kuratert fil i veien
// 2 bruksfeil -- feil aritet, katalog finnes ikke, ugyldig JSON paa stdin
//
// Klokke-soem: OKR_NOW (ISO-8601) overstyrer veggklokka, saa to kjoeringer over
// samme data gir byte-identisk fil (moenster: scripts/syklus-rapport.mjs:19).
//
// Zero npm dependencies.
import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFrontmatter, writeFrontmatter } from '../lib/frontmatter.mjs';
import { lesSyklus } from '../lib/syklus-data.mjs';
import { statusrapport } from '../lib/syklus-rapport.mjs';
const FILNAVN = 'status.md';
// Provenans-noekkelen kuratert-fil-vernet leser. Samme mekanikk som
// lib/innboks-write.mjs:88-91 bruker med `kilde: innboks`: en eksisterende
// maal-fil UTEN noekkelen er haandkuratert, og skrivingen avvises.
const PROVENANS = { noekkel: 'kilde', verdi: 'sporing' };
class Bruksfeil extends Error {}
// Vernet er det eneste stedet dette scriptet kan oedelegge noe for brukeren:
// status.md kan vaere skrevet for haand, og ligger i BRUKERENS syklusdata --
// ikke i repoet, altsaa ikke en commit unna. Et stille overskriv ville vaert
// datatap uten spor. Egne tidligere kjoeringer overskrives fritt: de baerer
// noekkelen, og idempotens er hele poenget med klokke-soemmen.
function krevSkrivbar(maal) {
if (!existsSync(maal)) return;
let fm;
try {
fm = parseFrontmatter(readFileSync(maal, 'utf8'));
} catch {
fm = null;
}
if (fm?.get(PROVENANS.noekkel) === PROVENANS.verdi) return;
throw new Error(
`nekter aa overskrive kuratert (ikke-generert) fil: ${maal}\n`
+ ` Fila mangler "${PROVENANS.noekkel}: ${PROVENANS.verdi}" i frontmatteren og kan vaere skrevet for haand.\n`
+ ' Flytt eller slett den hvis den skal erstattes av en generert statusrapport.',
);
}
// Atomisk skriv: temp i SAMME katalog (rename er kun atomisk innen filsystem),
// process.pid i navnet mot samtidige kjoeringer. Moenster: scripts/syklus-rapport.mjs:107.
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;
}
}
// 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
// riktig -- dette ER statusflaten, og den blir retrievbar for
// okr-second-brain-search paa kjoepet.
function komponer(syklus, naa, confidence) {
const frontmatter = writeFrontmatter({
type: 'Status',
resource: 'local',
title: `Statusrapport ${syklus.id}`,
description: 'Maskingenerert statusrapport. Confidence er satt av /okr:sporing.',
timestamp: naa,
[PROVENANS.noekkel]: PROVENANS.verdi,
});
return `${frontmatter}\n${statusrapport(syklus, { naa, confidence })}`;
}
export function skrivStatus(syklusDir, confidence, opts = {}) {
if (!syklusDir) {
throw new Bruksfeil('Bruk: node syklus-status.mjs <syklus-dir> < confidence.json');
}
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, FILNAVN);
// Vernet foer komponeringen: en avvist skriv skal ikke koste noe, og
// rekkefolgen gjoer det umulig aa skrive foerst og oppdage vernet etterpaa.
krevSkrivbar(maal);
skrivAtomisk(maal, komponer(syklus, naa, confidence));
return { fil: maal, syklus: syklus.id, krer: syklus.okrer.reduce((n, o) => n + o.krer.length, 0) };
}
// --- CLI ---
function lesStdin() {
let raa;
try {
raa = readFileSync(0, 'utf8');
} catch {
throw new Bruksfeil('Klarte ikke aa lese confidence fra stdin. Pipe inn JSON: ... | node syklus-status.mjs <dir>');
}
if (raa.trim() === '') {
throw new Bruksfeil('Tom stdin. Forventet JSON: { "<KR-navn>": "On Track | At Risk | Off Track" }');
}
try {
return JSON.parse(raa);
} catch (e) {
throw new Bruksfeil(`Ugyldig JSON paa stdin: ${e.message}`);
}
}
const isMain = process.argv[1]
&& fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
const [syklusDir] = process.argv.slice(2);
try {
// Arity foer stdin: uten katalog er det ingenting aa lese confidence FOR.
if (!syklusDir) {
throw new Bruksfeil('Bruk: node syklus-status.mjs <syklus-dir> < confidence.json');
}
const r = skrivStatus(syklusDir, lesStdin());
process.stdout.write(
`Status skrevet: ${r.fil}\n Syklus: ${r.syklus}\n KR med confidence: ${r.krer}\n`,
);
process.exit(0);
} catch (e) {
process.stderr.write(`${basename(process.argv[1])}: ${e.message}\n`);
process.exit(e instanceof Bruksfeil ? 2 : 1);
}
}