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
|
|
@ -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/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/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>"`
|
||||
|
||||
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/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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue