chore(cleanup): R-b R1 — Voyage's own stats, events and OTel export removed

Removes Voyage's measurement, statistics and export apparatus (decision 189;
condition 175 met in step 1) and the R-a leftovers: lib/stats/, lib/exporters/,
the otel-export and post-bash-stats hooks, yardstick, end-state and comparison
gates, synthesis-measure, gold-scorer, docs/observability.md,
examples/observability/, docs/T1-cc26-delegated-orchestration.md and their
tests and fixtures. The six command stats phases and the /trekexecute
main-merge event blocks go; intent-approval --stamp no longer emits
brief-approved. README and docs point to Claude Code's own OpenTelemetry.

Files removed: 52. Files edited: 37.
npm test: 2930 tests (2928 pass / 0 fail / 2 skipped) before, 2600 tests
(2598 pass / 0 fail / 2 skipped) after. removed-surfaces group R1 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-07 20:11:00 +01:00
commit 21480e8627
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
89 changed files with 94 additions and 10290 deletions

View file

@ -1,139 +0,0 @@
// lib/exporters/endpoint-validator.mjs
// Validate OTLP/HTTP endpoint URLs for the OTel exporter.
//
// CWE-918 (Server-Side Request Forgery) mitigation: reject loopback, RFC-1918,
// link-local (cloud metadata 169.254.169.254), and require HTTPS for non-loopback.
// Operator opt-in for private endpoints via VOYAGE_OTEL_ALLOW_PRIVATE=1
// (legitimate home-lab / docker-compose operator scenario).
import { ok, fail, issue } from '../util/result.mjs';
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']);
const LINK_LOCAL_PREFIXES = ['169.254.', 'fe80:'];
// Cloud metadata service endpoints — PERMANENTLY blocked even with
// VOYAGE_OTEL_ALLOW_PRIVATE=1. These addresses expose IAM credentials,
// instance secrets, and user-data on AWS/GCP/Azure/AliCloud workloads.
// Operator-trust is NOT extended to these specific IPs because the
// blast-radius (cloud-account compromise) is qualitatively different
// from home-lab RFC-1918 access.
const HARD_BLOCKED_HOSTS = new Set([
'169.254.169.254', // AWS / GCP / Azure metadata service
'100.100.100.200', // AliCloud metadata service
'metadata.google.internal',
'metadata.azure.com',
]);
function isRfc1918(host) {
// 10.0.0.0/8
if (/^10\./.test(host)) return true;
// 172.16.0.0/12
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
// 192.168.0.0/16
if (/^192\.168\./.test(host)) return true;
return false;
}
function isLoopback(host) {
return LOOPBACK_HOSTS.has(host);
}
function isLinkLocal(host) {
return LINK_LOCAL_PREFIXES.some(p => host.startsWith(p));
}
// IPv4-mapped IPv6 (::ffff:a.b.c.d) routes to the embedded IPv4. Node renders
// the literal as ::ffff:HHHH:HHHH, so decode the embedded IPv4 and classify on
// THAT — otherwise the loopback / RFC-1918 / link-local / cloud-metadata guards
// below are all bypassable via the mapped form over https (CWE-918, S21).
// Returns dotted-decimal IPv4, or null when host is not an IPv4-mapped literal.
function mappedV4(host) {
const m = /^::ffff:(.+)$/i.exec(host);
if (!m) return null;
const rest = m[1];
if (rest.includes('.')) {
// Dotted form: ::ffff:127.0.0.1
return /^\d{1,3}(\.\d{1,3}){3}$/.test(rest) ? rest : null;
}
// Hex-pair form: ::ffff:7f00:1 → two 16-bit groups → 4 octets
const parts = rest.split(':');
if (parts.length !== 2) return null;
const hi = parseInt(parts[0], 16);
const lo = parseInt(parts[1], 16);
if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff) return null;
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
}
/**
* Validate an OTLP/HTTP endpoint URL.
*
* @param {string} url
* @param {{env?: object}} [opts]
* @returns {import('../util/result.mjs').Result}
*/
export function validateOtlpEndpoint(url, opts = {}) {
const env = opts.env || process.env;
const allowPrivate = env.VOYAGE_OTEL_ALLOW_PRIVATE === '1';
if (typeof url !== 'string' || url.length === 0) {
return fail(issue('ENDPOINT_EMPTY', 'Endpoint must be a non-empty string'));
}
let parsed;
try { parsed = new URL(url); }
catch (e) {
return fail(issue('ENDPOINT_PARSE_ERROR', `Invalid URL: ${e.message}`));
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return fail(issue('ENDPOINT_BAD_PROTOCOL',
`Endpoint protocol must be http or https, got ${parsed.protocol}`));
}
// Strip brackets from IPv6
let host = parsed.hostname.replace(/^\[|\]$/g, '');
// Canonicalize IPv4-mapped IPv6 to its embedded IPv4 so every guard below
// classifies (and reports) the address the request would actually reach.
const embedded = mappedV4(host);
if (embedded) host = embedded;
// Cloud metadata services — PERMANENTLY blocked. VOYAGE_OTEL_ALLOW_PRIVATE
// does NOT override this; metadata endpoints expose IAM credentials.
if (HARD_BLOCKED_HOSTS.has(host)) {
return fail(issue('ENDPOINT_HARD_BLOCKED',
`Endpoint ${host} is permanently blocked (cloud metadata service). ` +
`VOYAGE_OTEL_ALLOW_PRIVATE does not override this restriction.`));
}
// Other link-local addresses — rejected unless explicit opt-in
if (isLinkLocal(host) && !allowPrivate) {
return fail(issue('ENDPOINT_LINK_LOCAL_REJECTED',
`Link-local endpoint ${host} rejected (potential cloud-metadata access). ` +
`Set VOYAGE_OTEL_ALLOW_PRIVATE=1 to allow.`));
}
// Loopback / RFC-1918 — rejected unless opt-in
if (isLoopback(host) && !allowPrivate) {
return fail(issue('ENDPOINT_LOOPBACK_REJECTED',
`Loopback endpoint ${host} rejected. Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for ` +
`home-lab / docker-compose scenarios.`));
}
if (isRfc1918(host) && !allowPrivate) {
return fail(issue('ENDPOINT_RFC1918_REJECTED',
`RFC-1918 private endpoint ${host} rejected. ` +
`Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for home-lab scenarios.`));
}
// For non-loopback, non-private endpoints: require HTTPS
const isPrivate = isLoopback(host) || isRfc1918(host) || isLinkLocal(host);
if (!isPrivate && parsed.protocol === 'http:') {
return fail(issue('ENDPOINT_HTTPS_REQUIRED',
`Public endpoint ${host} requires https:// (got http://)`));
}
return ok({ url: parsed.href, host, isPrivate });
}
export { LOOPBACK_HOSTS, LINK_LOCAL_PREFIXES };

View file

