crewforth 3.1.0-rc.5 → 3.1.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +76 -12
  2. package/README.md +3 -2
  3. package/kit/AGENT_TEMPLATE.md +41 -21
  4. package/kit/CLAUDE.md +9 -7
  5. package/kit/agents/crew-backend-expert.md +4 -1
  6. package/kit/agents/crew-commit-agent.md +8 -3
  7. package/kit/agents/crew-database-expert.md +4 -1
  8. package/kit/agents/crew-devops-expert.md +4 -1
  9. package/kit/agents/crew-frontend-expert.md +4 -1
  10. package/kit/agents/crew-performance-expert.md +4 -1
  11. package/kit/agents/crew-planner.md +10 -1
  12. package/kit/agents/crew-privacy-agent.md +4 -1
  13. package/kit/agents/crew-review-agent.md +4 -1
  14. package/kit/agents/crew-security-expert.md +4 -1
  15. package/kit/agents/crew-session-manager.md +4 -1
  16. package/kit/agents/crew-test-expert.md +4 -1
  17. package/kit/eval/smoke-test.sh +860 -27
  18. package/kit/hooks/agent-outcome.sh +437 -0
  19. package/kit/hooks/context-usage.sh +1 -1
  20. package/kit/hooks/guard-agent-model.sh +494 -60
  21. package/kit/hooks/guard-bash.sh +69 -8
  22. package/kit/hooks/guard-schedule.sh +8 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +94 -7
  25. package/kit/settings.json +43 -0
  26. package/kit/skills/crew-approve/SKILL.md +4 -1
  27. package/kit/skills/crew-loosen/SKILL.md +28 -0
  28. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  29. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  30. package/kit/studio/server/index.js +85 -6
  31. package/kit/studio/server/lib/agent-origin.js +44 -0
  32. package/kit/studio/server/lib/graph.js +45 -7
  33. package/kit/studio/server/lib/model-outcomes.js +287 -0
  34. package/kit/studio/server/lib/palette.js +5 -18
  35. package/kit/studio/server/lib/permissions.js +260 -17
  36. package/kit/studio/server/lib/session.js +41 -1
  37. package/kit/studio/server/lib/usage.js +8 -1
  38. package/kit/studio/web/app.js +203 -32
  39. package/kit/studio/web/approvals.js +53 -2
  40. package/kit/studio/web/ask.js +137 -0
  41. package/kit/studio/web/canvas.js +91 -42
  42. package/kit/studio/web/chat.js +11 -1
  43. package/kit/studio/web/convo.js +9 -0
  44. package/kit/studio/web/dock.js +30 -9
  45. package/kit/studio/web/graph-plan.js +29 -9
  46. package/kit/studio/web/icons/builtin.svg +1 -1
  47. package/kit/studio/web/index.html +11 -4
  48. package/kit/studio/web/keys.js +29 -0
  49. package/kit/studio/web/list-plan.js +8 -3
  50. package/kit/studio/web/list.js +27 -5
  51. package/kit/studio/web/models-plan.js +167 -0
  52. package/kit/studio/web/models.js +127 -0
  53. package/kit/studio/web/names.js +30 -0
  54. package/kit/studio/web/style.css +85 -9
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +26 -7
  57. package/package.json +1 -1
@@ -1,25 +1,41 @@
1
1
  #!/usr/bin/env bash
2
- # guard-agent-model.sh - a crew agent is called with a model, and not below its floor.
2
+ # guard-agent-model.sh - a crew agent is called with a task card and a model, and the model fits the card's risk.
3
3
  #
4
- # WHY. The model for a piece of work is chosen per call, by risk (the table in CLAUDE.md): haiku for mechanical
5
- # work, sonnet for ordinary work, opus where a mistake is expensive. Left to the session's default, every agent ran
6
- # on the session's model; left to a habit of "cheapest", an audit could run below the code it clears. Two things
7
- # are held here, at the tool level:
8
- # * a call to a crew-* agent NAMES its model, so the choice is made and can be read in the transcript;
9
- # * some agents have a floor: crew-security-expert runs on opus; crew-privacy-agent, crew-review-agent,
10
- # crew-planner and crew-database-expert on sonnet or above.
11
- # It only refuses, with the reason and the model that is needed. It never rewrites a call. THE WORDING IS READ BY
12
- # ANOTHER PROGRAM (the Studio panel): a refusal is one line that begins `GUARD (agent model):`, and a floor is said
13
- # as ` runs on <model> or above`. The suite pins both. Measured on Claude Code
14
- # 2.1.294: refused this way, the session repeated the same call with the model named, and a call with
15
- # model: "haiku" ran its agent on Haiku (the agent's own transcript names the model).
4
+ # WHY. The model for a piece of work is chosen per call, by the risk of the work (the table in CLAUDE.md). 3.1.0's
5
+ # first form of this gate looked at the AGENT only, so database work under payments/ ran on Sonnet and passed. The
6
+ # risk is in the work: which files, what kind of change, and whether a command can say it is right. So a call to a
7
+ # crew-* agent opens with a card,
8
+ # files: the files or globs it will touch
9
+ # change: text | feature | fix-known | fix-unknown | refactor | migration | security | architecture |
10
+ # test-run | test-write | audit
11
+ # verify: the command that says the work is right, or `none`
12
+ # and this gate reads the card. What it holds, all of it by refusing and never by rewriting a call:
13
+ # * the call names a model, and carries a card;
14
+ # * RISK CLASS. `critical` when the change is migration, security, architecture or fix-unknown, or a file is on a
15
+ # critical path (auth, payments, billing, migrations, security, crypto, secrets, *.sql, schema.prisma;
16
+ # .claude/crew-model-rules adds and removes paths). Otherwise `normal`.
17
+ # * critical work runs on opus, whatever the agent. The one exception is test-run: running the tests is objective.
18
+ # * the referee is as strong as the risk: test-write and audit on sonnet or above (opus when critical, by the rule
19
+ # above); review, privacy, planner and database agents on sonnet or above; the security agent on opus.
20
+ # * work with no verify command runs on sonnet or above: haiku goes only to work a command can check.
21
+ # * a class that failed too often is held one model up (crew-model-floors.auto, written by hooks/agent-outcome.sh),
22
+ # until the user lowers it again with /crew-loosen (one model, once for each raise).
23
+ # * a card whose verify failed is not run again on the same or a lower model, and not a third time at all.
24
+ # * critical work with a verify command is not sent to the background (run_in_background true), where its
25
+ # result does not reach the session. A call that leaves the field out passes: not every Agent tool has it.
26
+ # THE WORDING IS READ BY ANOTHER PROGRAM (the Studio panel): a refusal is one line that begins
27
+ # `GUARD (agent model):`, and a floor is said as ` runs on <model> or above`. The suite pins both.
28
+ #
29
+ # ALSO A LIBRARY. hooks/guard-write.sh sources this file for the check at write time (an agent that is not on opus
30
+ # does not write to a critical path, whatever its card said), and hooks/agent-outcome.sh for the card, the class and
31
+ # the state. Sourced, the file defines its functions and returns before it reads anything.
16
32
  #
