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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@
|
|||
//
|
||||
// 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
|
||||
// ${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>" }
|
||||
// No marker for this session => out of scope => allow, unconditionally. An
|
||||
// unrelated session must never be denied because some other run spent its
|
||||
|
|
@ -28,9 +32,10 @@
|
|||
// Fail-open vs fail-closed, deliberately split:
|
||||
// - Out of scope (no marker, no session_id, unparsable stdin, stale marker,
|
||||
// kill switch, STORM off) => exit 0. Fail OPEN.
|
||||
// - In scope but the ledger cannot be read (CLAUDE_PLUGIN_DATA absent) =>
|
||||
// exit 2. Fail CLOSED, mirroring research-loop-cap.mjs's own stance: a
|
||||
// budget control that cannot count must not grant.
|
||||
// - In scope and over budget => exit 2. Fail CLOSED, mirroring
|
||||
// research-loop-cap.mjs's own stance: a budget control that cannot count
|
||||
// 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
|
||||
// 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';
|
||||
|
||||
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'));
|
||||
|
||||
const SCOPE_DIRNAME = 'trekresearch-loop-scope';
|
||||
|
|
@ -77,11 +82,10 @@ try {
|
|||
const sessionId = input?.session_id;
|
||||
if (!sessionId || typeof sessionId !== 'string') allow();
|
||||
|
||||
// 4. Resolve the scope marker. VOYAGE_CAP_SCOPE_DIR exists so the marker
|
||||
// location stays addressable when CLAUDE_PLUGIN_DATA is being tested as
|
||||
// absent; in production both point at the same plugin data directory.
|
||||
const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || env.CLAUDE_PLUGIN_DATA;
|
||||
if (!scopeDir) allow();
|
||||
// 4. Resolve the scope marker through the writer's own root resolution.
|
||||
// VOYAGE_CAP_SCOPE_DIR stays as a test/override seam; unset, this lands on
|
||||
// exactly the directory the Phase 5 snippet writes into.
|
||||
const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || resolveDataRoot(env);
|
||||
|
||||
const markerPath = join(scopeDir, SCOPE_DIRNAME, `${sessionId}.json`);
|
||||
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.
|
||||
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) {
|
||||
if (!existsSync(path)) return 0;
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 hook cannot count, and a cap that cannot count must not grant.
|
||||
// The measured environment — CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool
|
||||
// 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 () => {
|
||||
const dir = fixture({ turns: 0 });
|
||||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
test('pre-agent-cap enforces via the fallback root when CLAUDE_PLUGIN_DATA is absent', 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'),
|
||||
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,
|
||||
CLAUDE_PLUGIN_DATA: '',
|
||||
VOYAGE_CAP_SCOPE_DIR: dir,
|
||||
HOME: home,
|
||||
});
|
||||
assert.strictEqual(code, 2);
|
||||
assert.match(stderr, /CLAUDE_PLUGIN_DATA/, 'stderr must name the missing variable');
|
||||
assert.strictEqual(code, 2, 'the hook must find marker AND ledger under the fallback root and deny');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 3–8 ceiling', () => {
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
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 { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
isStormEnabled,
|
||||
resolveMaxConvTurns,
|
||||
resolveLedgerPath,
|
||||
resolveDataRoot,
|
||||
MAX_CONV_TURNS,
|
||||
MAX_TOTAL_DIMENSIONS,
|
||||
} 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 --------------------------------------------------------
|
||||
|
||||
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', () => {
|
||||
const env = { VOYAGE_STORM_ENABLED: '1' }; // no CLAUDE_PLUGIN_DATA
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'no_plugin_data_dir');
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => {
|
||||
withTmpDataDir((home) => {
|
||||
const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA');
|
||||
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 ----------------------------------------------
|
||||
|
|
@ -180,9 +219,10 @@ test('isStormEnabled — only the literal string "1" enables', () => {
|
|||
assert.equal(isStormEnabled({}), false);
|
||||
});
|
||||
|
||||
test('resolveLedgerPath — null when CLAUDE_PLUGIN_DATA unset or empty', () => {
|
||||
assert.equal(resolveLedgerPath({}), null);
|
||||
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '' }), null);
|
||||
test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => {
|
||||
const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl');
|
||||
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', () => {
|
||||
|
|
@ -224,6 +264,19 @@ test('CLI shim — denies and exits 1 when 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', () => {
|
||||
const r = runShim(['--run-id', 'shim-3']);
|
||||
assert.equal(r.code, 1);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue