// lib/stats/event-emit.mjs // Atomic JSONL append for autonomy-lifecycle events (plan-v2 Step 6). // // Writes one line per event to /trekexecute-stats.jsonl, where the data // dir is resolved by stats-append.mjs's resolveStatsDir: CLAUDE_PLUGIN_DATA when // the harness provides it, else the plugin's directory under ~/.claude/plugins/data/, // the one scripts/yardstick.mjs reads (one resolver, no second copy here). The Bash tool env carries no // CLAUDE_PLUGIN_DATA, so the old "unset -> silent skip" dropped every event the // command prose emitted. A failed write is REPORTED ({written:false, reason}), // never thrown: stats still never block the 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, main-merge-approved, // main-merge-declined, gates_mode_flag_override, 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 '{...}' // node lib/stats/event-emit.mjs --event main-merge-gate --payload - <<'JSON' (payload on stdin) // → prints {"written":true,"path":...} exit 0 | {"written":false,"reason":...} exit 1 // (usage error, payload-not-json and a failed write alike). The command prose // prints the reason and continues. // // Import direction: event-emit imports stats-append; stats-append must never // import event-emit (that would be a cycle). import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveStatsDir } from './stats-append.mjs'; export const KNOWN_EVENTS = Object.freeze(new Set([ 'brief-approved', 'main-merge-gate', 'main-merge-approved', 'main-merge-declined', 'gates_mode_flag_override', 'user_input', ])); const STATS_FILENAME = 'trekexecute-stats.jsonl'; /** * Resolve the stats file path: CLAUDE_PLUGIN_DATA when set, else the directory * stats-append.mjs and scripts/yardstick.mjs use. Never null. */ export function resolveStatsPath(env = process.env) { return join(resolveStatsDir(env), 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 — a failed write is returned as {written:false, * reason} 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); try { const dir = dirname(path); if (!existsSync(dir)) { // A dir that cannot be created is reported, never thrown. 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; } // Run as a CLI? Compared as real paths: `file://${argv[1]}` misses a path with a // space (URL-encoded) and a symlink (import.meta.url is the realpath). function isMain() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } if (isMain()) { const args = parseArgs(process.argv.slice(2)); let result; if (!args.event) { result = { written: false, path: null, reason: 'usage: --event NAME [--payload JSON]' }; } else { let payload = {}; let bad = false; if (args.payload) { // `-` = read it from stdin: a quoted heredoc passes it with nothing expanded. try { payload = JSON.parse(args.payload === '-' ? readFileSync(0, 'utf8') : args.payload); } catch { bad = true; } } result = bad ? { written: false, path: null, reason: 'payload-not-json' } : emit(args.event, payload); } process.stdout.write(JSON.stringify(result) + '\n'); process.exit(result.written ? 0 : 1); }