From 08e7e72c6229ba2360615ef1b828bc17dd0747de Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 29 Jun 2026 14:58:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20Spor=203=20Port=203=20?= =?UTF-8?q?(rapporterende=20gulv)=20=E2=80=94=20KB-tillit-signal=20i=20Ses?= =?UTF-8?q?sionStart=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/scripts/session-start-context.mjs | 16 +++++ scripts/kb-update/lib/detection-schedule.mjs | 35 ++++++++++ .../test-detection-schedule.test.mjs | 65 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/hooks/scripts/session-start-context.mjs b/hooks/scripts/session-start-context.mjs index c67d597..5f98d05 100644 --- a/hooks/scripts/session-start-context.mjs +++ b/hooks/scripts/session-start-context.mjs @@ -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`); } diff --git a/scripts/kb-update/lib/detection-schedule.mjs b/scripts/kb-update/lib/detection-schedule.mjs index 386d7b1..7be329a 100644 --- a/scripts/kb-update/lib/detection-schedule.mjs +++ b/scripts/kb-update/lib/detection-schedule.mjs @@ -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)`; +} diff --git a/tests/kb-update/test-detection-schedule.test.mjs b/tests/kb-update/test-detection-schedule.test.mjs index e8424b6..3ffd3e5 100644 --- a/tests/kb-update/test-detection-schedule.test.mjs +++ b/tests/kb-update/test-detection-schedule.test.mjs @@ -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'); +});