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:
parent
92010d20de
commit
21480e8627
89 changed files with 94 additions and 10290 deletions
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue