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
|
|
@ -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');
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue