feat(ms-ai-architect): C3.5 — courses-kolleksjon i decisions-io.mjs (UID-nøklet, Spor C) + kb-update §3c kurs-gate (TDD) [skip-docs]

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 14:38:56 +02:00
commit e0d2d05dbb
3 changed files with 352 additions and 8 deletions

View file

@ -18,22 +18,29 @@ const DEFAULT_DATA_DIR = join(__dirname, '..', 'data');
/**
* Empty ledger scaffold.
*
* Two parallel collections, both written ONLY through the operator gate:
* 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). 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}}
* 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: {} };
return { version: 1, updated_at: null, decisions: {}, actions: {}, courses: {} };
}
/**
* 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.
* 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
*/
@ -42,6 +49,7 @@ export function loadDecisions(dataDir = DEFAULT_DATA_DIR) {
if (!existsSync(path)) return createLedger();
const led = JSON.parse(readFileSync(path, 'utf8'));
if (!led.actions) led.actions = {};
if (!led.courses) led.courses = {};
return led;
}
@ -184,3 +192,71 @@ export function setActionStatus(ledger, key, status, decided_at = null) {
},
};
}
// ===========================================================================
// 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 },
},
};
}