crewforth 3.1.1 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -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 ;;
@@ -390,6 +404,24 @@ case "$EV" in
390
404
  fi
391
405
  fi
392
406
  fi
407
+ # THE ONE-CLICK APPROVAL, OFFERED WHEN THE COMMIT MESSAGE IS ASKED FOR (B-2). In `auto` the click used to reach the
408
+ # user only inside a refused commit, and a session that asked for /crew-approve first never tried one (§4.4 says
409
+ # not to), so it was never offered. A call to crew-commit-agent is when the message is on its way: the session is
410
+ # told then, in foreground and background alike (a background launch answers here at once). Whether the question
411
+ # is open, and its words, come from prompt-approval.sh, as the refusal's do. Its verify is `none` (change: text),
412
+ # so no outcome below is lost by answering here; the model it ran on is already kept above.
413
+ _json_slice "$INPUT" subagent_type >/dev/null
414
+ if [ "$_JS" = crew-commit-agent ] && [ -f "$_ao_here/prompt-approval.sh" ]; then
415
+ _json_slice "$INPUT" permission_mode >/dev/null; _AOPM="$_JS"
416
+ if [ "$_AOPM" = auto ]; then
417
+ declare -F _crew_q_open >/dev/null 2>&1 || . "$_ao_here/prompt-approval.sh"
418
+ if declare -F _crew_q_open >/dev/null 2>&1 && _crew_q_open auto; then
419
+ _ao_json "Crewforth: when you show the user the commit message this agent proposes, ask with one click before anything else. $_CREW_Q_ASK {\"questions\":[$_CREW_Q_JSON]} Their answer is recorded by Crewforth itself (the line it adds says so); then run the commit it names. If that question is refused, the user can type /crew-approve instead, as the refusal says."
420
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$_AOJ"
421
+ exit 0
422
+ fi
423
+ fi
424
+ fi
393
425
  R="$_AMD/results/$AID"
394
426
  if [ ! -f "$R" ]; then
395
427
  # No result yet: the agent was launched in the background (measured, 2.1.294 and 2.1.296: an Agent call that
@@ -79,28 +79,7 @@ MSG="$(cat "$CACHE")
79
79
  Board state above is a cached snapshot; $_BS (Bash tool, not PowerShell — or the user can type /crew-board sync) refreshes it."
80
80
 
81
81
  # ONE PATH, no jq. The JSON is built here on every machine, so a Mac with jq and a Windows box without it emit the
82
- # same bytes. The escaper is jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL,
83
- # UTF-8, leading/trailing newlines, 5000 chars): 15/15 equal to `jq -cn --arg m`. The one it replaced escaped only
84
- # the quote, the backslash and the newline, so a tab in an item title produced JSON the CLI cannot parse
85
- # (measured: jq rc=5) and the board silently vanished from the session — on exactly the no-jq machine this
86
- # branch existed for. The trailing newline on the printf closes the last record, so a value ending in a newline
87
- # keeps it. One awk, no subshell per character. A CR that ENDS a line is dropped on purpose: it is a CRLF line
88
- # ending, not content, and MSYS gawk already drops it on read (text mode) while BSD awk keeps it — measured on
89
- # Windows. Stripping it here makes every OS emit the same bytes; a CR inside a line is still escaped as \r.
90
- ESC="$(printf '%s\n' "$MSG" | LC_ALL=C awk 'BEGIN { ORS = ""
91
- for (i = 1; i < 32; i++) ctl[i] = sprintf("%c", i)
92
- nm[8] = "\\b"; nm[9] = "\\t"; nm[12] = "\\f"; nm[13] = "\\r" }
93
- NR > 1 { print "\\n" }
94
- { s = $0; sub(/\r$/, "", s)
95
- gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
96
- for (i = 1; i < 32; i++) {
97
- if (i == 10 || !index(s, ctl[i])) continue
98
- r = (i in nm) ? nm[i] : sprintf("\\u%04x", i)
99
- out = ""; while ((p = index(s, ctl[i])) > 0) { out = out substr(s, 1, p - 1) r; s = substr(s, p + 1) }
100
- s = out s
101
- }
102
- d = sprintf("%c", 127)
103
- if (index(s, d)) { out = ""; while ((p = index(s, d)) > 0) { out = out substr(s, 1, p - 1) "\\u007f"; s = substr(s, p + 1) }; s = out s }
104
- print s }')"
82
+ # same bytes, through Crewforth's one escaper (eval/lib/json-string.awk: jq-identical, measured; why, in its header).
83
+ ESC="$(printf '%s\n' "$MSG" | LC_ALL=C awk -f "$HERE/../eval/lib/json-string.awk")"
105
84
  printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$ESC"
