crewforth 3.1.1 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -148,7 +148,14 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
148
148
  # ---- /CREW-JOIN
149
149
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
150
150
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
151
- [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
151
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
152
+ # Where the gate log goes: one rule for every gate and reader (eval/lib/gate-log.sh).
153
+ [ -f "$_crew_d/../eval/lib/gate-log.sh" ] && . "$_crew_d/../eval/lib/gate-log.sh"
154
+ # What a stopped or asked command would have touched (eval/lib/preview.sh), read only when one is stopped or asked.
155
+ _CREW_PV_LIB="$_crew_d/../eval/lib/preview.sh"; unset _crew_d
156
+ # The preview of a command. ALWAYS called as "$(_pv_line …)": the command substitution is the subshell that keeps an
157
+ # exit or a failure in the library from ending this hook or changing its decision (smoke plants both).
158
+ _pv_line(){ [ -f "$_CREW_PV_LIB" ] || return 0; . "$_CREW_PV_LIB" 2>/dev/null && crew_preview "$1" "$2" 2>/dev/null && printf '%s' "${_CREW_PV:-}"; return 0; }
152
159
  # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
153
160
  # of this hook. `read` returns 1 at end of input; the text is read regardless.
154
161
  IFS= read -r -d '' INPUT || true
@@ -168,20 +175,17 @@ IFS= read -r -d '' INPUT || true
168
175
  # token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
169
176
  # buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
170
177
  # positive, which is the only thing it is good for.
171
- # Where the default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
172
- # DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
173
- # the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
174
- # edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
175
- # a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
176
- # runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
177
- # never reach it.
178
+ # Where the log goes. An explicit CREW_GATE_LOG is the operator's call and is used as given. .claude/gate-log.tsv
179
+ # only where writing there cannot surprise anyone: outside a git repo, or where git ignores it (a kit install
180
+ # ignores .claude/; the plugin edition drops into repos the installer never touched, and this repo proved the
181
+ # failure: the suite left a gate-log.tsv in `git status` waiting to be committed). Elsewhere it goes inside the
182
+ # repository's .git, which is never committed. git is asked only when a decision is logged (a block, an approval
183
+ # prompt or a CLAUDE_GIT_OK allow); ordinary commands never reach it.
184
+ # Since 3.2.0 a repository that tracks .claude/ gets the log inside its .git (eval/lib/gate-log.sh), where it was
185
+ # not written at all before.
178
186
  _gatelog_path(){
179
- if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
180
- [ -d ".claude" ] || return 0
181
- if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
182
- git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
183
- fi
184
- printf '%s' ".claude/gate-log.tsv"
187
+ declare -F crew_gate_log_write >/dev/null || return 0
188
+ crew_gate_log_write; printf '%s' "$_CREW_GL"
185
189
  }
186
190
  gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
187
191
  # Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
@@ -720,6 +724,8 @@ block(){
720
724
  gatelog BLOCK "$2" "$1"
721
725
  echo "GUARD (§$2): '$1' stopped AT THE TOOL LEVEL." >&2
722
726
  [ -z "${_BLOCK_NOTE:-}" ] || echo "$_BLOCK_NOTE" >&2
727
+ # What it would have touched, when the rule names a kind (_PV_KIND): read only, after the decision is made.
728
+ if [ -n "${_PV_KIND:-}" ]; then _pvo="$(_pv_line "$_PV_KIND" "${_PV_CMD:-$CMD}")"; [ -n "$_pvo" ] && echo "$_pvo" >&2; fi
723
729
  case "${3:-}" in
724
730
  loss) echo "Nothing here is undone by retrying — but the refusal is not the end of the task. Do the same job the reversible way: name the paths instead of sweeping, and look first (git clean -n, ls) so you act on what you can see. Only if the broad form is genuinely required does the user run it in their own terminal." >&2 ;;
725
731
  history) echo "This rewrites or discards work that is already committed. Ask the person who shares the branch; a new commit usually reaches the same end without rewriting." >&2 ;;
@@ -865,15 +871,15 @@ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag
865
871
  _git_d45(){ # $1 = command text; block() exits on the first rule it breaks
866
872
  local CMD="$1" HAS_GIT=0 _PUSHSEG _seg # has() reads CMD: this one
867
873
  case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; esac
868
- { git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
874
+ { git_has "$CMD" 'reset' && has '--hard'; } && { _PV_KIND=reset; _PV_CMD="$CMD"; block "git reset --hard" "4.5" history; }
869
875
  if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
870
876
  _grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
871
877
  while IFS= read -r _seg; do
872
878
  [ -n "$_seg" ] || continue
873
- _push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
879
+ _push_forces "$_seg" && { _PV_KIND=push; _PV_CMD="$_seg"; block "git push --force" "4.5" history; break; }
874
880
  done <<< "$_PUSHSEG"
875
881
  fi
876
- { git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
882
+ { git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && { _PV_KIND=clean; _PV_CMD="$CMD"; block "git clean -f" "4.5" loss; }
877
883
  return 0
878
884
  }
879
885
  # ---- the quoted argument of a command that does not run it ---------------------------------------------------
@@ -1109,7 +1115,7 @@ case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
1109
1115
  # short cluster, so `rm -Rf /`, `rm -fR /`, `rm -f -r /` and `rm --recursive --force /` all walked past while
1110
1116
  # `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
1111
1117
  # another reaching the identical state. Case, flag order and the long form are all the same command.
1112
- case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && block "destructive rm -rf" "4.5" loss
1118
+ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && { _PV_KIND=rm; _PV_CMD="$CMD"; block "destructive rm -rf" "4.5" loss; }
1113
1119
  # ADDED (3.1.0): `.` and `..` as a target carry none of those three characters and are the widest targets there are —
1114
1120
  # `rm -rf .` at a project root takes the whole tree, `..` its parent (both passed). Narrower than the test above on
1115
1121
  # purpose, so that it adds no noise: `rm` must stand at a command position, `sudo` or not (`git rm -r --cached .`, `npm rm`,
@@ -1121,7 +1127,7 @@ case "$CMD" in *[Rr][Mm]*)
1121
1127
  _rmc="$CMD"
1122
1128
  # a backslash-newline continues the line (`rm -rf \⏎.`); folded only when one is there and the command is short
1123
1129
  case "$CMD" in *\\"$_RMNL"*) [ "${#CMD}" -le 4096 ] && _rmc="${CMD//\\$_RMNL/ }" ;; esac
1124
- [[ $_rmc =~ $_RMDOT ]] && block "destructive rm -rf" "4.5" loss ;; esac
1130
+ [[ $_rmc =~ $_RMDOT ]] && { _PV_KIND=rm; _PV_CMD="$_rmc"; block "destructive rm -rf" "4.5" loss; } ;; esac
1125
1131
  # A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
1126
1132
  # undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
1127
1133
  # Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
@@ -1303,15 +1309,20 @@ case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && _grep "$CMD"
1303
1309
  _HP_B=$' \t' # blank = space or TAB only; a literal `\t` inside [ ] is a backslash and a t to ERE
1304
1310
  _HP_OPT="(--local|--global|--system|--worktree|--show-origin|--show-scope|--includes|--no-includes|-z|--null|--name-only|--bool|--int|--path|--bool-or-int|--expiry-date|--all|--type=[A-Za-z-]+|--file=[^${_HP_B}]+|--blob=[^${_HP_B}]+|(--file|-f|--blob|--type)[${_HP_B}]+[^${_HP_B}]+)"
1305
1311
  _HP_PRE="^[${_HP_B}]*git([${_HP_B}]+(-C[${_HP_B}]+[^${_HP_B}]+|--git-dir=[^${_HP_B}]+|--work-tree=[^${_HP_B}]+))*[${_HP_B}]+config([${_HP_B}]+${_HP_OPT})*[${_HP_B}]+"
1306
- _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core]
1307
- local bsnl=$'\\\n' c="$1" t seg r n=0 nc=0
1308
- local bare="${_HP_PRE}core\\.hooksPath[${_HP_B}]*\$"
1312
+ # THE SAME RULE FOR crewforth.hookschain. When a project's own hook chain lives OUTSIDE the repository, adopt.sh
1313
+ # keeps its path in this clone's git config under that key and the shim reads it on every commit. A write to it
1314
+ # drops the project's chain or points the shim at another directory, so it is judged by the reader above, key for
1315
+ # key: reads pass, every write form is refused, and so is removing or renaming the [crewforth] section.
1316
+ _HP_G='*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*'; _HC_G='*[Hh][Oo][Oo][Kk][Ss][Cc][Hh][Aa][Ii][Nn]*'
1317
+ _hp_blocks() { # $1 = command, $2 = key as an ERE, $3 = glob of the key's name, $4 = its section -> 0 when it writes the key or drops the section
1318
+ local bsnl=$'\\\n' c="$1" key="$2" kg="$3" sec="$4" t seg r n=0 nc=0
1319
+ local bare="${_HP_PRE}${key}[${_HP_B}]*\$"
1309
1320
  local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
1310
1321
  local wr="[${_HP_B}](--unset|--unset-all|--add|--replace-all|--edit|-e|set|unset|--rename-section|--remove-section|rename-section|remove-section)([${_HP_B}]|\$)"
1311
1322
  local -a reads=()
1312
1323
  case "$c" in *"$bsnl"*) _gsub "$c" '\\'$'\n' ' ' bs; c="$_GS" ;; esac # backslash-newline -> a space
