crewforth 3.1.1 → 3.2.0-rc.2

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 (78) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +56 -5
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +14 -13
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +117 -6
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-default.sh +36 -0
  30. package/kit/eval/lib/settings-json.awk +15 -4
  31. package/kit/eval/lib/star.sh +23 -7
  32. package/kit/eval/lib/statusline.settings.json +6 -0
  33. package/kit/eval/lib/statusline.sh +100 -0
  34. package/kit/eval/lib/version.sh +41 -0
  35. package/kit/eval/smoke-test.sh +576 -41
  36. package/kit/hooks/agent-outcome.sh +26 -12
  37. package/kit/hooks/guard-agent-model.sh +109 -9
  38. package/kit/hooks/guard-bash.sh +240 -182
  39. package/kit/hooks/guard-question.sh +73 -0
  40. package/kit/hooks/guard-write.sh +21 -0
  41. package/kit/hooks/prompt-approval.sh +320 -103
  42. package/kit/hooks/refuter-ledger.sh +80 -0
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/owned-blobs.tsv +4 -4
  45. package/kit/settings.json +33 -0
  46. package/kit/skills/crew-approve/SKILL.md +7 -0
  47. package/kit/skills/crew-doctor/SKILL.md +5 -1
  48. package/kit/skills/crew-handoff/SKILL.md +3 -2
  49. package/kit/skills/crew-map/SKILL.md +44 -0
  50. package/kit/skills/crew-review/SKILL.md +2 -0
  51. package/kit/skills/crew-update/SKILL.md +1 -1
  52. package/kit/skills/incident-runbook/SKILL.md +2 -2
  53. package/kit/skills/security-scan/references/verify.md +6 -3
  54. package/kit/studio/README.md +12 -0
  55. package/kit/studio/server/index.js +125 -11
  56. package/kit/studio/server/lib/code-stamp.js +35 -0
  57. package/kit/studio/server/lib/crew-map.js +113 -0
  58. package/kit/studio/server/lib/model-outcomes.js +130 -15
  59. package/kit/studio/server/lib/owned-stream.js +56 -0
  60. package/kit/studio/server/lib/pricing.js +3 -0
  61. package/kit/studio/server/lib/projects.js +11 -4
  62. package/kit/studio/server/lib/remote.js +99 -0
  63. package/kit/studio/server/lib/usage.js +74 -14
  64. package/kit/studio/web/app.js +223 -15
  65. package/kit/studio/web/canvas.js +44 -120
  66. package/kit/studio/web/chat.js +82 -17
  67. package/kit/studio/web/graph-plan.js +10 -4
  68. package/kit/studio/web/index.html +10 -5
  69. package/kit/studio/web/liveness.js +14 -0
  70. package/kit/studio/web/map-plan.js +341 -0
  71. package/kit/studio/web/map.js +503 -0
  72. package/kit/studio/web/models-plan.js +61 -7
  73. package/kit/studio/web/models.js +19 -3
  74. package/kit/studio/web/style.css +131 -0
  75. package/kit/studio/web/usage.js +12 -0
  76. package/kit/studio/web/viewport.js +160 -0
  77. package/package.json +6 -5
  78. package/start.sh +10 -0
@@ -19,7 +19,8 @@
19
19
  # -> blocked: not run, nothing concluded.
20
20
  # not finished in CREW_VERIFY_TIMEOUT seconds (540)
21
21
  # -> timeout: stopped, nothing concluded.
22
- # Then one line is added to .claude/state/model-outcomes.tsv, and the class is looked at:
22
+ # Then one line is added to .claude/state/model-outcomes.tsv, its last column the gate's reason
23
+ # when the work is critical (risk_why, "-" when it is not), and the class is looked at:
23
24
  # where the first try failed too often, its floor goes one model up (crew-model-floors.auto).
24
25
  # Only ever up: lowering a floor is the user's decision and nothing here does it.
25
26
  # PostToolUse (Agent) a foreground call has come back. If its verify failed, or its report closed with
@@ -203,27 +204,36 @@ case "$EV" in
203
204
  low=9; cands=""; if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v NOWS '%(%s)T' -1; else NOWS="$(date +%s)"; fi
