DEFAULT_TTL_MS was 6h, measured from marker.startedAt rather than last
activity, and `claude --resume` keeps the same session_id - so a run that
died holding its marker handed the resumed session the remainder of that
window, denying every WebSearch/WebFetch/Task including work unrelated to
research. The header's design goal ("An unrelated session must never be
denied") held across sessions and read as broader than it was: by this
scope key a resume IS the same session.
Three changes, none of which pretends to close it:
- The tombstone boundary (32e20fc) already removed the common case. A run
that crashed MID-loop leaves no denial record, so the resume is allowed;
only a crash AFTER the cap denied a turn opens a window at all. Pinned by
a test with a part-spent ledger and no tombstone.
- TTL 6h -> 2h. A 24-turn loop at a couple of minutes per turn is under an
hour, so nothing needed six, and debris no longer owns the rest of the
working day.
- Every denial now prints the marker path with "if this loop is not
running, delete it", plus the auto-reset horizon. The window existed
before with no stated remedy, which is what made it expensive.
A liveness check would close it properly. This hook has nothing
trustworthy to check liveness against - the marker's writer is a shell
snippet whose $$ is a subshell, not the session - so the limit is written
into the header as a limit instead of being papered over.
Review finding d913d1b655012fe206ea925b9fc77b401566a39e (MINOR).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
556 lines
25 KiB
JavaScript
556 lines
25 KiB
JavaScript
// tests/hooks/agent-cap.test.mjs
|
||
// Step 10 — pins hooks/scripts/pre-agent-cap.mjs, the PreToolUse enforcement
|
||
// of the /trekresearch Phase 5 loop bound.
|
||
//
|
||
// The spike (docs/spike-pretooluse-subagent-reach.md, RESULT: FIRES) proved a
|
||
// plugin PreToolUse hook observes sub-agent tool calls, so the cap can be
|
||
// enforced rather than merely documented. This file pins the two properties
|
||
// that matter in opposite directions:
|
||
//
|
||
// (a) it DENIES (exit 2) once the ledger shows the budget spent, and
|
||
// (b) it does NOT over-block — an unrelated session, unparsable stdin, a
|
||
// stale marker, or the kill switch all exit 0.
|
||
//
|
||
// Pattern: tests/hooks/bash-guard.test.mjs (child process via runHook).
|
||
|
||
import { test } from 'node:test';
|
||
import { strict as assert } from 'node:assert';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { tmpdir } from 'node:os';
|
||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..');
|
||
const CAP_HOOK = join(ROOT, 'hooks', 'scripts', 'pre-agent-cap.mjs');
|
||
const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
|
||
|
||
const SESSION = 'sess-abc123';
|
||
const RUN_ID = 'run-xyz789';
|
||
|
||
/**
|
||
* Build a throwaway CLAUDE_PLUGIN_DATA dir holding a scope marker for
|
||
* `sessionId` and `turns` ledger entries for RUN_ID.
|
||
*/
|
||
function fixture({ turns = 0, sessionId = SESSION, startedAt = new Date(), exhausted = false } = {}) {
|
||
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-'));
|
||
mkdirSync(join(dir, 'trekresearch-loop-scope'), { recursive: true });
|
||
writeFileSync(
|
||
join(dir, 'trekresearch-loop-scope', `${sessionId}.json`),
|
||
JSON.stringify({ runId: RUN_ID, startedAt: startedAt.toISOString() }),
|
||
);
|
||
const lines = Array.from({ length: turns }, (_, i) =>
|
||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high', slot: i + 1 }),
|
||
);
|
||
// The tombstone research-loop-cap.mjs appends when it denies a turn for
|
||
// budget. Its presence is what tells this hook "the gate already said no".
|
||
if (exhausted) lines.push(JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, exhausted: true }));
|
||
writeFileSync(join(dir, 'trekresearch-loop-ledger.jsonl'), lines.length ? lines.join('\n') + '\n' : '');
|
||
return dir;
|
||
}
|
||
|
||
// TREKRESEARCH_MAX_CONV_TURNS=1 => budget = 1 * MAX_TOTAL_DIMENSIONS (8).
|
||
const CAPPED_ENV = { VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||
const BUDGET = 8;
|
||
|
||
function searchInput(sessionId = SESSION) {
|
||
return {
|
||
session_id: sessionId,
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'WebSearch',
|
||
tool_input: { query: 'claude code hooks reference' },
|
||
agent_id: 'aa6d19525a4680fe0',
|
||
agent_type: 'general-purpose',
|
||
};
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// DENY — budget spent
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap DENIES once the budget gate has denied a turn (tombstone present)', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2);
|
||
assert.match(stderr, /loop cap/i, 'stderr must name the cap it enforced');
|
||
assert.match(stderr, new RegExp(`${BUDGET}`), 'stderr must state the budget');
|
||
});
|
||
|
||
test('pre-agent-cap DENIES above the budget too (a breached ledger, whatever caused it)', async () => {
|
||
const dir = fixture({ turns: BUDGET + 5 });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ALLOW — under the cap, and ON the cap.
|
||
//
|
||
// allowTurn appends BEFORE the turn runs, so during the FINAL granted turn the
|
||
// ledger already holds `budget` records. Denying at `used >= budget` therefore
|
||
// blocked that turn's own tool calls: the primitive granted B turns, the
|
||
// harness permitted B-1, and an exhausted run always ended through an exit-2
|
||
// denial rather than the graceful "cap exhausted" exit the prose defines. The
|
||
// boundary belongs one turn later, and the tombstone above — not the count —
|
||
// is what marks a run actually finished.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS a loop turn under the budget', async () => {
|
||
const dir = fixture({ turns: BUDGET - 1 });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS the FINAL granted turn — its own record is already on the ledger', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(
|
||
code, 0,
|
||
'turn B is granted and in flight; denying it makes the harness permit B-1 turns and forces the wrong exit',
|
||
);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// DOES NOT OVER-BLOCK — the property that keeps this hook safe to wire
|
||
// globally. A broken PreToolUse hook would brick every session on the box.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS an unrelated session even when a loop is exhausted', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput('some-other-session'), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0, 'no scope marker for this session_id => out of scope');
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS when no scope marker directory exists at all', async () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-empty-'));
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS on unparsable stdin', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, 'not json at all', {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS when the input carries no session_id', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const input = searchInput();
|
||
delete input.session_id;
|
||
const { code } = await runHookWithEnv(CAP_HOOK, input, {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// TTL / auto-reset — a marker left behind by a crashed run must not deny
|
||
// tool calls forever.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS when the scope marker is older than the TTL', async () => {
|
||
const dir = fixture({ turns: BUDGET, startedAt: new Date(Date.now() - 48 * 3600 * 1000) });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
VOYAGE_CAP_SCOPE_TTL_MS: '1000',
|
||
});
|
||
assert.strictEqual(code, 0, 'a stale marker must auto-reset, not deny forever');
|
||
});
|
||
|
||
// The TTL runs from marker.startedAt, not from last activity, and `claude
|
||
// --resume` keeps the same session_id — so a run that died leaving its marker
|
||
// behind hands the resumed session whatever deny window is left. Two things
|
||
// bound that: the window is hours, not the machine's life (below), and it only
|
||
// opens at all once the budget gate has actually denied a turn.
|
||
test('pre-agent-cap ALLOWS a resumed session whose crashed run never exhausted its budget', async () => {
|
||
// Marker still fresh, ledger part-spent, no tombstone: the run died mid-loop.
|
||
const dir = fixture({ turns: 5 });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(
|
||
code, 0,
|
||
'a part-spent run leaves no denial record, so resuming its session must not brick unrelated work',
|
||
);
|
||
});
|
||
|
||
test('pre-agent-cap uses a default TTL of hours, not a day — a 3h-old marker auto-resets', async () => {
|
||
const dir = fixture({
|
||
turns: BUDGET,
|
||
exhausted: true,
|
||
startedAt: new Date(Date.now() - 3 * 3600 * 1000),
|
||
});
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
}); // no VOYAGE_CAP_SCOPE_TTL_MS — this is the built-in default
|
||
assert.strictEqual(code, 0, 'no real research run lasts 3h; a marker that old is debris');
|
||
});
|
||
|
||
test('pre-agent-cap names the marker path when it denies, so the operator has a remedy', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2);
|
||
assert.ok(
|
||
stderr.includes(join(dir, 'trekresearch-loop-scope', `${SESSION}.json`)),
|
||
`stderr must name the marker to delete; got:\n${stderr}`,
|
||
);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Fail CLOSED once in scope — the hook's own header says a budget control
|
||
// that cannot count must not grant. The unreadable-ledger branch returned 0
|
||
// and therefore ALLOWED, which is the opposite. A directory standing where
|
||
// the ledger file belongs reproduces it portably (EISDIR).
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap DENIES when the ledger cannot be read at all (fail closed)', async () => {
|
||
const dir = fixture({ turns: 0 });
|
||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
rmSync(ledgerPath, { force: true });
|
||
mkdirSync(ledgerPath, { recursive: true });
|
||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2, 'an in-scope run whose ledger cannot be counted must not be granted');
|
||
assert.match(stderr, /could not be read|unreadable/i, 'stderr must say counting failed, not that the budget is spent');
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS an unreadable ledger when the session is OUT of scope', async () => {
|
||
const dir = fixture({ turns: 0, sessionId: 'a-different-session' });
|
||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
rmSync(ledgerPath, { force: true });
|
||
mkdirSync(ledgerPath, { recursive: true });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0, 'fail-closed is scoped to the loop, it must not brick unrelated sessions');
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Kill switch + default-off
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap kill switch VOYAGE_DISABLE_CAP_HOOK=1 allows an exhausted loop', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
VOYAGE_DISABLE_CAP_HOOK: '1',
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap is inert when VOYAGE_STORM_ENABLED is not 1 (default-off)', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
VOYAGE_STORM_ENABLED: '0',
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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 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', slot: i + 1 })),
|
||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, exhausted: true }),
|
||
].join('\n') + '\n',
|
||
);
|
||
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: '',
|
||
HOME: home,
|
||
});
|
||
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);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Append-only counting — the hook reads the ledger, it never writes it.
|
||
// Writing per tool call would make the cap count its own enforcement.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap never writes to the ledger', async () => {
|
||
const dir = fixture({ turns: 2 });
|
||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
const before = readFileSync(ledgerPath, 'utf-8');
|
||
await runHookWithEnv(CAP_HOOK, searchInput(), { ...CAPPED_ENV, CLAUDE_PLUGIN_DATA: dir });
|
||
assert.strictEqual(readFileSync(ledgerPath, 'utf-8'), before);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Crash-time marker states. These are the states the TTL discussion in the
|
||
// hook header anticipates, and none of them had a test: a marker written
|
||
// half-way, and a marker whose runId never made it. Both must ALLOW — a
|
||
// marker we cannot read cannot tell us which run we are in, and guessing
|
||
// would deny tool calls in a session we know nothing about.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS on a partially written (corrupt) scope marker', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
// Exactly what an interrupted printf leaves behind: valid prefix, no close.
|
||
writeFileSync(join(dir, 'trekresearch-loop-scope', `${SESSION}.json`), '{"runId":"run-xyz789","star');
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0, 'an unparsable marker is not evidence of a loop turn');
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS a marker that carries no runId', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
writeFileSync(
|
||
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||
JSON.stringify({ startedAt: new Date().toISOString() }),
|
||
);
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0, 'without a runId there are no ledger lines to count against');
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS a marker whose runId is not a string', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
// Truthy, so it clears the `!marker?.runId` guard and the session counts as
|
||
// in scope — but readLedger compares runId with ===, so a number matches no
|
||
// record and the run reads as 0 turns spent. Allow is the right answer either
|
||
// way, which is why this stays a pin on the OUTCOME and not an argument for a
|
||
// type guard: no writer emits a non-string runId, and the two routes are
|
||
// indistinguishable from outside.
|
||
writeFileSync(
|
||
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||
JSON.stringify({ runId: 5, startedAt: new Date().toISOString() }),
|
||
);
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS a marker whose runId is the empty string', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
writeFileSync(
|
||
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||
JSON.stringify({ runId: '', startedAt: new Date().toISOString() }),
|
||
);
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Malformed ledger lines — a truncated final write must not be counted as a
|
||
// turn, and must not stop the well-formed lines around it from counting.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap does not count a malformed ledger line as a turn', async () => {
|
||
const dir = fixture({ turns: BUDGET - 1 });
|
||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
writeFileSync(ledgerPath, readFileSync(ledgerPath, 'utf-8') + '{"runId":"run-xyz789","dimen\n');
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0, 'a half-written line is not a spent turn');
|
||
});
|
||
|
||
test('pre-agent-cap still finds the tombstone with malformed lines around it', async () => {
|
||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
writeFileSync(ledgerPath, '{ garbage\n' + readFileSync(ledgerPath, 'utf-8') + 'also garbage\n');
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2, 'skipping bad lines must not mean skipping the run’s denial record');
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// The marker snippet is EXECUTED, not asserted about.
|
||
//
|
||
// Every existing pin on the marker lifecycle is a substring assertion on the
|
||
// prose in commands/trekresearch.md. A snippet that emitted invalid JSON, or
|
||
// wrote to a path the hook never looks at, would keep the whole suite green
|
||
// while the hook silently allowed everything — which is the exact failure S82
|
||
// found by hand. So these tests run the real shell blocks out of the command
|
||
// file, with CLAUDE_PLUGIN_DATA stripped and HOME sandboxed, and then run the
|
||
// real hook against what they produced.
|
||
// -----------------------------------------------------------------------
|
||
const CMD_FILE = join(ROOT, 'commands', 'trekresearch.md');
|
||
|
||
/** Pull the ```bash block that contains `needle` out of the command file. */
|
||
function bashBlockContaining(needle) {
|
||
const text = readFileSync(CMD_FILE, 'utf-8');
|
||
const at = text.indexOf(needle);
|
||
assert.ok(at > -1, `commands/trekresearch.md no longer contains ${JSON.stringify(needle)}`);
|
||
const open = text.lastIndexOf('```bash', at);
|
||
assert.ok(open > -1, `no \`\`\`bash fence opens before ${JSON.stringify(needle)}`);
|
||
const bodyStart = text.indexOf('\n', open) + 1;
|
||
const close = text.indexOf('```', bodyStart);
|
||
assert.ok(close > bodyStart, 'unterminated bash fence');
|
||
return text.slice(bodyStart, close);
|
||
}
|
||
|
||
function runSnippet(snippet, env) {
|
||
return execFileSync('bash', ['-c', snippet], {
|
||
encoding: 'utf-8',
|
||
env: { PATH: process.env.PATH, ...env },
|
||
});
|
||
}
|
||
|
||
test('the marker WRITE snippet lands valid JSON exactly where the hook looks for it', () => {
|
||
const home = mkdtempSync(join(tmpdir(), 'voyage-snippet-'));
|
||
const sessionId = 'snippet-session-1';
|
||
const snippet = bashBlockContaining('Arms the PreToolUse cap').replace(/\{run_id\}/g, 'snippet-run-1');
|
||
|
||
runSnippet(snippet, { HOME: home, CLAUDE_CODE_SESSION_ID: sessionId });
|
||
|
||
const markerPath = join(home, '.claude', 'voyage', 'trekresearch-loop-scope', `${sessionId}.json`);
|
||
assert.ok(existsSync(markerPath), `snippet wrote no marker at ${markerPath}`);
|
||
const marker = JSON.parse(readFileSync(markerPath, 'utf-8')); // throws if the printf emits bad JSON
|
||
assert.strictEqual(marker.runId, 'snippet-run-1', 'runId must be the same id passed to --run-id');
|
||
assert.ok(Number.isFinite(Date.parse(marker.startedAt)), `startedAt must parse, got ${marker.startedAt}`);
|
||
});
|
||
|
||
test('the marker snippet writes NO file when CLAUDE_CODE_SESSION_ID is empty', () => {
|
||
const home = mkdtempSync(join(tmpdir(), 'voyage-snippet-'));
|
||
const snippet = bashBlockContaining('Arms the PreToolUse cap').replace(/\{run_id\}/g, 'snippet-run-2');
|
||
|
||
const out = runSnippet(snippet, { HOME: home });
|
||
|
||
const scopeDir = join(home, '.claude', 'voyage', 'trekresearch-loop-scope');
|
||
assert.ok(!existsSync(join(scopeDir, '.json')), 'an empty session id must not produce a `.json` marker');
|
||
assert.match(out, /stays inert/i, 'the snippet must say the harness cap is inert, not fail silently');
|
||
});
|
||
|
||
test('write snippet then real hook: the loop’s own writer arms the enforcement end to end', async () => {
|
||
const home = mkdtempSync(join(tmpdir(), 'voyage-snippet-'));
|
||
const sessionId = 'snippet-session-3';
|
||
const runId = 'snippet-run-3';
|
||
|
||
runSnippet(
|
||
bashBlockContaining('Arms the PreToolUse cap').replace(/\{run_id\}/g, runId),
|
||
{ HOME: home, CLAUDE_CODE_SESSION_ID: sessionId },
|
||
);
|
||
// A spent, tombstoned ledger for that same runId, under the same resolved root.
|
||
writeFileSync(
|
||
join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl'),
|
||
[
|
||
...Array.from({ length: BUDGET }, (_, i) =>
|
||
JSON.stringify({ ts: new Date().toISOString(), runId, dimension: `d${i}`, effort: 'high', slot: i + 1 })),
|
||
JSON.stringify({ ts: new Date().toISOString(), runId, exhausted: true }),
|
||
].join('\n') + '\n',
|
||
);
|
||
|
||
const denied = await runHookWithEnv(CAP_HOOK, searchInput(sessionId), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: '',
|
||
HOME: home,
|
||
});
|
||
assert.strictEqual(denied.code, 2, 'the hook must find the snippet’s marker and enforce against it');
|
||
|
||
// And the removal snippet must disarm it again — same root, same guard.
|
||
runSnippet(
|
||
bashBlockContaining('Removal — idempotent').replace(/\{run_id\}/g, runId),
|
||
{ HOME: home, CLAUDE_CODE_SESSION_ID: sessionId },
|
||
);
|
||
assert.ok(
|
||
!existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-scope', `${sessionId}.json`)),
|
||
'the removal snippet must delete the marker the write snippet created',
|
||
);
|
||
const allowed = await runHookWithEnv(CAP_HOOK, searchInput(sessionId), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: '',
|
||
HOME: home,
|
||
});
|
||
assert.strictEqual(allowed.code, 0, 'a removed marker must take the session back out of scope');
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Wiring — pattern from tests/hooks/hooks-json-stop-wired.test.mjs
|
||
// -----------------------------------------------------------------------
|
||
function invocationOf(h) {
|
||
return [h.command || '', ...(h.args || [])].join(' ').trim();
|
||
}
|
||
|
||
test('hooks.json wires pre-agent-cap.mjs on PreToolUse with ${CLAUDE_PLUGIN_ROOT}', () => {
|
||
const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
|
||
const invocations = (cfg.hooks.PreToolUse || []).flatMap((entry) =>
|
||
(entry.hooks || []).map(invocationOf),
|
||
);
|
||
const capInvocation = invocations.find((cmd) => cmd.includes('pre-agent-cap.mjs'));
|
||
assert.ok(capInvocation, `no PreToolUse hook references pre-agent-cap.mjs. Found: ${JSON.stringify(invocations)}`);
|
||
assert.match(capInvocation, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'relative paths fail in headless sessions');
|
||
assert.match(capInvocation, /^node\s+/);
|
||
});
|
||
|
||
test('hooks.json matcher for pre-agent-cap covers the loop’s outbound surface', () => {
|
||
const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
|
||
const entry = (cfg.hooks.PreToolUse || []).find((e) =>
|
||
(e.hooks || []).some((h) => invocationOf(h).includes('pre-agent-cap.mjs')),
|
||
);
|
||
assert.ok(entry, 'pre-agent-cap entry missing from PreToolUse');
|
||
for (const tool of ['WebSearch', 'WebFetch', 'Task']) {
|
||
assert.match(entry.matcher, new RegExp(tool), `matcher must cover ${tool}`);
|
||
}
|
||
});
|