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
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
# Arms the PreToolUse cap for THIS session only.
# 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
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)" \
> "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
else
@ -475,6 +481,10 @@ else
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`.
The hook counts ledger lines carrying that id, so a marker written with any
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.
```bash
# Removal — idempotent, safe to repeat.
[ -n "${CLAUDE_PLUGIN_DATA:-}" ] && \
rm -f "${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/${CLAUDE_CODE_SESSION_ID}.json"
# Removal — idempotent, safe to repeat. Same root, same absolute-path guard as
# the write: a remove that accepts a root the write rejected (or vice versa)
# 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
@ -504,20 +518,37 @@ Each turn targets exactly one under-illuminated dimension, and runs two gates
before it spends anything:
```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.
# 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}
# 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.
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
is a rewrite: the hard-block tier (secret-shaped strings) is never
operator-overridable, so a query that trips it must be reformulated, not
forced through.
**Exit 2 is not exit 1.** A denied budget gate is an exit condition, not a
retry. A failed privacy gate is a rewrite: the hard-block tier (secret-shaped
strings) is never operator-overridable, so a query that trips it must be
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
citations, is marked `empty`. An empty turn is counted in `empty_turns` and