crewforth 0.0.1 → 3.0.0-rc.2

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 (169) hide show
  1. package/CHANGELOG.md +3805 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1739 -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 +639 -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/skill-listing.awk +43 -0
  29. package/kit/eval/lib/star.sh +70 -0
  30. package/kit/eval/parser-conformance.sh +612 -0
  31. package/kit/eval/preflight.sh +186 -0
  32. package/kit/eval/routing-eval.sh +209 -0
  33. package/kit/eval/scan-skill.sh +162 -0
  34. package/kit/eval/smoke-test.sh +6736 -0
  35. package/kit/eval/update-guard.sh +81 -0
  36. package/kit/eval/utilization.sh +130 -0
  37. package/kit/hooks/board-sync.sh +106 -0
  38. package/kit/hooks/board.sh +962 -0
  39. package/kit/hooks/commit-msg +49 -0
  40. package/kit/hooks/context-usage.sh +353 -0
  41. package/kit/hooks/floor-blocklist.txt +124 -0
  42. package/kit/hooks/guard-bash.sh +1433 -0
  43. package/kit/hooks/guard-commit-scan.sh +511 -0
  44. package/kit/hooks/guard-write.sh +513 -0
  45. package/kit/hooks/pre-commit +577 -0
  46. package/kit/hooks/route-hint.sh +185 -0
  47. package/kit/hooks/secret-blocklist.txt +82 -0
  48. package/kit/hooks/session-guard.sh +149 -0
  49. package/kit/hooks/session-rehydrate.sh +41 -0
  50. package/kit/hooks/session-stats.sh +252 -0
  51. package/kit/hooks/session-update-check.sh +236 -0
  52. package/kit/hooks/skill-trust.sh +163 -0
  53. package/kit/hooks/trace-blocklist.txt +53 -0
  54. package/kit/settings.json +132 -0
  55. package/kit/skills/a11y/SKILL.md +44 -0
  56. package/kit/skills/adr/SKILL.md +74 -0
  57. package/kit/skills/api-design/SKILL.md +50 -0
  58. package/kit/skills/automode-policy/SKILL.md +91 -0
  59. package/kit/skills/automode-policy/references/policy.json +13 -0
  60. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  61. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  62. package/kit/skills/backend-architecture/SKILL.md +103 -0
  63. package/kit/skills/brainstorm/SKILL.md +47 -0
  64. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  65. package/kit/skills/commit-message/SKILL.md +82 -0
  66. package/kit/skills/commit-message/references/examples.md +8 -0
  67. package/kit/skills/confidence-check/SKILL.md +86 -0
  68. package/kit/skills/crew-board/SKILL.md +55 -0
  69. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  70. package/kit/skills/crew-code-review/SKILL.md +151 -0
  71. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  72. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  73. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  74. package/kit/skills/crew-doctor/SKILL.md +46 -0
  75. package/kit/skills/crew-gates/SKILL.md +25 -0
  76. package/kit/skills/crew-handoff/SKILL.md +14 -0
  77. package/kit/skills/crew-plan/SKILL.md +18 -0
  78. package/kit/skills/crew-review/SKILL.md +26 -0
  79. package/kit/skills/crew-ship/SKILL.md +19 -0
  80. package/kit/skills/crew-skill/SKILL.md +62 -0
  81. package/kit/skills/crew-studio/SKILL.md +129 -0
  82. package/kit/skills/crew-update/SKILL.md +43 -0
  83. package/kit/skills/db-migration/SKILL.md +120 -0
  84. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  85. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  86. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  87. package/kit/skills/dependency-audit/SKILL.md +61 -0
  88. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  89. package/kit/skills/deploy/SKILL.md +100 -0
  90. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  91. package/kit/skills/deploy/references/rollback.md +18 -0
  92. package/kit/skills/deploy/references/scripts.md +9 -0
  93. package/kit/skills/deploy/references/topology-a.md +128 -0
  94. package/kit/skills/deploy/references/topology-b.md +44 -0
  95. package/kit/skills/docs-writer/SKILL.md +53 -0
  96. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  97. package/kit/skills/eval-grader/SKILL.md +75 -0
  98. package/kit/skills/eval-grader/references/method.md +51 -0
  99. package/kit/skills/frontend/SKILL.md +56 -0
  100. package/kit/skills/frontend/references/verify-contract.md +41 -0
  101. package/kit/skills/frontend-design/SKILL.md +71 -0
  102. package/kit/skills/frontend-design/references/design-review.md +34 -0
  103. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  104. package/kit/skills/handoff/SKILL.md +71 -0
  105. package/kit/skills/handoff/references/cold-reader.md +51 -0
  106. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  107. package/kit/skills/incident-runbook/SKILL.md +40 -0
  108. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  109. package/kit/skills/iterate/SKILL.md +40 -0
  110. package/kit/skills/mcp-builder/SKILL.md +54 -0
  111. package/kit/skills/mcp-builder/references/building.md +71 -0
  112. package/kit/skills/observability/SKILL.md +50 -0
  113. package/kit/skills/performance/SKILL.md +38 -0
  114. package/kit/skills/performance/references/bottlenecks.md +10 -0
  115. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  116. package/kit/skills/red-team/SKILL.md +48 -0
  117. package/kit/skills/reflect/SKILL.md +57 -0
  118. package/kit/skills/release/SKILL.md +44 -0
  119. package/kit/skills/security-scan/SKILL.md +99 -0
  120. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  121. package/kit/skills/security-scan/references/fronts.md +99 -0
  122. package/kit/skills/security-scan/references/prompting.md +23 -0
  123. package/kit/skills/security-scan/references/reporting.md +75 -0
  124. package/kit/skills/security-scan/references/verify.md +131 -0
  125. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  126. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  127. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  128. package/kit/skills/spec-planning/SKILL.md +92 -0
  129. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  130. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  131. package/kit/skills/teamboard/SKILL.md +88 -0
  132. package/kit/skills/teamboard/references/setup.md +37 -0
  133. package/kit/skills/testing/SKILL.md +49 -0
  134. package/kit/skills/testing/references/flaky-triage.md +51 -0
  135. package/kit/skills/threat-model/SKILL.md +57 -0
  136. package/kit/skills/threat-model/references/interview.md +51 -0
  137. package/kit/skills/threat-model/references/schema.md +42 -0
  138. package/kit/skills/token-budget/SKILL.md +51 -0
  139. package/kit/skills/trace-scan/SKILL.md +43 -0
  140. package/kit/skills/worktree/SKILL.md +40 -0
  141. package/kit/studio/README.md +266 -0
  142. package/kit/studio/ensure-node.sh +471 -0
  143. package/kit/studio/package.json +14 -0
  144. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  145. package/kit/studio/server/index.js +902 -0
  146. package/kit/studio/server/lib/crew-env.js +11 -0
  147. package/kit/studio/server/lib/fleet.js +146 -0
  148. package/kit/studio/server/lib/graph.js +533 -0
  149. package/kit/studio/server/lib/instance.js +135 -0
  150. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  151. package/kit/studio/server/lib/kit.js +158 -0
  152. package/kit/studio/server/lib/palette.js +118 -0
  153. package/kit/studio/server/lib/peers.js +72 -0
  154. package/kit/studio/server/lib/permissions.js +150 -0
  155. package/kit/studio/server/lib/projects.js +294 -0
  156. package/kit/studio/server/lib/roster.js +191 -0
  157. package/kit/studio/server/lib/session.js +309 -0
  158. package/kit/studio/server/lib/terminal.js +91 -0
  159. package/kit/studio/server/lib/transcript.js +100 -0
  160. package/kit/studio/web/app.js +1098 -0
  161. package/kit/studio/web/canvas.js +1179 -0
  162. package/kit/studio/web/chat.js +543 -0
  163. package/kit/studio/web/index.html +86 -0
  164. package/kit/studio/web/md.js +110 -0
  165. package/kit/studio/web/storage-migrate.js +23 -0
  166. package/kit/studio/web/style.css +1453 -0
  167. package/package.json +46 -8
  168. package/start.sh +764 -0
  169. package/bin/crewforth.js +0 -10
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env bash
2
+ # Claude Code PreToolUse guard for the FILE tools (Write / Edit / NotebookEdit).
3
+ # Companion to guard-bash.sh: that one covers shell tampering, this one covers the model editing the gate
4
+ # scripts directly with its file tools. A gate you can silently rewrite is not a gate.
5
+ #
6
+ # stdin JSON: {"tool_name":"Write|Edit|...","tool_input":{"file_path":"...", ...}}
7
+ #
8
+ # HARD BLOCK (exit 2, every permission mode) when the target is a gate FILE:
9
+ # - .claude/hooks/* (guard-bash.sh, guard-write.sh, pre-commit, commit-msg, session-guard.sh, blocklists)
10
+ # - .git/hooks/* (the armed git hooks themselves)
11
+ # - .claude/DISCIPLINE.md (kit-owned, @imported every session, and the text of §4.1-§4.5 itself — the gates
12
+ # enforce those rules, so leaving the rules writable means the gates can be emptied without touching a gate)
13
+ # settings.json is deliberately NOT blocked: the update-config skill legitimately edits it, and a hook/permission
14
+ # change there is reviewable — the irreversible, silent move is rewriting the scripts, so that is what we gate.
15
+ #
16
+ # THE PATH IS NORMALISED BEFORE IT IS MATCHED, and that is the whole point of this file's second half.
17
+ # Until this version the gate compared the RAW string, so it recognised exactly one spelling of each gate path.
18
+ # Every line below was measured against the shipped hook and reached rc=0 — a SINGLE Write call, no shell
19
+ # access, no symlink, no second step:
20
+ # .claude/skills/../hooks/guard-bash.sh .claude//hooks/… .claude/./hooks/…
21
+ # .git/refs/../hooks/pre-commit C:\…\.claude\hooks\… (backslashes)
22
+ # The backslash row is a string fact, measured here: the matcher recognised `/` only, while five other hooks in
23
+ # Crewforth already fold Windows separators and this one did not. What a real Windows install actually puts in
24
+ # `file_path` is NOT measured on the machine this was written on and must not be assumed — it is verified on
25
+ # Windows. Folding both spellings is correct either way, which is why the fix does not wait for that answer.
26
+ # NotebookEdit was a sixth hole on any machine with neither jq nor python3 — the pre-tier-3 fallback read
27
+ # only `file_path`, and `notebook_path` is a different key.
28
+ # Claude Code passes file_path to the hook VERBATIM (measured: a `..` survives into the payload) while the
29
+ # filesystem resolves it, so the string the model writes and the file it opens are two different things.
30
+ # Normalise first, match second — and normalise with parameter expansion only, because this hook runs before
31
+ # EVERY Write/Edit and a fork per call is a freeze on Windows (Git Bash charges 62-135 ms per process, measured).
32
+ set -uo pipefail
33
+ # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
34
+ _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
35
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
36
+ INPUT="$(cat)"
37
+
38
+ # The two helpers below are a byte-identical copy of guard-bash.sh's block. A shared file would have to be
39
+ # added to build-plugin.sh's explicit copy list and a miss there breaks the plugin channel silently — the same
40
+ # reasoning as the CREW-TRANSCRIPT-DIR resolver, which is duplicated for the same reason. Two copies are only
41
+ # safe while they cannot drift, so smoke-test pins these markers byte-identical rather than trusting it.
42
+ # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
43
+ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
44
+ local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
45
+ # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
46
+ # them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
47
+ # continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
48
+ # quote or backslash edge. What this line is worth in SPEED depends on the platform --
49
+ # read the table in _json_unescape below before quoting a number for it.
50
+ 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=("")
51
+ # It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
52
+ # printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
53
+ # caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
54
+ _JS=""
55
+ # Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
56
+ # constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
57
+ # n-byte literal costs O(n^2). It was in this function twice, and both were measured:
58
+ # * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
59
+ # otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
60
+ # Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
61
+ # that version this cost was not reachable -- this file's header had shown the opposite order, and was
62
+ # wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
63
+ # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
64
+ # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
65
+ # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
66
+ # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
67
+ # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
68
+ # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
69
+ # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
70
+ # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
71
+ # {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
72
+ # {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
73
+ # {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
74
+ # In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
75
+ # the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
76
+ # aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
77
+ # and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
78
+ # Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
79
+ # whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
80
+ # _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
81
+ # time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
82
+ # -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
83
+ # chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
84
+ # 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
85
+ # Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
86
+ # bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
87
+ # a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
88
+ # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
89
+ # the battery sees both.
90
+ # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
91
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
92
+ # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
93
+ # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
94
+ # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
95
+ # 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
96
+ # Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
97
+ # real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
98
+ # over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
99
+ # reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
100
+ # THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
101
+ # protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
102
+ # decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
103
+ # roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
104
+ # raising or removing it later is not a tidy-up.
105
+ # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
106
+ # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
107
+ # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
108
+ # predates this change and belongs to the walk, not to the cap.
109
+ local _cap=64 _seen=0
110
+ while :; do
111
+ pre="${hay%%"$k"*}" # everything before the next `"key"`
112
+ [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
113
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
114
+ rest="${hay:${#pre}+${#k}}" # past `"key"`
115
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
116
+ case "$rest" in
117
+ :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
118
+ *) hay="$rest" ;; # a value, or another token: keep looking
119
+ esac
120
+ done
121
+ # THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
122
+ # shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
123
+ # payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
124
+ # refusal fire, which is the honest answer for a value these gates cannot read.
125
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
126
+ case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
127
+ # Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
128
+ n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
129
+ while :; do
130
+ lim=$((cl - j))
131
+ if [ "$lim" -le 0 ]; then
132
+ [ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
133
+ base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
134
+ fi
135
+ [ "$lim" -le "$W" ] || lim=$W
136
+ w="${chunk:j:lim}"
137
+ seg="${w%%\"*}"
138
+ if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
139
+ acc+=("$w"); j=$((j+lim))
140
+ r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
141
+ continue
142
+ fi
143
+ acc+=("$seg"); j=$((j+${#seg}+1))
144
+ r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
145
+ if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
146
+ done
147
+ local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
148
+ }
149
+ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
150
+ # This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
151
+ # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
152
+ # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
153
+ #
154
+ # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
155
+ # default, and reading the default instead is how a first pass at this got the consequence wrong. A
156
+ # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
157
+ # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
158
+ # not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
159
+ # is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
160
+ # §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
161
+ #
162
+ # Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
163
+ # this comment exists to avoid repeating:
164
+ # * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
165
+ # of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
166
+ # * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
167
+ # substring operation. What that is WORTH depends on the platform, and putting a single number here
168
+ # would have been wrong three separate ways -- it was written as "another 5x" twice before this:
169
+ # macOS / bash 3.2, a locale set .................. 5x
170
+ # Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
171
+ # touches the locale once per escape
172
+ # rather than once per character, so its
173
+ # ratio is smaller and unmeasured)
174
+ # Git Bash, LANG empty -- what Claude Code starts .. nothing at all
175
+ # So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
176
+ # below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
177
+ # alone would justify the line, and it costs nothing. `local` restores the previous locale on return
178
+ # -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
179
+ #
180
+ # Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
181
+ # whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
182
+ # command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
183
+ # (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
184
+ # pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
185
+ # while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
186
+ # for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
187
+ # `\uXXXX` that starts in a chunk ends in it.
188
+ # The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
189
+ # written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
190
+ #
191
+ # Measured, isolated, previous shape -> this one:
192
+ # macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
193
+ # sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
194
+ # Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
195
+ # (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
196
+ # These numbers do not travel, which is why each one names its machine.
197
+ #
198
+ # Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
199
+ # down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
200
+ # the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
201
+ # truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
202
+ # not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
203
+ # the gate cases.
204
+ local LC_ALL=C
205
+ local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
206
+ case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
207
+ n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
208
+ while :; do
209
+ lim=$((cl - j))
210
+ if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
211
+ lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
212
+ if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
213
+ else
214
+ [ "$lim" -gt 0 ] || break
215
+ fi
216
+ [ "$lim" -le "$W" ] || lim=$W
217
+ w="${chunk:j:lim}"
218
+ pre="${w%%\\*}" # the literal run before the next backslash in view
219
+ if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
220
+ acc+=("$pre"); j=$((j+${#pre}+1))
221
+ if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
222
+ c="${chunk:j:1}"; j=$((j+1))
223
+ case "$c" in
224
+ n) acc+=($'\n') ;;
225
+ t) acc+=($'\t') ;;
226
+ r) ;;
227
+ b|f) acc+=(" ") ;;
228
+ 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
229
+ # A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
230
+ # so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
231
+ # decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
232
+ # attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
233
+ # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
234
+ case "$h" in
235
+ 00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
236
+ *) acc+=("?") ;;
237
+ esac ;;
238
+ *) acc+=("$c") ;;
239
+ esac
240
+ done
241
+ local IFS=''; printf '%s' "${acc[*]}"
242
+ }
243
+ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
244
+ # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
245
+ # …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
246
+ # every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
247
+ # per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
248
+ # This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
249
+ # added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
250
+ # keeps the count in the caller's own shell: expansion only, zero forks.
251
+ # The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
252
+ # looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
253
+ # tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
254
+ # * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
255
+ # refusal went blind while the parser happily read the first value.
256
+ # * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
257
+ # above now closes; counting the same form here is what keeps the two from drifting apart again.
258
+ # Sharing ONE definition with the parser is the point: a guard that searches for something else than the
259
+ # thing it guards is the defect, not an implementation detail.
260
+ # WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
261
+ # with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
262
+ # counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
263
+ # token out of its escaped quote plus the string's terminator --
264
+ # {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
265
+ # which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
266
+ # harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
267
+ # {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
268
+ # What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
269
+ # occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
270
+ # payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
271
+ # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
272
+ # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
273
+ # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
274
+ local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
275
+ _KC=0; _KC_CAPPED=0
276
+ while :; do
277
+ pre="${hay%%"$k"*}"
278
+ [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
279
+ # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
280
+ # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
281
+ # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
282
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
283
+ rest="${hay:${#pre}+${#k}}"
284
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
285
+ case "$rest" in :*) c=$((c+1)) ;; esac
286
+ hay="$rest"
287
+ done
288
+ }
289
+ # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
290
+
291
+ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix — /crew-gates groups on it), $2 = why
292
+ # Same write-only observability channel as guard-bash.sh, on by default into .claude/gate-log.tsv since 2.5.0
293
+ # and with the same rule about the payload: the path is NOT recorded unless CREW_GATE_LOG_CMD=1, because
294
+ # /crew-gates reports rule names and counts and never the argument. Logged after the verdict; it cannot
295
+ # change it. CREW_GATE_LOG overrides the path; point it at /dev/null to turn recording off.
296
+ _GL="${CREW_GATE_LOG:-}"
297
+ if [ -z "$_GL" ] && [ -d ".claude" ]; then
298
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null; then
299
+ _GL=".claude/gate-log.tsv"
300
+ fi
301
+ fi
302
+ if [ -n "$_GL" ]; then
303
+ if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
304
+ printf 'BLOCK\t§4.5\t%s\t%s\n' "$1" \
305
+ "$(printf '%s' "$FP" | tr -d '\000-\037' | cut -c1-200)" >> "$_GL" 2>/dev/null
306
+ else
307
+ printf 'BLOCK\t§4.5\t%s\t\n' "$1" >> "$_GL" 2>/dev/null
308
+ fi
309
+ fi
310
+ echo "GUARD (§4.5): editing '$FP' is blocked AT THE TOOL LEVEL." >&2
311
+ echo "$2" >&2
312
+ echo "Crewforth updates go through the installer/update script, not the assistant's file tools. If the user explicitly wants it changed, they edit it in their own editor." >&2
313
+ exit 2
314
+ }
315
+ WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
316
+ 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."
317
+ 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."
318
+ 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."
319
+ # Sized from the cost curve, not from PATH_MAX. Tier 3 walks the value character by character and bash string
320
+ # 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
321
+ # 2,048, ~30s at 4,096. Real paths are nowhere near: measured on a Windows install, actual `file_path` values
322
+ # average ~60 bytes and Windows itself stops at 260 without the long-path opt-in (macOS PATH_MAX is 1,024).
323
+ # 2,048 therefore keeps every path anyone can really produce and bounds the worst case at a few seconds,
324
+ # well inside this hook's 60s timeout — the number that matters, because a hook killed at its timeout emits
325
+ # no exit 2 and the write proceeds.
326
+ MAXPATH=2048
327
+
328
+ # ---- extract ---------------------------------------------------------------------------------------------
329
+ # Tiers are selected on whether they WORK, not on whether they exist — see the long note in guard-bash.sh.
330
+ # Short version, measured on a stock Windows 11 desktop: Windows ships a Microsoft Store redirector stub named
331
+ # python3 on PATH by default, `command -v` finds it, it exits 49 with an empty stdout, FP came back "" and
332
+ # `[ -z "$FP" ] && exit 0` let the model rewrite .claude/hooks/guard-bash.sh with its Write tool.
333
+ # AN AMBIGUOUS TARGET IS REFUSED, and this hook needed it most: it is the one that stops the model rewriting
334
+ # the other gates with its file tools. The parser takes the FIRST occurrence of the key, so a harmless path
335
+ # placed EARLIER used to decide the verdict. Measured on the shipped hook, tier 3 (the only tier on a stock
336
+ # Windows desktop), against `.claude/hooks/guard-bash.sh` as the real target:
337
+ # {"meta":{"file_path":"/tmp/ok.txt"},…"tool_input":{"file_path":".claude/hooks/guard-bash.sh"}} rc=0
338
+ # {"tool_input":{"file_path":".claude/hooks/guard-bash.sh"}} rc=2
339
+ # The raw-payload net further down only fires when FP is EMPTY, and here FP was a plausible non-gate path, so
340
+ # nothing looked at the real one. Counted on the raw payload rather than per tier, deliberately: refusing on
341
+ # one tier while another reads a different occurrence just moves the divergence instead of closing it.
342
+ # Both counts read `_KC` instead of `$( )`: a command substitution is a fork, and this hook runs before every
343
+ # Write, Edit, MultiEdit and NotebookEdit call. Measured on macOS the substitution form cost 0.79 ms per call
344
+ # and took the whole hook from 13.81 to 15.49 ms; on Git Bash a process is 62-135 ms, so two of them per call
345
+ # would have been a freeze on the platform this change exists to protect.
346
+ _json_keycount "$INPUT" file_path; _n_fp=$_KC; _cap_p=$_KC_CAPPED
347
+ _json_keycount "$INPUT" notebook_path; _n_nb=$_KC; [ "$_KC_CAPPED" = 0 ] || _cap_p=$_KC_CAPPED
348
+ if [ "$_cap_p" != 0 ]; then
349
+ echo "GUARD (§4.5): this payload contains more than $_cap_p occurrences of a path key, so the scan for" >&2
350
+ echo "the real one was stopped. Refusing rather than reading whichever one it had reached." >&2
351
+ exit 2
352
+ elif [ $((_n_fp + _n_nb)) -gt 1 ]; then
353
+ echo "GUARD (§4.5): this payload carries $((_n_fp + _n_nb)) path keys (file_path/notebook_path), so the" >&2
354
+ echo "file to judge is ambiguous. Refusing rather than reading whichever comes first." >&2
355
+ exit 2
356
+ fi
357
+ # ONE READER, EVERYWHERE — the jq/python3 ladder that stood here is gone; the reasoning and the measurements
358
+ # are in guard-bash.sh next to the same change. What the ladder cost THIS hook specifically: Windows ships a
359
+ # Microsoft Store redirector stub named python3 on PATH by default, `command -v` finds it, it exits 49 with an
360
+ # empty stdout, FP came back "" and `[ -z "$FP" ] && exit 0` let the model rewrite .claude/hooks/guard-bash.sh
361
+ # with its Write tool. Pure parameter expansion cannot fail that way.
362
+ _raw="$(_json_slice "$INPUT" file_path)"
363
+ [ -n "$_raw" ] || _raw="$(_json_slice "$INPUT" notebook_path)"
364
+ # THE CAP GOES BEFORE THE UNESCAPER, and the cost it bounds is one the shared reader INTRODUCED — worth saying
365
+ # plainly rather than dressing up as a pre-existing bug. What it replaced was a single `sed`, which is linear
366
+ # and was never slow; it was replaced because it truncated the value at the first escaped quote and never
367
+ # looked at `notebook_path`. The parser that fixes those walks character by character, which is quadratic in
368
+ # bash, and on Windows every separator is a backslash, i.e. an escape, so the "no escapes" fast path never
369
+ # fires. Uncapped, that is a gate with an off switch: a hook killed at its 60s timeout emits no exit 2 and the
370
+ # write proceeds. Refusing above the cap is safe in the direction that matters, and the cap sits far above any
371
+ # path a filesystem will accept.
372
+ # ONE cap, not two. The second check used to follow the unescaper because the jq tier reached the fold with no
373
+ # unescaper in front of it; with one reader that is gone, and the unescaper only ever SHRINKS its input —
374
+ # measured over 23 escape forms including a surrogate pair and a 4900-byte run of `€` across the chunk
375
+ # edge, with a deliberately-growing stand-in as the calibration, so the check could be seen to fail.
376
+ [ "${#_raw}" -le "$MAXPATH" ] || { FP="(oversized path: ${#_raw} bytes)"; block "gate-file edit (oversized path)" "$WHY_LONG"; }
377
+ FP="$(_json_unescape "$_raw")"
378
+
379
+ # Nothing extractable. Exiting 0 unconditionally is what a future field rename turns into a silent bypass, so
380
+ # look at the RAW payload instead: refuse only when the text itself names a gate tree. A payload that mentions
381
+ # no gate path still passes, so a rename cannot lock anyone out of ordinary work — it can only cost a false
382
+ # block on a file whose own path says `.claude/…hooks`, which is the trade this gate exists to make.
383
+ if [ -z "$FP" ]; then
384
+ case "$INPUT" in
385
+ *.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
386
+ esac
387
+ exit 0
388
+ fi
389
+
390
+ # ---- normalise -------------------------------------------------------------------------------------------
391
+ # Windows separators first: Crewforth folds `\\` then `\` in five other hooks and this is the same idiom.
392
+ RP="${FP//\\\\//}"; RP="${RP//\\//}"; NP="$RP"
393
+ # Lexical resolution of `.`, `..` and repeated slashes. Lexical is the right kind here: it is what makes
394
+ # `.claude/skills/../hooks/x` and `.claude/hooks/x` the same string, it costs zero processes, and it cannot be
395
+ # defeated by a directory that does not exist yet (a `realpath` on an unborn path returns the input unchanged,
396
+ # which is exactly the hole this closes). Symlinks are the one thing lexical resolution gets wrong, and they
397
+ # are handled separately below.
398
+ _norm(){ # assigns NORM; NOT `NP="$(_norm …)"` — a command substitution is a
399
+ local p="$1" lead="" seg rest out="" # fork, and a fork per Write/Edit is the cost this whole file avoids
400
+ case "$p" in /*) lead="/" ;; esac
401
+ rest="$p"
402
+ while [ -n "$rest" ]; do
403
+ seg="${rest%%/*}"
404
+ if [ "$seg" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
405
+ case "$seg" in
406
+ ''|'.') continue ;;
407
+ '..')
408
+ if [ -n "$out" ] && [ "${out##*/}" != ".." ]; then
409
+ case "$out" in */*) out="${out%/*}" ;; *) out="" ;; esac
410
+ elif [ -z "$lead" ]; then
411
+ out="${out:+$out/}.." # relative path climbing above the cwd: keep it, it is not a gate path
412
+ fi
413
+ continue ;; # absolute path at the root: `/..` is `/`, so drop it
414
+ esac
415
+ # Trailing dots and spaces are stripped from every component, because Win32 strips them when it OPENS the
416
+ # file: `.claude./hooks/x` and `DISCIPLINE.md ` reach the same inode as the plain spelling there. The
417
+ # `DISCIPLINE.md` rule is an exact tail match with no trailing wildcard, so one trailing byte defeated it.
418
+ # This value is only ever compared, never written through, so a POSIX file genuinely named `foo.` is
419
+ # unaffected in every way except that it would be matched as `foo`.
420
+ while :; do case "$seg" in *.|*' ') seg="${seg%?}" ;; *) break ;; esac; done
421
+ [ -n "$seg" ] || continue
422
+ out="${out:+$out/}$seg"
423
+ done
424
+ NORM="$lead$out"
425
+ }
426
+ _norm "$NP"; NP="$NORM"
427
+
428
+ # ---- match -----------------------------------------------------------------------------------------------
429
+ # One place where "is this a gate file?" is answered, because it has to be asked twice — once on the path as
430
+ # written, once on the path as it resolves through a symlink.
431
+ #
432
+ # THE PATTERNS FOLD CASE. APFS and NTFS are case-insensitive by default, so `.CLAUDE/HOOKS/GUARD-BASH.SH` and
433
+ # `.claude/hooks/guard-bash.sh` are the SAME FILE — measured on this machine: identical inode, and a write
434
+ # through the uppercase spelling landed in the real gate script. The shell-side guard already folds case
435
+ # (`grep -i`), so the two guards disagreed on the same path. The bracket form needs no `shopt`, cannot leak
436
+ # into a later `case`, and works on bash 3.2.
437
+ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
438
+ case "$1" in
439
+ */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Hh][Oo][Oo][Kk][Ss]/*|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Hh][Oo][Oo][Kk][Ss]/*)
440
+ GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
441
+ */.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*|.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*)
442
+ GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
443
+ */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Dd][Ii][Ss][Cc][Ii][Pp][Ll][Ii][Nn][Ee].[Mm][Dd]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Dd][Ii][Ss][Cc][Ii][Pp][Ll][Ii][Nn][Ee].[Mm][Dd])
444
+ GATE_RULE="gate-file edit (discipline document)"; GATE_WHY="$WHY_DISC"; return 0 ;;
445
+ # The plugin edition keeps the SAME gate scripts at $CLAUDE_PLUGIN_ROOT/hooks/, which is not `.claude/hooks/`
446
+ # and so matched nothing above — one of Crewforth's four channels shipped an unguarded copy of its own gates.
447
+ # Matched by Crewforth's own filenames rather than by guessing a plugin path, so a project's unrelated
448
+ # `hooks/` directory is untouched.
449
+ */[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])
450
+ GATE_RULE="gate-file edit (Crewforth gate script)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
451
+ esac
452
+ return 1
453
+ }
454
+ _is_gate "$NP" && block "$GATE_RULE" "$GATE_WHY"
455
+
456
+ # The one thing lexical resolution cannot see: a symlinked ancestor. Two directions matter and only the second
457
+ # one is dangerous — a link INTO the config tree (`cfg -> .claude`, then write `cfg/hooks/guard-bash.sh`),
458
+ # which names no gate path at all and so passes every pattern above. Measured before this loop existed: rc=0,
459
+ # and the file really was overwritten. The reverse direction — a symlink ABOVE the project (`~/Projects ->
460
+ # /Volumes/…`, or plain `/tmp -> private/tmp` on macOS) — is routine and must stay allowed.
461
+ #
462
+ # So the walk runs for EVERY path (`[ -L ]` is a builtin: no process, and the loop is bounded), and only when
463
+ # an ancestor really is a symlink does it pay ONE fork to resolve it and ask the same question again about the
464
+ # real location. That fork is charged to the rare case instead of to every Write, which is what the hot-path
465
+ # budget requires; an ordinary repo pays nothing at all.
466
+ # The walk runs over RP — the folded path BEFORE `..` was collapsed — and that ordering is the rule, not a
467
+ # detail. Lexical `..` collapsing is only valid when no component before it is a symlink: with `c -> .claude/
468
+ # skills`, the written path `c/../hooks/guard-bash.sh` collapses to `hooks/guard-bash.sh` (no gate) while the
469
+ # filesystem resolves `c/..` through the link to `.claude`, landing on the real gate script. Collapsing first
470
+ # would delete the very component that has to be examined. Resolving from RP and re-normalising afterwards
471
+ # gets both: `<real>/.claude/skills` + `/../hooks/guard-bash.sh` normalises back onto the gate.
472
+ _anc="$RP"; _sfx=""; _depth=0
473
+ while case "$_anc" in */*) true ;; *) false ;; esac; do
474
+ _depth=$((_depth+1)); [ "$_depth" -gt 64 ] && break
475
+ _sfx="${_anc##*/}${_sfx:+/$_sfx}"
476
+ _anc="${_anc%/*}"
477
+ [ -n "$_anc" ] || break
478
+ if [ -L "$_anc" ]; then
479
+ _real="$(cd -P "$_anc" 2>/dev/null && pwd)"
480
+ if [ -n "$_real" ]; then
481
+ _norm "$_real/$_sfx"
482
+ _is_gate "$NORM" && { FP="$FP (resolves to $NORM)"; block "gate-file edit (symlinked ancestor)" "$WHY_LINK"; }
483
+ fi
484
+ break
485
+ fi
486
+ done
487
+
488
+ # ---- team board: you may not start work nobody knows you started -----------------------------------------------
489
+ # The claim lock already makes it impossible for two people to HOLD the same item — a losing claim is refused in
490
+ # under a second, before any code exists. The hole this closes is the other one: somebody who never claims at all.
491
+ # Caught only at commit time, that is hours of work discovered as duplicated at the end, which is exactly the
492
+ # wasted effort the board exists to prevent. So the first file edit is where it is caught instead.
493
+ #
494
+ # Cost: this runs before EVERY Write/Edit, so it must not shell out. board.sh maintains a one-bit flag file
495
+ # (present == a board exists, it requires a claim, and this user holds none); everything here is a file test.
496
+ [ -n "${CREW_NO_BOARD:-}" ] && exit 0
497
+ GD=".git"
498
+ [ -d "$GD" ] || GD="$(git rev-parse --git-common-dir 2>/dev/null)" # worktree/submodule: .git is a file
499
+ if [ -n "$GD" ] && [ -f "$GD/crew-board-guard" ]; then
500
+ case "$FP" in
501
+ */docs/*|docs/*|*/.claude/*|.claude/*) ;; # planning notes and kit config are not the work being claimed
502
+ *)
503
+ # /crew-board is user-only: name the command Claude can run itself, then the slash form for the user.
504
+ _gwd="${BASH_SOURCE%/*}"; _gwd="$(cd "$_gwd" 2>/dev/null && pwd)"
505
+ if [ -f "$_gwd/../.claude-plugin/plugin.json" ]; then _BB="bash \"$_gwd/board.sh\""; else _BB='bash .claude/hooks/board.sh'; fi
506
+ echo "BOARD GATE: you hold no work item, so nobody else can see what you are starting." >&2
507
+ echo "Claim one first: $_BB claim <id> — $_BB status lists what is free, what is blocked and who holds the rest (or the user can type /crew-board claim <id>)." >&2
508
+ echo "Work that belongs to no item: set CREW_NO_BOARD=1 for this session, and commit it with [chore]." >&2
509
+ echo "Just claimed one elsewhere? The board view is cached — $_BB sync refreshes it (or the user can type /crew-board sync)." >&2
510
+ exit 2 ;;
511
+ esac
512
+ fi
513
+ exit 0