1313
1324
  _unquoted "$c"; t="$_GS"
1314
- case "$t" in *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*|*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*) ;; *) return 1 ;; esac # after unquoting: hooks"P"ath
1325
+ [[ $t == $kg || $t == *[Ss][Ee][Cc][Tt][Ii][Oo][Nn]* ]] || return 1 # after unquoting: hooks"P"ath
1315
1326
  shopt -q nocasematch && nc=1; shopt -s nocasematch
1316
1327
  # The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
1317
1328
  # would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
@@ -1327,7 +1338,7 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
1327
1338
  case "$raw" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
1328
1339
  _split_segs "$c"
1329
1340
  for seg in ${_SPL[@]+"${_SPL[@]}"}; do
1330
- [[ $seg == *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]* ]] || continue
1341
+ [[ $seg == $kg ]] || continue
1331
1342
  n=$((n+1)); [ "$n" -gt 64 ] && { reads=(); break; } # past 64, no read is argued: the old rule judges it all
1332
1343
  # A write flag anywhere in the segment disproves the read (`--list --unset core.hooksPath`): git 2.54 refuses
1333
1344
  # those pairs, but the gate does not lean on another tool's option parser.
@@ -1339,14 +1350,15 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
1339
1350
  # Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
1340
1351
  for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
1341
1352
  _unquoted "$c"; t="$_GS"
1342
- _ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && return 0
1343
- _ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?([^;&|[:space:]]+[[:space:]]+)?core([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0 # [core] as the old name OR the new one
1353
+ _ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*'"$key" "$kg" && return 0
1354
+ _ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?([^;&|[:space:]]+[[:space:]]+)?'"$sec"'([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0 # the section as the old name OR the new one
1344
1355
  return 1
1345
1356
  }
1346
1357
  # Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
1347
1358
  # that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
1348
1359
  [ "$HAS_GIT" = 1 ] && _ere i "$CMD" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
1349
- [ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
1360
+ [ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" 'core\.hooksPath' "$_HP_G" core && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
1361
+ [ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" 'crewforth\.hookschain' "$_HC_G" crewforth && block "git config crewforth.hookschain (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper
1350
1362
  # The same setting by two side doors. `git config include.path f` (or includeIf.<cond>.path) makes git read ANOTHER
1351
1363
  # file as configuration, and that file can carry hooksPath; GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
1352
1364
  # hand git a setting through the environment (measured: with them a commit skipped its hooks). Both are refused by
@@ -1359,6 +1371,8 @@ if [ "$HAS_GIT" = 1 ]; then
1359
1371
  # key right after `-c` (measured, 3.1.0 review).
1360
1372
  _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' \
1361
1373
  && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
1374
+ _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+crewforth\.hookschain' "$_HC_G" \
1375
+ && block "git -c crewforth.hookschain (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper
1362
1376
  _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+include(if)?\.' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
1363
1377
  && block "git config include.path (another file read as configuration)" "4.5" tamper
1364
1378
  _ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&][^|;&]*(include\.path|includeif\.)' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
@@ -1367,6 +1381,7 @@ if [ "$HAS_GIT" = 1 ]; then
1367
1381
  _ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&]([^|;&]*[[:space:]])?(--edit|-e)([[:space:]]|$)' '*[Cc][Oo][Nn][Ff][Ii][Gg]*' \
1368
1382
  && block "git config --edit (the configuration file opened for writing)" "4.5" tamper
1369
1383
  case "$_t" in *GIT_CONFIG_*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*) block "core.hooksPath through GIT_CONFIG_ variables (disarms the git hooks)" "4.5" tamper ;; esac
1384
+ case "$_t" in *GIT_CONFIG_*[Hh][Oo][Oo][Kk][Ss][Cc][Hh][Aa][Ii][Nn]*) block "crewforth.hookschain through GIT_CONFIG_ variables (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper ;; esac
1370
1385
  fi
1371
1386
  # A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
1372
1387
  # in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
@@ -1447,8 +1462,14 @@ fi
1447
1462
  # ---- the reader of a call (the commit gate further down describes what it is for) ----
1448
1463
  _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span and every escaped character as one marker
1449
1464
  # (\001 n \001, its text in _C47_Q[n]); redirections set apart from the words they touch; heredoc bodies gone
1465
+ [ "${_C47_SRC-$'\002'}" = "$1" ] && return 0 # the same text, already read in this call
1450
1466
  local s="$1" out="" pre c w hd="" c2 nl=$'\n' n=0 body t bt=0
1451
- _C47_M=$'\001'; _C47_Q=(); _C47_QX=""
1467
+ _C47_M=$'\001'; _C47_Q=(); _C47_QX=""; _C47_SRC="$1"
1468
+ # WHAT THE READER MET, for a caller that accepts only the plainest text (_a44_collapse): b = a backslash that
1469
+ # escapes a character outside quotes · u = a quote that is never closed · x = a double-quoted text holding `$`, a
1470
+ # backtick, a backslash or `!` (other than a message read from a here-document with a quoted delimiter, closed
1471
+ # at once) · a = a $'…' text · c = a comment that was left out. Flags only: what is read does not depend on them.
1472
+ _C47_F=""
1452
1473
  _gsub "$s" $'\r' ''; _gsub "$_GS" "$_C47_M" ''; s="$_GS"
1453
1474
  while :; do
1454
1475
  pre="${s%%[\"\'\\\;\&\|\<\>\#\(\)\`\$$nl]*}"; out="$out$pre"
@@ -1457,22 +1478,45 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
1457
1478
  case "$c" in
1458
1479
  \\) case "$s" in
1459
1480
  "$nl"*) s="${s:1}" ;; # a backslash-newline joins: both characters go
1460
- '') ;;
1461
- *) _C47_Q[n]="${s:0:1}"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)); s="${s:1}" ;; # an escaped character is itself, quoted
1481
+ '') _C47_F="${_C47_F}b" ;; # the last character of the call: it escapes nothing, and goes
1482
+ *) _C47_Q[n]="${s:0:1}"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)); s="${s:1}"; _C47_F="${_C47_F}b" ;; # an escaped character is itself, quoted
1462
1483
  esac ;;
1463
- \') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s="" ;; esac
1484
+ \') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s=""; _C47_F="${_C47_F}u" ;; esac
1464
1485
  _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1465
1486
  \") body=""
1487
+ t=0 # t = 1: the strict form of that message (below)
1466
1488
  case "$s" in
1467
1489
  '$(cat <<'*) # a message read from a here-document: its body is skipped as a whole, quotes and all
1490
+ # THE STRICT FORM, the only substitution an approved call may hold: `"$(cat <<'WORD'` + newline, a body
1491
+ # that ends at the first line that is WORD, then `)"` with nothing but blanks before it. Its body is
1492
+ # literal. Anything looser (an unquoted delimiter, text after it, something after the body) is flagged x.
1493
+ case "$s" in '$(cat <<'\'*)
1494
+ c2="${s:9}"; w="${c2%%\'*}"
1495
+ case "$w" in ''|*[!A-Za-z0-9_]*) ;; *)
1496
+ c2="${c2:${#w}+1}"
1497
+ case "$c2" in "$nl"*) c2="${c2:1}"
1498
+ case "$c2" in
1499
+ "$w$nl"*) c2="${c2:${#w}+1}"; t=1 ;;
1500
+ *"$nl$w$nl"*) pre="${c2%%"$nl$w$nl"*}"; c2="${c2:${#pre}+${#w}+2}"; t=1 ;;
1501
+ esac
1502
+ if [ "$t" = 1 ]; then
1503
+ while :; do case "$c2" in [$' \t']*) c2="${c2:1}" ;; *) break ;; esac; done
1504
+ case "$c2" in ')"'*) ;; *) t=0 ;; esac
1505
+ fi ;;
1506
+ esac ;;
1507
+ esac ;;
1508
+ esac
1509
+ [ "$t" = 1 ] || _C47_F="${_C47_F}x"
1468
1510
  w="${s:8}"; w="${w#-}"; w="${w#[\'\"]}"; w="${w%%[!A-Za-z0-9_]*}"
1469
1511
  case "$s" in *"$nl$w$nl"*) [ -n "$w" ] && { pre="${s%%"$nl$w$nl"*}"; s="${s:${#pre}+${#w}+2}"; body='(a here-document)'; } ;; esac ;;
1470
1512
  esac
1471
1513
  while :; do # to the closing quote that is not escaped
1472
1514
  pre="${s%%[\"\\]*}"
1473
- [ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
1515
+ case "$pre" in *[\$\`\!]*) [ "$t" = 1 ] || _C47_F="${_C47_F}x" ;; esac
1516
+ [ "$pre" = "$s" ] && { body="$body$s"; s=""; _C47_F="${_C47_F}u"; break; }
1474
1517
  c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
1475
1518
  [ "$c2" = '"' ] && break
1519
+ _C47_F="${_C47_F}x"
1476
1520
  body="$body${s:0:1}"; s="${s:1}"
1477
1521
  done
1478
1522
  # "$@" and "${A[@]}" stay quoted and still become SEVERAL words: `set -- -n; git commit -m x "$@"`.
@@ -1480,7 +1524,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
1480
1524
  _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1481
1525
  \$) case "$s" in
1482
1526
  \'*) # $'…' : a quoted text in which a backslash escapes, so a quote after one does not close it
1483
- s="${s:1}"; body=""
1527
+ s="${s:1}"; body=""; _C47_F="${_C47_F}a"
1484
1528
  while :; do
1485
1529
  pre="${s%%[\'\\]*}"
1486
1530
  [ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
@@ -1535,7 +1579,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
1535
1579
  esac
1536
1580
  out="$out$c" ;;
1537
1581
  \#) case "$out" in
1538
- ''|*[$' \t']) case "$s" in *"$nl"*) pre="${s%%"$nl"*}"; s="${s:${#pre}}" ;; *) s="" ;; esac ;; # a comment, to the end of its line
1582
+ ''|*[$' \t']) case "$s" in *"$nl"*) pre="${s%%"$nl"*}"; s="${s:${#pre}}" ;; *) s="" ;; esac; _C47_F="${_C47_F}c" ;; # a comment, to the end of its line
1539
1583
  *) out="$out#" ;;
1540
1584
  esac ;;
1541
1585
  esac
@@ -1557,6 +1601,30 @@ _c47_w(){ # $1 = one token of _C47_T -> _W: the word as the command receives it
1557
1601
  case "$t" in *[\$\*\?\[\{]*) _WX=1 ;; esac
1558
1602
  _W="$_W$t"
1559
1603
  }
1604
+ _c47_q(){ # _C47_T -> _C47_TQ: the same text with every quoted span and escaped character as the one placeholder Q
1605
+ # Split on the marker once (the fields are text, number, text, number, …) and joined: one pass. A first version
1606
+ # cut the markers off one at a time, and each cut scans the string: 9.6 s for 30 KB of escaped characters.
1607
+ # bash does not split on \001 itself (it is the character bash quotes with, measured: one field), so the marker
1608
+ # is first written as \037. A command that holds a \037 of its own is cut the slow way: its fields would shift.
1609
+ [ "${_C47_TQS-$'\002'}" = "$_C47_T" ] && return 0 # this text, already written so in this call
1610
+ local -a P=(); local f o="" b="" k=0 n=0 u=$'\037' m="$_C47_M" s="$_C47_T"
1611
+ case "$s" in
1612
+ *"$u"*) while :; do
1613
+ case "$s" in *"$m"*"$m"*) ;; *) break ;; esac
1614
+ b="${s%%"$m"*}"; s="${s:${#b}+1}"; s="${s#*"$m"}"; o="$o${b}Q"
1615
+ done
1616
+ o="$o$s" ;;
1617
+ *) # The joined text is gathered 64 fields at a time: appending each field to the whole text copies the whole text.
1618
+ _gsub "$s" "$m" "$u"
1619
+ IFS="$u" read -r -d '' -a P <<< "$_GS" || true
1620
+ for f in ${P[@]+"${P[@]}"}; do
1621
+ if [ "$k" = 0 ]; then b="$b$f"; k=1; else b="${b}Q"; k=0; fi
1622
+ n=$((n+1)); [ "$n" -ge 64 ] && { o="$o$b"; b=""; n=0; }
1623
+ done
1624
+ o="$o$b"; o="${o%$'\n'}" ;; # the newline the here-string added
1625
+ esac
1626
+ _C47_TQ="$o"; _C47_TQS="$_C47_T"
1627
+ }
1560
1628
  # The four rules below cost a grep each, so they run only for a command that could name a gate file at all. Builtin.
1561
1629
  _gate_named(){ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*|*[Gg][Ii][Tt][/\\]*[Cc][Oo][Nn][Ff][Ii][Gg]*|*[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]*|*.[Gg][Ii][Tt][Cc][Oo][Nn][Ff][Ii][Gg]*) return 0 ;; esac; return 1; }
1562
1630
  # CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
@@ -1823,7 +1891,7 @@ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a
1823
1891
  t="$CMD_UQ"; _APN=0
1824
1892
  shopt -q nocasematch && nc=1; shopt -s nocasematch
1825
1893
  case "$t" in
1826
- *crewforth-appr*|*crew-approval-pending*) _APN=1 ;;
1894
+ *crewforth-appr*|*crew-approval-pending*|*crew-approval-asked*) _APN=1 ;;
1827
1895
  esac
1828
1896
  [ "$nc" = 0 ] && shopt -u nocasematch
1829
1897
  # FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
@@ -1929,6 +1997,12 @@ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
1929
1997
  fi
1930
1998
  [ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
1931
1999
  _mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
2000
+ # The refuter ledger (hooks/refuter-ledger.sh) by its file name: the agent's name alone is not refused, so a command
2001
+ # that greps for crew-refuter still runs. The same honest scope as the rule above.
2002
+ _rf_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
2003
+ case "$CMD_UQ" in *crew-refuter.tsv*) rc=0 ;; esac
2004
+ [ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
2005
+ _rf_named && block "the refuter ledger named in a command (only Crewforth's hook writes it)" "4.5" tamper
1932
2006
  _appr_in_claude_call && block "a command that hands a Claude Code session an approval as its prompt (only the user's own message approves)" "4.4" tamper
1933
2007
 
1934
2008
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
@@ -2288,43 +2362,28 @@ _a44_collapse(){ # $1 = command, $2 = commit|push, $3 = tool -> 0 and _A44_S (q
2288
2362
  local LC_ALL=C
2289
2363
  case "$s" in *[!\ -~]*|*\?*) _A44_WHY="outside the Bash tool an approved call is plain ASCII on one line (PowerShell reads typographic quotes as quotes); use the Bash tool for any other message"; return 1 ;; esac
2290
2364
  fi
2365
+ # ONE READER. The pairing of quotes used to be done here a second time; the text is now read by _c47_read, and
2366
+ # this function only says which of the things that reader met an approved call may not hold. Blanks and newlines
2367
+ # around the call are no part of it (a newline INSIDE it is a second line, and is refused below as a separator).
2291
2368
  _gsub "$s" $'\r' ''; s="$_GS"
2369
+ while :; do case "$s" in [$' \t\n']*) s="${s:1}" ;; *) break ;; esac; done
2370
+ # A `2>&1` that ends the call goes with those blanks, on a line of its own too: it redirects nothing but the
2371
+ # call's own output (`git commit -m x` + newline + `2>&1` was accepted before the readers were one, and still is).
2292
2372
  while :; do
2293
- pre="${s%%[\"\'\\]*}"; out="$out$pre"
2294
- [ "$pre" = "$s" ] && break
2295
- c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
2296
- case "$c" in
2297
- \\) case "$s" in
2298
- $'\n'*) out="$out "; s="${s:1}" ;; # a backslash-newline joins two lines
2299
- *) _A44_WHY="a backslash outside quotes"; return 1 ;;
2300
- esac ;;
2301
- \') case "$s" in *\'*) ;; *) _A44_WHY="an unpaired quote"; return 1 ;; esac
2302
- body="${s%%\'*}"; s="${s:${#body}+1}"; out="${out}Q" ;;
2303
- *) case "$s" in
2304
- '$(cat <<'\'*)
2305
- rest="${s:9}"; w="${rest%%\'*}"
2306
- case "$w" in ''|*[!A-Za-z0-9_]*) _A44_WHY="a here-document whose delimiter is not a plain quoted word"; return 1 ;; esac
2307
- rest="${rest:${#w}+1}"
2308
- case "$rest" in $'\n'*) rest="${rest:1}" ;; *) _A44_WHY="text on the line that opens the here-document"; return 1 ;; esac
2309
- # The body ends at the FIRST line that is the delimiter, as it does for the shell.
2310
- case "$rest" in
2311
- "$w"$'\n'*) rest="${rest:${#w}+1}" ;;
2312
- *$'\n'"$w"$'\n'*) body="${rest%%$'\n'"$w"$'\n'*}"; rest="${rest:${#body}+${#w}+2}" ;;
2313
- *) _A44_WHY="a here-document that is not closed on its own line"; return 1 ;;
2314
- esac
2315
- while :; do case "$rest" in [$' \t']*) rest="${rest:1}" ;; *) break ;; esac; done
2316
- case "$rest" in
2317
- ')"'*) s="${rest:2}"; out="${out}Q" ;;
2318
- *) _A44_WHY="something follows the here-document inside the substitution"; return 1 ;;
2319
- esac ;;
2320
- *\"*)
2321
- body="${s%%\"*}"
2322
- case "$body" in *[\$\`\\\!]*) _A44_WHY="a double-quoted text that holds \$, a backtick, a backslash or ! (single-quote it, or read the message from a here-document with a quoted delimiter)"; return 1 ;; esac
2323
- s="${s:${#body}+1}"; out="${out}Q" ;;
2324
- *) _A44_WHY="an unpaired quote"; return 1 ;;
2325
- esac ;;
2373
+ case "$s" in
2374
+ *[$' \t\n']) s="${s%?}" ;;
2375
+ *[$' \t\n']'2>&1') s="${s%'2>&1'}" ;;
2376
+ *) break ;;
2326
2377
  esac
2327
2378
  done
2379
+ _c47_read "$s"
2380
+ case "$_C47_F" in
2381
+ *u*) _A44_WHY="an unpaired quote"; return 1 ;;
2382
+ *b*) _A44_WHY="a backslash outside quotes"; return 1 ;;
2383
+ *x*) _A44_WHY="a double-quoted text that holds \$, a backtick, a backslash or ! (single-quote it, or read the message from a here-document with a quoted delimiter)"; return 1 ;;
2384
+ *[ac]*) _A44_WHY="the call is more than one plain command (a separator, a substitution, a redirection, a comment, a glob or a second line)"; return 1 ;;
2385
+ esac
2386
+ _c47_q; out="$_C47_TQ" # a quoted span is one placeholder
2328
2387
  out="${out//$'\t'/ }"; out="${out// 2>&1/ }"
2329
2388
  while :; do case "$out" in [$' \n']*) out="${out:1}" ;; *) break ;; esac; done
2330
2389
  while :; do case "$out" in *[$' \n']) out="${out%?}" ;; *) break ;; esac; done
@@ -2378,7 +2437,7 @@ _p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u]
2378
2437
  return 0
2379
2438
  }
2380
2439
  _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
2381
- local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool
2440
+ local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool tag _h2
2382
2441
  _APW="no approval from the user is on record"
2383
2442
  git_has "$CMD_SEEN" 'commit' && hc=0; git_has "$CMD_SEEN" 'push' && hp=0
2384
2443
  if [ "$hc" = 0 ] && [ "$hp" = 0 ]; then
@@ -2419,7 +2478,17 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
2419
2478
  _p44_scan "$_A44_S" || { _APW="the push is not in the one form an approval covers, git push <remote> <branch> ($_P44_WHY)"; return 1; }
2420
2479
  [ -n "$b" ] && [ -n "$r" ] && [ -n "$u" ] || { _APW="the approval record could not be read"; return 1; }
2421
2480
  if [ "$_P44_REMOTE" != "$r" ]; then _APW="the user approved a push to $r, not to $_P44_REMOTE"; return 1; fi
2422
- case "$_P44_REF" in "$b"|HEAD) ;; *) _APW="the user approved a push of $b, not of $_P44_REF"; return 1 ;; esac
2481
+ # A TAG ON THE APPROVED COMMIT is part of that push (a release is tagged on what was approved): `git push <remote>
2482
+ # <tag>` passes when <tag> is a tag and not also a branch, and it points at the commit the approval covers. A tag
2483
+ # on any other commit, --tags, and a refspec are refused as before.
2484
+ tag=""
2485
+ case "$_P44_REF" in
2486
+ "$b"|HEAD) ;;
2487
+ *[!A-Za-z0-9._/-]*|-*|*..*|*/) _APW="the user approved a push of $b, not of $_P44_REF"; return 1 ;;
2488
+ *) if git -C "$dir" show-ref --verify --quiet "refs/tags/$_P44_REF" 2>/dev/null \
2489
+ && ! git -C "$dir" show-ref --verify --quiet "refs/heads/$_P44_REF" 2>/dev/null; then tag="$_P44_REF"
2490
+ else _APW="the user approved a push of $b, not of $_P44_REF"; return 1; fi ;;
2491
+ esac
2423
2492
  cur="$(git -C "$dir" symbolic-ref --short -q HEAD 2>/dev/null || true)"
