crewforth 3.2.0-rc.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 (74) hide show
  1. package/CHANGELOG.md +190 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +37 -3
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +81 -3
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-default.sh +36 -0
  28. package/kit/eval/lib/settings-json.awk +14 -4
  29. package/kit/eval/lib/statusline.sh +4 -19
  30. package/kit/eval/lib/verify-cmd.sh +17 -0
  31. package/kit/eval/lib/version.sh +41 -0
  32. package/kit/eval/review-bench.sh +26 -0
  33. package/kit/eval/smoke-test.sh +424 -26
  34. package/kit/hooks/agent-outcome.sh +38 -9
  35. package/kit/hooks/board-sync.sh +2 -23
  36. package/kit/hooks/guard-agent-model.sh +163 -20
  37. package/kit/hooks/guard-bash.sh +69 -36
  38. package/kit/hooks/guard-commit-scan.sh +10 -2
  39. package/kit/hooks/guard-write.sh +3 -7
  40. package/kit/hooks/precompact-snapshot.sh +75 -0
  41. package/kit/hooks/prompt-approval.sh +74 -5
  42. package/kit/hooks/session-rehydrate.sh +25 -2
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/hooks/trace-blocklist.txt +9 -0
  45. package/kit/settings.json +18 -0
  46. package/kit/skills/crew-code-review/SKILL.md +16 -0
  47. package/kit/skills/crew-map/SKILL.md +16 -5
  48. package/kit/skills/crew-review/SKILL.md +2 -1
  49. package/kit/skills/crew-ship/SKILL.md +3 -1
  50. package/kit/studio/README.md +21 -10
  51. package/kit/studio/server/index.js +85 -22
  52. package/kit/studio/server/lib/code-stamp.js +35 -0
  53. package/kit/studio/server/lib/graph.js +9 -1
  54. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  55. package/kit/studio/server/lib/lan.js +87 -0
  56. package/kit/studio/server/lib/owned-stream.js +56 -0
  57. package/kit/studio/server/lib/projects.js +11 -4
  58. package/kit/studio/server/lib/redact.js +60 -0
  59. package/kit/studio/server/lib/remote.js +104 -18
  60. package/kit/studio/server/lib/session.js +1 -1
  61. package/kit/studio/server/lib/terminal.js +51 -13
  62. package/kit/studio/server/lib/transcript.js +32 -0
  63. package/kit/studio/web/app.js +79 -11
  64. package/kit/studio/web/canvas.js +14 -2
  65. package/kit/studio/web/chat.js +114 -24
  66. package/kit/studio/web/graph-plan.js +3 -2
  67. package/kit/studio/web/liveness.js +14 -0
  68. package/kit/studio/web/map-plan.js +72 -3
  69. package/kit/studio/web/map.js +103 -10
  70. package/kit/studio/web/qr.js +200 -0
  71. package/kit/studio/web/style.css +40 -0
  72. package/kit/studio/web/tokens.css +6 -0
  73. package/package.json +1 -1
  74. package/start.sh +14 -1
@@ -204,27 +204,36 @@ case "$EV" in
204
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
205
205
  for f in "$_AMD/pending"/*; do
206
206
  [ -f "$f" ] || continue
207
- 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
208
208
  _am_rec_get "$f" agent || continue; [ "$_AMV" = "$AT" ] || continue
209
209
  cands="$cands$f"$'\n'
210
210
  _am_rec_get "$f" tier && { _am_rank "$_AMV"; [ "$_AMR" -lt "$low" ] && low="$_AMR"; }
211
211
  done
212
212
  [ -n "$cands" ] || exit 0
213
213
  mkdir -p "$_AMD/agents" 2>/dev/null || exit 0
214
- # TAKEN BY RENAME. Two agents of one type start at the same moment, each in its own hook process, and both see
215
- # the same oldest record. A rename either happens or fails, so exactly one of them gets it; the other goes on
216
- # to the next record. (Reading it and deleting it afterwards gave both agents the same record.)
217
- 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
218
223
  while IFS= read -r f; do
219
224
  [ -n "$f" ] || continue
225
+ take="$_AMD/agents/${f##*/}.take"
226
+ { : > "$take"; } 2>/dev/null || { take=""; continue; }
220
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
221
229
  done <<< "$cands"
230
+ set +C
222
231
  [ "$got" = 1 ] || exit 0
223
232
  _am_rec_get "$claim" tier; _am_rank "$_AMV"
224
233
  if [ "$low" -lt "$_AMR" ]; then _am_name "$low"
225
234
  { grep -v '^tier=' "$claim"; printf 'asked=%s\ntier=%s\nambiguous=1\n' "$_AMV" "$_AMN"; } > "$_AMD/agents/$AID" 2>/dev/null
226
235
  else { cat "$claim"; printf 'asked=%s\n' "$_AMV"; } > "$_AMD/agents/$AID" 2>/dev/null; fi
227
- rm -f "$claim" 2>/dev/null
236
+ rm -f "$claim" "$take" 2>/dev/null
228
237
  exit 0 ;;
229
238
 
230
239
  SubagentStop)
@@ -290,7 +299,9 @@ case "$EV" in
290
299
  RES=none; WHYNOT=""
291
300
  if [ -n "$VCMD" ] && [ "$VCMD" != none ]; then
292
301
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
293
- 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
294
305
  # Judged again here, at the moment it would run: the gate judged it when the agent was called, and this
