ktg-plugin-marketplace/plugins/voyage/lib/stats/event-emit.mjs
Kjell Tore Guttormsen 7a90d348ad feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope.

- git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved)
- .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local
- CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list
- README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph
- plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path
- plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed)

Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
2026-05-05 15:37:52 +02:00

117 lines
4 KiB
JavaScript

// 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}/trekexecute-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 = 'trekexecute-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);
}