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:
parent
2e352a7dbb
commit
6dafdf2a2a
8 changed files with 260 additions and 61 deletions
|
|
@ -13,10 +13,17 @@
|
|||
// 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).
|
||||
// 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 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:
|
||||
// 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 { 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
|
||||
|
|
@ -46,10 +54,21 @@ export function resolveMaxConvTurns(env = process.env) {
|
|||
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;
|
||||
if (!dir || typeof dir !== 'string' || dir.length === 0) return null;
|
||||
return join(dir, LEDGER_FILENAME);
|
||||
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);
|
||||
}
|
||||
|
||||
function countTurns(ledgerPath, runId) {
|
||||
|
|
@ -96,10 +115,6 @@ export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
|||
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' };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue