voyage/lib/stats/event-emit.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:11:57 +02:00

129 lines
4.9 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 } from 'node:fs';
import { dirname, join } from 'node:path';
import { resolveStatsDir } from './stats-append.mjs';
import { isMain } from '../util/is-main.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;
}
if (isMain(import.meta.url)) {
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);
}