feat(trekresearch): wire Phase 5 scope marker so the cap hook enforces
pre-agent-cap.mjs (S78) enforces the Phase 5 loop bound only while a scope
marker exists for the calling session. Nothing wrote that marker, so the hook
shipped correct but latent. Phase 5 now writes it at loop start and removes it
on all three exits.
- Write: ${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json with
{runId, startedAt}, keyed by CLAUDE_CODE_SESSION_ID. Verified 2026-08-12 that
this equals the session_id on the hook's PreToolUse payload.
- runId must be the same --run-id the ledger is counted under; a mismatched id
counts zero turns and enforces nothing.
- Fail-soft on write: the hook is defence in depth, research-loop-cap.mjs stays
the gate. Report and continue. The reverse (skipping the budget gate because
a marker exists) stays forbidden.
- Removal on every exit, load-bearing on the exhausted one: the hook keeps
denying WebSearch/WebFetch/Task while the marker is there, and Phase 6 spawns
agents. Crash is covered by the hook TTL, not by cleanup - stated as such
rather than claiming cleanup covers it.
- Marker written in Phase 5, not Phase 4.5: 4.5 mines already-retrieved Phase-4
results and spends no loop turns, so scoping there widens the window for
nothing. Pinned by a test.
Six pins in tests/lib/doc-consistency.test.mjs derive the directory name from
SCOPE_DIRNAME in the hook and the payload fields from marker.runId/startedAt,
so drift in either direction fails. hooks/scripts/pre-agent-cap.mjs untouched.
Verified end-to-end with the snippets as shipped: marker written -> hook allows
under budget, denies 8/8 at budget, allows again after removal; removal is
idempotent; unset CLAUDE_PLUGIN_DATA takes the fail-soft branch.
Suite 937 (935/0/2, baseline 931 + 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R77nGJjZ1hqjAQQHefFdnc
This commit is contained in:
parent
fce11a1178
commit
cef3e7fa24
3 changed files with 170 additions and 10 deletions
|
|
@ -1172,6 +1172,113 @@ 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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue