From 7848d113deb8450adcbf74027f2795bbb6093407 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 5 May 2026 11:12:37 +0200 Subject: [PATCH] feat(ms-ai-architect): session-start hook reads kb-update-status for failure surfacing [skip-docs] --- .../hooks/scripts/session-start-context.mjs | 20 ++ .../test-session-start-status.test.mjs | 172 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 plugins/ms-ai-architect/tests/kb-update/test-session-start-status.test.mjs diff --git a/plugins/ms-ai-architect/hooks/scripts/session-start-context.mjs b/plugins/ms-ai-architect/hooks/scripts/session-start-context.mjs index cb4a46f..9d3df47 100644 --- a/plugins/ms-ai-architect/hooks/scripts/session-start-context.mjs +++ b/plugins/ms-ai-architect/hooks/scripts/session-start-context.mjs @@ -6,6 +6,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, relative } from 'node:path'; import { spawn } from 'node:child_process'; +import { getCacheDir } from '../../scripts/kb-update/lib/cross-platform-paths.mjs'; const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || join(process.cwd()); const cwd = process.cwd(); @@ -130,6 +131,25 @@ if (staleLevels.critical > 0) staleEntries.push(`${staleLevels.critical} critica if (staleLevels.high > 0) staleEntries.push(`${staleLevels.high} high`); if (staleLevels.medium > 0) staleEntries.push(`${staleLevels.medium} medium`); +// KB-update auto-cron status (written by scripts/kb-update/weekly-kb-cron.mjs). +// Surfaced BEFORE the staleness-poll block because cron failure is a higher- +// signal event (something the user actively configured stopped working) than +// the slower-moving "files are getting old" signal that follows. +try { + const kbStatusPath = join(getCacheDir('ms-ai-architect'), 'kb-update-status.json'); + if (existsSync(kbStatusPath)) { + const kbStatus = JSON.parse(readFileSync(kbStatusPath, 'utf8')); + const surfaceStatuses = new Set(['failure', 'partial', 'budget_exceeded']); + if (kbStatus && surfaceStatuses.has(kbStatus.last_run_status)) { + parts.push( + `KB-update: ${kbStatus.last_run_status} (${kbStatus.last_run_ts}, log: ${kbStatus.log_file})` + ); + } + } +} catch { + // Never block session start — silent on read or parse failure. +} + if (staleEntries.length > 0) { const pollAge = lastPollDaysAgo < Infinity ? ` (pollet ${Math.floor(lastPollDaysAgo)}d siden)` : ''; parts.push(`KB: ${staleEntries.join(', ')} needs update${pollAge}`); diff --git a/plugins/ms-ai-architect/tests/kb-update/test-session-start-status.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-session-start-status.test.mjs new file mode 100644 index 0000000..b8febe8 --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-session-start-status.test.mjs @@ -0,0 +1,172 @@ +// tests/kb-update/test-session-start-status.test.mjs +// Verifies that hooks/scripts/session-start-context.mjs surfaces the +// KB-update status file correctly per Status File Schema (plan.md L122-153). +// +// Same fixture statuses as test-weekly-kb-cron-flags.test.mjs so producer/ +// consumer divergence is caught at test time. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir, platform as osPlatform } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'session-start-context.mjs'); +const PLUGIN_ROOT = join(__dirname, '..', '..'); + +function mkSandbox() { + return mkdtempSync(join(tmpdir(), 'sshook-test-')); +} + +function cacheDirFor(home) { + if (osPlatform() === 'darwin') { + return join(home, 'Library', 'Caches', 'ms-ai-architect'); + } + if (osPlatform() === 'win32') { + return join(home, 'AppData', 'Local', 'ms-ai-architect', 'Cache'); + } + return join(home, '.cache', 'ms-ai-architect'); +} + +function writeStatus(home, status) { + const dir = cacheDirFor(home); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'kb-update-status.json'), JSON.stringify(status, null, 2)); +} + +function runHook(home, extraEnv = {}) { + return spawnSync('node', [HOOK], { + env: { + PATH: process.env.PATH, + HOME: home, + CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT, + ...extraEnv, + }, + encoding: 'utf8', + timeout: 10_000, + cwd: home, // not in plugin dir, so utredning/onboarding checks stay quiet + }); +} + +const baseStatus = { + schema_version: 1, + last_run_status: 'success', + last_run_ts: '2026-05-05T10:00:00Z', + duration_seconds: 412, + auth_mode: 'api-key', + log_file: '/tmp/kb-update.log', + files_planned: 18, + files_committed: 18, + session_id: 'sess_demo', + total_cost_usd: 1.42, + tokens_input: 54000, + tokens_output: 27000, + max_turns_hit: false, + diagnostic: null, +}; + +test('failure status surfaces "KB-update: failure" line', () => { + const home = mkSandbox(); + try { + writeStatus(home, { ...baseStatus, last_run_status: 'failure', diagnostic: 'No commits produced' }); + const result = runHook(home); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.match(result.stdout, /KB-update: failure/); + assert.match(result.stdout, /2026-05-05T10:00:00Z/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('partial status surfaces "KB-update: partial" line', () => { + const home = mkSandbox(); + try { + writeStatus(home, { ...baseStatus, last_run_status: 'partial', files_committed: 7, max_turns_hit: true }); + const result = runHook(home); + assert.equal(result.status, 0); + assert.match(result.stdout, /KB-update: partial/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('budget_exceeded status surfaces a line', () => { + const home = mkSandbox(); + try { + writeStatus(home, { ...baseStatus, last_run_status: 'budget_exceeded', files_committed: 0 }); + const result = runHook(home); + assert.equal(result.status, 0); + assert.match(result.stdout, /KB-update: budget_exceeded/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('success status does NOT surface a KB-update line', () => { + const home = mkSandbox(); + try { + writeStatus(home, { ...baseStatus, last_run_status: 'success' }); + const result = runHook(home); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /KB-update:/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('dry-run status does NOT surface a KB-update line', () => { + const home = mkSandbox(); + try { + writeStatus(home, { ...baseStatus, last_run_status: 'dry-run' }); + const result = runHook(home); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /KB-update:/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('missing status file → hook still exits 0 with no KB-update line', () => { + const home = mkSandbox(); + try { + // No status file written. + const result = runHook(home); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.doesNotMatch(result.stdout, /KB-update:/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('malformed status file → hook tolerates and exits 0', () => { + const home = mkSandbox(); + try { + const dir = cacheDirFor(home); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'kb-update-status.json'), '{ this is: not, valid json'); + const result = runHook(home); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /KB-update:/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('hook completes in < 1 second on warm filesystem', () => { + const home = mkSandbox(); + try { + writeStatus(home, { ...baseStatus, last_run_status: 'failure' }); + // Warm-up. + runHook(home); + const start = Date.now(); + const result = runHook(home); + const elapsed = Date.now() - start; + assert.equal(result.status, 0); + assert.ok(elapsed < 1000, `hook took ${elapsed}ms (>1s)`); + } finally { + rmSync(home, { recursive: true, force: true }); + } +});