2424
2493
  [ "$cur" = "$b" ] || { _APW="the user approved a push of $b, and the branch checked out now is ${cur:-<detached>}"; return 1; }
2425
2494
  # Where the remote pushes TO, with pushurl and pushInsteadOf applied: the name alone can be pointed elsewhere.
@@ -2432,8 +2501,16 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
2432
2501
  cur="$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
2433
2502
  if [ "$op" = push ]; then
2434
2503
  [ "$cur" = "$h" ] || { _APW="HEAD moved after the user approved the push (approved ${h:0:7}, now ${cur:0:7})"; return 1; }
2504
+ if [ -n "$tag" ]; then
2505
+ cur="$(git -C "$dir" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || echo NONE)"
2506
+ [ "$cur" = "$h" ] || { _APW="the tag $tag is on ${cur:0:7}, not on the commit the user approved for push (${h:0:7})"; return 1; }
2507
+ fi
2435
2508
  return 0
2436
2509
  fi
2510
+ if [ -n "$tag" ]; then
2511
+ _h2="$cur"; cur="$(git -C "$dir" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || echo NONE)"
2512
+ [ "$cur" = "$_h2" ] || { _APW="the tag $tag is on ${cur:0:7}, not on the commit the user approved for push (${_h2:0:7})"; return 1; }
2513
+ fi
2437
2514
  # commit+push: HEAD has to be the commit the approved index became.
2438
2515
  cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^' 2>/dev/null || echo NONE)"
2439
2516
  if [ "$h" = NONE ] || [ "$cur" != "$h" ]; then
@@ -2463,12 +2540,130 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
2463
2540
  _APRW="read: ${seen:-no settings file found}"
2464
2541
  return 1
2465
2542
  }
2543
+ # IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
2544
+ # on PreToolUse, prompt-approval.sh on PostToolUse). Asked only to choose which way the refusal offers first.
2545
+ _q_wired(){ declare -F _crew_q_wired >/dev/null 2>&1 && _crew_q_wired; } # defined once, in prompt-approval.sh
2546
+ # MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
2547
+ # from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
2548
+ # asks as it always has.
2549
+ _gb_prh(){ # 0 = a PermissionRequest hook may be wired; _PRH names where
2550
+ local here="${BASH_SOURCE%/*}"
2551
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
2552
+ [ -f "$here/prompt-approval.sh" ] || return 1
2553
+ declare -F _crew_prh_scan >/dev/null 2>&1 || . "$here/prompt-approval.sh"
2554
+ declare -F _crew_prh_scan >/dev/null 2>&1 || return 1
2555
+ _crew_prh_scan
2556
+ }
2466
2557
  allow_approved(){
2467
2558
  gatelog ALLOW 4.4 "the user's own approval message covers this command"
2468
2559
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
2469
2560
  "$(json_escape "§4.4: the user approved this in their own message, and what was approved is what this command does")"
2470
2561
  exit 0
2471
2562
  }