@ -1,169 +0,0 @@
// lib/exporters/field-allowlist.mjs
// CWE-212 (Improper Cross-boundary Removal of Sensitive Data) mitigation:
// inline static allowlist that strips PII / high-cardinality fields before
// records reach the OTel exporter.
//
// The allowlist is INLINE STATIC at module-scope (Object.freeze). NEVER read
// runtime from tests/fixtures/ — fixture file is AUTHORING reference only
// (Source: tests/fixtures/jsonl-schemas.md per Step 1 audit).
//
// Per-schema allowlists drop:
// - command_excerpt (post-bash-stats: arbitrary Bash slice, CWE-212)
// - session_id (UUID, high-cardinality, log-only)
// - order_id / order_claimed_at / order_reason (trekbrief: coordination
// metadata - order ids name private repos; high-cardinality, never exported)
// - task / question / project_dir / brief_path / plan / project (PII-ish prose, paths)
// - payload (event-emit: open-ended object, allowlist payload-keys instead)
// - phase_models (structured object, summarize via `profile` label instead)
// - counts (review nested object, flatten to per-severity metrics elsewhere)
// ---- Per-schema allowlists (Source: tests/fixtures/jsonl-schemas.md) -------
// Source: tests/fixtures/jsonl-schemas.md row 1 (trekbrief)
const TREKBRIEF_ALLOWED = Object.freeze(new Set([
'ts', 'slug', 'mode', 'interview_turns', 'review_iterations',
'brief_quality', 'research_topics', 'auto_research', 'auto_result',
'intent_approved', // veikart steg 1: boolean, whether Phase 4h stamped the intent
'profile', 'profile_source',
]));
// Source: tests/fixtures/jsonl-schemas.md row 2 (trekresearch)
// `engine` is a low-cardinality label (swarm|deep-research) emitted at
// commands/trekresearch.md:533 and promised in prose (:570-572).
// DENY BY OMISSION: question (free prose), project_dir + brief_path
// (filesystem paths) are written into the jsonl but MUST NOT reach the
// exporter.
// The five v5.10 measurement fields are allowlisted too: `effort` is a
// low-cardinality label (low|standard|high) and the grouping key the
// measurement gate is computed on; `unique_sources`, `dimensions_baseline`,
// `conv_turns` and `empty_turns` are plain counters. None of them carry prose
// or paths. `dimensions_baseline_preserved` is a boolean, and it exists BECAUSE
// prose is denied here: the activation check needs to know that the final
// dimension list is a superset of the interview-derived one, and the dimension
// NAMES that would show it directly are free prose that must not reach the
// exporter. A boolean attestation carries the fact without the payload.
const TREKRESEARCH_ALLOWED = Object.freeze(new Set([
'ts', 'slug', 'mode', 'scope', 'engine', 'dimensions', 'agents_local',
'agents_external', 'gemini_used', 'confidence', 'contradictions',
'open_questions', 'profile', 'parallel_agents',
'external_research_enabled', 'profile_source',
'effort', 'unique_sources', 'dimensions_baseline', 'conv_turns',
'empty_turns', 'dimensions_baseline_preserved',
]));
// Source: tests/fixtures/jsonl-schemas.md row 3 (trekplan)
const TREKPLAN_ALLOWED = Object.freeze(new Set([
'ts', 'slug', 'mode', 'codebase_size', 'codebase_files',
'agents_deployed', 'deep_dives', 'research_briefs_used',
'research_scout_used', 'critic_verdict', 'guardian_verdict', 'outcome',
'profile', 'parallel_agents', 'profile_source',
]));
// Source: tests/fixtures/jsonl-schemas.md row 4 (trekexecute Phase 9 record)
const TREKEXECUTE_ALLOWED = Object.freeze(new Set([
'ts', 'plan_type', 'mode', 'result', 'steps_total', 'steps_passed',
'steps_failed', 'steps_skipped', 'failed_at_step',
'profile', 'profile_source',
]));
// Source: tests/fixtures/jsonl-schemas.md row 5 (trekexecute autonomy events)
const EVENT_EMIT_ALLOWED = Object.freeze(new Set([
'ts', 'event', 'known_event',
]));
// Source: tests/fixtures/jsonl-schemas.md row 5 (event-emit payload sub-allowlist)
const EVENT_EMIT_PAYLOAD_ALLOWED = Object.freeze(new Set([
'profile', 'phase_models', 'parallel_agents',
'external_research_enabled', 'profile_source',
'brief_quality', 'plan_grade',
]));
// Source: tests/fixtures/jsonl-schemas.md row 6 (post-bash-stats — PII-flag)
// CWE-212: command_excerpt + session_id MUST be stripped.
const POST_BASH_STATS_ALLOWED = Object.freeze(new Set([
'ts', 'duration_ms', 'success',
]));
// Source: tests/fixtures/jsonl-schemas.md row 7 (trekreview)
const TREKREVIEW_ALLOWED = Object.freeze(new Set([
'ts', 'slug', 'verdict', 'reviewed_files_count', 'mode',
'duration_ms', 'profile', 'profile_source',
]));
// Source: tests/fixtures/jsonl-schemas.md row 8 (trekcontinue)
const TREKCONTINUE_ALLOWED = Object.freeze(new Set([
'ts', 'next_session_label', 'status', 'profile', 'profile_source',
]));
// Source: tests/fixtures/jsonl-schemas.md row 9 (token-usage — SKAL-2)
// CWE-212: numeric + low-cardinality-label fields ONLY. DENY BY OMISSION:
// session_id (UUID), transcript_path (filesystem path), cwd (filesystem path)
// are written into the jsonl for upsert keying but MUST NOT reach the exporter.
const TOKEN_USAGE_ALLOWED = Object.freeze(new Set([
'ts', 'scope', 'model',
'tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read',
'cost_usd', 'is_estimate', 'price_table_version',
]));
// Schema-id → allowlist set
const SCHEMA_ALLOWLISTS = Object.freeze({
'trekbrief': TREKBRIEF_ALLOWED,
'trekresearch': TREKRESEARCH_ALLOWED,
'trekplan': TREKPLAN_ALLOWED,
'trekexecute': TREKEXECUTE_ALLOWED,
'event-emit': EVENT_EMIT_ALLOWED,
'post-bash-stats': POST_BASH_STATS_ALLOWED,
'post_bash_stats': POST_BASH_STATS_ALLOWED, // common alt-spelling
'trekreview': TREKREVIEW_ALLOWED,
'trekcontinue': TREKCONTINUE_ALLOWED,
'token-usage': TOKEN_USAGE_ALLOWED,
});
/**
* Apply field-allowlist to a record. Drops any field not in the schema's
* allowlist. Returns a new object (does not mutate input).
*
* Always preserves `_schema_id` (caller-supplied identifier used by exporters
* to group records by source).
*
* @param {object} record
* @param {string} schemaType Schema-id (e.g. 'trekplan', 'post-bash-stats')
* @returns {object} Redacted record with only allowlisted fields.
*/
export function applyFieldAllowlist(record, schemaType) {
if (!record || typeof record !== 'object') return {};
const allowed = SCHEMA_ALLOWLISTS[schemaType];
if (!allowed) {
// Unknown schema-type: be conservative — keep ts only + _schema_id.
return record.ts ? { _schema_id: schemaType, ts: record.ts } : { _schema_id: schemaType };
}
const out = { _schema_id: schemaType };
for (const k of allowed) {
if (k in record) out[k] = record[k];
}
// Special: event-emit records have a `payload` sub-object that needs its
// own allowlist applied recursively.
if (schemaType === 'event-emit' && record.payload && typeof record.payload === 'object') {
const subOut = {};
for (const k of EVENT_EMIT_PAYLOAD_ALLOWED) {
if (k in record.payload) subOut[k] = record.payload[k];
}
out.payload = subOut;
}
return out;
}
export {
SCHEMA_ALLOWLISTS,
EVENT_EMIT_PAYLOAD_ALLOWED,
POST_BASH_STATS_ALLOWED,
TREKBRIEF_ALLOWED,
TREKRESEARCH_ALLOWED,
TREKPLAN_ALLOWED,
TREKEXECUTE_ALLOWED,
TREKREVIEW_ALLOWED,
TREKCONTINUE_ALLOWED,
TOKEN_USAGE_ALLOWED,
};

