crewforth 3.0.3 → 3.1.0-rc.1

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 (67) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/routing-eval.sh +7 -3
  16. package/kit/eval/smoke-test.sh +2545 -214
  17. package/kit/hooks/commit-msg +1 -1
  18. package/kit/hooks/context-usage.sh +7 -1
  19. package/kit/hooks/guard-bash.sh +1608 -103
  20. package/kit/hooks/guard-commit-scan.sh +138 -19
  21. package/kit/hooks/guard-powershell.sh +89 -15
  22. package/kit/hooks/guard-write.sh +98 -22
  23. package/kit/hooks/no-bash-guard.ps1 +71 -0
  24. package/kit/hooks/pre-commit +5 -2
  25. package/kit/hooks/prompt-approval.sh +484 -0
  26. package/kit/hooks/route-hint.sh +13 -5
  27. package/kit/hooks/session-rehydrate.sh +18 -0
  28. package/kit/hooks/skill-trust.sh +17 -2
  29. package/kit/owned-blobs.tsv +43 -0
  30. package/kit/settings.json +20 -4
  31. package/kit/skills/a11y/SKILL.md +1 -0
  32. package/kit/skills/crew-doctor/SKILL.md +2 -2
  33. package/kit/skills/frontend/SKILL.md +3 -3
  34. package/kit/skills/frontend-flutter/SKILL.md +94 -0
  35. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
  36. package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
  37. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -25,7 +25,7 @@ set -uo pipefail
25
25
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
26
26
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
27
27
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
28
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
28
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
29
29
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
30
30
  # staged key all passed.
31
31
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -38,6 +38,27 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
38
38
  case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
39
39
  export LC_ALL=C
40
40
  # ---- /CREW-LOCALE
41
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
42
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
43
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
44
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
45
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
46
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
47
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
48
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
49
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
50
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
51
+ # that is not found (status 127, the script continues inside the same command).
52
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
53
+ _crew_stop(){ # $1 = what happened
54
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
55
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
56
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
57
+ exit 2
58
+ }
59
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
60
+ # ---- /CREW-FAILCLOSED
61
+ _gate_main(){
41
62
  # ---- CREW-MATCH -------------------------------------------------------------------------------------------
42
63
  # grep on the command, without a pipe and with its status read. Both halves were measured failing open:
43
64
  # * `echo "$CMD" | grep -q …` under pipefail. grep -q leaves at its first match; when the command is larger than
@@ -67,9 +88,74 @@ _grep_out(){ # the same for a caller that wants what grep printed -> _GO (empty
67
88
  _grep_stop "$rc"
68
89
  }
69
90
  # ---- /CREW-MATCH
70
- DIR="$(cd "$(dirname "$0")" && pwd)"
91
+ # ---- CREW-JOIN --------------------------------------------------------------------------------------------
92
+ # A line that ends in a backslash continues on the next one: the shell takes `rm -f \<newline>.claude/hooks/x` as
93
+ # ONE command. The rules read the command line by line and word by word, so each half looked harmless (measured,
94
+ # rc 0: a gate file deleted, copied over, moved, edited in place, redirected over; `rm \<newline>-rf`;
95
+ # `git push \<newline>--force`; and `git \<newline>push`, `git \<newline>commit`, which reached neither the approval
96
+ # prompt nor the review gate). _join_cmd joins such lines in CMD before anything reads it, the way the shell does:
97
+ # * an ODD number of backslashes before the newline continues the line; an even number is backslashes, and the
98
+ # newline still ends the command.
99
+ # * PowerShell continues a line with a backtick, so a PowerShell call is joined on that.
100
+ # * a `#` at the start of a word MAY open a comment, and in a comment the backslash continues nothing: the next
101
+ # line is a command of its own. Telling a comment from a quoted `#` needs the whole quoting, so that line is
102
+ # read BOTH ways: joined, and the lines it would have swallowed once more as a command after it.
103
+ # Inside single quotes and in a quoted here-document the shell keeps the pair; joining it there changes only data.
104
+ # Byte-identical in every gate that reads a command; the suite pins it.
105
+ _join_lines(){ # $1 = command, $2 = the character that continues a line -> _JL
106
+ local s="$1" e="$2" nl=$'\n' line t body c out="" cur="" extra="" amb=0
107
+ _JL="$s"
108
+ case "$s" in *"$e$nl"*) ;; *) return 0 ;; esac
109
+ while IFS= read -r line || [ -n "$line" ]; do
110
+ t="${line##*[!"$e"]}"; body="$line"; c=0
111
+ if [ $(( ${#t} % 2 )) = 1 ]; then body="${line%"$e"}"; c=1; fi
112
+ [ "$amb" = 1 ] && extra="$extra$body"
113
+ cur="$cur$body"
114
+ if [ "$c" = 1 ]; then
115
+ [ "$amb" = 1 ] || case "$cur" in '#'*|*[[:space:]\;\&\|\(]'#'*) amb=1 ;; esac
116
+ continue
117
+ fi
118
+ out="$out$cur$nl"; [ "$amb" = 1 ] && out="$out$extra$nl"
119
+ cur=""; extra=""; amb=0
120
+ done <<< "$s"
121
+ [ -n "$cur" ] && { out="$out$cur$e$nl"; [ "$amb" = 1 ] && out="$out$extra$nl"; } # the last line ended in one: nothing follows it
122
+ _JL="${out%"$nl"}"
123
+ }
124
+ _join_cmd(){ # CMD -> CMD with its continued lines joined
125
+ case "$INPUT" in *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*) _join_lines "$CMD" '`' ;; *) _join_lines "$CMD" '\' ;; esac
126
+ CMD="$_JL"
127
+ }
128
+ # ---- /CREW-JOIN
129
+ # DIR is resolved only once a `git commit` is in front of this hook (below): `$(cd "$(dirname "$0")" && pwd)` is two
130
+ # subshells and a process, and this hook runs before EVERY Bash call.
71
131
 
72
132
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
133
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
134
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
135
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
136
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
137
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
138
+ # counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
139
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
140
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
141
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
142
+ # taken from the whole text, and the small pieces from the block.
143
+ local LC_ALL=C
144
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
145
+ _JF=-1
146
+ while [ "$i" -lt "$n" ]; do
147
+ b="${1:i:B+kl-1}"
148
+ case "$b" in *"$2"*)
149
+ j=0; bn=${#b}
150
+ while [ "$j" -lt "$bn" ]; do
151
+ c="${b:j:C+kl-1}"
152
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
153
+ j=$((j+C))
154
+ done ;;
155
+ esac
156
+ i=$((i+B))
157
+ done
158
+ }
73
159
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
74
160
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
75
161
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -93,8 +179,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
93
179
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
94
180
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
95
181
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
96
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
97
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
182
+ # _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
98
183
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
99
184
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
100
185
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -118,7 +203,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
118
203
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
119
204
  # the battery sees both.