2563
+ # THE WRITTEN APPROVAL (and, in auto, the click). Reached where no prompt can be shown to a person - auto, dontAsk - and
2564
+ # in default / acceptEdits where a PermissionRequest hook may answer the prompt before the person does (3.2.0).
2565
+ _gate_written(){
2566
+ # No prompt reaches a person here, but their own message does: see _approval_ok above.
2567
+ _approval_ok && allow_approved
2568
+ [ -n "${_PRF:-}" ] || { _PRF=0; _gb_prh && _PRF=1; } # the default branch has already asked
2569
+ gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
2570
+ case "$PERM_MODE" in
2571
+ default|acceptEdits)
2572
+ echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and a PermissionRequest hook may be wired ($_PRH). Such a hook answers the permission prompt before the person does, so this gate does not raise one." >&2 ;;
2573
+ *)
2574
+ echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
2575
+ [ "$_PRF" = 1 ] && echo "The approval question is closed too: a PermissionRequest hook may be wired ($_PRH), and it can answer the question before the person does." >&2 ;;
2576
+ esac
2577
+ echo "Not allowed now: $_APW." >&2
2578
+ if ! _appr_wired; then
2579
+ # The record is written by one hook. Where it is not wired, a message from the user records nothing, and
2580
+ # telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
2581
+ echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A '/crew-approve' message records nothing here." >&2
2582
+ if [ "$_PRF" = 1 ]; then
2583
+ echo "Tell the user exactly that, in their language, and give them the way that works: the command in their own terminal (or wire prompt-approval.sh, or remove the PermissionRequest hook)." >&2
2584
+ else
2585
+ echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
2586
+ fi
2587
+ echo "Do not create an approval any other way." >&2
2588
+ exit 2
2589
+ fi
2590
+ _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
2591
+ # WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
2592
+ # the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
2593
+ # THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
2594
+ # within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
2595
+ # and the name rule above): a session that could write it would choose what the user's approval covers.
2596
+ _pw=""
2597
+ if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
2598
+ _pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
2599
+ _json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
2600
+ case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
2601
+ if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
2602
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _pn '%(%s)T' -1; else _pn="$(date +%s)"; fi
2603
+ _po=""; _ps=""; _pt=""
2604
+ if [ -f "$_pd/crew-approval-pending" ]; then
2605
+ while IFS= read -r _pl || [ -n "$_pl" ]; do case "$_pl" in sid=*) _ps="${_pl#sid=}" ;; op=*) _po="${_pl#op=}" ;; ts=*) _pt="${_pl#ts=}" ;; esac; done < "$_pd/crew-approval-pending"
2606
+ fi
2607
+ _pw="$_aps"
2608
+ case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
2609
+ if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
2610
+ case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
2611
+ fi
2612
+ { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
2613
+ fi
2614
+ fi
2615
+ # ONE WAY TO ASK. With the question open, the refusal used to print it and then the typed way as an equal choice,
2616
+ # and the session picked the typed one 7 times in 9 (a real `auto` session on rc.3, 2026-10-18): the user was told
2617
+ # to type a command for what one click does. The typed way is now the fallback, said as one.
2618
+ _QOFF=0
2619
+ if [ "$PERM_MODE" = auto ] && [ "$_PRF" = 0 ] && _q_wired && declare -F _crew_q_pre >/dev/null 2>&1 && [ -f "${BASH_SOURCE%/*}/guard-question.sh" ]; then
2620
+ _QOFF=1
2621
+ echo "$_CREW_Q_ASK" >&2
2622
+ echo "{\"questions\":[$_CREW_Q_JSON]}" >&2
2623
+ echo "Their answer is recorded by Crewforth itself (the line it adds says so); then run the command it names. Ask it this way only: do not also give the user a command to type." >&2
2624
+ fi
2625
+ if [ "$_QOFF" = 1 ]; then
2626
+ echo "ONLY IF THAT QUESTION CANNOT BE ASKED (the tool call is refused, or the user dismisses it), ask instead, in their language, in this shape and with nothing after it:" >&2
2627
+ else
2628
+ echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
2629
+ fi
2630
+ echo " 1. the commit message, whole, in ONE code block;" >&2
2631
+ if [ -n "$_pw" ]; then
2632
+ echo " 2. under it one sentence in their language (If you approve, send only this:), and then the command on a line of its own, exactly as it is:" >&2
2633
+ echo "/crew-approve"$'\n'" That line is the whole message, and only the user can type it. It approves what was just refused here: $_pw. (Spelled out, the user can type /crew-approve commit, push or commit+push instead.)" >&2
2634
+ else
2635
+ echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
2636
+ echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
2637
+ fi
2638
+ echo "Their message has to be that command alone, typed by them: a sentence such as 'go ahead and commit' is not an approval, and you cannot run the command for them. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
2639
+ echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
2640
+ if [ "$_PRF" = 1 ]; then
2641
+ echo "The other way: the command in their own terminal. (Shift+Tab to default/acceptEdits does not help while that hook may be wired: the gate does not raise a prompt it could answer.)" >&2
2642
+ else
2643
+ echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
2644
+ fi
2645
+ echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
2646
+ exit 2
2647
+ }
2648
+ # WHO WROTE THE MESSAGE. The discipline gives the commit message to crew-commit-agent; for a long time nothing
2649
+ # called it and the main thread wrote every message itself. This says so, and never blocks (K7): the latest
2650
+ # crew-review-agent or crew-commit-agent call in the session's transcript, and when it is not the commit agent the
2651
+ # approval carries one line. A call made by a subagent (agent_id in the payload), a push, or a transcript that
2652
+ # cannot be read says nothing: not known is not "did not run". One grep over the transcript, on a commit only
2653
+ # (measured: 0.52 s on a 107 MB transcript, 0.03 s on a 3 MB one, macOS).
2654
+ _commit_agent_note(){
2655
+ _CAN=""
2656
+ git_has "$CMD_SEEN" 'commit' || return 0
2657
+ case "$INPUT" in *'"agent_id"'*) return 0 ;; esac
2658
+ local tp last
2659
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
2660
+ [ -n "$tp" ] && [ -f "$tp" ] || return 0
2661
+ last="$(grep -E '"subagent_type": ?"([a-z0-9-]+:)?crew-(review|commit)-agent"' "$tp" 2>/dev/null | tail -n 1)"
2662
+ case "$last" in *crew-commit-agent*) return 0 ;; esac
2663
+ _CAN="⚠️ crew-commit-agent has not run since the last review: the message above was written by the main thread, not by the agent that owns it (/crew-ship).
2664
+
2665
+ "
2666
+ }
2472
2667
  # Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
2473
2668
  ask_user(){
2474
2669
  gatelog ASK 4.4 "commit/push approval prompt"
@@ -2669,7 +2864,7 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
2669
2864
  if [ "$_WX" = 1 ]; then _C47_CFG="a -c setting the shell expands first"
2670
2865
  else
2671
2866
  shopt -q nocasematch && i=1 || i=0; shopt -s nocasematch
2672
- case "$_W" in *hookspath*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
2867
+ case "$_W" in *hookspath*|*hookschain*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
2673
2868
  [ "$i" = 0 ] && shopt -u nocasematch
2674
2869
  fi ;;
2675
2870
  -C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix) shift ;;
@@ -2947,84 +3142,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
2947
3142
  # arguments.
2948
3143
  _c46_scan() {
2949
3144
  _C46_REDIR=0; _C46_WT=""
2950
- local s="$1" pre rest
2951
- # An ESCAPED quote is not a delimiter, so it is neutralised before anything tries to pair quotes.
2952
- # `git commit -m 'don'\''t break this'` is the canonical POSIX way to put an apostrophe in a
2953
- # single-quoted string, and it was MEASURED refused: the pairing read `'don'` as one span and then lost
2954
- # the rest, leaving `break` looking like a pathspec. Its argv is identical to the `-m "don't break this"`
2955
- # spelling, which was already clean — the same one-command-two-spellings trap as the quoted pathspec, in
2956
- # the over-block direction this time. An apostrophe in a commit message is not an edge case.
2957
- # NORMALISE TO REAL CHARACTERS FIRST, so every rule below sees one shape. Two spellings of the same command
2958
- # reach this code and they are not interchangeable: with `jq` the command arrives DECODED and carries real
2959
- # control characters, and on a stock machine without it the fallback parser leaves JSON's two-character
2960
- # `\r` and `\n` in place. A stock Windows machine is the second case, so there the fallback IS the product.
2961
- #
2962
- # ALL carriage returns go, escaped or real, and THIS is the line that fixes the defect CI caught — stated
2963
- # plainly because the first explanation written here was wrong. A CI run on `windows-latest` refused
2964
- # `git commit \` + CRLF + ` -m c` while the same case passed on macOS AND on a real Windows desktop, and
2965
- # the cause is the TIER: that image has jq, so the command arrives DECODED, while a stock desktop has
2966
- # neither jq nor python3 and sees JSON's two-character escapes. A Windows-native binary also opens stdout
2967
- # in TEXT mode, so every LF it writes becomes CRLF — and a command that already held `\r\n` reaches the
2968
- # hook as `\` + CR + CR + LF. The previous single CRLF fold ate one CR, the continuation rule then looked
2969
- # for `\` + LF, found the other CR in the way, and the lone backslash was read as a pathspec. Stripping
2970
- # every CR handles any number of them with no loop. Isolated by measurement: this one line, applied to the
2971
- # failing version on its own, turns that case green.
2972
- #
2973
- # A LONE CR keeps its verdict rather than its bytes: it vanishes into the token before it, so
2974
- # `git commit -m c<CR>echo done` still leaves `done` a bare token and is still refused — confirmed correct
2975
- # by a Windows session that checked bash's own argv (CR is not in IFS, so git really is handed `done`).
2976
- #
2977
- # The bracket expression `[\\]` is used for every backslash below, and it is NOT what fixed the above. It
2978
- # replaced escaped patterns on a hypothesis about bash 5 reading them differently, and that hypothesis was
2979
- # MEASURED FALSE on bash 5.3.15 — both spellings behave alike there. It stays only because a bracket
2980
- # expression cannot be misread by anyone (calibrated here: `[\\]n` matches a backslash before an `n` and
2981
- # leaves a bare `n` alone) and because it keeps replacements free of backslashes. No correctness claim.
2982
- # Through _gsub (see there): the same substitutions, a piece at a time.
2983
- _gsub "$s" '[\\]r' '' bs; _gsub "$_GS" $'\r' ''; _gsub "$_GS" '[\\]n' $'\n' bs
2984
- _gsub "$_GS" '[\\]'"\\'" Q bs; _gsub "$_GS" '[\\]\"' Q bs; s="$_GS"
2985
- # A quoted span collapses to the single placeholder `Q`, and this is the whole design: the CONTENT of a
2986
- # quote must not be read as an option or a path, but the TOKEN has to survive. The first version DELETED
2987
- # the span, and that was a measured fail-open on Windows — `git commit -m c "a.txt"` and
2988
- # `git commit -m c -- "a.txt"` lost the pathspec entirely and were allowed, while their unquoted spellings
2989
- # were refused, and both commit the same unreviewed line. No trick is needed to get past a gate like that,
2990
- # only the ordinary habit of quoting a path, which is mandatory once it contains a space. The placeholder
2991
- # keeps adjacency too, so `-m"msg"` becomes `-mQ` (an attached value, correct) and not `-m Q`.
2992
- # Double quotes go FIRST: an apostrophe inside a double-quoted message (`-m "don't"`) is ordinary, whereas
2993
- # a double quote inside a single-quoted one is rare, so this order mangles the rarer shape.
2994
- # THE WALK IS BY LENGTH, and only over what is left. It used to be `rest="${s#*\"}"` on the whole string, and
2995
- # that one expansion costs the SQUARE of the distance to the quote in bash (measured, bash 3.2, one call: 13 ms at
2996
- # 5 KB, 108 ms at 10 KB, 317 ms at 21 KB) — once per pair, on a string whose collapsed front keeps growing. A
2997
- # quote-dense commit message therefore took 6 s at 10 KB, 14 s at 16 KB and 335 s at 46 KB (macOS; 14.2 s at
2998
- # 18 KB on Windows), in 3.0.1 too, and a hook that reaches its 600 s timeout does not block. The result is the
2999
- # same string: the text before a pair holds no quote and neither does the `Q` that replaces it, so the next pair
3000
- # is the first one in the remainder (pinned in smoke-test against the old loop, on the same inputs).
3001
- local acc="" p2
3002
- while :; do
3003
- case "$s" in *\"*\"*) ;; *) break ;; esac
3004
- pre="${s%%\"*}"; rest="${s:${#pre}+1}"; p2="${rest%%\"*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
3005
- done
3006
- s="$acc$s"; acc=""
3007
- while :; do
3008
- case "$s" in *\'*\'*) ;; *) break ;; esac
3009
- pre="${s%%\'*}"; rest="${s:${#pre}+1}"; p2="${rest%%\'*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
3010
- done
3011
- s="$acc$s"
3012
- # A backslash-newline is a LINE CONTINUATION, the opposite of a separator: it JOINS. Measured, before this,
3013
- # `git commit \` + newline + ` -m c` refused the commit, because the lone `\` became a token and read as a
3014
- # pathspec. It has to run before the conversion below, or the newline is gone when we look for it.
3015
- _gsub "$s" '[\\]'$'\n' ' ' bs; s="$_GS"
3016
- # A NEWLINE IS A COMMAND SEPARATOR and has to become one, or a multi-line Bash call is misread: measured,
3017
- # `git commit -m c` followed by a line `echo done` refused the commit, because `done` was read as a
3018
- # pathspec. Splitting alone cannot save it — the default IFS eats newlines, so the boundary is gone by the
3019
- # time the walk sees tokens.
3020
- _gsub "$s" $'\n' ';'; s="$_GS"
3021
- # SEPARATORS BECOME THEIR OWN TOKENS. Without this, `git commit -m c; echo done` refused the commit: the
3022
- # token was `c;`, `-m` swallowed it whole, the separator inside it was never seen, and `echo` read as a
3023
- # pathspec. The fail-open twin is worse and was measured too — in
3024
- # `if true; then git commit -m c -- a.txt; fi` the pathspec token was `a.txt;`, which the `--` lookahead
3025
- # dismissed as a separator, so the commit was ALLOWED. Padding fixes both at once, and `&&`/`||` simply
3026
- # become two tokens, which the walk already treats as one boundary.
3027
- _gsub "$s" ';' ' ; '; _gsub "$_GS" '&' ' & '; _gsub "$_GS" '|' ' | '; s="$_GS"
3145
+ # ONE READER. This scan used to pair the quotes itself (double quotes first, then single, an escaped quote
3146
+ # neutralised by hand, a newline turned into `;`), and so did the approval reader; three readings of the same
3147
+ # text could disagree, and one of them did: `git commit -m 'a"' -a ; echo 'b"'` hid `-a` inside quotes paired
3148
+ # the wrong way. It now walks what _c47_read reads (quotes as the shell pairs them, an escaped character as a
3149
+ # character, a here-document's body gone, every separator as ` ; `, a redirection set apart from its word).
3150
+ # A quoted span or an escaped character becomes the one placeholder `Q`: its CONTENT must not be read as an
3151
+ # option or a path, but the TOKEN has to survive, and it keeps its place (`-m"msg"` is `-mQ`, an attached
3152
+ # value; a quoted pathspec is still a word after `--`).
3153
+ local s
3154
+ _c47_read "$1"; _c47_q; s="$_C47_TQ"
3028
3155
  # Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
3029
3156
  # commit could be judged on whatever files happen to sit there.
3030
3157
  local unglob=0
@@ -3227,7 +3354,11 @@ if git_has "$CMD_SEEN" 'commit|push'; then
3227
3354
  # answered by the thing it is gating is not a gate.
3228
3355
  case "$PERM_MODE" in
3229
3356
  default|acceptEdits)
