/trekreview of 5680591..84a0621 (verdict WARN: 0 BLOCKER, 3 MAJOR, 1 MINOR,
1 MAJOR suppressed as unverified for length only). All closed red -> green:
- Suppressed MAJOR 1d0171a7 (verified live): Phase 8 spliced {plan-path}
into a single-quoted --payload. A lone quote broke the shell parse (event
lost, no written:false to report); a quoted pair ran a command
substitution and still reported written:true. event-emit now reads
`--payload -` from stdin and the three Phase 8 blocks pass the payload
through a quoted heredoc. Checked under bash and zsh.
- 570ba19b: the SC5 set-step extractor read line by line and never saw the
wrapped Step 0 sentinel (`blocked`); it now matches across the break and
the test asserts `blocked` is read.
- c69a865a: the Phase 9 line_ref in jsonl-schemas.md was 9 lines off. Both
trekexecute rows are now pinned to the exact span by a test.
- 0004fa23 (MINOR): the gates_mode_flag_override block says what to do on
written:false (print the reason, continue).
- emit() docstring no longer says failures are swallowed silently.
- 7ffaede5: chosen to KEEP the prose-only legacy `passed` tolerance, because
a public-plugin run started under the 6.1.0 prose would otherwise re-run
every finished step on resume; recorded as an accepted deviation in the
(local-only) brief. No code change.
Suite 1400 (1398/0/2), was 1393.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
5.2 KiB
JavaScript
139 lines
5.2 KiB
JavaScript
// lib/stats/event-emit.mjs
|
|
// Atomic JSONL append for autonomy-lifecycle events (plan-v2 Step 6).
|
|
//
|
|
// Writes one line per event to <data dir>/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);
|
|
}
|