120
205
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
121
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
206
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
122
207
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
123
208
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
124
209
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -134,14 +219,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
134
219
  # raising or removing it later is not a tidy-up.
135
220
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
136
221
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
137
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
222
+ # both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
138
223
  # predates this change and belongs to the walk, not to the cap.
139
224
  local _cap=64 _seen=0
140
225
  while :; do
141
- pre="${hay%%"$k"*}" # everything before the next `"key"`
142
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
226
+ _json_find "$hay" "$k" # where the next `"key"` starts
227
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
143
228
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
144
- rest="${hay:${#pre}+${#k}}" # past `"key"`
229
+ rest="${hay:_JF+${#k}}" # past `"key"`
145
230
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
146
231
  case "$rest" in
147
232
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -181,7 +266,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
181
266
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
182
267
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
183
268
  #
184
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
269
+ # That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
185
270
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
186
271
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
187
272
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -233,7 +318,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
233
318
  # the gate cases.
234
319
  local LC_ALL=C
235
320
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
236
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
321
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
237
322
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
238
323
  while :; do
239
324
  lim=$((cl - j))
@@ -263,12 +348,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
263
348
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
264
349
  case "$h" in
265
350
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
351
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
352
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
353
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
266
354
  *) acc+=("?") ;;
267
355
  esac ;;
268
356
  *) acc+=("$c") ;;
269
357
  esac
270
358
  done
271
- local IFS=''; printf '%s' "${acc[*]}"
359
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
360
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
361
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
272
362
  }
273
363
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
274
364
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -301,16 +391,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
301
391
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
302
392
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
303
393
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
304
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
394
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
305
395
  _KC=0; _KC_CAPPED=0
306
396
  while :; do
307
- pre="${hay%%"$k"*}"
308
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
397
+ _json_find "$hay" "$k"
398
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
309
399
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
310
400
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
311
401
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
312
402
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
313
- rest="${hay:${#pre}+${#k}}"
403
+ rest="${hay:_JF+${#k}}"
314
404
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
315
405
  case "$rest" in :*) c=$((c+1)) ;; esac
316
406
  hay="$rest"
@@ -406,7 +496,29 @@ crew_opt_values() {
406
496
  }
