Skip to content

Commit 31db58e

Browse files
auto-trigger: enforce the per-case MAX_TURNS table, correct a stale claim in it
case_max_turns() (bash 3.2 safe -- no associative arrays, a plain case statement) replaces the flat $MAX_TURNS in both run_case and run_negative_case's claude invocations. Global default stays 3; a case with no entry in the table is not in the case statement either, so it inherits the default exactly as before this table existed. Only the two measured overrides get a different value: prove-it-works-declare-done=6, encode-lessons-lint=10. build-the-lever-headers and type-system-discipline-go stay unset -- flaky at 3, never measured above it, and a guessed number in an enforced table is worse than one sitting in a comment. Every PASS/FAIL/retry/fence-breach line and HIT_LINES entry in both functions now carries max-turns=N, so a pass at 10 and a pass at 3 read as the different results they are without cross-referencing the table. Also corrects the table's prove-it-works-declare-done entry: a same-day 3-attempt baseline on an unedited hook went 0/3 then 1/3 PASS on immediate rerun -- a flapper, not the hard zero the original wording claimed. The 6-turn deep-dive sample (subtype=success, no Skill call) is still real, but one sample of a stochastic process doesn't settle what a proper per-case rate would.
1 parent fc9102f commit 31db58e

1 file changed

Lines changed: 67 additions & 36 deletions

File tree

