// tests/lib/research-loop-cap.test.mjs // Cover lib/util/research-loop-cap.mjs: default-off, worst-case arithmetic, // anti-dead-data (different caps → different denial points), statefulness // (identical args → different answers once the budget is hit), env // coercion, fail-closed on missing CLAUDE_PLUGIN_DATA, and the CLI shim. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync, execFile } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { allowTurn, isStormEnabled, resolveMaxConvTurns, resolveLedgerPath, resolveDataRoot, readLedger, checkDimensionCeiling, MAX_CONV_TURNS, MAX_TOTAL_DIMENSIONS, } from '../../lib/util/research-loop-cap.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'); function withTmpDataDir(fn) { const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-')); try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } function runShim(args, env) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env }, }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } /** * Run the shim in a BUILT env rather than an inherited one. Spreading * process.env means no test can express "CLAUDE_PLUGIN_DATA is absent" — the * exact condition that holds in every real run — so the shim's behaviour there * went uncovered while the module was denying turn 1. */ function runShimStripped(args, env = {}) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH, ...env }, }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } // ---- (a) default-off -------------------------------------------------------- test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, false); assert.equal(r.reason, 'storm_disabled'); assert.equal(r.budget, 0); }); }); test('allowTurn — VOYAGE_STORM_ENABLED=0 denies same as unset', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '0' }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, false); assert.equal(r.reason, 'storm_disabled'); }); }); test('allowTurn — enabled but effort !== high denies with budget 0', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'standard' }, { env }); assert.equal(r.ok, false); assert.equal(r.reason, 'effort_not_high'); assert.equal(r.budget, 0); }); }); // ---- (f) CLAUDE_PLUGIN_DATA unset => documented fallback root ---------------- // // CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool's process env (measured in a // live plugin-enabled session), and the Bash snippet in commands/trekresearch.md // is the module's only caller. Denying on its absence therefore denied turn 1 // of every real run: the loop could never spend a turn, and the pre-registered // measurement could not be run at all. The root is resolved in code, not // demanded of the environment. test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => { withTmpDataDir((home) => { const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA'); assert.equal(r.used, 1); assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS); assert.ok( existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')), 'the ledger must be written under the fallback root', ); }); }); test('resolveDataRoot — CLAUDE_PLUGIN_DATA wins; empty or unset falls back to ~/.claude/voyage', () => { assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }), '/tmp/plugin-data'); assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), join('/home/x', '.claude', 'voyage')); assert.equal(resolveDataRoot({ HOME: '/home/x' }), join('/home/x', '.claude', 'voyage')); }); // ---- (c) worst-case arithmetic ---------------------------------------------- test('allowTurn — budget is max_conv_turns × max_total_dimensions (default 3×8=24)', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, true); assert.equal(r.budget, 24); assert.equal(r.used, 1); }); }); test('allowTurn — grants exactly `budget` turns then denies the next one (default 24)', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; let last; for (let i = 0; i < 24; i++) { last = allowTurn({ runId: 'r-exhaust', dimension: `d${i % 8}`, effort: 'high' }, { env }); assert.equal(last.ok, true, `turn ${i + 1} should be granted`); } const denied = allowTurn({ runId: 'r-exhaust', dimension: 'd0', effort: 'high' }, { env }); assert.equal(denied.ok, false); assert.equal(denied.reason, 'budget_exhausted'); assert.equal(denied.used, 24); assert.equal(denied.budget, 24); }); }); // ---- (c)/(anti-dead-data) — different caps → observably different denial points test('allowTurn — TREKRESEARCH_MAX_CONV_TURNS=1 denies after 8 turns (1×8), not 24', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; let last; for (let i = 0; i < 8; i++) { last = allowTurn({ runId: 'r-narrow', dimension: `d${i}`, effort: 'high' }, { env }); assert.equal(last.ok, true, `turn ${i + 1} should be granted`); } const denied = allowTurn({ runId: 'r-narrow', dimension: 'd8', effort: 'high' }, { env }); assert.equal(denied.ok, false); assert.equal(denied.budget, 8); assert.notEqual(denied.budget, 24, 'a narrower cap must produce a different denial point than the default'); }); }); // ---- (d) stateful — identical args give different answers once exhausted --- test('allowTurn — identical {runId, dimension, effort} args diverge once the budget is hit (proves statefulness)', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; const args = { runId: 'r-identical', dimension: 'same-dim', effort: 'high' }; const results = []; for (let i = 0; i < 9; i++) results.push(allowTurn(args, { env })); // First 8 (budget = 1*8) granted, 9th denied — same exact input object each time. assert.deepEqual(results.slice(0, 8).map(r => r.ok), Array(8).fill(true)); assert.equal(results[8].ok, false); assert.equal(results[8].reason, 'budget_exhausted'); }); }); // ---- (e) env coercion -------------------------------------------------------- test('resolveMaxConvTurns — NaN string falls back to default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'abc' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — empty string falls back to default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — negative value falls back to default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '-5' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — zero falls back to default (never unbounded)', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — unset falls back to default', () => { assert.equal(resolveMaxConvTurns({}), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — valid positive integer string is honored', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2); }); // A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and // 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop // silently dead, while README.md and docs/architecture.md both promise a // fallback of 3. The guard has to see the floored value, not the raw one. test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS); assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2); }); test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS); }); test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' }; const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded'); assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS); }); }); // ---- pure-core unit coverage -------------------------------------------------- test('isStormEnabled — only the literal string "1" enables', () => { assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: '1' }), true); assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: 'true' }), false); assert.equal(isStormEnabled({}), false); }); test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => { const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl'); assert.equal(resolveLedgerPath({ HOME: '/home/x' }), expected); assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), expected); }); test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => { const p = resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }); assert.equal(p, join('/tmp/plugin-data', 'trekresearch-loop-ledger.jsonl')); }); test('allowTurn — missing runId or dimension denies with missing_args', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r1 = allowTurn({ dimension: 'd1', effort: 'high' }, { env }); assert.equal(r1.ok, false); assert.equal(r1.reason, 'missing_args'); const r2 = allowTurn({ runId: 'r1', effort: 'high' }, { env }); assert.equal(r2.ok, false); assert.equal(r2.reason, 'missing_args'); }); }); // ---- fail-closed on an unreadable ledger ------------------------------------ // // The module's own header states that a budget control "must never silently // grant unlimited turns just because the data dir is missing". The // missing-DIRECTORY case already failed closed; the unreadable-FILE case // returned 0 from countTurns and therefore re-granted the full budget on every // call, unbounded — a fail-open in the same module that argues against one. // A directory standing where the ledger file belongs reproduces it portably // (EISDIR), with no chmod that a root test runner would ignore. function withUnreadableLedger(fn) { return withTmpDataDir((dir) => { mkdirSync(join(dir, 'trekresearch-loop-ledger.jsonl'), { recursive: true }); return fn(dir); }); } test('readLedger — a missing ledger is 0 turns, not an error (turn 1 must be grantable)', () => { withTmpDataDir((dir) => { assert.equal(readLedger(join(dir, 'nope.jsonl'), 'r1').granted, 0); }); }); test('readLedger — an unreadable ledger throws rather than reporting 0 turns spent', () => { withUnreadableLedger((dir) => { assert.throws( () => readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r1'), /unreadable/i, ); }); }); test('readLedger — malformed lines are skipped, well-formed ones for the run still count', () => { withTmpDataDir((dir) => { const p = join(dir, 'trekresearch-loop-ledger.jsonl'); writeFileSync(p, [ JSON.stringify({ runId: 'r1', dimension: 'd1' }), '{ not json', '', JSON.stringify({ runId: 'other', dimension: 'd1' }), JSON.stringify({ runId: 'r1', dimension: 'd2' }), ].join('\n') + '\n'); assert.equal(readLedger(p, 'r1').granted, 2); }); }); test('allowTurn — an unreadable ledger DENIES the turn instead of granting a fresh budget', () => { withUnreadableLedger((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r = allowTurn({ runId: 'r-unreadable', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, false, 'a budget control that cannot count must not grant'); assert.match(r.reason, /ledger-read-failed/); }); }); // ---- the bound holds under concurrency -------------------------------------- // // countTurns-then-appendFileSync had no atomic claim, while the comment above // allowTurn asserted "Append-only: never read-modify-write" and named the // concurrent case (Phase 4.5/5 may spawn several agents in one message) as the // reason. The decision path WAS read-then-write: N callers that all observe // used == budget-1 all grant, and the bound is exceeded by N-1. // // Two of these tests are deterministic. They do not race anything — they assert // the invariant the claim introduces: a slot that is already claimed is spent, // even when the ledger has not caught up yet, which is exactly the state a // mid-append competitor leaves behind. The third runs real processes. function seedLedger(dir, runId, n) { writeFileSync( join(dir, 'trekresearch-loop-ledger.jsonl'), Array.from({ length: n }, (_, i) => JSON.stringify({ ts: new Date().toISOString(), runId, dimension: `d${i}`, effort: 'high' }), ).join('\n') + (n ? '\n' : ''), ); } function seedClaims(dir, runId, slots) { const claimDir = join(dir, 'trekresearch-loop-claims'); mkdirSync(claimDir, { recursive: true }); for (const s of slots) writeFileSync(join(claimDir, `${runId}-${s}.claim`), ''); } function runShimAsync(args, env) { return new Promise((resolve) => { execFile( process.execPath, [SHIM, ...args], { encoding: 'utf-8', env: { PATH: process.env.PATH, ...env } }, (err, stdout) => resolve({ code: err ? (err.code ?? 1) : 0, out: stdout ?? '' }), ); }); } test('allowTurn — a slot already claimed is spent even when the ledger has not caught up', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; // budget = 1 × 8 = 8. Ledger shows 7 turns; a competitor already claimed // slot 8 and has not appended yet. Counting the ledger alone says "one slot // free" and grants a 9th turn overall — the breach this closes. seedLedger(dir, 'r-race', 7); seedClaims(dir, 'r-race', [1, 2, 3, 4, 5, 6, 7, 8]); const r = allowTurn({ runId: 'r-race', dimension: 'd0', effort: 'high' }, { env }); assert.equal(r.ok, false, 'every slot up to the budget is claimed, so there is nothing to grant'); assert.equal(r.reason, 'budget_exhausted'); assert.equal(r.budget, 8); }); }); test('allowTurn — it takes the first FREE slot and claims it, so a repeat call cannot retake it', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; seedLedger(dir, 'r-slot', 7); seedClaims(dir, 'r-slot', [1, 2, 3, 4, 5, 6, 7]); const first = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env }); assert.equal(first.ok, true, 'slot 8 is free and must be grantable'); assert.equal(first.used, 8, 'used is the slot number, so it never double-counts a claimed slot'); assert.ok( existsSync(join(dir, 'trekresearch-loop-claims', 'r-slot-8.claim')), 'the grant must leave the claim behind as the atomic record of the slot', ); const second = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env }); assert.equal(second.ok, false); assert.equal(second.reason, 'budget_exhausted'); }); }); test('allowTurn — a different runId is unaffected by another run’s claims', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; seedClaims(dir, 'r-other', [1, 2, 3, 4, 5, 6, 7, 8]); const r = allowTurn({ runId: 'r-mine', dimension: 'd0', effort: 'high' }, { env }); assert.equal(r.ok, true, 'claims are per-run; one run must not exhaust another'); }); }); test('allowTurn — parallel processes at the boundary cannot exceed the budget', async () => { const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-par-')); try { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; seedLedger(dir, 'r-par', 7); // budget 8 → exactly one turn left const results = await Promise.all( Array.from({ length: 6 }, (_, i) => runShimAsync(['--run-id', 'r-par', '--dimension', `p${i}`, '--effort', 'high'], env), ), ); const winners = results.filter((r) => r.code === 0).length; assert.equal(winners, 1, `exactly one of six concurrent callers may take the last slot, got ${winners}`); // Granted turns, not raw lines: the five denied callers also record // exhaustion tombstones, and a tombstone is not a turn. const { granted } = readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r-par'); assert.equal(granted, 8, `granted turns must never exceed the budget, got ${granted}`); } finally { rmSync(dir, { recursive: true, force: true }); } }); // ---- the exhaustion tombstone ----------------------------------------------- // // allowTurn appends BEFORE the turn runs, so during granted turn N the ledger // holds N records. The hook denied at `used >= budget`, which blocked every // tool call of the FINAL granted turn: the primitive granted B turns and the // harness permitted B-1. Worse, an exhausted run then always terminated through // an exit-2 tool denial instead of the graceful "cap exhausted" exit at // commands/trekresearch.md, which is the only exit the prose teaches. // // Letting the hook allow at `used == budget` fixes the count but would leave it // unable to catch the one case it exists for — the loop consults the gate, is // denied, and issues the tool call anyway. So the denial itself becomes a // record: a tombstone the hook can see. test('allowTurn — denying for budget writes an exhaustion tombstone', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; for (let i = 0; i < 8; i++) { assert.equal(allowTurn({ runId: 'r-tomb', dimension: `d${i}`, effort: 'high' }, { env }).ok, true); } const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl'); assert.equal(readLedger(ledgerPath, 'r-tomb').exhausted, 0, 'no tombstone before the gate has denied anything'); const denied = allowTurn({ runId: 'r-tomb', dimension: 'd0', effort: 'high' }, { env }); assert.equal(denied.ok, false); assert.equal(denied.reason, 'budget_exhausted'); const after = readLedger(ledgerPath, 'r-tomb'); assert.equal(after.exhausted, 1, 'the denial must leave a record the harness gate can read'); assert.equal(after.granted, 8, 'a tombstone is not a granted turn and must not count as one'); }); }); test('allowTurn — the tombstone is written once, not once per repeated denial', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; for (let i = 0; i < 8; i++) allowTurn({ runId: 'r-once', dimension: `d${i}`, effort: 'high' }, { env }); for (let i = 0; i < 5; i++) allowTurn({ runId: 'r-once', dimension: 'd0', effort: 'high' }, { env }); const after = readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r-once'); assert.equal(after.exhausted, 1, 'a hammered gate must not grow the ledger without bound'); assert.equal(after.granted, 8); }); }); test('readLedger — a tombstone is reported separately and never as a granted turn', () => { withTmpDataDir((dir) => { const p = join(dir, 'trekresearch-loop-ledger.jsonl'); writeFileSync(p, [ JSON.stringify({ runId: 'r1', dimension: 'd1', slot: 1 }), JSON.stringify({ runId: 'r1', exhausted: true }), JSON.stringify({ runId: 'other', exhausted: true }), ].join('\n') + '\n'); const l = readLedger(p, 'r1'); assert.equal(l.granted, 1); assert.equal(l.exhausted, 1); assert.equal(readLedger(p, 'other').granted, 0, 'another run’s tombstone is not a granted turn either'); }); }); // ---- the discovery ceiling has a reader, not just a sentence ---------------- // // The bounded-cost NFR asks for explicit ceilings on BOTH axes: max conversation // turns and max discovered dimensions. The turn axis got MAX_CONV_TURNS, a // ledger-backed reader and a PreToolUse enforcer. The discovery axis got a // sentence in Phase 4.5 — "append candidates only while the whole list stays at // or below maxDimensions: 8" — with no constant of its own, no reader, and no // test that a run exceeding it is caught. That is the same shape as the // brief_reviewer_iter_cap failure the operator decision warned about: a cap // nothing reads. // // The ceiling is deliberately the SAME constant that sizes the turn budget. Two // constants for one settings.json:16 value is how the two drift apart. test('checkDimensionCeiling — a list at the ceiling is accepted', () => { const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS }, (_, i) => `d${i}`)); assert.equal(r.ok, true); assert.equal(r.count, MAX_TOTAL_DIMENSIONS); assert.equal(r.ceiling, MAX_TOTAL_DIMENSIONS); }); test('checkDimensionCeiling — one dimension over the ceiling is REJECTED', () => { const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS + 1 }, (_, i) => `d${i}`)); assert.equal(r.ok, false, 'a ceiling that accepts ceiling+1 is not a ceiling'); assert.equal(r.reason, 'ceiling_exceeded'); assert.equal(r.count, MAX_TOTAL_DIMENSIONS + 1); }); test('checkDimensionCeiling — a plain count works as well as a list', () => { assert.equal(checkDimensionCeiling(8).ok, true); assert.equal(checkDimensionCeiling(9).ok, false); assert.equal(checkDimensionCeiling('8').ok, true); }); test('checkDimensionCeiling — an unreadable count is rejected, never waved through', () => { for (const bad of ['abc', null, undefined, {}, -1, NaN]) { const r = checkDimensionCeiling(bad); assert.equal(r.ok, false, `${JSON.stringify(bad)} must not pass a cost ceiling`); assert.equal(r.reason, 'unreadable_dimension_count'); } }); test('checkDimensionCeiling — the ceiling is the same constant that sizes the turn budget', () => { // Phase 4.5 and the Phase 5 budget must not be able to disagree about 8. assert.equal(checkDimensionCeiling(0).ceiling, MAX_TOTAL_DIMENSIONS); }); test('CLI shim — --check-dimensions exits 0 at the ceiling and 1 above it', () => { const at = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS)], {}); assert.equal(at.code, 0, `at the ceiling must exit 0; got ${at.out}`); assert.equal(JSON.parse(at.out.trim()).ok, true); const over = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS + 1)], {}); assert.equal(over.code, 1, 'a run over the ceiling must be rejected by exit code, not by prose'); const parsed = JSON.parse(over.out.trim()); assert.equal(parsed.ok, false); assert.equal(parsed.reason, 'ceiling_exceeded'); }); test('CLI shim — --check-dimensions needs no runId, effort or STORM flag', () => { // It is a cost ceiling on Phase 4.5, which never calls the budget gate, so it // must not inherit the budget gate's preconditions. const r = runShimStripped(['--check-dimensions', '3'], {}); assert.equal(r.code, 0, `should not require --run-id/--effort; got ${r.out}`); }); // ---- (g) shim contract -------------------------------------------------------- test('CLI shim — grants and exits 0 when enabled + high effort + budget available', () => { withTmpDataDir((dir) => { const r = runShim( ['--run-id', 'shim-1', '--dimension', 'd1', '--effort', 'high'], { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }, ); assert.equal(r.code, 0); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, true); }); }); test('CLI shim — denies and exits 1 when disabled', () => { const r = runShim(['--run-id', 'shim-2', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '0' }); assert.equal(r.code, 1); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, false); assert.equal(parsed.reason, 'storm_disabled'); }); test('CLI shim — grants with CLAUDE_PLUGIN_DATA STRIPPED from the environment', () => { withTmpDataDir((home) => { const r = runShimStripped( ['--run-id', 'shim-stripped', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '1', HOME: home }, ); assert.equal(r.code, 0, `shim must grant without CLAUDE_PLUGIN_DATA; got: ${r.out}`); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, true); assert.ok(existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl'))); }); }); test('CLI shim — missing required args exits 1 with usage reason', () => { const r = runShim(['--run-id', 'shim-3']); assert.equal(r.code, 1); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, false); assert.match(parsed.reason, /usage:/); });