feat(ms-ai-architect): Sesjon 3 - decision-ledger (lag 2) + discovery-dedup

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
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 21:48:03 +02:00
commit fe484ec323
7 changed files with 323 additions and 2 deletions

View file

@ -0,0 +1,5 @@
{
"version": 1,
"updated_at": null,
"decisions": {}
}

View file

@ -9,6 +9,9 @@ import { normalizeUrl } from './lib/url-normalize.mjs';
import { loadRegistry, saveReport } from './lib/registry-io.mjs';
import { streamSitemap, fetchSitemapIndex } from './lib/sitemap-stream.mjs';
import { loadTaxonomy, getSitemapPrefixes, makeClassifier } from './lib/taxonomy.mjs';
// Read-only: detection filters against the ledger but NEVER writes it (arkitektur-
// invariant — no saveDecisions / registry / atomic-write / backup imports here).
import { loadDecisions, isDecided } from './lib/decisions-io.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, 'data');
@ -33,6 +36,13 @@ async function main() {
const knownUrls = new Set(Object.keys(registry.urls));
console.log(`Registry: ${knownUrls.size} known URLs`);
// Decision ledger (lag 2): skip anything the operator already decided on
// (policy A — approved | rejected | pending all dedup). Read-only here.
const decisions = loadDecisions(DATA_DIR);
const decidedCount = Object.keys(decisions.decisions || {}).length;
console.log(`Decision ledger: ${decidedCount} decided URLs (excluded from re-proposal)`);
let dedupedByLedger = 0;
console.log('Fetching sitemap index...');
const indexEntries = await fetchSitemapIndex();
@ -55,6 +65,7 @@ async function main() {
for await (const entry of streamSitemap(child.loc)) {
const normalized = normalizeUrl(entry.loc);
if (!normalized || knownUrls.has(normalized)) continue;
if (isDecided(decisions, normalized)) { dedupedByLedger++; continue; }
const classification = classifyUrl(normalized);
if (!classification) continue;
@ -89,6 +100,7 @@ async function main() {
const report = {
generated_at: new Date().toISOString().split('T')[0],
new_candidates: candidates.length,
deduped_by_ledger: dedupedByLedger,
by_suggested_skill: bySkill,
candidates,
};
@ -97,6 +109,7 @@ async function main() {
console.log(`\n=== Discovery Report ===`);
console.log(`New relevant URLs found: ${candidates.length}`);
console.log(`Excluded by decision ledger: ${dedupedByLedger}`);
console.log('By skill:', JSON.stringify(bySkill, null, 2));
if (candidates.length > 0) {
console.log('\nNewest 10:');

View file

@ -0,0 +1,86 @@
// 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));
}