feat(okr): onboarding skriver OKF-frontmatter paa org-profil (SC6)

compose-org-profile.mjs prepender top-level OKF-noekler (type:
Organisasjonsprofil/resource/timestamp) over den nestede org-profilen i
ETT frontmatter-blokk, pipes til write-org-profile.mjs (uendret). Kun de
tre trygge noeklene top-level; FORBID nye top-level navn/id/fase/domene/
sektor som ville skygge nestede verdier hooken leser. oppsett.md (begge
profil-skriv-sites) + template oppdatert. Test: compose->pip->readback +
inject-roundtrip (4), full suite 51->55.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 17:19:14 +02:00
commit 5219d50d4b
4 changed files with 233 additions and 9 deletions

View file

@ -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.

View file

@ -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'));

View file

@ -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"

View file

@ -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)');
});
});