3230
- # A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
3357
+ # A prompt provably reaches the user in these modes: ask, and let them approve in one keypress. UNLESS a
3358
+ # PermissionRequest hook may be wired: it answers that prompt before the person does (measured on 2.1.294:
3359
+ # the Bash prompt this gate raises was approved by such a hook with nobody at the keyboard). Then the typed
3360
+ # approval is the only one left, exactly as in auto.
3361
+ _gb_prh && { _PRF=1; _gate_written; }
3231
3362
  SHORT="$CMD"
3232
3363
  [ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
3233
3364
  # §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
@@ -3235,70 +3366,30 @@ if git_has "$CMD_SEEN" 'commit|push'; then
3235
3366
  BRANCH_WARN=""
3236
3367
  # symbolic-ref names the branch even before its first commit, where rev-parse answers "HEAD" and the very
3237
3368
  # first commit onto main went by without this line. A detached HEAD reads empty: not the default branch.
3238
- case "$(git symbolic-ref --short -q HEAD 2>/dev/null)" in
3369
+ # Read from HEAD with builtins (the gate log's crew_git_dir finds the git dir, a linked worktree's too): the
3370
+ # preview below pays one subshell to keep this hook's decision out of its reach, and this pays it back.
3371
+ _hb=""; if declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && [ -f "$_CREW_GD/HEAD" ]; then
3372
+ IFS= read -r _hb < "$_CREW_GD/HEAD"; _hb="${_hb%$'\r'}"; case "$_hb" in "ref: refs/heads/"*) _hb="${_hb#ref: refs/heads/}" ;; *) _hb="" ;; esac
3373
+ else _hb="$(git symbolic-ref --short -q HEAD 2>/dev/null)"; fi
3374
+ case "$_hb" in
3239
3375
  main|master) BRANCH_WARN="⚠️ This commits DIRECTLY to the default branch. Prefer a feature branch unless you meant to.
3240
3376
 
3241
3377
  " ;;
3242
3378
  esac
3379
+ _commit_agent_note
3380
+ # What it takes or sends: the staged change for a commit, the commits for a push (read only).
3381
+ _PVA=""; case "$CMD" in *push*) _PVA="$(_pv_line push-ask "$CMD")" ;; *) _PVA="$(_pv_line commit "$CMD")" ;; esac
3243
3382
  ask_user "§4.4 commit/push approval gate. Claude wants to run:
3244
3383
 
3245
3384
  $SHORT
3246
3385
 
3247
- ${BRANCH_WARN}Approve only if the commit message above was shown to you and you agree with it. Approving lets Claude run the command itself."
3386
+ ${_PVA:+$_PVA
3387
+
3388
+ }${BRANCH_WARN}${_CAN}Approve only if the commit message above was shown to you and you agree with it. Approving lets Claude run the command itself."
3248
3389
  ;;
3249
3390
  auto|dontAsk)
3250
- # No prompt reaches a person here, but their own message does: see _approval_ok above.
3251
- _approval_ok && allow_approved
3252
- gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
3253
- echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
3254
- echo "Not allowed now: $_APW." >&2
3255
- if ! _appr_wired; then
3256
- # The record is written by one hook. Where it is not wired, a message from the user records nothing, and
3257
- # telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
3258
- echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A '/crew-approve' message records nothing here." >&2
3259
- echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3260
- echo "Do not create an approval any other way." >&2
3261
- exit 2
3262
- fi
3263
- _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
3264
- # WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
3265
- # the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
3266
- # THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
3267
- # within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
3268
- # and the name rule above): a session that could write it would choose what the user's approval covers.
3269
- _pw=""
3270
- if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
3271
- _pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
3272
- _json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
3273
- case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
3274
- if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
3275
- if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _pn '%(%s)T' -1; else _pn="$(date +%s)"; fi
3276
- _po=""; _ps=""; _pt=""
3277
- if [ -f "$_pd/crew-approval-pending" ]; then
3278
- while IFS= read -r _pl || [ -n "$_pl" ]; do case "$_pl" in sid=*) _ps="${_pl#sid=}" ;; op=*) _po="${_pl#op=}" ;; ts=*) _pt="${_pl#ts=}" ;; esac; done < "$_pd/crew-approval-pending"
3279
- fi
3280
- _pw="$_aps"
3281
- case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
3282
- if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
3283
- case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
3284
- fi
3285
- { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
3286
- fi
3287
- fi
3288
- echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
3289
- echo " 1. the commit message, whole, in ONE code block;" >&2
3290
- if [ -n "$_pw" ]; then
3291
- echo " 2. under it one sentence in their language (If you approve, send only this:), and then the command on a line of its own, exactly as it is:" >&2
3292
- echo "/crew-approve"$'\n'" That line is the whole message, and only the user can type it. It approves what was just refused here: $_pw. (Spelled out, the user can type /crew-approve commit, push or commit+push instead.)" >&2
3293
- else
3294
- echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
3295
- echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
3296
- fi
3297
- echo "Their message has to be that command alone, typed by them: a sentence such as 'go ahead and commit' is not an approval, and you cannot run the command for them. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3298
- echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
3299
- echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3300
- echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
3301
- exit 2 ;;
3391
+ _commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
3392
+ _gate_written ;;
3302
3393
  *)
3303
3394
  # bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
3304
3395
  # human, so we fail closed rather than let the gate silently evaporate.