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:
parent
21480e8627
commit
32621dcb63
30 changed files with 34 additions and 4070 deletions
|
|
@ -1,35 +0,0 @@
|
|||
// tests/commands/trekresearch-engine.test.mjs
|
||||
// Step 1 (deep-research-engine): pin the contract the `--engine deep-research`
|
||||
// adapter must hit. The adapted in-context `/deep-research` report, reduced into
|
||||
// the research-brief schema, must pass research-validator under the strict
|
||||
// default; and a brief missing a required section must fail. This is the one
|
||||
// genuinely automatable slice of SC2 (schema, not provenance).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateResearchContent } from '../../lib/validators/research-validator.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const FIXTURE = join(ROOT, 'tests', 'fixtures', 'research-deep-research-adapted.md');
|
||||
|
||||
test('deep-research adapter output contract — valid brief passes, missing section fails', () => {
|
||||
const text = readFileSync(FIXTURE, 'utf-8');
|
||||
|
||||
// (a) positive: the adapter's target output passes the validator (default = strict).
|
||||
const okResult = validateResearchContent(text);
|
||||
assert.equal(okResult.valid, true, JSON.stringify(okResult.errors));
|
||||
|
||||
// (b) negative: stripping a required section makes it fail with RESEARCH_MISSING_SECTION,
|
||||
// giving the contract teeth (a fixture that always passes proves nothing).
|
||||
const mutated = text.replace('## Dimensions', '## Removed');
|
||||
const badResult = validateResearchContent(mutated);
|
||||
assert.equal(badResult.valid, false);
|
||||
assert.ok(
|
||||
badResult.errors.find(e => e.code === 'RESEARCH_MISSING_SECTION'),
|
||||
'expected RESEARCH_MISSING_SECTION; got ' + JSON.stringify(badResult.errors),
|
||||
);
|
||||
});
|
||||
|
|
@ -54,7 +54,7 @@ test('trekresearch — low-effort path references --quick equivalent', () => {
|
|||
'heading-bounded slice must still fail on a genuine removal');
|
||||
});
|
||||
|
||||
// --- Step 7: Phase 5 bounded conversation loop (heading-bounded slices) ---
|
||||
// --- Phase 5 slice (heading-bounded) ---
|
||||
|
||||
// Same bounding discipline as the Composition-rule pin above: slice from the
|
||||
// phase heading to the NEXT phase heading, never a fixed character window.
|
||||
|
|
@ -70,142 +70,6 @@ function phase5(doc) {
|
|||
return phaseSlice(doc, '## Phase 5 —', '## Phase 6 —');
|
||||
}
|
||||
|
||||
test('trekresearch — Phase 5 loop is gated on effort == high and names both primitives', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /effort == 'high'/, 'Phase 5 loop must be gated on effort == \'high\'');
|
||||
assert.match(p5, /research-loop-cap\.mjs/, 'Phase 5 must call the loop-cap shim per turn');
|
||||
assert.match(p5, /query-privacy-gate\.mjs/, 'Phase 5 must route outbound queries through the privacy gate');
|
||||
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');
|
||||
assert.match(
|
||||
p5,
|
||||
/\*\*Maximum 3 turns per under-illuminated dimension\.\*\*/,
|
||||
'the bound must be stated verbatim',
|
||||
);
|
||||
// Three exits — converged / cap exhausted / operator stop.
|
||||
assert.match(p5, /converged/i, 'exit 1 (converged) must be documented');
|
||||
assert.match(p5, /exhaust/i, 'exit 2 (cap exhausted) must be documented');
|
||||
assert.match(p5, /operator stop/i, 'exit 3 (operator stop) must be documented');
|
||||
// Exhaustion must reach the operator — a silent cap is indistinguishable
|
||||
// from convergence, which is the failure this loop exists to avoid.
|
||||
assert.match(
|
||||
p5,
|
||||
/visibl|visible|print/i,
|
||||
'cap exhaustion must be written visibly to the operator',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 marks empty turns without re-targeting the same dimension', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /`empty`/, 'a finding-less or citation-less turn must be marked `empty`');
|
||||
assert.match(p5, /empty_turns/, 'empty turns must be counted (empty_turns)');
|
||||
assert.match(
|
||||
p5,
|
||||
/does NOT re-target|not re-target/i,
|
||||
'an empty turn must not re-target the same dimension',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 states the no-brief default and the moot precedence matrix', () => {
|
||||
const p5 = phase5(read());
|
||||
// (a) no-brief default
|
||||
assert.match(p5, /effort = 'standard'/, 'no-brief default effort must be stated');
|
||||
assert.match(
|
||||
p5,
|
||||
/--project/,
|
||||
'the no-brief default must be anchored to the absence of --project/brief.md',
|
||||
);
|
||||
// (b) precedence matrix — each entry independently makes the loop moot,
|
||||
// mirroring the --engine moot gate in Phase 4.
|
||||
for (const token of ['--quick', '--local', 'external_research_enabled']) {
|
||||
assert.ok(p5.includes(token), `moot matrix must name ${token}`);
|
||||
}
|
||||
assert.match(p5, /moot/i, 'the matrix must use the same moot vocabulary as the engine gate');
|
||||
// (c) interaction rule — effort: high without model under a cheap profile.
|
||||
assert.match(
|
||||
p5,
|
||||
/effort: high/,
|
||||
'the interaction rule for a brief carrying effort: high without model must be stated',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 restates the honesty rule for loop output', () => {
|
||||
const p5 = phase5(read());
|
||||
// Whitespace-tolerant: the pin is on the sentence, not on where the
|
||||
// paragraph happens to wrap.
|
||||
assert.match(
|
||||
p5,
|
||||
/more\s+turns\s+do\s+not\s+make\s+a\s+finding\s+more\s+credible/i,
|
||||
'the honesty hard rule must be restated for the loop output',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 pins survive only while the prose does (mutation control)', () => {
|
||||
const text = read();
|
||||
const mutated = text.replace(/research-loop-cap\.mjs/g, 'removed-cap.mjs');
|
||||
assert.doesNotMatch(
|
||||
phase5(mutated),
|
||||
/research-loop-cap\.mjs/,
|
||||
'heading-bounded Phase 5 slice must still fail on a genuine removal',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
|
||||
const text = read();
|
||||
const flatText = text.replace(/\s+/g, ' ');
|
||||
|
|
@ -223,64 +87,6 @@ test('trekresearch — High-effort behavior gives standard effort the wave-2 sen
|
|||
);
|
||||
});
|
||||
|
||||
// --- Step 8: Phase 4.5 dimension discovery + Independence amendment ---
|
||||
|
||||
test('trekresearch — Phase 4.5 exists between Phase 4 and Phase 5 with the effort skip-guard', () => {
|
||||
const text = read();
|
||||
const p45 = text.indexOf('## Phase 4.5 —');
|
||||
assert.ok(p45 >= 0, 'Phase 4.5 heading missing');
|
||||
const p4 = text.indexOf('## Phase 4 —');
|
||||
const p5 = text.indexOf('## Phase 5 —');
|
||||
assert.ok(p4 >= 0 && p5 > p45 && p45 > p4, 'Phase 4.5 must sit between Phase 4 and Phase 5');
|
||||
|
||||
const slice = text.slice(p45, p5);
|
||||
assert.match(
|
||||
slice,
|
||||
/\*\*Skip this phase entirely unless `phase_signal_result\.effort == 'high'`/,
|
||||
'Phase 4.5 must carry the bolded skip-guard in the Phase 3.5 form',
|
||||
);
|
||||
assert.match(slice, /query-privacy-gate\.mjs/,
|
||||
'Phase 4.5 must name the privacy gate as its compensating control');
|
||||
assert.match(slice, /maxDimensions: 8|maxDimensions` *: *8/,
|
||||
'Phase 4.5 must augment under the existing maxDimensions ceiling, not raise it');
|
||||
});
|
||||
|
||||
// Phase 4.5 never invokes research-loop-cap.mjs, so the cap's own flag check
|
||||
// does not reach it. Gating on effort alone means unsetting VOYAGE_STORM_ENABLED
|
||||
// leaves half the mechanism live and the decline branch unreachable — while
|
||||
// CLAUDE.md claims both phases go inert. The guard has to name both conditions.
|
||||
test('trekresearch — Phase 4.5 skip-guard is gated on VOYAGE_STORM_ENABLED as well as effort', () => {
|
||||
const text = read();
|
||||
const p45 = text.indexOf('## Phase 4.5 —');
|
||||
const p5 = text.indexOf('## Phase 5 —');
|
||||
const slice = text.slice(p45, p5);
|
||||
|
||||
const guard = slice.slice(0, slice.indexOf('\n\n', slice.indexOf('**Skip this phase')));
|
||||
assert.match(guard, /VOYAGE_STORM_ENABLED/,
|
||||
'the Phase 4.5 skip-guard must name VOYAGE_STORM_ENABLED, not effort alone');
|
||||
assert.match(guard, /\bAND\b|\*\*and\*\*/,
|
||||
'the guard must be a conjunction — both conditions, not either');
|
||||
assert.match(slice, /decline/i,
|
||||
'Phase 4.5 must say why the flag gates it: the decline branch has to stay reachable');
|
||||
});
|
||||
|
||||
test('trekresearch — Independence hard rule carries an explicit Phase 4.5 amendment', () => {
|
||||
const text = read();
|
||||
const rulesIdx = text.indexOf('## Hard rules');
|
||||
assert.ok(rulesIdx >= 0, 'Hard rules section missing');
|
||||
const rules = text.slice(rulesIdx);
|
||||
const indIdx = rules.indexOf('**Independence:**');
|
||||
assert.ok(indIdx >= 0, 'Independence hard rule missing');
|
||||
// Bound the rule at the next bullet so the amendment must live inside it.
|
||||
const nextBullet = rules.indexOf('\n- **', indIdx);
|
||||
const independence = nextBullet > indIdx ? rules.slice(indIdx, nextBullet) : rules.slice(indIdx);
|
||||
assert.match(independence, /Amend(ed|ment)/i,
|
||||
'Independence must be explicitly amended, not silently contradicted');
|
||||
assert.match(independence, /Phase 4\.5/, 'the amendment must name Phase 4.5 as the crossing');
|
||||
assert.match(independence, /query-privacy-gate\.mjs/,
|
||||
'the amendment must name the compensating control');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 ---
|
||||
|
||||
test('trekresearch — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
|
||||
|
|
@ -331,7 +137,8 @@ function paragraphAt(text, marker) {
|
|||
test('trekresearch — Phase 5 follow-ups take phase_signal_result.model, else opus', () => {
|
||||
const text = read();
|
||||
assert.doesNotMatch(flat(phase5(text)), /model: "opus"/);
|
||||
const part = paragraphAt(text, 'launch a targeted follow-up agent');
|
||||
const part = paragraphAt(phase5(text), 'launch a targeted follow-up agent');
|
||||
assert.match(part, /Maximum 2 follow-ups\./);
|
||||
assert.match(part, /launch a targeted follow-up agent \(model: phase_signal_result\.model, else opus — a follow-up may be one of Claude Code's built-in agents, which have no Voyage frontmatter to fall back to; see Launch rules\)/);
|
||||
assert.match(flat(text), /captured as `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject the resolved model/);
|
||||
});
|
||||
|
|
|
|||
49
tests/fixtures/research-deep-research-adapted.md
vendored
49
tests/fixtures/research-deep-research-adapted.md
vendored
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
type: trekresearch-brief
|
||||
created: 2026-06-30
|
||||
question: "Should /trekresearch delegate its external phase to the built-in /deep-research workflow?"
|
||||
confidence: 0.8
|
||||
dimensions: 2
|
||||
mcp_servers_used: []
|
||||
local_agents_used: []
|
||||
external_agents_used:
|
||||
- deep-research
|
||||
---
|
||||
|
||||
# Deep-research engine adapter output
|
||||
|
||||
> Fixture: a `/deep-research` in-context report reduced into the research-brief
|
||||
> schema by the `--engine deep-research` adapter (Step 4). Models the target the
|
||||
> adapter must hit; not real engine output.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Delegating the external phase to the built-in `/deep-research` workflow is a
|
||||
viable opt-in engine that supplies fan-out and cited claim-verification for free.
|
||||
Confidence is medium-high on the mechanism but lower on availability, because the
|
||||
workflow exposes no positive "is-enabled" probe. The load-bearing caveat is
|
||||
provenance: structural validity does not certify that the cited URLs are real, so
|
||||
a swarm fallback plus a human URL spot-check stay mandatory.
|
||||
|
||||
## Dimensions
|
||||
|
||||
### Engine mechanism -- Confidence: high
|
||||
|
||||
**External findings:**
|
||||
- `/deep-research` is a built-in dynamic workflow reachable only by prose instruction, with no programmatic API (https://code.claude.com/docs/workflows).
|
||||
- Its report lands in-context with no on-disk artifact, so the adapter must transform what is already in the turn (https://code.claude.com/docs/commands).
|
||||
|
||||
### Fallback ergonomics -- Confidence: high
|
||||
|
||||
**External findings:**
|
||||
- There is no positive availability probe; only `disableWorkflows` / `CLAUDE_CODE_DISABLE_WORKFLOWS` off-switches and a 2.1.154 version floor are documented (https://code.claude.com/docs/skills).
|
||||
- Disabled-workflow behavior under `claude -p` is undocumented, so the post-hoc presence check must be robust to every failure manifestation (https://github.com/anthropics/claude-code/issues/52272).
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | Type | Quality | Used in |
|
||||
|---|--------|------|---------|---------|
|
||||
| 1 | https://code.claude.com/docs/workflows | official | high | Engine mechanism |
|
||||
| 2 | https://code.claude.com/docs/commands | official | high | Engine mechanism |
|
||||
| 3 | https://code.claude.com/docs/skills | official | high | Fallback ergonomics |
|
||||
| 4 | https://github.com/anthropics/claude-code/issues/52272 | community | medium | Fallback ergonomics |
|
||||
|
|
@ -1,556 +0,0 @@
|
|||
// 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
|
||||
// -----------------------------------------------------------------------
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// tests/hooks/storm-matcher.test.mjs
|
||||
// The STORM cap (pre-agent-cap.mjs) must see subagent spawns. The tool was
|
||||
// renamed Task -> Agent in Claude Code 2.1.63; the docs state the old name
|
||||
// still works as an alias «in settings and agent definitions»
|
||||
// (code.claude.com/docs/en/sub-agents, the rename note) and say nothing about
|
||||
// hook matchers, which compare against the tool name the hook input carries.
|
||||
// So the matcher must name `Agent` itself. Resolved with Claude Code's matcher
|
||||
// rules (hooks § Matcher patterns), as write-guard-coverage.test.mjs does.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { scriptsFor } from './guard-helpers.mjs';
|
||||
|
||||
const fires = (tool) => scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-agent-cap.mjs'));
|
||||
|
||||
test('STORM cap fires on Agent (the tool name since 2.1.63)', () => {
|
||||
assert.ok(fires('Agent'), 'pre-agent-cap.mjs must fire for tool_name "Agent"');
|
||||
});
|
||||
|
||||
test('STORM cap still fires on WebSearch, WebFetch and the old Task name', () => {
|
||||
for (const t of ['WebSearch', 'WebFetch', 'Task']) assert.ok(fires(t), t);
|
||||
});
|
||||
|
||||
test('STORM cap does not fire on unrelated tools', () => {
|
||||
for (const t of ['Bash', 'Edit', 'Write', 'Read', 'AgentOutput', 'ExitPlanMode']) assert.ok(!fires(t), t);
|
||||
});
|
||||
|
|
@ -980,201 +980,22 @@ test('S18: --min-brief-version is documented at the trekplan + trekresearch boun
|
|||
}
|
||||
});
|
||||
|
||||
test('deep-research-engine: --engine is documented + consistent across surfaces', () => {
|
||||
// Cross-doc pin mirroring S18 (:1057). The opt-in external-research engine flag
|
||||
// must be discoverable wherever /trekresearch flags live: the command itself
|
||||
// plus the three reference surfaces.
|
||||
for (const f of ['commands/trekresearch.md', 'docs/command-modes.md', 'CLAUDE.md', 'README.md']) {
|
||||
assert.ok(
|
||||
read(f).includes('--engine'),
|
||||
`${f} must document the --engine flag (deep-research-engine)`,
|
||||
);
|
||||
}
|
||||
// README documents it specifically as an **Engine** mode-table row.
|
||||
assert.ok(
|
||||
/\*\*Engine\*\*/.test(read('README.md')),
|
||||
'README.md must document --engine as an **Engine** mode row',
|
||||
);
|
||||
// The command prose must name both engine values and the swarm fallback, so the
|
||||
// opt-in + graceful-degradation contract is pinned — not merely the flag string.
|
||||
const research = read('commands/trekresearch.md');
|
||||
assert.ok(/\bswarm\b/.test(research), 'trekresearch.md must name the swarm engine value');
|
||||
assert.ok(/\bdeep-research\b/.test(research), 'trekresearch.md must name the deep-research engine value');
|
||||
assert.ok(
|
||||
/fall back|falls back/.test(research),
|
||||
'trekresearch.md must document the swarm fallback (graceful degradation)',
|
||||
);
|
||||
});
|
||||
|
||||
test('deep-research-engine: the CC 2.1.218 operator-only ceiling is documented on every surface', () => {
|
||||
// CC 2.1.218 changed /deep-research to "start only when invoked manually"; from
|
||||
// there the Skill tool refuses a model invocation outright with
|
||||
// `disable-model-invocation`. The flag stays (additive opt-in, always degrades to
|
||||
// swarm), but no surface may keep promising a path Claude Code has removed, and
|
||||
// none may leave `2.1.154+` standing alone as the engine's requirement.
|
||||
const SURFACES = ['commands/trekresearch.md', 'docs/command-modes.md', 'README.md'];
|
||||
for (const f of SURFACES) {
|
||||
const body = read(f);
|
||||
assert.ok(
|
||||
body.includes('2.1.218'),
|
||||
`${f} must document the CC 2.1.218 ceiling for the deep-research engine`,
|
||||
);
|
||||
assert.ok(
|
||||
body.includes('disable-model-invocation'),
|
||||
`${f} must name disable-model-invocation as the fallback reason from CC 2.1.218`,
|
||||
);
|
||||
}
|
||||
|
||||
// The one-line reference rows carry the whole truth on their own line — an
|
||||
// operator reading the flag table never scrolls to the command prose.
|
||||
const ROWS = [
|
||||
['docs/command-modes.md', /^.*`--engine \{swarm\\\|deep-research\}`.*$/m],
|
||||
['README.md', /^\|\s*\*\*Engine\*\*.*$/m],
|
||||
];
|
||||
for (const [f, re] of ROWS) {
|
||||
const m = read(f).match(re);
|
||||
assert.ok(m, `${f} must still carry the --engine reference row`);
|
||||
assert.ok(
|
||||
m[0].includes('2.1.218') && m[0].includes('disable-model-invocation'),
|
||||
`${f} --engine row must state the real window (2.1.218 + disable-model-invocation)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Both load-bearing regions of the command itself: the flag bullet and the
|
||||
// pre-gate that decides whether the delegation is attempted at all.
|
||||
const research = read('commands/trekresearch.md');
|
||||
const bullet = research.match(/8\. `--engine <name>`[\s\S]*?Flags can be combined/);
|
||||
assert.ok(bullet, 'trekresearch.md must still carry the --engine flag bullet');
|
||||
assert.ok(
|
||||
bullet[0].includes('2.1.218') && bullet[0].includes('disable-model-invocation'),
|
||||
'trekresearch.md --engine bullet must state the real window, not just the 2.1.154 floor',
|
||||
);
|
||||
const pregate = research.match(/\*\*Coarse pre-gate[\s\S]*?authoritative guard\./);
|
||||
assert.ok(pregate, 'trekresearch.md must still carry the deep-research pre-gate');
|
||||
assert.ok(
|
||||
pregate[0].includes('2.1.218'),
|
||||
'the pre-gate must carry an upper ceiling at 2.1.218, not only the 2.1.154 floor',
|
||||
);
|
||||
});
|
||||
|
||||
// ── STORM bounded loop — env-vars documented across the four surfaces ──────
|
||||
// Same cross-doc shape as the --engine pin above. An operator-facing switch
|
||||
// documented on one surface is a switch most operators never find; and the
|
||||
// three below decide cost, so they are the ones worth pinning.
|
||||
|
||||
const STORM_SURFACES = ['docs/command-modes.md', 'CLAUDE.md', 'README.md', 'docs/architecture.md'];
|
||||
const STORM_ENV_VARS = ['VOYAGE_STORM_ENABLED', 'TREKRESEARCH_MAX_CONV_TURNS', 'VOYAGE_DISABLE_CAP_HOOK'];
|
||||
|
||||
for (const envVar of STORM_ENV_VARS) {
|
||||
test(`STORM: ${envVar} is documented on all four reference surfaces`, () => {
|
||||
for (const f of STORM_SURFACES) {
|
||||
assert.ok(read(f).includes(envVar), `${f} must document ${envVar} (STORM bounded-loop env-vars)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('STORM: VOYAGE_STORM_ENABLED is documented WITH its default-off contract', () => {
|
||||
// Naming a switch without its default is how a default-on mechanism ships by
|
||||
// accident. Default-off is also the decline branch: declining costs nothing.
|
||||
for (const f of STORM_SURFACES) {
|
||||
const t = read(f);
|
||||
const i = t.indexOf('VOYAGE_STORM_ENABLED');
|
||||
const window = t.slice(Math.max(0, i - 400), i + 400);
|
||||
assert.match(
|
||||
window,
|
||||
/default-off|default off|opt-in/i,
|
||||
`${f}: VOYAGE_STORM_ENABLED must be documented together with its default-off contract`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The flag gates BOTH STORM phases, not just the Phase 5 loop. A surface that
|
||||
// scopes it to "the loop" tells an operator that unsetting it still leaves
|
||||
// Phase 4.5 discovery running — which was true until the guard was fixed, and
|
||||
// is the half-off state the decline branch cannot survive.
|
||||
test('STORM: VOYAGE_STORM_ENABLED is documented as gating BOTH phases, not the loop alone', () => {
|
||||
for (const f of STORM_SURFACES) {
|
||||
const t = read(f);
|
||||
const i = t.indexOf('VOYAGE_STORM_ENABLED');
|
||||
const window = t.slice(Math.max(0, i - 400), i + 400);
|
||||
assert.match(
|
||||
window,
|
||||
/4\.5|discovery|dimension discovery|both/i,
|
||||
`${f}: VOYAGE_STORM_ENABLED must be documented as gating Phase 4.5 too, not only the Phase 5 loop`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 the maxDimensions: 8 ceiling.
|
||||
// Rewriting this prose upward would raise a ceiling the brief asked us to hold.
|
||||
test('README research-dimension prose stays at the existing 3–8 ceiling', () => {
|
||||
// /trekresearch Phase 2 determines 3-8 research dimensions; the README must not raise it.
|
||||
assert.ok(
|
||||
read('README.md').includes('3–8 research dimensions'),
|
||||
'README.md must keep the "3–8 research dimensions" prose — augmentation happens under the existing cap, it does not raise it',
|
||||
'README.md must keep the "3–8 research dimensions" prose — it matches the Phase 2 ceiling, it does not raise it',
|
||||
);
|
||||
});
|
||||
|
||||
// The Phase 4.5 amendment to the Independence hard rule crosses that rule
|
||||
// deliberately: discovered dimensions are mined from Phase-4 output, which holds
|
||||
// local-agent findings, so a discovered dimension can steer an external query.
|
||||
// The crossing is defensible — bounded, disclosed, and resolving a tension the
|
||||
// brief created itself. What was not defensible was naming query-privacy-gate.mjs
|
||||
// as its compensating control: that gate inspects outbound query CONTENT for
|
||||
// paths, repo identifiers and secret-shaped strings. It compensates the EGRESS
|
||||
// risk the crossing creates. It cannot stop a local finding from shaping an
|
||||
// external agent's question, so the bias risk was left with no named control
|
||||
// while the text read as though it had one.
|
||||
test('STORM: the Independence amendment does not name the privacy gate as the BIAS control', () => {
|
||||
const t = read('commands/trekresearch.md');
|
||||
const amendment = t.slice(t.indexOf('**Independence:**'), t.indexOf('**Graceful degradation:**'));
|
||||
assert.ok(amendment.length > 100, 'the Independence hard rule and its amendment must still be present');
|
||||
assert.ok(
|
||||
!/compensating\s+control\s+is\s+`query-privacy-gate/.test(amendment),
|
||||
'query-privacy-gate.mjs compensates egress, not bias — naming it as THE compensating control for the ' +
|
||||
'Independence crossing claims a control the gate cannot provide',
|
||||
);
|
||||
// The bias risk must carry a control that actually bears on bias.
|
||||
assert.match(
|
||||
amendment,
|
||||
/contrarian-researcher/,
|
||||
'the amendment must name the control that does bear on bias — contrarian-researcher runs unconditionally ' +
|
||||
'at effort: high, the only effort at which the crossing happens',
|
||||
);
|
||||
assert.match(
|
||||
amendment,
|
||||
/egress/i,
|
||||
'the privacy gate should still be named, as the control for the egress risk the same crossing creates',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM: no banned Sonnet-swarm phrase introduced on any STORM surface', () => {
|
||||
test('no banned Sonnet-swarm phrase on the reference surfaces', () => {
|
||||
const BANNED = [
|
||||
'Sonnet exploration',
|
||||
'Sonnet runs the exploration',
|
||||
'front-loads cheap Sonnet',
|
||||
'exploration agents stay on Sonnet',
|
||||
];
|
||||
for (const f of STORM_SURFACES) {
|
||||
for (const f of ['docs/command-modes.md', 'CLAUDE.md', 'README.md', 'docs/architecture.md']) {
|
||||
const t = read(f);
|
||||
for (const phrase of BANNED) {
|
||||
assert.ok(!t.includes(phrase), `${f} must not claim "${phrase}" — sub-agents are opus-pinned`);
|
||||
|
|
@ -1182,113 +1003,6 @@ test('STORM: no banned Sonnet-swarm phrase introduced on any STORM surface', ()
|
|||
}
|
||||
});
|
||||
|
||||
// ── STORM bounded loop — Phase 5 scope-marker wiring (S79) ────────────────
|
||||
// hooks/scripts/pre-agent-cap.mjs enforces the loop bound ONLY while a scope
|
||||
// marker exists for the calling session. Nothing wrote that marker, so the
|
||||
// hook shipped correct-but-latent. These pins bind the two ends of one
|
||||
// contract: the reader (the hook) and the writer (Phase 5 prose). Both sides
|
||||
// are derived from the hook source where possible, so drift in EITHER
|
||||
// direction fails — renaming the directory in the hook breaks the prose pin
|
||||
// just as rewriting the prose does.
|
||||
|
||||
const CAP_HOOK_SRC = read('hooks/scripts/pre-agent-cap.mjs');
|
||||
const RESEARCH_CMD = read('commands/trekresearch.md');
|
||||
|
||||
function phase5Section(text) {
|
||||
const start = text.indexOf('## Phase 5');
|
||||
const end = text.indexOf('## Phase 6', start);
|
||||
assert.ok(start > 0 && end > start, 'commands/trekresearch.md must keep ## Phase 5 … ## Phase 6');
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
test('STORM marker: the directory the hook reads is the directory Phase 5 writes', () => {
|
||||
const m = CAP_HOOK_SRC.match(/const SCOPE_DIRNAME = '([^']+)'/);
|
||||
assert.ok(m, 'pre-agent-cap.mjs must keep SCOPE_DIRNAME as a single-quoted literal');
|
||||
const scopeDir = m[1];
|
||||
assert.ok(
|
||||
phase5Section(RESEARCH_CMD).includes(scopeDir),
|
||||
`commands/trekresearch.md Phase 5 must write the scope marker under ${scopeDir}/ — a hook keyed on a directory nobody writes is latent, not enforcing`,
|
||||
);
|
||||
assert.ok(
|
||||
phase5Section(RESEARCH_CMD).includes('CLAUDE_PLUGIN_DATA'),
|
||||
'Phase 5 must root the marker at CLAUDE_PLUGIN_DATA — the same root the hook resolves',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM marker: both payload fields the hook reads are named in Phase 5', () => {
|
||||
// The hook rejects a marker without runId, and treats an unparsable
|
||||
// startedAt as stale. A writer that emits neither name produces a marker
|
||||
// that is silently ignored.
|
||||
const phase5 = phase5Section(RESEARCH_CMD);
|
||||
for (const field of ['runId', 'startedAt']) {
|
||||
assert.ok(
|
||||
CAP_HOOK_SRC.includes(`marker.${field}`) || CAP_HOOK_SRC.includes(`marker?.${field}`),
|
||||
`pre-agent-cap.mjs must still read marker.${field}`,
|
||||
);
|
||||
assert.ok(
|
||||
phase5.includes(field),
|
||||
`commands/trekresearch.md Phase 5 must write the ${field} field the hook reads`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('STORM marker: Phase 5 keys the marker filename by the harness session id', () => {
|
||||
// Verified 2026-08-12: the session_id on a PreToolUse payload equals
|
||||
// $CLAUDE_CODE_SESSION_ID for the same session. The marker filename is the
|
||||
// scope key — key it by anything else and the hook never matches.
|
||||
assert.ok(
|
||||
phase5Section(RESEARCH_CMD).includes('CLAUDE_CODE_SESSION_ID'),
|
||||
'Phase 5 must name CLAUDE_CODE_SESSION_ID as the marker filename key (== the hook payload session_id)',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM marker: only Phase 5 writes it — Phase 4.5 does not', () => {
|
||||
// The hook contract says "a marker file only the Phase 5 loop writes".
|
||||
// Phase 4.5 mines already-retrieved Phase-4 results and spends no loop
|
||||
// turns; scoping enforcement there widens the window for nothing.
|
||||
const m = CAP_HOOK_SRC.match(/const SCOPE_DIRNAME = '([^']+)'/);
|
||||
const scopeDir = m[1];
|
||||
const start = RESEARCH_CMD.indexOf('## Phase 4.5');
|
||||
const end = RESEARCH_CMD.indexOf('## Phase 5', start);
|
||||
assert.ok(start > 0 && end > start, 'commands/trekresearch.md must keep ## Phase 4.5 … ## Phase 5');
|
||||
assert.ok(
|
||||
!RESEARCH_CMD.slice(start, end).includes(scopeDir),
|
||||
'Phase 4.5 must not write the scope marker — only the Phase 5 loop does',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM marker: every one of the three loop exits removes the marker', () => {
|
||||
// A marker left behind after the cap is spent denies WebSearch/WebFetch/Task
|
||||
// for the REST of the session — Phase 6 synthesis spawns agents. Cleanup on
|
||||
// the exhausted exit is what keeps enforcement from becoming a session brick.
|
||||
const phase5 = phase5Section(RESEARCH_CMD);
|
||||
const exitsStart = phase5.indexOf('### Exits');
|
||||
assert.ok(exitsStart > 0, 'Phase 5 must keep the ### Exits section');
|
||||
const exits = phase5.slice(exitsStart, phase5.indexOf('###', exitsStart + 5));
|
||||
for (const [n, label] of [['1.', 'converged'], ['2.', 'cap exhausted'], ['3.', 'operator stop']]) {
|
||||
const from = exits.indexOf(`\n${n}`);
|
||||
assert.ok(from > 0, `Exits must keep numbered entry ${n} (${label})`);
|
||||
const nextMarker = exits.indexOf(`\n${Number(n[0]) + 1}.`, from);
|
||||
const entry = exits.slice(from, nextMarker > from ? nextMarker : undefined);
|
||||
assert.match(
|
||||
entry,
|
||||
/remove the (scope )?marker|rm -f/i,
|
||||
`Exit ${n} (${label}) must remove the scope marker — a marker outliving the loop blocks the rest of the session`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('STORM marker: the crash path is documented as TTL auto-reset, not cleanup', () => {
|
||||
// A crashed session runs no cleanup at all. The honest statement is that the
|
||||
// hook's TTL covers it; claiming cleanup covers crashes would be false.
|
||||
const phase5 = phase5Section(RESEARCH_CMD);
|
||||
assert.match(
|
||||
phase5,
|
||||
/TTL/,
|
||||
'Phase 5 must state that a crashed run is covered by the hook TTL, not by exit cleanup',
|
||||
);
|
||||
});
|
||||
|
||||
test('S18: HANDOVER-CONTRACTS documents the pre-2.2 zero-framing-enforcement hole', () => {
|
||||
// The framing defense is producer-elective: a brief declaring ≤ 2.1 sidesteps
|
||||
// it entirely. Handover 1 (PUBLIC CONTRACT) must disclose this and name the remedy.
|
||||
|
|
@ -1528,7 +1242,6 @@ const GEMINI_LIVE_SURFACES = [
|
|||
// Historical/measurement records that MUST keep their gemini references.
|
||||
const GEMINI_HISTORICAL = [
|
||||
'CHANGELOG.md',
|
||||
'docs/deep-research-engine-brief.md',
|
||||
'examples/02-real-cli/REGENERATED.md',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ export const MOD_PRECONDITION = "Voyage's guards are deterministic only when no
|
|||
const STOP_GUARDS = [
|
||||
'hooks/scripts/pre-bash-executor.mjs',
|
||||
'hooks/scripts/pre-write-executor.mjs',
|
||||
'hooks/scripts/pre-agent-cap.mjs',
|
||||
'hooks/scripts/pre-plan-gate.mjs',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -103,13 +103,11 @@ function repoScan() {
|
|||
// Named exceptions: "file path" -> reason. Each must still be dead (a stale
|
||||
// exemption fails below).
|
||||
const TARGET = 'target-repo example';
|
||||
const RB = 'removed in R-b';
|
||||
const EXEMPT = new Map([
|
||||
['commands/trekplan.md tests/e2e.sh', TARGET],
|
||||
['docs/HANDOVER-CONTRACTS.md docs/specs/api.md', TARGET],
|
||||
['templates/trekreview-template.md lib/foo.mjs', TARGET],
|
||||
['docs/release-runbook.md scripts/release-plugin.mjs', "the catalog repo's script"],
|
||||
['docs/deep-research-engine-research.md docs/cc-upgrade-2.1.181-decision-matrix.md', RB],
|
||||
]);
|
||||
|
||||
const exemptKey = hit => hit.replace(/:\d+ /, ' ');
|
||||
|
|
|
|||
|
|
@ -51,10 +51,7 @@ const PATTERNS = [
|
|||
const EXEMPT = new Map([
|
||||
[SELF, ['self', 17, "the gate's own pattern table and known-positive fixture"]],
|
||||
['CHANGELOG.md', ['c', 15, 'release history; never rewritten']],
|
||||
['docs/deep-research-engine-research.md', ['c', 6, 'research record (deep-research engine)']],
|
||||
['docs/operations.md', ['c', 1, 'Path A decision record: why cache-first was rejected']],
|
||||
['docs/spike-pretooluse-subagent-reach.md', ['c', 2, 'spike record (PreToolUse reach)']],
|
||||
['tests/fixtures/research-deep-research-adapted.md', ['c', 1, 'recorded research fixture']],
|
||||
['docs/proevesett.md', ['operator-instrument', 3, "the operator's own eval run through Anthropic's `claude plugin eval`; Voyage starts nothing - kept by operator decision 2026-10-02"]],
|
||||
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
|
||||
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
|
||||
|
|
|
|||
|
|
@ -48,10 +48,10 @@ function run(blockText, home) {
|
|||
}
|
||||
|
||||
// trekexecute blocks 3 and 4 are Sub-step D4 (step check + regression) and Phase 7's K5/K6 checks (wave 2, order 6).
|
||||
test('commands carry exactly the six known VOYAGE_ROOT blocks', () => {
|
||||
test('commands carry exactly the five known VOYAGE_ROOT blocks', () => {
|
||||
assert.deepEqual(resolutionBlocks().map(b => b.where.split(':')[0]), [
|
||||
'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md',
|
||||
'commands/trekresearch.md', 'commands/trekreview.md',
|
||||
'commands/trekreview.md',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -47,20 +47,11 @@ export const GROUPS = {
|
|||
|
||||
// Groups whose removal stage has not run yet. Each stage of R-b empties its
|
||||
// own entry; REMOVED_SURFACES_ALL=1 checks every group (the red proof).
|
||||
const PENDING = new Set(['R2', 'R3']);
|
||||
const PENDING = new Set(['R3']);
|
||||
|
||||
// Named exceptions: "path:line" -> reason. Each must still hit (a stale
|
||||
// exemption fails below).
|
||||
const R2_FILE = 'R2 removes this file (the STORM gate reads the old trekresearch stats file); drop the entry with it';
|
||||
const EXEMPT = new Map([
|
||||
['docs/storm-measurement.md:80', R2_FILE],
|
||||
['docs/storm-measurement.md:97', R2_FILE],
|
||||
['docs/storm-measurement.md:100', R2_FILE],
|
||||
['docs/storm-measurement.md:109', R2_FILE],
|
||||
['scripts/storm-measure.mjs:4', R2_FILE],
|
||||
['scripts/storm-measure.mjs:34', R2_FILE],
|
||||
['scripts/storm-measure.mjs:47', R2_FILE],
|
||||
['tests/scripts/storm-measure.test.mjs:8', R2_FILE],
|
||||
]);
|
||||
|
||||
const matches = (term, line) => (typeof term === 'string' ? line.includes(term) : term.test(line));
|
||||
|
|
|
|||
|
|
@ -1,598 +0,0 @@
|
|||
// tests/lib/research-loop-cap.test.mjs
|
||||
// Cover lib/util/research-loop-cap.mjs: default-off, worst-case arithmetic,
|
||||
// anti-dead-data (different caps → different denial points), statefulness
|
||||
// (identical args → different answers once the budget is hit), env
|
||||
// coercion, fail-closed on missing CLAUDE_PLUGIN_DATA, and the CLI shim.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync, execFile } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
allowTurn,
|
||||
isStormEnabled,
|
||||
resolveMaxConvTurns,
|
||||
resolveLedgerPath,
|
||||
resolveDataRoot,
|
||||
readLedger,
|
||||
checkDimensionCeiling,
|
||||
MAX_CONV_TURNS,
|
||||
MAX_TOTAL_DIMENSIONS,
|
||||
} from '../../lib/util/research-loop-cap.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs');
|
||||
|
||||
function withTmpDataDir(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runShim(args, env) {
|
||||
try {
|
||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: childEnv(env),
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'storm_disabled');
|
||||
assert.equal(r.budget, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — VOYAGE_STORM_ENABLED=0 denies same as unset', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '0' };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'storm_disabled');
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — enabled but effort !== high denies with budget 0', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'standard' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'effort_not_high');
|
||||
assert.equal(r.budget, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (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 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 ----------------------------------------------
|
||||
|
||||
test('allowTurn — budget is max_conv_turns × max_total_dimensions (default 3×8=24)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.budget, 24);
|
||||
assert.equal(r.used, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — grants exactly `budget` turns then denies the next one (default 24)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
let last;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
last = allowTurn({ runId: 'r-exhaust', dimension: `d${i % 8}`, effort: 'high' }, { env });
|
||||
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
|
||||
}
|
||||
const denied = allowTurn({ runId: 'r-exhaust', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(denied.reason, 'budget_exhausted');
|
||||
assert.equal(denied.used, 24);
|
||||
assert.equal(denied.budget, 24);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (c)/(anti-dead-data) — different caps → observably different denial points
|
||||
|
||||
test('allowTurn — TREKRESEARCH_MAX_CONV_TURNS=1 denies after 8 turns (1×8), not 24', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
let last;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
last = allowTurn({ runId: 'r-narrow', dimension: `d${i}`, effort: 'high' }, { env });
|
||||
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
|
||||
}
|
||||
const denied = allowTurn({ runId: 'r-narrow', dimension: 'd8', effort: 'high' }, { env });
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(denied.budget, 8);
|
||||
assert.notEqual(denied.budget, 24, 'a narrower cap must produce a different denial point than the default');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (d) stateful — identical args give different answers once exhausted ---
|
||||
|
||||
test('allowTurn — identical {runId, dimension, effort} args diverge once the budget is hit (proves statefulness)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
const args = { runId: 'r-identical', dimension: 'same-dim', effort: 'high' };
|
||||
const results = [];
|
||||
for (let i = 0; i < 9; i++) results.push(allowTurn(args, { env }));
|
||||
// First 8 (budget = 1*8) granted, 9th denied — same exact input object each time.
|
||||
assert.deepEqual(results.slice(0, 8).map(r => r.ok), Array(8).fill(true));
|
||||
assert.equal(results[8].ok, false);
|
||||
assert.equal(results[8].reason, 'budget_exhausted');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (e) env coercion --------------------------------------------------------
|
||||
|
||||
test('resolveMaxConvTurns — NaN string falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'abc' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — empty string falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — negative value falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '-5' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — zero falls back to default (never unbounded)', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — unset falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({}), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — valid positive integer string is honored', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2);
|
||||
});
|
||||
|
||||
// A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and
|
||||
// 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop
|
||||
// silently dead, while README.md and docs/architecture.md both promise a
|
||||
// fallback of 3. The guard has to see the floored value, not the raw one.
|
||||
test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS);
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' };
|
||||
const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded');
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- pure-core unit coverage --------------------------------------------------
|
||||
|
||||
test('isStormEnabled — only the literal string "1" enables', () => {
|
||||
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: '1' }), true);
|
||||
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: 'true' }), false);
|
||||
assert.equal(isStormEnabled({}), false);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const p = resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' });
|
||||
assert.equal(p, join('/tmp/plugin-data', 'trekresearch-loop-ledger.jsonl'));
|
||||
});
|
||||
|
||||
test('allowTurn — missing runId or dimension denies with missing_args', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r1 = allowTurn({ dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r1.ok, false);
|
||||
assert.equal(r1.reason, 'missing_args');
|
||||
const r2 = allowTurn({ runId: 'r1', effort: 'high' }, { env });
|
||||
assert.equal(r2.ok, false);
|
||||
assert.equal(r2.reason, 'missing_args');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- fail-closed on an unreadable ledger ------------------------------------
|
||||
//
|
||||
// The module's own header states that a budget control "must never silently
|
||||
// grant unlimited turns just because the data dir is missing". The
|
||||
// missing-DIRECTORY case already failed closed; the unreadable-FILE case
|
||||
// returned 0 from countTurns and therefore re-granted the full budget on every
|
||||
// call, unbounded — a fail-open in the same module that argues against one.
|
||||
// A directory standing where the ledger file belongs reproduces it portably
|
||||
// (EISDIR), with no chmod that a root test runner would ignore.
|
||||
|
||||
function withUnreadableLedger(fn) {
|
||||
return withTmpDataDir((dir) => {
|
||||
mkdirSync(join(dir, 'trekresearch-loop-ledger.jsonl'), { recursive: true });
|
||||
return fn(dir);
|
||||
});
|
||||
}
|
||||
|
||||
test('readLedger — a missing ledger is 0 turns, not an error (turn 1 must be grantable)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
assert.equal(readLedger(join(dir, 'nope.jsonl'), 'r1').granted, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('readLedger — an unreadable ledger throws rather than reporting 0 turns spent', () => {
|
||||
withUnreadableLedger((dir) => {
|
||||
assert.throws(
|
||||
() => readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r1'),
|
||||
/unreadable/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('readLedger — malformed lines are skipped, well-formed ones for the run still count', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const p = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
writeFileSync(p, [
|
||||
JSON.stringify({ runId: 'r1', dimension: 'd1' }),
|
||||
'{ not json',
|
||||
'',
|
||||
JSON.stringify({ runId: 'other', dimension: 'd1' }),
|
||||
JSON.stringify({ runId: 'r1', dimension: 'd2' }),
|
||||
].join('\n') + '\n');
|
||||
assert.equal(readLedger(p, 'r1').granted, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — an unreadable ledger DENIES the turn instead of granting a fresh budget', () => {
|
||||
withUnreadableLedger((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r = allowTurn({ runId: 'r-unreadable', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false, 'a budget control that cannot count must not grant');
|
||||
assert.match(r.reason, /ledger-read-failed/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the bound holds under concurrency --------------------------------------
|
||||
//
|
||||
// countTurns-then-appendFileSync had no atomic claim, while the comment above
|
||||
// allowTurn asserted "Append-only: never read-modify-write" and named the
|
||||
// concurrent case (Phase 4.5/5 may spawn several agents in one message) as the
|
||||
// reason. The decision path WAS read-then-write: N callers that all observe
|
||||
// used == budget-1 all grant, and the bound is exceeded by N-1.
|
||||
//
|
||||
// Two of these tests are deterministic. They do not race anything — they assert
|
||||
// the invariant the claim introduces: a slot that is already claimed is spent,
|
||||
// even when the ledger has not caught up yet, which is exactly the state a
|
||||
// mid-append competitor leaves behind. The third runs real processes.
|
||||
|
||||
function seedLedger(dir, runId, n) {
|
||||
writeFileSync(
|
||||
join(dir, 'trekresearch-loop-ledger.jsonl'),
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
JSON.stringify({ ts: new Date().toISOString(), runId, dimension: `d${i}`, effort: 'high' }),
|
||||
).join('\n') + (n ? '\n' : ''),
|
||||
);
|
||||
}
|
||||
|
||||
function seedClaims(dir, runId, slots) {
|
||||
const claimDir = join(dir, 'trekresearch-loop-claims');
|
||||
mkdirSync(claimDir, { recursive: true });
|
||||
for (const s of slots) writeFileSync(join(claimDir, `${runId}-${s}.claim`), '');
|
||||
}
|
||||
|
||||
function runShimAsync(args, env) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[SHIM, ...args],
|
||||
{ encoding: 'utf-8', env: { PATH: process.env.PATH, ...env } },
|
||||
(err, stdout) => resolve({ code: err ? (err.code ?? 1) : 0, out: stdout ?? '' }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('allowTurn — a slot already claimed is spent even when the ledger has not caught up', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
// budget = 1 × 8 = 8. Ledger shows 7 turns; a competitor already claimed
|
||||
// slot 8 and has not appended yet. Counting the ledger alone says "one slot
|
||||
// free" and grants a 9th turn overall — the breach this closes.
|
||||
seedLedger(dir, 'r-race', 7);
|
||||
seedClaims(dir, 'r-race', [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const r = allowTurn({ runId: 'r-race', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false, 'every slot up to the budget is claimed, so there is nothing to grant');
|
||||
assert.equal(r.reason, 'budget_exhausted');
|
||||
assert.equal(r.budget, 8);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — it takes the first FREE slot and claims it, so a repeat call cannot retake it', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
seedLedger(dir, 'r-slot', 7);
|
||||
seedClaims(dir, 'r-slot', [1, 2, 3, 4, 5, 6, 7]);
|
||||
const first = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(first.ok, true, 'slot 8 is free and must be grantable');
|
||||
assert.equal(first.used, 8, 'used is the slot number, so it never double-counts a claimed slot');
|
||||
assert.ok(
|
||||
existsSync(join(dir, 'trekresearch-loop-claims', 'r-slot-8.claim')),
|
||||
'the grant must leave the claim behind as the atomic record of the slot',
|
||||
);
|
||||
const second = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(second.ok, false);
|
||||
assert.equal(second.reason, 'budget_exhausted');
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — a different runId is unaffected by another run’s claims', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
seedClaims(dir, 'r-other', [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const r = allowTurn({ runId: 'r-mine', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'claims are per-run; one run must not exhaust another');
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — parallel processes at the boundary cannot exceed the budget', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-par-'));
|
||||
try {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
seedLedger(dir, 'r-par', 7); // budget 8 → exactly one turn left
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 6 }, (_, i) =>
|
||||
runShimAsync(['--run-id', 'r-par', '--dimension', `p${i}`, '--effort', 'high'], env),
|
||||
),
|
||||
);
|
||||
const winners = results.filter((r) => r.code === 0).length;
|
||||
assert.equal(winners, 1, `exactly one of six concurrent callers may take the last slot, got ${winners}`);
|
||||
|
||||
// Granted turns, not raw lines: the five denied callers also record
|
||||
// exhaustion tombstones, and a tombstone is not a turn.
|
||||
const { granted } = readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r-par');
|
||||
assert.equal(granted, 8, `granted turns must never exceed the budget, got ${granted}`);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the exhaustion tombstone -----------------------------------------------
|
||||
//
|
||||
// allowTurn appends BEFORE the turn runs, so during granted turn N the ledger
|
||||
// holds N records. The hook denied at `used >= budget`, which blocked every
|
||||
// tool call of the FINAL granted turn: the primitive granted B turns and the
|
||||
// harness permitted B-1. Worse, an exhausted run then always terminated through
|
||||
// an exit-2 tool denial instead of the graceful "cap exhausted" exit at
|
||||
// commands/trekresearch.md, which is the only exit the prose teaches.
|
||||
//
|
||||
// Letting the hook allow at `used == budget` fixes the count but would leave it
|
||||
// unable to catch the one case it exists for — the loop consults the gate, is
|
||||
// denied, and issues the tool call anyway. So the denial itself becomes a
|
||||
// record: a tombstone the hook can see.
|
||||
|
||||
test('allowTurn — denying for budget writes an exhaustion tombstone', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
for (let i = 0; i < 8; i++) {
|
||||
assert.equal(allowTurn({ runId: 'r-tomb', dimension: `d${i}`, effort: 'high' }, { env }).ok, true);
|
||||
}
|
||||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
assert.equal(readLedger(ledgerPath, 'r-tomb').exhausted, 0, 'no tombstone before the gate has denied anything');
|
||||
|
||||
const denied = allowTurn({ runId: 'r-tomb', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(denied.reason, 'budget_exhausted');
|
||||
|
||||
const after = readLedger(ledgerPath, 'r-tomb');
|
||||
assert.equal(after.exhausted, 1, 'the denial must leave a record the harness gate can read');
|
||||
assert.equal(after.granted, 8, 'a tombstone is not a granted turn and must not count as one');
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — the tombstone is written once, not once per repeated denial', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
for (let i = 0; i < 8; i++) allowTurn({ runId: 'r-once', dimension: `d${i}`, effort: 'high' }, { env });
|
||||
for (let i = 0; i < 5; i++) allowTurn({ runId: 'r-once', dimension: 'd0', effort: 'high' }, { env });
|
||||
const after = readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r-once');
|
||||
assert.equal(after.exhausted, 1, 'a hammered gate must not grow the ledger without bound');
|
||||
assert.equal(after.granted, 8);
|
||||
});
|
||||
});
|
||||
|
||||
test('readLedger — a tombstone is reported separately and never as a granted turn', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const p = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
writeFileSync(p, [
|
||||
JSON.stringify({ runId: 'r1', dimension: 'd1', slot: 1 }),
|
||||
JSON.stringify({ runId: 'r1', exhausted: true }),
|
||||
JSON.stringify({ runId: 'other', exhausted: true }),
|
||||
].join('\n') + '\n');
|
||||
const l = readLedger(p, 'r1');
|
||||
assert.equal(l.granted, 1);
|
||||
assert.equal(l.exhausted, 1);
|
||||
assert.equal(readLedger(p, 'other').granted, 0, 'another run’s tombstone is not a granted turn either');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the discovery ceiling has a reader, not just a sentence ----------------
|
||||
//
|
||||
// The bounded-cost NFR asks for explicit ceilings on BOTH axes: max conversation
|
||||
// turns and max discovered dimensions. The turn axis got MAX_CONV_TURNS, a
|
||||
// ledger-backed reader and a PreToolUse enforcer. The discovery axis got a
|
||||
// sentence in Phase 4.5 — "append candidates only while the whole list stays at
|
||||
// or below maxDimensions: 8" — with no constant of its own, no reader, and no
|
||||
// test that a run exceeding it is caught. That is the same shape as the
|
||||
// brief_reviewer_iter_cap failure the operator decision warned about: a cap
|
||||
// nothing reads.
|
||||
//
|
||||
// The ceiling is deliberately the SAME constant that sizes the turn budget. Two
|
||||
// constants for one maxDimensions value is how the two drift apart.
|
||||
|
||||
test('checkDimensionCeiling — a list at the ceiling is accepted', () => {
|
||||
const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS }, (_, i) => `d${i}`));
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.count, MAX_TOTAL_DIMENSIONS);
|
||||
assert.equal(r.ceiling, MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — one dimension over the ceiling is REJECTED', () => {
|
||||
const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS + 1 }, (_, i) => `d${i}`));
|
||||
assert.equal(r.ok, false, 'a ceiling that accepts ceiling+1 is not a ceiling');
|
||||
assert.equal(r.reason, 'ceiling_exceeded');
|
||||
assert.equal(r.count, MAX_TOTAL_DIMENSIONS + 1);
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — a plain count works as well as a list', () => {
|
||||
assert.equal(checkDimensionCeiling(8).ok, true);
|
||||
assert.equal(checkDimensionCeiling(9).ok, false);
|
||||
assert.equal(checkDimensionCeiling('8').ok, true);
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — an unreadable count is rejected, never waved through', () => {
|
||||
for (const bad of ['abc', null, undefined, {}, -1, NaN]) {
|
||||
const r = checkDimensionCeiling(bad);
|
||||
assert.equal(r.ok, false, `${JSON.stringify(bad)} must not pass a cost ceiling`);
|
||||
assert.equal(r.reason, 'unreadable_dimension_count');
|
||||
}
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — the ceiling is the same constant that sizes the turn budget', () => {
|
||||
// Phase 4.5 and the Phase 5 budget must not be able to disagree about 8.
|
||||
assert.equal(checkDimensionCeiling(0).ceiling, MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
|
||||
test('CLI shim — --check-dimensions exits 0 at the ceiling and 1 above it', () => {
|
||||
const at = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS)], {});
|
||||
assert.equal(at.code, 0, `at the ceiling must exit 0; got ${at.out}`);
|
||||
assert.equal(JSON.parse(at.out.trim()).ok, true);
|
||||
|
||||
const over = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS + 1)], {});
|
||||
assert.equal(over.code, 1, 'a run over the ceiling must be rejected by exit code, not by prose');
|
||||
const parsed = JSON.parse(over.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.equal(parsed.reason, 'ceiling_exceeded');
|
||||
});
|
||||
|
||||
test('CLI shim — --check-dimensions needs no runId, effort or STORM flag', () => {
|
||||
// It is a cost ceiling on Phase 4.5, which never calls the budget gate, so it
|
||||
// must not inherit the budget gate's preconditions.
|
||||
const r = runShimStripped(['--check-dimensions', '3'], {});
|
||||
assert.equal(r.code, 0, `should not require --run-id/--effort; got ${r.out}`);
|
||||
});
|
||||
|
||||
// ---- (g) shim contract --------------------------------------------------------
|
||||
|
||||
test('CLI shim — grants and exits 0 when enabled + high effort + budget available', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const r = runShim(
|
||||
['--run-id', 'shim-1', '--dimension', 'd1', '--effort', 'high'],
|
||||
{ CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' },
|
||||
);
|
||||
assert.equal(r.code, 0);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('CLI shim — denies and exits 1 when disabled', () => {
|
||||
const r = runShim(['--run-id', 'shim-2', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '0' });
|
||||
assert.equal(r.code, 1);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
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);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.reason, /usage:/);
|
||||
});
|
||||
|
|
@ -85,7 +85,7 @@ test('researching-sources: "Enforced by" names the structure-only check and says
|
|||
assert.match(enforced, /research-validator\.mjs/);
|
||||
assert.match(enforced, /structure/i);
|
||||
assert.match(enforced, /query-privacy-gate\.mjs/);
|
||||
assert.match(enforced, /off by default/i, 'the privacy gate runs only in the default-off steps; say so');
|
||||
assert.match(enforced, /not called by any command/i, 'no command calls the privacy gate now; say so');
|
||||
assert.match(enforced, /\*\*prose only\*\*/);
|
||||
});
|
||||
|
||||
|
|
@ -187,10 +187,11 @@ test('one confidence scale: the skill table and /trekresearch triangulation defi
|
|||
assert.deepEqual(cmdRows, skillRows, 'Phase 6 must define the ratings word for word as the skill does');
|
||||
});
|
||||
|
||||
test('one confidence scale: the deep-research adapter and the brief template accept the same four ratings', () => {
|
||||
const cmd = read('commands/trekresearch.md');
|
||||
assert.match(cmd, /`### \{Dimension\} -- Confidence: \{high\|medium\|low\|contradictory\}`/);
|
||||
assert.match(read('templates/research-brief-template.md'), /Confidence: \{high \| medium \| low \| contradictory\}/);
|
||||
test('one confidence scale: the brief template dimension heading accepts the same four ratings', () => {
|
||||
assert.match(
|
||||
read('templates/research-brief-template.md'),
|
||||
/^### \{Dimension Name\} -- Confidence: \{high \| medium \| low \| contradictory\}$/m,
|
||||
);
|
||||
});
|
||||
|
||||
// --- the eval cases ---------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
// tests/scripts/storm-measure.test.mjs
|
||||
// Step 11 — the STORM adoption gate's deterministic accounting core.
|
||||
//
|
||||
// The gate decides ONE thing: does the bounded Phase 5 loop buy enough extra
|
||||
// source/coverage breadth to be worth flipping VOYAGE_STORM_ENABLED on by
|
||||
// default. Thresholds are pre-registered in docs/storm-measurement.md BEFORE
|
||||
// any measurement run, so this file pins the arithmetic that turns a
|
||||
// trekresearch-stats.jsonl into a verdict — not the verdict itself.
|
||||
//
|
||||
// Two properties carry the gate's honesty:
|
||||
// - runs with empty_turns > 0 are EXCLUDED from the gain and COUNTED, so
|
||||
// adoption is never decided on a broken denominator, and
|
||||
// - a stats file with no `effort` field is a loud error, never a silently
|
||||
// empty group that reads as "no gain".
|
||||
//
|
||||
// Pure core, no fixtures on disk.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
median,
|
||||
parseStats,
|
||||
partitionEligible,
|
||||
measure,
|
||||
decideVerdict,
|
||||
activationCheck,
|
||||
ADOPT_THRESHOLD,
|
||||
DECLINE_THRESHOLD,
|
||||
} from '../../scripts/storm-measure.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers — synthetic JSONL, one object per line, exactly as the orchestrator emits
|
||||
// ---------------------------------------------------------------------------
|
||||
function run({ effort, unique_sources, dimensions, dimensions_baseline, empty_turns = 0, conv_turns = 0 }) {
|
||||
return JSON.stringify({
|
||||
ts: '2026-08-12T00:00:00.000Z',
|
||||
question: 'q',
|
||||
mode: 'full',
|
||||
scope: 'both',
|
||||
engine: 'swarm',
|
||||
effort,
|
||||
unique_sources,
|
||||
dimensions,
|
||||
dimensions_baseline,
|
||||
conv_turns,
|
||||
empty_turns,
|
||||
});
|
||||
}
|
||||
|
||||
function jsonl(...lines) {
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// A control arm at 10 sources / 5 dimensions, and a treatment arm at 13
|
||||
// sources / 8 dimensions: +30.0% sources, +60.0% dimensions.
|
||||
const STANDARD = [
|
||||
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
];
|
||||
const HIGH = [
|
||||
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
||||
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
||||
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// median
|
||||
// ---------------------------------------------------------------------------
|
||||
test('median: odd, even, single', () => {
|
||||
assert.equal(median([3, 1, 2]), 2);
|
||||
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(median([7]), 7);
|
||||
});
|
||||
|
||||
test('median: empty list is null, never 0 — 0 would read as a real measurement', () => {
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
|
||||
test('median does not mutate its input', () => {
|
||||
const xs = [3, 1, 2];
|
||||
median(xs);
|
||||
assert.deepEqual(xs, [3, 1, 2]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseStats — the loud-error requirement
|
||||
// ---------------------------------------------------------------------------
|
||||
test('parseStats: a file with no effort field throws, it does not yield empty groups', () => {
|
||||
const noEffort = jsonl(
|
||||
JSON.stringify({ ts: 'x', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
JSON.stringify({ ts: 'y', unique_sources: 11, dimensions: 5, dimensions_baseline: 5 }),
|
||||
);
|
||||
assert.throws(() => parseStats(noEffort), /effort/i);
|
||||
});
|
||||
|
||||
test('parseStats: skips blank and malformed lines but keeps the good ones', () => {
|
||||
const text = jsonl(STANDARD[0], '', 'not json', HIGH[0]);
|
||||
const { records, malformed } = parseStats(text);
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(malformed, 1);
|
||||
});
|
||||
|
||||
test('parseStats: an empty file throws rather than reporting a zero-gain verdict', () => {
|
||||
assert.throws(() => parseStats(''), /no records/i);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exclusion of broken runs
|
||||
// ---------------------------------------------------------------------------
|
||||
test('partitionEligible: runs with empty_turns > 0 are excluded and counted', () => {
|
||||
const { records } = parseStats(jsonl(
|
||||
...HIGH,
|
||||
run({ effort: 'high', unique_sources: 99, dimensions: 8, dimensions_baseline: 5, empty_turns: 2 }),
|
||||
));
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
assert.equal(eligible.length, 3);
|
||||
assert.equal(excluded, 1);
|
||||
});
|
||||
|
||||
test('partitionEligible: empty_turns === 0 is eligible; a missing field counts as 0', () => {
|
||||
const { records } = parseStats(jsonl(
|
||||
STANDARD[0],
|
||||
JSON.stringify({ ts: 'z', effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
));
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
assert.equal(eligible.length, 2);
|
||||
assert.equal(excluded, 0);
|
||||
});
|
||||
|
||||
// A malformed empty_turns used to land in the ELIGIBLE arm: Number('many') is
|
||||
// NaN, NaN fails the isFinite test, and the `else` branch pushed it in. The
|
||||
// exclusion is one of the two properties carrying this gate's honesty, so a
|
||||
// garbage value silently re-entering the denominator defeats it — and it does so
|
||||
// in the direction that flatters adoption, since the run that broke is the run
|
||||
// whose numbers are least trustworthy.
|
||||
test('partitionEligible: a non-numeric empty_turns is EXCLUDED, never silently eligible', () => {
|
||||
const { records } = parseStats(jsonl(
|
||||
STANDARD[0],
|
||||
JSON.stringify({
|
||||
ts: 'm', effort: 'high', unique_sources: 999,
|
||||
dimensions: 8, dimensions_baseline: 5, empty_turns: 'many',
|
||||
}),
|
||||
));
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
assert.equal(excluded, 1, 'a value that cannot be read as a turn count is not evidence of zero empty turns');
|
||||
assert.equal(eligible.length, 1);
|
||||
});
|
||||
|
||||
test('partitionEligible: an unparsable empty_turns cannot move the median either', () => {
|
||||
const m = measure(parseStats(jsonl(
|
||||
...STANDARD,
|
||||
...HIGH,
|
||||
JSON.stringify({
|
||||
ts: 'm', effort: 'high', unique_sources: 900,
|
||||
dimensions: 8, dimensions_baseline: 5, empty_turns: {},
|
||||
}),
|
||||
)).records);
|
||||
assert.equal(m.excluded, 1);
|
||||
assert.equal(m.sources.treatment, 13, 'the 900-source malformed run must not reach the median');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// measure — the known-answer test
|
||||
// ---------------------------------------------------------------------------
|
||||
test('measure: median gains on synthetic runs give the known answer', () => {
|
||||
const m = measure(parseStats(jsonl(...STANDARD, ...HIGH)).records);
|
||||
assert.equal(m.control.n, 3);
|
||||
assert.equal(m.treatment.n, 3);
|
||||
assert.equal(m.sources.control, 10);
|
||||
assert.equal(m.sources.treatment, 13);
|
||||
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9, `sources gain ${m.sources.gain}`);
|
||||
// (8 - 5) / 5 = 0.60 within each treatment run.
|
||||
assert.ok(Math.abs(m.dimensions.gain - 0.60) < 1e-9, `dimensions gain ${m.dimensions.gain}`);
|
||||
});
|
||||
|
||||
test('measure: an excluded run cannot move the median', () => {
|
||||
const withBroken = jsonl(
|
||||
...STANDARD,
|
||||
...HIGH,
|
||||
run({ effort: 'high', unique_sources: 900, dimensions: 8, dimensions_baseline: 5, empty_turns: 1 }),
|
||||
);
|
||||
const m = measure(parseStats(withBroken).records);
|
||||
assert.equal(m.excluded, 1);
|
||||
assert.equal(m.sources.treatment, 13, 'the 900-source broken run must not reach the median');
|
||||
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9);
|
||||
});
|
||||
|
||||
test('measure: reports null gain (not 0) when an arm has no eligible runs', () => {
|
||||
const m = measure(parseStats(jsonl(...HIGH)).records);
|
||||
assert.equal(m.control.n, 0);
|
||||
assert.equal(m.sources.gain, null);
|
||||
assert.equal(m.verdict, 'insufficient-data');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verdict mapping — both sides of both thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
test('decideVerdict: at and above the adopt threshold', () => {
|
||||
assert.equal(decideVerdict(ADOPT_THRESHOLD, ADOPT_THRESHOLD), 'adopt');
|
||||
assert.equal(decideVerdict(0.55, 0.44), 'adopt');
|
||||
});
|
||||
|
||||
test('decideVerdict: both metrics between the bars is inconclusive, not adopt', () => {
|
||||
assert.equal(decideVerdict(ADOPT_THRESHOLD - 0.0001, 0.2), 'inconclusive');
|
||||
});
|
||||
|
||||
test('decideVerdict: below the decline threshold on both metrics declines', () => {
|
||||
assert.equal(decideVerdict(0.14, 0.05), 'decline');
|
||||
assert.equal(decideVerdict(DECLINE_THRESHOLD - 0.0001, 0), 'decline');
|
||||
});
|
||||
|
||||
test('decideVerdict: at the decline threshold is inconclusive, not decline', () => {
|
||||
assert.equal(decideVerdict(DECLINE_THRESHOLD, DECLINE_THRESHOLD), 'inconclusive');
|
||||
});
|
||||
|
||||
// The brief pre-registers "median forbedring >= 30 % på (a) eller (b) → adopt.
|
||||
// < 15 % → decline." — OR on both sides, with adopt evaluated first.
|
||||
test('decideVerdict: adopt needs EITHER metric — one strong metric carries a weak one', () => {
|
||||
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
|
||||
assert.equal(decideVerdict(0.10, 0.90), 'adopt');
|
||||
});
|
||||
|
||||
test('decideVerdict: either metric below the decline bar declines', () => {
|
||||
assert.equal(decideVerdict(0.02, 0.20), 'decline');
|
||||
assert.equal(decideVerdict(0.20, 0.02), 'decline');
|
||||
});
|
||||
|
||||
test('decideVerdict: adopt outranks decline when one metric clears and the other is under the decline bar', () => {
|
||||
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
|
||||
});
|
||||
|
||||
test('decideVerdict: a null gain is insufficient data, never a decline', () => {
|
||||
assert.equal(decideVerdict(null, 0.4), 'insufficient-data');
|
||||
assert.equal(decideVerdict(0.4, null), 'insufficient-data');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// activationCheck — BOTH halves of the SC, not just the count
|
||||
//
|
||||
// The SC requires that an `effort: high` run discovers at least one dimension
|
||||
// AND that the dimension list in the output brief is a TRUE SUPERSET of the
|
||||
// interview-derived ones. activationCheck only computed dimensions -
|
||||
// dimensions_baseline >= 1, so a run that replaced two interview dimensions with
|
||||
// three discovered ones passed the check while violating the SC's second half.
|
||||
// Supersetness was asserted only by Phase 4.5's prose contract that discovery
|
||||
// appends; nothing read it.
|
||||
//
|
||||
// The stats record carries counts, not names — names are free prose and
|
||||
// the record denies prose by omission — so the run attests membership
|
||||
// with a low-cardinality boolean instead, and the gate refuses to call
|
||||
// activation OK without it.
|
||||
// ---------------------------------------------------------------------------
|
||||
function highRun(over = {}) {
|
||||
return JSON.stringify({
|
||||
ts: '2026-08-12T00:00:00.000Z',
|
||||
effort: 'high',
|
||||
unique_sources: 13,
|
||||
dimensions: 8,
|
||||
dimensions_baseline: 5,
|
||||
conv_turns: 3,
|
||||
empty_turns: 0,
|
||||
dimensions_baseline_preserved: true,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
test('activationCheck: discovery plus a preserved baseline is activation', () => {
|
||||
const r = activationCheck(parseStats(jsonl(highRun())).records);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.discovered_dimensions, 3);
|
||||
assert.equal(r.dimensions_baseline_preserved, true);
|
||||
});
|
||||
|
||||
test('activationCheck: a REPLACED baseline is not activation, however many were discovered', () => {
|
||||
const r = activationCheck(parseStats(jsonl(
|
||||
highRun({ dimensions: 8, dimensions_baseline: 5, dimensions_baseline_preserved: false }),
|
||||
)).records);
|
||||
assert.equal(r.ok, false, 'a count delta of +3 says nothing about which dimensions survived');
|
||||
assert.match(r.reason, /superset|baseline/i);
|
||||
});
|
||||
|
||||
test('activationCheck: a run that does not attest baseline membership cannot pass', () => {
|
||||
const rec = JSON.parse(highRun());
|
||||
delete rec.dimensions_baseline_preserved;
|
||||
const r = activationCheck(parseStats(jsonl(JSON.stringify(rec))).records);
|
||||
assert.equal(r.ok, false, 'an absent attestation is not an attestation');
|
||||
assert.match(r.reason, /dimensions_baseline_preserved/);
|
||||
});
|
||||
|
||||
test('activationCheck: no discovery is still not activation even with the baseline preserved', () => {
|
||||
const r = activationCheck(parseStats(jsonl(
|
||||
highRun({ dimensions: 5, dimensions_baseline: 5 }),
|
||||
)).records);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.discovered_dimensions, 0);
|
||||
});
|
||||
|
||||
test('activationCheck: no effort:high run at all is reported as such', () => {
|
||||
const r = activationCheck(parseStats(jsonl(...STANDARD)).records);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.reason, /high/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pre-registration — the thresholds are the doc's, and the doc is committed first
|
||||
// ---------------------------------------------------------------------------
|
||||
test('thresholds are the pre-registered 30% / 15%', () => {
|
||||
assert.equal(ADOPT_THRESHOLD, 0.30);
|
||||
assert.equal(DECLINE_THRESHOLD, 0.15);
|
||||
});
|
||||
|
||||
// The human-readable summary is the only form of the rule most readers will
|
||||
// ever see. It said "on BOTH" on both sides while decideVerdict evaluated OR —
|
||||
// so the report described a stricter gate than the one that produced the verdict
|
||||
// printed one line below it.
|
||||
test('the printed threshold line states the OR rule that decideVerdict actually applies', () => {
|
||||
const src = readFileSync(new URL('../../scripts/storm-measure.mjs', import.meta.url), 'utf-8');
|
||||
const line = src.split('\n').find((l) => l.includes('thresholds: adopt'));
|
||||
assert.ok(line, 'the summary must still print its threshold rule');
|
||||
assert.doesNotMatch(line, /on BOTH/, 'the rule is OR on both sides — printing BOTH misstates the gate');
|
||||
assert.match(line, /EITHER/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue