crewforth 3.0.4 → 3.1.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 (67) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/smoke-test.sh +2572 -373
  16. package/kit/hooks/commit-msg +1 -1
  17. package/kit/hooks/context-usage.sh +7 -1
  18. package/kit/hooks/guard-bash.sh +1545 -104
  19. package/kit/hooks/guard-commit-scan.sh +75 -19
  20. package/kit/hooks/guard-powershell.sh +65 -15
  21. package/kit/hooks/guard-write.sh +74 -22
  22. package/kit/hooks/no-bash-guard.ps1 +71 -0
  23. package/kit/hooks/pre-commit +5 -2
  24. package/kit/hooks/prompt-approval.sh +484 -0
  25. package/kit/hooks/route-hint.sh +13 -5
  26. package/kit/hooks/session-rehydrate.sh +18 -0
  27. package/kit/hooks/skill-trust.sh +17 -2
  28. package/kit/owned-blobs.tsv +43 -0
  29. package/kit/settings.json +20 -4
  30. package/kit/skills/a11y/SKILL.md +1 -0
  31. package/kit/skills/crew-doctor/SKILL.md +2 -2
  32. package/kit/skills/frontend/SKILL.md +3 -3
  33. package/kit/skills/frontend-flutter/SKILL.md +124 -0
  34. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +81 -0
  35. package/kit/skills/frontend-flutter/references/platform-async-release.md +100 -0
  36. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  37. package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -0,0 +1,71 @@
