ms-ai-architect/hooks/scripts/session-start-context.mjs
Kjell Tore Guttormsen a7098e0fdf feat(ms-ai-architect): erstatt per-felt-cap med totalbudsjett og aapne det frosne filsettet [skip-docs]
To maalte defekter i lastesiden for brukerkontekst (underlag: designnotatets
paragraf 3.1-3.3, maalt ved aa kjoere den ekte mekanismen):

1. DEFAULT_MAX_VALUE_LEN = 160 kappet hvert felt uavhengig av hvor mye av
   budsjettet som sto ubrukt. Maalt: et 300-tegns svar tapte 47 prosent, et
   2000-tegns svar 92 prosent - ogsaa free-context.md, gjennom sin egen
   kodevei. Erstattet av DEFAULT_MAX_TOTAL_LEN = 4000: et totalbudsjett fordelt
   max-min-rettferdig over feltene som overlever linje-cappen.

   Hvorfor 4000: det er noeyaktig hva den gamle formen alt tillot i verste fall
   (cap 25 linjer x 160 tegn), saa endringen er kostnadsnoeytral mot dagens tak
   og strengt bedre under det. Fordi budsjettet deles kun over de hoeyst 25
   feltene som faktisk skrives ut, faar intet felt mindre enn 4000/25 = 160:
   den gamle cappen er blitt gulvet. Begrunnelsen staar der konstanten
   defineres.

   Per-felt-cap var feil FORM, ikke bare feil tall: den kan ikke uttrykke at ett
   langt, viktig svar overlever fordi fem korte ikke trengte sin andel.

2. readOrgFiles itererte den frosne ORG_FILES-lista uten readdir, saa en fil
   brukeren la til i org/ naadde aldri modellen. Begge sider er endret - hooken
   leser katalogen, og buildOrgSummary ignorerer ikke lenger filnavn utenfor
   ORG_FILES - ellers ville endringen vaert en no-op som ser ut som en fiks.
   Bare de fem kanoniske teller fortsatt mot onboarding-fullfoerthet.

Ny ren, delt hjelper orderOrgFiles(names) gir begge sider samme deterministiske
rekkefoelge: ORG_FILES foerst, saa oevrige .md sortert, free-context sist.

Verifisert paa den ekte hooken mot en fixture-org-katalog, med kontrollmaaling
mot HEAD: den syvende fila var usynlig foer og gir 43 tegn naa; en 1198-tegns
fri kontekst ble kappet til 160 foer og overlever hel naa.

TDD: 10 nye tester i tests/kb-update/test-user-data.test.mjs, alle roede foerst
(7 assertion-feil mot gammel kode, 3 paa manglende eksport). Suite 1062/1062.

[skip-docs]: ren intern mekanikk - ingen ny kommando, agent, skill eller hook,
og ingen endring i utoverrettet flate.
2026-08-26 10:15:05 +02:00

280 lines
10 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,
orderOrgFiles,
} 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)) {
let entries = [];
try {
entries = readdirSync(dir);
} catch {
// Unreadable dir — skip; advisory only
}
// orderOrgFiles, not the frozen ORG_FILES list: this loop used to iterate
// the five canonical names, so a sixth structured file added to org/ (the
// seventh file there) never reached the summary — nor any consumer relying on it. Only the
// canonical five count towards onboarding completeness.
for (const f of orderOrgFiles(entries)) {
try {
files[f] = readFileSync(join(dir, f), 'utf8');
if (ORG_FILES.includes(f)) 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;
}