// lib/stats/stats-append.mjs // Append one command-stats record to /-stats.jsonl. // // Why this exists: the commands' stats phases said "append to // ${CLAUDE_PLUGIN_DATA}/-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 <<'JSON' // { ...record... } // JSON // → prints {"written":true,"path":...} exit 0 | {"written":false,"reason":...} exit 1 import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { attributeOrder } from './claimed-order.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 }; } // Run as a CLI? Compared as real paths: `file://${argv[1]}` misses a path with a // space (URL-encoded) and a symlink (import.meta.url is the realpath). function isMain() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } if (isMain()) { 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); }