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
@@ -36,7 +36,7 @@ set -uo pipefail
36
36
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
37
37
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
38
38
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
39
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
39
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
40
40
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
41
41
  # staged key all passed.
42
42
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -49,16 +49,65 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
49
49
  case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
50
50
  export LC_ALL=C
51
51
  # ---- /CREW-LOCALE
52
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
53
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
54
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
55
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
56
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
57
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
58
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
59
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
60
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
61
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
62
+ # that is not found (status 127, the script continues inside the same command).
63
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
64
+ _crew_stop(){ # $1 = what happened
65
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
66
+ 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
67
+ 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
68
+ exit 2
69
+ }
70
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
71
+ # ---- /CREW-FAILCLOSED
72
+ _gate_main(){
52
73
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
53
74
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
54
75
  [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
55
- INPUT="$(cat)"
76
+ # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
77
+ # of this hook. `read` returns 1 at end of input; the text is read regardless.
78
+ IFS= read -r -d '' INPUT || true
56
79
 
57
80
  # The two helpers below are a byte-identical copy of guard-bash.sh's block. A shared file would have to be
58
81
  # added to build-plugin.sh's explicit copy list and a miss there breaks the plugin channel silently — the same
59
82
  # reasoning as the CREW-TRANSCRIPT-DIR resolver, which is duplicated for the same reason. Two copies are only
60
83
  # safe while they cannot drift, so smoke-test pins these markers byte-identical rather than trusting it.
61
84
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
85
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
86
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
87
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
88
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
89
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
90
+ # 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
91
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
92
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
93
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
94
+ # taken from the whole text, and the small pieces from the block.
95
+ local LC_ALL=C
96
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
97
+ _JF=-1
98
+ while [ "$i" -lt "$n" ]; do
99
+ b="${1:i:B+kl-1}"
100
+ case "$b" in *"$2"*)
101
+ j=0; bn=${#b}
102
+ while [ "$j" -lt "$bn" ]; do
103
+ c="${b:j:C+kl-1}"
104
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
105
+ j=$((j+C))
106
+ done ;;
107
+ esac
108
+ i=$((i+B))
109
+ done
110
+ }
62
111
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
63
112
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
64
113
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -82,8 +131,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
82
131
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
83
132
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
84
133
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
85
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
86
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
134
+ # _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
87
135
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
88
136
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
89
137
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -107,7 +155,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
107
155
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
108
156
  # the battery sees both.
109
157
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
110
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
158
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
111
159
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
112
160
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
113
161
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -123,14 +171,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
123
171
  # raising or removing it later is not a tidy-up.
124
172
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
125
173
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
126
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
174
+ # 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
127
175
  # predates this change and belongs to the walk, not to the cap.
128
176
  local _cap=64 _seen=0
129
177
  while :; do
130
- pre="${hay%%"$k"*}" # everything before the next `"key"`
131
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
178
+ _json_find "$hay" "$k" # where the next `"key"` starts
179
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
132
180
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
133
- rest="${hay:${#pre}+${#k}}" # past `"key"`
181
+ rest="${hay:_JF+${#k}}" # past `"key"`
134
182
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
135
183
  case "$rest" in
136
184
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -170,7 +218,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
170
218
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
171
219
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
172
220
  #
173
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
221
+ # 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
174
222
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
175
223
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
176
224
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -222,7 +270,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
222
270
  # the gate cases.
223
271
  local LC_ALL=C
224
272
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
225
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
273
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
226
274
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
227
275
  while :; do
228
276
  lim=$((cl - j))
@@ -252,12 +300,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
252
300
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
253
301
  case "$h" in
254
302
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
303
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
304
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
305
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
255
306
  *) acc+=("?") ;;
256
307
  esac ;;
257
308
  *) acc+=("$c") ;;
258
309
  esac
259
310
  done
260
- local IFS=''; printf '%s' "${acc[*]}"
311
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
312
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
313
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
261
314
  }
262
315
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
263
316
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -290,16 +343,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
290
343
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
291
344
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
292
345
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
293
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
346
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
294
347
  _KC=0; _KC_CAPPED=0
295
348
  while :; do
296
- pre="${hay%%"$k"*}"
297
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
349
+ _json_find "$hay" "$k"
350
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
298
351
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
299
352
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
300
353
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
301
354
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
302
- rest="${hay:${#pre}+${#k}}"
355
+ rest="${hay:_JF+${#k}}"
303
356
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
304
357
  case "$rest" in :*) c=$((c+1)) ;; esac
305
358
  hay="$rest"
@@ -334,13 +387,15 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
334
387
  WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
335
388
  WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
336
389
  WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
390
+ WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
391
+ WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
337
392
  WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