295
306
  # is the place the command actually starts. Not run; nothing is concluded about the work.
296
307
  RES=blocked; WHYNOT="$_AMVW"; VRC=0
@@ -305,11 +316,11 @@ case "$EV" in
305
316
  # control, which gives a background job its own group in bash (macOS ships no setsid). On Windows the tree
306
317
  # is ended by its Windows process id.
307
318
  if [ -z "${CREW_VERIFY_NO_SETSID:-}" ] && command -v setsid >/dev/null 2>&1; then
308
- ( 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 &
309
320
  VPID=$!
310
321
  else
311
322
  set -m
312
- ( 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 &
313
324
  VPID=$!
314
325
  set +m
315
326
  fi
@@ -393,6 +404,24 @@ case "$EV" in
393
404
  fi
394
405
  fi
395
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
396
425
  R="$_AMD/results/$AID"
397
426
  if [ ! -f "$R" ]; then
398
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.
@@ -412,35 +417,100 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
412
417
  return "$hit"
413
418
  }
414
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
+
415
432
  # ---- THE CARD ---------------------------------------------------------------------------------------------------
416
433
  _AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
417
- _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1 and CARD_WHY
418
- local s="$1" l k v n=0 seen=""
419
- 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=""
420
465
  s="${s//$'\r'/}"
421
- 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
422
473
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
423
474
  while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
424
475
  [ -n "$l" ] || continue
425
- n=$((n+1))
426
- 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
427
478
  k="${l%%:*}"; v="${l#*:}"
428
479
  while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
429
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:).
430
482
  case "$k" in
483
+ above) [ -n "$CARD_ABOVE" ] || CARD_ABOVE="${v:0:200}"; continue ;;
431
484
  files) CARD_FILES="$v" ;;
432
485
  change) CARD_CHANGE="$v" ;;
433
486
  verify) CARD_VERIFY="$v" ;;
434
- *) 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 ;;
435
488
  esac
489
+ n=$((n+1))
436
490
  case "$seen" in *" $k "*) CARD_WHY="the card names $k twice"; return 1 ;; esac
437
491
  seen="$seen $k "
438
492
  done
439
493
  [ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
440
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
441
502
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
442
503
  [ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
443
- 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
444
514
  return 0
445
515
  }
446
516
  _am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
@@ -460,6 +530,53 @@ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "th
460
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
461
531
  CARD_ID="${t:0:12}"
462
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
+ }
463
580
  _am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
464
581
  local l; _AMV=""
465
582
  [ -f "$1" ] || return 1
@@ -501,13 +618,10 @@ _am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor,
501
618
 
502
619
  # ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
503
620
  _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog; the call's text is never recorded.
504
- local gl="${CREW_GATE_LOG:-}" v=BLOCK
621
+ local gl="" v=BLOCK
505
622
  case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
506
- if [ -z "$gl" ]; then
507
- [ -d ".claude" ] || return 0
508
- 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
509
- gl=".claude/gate-log.tsv"
510
- fi
623
+ declare -F crew_gate_log_write >/dev/null || return 0
624
+ crew_gate_log_write; gl="$_CREW_GL"; [ -n "$gl" ] || return 0
511
625
  printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
512
626
  }
513
627
 
@@ -538,6 +652,7 @@ _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog
538
652
  # 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
539
653
  # explicit allow passes.
540
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
541
656
  # CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
542
657
  _AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
543
658
  'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
@@ -609,6 +724,9 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
609
724
  done <<< "$_AMSF"
610
725
  return 1
611
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; }
612
730
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
613
731
  local c="$1" pm="${2:-default}" f r rc k g
614
732
  _AMVW=""; _AMVB=""
@@ -690,6 +808,8 @@ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> ret
690
808
  if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
691
809
  fi
692
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
693
813
  _am_log "critical path: write by an agent not on opus"
694
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
695
815
  exit 2
@@ -729,7 +849,7 @@ _json_keycount "$ti" subagent_type
729
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
730
850
  _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
731
851
  st="${st##*:}" # a plugin names its agents <plugin>:crew-…
732
- case "$st" in crew-*) ;; *) exit 0 ;; esac
852
+ case "$st" in crew-*) ;; *) _am_outside "$ti" "$st"; exit 0 ;; esac
733
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
734
854
  _json_keycount "$ti" model
735
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
@@ -763,9 +883,11 @@ if ! _am_card "$prompt"; then
763
883
  fi
764
884
  _am_state; _am_risk; _am_digest
765
885
  # ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
766
- 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
767
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
768
- 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
769
891
  # A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
770
892
  _am_rules
771
893
  for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
@@ -802,6 +924,23 @@ if [ "$rank" -lt "$floor" ]; then
802
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
803
925
  _am_log "below the floor of the card"; exit 2
804
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
805
944
  # ---- where it runs, and what its verify command is ----
806
945
  _json_keycount "$ti" run_in_background
807
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
@@ -828,7 +967,11 @@ if mkdir -p "$_AMD/pending" 2>/dev/null; then
828
967
  # Why the work counts as critical, as the gate decided it, so a reader shows the reason and does not work it out
829
968
  # again: one line, no tab (it ends up in a column of model-outcomes.tsv).
830
969
  rw="${CARD_RISKWHY:--}"; rw="${rw//$'\t'/ }"; rw="${rw//$'\r'/ }"; rw="${rw//$'\n'/ }"
831
- { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\nwhy=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" "$rw" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
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"
832
975
  fi
833
976
  exit 0
834
977
  }