crewforth 3.1.0-rc.5 โ†’ 3.1.0-rc.7

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