106
85
  exit 0
@@ -14,10 +14,15 @@
14
14
  # * RISK CLASS. `critical` when the change is migration, security, architecture or fix-unknown, or a file is on a
15
15
  # critical path (auth, payments, billing, migrations, security, crypto, secrets, *.sql, schema.prisma;
16
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.
17
+ # * critical work runs on opus, whatever the agent. Two exceptions: test-run (running the tests is objective), and
18
+ # a text change whose every file is prose (.md .txt .rst .adoc), which runs on sonnet or above: a reworded
19
+ # paragraph under legal/ is read by a person, and opus for it is the cost this gate exists to stop. A project
20
+ # that wants opus there says so in .claude/crew-model-rules (floor * text critical opus).
18
21
  # * the referee is as strong as the risk: test-write and audit on sonnet or above (opus when critical, by the rule
19
22
  # 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.
23
+ # * work with no verify command runs on sonnet or above: haiku goes only to work a command can check. Not for
24
+ # change: text, nor for the commit agent: a reader is all prose ever has, and a commit message is checked by
25
+ # the commit-msg hook (seen on a real project: a commit message proposed on sonnet for that reason alone).
21
26
  # * a class that failed too often is held one model up (crew-model-floors.auto, written by hooks/agent-outcome.sh),
22
27
  # until the user lowers it again with /crew-loosen (one model, once for each raise).
23
28
  # * a card whose verify failed is not run again on the same or a lower model, and not a third time at all.
