feat(ms-ai-architect): Spor 3 Port 3 (rapporterende gulv) — KB-tillit-signal i SessionStart [skip-docs]

Det rapporterende gulvet (§4 Port 3, P3b): SessionStart-hooken surfacer en
KB-tillit-énlinjer fra cachet verified-staleness-report.json. Et GULV, ikke en
gate (§4c — unngå alarm-tretthet på 84 %-signal).

summarizeTrustFreshness (detection-schedule.mjs, ren, speiler
summarizeSkillQuality): surfacer KUN drift (stale-since-verified) +
kontraktbrudd (unverified) — det handlingsbare «bekreft mot kilde»-settet.
unmigrated (Spor-1-backlog, 306 i dag) ekskluderes bevisst: ved det volumet
ville det fyre hver økt og bli støy; migrerings-censusen hører i CT5 (P3c).
Tolererer manglende/malformed cache → null (gitignored, fraværende i fersk
klon, krasjer aldri hooken).

session-start-context.mjs: leser cachen read-only (samme mønster som
skill-quality-blokken), dytter linjen når den finnes.

Ende-til-ende verifisert: injisert stale-rapport → «KB-tillit: 3 filer driftet
siden verifisering, 1 uverifisert — bekreft mot kilde (aldri auto-fiks)»; ekte
korpus (306 unmigrated) → taus (ingen falsk alarm).

TDD (Iron Law): 8 nye tester. Suite 612/612. Plugin-validering PASS.
This commit is contained in:
Kjell Tore Guttormsen 2026-06-29 14:58:32 +02:00
commit 08e7e72c62
3 changed files with 116 additions and 0 deletions

View file

