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.
- package/CHANGELOG.md +190 -5
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +55 -6
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +6 -6
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +37 -3
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/lib/crew-map/narrative.mjs +42 -8
- package/kit/eval/lib/crew-map/project.mjs +3 -2
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +56 -4
- package/kit/eval/lib/crew-map/stacks.mjs +114 -8
- package/kit/eval/lib/crew-map/surface.mjs +81 -3
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +14 -4
- package/kit/eval/lib/statusline.sh +4 -19
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +424 -26
- package/kit/hooks/agent-outcome.sh +38 -9
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +163 -20
- package/kit/hooks/guard-bash.sh +69 -36
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +74 -5
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +18 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-map/SKILL.md +16 -5
- package/kit/skills/crew-review/SKILL.md +2 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +85 -22
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +104 -18
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/web/app.js +79 -11
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +72 -3
- package/kit/studio/web/map.js +103 -10
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +40 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- 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
|
|
215
|
-
# the same oldest record.
|
|
216
|
-
#
|
|
217
|
-
|
|
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
|
-
|
|
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 "$
|
|
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 "$
|
|
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
|
package/kit/hooks/board-sync.sh
CHANGED
|
@@ -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
|
|
83
|
-
|
|
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.
|
|
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
|
-
|
|
418
|
-
local
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
case "$l" in *:*) ;; *) CARD_WHY="line $
|
|
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 $
|
|
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
|
|
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="
|
|
621
|
+
local gl="" v=BLOCK
|
|
505
622
|
case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
|
|
506
|
-
|
|
507
|
-
|
|
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"
|
|
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
|
}
|