338
393
  # Sized from the cost curve, not from PATH_MAX. Tier 3 walks the value character by character and bash string
339
394
  # append is O(n) each time, so the walk is quadratic: measured 0.09s at 512 raw bytes, 0.52s at 1,024, 3.7s at
340
395
  # 2,048, ~30s at 4,096. Real paths are nowhere near: measured on a Windows install, actual `file_path` values
341
396
  # average ~60 bytes and Windows itself stops at 260 without the long-path opt-in (macOS PATH_MAX is 1,024).
342
397
  # 2,048 therefore keeps every path anyone can really produce and bounds the worst case at a few seconds,
343
- # well inside this hook's 60s timeout — the number that matters, because a hook killed at its timeout emits
398
+ # well inside the 60s timeout this hook had then — the number that mattered, because a hook killed at its timeout emits
344
399
  # no exit 2 and the write proceeds.
345
400
  MAXPATH=2048
346
401
 
@@ -378,14 +433,15 @@ fi
378
433
  # Microsoft Store redirector stub named python3 on PATH by default, `command -v` finds it, it exits 49 with an
379
434
  # empty stdout, FP came back "" and `[ -z "$FP" ] && exit 0` let the model rewrite .claude/hooks/guard-bash.sh
380
435
  # with its Write tool. Pure parameter expansion cannot fail that way.
381
- _raw="$(_json_slice "$INPUT" file_path)"
382
- [ -n "$_raw" ] || _raw="$(_json_slice "$INPUT" notebook_path)"
436
+ # Through `_JS` / `_JU`, not `$( )`: no fork on the path every Write and Edit takes (3.1.0).
437
+ _json_slice "$INPUT" file_path >/dev/null; _raw="$_JS"
438
+ [ -n "$_raw" ] || { _json_slice "$INPUT" notebook_path >/dev/null; _raw="$_JS"; }
383
439
  # THE CAP GOES BEFORE THE UNESCAPER, and the cost it bounds is one the shared reader INTRODUCED — worth saying
384
440
  # plainly rather than dressing up as a pre-existing bug. What it replaced was a single `sed`, which is linear
385
441
  # and was never slow; it was replaced because it truncated the value at the first escaped quote and never
386
442
  # looked at `notebook_path`. The parser that fixes those walks character by character, which is quadratic in
387
443
  # bash, and on Windows every separator is a backslash, i.e. an escape, so the "no escapes" fast path never
388
- # fires. Uncapped, that is a gate with an off switch: a hook killed at its 60s timeout emits no exit 2 and the
444
+ # fires. Uncapped, that is a gate with an off switch: a hook killed at its timeout emits no exit 2 and the
389
445
  # write proceeds. Refusing above the cap is safe in the direction that matters, and the cap sits far above any
390
446
  # path a filesystem will accept.
391
447
  # ONE cap, not two. The second check used to follow the unescaper because the jq tier reached the fold with no
@@ -393,7 +449,7 @@ _raw="$(_json_slice "$INPUT" file_path)"
393
449
  # measured over 23 escape forms including a surrogate pair and a 4900-byte run of `€` across the chunk
394
450
  # edge, with a deliberately-growing stand-in as the calibration, so the check could be seen to fail.
395
451
  [ "${#_raw}" -le "$MAXPATH" ] || { FP="(oversized path: ${#_raw} bytes)"; block "gate-file edit (oversized path)" "$WHY_LONG"; }
396
- FP="$(_json_unescape "$_raw")"
452
+ _json_unescape "$_raw" >/dev/null; FP="$_JU"
397
453
 
398
454
  # Nothing extractable. Exiting 0 unconditionally is what a future field rename turns into a silent bypass, so
399
455
  # look at the RAW payload instead: refuse only when the text itself names a gate tree. A payload that mentions
@@ -401,7 +457,7 @@ FP="$(_json_unescape "$_raw")"
401
457
  # block on a file whose own path says `.claude/…hooks`, which is the trade this gate exists to make.
402
458
  if [ -z "$FP" ]; then
403
459
  case "$INPUT" in
404
- *.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
460
+ *.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*|*crewforth-approval*|*.git/config*|*.git\\config*|*git-shim*|*.gitconfig*|*.config/git/config*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
405
461
  esac
406
462
  exit 0
407
463
  fi
@@ -468,6 +524,9 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
468
524
  GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
469
525
  */.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*|.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*)
470
526
  GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
527
+ # .claude/git-shim is where core.hooksPath points when Crewforth shares the hooks with a project's own chain.
528
+ */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]/*|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]/*|*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm])
529
+ GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
471
530
  # The gates SOURCE eval/lib/crew-env.sh on every call, so it is part of them: overwritten with `exit 0`, every
472
531
  # rule stopped firing (3.0.1 review). Same file in the plugin edition, below.
473
532
  */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ee][Vv][Aa][Ll]/[Ll][Ii][Bb]/[Cc][Rr][Ee][Ww]-[Ee][Nn][Vv].[Ss][Hh]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ee][Vv][Aa][Ll]/[Ll][Ii][Bb]/[Cc][Rr][Ee][Ww]-[Ee][Nn][Vv].[Ss][Hh])
@@ -480,6 +539,20 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
480
539
  # `hooks/` directory is untouched.
481
540
  */[Hh][Oo][Oo][Kk][Ss]/[Gg][Uu][Aa][Rr][Dd]-*.[Ss][Hh]|*/[Hh][Oo][Oo][Kk][Ss]/[Ss][Ee][Ss][Ss][Ii][Oo][Nn]-[Gg][Uu][Aa][Rr][Dd].[Ss][Hh])
482
541
  GATE_RULE="gate-file edit (Crewforth gate script)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
542
+ # The record of the user's approval for a commit or a push (§4.4, hooks/prompt-approval.sh). It sits in the git
543
+ # directory and is written from the user's own message only; a session that writes it approves its own commit.
544
+ # Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
545
+ */[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
546
+ GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
547
+ esac
548
+ # core.hooksPath lives in git's configuration files: the repository's (.git/config, with a linked worktree's and a
549
+ # submodule's own) and the user's (~/.gitconfig, ~/.config/git/config). Writing one switches the git hooks off without
550
+ # touching a hook. guard-bash.sh carries the same list for the shell. The path gets a slash in front, so one pattern
551
+ # serves both `<dir>/.git/config` and a bare `.git/config`; matched by name, not against $HOME — on Windows the
552
+ # payload says C:\Users\… where the shell says /c/Users/…, and two spellings of one path never compare equal.
553
+ case "/$1" in
554
+ */.[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg].[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee]|*/.[Gg][Ii][Tt]/[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt]/[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg].[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee]|*/.[Gg][Ii][Tt]/[Mm][Oo][Dd][Uu][Ll][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt][Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Cc][Oo][Nn][Ff][Ii][Gg]/[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg])
555
+ GATE_RULE="git-config edit (Write/Edit tools)"; GATE_WHY="$WHY_GITCFG"; return 0 ;;
483
556
  esac
484
557
  # ...and not only the gate scripts it knew by name: everything under the plugin root's hooks/ (hooks.json, the
485
558
  # blocklists, the git hooks, every hook), .claude-plugin/ and the sourced crew-env.sh is the plugin's equivalent of
@@ -555,3 +628,6 @@ if [ -n "$GD" ] && [ -f "$GD/crew-board-guard" ]; then
555
628
  esac
556
629
  fi
557
630
  exit 0