View file

@ -1,197 +0,0 @@
// lib/exporters/otlp-format.mjs
// Pure transform: voyage JSONL stats records → OTLP/JSON v1.0 metrics payload.
//
// Per OpenTelemetry Protocol § metrics.proto + research/01 dim 4 (CRITICAL):
// AggregationTemporality enum values are INTEGERS in JSON, NOT strings.
// "CUMULATIVE" → 2 (not the string)
// "DELTA" → 1
// timeUnixNano is uint64 over the wire — emit as decimal STRING in JSON to
// avoid JS Number precision loss (per research/01 + risk-assessor CRITICAL 2).
//
// Output contract:
// {
// resourceMetrics: [{
// resource: { attributes: [...] },
// scopeMetrics: [{
// scope: { name: 'voyage', version: '...' },
// metrics: [{
// name: 'voyage.<metric>',
// description: '...',
// unit: '1' | 'ms' | ...,
// sum: { dataPoints: [{ ... aggregationTemporality: <int> ...}] }
// | gauge: { dataPoints: [...] }
// }]
// }]
// }]
// }
// ---- Inline integer enum constants (CRITICAL: integers, NOT strings) -------
const AGG_TEMPORALITY_UNSPECIFIED = 0;
const AGG_TEMPORALITY_DELTA = 1;
const AGG_TEMPORALITY_CUMULATIVE = 2;
const DATA_POINT_FLAGS_NONE = 0;
const DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK = 1;
const VOYAGE_SCOPE_NAME = 'voyage';
const VOYAGE_SCOPE_VERSION = '4.1.0';
// ---- Helpers ---------------------------------------------------------------
/**
* Convert ISO-8601 timestamp to OTLP timeUnixNano (uint64 as decimal STRING).
* Avoids Number precision loss for nanosecond-scale values.
*/
function toUnixNanoString(iso) {
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return '0';
// ms × 1e6 = nanoseconds; use BigInt for precision
return (BigInt(ms) * 1000000n).toString();
}
/**
* Build OTLP attribute object: {key, value: {stringValue: "..."}} or numeric variants.
*/
function attribute(key, value) {
if (typeof value === 'string') return { key, value: { stringValue: value } };
if (typeof value === 'boolean') return { key, value: { boolValue: value } };
if (typeof value === 'number' && Number.isInteger(value)) return { key, value: { intValue: String(value) } };
if (typeof value === 'number') return { key, value: { doubleValue: value } };
return { key, value: { stringValue: String(value) } };
}
/**
* Partition record into numeric metrics and string/bool labels.
* (Same convention as textfile-format.mjs.)
*/
function partitionRecord(record) {
const labels = {};
const metrics = {};
for (const [k, v] of Object.entries(record)) {
if (k === 'ts' || k === '_schema_id') continue;
if (typeof v === 'number') metrics[k] = v;
else if (typeof v === 'boolean') metrics[k] = v ? 1 : 0;
else if (typeof v === 'string') labels[k] = v;
}
return { labels, metrics };
}
/**
* Build OTLP DataPoint object for a numeric value.
*/
function dataPoint(value, ts, labels) {
const tsNano = toUnixNanoString(ts);
return {
attributes: Object.entries(labels).map(([k, v]) => attribute(k, v)),
startTimeUnixNano: tsNano,
timeUnixNano: tsNano,
asDouble: Number.isInteger(value) ? undefined : value,
asInt: Number.isInteger(value) ? String(value) : undefined,
flags: DATA_POINT_FLAGS_NONE,
};
}
/**
* Decide metric "kind": sum (for *_total/*_count/*_passed/*_failed) or gauge.
* Sum metrics get aggregationTemporality + isMonotonic; gauges get neither.
*/
function isSumMetric(name) {
return /_total$|_count$|_passed$|_failed$|_skipped$/.test(name);
}
/**
* Transform JSONL records → OTLP/JSON metrics payload. Pure function.
*
* @param {Array<object>} records Allowlist-redacted records (caller responsibility).
* @param {{help?: object}} [opts]
* @returns {object} OTLP-shaped payload (POST body for /v1/metrics).
*/
export function transformToOtlpJson(records, opts = {}) {
const helpMap = opts.help || {};
if (!Array.isArray(records) || records.length === 0) {
return {
resourceMetrics: [{
resource: { attributes: [attribute('service.name', VOYAGE_SCOPE_NAME)] },
scopeMetrics: [{
scope: { name: VOYAGE_SCOPE_NAME, version: VOYAGE_SCOPE_VERSION },
metrics: [],
}],
}],
};
}
// Group all data points by metric name (schema_id_field).
const metricsMap = new Map();
for (const record of records) {
const schemaId = (record && typeof record._schema_id === 'string') ? record._schema_id : 'unknown';
const ts = record.ts || new Date().toISOString();
const { labels, metrics } = partitionRecord(record);
const allLabels = { ...labels, _schema_id: schemaId };
for (const [field, value] of Object.entries(metrics)) {
const name = `${VOYAGE_SCOPE_NAME}.${schemaId}.${field}`;
if (!metricsMap.has(name)) {
metricsMap.set(name, {
name,
description: helpMap[name] || `voyage stats — ${schemaId}.${field}`,
unit: /_ms$|_duration/.test(field) ? 'ms' : (/_seconds$/.test(field) ? 's' : '1'),
dataPoints: [],
isSum: isSumMetric(name),
});
}
metricsMap.get(name).dataPoints.push(dataPoint(value, ts, allLabels));
}
}
// Sort metrics for deterministic output
const sortedNames = [...metricsMap.keys()].sort();
const otlpMetrics = sortedNames.map(name => {
const m = metricsMap.get(name);
if (m.isSum) {
return {
name: m.name,
description: m.description,
unit: m.unit,
sum: {
dataPoints: m.dataPoints,
aggregationTemporality: AGG_TEMPORALITY_CUMULATIVE, // INTEGER 2
isMonotonic: true,
},
};
}
return {
name: m.name,
description: m.description,
unit: m.unit,
gauge: {
dataPoints: m.dataPoints,
},
};
});
return {
resourceMetrics: [{
resource: {
attributes: [
attribute('service.name', VOYAGE_SCOPE_NAME),
attribute('service.version', VOYAGE_SCOPE_VERSION),
],
},
scopeMetrics: [{
scope: { name: VOYAGE_SCOPE_NAME, version: VOYAGE_SCOPE_VERSION },
metrics: otlpMetrics,
}],
}],
};
}
export {
AGG_TEMPORALITY_UNSPECIFIED,
AGG_TEMPORALITY_DELTA,
AGG_TEMPORALITY_CUMULATIVE,
DATA_POINT_FLAGS_NONE,
DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK,
};

View file

