// lib/util/research-loop-cap.mjs // Stateful, default-off cost cap for the /trekresearch bounded conversation // loop (Phase 4.5 dimension discovery + Phase 5 loop turns). // // Three properties the plan review required: // (a) Default-off — VOYAGE_STORM_ENABLED must be '1'; otherwise the budget // is 0 regardless of effort. This IS the decline branch: doing nothing // leaves the mechanism off, and adopt is flipping this one constant. // (b) The cap counts itself — allowTurn() derives used-turn count from an // append-only JSONL ledger, never from a caller-supplied number. A cap // that asks the caller how many turns it has used is not a cap. // (c) Correct size bound — worst case is max_conv_turns × max_total_dimensions, // where max_total_dimensions is the WHOLE list (interview + discovered) // under settings.json:16's cap of 8 — not × discovered-only. // // CLAUDE_PLUGIN_DATA absent => fall back to ~/.claude/voyage. The variable is // EMPTY in the Bash tool's process env (measured in a live plugin-enabled // session), and the Phase 5 bash snippet is this module's only caller — so // denying on its absence denied turn 1 of every real run. The root is resolved // in code rather than demanded of the environment, and hooks/scripts/ // pre-agent-cap.mjs resolves it through the SAME function, so the writer and // the reader can never disagree about where the ledger lives. // // The fail-closed stance stays where it is still real: a ledger that cannot be // written denies the turn. This module is a budget control, not telemetry — // the opposite of lib/stats/event-emit.mjs's fail-open. // // CLI shim: // node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E // → JSON: { ok, used, budget, reason? } (exit 0 = granted, exit 1 = denied) import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; export const MAX_CONV_TURNS = 3; export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only const LEDGER_FILENAME = 'trekresearch-loop-ledger.jsonl'; export function isStormEnabled(env = process.env) { return env.VOYAGE_STORM_ENABLED === '1'; } /** * Coerce TREKRESEARCH_MAX_CONV_TURNS. NaN, empty, negative, or zero all fall * back to MAX_CONV_TURNS — never to unbounded. */ export function resolveMaxConvTurns(env = process.env) { const raw = env.TREKRESEARCH_MAX_CONV_TURNS; if (raw === undefined || raw === null || raw === '') return MAX_CONV_TURNS; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return MAX_CONV_TURNS; return Math.floor(n); } /** * The one data root for everything this loop writes: the turn ledger and the * PreToolUse scope marker. CLAUDE_PLUGIN_DATA when the harness provides it, * ~/.claude/voyage when it does not — which is the case in every Bash tool * invocation today. */ export function resolveDataRoot(env = process.env) { const dir = env.CLAUDE_PLUGIN_DATA; if (dir && typeof dir === 'string' && dir.length > 0) return dir; const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir(); return join(home, '.claude', 'voyage'); } export function resolveLedgerPath(env = process.env) { return join(resolveDataRoot(env), LEDGER_FILENAME); } function countTurns(ledgerPath, runId) { if (!existsSync(ledgerPath)) return 0; let text; try { text = readFileSync(ledgerPath, 'utf-8'); } catch { return 0; } let count = 0; for (const line of text.split('\n')) { if (!line) continue; try { const rec = JSON.parse(line); if (rec.runId === runId) count++; } catch { /* skip malformed lines */ } } return count; } /** * Decide whether one more research-loop turn may run. Append-only: never * read-modify-write, because Phase 4.5/5 may spawn multiple agents in a * single message and a read-modify-write counter would lose concurrent * grants. * * @param {{runId: string, dimension: string, effort: string}} args * @param {{env?: object, now?: Date}} [opts] * @returns {{ok: boolean, used: number, budget: number, reason?: string}} */ export function allowTurn({ runId, dimension, effort } = {}, opts = {}) { const env = opts.env || process.env; const now = opts.now || new Date(); if (!isStormEnabled(env)) { return { ok: false, used: 0, budget: 0, reason: 'storm_disabled' }; } if (effort !== 'high') { return { ok: false, used: 0, budget: 0, reason: 'effort_not_high' }; } if (!runId || !dimension) { return { ok: false, used: 0, budget: 0, reason: 'missing_args' }; } const maxConvTurns = resolveMaxConvTurns(env); const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS; const ledgerPath = resolveLedgerPath(env); const used = countTurns(ledgerPath, runId); if (used >= budget) { return { ok: false, used, budget, reason: 'budget_exhausted' }; } try { const dir = dirname(ledgerPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, dimension, effort }) + '\n'); } catch (e) { return { ok: false, used, budget, reason: `ledger-write-failed: ${e.message}` }; } return { ok: true, used: used + 1, budget }; } // ---- CLI shim ---------------------------------------------------------------- function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--run-id') out.runId = argv[++i]; else if (a === '--dimension') out.dimension = argv[++i]; else if (a === '--effort') out.effort = argv[++i]; } return out; } if (import.meta.url === `file://${process.argv[1]}`) { const args = parseArgs(process.argv.slice(2)); if (!args.runId || !args.dimension || !args.effort) { process.stdout.write(JSON.stringify({ ok: false, reason: 'usage: research-loop-cap.mjs --run-id ID --dimension D --effort standard|high|low', }) + '\n'); process.exit(1); } const result = allowTurn(args); process.stdout.write(JSON.stringify(result) + '\n'); process.exit(result.ok ? 0 : 1); }