From bbe7971d017b39f9e52214d8943c0f4ad30d2c84 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 4 May 2026 06:31:52 +0200 Subject: [PATCH] feat(ultraplan-local): add stats event-emit for autonomy lifecycle events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of plan-v2 (ultra-pipeline-speedup). lib/stats/event-emit.mjs (NEW) Atomic JSONL append to ${CLAUDE_PLUGIN_DATA}/ultraexecute-stats.jsonl. Every record carries: ts : ISO-8601 timestamp (REQUIRED per SC4) event : caller-supplied name known_event : true for { brief-approved, main-merge-gate, user_input }, false for everything else (still emitted — audit-complete) payload : caller object (defaults to {}) Stats failures NEVER block workflow: missing CLAUDE_PLUGIN_DATA, missing dir, mkdir failure, append failure → all return { written: false, reason } without throwing. CLI shim: node lib/stats/event-emit.mjs --event NAME [--payload JSON] Always exits 0 (telemetry is best-effort). Tests: 12 (record-build + ISO-8601 ts + known/unknown distinction + silent skip + dir-on-demand + CLI shim happy-path + bad-payload tolerance + concurrent-append smoke). [skip-docs] --- .../ultraplan-local/lib/stats/event-emit.mjs | 117 +++++++++++++ .../tests/lib/stats-event-emit.test.mjs | 158 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 plugins/ultraplan-local/lib/stats/event-emit.mjs create mode 100644 plugins/ultraplan-local/tests/lib/stats-event-emit.test.mjs diff --git a/plugins/ultraplan-local/lib/stats/event-emit.mjs b/plugins/ultraplan-local/lib/stats/event-emit.mjs new file mode 100644 index 0000000..ab45cd8 --- /dev/null +++ b/plugins/ultraplan-local/lib/stats/event-emit.mjs @@ -0,0 +1,117 @@ +// lib/stats/event-emit.mjs +// Atomic JSONL append for autonomy-lifecycle events (plan-v2 Step 6). +// +// Writes one line per event to ${CLAUDE_PLUGIN_DATA}/ultraexecute-stats.jsonl +// (or override via CLAUDE_PLUGIN_DATA env var; falls back to silent skip if +// the directory doesn't exist — stats failures must NEVER block workflow). +// +// Every emission carries: +// - ts : ISO-8601 timestamp (REQUIRED per SC4 contract) +// - event : the requested event name +// - known_event : true for recognized events, false otherwise +// - payload : caller-supplied object (may be {}) +// +// Recognized events: brief-approved, main-merge-gate, user_input. +// Unknown event names are still emitted (with known_event: false) so that +// the audit trail is complete; downstream consumers filter as needed. +// +// CLI shim: +// node lib/stats/event-emit.mjs --event brief-approved --payload '{...}' +// → exit 0 (always); silent on stat dir absence. + +import { appendFileSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export const KNOWN_EVENTS = Object.freeze(new Set([ + 'brief-approved', + 'main-merge-gate', + 'user_input', +])); + +const STATS_FILENAME = 'ultraexecute-stats.jsonl'; + +/** + * Resolve the stats file path. Honors CLAUDE_PLUGIN_DATA env var. + * Returns null if no plugin-data dir is configured (silent-skip mode). + */ +export function resolveStatsPath(env = process.env) { + const dir = env.CLAUDE_PLUGIN_DATA; + if (!dir || typeof dir !== 'string' || dir.length === 0) return null; + return join(dir, STATS_FILENAME); +} + +/** + * Build the JSON record. Pure — no I/O. + */ +export function buildRecord(event, payload = {}, now = new Date()) { + if (typeof event !== 'string' || event.length === 0) { + throw new TypeError('event must be a non-empty string'); + } + return { + ts: now.toISOString(), + event, + known_event: KNOWN_EVENTS.has(event), + payload: (payload && typeof payload === 'object') ? payload : {}, + }; +} + +/** + * Emit an event. Never throws — stat failures are swallowed silently + * because lifecycle telemetry must not block the user's workflow. + * + * @returns {{ written: boolean, path: string | null, reason?: string }} + */ +export function emit(event, payload = {}, opts = {}) { + const env = opts.env || process.env; + const now = opts.now || new Date(); + let record; + try { + record = buildRecord(event, payload, now); + } catch (e) { + return { written: false, path: null, reason: `record-build: ${e.message}` }; + } + const path = opts.path || resolveStatsPath(env); + if (!path) return { written: false, path: null, reason: 'CLAUDE_PLUGIN_DATA unset' }; + try { + const dir = dirname(path); + if (!existsSync(dir)) { + // Best-effort dir creation; if it fails, swallow and skip. + try { mkdirSync(dir, { recursive: true }); } catch { return { written: false, path, reason: 'dir-mkdir-failed' }; } + } + appendFileSync(path, JSON.stringify(record) + '\n'); + return { written: true, path }; + } catch (e) { + return { written: false, path, reason: `append-failed: ${e.message}` }; + } +} + +// ---- CLI shim ---------------------------------------------------------------- + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--event') out.event = argv[++i]; + else if (a === '--payload') out.payload = argv[++i]; + } + return out; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = parseArgs(process.argv.slice(2)); + if (!args.event) { + process.stdout.write(JSON.stringify({ written: false, reason: 'usage: --event NAME [--payload JSON]' }) + '\n'); + process.exit(0); // never block: usage error still exits clean + } + let payload = {}; + if (args.payload) { + try { payload = JSON.parse(args.payload); } + catch { + process.stdout.write(JSON.stringify({ written: false, reason: 'payload-not-json' }) + '\n'); + process.exit(0); + } + } + const result = emit(args.event, payload); + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(0); +} diff --git a/plugins/ultraplan-local/tests/lib/stats-event-emit.test.mjs b/plugins/ultraplan-local/tests/lib/stats-event-emit.test.mjs new file mode 100644 index 0000000..f86f488 --- /dev/null +++ b/plugins/ultraplan-local/tests/lib/stats-event-emit.test.mjs @@ -0,0 +1,158 @@ +// tests/lib/stats-event-emit.test.mjs +// Cover lib/stats/event-emit.mjs: +// - emit appends a JSONL line with required ISO-8601 ts +// - known_event flag distinguishes recognized vs unknown events +// - missing CLAUDE_PLUGIN_DATA does NOT throw (stats must never block) +// - CLI shim parses --payload JSON and writes via emit() +// - concurrent appends don't corrupt the file (smoke test) + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { emit, buildRecord, resolveStatsPath, KNOWN_EVENTS } from '../../lib/stats/event-emit.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SHIM = join(HERE, '..', '..', 'lib', 'stats', 'event-emit.mjs'); + +const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function tmp(prefix = 'stats-event-emit-') { + return mkdtempSync(join(tmpdir(), prefix)); +} + +test('KNOWN_EVENTS contains plan-v2 spec set', () => { + for (const e of ['brief-approved', 'main-merge-gate', 'user_input']) { + assert.ok(KNOWN_EVENTS.has(e), `missing recognized event: ${e}`); + } +}); + +test('buildRecord emits ISO-8601 ts (REQUIRED per SC4)', () => { + const r = buildRecord('brief-approved', { foo: 1 }); + assert.match(r.ts, ISO_8601_RE); + assert.equal(r.event, 'brief-approved'); + assert.equal(r.known_event, true); + assert.deepEqual(r.payload, { foo: 1 }); +}); + +test('buildRecord marks unrecognized events known_event: false', () => { + const r = buildRecord('totally-made-up-event'); + assert.equal(r.known_event, false); + assert.deepEqual(r.payload, {}); +}); + +test('buildRecord rejects empty event name', () => { + assert.throws(() => buildRecord(''), TypeError); + assert.throws(() => buildRecord(null), TypeError); +}); + +test('emit appends one JSONL line per call', () => { + const dir = tmp(); + try { + const path = join(dir, 'stats.jsonl'); + const r1 = emit('brief-approved', { ok: true }, { path }); + const r2 = emit('main-merge-gate', { branch: 'main' }, { path }); + assert.equal(r1.written, true); + assert.equal(r2.written, true); + const lines = readFileSync(path, 'utf-8').trim().split('\n'); + assert.equal(lines.length, 2); + const a = JSON.parse(lines[0]); + const b = JSON.parse(lines[1]); + assert.match(a.ts, ISO_8601_RE); + assert.match(b.ts, ISO_8601_RE); + assert.equal(a.event, 'brief-approved'); + assert.equal(b.event, 'main-merge-gate'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('emit creates the stats directory on demand', () => { + const dir = tmp(); + try { + const path = join(dir, 'nested', 'stats.jsonl'); + const r = emit('user_input', {}, { path }); + assert.equal(r.written, true); + assert.ok(existsSync(path)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('emit with no CLAUDE_PLUGIN_DATA returns { written: false } (silent skip)', () => { + const r = emit('brief-approved', {}, { env: {} }); + assert.equal(r.written, false); + assert.equal(r.path, null); + assert.match(r.reason, /CLAUDE_PLUGIN_DATA unset/); +}); + +test('emit never throws when stats path is unwritable', () => { + // Pointing at a path under a non-existent dir on a readonly mount would + // be brittle in CI; instead, force the env-resolved path to be empty + // and confirm no exception leaks. + let threw = false; + try { emit('user_input', { foo: 'bar' }, { env: {} }); } + catch { threw = true; } + assert.equal(threw, false); +}); + +test('resolveStatsPath honors CLAUDE_PLUGIN_DATA env var', () => { + const r = resolveStatsPath({ CLAUDE_PLUGIN_DATA: '/var/data/plugin' }); + assert.equal(r, '/var/data/plugin/ultraexecute-stats.jsonl'); + assert.equal(resolveStatsPath({}), null); +}); + +test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => { + const dir = tmp(); + try { + execFileSync(process.execPath, [ + SHIM, '--event', 'brief-approved', '--payload', '{"foo":42}', + ], { + env: { ...process.env, CLAUDE_PLUGIN_DATA: dir }, + encoding: 'utf-8', + }); + const path = join(dir, 'ultraexecute-stats.jsonl'); + assert.ok(existsSync(path)); + const line = readFileSync(path, 'utf-8').trim(); + const parsed = JSON.parse(line); + assert.equal(parsed.event, 'brief-approved'); + assert.deepEqual(parsed.payload, { foo: 42 }); + assert.match(parsed.ts, ISO_8601_RE); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('CLI shim with malformed --payload returns reason payload-not-json (exit 0)', () => { + const r = execFileSync(process.execPath, [ + SHIM, '--event', 'user_input', '--payload', 'not-json{{', + ], { encoding: 'utf-8' }); + const parsed = JSON.parse(r.trim()); + assert.equal(parsed.written, false); + assert.equal(parsed.reason, 'payload-not-json'); +}); + +test('concurrent appends do not corrupt JSONL (smoke)', async () => { + const dir = tmp(); + try { + const path = join(dir, 'stats.jsonl'); + const N = 25; + await Promise.all( + Array.from({ length: N }, (_, i) => + Promise.resolve().then(() => emit('user_input', { i }, { path })), + ), + ); + const lines = readFileSync(path, 'utf-8').trim().split('\n'); + assert.equal(lines.length, N); + for (const l of lines) { + const parsed = JSON.parse(l); // throws if any line is corrupt + assert.ok('ts' in parsed); + assert.equal(parsed.event, 'user_input'); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});