17
33
  # NOT TOUCHED: an agent that is not crew-* (Explore, general-purpose, a project's own), and any tool but Agent.
18
34
  # SWITCHES, read from the session's environment:
19
- # CREW_MODEL_ROUTING=off the gate does nothing, which is how it was before 3.1.0.
35
+ # CREW_MODEL_ROUTING=off nothing here does anything, which is how it was before 3.1.0.
20
36
  # CREW_ALLOW_FABLE=1 `fable` may be named; without it a call that names it is refused.
21
- # HONEST SCOPE: which model fits the TASK is the caller's judgement and nothing here reads the task. The model is
22
- # read from the call's own `model` field; an agent file's frontmatter is not read.
37
+ # HONEST SCOPE: the card is written by the caller and can be wrong. The write-time check and the verify command are
38
+ # what bound that; neither reads intent.
23
39
  set -uo pipefail
24
40
  # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
25
41
  # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
@@ -40,30 +56,6 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
40
56
  case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
41
57
  export LC_ALL=C
42
58
  # ---- /CREW-LOCALE
43
- # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
44
- # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
45
- # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
46
- # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
47
- # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
48
- # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
49
- # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
50
- # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
51
- # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
52
- # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
53
- # that is not found (status 127, the script continues inside the same command).
54
- # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
55
- _crew_stop(){ # $1 = what happened
56
- declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
57
- echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
58
- echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
59
- exit 2
60
- }
61
- trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
62
- # ---- /CREW-FAILCLOSED
63
- _gate_main(){
64
- local tn st m ti rank=0 floor=0 fname=""
65
- [ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
66
- IFS= read -r -d '' INPUT || true
67
59
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
68
60
  _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
69
61
  # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
@@ -342,6 +334,384 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
342
334
  done
343
335
  }
344
336
  # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
337
+ _AM_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _AM_LIB=1
338
+ # ---- state: where the records of this gate and of agent-outcome.sh live ----------------------------------------
339
+ _am_state(){ # -> _AMP (the project), _AMS (.claude/state), _AMD (the records of calls, agents and results)
340
+ _AMP="${CLAUDE_PROJECT_DIR:-$PWD}"; _AMP="${_AMP//\\//}"; _AMP="${_AMP%/}"
341
+ _AMS="$_AMP/.claude/state"; _AMD="$_AMS/crew-model"
342
+ }
343
+ _am_rank(){ # $1 = haiku|sonnet|opus|fable -> _AMR 1..4, 0 for anything else
344
+ case "$1" in haiku) _AMR=1 ;; sonnet) _AMR=2 ;; opus) _AMR=3 ;; fable) _AMR=4 ;; *) _AMR=0 ;; esac; }
345
+ _am_name(){ # $1 = 1..4 -> _AMN
346
+ case "$1" in 1) _AMN=haiku ;; 2) _AMN=sonnet ;; 3) _AMN=opus ;; 4) _AMN=fable ;; *) _AMN="" ;; esac; }
347
+ _am_tier(){ # $1 = a model as a call or a transcript names it -> _AMT haiku|sonnet|opus|fable, "" unknown
348
+ local nc=0; shopt -q nocasematch && nc=1; shopt -s nocasematch
349
+ case "$1" in *haiku*) _AMT=haiku ;; *sonnet*) _AMT=sonnet ;; *opus*) _AMT=opus ;; *fable*) _AMT=fable ;; *) _AMT="" ;; esac
350
+ [ "$nc" = 0 ] && shopt -u nocasematch; return 0; }
351
+
352
+ # ---- CRITICAL PATHS ---------------------------------------------------------------------------------------------
353
+ # One definition, asked of a card's files (this gate), of the file an agent is about to write (guard-write.sh) and
354
+ # of every file of the project (the scan in agent-outcome.sh). A path is critical when one of its parts is one of
355
+ # these WORDS, standing as a word: after a separator or at a capital (`auth/`, `user_auth.py`, `AuthController.cs`,
356
+ # `userAuth.ts`, `AUTH_KEYS`) and not running on in lower case (`author.md`, `cryptography/` are not). And by name:
357
+ # a .sql file, schema.prisma. The path is read from the project's root down, so the name of the folder the project
358
+ # sits in decides nothing. .claude/crew-model-rules, which only the user writes, adds (`+ glob`) and removes
359
+ # (`- glob`); the last line that matches wins.
360
+ _AM_W_L='auth|authn|authz|authentication|authorization|oauth|payments?|billing|migrations?|security|crypto|secrets?'
361
+ _AM_W_C='Auth|Authn|Authz|Authentication|Authorization|OAuth|Payments?|Billing|Migrations?|Security|Crypto|Secrets?'
362
+ _AM_W_U='AUTH|AUTHN|AUTHZ|OAUTH|PAYMENTS?|BILLING|MIGRATIONS?|SECURITY|CRYPTO|SECRETS?'
363
+ _AM_RE_L="(^|[^A-Za-z])($_AM_W_L)([^a-z]|\$)"
364
+ _AM_RE_C="(^|[^A-Z])($_AM_W_C)([^a-z]|\$)"
365
+ _AM_RE_U="(^|[^A-Za-z])($_AM_W_U)([^A-Za-z]|\$)"
366
+ _AM_RULES_READ=0; _AM_RULES=()
367
+ _am_rules(){ # reads .claude/crew-model-rules once -> _AM_RULES: "+<TAB>glob", "-<TAB>glob", "f<TAB>agent change risk tier", "v<TAB>command"
368
+ local l a b c d
369
+ [ "$_AM_RULES_READ" = 1 ] && return 0
370
+ _AM_RULES_READ=1; _AM_RULES=()
371
+ [ -n "${_AMP:-}" ] || _am_state
372
+ [ -f "$_AMP/.claude/crew-model-rules" ] || return 0
373
+ while IFS= read -r l || [ -n "$l" ]; do
374
+ l="${l%$'\r'}"; l="${l%%#*}"
375
+ while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
376
+ while :; do case "$l" in *[$' \t']) l="${l%?}" ;; *) break ;; esac; done
377
+ case "$l" in
378
+ "+ "*|"+"$'\t'*) l="${l:1}"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done; _AM_RULES+=("+"$'\t'"$l") ;;
379
+ "- "*|"-"$'\t'*) l="${l:1}"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done; _AM_RULES+=("-"$'\t'"$l") ;;
380
+ "verify "*) l="${l#verify }"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
381
+ [ -n "$l" ] && case "$l" in *[\;\&\|\<\>\`\$]*) ;; *) _AM_RULES+=("v"$'\t'"$l") ;; esac ;;
382
+ "floor "*) IFS=$' \t' read -r a b c d _ <<< "${l#floor }"; _am_tier "${d:-}"
383
+ [ -n "$_AMT" ] && [ -n "${c:-}" ] && _AM_RULES+=("f"$'\t'"$a $b $c $_AMT") ;;
384
+ esac
385
+ done < "$_AMP/.claude/crew-model-rules"
386
+ }
387
+ _am_rel(){ # $1 = a path as a card or a tool names it -> _AMRP, from the project's root where it is under it
388
+ local p="${1//\\//}"
389
+ [ -n "${_AMP:-}" ] || _am_state
390
+ case "$p" in "$_AMP"/*) p="${p#"$_AMP"/}" ;; ./*) p="${p#./}" ;; esac
391
+ _AMRP="$p"
392
+ }
393
+ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 not
394
+ local p r k g hit=1
395
+ _am_rel "$1"; p="$_AMRP"; _AMW=""
396
+ if [[ "$p" =~ $_AM_RE_L ]] || [[ "$p" =~ $_AM_RE_C ]] || [[ "$p" =~ $_AM_RE_U ]]; then hit=0; _AMW="${BASH_REMATCH[2]}"
397
+ else case "$p" in *.sql|*.SQL) hit=0; _AMW="a .sql file" ;; schema.prisma|*/schema.prisma) hit=0; _AMW="schema.prisma" ;; esac; fi
398
+ _am_rules
399
+ for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
400
+ k="${r%%$'\t'*}"; g="${r#*$'\t'}"
401
+ case "$k" in +|-) ;; *) continue ;; esac
402
+ # A rule's glob matches the path, anything under it, and the same at any depth.
403
+ case "$p" in $g|$g/*|*/$g|*/$g/*) if [ "$k" = + ]; then hit=0; _AMW="crew-model-rules: + $g"; else hit=1; _AMW=""; fi ;; esac
404
+ done
405
+ return "$hit"
406
+ }
407
+
408
+ # ---- THE CARD ---------------------------------------------------------------------------------------------------
409
+ _AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
410
+ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1 and CARD_WHY
411
+ local s="$1" l k v n=0 seen=""
412
+ CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""
413
+ s="${s//$'\r'/}"
414
+ while [ "$n" -lt 3 ] && [ -n "$s" ]; do
415
+ l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
416
+ while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
417
+ [ -n "$l" ] || continue
418
+ n=$((n+1))
419
+ case "$l" in *:*) ;; *) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;; esac
420
+ k="${l%%:*}"; v="${l#*:}"
421
+ while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
422
+ while :; do case "$v" in *[$' \t']) v="${v%?}" ;; *) break ;; esac; done
423
+ case "$k" in
424
+ files) CARD_FILES="$v" ;;
425
+ change) CARD_CHANGE="$v" ;;
426
+ verify) CARD_VERIFY="$v" ;;
427
+ *) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;;
428
+ esac
429
+ case "$seen" in *" $k "*) CARD_WHY="the card names $k twice"; return 1 ;; esac
430
+ seen="$seen $k "
431
+ done
432
+ [ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
433
+ [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
434
+ case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
435
+ [ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
436
+ case "$CARD_VERIFY" in none|None|NONE|-) CARD_VERIFY=none ;; esac
437
+ return 0
438
+ }
439
+ _am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
440
+ local f unglob=0
441
+ CARD_RISK=normal; CARD_RISKWHY=""
442
+ case " migration security architecture fix-unknown " in *" $CARD_CHANGE "*) CARD_RISK=critical; CARD_RISKWHY="change: $CARD_CHANGE" ;; esac
443
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
444
+ for f in ${CARD_FILES//,/ }; do
445
+ if _am_crit_path "$f"; then CARD_RISK=critical; [ -n "$CARD_RISKWHY" ] || CARD_RISKWHY="$f is on a critical path: $_AMW"; fi
446
+ done
447
+ [ "$unglob" = 1 ] && set +f
448
+ return 0
449
+ }
450
+ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "the same card" is the same value
451
+ local t
452
+ t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | git hash-object --stdin 2>/dev/null)" || t=""
453
+ case "$t" in ''|*[!0-9a-f]*) t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | cksum 2>/dev/null)"; t="${t%% *}"; printf -v t '%012x' "${t:-0}" 2>/dev/null || t=000000000000 ;; esac
454
+ CARD_ID="${t:0:12}"
455
+ }
456
+ _am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
457
+ local l; _AMV=""
458
+ [ -f "$1" ] || return 1
459
+ while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$2="*) _AMV="${l#*=}"; return 0 ;; esac; done < "$1"
460
+ return 1
461
+ }
462
+
463
+ # ---- THE CALIBRATION FLOOR IN FORCE for a class ------------------------------------------------------------------
464
+ # hooks/agent-outcome.sh raises it (crew-model-floors.auto: agent, change, risk, model, calls, not-first-try, when);
465
+ # the user lowers it, one model, once for each raise, with /crew-loosen (crew-model-loosened.tsv: ts, agent, change,
466
+ # risk, from, to; written by hooks/prompt-approval.sh from the user's own message). A lowering counts when it is
467
+ # NEWER than the raise it answers: raised again afterwards, the floor is the raised one. One function, asked by this
468
+ # gate, by the hook that raises, and by the hook that records the lowering.
469
+ _am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor, "" none), _AMF_WHEN, _AMF_LOOSE 0|1,
470
+ # _AMF_TO and _AMF_LTS (the lowering that is in force), _AMF_EFF (the floor that holds now, "" none)
471
+ local a b c t n bad w ts la lb lc lf lt
472
+ _AMF_TIER=""; _AMF_WHEN=""; _AMF_LOOSE=0; _AMF_TO=""; _AMF_LTS=""; _AMF_EFF=""
473
+ [ -n "${_AMS:-}" ] || _am_state
474
+ [ -f "$_AMS/crew-model-floors.auto" ] || return 0
475
+ while IFS=$'\t' read -r a b c t n bad w || [ -n "$a" ]; do
476
+ [ "$a" = "$1" ] && [ "$b" = "$2" ] && [ "$c" = "$3" ] || continue
477
+ _AMF_TIER="${t%$'\r'}"; _AMF_WHEN="${w%$'\r'}"
478
+ done < "$_AMS/crew-model-floors.auto"
479
+ [ -n "$_AMF_TIER" ] || return 0
480
+ _AMF_EFF="$_AMF_TIER"
481
+ [ -f "$_AMS/crew-model-loosened.tsv" ] || return 0
482
+ while IFS=$'\t' read -r ts la lb lc lf lt || [ -n "$ts" ]; do
483
+ [ "$la" = "$1" ] && [ "$lb" = "$2" ] && [ "$lc" = "$3" ] || continue
484
+ lt="${lt%$'\r'}"
485
+ # ISO 8601 in UTC sorts as text. Only a lowering of THIS raise counts: not older than it (the same second is a
486
+ # lowering of it; the hook that raises again never stamps the second of a lowering), from its model, one down.
487
+ [[ "$ts" < "$_AMF_WHEN" ]] && continue
488
+ [ "$lf" = "$_AMF_TIER" ] || continue
489
+ _am_rank "$lf"; n="$_AMR"; _am_rank "$lt"; [ "$_AMR" -gt 0 ] && [ "$_AMR" = $((n-1)) ] || continue
490
+ _AMF_LOOSE=1; _AMF_TO="$lt"; _AMF_LTS="$ts"; _AMF_EFF="$lt"
491
+ done < "$_AMS/crew-model-loosened.tsv"
492
+ return 0
493
+ }
494
+
495
+ # ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
496
+ _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog; the call's text is never recorded.
497
+ local gl="${CREW_GATE_LOG:-}" v=BLOCK
498
+ case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
499
+ if [ -z "$gl" ]; then
500
+ [ -d ".claude" ] || return 0
501
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0; fi
502
+ gl=".claude/gate-log.tsv"
503
+ fi
504
+ printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
505
+ }
506
+
507
+ # ---- IS THIS VERIFY COMMAND ONE A HOOK MAY RUN? ------------------------------------------------------------------
508
+ # The command comes from the card, which the session wrote, and hooks/agent-outcome.sh would run it when the agent
509
+ # stops: outside any tool call, so past the permission rules, the auto-mode classifier and every PreToolUse gate.
510
+ # A list of what must NOT run cannot close that (the shell gate is such a list, and `python3 -c`, `node -e`, `npx`,
511
+ # `find -delete`, a wrapper like `timeout`, and a runner's own options such as `go test -exec` or `make SHELL=…`
512
+ # all pass it; found in review). So the rule is what MAY run, and everything else is left for the session to run
513
+ # itself, with the Bash tool, where the permission layer sees it. One function, asked at the moment the command
514
+ # would start; the rules, in this order:
515
+ # 1. ONE command: no `;`, `&`, `|`, `<`, `>`, backtick, `$(` or second line.
516
+ # 2. Never a git commit or a git push: an approval the user gave the SESSION is not for a hook.
517
+ # 3. NOT DENIED. A `Bash(...)` rule of permissions.deny or permissions.ask that covers it, in ANY settings file
518
+ # (the project's two, the user's, a managed one), stops it: a hook cannot ask, and a deny is a deny. What
519
+ # cannot be read stops it too: a settings file that is there and does not parse (a comment, a byte-order
520
+ # mark), and a deny or ask rule in a form this reader does not match (a `*` that is not at the end, as in
521
+ # `Bash(git * --force)`). Not knowing what is denied is not the same as nothing being denied.
522
+ # 4. ALLOWED BY NAME, one of:
523
+ # a. a line `verify <command>` of .claude/crew-model-rules (the user's file);
524
+ # b. a `Bash(...)` rule of permissions.allow in any of those settings files (a bare `Bash` allows nothing);
525
+ # c. ONLY in `auto` and `bypassPermissions`, the two modes in which Claude Code itself runs a test command
526
+ # without the user having allowed it: a test or build runner from the list below, with arguments that
527
+ # are paths or test names (no argument that begins with `-` or holds `=`: an option can name a program
528
+ # to run), and for make, mvn and gradle no argument at all (a second target is a second task). In
529
+ # `default`, `acceptEdits` and `plan` Claude Code asks first, and `dontAsk` refuses whatever was not
530
+ # allowed beforehand, so there the list allows nothing and only a or b does.
531
+ # 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
532
+ # explicit allow passes.
533
+ _AM_HERE="${BASH_SOURCE%/*}"; [ "$_AM_HERE" = "${BASH_SOURCE}" ] && _AM_HERE=.
534
+ # CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
535
+ _AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
536
+ 'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
537
+ 'cargo test' 'cargo build' 'cargo check' 'cargo clippy' 'pytest' 'python -m pytest' 'python3 -m pytest'
538
+ 'python -m unittest' 'python3 -m unittest' 'mvn test' 'mvn verify' './mvnw test' './mvnw verify' 'gradle test'
539
+ 'gradle check' 'gradle build' './gradlew test' './gradlew check' './gradlew build' 'make test' 'make check'
540
+ 'make lint' 'make build' 'tsc' 'eslint' 'ruff check' 'mypy' 'phpunit' 'rspec' 'bundle exec rspec' 'swift test'
541
+ 'swift build' 'ctest' 'jest' 'vitest' 'mocha' 'mix test' 'composer test' 'test' 'true')
542
+ # /CREW-NOT-A-RUNG
543
+ _am_starts(){ # $1 = command, $2 = prefix -> 0 when the command is the prefix or the prefix and more words
544
+ case "$1" in "$2"|"$2 "*) return 0 ;; esac; return 1; }
545
+ _am_plain_args(){ # $1 = runner, $2 = what follows it -> 0 when every word is a path or a test name
546
+ local w unglob=0 rc=0
547
+ # For these a further word is a further TASK: `make test deploy`, `gradle build publish`, `mvn test install`.
548
+ case "$1" in make\ *|mvn\ *|./mvnw\ *|gradle\ *|./gradlew\ *) [ -z "$2" ]; return ;; esac
549
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
550
+ for w in $2; do
551
+ # dotnet takes a project, a solution or a folder and nothing else: a bare word there is a verb or a target.
552
+ case "$1" in dotnet\ *) case "$w" in */*|*.*) ;; *) rc=1 ;; esac ;; esac
553
+ case "$w" in
554
+ -[A-Za-z]) [ "$1" = test ] || rc=1 ;; # `test -f x`: the one runner whose flags are its questions
555
+ -*|*=*) rc=1 ;;
556
+ *[!A-Za-z0-9_./:@,+%^~\#\[\]\*\?-]*) rc=1 ;;
557
+ esac
558
+ done
559
+ [ "$unglob" = 1 ] && set +f
560
+ return "$rc"
561
+ }
562
+ _am_settings_files(){ # -> _AMSF: the settings files that exist, one per line: project, local, user, managed
563
+ local f u; _AMSF=""
564
+ # The user's settings are where Claude Code keeps them: CLAUDE_CONFIG_DIR when it is set, ~/.claude otherwise.
565
+ u="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}"; u="${u//\\//}"
566
+ for f in "$_AMP/.claude/settings.json" "$_AMP/.claude/settings.local.json" "${u:+$u/settings.json}" \
567
+ "/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json" \
568
+ "/c/Program Files/ClaudeCode/managed-settings.json" "/c/ProgramData/ClaudeCode/managed-settings.json" \
569
+ "${CREW_MANAGED_SETTINGS:-}"; do # one more file to read as managed: it can only add rules
570
+ [ -n "$f" ] && [ -f "$f" ] && _AMSF="$_AMSF$f"$'\n'
571
+ done
572
+ }
573
+ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of that list covers it (_AMPH names it);
574
+ # 1 none does; 2 the list cannot be relied on (_AMPH says why): a file that does not parse, or,
575
+ # for deny and ask, a rule in a form this reader does not match
576
+ local c="$1" list="$2" sj="$_AM_HERE/../eval/lib/settings-json.awk" f x g out
577
+ _AMPH=""
578
+ while IFS= read -r f; do
579
+ [ -n "$f" ] || continue
580
+ if ! awk -v op=validate -f "$sj" "$f" >/dev/null 2>&1; then _AMPH="${f##*/} is there and cannot be read as JSON"; return 2; fi
581
+ out="$(awk -v op=strings -v path="permissions.$list" -f "$sj" "$f" 2>/dev/null)" || out=""
582
+ while IFS= read -r x; do
583
+ x="${x%$'\r'}"; [ -n "$x" ] || continue
584
+ if [ "$x" = Bash ]; then [ "$list" = allow ] && continue; _AMPH="$list: Bash (${f##*/})"; return 0; fi
585
+ case "$x" in 'Bash('*')') ;; *) continue ;; esac
586
+ x="${x#Bash(}"; x="${x%)}"; g=""
587
+ case "$x" in
588
+ ''|'*'|':*') [ "$list" = allow ] && continue; _AMPH="$list: Bash($x) (${f##*/})"; return 0 ;;
589
+ esac
590
+ # A `*` anywhere but at the end is a form this reader does not match. An allow rule it cannot read allows
591
+ # nothing; a deny or an ask rule it cannot read may cover the command, so the list cannot be relied on.
592
+ g="${x%:\*}"; g="${g% \*}"; g="${g%\*}"
593
+ case "$g" in *'*'*) [ "$list" = allow ] && continue; _AMPH="$list: Bash($x) (${f##*/}) is a form this hook does not match"; return 2 ;; esac
594
+ case "$x" in
595
+ *':*') g="${x%:\*}"; _am_starts "$c" "$g" || continue ;;
596
+ *' *') g="${x% \*}"; _am_starts "$c" "$g" || continue ;;
597
+ *'*') g="${x%\*}"; case "$c" in "$g"*) ;; *) continue ;; esac ;;
598
+ *) [ "$c" = "$x" ] || continue ;;
599
+ esac
600
+ _AMPH="$list: Bash($x) (${f##*/})"; return 0
601
+ done <<< "$out"
602
+ done <<< "$_AMSF"
603
+ return 1
604
+ }
605
+ _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
606
+ local c="$1" pm="${2:-default}" f r rc k g
607
+ _AMVW=""; _AMVB=""
608
+ case "$c" in ''|none) return 0 ;; esac
609
+ case "$c" in
610
+ *$'\n'*|*$'\r'*) _AMVW="it is more than one line"; return 1 ;;
611
+ *';'*|*'&'*|*'|'*|*'<'*|*'>'*|*'`'*|*'$('*) _AMVW="it chains, pipes, redirects or substitutes (; & | < > \` \$( )"; return 1 ;;
612
+ *[$'\001'-$'\010'$'\013'-$'\037']*) _AMVW="it holds a control character"; return 1 ;;
613
+ esac
614
+ c="${c//$'\t'/ }"; while :; do case "$c" in *' '*) c="${c// / }" ;; *) break ;; esac; done
615
+ c="${c# }"; c="${c% }"
616
+ if [[ " $c " =~ [[:space:]/]git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)[[:space:]] ]]; then
617
+ _AMVW="a hook never commits or pushes: the user's approval is for the session's own call"; return 1
618
+ fi
619
+ [ -n "${_AMP:-}" ] || _am_state
620
+ [ -f "$_AM_HERE/../eval/lib/settings-json.awk" ] || { _AMVW="the settings reader is not beside this hook, so the permission rules cannot be read"; return 1; }
621
+ _am_settings_files
622
+ _am_perm_hit "$c" deny; r=$?
623
+ if [ "$r" = 0 ]; then _AMVW="a permission rule denies it ($_AMPH)"; return 1; fi
624
+ if [ "$r" = 2 ]; then _AMVW="what is denied cannot be known ($_AMPH), so nothing is run by the hook"; return 1; fi
625
+ _am_perm_hit "$c" ask; r=$?
626
+ if [ "$r" = 0 ]; then _AMVW="a permission rule says to ask the user first ($_AMPH), and a hook cannot ask"; return 1; fi
627
+ if [ "$r" = 2 ]; then _AMVW="what has to be asked first cannot be known ($_AMPH), so nothing is run by the hook"; return 1; fi
628
+ _am_rules
629
+ for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
630
+ k="${r%%$'\t'*}"; g="${r#*$'\t'}"; [ "$k" = v ] || continue
631
+ _am_starts "$c" "$g" && { _AMVB="crew-model-rules: verify $g"; break; }
632
+ done
633
+ if [ -z "$_AMVB" ]; then _am_perm_hit "$c" allow; [ "$?" = 0 ] && _AMVB="permissions.$_AMPH"; fi
634
+ if [ -z "$_AMVB" ]; then
635
+ case "$pm" in
636
+ auto|bypassPermissions)
637
+ for r in "${_AM_RUNNERS[@]}"; do
638
+ _am_starts "$c" "$r" || continue
639
+ g=""; [ "$c" != "$r" ] && g="${c#"$r "}"
640
+ if _am_plain_args "$r" "$g"; then _AMVB="runner: $r"; break; fi
641
+ _AMVW="it is a known runner ($r) with an argument a hook does not pass on: an option, an assignment, or for make, mvn and gradle anything after the one target"; return 1
642
+ done
643
+ [ -n "$_AMVB" ] || { _AMVW="it is not a test or build command on this hook's list, and no 'verify <command>' line of .claude/crew-model-rules or Bash(...) rule of permissions.allow names it"; return 1; } ;;
644
+ *) _AMVW="in '${pm:-default}' mode Claude Code does not run a command the user has not allowed, so neither does a hook: allow it by name with a 'verify <command>' line of .claude/crew-model-rules, or a Bash(...) rule of permissions.allow"; return 1 ;;
645
+ esac
646
+ fi
647
+ [ -f "$_AM_HERE/guard-bash.sh" ] || { _AMVW="the shell gate is not beside this hook, so the command cannot be judged"; return 1; }
648
+ case "$pm" in *[!A-Za-z]*|'') pm=default ;; esac
649
+ f="${c//\\/\\\\}"; f="${f//\"/\\\"}"
650
+ r="$(printf '{"session_id":"crew-verify-not-a-session","permission_mode":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"%s"}}' "$pm" "$f" | CREW_GATE_LOG=/dev/null bash "$_AM_HERE/guard-bash.sh" 2>/dev/null)"; rc=$?
651
+ if [ "$rc" != 0 ]; then _AMVW="the shell gate refuses it"; return 1; fi
652
+ case "$r" in
653
+ '') return 0 ;;
654
+ *'"permissionDecision"'*'"ask"'*) _AMVW="the shell gate would ask the user about it, and a hook cannot ask"; return 1 ;;
655
+ *'"permissionDecision"'*'"deny"'*) _AMVW="the shell gate denies it"; return 1 ;;
656
+ *'"permissionDecision":"allow"'*|*'"permissionDecision": "allow"'*) return 0 ;;
657
+ *) _AMVW="the shell gate answered something that is not an allow"; return 1 ;;
658
+ esac
659
+ }
660
+
661
+ # ---- AT WRITE TIME (called by guard-write.sh) -------------------------------------------------------------------
662
+ # The card can leave a file out. So an agent that is not on opus does not write to a critical path at all: the write
663
+ # is refused where it happens, and the agent is told to stop and say so. Which model the agent is on: the record
664
+ # made when it was called (matched to it when it started), else what its own transcript names. Not known is not
665
+ # opus. The session's own writes carry no agent_id and are not this check's.
666
+ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> returns 0, or refuses and exits 2
667
+ local in="$1" fp="$2" aid tp tier="" f m
668
+ [ "${CREW_MODEL_ROUTING:-}" = off ] && return 0
669
+ case "$in" in *'"agent_id"'*) ;; *) return 0 ;; esac
670
+ _am_state
671
+ _am_crit_path "$fp" || return 0
672
+ _json_keycount "$in" agent_id; _json_slice "$in" agent_id >/dev/null; aid="$_JS"
673
+ if [ "$_KC" != 1 ] || [ -z "$aid" ] || [ "${aid//[A-Za-z0-9._-]/}" != "" ]; then
674
+ # The call says it comes from inside an agent and does not say which one. Not known is not opus.
675
+ _am_log "critical path: write by an agent whose id could not be read"
676
+ echo "GUARD (agent model): $_AMRP is on a critical path ($_AMW) and this call comes from an agent whose id could not be read; only an agent on opus writes there. Stop, and end your report with the two lines: escalate: $_AMRP / confidence: low" >&2
677
+ exit 2
678
+ fi
679
+ if _am_rec_get "$_AMD/agents/$aid" tier; then tier="$_AMV"; fi
680
+ if [ -z "$tier" ]; then
681
+ _json_slice "$in" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
682
+ f="${tp%.jsonl}/subagents/agent-$aid.jsonl"
683
+ if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
684
+ fi
685
+ case "$tier" in opus|fable) return 0 ;; esac
686
+ _am_log "critical path: write by an agent not on opus"
687
+ echo "GUARD (agent model): $_AMRP is on a critical path ($_AMW) and this agent runs on ${tier:-a model that could not be read}; only an agent on opus writes there. Do not reach the file another way. Stop, and end your report with the two lines: escalate: $_AMRP / confidence: low" >&2
688
+ exit 2
689
+ }
690
+ [ "$_AM_LIB" = 1 ] && return 0
691
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
692
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
693
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
694
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
695
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
696
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
697
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
698
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
699
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
700
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
701
+ # that is not found (status 127, the script continues inside the same command).
702
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
703
+ _crew_stop(){ # $1 = what happened
704
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
705
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
706
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
707
+ exit 2
708
+ }
709
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
710
+ # ---- /CREW-FAILCLOSED
711
+ _gate_main(){
712
+ local tn st m ti rank=0 floor=0 fname="" why="" prompt bg tid here nf=0 l d f p pm vrc r a b c t
713
+ [ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
714
+ IFS= read -r -d '' INPUT || true
345
715
  _json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
346
716
  case "$tn" in Agent|Task) ;; *) exit 0 ;; esac
347
717
  # The call's own fields: everything from "tool_input" on, so a key of the same name in front of it is not read.
@@ -349,42 +719,106 @@ _json_find "$INPUT" '"tool_input"'
349
719
  [ "$_JF" -ge 0 ] || exit 0
350
720
  ti="${INPUT:_JF}"
351
721
  _json_keycount "$ti" subagent_type
352
- if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names subagent_type more than once, so which agent it starts cannot be read and the call is refused. Send one." >&2; exit 2; fi
722
+ if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names subagent_type more than once, so which agent it starts cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
353
723
  _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
354
724
  st="${st##*:}" # a plugin names its agents <plugin>:crew-…
355
725
  case "$st" in crew-*) ;; *) exit 0 ;; esac
726
+ case "$st" in *[!A-Za-z0-9_-]*) echo "GUARD (agent model): the agent's name holds a character this gate does not read, so the call is refused." >&2; _am_log "an agent name it cannot read"; exit 2 ;; esac
356
727
  _json_keycount "$ti" model
357
- if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; exit 2; fi
728
+ if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
358
729
  _json_slice "$ti" model >/dev/null; _json_unescape "$_JS" >/dev/null; m="$_JU"
359
- shopt -s nocasematch
360
- case "$m" in
361
- '') rank=0 ;;
362
- *haiku*) rank=1; m=haiku ;;
363
- *sonnet*) rank=2; m=sonnet ;;
364
- *opus*) rank=3; m=opus ;;
365
- *fable*) rank=4; m=fable ;;
366
- *) rank=-1 ;;
367
- esac
368
- shopt -u nocasematch
730
+ if [ -z "$m" ]; then rank=0; else _am_tier "$m"; if [ -n "$_AMT" ]; then m="$_AMT"; _am_rank "$m"; rank="$_AMR"; else rank=-1; fi; fi
731
+ # The agent's own floor, whatever the card says.
369
732
  case "$st" in
370
- crew-security-expert) floor=3; fname=opus ;;
371
- crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; fname=sonnet ;;
733
+ crew-security-expert) floor=3; why="the security audit" ;;
734
+ crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; why="this agent's floor" ;;
372
735
  esac
736
+ _am_name "$floor"; fname="$_AMN"
373
737
  if [ "$rank" = 0 ]; then
374
738
  echo "GUARD (agent model): a call to $st has to name its model. Choose haiku, sonnet or opus by the table in CLAUDE.md (risk decides, not size) and repeat the same call with model set.${fname:+ $st runs on $fname or above.}" >&2
375
- exit 2
739
+ _am_log "no model"; exit 2
376
740
  fi
377
741
  if [ "$rank" = -1 ]; then
378
- echo "GUARD (agent model): '$m' is not a model this gate knows. Repeat the call to $st with model haiku, sonnet or opus.${fname:+ $st runs on $fname or above.}" >&2
379
- exit 2
742
+ echo "GUARD (agent model): '${m:0:40}' is not a model this gate knows. Repeat the call to $st with model haiku, sonnet or opus.${fname:+ $st runs on $fname or above.}" >&2
743
+ _am_log "unknown model"; exit 2
380
744
  fi
381
745
  if [ "$rank" = 4 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
382
746
  echo "GUARD (agent model): fable is not used for a crew agent unless the user has set CREW_ALLOW_FABLE=1 for the session.${fname:+ $st runs on $fname or above.} Repeat the call to $st with haiku, sonnet or opus, by the table in CLAUDE.md." >&2
383
- exit 2
747
+ _am_log "fable not allowed"; exit 2
748
+ fi
749
+ # ---- the card ----
750
+ _json_keycount "$ti" prompt
751
+ if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names prompt more than once, so its task card cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
752
+ _json_slice "$ti" prompt >/dev/null; prompt="${_JS:0:6000}"; _json_unescape "$prompt" >/dev/null; prompt="$_JU"
753
+ if ! _am_card "$prompt"; then
754
+ echo "GUARD (agent model): a call to $st opens with its task card and this one does not ($CARD_WHY). Begin the task with three lines, then the task itself: files: <files or globs> / change: <text|feature|fix-known|fix-unknown|refactor|migration|security|architecture|test-run|test-write|audit> / verify: <the command that proves it, or none>.${fname:+ $st runs on $fname or above.}" >&2
755
+ _am_log "no card"; exit 2
756
+ fi
757
+ _am_state; _am_risk; _am_digest
758
+ # ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
759
+ if [ "$CARD_VERIFY" = none ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
760
+ case "$CARD_CHANGE" in test-write|audit) if [ "$floor" -lt 2 ]; then floor=2; why="change: $CARD_CHANGE, the referee of the work"; fi ;; esac
761
+ if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
762
+ # A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
763
+ _am_rules
764
+ for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
765
+ case "$r" in f$'\t'*) ;; *) continue ;; esac
766
+ IFS=' ' read -r a b c t <<< "${r#*$'\t'}"
767
+ case "$a" in '*'|"$st") ;; *) continue ;; esac; case "$b" in '*'|"$CARD_CHANGE") ;; *) continue ;; esac; case "$c" in '*'|"$CARD_RISK") ;; *) continue ;; esac
768
+ _am_rank "$t"; if [ "$_AMR" -gt "$floor" ]; then floor="$_AMR"; why="crew-model-rules: floor $a $b $c $t"; fi
769
+ done
770
+ _am_floor_now "$st" "$CARD_CHANGE" "$CARD_RISK"
771
+ if [ -n "$_AMF_EFF" ]; then
772
+ _am_rank "$_AMF_EFF"
773
+ if [ "$_AMR" -gt "$floor" ]; then floor="$_AMR"; why="this class ($st, $CARD_CHANGE, $CARD_RISK) failed its first try too often on the model below"; [ "$_AMF_LOOSE" = 1 ] && why="$why; lowered once by the user, from $_AMF_TIER"; fi
774
+ fi
775
+ # A card whose verify failed: not on the same or a lower model again, and not a third time.
776
+ p=""
777
+ if [ -f "$_AMD/fails/$CARD_ID" ]; then
778
+ d=0
779
+ while IFS= read -r l || [ -n "$l" ]; do l="${l%$'\r'}"; [ -n "$l" ] || continue; nf=$((nf+1)); _am_rank "$l"; if [ "$_AMR" -gt "$d" ]; then d="$_AMR"; p="$l"; fi; done < "$_AMD/fails/$CARD_ID"
384
780
  fi
781
+ if [ "$nf" -ge 2 ]; then
782
+ echo "GUARD (agent model): this card's verify command has failed twice, the second time one model up, so it is not run a third time. Ask the user what to do (AskUserQuestion): what failed, on which models, and the choices. A changed task is a new card." >&2
783
+ _am_log "a third run of a failed card"; exit 2
784
+ fi
785
+ if [ "$nf" = 1 ]; then
786
+ _am_rank "$p"; d=$((_AMR+1))
787
+ if [ "$d" -gt 3 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
788
+ echo "GUARD (agent model): this card's verify command failed on $p and there is no model above it to repeat on. Ask the user what to do (AskUserQuestion). A changed task is a new card." >&2
789
+ _am_log "a failed card with no model above"; exit 2
790
+ fi
791
+ if [ "$d" -gt "$floor" ]; then floor="$d"; why="its verify command failed on $p, so the repeat goes one model up"; fi
792
+ fi
793
+ _am_name "$floor"; fname="$_AMN"
385
794
  if [ "$rank" -lt "$floor" ]; then
386
- echo "GUARD (agent model): $st runs on $fname or above, and this call asks for $m. Repeat the same call with model $fname. An audit does not run below the work it clears." >&2
387
- exit 2
795
+ echo "GUARD (agent model): $st runs on $fname or above for this card ($why), and this call asks for $m. Repeat the same call with model $fname." >&2
796
+ _am_log "below the floor of the card"; exit 2
797
+ fi
798
+ # ---- where it runs, and what its verify command is ----
799
+ _json_keycount "$ti" run_in_background
800
+ if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names run_in_background more than once, so where it runs cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
801
+ _json_find "$ti" '"run_in_background"'; bg=unset
802
+ if [ "$_KC" = 1 ] && [ "$_JF" -ge 0 ]; then f="${ti:_JF+19:12}"; f="${f//[$' \t\n\r']/}"; case "$f" in :false*) bg=false ;; :true*) bg=true ;; esac; fi
803
+ # Measured on Claude Code 2.1.294 and 2.1.296. Where the Agent tool has run_in_background, a call that leaves it out
804
+ # starts in the background. Where background tasks are off the tool has NO such parameter: the model cannot send it
805
+ # and the call runs in the foreground. The payload of the two is the same, so a call that leaves the field out is
806
+ # let through. (Asking for `false` refused every critical call with a verify command in a session whose tool has
807
+ # no such field, each time.) Only a call that SAYS background is refused; agent-outcome.sh tells the session when a
808
+ # call that left the field out turned out to be launched in the background.
809
+ if [ "$CARD_RISK" = critical ] && [ "$CARD_VERIFY" != none ] && [ "$bg" = true ]; then
810
+ echo "GUARD (agent model): critical work with a verify command is not sent to the background, where its result does not come back to you: repeat the same call with run_in_background set to false." >&2
811
+ _am_log "critical work sent to the background"; exit 2
812
+ fi
813
+ # The verify command is NOT judged here. Whether the hook runs it is decided when it would start (_am_verify_ok, in
814
+ # hooks/agent-outcome.sh): a command it will not run is recorded as blocked and the session is told to run it
815
+ # itself with the Bash tool, where the permission layer and the shell gate see it. Refusing the call for it would
816
+ # leave no way to use a verify command in a mode where nothing is allowed by name.
817
+ # ---- the record of this call, for the agent it starts ----
818
+ _json_slice "$INPUT" tool_use_id >/dev/null; tid="$_JS"; case "$tid" in ''|*[!A-Za-z0-9._-]*) tid="x$RANDOM" ;; esac
819
+ if mkdir -p "$_AMD/pending" 2>/dev/null; then
820
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v t '%(%s)T' -1; else t="$(date +%s)"; fi
821
+ { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
388
822
  fi
389
823
  exit 0
390
824
  }