crewforth 0.0.1 → 3.0.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 (168) hide show
  1. package/CHANGELOG.md +3784 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1670 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +465 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/star.sh +70 -0
  29. package/kit/eval/parser-conformance.sh +612 -0
  30. package/kit/eval/preflight.sh +186 -0
  31. package/kit/eval/routing-eval.sh +209 -0
  32. package/kit/eval/scan-skill.sh +162 -0
  33. package/kit/eval/smoke-test.sh +6693 -0
  34. package/kit/eval/update-guard.sh +81 -0
  35. package/kit/eval/utilization.sh +130 -0
  36. package/kit/hooks/board-sync.sh +106 -0
  37. package/kit/hooks/board.sh +962 -0
  38. package/kit/hooks/commit-msg +49 -0
  39. package/kit/hooks/context-usage.sh +353 -0
  40. package/kit/hooks/floor-blocklist.txt +124 -0
  41. package/kit/hooks/guard-bash.sh +1431 -0
  42. package/kit/hooks/guard-commit-scan.sh +511 -0
  43. package/kit/hooks/guard-write.sh +513 -0
  44. package/kit/hooks/pre-commit +577 -0
  45. package/kit/hooks/route-hint.sh +185 -0
  46. package/kit/hooks/secret-blocklist.txt +82 -0
  47. package/kit/hooks/session-guard.sh +149 -0
  48. package/kit/hooks/session-rehydrate.sh +41 -0
  49. package/kit/hooks/session-stats.sh +252 -0
  50. package/kit/hooks/session-update-check.sh +236 -0
  51. package/kit/hooks/skill-trust.sh +150 -0
  52. package/kit/hooks/trace-blocklist.txt +53 -0
  53. package/kit/settings.json +132 -0
  54. package/kit/skills/a11y/SKILL.md +44 -0
  55. package/kit/skills/adr/SKILL.md +74 -0
  56. package/kit/skills/api-design/SKILL.md +50 -0
  57. package/kit/skills/automode-policy/SKILL.md +91 -0
  58. package/kit/skills/automode-policy/references/policy.json +13 -0
  59. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  60. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  61. package/kit/skills/backend-architecture/SKILL.md +103 -0
  62. package/kit/skills/brainstorm/SKILL.md +47 -0
  63. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  64. package/kit/skills/commit-message/SKILL.md +82 -0
  65. package/kit/skills/commit-message/references/examples.md +8 -0
  66. package/kit/skills/confidence-check/SKILL.md +86 -0
  67. package/kit/skills/crew-board/SKILL.md +55 -0
  68. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  69. package/kit/skills/crew-code-review/SKILL.md +151 -0
  70. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  71. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  72. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  73. package/kit/skills/crew-doctor/SKILL.md +36 -0
  74. package/kit/skills/crew-gates/SKILL.md +25 -0
  75. package/kit/skills/crew-handoff/SKILL.md +14 -0
  76. package/kit/skills/crew-plan/SKILL.md +18 -0
  77. package/kit/skills/crew-review/SKILL.md +23 -0
  78. package/kit/skills/crew-ship/SKILL.md +19 -0
  79. package/kit/skills/crew-skill/SKILL.md +61 -0
  80. package/kit/skills/crew-studio/SKILL.md +129 -0
  81. package/kit/skills/crew-update/SKILL.md +43 -0
  82. package/kit/skills/db-migration/SKILL.md +120 -0
  83. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  84. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  85. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  86. package/kit/skills/dependency-audit/SKILL.md +61 -0
  87. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  88. package/kit/skills/deploy/SKILL.md +100 -0
  89. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  90. package/kit/skills/deploy/references/rollback.md +18 -0
  91. package/kit/skills/deploy/references/scripts.md +9 -0
  92. package/kit/skills/deploy/references/topology-a.md +128 -0
  93. package/kit/skills/deploy/references/topology-b.md +44 -0
  94. package/kit/skills/docs-writer/SKILL.md +53 -0
  95. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  96. package/kit/skills/eval-grader/SKILL.md +75 -0
  97. package/kit/skills/eval-grader/references/method.md +51 -0
  98. package/kit/skills/frontend/SKILL.md +56 -0
  99. package/kit/skills/frontend/references/verify-contract.md +41 -0
  100. package/kit/skills/frontend-design/SKILL.md +71 -0
  101. package/kit/skills/frontend-design/references/design-review.md +34 -0
  102. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  103. package/kit/skills/handoff/SKILL.md +71 -0
  104. package/kit/skills/handoff/references/cold-reader.md +51 -0
  105. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  106. package/kit/skills/incident-runbook/SKILL.md +40 -0
  107. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  108. package/kit/skills/iterate/SKILL.md +40 -0
  109. package/kit/skills/mcp-builder/SKILL.md +54 -0
  110. package/kit/skills/mcp-builder/references/building.md +71 -0
  111. package/kit/skills/observability/SKILL.md +50 -0
  112. package/kit/skills/performance/SKILL.md +38 -0
  113. package/kit/skills/performance/references/bottlenecks.md +10 -0
  114. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  115. package/kit/skills/red-team/SKILL.md +48 -0
  116. package/kit/skills/reflect/SKILL.md +57 -0
  117. package/kit/skills/release/SKILL.md +44 -0
  118. package/kit/skills/security-scan/SKILL.md +99 -0
  119. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  120. package/kit/skills/security-scan/references/fronts.md +99 -0
  121. package/kit/skills/security-scan/references/prompting.md +23 -0
  122. package/kit/skills/security-scan/references/reporting.md +75 -0
  123. package/kit/skills/security-scan/references/verify.md +131 -0
  124. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  125. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  126. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  127. package/kit/skills/spec-planning/SKILL.md +92 -0
  128. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  129. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  130. package/kit/skills/teamboard/SKILL.md +88 -0
  131. package/kit/skills/teamboard/references/setup.md +37 -0
  132. package/kit/skills/testing/SKILL.md +49 -0
  133. package/kit/skills/testing/references/flaky-triage.md +51 -0
  134. package/kit/skills/threat-model/SKILL.md +57 -0
  135. package/kit/skills/threat-model/references/interview.md +51 -0
  136. package/kit/skills/threat-model/references/schema.md +42 -0
  137. package/kit/skills/token-budget/SKILL.md +51 -0
  138. package/kit/skills/trace-scan/SKILL.md +43 -0
  139. package/kit/skills/worktree/SKILL.md +40 -0
  140. package/kit/studio/README.md +266 -0
  141. package/kit/studio/ensure-node.sh +471 -0
  142. package/kit/studio/package.json +14 -0
  143. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  144. package/kit/studio/server/index.js +902 -0
  145. package/kit/studio/server/lib/crew-env.js +11 -0
  146. package/kit/studio/server/lib/fleet.js +146 -0
  147. package/kit/studio/server/lib/graph.js +533 -0
  148. package/kit/studio/server/lib/instance.js +135 -0
  149. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  150. package/kit/studio/server/lib/kit.js +158 -0
  151. package/kit/studio/server/lib/palette.js +118 -0
  152. package/kit/studio/server/lib/peers.js +72 -0
  153. package/kit/studio/server/lib/permissions.js +150 -0
  154. package/kit/studio/server/lib/projects.js +294 -0
  155. package/kit/studio/server/lib/roster.js +191 -0
  156. package/kit/studio/server/lib/session.js +309 -0
  157. package/kit/studio/server/lib/terminal.js +91 -0
  158. package/kit/studio/server/lib/transcript.js +100 -0
  159. package/kit/studio/web/app.js +1098 -0
  160. package/kit/studio/web/canvas.js +1179 -0
  161. package/kit/studio/web/chat.js +543 -0
  162. package/kit/studio/web/index.html +86 -0
  163. package/kit/studio/web/md.js +110 -0
  164. package/kit/studio/web/storage-migrate.js +23 -0
  165. package/kit/studio/web/style.css +1453 -0
  166. package/package.json +46 -8
  167. package/start.sh +748 -0
  168. package/bin/crewforth.js +0 -10
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env bash
2
+ # The commit content gate, for installs that have no git hooks to put it in.
3
+ #
4
+ # §4.1/§4.2 (no AI trace, no vendor name) and the secret scan are enforced by `pre-commit` and `commit-msg`,
5
+ # which git runs via `core.hooksPath`. The PLUGIN edition cannot set that — a plugin ships Claude Code hooks,
6
+ # not git hooks — so a plugin-only install had the approval gate but none of the CONTENT gates: the model could
7
+ # commit a credential or an authorship trailer and nothing would look at it. Four distribution channels, one of
8
+ # them quietly weaker than the other three.
9
+ #
10
+ # This closes that by running the REAL scanners from PreToolUse, before the commit command executes. It does
11
+ # not re-implement them. A second matcher is how a gate passes while the thing it guards is broken — the same
12
+ # reasoning that made the blocklists carry their own test cases and made the eval graders reuse this very
13
+ # pattern file.
14
+ #
15
+ # In a FULL install this is harmless duplication: the git hooks still fire afterwards and catch the same
16
+ # content. Belt and braces on the strictest rules in Crewforth is a fair trade for the plugin edition no longer
17
+ # being the weak channel.
18
+ #
19
+ # Deliberately NOT covered: `--no-verify`. It is §4.5 and `guard-bash.sh` blocks it outright, so it never
20
+ # reaches this hook.
21
+ set -uo pipefail
22
+ DIR="$(cd "$(dirname "$0")" && pwd)"
23
+
24
+ # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
25
+ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
26
+ local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
27
+ # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
28
+ # them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
29
+ # continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
30
+ # quote or backslash edge. What this line is worth in SPEED depends on the platform --
31
+ # read the table in _json_unescape below before quoting a number for it.
32
+ local pre rest seg w r n base=0 j=0 cl lim run=0 chunk C=4096 W=256 hay="$1" k="\"$2\""; local -a acc=("")
33
+ # It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
34
+ # printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
35
+ # caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
36
+ _JS=""
37
+ # Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
38
+ # constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
39
+ # n-byte literal costs O(n^2). It was in this function twice, and both were measured:
40
+ # * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
41
+ # otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
42
+ # Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
43
+ # that version this cost was not reachable -- this file's header had shown the opposite order, and was
44
+ # wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
45
+ # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
46
+ # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
47
+ # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
48
+ # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
49
+ # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
50
+ # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
51
+ # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
52
+ # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
53
+ # {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
54
+ # {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
55
+ # {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
56
+ # In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
57
+ # the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
58
+ # aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
59
+ # and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
60
+ # Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
61
+ # whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
62
+ # _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
63
+ # time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
64
+ # -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
65
+ # chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
66
+ # 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
67
+ # Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
68
+ # bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
69
+ # a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
70
+ # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
71
+ # the battery sees both.
72
+ # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
73
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
74
+ # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
75
+ # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
76
+ # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
77
+ # 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
78
+ # Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
79
+ # real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
80
+ # over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
81
+ # reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
82
+ # THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
83
+ # protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
84
+ # decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
85
+ # roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
86
+ # raising or removing it later is not a tidy-up.
87
+ # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
88
+ # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
89
+ # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
90
+ # predates this change and belongs to the walk, not to the cap.
91
+ local _cap=64 _seen=0
92
+ while :; do
93
+ pre="${hay%%"$k"*}" # everything before the next `"key"`
94
+ [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
95
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
96
+ rest="${hay:${#pre}+${#k}}" # past `"key"`
97
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
98
+ case "$rest" in
99
+ :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
100
+ *) hay="$rest" ;; # a value, or another token: keep looking
101
+ esac
102
+ done
103
+ # THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
104
+ # shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
105
+ # payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
106
+ # refusal fire, which is the honest answer for a value these gates cannot read.
107
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
108
+ case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
109
+ # Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
110
+ n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
111
+ while :; do
112
+ lim=$((cl - j))
113
+ if [ "$lim" -le 0 ]; then
114
+ [ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
115
+ base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
116
+ fi
117
+ [ "$lim" -le "$W" ] || lim=$W
118
+ w="${chunk:j:lim}"
119
+ seg="${w%%\"*}"
120
+ if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
121
+ acc+=("$w"); j=$((j+lim))
122
+ r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
123
+ continue
124
+ fi
125
+ acc+=("$seg"); j=$((j+${#seg}+1))
126
+ r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
127
+ if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
128
+ done
129
+ local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
130
+ }
131
+ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
132
+ # This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
133
+ # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
134
+ # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
135
+ #
136
+ # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
137
+ # default, and reading the default instead is how a first pass at this got the consequence wrong. A
138
+ # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
139
+ # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
140
+ # not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
141
+ # is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
142
+ # §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
143
+ #
144
+ # Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
145
+ # this comment exists to avoid repeating:
146
+ # * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
147
+ # of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
148
+ # * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
149
+ # substring operation. What that is WORTH depends on the platform, and putting a single number here
150
+ # would have been wrong three separate ways -- it was written as "another 5x" twice before this:
151
+ # macOS / bash 3.2, a locale set .................. 5x
152
+ # Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
153
+ # touches the locale once per escape
154
+ # rather than once per character, so its
155
+ # ratio is smaller and unmeasured)
156
+ # Git Bash, LANG empty -- what Claude Code starts .. nothing at all
157
+ # So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
158
+ # below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
159
+ # alone would justify the line, and it costs nothing. `local` restores the previous locale on return
160
+ # -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
161
+ #
162
+ # Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
163
+ # whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
164
+ # command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
165
+ # (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
166
+ # pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
167
+ # while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
168
+ # for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
169
+ # `\uXXXX` that starts in a chunk ends in it.
170
+ # The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
171
+ # written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
172
+ #
173
+ # Measured, isolated, previous shape -> this one:
174
+ # macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
175
+ # sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
176
+ # Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
177
+ # (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
178
+ # These numbers do not travel, which is why each one names its machine.
179
+ #
180
+ # Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
181
+ # down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
182
+ # the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
183
+ # truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
184
+ # not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
185
+ # the gate cases.
186
+ local LC_ALL=C
187
+ local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
188
+ case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
189
+ n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
190
+ while :; do
191
+ lim=$((cl - j))
192
+ if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
193
+ lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
194
+ if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
195
+ else
196
+ [ "$lim" -gt 0 ] || break
197
+ fi
198
+ [ "$lim" -le "$W" ] || lim=$W
199
+ w="${chunk:j:lim}"
200
+ pre="${w%%\\*}" # the literal run before the next backslash in view
201
+ if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
202
+ acc+=("$pre"); j=$((j+${#pre}+1))
203
+ if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
204
+ c="${chunk:j:1}"; j=$((j+1))
205
+ case "$c" in
206
+ n) acc+=($'\n') ;;
207
+ t) acc+=($'\t') ;;
208
+ r) ;;
209
+ b|f) acc+=(" ") ;;
210
+ u) if [ $((n - base - j)) -ge 4 ]; then h="${chunk:j:4}"; j=$((j+4)); else h=""; fi # a short tail is left alone, as before
211
+ # A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
212
+ # so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
213
+ # decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
214
+ # attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
215
+ # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
216
+ case "$h" in
217
+ 00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
218
+ *) acc+=("?") ;;
219
+ esac ;;
220
+ *) acc+=("$c") ;;
221
+ esac
222
+ done
223
+ local IFS=''; printf '%s' "${acc[*]}"
224
+ }
225
+ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
226
+ # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
227
+ # …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
228
+ # every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
229
+ # per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
230
+ # This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
231
+ # added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
232
+ # keeps the count in the caller's own shell: expansion only, zero forks.
233
+ # The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
234
+ # looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
235
+ # tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
236
+ # * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
237
+ # refusal went blind while the parser happily read the first value.
238
+ # * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
239
+ # above now closes; counting the same form here is what keeps the two from drifting apart again.
240
+ # Sharing ONE definition with the parser is the point: a guard that searches for something else than the
241
+ # thing it guards is the defect, not an implementation detail.
242
+ # WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
243
+ # with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
244
+ # counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
245
+ # token out of its escaped quote plus the string's terminator --
246
+ # {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
247
+ # which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
248
+ # harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
249
+ # {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
250
+ # What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
251
+ # occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
252
+ # payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
253
+ # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
254
+ # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
255
+ # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
256
+ local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
257
+ _KC=0; _KC_CAPPED=0
258
+ while :; do
259
+ pre="${hay%%"$k"*}"
260
+ [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
261
+ # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
262
+ # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
263
+ # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
264
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
265
+ rest="${hay:${#pre}+${#k}}"
266
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
267
+ case "$rest" in :*) c=$((c+1)) ;; esac
268
+ hay="$rest"
269
+ done
270
+ }
271
+ # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
272
+
273
+ # Extract the -m/--message VALUES from the command line without an interpreter.
274
+ #
275
+ # Without this tier the fallback below hands the WHOLE command line to the message scanner, and a file path is
276
+ # not a message: `git commit -m "feat: add scaffolding" -- src/AI-generated/scaffold.ts` was refused with
277
+ # "TRACE-SCANNER (message): 'AI-generated'" while the message itself is clean. The same commit is ACCEPTED
278
+ # by a python3 shlex tier — which this file used to prefer where python ran — so the verdict depended on the
279
+ # machine, not on the commit. That tier is gone: this tokenizer is now the only one, on every machine.
280
+ #
281
+ # It is awk, not parameter expansion, and that is deliberate on a hook that already spawns ~49 processes for a
282
+ # commit: a pure-shell character walk was written first and MEASURED QUADRATIC — 146 B 0.006s,
283
+ # 4 KB 0.181s, 16 KB 1.03s, 64 KB 8.08s. That does NOT blow the 60 s timeout Crewforth sets, and the
284
+ # largest command payload seen across 6791 real Bash calls was 46.8 KB — so this is a tail-risk
285
+ # trade, not a rescue: awk is 0.049s flat to 64 KB and 0.585s at 1 MB, so the cost stops depending
286
+ # on what someone pasted into a commit message. One fork, on a path that already spawns ~49 and runs only
287
+ # for a `git commit`; the zero-fork rule still governs the per-Bash-call path.
288
+ # Byte-oriented under LC_ALL=C, which is safe because a UTF-8 continuation byte is always >= 0x80
289
+ # and can never equal an ASCII quote.
290
+ #
291
+ # Contract: rc=0 with the values, or rc=1 when the line cannot be parsed with confidence (unterminated quote,
292
+ # trailing backslash, an -m with nothing after it). rc=1 keeps the over-inclusive fallback, so this can only
293
+ # remove false positives — it can never open a blind spot. Verified against a POSIX shlex oracle written
294
+ # independently in another language: 37 command shapes, 33 byte-identical, 4 safe fallbacks, 0 mismatches,
295
+ # with the harness itself calibrated by two deliberate mutations.
296
+ # The one tokenizer: shell quoting rules (the subset shlex.split applies), then every value of the named option.
297
+ # $1 = command, $2 = short flag, $3 = long flag, $4 = 1 to stop at the first value.
298
+ crew_opt_values() {
299
+ CREW_CMD="$1" CREW_S="$2" CREW_L="$3" CREW_FIRST="${4:-0}" LC_ALL=C awk '
300
+ BEGIN {
301
+ s = ENVIRON["CREW_CMD"]; n = length(s); i = 1
302
+ ntok = 0; cur = ""; have = 0
303
+ while (i <= n) {
304
+ c = substr(s, i, 1)
305
+ if (c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\f" || c == "\v") {
306
+ if (have) { ntok++; tok[ntok] = cur; cur = ""; have = 0 }
307
+ i++; continue
308
+ }
309
+ if (c == "\\") { # outside quotes: escapes the next byte
310
+ i++
311
+ if (i > n) exit 1 # trailing backslash
312
+ cur = cur substr(s, i, 1); have = 1; i++; continue
313
+ }
314
+ if (c == "'"'"'") { # single quotes: literal to the next quote
315
+ i++; have = 1
316
+ while (1) {
317
+ if (i > n) exit 1 # no closing quote
318
+ c = substr(s, i, 1)
319
+ if (c == "'"'"'") { i++; break }
320
+ cur = cur c; i++
321
+ }
322
+ continue
323
+ }
324
+ if (c == "\"") { # double quotes: backslash escapes " and \
325
+ i++; have = 1
326
+ while (1) {
327
+ if (i > n) exit 1 # no closing quote
328
+ c = substr(s, i, 1)
329
+ if (c == "\"") { i++; break }
330
+ if (c == "\\" && i < n) {
331
+ nx = substr(s, i + 1, 1)
332
+ if (nx == "\"" || nx == "\\") { cur = cur nx; i += 2; continue }
333
+ }
334
+ cur = cur c; i++
335
+ }
336
+ continue
337
+ }
338
+ cur = cur c; have = 1; i++
339
+ }
340
+ if (have) { ntok++; tok[ntok] = cur }
341
+
342
+ S = ENVIRON["CREW_S"]; L = ENVIRON["CREW_L"]; first = ENVIRON["CREW_FIRST"] == "1"; LE = L "="
343
+ out = ""; nout = 0
344
+ for (k = 1; k <= ntok; k++) {
345
+ t = tok[k]
346
+ if (t == S || t == L) {
347
+ k++
348
+ if (k > ntok) exit 1 # -m with nothing after it: do not guess
349
+ nout++; res[nout] = tok[k]
350
+ } else if (substr(t, 1, length(LE)) == LE) {
351
+ nout++; res[nout] = substr(t, length(LE) + 1)
352
+ }
353
+ if (first && nout) break
354
+ }
355
+ for (k = 1; k <= nout; k++) printf "%s\n", res[k]
356
+ exit 0
357
+ }'
358
+ }
359
+ crew_msg_values() { crew_opt_values "$1" -m --message; }
360
+
361
+ INPUT="$(cat)"
362
+ # Same ladder as guard-bash.sh, and for the same reason: the raw-text fallback leaves JSON escapes in place,
363
+ # so `-m \"…\"` never matches a quote-based extraction and the message silently goes unscanned. That is
364
+ # precisely how the first version of this hook passed a commit carrying a co-author trailer.
365
+ # And the same rule as guard-bash.sh about WHICH rung is taken: a tier is chosen on whether it works, not on
366
+ # whether it exists. Windows ships a Store redirector stub named python3 on PATH by default; `command -v` finds
367
+ # it, it exits 49 with an empty stdout, and this hook then read CMD="" and exited 0 — the commit content scan
368
+ # never ran. Measured on a stock Windows 11 desktop. The extraction's own exit status is the probe.
369
+ # AN AMBIGUOUS COMMAND IS REFUSED HERE TOO, rather than leaning on guard-bash.sh doing it. That argument —
370
+ # "the other hook on the same matcher already refuses this" — was made in this very file and was wrong twice
371
+ # in one review round: for the value-form shadow BOTH hooks allowed, so §4.1/§4.2 and the secret scan never
372
+ # ran on a commit that carried a trailer. A content scanner that silently does not run is the exact failure
373
+ # `pre-commit` exists to prevent, and this hook is the plugin edition's only copy of it.
374
+ # `_KC`, not `$( )`: this hook runs before every Bash call and a command substitution is a fork. It is also
375
+ # deliberately BEFORE the `case "$CMD" in *git*` fast bail below, even though that means non-git commands pay
376
+ # for the count: an ambiguous payload's FIRST value can be `ls` while a `git commit` sits in the second key,
377
+ # and bailing on the first value is exactly how a content scan silently does not run. Fork-free, the count is
378
+ # expansion only, so paying it on every call is measured in microseconds rather than in processes.
379
+ _json_keycount "$INPUT" command; _n_cmd=$_KC
380
+ if [ "$_KC_CAPPED" != 0 ]; then
381
+ echo "GUARD: this payload contains more than $_KC_CAPPED occurrences of \"command\", so the scan for the" >&2
382
+ echo "real key was stopped. Refusing rather than scanning whichever one it had reached." >&2
383
+ exit 2
384
+ elif [ "$_n_cmd" -gt 1 ]; then
385
+ echo "GUARD: this payload carries $_n_cmd \"command\" keys, so the commit content to scan is ambiguous." >&2
386
+ echo "Refusing rather than scanning whichever comes first." >&2
387
+ exit 2
388
+ fi
389
+ # ONE READER, EVERYWHERE — the ladder is gone here too; the reasoning lives in guard-bash.sh next to the same
390
+ # change. Two things specific to THIS hook are worth keeping:
391
+ # * The value must be UNESCAPED, not raw. A raw-text extraction leaves JSON escapes in place, so `-m \"…\"`
392
+ # never matches a quote-based scan and the message silently goes unscanned. That is precisely how the
393
+ # first version of this hook passed a commit carrying a co-author trailer.
394
+ # * The Windows stub. `command -v python3` found the Microsoft Store redirector, it exited 49 with an empty
395
+ # stdout, this hook read CMD="" and exited 0, and the commit content scan never ran at all.
396
+ # The shared reader also replaced a `sed | head | sed` pipeline: four processes on EVERY Bash tool call, to
397
+ # re-derive a string guard-bash.sh had already parsed one hook earlier. Measured on `ls -la`: guard-bash.sh 2
398
+ # processes, this hook 7. With the ladder gone neither hook spawns a process to SELECT a reader.
399
+ CMD="$(_json_unescape "$(_json_slice "$INPUT" command)")"
400
+ [ -z "$CMD" ] && exit 0
401
+
402
+ # A command that does not contain `git` at all cannot match the pattern below, and finding that out
403
+ # should not cost a process. Measured on `ls -la`: this hook spawned 7 processes to answer "no".
404
+ # The test is case-INSENSITIVE while the pattern is not, so it is a superset: it can only let more
405
+ # through to the real matcher, never less.
406
+ case "$CMD" in *[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
407
+
408
+ # Only git commit. Matching mirrors guard-bash.sh's tolerance for `git -C dir commit`, TAB separators and a
409
+ # quoted binary, because a gate that a whitespace change walks past is not a gate.
410
+ printf '%s' "$CMD" | grep -qE '(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' || exit 0
411
+
412
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
413
+
414
+ # `git commit -a` has not staged anything yet at this point; tell the scanner to look at tracked-but-unstaged
415
+ # changes too. Matches -a, --all and clusters like -am.
416
+ UNSTAGED=0
417
+ printf '%s' "$CMD" | grep -qE '(^|[[:space:]])(--all|-[A-Za-z]*a[A-Za-z]*)([[:space:]]|$)' && UNSTAGED=1
418
+
419
+ FAILED=0
420
+ OUT=""
421
+ if [ -x "$DIR/pre-commit" ]; then
422
+ OUT="$(CREW_SCAN_UNSTAGED="$UNSTAGED" bash "$DIR/pre-commit" 2>&1)" || FAILED=1
423
+ fi
424
+
425
+ # The message carries its own trace risk (a co-author trailer lives there, not in the diff), and commit-msg is
426
+ # what scans it. Reuse it on the -m value when there is one; an editor-composed message is not visible here and
427
+ # stays the git hook's job.
428
+ if [ "$FAILED" = 0 ] && [ -x "$DIR/commit-msg" ]; then
429
+ # The message is tokenized the way a shell does (crew_opt_values), which matters because a real commit message
430
+ # is MULTI-LINE: a line-oriented `sed` extraction found the subject and stopped, so a co-author trailer on line
431
+ # 3 — the single most likely §4.1 violation, and the one the bare arm of the eval actually produced — went
432
+ # unscanned. When the tokenizer cannot parse the command, scan the whole command text instead of guessing
433
+ # where the message ends: over-inclusive beats a gate with a blind spot.
434
+ #
435
+ # But WHICH branch we are in cannot be decided from MSG's emptiness either, and that is the subtler half.
436
+ # "there is no -m" and "there is an -m but nothing here can extract it" are different facts with opposite
437
+ # correct answers: the first must fall through to the fail-closed editor/-F path below, the second must
438
+ # scan. Reading both off one empty string is what let a first attempt at this fix skip the fail-closed path
439
+ # on exactly the Windows machines it was written for. So ask the question directly, with a test that needs
440
+ # no interpreter. Cheap: nothing below runs unless the command is already known to be a `git commit`.
441
+ HAS_M=0
442
+ printf '%s' "$CMD" | grep -qE '(^|[[:space:]])(-[A-Za-z]*m|--message)([[:space:]]|=|$)' && HAS_M=1
443
+ # ONE tokenizer, awk, on every machine. A python3 `shlex` branch used to run first where python worked, so a
444
+ # Mac and a Windows box took different code through a gate. Measured before it was removed: on 18 `-m`
445
+ # shapes the awk tokenizer returned what shlex returned, 18/18. A command awk cannot tokenize (an unclosed
446
+ # quote, a -m with nothing after it) scans the whole command instead, which can only over-report.
447
+ if ! MSG="$(crew_msg_values "$CMD")"; then
448
+ MSG="$CMD"
449
+ fi
450
+ if [ "$HAS_M" = 1 ] && [ -n "$MSG" ]; then
451
+ MF="$(mktemp "${TMPDIR:-/tmp}/crew-msg.XXXXXX")"
452
+ printf '%s\n' "$MSG" > "$MF"
453
+ OUT="$OUT
454
+ $(bash "$DIR/commit-msg" "$MF" 2>&1)" || FAILED=1
455
+ rm -f "$MF"
456
+ else
457
+ # No -m: either the message comes from a file (-F/--file, which we CAN read) or from an editor, which does
458
+ # not exist yet at this point. In a full install the commit-msg git hook reads it afterwards and the gap
459
+ # closes itself. In a plugin-only install nothing does — and a co-authorship trailer lives in the message,
460
+ # not the diff, so that is precisely where §4.1 would be lost. Fail closed rather than wave it through:
461
+ # a gate that silently skips the case it was built for is worse than no gate, because it reads as covered.
462
+ # The same tokenizer finds -F/--file. The sed extraction it replaced stopped at the first space, so on the
463
+ # machines with no python it read `-F "my msg.txt"` as `my` — measured, 5 of 12 shapes wrong (quoted or
464
+ # spaced paths, a repeated -F, a Windows backslash path) — and the branch below then refused a CLEAN
465
+ # commit. 12/12 now equal what shlex returned.
466
+ #
467
+ # The LAST -F wins, because that is the file git reads: measured on git 2.54, `commit -F one -F two` commits
468
+ # two. Taking the first (what shlex-first-match did) let `-F clean.txt -F traced.txt` scan the clean file and
469
+ # commit the traced one — a §4.1 hole, found in review. And when the tokenizer cannot parse the line (a shape
470
+ # it does not model, e.g. ANSI-C `$'…'` quoting later in the command), a -F is still there: fall back to the
471
+ # greedy extraction (last match), and if even that names no readable file, refuse rather than skip.
472
+ if MFILE="$(crew_opt_values "$CMD" -F --file)"; then
473
+ MFILE="${MFILE##*$'\n'}"
474
+ else
475
+ MFILE="$(printf '%s' "$CMD" \
476
+ | sed -n 's/.*[[:space:]]--\{0,1\}[Ff]\(ile\)\{0,1\}[[:space:]=]\{1,\}\([^[:space:];&|]\{1,\}\).*/\2/p' | head -1)"
477
+ MFILE="${MFILE%\"}"; MFILE="${MFILE#\"}"; MFILE="${MFILE%\'}"; MFILE="${MFILE#\'}"
478
+ if printf '%s' "$CMD" | grep -qE '(^|[[:space:]])(-F|--file)([[:space:]=]|$)' && { [ -z "$MFILE" ] || [ ! -f "$MFILE" ]; }; then
479
+ echo "GUARD (§4.1): this commit reads its message from a file (-F), and the command could not be parsed" >&2
480
+ echo "well enough to know which file. Scan cannot run, so the commit is refused. Commit with a plain" >&2
481
+ echo "-F <path> (or -m) in a command of its own." >&2
482
+ exit 2
483
+ fi
484
+ fi
485
+ if [ -n "$MFILE" ] && [ -f "$MFILE" ]; then
486
+ OUT="$OUT
487
+ $(bash "$DIR/commit-msg" "$MFILE" 2>&1)" || FAILED=1
488
+ else
489
+ HP="$(git config core.hooksPath 2>/dev/null || true)"
490
+ GITMSG_HOOK=""
491
+ [ -n "$HP" ] && [ -x "$HP/commit-msg" ] && GITMSG_HOOK="$HP/commit-msg"
492
+ [ -z "$GITMSG_HOOK" ] && [ -x "$(git rev-parse --git-path hooks/commit-msg 2>/dev/null)" ] \
493
+ && GITMSG_HOOK="git-default"
494
+ if [ -z "$GITMSG_HOOK" ]; then
495
+ echo "GUARD (§4.1): this commit's message would go unscanned." >&2
496
+ echo "No -m/-F was given, so the message is composed in an editor after this point, and no commit-msg" >&2
497
+ echo "git hook is wired here to read it afterwards (plugin-only install: a plugin cannot set" >&2
498
+ echo "core.hooksPath). Pass the message with -m so it can be scanned, or install Crewforth fully." >&2
499
+ exit 2
500
+ fi
501
+ fi
502
+ fi
503
+ fi
504
+
505
+ if [ "$FAILED" = 1 ]; then
506
+ echo "GUARD (§4.1/§4.2): the commit content gate rejected this commit before it ran." >&2
507
+ echo "$OUT" >&2
508
+ echo "Fix the flagged content and commit again. --no-verify is §4.5 and stays blocked." >&2
509
+ exit 2
510
+ fi
511
+ exit 0