diff --git a/commands/oppsett.md b/commands/oppsett.md index eee0926..1f56204 100644 --- a/commands/oppsett.md +++ b/commands/oppsett.md @@ -225,15 +225,20 @@ Etter alle 6 faser: 2. Sett `onboarding_status: fullfort` og `Sist oppdatert: [dato]` 3. **Skriv reinstall-overlevende org-profil til hjem-stien.** Profilen (`organisasjon:`- og `program:`-seksjonene — org-identiteten som skal overleve - reinstall og være tilgjengelig på tvers av prosjekter) skrives til en temp-fil og - sendes gjennom skrivehelperen via Bash: + reinstall og være tilgjengelig på tvers av prosjekter) skrives **uten `---`-fences** + til en temp-fil og sendes gjennom OKF-komponisten + skrivehelperen via Bash: ```bash - OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs < /tmp/okr-profil.tmp) + OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/compose-org-profile.mjs < /tmp/okr-profil.tmp \ + | node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs) echo "$OUT" ``` - Helperen skriver atomisk (temp + `renameSync`) til `~/.claude/okr/org/profil.md` og + `compose-org-profile.mjs` prepender de tre top-level OKF-nøklene (`type: + Organisasjonsprofil`, `resource:`, `timestamp:`) over den nestede YAML-en og pakker + alt i **ett** frontmatter-blokk (second-brain-kompatibelt). Legg **aldri** til en ny + top-level `navn:`/`id:`/`fase:`/`domene:`/`sektor:` i profilen — de ville skygge de + nestede verdiene som hooken leser. Helperen skriver atomisk (temp + `renameSync`) til `~/.claude/okr/org/profil.md` og rapporterer den **faktisk brukte stien** på stdout (`$OUT`); en eventuell fallback-notice går til stderr. Rapporter til brukeren: «Org-profil skrevet til: $OUT». Hvis `$OUT` peker på den prosjektlokale fallbacken (`.claude/okr.local.md` — skjer kun @@ -337,16 +342,20 @@ preferanser: ``` **Skriv også reinstall-overlevende org-profil til hjem-stien.** Skriv profil-YAML-en -(`organisasjon:` + `program:`) til en temp-fil og kjør skrivehelperen via Bash: +(`organisasjon:` + `program:`, **uten `---`-fences**) til en temp-fil og kjør OKF-komponisten ++ skrivehelperen via Bash: ```bash -OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs < /tmp/okr-profil.tmp) +OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/compose-org-profile.mjs < /tmp/okr-profil.tmp \ + | node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs) echo "$OUT" ``` -Rapporter «Org-profil skrevet til: $OUT». Hvis `$OUT` er den prosjektlokale fallbacken, -vis YAML-en for manuell plassering (jf. Feilhåndtering). Syklus-/kontekstdata forblir -prosjektlokalt i `.claude/okr.local.md`. +`compose-org-profile.mjs` prepender top-level OKF-nøkler (`type: Organisasjonsprofil`, +`resource:`, `timestamp:`) over den nestede profilen i ett frontmatter-blokk. Rapporter +«Org-profil skrevet til: $OUT». Hvis `$OUT` er den prosjektlokale fallbacken, vis YAML-en +for manuell plassering (jf. Feilhåndtering). Syklus-/kontekstdata forblir prosjektlokalt +i `.claude/okr.local.md`. Vis kort oppsummering og foreslå `/okr:oppsett full` for å fylle ut resten senere. diff --git a/scripts/compose-org-profile.mjs b/scripts/compose-org-profile.mjs new file mode 100644 index 0000000..332bcbb --- /dev/null +++ b/scripts/compose-org-profile.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +// compose-org-profile.mjs +// Purpose: wrap the NESTED org-profile YAML body (organisasjon:/program: ..., +// read from stdin) in an OKF-compatible frontmatter document, prepending the +// three top-level OKF keys the second-brain "Documents/kb" layout expects on a +// concept file: type / resource / timestamp. Emits the full document to stdout, +// meant to be piped into write-org-profile.mjs (which writes it atomically to +// the reinstall-surviving home path ~/.claude/okr/org/profil.md). +// +// Why a dedicated composer (not lib/frontmatter.mjs writeFrontmatter): the org +// profile is NESTED (organisasjon: { navn, type }, program: { ... }). The flat +// writeFrontmatter would flatten/destroy that structure. We instead keep the +// caller's nested YAML verbatim and only PREPEND OKF keys above it. +// +// Why ONLY type/resource/timestamp top-level: the home profile is read by the +// flat parser (lib/frontmatter.mjs) in inject-okr-context.mjs via +// get('navn')/get('id')/get('domene')/... A NEW top-level key colliding with +// any of those (navn/name/id/fase/domene/sektor) would be matched FIRST and +// shadow the nested value, breaking org/cycle resolution (the Critical risk). +// type/resource/timestamp are read by NO consumer, so they are safe to prepend. +// The nested organisasjon.type ("offentlig") is untouched and still unread. +// +// Single-block invariant: the OKF keys and the nested body MUST live in ONE +// frontmatter block. A stray intermediate '---' would truncate the block the +// flat parser reads (dropping organisasjon: -> get('navn') === null -> no +// injection), so any fences the caller already put around the body are stripped +// before re-wrapping. +// +// Testable clock seam: OKR_NOW (ISO-8601) overrides the wall clock so the +// emitted timestamp is deterministic under test. +// +// Zero npm dependencies (node: builtins only). ASCII-clean identifiers. + +import { readFileSync } from 'node:fs'; + +// OKF resource pointer: where the canonical org profile lives. A bare string +// pointer (OKF: "URL/path to the source"); no consumer reads it. +const RESOURCE = '~/.claude/okr/org/profil.md'; + +let body = ''; +try { + body = readFileSync(0, 'utf8'); +} catch { + body = ''; +} + +// Strip a leading/trailing frontmatter fence if the caller already wrapped the +// body, so the result is exactly one frontmatter block (see single-block note). +body = body.replace(/^\s*---\s*\r?\n/, ''); +body = body.replace(/\r?\n---\s*\r?\n?\s*$/, '\n'); +body = body.replace(/\s+$/, ''); + +const timestamp = process.env.OKR_NOW || new Date().toISOString(); + +const lines = [ + '---', + 'type: Organisasjonsprofil', + `resource: ${RESOURCE}`, + `timestamp: '${timestamp}'`, +]; +if (body) lines.push(body); +lines.push('---', ''); + +process.stdout.write(lines.join('\n')); diff --git a/templates/okr.local.md.template b/templates/okr.local.md.template index ab7f156..e5e844d 100644 --- a/templates/okr.local.md.template +++ b/templates/okr.local.md.template @@ -7,6 +7,13 @@ # hjem-profil: ~/.claude/okr/org/profil.md (skrevet av /okr:oppsett). En prosjektlokal # fil her vinner over hjem-profilen (mest-spesifikk-vinner); syklus-/kontekstdata er # alltid prosjektlokalt. +# OKF-frontmatter (second-brain-kompatibel): hjem-profilen faar disse tre top-level- +# noeklene av /okr:oppsett via compose-org-profile.mjs. Kun type/resource/timestamp er +# trygge top-level (leses ikke av hooks); legg ALDRI til top-level navn/id/fase/domene/ +# sektor -- de ville skygge de nestede verdiene under organisasjon:/gjeldende_syklus:. +type: Organisasjonsprofil +resource: ~/.claude/okr/org/profil.md +timestamp: '' # ISO-8601, settes ved skriv onboarding_status: partial # partial | fullfort organisasjon: navn: "Din organisasjon" diff --git a/tests/oppsett-okf-write.test.mjs b/tests/oppsett-okf-write.test.mjs new file mode 100644 index 0000000..e5fc63e --- /dev/null +++ b/tests/oppsett-okf-write.test.mjs @@ -0,0 +1,143 @@ +// oppsett-okf-write.test.mjs +// Step 7 (SC6): onboarding skriver OKF-frontmatter paa org-profilen. +// Profilen er NESTET (organisasjon: { navn, type }), saa flat writeFrontmatter +// er feil verktoey. compose-org-profile.mjs bygger den fulle nestede YAML-en og +// PREPENDER tre top-level OKF-noekler (type/resource/timestamp) FOER organisasjon:. +// Pipes til write-org-profile.mjs (uendret) som skriver atomisk til hjem. +// +// Kritisk invariant: kun type/resource/timestamp top-level (leses ikke av hooks). +// En NY top-level navn/id/fase/domene/sektor ville matches FOERST av den flate +// parseren og skygge den nestede verdien -> brutt org-/syklus-resolusjon. +// Zero npm deps. Moenster: tests/org-profile-write.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const COMPOSE = join(ROOT, 'scripts', 'compose-org-profile.mjs'); +const WRITER = join(ROOT, 'scripts', 'write-org-profile.mjs'); +const HOOK = join(ROOT, 'hooks', 'scripts', 'inject-okr-context.mjs'); + +// Nestet profil-body slik /okr:oppsett produserer den (organisasjon: + program:). +// Bevisst UTEN '#'-kommentarer her -- comment-strip dekkes av frontmatter-testen. +const NESTED_BODY = [ + 'onboarding_status: fullfort', + 'organisasjon:', + ' navn: "Testdirektoratet"', + ' kortform: "TDIR"', + ' type: "offentlig"', + ' domene: "digitalisering"', + 'program:', + ' modenhetsnivaa: "pilot"', + ' okr_frikoblet_fra_loenn: true', + '', +].join('\n'); + +const NOW = '2026-06-26T10:00:00+00:00'; + +function compose(input, env = {}) { + return execFileSync('node', [COMPOSE], { + env: { ...process.env, ...env }, + input, + encoding: 'utf8', + }); +} + +function write(cwd, home, input) { + return execFileSync('node', [WRITER], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function runHook(cwd, home) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('compose: prepender top-level type/resource/timestamp, bevarer nestet organisasjon/navn', () => { + const composed = compose(NESTED_BODY, { OKR_NOW: NOW }); + + // Top-level OKF-noekler (linjestart): + assert.match(composed, /^type: Organisasjonsprofil$/m, 'top-level type'); + assert.match(composed, /^resource: /m, 'top-level resource'); + assert.match(composed, /^timestamp: '2026-06-26T10:00:00\+00:00'$/m, 'top-level timestamp (ISO, sitert)'); + + // Nestet struktur bevart: + assert.match(composed, /^organisasjon:$/m, 'nestet organisasjon: bevart'); + assert.match(composed, /^ {2}navn: "Testdirektoratet"$/m, 'nestet navn bevart'); + + // ETT samlet frontmatter-blokk: parseFrontmatter ser BAADE OKF-noekler OG nestet navn. + const { get } = parseFrontmatter(composed); + assert.equal(get('navn'), 'Testdirektoratet', 'navn resolver (ingen intern --- som trunkerer blokken)'); + assert.equal(get('type'), 'Organisasjonsprofil', 'type resolver til OKF-verdi'); + + // FORBID-regel (Critical risk): ingen NY top-level noekkel som skygger nestet verdi. + assert.doesNotMatch(composed, /^navn:/m, 'ingen top-level navn (ville skygge nestet org-navn)'); + assert.doesNotMatch(composed, /^id:/m, 'ingen top-level id (ville skygge nestet cycle-id)'); + assert.doesNotMatch(composed, /^fase:/m, 'ingen top-level fase'); + assert.doesNotMatch(composed, /^domene:/m, 'ingen top-level domene'); + assert.doesNotMatch(composed, /^sektor:/m, 'ingen top-level sektor'); +}); + +test('compose: tolererer body som allerede baerer --- fences (samler til ett blokk)', () => { + const fenced = `---\n${NESTED_BODY}---\n`; + const composed = compose(fenced, { OKR_NOW: NOW }); + const { get } = parseFrontmatter(composed); + assert.equal(get('navn'), 'Testdirektoratet', 'navn resolver (ingen dobbel-fence trunkerer blokken)'); + assert.equal(get('type'), 'Organisasjonsprofil'); + const fences = composed.match(/^---$/gm) || []; + assert.equal(fences.length, 2, 'noeyaktig to frontmatter-fences'); +}); + +test('compose -> write-org-profile: hjem-profil faar OKF-frontmatter + bevart nestet navn', () => { + withDirs((home, work) => { + const composed = compose(NESTED_BODY, { OKR_NOW: NOW }); + const out = write(work, home, composed); + const target = join(home, '.claude', 'okr', 'org', 'profil.md'); + assert.ok(existsSync(target), 'hjem-profil skal finnes'); + const written = readFileSync(target, 'utf8'); + assert.match(written, /^type: Organisasjonsprofil$/m, 'skrevet profil har top-level type'); + assert.match(written, /^resource: /m, 'skrevet profil har resource'); + assert.match(written, /^timestamp: /m, 'skrevet profil har timestamp'); + assert.match(written, /navn: "Testdirektoratet"/, 'skrevet profil bevarer nestet navn'); + assert.equal(written, composed, 'write-org-profile er byte-passthrough (skriver stdin uendret)'); + assert.ok(out.trim().length > 0, 'helper rapporterer brukt sti paa stdout'); + }); +}); + +test('inject-roundtrip: get(navn) resolver org-navn (ikke "Organisasjonsprofil"), ingen #, cap < 512 B', () => { + withDirs((home, work) => { + const composed = compose(NESTED_BODY, { OKR_NOW: NOW }); + write(work, home, composed); + // Tomt prosjekt-cwd -> hooken faller til hjem-profil (mest-spesifikk-vinner). + const injected = runHook(work, home); + const { systemMessage } = JSON.parse(injected); + assert.match(systemMessage, /Testdirektoratet/, 'org-navn injiseres'); + assert.doesNotMatch(systemMessage, /Organisasjonsprofil/, 'top-level type skal IKKE skygge nestet navn'); + assert.ok(!systemMessage.includes('#'), 'ingen # i payload'); + assert.ok(Buffer.byteLength(systemMessage, 'utf8') < 512, 'kjerne-payload < 512 B (SC5)'); + }); +});