Lukker discovery-løkken gjennom operatør-gaten. data/decisions.json er eneste skrive-autoriserte bro mellom deteksjon og KB/registry; discovery LESER den og utelater alt operatøren har tatt stilling til. Dedup-policy = A (operatør-valg): isDecided = enhver ledger-entry (approved/ rejected/pending). Kun helt fraværende URLer re-foreslås. - lib/decisions-io.mjs: createLedger/load/save(atomisk)/isDecided/recordDecision (ren)/filterUndecided. TDD: 10 tester før kode. - discover-new-urls.mjs leser ledger, filtrerer, rapporterer deduped_by_ledger. Importerer ALDRI write-utils (invariant verifisert, 6 guard-tester). - Gate dokumentert i kb-update.md §3b (eneste skrivevei). - decisions.json tracket via gitignore-negasjon (som domain-taxonomy.json). Kriterium møtt: dedup-diff (rejected re-foreslås ikke runde 2). Tester: validate 239 · kb-update 82 (+16) · kb-eval 13 · kb-integrity 115/115. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
86 lines
3.2 KiB
JavaScript
86 lines
3.2 KiB
JavaScript
// 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));
|
|
}
|