#!/usr/bin/env node // innboks-ingest.mjs // Step 12 (A2): pipeline-orkestrator for innboks-ingestion. Kjoerer drop-zonen // (.claude/okr/innboks/) gjennom den deterministiske kjeden og persisterer // konsepter i prosjekt-bundlen. INGEN LLM i v1 (--enrich = v1.1-soem, ingen flagg). // // Faserekkefoelge (A2-redesign av plan.md Step 12 -- begrunnelse under): // 1. discover: flat walk av drop-zonen (kun filer, dotfiler skippes), sortert // (deterministisk rekkefoelge = stabil disambiguering). Ukjent extension -> // skip + norsk notice (via convert), aldri feil. // 2. PER DOKUMENT (staging + gate, laast A0): convert -> splitConcepts -> // projectFrontmatter (timestamp = original-mtime, IKKE veggklokke) -> // kryss-kilde destRel-disambiguering (B5: kolliderende destRel fra annen // kilde faar ---suffiks, stabilt gitt sortert rekkefoelge) -> // writeConcepts (delt claimed-register) -> per-dokument-gate // checkBundle(root, {strictIngest: true, files: }) // -- strict scopes til KUN kjoeringens skrevne filer, ALDRI hele roten // (B2: kuratert innhold med lovlige eksterne lenker/out-of-vocab-type kan // ikke felle ingestion). Gate-feil -> discard-on-fail: KUN det dokumentets // skrivinger rulles tilbake (Map-Reduce-isolasjon -- ett fiendtlig dok // forgifter ikke andre). // 3. resolveRelations over ALLE overlevende dokumenters konsepter, DERETTER // relasjons-omskriv av konsepter som fikk relasjoner. // A2-REDESIGN (avvik fra plan-kjedens "relations foer write"): relasjoner // per dokument alene kan aldri emittere kryss-dokument-lenker (fixture-SC // dok-a -> dok-b), og relasjoner FOER gaten ville latt et discardet // dokument etterlate dangling relasjonslenker i overlevende dokumenter // (isolasjonsbrudd). Gate foerst, relasjoner blant overlevende etterpaa: // hvert relasjonsmaal er da garantert paa disk. // 4. generateIndexes(root) over det overlevende settet. // 5. Belte+seler: scoped strict-sjekk av ALLE overlevende skrevne filer -- // feil her er et internt invariant-brudd (skal aldri skje), ikke dok-feil. // // Idempotens by construction: fil-settet er en funksjon av drop-zonens innhold // + mtime alene (run1 vs run2 byte-identisk, testet via sha256-manifest). // Project-root-only: writeConcepts avviser home-org-rota; ingen cross-repo. // // Bruk: node scripts/innboks-ingest.mjs // Exit: 0 = alle dokumenter OK (skip av ukjent filtype er OK), 1 = minst ett // dokument feilet/discardet (eller internt invariant-brudd), 2 = bruksfeil. import { readdirSync, statSync, unlinkSync, rmdirSync, existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { convert } from '../lib/convert/index.mjs'; 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'; import { generateIndexes } from './okf-index.mjs'; import { checkBundle } from './okf-check.mjs'; // Kebab-slug -- speiler innboks-split.mjs:13-21 (frosset modul, eksporterer // ikke slugify; semantikken MAA vaere identisk med konsept-slugging). function slugify(text) { const s = String(text) .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return s || 'konsept'; } // Flat, sortert discover av drop-zonen: kun regulaere filer, dotfiler skippes. // (Underkataloger i drop-zonen er udefinert i v1 -- de ignoreres stille.) function discover(inboxDir) { return readdirSync(inboxDir, { withFileTypes: true }) .filter((e) => !e.name.startsWith('.') && !e.isDirectory()) .map((e) => e.name) .sort((a, b) => a.localeCompare(b)); } // Unik sourceSlug per fil: basename uten extension; kollisjon (dok.docx vs // dok.pdf) -> extension-suffiks; deretter teller. Deterministisk i sortert orden. function assignSourceSlug(name, taken) { const ext = path.extname(name); const base = slugify(path.basename(name, ext)); const candidates = [base, `${base}-${slugify(ext)}`]; for (const c of candidates) { if (!taken.has(c)) { taken.add(c); return c; } } for (let i = 2; ; i += 1) { const c = `${candidates[1]}-${i}`; if (!taken.has(c)) { taken.add(c); return c; } } } // B5: destRel-disambiguering PAA TVERS av kilder. claimed = det delte registeret // (absolutt maal -> sourceSlug) som writeConcepts ogsaa haandhever. Kolliderende // destRel fra en ANNEN kilde faar ---suffiks (stabilt: avhenger kun // av input-settet + sortert rekkefoelge, aldri veggklokke). function disambiguate(concepts, resolvedBundle, claimed) { return concepts.map((c) => { let destRel = c.destRel; const owner = claimed.get(path.resolve(resolvedBundle, destRel)); if (owner !== undefined && owner !== c.sourceSlug) { const dir = path.dirname(destRel); const base = path.basename(destRel, '.md'); destRel = `${dir}/${base}--${c.sourceSlug}.md`; for (let i = 2; claimed.has(path.resolve(resolvedBundle, destRel)); i += 1) { destRel = `${dir}/${base}--${c.sourceSlug}-${i}.md`; } } return destRel === c.destRel ? c : { ...c, destRel }; }); } // Discard-on-fail: fjern dokumentets skrevne filer + toemte foreldre-kataloger // (opp til bundle-rota), og slipp claimed-registreringene. function discard(written, resolvedBundle, claimed) { for (const file of written) { if (existsSync(file)) unlinkSync(file); claimed.delete(file); let dir = path.dirname(file); while (dir !== resolvedBundle && dir.startsWith(resolvedBundle + path.sep)) { try { rmdirSync(dir); // kaster hvis ikke tom -> ferdig aa rydde } catch { break; } dir = path.dirname(dir); } } } // Orkestrer hele ingest-kjoeringen. Returnerer // { ingested: [sourceSlug], skipped: [filnavn], failed: [{source, reason}], written: [absolutt sti] } export async function ingestInbox(inboxDir, bundleRoot, { onNotice = (msg) => process.stderr.write(`${msg}\n`) } = {}) { const resolvedBundle = path.resolve(bundleRoot); const claimed = new Map(); // absolutt maal -> sourceSlug (delt med writeConcepts, B5) const takenSlugs = new Set(); const ingested = []; const skipped = []; const failed = []; const survivors = []; // { source, concepts (disambiguert), written } for (const name of discover(inboxDir)) { const filePath = path.join(inboxDir, name); const sourceSlug = assignSourceSlug(name, takenSlugs); let written = null; try { const markdown = await convert(filePath, { onNotice }); if (markdown === null) { skipped.push(name); continue; } const mtime = statSync(filePath).mtime; const sourcePath = path.relative(resolvedBundle, path.resolve(filePath)).split(path.sep).join('/'); const raw = splitConcepts(markdown, { sourceSlug }); const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: mtime })); const concepts = disambiguate(projected, resolvedBundle, claimed); const result = writeConcepts(concepts, { bundleRoot: resolvedBundle, originals: [{ sourceSlug, path: filePath }], claimed, }); written = [...result.concepts, ...result.pointers]; // Per-dokument-gate, scoped til KUN dette dokumentets skrevne filer (B2). const gate = checkBundle(resolvedBundle, { strictIngest: true, files: written }); const errors = [...gate.missingType.map((f) => `mangler type: ${f}`), ...gate.strictErrors]; if (errors.length > 0) { discard(written, resolvedBundle, claimed); failed.push({ source: name, reason: errors.join('; ') }); continue; } survivors.push({ source: name, sourceSlug, concepts, written }); ingested.push(sourceSlug); } catch (e) { // Skrive-/konverteringsfeil (kuratert-kollisjon, symlink, manglende dep): // discard det som maatte vaere skrevet, kjoeringen fortsetter (isolasjon). if (written) discard(written, resolvedBundle, claimed); failed.push({ source: name, reason: e.message }); } } // Fase 3: relasjoner blant OVERLEVENDE dokumenters konsepter (kryss-dokument; // hvert maal er garantert paa disk). Omskriv kun konsepter som fikk relasjoner. const allConcepts = survivors.flatMap((s) => s.concepts); const related = resolveRelations(allConcepts); const withRelations = related.filter((c) => c.relations.length > 0); if (withRelations.length > 0) { writeConcepts(withRelations, { bundleRoot: resolvedBundle, claimed }); } // Fase 4: indekser det overlevende settet (rot + alle nivaaer). generateIndexes(resolvedBundle); // Fase 5 (belte+seler): alle overlevende skrevne filer maa passere scoped // strict. Feil her er et internt invariant-brudd, ikke en dokument-feil. const allWritten = survivors.flatMap((s) => s.written); if (allWritten.length > 0) { const final = checkBundle(resolvedBundle, { strictIngest: true, files: allWritten }); const finalErrors = [...final.missingType, ...final.strictErrors]; if (finalErrors.length > 0) { throw new Error(`innboks-ingest: intern invariant brutt etter relasjons-fasen: ${finalErrors.join('; ')}`); } } return { ingested, skipped, failed, written: allWritten }; } // --- CLI --- const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { const [inboxDir, bundleRoot] = process.argv.slice(2); if (!inboxDir || !bundleRoot) { process.stderr.write('Bruk: node innboks-ingest.mjs \n'); process.exit(2); } if (!existsSync(inboxDir) || !existsSync(bundleRoot)) { process.stderr.write(`Finnes ikke: ${existsSync(inboxDir) ? bundleRoot : inboxDir}\n`); process.exit(2); } try { const r = await ingestInbox(inboxDir, bundleRoot); const lines = [`Innboks-ingest: ${inboxDir} -> ${bundleRoot}`]; lines.push(` Ingested: ${r.ingested.length} dokument(er)${r.ingested.length ? ` (${r.ingested.join(', ')})` : ''}`); lines.push(` Skippet (ukjent filtype): ${r.skipped.length}`); lines.push(` Feilet/discardet: ${r.failed.length}`); for (const f of r.failed) lines.push(` x ${f.source}: ${f.reason}`); lines.push(r.failed.length === 0 ? 'OK: alle dokumenter ingested' : 'FEIL: se discardede dokumenter over'); process.stdout.write(`${lines.join('\n')}\n`); process.exit(r.failed.length === 0 ? 0 : 1); } catch (e) { process.stderr.write(`innboks-ingest: ${e.message}\n`); process.exit(1); } }