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:
parent
9701c81158
commit
5219d50d4b
4 changed files with 233 additions and 9 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
65
scripts/compose-org-profile.mjs
Normal file
65
scripts/compose-org-profile.mjs
Normal 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'));
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
143
tests/oppsett-okf-write.test.mjs
Normal file
143
tests/oppsett-okf-write.test.mjs
Normal 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)');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue