// decisions-io.mjs — Read/write + pure helpers for data/decisions.json (lag 2). // The decision ledger is the ONLY write-authorized bridge between detection and // the KB/registry: discovery READS it to filter, the operator gate WRITES it. // Zero dependencies. Atomic writes via .tmp + rename (mirrors registry-io). // // Dedup policy = A (operatør 2026-06-19): isDecided(url) is true for ANY ledger // entry (pending | approved | rejected). Discovery re-proposes only URLs that // are entirely absent from the ledger; everything the operator has touched — // approved, rejected, or explicitly deferred (pending) — stays off the list. import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_DATA_DIR = join(__dirname, '..', 'data'); /** * Empty ledger scaffold. * * Three parallel collections, all written ONLY through the operator gate: * - decisions — URL-keyed (Spor A): which Microsoft Learn page belongs where. * - actions — skill-keyed (Spor B / B3): skill-lifecycle ops (merge_skills, * sanitize_skill, retire_skill, create_skill). * - courses — UID-keyed (Spor C / C3): training-course leads (new | updated) * the operator may want covered in the KB. A SEPARATE collection * on purpose — an approved course lead must never reach the * doc-transform/ingest pipeline (auto-ingest is a non-goal), so * the apply-path (which reads only decisions/actions) never sees * it. Dedup is per stable UID, not URL (spec §4.5). * Additive: version stays 1, and a ledger written before any of the actions or * courses layers existed normalizes cleanly (loadDecisions backfills the missing * keys). * @returns {{version: number, updated_at: string|null, decisions: object, actions: object, courses: object}} */ export function createLedger() { return { version: 1, updated_at: null, decisions: {}, actions: {}, courses: {} }; } /** * Load the decision ledger from disk. Backward-compatible: a ledger written * before the action or course layers existed (no `actions` / `courses` key) is * normalized to `{}` so callers never have to guard against undefined. * @param {string} [dataDir] — defaults to ../data/ relative to lib/ * @returns {object} parsed ledger or empty scaffold */ export function loadDecisions(dataDir = DEFAULT_DATA_DIR) { const path = join(dataDir, 'decisions.json'); if (!existsSync(path)) return createLedger(); const led = JSON.parse(readFileSync(path, 'utf8')); if (!led.actions) led.actions = {}; if (!led.courses) led.courses = {}; return led; } /** * Save the decision ledger atomically (write to .tmp, then rename). * This is the gate's write path — detection scripts must NOT import it. * @param {object} ledger * @param {string} [dataDir] */ export function saveDecisions(ledger, dataDir = DEFAULT_DATA_DIR) { if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); const path = join(dataDir, 'decisions.json'); const tmp = path + '.tmp'; writeFileSync(tmp, JSON.stringify(ledger, null, 2) + '\n', 'utf8'); renameSync(tmp, path); } /** * Has the operator already decided on this URL? Policy A: any entry counts. * @param {object} ledger * @param {string} url — normalized URL * @returns {boolean} */ export function isDecided(ledger, url) { return Boolean(ledger.decisions && ledger.decisions[url]); } /** * Record a decision for a URL. Pure — returns a new ledger, does not mutate. * @param {object} ledger * @param {string} url — normalized URL * @param {{status: string, decided_at?: string, suggested_skill?: string|null, * suggested_category?: string, note?: string}} decision * @returns {object} new ledger */ export function recordDecision(ledger, url, decision) { return { ...ledger, updated_at: decision.decided_at ?? ledger.updated_at, decisions: { ...ledger.decisions, [url]: { ...decision } }, }; } /** * Drop candidates the operator has already decided on (policy A). * Discovery applies this before emitting its report. * @param {object} ledger * @param {Array<{url: string}>} candidates * @returns {Array<{url: string}>} candidates with no ledger entry */ export function filterUndecided(ledger, candidates) { return candidates.filter((c) => !isDecided(ledger, c.url)); } // =========================================================================== // Skill-level action layer (Spor B / B3) — additive, mirrors the URL helpers. // Detection/transformation produce a PENDING action entry; only the gate writes // it. recordAction never mutates skills/ — it records a proposal in the ledger. // =========================================================================== /** * Deterministic dedup key for a skill-lifecycle action. merge_skills is keyed * symmetrically on the pair (merging A<-B and B<-A dedup to the same key); * single-skill ops (sanitize/retire/create) are keyed by their one target. * @param {{operation_type: string, targets?: object}} entry * @returns {string} */ export function actionKey(entry) { const op = entry.operation_type; const t = entry.targets ?? {}; if (op === 'merge_skills') { const pair = [t.absorber, t.absorbed].filter(Boolean).sort(); return `merge_skills:${pair.join('+')}`; } const single = t.skill ?? t.name ?? t.absorber ?? '?'; return `${op}:${single}`; } /** * Has the operator already decided on this action? Policy A: any entry * (pending | approved | rejected) counts — mirrors isDecided for URLs. * @param {object} ledger * @param {string} key — from actionKey() * @returns {boolean} */ export function isActionDecided(ledger, key) { return Boolean(ledger.actions && ledger.actions[key]); } /** * Record a skill-lifecycle action. Pure — returns a new ledger, does not mutate. * Keyed by actionKey(entry). Leaves the URL-keyed decisions map untouched. * @param {object} ledger * @param {{operation_type: string, status: string, decided_at?: string|null, * targets?: object, guardrail?: object, note?: string}} entry * @returns {object} new ledger */ export function recordAction(ledger, entry) { const key = actionKey(entry); return { ...ledger, updated_at: entry.decided_at ?? ledger.updated_at, actions: { ...(ledger.actions ?? {}), [key]: { ...entry } }, }; } /** * List recorded actions, optionally filtered by status. * @param {object} ledger * @param {string|null} [status] — when given, keep only entries with this status * @returns {Array} */ export function listActions(ledger, status = null) { const all = Object.values(ledger.actions ?? {}); return status ? all.filter((a) => a.status === status) : all; } /** * Transition a recorded action to a new status. Pure — returns a new ledger, * does not mutate. Every other field (targets, guardrail, note) is preserved. * This is the apply-path's one write primitive (Sesjon 18): after a successful * skills/ mutation it flips the operator-approved entry approved -> applied, * keeping the entry as an audit record (policy A dedup still holds). * @param {object} ledger * @param {string} key — from actionKey() * @param {string} status — new status (e.g. 'approved', 'applied') * @param {string|null} [decided_at] — when given, advances the entry date + updated_at * @returns {object} new ledger * @throws if no action exists under `key` */ export function setActionStatus(ledger, key, status, decided_at = null) { const existing = ledger.actions?.[key]; if (!existing) throw new Error(`setActionStatus: no action recorded under key "${key}"`); return { ...ledger, updated_at: decided_at ?? ledger.updated_at, actions: { ...ledger.actions, [key]: { ...existing, status, decided_at: decided_at ?? existing.decided_at }, }, }; } // =========================================================================== // Course-lead layer (Spor C / C3) — additive, UID-keyed, mirrors the action // helpers. detect-courses.mjs (Claude-free detection) produces a report; the // operator gate records APPROVED/REJECTED leads here. A course lead is a SIGNAL // that a topic exists, never a doc to ingest — so it lives in its own `courses` // collection that the apply-path (apply-skill-op.mjs / discover-new-urls.mjs) // never reads. Dedup is per stable UID (spec §4.5: the course `id`, not its URL, // is the durable key). // =========================================================================== /** * Has the operator already decided on this course lead? Policy A: any entry * (pending | approved | rejected) counts — mirrors isDecided / isActionDecided. * The gate filters detection-report candidates through this so a course the * operator has touched never resurfaces. * @param {object} ledger * @param {string} uid — the course's stable UID (module / learning-path id) * @returns {boolean} */ export function isCourseLeadDecided(ledger, uid) { return Boolean(ledger.courses && ledger.courses[uid]); } /** * Record a course lead. Pure — returns a new ledger, does not mutate. Keyed by * the course's stable UID. Leaves the URL-keyed decisions and skill-keyed * actions maps untouched. * @param {object} ledger * @param {string} uid — the course's stable UID * @param {{kind: string, status: string, decided_at?: string|null, title?: string, * url?: string, products?: string[], suggested_skill?: string|null, * suggested_category?: string, updated_at?: string, detected_at?: string, * note?: string}} lead * @returns {object} new ledger */ export function recordCourseLead(ledger, uid, lead) { return { ...ledger, updated_at: lead.decided_at ?? ledger.updated_at, courses: { ...(ledger.courses ?? {}), [uid]: { ...lead } }, }; } /** * Transition a recorded course lead to a new status. Pure — returns a new * ledger, does not mutate. Every other field (title, url, products, * suggested_skill, …) is preserved. Mirrors setActionStatus; lets the gate flip * a pending lead approved/rejected without rebuilding the entry. * @param {object} ledger * @param {string} uid — the course's stable UID * @param {string} status — new status (pending | approved | rejected) * @param {string|null} [decided_at] — when given, advances the entry date + updated_at * @returns {object} new ledger * @throws if no course lead exists under `uid` */ export function setCourseLeadStatus(ledger, uid, status, decided_at = null) { const existing = ledger.courses?.[uid]; if (!existing) throw new Error(`setCourseLeadStatus: no course recorded under uid "${uid}"`); return { ...ledger, updated_at: decided_at ?? ledger.updated_at, courses: { ...ledger.courses, [uid]: { ...existing, status, decided_at: decided_at ?? existing.decided_at }, }, }; }