@ -1,105 +0,0 @@
// lib/exporters/path-validator.mjs
// Validate textfile output paths for the OTel exporter.
//
// CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored
// directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`,
// `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via
// `fs.realpathSync` before checking.
import { realpathSync, existsSync, statSync } from 'node:fs';
import { resolve, normalize, sep } from 'node:path';
import { ok, fail, issue } from '../util/result.mjs';
// macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp,
// /private/var; realpathSync resolves the symlink and adds the /private prefix.
// Include both forms so the deny check works on macOS + Linux.
const FORBIDDEN_PREFIXES = [
'/etc/', '/private/etc/',
'/proc/',
'/sys/',
'/var/', '/private/var/',
'/usr/',
'/bin/',
'/sbin/',
'/boot/',
'/dev/',
];
/**
* Validate that a path is safe for the OTel textfile exporter to write.
*
* @param {string} path Caller-supplied path.
* @param {{
* allowedRoots?: string[] // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR)
* }} [opts]
* @returns {import('../util/result.mjs').Result}
*/
export function validateTextfilePath(path, opts = {}) {
if (typeof path !== 'string' || path.length === 0) {
return fail(issue('PATH_EMPTY', 'Path must be a non-empty string'));
}
// Reject home-shorthand — caller must expand explicitly
if (path.startsWith('~')) {
return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`));
}
// Normalize to absolute (relative becomes resolved against cwd)
const normalized = normalize(path);
// Reject any path component containing `..` (traversal attempt)
// Even after normalize, if `..` survives, the path leaves intended root.
const segments = normalized.split(sep);
if (segments.some(s => s === '..')) {
return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`));
}
const absolute = resolve(normalized);
// Resolve symlinks if file exists; if it doesn't exist yet, resolve parent
let resolved;
try {
if (existsSync(absolute)) {
resolved = realpathSync(absolute);
} else {
// Resolve parent dir (which must exist for any meaningful write target)
const parent = absolute.split(sep).slice(0, -1).join(sep) || '/';
if (!existsSync(parent)) {
return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`));
}
resolved = realpathSync(parent) + sep + absolute.split(sep).pop();
}
} catch (e) {
return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`));
}
// If allowedRoots is provided, that's the primary defense — caller has
// explicitly opted into a root. Reject anything outside; accept anything
// inside (callers vetting their roots is the threat model).
if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) {
const inside = opts.allowedRoots.some(root => {
if (typeof root !== 'string' || root.length === 0) return false;
let resolvedRoot;
try { resolvedRoot = realpathSync(root); }
catch { resolvedRoot = resolve(root); }
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
});
if (!inside) {
return fail(issue('PATH_OUT_OF_ALLOWLIST',
`Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`));
}
return ok({ path: resolved });
}
// No allowedRoots: fall back to forbidden-system-prefix denylist.
for (const prefix of FORBIDDEN_PREFIXES) {
if (resolved.startsWith(prefix)) {
return fail(issue('PATH_FORBIDDEN_SYSTEM',
`Path resolves into forbidden system directory ${prefix}: ${resolved}`));
}
}
return ok({ path: resolved });
}
export { FORBIDDEN_PREFIXES };

View file

