crewforth 3.4.0 → 3.5.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 (153) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +3 -2
  3. package/VERSION +1 -1
  4. package/adopt.sh +40 -5
  5. package/bin/cli.js +138 -2
  6. package/kit/AGENT_TEMPLATE.md +15 -0
  7. package/kit/CLAUDE.md +49 -17
  8. package/kit/agents/crew-backend-expert.md +4 -3
  9. package/kit/agents/crew-commit-agent.md +2 -0
  10. package/kit/agents/crew-database-expert.md +2 -1
  11. package/kit/agents/crew-devops-expert.md +1 -0
  12. package/kit/agents/crew-docs-agent.md +3 -1
  13. package/kit/agents/crew-frontend-expert.md +2 -0
  14. package/kit/agents/crew-performance-expert.md +7 -2
  15. package/kit/agents/crew-planner.md +15 -3
  16. package/kit/agents/crew-privacy-agent.md +2 -0
  17. package/kit/agents/crew-review-agent.md +8 -1
  18. package/kit/agents/crew-security-expert.md +4 -3
  19. package/kit/agents/crew-test-expert.md +6 -2
  20. package/kit/eval/doctor.sh +201 -1
  21. package/kit/eval/gate-report.sh +28 -0
  22. package/kit/eval/golden-holdout.txt +2 -0
  23. package/kit/eval/golden-routing.txt +17 -0
  24. package/kit/eval/lib/crew-map/contracts.mjs +77 -0
  25. package/kit/eval/lib/crew-map/crew-map.sh +2 -1
  26. package/kit/eval/lib/crew-map/detect.mjs +2 -1
  27. package/kit/eval/lib/crew-map/narrative.mjs +14 -3
  28. package/kit/eval/lib/crew-map/project.mjs +45 -6
  29. package/kit/eval/lib/crew-map/scan.mjs +153 -14
  30. package/kit/eval/lib/findings/findings.schema.json +11 -1
  31. package/kit/eval/lib/findings/findings.sh +24 -1
  32. package/kit/eval/lib/hook-inject.sh +159 -0
  33. package/kit/eval/lib/lockfiles.sh +71 -0
  34. package/kit/eval/lib/payload-check.sh +29 -0
  35. package/kit/eval/lib/preview.sh +3 -3
  36. package/kit/eval/lib/review-bench/bench.mjs +14 -2
  37. package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
  38. package/kit/eval/lib/settings-json.awk +18 -0
  39. package/kit/eval/lib/untrusted-text.awk +38 -0
  40. package/kit/eval/scan-skill.sh +67 -20
  41. package/kit/eval/smoke-test.sh +1700 -40
  42. package/kit/eval/utilization.sh +77 -3
  43. package/kit/hooks/agent-outcome.sh +9 -1
  44. package/kit/hooks/board-sync.sh +49 -3
  45. package/kit/hooks/board.sh +8 -8
  46. package/kit/hooks/branch-fresh.sh +101 -0
  47. package/kit/hooks/floor-blocklist.txt +19 -1
  48. package/kit/hooks/guard-bash.sh +92 -8
  49. package/kit/hooks/guard-question.sh +2 -1
  50. package/kit/hooks/loop-guard.sh +445 -0
  51. package/kit/hooks/map-stale.sh +437 -0
  52. package/kit/hooks/pre-commit +48 -7
  53. package/kit/hooks/precompact-snapshot.sh +102 -6
  54. package/kit/hooks/prompt-approval.sh +45 -9
  55. package/kit/hooks/route-hint.sh +36 -7
  56. package/kit/hooks/secret-blocklist.txt +55 -1
  57. package/kit/hooks/session-guard.sh +45 -15
  58. package/kit/hooks/session-rehydrate.sh +34 -11
  59. package/kit/hooks/session-stats.sh +169 -8
  60. package/kit/hooks/session-update-check.sh +5 -1
  61. package/kit/hooks/skill-trust.sh +23 -14
  62. package/kit/hooks/turn-check.sh +190 -0
  63. package/kit/owned-blobs.tsv +4 -4
  64. package/kit/settings.json +64 -0
  65. package/kit/skills/a11y/SKILL.md +50 -3
  66. package/kit/skills/a11y/references/recipes.md +41 -0
  67. package/kit/skills/adr/SKILL.md +14 -2
  68. package/kit/skills/api-design/SKILL.md +6 -1
  69. package/kit/skills/api-design/references/idempotency.md +39 -0
  70. package/kit/skills/brainstorm/SKILL.md +4 -2
  71. package/kit/skills/brainstorm/references/asking.md +47 -0
  72. package/kit/skills/ci-pipeline/SKILL.md +11 -1
  73. package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
  74. package/kit/skills/commit-message/SKILL.md +9 -3
  75. package/kit/skills/confidence-check/SKILL.md +9 -7
  76. package/kit/skills/confidence-check/references/source-check.md +37 -0
  77. package/kit/skills/crew-approve/SKILL.md +3 -0
  78. package/kit/skills/crew-audit/SKILL.md +63 -0
  79. package/kit/skills/crew-board/SKILL.md +2 -2
  80. package/kit/skills/crew-code-review/SKILL.md +27 -4
  81. package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
  82. package/kit/skills/crew-code-review/references/simplify.md +28 -0
  83. package/kit/skills/crew-doctor/SKILL.md +3 -2
  84. package/kit/skills/crew-gates/SKILL.md +3 -0
  85. package/kit/skills/crew-gates/references/enforcers.md +30 -0
  86. package/kit/skills/crew-interview/SKILL.md +42 -0
  87. package/kit/skills/crew-map/SKILL.md +26 -6
  88. package/kit/skills/crew-plan/SKILL.md +9 -2
  89. package/kit/skills/crew-pr/SKILL.md +57 -0
  90. package/kit/skills/crew-pr/references/checks.md +35 -0
  91. package/kit/skills/crew-pr/references/threads.md +23 -0
  92. package/kit/skills/crew-review/SKILL.md +34 -1
  93. package/kit/skills/crew-ship/SKILL.md +5 -2
  94. package/kit/skills/crew-skill/SKILL.md +26 -1
  95. package/kit/skills/crew-update/SKILL.md +4 -1
  96. package/kit/skills/db-migration/SKILL.md +15 -0
  97. package/kit/skills/db-migration/references/expand-contract.md +52 -0
  98. package/kit/skills/dependency-audit/SKILL.md +34 -1
  99. package/kit/skills/dependency-audit/scripts/record.sh +37 -0
  100. package/kit/skills/dependency-upgrade/SKILL.md +29 -1
  101. package/kit/skills/deploy/SKILL.md +18 -0
  102. package/kit/skills/deploy/references/topology-a.md +7 -0
  103. package/kit/skills/deploy/references/topology-b.md +2 -2
  104. package/kit/skills/docs-writer/SKILL.md +37 -2
  105. package/kit/skills/eval-grader/SKILL.md +9 -0
  106. package/kit/skills/eval-grader/references/method.md +26 -0
  107. package/kit/skills/frontend/SKILL.md +5 -2
  108. package/kit/skills/frontend/references/from-design.md +29 -0
  109. package/kit/skills/frontend/references/page-metadata.md +17 -0
  110. package/kit/skills/frontend/references/verify-contract.md +18 -1
  111. package/kit/skills/frontend/references/worst-data.md +35 -0
  112. package/kit/skills/frontend-design/SKILL.md +20 -4
  113. package/kit/skills/frontend-design/references/design-review.md +21 -0
  114. package/kit/skills/frontend-design/references/detection.md +44 -0
  115. package/kit/skills/frontend-design/references/motion.md +54 -0
  116. package/kit/skills/frontend-design/references/ui-copy.md +35 -0
  117. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  118. package/kit/skills/handoff/SKILL.md +28 -3
  119. package/kit/skills/handoff/references/cold-reader.md +13 -6
  120. package/kit/skills/i18n-integrity/SKILL.md +3 -0
  121. package/kit/skills/iterate/SKILL.md +7 -2
  122. package/kit/skills/mcp-builder/SKILL.md +21 -4
  123. package/kit/skills/mcp-builder/references/building.md +74 -13
  124. package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
  125. package/kit/skills/observability/SKILL.md +13 -1
  126. package/kit/skills/performance/SKILL.md +25 -1
  127. package/kit/skills/performance/references/bottlenecks.md +11 -0
  128. package/kit/skills/performance/references/web-vitals.md +31 -0
  129. package/kit/skills/red-team/SKILL.md +25 -3
  130. package/kit/skills/reflect/SKILL.md +7 -2
  131. package/kit/skills/release/SKILL.md +13 -0
  132. package/kit/skills/security-scan/SKILL.md +6 -2
  133. package/kit/skills/security-scan/references/ai-agents.md +16 -0
  134. package/kit/skills/security-scan/references/fronts.md +59 -2
  135. package/kit/skills/security-scan/references/reporting.md +13 -0
  136. package/kit/skills/security-scan/references/verify.md +9 -0
  137. package/kit/skills/sonarqube-check/SKILL.md +32 -2
  138. package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
  139. package/kit/skills/spec-planning/SKILL.md +25 -8
  140. package/kit/skills/systematic-debugging/SKILL.md +15 -7
  141. package/kit/skills/systematic-debugging/references/techniques.md +5 -0
  142. package/kit/skills/teamboard/SKILL.md +3 -3
  143. package/kit/skills/testing/SKILL.md +18 -3
  144. package/kit/skills/testing/references/browser-headless.md +21 -0
  145. package/kit/skills/token-budget/SKILL.md +26 -5
  146. package/kit/skills/worktree/SKILL.md +13 -5
  147. package/kit/studio/server/lib/session.js +23 -2
  148. package/kit/studio/server/lib/worktree-changes.js +64 -0
  149. package/kit/studio/web/convo.js +14 -0
  150. package/kit/studio/web/map-plan.js +54 -8
  151. package/kit/studio/web/map.js +15 -3
  152. package/package.json +5 -4
  153. package/start.sh +11 -2
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env bash
2
+ # loop-guard.sh - notices a turn that has stopped converging: many calls, the same files edited again and again,
3
+ # and the same failure coming back. Shipped in SHADOW mode: it records that it would have spoken, and says nothing.
4
+ #
5
+ # WHY. systematic-debugging step 3 asks for one falsifiable hypothesis before the next change. A session that has
6
+ # lost the thread does the opposite: it edits, runs, sees the same error, edits again. Nothing in that loop is
7
+ # wrong call by call, so no gate sees it; only the shape of the turn does. This hook counts the shape.
8
+ #
9
+ # WIRING (settings.json / hooks.json): PostToolUse and PostToolUseFailure, matcher Bash|Edit|Write|MultiEdit|NotebookEdit.
10
+ # PostToolUse a call that finished: counted; an edit is counted against its file.
11
+ # PostToolUseFailure a call that failed (Claude Code's own event; a Bash command that exits non-zero arrives here
12
+ # with its `error` text): counted as a call, and as an error under a normalised signature.
13
+ # An interrupt (`is_interrupt: true`) is the user's decision, not a failure, and is not an error.
14
+ #
15
+ # THE TURN. Counters belong to one user prompt. Every hook payload carries `prompt_id` (Claude Code's common input
16
+ # field, "UUID identifying the user prompt currently being processed"), so a new prompt is seen here as a changed
17
+ # id and the counters start again. No UserPromptSubmit hook is added for it: that would be one more process on every
18
+ # prompt to learn what the next tool call already says. A payload without `prompt_id` (an older Claude Code, or a
19
+ # call before the first prompt) keeps the counters it has: the window is then the session, and the record says so.
20
+ # Subagents count separately: their calls carry `agent_id`, and the state file is named by session and agent.
21
+ #
22
+ # FIRES when ALL of these hold, once per turn:
23
+ # calls >= 20 AND (one file edited >= 4 times OR edits across >= 3 files) AND errors >= 6
24
+ # AND the most frequent error signature is >= 20% of the errors and was seen at least twice.
25
+ # The signature is the first 400 bytes of the error with paths, hex and runs of digits collapsed, so the same failure
26
+ # at another line number or in another temp folder is the same signature.
27
+ #
28
+ # MODES (CREW_LOOP_GUARD):
29
+ # unset / shadow one JSON line is appended to .claude/state/loop-guard.jsonl: the time, the session, the counters.
30
+ # Never a command, a path or error text. Nothing reaches the session.
31
+ # on the redirect below is returned as additionalContext instead (for the A/B that measures it).
32
+ # off nothing runs and no state is written.
33
+ #
34
+ # NOT A GATE. It never blocks and never asks: every path out is exit 0, and a failure of its own is silence.
35
+ #
36
+ # COST. It runs after EVERY Bash, Edit and Write call, and on Git Bash a process costs 20-130 ms. A call that does
37
+ # not fire starts no process: the payload is read with the builtin `read` and Crewforth's builtin JSON parser below, the
38
+ # state is read with `read` and written with a redirection, the hashes are arithmetic. The first call in a project
39
+ # may pay one `mkdir`; a firing call in shadow mode may pay one more `mkdir` and, on bash < 4.2, one `date`.
40
+ # Two calls finishing at the same moment can lose one count between them: the state is rewritten without a lock,
41
+ # because a lock costs processes on every call and an undercount only makes this quieter.
42
+ LG_REDIRECT="Do not stop; form one new falsifiable hypothesis (systematic-debugging step 3) and test it before the next edit."
43
+ LG_CALLS=20; LG_FILE_EDITS=4; LG_FILES=3; LG_ERRORS=6; LG_SIG_PCT=20
44
+ case "${CREW_LOOP_GUARD:-shadow}" in off|OFF|0|false) exit 0 ;; on|ON|1|true) LG_MODE=on ;; *) LG_MODE=shadow ;; esac
45
+ export LC_ALL=C
46
+ trap 'exit 0' EXIT
47
+ IFS= read -r -d '' INPUT || true
48
+ # The fields this reads sit near the front of a payload; a Write's content after them is not needed.
49
+ INPUT="${INPUT:0:65536}"
50
+
51
+ # The JSON helpers are a byte-identical copy of the gates' block (smoke-test pins every copy equal).
52
+ # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
53
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
54
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
55
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
56
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
57
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
58
+ # counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
59
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
60
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
61
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
62
+ # taken from the whole text, and the small pieces from the block.
63
+ local LC_ALL=C
64
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
65
+ _JF=-1
66
+ while [ "$i" -lt "$n" ]; do
67
+ b="${1:i:B+kl-1}"
68
+ case "$b" in *"$2"*)
69
+ j=0; bn=${#b}
70
+ while [ "$j" -lt "$bn" ]; do
71
+ c="${b:j:C+kl-1}"
72
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
73
+ j=$((j+C))
74
+ done ;;
75
+ esac
76
+ i=$((i+B))
77
+ done
78
+ }
79
+ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
80
+ local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
81
+ # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
82
+ # them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
83
+ # continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
84
+ # quote or backslash edge. What this line is worth in SPEED depends on the platform --
85
+ # read the table in _json_unescape below before quoting a number for it.
86
+ 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=("")
87
+ # It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
88
+ # printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
89
+ # caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
90
+ _JS=""
91
+ # Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
92
+ # constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
93
+ # n-byte literal costs O(n^2). It was in this function twice, and both were measured:
94
+ # * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
95
+ # otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
96
+ # Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
97
+ # that version this cost was not reachable -- this file's header had shown the opposite order, and was
98
+ # wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
99
+ # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
100
+ # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
101
+ # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
102
+ # _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
103
+ # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
104
+ # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
105
+ # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
106
+ # {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
107
+ # {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
108
+ # {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
109
+ # In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
110
+ # the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
111
+ # aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
112
+ # and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
113
+ # Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
114
+ # whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
115
+ # _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
116
+ # time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
117
+ # -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
118
+ # chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
119
+ # 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
120
+ # Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
121
+ # bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
122
+ # a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
123
+ # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
124
+ # the battery sees both.
125
+ # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
126
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
127
+ # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
128
+ # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
129
+ # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
130
+ # 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
131
+ # Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
132
+ # real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
133
+ # over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
134
+ # reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
135
+ # THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
136
+ # protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
137
+ # decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
138
+ # roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
139
+ # raising or removing it later is not a tidy-up.
140
+ # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
141
+ # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
142
+ # both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
143
+ # predates this change and belongs to the walk, not to the cap.
144
+ local _cap=64 _seen=0
145
+ while :; do
146
+ _json_find "$hay" "$k" # where the next `"key"` starts
147
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
148
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
149
+ rest="${hay:_JF+${#k}}" # past `"key"`
150
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
151
+ case "$rest" in
152
+ :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
153
+ *) hay="$rest" ;; # a value, or another token: keep looking
154
+ esac
155
+ done
156
+ # THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
157
+ # shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
158
+ # payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
159
+ # refusal fire, which is the honest answer for a value these gates cannot read.
160
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
161
+ case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
162
+ # Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
163
+ n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
164
+ while :; do
165
+ lim=$((cl - j))
166
+ if [ "$lim" -le 0 ]; then
167
+ [ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
168
+ base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
169
+ fi
170
+ [ "$lim" -le "$W" ] || lim=$W
171
+ w="${chunk:j:lim}"
172
+ seg="${w%%\"*}"
173
+ if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
174
+ acc+=("$w"); j=$((j+lim))
175
+ r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
176
+ continue
177
+ fi
178
+ acc+=("$seg"); j=$((j+${#seg}+1))
179
+ r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
180
+ if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
181
+ done
182
+ local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
183
+ }
184
+ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
185
+ # This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
186
+ # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
187
+ # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
188
+ #
189
+ # That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
190
+ # default, and reading the default instead is how a first pass at this got the consequence wrong. A
191
+ # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
192
+ # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
193
+ # not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
194
+ # is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
195
+ # §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
196
+ #
197
+ # Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
198
+ # this comment exists to avoid repeating:
199
+ # * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
200
+ # of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
201
+ # * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
202
+ # substring operation. What that is WORTH depends on the platform, and putting a single number here
203
+ # would have been wrong three separate ways -- it was written as "another 5x" twice before this:
204
+ # macOS / bash 3.2, a locale set .................. 5x
205
+ # Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
206
+ # touches the locale once per escape
207
+ # rather than once per character, so its
208
+ # ratio is smaller and unmeasured)
209
+ # Git Bash, LANG empty -- what Claude Code starts .. nothing at all
210
+ # So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
211
+ # below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
212
+ # alone would justify the line, and it costs nothing. `local` restores the previous locale on return
213
+ # -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
214
+ #
215
+ # Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
216
+ # whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
217
+ # command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
218
+ # (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
219
+ # pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
220
+ # while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
221
+ # for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
222
+ # `\uXXXX` that starts in a chunk ends in it.
223
+ # The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
224
+ # written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
225
+ #
226
+ # Measured, isolated, previous shape -> this one:
227
+ # macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
228
+ # sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
229
+ # Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
230
+ # (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
231
+ # These numbers do not travel, which is why each one names its machine.
232
+ #
233
+ # Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
234
+ # down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
235
+ # the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
236
+ # truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
237
+ # not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
238
+ # the gate cases.
239
+ local LC_ALL=C
240
+ local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
241
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
242
+ n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
243
+ while :; do
244
+ lim=$((cl - j))
245
+ if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
246
+ lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
247
+ if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
248
+ else
249
+ [ "$lim" -gt 0 ] || break
250
+ fi
251
+ [ "$lim" -le "$W" ] || lim=$W
252
+ w="${chunk:j:lim}"
253
+ pre="${w%%\\*}" # the literal run before the next backslash in view
254
+ if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
255
+ acc+=("$pre"); j=$((j+${#pre}+1))
256
+ if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
257
+ c="${chunk:j:1}"; j=$((j+1))
258
+ case "$c" in
259
+ n) acc+=($'\n') ;;
260
+ t) acc+=($'\t') ;;
261
+ r) ;;
262
+ b|f) acc+=(" ") ;;
263
+ 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
264
+ # A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
265
+ # so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
266
+ # decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
267
+ # attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
268
+ # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
269
+ case "$h" in
270
+ 00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
271
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
272
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
273
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
274
+ *) acc+=("?") ;;
275
+ esac ;;
276
+ *) acc+=("$c") ;;
277
+ esac
278
+ done
279
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
280
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
281
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
282
+ }
283
+ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
284
+ # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
285
+ # …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
286
+ # every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
287
+ # per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
288
+ # This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
289
+ # added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
290
+ # keeps the count in the caller's own shell: expansion only, zero forks.
291
+ # The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
292
+ # looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
293
+ # tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
294
+ # * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
295
+ # refusal went blind while the parser happily read the first value.
296
+ # * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
297
+ # above now closes; counting the same form here is what keeps the two from drifting apart again.
298
+ # Sharing ONE definition with the parser is the point: a guard that searches for something else than the
299
+ # thing it guards is the defect, not an implementation detail.
300
+ # WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
301
+ # with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
302
+ # counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
303
+ # token out of its escaped quote plus the string's terminator --
304
+ # {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
305
+ # which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
306
+ # harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
307
+ # {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
308
+ # What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
309
+ # occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
310
+ # payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
311
+ # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
312
+ # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
313
+ # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
314
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
315
+ _KC=0; _KC_CAPPED=0
316
+ while :; do
317
+ _json_find "$hay" "$k"
318
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
319
+ # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
320
+ # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
321
+ # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
322
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
323
+ rest="${hay:_JF+${#k}}"
324
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
325
+ case "$rest" in :*) c=$((c+1)) ;; esac
326
+ hay="$rest"
327
+ done
328
+ }
329
+ # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
330
+
331
+ # ---- helpers: builtins only ------------------------------------------------------------------------------
332
+ _lg_id(){ # $1 = raw id -> _LGI: letters, digits, _ and - only, 80 at most (it names a file)
333
+ _LGI="${1//[!A-Za-z0-9_-]/}"; _LGI="${_LGI:0:80}"; }
334
+ _lg_hash(){ # $1 = text -> _LGH, a 31-bit djb2 of its bytes (LC_ALL=C, so ${s:i:1} is one byte)
335
+ local s="$1" i=0 n=${#1} h=5381 o
336
+ while [ "$i" -lt "$n" ]; do printf -v o '%d' "'${s:i:1}"; h=$(( (h * 33 + o) & 2147483647 )); i=$((i + 1)); done
337
+ _LGH=$h; }
338
+ _lg_sig(){ # $1 = the error, still JSON-escaped -> _LGS: its first 400 bytes, paths/hex/numbers collapsed
339
+ local s="${1:0:400}" w out=""
340
+ s="${s//\\n/ }"; s="${s//\\r/ }"; s="${s//\\t/ }"; s="${s//\\\"/ }"; s="${s//\\\\/\\}"
341
+ set -f; local IFS=$' \t'
342
+ for w in $s; do
343
+ case "$w" in
344
+ */*|*\\*) w=P ;; # a path, a URL, a Windows path: where it happened, not what
345
+ 0x*|0X*) w=H ;;
346
+ *) if [ "${#w}" -ge 7 ] && [[ "$w" != *[!0-9a-fA-F]* && "$w" == *[0-9]* ]]; then w=H # a hash, an id
347
+ else w="${w//[0-9]/#}"; while [[ "$w" == *'##'* ]]; do w="${w//'##'/#}"; done; fi ;; # 42, 1337 -> #
348
+ esac
349
+ out="$out $w"
350
+ done
351
+ set +f; _LGS="${out:0:200}"; }
352
+ _lg_num(){ # $@ = values -> 0 when every one is a plain non-negative integer
353
+ local v; for v in "$@"; do case "$v" in ''|*[!0-9]*) return 1 ;; esac; done; return 0; }
354
+ _lg_bump(){ # $1 = table name (FT|ST), $2 = key -> _LGN = its new count, _LGNEW = 1 when the key is new
355
+ local tab="${!1}" cur
356
+ _LGNEW=0
357
+ case "$tab" in
358
+ *" $2="*) cur="${tab#* $2=}"; cur="${cur%% *}"; _lg_num "$cur" || cur=0
359
+ _LGN=$((cur + 1)); tab="${tab/ $2=$cur / $2=$_LGN }" ;;
360
+ *) _LGN=1; _LGNEW=1; [ "${#tab}" -lt 6000 ] && tab="$tab$2=1 " ;; # bounded: a key not kept still counts as new
361
+ esac
362
+ printf -v "$1" '%s' "$tab"; }
363
+
364
+ # ---- the payload -----------------------------------------------------------------------------------------
365
+ _json_slice "$INPUT" hook_event_name >/dev/null; EV="$_JS"
366
+ case "$EV" in PostToolUse|PostToolUseFailure) ;; *) exit 0 ;; esac
367
+ _json_slice "$INPUT" tool_name >/dev/null; TN="$_JS"
368
+ case "$TN" in Bash|Edit|Write|MultiEdit|NotebookEdit) ;; *) exit 0 ;; esac
369
+ _json_slice "$INPUT" session_id >/dev/null; _lg_id "$_JS"; SID="$_LGI"; [ -n "$SID" ] || exit 0
370
+ _json_slice "$INPUT" agent_id >/dev/null; _lg_id "$_JS"; AG="$_LGI"
371
+ _json_slice "$INPUT" prompt_id >/dev/null; _lg_id "$_JS"; PID="$_LGI"
372
+
373
+ # The project: the hook is started in it (settings.json cds there); the plugin edition reads it from the env or the payload.
374
+ P="${CLAUDE_PROJECT_DIR:-}"
375
+ if [ -z "$P" ]; then _json_slice "$INPUT" cwd >/dev/null; P="${_JS//\\\\/\\}"; fi
376
+ P="${P//\\//}"; P="${P%/}"; [ -n "$P" ] || P=.
377
+ D="$P/.claude/state"
378
+ [ -d "$D" ] || mkdir -p "$D" 2>/dev/null || exit 0
379
+ F="$D/loop-$SID${AG:+-$AG}.txt"
380
+
381
+ # ---- the state: one line of counters, then the file table, then the signature table --------------------------
382
+ # lg1 <prompt|-> <calls> <errors> <fired> <files> <max edits one file> <max one signature> <signatures>
383
+ S_P=-; S_C=0; S_E=0; S_X=0; S_NF=0; S_MF=0; S_MS=0; S_NS=0; FT=""; ST=""
384
+ if [ -f "$F" ]; then
385
+ { read -r _v S_P S_C S_E S_X S_NF S_MF S_MS S_NS; read -r FT; read -r ST; } < "$F" 2>/dev/null
386
+ if [ "${_v:-}" != lg1 ] || ! _lg_num "$S_C" "$S_E" "$S_X" "$S_NF" "$S_MF" "$S_MS" "$S_NS"; then
387
+ S_P=-; S_C=0; S_E=0; S_X=0; S_NF=0; S_MF=0; S_MS=0; S_NS=0; FT=""; ST=""
388
+ fi
389
+ fi
390
+ # A new prompt is a new turn: everything starts again.
391
+ if [ -n "$PID" ] && [ "$PID" != "$S_P" ]; then
392
+ S_P="$PID"; S_C=0; S_E=0; S_X=0; S_NF=0; S_MF=0; S_MS=0; S_NS=0; FT=""; ST=""
393
+ fi
394
+ FT=" $FT "; ST=" $ST "
395
+
396
+ S_C=$((S_C + 1))
397
+ case "$TN" in
398
+ Edit|Write|MultiEdit|NotebookEdit)
399
+ _json_slice "$INPUT" file_path >/dev/null; FP="$_JS"
400
+ [ -n "$FP" ] || { _json_slice "$INPUT" notebook_path >/dev/null; FP="$_JS"; }
401
+ if [ -n "$FP" ]; then
402
+ FP="${FP//\\\\/\\}"; FP="${FP//\\//}"
403
+ _lg_hash "$FP"; _lg_bump FT "f$_LGH"
404
+ [ "$_LGNEW" = 1 ] && S_NF=$((S_NF + 1))
405
+ [ "$_LGN" -gt "$S_MF" ] && S_MF=$_LGN
406
+ fi ;;
407
+ esac
408
+ if [ "$EV" = PostToolUseFailure ]; then
409
+ case "$INPUT" in
410
+ *'"is_interrupt":true'*|*'"is_interrupt": true'*) ;; # the user stopped it: not a failure
411
+ *) S_E=$((S_E + 1))
412
+ _json_slice "$INPUT" error >/dev/null; _lg_sig "$_JS"; _lg_hash "$_LGS"; _lg_bump ST "s$_LGH"
413
+ [ "$_LGNEW" = 1 ] && S_NS=$((S_NS + 1))
414
+ [ "$_LGN" -gt "$S_MS" ] && S_MS=$_LGN ;;
415
+ esac
416
+ fi
417
+
418
+ # ---- the conjunction -------------------------------------------------------------------------------------------
419
+ FIRE=0
420
+ if [ "$S_X" = 0 ] && [ "$S_C" -ge "$LG_CALLS" ] && [ "$S_E" -ge "$LG_ERRORS" ] \
421
+ && { [ "$S_MF" -ge "$LG_FILE_EDITS" ] || [ "$S_NF" -ge "$LG_FILES" ]; } \
422
+ && [ "$S_MS" -ge 2 ] && [ $((S_MS * 100)) -ge $((S_E * LG_SIG_PCT)) ] \
423
+ && :; then
424
+ FIRE=1; S_X=1
425
+ fi
426
+ FT="${FT# }"; FT="${FT% }"; ST="${ST# }"; ST="${ST% }"
427
+ printf 'lg1 %s %s %s %s %s %s %s %s\n%s\n%s\n' "$S_P" "$S_C" "$S_E" "$S_X" "$S_NF" "$S_MF" "$S_MS" "$S_NS" "$FT" "$ST" > "$F" 2>/dev/null
428
+ [ "$FIRE" = 1 ] || exit 0
429
+
430
+ if [ "$LG_MODE" = on ]; then
431
+ printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' "$EV" "$LG_REDIRECT"
432
+ exit 0
433
+ fi
434
+ # Shadow: one line, counters only. Epoch seconds, which no time zone changes.
435
+ if [ -n "${EPOCHSECONDS:-}" ]; then TS=$EPOCHSECONDS
436
+ elif [ "${BASH_VERSINFO[0]}" -gt 4 ] || { [ "${BASH_VERSINFO[0]}" = 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v TS '%(%s)T' -1
437
+ else TS="$(date +%s 2>/dev/null)"; fi
438
+ _lg_num "$TS" || TS=0
439
+ TURN=prompt; [ "$S_P" = - ] && TURN=session
440
+ # Beside the counters, in .claude/state, and not in .crewforth/: that directory holds what a team shares (crew-map.json,
441
+ # review.json), so a per-session log written there could be committed by a team that tracks it.
442
+ O="$P/.claude/state"; [ -d "$O" ] || mkdir -p "$O" 2>/dev/null || exit 0
443
+ printf '{"ts":%s,"session":"%s","agent":"%s","mode":"shadow","window":"%s","calls":%s,"errors":%s,"files_edited":%s,"max_edits_one_file":%s,"top_signature":%s,"signatures":%s}\n' \
444
+ "$TS" "$SID" "$AG" "$TURN" "$S_C" "$S_E" "$S_NF" "$S_MF" "$S_MS" "$S_NS" >> "$O/loop-guard.jsonl" 2>/dev/null
445
+ exit 0