204
205
  for f in "$_AMD/pending"/*; do
205
206
  [ -f "$f" ] || continue
206
- t="${f##*/}"; t="${t%%-*}"; case "$t" in ''|*[!0-9]*) ;; *) if [ $((NOWS - t)) -gt 300 ]; then rm -f "$f" 2>/dev/null; continue; fi ;; esac
207
+ t="${f##*/}"; t="${t%%-*}"; case "$t" in ''|*[!0-9]*) ;; *) if [ $((NOWS - t)) -gt 300 ]; then rm -f "$f" "$_AMD/agents/${f##*/}.take" 2>/dev/null; continue; fi ;; esac
207
208
  _am_rec_get "$f" agent || continue; [ "$_AMV" = "$AT" ] || continue
208
209
  cands="$cands$f"$'\n'
209
210
  _am_rec_get "$f" tier && { _am_rank "$_AMV"; [ "$_AMR" -lt "$low" ] && low="$_AMR"; }
210
211
  done
211
212
  [ -n "$cands" ] || exit 0
212
213
  mkdir -p "$_AMD/agents" 2>/dev/null || exit 0
213
- # TAKEN BY RENAME. Two agents of one type start at the same moment, each in its own hook process, and both see
214
- # the same oldest record. A rename either happens or fails, so exactly one of them gets it; the other goes on
215
- # to the next record. (Reading it and deleting it afterwards gave both agents the same record.)
216
- claim="$_AMD/agents/$AID.claim"; got=0
214
+ # TAKEN UNDER A LOCK, THEN BY RENAME. Two agents of one type start at the same moment, each in its own hook
215
+ # process, and both see the same oldest record. (Reading it and deleting it afterwards gave both the same record.)
216
+ # A rename alone was the lock on POSIX, and was not on Windows: two hook processes both reported the same rename
217
+ # as done (smoke, windows-latest, "2 got it", twice). Why is not measured; a rename there goes through an open
218
+ # handle, which two processes can both hold. So each record is first locked by creating <record>.take with
219
+ # O_EXCL (bash's noclobber, a builtin: no process); only the one that made the lock renames, the others go on to
220
+ # the next record.
221
+ claim="$_AMD/agents/$AID.claim"; got=0; take=""
222
+ set -C
217
223
  while IFS= read -r f; do
218
224
  [ -n "$f" ] || continue
225
+ take="$_AMD/agents/${f##*/}.take"
226
+ { : > "$take"; } 2>/dev/null || { take=""; continue; }
219
227
  if mv "$f" "$claim" 2>/dev/null; then got=1; break; fi
228
+ rm -f "$take" 2>/dev/null; take="" # locked, but the record had gone (dropped as stale): let it be
220
229
  done <<< "$cands"
230
+ set +C
221
231
  [ "$got" = 1 ] || exit 0
222
232
  _am_rec_get "$claim" tier; _am_rank "$_AMV"
223
233
  if [ "$low" -lt "$_AMR" ]; then _am_name "$low"
224
234
  { grep -v '^tier=' "$claim"; printf 'asked=%s\ntier=%s\nambiguous=1\n' "$_AMV" "$_AMN"; } > "$_AMD/agents/$AID" 2>/dev/null
225
235
  else { cat "$claim"; printf 'asked=%s\n' "$_AMV"; } > "$_AMD/agents/$AID" 2>/dev/null; fi
226
- rm -f "$claim" 2>/dev/null
236
+ rm -f "$claim" "$take" 2>/dev/null
227
237
  exit 0 ;;
228
238
 
229
239
  SubagentStop)
@@ -280,6 +290,8 @@ case "$EV" in
280
290
  _am_rec_get "$REC" verify; VCMD="$_AMV"
281
291
  _am_rec_get "$REC" agent; AG="$_AMV"; _am_rec_get "$REC" change; CH="$_AMV"; _am_rec_get "$REC" risk; RK="$_AMV"
282
292
  _am_rec_get "$REC" card; CID="$_AMV"; _am_rec_get "$REC" esc; ESC="${_AMV:--}"
293
+ # A record written before the gate kept its reason has none: that is "-", not a reason made up here.
294
+ _am_rec_get "$REC" why; RW="${_AMV:--}"; RW="${RW//$'\t'/ }"
283
295
  _am_rec_get "$REC" asked || _am_rec_get "$REC" tier; ASK="$_AMV"