@ -1,135 +0,0 @@
// lib/exporters/textfile-format.mjs
// Pure transform: voyage JSONL stats records → Prometheus text-format 0.0.4.
//
// Output contract (Prometheus exposition format 0.0.4):
// # HELP voyage_<metric_name> <description>
// # TYPE voyage_<metric_name> {gauge|counter|histogram}
// voyage_<metric_name>{label="value",...} <numeric_value>
//
// Hard rules:
// - NO client-side timestamps (per research/01 — node_exporter#1284 known issue
// where stale textfile samples re-emit with old timestamps).
// - Allowlist-redacted records ONLY (caller must apply field-allowlist first).
// - UTF-8 metric names normalized: dots/dashes → underscore, lowercase, prefixed `voyage_`.
// - Empty input → empty string output (no headers, no errors).
const METRIC_PREFIX = 'voyage_';
/**
* Normalize a JSONL field name to a Prometheus-safe metric name.
* Per Prometheus 3.0 rules: [a-zA-Z_:][a-zA-Z0-9_:]*. Replace dot/dash/space → '_'.
*/
function normalizeMetricName(name) {
const safe = String(name).toLowerCase().replace(/[.\-\s]+/g, '_').replace(/[^a-zA-Z0-9_:]/g, '_');
return METRIC_PREFIX + safe;
}
/**
* Quote a Prometheus label value per spec § Format: backslash, double-quote, newline.
*/
function escapeLabel(v) {
return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
/**
* Determine which fields in a record are numeric metrics vs string labels.
* Numeric → emitted as samples. String/bool → labels on the sample.
*/
function partitionRecord(record) {
const labels = {};
const metrics = {};
for (const [k, v] of Object.entries(record)) {
if (k === 'ts') continue; // never emit ts as label nor metric (Prom server timestamps)
if (typeof v === 'number') metrics[k] = v;
else if (typeof v === 'boolean') metrics[k] = v ? 1 : 0;
else if (typeof v === 'string') labels[k] = v;
// Skip arrays/objects — caller's allowlist should have flattened these.
}
return { labels, metrics };
}
/**
* Group records by their schema_id (an extracted convention: schema_id is the
* caller-provided identifier; if absent, fall back to 'unknown').
*/
function groupBySchema(records) {
const groups = new Map();
for (const r of records) {
const id = (r && typeof r._schema_id === 'string') ? r._schema_id : 'unknown';
if (!groups.has(id)) groups.set(id, []);
groups.get(id).push(r);
}
return groups;
}
/**
* Transform JSONL records into Prometheus text-format. Pure function.
*
* @param {Array<object>} records Allowlist-redacted records (caller responsibility).
* @param {{help?: object}} [opts] Optional: help-text overrides per metric (object).
* @returns {string} Prometheus text-format. Empty input → empty string.
*/
export function transformToPrometheus(records, opts = {}) {
if (!Array.isArray(records) || records.length === 0) return '';
const lines = [];
const helpMap = opts.help || {};
const groups = groupBySchema(records);
// Track which metric names we've emitted HELP/TYPE for (per Prometheus spec:
// emit HELP/TYPE once per metric, then all samples for that metric).
const emittedMeta = new Set();
// First pass: collect all unique (schema, metric_field) → sample lines
// grouped by metric so HELP/TYPE come before all samples for that metric.
const samplesByMetric = new Map();
for (const [schemaId, group] of groups.entries()) {
for (const record of group) {
const { labels, metrics } = partitionRecord(record);
const labelStr = Object.entries(labels)
.map(([k, v]) => `${k}="${escapeLabel(v)}"`)
.join(',');
const labelBlock = labelStr ? `{${labelStr}}` : '';
for (const [metricField, value] of Object.entries(metrics)) {
const metricName = normalizeMetricName(`${schemaId}_${metricField}`);
if (!samplesByMetric.has(metricName)) {
samplesByMetric.set(metricName, []);
}
samplesByMetric.get(metricName).push(`${metricName}${labelBlock} ${value}`);
}
}
}
// Sort metric names for deterministic output (snapshot-test-friendly)
const sortedMetrics = [...samplesByMetric.keys()].sort();
for (const metricName of sortedMetrics) {
const help = helpMap[metricName] || `voyage stats — ${metricName.slice(METRIC_PREFIX.length)}`;
const type = inferMetricType(metricName);
if (!emittedMeta.has(metricName)) {
lines.push(`# HELP ${metricName} ${help}`);
lines.push(`# TYPE ${metricName} ${type}`);
emittedMeta.add(metricName);
}
// Sort samples for determinism
const samples = samplesByMetric.get(metricName).sort();
for (const s of samples) lines.push(s);
}
return lines.join('\n') + (lines.length > 0 ? '\n' : '');
}
/**
* Heuristic: counter for *_total / *_count / *_passed / *_failed; histogram for
* *_ms / *_duration / *_p50 / *_p99; gauge for everything else (per Prometheus
* conventions). Pure & deterministic.
*/
function inferMetricType(metricName) {
if (/_total$|_count$|_passed$|_failed$|_skipped$/.test(metricName)) return 'counter';
if (/_ms$|_duration|_p\d+$|_seconds$/.test(metricName)) return 'histogram';
return 'gauge';
}
export { normalizeMetricName, partitionRecord, inferMetricType };

View file

@ -1,89 +0,0 @@
// lib/review/gold-scorer.mjs
// SKAL-1·4b — offline gold-scored output eval scorer.
//
// Scores a recorded agent-run's findings against a golden corpus record
// (voyage-eval-gold/1, see docs/eval-corpus/README.md) at (file, rule_key)
// granularity. line and severity are deliberately NOT part of the match key:
// the eval asks "did the run surface this issue at all", and (file, rule_key)
// is the identity pair a delivered diff + the catalogue uniquely determine.
//
// Pairs with coordinator-contract.mjs (4a): that module turns committed
// reviewer payloads into the run findings scored here. This module is the
// offline counterpart to the live LLM-in-the-loop eval (4c) — pure: no I/O,
// no LLM, no network, no time, no randomness.
//
// Vacuous-set conventions (documented so the degenerate numbers are honest):
// - precision when nothing is predicted (tp+fp === 0) -> 1 (no false positives)
// - recall when there is nothing to find (tp+fn === 0) -> 1 (found all zero)
// - f1 collapses to 0 whenever precision or recall is 0, so it stays the
// honest single-number summary for the degenerate run/gold cases.
// NUL separator: file paths and rule_keys never contain it, so the join is
// an injective (file, rule_key) -> string key.
const SEP = '\x00';
function pairKey(f) {
return `${f.file}${SEP}${f.rule_key}`;
}
// Human-readable form of a pair key (for matched/missed/spurious reporting).
function pairLabel(key) {
return key.replace(SEP, ' ');
}
// Set of unique (file, rule_key) pair keys from a findings list. Findings
// missing either field are skipped (they cannot identify a pair).
function pairSet(findings) {
const s = new Set();
for (const f of findings ?? []) {
if (f && typeof f.file === 'string' && typeof f.rule_key === 'string') {
s.add(pairKey(f));
}
}
return s;
}
/**
* Score run findings against gold findings at (file, rule_key) granularity.
* @param {object[]|null|undefined} runFindings findings produced by the recorded run
* @param {object[]|null|undefined} goldFindings the golden corpus findings
* @returns {{
* tp: number, fp: number, fn: number,
* precision: number, recall: number, f1: number,
* matched: string[], missed: string[], spurious: string[]
* }} matched/missed/spurious are "<file> <rule_key>" labels.
*/
export function scoreFindings(runFindings, goldFindings) {
const runPairs = pairSet(runFindings);
const goldPairs = pairSet(goldFindings);
const matched = [];
const spurious = [];
for (const p of runPairs) {
(goldPairs.has(p) ? matched : spurious).push(pairLabel(p));
}
const missed = [];
for (const p of goldPairs) {
if (!runPairs.has(p)) missed.push(pairLabel(p));
}
const tp = matched.length;
const fp = spurious.length;
const fn = missed.length;
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
return { tp, fp, fn, precision, recall, f1, matched, missed, spurious };
}
/**
* Exact match of a run's coordinator verdict against the gold expected_verdict.
* @param {string} runVerdict
* @param {string} goldVerdict
* @returns {boolean}
*/
export function scoreVerdict(runVerdict, goldVerdict) {
return runVerdict === goldVerdict;
}

View file

@ -1,142 +0,0 @@
// lib/stats/cache-analyzer.mjs
// Summarizes trekexecute-stats.jsonl: total events, percentile wall times,
// time range. Companion to event-emit.mjs (which produces the jsonl).
//
// Designed for /trekplan Spor C: gives C3 telemetry context when
// interpreting Q3 experiment numbers (5+ weeks of accumulated data on the
// operator's machine as of 2026-05-04).
//
// Zero npm dependencies. Node stdlib only.
import { readFileSync, existsSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs';
function usage() {
return `cache-analyzer.mjs — summarize trekexecute-stats.jsonl
USAGE:
node lib/stats/cache-analyzer.mjs --json <path-to-jsonl>
OUTPUT (stdout, JSON):
{
"total_events": <n>,
"events_with_duration": <n>,
"wall_time_ms_p50": <ms or null>,
"wall_time_ms_p90": <ms or null>,
"wall_time_ms_max": <ms or null>,
"unique_event_names": [...],
"oldest_event_iso": "<iso8601 or null>",
"newest_event_iso": "<iso8601 or null>"
}
EXIT:
0 success, 1 file not found / read error, 2 usage error.
`;
}
export function summarize(lines) {
const summary = {
total_events: 0,
events_with_duration: 0,
wall_time_ms_p50: null,
wall_time_ms_p90: null,
wall_time_ms_max: null,
unique_event_names: [],
oldest_event_iso: null,
newest_event_iso: null,
// SKAL-2 token/cost aggregation (additive; zero when no token records).
// With upsert semantics (one record per session_id), summing across lines
// = correct cross-session aggregate. cost_usd is summed only when finite;
// a null (refuse-to-estimate) record still counts in sessions_with_tokens.
total_tokens_input: 0,
total_tokens_output: 0,
total_tokens_cache_creation: 0,
total_tokens_cache_read: 0,
total_cost_usd: 0,
sessions_with_tokens: 0,
};
const durations = [];
const names = new Set();
let oldestMs = null;
let newestMs = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '') continue;
let obj;
try { obj = JSON.parse(trimmed); }
catch { continue; }
summary.total_events++;
if (obj.event && typeof obj.event === 'string') names.add(obj.event);
else if (obj.name && typeof obj.name === 'string') names.add(obj.name);
if (typeof obj.duration_ms === 'number' && Number.isFinite(obj.duration_ms)) {
durations.push(obj.duration_ms);
summary.events_with_duration++;
}
const tsField = obj.timestamp || obj.ts || obj.iso || obj.time;
if (typeof tsField === 'string') {
const t = Date.parse(tsField);
if (!Number.isNaN(t)) {
if (oldestMs === null || t < oldestMs) oldestMs = t;
if (newestMs === null || t > newestMs) newestMs = t;
}
}
// SKAL-2: aggregate token/cost from token-bearing records (token-usage
// schema). Detect by presence of any numeric token field.
const tokenKeys = ['tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read'];
const hasTokens = tokenKeys.some(k => typeof obj[k] === 'number' && Number.isFinite(obj[k]));
if (hasTokens) {
summary.sessions_with_tokens++;
if (Number.isFinite(obj.tokens_input)) summary.total_tokens_input += obj.tokens_input;
if (Number.isFinite(obj.tokens_output)) summary.total_tokens_output += obj.tokens_output;
if (Number.isFinite(obj.tokens_cache_creation)) summary.total_tokens_cache_creation += obj.tokens_cache_creation;
if (Number.isFinite(obj.tokens_cache_read)) summary.total_tokens_cache_read += obj.tokens_cache_read;
// cost_usd may be null (refuse-to-estimate) — sum finite values only.
if (Number.isFinite(obj.cost_usd)) summary.total_cost_usd += obj.cost_usd;
}
}
if (durations.length > 0) {
durations.sort((a, b) => a - b);
const p50Idx = Math.floor(durations.length * 0.5);
const p90Idx = Math.floor(durations.length * 0.9);
summary.wall_time_ms_p50 = durations[Math.min(p50Idx, durations.length - 1)];
summary.wall_time_ms_p90 = durations[Math.min(p90Idx, durations.length - 1)];
summary.wall_time_ms_max = durations[durations.length - 1];
}
summary.unique_event_names = [...names].sort();
if (oldestMs !== null) summary.oldest_event_iso = new Date(oldestMs).toISOString();
if (newestMs !== null) summary.newest_event_iso = new Date(newestMs).toISOString();
return summary;
}
export function summarizeFile(path) {
if (!existsSync(path)) {
return { error: `file not found: ${path}` };
}
let text;
try { text = readFileSync(path, 'utf-8'); }
catch (e) { return { error: `read error: ${e.message}` }; }
return summarize(text.split('\n'));
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const jsonIdx = args.indexOf('--json');
if (jsonIdx === -1 || !args[jsonIdx + 1]) {
process.stderr.write(usage());
process.exit(2);
}
const path = args[jsonIdx + 1];
const result = summarizeFile(path);
if (result.error) {
process.stderr.write(`cache-analyzer: ${result.error}\n`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(0);
}

View file

@ -1,126 +0,0 @@
// lib/stats/claimed-order.mjs
// Which coord-queue order is this Voyage run working on? Read from the queue
// itself, never typed by the model and never guessed.
//
// Why this exists: the yardstick's `use` number joins a new-work order to a
// Voyage run. A key declared on the order before any run (a `slug:`) is not
// proof a run happened, and no order carried one. The run itself has to say
// which order it ran, so /trekbrief's stats record carries the id this module
// finds (lib/stats/stats-append.mjs adds it; the model never copies it).
//
// The contract it mirrors is repo-mailbox's claim verb (coord-order-claim.sh,
// 0.37.0), read here, never called:
// - coord root ${CLAUDE_COORD_DIR:-$HOME/.claude/coord}
// - mailbox basename of `git rev-parse --show-toplevel`; `_*` is reserved
// - claim `mv orders/<id>.md orders/claimed/<id>.md`, so the id is the
// file stem, plus a best-effort sidecar `claimed/<id>.claim`
// holding `claimed-at: <ISO>`
// - close coord-order-done.sh deletes the `.claim`, so the claim time
// survives only in the record this module feeds.
// A worktree's toplevel basename is the worktree's name, so the main repo's
// name (the parent of the common git dir) is searched too.
//
// Exactly one claimed order -> { order_id, order_claimed_at, order_reason: null }.
// Anything else -> order_id null (or the id with `no-claim-time`) and a reason:
// mailbox-unresolved · coord-missing · coord-unreadable · none-claimed ·
// several-claimed:N · to-mismatch · no-claim-time. It only reads, never throws,
// and starts nothing. Zero npm dependencies.
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
const GIT_TIMEOUT_MS = 5000;
const unattributed = (order_reason) => ({ order_id: null, order_claimed_at: null, order_reason });
/** The coord root the claim script uses: CLAUDE_COORD_DIR, else $HOME/.claude/coord. */
export function resolveCoordDir(env = process.env) {
if (env.CLAUDE_COORD_DIR) return env.CLAUDE_COORD_DIR;
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
return join(home, '.claude', 'coord');
}
function gitLine(dir, args, env) {
try {
return execFileSync('git', ['-C', dir, 'rev-parse', ...args], {
encoding: 'utf8', timeout: GIT_TIMEOUT_MS, env, stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return '';
}
}
/**
* The mailbox names `dir` can be filed under: the toplevel's basename (the
* claim script's rule) and, in a linked worktree, the main repo's name. Two
* independent git calls, so one failing never loses the other. An inherited
* GIT_DIR / GIT_WORK_TREE would point git at another repo, so both are dropped.
*/
export function resolveMailboxes(dir, { env = {} } = {}) {
const childEnv = { ...process.env, ...env };
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete childEnv[key];
const names = [];
const top = gitLine(dir, ['--show-toplevel'], childEnv);
if (top) names.push(basename(top));
const common = gitLine(dir, ['--path-format=absolute', '--git-common-dir'], childEnv);
// Only `<repo>/.git` names a repo; a submodule's `.git/modules/<x>` does not.
if (common && basename(common) === '.git') names.push(basename(dirname(common)));
return [...new Set(names)];
}
const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
/** The one claimed order across `mailboxes`, or null and the reason it is not one. */
export function findClaimedOrder({ coordDir, mailboxes }) {
try {
if (!isDir(coordDir)) return unattributed('coord-missing');
const names = (mailboxes ?? []).filter((m) => typeof m === 'string' && m !== '' && !m.startsWith('_'));
if (names.length === 0) return unattributed('mailbox-unresolved');
const found = [];
for (const mailbox of names) {
const dir = join(coordDir, mailbox, 'orders', 'claimed');
if (!isDir(dir)) continue;
for (const name of readdirSync(dir).sort()) {
if (name.endsWith('.md')) found.push({ mailbox, dir, id: name.slice(0, -3) });
}
}
if (found.length === 0) return unattributed('none-claimed');
if (found.length > 1) return unattributed(`several-claimed:${found.length}`);
const [{ mailbox, dir, id }] = found;
const text = readFileSync(join(dir, `${id}.md`), 'utf8');
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const to = fm.match(/^to:[ \t]*(.*?)[ \t]*$/m)?.[1];
if (to !== undefined && to !== mailbox) return unattributed('to-mismatch');
const claimPath = join(dir, `${id}.claim`);
const at = existsSync(claimPath)
? readFileSync(claimPath, 'utf8').match(/^claimed-at:[ \t]*(\S+)[ \t]*$/m)?.[1]
: undefined;
if (!at) return { order_id: id, order_claimed_at: null, order_reason: 'no-claim-time' };
return { order_id: id, order_claimed_at: at, order_reason: null };
} catch {
return unattributed('coord-unreadable');
}
}
// The directory to resolve the mailbox from: `projectDir` when it is a directory
// inside `cwd` (a brief's project dir), else `cwd`. A path that walks out of cwd
// cannot move the lookup to another repo.
function baseDir(cwd, projectDir) {
if (typeof projectDir !== 'string' || projectDir === '') return cwd;
try {
const abs = resolve(cwd, projectDir);
if (!isDir(abs)) return cwd;
const rel = relative(realpathSync(cwd), realpathSync(abs));
return rel.startsWith('..') || isAbsolute(rel) ? cwd : abs;
} catch {
return cwd;
}
}
/** The attribution a /trekbrief record carries: always the three keys. */
export function attributeOrder({ cwd = process.cwd(), env = process.env, projectDir } = {}) {
const mailboxes = resolveMailboxes(baseDir(cwd, projectDir), { env });
if (mailboxes.length === 0) return unattributed('mailbox-unresolved');
return findClaimedOrder({ coordDir: resolveCoordDir(env), mailboxes });
}

View file

@ -1,129 +0,0 @@
// 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);
}

View file

@ -1,83 +0,0 @@
// lib/stats/stats-append.mjs
// Append one command-stats record to <data dir>/<kind>-stats.jsonl.
//
// Why this exists: the commands' stats phases said "append to
// ${CLAUDE_PLUGIN_DATA}/<kind>-stats.jsonl; if it is not set, skip silently".
// The Bash tool's env carries no CLAUDE_PLUGIN_DATA, so the record was skipped
// whenever the model did not guess the directory itself — the same silent skip
// that left brief-approved at 0 records. This writer resolves the directory
// the way scripts/yardstick.mjs reads it, and a failed write is REPORTED
// (exit 1 + reason), never swallowed. Stats still never block the workflow:
// the command prose reports the failure and carries on.
//
// A `trekbrief` record also carries the coord-queue order the run is working
// on: order_id, order_claimed_at, order_reason, found by
// lib/stats/claimed-order.mjs in the queue itself. The WRITER adds them - any
// order_* key the record arrived with is dropped first - so the model never
// types an order id, and the keys are always present (null + reason when no
// single order can be named; absent keys mean the record predates this).
//
// CLI (the record is read from stdin, so no shell quoting of JSON):
// node lib/stats/stats-append.mjs <kind> <<'JSON'
// { ...record... }
// JSON
// → prints {"written":true,"path":...} exit 0 | {"written":false,"reason":...} exit 1
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { attributeOrder } from './claimed-order.mjs';
import { isMain } from '../util/is-main.mjs';
export const STATS_KINDS = Object.freeze([
'trekbrief', 'trekresearch', 'trekplan', 'trekexecute', 'trekreview', 'trekcontinue',
]);
/**
* The plugin data directory: CLAUDE_PLUGIN_DATA when the harness provides it,
* otherwise the directory the hooks write to and scripts/yardstick.mjs reads.
*/
export function resolveStatsDir(env = process.env) {
if (env.CLAUDE_PLUGIN_DATA) return env.CLAUDE_PLUGIN_DATA;
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
return join(home, '.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
}
/**
* @returns {{ written: boolean, path: string|null, reason?: string }}
*/
export function appendStatsRecord(kind, record, env = process.env, { cwd = process.cwd() } = {}) {
if (!STATS_KINDS.includes(kind)) {
return { written: false, path: null, reason: `unknown kind "${kind}" (one of ${STATS_KINDS.join(', ')})` };
}
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return { written: false, path: null, reason: 'record must be a JSON object' };
}
if (kind === 'trekbrief') {
const own = Object.fromEntries(Object.entries(record).filter(([k]) => !k.startsWith('order_')));
record = { ...own, ...attributeOrder({ cwd, env, projectDir: record.project_dir }) };
}
const dir = resolveStatsDir(env);
const path = join(dir, `${kind}-stats.jsonl`);
try {
mkdirSync(dir, { recursive: true });
appendFileSync(path, JSON.stringify(record) + '\n');
} catch (e) {
return { written: false, path, reason: `${e.code || 'write-failed'}: ${e.message}` };
}
return { written: true, path };
}
if (isMain(import.meta.url)) {
const kind = process.argv[2];
let record = null;
let reason = null;
try {
record = JSON.parse(readFileSync(0, 'utf8'));
} catch (e) {
reason = `stdin is not JSON: ${e.message}`;
}
const r = reason ? { written: false, path: null, reason } : appendStatsRecord(kind, record);
process.stdout.write(JSON.stringify(r) + '\n');
process.exit(r.written ? 0 : 1);
}

View file

@ -1,234 +0,0 @@
// lib/stats/token-usage.mjs
// SKAL-2 — pure token-usage parser + cache-aware cost derivation for Voyage
// observability. Captures MAIN-CONTEXT token/cost from the Claude Code
// transcript (transcript_path). Each assistant record carries message.usage
// with a per-REQUEST snapshot.
//
// Verified 2026-06-26 against a real local transcript (216 lines): 70
// assistant records collapse to 31 distinct requestIds — records duplicate
// per requestId (streamed snapshots), so we dedup by requestId keeping the
// LAST occurrence (GH #28197 streaming-placeholder mitigation), then sum
// across requests. The main transcript carried zero isSidechain:true records.
//
// v1 scope = MAIN-CONTEXT only. Sub-agent (swarm) turns live in separate
// agent-*.jsonl siblings and are a documented v2 follow-on — so the
// main-transcript sum UNDER-counts total Voyage cost. Every record is stamped
// scope:'main-context' so no reader mistakes cost_usd for the session total.
//
// The four core functions (parseTranscriptUsage, deriveCost, buildRecord,
// upsertSessionRecord) are PURE (no I/O). captureTokenUsage is the impure
// shell that wires them to the filesystem (read transcript → upsert jsonl).
//
// Zero npm dependencies. Node stdlib only.
import { readFileSync, existsSync, writeFileSync, renameSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
// Per-Mtok USD prices, resolved 2026-06-26 via the claude-api skill reference:
// base input/output from the model table; cache rates from the prompt-caching
// doc multipliers (cache_read 0.1x, write_5m 1.25x, write_1h 2.0x of input).
// claude-fable-5 resolved 2026-07-02 from the official platform pricing docs.
export const PRICE_TABLE = Object.freeze({
'claude-opus-4-8': Object.freeze({
input: 5.0,
output: 25.0,
cache_read: 0.5,
cache_write_5m: 6.25,
cache_write_1h: 10.0,
}),
'claude-fable-5': Object.freeze({
input: 10.0,
output: 50.0,
cache_read: 1.0,
cache_write_5m: 12.5,
cache_write_1h: 20.0,
}),
});
// Date the PRICE_TABLE values were resolved/verified. Bump when prices change.
export const PRICE_TABLE_VERSION = '2026-07-02';
function num(v) {
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
}
/**
* Single pass over transcript JSONL text. Keeps only main-chain assistant
* records (type==='assistant', isSidechain!==true), dedups by requestId
* keeping the LAST occurrence (per-request usage snapshots duplicate across
* streamed records), and sums the four usage token fields. Malformed lines
* are skipped.
*
* @returns {{tokens_input:number, tokens_output:number,
* tokens_cache_creation:number, tokens_cache_read:number}}
*/
export function parseTranscriptUsage(text) {
const lines = (text || '').split('\n');
// requestId -> usage (last occurrence wins). Records without a requestId
// get a unique sentinel key so each is still counted exactly once.
const byRequest = new Map();
let anon = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '') continue;
let obj;
try { obj = JSON.parse(trimmed); }
catch { continue; }
if (!obj || obj.type !== 'assistant') continue;
if (obj.isSidechain === true) continue;
const usage = obj.message && obj.message.usage;
if (!usage || typeof usage !== 'object') continue;
const key = typeof obj.requestId === 'string' && obj.requestId
? obj.requestId
: `__anon_${anon++}`;
byRequest.set(key, usage); // last occurrence wins
}
const totals = {
tokens_input: 0,
tokens_output: 0,
tokens_cache_creation: 0,
tokens_cache_read: 0,
};
for (const usage of byRequest.values()) {
totals.tokens_input += num(usage.input_tokens);
totals.tokens_output += num(usage.output_tokens);
totals.tokens_cache_creation += num(usage.cache_creation_input_tokens);
totals.tokens_cache_read += num(usage.cache_read_input_tokens);
}
return totals;
}
/**
* Cache-aware USD cost. Lumped cache_creation is priced at the 5m write rate
* (Claude Code's default cache TTL; verified the dominant case on a real
* transcript — the 1h split is a documented v2 refinement). When the model is
* absent from the table we REFUSE to estimate: {cost_usd:null, is_estimate:true}.
*
* @returns {{cost_usd:number|null, is_estimate:boolean}}
*/
export function deriveCost(totals, model, priceTable = PRICE_TABLE) {
const price = priceTable && priceTable[model];
if (!price) return { cost_usd: null, is_estimate: true };
const t = totals || {};
const cost =
(num(t.tokens_input) * price.input +
num(t.tokens_output) * price.output +
num(t.tokens_cache_creation) * price.cache_write_5m +
num(t.tokens_cache_read) * price.cache_read) / 1_000_000;
return { cost_usd: cost, is_estimate: false };
}
/**
* Build the flat numeric record. Stamps scope:'main-context' and
* price_table_version. session_id is written for upsert keying but MUST be
* stripped at export (CWE-212, enforced by the field allowlist in Step 2).
*
* @param {{sessionId:string, model:string, totals:object,
* priceTable?:object, now:string}} args
*/
export function buildRecord({ sessionId, model, totals, priceTable = PRICE_TABLE, now }) {
const t = totals || {};
const { cost_usd, is_estimate } = deriveCost(t, model, priceTable);
return {
ts: now,
session_id: sessionId,
scope: 'main-context',
model,
tokens_input: num(t.tokens_input),
tokens_output: num(t.tokens_output),
tokens_cache_creation: num(t.tokens_cache_creation),
tokens_cache_read: num(t.tokens_cache_read),
cost_usd,
is_estimate,
price_table_version: PRICE_TABLE_VERSION,
};
}
/**
* Pure read-modify-write. Parses existing JSONL lines, REPLACES the line whose
* session_id matches the record (dropping any further same-session dupes),
* else APPENDS. Returns the new file text (one line per session, trailing
* newline). One-line-per-session is what keeps cross-session sums from
* double-counting (vs append-and-grow).
*/
export function upsertSessionRecord(existingText, record) {
const lines = (existingText || '').split('\n');
const out = [];
let replaced = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '') continue;
let obj;
try { obj = JSON.parse(trimmed); }
catch { out.push(trimmed); continue; } // preserve unparseable lines verbatim
if (obj && obj.session_id === record.session_id) {
if (!replaced) { out.push(JSON.stringify(record)); replaced = true; }
// else: duplicate same-session line — drop it
} else {
out.push(trimmed);
}
}
if (!replaced) out.push(JSON.stringify(record));
return out.join('\n') + '\n';
}
/**
* Last main-chain (non-sidechain) assistant model in the transcript. Used to
* pick the price-table key. Returns null when no model is found (→ deriveCost
* refuses to estimate). Pure.
*/
export function lastMainChainModel(text) {
const lines = (text || '').split('\n');
let model = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '') continue;
let obj;
try { obj = JSON.parse(trimmed); }
catch { continue; }
if (!obj || obj.type !== 'assistant' || obj.isSidechain === true) continue;
const m = obj.message && obj.message.model;
if (typeof m === 'string' && m) model = m;
}
return model;
}
/**
* Impure capture shell (Step 4). Reads the transcript, derives main-context
* token totals + cost, and UPSERTS a one-line-per-session record into
* {dataDir}/token-usage-stats.jsonl via an atomic temp+rename write
* (EXDEV mitigation: tmp lives in the same dir as the target).
*
* Returns the written record, or null when skipped (no path / no dataDir /
* transcript not a readable regular file). Throws propagate to the caller —
* the Stop hook wraps this in try/catch so capture stays fail-open.
*
* @param {{transcriptPath:string, sessionId:string, dataDir:string,
* now?:string}} args
*/
export function captureTokenUsage({ transcriptPath, sessionId, dataDir, now }) {
if (!transcriptPath || !dataDir) return null;
let st;
try { st = statSync(transcriptPath); }
catch { return null; }
if (!st.isFile()) return null;
const text = readFileSync(transcriptPath, 'utf-8');
const totals = parseTranscriptUsage(text);
const model = lastMainChainModel(text);
const record = buildRecord({
sessionId,
model,
totals,
now: now || new Date().toISOString(),
});
const outPath = join(dataDir, 'token-usage-stats.jsonl');
const existing = existsSync(outPath) ? readFileSync(outPath, 'utf-8') : '';
const updated = upsertSessionRecord(existing, record);
const tmpPath = join(dirname(outPath), '.token-usage-stats.jsonl.tmp');
writeFileSync(tmpPath, updated);
renameSync(tmpPath, outPath);
return record;
}

