Tredje ledger-kolleksjon (courses), additivt ved siden av decisions (URL,
Spor A) og actions (skill, Spor B). De tre rene helperne speiler
recordAction-trioen 1:1:
- recordCourseLead(led, uid, lead) (ren, UID-nøklet)
- setCourseLeadStatus(led, uid, status, at) (ren transisjon, kaster på ukjent UID)
- isCourseLeadDecided(led, uid) (dedup policy A: any status)
createLedger()->courses:{}; loadDecisions backfiller courses ??= {} (bakoverkompat).
Strukturell ingen-ingest-invariant: apply-pathene leser ALDRI courses
(apply-skill-op.mjs->kun actions, discover-new-urls.mjs->kun decisions),
så et godkjent kurs-lead trigger aldri fetch/transform/KB-skriving.
commands/kb-update.md §3c: operatør-gate som leser course-detection-report.json,
dedup'er via isCourseLeadDecided, skriver godkjente leads via recordCourseLead
(--dry-run viser leads uten å skrive).
Tester (+16): test-decisions-courses.test.mjs. Eksisterende test-decisions-io
+ test-decisions-actions URØRT grønne (ikke-regresjons-bevis, samme mønster
som Spor B). kb-update 291->307 · validate 239/0 · kb-eval 100/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
262 lines
11 KiB
JavaScript
262 lines
11 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.
|
|
*
|
|
* 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<object>}
|
|
*/
|
|
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 },
|
|
},
|
|
};
|
|
}
|