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,577 @@
1
+ #!/usr/bin/env bash
2
+ # Pre-commit gate on staged ADDED lines:
3
+ # (1) §4.1/§4.2 trace scanner — AI-authorship traces / vendor names (trace-blocklist.txt)
4
+ # (2) secret scanner — API keys / tokens / private keys (secret-blocklist.txt)
5
+ # (3) private-path scanner — this machine's $HOME + .private-terms.txt (§4.3)
6
+ # Skipping requires an explicit request per §4.5 (git commit --no-verify).
7
+ set -euo pipefail
8
+ # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
9
+ _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
10
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
11
+ HERE="$(cd "$(dirname "$0")" && pwd)"
12
+ BL="$HERE/trace-blocklist.txt"
13
+ SL="$HERE/secret-blocklist.txt"
14
+
15
+ # Repo-root exception lists (optional): a pattern appearing here as a FULL line is SKIPPED.
16
+ # These files are NOT part of the payload; they are specific to their own repo.
17
+ GITROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
18
+ AL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.trace-allowlist.txt" ] && AL="$GITROOT/.trace-allowlist.txt"
19
+ SAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.secret-allowlist.txt" ] && SAL="$GITROOT/.secret-allowlist.txt"
20
+
21
+ # Only added content lines (start with +, excluding the +++ file header).
22
+ #
23
+ # The added lines go to FILES, and every scan greps a file. Never `printf "$ADDED" | grep -q`: grep -q exits on
24
+ # the first match, the pipe closes, printf dies of SIGPIPE (141), and `set -o pipefail` turns that into a failed
25
+ # `if` — so a match in a LARGE staged diff would silently count as no match. A gate that only works on small
26
+ # commits is worse than no gate.
27
+ TRACE_F="$(mktemp)"; SECRET_F="$(mktemp)"
28
+ # Cleanup runs on EXIT; the SIGNAL handlers additionally EXIT. A bare `trap '...' EXIT INT TERM` looks like it
29
+ # handles all three, but bash returns to the script after a signal handler that does not exit — so TERM removed
30
+ # the temp files this hook is still reading from and then let it carry on, printing `grep: /tmp/tmp.X: No such
31
+ # file or directory` for the rest of the run. Measured here: after a TERM the hook exited 0, not 143, which is
32
+ # the signature — a process killed by a signal cannot exit 0.
33
+ #
34
+ # That is how a slow commit becomes a STUCK one. A wrapper with a two-minute timeout sends TERM, the hook
35
+ # swallows it, git keeps running, and `.git/index.lock` stays behind holding the repository. Every later git
36
+ # command then fails with "Another git process seems to be running", which names nothing about the real cause.
37
+ # 143 = 128 + SIGTERM, the exit status a shell reports for a process the signal actually stopped.
38
+ _crew_cleanup(){ rm -f "$TRACE_F" "$SECRET_F" ${PRIV_F:+"$PRIV_F"} ${PTERM_F:+"$PTERM_F"} ${FLOOR_F:+"$FLOOR_F"} ${FLOOR_P:+"$FLOOR_P"}; }
39
+ trap _crew_cleanup EXIT
40
+ trap '_crew_cleanup; exit 143' INT TERM
41
+
42
+ # Secrets are scanned EVERYWHERE. A token pasted into .claude/settings.json is still a token.
43
+ # Crewforth's own blocklist files are excluded — they definitionally contain the patterns (circular), and since
44
+ # 1.8.0 they also carry their own `#test:` cases. Matched by NAME rather than by the installed path: the same
45
+ # files live at .claude/hooks/ in a project, kit/hooks/ in Crewforth's own repo and hooks/ in the
46
+ # plugin build, and a path-anchored exclusion silently stopped applying in every layout but the first.
47
+ git diff --cached --unified=0 -- . \
48
+ ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
49
+ ':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
50
+ | grep -E '^\+' | grep -Ev '^\+\+\+' > "$SECRET_F" || true
51
+
52
+ # Traces (§4.1/§4.2) are scanned in the PROJECT's own files only. `.claude/` is Crewforth's tree: it configures the
53
+ # assistant, it legitimately names the tool it configures, and an update overwrites it — it is not an artifact the
54
+ # project authored. Teams that share `.claude/` would otherwise be unable to commit it at all.
55
+ # The blocklist is excluded by name for the same circularity reason as above: in an installed project it already
56
+ # falls under .claude/, but in Crewforth's own repo it sits at kit/hooks/ where that exclusion misses it.
57
+ git diff --cached --unified=0 -- . ':(top,exclude).claude' \
58
+ ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
59
+ | grep -E '^\+' | grep -Ev '^\+\+\+' > "$TRACE_F" || true
60
+
61
+ # `git commit -a` stages tracked changes as part of the commit. As a git hook that is invisible — git stages
62
+ # first, so --cached already contains them by the time this runs. Called AHEAD of the commit (the plugin
63
+ # edition's PreToolUse path, which has no core.hooksPath to hook into), it is not: the content about to be
64
+ # committed is still unstaged, and scanning only --cached would wave it through. The caller sets this when it
65
+ # sees -a/--all.
66
+ if [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ]; then
67
+ git diff --unified=0 -- . \
68
+ ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
69
+ ':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
70
+ | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$SECRET_F" || true
71
+ git diff --unified=0 -- . ':(top,exclude).claude' \
72
+ ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
73
+ | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$TRACE_F" || true
74
+ fi
75
+
76
+ HIT=0
77
+ # (0) repo-bloat gate — scans the staged FILE LIST, not added lines: a binary produces no '^+' line, so this
78
+ # must run BEFORE the added-text early-exit below or it would never fire on the very files it targets.
79
+ MAXBYTES="${CREW_MAX_FILE_BYTES:-5242880}" # 5 MiB; override per-repo via env
80
+ BLOAT_DIRS='(^|/)(node_modules|bower_components|dist|build|out|\.next|\.nuxt|\.svelte-kit|target|bin|obj|vendor|__pycache__|\.venv|venv|coverage|\.gradle)/'
81
+ # COST NOTE, measured on a 373-file merge: the loop below used to spawn ~7 processes per file — three
82
+ # `printf | grep` pairs plus a `git cat-file` — for 2,644 processes in total. On Git Bash a process costs
83
+ # 20-50 ms and this hook ran for over twenty minutes on a real merge, which is how a gate turns into the thing
84
+ # people reach for --no-verify to avoid. The pattern scanning was never the expensive part: 23 patterns are one
85
+ # grep each. The file loop was. So the name tests are pure bash `case` now (no process at all) and the two
86
+ # object lookups are batched into one `git cat-file --batch-check` after the loop. Same rules, same verdicts.
87
+ BLOAT_CANDIDATES=""; BLOAT_QUERY=""
88
+ SIZE_TARGETS=""; SIZE_QUERY=""
89
+ NL='
90
+ '
91
+ while IFS= read -r f; do
92
+ [ -z "$f" ] && continue
93
+ base="${f##*/}"
94
+ # (F) secret-FILE gate — a file that is a secret by its very NAME. The content scan (2) can miss it: a private
95
+ # key is high-entropy, not a known prefix. .env.example/.sample/.template/.dist are committable templates.
96
+ is_secret_file=0
97
+ case "$base" in
98
+ .env|.env.*)
99
+ # Case-insensitive for THIS test only — the rule it replaces used `grep -qiE` here and plain `grep -qE`
100
+ # everywhere else. Turning nocasematch on for the whole loop looked harmless and quietly widened a
101
+ # different rule: `server.PEM` went from allowed to blocked. Arguably an improvement, but not one to make
102
+ # inside a performance change, and not one to make without casing the "must not block" half.
103
+ shopt -s nocasematch 2>/dev/null || true
104
+ case "$base" in
105
+ *.example|*.sample|*.template|*.dist|*.schema|*.md) : ;;
106
+ *) is_secret_file=1 ;;
107
+ esac
108
+ shopt -u nocasematch 2>/dev/null || true ;;
109
+ esac
110
+ # Case-insensitive, and that IS a widening — the rule this replaced used a case-SENSITIVE `grep -qE`, so
111
+ # `server.PEM`, `id.KEY` and `cert.P12` were committable while their lowercase twins were blocked. Left open
112
+ # once already, on the grounds that widening a gate does not belong inside a performance change. It belongs
113
+ # here: Windows and macOS filesystems are case-insensitive, so `server.PEM` and `server.pem` are the SAME
114
+ # FILE, and a rule that blocks one spelling of a private key while allowing another has protected nothing.
115
+ # Same class as the `chmod 777` vs `chmod 1777` hole and the `rm -rf` vs `rm -Rf` one this file already fixed.
116
+ #
117
+ # nocasematch is opened and closed around this block ALONE. Left on across the whole loop it silently changed
118
+ # the .env test above, which is exactly how `server.PEM` moved from allowed to blocked as an invisible side
119
+ # effect the first time. The must-not-block half is cased too: `.pem.example`, `key.md`, `monkey.ts`,
120
+ # `KEYS.md` and `public.pub` all stay committable, and smoke-test pins them in both directions.
121
+ shopt -s nocasematch 2>/dev/null || true
122
+ case "$base" in
123
+ *.example|*.sample|*.template|*.dist|*.md|*.pub) : ;;
124
+ id_rsa|id_dsa|id_ecdsa|id_ed25519|.npmrc|.pypirc|.htpasswd|credentials.json) is_secret_file=1 ;;
125
+ *.pem|*.pfx|*.p12|*.ppk|*.key|*.keystore|*.jks) is_secret_file=1 ;;
126
+ esac
127
+ shopt -u nocasematch 2>/dev/null || true
128
+ if [ "$is_secret_file" = 1 ] && ! { [ -n "$SAL" ] && grep -qxF -- "$f" "$SAL"; }; then
129
+ echo "SECRET-FILE: '$f' is a secrets / credentials / private-key file — never commit it (gitignore it; ship a .env.example instead)."
130
+ HIT=1; continue
131
+ fi
132
+ # The PATTERN half only judges files that are NEW to the repository. A path already in HEAD is one the project
133
+ # decided to keep, and refusing it means that file can never be edited again without --no-verify — the exact
134
+ # shape of a gate that gets worked around. This repo hit it on its own `bin/cli.js`: `bin/` is build output in
135
+ # .NET and Java, and the conventional home of a CLI entry point in Node, where package.json's `bin` field
136
+ # points at it. The SIZE check below still applies to tracked files, so a tracked path cannot quietly grow.
137
+ case "/$f" in
138
+ */node_modules/*|*/bower_components/*|*/dist/*|*/build/*|*/out/*|*/.next/*|*/.nuxt/*|*/.svelte-kit/*|*/target/*|*/bin/*|*/obj/*|*/vendor/*|*/__pycache__/*|*/.venv/*|*/venv/*|*/coverage/*|*/.gradle/*)
139
+ BLOAT_CANDIDATES="$BLOAT_CANDIDATES$f
140
+ "; BLOAT_QUERY="$BLOAT_QUERY${NL}HEAD:$f" ;;
141
+ esac
142
+ SIZE_TARGETS="$SIZE_TARGETS$f
143
+ "; SIZE_QUERY="$SIZE_QUERY${NL}:$f"
144
+ done < <(git diff --cached --name-only --diff-filter=AM -- . 2>/dev/null)
145
+
146
+ # Both object lookups, batched. `--batch-check` answers in input order, so the paths are pasted back on rather
147
+ # than looked up again — bash 3.2 (what macOS ships) has no associative arrays, and a second lookup per file
148
+ # would put back the cost this removes.
149
+ if [ -n "$BLOAT_CANDIDATES" ]; then
150
+ while IFS=' ' read -r f verdict; do
151
+ [ -z "$f" ] && continue
152
+ case "$verdict" in *missing*)
153
+ echo "REPO-BLOAT: '$f' is a build/vendored artifact — gitignore it, don't commit it."
154
+ HIT=1 ;;
155
+ esac
156
+ done < <(printf '%s\n' "${BLOAT_QUERY#$NL}" | git cat-file --batch-check 2>/dev/null \
157
+ | paste -d'\t' <(printf '%s' "$BLOAT_CANDIDATES") - 2>/dev/null)
158
+ fi
159
+
160
+ if [ -n "$SIZE_TARGETS" ]; then
161
+ while IFS=' ' read -r f info; do
162
+ [ -z "$f" ] && continue
163
+ sz="${info##* }"
164
+ case "$sz" in ''|*[!0-9]*) sz=0;; esac
165
+ if [ "$sz" -gt "$MAXBYTES" ]; then
166
+ echo "REPO-BLOAT: '$f' is $((sz/1024)) KiB (> $((MAXBYTES/1024)) KiB) — large blobs bloat history; use Git LFS or gitignore."
167
+ HIT=1
168
+ fi
169
+ done < <(printf '%s\n' "${SIZE_QUERY#$NL}" | git cat-file --batch-check 2>/dev/null \
170
+ | paste -d'\t' <(printf '%s' "$SIZE_TARGETS") - 2>/dev/null)
171
+ fi
172
+
173
+ # Floor guard, STRUCTURAL half — a test file deleted outright, or assertions taken out of one that stays.
174
+ #
175
+ # It sits here, ahead of the early exit below, because neither move adds a line: a commit that only deletes has
176
+ # nothing for the text scans, and that exit used to end the hook before this ever ran — measured, a deleted test
177
+ # file committed cleanly. The line-pattern half of the floor guard is further down with the other text scans.
178
+ # (The line patterns and why each exists: floor-blocklist.txt.)
179
+ FAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.floor-allowlist.txt" ] && FAL="$GITROOT/.floor-allowlist.txt"
180
+ # .floor-allowlist.txt: `path:<glob>` exempts a path, `rule:<name>` turns a rule off, any other line exempts that
181
+ # EXACT pattern line. `path:` is matched as a shell `case` pattern: `*` crosses `/` and `**` is not special, so
182
+ # `path:*.cs` exempts every C# file in the tree. Documented in floor-blocklist.txt and pinned in smoke §7h2. The allowlist sits in the same diff as the code it excuses, which is the point: an exception
183
+ # is a decision someone can see, not a rule that quietly got weaker.
184
+ _floor_allowed(){ # $1 = rule, $2 = pattern (may be empty), $3 = path
185
+ [ -n "$FAL" ] || return 1
186
+ local a
187
+ while IFS= read -r a || [ -n "$a" ]; do
188
+ a="${a%$'\r'}"
189
+ case "$a" in
190
+ ''|\#*) continue ;;
191
+ path:*) case "$3" in ${a#path:}) return 0 ;; esac ;;
192
+ rule:*) [ "${a#rule:}" = "$1" ] && return 0 ;;
193
+ *) [ -n "$2" ] && [ "$a" = "$2" ] && return 0 ;;
194
+ esac
195
+ done < "$FAL"
196
+ return 1
197
+ }
198
+
199
+ # Test files, by the naming each ecosystem uses. Names and not whole `test/` directories, because deleting a
200
+ # fixture or a helper there is not a test made easier. Rust integration tests are the one convention that is a
201
+ # directory rather than a name.
202
+ _floor_is_test(){ # $1 = path
203
+ case "$1" in
204
+ *.test.*|*.spec.*|*_test.*|*_spec.rb|test_*.py|*/test_*.py) return 0 ;;
205
+ *Test.java|*Tests.java|*Test.kt|*Tests.kt|*Test.cs|*Tests.cs|*Test.php|*Spec.scala|*Tests.swift) return 0 ;;
206
+ __tests__/*|*/__tests__/*|tests/*.rs|*/tests/*.rs) return 0 ;;
207
+ esac
208
+ return 1
209
+ }
210
+
211
+ # One diff pass serves both checks, so the cost on an ordinary commit is two processes rather than four. Headers
212
+ # are read only between `diff --git` and the first hunk: a removed content line that itself starts with `-- ` (a
213
+ # SQL comment) shows up as `--- ` and would otherwise be mistaken for a file header.
214
+ #
215
+ # Assertions are counted NET per file, not line by line: changing an expectation removes one and adds one, and a
216
+ # line-level rule would flag every such edit. A test only loses its checks when it ends with fewer than it began.
217
+ # A rename is not a deletion and git does not report it as one, so moving a test file stays free.
218
+ # Whether a path is a test is decided once, by _floor_is_test, rather than written a second time in awk.
219
+ # ONE diff and ONE awk serve the whole floor guard — the deleted-test and assertion checks here, and the added-line
220
+ # corpus the pattern half reads further down. They used to be two diffs and two awks, plus two mktemps, which is
221
+ # what an ordinary commit paid for this guard: +7 processes, measured on Windows 11 at about +304 ms over a ~900 ms
222
+ # hook. Merging them removes four processes; the corpus files are named off TRACE_F and created with a builtin
223
+ # redirection, so no process makes them. Nothing about the trace or secret scans changed.
224
+ #
225
+ # Headers are read only between `diff --git` and the first hunk. The corpus parser used to treat any `+++ ` line as a
226
+ # file header, so an added line that itself began `++ ` came through as `+++ …` and re-pointed the path of every line
227
+ # after it; tracking the header state closes that as well. Unfiltered on purpose: deletions, modifications, additions
228
+ # and renames all arrive in one stream. A new file is never counted for assertions (it cannot lose any); a renamed
229
+ # test that also lost assertions is counted, which the old DM-only filter let through.
230
+ FLOOR_F="$TRACE_F.floor"; FLOOR_P="$TRACE_F.floorp"; : > "$FLOOR_F"; : > "$FLOOR_P"
231
+ _floor_diff(){ # $@ = extra git diff arguments
232
+ git diff "$@" --unified=0 --no-color --src-prefix=a/ --dst-prefix=b/ -- . ':(top,exclude).claude' 2>/dev/null
233
+ }
234
+ while IFS=$'\t' read -r kind f r a; do
235
+ [ -n "$f" ] || continue
236
+ _floor_is_test "$f" || continue
237
+ case "$kind" in
238
+ D) _floor_allowed "test-deleted" "" "$f" && continue
239
+ echo "FLOOR-GUARD [test-deleted]: $f is a test file this commit removes"
240
+ HIT=1 ;;
241
+ M) [ "$r" -gt "$a" ] || continue
242
+ _floor_allowed "assertion-removed" "" "$f" && continue
243
+ echo "FLOOR-GUARD [assertion-removed]: $f loses $((r - a)) assertion(s) and stays in the tree (removed $r, added $a)"
244
+ HIT=1 ;;
245
+ esac
246
+ done < <({ _floor_diff --cached; [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ] && _floor_diff; } \
247
+ | LC_ALL=C awk -v F="$FLOOR_F" -v P="$FLOOR_P" '
248
+ /^diff --git / { hdr = 1; old = ""; p = ""; isnew = 0; skip = 1; next }
249
+ hdr && /^--- / { old = $0; sub(/^--- (a\/)?/, "", old); isnew = (old == "/dev/null"); next }
250
+ hdr && /^\+\+\+ / { np = $0; sub(/^\+\+\+ (b\/)?/, "", np)
251
+ if (np == "/dev/null") { if (old != "" && old != "/dev/null") printf "D\t%s\t0\t0\n", old; p = ""; skip = 1 }
252
+ else { p = np; skip = (np ~ /\.(md|mdx|txt|rst|adoc)$/) }
253
+ next }
254
+ /^@@/ { hdr = 0; s = $0; sub(/^@@ -[0-9,]+ \+/, "", s); sub(/[, ].*$/, "", s); ln = s + 0; next }
255
+ hdr { next }
256
+ p == "" { next }
257
+ /^[-+]/ {
258
+ b = substr($0, 2); plus = (substr($0, 1, 1) == "+")
259
+ if (plus && !skip) { print b >> F; print p ":" ln >> P }
260
+ if (plus) ln++
261
+ if (!isnew && (b ~ /(^|[^A-Za-z0-9_])(assert|Assert|expect|Expect|should|Should|verify|Verify)[A-Za-z_]*[[:space:]]*[.(]/ ||
262
+ b ~ /(^|[^A-Za-z0-9_])t\.(Error|Errorf|Fatal|Fatalf)\(/ ||
263
+ b ~ /(^|[^A-Za-z0-9_])require\./ ||
264
+ b ~ /(^|[[:space:]])assert[[:space:]]/)) {
265
+ if (plus) add[p]++; else rem[p]++
266
+ seen[p] = 1
267
+ }
268
+ }
269
+ END { for (f in seen) printf "M\t%s\t%d\t%d\n", f, rem[f] + 0, add[f] + 0 }')
270
+
271
+ # Nothing staged for the text scans AND the bloat gate is clean -> done.
272
+ { [ -s "$TRACE_F" ] || [ -s "$SECRET_F" ] || [ "$HIT" -ne 0 ]; } || exit 0
273
+
274
+
275
+ # Ask "can ANY pattern match" once, before asking "which one".
276
+ #
277
+ # The three loops below spawn one grep PER PATTERN — 12 + 11 + 3 = 26 processes on every commit, and the
278
+ # overwhelming majority of commits are clean, so 26 of them exist only to say "no". Measured on 205 added
279
+ # lines: the trace loop 0.621 s -> 0.063 s, the secret loop 0.572 s -> 0.059 s. One decision, then the loop
280
+ # ONLY when the decision says there is something to name.
281
+ #
282
+ # Three-way on purpose, because grep has three answers and two of them must not shortcut:
283
+ # rc=1 nothing matches -> done. This is the fast path and the common one.
284
+ # rc=0 something matches -> run the loop, which names the pattern. The user still reads
285
+ # "forbidden expression -> '<pattern>'" verbatim; nothing is lost but time.
286
+ # rc=2 grep could not decide -> run the loop. TODAY'S BEHAVIOUR, deliberately.
287
+ #
288
+ # That last rung is what makes this safe rather than clever, and it was earned: joining the patterns compiles
289
+ # them TOGETHER, so ONE broken regex kills the whole alternation. Measured with a pattern file carrying a valid
290
+ # `Co-Authored-By` and a broken `foo[`, against a message that really does carry the trailer:
291
+ # grep -inEf together rc=2, ZERO matches <- the trailer goes unreported
292
+ # one grep per pattern catches it; only the broken pattern is skipped
293
+ # A blocklist is a file users edit by hand, so a broken pattern is not exotic — and failing OPEN there would be
294
+ # the worst possible direction. rc=2 also covers an alternation too large to compile (measured: 40 KB fine,
295
+ # 200 KB -> "grep: Memory exhausted", rc=2 -> loop), so the ARG_MAX question answers itself.
296
+ #
297
+ # Verified on the blocklists' OWN #test:/#test-clean: cases — 32 of them, the payload the gate actually
298
+ # defends: 32/32 identical verdicts. Calibrated by mutation (strip the parentheses so a group-scoped `|`
299
+ # escapes to top level): 8 mismatches, in BOTH directions — three clean messages newly rejected and two real
300
+ # secrets newly passed. Also checked structurally: no pattern in either shipped blocklist carries a bare
301
+ # top-level `|` (every one is inside a group) and none uses a back-reference, so joining cannot reshape them.
302
+ _crew_any(){ # $1 = pattern list $2 = corpus $3 = grep flags $4 = 1 for newline-joined fixed strings
303
+ local pat alt="" sep="|"
304
+ [ "$4" = 1 ] && sep="
305
+ "
306
+ while IFS= read -r pat || [ -n "$pat" ]; do
307
+ pat="${pat%$'\r'}"
308
+ case "$pat" in ''|\#*) continue;; esac
309
+ alt="${alt:+$alt$sep}$pat"
310
+ done < "$1"
311
+ [ -n "$alt" ] || return 1
312
+ grep -q$3 -- "$alt" "$2" 2>/dev/null
313
+ }
314
+ # (1) trace scan — case-INsensitive (vendor / AI-authorship strings), project files only
315
+ # `!= 1` AND NOT `= 0`, AND THE DIFFERENCE IS THE WHOLE GATE. _crew_any joins every pattern into ONE
316
+ # alternation and runs a single grep, so one malformed pattern anywhere in the file takes that grep down with
317
+ # it -- exit 2, not 1. Measured, with a list holding `(AKIA|ASIA)[A-Z0-9]{16}` and `[unclosed`:
318
+ # good list + matching corpus -> 0 good list + clean corpus -> 1
319
+ # broken list + matching corpus -> 2 broken list + clean corpus -> 2
320
+ # rc 2 means "I could not look", which is not "there is nothing there". So the guard treats anything that is
321
+ # not a clean 1 as a reason to run the per-pattern loop below, where every pattern is tried on its own and the
322
+ # broken one merely fails by itself.
323
+ #
324
+ # `= 0` reads as the same thing and is not: it skips the loop whenever the combined grep errors, so ONE typo
325
+ # in a project's own added pattern turns the entire scanner fail-open and lets the commit through.
326
+ #
327
+ # NOTHING WOULD CATCH THAT SUBSTITUTION. On a well-formed blocklist the two spellings are identical in every
328
+ # case -- measured above, 0 and 1 both route the same way -- so every test in this repo stays green, including
329
+ # the malformed-pattern case, which only goes red BECAUSE the loop still runs. This comment is the only thing
330
+ # standing between that rewrite and a silently open gate.
331
+ if _crew_any "$BL" "$TRACE_F" iE 0; then _dec=0; else _dec=$?; fi
332
+ if [ -f "$BL" ] && [ -s "$TRACE_F" ] && [ "$_dec" != 1 ]; then
333
+ while IFS= read -r pat || [ -n "$pat" ]; do
334
+ pat="${pat%$'\r'}" # tolerate a CRLF blocklist (Windows/autocrlf checkout) — a trailing \r
335
+ case "$pat" in ''|\#*) continue;; esac # in the pattern never matches the LF-normalised diff, blinding the gate
336
+ if [ -n "$AL" ] && grep -qxF -- "$pat" "$AL"; then continue; fi
337
+ # A PATTERN THAT CANNOT COMPILE IS A GATE THAT IS NOT THERE, and it used to say so in grep's words, not
338
+ # ours: an unbalanced bracket printed "grep: brackets ([ ]) not balanced" into the middle of the commit
339
+ # output with nothing naming the file, the line, or the consequence. This file invites users to add their
340
+ # own vendor pattern, and a user who adds a broken one believes they are covered while nothing matches.
341
+ # CI catches it in this repo -- smoke drives every pattern with its own case and goes red -- but a consumer
342
+ # project runs no such suite, so the hook has to say it itself. It warns rather than blocks: the other
343
+ # patterns all still ran, so the commit is no less scanned than it was, and refusing every commit over one
344
+ # typo would cost more than the typo does.
345
+ # `|| _gr=$?` and not a bare call: `set -e` is in force at the top of this file, and a grep that simply
346
+ # does not match exits 1, which would kill the hook on the first clean pattern. Measured the wrong way once
347
+ # already -- the bare form made every commit exit 1 with no output at all.
348
+ _gr=0; grep -iqE -- "$pat" "$TRACE_F" 2>/dev/null || _gr=$?
349
+ if [ "$_gr" = 0 ]; then
350
+ echo "TRACE-SCANNER: forbidden expression in added code -> '$pat' (§4.1/§4.2)"
351
+ HIT=1
352
+ elif [ "$_gr" -ge 2 ]; then
353
+ echo "TRACE-SCANNER: /$pat/ in $BL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
354
+ fi
355
+ done < "$BL"
356
+ fi
357
+ # (2) secret scan — case-SENSITIVE (token prefixes are case-specific); prints the PATTERN, never the value
358
+ if _crew_any "$SL" "$SECRET_F" E 0; then _dec=0; else _dec=$?; fi
359
+ if [ -f "$SL" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
360
+ while IFS= read -r pat || [ -n "$pat" ]; do
361
+ pat="${pat%$'\r'}" # tolerate a CRLF blocklist (Windows/autocrlf checkout)
362
+ case "$pat" in ''|\#*) continue;; esac
363
+ if [ -n "$SAL" ] && grep -qxF -- "$pat" "$SAL"; then continue; fi
364
+ _gr=0; grep -qE -- "$pat" "$SECRET_F" 2>/dev/null || _gr=$?
365
+ if [ "$_gr" = 0 ]; then
366
+ echo "SECRET-SCANNER: a staged line matches a secret pattern -> /$pat/"
367
+ HIT=1
368
+ elif [ "$_gr" -ge 2 ]; then
369
+ # Same reasoning as the trace loop above; the comment there is the long form.
370
+ echo "SECRET-SCANNER: /$pat/ in $SL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
371
+ fi
372
+ done < "$SL"
373
+ fi
374
+
375
+ # (3) private-path scan — a path that only exists on THIS machine has no business in a shared artifact.
376
+ #
377
+ # Why this is not a blocklist regex: "is this path private?" cannot be answered by a pattern. `/Users/me` is a
378
+ # placeholder every README wants, `/Users/ada` is a real person's home, and no ERE separates them (and ERE has
379
+ # no negative lookahead to spell "not a placeholder"). So the terms are derived from the machine doing the
380
+ # commit — where the answer is knowable exactly — plus whatever the repo owner adds by hand.
381
+ #
382
+ # It exists because this failure is not hypothetical: a work project's absolute path, pasted from a terminal
383
+ # into a CHANGELOG entry, shipped in eight consecutive releases before anyone noticed. The paste is the vector,
384
+ # so the gate has to sit where pasted text becomes a commit.
385
+ #
386
+ # automatic : $HOME, in the three spellings the same directory gets written as on Windows
387
+ # (/c/Users/x · C:/Users/x · C:\Users\x) — case-insensitively, since that filesystem is.
388
+ # by hand : .private-terms.txt at the repo root, one literal term per line ('#' comments). Put internal
389
+ # project names, client names, and host names there. GITIGNORE IT — it is a list of the very
390
+ # strings you do not want published.
391
+ # escape : .private-allowlist.txt, a term per full line, for the rare case a term is legitimately public.
392
+ PRIV_F="$(mktemp)"; PTERM_F="$(mktemp)"
393
+ PTF=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-terms.txt" ] && PTF="$GITROOT/.private-terms.txt"
394
+ PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$GITROOT/.private-allowlist.txt"
395
+ {
396
+ # A home path shorter than this cannot be distinguishing (`/root`, `/`), and matching it would flag everything.
397
+ if [ -n "${HOME:-}" ] && [ "${#HOME}" -ge 8 ]; then
398
+ printf '%s\n' "$HOME"
399
+ # Git Bash reports /c/Users/x for what Windows itself calls C:\Users\x — both spellings reach a file.
400
+ case "$HOME" in
401
+ /[a-zA-Z]/?*)
402
+ d="$(printf '%s' "${HOME:1:1}" | tr 'a-z' 'A-Z')"; r="${HOME:3}"
403
+ printf '%s\n' "$d:/$r" "$d:\\${r//\//\\}" ;;
404
+ esac
405
+ fi
406
+ [ -n "$PTF" ] && sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$PTF"
407
+ } > "$PTERM_F" 2>/dev/null || true
408
+ if _crew_any "$PTERM_F" "$SECRET_F" iF 1; then _dec=0; else _dec=$?; fi
409
+ if [ -s "$PTERM_F" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
410
+ while IFS= read -r term || [ -n "$term" ]; do
411
+ term="${term%$'\r'}"
412
+ [ -n "$term" ] || continue
413
+ if [ -n "$PAL" ] && grep -qxF -- "$term" "$PAL"; then continue; fi
414
+ if grep -qiF -- "$term" "$SECRET_F"; then
415
+ # The term is echoed back deliberately: unlike a secret, the author has to see WHICH private string to
416
+ # replace, and it is already sitting in their own working tree.
417
+ echo "PRIVATE-PATH SCANNER: a staged line contains a machine-private string -> '$term' (§4.3)"
418
+ HIT=1
419
+ fi
420
+ done < "$PTERM_F"
421
+ fi
422
+ rm -f "$PRIV_F"
423
+
424
+ # (4) floor guard — a commit must not LOWER the quality bar on its way to a green check.
425
+ #
426
+ # An agent that hits a red check does not invent a clever loophole; it takes the cheapest road to green. That road
427
+ # is nearly always visible in the lines a commit adds (a checker switched off where it fired, a test told to stop
428
+ # running, a stub or a swallowed error where the work should be) or in what it takes away (a test file, the
429
+ # assertions inside one that stays). The patterns and their cases live in floor-blocklist.txt; the two structural
430
+ # checks that a single line cannot show are here.
431
+ #
432
+ # Built for this hook: the STAGED diff rather than a merge base, since at commit time that is exactly what enters
433
+ # history; bash, since every gate here must run with nothing installed; and the two exemptions a real stack needs,
434
+ # documentation and generated code.
435
+ #
436
+ # It reports the rule, the file, the line and the PATTERN, never the line's text: a suppressed line can carry a
437
+ # secret beside the comment, and the secret scan above is the only place that decides how a value is shown.
438
+ FL="$HERE/floor-blocklist.txt"
439
+ # FLOOR_F (added lines of project code) and FLOOR_P (`path:line` at the same line number) were built by the single
440
+ # floor pass ahead of the early exit above.
441
+
442
+ # A file that opens with a generator marker was not written by this commit's author, and an EF Core project needs
443
+ # this: EF Core puts a warning pragma into every model snapshot it emits (395k such files on GitHub, measured).
444
+ # `sed -n '1,5p'` and NOT `head -n 5`: under `set -o pipefail`, head closing the pipe early makes the producer die
445
+ # of SIGPIPE, and the pipeline reports 141 even when grep matched — measured, so the exemption would never apply.
446
+ _FLOOR_GEN="|"
447
+ _floor_generated(){ # $1 = repo-relative path; 0 when the file opens with a generator marker
448
+ case "$_FLOOR_GEN" in *"|$1=1|"*) return 0 ;; *"|$1=0|"*) return 1 ;; esac
449
+ local hdr
450
+ hdr="$(git show ":$1" 2>/dev/null | sed -n '1,5p')" || hdr=""
451
+ [ -n "$hdr" ] || hdr="$(sed -n '1,5p' -- "${GITROOT:-.}/$1" 2>/dev/null)" || hdr=""
452
+ if printf '%s\n' "$hdr" | grep -qE '<auto-generated|@generated|Code generated .*DO NOT EDIT|DO NOT EDIT|[Aa]uto-?generated|This file (is|was) (automatically )?generated'; then
453
+ _FLOOR_GEN="$_FLOOR_GEN$1=1|"; return 0
454
+ fi
455
+ _FLOOR_GEN="$_FLOOR_GEN$1=0|"; return 1
456
+ }
457
+
458
+ # (4a) line patterns. `!= 1` and not `= 0`, for the reason spelled out at the trace scan: a malformed pattern takes
459
+ # the combined grep down with exit 2, which means "could not look", not "nothing is there".
460
+ #
461
+ # EXEMPTIONS ARE APPLIED BEFORE THE PER-PATTERN LOOP, not inside it, and that ordering is a measured cost fix.
462
+ # Attributing a hit to its rule costs one grep per pattern (34 of them). The first version paid that on every
463
+ # commit the cheap pre-check flagged, then exempted the lines — so an EF Core migration, whose three generated files
464
+ # all carry a warning pragma, went from 20 processes to 73 and was allowed anyway. At the 62-135 ms a Git Bash
465
+ # process was measured to cost, that is several seconds on every migration commit of an EF Core project. Now the
466
+ # candidate lines are found with one grep, their files checked once each, and the loop runs only when a line that
467
+ # is NOT exempt remains. A malformed pattern takes the combined grep down with exit 2; then the slow path runs over
468
+ # every line, exactly as before, so a broken list still reports itself and still scans.
469
+ if _crew_any "$FL" "$FLOOR_F" E 0; then _dec=0; else _dec=$?; fi
470
+ if [ -f "$FL" ] && [ -s "$FLOOR_F" ] && [ "$_dec" != 1 ]; then
471
+ FLOOR_ALT=""
472
+ while IFS= read -r pat || [ -n "$pat" ]; do
473
+ pat="${pat%$'\r'}"
474
+ case "$pat" in ''|\#*) continue ;; esac
475
+ FLOOR_ALT="${FLOOR_ALT:+$FLOOR_ALT|}$pat"
476
+ done < "$FL"
477
+ KEEP="|"; SLOW=0; FLOOR_MAP=""
478
+ _cg=0; cand="$(grep -nE -- "$FLOOR_ALT" "$FLOOR_F" 2>/dev/null)" || _cg=$?
479
+ if [ "$_cg" -ge 2 ]; then
480
+ SLOW=1
481
+ elif [ -n "$cand" ]; then
482
+ nums="$(printf '%s\n' "$cand" | sed 's/:.*//' | tr '\n' ' ')"
483
+ # One awk maps every candidate line number to its `path:line` at once, instead of one sed per line.
484
+ CANDMAP="$(LC_ALL=C awk -v L="$nums" 'BEGIN { n = split(L, a, " "); for (i = 1; i <= n; i++) w[a[i]] = 1 } (NR in w) { print NR ":" $0 }' "$FLOOR_P")"
485
+ FLOOR_MAP="
486
+ $CANDMAP
487
+ "
488
+ # WHICH CANDIDATE FILES ARE GENERATED, ASKED ONCE FOR ALL OF THEM — measured on Windows, not assumed. Asking per
489
+ # file cost a Git Bash process pipeline each (~40 ms marginal there, three processes here), so 120 candidate
490
+ # files took 4.8 s while a single pass over all of them stayed flat at ~130 ms whatever the count. The pass is
491
+ # awk over the first FIVE lines of each file, not `grep -l` over whole files: a hand-written file that merely
492
+ # mentions a generator marker in a comment further down must not be exempted. It reads the working tree rather
493
+ # than the index; a generator header present in one and absent from the other is not a case worth a process per
494
+ # file, and the direction that could matter only makes the guard stricter.
495
+ # The answer seeds the same cache _floor_generated uses, so the attribution loop below pays nothing again.
496
+ FGEN_FILES=""; FGEN_SEEN="|"; set --
497
+ while IFS=: read -r n fpath _; do
498
+ [ -n "$fpath" ] || continue
499
+ case "$FGEN_SEEN" in *"|$fpath|"*) continue ;; esac
500
+ FGEN_SEEN="$FGEN_SEEN$fpath|"
501
+ [ -f "${GITROOT:-.}/$fpath" ] && set -- "$@" "${GITROOT:-.}/$fpath"
502
+ done <<EOF_FLOOR_CAND
503
+ $CANDMAP
504
+ EOF_FLOOR_CAND
505
+ FGEN_LIST=""
506
+ if [ "$#" -gt 0 ]; then
507
+ FGEN_LIST="$(LC_ALL=C awk 'FNR > 5 { nextfile }
508
+ /<auto-generated|@generated|Code generated .*DO NOT EDIT|DO NOT EDIT|[Aa]uto-?generated|This file (is|was) (automatically )?generated/ { print FILENAME; nextfile }' "$@" 2>/dev/null)" || FGEN_LIST=""
509
+ fi
510
+ for f in "$@"; do
511
+ rel="${f#"${GITROOT:-.}"/}"
512
+ case "
513
+ $FGEN_LIST
514
+ " in *"
515
+ $f
516
+ "*) _FLOOR_GEN="$_FLOOR_GEN$rel=1|" ;; *) _FLOOR_GEN="$_FLOOR_GEN$rel=0|" ;; esac
517
+ done
518
+ set --
519
+ while IFS=: read -r n fpath _; do
520
+ [ -n "$n" ] || continue
521
+ _floor_allowed "" "" "$fpath" && continue # path: exemptions only — no rule or pattern is known yet
522
+ _floor_generated "$fpath" && continue # answered from the cache seeded above
523
+ KEEP="$KEEP$n|"
524
+ done <<EOF_FLOOR_CAND2
525
+ $CANDMAP
526
+ EOF_FLOOR_CAND2
527
+ fi
528
+ if [ "$SLOW" = 1 ] || [ "$KEEP" != "|" ]; then
529
+ rule="unclassified"
530
+ while IFS= read -r pat || [ -n "$pat" ]; do
531
+ pat="${pat%$'\r'}"
532
+ case "$pat" in
533
+ '# rule: '*) rule="${pat#\# rule: }"; continue ;;
534
+ ''|\#*) continue ;;
535
+ esac
536
+ _gr=0; hits="$(grep -nE -- "$pat" "$FLOOR_F" 2>/dev/null)" || _gr=$?
537
+ if [ "$_gr" -ge 2 ]; then
538
+ echo "FLOOR-GUARD: /$pat/ in $FL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
539
+ continue
540
+ fi
541
+ [ -n "$hits" ] || continue
542
+ while IFS=: read -r n _; do
543
+ [ -n "$n" ] || continue
544
+ [ "$SLOW" = 1 ] || case "$KEEP" in *"|$n|"*) ;; *) continue ;; esac
545
+ # The location comes from CANDMAP, already in memory, with parameter expansion — not one sed per hit. A commit
546
+ # that sweeps a suppression across many files is exactly the one this loop sees, and it is typically retried
547
+ # after each fix, so a process per hit was paid again on every attempt. The slow path has no map; it uses sed.
548
+ if [ "$SLOW" = 1 ]; then
549
+ loc="$(sed -n "${n}p" "$FLOOR_P")"
550
+ else
551
+ loc="${FLOOR_MAP#*"
552
+ $n:"}"; loc="${loc%%
553
+ *}"
554
+ fi
555
+ fpath="${loc%:*}"
556
+ _floor_allowed "$rule" "$pat" "$fpath" && continue
557
+ _floor_generated "$fpath" && continue
558
+ echo "FLOOR-GUARD [$rule]: $loc adds a move that lowers the bar -> /$pat/"
559
+ HIT=1
560
+ done <<EOF_FLOOR_HITS
561
+ $hits
562
+ EOF_FLOOR_HITS
563
+ done < "$FL"
564
+ fi
565
+ fi
566
+
567
+ if [ "$HIT" -ne 0 ]; then
568
+ echo "-----------------------------------------------------------"
569
+ echo "Commit stopped. Remove the AI-authorship trace / vendor name / secret / bloated file / bar-lowering move from the staged changes."
570
+ echo "A bar-lowering move that is genuinely warranted? record it in .floor-allowlist.txt at the repo root (path:<glob>, rule:<name>, or the exact pattern line) — in the same commit, where review sees it. A path: glob's * also crosses directories: write the narrowest path."
571
+ echo "False positive (trace/secret)? add the EXACT pattern line to .trace-allowlist.txt or .secret-allowlist.txt at the repo root."
572
+ echo "Private path flagged but genuinely public? add that exact term to .private-allowlist.txt at the repo root."
573
+ echo "Large file is intentional? raise CREW_MAX_FILE_BYTES, use Git LFS, or gitignore the build/vendored path."
574
+ echo "Deliberate exception: 'git commit --no-verify' — only on an EXPLICIT request (§4.5)."
575
+ exit 1
576
+ fi
577
+ exit 0