fix(research-loop-cap): resolve the data root in code so the loop can run

CLAUDE_PLUGIN_DATA is empty in the Bash tool's process env, and the Phase 5
bash snippet is the cap's only caller. resolveLedgerPath() returned null there
and allowTurn() failed closed, so the budget gate denied turn 1 of every real
run: the loop this delivery exists to bound could never spend a turn, and the
pre-registered measurement could not be run at all.

resolveDataRoot() is now the single root for everything the loop writes --
CLAUDE_PLUGIN_DATA when the harness sets it, ~/.claude/voyage when it does
not. Three consumers resolve through it, which is the point: the cap ledger,
the PreToolUse hook's scope-marker lookup, and the command's bash snippets.
A writer and a reader that resolved the root separately are what made the
enforcement hook allow unconditionally in every real run while CLAUDE.md and
docs/architecture.md called it enforcing.

Same root cause, same commit:
- Marker write and remove now share ONE absolute-path guard and one root; the
  write requires a non-empty CLAUDE_CODE_SESSION_ID before composing the path
  (unset, the marker was named `.json`, which no lookup matches and no TTL
  sweep cleans up).
- The per-turn gates resolve VOYAGE_ROOT with a plugin-cache fallback and
  reserve exit 2 for "gate could not run". Interpolating an empty
  ${CLAUDE_PLUGIN_ROOT} ran `node /lib/...` -> exit 1, which the contract read
  as "privacy gate says no" -- an unsatisfiable rewrite loop no query could
  clear.

Two now-unreachable deny branches are removed rather than left as dead safety
claims (allowTurn's no_plugin_data_dir; the hook's uncountable-ledger deny).
The fail-closed stance stays where it is still real: a ledger that cannot be
WRITTEN denies the turn.

Verified end-to-end through the real bash snippets and the real hook with both
variables stripped and HOME sandboxed: marker written under the fallback root,
8 turns spent, 9th denied, hook exits 2, and exits 0 again after removal.
Note: the fallback exit-2 branch fires against the installed v5.9.1 cache,
which predates lib/util/research-loop-cap.mjs -- correct behaviour, and it
clears when the plugin is reinstalled.

Review findings 2670c10a, fbd6d534, 93550dfb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 22:26:46 +02:00
commit 6dafdf2a2a
8 changed files with 260 additions and 61 deletions

View file

