// 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 => DENY (fail-closed). This is the opposite of // lib/stats/event-emit.mjs's fail-open: that module is telemetry (must never // block workflow); this module is a budget control (must never silently // grant unlimited turns just because the data dir is missing). // // 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'; 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); } export function resolveLedgerPath(env = process.env) { const dir = env.CLAUDE_PLUGIN_DATA; if (!dir || typeof dir !== 'string' || dir.length === 0) return null; return join(dir, 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); if (!ledgerPath) { return { ok: false, used: 0, budget, reason: 'no_plugin_data_dir' }; } 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); }