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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +41 -21
  4. package/kit/CLAUDE.md +9 -7
  5. package/kit/agents/crew-backend-expert.md +4 -1
  6. package/kit/agents/crew-commit-agent.md +4 -1
  7. package/kit/agents/crew-database-expert.md +4 -1
  8. package/kit/agents/crew-devops-expert.md +4 -1
  9. package/kit/agents/crew-frontend-expert.md +4 -1
  10. package/kit/agents/crew-performance-expert.md +4 -1
  11. package/kit/agents/crew-planner.md +10 -1
  12. package/kit/agents/crew-privacy-agent.md +4 -1
  13. package/kit/agents/crew-review-agent.md +4 -1
  14. package/kit/agents/crew-security-expert.md +4 -1
  15. package/kit/agents/crew-session-manager.md +4 -1
  16. package/kit/agents/crew-test-expert.md +4 -1
  17. package/kit/eval/smoke-test.sh +744 -18
  18. package/kit/hooks/agent-outcome.sh +416 -0
  19. package/kit/hooks/guard-agent-model.sh +487 -60
  20. package/kit/hooks/guard-bash.sh +26 -1
  21. package/kit/hooks/guard-schedule.sh +7 -2
  22. package/kit/hooks/guard-write.sh +31 -0
  23. package/kit/hooks/prompt-approval.sh +55 -5
  24. package/kit/settings.json +43 -0
  25. package/kit/skills/crew-loosen/SKILL.md +28 -0
  26. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  27. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  28. package/kit/studio/server/index.js +85 -6
  29. package/kit/studio/server/lib/agent-origin.js +44 -0
  30. package/kit/studio/server/lib/graph.js +45 -7
  31. package/kit/studio/server/lib/model-outcomes.js +287 -0
  32. package/kit/studio/server/lib/palette.js +5 -18
  33. package/kit/studio/server/lib/permissions.js +260 -17
  34. package/kit/studio/server/lib/session.js +41 -1
  35. package/kit/studio/server/lib/usage.js +8 -1
  36. package/kit/studio/web/app.js +203 -32
  37. package/kit/studio/web/approvals.js +53 -2
  38. package/kit/studio/web/ask.js +137 -0
  39. package/kit/studio/web/canvas.js +91 -42
  40. package/kit/studio/web/chat.js +11 -1
  41. package/kit/studio/web/convo.js +9 -0
  42. package/kit/studio/web/dock.js +30 -9
  43. package/kit/studio/web/graph-plan.js +29 -9
  44. package/kit/studio/web/icons/builtin.svg +1 -1
  45. package/kit/studio/web/index.html +11 -4
  46. package/kit/studio/web/keys.js +29 -0
  47. package/kit/studio/web/list-plan.js +8 -3
  48. package/kit/studio/web/list.js +27 -5
  49. package/kit/studio/web/models-plan.js +167 -0
  50. package/kit/studio/web/models.js +127 -0
  51. package/kit/studio/web/names.js +30 -0
  52. package/kit/studio/web/style.css +85 -9
  53. package/kit/studio/web/timeline-plan.js +8 -0
  54. package/kit/studio/web/timeline.js +26 -7
  55. package/package.json +1 -1
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env bash
2
+ # agent-outcome.sh - what became of a crew agent's work: verified, fixed once, recorded, and told to the session.
3
+ #
4
+ # WHY. Whether a model was enough for a piece of work cannot be known before the work; it can be measured after it.
5
+ # The card a crew agent is called with (hooks/guard-agent-model.sh) names a verify command. This hook runs it when
6
+ # the agent stops, so a cheap model that was not enough costs one more run and not broken code, and the record of
7
+ # what happened is what the next choice is made from.
8
+ #
9
+ # FOUR EVENTS, one script (the wiring is in settings.json / hooks.json):
10
+ # SubagentStart the call's record, written by the gate when it allowed the call, is matched to the agent
11
+ # that has just started: from here on the agent's id names its model, card and class.
12
+ # SubagentStop the verify command is run in the project.
13
+ # green -> pass.
14
+ # red, the first time -> the agent is kept running, once, with the failing command and
15
+ # the end of its output as its next instruction.
16
+ # red, the second time -> the agent ends; the result is fail, and the card is marked so
17
+ # the gate refuses it on the same or a lower model.
18
+ # not a command a hook may run (it chains, or the shell gate would refuse or ask)
19
+ # -> blocked: not run, nothing concluded.
20
+ # not finished in CREW_VERIFY_TIMEOUT seconds (540)
21
+ # -> timeout: stopped, nothing concluded.
22
+ # Then one line is added to .claude/state/model-outcomes.tsv, and the class is looked at:
23
+ # where the first try failed too often, its floor goes one model up (crew-model-floors.auto).
24
+ # Only ever up: lowering a floor is the user's decision and nothing here does it.
25
+ # PostToolUse (Agent) a foreground call has come back. If its verify failed, or its report closed with
26
+ # confidence: low, the session is told to repeat the task once, one model up, and to ask
27
+ # the user if that fails too. A background call comes back before its agent has finished,
28
+ # so nothing is said then; the record and the gate still hold.
29
+ # SessionStart, --scan the project's critical paths are listed in .claude/state/crew-critical-paths.auto, when the
30
+ # list of files has changed. A generated file: the gate does not read it, it reads the paths.
31
+ #
32
+ # Measured on Claude Code 2.1.294 before this was written: an agent's Edit carries its agent_id, and it is the id
33
+ # SubagentStart was given and the one PostToolUse(Agent) returns as agentId; a SubagentStop that answers "block"
34
+ # keeps the agent working and the agent acts on the reason; PostToolUse's additionalContext reaches the session.
35
+ #
36
+ # THIS IS NOT A GATE. It cannot undo what an agent wrote, and when it fails it fails OPEN: a stop hook that refused
37
+ # on its own error would keep an agent running for ever. What stops broken work is the repeat one model up and the
38
+ # review before a commit. CREW_MODEL_ROUTING=off: nothing here does anything.
39
+ #
40
+ # THE VERIFY COMMAND comes from the card, which the session wrote, and would run here where no PreToolUse gate sees
41
+ # it. So it is one command with nothing chained to it, and it is handed to hooks/guard-bash.sh first, as the Bash
42
+ # call it would be: what that gate refuses, or would ask the user about, is not run (_am_verify_ok, one function,
43
+ # asked by the gate when the agent is called and here again when the command would start).
44
+ set -uo pipefail
45
+ export LC_ALL=C
46
+ [ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
47
+ _ao_here="${BASH_SOURCE%/*}"; [ "$_ao_here" = "${BASH_SOURCE}" ] && _ao_here=.
48
+ [ -f "$_ao_here/guard-agent-model.sh" ] || exit 0
49
+ . "$_ao_here/guard-agent-model.sh"
50
+ declare -F _am_state >/dev/null 2>&1 || exit 0
51
+ _am_state
52
+
53
+ # UTC, from `date -u` on every bash. The builtin `printf '%(…)T'` prints LOCAL time: with a `Z` after it the record
54
+ # was three hours off on a machine at +0300 (measured on Windows, bash 5.3; macOS's bash 3.2 took the `date` branch
55
+ # and CI runs in UTC, so neither showed it). One process, on a hook that is not on any hot path.
56
+ _ao_now(){ _AON="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"; case "$_AON" in [0-9][0-9][0-9][0-9]-*Z) ;; *) _AON=- ;; esac; }
57
+ # One writer at a time on the record: a line is added when an agent stops and a line is rewritten when its model
58
+ # arrives, and two hooks can run at once. The lock is a folder (made, or not) with the second it was taken inside.
59
+ # held now waited for, three seconds at most, then gone ahead without it: this hook does not hang an
60
+ # agent over a file, and the holder keeps its lock.
61
+ # older than 30 s nobody's: a hook that died holding it left it, and every write after it paid the three
62
+ # seconds. Removed and taken again.
63
+ # no stamp its holder died between making it and stamping it: cleared after the wait.
64
+ _ao_lock(){ local i=0 at now
65
+ _AOL="$_AMS/.outcomes.lock"
66
+ while [ "$i" -lt 6 ]; do
67
+ if mkdir "$_AOL" 2>/dev/null; then date +%s > "$_AOL/at" 2>/dev/null; return 0; fi
68
+ now="$(date +%s 2>/dev/null)"; at=""; [ -f "$_AOL/at" ] && IFS= read -r at < "$_AOL/at"
69
+ case "$at" in ''|*[!0-9]*) at="" ;; esac; case "$now" in ''|*[!0-9]*) now=0 ;; esac
70
+ if [ -n "$at" ] && [ $((now - at)) -gt 30 ]; then rm -rf "$_AOL" 2>/dev/null; i=$((i+1)); continue; fi
71
+ if [ "$i" -ge 3 ]; then
72
+ [ -z "$at" ] && { rm -rf "$_AOL" 2>/dev/null; i=$((i+1)); continue; }
73
+ break
74
+ fi
75
+ i=$((i+1)); sleep 1
76
+ done
77
+ _AOL=""; return 0; }
78
+ _ao_unlock(){ [ -n "${_AOL:-}" ] && rm -rf "$_AOL" 2>/dev/null; _AOL=""; return 0; }
79
+ _ao_json(){ # $1 = text -> _AOJ, safe inside a JSON string
80
+ local s="$1"
81
+ s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\n'/\\n}"; s="${s//$'\t'/ }"; s="${s//$'\r'/}"
82
+ _AOJ="$(printf '%s' "$s" | tr -d '\000-\010\013-\037\177')"
83
+ }
84
+
85
+ _ao_cut(){ # $1 = text, $2 = bytes -> _AOC: at most that many bytes, never ending inside a UTF-8 character
86
+ # In bytes, by tools that count bytes whatever the shell makes of the text, and with the arithmetic done here.
87
+ # Two earlier versions each passed on one macOS and failed on another: a shell substring counts bytes or
88
+ # characters depending on how the shell was started, and `iconv -c` does not drop a character cut at the end of
89
+ # its input on every macOS. So: `head -c` cuts; `od` shows the last four bytes; a character begun and not
90
+ # finished there (a lead byte with too few continuation bytes after it) is cut off as well.
91
+ local n="$2" hx b k=0 need=0 drop=0 total
92
+ total="$(printf '%s' "$1" | wc -c | tr -d ' ')"; case "$total" in ''|*[!0-9]*) total=0 ;; esac
93
+ if [ "$total" -le "$n" ]; then _AOC="$1"; return 0; fi
94
+ hx="$(printf '%s' "$1" | head -c "$n" | tail -c 4 | od -An -tx1 | tr -d ' \n')"
95
+ while [ -n "$hx" ]; do
96
+ b="${hx: -2}"; hx="${hx%??}"
97
+ case "$b" in
98
+ [89ab][0-9a-f]) k=$((k+1)); [ "$k" -ge 4 ] && { drop=$k; break; } ;; # a continuation byte
99
+ [cd][0-9a-f]) need=1; [ "$k" -lt "$need" ] && drop=$((k+1)); break ;;
100
+ e[0-9a-f]) need=2; [ "$k" -lt "$need" ] && drop=$((k+1)); break ;;
101
+ f[0-9a-f]) need=3; [ "$k" -lt "$need" ] && drop=$((k+1)); break ;;
102
+ *) break ;; # ASCII: nothing is open
103
+ esac
104
+ done
105
+ _AOC="$(printf '%s' "$1" | head -c "$((n - drop))")"
106
+ }
107
+
108
+ # ---- the list of critical paths (SessionStart, or `--scan` from the installers) ----------------------------------
109
+ _ao_scan(){
110
+ local out="$_AMS/crew-critical-paths.auto" stamp="$_AMS/crew-critical-paths.stamp" list sum old="" f rest seg pre n=0
111
+ [ -d "$_AMP/.claude" ] || return 0
112
+ if git -C "$_AMP" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
113
+ list="$(git -C "$_AMP" ls-files -co --exclude-standard 2>/dev/null)" || return 0
114
+ else
115
+ list="$(cd "$_AMP" 2>/dev/null && find . \( -name .git -o -name node_modules -o -name .claude \) -prune -o -type f -print 2>/dev/null | sed 's|^\./||')" || return 0
116
+ fi
117
+ sum="$(printf '%s\n' "$list" | cat - "$_AMP/.claude/crew-model-rules" 2>/dev/null | cksum)"; sum="${sum%% *}"
118
+ [ -f "$stamp" ] && IFS= read -r old < "$stamp"
119
+ [ "$1" != force ] && [ "$old" = "$sum" ] && [ -f "$out" ] && return 0
120
+ mkdir -p "$_AMS" 2>/dev/null || return 0
121
+ {
122
+ echo "# Generated by Crewforth (hooks/agent-outcome.sh). Do not edit: it is written again when the files change."
123
+ echo "# The paths of this project that are critical for model routing: work that touches one runs on opus."
124
+ echo "# To add or remove a path, write .claude/crew-model-rules ('+ <glob>' adds, '- <glob>' removes)."
125
+ while IFS= read -r f; do
126
+ [ -n "$f" ] || continue
127
+ case "$f" in .claude/*|.git/*) continue ;; esac # Crewforth's own files and git's are not the project's work
128
+ n=$((n+1)); [ "$n" -le 20000 ] || { echo "# stopped after 20000 files: the rest was not read"; break; }
129
+ _am_crit_path "$f" || continue
130
+ # The shortest leading part of the path that is critical by itself: a folder is listed once, not per file.
131
+ pre=""; rest="$f"
132
+ while :; do
133
+ seg="${rest%%/*}"; pre="${pre:+$pre/}$seg"
134
+ [ "$seg" = "$rest" ] && break
135
+ rest="${rest#*/}"
136
+ if _am_crit_path "$pre"; then pre="$pre/"; break; fi
137
+ done
138
+ printf '%s\n' "$pre"
139
+ done <<< "$list" | sort -u
140
+ } > "$out.tmp" 2>/dev/null && mv "$out.tmp" "$out" 2>/dev/null && printf '%s\n' "$sum" > "$stamp" 2>/dev/null
141
+ return 0
142
+ }
143
+ case "${1:-}" in --scan) _ao_scan force; exit 0 ;; esac
144
+
145
+ IFS= read -r -d '' INPUT || true
146
+ _json_slice "$INPUT" hook_event_name >/dev/null; EV="$_JS"
147
+
148
+ # ---- the class's floor, after a result (tightening only) --------------------------------------------------------
149
+ # A class is agent x change x risk. Where at least CREW_MODEL_CAL_N (5) of its calls on one model had a verify
150
+ # command, and more than CREW_MODEL_CAL_PCT (20) percent of them did not pass on the first try, the class is held
151
+ # one model up. Both numbers are a starting point that has not been measured.
152
+ _ao_calibrate(){ # $1 agent, $2 change, $3 risk
153
+ local tsv="$_AMS/model-outcomes.tsv" fl="$_AMS/crew-model-floors.auto" want a b c t rest keep="" since=""
154
+ [ -f "$tsv" ] || return 0
155
+ # After the user lowered this class's floor its outcomes are counted from that moment: the failures that raised
156
+ # it are not counted a second time, and new ones raise it again.
157
+ _am_floor_now "$1" "$2" "$3"
158
+ [ "$_AMF_LOOSE" = 1 ] && since="$_AMF_LTS"
159
+ want="$(awk -F'\t' -v A="$1" -v C="$2" -v R="$3" -v S="$since" -v N="${CREW_MODEL_CAL_N:-5}" -v P="${CREW_MODEL_CAL_PCT:-20}" '
160
+ NR > 1 && $3 == A && $4 == C && $5 == R && ($8 == "pass" || $8 == "fail") && (S == "" || $1 > S) { n[$7]++; if ($8 == "fail" || $9 + 0 > 0) bad[$7]++ }
161
+ END { r["haiku"] = 1; r["sonnet"] = 2; r["opus"] = 3; nm[2] = "sonnet"; nm[3] = "opus"; best = 0
162
+ for (m in n) if (r[m] > 0 && r[m] < 3 && n[m] >= N && bad[m] * 100 > P * n[m] && r[m] + 1 > best) { best = r[m] + 1; bn = n[m]; bb = bad[m] + 0 }
163
+ if (best) printf "%s\t%d\t%d", nm[best], bn, bb }' "$tsv" 2>/dev/null)" || return 0
164
+ [ -n "$want" ] || return 0
165
+ # Higher than the floor that HOLDS now (the lowered one, where the user lowered it): then it is raised, with a
166
+ # new time, which is what puts a lowering behind it.
167
+ _am_rank "$_AMF_EFF"; a="$_AMR"; _am_rank "${want%%$'\t'*}"
168
+ [ "$_AMR" -gt "$a" ] || return 0
169
+ if [ -f "$fl" ]; then
170
+ while IFS=$'\t' read -r a b c t rest || [ -n "$a" ]; do
171
+ case "$a" in ''|'#'*) continue ;; esac
172
+ [ "$a" = "$1" ] && [ "$b" = "$2" ] && [ "$c" = "$3" ] && continue
173
+ keep="$keep$a"$'\t'"$b"$'\t'"$c"$'\t'"$t"$'\t'"$rest"$'\n'
174
+ done < "$fl"
175
+ fi
176
+ _ao_now
177
+ # A raise that follows a lowering is stamped LATER than it, so the lowering is behind it: within the same second
178
+ # the two could not be told apart.
179
+ if [ "$_AMF_LOOSE" = 1 ] && [[ ! "$_AON" > "$_AMF_LTS" ]]; then sleep 1; _ao_now; fi
180
+ { echo "# Generated by Crewforth (hooks/agent-outcome.sh) from model-outcomes.tsv. A class held above its usual model"
181
+ echo "# because its first try failed too often one model down. agent, change, risk, model, calls, not-first-try, when."
182
+ echo "# Nothing lowers a line here but the user: the user can type /crew-loosen <agent> <change> <risk> (one model, once for each raise)."
183
+ printf '%s' "$keep"
184
+ printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$want" "$_AON"; } > "$fl.tmp" 2>/dev/null && mv "$fl.tmp" "$fl" 2>/dev/null
185
+ return 0
186
+ }
187
+
188
+ case "$EV" in
189
+ SessionStart) _ao_scan auto; exit 0 ;;
190
+
191
+ SubagentStart)
192
+ _json_slice "$INPUT" agent_id >/dev/null; AID="$_JS"
193
+ _json_slice "$INPUT" agent_type >/dev/null; AT="${_JS##*:}"
194
+ case "$AID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac
195
+ case "$AT" in crew-*) ;; *) exit 0 ;; esac
196
+ [ -d "$_AMD/pending" ] || exit 0
197
+ # The oldest call of this agent type that no agent has taken yet. Two calls of one type made together can start
198
+ # in either order; where they ask for different models the agent is recorded on the LOWER one, so the check at
199
+ # write time errs towards refusing.
200
+ # A call the gate allowed and nothing started (another hook refused it, the user did) leaves its record behind.
201
+ # An agent starts within moments of its call, so a record older than five minutes is nobody's and is dropped.
202
+ 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
203
+ for f in "$_AMD/pending"/*; do
204
+ [ -f "$f" ] || continue
205
+ t="${f##*/}"; t="${t%%-*}"; case "$t" in ''|*[!0-9]*) ;; *) if [ $((NOWS - t)) -gt 300 ]; then rm -f "$f" 2>/dev/null; continue; fi ;; esac
206
+ _am_rec_get "$f" agent || continue; [ "$_AMV" = "$AT" ] || continue
207
+ cands="$cands$f"$'\n'
208
+ _am_rec_get "$f" tier && { _am_rank "$_AMV"; [ "$_AMR" -lt "$low" ] && low="$_AMR"; }
209
+ done
210
+ [ -n "$cands" ] || exit 0
211
+ mkdir -p "$_AMD/agents" 2>/dev/null || exit 0
212
+ # TAKEN BY RENAME. Two agents of one type start at the same moment, each in its own hook process, and both see
213
+ # the same oldest record. A rename either happens or fails, so exactly one of them gets it; the other goes on
214
+ # to the next record. (Reading it and deleting it afterwards gave both agents the same record.)
215
+ claim="$_AMD/agents/$AID.claim"; got=0
216
+ while IFS= read -r f; do
217
+ [ -n "$f" ] || continue
218
+ if mv "$f" "$claim" 2>/dev/null; then got=1; break; fi
219
+ done <<< "$cands"
220
+ [ "$got" = 1 ] || exit 0
221
+ _am_rec_get "$claim" tier; _am_rank "$_AMV"
222
+ if [ "$low" -lt "$_AMR" ]; then _am_name "$low"
223
+ { grep -v '^tier=' "$claim"; printf 'asked=%s\ntier=%s\nambiguous=1\n' "$_AMV" "$_AMN"; } > "$_AMD/agents/$AID" 2>/dev/null
224
+ else { cat "$claim"; printf 'asked=%s\n' "$_AMV"; } > "$_AMD/agents/$AID" 2>/dev/null; fi
225
+ rm -f "$claim" 2>/dev/null
226
+ exit 0 ;;
227
+
228
+ SubagentStop)
229
+ _json_slice "$INPUT" agent_id >/dev/null; AID="$_JS"
230
+ case "$AID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac
231
+ REC="$_AMD/agents/$AID"
232
+ [ -f "$REC" ] || exit 0
233
+ [ -f "$_AMD/results/$AID" ] && exit 0 # an agent that is resumed stops again: its work was judged once
234
+ # WHOSE RECORD IS THIS? The record was matched to the agent by type and order, which two calls made together
235
+ # can get wrong. The card itself is in the agent's own transcript: the task it was given opens with it. So the
236
+ # card is read from there, and the record that is used is the one whose card it is: this agent's, or, where the
237
+ # two were swapped, the one filed under the other agent (left in place for that agent to find its own the same
238
+ # way). No transcript to read: the record is used as it was matched. A card no record carries: nothing is
239
+ # judged, because the command that would run is not one the gate saw for this agent.
240
+ _json_slice "$INPUT" agent_transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; ATP="${_JU//\\//}"
241
+ if [ -f "$ATP" ]; then
242
+ L1=""; IFS= read -r L1 < "$ATP" || true
243
+ # The task is the first record's content: a string (measured, Claude Code 2.1.294), or, where it is a list of
244
+ # blocks, the first block's text.
245
+ _json_slice "$L1" content >/dev/null; TXT="$_JS"
246
+ [ -n "$TXT" ] || { _json_slice "$L1" text >/dev/null; TXT="$_JS"; }
247
+ TXT="${TXT:0:6000}"; CARDOK=0
248
+ if [ -n "$TXT" ]; then _json_unescape "$TXT" >/dev/null; _am_card "$_JU" && CARDOK=1; fi
249
+ if [ "$CARDOK" = 0 ]; then
250
+ # Nothing to check the record against. Said in the gate log, and the record is used as it was matched.
251
+ _am_log "note: the card could not be read from the agent's transcript; its record was used as matched"
252
+ else
253
+ _am_digest
254
+ _am_rec_get "$REC" card
255
+ if [ "$_AMV" != "$CARD_ID" ]; then
256
+ _am_rec_get "$REC" agent; MYT="$_AMV"; FOUND=""
257
+ # First a call nobody has taken yet, then the newest record that has not judged anyone. (A record is
258
+ # used up by the agent it judges, which is not always the agent it is filed under: that is the swap.)
259
+ for f in "$_AMD/pending"/*; do
260
+ [ -f "$f" ] || continue
261
+ _am_rec_get "$f" card && [ "$_AMV" = "$CARD_ID" ] || continue
262
+ _am_rec_get "$f" agent && [ "$_AMV" = "$MYT" ] || continue
263
+ FOUND="$f"; break
264
+ done
265
+ if [ -z "$FOUND" ]; then
266
+ while IFS= read -r f; do
267
+ [ -n "$f" ] && [ -f "$f" ] || continue; case "$f" in *.claim|*.out|*.used) continue ;; esac
268
+ [ -f "$f.used" ] && continue # that record has already judged an agent
269
+ _am_rec_get "$f" card && [ "$_AMV" = "$CARD_ID" ] || continue
270
+ _am_rec_get "$f" agent && [ "$_AMV" = "$MYT" ] || continue
271
+ FOUND="$f"; break
272
+ done <<< "$(ls -t "$_AMD/agents"/* 2>/dev/null)"
273
+ fi
274
+ if [ -z "$FOUND" ]; then _am_log "note: no record carries the card in the agent's transcript; the agent was not judged"; exit 0; fi
275
+ REC="$FOUND"
276
+ fi
277
+ fi
278
+ fi
279
+ _am_rec_get "$REC" verify; VCMD="$_AMV"
280
+ _am_rec_get "$REC" agent; AG="$_AMV"; _am_rec_get "$REC" change; CH="$_AMV"; _am_rec_get "$REC" risk; RK="$_AMV"
281
+ _am_rec_get "$REC" card; CID="$_AMV"; _am_rec_get "$REC" esc; ESC="${_AMV:--}"
282
+ _am_rec_get "$REC" asked || _am_rec_get "$REC" tier; ASK="$_AMV"
283
+ mkdir -p "$_AMD/results" "$_AMD/tries" "$_AMD/fails" 2>/dev/null || exit 0
284
+ TRIES=0; [ -f "$_AMD/tries/$AID" ] && IFS= read -r TRIES < "$_AMD/tries/$AID"; case "$TRIES" in ''|*[!0-9]*) TRIES=0 ;; esac
285
+ SHA=0; case "$INPUT" in *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) SHA=1 ;; esac
286
+ RES=none; WHYNOT=""
287
+ if [ -n "$VCMD" ] && [ "$VCMD" != none ]; then
288
+ _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
289
+ if ! _am_verify_ok "$VCMD" "$PM"; then
290
+ # Judged again here, at the moment it would run: the gate judged it when the agent was called, and this
291
+ # is the place the command actually starts. Not run; nothing is concluded about the work.
292
+ RES=blocked; WHYNOT="$_AMVW"; VRC=0
293
+ else
294
+ # A time limit of its own, below the hook's: a hook killed at its timeout records nothing, and a verify
295
+ # that hangs must not read as a pass or as a fail.
296
+ # The limit stays under the hook's own 600 s whatever is asked for.
297
+ LIM="${CREW_VERIFY_TIMEOUT:-540}"; case "$LIM" in ''|*[!0-9]*) LIM=540 ;; esac; [ "$LIM" -le 560 ] || LIM=560
298
+ VF="$_AMD/tries/$AID.out"
299
+ # IN A PROCESS GROUP OF ITS OWN, so that stopping it stops what it started: a test runner's workers, a
300
+ # server a test left behind. `setsid` where there is one (CREW_VERIFY_NO_SETSID=1 skips it); otherwise job
301
+ # control, which gives a background job its own group in bash (macOS ships no setsid). On Windows the tree
302
+ # is ended by its Windows process id.
303
+ if [ -z "${CREW_VERIFY_NO_SETSID:-}" ] && command -v setsid >/dev/null 2>&1; then
304
+ ( cd "$_AMP" 2>/dev/null && exec setsid bash -c "$VCMD" ) > "$VF" 2>&1 </dev/null &
305
+ VPID=$!
306
+ else
307
+ set -m
308
+ ( cd "$_AMP" 2>/dev/null && exec bash -c "$VCMD" ) > "$VF" 2>&1 </dev/null &
309
+ VPID=$!
310
+ set +m
311
+ fi
312
+ WAITED=0; VRC=""
313
+ while kill -0 "$VPID" 2>/dev/null; do
314
+ if [ "$WAITED" -ge "$LIM" ]; then
315
+ case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*)
316
+ WP=""; [ -r "/proc/$VPID/winpid" ] && IFS= read -r WP < "/proc/$VPID/winpid"
317
+ case "$WP" in ''|*[!0-9]*) ;; *) taskkill //T //F //PID "$WP" >/dev/null 2>&1 ;; esac ;;
318
+ esac
319
+ kill -TERM -- "-$VPID" 2>/dev/null; kill -TERM "$VPID" 2>/dev/null
320
+ W2=0; while kill -0 "$VPID" 2>/dev/null && [ "$W2" -lt 3 ]; do sleep 1; W2=$((W2+1)); done
321
+ kill -KILL -- "-$VPID" 2>/dev/null; kill -KILL "$VPID" 2>/dev/null
322
+ VRC=timeout; break
323
+ fi
324
+ sleep 1; WAITED=$((WAITED+1))
325
+ done
326
+ if [ "$VRC" = timeout ]; then wait "$VPID" 2>/dev/null; RES=timeout; WHYNOT="it did not finish in ${LIM}s"; VRC=0
327
+ else wait "$VPID" 2>/dev/null; VRC=$?; fi
328
+ VOUT="$(tail -n 15 "$VF" 2>/dev/null)"; rm -f "$VF" 2>/dev/null
329
+ fi
330
+ if [ "$RES" = blocked ] || [ "$RES" = timeout ]; then :
331
+ elif [ "$VRC" = 0 ]; then RES=pass
332
+ elif [ "$TRIES" = 0 ] && [ "$SHA" = 0 ] && { printf '1\n' > "$_AMD/tries/$AID"; } 2>/dev/null; then
333
+ # Asked ONCE, and only where that can be counted: Claude Code says when a stop hook has already kept this
334
+ # agent running (stop_hook_active), and the counter has to be on disk before the answer goes out. Either
335
+ # one missing, the agent is not kept: a stop hook that blocks without counting blocks for ever.
336
+ TAILV="$(printf '%s\n' "$VOUT" | tail -n 15)"; _ao_cut "$TAILV" 1500; TAILV="$_AOC"
337
+ _ao_json "The verify command of your task failed (exit $VRC): $VCMD"$'\n'"The end of its output:"$'\n'"$TAILV"$'\n'"Fix the work so that the command passes, then finish. This is asked once: if it fails again your report is taken as it is."
338
+ printf '{"decision":"block","reason":"%s"}\n' "$_AOJ"
339
+ exit 0
340
+ else RES=fail; fi
341
+ fi
342
+ # The report's last line, and the model the agent's own transcript names.
343
+ _json_slice "$INPUT" last_assistant_message >/dev/null; LAST="$_JS"; [ "${#LAST}" -gt 400 ] && LAST="${LAST:${#LAST}-400}"
344
+ _json_unescape "$LAST" >/dev/null; LAST="$_JU"
345
+ while :; do case "$LAST" in *[$' \t\n\r']) LAST="${LAST%?}" ;; *) break ;; esac; done
346
+ ESCL=0; case $'\n'"$LAST" in *$'\n'"escalate:"*) ESCL=1 ;; esac # a write to a critical path was refused
347
+ LAST="${LAST##*$'\n'}"
348
+ case "$LAST" in "confidence: high") CONF=high ;; "confidence: low") CONF=low ;; *) CONF=- ;; esac
349
+ # The model the agent ran on. Claude Code names it in the answer to the Agent call (resolvedModel), which the
350
+ # PostToolUse branch below keeps under the agent's id: for a background call that answer comes when the agent is
351
+ # launched, so it is here already; for a foreground call it comes after this, and that branch then writes it
352
+ # into the line this one adds. Failing that, the first model the agent's own transcript names; failing that, `-`.
353
+ RAN=-
354
+ if [ -s "$_AMD/ran/$AID" ]; then IFS= read -r RAN < "$_AMD/ran/$AID" || true
355
+ elif [ -f "$ATP" ]; then RAN="$(grep -m1 -o '"model":"[^"]*"' "$ATP" 2>/dev/null)" || RAN=""; RAN="${RAN#\"model\":\"}"; RAN="${RAN%\"}"; fi
356
+ case "$RAN" in ''|*[!A-Za-z0-9._:\[\]-]*) RAN=- ;; esac
357
+ [ "$RES" = fail ] && printf '%s\n' "$ASK" >> "$_AMD/fails/$CID" 2>/dev/null
358
+ 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
359
+ case "$REC" in "$_AMD/pending/"*) rm -f "$REC" 2>/dev/null ;; *) : > "$REC.used" 2>/dev/null ;; esac
360
+ TSV="$_AMS/model-outcomes.tsv"
361
+ [ -s "$TSV" ] || printf 'ts\tagent_id\tagent\tchange\trisk\tcard\tmodel\tverify\tfixes\tescalated_from\tran_on\tconfidence\n' > "$TSV" 2>/dev/null
362
+ _ao_now
363
+ _ao_lock
364
+ 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
365
+ _ao_unlock
366
+ case "$RES" in pass|fail) _ao_calibrate "$AG" "$CH" "$RK" ;; esac
367
+ exit 0 ;;
368
+
369
+ PostToolUse)
370
+ _json_slice "$INPUT" tool_name >/dev/null; case "$_JS" in Agent|Task) ;; *) exit 0 ;; esac
371
+ _json_slice "$INPUT" agentId >/dev/null; AID="$_JS"
372
+ case "$AID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac
373
+ # The model the agent really ran on, as Claude Code resolved it (measured, 2.1.294: tool_response.resolvedModel,
374
+ # in the answer of a finished foreground call and of a background launch alike). Kept under the agent's id for
375
+ # the stop hook, and written into the agent's line where that line is there already.
376
+ _json_keycount "$INPUT" resolvedModel
377
+ if [ "$_KC" = 1 ]; then
378
+ _json_slice "$INPUT" resolvedModel >/dev/null; RM="$_JS"
379
+ case "$RM" in ''|*[!A-Za-z0-9._:\[\]-]*) RM="" ;; esac
380
+ if [ -n "$RM" ] && [ "${#RM}" -le 80 ] && mkdir -p "$_AMD/ran" 2>/dev/null; then
381
+ printf '%s\n' "$RM" > "$_AMD/ran/$AID" 2>/dev/null
382
+ TSV="$_AMS/model-outcomes.tsv"
383
+ if [ -f "$TSV" ] && grep -q "$(printf '\t')$AID$(printf '\t')" "$TSV" 2>/dev/null; then
384
+ _ao_lock
385
+ awk -F'\t' -v OFS='\t' -v A="$AID" -v M="$RM" 'NR > 1 && $2 == A && NF >= 11 { $11 = M } { print }' "$TSV" > "$TSV.tmp" 2>/dev/null \
386
+ && [ "$(wc -l < "$TSV.tmp")" = "$(wc -l < "$TSV")" ] && mv "$TSV.tmp" "$TSV" 2>/dev/null
387
+ rm -f "$TSV.tmp" 2>/dev/null
388
+ _ao_unlock
389
+ fi
390
+ fi
391
+ fi
392
+ R="$_AMD/results/$AID"; [ -f "$R" ] || exit 0
393
+ _am_rec_get "$R" verify; RES="$_AMV"; _am_rec_get "$R" confidence; CONF="$_AMV"; _am_rec_get "$R" tier; TI="$_AMV"
394
+ _am_rec_get "$R" esc; ESC="$_AMV"; _am_rec_get "$R" cmd; VCMD="$_AMV"
395
+ case "$RES" in fail|blocked|timeout) ;; *) [ "$CONF" = low ] || exit 0 ;; esac
396
+ if [ "$RES" = blocked ] || [ "$RES" = timeout ]; then
397
+ # Nothing is known about the work, so nothing is escalated: the session runs the command where a gate sees it.
398
+ _am_rec_get "$R" whynot; WN="$_AMV"
399
+ _ao_json "Crewforth: the agent's verify command was NOT run by the hook ($VCMD): $WN. Nothing is concluded about the work. Run the verify command yourself with the Bash tool and judge the result; do not repeat the task on another model for this."
400
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$_AOJ"
401
+ exit 0
402
+ fi
403
+ _am_rank "$TI"; _am_name $((_AMR+1)); UP="$_AMN"
404
+ _am_rec_get "$R" escalate; ESCL="$_AMV"
405
+ if [ "$RES" = fail ]; then MSG="verify failed after one in-agent fix ($VCMD)."; TAILM="The work the agent left is in the tree and has NOT passed its verify command."
406
+ else MSG="the agent closed its report with confidence: low."; TAILM="What the agent left is in the tree; read its report for what it could not do."; fi
407
+ # A write refused on a critical path is not helped by one model up: that file takes opus.
408
+ if [ "$ESCL" = 1 ] && [ "$TI" != opus ] && [ "$TI" != fable ]; then UP=opus; MSG="$MSG It was refused a write to a critical path, which only an agent on opus may make; name that file in the card."; fi
409
+ if [ "$ESC" != - ] && [ -n "$ESC" ]; then MSG="$MSG This was already the repeat one model up (first run: $ESC). Do not run it a third time: ask the user what to do (AskUserQuestion), with what failed and on which models."
410
+ elif [ -z "$UP" ] || [ "$UP" = fable ]; then MSG="$MSG It ran on $TI and there is no model above it to repeat on: ask the user what to do (AskUserQuestion)."
411
+ else MSG="$MSG Re-run this task once with $UP: the same card, the same agent, model $UP. If that also fails, ask the user."; fi
412
+ _ao_json "Crewforth: $MSG $TAILM"
413
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$_AOJ"
414
+ exit 0 ;;
415
+ esac
416
+ exit 0