@ -462,12 +462,18 @@ unconditionally for every session that does not. That is what keeps a globally
wired PreToolUse hook from denying tool calls in unrelated sessions. Write the wired PreToolUse hook from denying tool calls in unrelated sessions. Write the
marker once, immediately before the first turn: marker once, immediately before the first turn:
`CLAUDE_PLUGIN_DATA` is **empty in the Bash tool's process env** even in a
plugin-enabled session, so the root is resolved with the same fallback
`research-loop-cap.mjs` uses — `~/.claude/voyage`. Reader and writer must
resolve identically; a marker written where the hook does not look leaves the
hook allowing unconditionally while the docs call it enforcing.
```bash ```bash
# Arms the PreToolUse cap for THIS session only. # Arms the PreToolUse cap for THIS session only.
# CLAUDE_CODE_SESSION_ID is the same id the hook reads as `session_id`. # CLAUDE_CODE_SESSION_ID is the same id the hook reads as `session_id`.
DATA="${CLAUDE_PLUGIN_DATA:-}" DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
if [ -n "$SCOPE_DIR" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then if [ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then
printf '{"runId":"%s","startedAt":"%s"}\n' "{run_id}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ printf '{"runId":"%s","startedAt":"%s"}\n' "{run_id}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json" > "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
else else
@ -475,6 +481,10 @@ else
fi fi
``` ```
An empty `CLAUDE_CODE_SESSION_ID` is checked before the path is composed, not
after: unset, the marker becomes `.json`, which no hook lookup matches and no
TTL sweep ever cleans up.
`runId` MUST be the same `{run_id}` passed to `research-loop-cap.mjs --run-id`. `runId` MUST be the same `{run_id}` passed to `research-loop-cap.mjs --run-id`.
The hook counts ledger lines carrying that id, so a marker written with any The hook counts ledger lines carrying that id, so a marker written with any
other id counts zero turns and enforces nothing. other id counts zero turns and enforces nothing.
@ -493,9 +503,13 @@ crashed session runs no cleanup at all, and is covered instead by the hook's
TTL (default 6h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker. TTL (default 6h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker.
```bash ```bash
# Removal — idempotent, safe to repeat. # Removal — idempotent, safe to repeat. Same root, same absolute-path guard as
[ -n "${CLAUDE_PLUGIN_DATA:-}" ] && \ # the write: a remove that accepts a root the write rejected (or vice versa)
rm -f "${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/${CLAUDE_CODE_SESSION_ID}.json" # leaves markers the loop believes it cleaned up.
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
[ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && \
rm -f "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
``` ```
### Per-turn protocol ### Per-turn protocol
@ -504,20 +518,37 @@ Each turn targets exactly one under-illuminated dimension, and runs two gates
before it spends anything: before it spends anything:
```bash ```bash
# 0. Resolve the plugin root ONCE. ${CLAUDE_PLUGIN_ROOT} is substituted in this
# command's text but is EMPTY in the Bash tool's process env, and a bare
# `node ${CLAUDE_PLUGIN_ROOT}/lib/…` then runs `node /lib/…`, which exits 1 —
# indistinguishable from a gate that said no.
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
case "$VOYAGE_ROOT" in
/*) ;;
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage 2>/dev/null | head -1)" ;;
esac
if [ ! -f "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" ]; then
echo "[voyage] gates could not run — plugin root unresolved (exit 2). NOT a denial:"
echo " stop the loop and report to the operator. Never proceed ungated."
exit 2
fi
# 1. Budget gate — per turn, per dimension. Exit 0 = granted, exit 1 = denied. # 1. Budget gate — per turn, per dimension. Exit 0 = granted, exit 1 = denied.
# JSON on stdout: {ok, used, budget, reason?} # JSON on stdout: {ok, used, budget, reason?}
node ${CLAUDE_PLUGIN_ROOT}/lib/util/research-loop-cap.mjs \ node "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" \
--run-id {run_id} --dimension {dimension} --effort {phase_signal_result.effort} --run-id {run_id} --dimension {dimension} --effort {phase_signal_result.effort}
# 2. Privacy gate — EVERY outbound query, before it leaves the machine. # 2. Privacy gate — EVERY outbound query, before it leaves the machine.
# Exit 0 = send as-is; exit 1 = rewrite the query and re-gate. Never bypass. # Exit 0 = send as-is; exit 1 = rewrite the query and re-gate. Never bypass.
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/query-privacy-gate.mjs "{query text}" node "$VOYAGE_ROOT/lib/validators/query-privacy-gate.mjs" "{query text}"
``` ```
A denied budget gate is an exit condition, not a retry. A failed privacy gate **Exit 2 is not exit 1.** A denied budget gate is an exit condition, not a
is a rewrite: the hard-block tier (secret-shaped strings) is never retry. A failed privacy gate is a rewrite: the hard-block tier (secret-shaped
operator-overridable, so a query that trips it must be reformulated, not strings) is never operator-overridable, so a query that trips it must be
forced through. reformulated, not forced through. A gate that *could not run* is neither — no
rewrite can clear it, so treat it as a hard stop and say so, rather than
rewriting a query that was never the problem.
**Empty turns.** A turn that returns no findings, or findings without **Empty turns.** A turn that returns no findings, or findings without
citations, is marked `empty`. An empty turn is counted in `empty_turns` and citations, is marked `empty`. An empty turn is counted in `empty_turns` and

View file

@ -12,7 +12,7 @@ Imported from `CLAUDE.md` via pointer.
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0) - `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`) - `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md` - `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`
- `lib/util/research-loop-cap.mjs` — stateful, **default-off** turn budget for the `/trekresearch` bounded conversation loop. `allowTurn()` derives the used-turn count from its own append-only JSONL ledger; it never asks the caller how many turns it has spent, because a cap that does is not a cap. Budget = `TREKRESEARCH_MAX_CONV_TURNS` (default `3`, invalid values fall back to `3`) × `maxDimensions` (8, `settings.json:16`). Grants 0 unless `VOYAGE_STORM_ENABLED=1`; missing `CLAUDE_PLUGIN_DATA` denies (fail-closed — the opposite of `event-emit.mjs`, which is telemetry and must never block). The same flag is the second condition on Phase 4.5's skip-guard, which does not call this module: unset, **both** STORM phases are inert. CLI shim: `node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E` - `lib/util/research-loop-cap.mjs` — stateful, **default-off** turn budget for the `/trekresearch` bounded conversation loop. `allowTurn()` derives the used-turn count from its own append-only JSONL ledger; it never asks the caller how many turns it has spent, because a cap that does is not a cap. Budget = `TREKRESEARCH_MAX_CONV_TURNS` (default `3`, invalid values fall back to `3`) × `maxDimensions` (8, `settings.json:16`). Grants 0 unless `VOYAGE_STORM_ENABLED=1`, which is also the second condition on Phase 4.5's skip-guard (that phase does not call this module): unset, **both** STORM phases are inert. `resolveDataRoot()` is the single root for everything the loop writes — `CLAUDE_PLUGIN_DATA` when the harness sets it, `~/.claude/voyage` when it does not (it is empty in the Bash tool's process env, which is where the loop actually runs); the cap hook resolves through the same function, so writer and reader cannot disagree. A ledger that cannot be **written** still denies the turn (fail-closed — the opposite of `event-emit.mjs`, which is telemetry and must never block). CLI shim: `node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E`
- `lib/validators/query-privacy-gate.mjs` — gates **every** outbound research query before it leaves the machine; the hard-block tier (secret-shaped strings) is not operator-overridable, so a query that trips it must be reformulated rather than forced through. CLI shim: `node lib/validators/query-privacy-gate.mjs "<query>"` - `lib/validators/query-privacy-gate.mjs` — gates **every** outbound research query before it leaves the machine; the hard-block tier (secret-shaped strings) is not operator-overridable, so a query that trips it must be reformulated rather than forced through. CLI shim: `node lib/validators/query-privacy-gate.mjs "<query>"`
Wiring points (replaces previous prose-grep instructions): Wiring points (replaces previous prose-grep instructions):
@ -33,7 +33,7 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
`hooks/scripts/post-bash-stats.mjs` (PostToolUse, CC v2.1.97+) appends `duration_ms` for each Bash call into `${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`. Useful for finding long-running verify or checkpoint commands. `hooks/scripts/post-bash-stats.mjs` (PostToolUse, CC v2.1.97+) appends `duration_ms` for each Bash call into `${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`. Useful for finding long-running verify or checkpoint commands.
`hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`) enforces the `/trekresearch` Phase 5 loop bound in the harness, so the cap is not merely prose the model is asked to obey. It counts spent turns read-only from the append-only ledger `research-loop-cap.mjs` writes, and denies (exit 2) once the budget is gone. Scope key = `session_id` + a marker file only the loop writes (`${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json`); without a marker the hook allows unconditionally, which is what keeps a globally-wired `PreToolUse` hook from over-blocking unrelated sessions. Stale markers auto-reset on a TTL, `VOYAGE_DISABLE_CAP_HOOK=1` is the kill switch, and in-scope-but-uncountable (no `CLAUDE_PLUGIN_DATA`) fails closed. Defence in depth only — `lib/util/research-loop-cap.mjs` must stay correct if the hook stops firing (see `docs/spike-pretooluse-subagent-reach.md`). `hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`) enforces the `/trekresearch` Phase 5 loop bound in the harness, so the cap is not merely prose the model is asked to obey. It counts spent turns read-only from the append-only ledger `research-loop-cap.mjs` writes, and denies (exit 2) once the budget is gone. Scope key = `session_id` + a marker file only the loop writes (`<resolveDataRoot()>/trekresearch-loop-scope/<session_id>.json`, the same root the ledger uses); without a marker the hook allows unconditionally, which is what keeps a globally-wired `PreToolUse` hook from over-blocking unrelated sessions. Stale markers auto-reset on a TTL and `VOYAGE_DISABLE_CAP_HOOK=1` is the kill switch. Defence in depth only — `lib/util/research-loop-cap.mjs` must stay correct if the hook stops firing (see `docs/spike-pretooluse-subagent-reach.md`).
`hooks/scripts/post-compact-flush.mjs` (PostCompact event, v3.4.0) re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary. Companion to `pre-compact-flush.mjs` (which writes the state file before compaction); together they form the rehydrate cycle that keeps `/trekcontinue` reliable across long-running multi-session work. `hooks/scripts/post-compact-flush.mjs` (PostCompact event, v3.4.0) re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary. Companion to `pre-compact-flush.mjs` (which writes the state file before compaction); together they form the rehydrate cycle that keeps `/trekcontinue` reliable across long-running multi-session work.

View file

@ -19,7 +19,11 @@
// //
// Scope key — the property that makes this safe to wire globally: // Scope key — the property that makes this safe to wire globally:
// session_id + a scope marker file that only the Phase 5 loop writes, at // session_id + a scope marker file that only the Phase 5 loop writes, at
// ${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json: // <data root>/trekresearch-loop-scope/<session_id>.json, where the data root
// comes from research-loop-cap.mjs's resolveDataRoot() — the same function
// the writer resolves through, because a writer and a reader that resolve
// the root separately are a hook that enforces nothing while reporting that
// it does:
// { "runId": "<run id>", "startedAt": "<ISO-8601>" } // { "runId": "<run id>", "startedAt": "<ISO-8601>" }
// No marker for this session => out of scope => allow, unconditionally. An // No marker for this session => out of scope => allow, unconditionally. An
// unrelated session must never be denied because some other run spent its // unrelated session must never be denied because some other run spent its
@ -28,9 +32,10 @@
// Fail-open vs fail-closed, deliberately split: // Fail-open vs fail-closed, deliberately split:
// - Out of scope (no marker, no session_id, unparsable stdin, stale marker, // - Out of scope (no marker, no session_id, unparsable stdin, stale marker,
// kill switch, STORM off) => exit 0. Fail OPEN. // kill switch, STORM off) => exit 0. Fail OPEN.
// - In scope but the ledger cannot be read (CLAUDE_PLUGIN_DATA absent) => // - In scope and over budget => exit 2. Fail CLOSED, mirroring
// exit 2. Fail CLOSED, mirroring research-loop-cap.mjs's own stance: a // research-loop-cap.mjs's own stance: a budget control that cannot count
// budget control that cannot count must not grant. // must not grant. (The former "CLAUDE_PLUGIN_DATA absent" deny is gone —
// the root now always resolves, so that branch could no longer fire.)
// //
// Counting is read-only. The ledger is append-only and written solely by // Counting is read-only. The ledger is append-only and written solely by
// research-loop-cap.mjs's allowTurn(); if this hook appended, the cap would // research-loop-cap.mjs's allowTurn(); if this hook appended, the cap would
@ -43,7 +48,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const { resolveLedgerPath, resolveMaxConvTurns, isStormEnabled, MAX_TOTAL_DIMENSIONS } = const { resolveLedgerPath, resolveDataRoot, resolveMaxConvTurns, isStormEnabled, MAX_TOTAL_DIMENSIONS } =
await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs')); await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'));
const SCOPE_DIRNAME = 'trekresearch-loop-scope'; const SCOPE_DIRNAME = 'trekresearch-loop-scope';
@ -77,11 +82,10 @@ try {
const sessionId = input?.session_id; const sessionId = input?.session_id;
if (!sessionId || typeof sessionId !== 'string') allow(); if (!sessionId || typeof sessionId !== 'string') allow();
// 4. Resolve the scope marker. VOYAGE_CAP_SCOPE_DIR exists so the marker // 4. Resolve the scope marker through the writer's own root resolution.
// location stays addressable when CLAUDE_PLUGIN_DATA is being tested as // VOYAGE_CAP_SCOPE_DIR stays as a test/override seam; unset, this lands on
// absent; in production both point at the same plugin data directory. // exactly the directory the Phase 5 snippet writes into.
const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || env.CLAUDE_PLUGIN_DATA; const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || resolveDataRoot(env);
if (!scopeDir) allow();
const markerPath = join(scopeDir, SCOPE_DIRNAME, `${sessionId}.json`); const markerPath = join(scopeDir, SCOPE_DIRNAME, `${sessionId}.json`);
if (!existsSync(markerPath)) allow(); if (!existsSync(markerPath)) allow();
@ -109,14 +113,6 @@ if (!Number.isFinite(startedAt) || Date.now() - startedAt > ttlMs) {
// 6. The ledger is the only source of truth for turns spent. // 6. The ledger is the only source of truth for turns spent.
const ledgerPath = resolveLedgerPath(env); const ledgerPath = resolveLedgerPath(env);
if (!ledgerPath) {
deny(
` Session ${sessionId} is inside a Phase 5 research loop (run ${marker.runId}),\n` +
` but CLAUDE_PLUGIN_DATA is not set, so the turn ledger cannot be read.\n` +
` A budget control that cannot count does not grant. Set CLAUDE_PLUGIN_DATA,\n` +
` or set VOYAGE_DISABLE_CAP_HOOK=1 to disable this hook.`,
);
}
function countTurns(path, runId) { function countTurns(path, runId) {
if (!existsSync(path)) return 0; if (!existsSync(path)) return 0;

View file

@ -13,10 +13,17 @@
// where max_total_dimensions is the WHOLE list (interview + discovered) // where max_total_dimensions is the WHOLE list (interview + discovered)
// under settings.json:16's cap of 8 — not × discovered-only. // under settings.json:16's cap of 8 — not × discovered-only.
// //
// CLAUDE_PLUGIN_DATA absent => DENY (fail-closed). This is the opposite of // CLAUDE_PLUGIN_DATA absent => fall back to ~/.claude/voyage. The variable is
// lib/stats/event-emit.mjs's fail-open: that module is telemetry (must never // EMPTY in the Bash tool's process env (measured in a live plugin-enabled
// block workflow); this module is a budget control (must never silently // session), and the Phase 5 bash snippet is this module's only caller — so
// grant unlimited turns just because the data dir is missing). // 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: // CLI shim:
// node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E // node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E
@ -24,6 +31,7 @@
import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs'; import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
export const MAX_CONV_TURNS = 3; export const MAX_CONV_TURNS = 3;
export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only
@ -46,10 +54,21 @@ export function resolveMaxConvTurns(env = process.env) {
return Math.floor(n); return Math.floor(n);
} }
export function resolveLedgerPath(env = process.env) { /**
* 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; const dir = env.CLAUDE_PLUGIN_DATA;
if (!dir || typeof dir !== 'string' || dir.length === 0) return null; if (dir && typeof dir === 'string' && dir.length > 0) return dir;
return join(dir, LEDGER_FILENAME); 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) { function countTurns(ledgerPath, runId) {
@ -96,10 +115,6 @@ export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS; const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS;
const ledgerPath = resolveLedgerPath(env); const ledgerPath = resolveLedgerPath(env);
if (!ledgerPath) {
return { ok: false, used: 0, budget, reason: 'no_plugin_data_dir' };
}
const used = countTurns(ledgerPath, runId); const used = countTurns(ledgerPath, runId);
if (used >= budget) { if (used >= budget) {
return { ok: false, used, budget, reason: 'budget_exhausted' }; return { ok: false, used, budget, reason: 'budget_exhausted' };

View file

@ -78,6 +78,58 @@ test('trekresearch — Phase 5 loop is gated on effort == high and names both pr
assert.match(p5, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'shim invocations must use the ${CLAUDE_PLUGIN_ROOT} path form'); assert.match(p5, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'shim invocations must use the ${CLAUDE_PLUGIN_ROOT} path form');
}); });
// CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are substituted in this command's
// TEXT but are EMPTY in the Bash tool's process env. Every snippet below runs
// in that env, so each needs a resolution that does not depend on it.
test('trekresearch — the scope-marker snippets resolve a root instead of requiring CLAUDE_PLUGIN_DATA', () => {
const p5 = phase5(read());
const blocks = [...p5.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
const write = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('printf'));
const remove = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('rm -f'));
assert.ok(write, 'Phase 5 must carry the scope-marker write snippet');
assert.ok(remove, 'Phase 5 must carry the scope-marker removal snippet');
for (const [name, block] of [['write', write], ['remove', remove]]) {
assert.match(
block,
/\$\{CLAUDE_PLUGIN_DATA:-\$HOME\/\.claude\/voyage\}/,
`the ${name} snippet must fall back to the same root research-loop-cap.mjs resolves`,
);
assert.match(
block,
/case .* in\s*\n?\s*\/\*\)/,
`the ${name} snippet must guard on ONE absolute-path test — write and remove must not disagree on what counts as usable`,
);
}
// Unset, ${CLAUDE_CODE_SESSION_ID} composes a marker named `.json`, which no
// hook lookup and no TTL sweep ever matches or cleans up.
assert.match(
write,
/-n "\$\{?CLAUDE_CODE_SESSION_ID/,
'the write snippet must require a non-empty CLAUDE_CODE_SESSION_ID before composing the marker path',
);
});
test('trekresearch — the per-turn gates separate "gate could not run" from "gate says no"', () => {
const p5 = phase5(read());
assert.match(
p5,
/VOYAGE_ROOT/,
'the gate snippet must resolve a plugin root rather than interpolating ${CLAUDE_PLUGIN_ROOT} straight into `node`',
);
assert.match(
p5,
/exit 2|could not run/i,
'an unresolvable gate must be distinguishable from a denial — otherwise every query reads as a privacy violation no rewrite can clear',
);
assert.match(
p5,
/plugins\/cache/,
'the fallback must name the plugin cache location it searches',
);
});
test('trekresearch — Phase 5 declares the loop bound and all three exits', () => { test('trekresearch — Phase 5 declares the loop bound and all three exits', () => {
const p5 = phase5(read()); const p5 = phase5(read());
assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading'); assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading');

View file

@ -177,18 +177,50 @@ test('pre-agent-cap is inert when VOYAGE_STORM_ENABLED is not 1 (default-off)',
}); });
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Fail-closed inside scope — in scope but the ledger is unreachable means // The measured environment — CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool
// the hook cannot count, and a cap that cannot count must not grant. // env, so that is the environment every real run happens in. The writer (the
// Phase 5 bash snippet) and the reader (this hook) must land on the SAME
// fallback root, or the hook allows unconditionally while claiming to enforce.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
test('pre-agent-cap DENIES in scope when CLAUDE_PLUGIN_DATA is absent', async () => { test('pre-agent-cap enforces via the fallback root when CLAUDE_PLUGIN_DATA is absent', async () => {
const dir = fixture({ turns: 0 }); const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), { const root = join(home, '.claude', 'voyage');
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
writeFileSync(
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
);
writeFileSync(
join(root, 'trekresearch-loop-ledger.jsonl'),
Array.from({ length: BUDGET }, (_, i) =>
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high' }),
).join('\n') + '\n',
);
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV, ...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_DATA: '',
VOYAGE_CAP_SCOPE_DIR: dir, HOME: home,
}); });
assert.strictEqual(code, 2); assert.strictEqual(code, 2, 'the hook must find marker AND ledger under the fallback root and deny');
assert.match(stderr, /CLAUDE_PLUGIN_DATA/, 'stderr must name the missing variable'); });
test('pre-agent-cap allows under budget in the fallback root — the fallback is not a blanket deny', async () => {
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
const root = join(home, '.claude', 'voyage');
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
writeFileSync(
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
);
writeFileSync(join(root, 'trekresearch-loop-ledger.jsonl'), '');
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: '',
HOME: home,
});
assert.strictEqual(code, 0);
}); });
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View file

@ -1165,6 +1165,26 @@ test('STORM: VOYAGE_STORM_ENABLED is documented as gating BOTH phases, not the l
} }
}); });
// The variable is empty in the Bash tool env, so "missing CLAUDE_PLUGIN_DATA
// denies" described a loop that could never spend turn 1. The root is resolved
// in code now; a doc that still promises the deny describes a mechanism the
// code does not have.
test('STORM: no surface claims a missing CLAUDE_PLUGIN_DATA denies — the root falls back', () => {
for (const f of ['docs/architecture.md', 'CLAUDE.md', 'README.md', 'docs/command-modes.md']) {
const t = read(f);
assert.doesNotMatch(
t,
/(missing|no|absent) `?CLAUDE_PLUGIN_DATA`?[^.\n]*(denies|fails closed)/i,
`${f}: CLAUDE_PLUGIN_DATA absence no longer denies — it resolves to ~/.claude/voyage`,
);
}
assert.match(
read('docs/architecture.md'),
/~\/\.claude\/voyage/,
'docs/architecture.md must name the fallback data root the cap and the hook share',
);
});
test('STORM: README research-dimension prose stays at the existing 38 ceiling', () => { test('STORM: README research-dimension prose stays at the existing 38 ceiling', () => {
// Phase 4.5 discovers dimensions UNDER settings.json:16's maxDimensions: 8. // Phase 4.5 discovers dimensions UNDER settings.json:16's maxDimensions: 8.
// Rewriting this prose upward would raise a ceiling the brief asked us to hold. // Rewriting this prose upward would raise a ceiling the brief asked us to hold.

View file

@ -7,7 +7,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -16,6 +16,7 @@ import {
isStormEnabled, isStormEnabled,
resolveMaxConvTurns, resolveMaxConvTurns,
resolveLedgerPath, resolveLedgerPath,
resolveDataRoot,
MAX_CONV_TURNS, MAX_CONV_TURNS,
MAX_TOTAL_DIMENSIONS, MAX_TOTAL_DIMENSIONS,
} from '../../lib/util/research-loop-cap.mjs'; } from '../../lib/util/research-loop-cap.mjs';
@ -45,6 +46,25 @@ function runShim(args, env) {
} }
} }
/**
* 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 -------------------------------------------------------- // ---- (a) default-off --------------------------------------------------------
test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => { test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => {
@ -76,14 +96,33 @@ test('allowTurn — enabled but effort !== high denies with budget 0', () => {
}); });
}); });
// ---- (f) CLAUDE_PLUGIN_DATA unset => fail-closed deny ----------------------- // ---- (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 denies even when enabled + high effort', () => { test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => {
const env = { VOYAGE_STORM_ENABLED: '1' }; // no CLAUDE_PLUGIN_DATA withTmpDataDir((home) => {
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA
assert.equal(r.ok, false); const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.reason, 'no_plugin_data_dir'); assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA');
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS); 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 ---------------------------------------------- // ---- (c) worst-case arithmetic ----------------------------------------------
@ -180,9 +219,10 @@ test('isStormEnabled — only the literal string "1" enables', () => {
assert.equal(isStormEnabled({}), false); assert.equal(isStormEnabled({}), false);
}); });
test('resolveLedgerPath — null when CLAUDE_PLUGIN_DATA unset or empty', () => { test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => {
assert.equal(resolveLedgerPath({}), null); const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl');
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '' }), null); 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', () => { test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => {
@ -224,6 +264,19 @@ test('CLI shim — denies and exits 1 when disabled', () => {
assert.equal(parsed.reason, 'storm_disabled'); 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', () => { test('CLI shim — missing required args exits 1 with usage reason', () => {
const r = runShim(['--run-id', 'shim-3']); const r = runShim(['--run-id', 'shim-3']);
assert.equal(r.code, 1); assert.equal(r.code, 1);