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
|
|
@ -445,6 +445,51 @@ own append-only ledger — it never asks this prose how many turns it has used.
|
||||||
The loop is **default-off**: `research-loop-cap.mjs` grants a budget of 0
|
The loop is **default-off**: `research-loop-cap.mjs` grants a budget of 0
|
||||||
unless `VOYAGE_STORM_ENABLED=1`. Doing nothing leaves the mechanism off.
|
unless `VOYAGE_STORM_ENABLED=1`. Doing nothing leaves the mechanism off.
|
||||||
|
|
||||||
|
### Loop scope marker
|
||||||
|
|
||||||
|
`hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`)
|
||||||
|
enforces the same bound in the harness rather than trusting this prose — but it
|
||||||
|
enforces **only** for a session that carries a scope marker, and allows
|
||||||
|
unconditionally for every session that does not. That is what keeps a globally
|
||||||
|
wired PreToolUse hook from denying tool calls in unrelated sessions. Write the
|
||||||
|
marker once, immediately before the first turn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arms the PreToolUse cap for THIS session only.
|
||||||
|
# CLAUDE_CODE_SESSION_ID is the same id the hook reads as `session_id`.
|
||||||
|
DATA="${CLAUDE_PLUGIN_DATA:-}"
|
||||||
|
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||||
|
if [ -n "$SCOPE_DIR" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then
|
||||||
|
printf '{"runId":"%s","startedAt":"%s"}\n' "{run_id}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
> "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||||
|
else
|
||||||
|
echo "[voyage] scope marker not written — harness cap stays inert for this run"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
`runId` MUST be the same `{run_id}` passed to `research-loop-cap.mjs --run-id`.
|
||||||
|
The hook counts ledger lines carrying that id, so a marker written with any
|
||||||
|
other id counts zero turns and enforces nothing.
|
||||||
|
|
||||||
|
**A failed marker write is not a reason to stop.** The hook is defence in
|
||||||
|
depth; `research-loop-cap.mjs` is the gate and stays correct on its own. Report
|
||||||
|
the failure to the operator and run the loop. The reverse — skipping the budget
|
||||||
|
gate because a marker exists — is never allowed.
|
||||||
|
|
||||||
|
**Removal belongs to every exit below, especially the exhausted one.** The hook
|
||||||
|
denies `WebSearch`/`WebFetch`/`Task` once the budget is spent, and it keeps
|
||||||
|
denying for as long as the marker is there — including Phase 6, which spawns
|
||||||
|
agents. A marker that outlives the loop turns a bound on this loop into a brick
|
||||||
|
on the rest of the session. Cleanup covers the three exits and nothing else: a
|
||||||
|
crashed session runs no cleanup at all, and is covered instead by the hook's
|
||||||
|
TTL (default 6h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Removal — idempotent, safe to repeat.
|
||||||
|
[ -n "${CLAUDE_PLUGIN_DATA:-}" ] && \
|
||||||
|
rm -f "${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/${CLAUDE_CODE_SESSION_ID}.json"
|
||||||
|
```
|
||||||
|
|
||||||
### Per-turn protocol
|
### Per-turn protocol
|
||||||
|
|
||||||
Each turn targets exactly one under-illuminated dimension, and runs two gates
|
Each turn targets exactly one under-illuminated dimension, and runs two gates
|
||||||
|
|
@ -475,16 +520,18 @@ next under-illuminated dimension, or exit.
|
||||||
### Exits (all three, always one of them)
|
### Exits (all three, always one of them)
|
||||||
|
|
||||||
1. **Converged** — the dimension carries findings with citations and no
|
1. **Converged** — the dimension carries findings with citations and no
|
||||||
remaining contradiction. Stop turning on it. This is the normal exit.
|
remaining contradiction. Stop turning on it. This is the normal exit. Once
|
||||||
|
the last dimension has converged, remove the scope marker.
|
||||||
2. **Cap exhausted** — `research-loop-cap.mjs` denies the turn. Print the
|
2. **Cap exhausted** — `research-loop-cap.mjs` denies the turn. Print the
|
||||||
exhaustion **visibly** to the operator, never silently:
|
exhaustion **visibly** to the operator, never silently:
|
||||||
`Loop bound reached for dimension {dimension} after {N} turns — remaining
|
`Loop bound reached for dimension {dimension} after {N} turns — remaining
|
||||||
gaps are carried into the brief as open questions.` A silent cap is
|
gaps are carried into the brief as open questions.` A silent cap is
|
||||||
indistinguishable from convergence, and that confusion is exactly what this
|
indistinguishable from convergence, and that confusion is exactly what this
|
||||||
phase exists to prevent.
|
phase exists to prevent. Then remove the scope marker — leaving it here is
|
||||||
3. **Operator stop** — the operator interrupts. Carry whatever has been
|
what would block Phase 6.
|
||||||
gathered into Phase 6 and record the remaining gaps as open questions. Do
|
3. **Operator stop** — the operator interrupts. Remove the scope marker first,
|
||||||
not re-enter the loop after a stop.
|
then carry whatever has been gathered into Phase 6 and record the remaining
|
||||||
|
gaps as open questions. Do not re-enter the loop after a stop.
|
||||||
|
|
||||||
### When the loop does not apply
|
### When the loop does not apply
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,14 @@ inside a sub-agent was never measured — so this hook is **defence in depth**,
|
||||||
and `research-loop-cap.mjs` must remain correct on its own if the hook
|
and `research-loop-cap.mjs` must remain correct on its own if the hook
|
||||||
silently stops firing.
|
silently stops firing.
|
||||||
|
|
||||||
**Open follow-up (outside Step 10's scope fence):** the marker file is written
|
**Follow-up, closed (S79).** Step 10 shipped the hook correct but **latent** —
|
||||||
by nothing yet. `commands/trekresearch.md` is on Session 4's never-touch list,
|
nothing wrote the marker, and it enforces exactly when a marker exists.
|
||||||
so wiring Phase 5 to write and remove the marker belongs to a later session.
|
`commands/trekresearch.md` Phase 5 now writes it (`### Loop scope marker`,
|
||||||
Until then the hook is correct but latent: it enforces exactly when a marker
|
keyed by `CLAUDE_CODE_SESSION_ID`, carrying the same `run_id` the ledger is
|
||||||
exists, and no marker is ever created.
|
counted under) and removes it on all three exits. Cleanup covers exits only; a
|
||||||
|
crashed session is covered by the TTL above. Verified end-to-end: the snippet
|
||||||
|
as shipped arms the hook, the hook denies at `8/8` turns, and the removal
|
||||||
|
snippet returns it to allow so Phase 6 can still spawn agents. Pinned by
|
||||||
|
`tests/lib/doc-consistency.test.mjs` (STORM marker), which derives the
|
||||||
|
directory name from `SCOPE_DIRNAME` in the hook, so renaming either side
|
||||||
|
fails.
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
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
|
// 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.
|
// it entirely. Handover 1 (PUBLIC CONTRACT) must disclose this and name the remedy.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue