feat(ms-ai-architect): session-start hook reads kb-update-status for failure surfacing [skip-docs]
This commit is contained in:
parent
a0528e6ef7
commit
7848d113de
2 changed files with 192 additions and 0 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue