crewforth 3.1.0-rc.5 → 3.1.0-rc.6

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