feat(ms-ai-architect): Spor D Steg C — skill-quality SessionStart-surfacing (TDD) [skip-docs]

summarizeSkillQuality(cache) (pure) in detection-schedule.mjs: one-liner
"Skill-kvalitet: N skills < 90 % (navn)"; null when all skills meet target,
when the cache is missing (gitignored → absent in a fresh clone), or when the
shape is malformed. Reads the CACHED skill-score-report.json (from Steg B) —
never scores live (buildReport reads ~400 ref-files). Wired into
session-start-context.mjs after the course-signals block (same
existsSync/try-catch pattern). 7 new tests (kb-update 316 → 323).

[skip-docs]: README is the public end-user doc; skill-quality scoring is
maintainer-only tooling (the cache is gitignored, so the signal never surfaces
for end-users). The right doc homes ARE updated: CLAUDE.md hook table + a
caveat note (this commit), and docs/skill-quality-scoring-plan.md §3 komponent 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 18:38:35 +02:00
commit 97ebcd737a
4 changed files with 156 additions and 1 deletions

View file

@ -26,6 +26,7 @@ import {
selectDetectionSteps,
summarizeSkillLifecycle,
summarizeCourses,
summarizeSkillQuality,
daysSinceLastPoll,
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
import { writeScheduleConfig } from '../../scripts/kb-update/write-schedule-config.mjs';
@ -469,6 +470,92 @@ test('summarizeCourses — tolerates a missing/partial report shape', () => {
assert.equal(summarizeCourses({ status: 'ok' }), null); // no counts
});
// ===========================================================================
// (C3) summarizeSkillQuality — skill-quality one-liner at session start (Spor D / Steg C)
// ===========================================================================
// Reads the CACHED skill-score-report.json (produced by `score-skill.mjs
// --write`), it NEVER scores live — buildReport reads ~400 ref-files, far too
// expensive for a SessionStart hook. The cache is gitignored => ABSENT in a
// fresh clone, so this MUST tolerate a missing/malformed cache → null and never
// crash the hook. Mirrors summarizeCourses / summarizeSkillLifecycle: a pure
// one-liner, null when there is nothing worth surfacing (all skills ≥ target).
const SCORE_CACHE_ALL_PASS = {
generatedAt: '2026-06-23',
target: 90,
scored: [
{ name: 'ms-ai-advisor', score: 91, meetsTarget: true },
{ name: 'ms-ai-security', score: 96, meetsTarget: true },
],
below: [],
};
const SCORE_CACHE_TWO_BELOW = {
generatedAt: '2026-06-23',
target: 90,
scored: [
{ name: 'ms-ai-advisor', score: 88, meetsTarget: false },
{ name: 'ms-ai-engineering', score: 72, meetsTarget: false },
{ name: 'ms-ai-security', score: 96, meetsTarget: true },
],
below: ['ms-ai-advisor', 'ms-ai-engineering'],
};
test('summarizeSkillQuality — surfaces the count + names of skills below target', () => {
const s = summarizeSkillQuality(SCORE_CACHE_TWO_BELOW);
assert.match(s, /Skill-kvalitet:/);
assert.match(s, /2 skills < 90 %/, 'canonical one-liner form (spec §3 komponent 4)');
assert.match(s, /ms-ai-advisor/);
assert.match(s, /ms-ai-engineering/);
});
test('summarizeSkillQuality — singular noun for exactly one skill below target', () => {
const s = summarizeSkillQuality({
target: 90,
below: ['ms-ai-advisor'],
scored: [{ name: 'ms-ai-advisor', meetsTarget: false }],
});
assert.match(s, /1 skill < 90 %/);
assert.doesNotMatch(s, /1 skills/, 'no plural "skills" for a single skill');
});
test('summarizeSkillQuality — all skills meet target returns null (nothing to surface)', () => {
assert.equal(summarizeSkillQuality(SCORE_CACHE_ALL_PASS), null);
});
test('summarizeSkillQuality — missing cache (fresh clone, gitignored) returns null', () => {
assert.equal(summarizeSkillQuality(null), null);
assert.equal(summarizeSkillQuality(undefined), null);
});
test('summarizeSkillQuality — malformed cache returns null (never crashes the hook)', () => {
assert.equal(summarizeSkillQuality({}), null, 'no below[] and no scored[] => unusable');
assert.equal(summarizeSkillQuality('nonsense'), null, 'non-object');
assert.equal(summarizeSkillQuality({ target: 90 }), null, 'target only, no skill data');
});
test('summarizeSkillQuality — uses the cache target in the message (not a hardcoded 90)', () => {
const s = summarizeSkillQuality({
target: 85,
below: ['ms-ai-advisor'],
scored: [{ name: 'ms-ai-advisor', meetsTarget: false }],
});
assert.match(s, /< 85 %/);
});
test('summarizeSkillQuality — derives below from scored when below[] is absent (shape tolerance)', () => {
const s = summarizeSkillQuality({
target: 90,
scored: [
{ name: 'ms-ai-advisor', meetsTarget: false },
{ name: 'ms-ai-security', meetsTarget: true },
],
});
assert.match(s, /1 skill < 90 %/);
assert.match(s, /ms-ai-advisor/);
assert.doesNotMatch(s, /ms-ai-security/, 'only below-target skills are named');
});
// ===========================================================================
// (D) STRUCTURAL ToS GUARANTEE — detection is Claude-free
// ===========================================================================