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
|
|
@ -80,7 +80,7 @@ All four pipeline commands accept `--gates {open|closed|adaptive}` to control ho
|
|||
| `closed` | Stop at every checkpoint. The operator confirms each transition. Suitable for high-stakes work or unfamiliar repos. |
|
||||
| `adaptive` (default) | Stop only when the autonomy-gate state machine reports a meaningful boundary (manifest-audit FAIL, plan-critic BLOCKER, main-merge gate). Best balance of velocity and safety. |
|
||||
|
||||
Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle → approved → executing → merge-pending → main-merged`) and `lib/stats/event-emit.mjs` records each transition to `${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl`. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
|
||||
Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle → gates_on | auto_running → paused_for_gate → completed`), and `lib/stats/event-emit.mjs` records named lifecycle events (the main-merge gate and its answer, among others) to `trekexecute-stats.jsonl` in the plugin data directory. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ All four pipeline commands accept a boolean `--gates {true|false}` flag. Presenc
|
|||
| `closed` | high | Stop at every autonomy boundary; operator confirms each transition |
|
||||
| `open` | low | Skip optional checkpoints; trust manifests + verify gates only |
|
||||
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates true`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `user_input`) to `${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl`. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates true`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `gates_mode_flag_override`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
|
||||
### Path A/B/C decision (v3.4.0; Path C closed 2026-05-05)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -2,18 +2,25 @@
|
|||
// Cover lib/stats/event-emit.mjs:
|
||||
// - emit appends a JSONL line with required ISO-8601 ts
|
||||
// - known_event flag distinguishes recognized vs unknown events
|
||||
// - missing CLAUDE_PLUGIN_DATA does NOT throw (stats must never block)
|
||||
// - CLI shim parses --payload JSON and writes via emit()
|
||||
// - missing CLAUDE_PLUGIN_DATA falls back to the directory stats-append and
|
||||
// scripts/yardstick.mjs use, and a failed write never throws
|
||||
// - CLI shim parses --payload JSON, writes via emit(), exits 1 on any
|
||||
// written:false, and runs through a symlink or a path with a space
|
||||
// - concurrent appends don't corrupt the file (smoke test)
|
||||
//
|
||||
// Isolation: every test that reaches the fallback pins HOME to a temp dir and
|
||||
// empties CLAUDE_PLUGIN_DATA. `env: {}` is never used - resolveStatsDir would fall
|
||||
// back to os.homedir(), the operator's REAL data directory.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { emit, buildRecord, resolveStatsPath, KNOWN_EVENTS } from '../../lib/stats/event-emit.mjs';
|
||||
import { resolveStatsDir } from '../../lib/stats/stats-append.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'stats', 'event-emit.mjs');
|
||||
|
|
@ -24,6 +31,24 @@ function tmp(prefix = 'stats-event-emit-') {
|
|||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const DATA_REL = join('.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
|
||||
|
||||
/** process.env with HOME pinned and CLAUDE_PLUGIN_DATA empty: never the real data dir. */
|
||||
function isolatedEnv(home) {
|
||||
const env = { ...process.env, CLAUDE_PLUGIN_DATA: '', HOME: home };
|
||||
delete env.CLAUDE_COORD_DIR;
|
||||
return env;
|
||||
}
|
||||
|
||||
function runShim(cli, args, env) {
|
||||
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf-8', env });
|
||||
}
|
||||
|
||||
function records(file) {
|
||||
return readFileSync(file, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
test('KNOWN_EVENTS contains plan-v2 spec set', () => {
|
||||
for (const e of ['brief-approved', 'main-merge-gate', 'user_input']) {
|
||||
assert.ok(KNOWN_EVENTS.has(e), `missing recognized event: ${e}`);
|
||||
|
|
@ -82,27 +107,60 @@ test('emit creates the stats directory on demand', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('emit with no CLAUDE_PLUGIN_DATA returns { written: false } (silent skip)', () => {
|
||||
const r = emit('brief-approved', {}, { env: {} });
|
||||
assert.equal(r.written, false);
|
||||
assert.equal(r.path, null);
|
||||
assert.match(r.reason, /CLAUDE_PLUGIN_DATA unset/);
|
||||
// Inverted contract (was: "returns { written: false } (silent skip)"). The silent
|
||||
// skip is how Phase 8's events never landed: the Bash tool env carries no
|
||||
// CLAUDE_PLUGIN_DATA.
|
||||
test('emit with no CLAUDE_PLUGIN_DATA lands in the fallback dir under HOME', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
const r = emit('brief-approved', {}, { env: { HOME: home } });
|
||||
assert.equal(r.written, true, JSON.stringify(r));
|
||||
assert.equal(r.path, join(home, DATA_REL, 'trekexecute-stats.jsonl'));
|
||||
assert.equal(records(r.path).length, 1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Inverted contract (was: env {} as the "unwritable" case - after the fallback an
|
||||
// empty env resolves to the real home, so it is no longer unwritable).
|
||||
test('emit never throws when stats path is unwritable', () => {
|
||||
// Pointing at a path under a non-existent dir on a readonly mount would
|
||||
// be brittle in CI; instead, force the env-resolved path to be empty
|
||||
// and confirm no exception leaks.
|
||||
let threw = false;
|
||||
try { emit('user_input', { foo: 'bar' }, { env: {} }); }
|
||||
catch { threw = true; }
|
||||
assert.equal(threw, false);
|
||||
const dir = tmp();
|
||||
try {
|
||||
const file = join(dir, 'a-file');
|
||||
writeFileSync(file, 'not a directory');
|
||||
let r;
|
||||
assert.doesNotThrow(() => { r = emit('user_input', { foo: 'bar' }, { path: join(file, 'x.jsonl') }); });
|
||||
assert.equal(r.written, false);
|
||||
assert.ok(r.reason && r.reason.length > 0, JSON.stringify(r));
|
||||
|
||||
mkdirSync(join(dir, '.claude', 'plugins', 'data'), { recursive: true });
|
||||
writeFileSync(join(dir, DATA_REL), 'not a directory');
|
||||
assert.doesNotThrow(() => { r = emit('user_input', {}, { env: { HOME: dir } }); });
|
||||
assert.equal(r.written, false);
|
||||
assert.ok(r.reason && r.reason.length > 0, JSON.stringify(r));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveStatsPath honors CLAUDE_PLUGIN_DATA env var', () => {
|
||||
// Inverted contract (was: resolveStatsPath({}) === null).
|
||||
test('resolveStatsPath honors CLAUDE_PLUGIN_DATA, else the stats-append directory', () => {
|
||||
const r = resolveStatsPath({ CLAUDE_PLUGIN_DATA: '/var/data/plugin' });
|
||||
assert.equal(r, '/var/data/plugin/trekexecute-stats.jsonl');
|
||||
assert.equal(resolveStatsPath({}), null);
|
||||
assert.equal(resolveStatsPath({ HOME: '/h' }), join(resolveStatsDir({ HOME: '/h' }), 'trekexecute-stats.jsonl'));
|
||||
assert.equal(resolveStatsPath({ HOME: '/h', CLAUDE_PLUGIN_DATA: '' }), join('/h', DATA_REL, 'trekexecute-stats.jsonl'));
|
||||
});
|
||||
|
||||
test('the Phase 8 and override events are known events', () => {
|
||||
for (const e of ['main-merge-approved', 'main-merge-declined', 'gates_mode_flag_override']) {
|
||||
assert.equal(buildRecord(e).known_event, true, `${e} is emitted by /trekexecute by design`);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard: one resolver - event-emit carries no second copy of the fallback path.
|
||||
test('event-emit has no copy of the fallback directory name', () => {
|
||||
assert.doesNotMatch(readFileSync(SHIM, 'utf-8'), /voyage-ktg-plugin-marketplace/);
|
||||
});
|
||||
|
||||
test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => {
|
||||
|
|
@ -126,13 +184,107 @@ test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('CLI shim with malformed --payload returns reason payload-not-json (exit 0)', () => {
|
||||
const r = execFileSync(process.execPath, [
|
||||
SHIM, '--event', 'user_input', '--payload', 'not-json{{',
|
||||
], { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(r.trim());
|
||||
// Inverted contract (was: "... payload-not-json (exit 0)"): every written:false exits 1.
|
||||
test('CLI shim with malformed --payload returns reason payload-not-json (exit 1)', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
const r = runShim(SHIM, ['--event', 'user_input', '--payload', 'not-json{{'], isolatedEnv(home));
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
const parsed = JSON.parse(r.stdout.trim());
|
||||
assert.equal(parsed.written, false);
|
||||
assert.equal(parsed.reason, 'payload-not-json');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: with CLAUDE_PLUGIN_DATA empty the event lands under HOME', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
const r = runShim(SHIM, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home));
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout.trim()).written, true, r.stdout);
|
||||
const recs = records(join(home, DATA_REL, 'trekexecute-stats.jsonl'));
|
||||
assert.equal(recs.length, 1);
|
||||
assert.equal(recs[0].event, 'main-merge-gate');
|
||||
assert.equal(recs[0].known_event, true);
|
||||
assert.deepEqual(recs[0].payload, {});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: with CLAUDE_PLUGIN_DATA absent the event lands under HOME', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
const env = isolatedEnv(home);
|
||||
delete env.CLAUDE_PLUGIN_DATA;
|
||||
const r = runShim(SHIM, ['--event', 'main-merge-gate', '--payload', '{}'], env);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(records(join(home, DATA_REL, 'trekexecute-stats.jsonl')).length, 1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: an unwritable target exits 1 with a reason', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
mkdirSync(join(home, '.claude', 'plugins', 'data'), { recursive: true });
|
||||
writeFileSync(join(home, DATA_REL), 'not a directory');
|
||||
const r = runShim(SHIM, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home));
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
const out = JSON.parse(r.stdout.trim());
|
||||
assert.equal(out.written, false);
|
||||
assert.ok(out.reason && out.reason.length > 0, r.stdout);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: a usage error exits 1 with a reason', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
const r = runShim(SHIM, [], isolatedEnv(home));
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
const out = JSON.parse(r.stdout.trim());
|
||||
assert.equal(out.written, false);
|
||||
assert.match(out.reason, /usage/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: event-emit writes when it is run through a symlink', () => {
|
||||
const home = tmp();
|
||||
const links = tmp('stats-event-emit-link-');
|
||||
try {
|
||||
const link = join(links, 'event-emit.mjs');
|
||||
symlinkSync(SHIM, link);
|
||||
const r = runShim(link, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home));
|
||||
assert.ok(r.stdout.trim().length > 0, `no result line - the CLI guard did not fire; stderr: ${r.stderr}`);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.ok(existsSync(join(home, DATA_REL, 'trekexecute-stats.jsonl')), `no record landed; stdout: "${r.stdout}"`);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(links, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: event-emit writes when its path contains a space', () => {
|
||||
const home = tmp();
|
||||
const spaced = tmp('stats event emit space-');
|
||||
try {
|
||||
cpSync(join(ROOT, 'lib'), join(spaced, 'with space', 'lib'), { recursive: true });
|
||||
const cli = join(spaced, 'with space', 'lib', 'stats', 'event-emit.mjs');
|
||||
const r = runShim(cli, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home));
|
||||
assert.ok(r.stdout.trim().length > 0, `no result line - the CLI guard did not fire; stderr: ${r.stderr}`);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.ok(existsSync(join(home, DATA_REL, 'trekexecute-stats.jsonl')), `no record landed; stdout: "${r.stdout}"`);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(spaced, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent appends do not corrupt JSONL (smoke)', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue