chore(cleanup): R-b R2 — STORM loop, cap hook and the --engine research choice removed

/trekresearch loses Phase 4.5, the bounded Phase 5 loop (Phase 5 keeps its
single pass of at most 2 follow-ups), the --engine flag, the deep-research
adapter and its output self-check. hooks.json loses the pre-agent-cap
PreToolUse entry (README: 9 hooks = 9 scripts). The STORM env vars leave the
docs; query-privacy-gate.mjs stays, called by no command, and says so.

Files removed: 13 (4 docs, 1 hook, 1 lib module, 1 script, 5 tests, 1 fixture).
Files edited: 17. Tests: 2600 (2598 pass / 0 fail / 2 skipped) before,
2467 (2466 pass / 0 fail / 1 skipped, R3 pending) after.
Durable check: group R2 green and no longer pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-07 20:32:38 +01:00
commit 32621dcb63
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
30 changed files with 34 additions and 4070 deletions

View file

@ -1,219 +0,0 @@
#!/usr/bin/env node
// Hook: pre-agent-cap.mjs
// Event: PreToolUse (WebSearch | WebFetch | Task | Agent)
// Purpose: Enforce the /trekresearch Phase 5 loop bound at the harness level,
// so the cap is a reader that fells rather than prose the model obeys.
// The subagent tool is `Agent` since Claude Code 2.1.63; the docs keep `Task`
// as an alias only "in settings and agent definitions", so the matcher names
// both (tests/hooks/storm-matcher.test.mjs).
//
// Precondition: Voyage's guards are deterministic only when no installed mod
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
// permissions with hooks). A hook cannot see whether such a mod is installed.
//
// Why this exists: the Phase 5 budget gate (lib/util/research-loop-cap.mjs) is
// invoked BY the loop. A gate the caller chooses to consult is advice. The
// spike in docs/spike-pretooluse-subagent-reach.md (RESULT: FIRES) established
// that a plugin PreToolUse hook does observe tool calls made INSIDE sub-agents
// on CC 2.1.226, which is what makes a second, non-optional gate possible.
//
// Two limits carried over from that spike, neither of which changes the design:
// - Reach was measured on one CC version and regressed once before (#34692),
// so this hook is defence in depth, never the only gate. research-loop-cap
// must stay correct if this hook silently stops firing.
// - Whether a blocking (exit 2) decision from inside a sub-agent propagates
// usefully was NOT measured — the probe always exited 0 by design.
//
// 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
// <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
// budget; a PreToolUse hook that over-blocks breaks every session on the box.
//
// Stated limit, because the guarantee above is about OTHER sessions and reads
// as broader than it is: resuming a session (`--resume`) keeps the same session_id, so a
// resumed session is the same session by this key. If a run reached its cap
// and then died before removing the marker, the resume inherits the remainder
// of the TTL, for any WebSearch/WebFetch/Task — research or not. Three things
// bound it rather than close it: only an EXHAUSTED run denies at all (a
// part-spent crash leaves no tombstone and is allowed), the TTL is 2h rather
// than a working day, and every denial prints the marker path to delete. A
// liveness check would close it properly, but a PreToolUse hook has nothing
// trustworthy to check liveness against — the marker's writer is a shell
// snippet whose $$ is a subshell, not the session.
//
// 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 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.)
// - In scope and the ledger cannot be counted (EISDIR, EACCES, EIO — anything
// but ENOENT) => exit 2, same reason. This branch used to ALLOW: the hook
// carried a private countTurns() whose catch returned 0, so an unreadable
// ledger read as "no turns spent". Counting now goes through the
// primitive's exported readLedger(), so reader and writer cannot hold
// different rules about what an unreadable ledger means.
//
// 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
// count its own enforcement.
//
// Kill switch: VOYAGE_DISABLE_CAP_HOOK=1 disables enforcement entirely.
import { readFileSync, existsSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const { resolveLedgerPath, resolveDataRoot, resolveMaxConvTurns, isStormEnabled, readLedger, MAX_TOTAL_DIMENSIONS } =
await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'));
const SCOPE_DIRNAME = 'trekresearch-loop-scope';
// 2h — comfortably longer than any real research run (a 24-turn loop at a couple
// of minutes a turn is under an hour), and short enough that debris does not own
// the rest of the working day. The TTL is measured from marker.startedAt rather
// than from last activity, and resuming a session (`--resume`) keeps the same session_id, so
// this window is what a resumed session can inherit from a run that died holding
// the marker. It was 6h; nothing needed six.
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
const env = process.env;
function allow() {
process.exit(0);
}
function deny(message) {
process.stderr.write(`[voyage] BLOCKED: trekresearch loop cap\n${message}\n`);
process.exit(2);
}
// 1. Kill switch.
if (env.VOYAGE_DISABLE_CAP_HOOK === '1') allow();
// 2. Default-off: no loop runs unless STORM is enabled, so nothing to enforce.
if (!isStormEnabled(env)) allow();
// 3. Parse stdin. Unparsable input is not evidence of a loop turn.
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
allow();
}
const sessionId = input?.session_id;
if (!sessionId || typeof sessionId !== 'string') 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();
let marker;
try {
marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
} catch {
allow(); // A marker we cannot read cannot tell us which run we are in.
}
if (!marker?.runId) allow();
// 5. TTL / auto-reset. A marker left behind by a crashed run must not deny
// tool calls for the rest of the machine's life.
const ttlRaw = Number(env.VOYAGE_CAP_SCOPE_TTL_MS);
const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? ttlRaw : DEFAULT_TTL_MS;
const startedAt = Date.parse(marker.startedAt ?? '');
if (!Number.isFinite(startedAt) || Date.now() - startedAt > ttlMs) {
try { rmSync(markerPath, { force: true }); } catch { /* best effort */ }
allow();
}
// --- In scope from here on. ---
// 6. The ledger is the only source of truth for turns spent, and it is counted
// through the primitive's OWN readLedger(). This hook used to carry a
// private copy of the counting rule whose read error returned 0 — so an
// unreadable ledger read as "no turns spent" and ALLOWED, in the one branch
// where this hook is supposed to fail closed.
const ledgerPath = resolveLedgerPath(env);
// 7. Same bound the primitive uses: turns-per-dimension × the whole dimension
// list under the maxDimensions: 8 ceiling.
const budget = resolveMaxConvTurns(env) * MAX_TOTAL_DIMENSIONS;
let ledger;
try {
ledger = readLedger(ledgerPath, marker.runId);
} catch (e) {
deny(
` Run ${marker.runId} is in scope, but its turn ledger could not be read:\n` +
` ${e.message}\n` +
` A budget control that cannot count must not grant. Fix or remove the\n` +
` ledger, or set VOYAGE_DISABLE_CAP_HOOK=1 to disable enforcement.`,
);
}
const toolLine =
` Tool: ${input?.tool_name ?? 'unknown'}${input?.agent_type ? ` (agent: ${input.agent_type})` : ''}\n`;
// Every denial names the marker. If this run is over and the marker outlived it
// — the loop's own cleanup covers its three exits, but a crash between the
// exhaustion record and the removal runs no cleanup at all — deleting this file
// is the remedy, and a resumed session (same session_id) would otherwise sit out
// the remaining TTL for work that has nothing to do with research.
const remedyLines =
` If this loop is not running, the marker is debris — delete it:\n` +
` ${markerPath}\n` +
` It also auto-resets ${Math.round(ttlMs / 3600000)}h after the run started (VOYAGE_CAP_SCOPE_TTL_MS).\n`;
// 8. The boundary is the TOMBSTONE, not the count.
//
// allowTurn() appends before the turn runs, so during the final granted turn the
// ledger already holds `budget` records. Denying at `granted >= budget` blocked
// that turn's own tool calls — the primitive granted B turns and this hook
// permitted B-1 — and it forced every exhausted run out through an exit-2 tool
// denial rather than the graceful "cap exhausted" exit, the only exit the prose
// at commands/trekresearch.md teaches the model to handle.
//
// Moving the boundary to `granted > budget` alone would have made this hook
// unable to fire at all once the claim mechanism made a breached ledger
// impossible — a deny branch that cannot be reached is a dead security claim,
// not a backstop. So the primitive records its own denials, and the case this
// hook exists for is the one it now catches: the gate said no and a tool call
// arrived anyway.
if (ledger.exhausted > 0) {
deny(
` Run ${marker.runId} was already denied a turn by the budget gate\n` +
` (${ledger.granted}/${budget} loop turns spent), and this call came after it.\n` +
toolLine +
` Remaining gaps belong in the brief as open questions, not in another turn.\n` +
remedyLines +
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
);
}
// 9. Backstop for a ledger that exceeded the bound however it managed to.
if (ledger.granted > budget) {
deny(
` Run ${marker.runId} shows ${ledger.granted} granted turns against a budget of ${budget}.\n` +
toolLine +
` The ledger has been breached; the loop is over regardless of cause.\n` +
remedyLines +
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
);
}
allow();