631
+ }
632
+ _gate_main "$@"
633
+ _crew_stop "a command of the gate was abandoned"
@@ -0,0 +1,71 @@
1
+ # no-bash-guard.ps1 - the gate that runs when no other gate can.
2
+ #
3
+ # Crewforth's gates are bash scripts. On Windows, when Claude Code finds no Git Bash, a hook that names bash fails
4
+ # with "requires bash but Git Bash was not found" and exit 1 - which does not block - and the Bash tool does not
5
+ # exist at all: PowerShell is the shell, and nothing guards it (measured in the field, 3.0.1-rc.2).
6
+ # One hook in settings.json names no shell. Claude Code runs it through bash where it finds one, and that branch
7
+ # exits 0 without starting a process; where it finds none it runs it through PowerShell, and that branch loads this
8
+ # file. So this code runs only in the state it exists for.
9
+ #
10
+ # It is read with Get-Content and run with Invoke-Expression, never as a script file: the default execution policy
11
+ # on a Windows client refuses to run .ps1 files, and a gate that the policy switches off is not a gate.
12
+ #
13
+ # The lookup is Claude Code's own, in its order (read from its 2.1.284 binary; eval/lib/git-bash.sh holds the same
14
+ # order for the doctor): CLAUDE_CODE_GIT_BASH_PATH when its name is bash/sh and the file exists, the two default
15
+ # Git folders, then <git on PATH>\..\..\bin\bash.exe. A bash found here means Claude Code ran this hook through
16
+ # PowerShell for another reason, and the bash gates work: exit 0.
17
+ #
18
+ # Windows PowerShell 5.1 and PowerShell 7. ASCII only: 5.1 reads a file without a BOM in the ANSI code page.
19
+ # Paths are joined with `/` and passed through GetFullPath: Windows prints them with `\`, and the same code can be
20
+ # exercised by pwsh on Linux and macOS, where the smoke test runs it.
21
+ $ErrorActionPreference = 'Stop'
22
+
23
+ function Test-CrewBash([string]$p) {
24
+ if (-not $p) { return $false }
25
+ $n = [IO.Path]::GetFileName($p).ToLowerInvariant()
26
+ if (@('bash.exe', 'sh.exe', 'bash', 'sh') -notcontains $n) { return $false }
27
+ return [IO.File]::Exists($p)
28
+ }
29
+
30
+ $found = $null
31
+ if (Test-CrewBash $env:CLAUDE_CODE_GIT_BASH_PATH) { $found = $env:CLAUDE_CODE_GIT_BASH_PATH }
32
+ if (-not $found) {
33
+ foreach ($c in @('C:\Program Files\Git\bin\bash.exe', 'C:\Program Files (x86)\Git\bin\bash.exe')) {
34
+ if ([IO.File]::Exists($c)) { $found = $c; break }
35
+ }
36
+ }
37
+ if (-not $found) {
38
+ $g = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
39
+ if ($g) {
40
+ $d = Split-Path -Parent (Split-Path -Parent $g.Source)
41
+ if ($d) { $c = [IO.Path]::GetFullPath((Join-Path $d 'bin/bash.exe')); if ([IO.File]::Exists($c)) { $found = $c } }
42
+ }
43
+ }
44
+ if ($found) { exit 0 }
45
+
46
+ # Not found. Name the path to set when one exists on this machine - checked here, never guessed.
47
+ $fix = $null
48
+ $v = $env:CLAUDE_CODE_GIT_BASH_PATH
49
+ if ($v -and ([IO.Path]::GetFileName($v).ToLowerInvariant() -eq 'git-bash.exe')) {
50
+ $c = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $v) 'bin/bash.exe'))
51
+ if ([IO.File]::Exists($c)) { $fix = $c }
52
+ }
53
+ if (-not $fix -and $env:LOCALAPPDATA) {
54
+ $c = [IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'Programs/Git/bin/bash.exe'))
55
+ if ([IO.File]::Exists($c)) { $fix = $c }
56
+ }
57
+
58
+ $m = "GUARD: Crewforth's gates cannot run here, so this tool call is stopped." + [Environment]::NewLine
59
+ $m += "Claude Code found no Git Bash. Every Crewforth gate is a bash script: none of them started, and the rules they enforce (commit and push approval, destructive commands, secrets, the gate files) are not being checked." + [Environment]::NewLine
60
+ if ($v -and -not (Test-CrewBash $v)) {
61
+ $m += "CLAUDE_CODE_GIT_BASH_PATH is set to '$v', which Claude Code does not accept: it must name bash.exe and exist." + [Environment]::NewLine
62
+ }
63
+ if ($fix) {
64
+ $m += "Tell the user to set the user environment variable CLAUDE_CODE_GIT_BASH_PATH to `"$fix`"" + [Environment]::NewLine
65
+ } else {
66
+ $m += "Tell the user to install Git for Windows in its default folder, or to set the user environment variable CLAUDE_CODE_GIT_BASH_PATH to <Git>\bin\bash.exe" + [Environment]::NewLine
67
+ }
68
+ $m += "and then to close the terminal and Claude Code and open them again (an open terminal keeps the old value). /crew-doctor confirms it." + [Environment]::NewLine
69
+ $m += "Reading and searching still work. Do not look for another way to write or run commands: there is no gate behind it."
70
+ [Console]::Error.WriteLine($m)
71
+ exit 2
@@ -11,7 +11,7 @@ set -euo pipefail
11
11
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
12
12
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
13
13
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
14
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
14
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
15
15
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
16
16
  # staged key all passed.
17
17
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -463,7 +463,10 @@ PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$
463
463
  # Git Bash reports /c/Users/x for what Windows itself calls C:\Users\x — both spellings reach a file.
464
464
  case "$HOME" in
465
465
  /[a-zA-Z]/?*)
466
- d="$(printf '%s' "${HOME:1:1}" | tr 'a-z' 'A-Z')"; r="${HOME:3}"
466
+ # The drive letter, upper-cased with a builtin: `$(printf … | tr a-z A-Z)` was a subshell and a process on
467
+ # every commit, on the platform where a process costs most (bash 3.2 has no ${x^^}).
468
+ d="${HOME:1:1}"; r="${HOME:3}"
469
+ case "$d" in [a-z]) _lc=abcdefghijklmnopqrstuvwxyz; _uc=ABCDEFGHIJKLMNOPQRSTUVWXYZ; _i="${_lc%%"$d"*}"; d="${_uc:${#_i}:1}" ;; esac
467
470
  printf '%s\n' "$d:/$r" "$d:\\${r//\//\\}" ;;
468
471
  esac
469
472
  fi