tests/auto-trigger.sh

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,20 @@ set -uo pipefail
1414
PER_CASE_TIMEOUT=120 # seconds; enforced by the polling loop in run_case (macOS has no timeout(1))
1515
MODEL="sonnet"
1616
MAX_TURNS=3
17-
# Per-case turn budget, measured not guessed (2026-08-23). MAX_TURNS above is the one global
18-
# default this file actually uses -- a case with no row below inherits it unchanged, same as
19-
# today. This table does not change that; it exists so the next person deciding whether to
20-
# split MAX_TURNS per case has measurements instead of a guess. Each row is a real claude
21-
# invocation at that value, run outside this harness with matching flags (including the
22-
# "Agent" denial), workdir inspected before cleanup for terminal `subtype`, `num_turns`, and
23-
# whether a `Skill` tool_use appears.
17+
# Per-case turn budget, measured not guessed (2026-08-23), enforced by case_max_turns() below.
18+
# MAX_TURNS above is still the global default -- a case with no row here is not in the case
19+
# statement below either, and inherits MAX_TURNS unchanged, same as before this table existed.
20+
# Do not silently raise the floor for a case with no row: only the two entries under
21+
# "needs more than the default" get an override. Each row is a real claude invocation at
22+
# that value, run outside this harness with matching flags (including the "Agent" denial),
23+
# workdir inspected before cleanup for terminal `subtype`, `num_turns`, and whether a `Skill`
24+
# tool_use appears. `num_turns` in that result event is NOT the same counter --max-turns
25+
# enforces against: it also tallies turns from spawned subagents/internal tool infrastructure
26+
# (a haiku-model subagent's turns showed up in the SAME result event as the capped sonnet
27+
# agent's own, in the encode-lessons-lint @6 transcript). The cap itself is proven binding by
28+
# the CLI's own error text, which cites the configured value exactly ("Reached maximum number
29+
# of turns (6)" at --max-turns 6) even though num_turns reported 7 for that same run --
30+
# read num_turns as a rough diagnostic, not as turns-consumed-of-budget.
2431
#
2532
# MEASURED, sufficient at the current default (3) -- no override needed:
2633
# root-cause-guard PASS both historical runs, attempt 1
@@ -29,11 +36,18 @@ MAX_TURNS=3
2936
# sequence-verifiable-units-migration PASS both historical runs, attempt 1
3037
#
3138
# MEASURED, needs more than the default -- recommended per-case value:
32-
# prove-it-works-declare-done -> 6 @6: subtype=success, num_turns=4/6, no Skill call,
33-
# tools used: Grep,Grep,Read. Genuinely completes
34-
# without ever consulting the dispatcher -- not
35-
# starvation, a real "never fires" signal. 2 turns
36-
# of margin measured; 6 is sufficient.
39+
# prove-it-works-declare-done -> 6 @6, one sample: subtype=success, num_turns=4/6,
40+
# no Skill call, tools used Grep,Grep,Read --
41+
# completed without consulting the dispatcher. But
42+
# a same-day 3-attempt baseline at the DEFAULT (3)
43+
# was 0/3 miss, then 1/3 PASS on the very next run
44+
# (attempt 1) -- a flapper, not a hard zero. One
45+
# sample of a stochastic process is not a
46+
# measurement of it: do NOT read this as a proven
47+
# "never fires." 6 is still a reasonable budget
48+
# (the one deep-dive sample used only 4 of it), but
49+
# the dispatch-rate question is open pending a
50+
# proper per-case sample count.
3751
# encode-lessons-lint -> 10 @3: starved, never fired in 3/3 attempts (two
3852
# separate historical runs).
3953
# @6: still subtype=error_max_turns, num_turns=7,
@@ -61,6 +75,20 @@ MAX_TURNS=3
6175
# executing-plans-checkpoints, impeccable-polish, interrogate-auth-diff,
6276
# maintain-verification-skill-drift, reflect-session, unslop-draft-pass,
6377
# negative-arithmetic, negative-factual
78+
# ---------------------------------------------------------------------------
79+
# case_max_turns NAME
80+
# Prints the --max-turns value for NAME: the override from the table above if it has one,
81+
# else the global MAX_TURNS default. A plain case statement, not an associative array --
82+
# macOS ships bash 3.2, which doesn't have those.
83+
# ---------------------------------------------------------------------------
84+
case_max_turns() {
85+
case "$1" in
86+
prove-it-works-declare-done) echo 6 ;;
87+
encode-lessons-lint) echo 10 ;;
88+
*) echo "$MAX_TURNS" ;;
89+
esac
90+
}
91+
6492
# Attempts per case before calling it a failure. Skill dispatch is a model decision, so a
6593
# single sample is a coin flip; a skill that has actually stopped firing misses every attempt.
6694
# feature-chain is the measured-marginal case (~50% per attempt across runs: the model often
@@ -195,6 +223,7 @@ run_case() {
195223
selected_ "$name" || return 0
196224
local attempt fired fired_csv matched workdir out_jsonl err_log runner_pid waited
197225
local baseline violations term_subtype last_fired_csv=""
226+
local case_turns; case_turns="$(case_max_turns "$name")"
198227

199228
# Skill dispatch is a model decision, not a deterministic branch, so one sample is a coin
200229
# flip and a single-shot assertion makes this suite cry wolf. Retry a miss up to $ATTEMPTS
@@ -221,7 +250,7 @@ run_case() {
221250
claude -p "$prompt" \
222251
--output-format stream-json --verbose \
223252
--disallowedTools "Write,Edit,MultiEdit,NotebookEdit,Bash,Agent" \
224-
--model "$MODEL" --max-turns "$MAX_TURNS" \
253+
--model "$MODEL" --max-turns "$case_turns" \
225254
< /dev/null > "$out_jsonl" 2> "$err_log"
226255
) &
227256
runner_pid=$!
@@ -258,10 +287,10 @@ run_case() {
258287
rm -rf "$workdir"
259288

260289
if [[ -n "$violations" ]]; then
261-
echo "FAIL $name -> fence breach: run wrote outside its allowed tools:"
290+
echo "FAIL $name -> fence breach (max-turns=$case_turns): run wrote outside its allowed tools:"
262291
while IFS= read -r v; do printf ' %s\n' "$v"; done <<< "$violations"
263-
RESULT_LINES+=("FAIL $name -> fence breach (wrote: $(echo "$violations" | tr '\n' ';' | sed 's/;$//'))")
264-
HIT_LINES+=("$(printf '%-22s FENCE BREACH -> wrote %s' "$name" "$(echo "$violations" | wc -l | tr -d ' ')file(s)")")
292+
RESULT_LINES+=("FAIL $name -> fence breach, max-turns=$case_turns (wrote: $(echo "$violations" | tr '\n' ';' | sed 's/;$//'))")
293+
HIT_LINES+=("$(printf '%-22s FENCE BREACH -> max-turns=%s, wrote %s' "$name" "$case_turns" "$(echo "$violations" | wc -l | tr -d ' ')file(s)")")
265294
FAIL_COUNT=$((FAIL_COUNT + 1))
266295
return
267296
fi
@@ -271,23 +300,23 @@ run_case() {
271300
if [[ -n "$fired" ]] && echo "$fired" | grep -qE "^($expected_regex)$"; then
272301
matched="$(echo "$fired" | grep -E "^($expected_regex)$" | head -1)"
273302
if (( attempt > 1 )); then
274-
echo "PASS $name -> $matched (on attempt $attempt of $ATTEMPTS)"
275-
RESULT_LINES+=("PASS $name -> $matched (attempt $attempt)")
303+
echo "PASS $name -> $matched (on attempt $attempt of $ATTEMPTS, max-turns=$case_turns)"
304+
RESULT_LINES+=("PASS $name -> $matched (attempt $attempt, max-turns=$case_turns)")
276305
else
277-
echo "PASS $name -> $matched"
278-
RESULT_LINES+=("PASS $name -> $matched")
306+
echo "PASS $name -> $matched (max-turns=$case_turns)"
307+
RESULT_LINES+=("PASS $name -> $matched (max-turns=$case_turns)")
279308
fi
280-
HIT_LINES+=("$(printf '%-22s attempt %s/%s -> %s' "$name" "$attempt" "$ATTEMPTS" "$matched")")
309+
HIT_LINES+=("$(printf '%-22s attempt %s/%s -> %s (max-turns=%s)' "$name" "$attempt" "$ATTEMPTS" "$matched" "$case_turns")")
281310
PASS_COUNT=$((PASS_COUNT + 1))
282311
return
283312
fi
284313

285-
(( attempt < ATTEMPTS )) && echo " retry $name (attempt $attempt fired: [$fired_csv])"
314+
(( attempt < ATTEMPTS )) && echo " retry $name (attempt $attempt, max-turns=$case_turns, fired: [$fired_csv])"
286315
done
287316

288-
echo "FAIL $name -> expected $expected_regex, never fired in $ATTEMPTS attempts (last: [$last_fired_csv])"
289-
RESULT_LINES+=("FAIL $name -> expected $expected_regex, $ATTEMPTS attempts (last: $last_fired_csv)")
290-
HIT_LINES+=("$(printf '%-22s never in %s -> %s' "$name" "$ATTEMPTS" "$last_fired_csv")")
317+
echo "FAIL $name -> expected $expected_regex, never fired in $ATTEMPTS attempts at max-turns=$case_turns (last: [$last_fired_csv])"
318+
RESULT_LINES+=("FAIL $name -> expected $expected_regex, $ATTEMPTS attempts, max-turns=$case_turns (last: $last_fired_csv)")
319+
HIT_LINES+=("$(printf '%-22s never in %s -> %s (max-turns=%s)' "$name" "$ATTEMPTS" "$last_fired_csv" "$case_turns")")
291320
FAIL_COUNT=$((FAIL_COUNT + 1))
292321
}
293322

@@ -299,6 +328,7 @@ run_negative_case() {
299328
local name="$1" prompt="$2" forbidden_regex="$3"
300329
selected_ "$name" || return 0
301330
local workdir out_jsonl err_log runner_pid waited fired fired_csv baseline violations
331+
local case_turns; case_turns="$(case_max_turns "$name")"
302332

303333
workdir="$(mktemp -d "/tmp/auto-trigger-neg.XXXXXX")"
304334
baseline="$(find "$workdir" -mindepth 1 2>/dev/null | sort)"
@@ -309,7 +339,7 @@ run_negative_case() {
309339
claude -p "$prompt" \
310340
--output-format stream-json --verbose \
311341
--disallowedTools "Write,Edit,MultiEdit,NotebookEdit,Bash,Agent" \
312-
--model "$MODEL" --max-turns "$MAX_TURNS" \
342+
--model "$MODEL" --max-turns "$case_turns" \
313343
< /dev/null > "$out_jsonl" 2> "$err_log"
314344
) &
315345
runner_pid=$!
@@ -327,23 +357,23 @@ run_negative_case() {
327357
rm -rf "$workdir"
328358

329359
if [[ -n "$violations" ]]; then
330-
echo "FAIL $name -> fence breach: run wrote outside its allowed tools:"
360+
echo "FAIL $name -> fence breach (max-turns=$case_turns): run wrote outside its allowed tools:"
331361
while IFS= read -r v; do printf ' %s\n' "$v"; done <<< "$violations"
332-
RESULT_LINES+=("FAIL $name -> fence breach (wrote: $(echo "$violations" | tr '\n' ';' | sed 's/;$//'))")
333-
HIT_LINES+=("$(printf '%-22s FENCE BREACH -> wrote %s' "$name" "$(echo "$violations" | wc -l | tr -d ' ')file(s)")")
362+
RESULT_LINES+=("FAIL $name -> fence breach, max-turns=$case_turns (wrote: $(echo "$violations" | tr '\n' ';' | sed 's/;$//'))")
363+
HIT_LINES+=("$(printf '%-22s FENCE BREACH -> max-turns=%s, wrote %s' "$name" "$case_turns" "$(echo "$violations" | wc -l | tr -d ' ')file(s)")")
334364
FAIL_COUNT=$((FAIL_COUNT + 1))
335365
return
336366
fi
337367

338368
if [[ -n "$fired" ]] && echo "$fired" | grep -qE "^($forbidden_regex)$"; then
339-
echo "FAIL $name -> $forbidden_regex fired on a prompt that is not its situation [$fired_csv]"
340-
RESULT_LINES+=("FAIL $name -> over-triggered")
341-
HIT_LINES+=("$(printf '%-22s NEGATIVE -> fired: %s' "$name" "$fired_csv")")
369+
echo "FAIL $name -> $forbidden_regex fired on a prompt that is not its situation (max-turns=$case_turns) [$fired_csv]"
370+
RESULT_LINES+=("FAIL $name -> over-triggered, max-turns=$case_turns")
371+
HIT_LINES+=("$(printf '%-22s NEGATIVE -> fired: %s (max-turns=%s)' "$name" "$fired_csv" "$case_turns")")
342372
FAIL_COUNT=$((FAIL_COUNT + 1))
343373
else
344-
echo "PASS $name -> did not over-trigger [$fired_csv]"
345-
RESULT_LINES+=("PASS $name -> no over-trigger")
346-
HIT_LINES+=("$(printf '%-22s NEGATIVE -> clean (%s)' "$name" "$fired_csv")")
374+
echo "PASS $name -> did not over-trigger (max-turns=$case_turns) [$fired_csv]"
375+
RESULT_LINES+=("PASS $name -> no over-trigger, max-turns=$case_turns")
376+
HIT_LINES+=("$(printf '%-22s NEGATIVE -> clean (%s) (max-turns=%s)' "$name" "$fired_csv" "$case_turns")")
347377
PASS_COUNT=$((PASS_COUNT + 1))
348378
fi
349379
}
@@ -523,7 +553,8 @@ EOF
523553
# ---------------------------------------------------------------------------
524554
# Test cases
525555
# ---------------------------------------------------------------------------
526-
echo "Running skill auto-trigger regression suite (model=$MODEL, max-turns=$MAX_TURNS, per-case timeout=${PER_CASE_TIMEOUT}s)"
556+
echo "Running skill auto-trigger regression suite (model=$MODEL, default max-turns=$MAX_TURNS, per-case timeout=${PER_CASE_TIMEOUT}s)"
557+
echo "Per-case max-turns overrides: prove-it-works-declare-done=$(case_max_turns prove-it-works-declare-done), encode-lessons-lint=$(case_max_turns encode-lessons-lint) (every other case uses the default)"
527558
echo "---"
528559

529560
run_case \

0 commit comments

Comments
 (0)