407
497
  crew_msg_values() { crew_opt_values "$1" -m --message; }
408
498
 
409
- INPUT="$(cat)"
499
+ # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
500
+ # of this hook. `read` returns 1 at end of input; the text is read regardless.
501
+ IFS= read -r -d '' INPUT || true
502
+ # ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
503
+ # A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
504
+ # its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
505
+ # on macOS for an escape-dense command: 256 KB 49 s, 512 KB 200 s, so about 900 KB is where the timeout is, and at
506
+ # that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
507
+ # The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
508
+ # large file is ordinary there — which is why the refusal points at them.
509
+ # Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
510
+ _PAYLOAD_MAX=262144
511
+ _payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
512
+ local LC_ALL=C
513
+ [ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
514
+ echo "GUARD (§4.5): this tool call is ${#INPUT} bytes long; the gates read a Bash or PowerShell call of up to $_PAYLOAD_MAX bytes." >&2
515
+ echo "A larger one could take longer to read than a hook is given, and a hook that runs out of time stops nothing," >&2
516
+ echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
517
+ echo "the path of that file." >&2
518
+ return 0
519
+ }
520
+ # ---- /CREW-PAYLOAD-MAX
521
+ _payload_over && exit 2
410
522
  # Same ladder as guard-bash.sh, and for the same reason: the raw-text fallback leaves JSON escapes in place,
411
523
  # so `-m \"…\"` never matches a quote-based extraction and the message silently goes unscanned. That is
412
524
  # precisely how the first version of this hook passed a commit carrying a co-author trailer.
@@ -444,7 +556,8 @@ fi
444
556
  # The shared reader also replaced a `sed | head | sed` pipeline: four processes on EVERY Bash tool call, to
445
557
  # re-derive a string guard-bash.sh had already parsed one hook earlier. Measured on `ls -la`: guard-bash.sh 2
446
558
  # processes, this hook 7. With the ladder gone neither hook spawns a process to SELECT a reader.
447
- CMD="$(_json_unescape "$(_json_slice "$INPUT" command)")"
559
+ _json_slice "$INPUT" command >/dev/null; _json_unescape "$_JS" >/dev/null; CMD="$_JU" # via _JS / _JU: no fork
560
+ _join_cmd # continued lines joined, as the shell runs them (CREW-JOIN)
448
561
  [ -z "$CMD" ] && exit 0
449
562
 
450
563
  # A command that does not contain `git` at all cannot match the pattern below, and finding that out
@@ -455,7 +568,10 @@ case "$CMD" in *[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
455
568
 
456
569
  # Only git commit. Matching mirrors guard-bash.sh's tolerance for `git -C dir commit`, TAB separators and a
457
570
  # quoted binary, because a gate that a whitespace change walks past is not a gate.
458
- _grep "$CMD" -qE '(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' || exit 0
571
+ # A builtin match: `printf | grep` was two processes on every command that merely mentions git (`git status`).
572
+ _GC_RE='(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)'
573
+ [[ $CMD =~ $_GC_RE ]] || exit 0
574
+ DIR="$(cd "$(dirname "$0")" && pwd)"
459
575
 
460
576
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
461
577
 
@@ -557,3 +673,6 @@ if [ "$FAILED" = 1 ]; then
557
673
  exit 2
558
674
  fi
559
675
  exit 0
676
+ }
677
+ _gate_main "$@"
678
+ _crew_stop "a command of the gate was abandoned"
@@ -24,7 +24,7 @@ set -uo pipefail
24
24
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
25
25
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
26
26
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
27
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
27
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
28
28
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
29
29
  # staged key all passed.
30
30
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -37,13 +37,80 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
37
37
  case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
38
38
  export LC_ALL=C
39
39
  # ---- /CREW-LOCALE
