ms-ai-architect/hooks/scripts/session-start-context.mjs
Kjell Tore Guttormsen a8ae1fd276 feat(ms-ai-architect): RX-REG — én maskinlesbar AI Act-frist-kilde + synk-test (B2-B5, R3-5, M10) [skip-docs]
Rotårsak-mekanisme: scripts/kb-update/data/ai-act-deadlines.json er nå eneste
kilde for AI Act-frister; CLAUDE.md-tabellen, assessor-malen og begge hooks
synk-testes mot den (tests/kb-update/test-ai-act-deadlines-sync.test.mjs, 9
tester, TDD rød→grønn). Suite 815/815.

- B2: «Kommisjonen kan fremskynde»/«ytre grense» fjernet (trigger droppet i
  endelig Omnibus-tekst)
- B3: status oppdatert i alle 3 lag — formelt vedtatt (EP 2026-06-16, Rådet
  2026-06-29), trer i kraft ved OJ-publisering (ikke publisert per 2026-07-15,
  verifisert mot EUR-Lex 32024R1689)
- B4: Art. 50-raden «Gjeldende» → «Fra 2026-08-02»
- B5: stop-hooken feilmerket 2026-08-02 som «GPAI-frist» — begge hooks leser
  nå kilden, 0 hardkodede frist-literals
- R3-5: 2026-12-02-raden (Art. 50(2)) inn i assessor-malen
- M10: Nkom navngitt som koordinerende markedstilsynsmyndighet + nasjonalt
  kontaktpunkt (kilde: nkom.no/ki/regulering/hvem-handhever)
- pending_oj-markører i kilden for RX-REG² (nudifiserings-forbud, Art. 111)
- .gitignore: whitelist for den kuraterte frist-kilden
2026-07-15 06:13:36 +02:00