284
296
  mkdir -p "$_AMD/results" "$_AMD/tries" "$_AMD/fails" 2>/dev/null || exit 0
285
297
  TRIES=0; [ -f "$_AMD/tries/$AID" ] && IFS= read -r TRIES < "$_AMD/tries/$AID"; case "$TRIES" in ''|*[!0-9]*) TRIES=0 ;; esac
@@ -287,7 +299,9 @@ case "$EV" in
287
299
  RES=none; WHYNOT=""
288
300
  if [ -n "$VCMD" ] && [ "$VCMD" != none ]; then
289
301
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
290
- if ! _am_verify_ok "$VCMD" "$PM"; then
302
+ # `cd <folder> && <command>` is judged as the command and run in the folder (guard-agent-model.sh).
303
+ _am_verify_split "$VCMD"; VDIR="$_AMVD"; VRUN="$_AMVC"
304
+ if ! _am_verify_ok "$VRUN" "$PM"; then
291
305
  # Judged again here, at the moment it would run: the gate judged it when the agent was called, and this
292
306
  # is the place the command actually starts. Not run; nothing is concluded about the work.
293
307
  RES=blocked; WHYNOT="$_AMVW"; VRC=0
@@ -302,11 +316,11 @@ case "$EV" in
302
316
  # control, which gives a background job its own group in bash (macOS ships no setsid). On Windows the tree
303
317
  # is ended by its Windows process id.
304
318
  if [ -z "${CREW_VERIFY_NO_SETSID:-}" ] && command -v setsid >/dev/null 2>&1; then
305
- ( cd "$_AMP" 2>/dev/null && exec setsid bash -c "$VCMD" ) > "$VF" 2>&1 </dev/null &
319
+ ( cd "$_AMP/$VDIR" 2>/dev/null && exec setsid bash -c "$VRUN" ) > "$VF" 2>&1 </dev/null &
306
320
  VPID=$!
307
321
  else
308
322
  set -m
309
- ( cd "$_AMP" 2>/dev/null && exec bash -c "$VCMD" ) > "$VF" 2>&1 </dev/null &
323
+ ( cd "$_AMP/$VDIR" 2>/dev/null && exec bash -c "$VRUN" ) > "$VF" 2>&1 </dev/null &
310
324
  VPID=$!
311
325
  set +m
312
326
  fi
@@ -359,10 +373,10 @@ case "$EV" in
359
373
  printf 'verify=%s\nfixes=%s\nconfidence=%s\ntier=%s\nesc=%s\nescalate=%s\nwhynot=%s\ncmd=%s\n' "$RES" "$TRIES" "$CONF" "$ASK" "$ESC" "$ESCL" "$WHYNOT" "$VCMD" > "$_AMD/results/$AID" 2>/dev/null
360
374
  case "$REC" in "$_AMD/pending/"*) rm -f "$REC" 2>/dev/null ;; *) : > "$REC.used" 2>/dev/null ;; esac
361
375
  TSV="$_AMS/model-outcomes.tsv"
362
- [ -s "$TSV" ] || printf 'ts\tagent_id\tagent\tchange\trisk\tcard\tmodel\tverify\tfixes\tescalated_from\tran_on\tconfidence\n' > "$TSV" 2>/dev/null
376
+ [ -s "$TSV" ] || printf 'ts\tagent_id\tagent\tchange\trisk\tcard\tmodel\tverify\tfixes\tescalated_from\tran_on\tconfidence\trisk_why\n' > "$TSV" 2>/dev/null
363
377
  _ao_now
364
378
  _ao_lock
365
- printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$_AON" "$AID" "$AG" "$CH" "$RK" "$CID" "$ASK" "$RES" "$TRIES" "$ESC" "$RAN" "$CONF" >> "$TSV" 2>/dev/null
379
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$_AON" "$AID" "$AG" "$CH" "$RK" "$CID" "$ASK" "$RES" "$TRIES" "$ESC" "$RAN" "$CONF" "$RW" >> "$TSV" 2>/dev/null
366
380
  _ao_unlock
367
381
  case "$RES" in pass|fail) _ao_calibrate "$AG" "$CH" "$RK" ;; esac
368
382
  exit 0 ;;