@ -12,6 +12,7 @@ import {
summarizeSkillLifecycle,
summarizeCourses,
summarizeSkillQuality,
summarizeTrustFreshness,
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
import {
resolveOrgDir,
@ -219,6 +220,21 @@ if (existsSync(scoreCachePath)) {
}
}
// KB-trust reporting floor (Spor 3 Port 3 / P3b) — read-only one-liner; never
// judges live. Reads the CACHED verified-staleness-report.json (written by
// report-verified-staleness.mjs on the KB-refresh cadence). Gitignored => absent
// in a fresh clone; summarizeTrustFreshness tolerates the missing/malformed case
// → null. Surfaces only drift + contract breaches (§4c — a floor, not a gate).
const stalenessCachePath = join(pluginRoot, 'scripts', 'kb-update', 'data', 'verified-staleness-report.json');
if (existsSync(stalenessCachePath)) {
try {
const trustSummary = summarizeTrustFreshness(JSON.parse(readFileSync(stalenessCachePath, 'utf8')));
if (trustSummary) parts.push(trustSummary);
} catch {
// Ignore — advisory only
}
}
if (nearestDeadline) {
parts.push(`EU AI Act: ${nearestDeadline.daysLeft} dager til ${nearestDeadline.label}. Kjør /architect:classify`);
}

View file

@ -300,3 +300,38 @@ export function summarizeSkillQuality(cache) {
const noun = below.length === 1 ? 'skill' : 'skills';
return `Skill-kvalitet: ${below.length} ${noun} < ${target} % (${below.join(', ')})`;
}
/**
* Build the KB-trust reporting floor one-liner (Spor 3 Port 3, P3b) for the
* SessionStart hook. Pure; tolerant of partial/missing reports. Mirrors
* summarizeSkillQuality.
*
* Consumes the CACHED verified-staleness-report.json (produced by
* report-verified-staleness.mjs on the KB-refresh cadence) it NEVER judges
* live. That cache is gitignored (derived/regenerable), so it is ABSENT in a
* fresh clone this MUST tolerate a missing/malformed report null and never
* crash the hook.
*
* A REPORTING FLOOR, not a gate (§4c avoid alarm fatigue on an 84 % signal):
* surfaces ONLY drift (stale-since-verified) + contract breaches (unverified),
* the actionable "confirm against source" set. `unmigrated` (the Spor-1 backlog,
* 306 today) is DELIBERATELY excluded: at that volume it would fire every
* session and become noise; the migration census belongs to CT5 (P3c).
*
* @param {object|null} report parsed verified-staleness-report.json
* ({counts: {stale, unverified, ...}})
* @returns {string|null}
*/
export function summarizeTrustFreshness(report) {
if (!report || typeof report !== 'object') return null;
const counts = report.counts;
if (!counts || typeof counts !== 'object') return null;
const stale = Number(counts.stale) || 0;
const unverified = Number(counts.unverified) || 0;
if (stale === 0 && unverified === 0) return null;
const parts = [];
if (stale > 0) parts.push(`${stale} ${stale === 1 ? 'fil' : 'filer'} driftet siden verifisering`);
if (unverified > 0) parts.push(`${unverified} uverifisert`);
return `KB-tillit: ${parts.join(', ')} — bekreft mot kilde (aldri auto-fiks)`;
}

View file

@ -27,6 +27,7 @@ import {
summarizeSkillLifecycle,
summarizeCourses,
summarizeSkillQuality,
summarizeTrustFreshness,
daysSinceLastPoll,
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
import { writeScheduleConfig } from '../../scripts/kb-update/write-schedule-config.mjs';
@ -623,3 +624,67 @@ test('run-detection.mjs source — spawns node, NEVER claude (the ToS boundary m
assert.doesNotMatch(code, /['"`]claude['"`]/i, 'no claude binary referenced in code');
assert.doesNotMatch(code, /anthropic/i, 'code must not call Anthropic');
});
// ===========================================================================
// summarizeTrustFreshness — KB-trust reporting floor at session start (Spor 3 P3b)
// ===========================================================================
// Reads the CACHED verified-staleness-report.json (produced by
// report-verified-staleness.mjs on the KB-refresh cadence) — it NEVER judges
// live. That cache is gitignored => ABSENT in a fresh clone, so this MUST
// tolerate a missing/malformed report → null and never crash the hook. Mirrors
// summarizeSkillQuality: a pure one-liner, null when there is nothing worth
// surfacing. A REPORTING FLOOR, not a gate (§4c — avoid alarm fatigue): it
// surfaces only drift (stale-since-verified) + contract breaches (unverified).
// `unmigrated` (the Spor-1 backlog, 306 today) is DELIBERATELY excluded — at
// that volume it would fire every session and become pure noise; the migration
// census belongs to CT5 (P3c), not this per-session line.
const STALE_REPORT = {
generated_at: '2026-06-29',
counts: { total: 10, fresh: 4, stale: 3, unverified: 1, unmigrated: 2, outOfScope: 0 },
flagged: [],
};
test('summarizeTrustFreshness — surfaces drift + contract breaches', () => {
const s = summarizeTrustFreshness(STALE_REPORT);
assert.match(s, /KB-tillit:/);
assert.match(s, /3 .*driftet/, 'stale count surfaced');
assert.match(s, /1 uverifisert/, 'unverified count surfaced');
});
test('summarizeTrustFreshness — stale only renders without the unverified clause', () => {
const s = summarizeTrustFreshness({ counts: { stale: 2, unverified: 0, unmigrated: 50 } });
assert.match(s, /2 filer driftet/);
assert.doesNotMatch(s, /uverifisert/);
});
test('summarizeTrustFreshness — unverified only renders without the drift clause', () => {
const s = summarizeTrustFreshness({ counts: { stale: 0, unverified: 1, unmigrated: 50 } });
assert.match(s, /1 uverifisert/);
assert.doesNotMatch(s, /driftet/);
});
test('summarizeTrustFreshness — singular "fil" for exactly one drifted file', () => {
const s = summarizeTrustFreshness({ counts: { stale: 1, unverified: 0 } });
assert.match(s, /1 fil driftet/);
assert.doesNotMatch(s, /1 filer/, 'no plural for a single file');
});
test('summarizeTrustFreshness — unmigrated-only is silent (Spor-1 backlog, not a drift signal)', () => {
assert.equal(summarizeTrustFreshness({ counts: { stale: 0, unverified: 0, unmigrated: 306 } }), null);
});
test('summarizeTrustFreshness — clean corpus (no stale, no unverified) returns null', () => {
assert.equal(summarizeTrustFreshness({ counts: { total: 5, fresh: 5, stale: 0, unverified: 0 } }), null);
});
test('summarizeTrustFreshness — missing report (fresh clone, gitignored) returns null', () => {
assert.equal(summarizeTrustFreshness(null), null);
assert.equal(summarizeTrustFreshness(undefined), null);
});
test('summarizeTrustFreshness — malformed report returns null (never crashes the hook)', () => {
assert.equal(summarizeTrustFreshness({}), null, 'no counts => unusable');
assert.equal(summarizeTrustFreshness('nonsense'), null, 'non-object');
assert.equal(summarizeTrustFreshness({ counts: 'bad' }), null, 'counts not an object');
});