272 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
// session-start-context.mjs
// Shows active utredning sessions and KB staleness on session start.
// Output: plain text to stdout (advisory, never blocking).
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, relative } from 'node:path';
import { spawn } from 'node:child_process';
import {
loadScheduleConfig,
shouldRunDetection,
summarizeSkillLifecycle,
summarizeCourses,
summarizeSkillQuality,
summarizeTrustFreshness,
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
import {
resolveOrgDir,
ORG_FILES,
FREE_CONTEXT_FILE,
buildOrgSummary,
} from '../../scripts/kb-update/lib/user-data.mjs';
import { loadAiActDeadlines } from '../../scripts/kb-update/lib/ai-act-deadlines.mjs';
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || join(process.cwd());
const cwd = process.cwd();
const lines = [];
// --- 1. Check for active utredning sessions (.work/ directories) ---
const workDir = join(cwd, '.work');
let activeUtredninger = 0;
if (existsSync(workDir)) {
try {
const entries = readdirSync(workDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
activeUtredninger++;
}
}
} catch {
// Ignore read errors
}
}
// Also check docs/**/utredning.md
const docsDir = join(cwd, 'docs');
let utredningFiles = 0;
if (existsSync(docsDir)) {
try {
utredningFiles = countFiles(docsDir, 'utredning.md');
} catch {
// Ignore
}
}
// --- 2. Check KB staleness (from sitemap-based change report) ---
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
const staleLevels = { critical: 0, high: 0, medium: 0 };
let lastPollDaysAgo = Infinity;
const changeReportPath = join(pluginRoot, 'scripts', 'kb-update', 'data', 'change-report.json');
if (existsSync(changeReportPath)) {
try {
const report = JSON.parse(readFileSync(changeReportPath, 'utf8'));
staleLevels.critical = report.by_priority?.critical || 0;
staleLevels.high = report.by_priority?.high || 0;
staleLevels.medium = report.by_priority?.medium || 0;
if (report.last_poll) {
lastPollDaysAgo = (now - new Date(report.last_poll).getTime()) / DAY_MS;
}
} catch {
// Ignore — fall back to showing no data
}
}
// Opt-in background detection (Spor C / C1). Default OFF: nothing spawns unless
// the user enabled scheduled_detection in ms-ai-architect.local.md. When on +
// stale, run the Claude-FREE detection entrypoint (poll → report → discover →
// skill-lifecycle) in the background. This never invokes Claude — apply stays
// manual + in-session. See detection-schedule.mjs for the ToS rationale.
const scheduleConfig = loadScheduleConfig(pluginRoot);
if (shouldRunDetection(scheduleConfig, lastPollDaysAgo).run) {
const detectScript = join(pluginRoot, 'scripts', 'kb-update', 'run-detection.mjs');
if (existsSync(detectScript)) {
try {
spawn('node', [detectScript], { detached: true, stdio: 'ignore' }).unref();
} catch {
// Non-critical — silent fail
}
}
}
// --- 3. Check EU AI Act deadlines (single source: scripts/kb-update/data/ai-act-deadlines.json) ---
const aiActSource = loadAiActDeadlines();
let nearestDeadline = null;
for (const dl of aiActSource ? aiActSource.deadlines : []) {
const daysLeft = Math.ceil((new Date(dl.date).getTime() - now) / DAY_MS);
if (daysLeft > 0 && daysLeft <= 180) {
if (!nearestDeadline || daysLeft < nearestDeadline.daysLeft) {
nearestDeadline = { ...dl, daysLeft };
}
}
}
// --- 4. Onboarding status + ambient org context (C2.1) ---
// The user-owned org dir (~/.claude/ms-ai-architect/org) wins; the legacy
// plugin org/ dir is the backward-compat fallback. Read the files once: their
// count drives the status line, their content drives the ambient summary that
// makes org context present in EVERY session (not just a "run onboarding" nudge).
function readOrgFiles(dir) {
const files = {};
let count = 0;
if (existsSync(dir)) {
for (const f of ORG_FILES) {
const p = join(dir, f);
if (existsSync(p)) {
try {
files[f] = readFileSync(p, 'utf8');
count++;
} catch {
// Unreadable file — skip; advisory only
}
}
}
// Optional free-prose context (C2.2): read it for the ambient summary, but
// do NOT count it — it is optional and must not affect onboarding completeness.
const fp = join(dir, FREE_CONTEXT_FILE);
if (existsSync(fp)) {
try {
files[FREE_CONTEXT_FILE] = readFileSync(fp, 'utf8');
} catch {
// Unreadable — skip; advisory only
}
}
}
return { files, count };
}
const userOrg = readOrgFiles(resolveOrgDir());
const orgData = userOrg.count > 0 ? userOrg : readOrgFiles(join(pluginRoot, 'org'));
const orgComplete = orgData.count;
const orgSummary = buildOrgSummary(orgData.files);
// --- 4. Build output ---
const parts = [];
if (activeUtredninger > 0) {
parts.push(`${activeUtredninger} aktiv(e) utredning(er) i .work/`);
}
if (utredningFiles > 0) {
parts.push(`${utredningFiles} utredningsdokument(er) i docs/`);
}
if (orgComplete === 0) {
parts.push('Ingen virksomhetstilpasning. Kjør /architect:onboard (~5 min)');
} else if (orgComplete < ORG_FILES.length) {
parts.push(`Onboarding ${orgComplete}/${ORG_FILES.length}. Kjør /architect:onboard for å fullføre`);
}
const staleEntries = [];
if (staleLevels.critical > 0) staleEntries.push(`${staleLevels.critical} critical`);
if (staleLevels.high > 0) staleEntries.push(`${staleLevels.high} high`);
if (staleLevels.medium > 0) staleEntries.push(`${staleLevels.medium} medium`);
if (staleEntries.length > 0) {
const pollAge = lastPollDaysAgo < Infinity ? ` (pollet ${Math.floor(lastPollDaysAgo)}d siden)` : '';
parts.push(`KB: ${staleEntries.join(', ')} needs update${pollAge}`);
} else if (lastPollDaysAgo > 7) {
parts.push('KB: poll overdue');
}
// Skill-lifecycle signals (Spor B/C) — read-only one-liner; never spawns.
const skillReportPath = join(pluginRoot, 'scripts', 'kb-eval', 'data', 'skill-lifecycle-report.json');
if (existsSync(skillReportPath)) {
try {
const skillSummary = summarizeSkillLifecycle(JSON.parse(readFileSync(skillReportPath, 'utf8')));
if (skillSummary) parts.push(skillSummary);
} catch {
// Ignore — advisory only
}
}
// Course-detection signals (Spor C / C3.6) — read-only one-liner; never spawns.
// A lead is a signal that a topic exists, not auto-ingest: surfacing it here
// invites the operator to run /architect:kb-update §3c, which gates the lead
// into the courses ledger. Mirrors the skill-signaler block above.
const courseReportPath = join(pluginRoot, 'scripts', 'kb-update', 'data', 'course-detection-report.json');
if (existsSync(courseReportPath)) {
try {
const courseSummary = summarizeCourses(JSON.parse(readFileSync(courseReportPath, 'utf8')));
if (courseSummary) parts.push(`${courseSummary}. Kjør /architect:kb-update`);
} catch {
// Ignore — advisory only
}
}
// Skill-quality score signals (Spor D / Steg C) — read-only one-liner; never
// spawns, never scores live. Reads the CACHED skill-score-report.json (written
// by `score-skill.mjs --write`). The cache is gitignored => absent in a fresh
// clone, so this surfaces only for a maintainer who has produced it locally;
// summarizeSkillQuality tolerates the missing/malformed case → null.
const scoreCachePath = join(pluginRoot, 'scripts', 'kb-eval', 'data', 'skill-score-report.json');
if (existsSync(scoreCachePath)) {
try {
const qualitySummary = summarizeSkillQuality(JSON.parse(readFileSync(scoreCachePath, 'utf8')));
if (qualitySummary) parts.push(qualitySummary);
} catch {
// Ignore — advisory only
}
}
// 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`);
}
if (parts.length > 0) {
lines.push(`Architect: ${parts.join('. ')}. /architect:help`);
} else {
lines.push('Architect: Ingen aktive sesjoner. KB oppdatert. /architect:help');
}
// Ambient org context (C2.1): inject the compact summary so org facts are
// PRESENT in the session, not merely discoverable. Length-capped by
// buildOrgSummary. Only emitted when onboarding has produced content.
if (orgSummary) {
lines.push('Virksomhetskontekst (auto-injisert fra ~/.claude/ms-ai-architect/org):');
for (const l of orgSummary.split('\n')) lines.push(`- ${l}`);
}
if (lines.length > 0) {
process.stdout.write(lines.join('\n') + '\n');
}
// --- Helpers ---
function countFiles(dir, filename) {
let count = 0;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
count += countFiles(fullPath, filename);
} else if (entry.name === filename) {
count++;
}
}
} catch {
// Ignore permission errors
}
return count;
}