@@ -357,9 +357,13 @@ _am_tier(){ # $1 = a model as a call or a transcript names it -> _AMT haiku|son
357
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
358
  # sits in decides nothing. .claude/crew-model-rules, which only the user writes, adds (`+ glob`) and removes
359
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?'
360
+ # Legal and personal-data texts are critical too (crew-docs-agent's work): legal, privacy, consent in every form, and
361
+ # terms only in lower case — `terms/`, `terms-of-service.md` — because the camelCase `searchTerms` is a search box.
362
+ # Store listings are critical by place and only as text: under store/, store-listing/, fastlane/metadata/ or
363
+ # metadata/android|ios/, a .txt .md .xml .yml .yaml is; code there (a Redux `store/index.ts`) and images are not.
364
+ _AM_W_L='auth|authn|authz|authentication|authorization|oauth|payments?|billing|migrations?|security|crypto|secrets?|legal|privacy|terms|consent'
365
+ _AM_W_C='Auth|Authn|Authz|Authentication|Authorization|OAuth|Payments?|Billing|Migrations?|Security|Crypto|Secrets?|Legal|Privacy|Consent'
366
+ _AM_W_U='AUTH|AUTHN|AUTHZ|OAUTH|PAYMENTS?|BILLING|MIGRATIONS?|SECURITY|CRYPTO|SECRETS?|LEGAL|PRIVACY|TERMS|CONSENT'
363
367
  _AM_RE_L="(^|[^A-Za-z])($_AM_W_L)([^a-z]|\$)"
364
368
  _AM_RE_C="(^|[^A-Z])($_AM_W_C)([^a-z]|\$)"
365
369
  _AM_RE_U="(^|[^A-Za-z])($_AM_W_U)([^A-Za-z]|\$)"
@@ -394,7 +398,10 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
394
398
  local p r k g hit=1
395
399
  _am_rel "$1"; p="$_AMRP"; _AMW=""
396
400
  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
401
+ else case "$p" in *.sql|*.SQL) hit=0; _AMW="a .sql file" ;; schema.prisma|*/schema.prisma) hit=0; _AMW="schema.prisma" ;;
402
+ store/*|*/store/*|store-listing/*|*/store-listing/*|fastlane/metadata/*|*/fastlane/metadata/*|metadata/android/*|*/metadata/android/*|metadata/ios/*|*/metadata/ios/*)
403
+ case "$p" in *.txt|*.md|*.xml|*.yml|*.yaml) hit=0; _AMW="a store listing text" ;; esac ;;
404
+ esac; fi
398
405
  _am_rules
399
406
  for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
400
407
  k="${r%%$'\t'*}"; g="${r#*$'\t'}"
@@ -407,9 +414,9 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
407
414
 
408
415
  # ---- THE CARD ---------------------------------------------------------------------------------------------------
409
416
  _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
417
+ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
411
418
  local s="$1" l k v n=0 seen=""
412
- CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""
419
+ CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""; CARD_ABOVE=""
413
420
  s="${s//$'\r'/}"
414
421
  while [ "$n" -lt 3 ] && [ -n "$s" ]; do
415
422
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
@@ -433,7 +440,16 @@ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1
433
440
  [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
434
441
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
435
442
  [ -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
443
+ case "$CARD_VERIFY" in none|None|NONE|-|'none '*|'none('*|'None '*|'NONE '*) CARD_VERIFY=none ;; esac # "none (the author ran it)" is none
444
+ # Optional, right after the three: `above: <why>` keeps a model above the card's floor. Without it a call above
445
+ # the floor is run on the floor (the cost of a model nobody needed is what this gate exists to stop).
446
+ while [ -n "$s" ]; do
447
+ l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
448
+ while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
449
+ [ -n "$l" ] || continue
450
+ case "$l" in above:*) v="${l#above:}"; while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done; CARD_ABOVE="${v:0:200}" ;; esac
451
+ break
452
+ done
437
453
  return 0
438
454
  }
439
455
  _am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
@@ -453,6 +469,53 @@ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "th
453
469
  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
470
  CARD_ID="${t:0:12}"
455
471
  }
472
+ # A call to an agent that is not Crewforth's (general-purpose, a project's own): no card and no floor, so only the
473
+ # expensive end is held, the same rewrite as a crew call above its floor.
474
+ # * It names opus or fable, and its task has no `above: <why>` line: it runs on sonnet.
475
+ # * It names no model, and the agent takes the session's (general-purpose, or a project or user agent whose
476
+ # definition has no model of its own): it runs on sonnet, unless the session itself is on haiku, read from the
477
+ # end of its transcript. An agent whose definition names a model (Explore, claude-code-guide) is left to it:
478
+ # a model in the call would override a cheaper one there.
479
+ # Measured on 2.1.299: a general-purpose call that named no model ran on the session's model; CLAUDE_CODE_SUBAGENT_MODEL
480
+ # in the project's settings `env` did not change that (in the shell's environment it did), so the hook sets it.
481
+ _am_outside(){ # $1 = the tool_input text, $2 = the agent's name
482
+ local m pr to=sonnet ui name="$2" f d l ln inherit=0 tp last
483
+ case "$name" in ''|*[!A-Za-z0-9._-]*) name="an agent" ;; esac
484
+ _json_keycount "$1" prompt; [ "$_KC" = 1 ] || return 0
485
+ _json_slice "$1" prompt >/dev/null; pr="${_JS:0:6000}"; _json_unescape "$pr" >/dev/null; pr=$'\n'"${_JU//$'\r'/}"
486
+ case "$pr" in *$'\n'above:*|*$'\n'[$' \t']above:*) return 0 ;; esac
487
+ _json_keycount "$1" model
488
+ if [ "$_KC" = 0 ]; then
489
+ case "$name" in
490
+ general-purpose) inherit=1 ;;
491
+ 'an agent') ;;
492
+ *) for d in "${CLAUDE_PROJECT_DIR:-.}/.claude/agents" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/agents"; do
493
+ f="$d/$name.md"; [ -f "$f" ] || continue
494
+ inherit=1; ln=0
495
+ while IFS= read -r l || [ -n "$l" ]; do
496
+ ln=$((ln+1)); [ "$ln" -le 40 ] || break
497
+ case "$l" in ---*) [ "$ln" -gt 1 ] && break ;; model:*) case "${l#model:}" in *inherit*|'') ;; *) inherit=0 ;; esac ;; esac
498
+ done < "$f"
499
+ break
500
+ done ;;
501
+ esac
502
+ [ "$inherit" = 1 ] || return 0
503
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="$_JU"
504
+ if [ -n "$tp" ] && [ -f "$tp" ]; then
505
+ last="$(tail -c 65536 "$tp" 2>/dev/null)"; last="${last##*\"model\":\"}"; last="${last%%\"*}"
506
+ case "$last" in *haiku*) return 0 ;; esac
507
+ fi
508
+ m="the session's model"
509
+ else
510
+ [ "$_KC" = 1 ] || return 0
511
+ _json_slice "$1" model >/dev/null; _json_unescape "$_JS" >/dev/null; _am_tier "$_JU"; m="$_AMT"
512
+ case "$m" in opus|fable) ;; *) return 0 ;; esac
513
+ fi
514
+ ui="$(printf '%s' "$INPUT" | awk -v op=get -v path=tool_input -v setk=model -v setv="\"$to\"" -f "$_AM_HERE/../eval/lib/settings-json.awk" - 2>/dev/null)" || return 0
515
+ case "$ui" in '{'*'}') ;; *) return 0 ;; esac
516
+ _am_log "note: $name (not a crew agent) asked for $m, run on $to; add 'above: <why>' to its task to keep a model above it"
517
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"Crewforth: %s asked for %s and runs on %s (a line above: <why> in its task keeps a model above it)","updatedInput":%s}}\n' "$name" "$m" "$to" "$ui"
518
+ }
456
519
  _am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
457
520
  local l; _AMV=""
458
521
  [ -f "$1" ] || return 1
@@ -602,6 +665,19 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
602
665
  done <<< "$_AMSF"
603
666
  return 1
604
667
  }
668
+ # A verify command in a sub-folder: `cd apps/mobile && flutter test`, the usual shape in a repository of several
669
+ # projects (measured on one: 41 of 71 refused verify commands had it). Split into the folder and the one command,
670
+ # so the command is judged alone and run in that folder with no chain. Only a relative folder inside the project,
671
+ # with no .. step, that exists; anything else is left whole, and is refused as a chain as before.
672
+ _am_verify_split(){ # $1 = the command -> _AMVD the folder ("" = the project root), _AMVC the command
673
+ local c="$1" d r
674
+ _AMVD=""; _AMVC="$c"
675
+ case "$c" in 'cd '*' && '*) ;; *) return 0 ;; esac
676
+ d="${c#cd }"; d="${d%% && *}"; r="${c#cd "$d" && }"
677
+ case "$d" in ''|/*|~*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 0 ;; esac
678
+ [ -n "$r" ] && [ -d "${_AMP:-.}/$d" ] || return 0
679
+ _AMVD="$d"; _AMVC="$r"
680
+ }
605
681
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
606
682
  local c="$1" pm="${2:-default}" f r rc k g
607
683
  _AMVW=""; _AMVB=""
@@ -722,7 +798,7 @@ _json_keycount "$ti" subagent_type
722
798
  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
723
799
  _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
724
800
  st="${st##*:}" # a plugin names its agents <plugin>:crew-…
725
- case "$st" in crew-*) ;; *) exit 0 ;; esac
801
+ case "$st" in crew-*) ;; *) _am_outside "$ti" "$st"; exit 0 ;; esac
726
802
  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
727
803
  _json_keycount "$ti" model
728
804
  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
@@ -795,6 +871,23 @@ if [ "$rank" -lt "$floor" ]; then
795
871
  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
872
  _am_log "below the floor of the card"; exit 2
797
873
  fi
874
+ # ---- above the floor: run on the floor, unless the card says why not ----
875
+ # The floor is what the work needs; a model above it is cost nobody asked for (measured on a real project: 73 of 186
876
+ # crew tasks ran above their floor, 0 on haiku). Unless the card carries `above: <why>`, the call is let through ON
877
+ # THE FLOOR: the hook hands Claude Code the same input with `model` replaced (PreToolUse updatedInput; measured on
878
+ # 2.1.299: a call asking for opus ran on haiku alone). The rewrite is one awk over the payload, on this path only.
879
+ lowered=""
880
+ low=$floor; [ "$low" -lt 1 ] && low=1
881
+ if [ "$rank" -gt "$low" ] && [ -z "$CARD_ABOVE" ]; then
882
+ _am_name "$low"; to="$_AMN"
883
+ ui="$(printf '%s' "$INPUT" | awk -v op=get -v path=tool_input -v setk=model -v setv="\"$to\"" -f "$_AM_HERE/../eval/lib/settings-json.awk" - 2>/dev/null)" || ui=""
884
+ case "$ui" in '{'*'}') ;; *) ui="" ;; esac
885
+ if [ -n "$ui" ]; then
886
+ lowered="$m"
887
+ _am_log "note: $st asked for $m, run on $to, the floor of this card (${why:-no floor above haiku}); add 'above: <why>' to the card to keep $m"
888
+ m="$to"
889
+ fi
890
+ fi
798
891
  # ---- where it runs, and what its verify command is ----
799
892
  _json_keycount "$ti" run_in_background
800
893
  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
@@ -818,7 +911,14 @@ fi
818
911
  _json_slice "$INPUT" tool_use_id >/dev/null; tid="$_JS"; case "$tid" in ''|*[!A-Za-z0-9._-]*) tid="x$RANDOM" ;; esac
819
912
  if mkdir -p "$_AMD/pending" 2>/dev/null; then
820
913
  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
914
+ # Why the work counts as critical, as the gate decided it, so a reader shows the reason and does not work it out
915
+ # again: one line, no tab (it ends up in a column of model-outcomes.tsv).
916
+ rw="${CARD_RISKWHY:--}"; rw="${rw//$'\t'/ }"; rw="${rw//$'\r'/ }"; rw="${rw//$'\n'/ }"
917
+ { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\nlowered=%s\nwhy=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" "${lowered:--}" "$rw" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
918
+ fi
919
+ # The call, on the floor: the same input with `model` replaced, and allowed. Nothing else is printed on this path.
920
+ if [ -n "$lowered" ]; then
921
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"Crewforth: %s asked for %s and runs on %s, the floor of its card (add above: <why> to the card to keep %s)","updatedInput":%s}}\n' "$st" "$lowered" "$m" "$lowered" "$ui"
822
922
  fi
823
923
  exit 0
824
924
  }