40
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
41
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
42
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
43
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
44
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
45
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
46
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
47
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
48
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
49
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
50
+ # that is not found (status 127, the script continues inside the same command).
51
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
52
+ _crew_stop(){ # $1 = what happened
53
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
54
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
55
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
56
+ exit 2
57
+ }
58
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
59
+ # ---- /CREW-FAILCLOSED
60
+ _gate_main(){
40
61
  # Read with the builtin: `INPUT="$(cat)"` is a subshell plus a process on EVERY PowerShell call, and on Git Bash a
41
62
  # process costs 62-135 ms. With this, a command that cannot match (no `bash` in the payload) opens no process at all
42
63
  # (measured with the xtrace counter: `cat` 1 -> 0). `read` returns 1 at end of input; the text is read regardless.
43
64
  IFS= read -r -d '' INPUT || true
65
+ # ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
66
+ # A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
67
+ # its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
68
+ # on macOS for an escape-dense command: 256 KB 49 s, 512 KB 200 s, so about 900 KB is where the timeout is, and at
69
+ # that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
70
+ # The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
71
+ # large file is ordinary there — which is why the refusal points at them.
72
+ # Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
73
+ _PAYLOAD_MAX=262144
74
+ _payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
75
+ local LC_ALL=C
76
+ [ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
77
+ echo "GUARD (§4.5): this tool call is ${#INPUT} bytes long; the gates read a Bash or PowerShell call of up to $_PAYLOAD_MAX bytes." >&2
78
+ echo "A larger one could take longer to read than a hook is given, and a hook that runs out of time stops nothing," >&2
79
+ echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
80
+ echo "the path of that file." >&2
81
+ return 0
82
+ }
83
+ # ---- /CREW-PAYLOAD-MAX
84
+ _payload_over && exit 2
44
85
  case "$INPUT" in *[Bb][Aa][Ss][Hh]*) ;; *) exit 0 ;; esac
45
86
 
46
87
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
88
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
89
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
90
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
91
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
92
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
93
+ # counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
94
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
95
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
96
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
97
+ # taken from the whole text, and the small pieces from the block.
98
+ local LC_ALL=C
99
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
100
+ _JF=-1
101
+ while [ "$i" -lt "$n" ]; do
102
+ b="${1:i:B+kl-1}"
103
+ case "$b" in *"$2"*)
104
+ j=0; bn=${#b}
105
+ while [ "$j" -lt "$bn" ]; do
106
+ c="${b:j:C+kl-1}"
107
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
108
+ j=$((j+C))
109
+ done ;;
110
+ esac
111
+ i=$((i+B))
112
+ done
113
+ }
47
114
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
48
115
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
49
116
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -67,8 +134,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
67
134
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
68
135
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
69
136
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
70
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
71
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
137
+ # _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
72
138
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
73
139
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
74
140
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -92,7 +158,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
92
158
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
93
159
  # the battery sees both.
94
160
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
95
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
161
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
96
162
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
97
163
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
98
164
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -108,14 +174,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
108
174
  # raising or removing it later is not a tidy-up.
109
175
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
110
176
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
111
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
177
+ # both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
112
178
  # predates this change and belongs to the walk, not to the cap.
113
179
  local _cap=64 _seen=0
114
180
  while :; do
115
- pre="${hay%%"$k"*}" # everything before the next `"key"`
116
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
181
+ _json_find "$hay" "$k" # where the next `"key"` starts
182
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
117
183
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
118
- rest="${hay:${#pre}+${#k}}" # past `"key"`
184
+ rest="${hay:_JF+${#k}}" # past `"key"`
119
185
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
120
186
  case "$rest" in
121
187
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -155,7 +221,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
155
221
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
156
222
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
157
223
  #
158
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
224
+ # That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
159
225
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
160
226
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
161
227
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -207,7 +273,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
207
273
  # the gate cases.
208
274
  local LC_ALL=C
209
275
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
210
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
276
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
211
277
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
212
278
  while :; do
213
279
  lim=$((cl - j))
@@ -237,12 +303,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
237
303
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
238
304
  case "$h" in
239
305
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
306
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
307
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
308
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
240
309
  *) acc+=("?") ;;
241
310
  esac ;;
242
311
  *) acc+=("$c") ;;
243
312
  esac
244
313
  done
245
- local IFS=''; printf '%s' "${acc[*]}"
314
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
315
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
316
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
246
317
  }
247
318
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
248
319
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -275,16 +346,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
275
346
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
276
347
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
277
348
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
278
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
349
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
279
350
  _KC=0; _KC_CAPPED=0
280
351
  while :; do
281
- pre="${hay%%"$k"*}"
282
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
352
+ _json_find "$hay" "$k"
353
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
283
354
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
284
355
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
285
356
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
286
357
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
287
- rest="${hay:${#pre}+${#k}}"
358
+ rest="${hay:_JF+${#k}}"
288
359
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
289
360
  case "$rest" in :*) c=$((c+1)) ;; esac
290
361
  hay="$rest"
@@ -345,3 +416,6 @@ while [ "$_i" -lt 20 ] && [[ $_c =~ $_re ]]; do _i=$((_i+1))
345
416
  exit 2
346
417
  done
347
418
  exit 0
419
+ }
420
+ _gate_main "$@"
421
+ _crew_stop "a command of the gate was abandoned"