@@ -14,13 +14,20 @@ set -uo pipefail
1414PER_CASE_TIMEOUT=120 # seconds; enforced by the polling loop in run_case (macOS has no timeout(1))
1515MODEL=" sonnet"
1616MAX_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}
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)"
527558echo " ---"
528559
529560run_case \
0 commit comments