1
+ # no-bash-guard.ps1 - the gate that runs when no other gate can.
2
+ #
3
+ # Crewforth's gates are bash scripts. On Windows, when Claude Code finds no Git Bash, a hook that names bash fails
4
+ # with "requires bash but Git Bash was not found" and exit 1 - which does not block - and the Bash tool does not
5
+ # exist at all: PowerShell is the shell, and nothing guards it (measured in the field, 3.0.1-rc.2).
6
+ # One hook in settings.json names no shell. Claude Code runs it through bash where it finds one, and that branch
7
+ # exits 0 without starting a process; where it finds none it runs it through PowerShell, and that branch loads this
8
+ # file. So this code runs only in the state it exists for.
9
+ #
10
+ # It is read with Get-Content and run with Invoke-Expression, never as a script file: the default execution policy
11
+ # on a Windows client refuses to run .ps1 files, and a gate that the policy switches off is not a gate.
12
+ #
13
+ # The lookup is Claude Code's own, in its order (read from its 2.1.284 binary; eval/lib/git-bash.sh holds the same
14
+ # order for the doctor): CLAUDE_CODE_GIT_BASH_PATH when its name is bash/sh and the file exists, the two default
15
+ # Git folders, then <git on PATH>\..\..\bin\bash.exe. A bash found here means Claude Code ran this hook through
16
+ # PowerShell for another reason, and the bash gates work: exit 0.
17
+ #
18
+ # Windows PowerShell 5.1 and PowerShell 7. ASCII only: 5.1 reads a file without a BOM in the ANSI code page.
19
+ # Paths are joined with `/` and passed through GetFullPath: Windows prints them with `\`, and the same code can be
20
+ # exercised by pwsh on Linux and macOS, where the smoke test runs it.
21
+ $ErrorActionPreference = 'Stop'
22
+
23
+ function Test-CrewBash([string]$p) {
24
+ if (-not $p) { return $false }
25
+ $n = [IO.Path]::GetFileName($p).ToLowerInvariant()
26
+ if (@('bash.exe', 'sh.exe', 'bash', 'sh') -notcontains $n) { return $false }
27
+ return [IO.File]::Exists($p)
28
+ }
29
+
30
+ $found = $null
31
+ if (Test-CrewBash $env:CLAUDE_CODE_GIT_BASH_PATH) { $found = $env:CLAUDE_CODE_GIT_BASH_PATH }
32
+ if (-not $found) {
33
+ foreach ($c in @('C:\Program Files\Git\bin\bash.exe', 'C:\Program Files (x86)\Git\bin\bash.exe')) {
34
+ if ([IO.File]::Exists($c)) { $found = $c; break }
35
+ }
36
+ }
37
+ if (-not $found) {
38
+ $g = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
39
+ if ($g) {
40
+ $d = Split-Path -Parent (Split-Path -Parent $g.Source)
41
+ if ($d) { $c = [IO.Path]::GetFullPath((Join-Path $d 'bin/bash.exe')); if ([IO.File]::Exists($c)) { $found = $c } }
42
+ }
43
+ }
44
+ if ($found) { exit 0 }
45
+
46
+ # Not found. Name the path to set when one exists on this machine - checked here, never guessed.
47
+ $fix = $null
48
+ $v = $env:CLAUDE_CODE_GIT_BASH_PATH
49
+ if ($v -and ([IO.Path]::GetFileName($v).ToLowerInvariant() -eq 'git-bash.exe')) {
50
+ $c = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $v) 'bin/bash.exe'))
51
+ if ([IO.File]::Exists($c)) { $fix = $c }
52
+ }
53
+ if (-not $fix -and $env:LOCALAPPDATA) {
54
+ $c = [IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'Programs/Git/bin/bash.exe'))
55
+ if ([IO.File]::Exists($c)) { $fix = $c }
56
+ }
57
+
58
+ $m = "GUARD: Crewforth's gates cannot run here, so this tool call is stopped." + [Environment]::NewLine
59
+ $m += "Claude Code found no Git Bash. Every Crewforth gate is a bash script: none of them started, and the rules they enforce (commit and push approval, destructive commands, secrets, the gate files) are not being checked." + [Environment]::NewLine
60
+ if ($v -and -not (Test-CrewBash $v)) {
61
+ $m += "CLAUDE_CODE_GIT_BASH_PATH is set to '$v', which Claude Code does not accept: it must name bash.exe and exist." + [Environment]::NewLine
62
+ }
63
+ if ($fix) {
64
+ $m += "Tell the user to set the user environment variable CLAUDE_CODE_GIT_BASH_PATH to `"$fix`"" + [Environment]::NewLine
65
+ } else {
66
+ $m += "Tell the user to install Git for Windows in its default folder, or to set the user environment variable CLAUDE_CODE_GIT_BASH_PATH to <Git>\bin\bash.exe" + [Environment]::NewLine
67
+ }
68
+ $m += "and then to close the terminal and Claude Code and open them again (an open terminal keeps the old value). /crew-doctor confirms it." + [Environment]::NewLine
69
+ $m += "Reading and searching still work. Do not look for another way to write or run commands: there is no gate behind it."
70
+ [Console]::Error.WriteLine($m)
71
+ exit 2
@@ -11,7 +11,7 @@ set -euo pipefail
11
11
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
12
12
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
13
13
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
14
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
14
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
15
15
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
16
16
  # staged key all passed.
17
17
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -463,7 +463,10 @@ PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$
463
463
  # Git Bash reports /c/Users/x for what Windows itself calls C:\Users\x — both spellings reach a file.
464
464
  case "$HOME" in
465
465
  /[a-zA-Z]/?*)
466
- d="$(printf '%s' "${HOME:1:1}" | tr 'a-z' 'A-Z')"; r="${HOME:3}"
466
+ # The drive letter, upper-cased with a builtin: `$(printf … | tr a-z A-Z)` was a subshell and a process on
467
+ # every commit, on the platform where a process costs most (bash 3.2 has no ${x^^}).
468
+ d="${HOME:1:1}"; r="${HOME:3}"
469
+ case "$d" in [a-z]) _lc=abcdefghijklmnopqrstuvwxyz; _uc=ABCDEFGHIJKLMNOPQRSTUVWXYZ; _i="${_lc%%"$d"*}"; d="${_uc:${#_i}:1}" ;; esac
467
470
  printf '%s\n' "$d:/$r" "$d:\\${r//\//\\}" ;;
468
471
  esac
469
472
  fi
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env bash
2
+ # prompt-approval.sh - the user's own message as the approval for a commit or a push (§4.4).
3
+ #
4
+ # WHY. In `auto` and `dontAsk` the §4.4 gate fails closed: a permission prompt raised there is answered by
5
+ # software, so it proves nothing. Until now the only way through was to switch mode for every commit. What a
6
+ # person DOES still do in those modes is type. `UserPromptSubmit` is handed the text of the submitted prompt, and
7
+ # the model cannot raise that event: so a message that is nothing but an approval is evidence that a person said
8
+ # yes, and this hook is the only thing that turns it into a record.
9
+ #
10
+ # WHAT COUNTS. The WHOLE message, trimmed, is one of (letter case does not matter):
11
+ # approve: commit approve: push approve: commit+push
12
+ # onay: commit onay: push onay: commit+push
13
+ # A sentence that contains those words is not an approval, and neither is a message with a second line: text
14
+ # that was pasted, quoted or relayed from somewhere else never has that shape by accident.
15
+ #
16
+ # WHAT IS RECORDED is measured here, never taken from the message:
17
+ # commit -> the id of the tree the index writes + HEAD, so the approval covers exactly what was staged
18
+ # when the user answered
19
+ # push -> HEAD + the current branch + the remote that branch pushes to, and the address it pushes to
20
+ # commit+push -> both; the push half is checked against the commit the approved index became
21
+ # (its parent is the recorded HEAD and its tree is the recorded tree)
22
+ # plus the session and the time. guard-bash.sh reads the record back in `auto` / `dontAsk` and allows only a command that matches
23
+ # it; 30 minutes later, or at the user's next message, it is gone. Nothing here opens §4.5 or §4.6.
24
+ #
25
+ # WHERE. `crewforth-approval` in the worktree's git directory: never in `git status`, never committed, the same
26
+ # place in both editions. A record the model can produce is not a record of the user, so guard-write.sh refuses the
27
+ # file tools on it, and guard-bash.sh refuses a shell command that names it, one that feeds THIS hook a payload of
28
+ # its own, and (in auto / dontAsk) one that starts a session continuing this one. HONEST SCOPE: those rules match
29
+ # names, and the shell is Turing-complete - a command that reaches the same thing without spelling it is past them,
30
+ # exactly as it is past the rules that protect the gate scripts.
31
+ #
32
+ # COST. This runs on every prompt, so the ordinary one is builtins only: no process is started unless the
33
+ # message is an approval (then git is asked what is staged). eval/smoke-test.sh pins both.
34
+ set -uo pipefail
35
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
36
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
37
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
38
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
39
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
40
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
41
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
42
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
43
+ # staged key all passed.
44
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
45
+ # scan for private strings read that as "no match".
46
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
47
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
48
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
49
+ # Byte-identical in every gate; the suite pins it.
50
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
51
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
52
+ export LC_ALL=C
53
+ # ---- /CREW-LOCALE
54
+ IFS= read -r -d '' INPUT || true
55
+ case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
56
+ # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
57
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
58
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
59
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
60
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
61
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
62
+ # 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
63
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
64
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
65
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
66
+ # taken from the whole text, and the small pieces from the block.
67
+ local LC_ALL=C
68
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
69
+ _JF=-1
70
+ while [ "$i" -lt "$n" ]; do
71
+ b="${1:i:B+kl-1}"
72
+ case "$b" in *"$2"*)
73
+ j=0; bn=${#b}
74
+ while [ "$j" -lt "$bn" ]; do
75
+ c="${b:j:C+kl-1}"
76
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
77
+ j=$((j+C))
78
+ done ;;
79
+ esac
80
+ i=$((i+B))
81
+ done
82
+ }
83
+ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
84
+ local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
85
+ # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
86
+ # them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
87
+ # continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
88
+ # quote or backslash edge. What this line is worth in SPEED depends on the platform --
89
+ # read the table in _json_unescape below before quoting a number for it.
90
+ 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=("")
91
+ # It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
92
+ # printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
93
+ # caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
94
+ _JS=""
95
+ # Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
96
+ # constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
97
+ # n-byte literal costs O(n^2). It was in this function twice, and both were measured:
98
+ # * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
99
+ # otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
100
+ # Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
101
+ # that version this cost was not reachable -- this file's header had shown the opposite order, and was
102
+ # wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
103
+ # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
104
+ # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
105
+ # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
106
+ # _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
107
+ # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
108
+ # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
109
+ # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
110
+ # {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
111
+ # {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
112
+ # {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
113
+ # In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
114
+ # the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
115
+ # aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
116
+ # and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
117
+ # Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
118
+ # whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
119
+ # _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
120
+ # time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
121
+ # -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
122
+ # chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
123
+ # 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
124
+ # Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
125
+ # bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
126
+ # a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
127
+ # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
128
+ # the battery sees both.
129
+ # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
130
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
131
+ # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
132
+ # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
133
+ # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
134
+ # 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
135
+ # Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
136
+ # real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
137
+ # over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
138
+ # reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
139
+ # THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
140
+ # protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
141
+ # decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
142
+ # roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
143
+ # raising or removing it later is not a tidy-up.
144
+ # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
145
+ # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
146
+ # 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
147
+ # predates this change and belongs to the walk, not to the cap.
148
+ local _cap=64 _seen=0
149
+ while :; do
150
+ _json_find "$hay" "$k" # where the next `"key"` starts
151
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
152
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
153
+ rest="${hay:_JF+${#k}}" # past `"key"`
154
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
155
+ case "$rest" in
156
+ :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
157
+ *) hay="$rest" ;; # a value, or another token: keep looking
158
+ esac
159
+ done
160
+ # THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
161
+ # shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
162
+ # payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
163
+ # refusal fire, which is the honest answer for a value these gates cannot read.
164
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
165
+ case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
166
+ # Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
167
+ n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
168
+ while :; do
169
+ lim=$((cl - j))
170
+ if [ "$lim" -le 0 ]; then
171
+ [ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
172
+ base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
173
+ fi
174
+ [ "$lim" -le "$W" ] || lim=$W
175
+ w="${chunk:j:lim}"
176
+ seg="${w%%\"*}"
177
+ if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
178
+ acc+=("$w"); j=$((j+lim))
179
+ r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
180
+ continue
181
+ fi
182
+ acc+=("$seg"); j=$((j+${#seg}+1))
183
+ r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
184
+ if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
185
+ done
186
+ local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
187
+ }
188
+ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
189
+ # This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
190
+ # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
191
+ # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
192
+ #
193
+ # 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
194
+ # default, and reading the default instead is how a first pass at this got the consequence wrong. A
195
+ # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
196
+ # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
197
+ # not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
198
+ # is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
199
+ # §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
200
+ #
201
+ # Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
202
+ # this comment exists to avoid repeating:
203
+ # * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
204
+ # of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
205
+ # * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
206
+ # substring operation. What that is WORTH depends on the platform, and putting a single number here
207
+ # would have been wrong three separate ways -- it was written as "another 5x" twice before this:
208
+ # macOS / bash 3.2, a locale set .................. 5x
209
+ # Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
210
+ # touches the locale once per escape
211
+ # rather than once per character, so its
212
+ # ratio is smaller and unmeasured)
213
+ # Git Bash, LANG empty -- what Claude Code starts .. nothing at all
214
+ # So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
215
+ # below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
216
+ # alone would justify the line, and it costs nothing. `local` restores the previous locale on return
217
+ # -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
218
+ #
219
+ # Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
220
+ # whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
221
+ # command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
222
+ # (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
223
+ # pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
224
+ # while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
225
+ # for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
226
+ # `\uXXXX` that starts in a chunk ends in it.
227
+ # The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
228
+ # written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
229
+ #
230
+ # Measured, isolated, previous shape -> this one:
231
+ # macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
232
+ # sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
233
+ # Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
234
+ # (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
235
+ # These numbers do not travel, which is why each one names its machine.
236
+ #
237
+ # Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
238
+ # down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
239
+ # the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
240
+ # truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
241
+ # not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
242
+ # the gate cases.
243
+ local LC_ALL=C
244
+ local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
245
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
246
+ n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
247
+ while :; do
248
+ lim=$((cl - j))
249
+ if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
250
+ lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
251
+ if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
252
+ else
253
+ [ "$lim" -gt 0 ] || break
254
+ fi
255
+ [ "$lim" -le "$W" ] || lim=$W
256
+ w="${chunk:j:lim}"
257
+ pre="${w%%\\*}" # the literal run before the next backslash in view
258
+ if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
259
+ acc+=("$pre"); j=$((j+${#pre}+1))
260
+ if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
261
+ c="${chunk:j:1}"; j=$((j+1))
262
+ case "$c" in
263
+ n) acc+=($'\n') ;;
264
+ t) acc+=($'\t') ;;
265
+ r) ;;
266
+ b|f) acc+=(" ") ;;
267
+ 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
268
+ # A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
269
+ # so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
270
+ # decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
271
+ # attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
272
+ # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
273
+ case "$h" in
274
+ 00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
275
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
276
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
277
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
278
+ *) acc+=("?") ;;
279
+ esac ;;
280
+ *) acc+=("$c") ;;
281
+ esac
282
+ done
283
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
284
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
285
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
286
+ }
287
+ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
288
+ # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
289
+ # …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
290
+ # every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
291
+ # per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
292
+ # This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
293
+ # added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
294
+ # keeps the count in the caller's own shell: expansion only, zero forks.
295
+ # The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
296
+ # looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
297
+ # tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
298
+ # * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
299
+ # refusal went blind while the parser happily read the first value.
300
+ # * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
301
+ # above now closes; counting the same form here is what keeps the two from drifting apart again.
302
+ # Sharing ONE definition with the parser is the point: a guard that searches for something else than the
303
+ # thing it guards is the defect, not an implementation detail.
304
+ # WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
305
+ # with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
306
+ # counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
307
+ # token out of its escaped quote plus the string's terminator --
308
+ # {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
309
+ # which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
310
+ # harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
311
+ # {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
312
+ # What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
313
+ # occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
314
+ # payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
315
+ # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
316
+ # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
317
+ # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
318
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
319
+ _KC=0; _KC_CAPPED=0
320
+ while :; do
321
+ _json_find "$hay" "$k"
322
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
323
+ # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
324
+ # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
325
+ # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
326
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
327
+ rest="${hay:_JF+${#k}}"
328
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
329
+ case "$rest" in :*) c=$((c+1)) ;; esac
330
+ hay="$rest"
331
+ done
332
+ }
333
+ # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
334
+ # ---- CREW-APPROVAL-PATH (one definition, carried by prompt-approval.sh and guard-bash.sh; smoke-test pins it) ----
335
+ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktree's git directory, "" outside one
336
+ local d="${1//\\//}" l g
337
+ _AP=""; d="${d%/}"
338
+ while :; do
339
+ if [ -f "$d/.git/HEAD" ]; then _AP="$d/.git/crewforth-approval"; return 0; fi # a git directory, not a folder named .git
340
+ if [ -f "$d/.git" ]; then # a linked worktree or a submodule: `.git` is a one-line pointer
341
+ l=""; IFS= read -r l < "$d/.git" || true; l="${l%$'\r'}"
342
+ case "$l" in "gitdir: "*)
343
+ g="${l#gitdir: }"; g="${g//\\//}"
344
+ case "$g" in /*|[A-Za-z]:*) ;; *) g="$d/$g" ;; esac
345
+ [ -f "$g/HEAD" ] && _AP="$g/crewforth-approval" ;;
346
+ esac
347
+ return 0
348
+ fi
349
+ case "$d" in */*) d="${d%/*}" ;; *) return 0 ;; esac
350
+ done
351
+ }
352
+ # ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
353
+
354
+ # A second "prompt" key cannot come from the text of a prompt (inside a JSON string every quote is escaped), so
355
+ # more than one means the payload is not the shape this hook knows, and nothing is read from it.
356
+ _json_keycount "$INPUT" prompt; _n_prompt=$_KC
357
+ _json_slice "$INPUT" prompt >/dev/null; RAW="$_JS"
358
+
359
+ # ---- CREW-NOT-A-PERSON (one definition, carried by prompt-approval.sh and route-hint.sh; smoke-test pins it) ----
360
+ # Turns that open with one of these are not something a person typed: a finished background task, a subagent's
361
+ # hand-back, a message from another session. Measured on real hand-backs (3.0.1): they do raise UserPromptSubmit.
362
+ _crew_not_a_person(){ # $1 = the prompt as it sits in the payload -> 0 when it is not a person's message
363
+ case "$1" in
364
+ '<task-notification'*|'<system-reminder'*|'<cross-session-message'*|'<local-command-stdout'*|'<agent-message'*\
365
+ |'[SYSTEM NOTIFICATION'*|'[Task '*|'Another Claude session sent a message'*)
366
+ return 0 ;;
367
+ esac
368
+ return 1
369
+ }
370
+ # ---- /CREW-NOT-A-PERSON ----------------------------------------------------------------------------------
371
+ # Such a turn neither gives an approval nor ends one: a review agent handing back between the user's yes and the
372
+ # commit must not cost the user a second yes.
373
+ _crew_not_a_person "$RAW" && exit 0
374
+
375
+ _json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; CWD="$_JU"
376
+ [ -n "$CWD" ] || CWD="${CLAUDE_PROJECT_DIR:-.}"
377
+ CWD="${CWD//\\//}"
378
+ _crew_appr_path "$CWD"; REC="$_AP"
379
+
380
+ # THE USER'S NEXT MESSAGE ENDS THE APPROVAL, whatever it says. Emptied with a redirection, which is a builtin:
381
+ # `rm` would be a process on a hook that runs on every prompt.
382
+ if [ -n "$REC" ] && [ -s "$REC" ]; then : > "$REC" 2>/dev/null || true; fi
383
+
384
+ # ---- is this message an approval? ------------------------------------------------------------------------
385
+ [ "$_n_prompt" = 1 ] || exit 0
386
+ [ "${#RAW}" -le 120 ] || exit 0 # an approval is a few words; a pasted page is not decoded to find that out
387
+ _json_unescape "$RAW" >/dev/null; P="$_JU"
388
+ P="${P//$'\r'/}"
389
+ while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
390
+ while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
391
+ [ "${#P}" -le 40 ] || exit 0
392
+ case "$P" in *$'\n'*) exit 0 ;; *:*) ;; *) exit 0 ;; esac
393
+ _k="${P%%:*}"; _v="${P#*:}"
394
+ _k="${_k//[$' \t']/}"; _v="${_v//[$' \t']/}"
395
+ OP=""
396
+ # In the C locale: under tr_TR an upper-case I does not fold to i, and `ONAY: COMMIT` was not an approval (review).
397
+ _pa_match(){ local LC_ALL=C; shopt -s nocasematch
398
+ case "$_k" in
399
+ approve|onay)
400
+ case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
401
+ esac
402
+ shopt -u nocasematch; }
403
+ _pa_match
404
+ [ -n "$OP" ] || exit 0
405
+
406
+ # From here on the message IS an approval, so every way of not recording it says why - to the user and to the
407
+ # model - instead of leaving a commit to fail later with no explanation.
408
+ say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text and values checked below.
409
+ printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$1" "$2"
410
+ exit 0
411
+ }
412
+ no(){ say "Crewforth: approval NOT recorded - $1" "The user's message is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
413
+
414
+ _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
415
+ case "$PM" in
416
+ auto|dontAsk) ;;
417
+ # Where a prompt reaches the user the gate asks them itself; in plan and bypassPermissions this route is not
418
+ # open (a decision, 3.1.0). Nothing is recorded and nothing is said: guard-bash.sh explains when it refuses.
419
+ *) exit 0 ;;
420
+ esac
421
+ [ -n "$REC" ] || no "this directory is not inside a git repository."
422
+
423
+ # The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
424
+ # command can be handed any prompt.
425
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
426
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) no "this prompt carries no session id to bind the approval to." ;; esac
427
+
428
+ HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
429
+ case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
430
+ TREE=""; BR=""; RM=""; URL=""
431
+ case "$OP" in commit*)
432
+ # THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
433
+ # (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
434
+ TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
435
+ case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
436
+ if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
437
+ else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
438
+ [ "$TREE" != "$_ht" ] || no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again." ;;
439
+ esac
440
+ case "$OP" in *push)
441
+ [ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
442
+ BR="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
443
+ [ -n "$BR" ] || no "HEAD is detached, so there is no branch to bind the push to."
444
+ RM="$(git -C "$CWD" config --get "branch.$BR.pushRemote" 2>/dev/null)" \
445
+ || RM="$(git -C "$CWD" config --get remote.pushDefault 2>/dev/null)" \
446
+ || RM="$(git -C "$CWD" config --get "branch.$BR.remote" 2>/dev/null)" || RM=""
447
+ case "$RM" in ''|.)
448
+ RM="$(git -C "$CWD" remote 2>/dev/null)"
449
+ case "$RM" in ''|*$'\n'*) no "this branch has no upstream and the repository does not have exactly one remote, so the push target is not known. Set the upstream, or approve the commit alone." ;; esac ;;
450
+ esac
451
+ # Both values go into JSON and into a line-per-field record, so anything outside this set is refused, not escaped.
452
+ case "$BR$RM" in *[!A-Za-z0-9._/@+-]*) no "the branch or remote name has a character this record does not carry." ;; esac
453
+ # Where that remote pushes TO, with pushurl and pushInsteadOf applied. The name alone can be pointed elsewhere
454
+ # after the approval (`git config remote.origin.pushurl …`), so the address is part of what was approved.
455
+ URL="$(git -C "$CWD" remote get-url --push "$RM" 2>/dev/null)"
456
+ case "$URL" in ''|*$'\n'*) no "the remote $RM does not push to exactly one address." ;; esac ;;
457
+ esac
458
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
459
+ printf -v TS '%(%s)T' -1
460
+ else TS="$(date +%s)"; fi
461
+ case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
462
+
463
+ { printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
464
+ || no "the record could not be written in the git directory."
465
+
466
+ _h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
467
+ # THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
468
+ # already in what git reports, so the record and the push agree and only the person can tell that "origin" is not
469
+ # where they think (review). Shown to the user alone, with any `user:secret@` taken out, and never put in front of
470
+ # the model.
471
+ _u="$URL"
472
+ case "$_u" in *://*@*) _ua="${_u#*://}"; case "${_ua%%/*}" in *@*) _u="${_u%%://*}://${_ua#*@}" ;; esac ;; esac
473
+ case "$_u" in *[\"\\]*|*[![:print:]]*) _u="an address this line cannot print; check it with: git remote get-url --push $RM" ;; esac
474
+ case "$OP" in
475
+ commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
476
+ HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
477
+ push) WHAT="push of $BR at $_h to $RM"
478
+ HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
479
+ commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
480
+ HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
481
+ esac
482
+ [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
483
+ say "Crewforth: approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
484
+ "The user approved this in their own message and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
@@ -90,11 +90,19 @@ CREW_PROMPT="$(printf '%s' "$IN" | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*
90
90
  # `[Subagent hand-back]` on the next line — measured by capturing this hook's stdin on a real hand-back (3.0.1,
91
91
  # field report P2): the first 80 bytes of `prompt` were exactly that, and the hook named the agent whose work had
92
92
  # just been reported, in three field sessions on two machines.
93
- case "$CREW_PROMPT" in
94
- '<task-notification'*|'<system-reminder'*|'<cross-session-message'*|'<local-command-stdout'*|'<agent-message'*\
95
- |'[SYSTEM NOTIFICATION'*|'[Task '*|'Another Claude session sent a message'*)
96
- exit 0 ;;
97
- esac
93
+ # ---- CREW-NOT-A-PERSON (one definition, carried by prompt-approval.sh and route-hint.sh; smoke-test pins it) ----
94
+ # Turns that open with one of these are not something a person typed: a finished background task, a subagent's
95
+ # hand-back, a message from another session. Measured on real hand-backs (3.0.1): they do raise UserPromptSubmit.
96
+ _crew_not_a_person(){ # $1 = the prompt as it sits in the payload -> 0 when it is not a person's message
97
+ case "$1" in
98
+ '<task-notification'*|'<system-reminder'*|'<cross-session-message'*|'<local-command-stdout'*|'<agent-message'*\
99
+ |'[SYSTEM NOTIFICATION'*|'[Task '*|'Another Claude session sent a message'*)
100
+ return 0 ;;
101
+ esac
102
+ return 1
103
+ }
104
+ # ---- /CREW-NOT-A-PERSON ----------------------------------------------------------------------------------
105
+ _crew_not_a_person "$CREW_PROMPT" && exit 0
98
106
  export CREW_PROMPT
99
107
 
100
108
  # Glob expansion is a shell builtin — no forks. Agents FIRST, then skills: awk reads its arguments in order and