// 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 covers both directions of ledger IO: a ledger that // 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. // // 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, zero, Infinity, or * any fraction that floors below 1 all fall back to MAX_CONV_TURNS — never to * unbounded, and never to 0. * * The floor is applied BEFORE the `<= 0` guard, not after. Flooring afterwards * let '0.5' and '0.9' clear a guard written against the raw value and then * become 0, making the budget 0 × MAX_TOTAL_DIMENSIONS = 0: every turn denied * and the loop silently dead rather than bounded. A cap of 0 is not a narrower * cap, it is an off switch that the documented fallback promises not to be. */ 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 = Math.floor(Number(raw)); if (!Number.isFinite(n) || n <= 0) return MAX_CONV_TURNS; return 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); } /** * Read one run's turn count off the append-only ledger. * * ENOENT is 0 turns spent — the legitimate first-turn state, and the reason * this cannot simply throw on every read failure. Every OTHER read error * (EISDIR, EACCES, EIO) THROWS, because returning 0 from an unreadable ledger * re-granted the full budget on every call: unbounded, and the exact * silently-grant-unlimited failure this module's header argues against three * lines above the code that did it. The missing-directory case already failed * closed; this makes the unreadable-file case agree with it. * * The `existsSync` pre-check is deliberately gone: readFileSync's own ENOENT * carries the same information without a second syscall that can disagree with * the read that follows it. * * Exported so hooks/scripts/pre-agent-cap.mjs counts through this exact * function. A reader and a writer with private copies of the counting rule are * how a hook ends up enforcing a different bound than the gate it backs. * * @param {string} ledgerPath * @param {string} runId * @returns {{granted: number}} * @throws when the ledger exists but cannot be read */ export function readLedger(ledgerPath, runId) { let text; try { text = readFileSync(ledgerPath, 'utf-8'); } catch (e) { if (e && e.code === 'ENOENT') return { granted: 0 }; const err = new Error(`ledger unreadable at ${ledgerPath}: ${e.message}`); err.code = 'VOYAGE_LEDGER_UNREADABLE'; throw err; } let granted = 0; for (const line of text.split('\n')) { if (!line) continue; try { if (JSON.parse(line).runId === runId) granted++; } catch { /* skip malformed lines */ } } return { granted }; } /** * 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); let used; try { used = readLedger(ledgerPath, runId).granted; } catch (e) { return { ok: false, used: 0, budget, reason: `ledger-read-failed: ${e.message}` }; } 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); }