#!/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'; 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 --- const AI_ACT_DEADLINES = [ // NB: Digital Omnibus (prov. enighet 2026-05-07) utsatte høyrisiko; datoer foreløpige til OJ-publisering. { date: new Date('2025-02-02'), label: 'Forbudte AI-praksiser (Art. 5)' }, { date: new Date('2025-08-02'), label: 'GPAI-krav + governance/sanksjoner (Art. 99)' }, { date: new Date('2026-08-02'), label: 'Transparens Art. 50 (syntetisk innhold)' }, { date: new Date('2026-12-02'), label: 'Art. 50(2) merking — frist for eksisterende generative systemer (Omnibus)' }, { date: new Date('2027-12-02'), label: 'Annex III høyrisiko (provisorisk, Omnibus — utsatt fra 2026-08-02)' }, { date: new Date('2028-08-02'), label: 'Annex I høyrisiko innebygd (provisorisk, Omnibus)' }, ]; let nearestDeadline = null; for (const dl of AI_ACT_DEADLINES) { const daysLeft = Math.ceil((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; }