// 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 }; }