feat(okr): konsept-skriver m/ type-til-nivaa-ruting + original-bevaring (SC original, riktig nivaa) [skip-docs]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y
This commit is contained in:
Kjell Tore Guttormsen 2026-06-30 13:33:38 +02:00
commit 935c1d0ae9
2 changed files with 274 additions and 0 deletions

122
lib/innboks-write.mjs Normal file
View file

@ -0,0 +1,122 @@
// innboks-write.mjs
// Step 7: persister berikede konsepter (splitConcepts -> projectFrontmatter ->
// resolveRelations) inn i prosjekt-bundlen, ikke-destruktivt og idempotent.
//
// (1) Hvert konsept skrives til sin FORHAANDSBEREGNEDE concept.destRel (rutet
// via routeLevel i Step 5 -- skriveren ruter ikke selv). Skrevet fil =
// concept.frontmatter (ferdig OKF-blokk) + concept.body (baerer allerede
// relasjons-seksjonen) verbatim -- INGEN re-serialisering. Trailing newline
// garanteres (POSIX-ren, deterministisk -> byte-idempotent re-kjoering).
// (2) Atomisk skriv: temp i SAMME katalog + renameSync over maalet (en krasj
// midt i skriv kan aldri etterlate en halv-skrevet fil).
// (3) Original-bevaring (SC3, ikke-destruktiv): skriveren roerer ALDRI
// drop-zone-originalene -- den asserterer kun at de finnes (saa pekeren
// aldri dangler). Per original skrives EN markdown-peker i konseptets nivaa
// som lenker bundle-root-relativt til originalen i .claude/okr/innboks/.
// (4) Path-confinement (innboks-dok = fiendtlig, RAG-poisoning): hver maal-sti
// maa resolvere UNDER bundle-rota (avvis '..'-escape / absolutt-override),
// og bundle-rota selv maa ikke vaere den home-kanoniske org-profilen
// (~/.claude/okr/org) -- ingestion skriver kun i prosjekt-bundlen.
//
// Reuses: atomisk-skriv-moenster (scripts/write-org-profile.mjs:34-40);
// writeFrontmatter (lib/frontmatter.mjs) for peker-frontmatter. Zero npm deps.
import { writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
import path from 'node:path';
import { homedir } from 'node:os';
import { writeFrontmatter } from './frontmatter.mjs';
// Den home-kanoniske org-profil-rota. Ingestion skal ALDRI skrive hit (den eies
// av write-org-profile.mjs); prosjekt-bundlen er .claude/okr under cwd.
const HOME_ORG = path.join(homedir(), '.claude', 'okr', 'org');
// Atomisk: temp-fil i samme katalog, deretter renameSync over maalet (atomisk
// paa samme filsystem). Speiler write-org-profile.mjs:34-40.
function writeAtomic(target, data) {
const dir = path.dirname(target);
mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `${path.basename(target)}.${process.pid}.tmp`);
writeFileSync(tmp, data);
renameSync(tmp, target);
}
// Resolver en bundle-relativ sti og asserter at den blir UNDER bundle-rota.
// Avviser '..'-escape og absolutt-override (path.resolve lar en absolutt rel
// vinne -- containment-sjekken fanger det).
function resolveUnderBundle(resolvedBundle, rel) {
const resolved = path.resolve(resolvedBundle, rel);
if (resolved !== resolvedBundle && !resolved.startsWith(resolvedBundle + path.sep)) {
throw new Error(`innboks-write: maal-sti utenfor bundle-rot avvist: ${rel}`);
}
return resolved;
}
// Skrevet fil = frontmatter + body verbatim, med garantert trailing newline.
function fileContent(concept) {
const out = `${concept.frontmatter}${concept.body}`;
return out.endsWith('\n') ? out : `${out}\n`;
}
// Peker-fil: minimal gyldig OKF-fil (type i lukket vokab) som lenker til den
// bevarte originalen. resource = bundle-relativ original-sti (uten leading '/').
function pointerContent(original, link) {
const basename = path.basename(original.path);
const frontmatter = writeFrontmatter({
type: 'Notat',
resource: link.replace(/^\/+/, ''),
title: `Kilde: ${basename}`,
kilde: 'innboks',
});
const body = `Peker til bevart original i drop-zonen (ikke-destruktiv ingestion).\n\n- [${basename}](${link})\n`;
return `${frontmatter}${body}`;
}
// concepts (berikede, m/ destRel + frontmatter + body) + { bundleRoot, originals }
// -> skriver konsept-filer + peker-filer atomisk under bundle-rota.
// originals: [{ sourceSlug, path }] -- path = originalens plassering i drop-zonen.
// Returnerer { concepts: [skrevne konsept-stier], pointers: [skrevne peker-stier] }
// (absolutte stier; pipelinen (Step 10) bruker dette til discard-on-fail rollback).
export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) {
if (!bundleRoot) throw new Error('innboks-write: bundleRoot kreves');
const resolvedBundle = path.resolve(bundleRoot);
// Confinement: avvis skriv til home-org-rota -- ingestion eier kun prosjekt-bundlen.
if (resolvedBundle === HOME_ORG || resolvedBundle.startsWith(HOME_ORG + path.sep)) {
throw new Error(`innboks-write: skriv til home-org-rot avvist: ${bundleRoot}`);
}
const writtenConcepts = [];
for (const concept of concepts) {
if (!concept.destRel) {
throw new Error(`innboks-write: konsept mangler destRel: ${concept.slug ?? '?'}`);
}
const target = resolveUnderBundle(resolvedBundle, concept.destRel);
writeAtomic(target, fileContent(concept));
writtenConcepts.push(target);
}
const writtenPointers = [];
for (const original of originals) {
const resolvedOriginal = path.resolve(original.path);
if (!existsSync(resolvedOriginal)) {
throw new Error(`innboks-write: original mangler (peker ville dangle): ${original.path}`);
}
// Original maa ligge under bundle-rota (drop-zonen .claude/okr/innboks/).
const relToBundle = path.relative(resolvedBundle, resolvedOriginal);
if (relToBundle === '' || relToBundle.startsWith('..') || path.isAbsolute(relToBundle)) {
throw new Error(`innboks-write: original utenfor bundle-rot: ${original.path}`);
}
const link = `/${relToBundle.split(path.sep).join('/')}`;
// Nivaa = nivaaet til foerste konsept fra samme kilde; default dokumenter/.
const sibling = concepts.find((c) => c.sourceSlug === original.sourceSlug && c.destRel);
const level = sibling ? path.dirname(sibling.destRel) : 'dokumenter';
const pointerRel = path.join(level, `${original.sourceSlug}.kilde.md`);
const pointerTarget = resolveUnderBundle(resolvedBundle, pointerRel);
writeAtomic(pointerTarget, pointerContent(original, link));
writtenPointers.push(pointerTarget);
}
return { concepts: writtenConcepts, pointers: writtenPointers };
}

View file

@ -0,0 +1,152 @@
// innboks-write.test.mjs
// Step 7 (SC original + riktig nivaa): konsept-skriver med type->nivaa-ruting og
// ikke-destruktiv original-bevaring. Driver den reelle kjernen
// (splitConcepts -> projectFrontmatter -> resolveRelations) per original og lar
// writeConcepts persistere settet i en temp-bundle. Verifiserer:
// - konsept med strategisk-kontekst/-destRel skrives dit, dokumenter/-destRel dit
// - skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline
// - originalens sha256 er uendret etter skriv (ikke-destruktiv, SC3)
// - peker-fil i konseptets nivaa lenker til originalen i drop-zonen (grep-bar)
// - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist
// - ingen .tmp lekker (atomisk temp+renameSync)
// Zero npm deps. Moenster: tests/org-profile-write.test.mjs (mkdtemp + realpath/sha).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { splitConcepts } from '../lib/innboks-split.mjs';
import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs';
import { resolveRelations } from '../lib/innboks-relations.mjs';
import { writeConcepts } from '../lib/innboks-write.mjs';
const MTIME = new Date('2026-03-15T08:30:00.000Z');
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
// Kjor den reelle kjernen for ett dokument -> konsepter (m/ destRel + frontmatter + body).
function ingestOne(markdown, sourceSlug, sourcePath) {
const raw = splitConcepts(markdown, { sourceSlug });
const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: MTIME }));
return resolveRelations(projected);
}
// Temp-bundle: <tmp>/.claude/okr (+ innboks/ drop-zone). Ryddes alltid.
function withBundle(fn) {
const tmp = mkdtempSync(join(tmpdir(), 'okrwrite-'));
const bundleRoot = join(tmp, '.claude', 'okr');
const inbox = join(bundleRoot, 'innboks');
mkdirSync(inbox, { recursive: true });
try {
fn({ tmp, bundleRoot, inbox });
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
const TILDELING = '# Tildelingsbrev 2026\n\nStatens vegvesen skal levere paa foelgende maal i 2026.\n';
const MERKNAD = '# Generell merknad\n\nEt fritt notat uten foeringsord i tittel.\n';
test('writeConcepts: type->nivaa-ruting (strategisk-kontekst + dokumenter)', () => {
withBundle(({ bundleRoot, inbox }) => {
const tPath = join(inbox, 'tildeling.txt');
const mPath = join(inbox, 'merknad.txt');
writeFileSync(tPath, TILDELING);
writeFileSync(mPath, MERKNAD);
const tConcepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
const mConcepts = ingestOne(MERKNAD, 'merknad', 'innboks/merknad.txt');
const [tc] = tConcepts;
const [mc] = mConcepts;
// Forventning fra Step 5-ruting (sanity foer skriv).
assert.equal(dirname(tc.destRel), 'strategisk-kontekst', 'Tildelingsbrev -> strategisk-kontekst/');
assert.equal(dirname(mc.destRel), 'dokumenter', 'default Dokument -> dokumenter/');
writeConcepts([...tConcepts, ...mConcepts], {
bundleRoot,
originals: [
{ sourceSlug: 'tildeling', path: tPath },
{ sourceSlug: 'merknad', path: mPath },
],
});
const tFile = join(bundleRoot, tc.destRel);
const mFile = join(bundleRoot, mc.destRel);
assert.ok(existsSync(tFile), 'tildelingsbrev-konsept skrevet til strategisk-kontekst/');
assert.ok(existsSync(mFile), 'merknad-konsept skrevet til dokumenter/');
// Skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline. IKKE re-serialisert.
const tContent = readFileSync(tFile, 'utf8');
assert.ok(tContent.startsWith(tc.frontmatter), 'frontmatter verbatim oeverst');
assert.ok(tContent.includes(tc.body), 'body verbatim bevart');
assert.ok(tContent.endsWith('\n'), 'fil avsluttes med newline');
});
});
test('writeConcepts: original uendret (sha256) + peker-fil lenker til drop-zone-original', () => {
withBundle(({ bundleRoot, inbox }) => {
const tPath = join(inbox, 'tildeling.txt');
writeFileSync(tPath, TILDELING);
const before = sha256(tPath);
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
writeConcepts(concepts, {
bundleRoot,
originals: [{ sourceSlug: 'tildeling', path: tPath }],
});
// SC3: ikke-destruktiv -- originalen er byte-uendret.
assert.equal(sha256(tPath), before, 'original-sha256 uendret etter skriv');
assert.ok(existsSync(tPath), 'original finnes fortsatt i drop-zonen');
// Peker-fil i konseptets nivaa, grep-bar lenke til originalen.
const level = dirname(concepts[0].destRel);
const pointer = join(bundleRoot, level, 'tildeling.kilde.md');
assert.ok(existsSync(pointer), 'peker-fil skrevet i konseptets nivaa');
const pointerBody = readFileSync(pointer, 'utf8');
assert.match(pointerBody, /\(\/innboks\/tildeling\.txt\)/, 'peker lenker til original i drop-zonen');
assert.match(pointerBody, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)');
});
});
test('writeConcepts: ../-escape destRel avvist (path-confinement)', () => {
withBundle(({ bundleRoot }) => {
const evil = { slug: 'evil', sourceSlug: 'evil', destRel: '../escape.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
assert.throws(
() => writeConcepts([evil], { bundleRoot, originals: [] }),
/utenfor bundle-rot/,
'destRel som escaper bundle-rota skal kaste',
);
assert.ok(!existsSync(join(bundleRoot, '..', 'escape.md')), 'ingenting skrevet utenfor bundle');
});
});
test('writeConcepts: skriv til home-org-rot avvist (ingestion roerer aldri ~/.claude/okr/org)', () => {
const homeOrg = join(homedir(), '.claude', 'okr', 'org');
const concept = { slug: 'p', sourceSlug: 'p', destRel: 'dokumenter/p.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
// Kaster FOER noen skriv -- home-profilen roeres aldri.
assert.throws(
() => writeConcepts([concept], { bundleRoot: homeOrg, originals: [] }),
/home-org-rot avvist/,
'home-org bundleRoot skal kaste',
);
});
test('writeConcepts: atomisk -- ingen .tmp lekker etter skriv', () => {
withBundle(({ bundleRoot, inbox }) => {
const tPath = join(inbox, 'tildeling.txt');
writeFileSync(tPath, TILDELING);
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
writeConcepts(concepts, { bundleRoot, originals: [{ sourceSlug: 'tildeling', path: tPath }] });
const level = dirname(concepts[0].destRel);
const leftover = readdirSync(join(bundleRoot, level)).filter((f) => f.endsWith('.tmp'));
assert.deepEqual(leftover, [], 'ingen temp-fil igjen etter atomisk renameSync');
});
});