View file

@ -27,7 +27,7 @@
// cannot be WRITTEN denies the turn, and a ledger that exists but cannot be
// READ denies it too. Only ENOENT counts as zero turns spent, because that is
// the legitimate first-turn state. This module is a budget control, not
// telemetry — the opposite of lib/stats/event-emit.mjs's fail-open.
// telemetry.
//
// CLI shim, two modes:
// node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E

View file

@ -34,7 +34,7 @@
// node lib/validators/intent-approval.mjs --check [--json] <brief.md>
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
// node lib/validators/intent-approval.mjs --stamp <brief.md>
// writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused
// writes the marker; exit 0 stamped, 1 refused
// node lib/validators/intent-approval.mjs --resolve <project-dir | brief-file>
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
@ -43,15 +43,12 @@ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'n
import { basename, dirname, join } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue } from '../util/result.mjs';
import { emit } from '../stats/event-emit.mjs';
import { resolveStatsDir } from '../stats/stats-append.mjs';
import { isMain } from '../util/is-main.mjs';
export const INTENT_HASH_FIELD = 'intent_approved_hash';
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
const STATS_FILENAME = 'trekexecute-stats.jsonl'; // where event-emit writes lifecycle events
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal and answers "Approve".';
@ -200,18 +197,6 @@ export function stampIntentApproval(text, now = new Date()) {
return { stamped: true, text: out, hash, approvedAt };
}
/**
* 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.
* 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);
}
/**
* What `/trekbrief --approve <arg>` approves. A directory means `<dir>/brief.md`
* (a /trekplan --project brief); a file is itself (a /trekplan --brief brief,
@ -278,12 +263,8 @@ if (isMain(import.meta.url)) {
const tmp = join(dirname(file), `.${basename(file)}.approve.tmp`);
writeFileSync(tmp, s.text);
renameSync(tmp, file);
const fm = parseDocument(s.text).parsed.frontmatter || {};
const record = emit('brief-approved',
{ project: dirname(file), slug: fm.slug ?? null, intent_hash: s.hash },
{ path: join(resolveApprovalDataDir(), STATS_FILENAME) });
process.stdout.write(JSON.stringify({
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt, record,
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt,
}) + '\n');
process.exit(0);
}

View file

@ -4,14 +4,13 @@
// bounded Phase 5 loop turns) — the existing single-pass Phase 5 path is
// unchanged (Step 6, plan-v2).
//
// Two-tier, same shape as lib/exporters/endpoint-validator.mjs's SSRF gate:
// Two-tier:
// - WARN tier — absolute filesystem paths, repo-internal identifiers.
// Operator-overridable via `strict: false` / `--soft` (matches
// lib/validators/research-validator.mjs's strict/soft convention), and
// fully bypassable via the VOYAGE_QUERY_PRIVACY_ALLOW=1 opt-in.
// - HARD-BLOCK tier — secret-shaped tokens. NEVER overridable by strict,
// --soft, or the opt-in env var — mirrors endpoint-validator.mjs's
// HARD_BLOCKED_HOSTS, where an opt-in widens the warn tier but never
// --soft, or the opt-in env var: an opt-in widens the warn tier but never
// unlocks the permanently-blocked one.
//
// CLI shim: