ms-ai-architect/scripts/kb-update/lib/decisions-io.mjs
Kjell Tore Guttormsen 4ac18a76c8 feat(ms-ai-architect): Sesjon 18 — B3 apply-path (sanitize+retire), merge→S19 [skip-docs]
Apply-path = den destruktive frontieren: eksekver en operatør-godkjent
(status:approved) ledger-entry → faktisk skills/-mutasjon. Staget per
operatør-beslutning til single-skill ops (sanitize+retire); merge-apply
isoleres til S19 (krever taksonomi-persistering + cross-skill flytt).

- revalidateApply (ren, skill-ops.mjs): idempotent revalidering mot fersk
  re-plan — nekter ved status≠approved, op-mismatch, fersk guardrail≠ok,
  eller drift (isDeepStrictEqual fresh vs approved guardrail-snapshot).
- applyApprovedAction (impur): arkiver-så-slett (rename skills/…→archive/…
  atomisk; retire rmdir'er tom katalog sist) + flipp ledger approved→applied.
  apply:false = preview (0 mutasjon). merge_skills → throw (S19).
- setActionStatus (ren, decisions-io.mjs): ledger status-transisjon, bevarer
  targets+guardrail, audit-record beholdes.
- CLI apply-skill-op.mjs {list|sanitize|retire} — default PREVIEW, --apply =
  dobbel-gate utover ledger-approved.

TDD, tmpdir-fixtures: 0 ekte skills/-mutasjon. Tester: kb-eval 66→78,
kb-update 132→137; validate 239 · kb-integrity 192/192 uendret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:32:51 +02:00

186 lines
7.3 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.
*
* Two parallel collections, both 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). Additive: version
* stays 1, and a pre-action ledger on disk normalizes cleanly
* (loadDecisions backfills the missing actions key).
* @returns {{version: number, updated_at: string|null, decisions: object, actions: object}}
*/
export function createLedger() {
return { version: 1, updated_at: null, decisions: {}, actions: {} };
}
/**
* Load the decision ledger from disk. Backward-compatible: a ledger written
* before the action layer existed (no `actions` 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 = {};
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 },
},
};
}