@@ -357,9 +362,13 @@ _am_tier(){ # $1 = a model as a call or a transcript names it -> _AMT haiku|son
357
362
  # a .sql file, schema.prisma. The path is read from the project's root down, so the name of the folder the project
358
363
  # sits in decides nothing. .claude/crew-model-rules, which only the user writes, adds (`+ glob`) and removes
359
364
  # (`- 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?'
365
+ # Legal and personal-data texts are critical too (crew-docs-agent's work): legal, privacy, consent in every form, and
366
+ # terms only in lower case — `terms/`, `terms-of-service.md` — because the camelCase `searchTerms` is a search box.
367
+ # Store listings are critical by place and only as text: under store/, store-listing/, fastlane/metadata/ or
368
+ # metadata/android|ios/, a .txt .md .xml .yml .yaml is; code there (a Redux `store/index.ts`) and images are not.
369
+ _AM_W_L='auth|authn|authz|authentication|authorization|oauth|payments?|billing|migrations?|security|crypto|secrets?|legal|privacy|terms|consent'
370
+ _AM_W_C='Auth|Authn|Authz|Authentication|Authorization|OAuth|Payments?|Billing|Migrations?|Security|Crypto|Secrets?|Legal|Privacy|Consent'
371
+ _AM_W_U='AUTH|AUTHN|AUTHZ|OAUTH|PAYMENTS?|BILLING|MIGRATIONS?|SECURITY|CRYPTO|SECRETS?|LEGAL|PRIVACY|TERMS|CONSENT'
363
372
  _AM_RE_L="(^|[^A-Za-z])($_AM_W_L)([^a-z]|\$)"
364
373
  _AM_RE_C="(^|[^A-Z])($_AM_W_C)([^a-z]|\$)"
365
374
  _AM_RE_U="(^|[^A-Za-z])($_AM_W_U)([^A-Za-z]|\$)"
@@ -394,7 +403,10 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
394
403
  local p r k g hit=1
395
404
  _am_rel "$1"; p="$_AMRP"; _AMW=""
396
405
  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
406
+ else case "$p" in *.sql|*.SQL) hit=0; _AMW="a .sql file" ;; schema.prisma|*/schema.prisma) hit=0; _AMW="schema.prisma" ;;
407
+ store/*|*/store/*|store-listing/*|*/store-listing/*|fastlane/metadata/*|*/fastlane/metadata/*|metadata/android/*|*/metadata/android/*|metadata/ios/*|*/metadata/ios/*)
408
+ case "$p" in *.txt|*.md|*.xml|*.yml|*.yaml) hit=0; _AMW="a store listing text" ;; esac ;;
409
+ esac; fi
398
410
  _am_rules
399
411
  for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
400
412
  k="${r%%$'\t'*}"; g="${r#*$'\t'}"
@@ -405,35 +417,100 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
405
417
  return "$hit"
406
418
  }
407
419
 
420
+ _am_prose(){ # $1 = a path or a glob -> 0 when it is prose a person reads (the exception for a text change)
421
+ case "$1" in *.md|*.MD|*.markdown|*.txt|*.TXT|*.rst|*.adoc) return 0 ;; esac
422
+ return 1
423
+ }
424
+ _am_all_prose(){ # after _am_card -> 0 when every file of the card is prose
425
+ local f r=0 unglob=0
426
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
427
+ for f in ${CARD_FILES//,/ }; do _am_prose "$f" || { r=1; break; }; done
428
+ [ "$unglob" = 1 ] && set +f
429
+ return "$r"
430
+ }
431
+
408
432
  # ---- THE CARD ---------------------------------------------------------------------------------------------------
409
433
  _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=""
434
+ _am_trim(){ # $1 -> _AMT: no white space around it, and no separator or full stop left at its end (one-line cards)
435
+ local v="$1" o
436
+ while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
437
+ while :; do o="$v"
438
+ case "$v" in *[$' \t']) v="${v%?}" ;; *'·') v="${v%'·'}" ;; *'—') v="${v%'—'}" ;; *'–') v="${v%'–'}" ;; *[\|\;,.]) v="${v%?}" ;; esac
439
+ [ "$v" = "$o" ] && break
440
+ done
441
+ _AMT="$v"
442
+ }
443
+ # THE CARD ON ONE LINE. Sessions write it as `files: … · change: … · verify: …` (measured on this machine's transcripts,
444
+ # 2026-10-18: 17 of the 39 calls refused for their card, in 6 projects; the next call was the same card on three lines).
445
+ # It is read by its labels, in that order, after an optional `Card:` / `Card —` (or `Kart`, the Turkish word: 2 refused
446
+ # calls, 2026-10-21); `above: <why>` may close the line. The fields are then held to exactly the rules of the
447
+ # three-line card below, so nothing passes here that would not there.
448
+ _am_card_line(){ # $1 = the first line of the task -> 0 and CARD_* set (unchecked); 1 when it is not a one-line card
449
+ local l="$1" r f c v a=""
450
+ case "$l" in [Cc]ard*|[Kk]art*) l="${l#[Cc]ard}"; l="${l#[Kk]art}"; l="${l#\*\*}"
451
+ while :; do case "$l" in [$' \t:*']*|'—'*|'–'*|-*) case "$l" in '—'*) l="${l#'—'}" ;; '–'*) l="${l#'–'}" ;; *) l="${l:1}" ;; esac ;; *) break ;; esac; done ;;
452
+ esac
453
+ case "$l" in files:*) ;; *) return 1 ;; esac
454
+ r="${l#files:}"
455
+ case "$r" in *change:*verify:*) ;; *) return 1 ;; esac
456
+ f="${r%%change:*}"; r="${r#*change:}"; c="${r%%verify:*}"; v="${r#*verify:}"
457
+ case "$v" in *above:*) a="${v#*above:}"; v="${v%%above:*}" ;; esac
458
+ _am_trim "$f"; CARD_FILES="$_AMT"; _am_trim "$c"; CARD_CHANGE="$_AMT"; _am_trim "$v"; CARD_VERIFY="$_AMT"
459
+ [ -n "$a" ] && { _am_trim "$a"; CARD_ABOVE="${_AMT:0:200}"; }
460
+ return 0
461
+ }
462
+ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
463
+ local s="$1" l k v n=0 ln=0 seen="" one=0
464
+ CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""; CARD_ABOVE=""
413
465
  s="${s//$'\r'/}"
414
- while [ "$n" -lt 3 ] && [ -n "$s" ]; do
466
+ # One line first: the first line that is not empty, read by its labels; the three-line reading otherwise.
467
+ l="$s"; while :; do case "$l" in [$' \t\n']*) l="${l:1}" ;; *) break ;; esac; done; l="${l%%$'\n'*}"
468
+ if _am_card_line "$l"; then
469
+ one=1; n=3
470
+ s="${s#*"$l"}"; s="${s#$'\n'}"
471
+ fi
472
+ while [ "$one" = 0 ] && [ "$n" -lt 3 ] && [ -n "$s" ]; do
415
473
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
416
474
  while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
417
475
  [ -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
476
+ ln=$((ln+1))
477
+ case "$l" in *:*) ;; *) CARD_WHY="line $ln of the task is not one of files:, change:, verify:"; return 1 ;; esac
420
478
  k="${l%%:*}"; v="${l#*:}"
421
479
  while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
422
480
  while :; do case "$v" in *[$' \t']) v="${v%?}" ;; *) break ;; esac; done
481
+ # `above:` may stand anywhere among the three (2 of 19 line refusals, 2026-10-19: between change: and verify:).
423
482
  case "$k" in
483
+ above) [ -n "$CARD_ABOVE" ] || CARD_ABOVE="${v:0:200}"; continue ;;
424
484
  files) CARD_FILES="$v" ;;
425
485
  change) CARD_CHANGE="$v" ;;
426
486
  verify) CARD_VERIFY="$v" ;;
427
- *) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;;
487
+ *) CARD_WHY="line $ln of the task is not one of files:, change:, verify:"; return 1 ;;
428
488
  esac
489
+ n=$((n+1))
429
490
  case "$seen" in *" $k "*) CARD_WHY="the card names $k twice"; return 1 ;; esac
430
491
  seen="$seen $k "
431
492
  done
432
493
  [ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
433
494
  [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
495
+ # A note in parentheses after the kind is the session's own comment, as `none (…)` is for verify: `architecture
496
+ # (planning only)` is architecture (4 of the cards refused on this machine, 2026-10-18). Only a kind on the list, and
497
+ # only a note that is the whole rest of the value in parentheses.
498
+ # `review` is an audit: the discipline's model table names it with them, and 4 crew-review-agent cards were refused
499
+ # for it (2026-10-19).
500
+ case "$CARD_CHANGE" in review|'review ('*')') CARD_CHANGE=audit ;; esac
501
+ case "$CARD_CHANGE" in *' ('*')') v="${CARD_CHANGE%% (*}"; case "$_AM_CHANGES" in *" $v "*) CARD_CHANGE="$v" ;; esac ;; esac
434
502
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
435
503
  [ -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
504
+ case "$CARD_VERIFY" in none|None|NONE|-|'none '*|'none('*|'None '*|'NONE '*) CARD_VERIFY=none ;; esac # "none (the author ran it)" is none
505
+ # Optional, right after the three: `above: <why>` keeps a model above the card's floor. Without it a call above
506
+ # the floor is run on the floor (the cost of a model nobody needed is what this gate exists to stop).
507
+ while [ -z "$CARD_ABOVE" ] && [ -n "$s" ]; do
508
+ l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
509
+ while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
510
+ [ -n "$l" ] || continue
511
+ 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
512
+ break
513
+ done
437
514
  return 0
438
515
  }
439
516
  _am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
@@ -453,6 +530,53 @@ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "th
453
530
  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
531
  CARD_ID="${t:0:12}"
455
532
  }
533
+ # A call to an agent that is not Crewforth's (general-purpose, a project's own): no card and no floor, so only the
534
+ # expensive end is held, the same rewrite as a crew call above its floor.
535
+ # * It names opus or fable, and its task has no `above: <why>` line: it runs on sonnet.
536
+ # * It names no model, and the agent takes the session's (general-purpose, or a project or user agent whose
537
+ # definition has no model of its own): it runs on sonnet, unless the session itself is on haiku, read from the
538
+ # end of its transcript. An agent whose definition names a model (Explore, claude-code-guide) is left to it:
539
+ # a model in the call would override a cheaper one there.
540
+ # Measured on 2.1.299: a general-purpose call that named no model ran on the session's model; CLAUDE_CODE_SUBAGENT_MODEL
541
+ # in the project's settings `env` did not change that (in the shell's environment it did), so the hook sets it.
542
+ _am_outside(){ # $1 = the tool_input text, $2 = the agent's name
543
+ local m pr to=sonnet ui name="$2" f d l ln inherit=0 tp last
544
+ case "$name" in ''|*[!A-Za-z0-9._-]*) name="an agent" ;; esac
545
+ _json_keycount "$1" prompt; [ "$_KC" = 1 ] || return 0
546
+ _json_slice "$1" prompt >/dev/null; pr="${_JS:0:6000}"; _json_unescape "$pr" >/dev/null; pr=$'\n'"${_JU//$'\r'/}"
547
+ case "$pr" in *$'\n'above:*|*$'\n'[$' \t']above:*) return 0 ;; esac
548
+ _json_keycount "$1" model
549
+ if [ "$_KC" = 0 ]; then
550
+ case "$name" in
551
+ general-purpose) inherit=1 ;;
552
+ 'an agent') ;;
553
+ *) for d in "${CLAUDE_PROJECT_DIR:-.}/.claude/agents" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/agents"; do
554
+ f="$d/$name.md"; [ -f "$f" ] || continue
555
+ inherit=1; ln=0
556
+ while IFS= read -r l || [ -n "$l" ]; do
557
+ ln=$((ln+1)); [ "$ln" -le 40 ] || break
558
+ case "$l" in ---*) [ "$ln" -gt 1 ] && break ;; model:*) case "${l#model:}" in *inherit*|'') ;; *) inherit=0 ;; esac ;; esac
559
+ done < "$f"
560
+ break
561
+ done ;;
562
+ esac
563
+ [ "$inherit" = 1 ] || return 0
564
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="$_JU"
565
+ if [ -n "$tp" ] && [ -f "$tp" ]; then
566
+ last="$(tail -c 65536 "$tp" 2>/dev/null)"; last="${last##*\"model\":\"}"; last="${last%%\"*}"
567
+ case "$last" in *haiku*) return 0 ;; esac
568
+ fi
569
+ m="the session's model"
570
+ else
571
+ [ "$_KC" = 1 ] || return 0
572
+ _json_slice "$1" model >/dev/null; _json_unescape "$_JS" >/dev/null; _am_tier "$_JU"; m="$_AMT"
573
+ case "$m" in opus|fable) ;; *) return 0 ;; esac
574
+ fi
575
+ 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
576
+ case "$ui" in '{'*'}') ;; *) return 0 ;; esac
577
+ _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"
578
+ 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"
579
+ }
456
580
  _am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
457
581
  local l; _AMV=""
458
582
  [ -f "$1" ] || return 1
@@ -494,13 +618,10 @@ _am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor,
494
618
 
495
619
  # ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
496
620
  _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
621
+ local gl="" v=BLOCK
498
622
  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
623
+ declare -F crew_gate_log_write >/dev/null || return 0
624
+ crew_gate_log_write; gl="$_CREW_GL"; [ -n "$gl" ] || return 0
504
625
  printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
505
626
  }
506
627
 
@@ -531,6 +652,7 @@ _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog
531
652
  # 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
532
653
  # explicit allow passes.
533
654
  _AM_HERE="${BASH_SOURCE%/*}"; [ "$_AM_HERE" = "${BASH_SOURCE}" ] && _AM_HERE=.
655
+ [ -f "$_AM_HERE/../eval/lib/gate-log.sh" ] && . "$_AM_HERE/../eval/lib/gate-log.sh" # where the gate log goes
534
656
  # CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
535
657
  _AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
536
658
  'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
@@ -602,6 +724,9 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
602
724
  done <<< "$_AMSF"
603
725
  return 1
604
726
  }
727
+ # How a verify command is read (the `cd <folder> && <command>` split): eval/lib/verify-cmd.sh, shared with doctor.
728
+ [ -f "$_AM_HERE/../eval/lib/verify-cmd.sh" ] && . "$_AM_HERE/../eval/lib/verify-cmd.sh"
729
+ _am_verify_split(){ _AMVD=""; _AMVC="$1"; declare -F crew_verify_split >/dev/null && crew_verify_split "$1" "${_AMP:-.}"; return 0; }
605
730
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
606
731
  local c="$1" pm="${2:-default}" f r rc k g
607
732
  _AMVW=""; _AMVB=""
@@ -683,6 +808,8 @@ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> ret
683
808
  if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
684
809
  fi
685
810
  case "$tier" in opus|fable) return 0 ;; esac
811
+ # A text change on sonnet may write prose there: the call was let through on that floor for exactly that.
812
+ if [ "$tier" = sonnet ] && _am_prose "$_AMRP" && _am_rec_get "$_AMD/agents/$aid" change && [ "$_AMV" = text ]; then return 0; fi
686
813
  _am_log "critical path: write by an agent not on opus"
687
814
  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
815
  exit 2
@@ -722,7 +849,7 @@ _json_keycount "$ti" subagent_type
722
849
  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
850
  _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
724
851
  st="${st##*:}" # a plugin names its agents <plugin>:crew-…
725
- case "$st" in crew-*) ;; *) exit 0 ;; esac
852
+ case "$st" in crew-*) ;; *) _am_outside "$ti" "$st"; exit 0 ;; esac
726
853
  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
854
  _json_keycount "$ti" model
728
855
  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
@@ -756,9 +883,11 @@ if ! _am_card "$prompt"; then
756
883
  fi
757
884
  _am_state; _am_risk; _am_digest
758
885
  # ---- 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
886
+ if [ "$CARD_VERIFY" = none ] && [ "$CARD_CHANGE" != text ] && [ "$st" != crew-commit-agent ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
760
887
  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
888
+ if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" = text ] && _am_all_prose; then
889
+ if [ "$floor" -lt 2 ]; then floor=2; why="a text change on a critical path: $CARD_RISKWHY"; fi
890
+ elif [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
762
891
  # A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
763
892
  _am_rules
764
893
  for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
@@ -795,6 +924,23 @@ if [ "$rank" -lt "$floor" ]; then
795
924
  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
925
  _am_log "below the floor of the card"; exit 2
797
926
  fi
927
+ # ---- above the floor: run on the floor, unless the card says why not ----
928
+ # The floor is what the work needs; a model above it is cost nobody asked for (measured on a real project: 73 of 186
929
+ # crew tasks ran above their floor, 0 on haiku). Unless the card carries `above: <why>`, the call is let through ON
930
+ # THE FLOOR: the hook hands Claude Code the same input with `model` replaced (PreToolUse updatedInput; measured on
931
+ # 2.1.299: a call asking for opus ran on haiku alone). The rewrite is one awk over the payload, on this path only.
932
+ lowered=""
933
+ low=$floor; [ "$low" -lt 1 ] && low=1
934
+ if [ "$rank" -gt "$low" ] && [ -z "$CARD_ABOVE" ]; then
935
+ _am_name "$low"; to="$_AMN"
936
+ 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=""
937
+ case "$ui" in '{'*'}') ;; *) ui="" ;; esac
938
+ if [ -n "$ui" ]; then
939
+ lowered="$m"
940
+ _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"
941
+ m="$to"
942
+ fi
943
+ fi
798
944
  # ---- where it runs, and what its verify command is ----
799
945
  _json_keycount "$ti" run_in_background
800
946
  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 +964,14 @@ fi
818
964
  _json_slice "$INPUT" tool_use_id >/dev/null; tid="$_JS"; case "$tid" in ''|*[!A-Za-z0-9._-]*) tid="x$RANDOM" ;; esac
819
965
  if mkdir -p "$_AMD/pending" 2>/dev/null; then
820
966
  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
967
+ # Why the work counts as critical, as the gate decided it, so a reader shows the reason and does not work it out
968
+ # again: one line, no tab (it ends up in a column of model-outcomes.tsv).
969
+ rw="${CARD_RISKWHY:--}"; rw="${rw//$'\t'/ }"; rw="${rw//$'\r'/ }"; rw="${rw//$'\n'/ }"
970
+ { 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
971
+ fi
972
+ # The call, on the floor: the same input with `model` replaced, and allowed. Nothing else is printed on this path.
973
+ if [ -n "$lowered" ]; then
974
+ 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
975
  fi
823
976
  exit 0
824
977
  }