fix(stats): event-emit lands without the env var and reports every failure
event-emit returned {written:false, reason:'CLAUDE_PLUGIN_DATA unset'} whenever the harness did not export the variable - the normal case for the Bash tool - so /trekexecute's Phase 8 events never landed. It now resolves the directory through stats-append's resolveStatsDir (one resolver; opts.path still wins, so intent-approval is unchanged). The CLI uses the realpath isMain() guard (the file:// guard was a silent no-op under a symlink or a path with a space) and exits 1 on every written:false - usage error, payload-not-json, failed write - instead of always 0. KNOWN_EVENTS gains main-merge-approved, main-merge-declined and gates_mode_flag_override, which /trekexecute emits by design. Header, intent-approval docstring (comment only), docs/operations.md and the README autonomy sentence (wrong state names, 'records each transition') updated.
Red: 10 of 20 tests fail on the pre-fix code - 7 new (CLI lands with CLAUDE_PLUGIN_DATA empty / absent, unwritable target exit 1, usage error exit 1, symlink, space path, known events) and 3 inverted contracts. Regression guards (green on arrival): never throws when unwritable; no second copy of the fallback directory name.
Inverted (the contract this changes, not deleted): emit with no CLAUDE_PLUGIN_DATA (silent skip) -> lands under a pinned HOME; never throws when unwritable -> a genuinely unwritable target (env {} now resolves to the real home); resolveStatsPath({}) === null -> the fallback path; malformed --payload exit 0 -> exit 1.
Readers checked: otel-export keys on 'event', yardstick counts only user_input, cache-analyzer and research-loop-cap name event-emit only in comments. Verify 268/268 (stats-event-emit, intent-approval, doc-consistency, otel-export-validators, yardstick). Suite 1376 (1374/0/2); non-hook lines in the real trekexecute-stats.jsonl 60 before and after.
Voyage order 20261005T104513Z-1250514346, plan step 2/5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8a56187341
commit
27d586841a
5 changed files with 230 additions and 57 deletions
|
|
@ -1,9 +1,13 @@
|
|||
// 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).
|
||||
// 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)
|
||||
|
|
@ -11,33 +15,42 @@
|
|||
// - 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.
|
||||
// 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 '{...}'
|
||||
// → exit 0 (always); silent on stat dir absence.
|
||||
// → 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 } from 'node:fs';
|
||||
import { appendFileSync, existsSync, mkdirSync, 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. Honors CLAUDE_PLUGIN_DATA env var.
|
||||
* Returns null if no plugin-data dir is configured (silent-skip mode).
|
||||
* 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) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dir || typeof dir !== 'string' || dir.length === 0) return null;
|
||||
return join(dir, STATS_FILENAME);
|
||||
return join(resolveStatsDir(env), STATS_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,11 +84,10 @@ export function emit(event, payload = {}, opts = {}) {
|
|||
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.
|
||||
// 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');
|
||||
|
|
@ -97,21 +109,29 @@ function parseArgs(argv) {
|
|||
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
|
||||
// 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
try { payload = JSON.parse(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,9 +202,10 @@ export function stampIntentApproval(text, now = new Date()) {
|
|||
/**
|
||||
* Where the brief-approved record goes. CLAUDE_PLUGIN_DATA when the harness
|
||||
* provides it; otherwise the plugin data dir the hooks write to and
|
||||
* scripts/yardstick.mjs reads — the Bash tool env carries no CLAUDE_PLUGIN_DATA,
|
||||
* and event-emit's own fallback is a silent skip, which is how brief-approved
|
||||
* reached 0 records. One resolver for every stats writer: lib/stats/stats-append.mjs.
|
||||
* scripts/yardstick.mjs reads — the Bash tool env carries no CLAUDE_PLUGIN_DATA.
|
||||
* event-emit's fallback used to be a silent skip, which is how brief-approved
|
||||
* reached 0 records; it now resolves the same directory. One resolver for every
|
||||
* stats writer: lib/stats/stats-append.mjs.
|
||||
*/
|
||||
export function resolveApprovalDataDir(env = process.env) {
|
||||
return resolveStatsDir(env);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue