feat(ms-ai-architect): Spor D Steg B — skill-quality lifecycle-gate + score-cache (TDD)

B1: gateSkill(report, name) — pure post-mutation verdict (blocked/provisional/
improvements), wired into apply-skill-op.mjs to re-score the affected skill from
fresh disk after create/merge/sanitize-apply. K10 floor enforced immediately;
unjudged → provisional + nudge to re-run the judge pass. retire → skipped.

B2: buildScoreCache(result) — compact, deterministic cache shape + score-skill.mjs
--write [path] → data/skill-score-report.json (always whole-corpus). Gitignored
(derived/regenerable; avoids churn in the public repo). Consumed by Steg C surfacing.

8 new tests (tests/kb-eval/test-skill-score-gate.test.mjs). kb-eval 150 pass,
validate 239 pass. Live score unchanged: advisor 91, eng/gov/infra/sec 96, 0 < 90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 18:26:29 +02:00
commit 92cd93771b
5 changed files with 294 additions and 3 deletions

View file

@ -250,6 +250,67 @@ export function scoreReport(report, opts = {}) {
return { target, scored, below };
}
/**
* Re-score ONE affected skill out of a freshly built corpus report and return a
* lifecycle-gate verdict (Spor D, STEG B). Used by apply-skill-op.mjs after a
* create/merge/sanitize mutation: the whole corpus is needed for K10 sibling
* overlap, but only the affected skill is judged. The deterministic floor (K10)
* is enforced immediately via `blocked`; an unjudged skill is `provisional`
* (the K1 floor cannot be enforced -> the caller nudges to re-run the judge).
* @param {{skills: object[]}} report buildReport() output (fresh disk)
* @param {string} skillName the mutated skill to re-score
* @param {{target?: number, floorCap?: number}} [opts]
* @returns {object} { name, found, score?, meetsTarget?, blocked?, provisional?, judged?, floored?, improvements? }
*/
export function gateSkill(report, skillName, opts = {}) {
const target = opts.target ?? TARGET;
const { scored } = scoreReport(report, { ...opts, target });
const s = scored.find((x) => x.name === skillName);
if (!s) return { name: skillName, found: false };
return {
name: skillName,
found: true,
score: s.score,
meetsTarget: s.meetsTarget,
blocked: !s.meetsTarget,
provisional: s.provisional,
judged: s.judged,
floored: s.floored,
improvements: s.improvements,
};
}
/**
* Project a scoreReport() result into the compact, PURE cache shape persisted as
* data/skill-score-report.json and consumed by the STEG C SessionStart surfacing
* (which must read the cache, never score live). Drops the verbose criteria[] and
* keeps only what the one-liner needs; `below` is a list of names. Deterministic
* given an injected `generatedAt` (no Date.now in the pure layer).
* @param {{target: number, scored: object[]}} result scoreReport() output
* @param {{generatedAt?: string|null}} [opts]
* @returns {{generatedAt: string|null, target: number, scored: object[], below: string[]}}
*/
export function buildScoreCache(result, opts = {}) {
const target = result?.target ?? TARGET;
const scored = (result?.scored || []).map((s) => ({
name: s.name,
score: s.score,
meetsTarget: s.meetsTarget,
provisional: s.provisional,
floored: s.floored,
judged: s.judged,
topFix: s.improvements?.[0]
? { key: s.improvements[0].key, label: s.improvements[0].label, fix: s.improvements[0].fix }
: null,
}));
return {
generatedAt: opts.generatedAt ?? null,
target,
scored,
below: scored.filter((s) => !s.meetsTarget).map((s) => s.name),
};
}
/**
* Render a human-readable quality report (pure). Each skill shows its score and
* status; below-target skills list their sorted improvements (biggest weighted