// 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. * @returns {{version: number, updated_at: string|null, decisions: object}} */ export function createLedger() { return { version: 1, updated_at: null, decisions: {} }; } /** * Load the decision ledger from disk. * @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(); return JSON.parse(readFileSync(path, 'utf8')); } /** * 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)); }