crewforth 3.1.1 → 3.2.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 (78) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +56 -5
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +14 -13
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +117 -6
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-default.sh +36 -0
  30. package/kit/eval/lib/settings-json.awk +15 -4
  31. package/kit/eval/lib/star.sh +23 -7
  32. package/kit/eval/lib/statusline.settings.json +6 -0
  33. package/kit/eval/lib/statusline.sh +100 -0
  34. package/kit/eval/lib/version.sh +41 -0
  35. package/kit/eval/smoke-test.sh +576 -41
  36. package/kit/hooks/agent-outcome.sh +26 -12
  37. package/kit/hooks/guard-agent-model.sh +109 -9
  38. package/kit/hooks/guard-bash.sh +240 -182
  39. package/kit/hooks/guard-question.sh +73 -0
  40. package/kit/hooks/guard-write.sh +21 -0
  41. package/kit/hooks/prompt-approval.sh +320 -103
  42. package/kit/hooks/refuter-ledger.sh +80 -0
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/owned-blobs.tsv +4 -4
  45. package/kit/settings.json +33 -0
  46. package/kit/skills/crew-approve/SKILL.md +7 -0
  47. package/kit/skills/crew-doctor/SKILL.md +5 -1
  48. package/kit/skills/crew-handoff/SKILL.md +3 -2
  49. package/kit/skills/crew-map/SKILL.md +44 -0
  50. package/kit/skills/crew-review/SKILL.md +2 -0
  51. package/kit/skills/crew-update/SKILL.md +1 -1
  52. package/kit/skills/incident-runbook/SKILL.md +2 -2
  53. package/kit/skills/security-scan/references/verify.md +6 -3
  54. package/kit/studio/README.md +12 -0
  55. package/kit/studio/server/index.js +125 -11
  56. package/kit/studio/server/lib/code-stamp.js +35 -0
  57. package/kit/studio/server/lib/crew-map.js +113 -0
  58. package/kit/studio/server/lib/model-outcomes.js +130 -15
  59. package/kit/studio/server/lib/owned-stream.js +56 -0
  60. package/kit/studio/server/lib/pricing.js +3 -0
  61. package/kit/studio/server/lib/projects.js +11 -4
  62. package/kit/studio/server/lib/remote.js +99 -0
  63. package/kit/studio/server/lib/usage.js +74 -14
  64. package/kit/studio/web/app.js +223 -15
  65. package/kit/studio/web/canvas.js +44 -120
  66. package/kit/studio/web/chat.js +82 -17
  67. package/kit/studio/web/graph-plan.js +10 -4
  68. package/kit/studio/web/index.html +10 -5
  69. package/kit/studio/web/liveness.js +14 -0
  70. package/kit/studio/web/map-plan.js +341 -0
  71. package/kit/studio/web/map.js +503 -0
  72. package/kit/studio/web/models-plan.js +61 -7
  73. package/kit/studio/web/models.js +19 -3
  74. package/kit/studio/web/style.css +131 -0
  75. package/kit/studio/web/usage.js +12 -0
  76. package/kit/studio/web/viewport.js +160 -0
  77. package/package.json +6 -5
  78. package/start.sh +10 -0
@@ -1303,15 +1303,20 @@ case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && _grep "$CMD"
1303
1303
  _HP_B=$' \t' # blank = space or TAB only; a literal `\t` inside [ ] is a backslash and a t to ERE
1304
1304
  _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
1305
  _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}]*\$"
1306
+ # THE SAME RULE FOR crewforth.hookschain. When a project's own hook chain lives OUTSIDE the repository, adopt.sh
1307
+ # keeps its path in this clone's git config under that key and the shim reads it on every commit. A write to it
1308
+ # drops the project's chain or points the shim at another directory, so it is judged by the reader above, key for
1309
+ # key: reads pass, every write form is refused, and so is removing or renaming the [crewforth] section.
1310
+ _HP_G='*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*'; _HC_G='*[Hh][Oo][Oo][Kk][Ss][Cc][Hh][Aa][Ii][Nn]*'
1311
+ _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
1312
+ local bsnl=$'\\\n' c="$1" key="$2" kg="$3" sec="$4" t seg r n=0 nc=0
1313
+ local bare="${_HP_PRE}${key}[${_HP_B}]*\$"
1309
1314
  local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
1310
1315
  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
1316
  local -a reads=()
1312
1317
  case "$c" in *"$bsnl"*) _gsub "$c" '\\'$'\n' ' ' bs; c="$_GS" ;; esac # backslash-newline -> a space
1313
1318
  _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
1319
+ [[ $t == $kg || $t == *[Ss][Ee][Cc][Tt][Ii][Oo][Nn]* ]] || return 1 # after unquoting: hooks"P"ath
1315
1320
  shopt -q nocasematch && nc=1; shopt -s nocasematch
1316
1321
  # The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
1317
1322
  # would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
@@ -1327,7 +1332,7 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
1327
1332
  case "$raw" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
1328
1333
  _split_segs "$c"
1329
1334
  for seg in ${_SPL[@]+"${_SPL[@]}"}; do
1330
- [[ $seg == *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]* ]] || continue
1335
+ [[ $seg == $kg ]] || continue
1331
1336
  n=$((n+1)); [ "$n" -gt 64 ] && { reads=(); break; } # past 64, no read is argued: the old rule judges it all
1332
1337
  # A write flag anywhere in the segment disproves the read (`--list --unset core.hooksPath`): git 2.54 refuses
1333
1338
  # those pairs, but the gate does not lean on another tool's option parser.
@@ -1339,14 +1344,15 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
1339
1344
  # Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
1340
1345
  for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
1341
1346
  _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
1347
+ _ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*'"$key" "$kg" && return 0
1348
+ _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
1349
  return 1
1345
1350
  }
1346
1351
  # Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
1347
1352
  # that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
1348
1353
  [ "$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
1354
+ [ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" 'core\.hooksPath' "$_HP_G" core && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
1355
+ [ "$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
1356
  # The same setting by two side doors. `git config include.path f` (or includeIf.<cond>.path) makes git read ANOTHER
1351
1357
  # file as configuration, and that file can carry hooksPath; GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
1352
1358
  # hand git a setting through the environment (measured: with them a commit skipped its hooks). Both are refused by
@@ -1359,6 +1365,8 @@ if [ "$HAS_GIT" = 1 ]; then
1359
1365
  # key right after `-c` (measured, 3.1.0 review).
1360
1366
  _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' \
1361
1367
  && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
1368
+ _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+crewforth\.hookschain' "$_HC_G" \
1369
+ && block "git -c crewforth.hookschain (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper
1362
1370
  _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+include(if)?\.' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
1363
1371
  && block "git config include.path (another file read as configuration)" "4.5" tamper
1364
1372
  _ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&][^|;&]*(include\.path|includeif\.)' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
@@ -1367,6 +1375,7 @@ if [ "$HAS_GIT" = 1 ]; then
1367
1375
  _ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&]([^|;&]*[[:space:]])?(--edit|-e)([[:space:]]|$)' '*[Cc][Oo][Nn][Ff][Ii][Gg]*' \
1368
1376
  && block "git config --edit (the configuration file opened for writing)" "4.5" tamper
1369
1377
  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
1378
+ 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
1379
  fi
1371
1380
  # A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
1372
1381
  # in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
@@ -1447,8 +1456,14 @@ fi
1447
1456
  # ---- the reader of a call (the commit gate further down describes what it is for) ----
1448
1457
  _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span and every escaped character as one marker
1449
1458
  # (\001 n \001, its text in _C47_Q[n]); redirections set apart from the words they touch; heredoc bodies gone
1459
+ [ "${_C47_SRC-$'\002'}" = "$1" ] && return 0 # the same text, already read in this call
1450
1460
  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=""
1461
+ _C47_M=$'\001'; _C47_Q=(); _C47_QX=""; _C47_SRC="$1"
1462
+ # WHAT THE READER MET, for a caller that accepts only the plainest text (_a44_collapse): b = a backslash that
1463
+ # escapes a character outside quotes · u = a quote that is never closed · x = a double-quoted text holding `$`, a
1464
+ # backtick, a backslash or `!` (other than a message read from a here-document with a quoted delimiter, closed
1465
+ # at once) · a = a $'…' text · c = a comment that was left out. Flags only: what is read does not depend on them.
1466
+ _C47_F=""
1452
1467
  _gsub "$s" $'\r' ''; _gsub "$_GS" "$_C47_M" ''; s="$_GS"
1453
1468
  while :; do
1454
1469
  pre="${s%%[\"\'\\\;\&\|\<\>\#\(\)\`\$$nl]*}"; out="$out$pre"
@@ -1457,22 +1472,45 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
1457
1472
  case "$c" in
1458
1473
  \\) case "$s" in
1459
1474
  "$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
1475
+ '') _C47_F="${_C47_F}b" ;; # the last character of the call: it escapes nothing, and goes
1476
+ *) _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
1477
  esac ;;
1463
- \') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s="" ;; esac
1478
+ \') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s=""; _C47_F="${_C47_F}u" ;; esac
1464
1479
  _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1465
1480
  \") body=""
1481
+ t=0 # t = 1: the strict form of that message (below)
1466
1482
  case "$s" in
1467
1483
  '$(cat <<'*) # a message read from a here-document: its body is skipped as a whole, quotes and all
1484
+ # THE STRICT FORM, the only substitution an approved call may hold: `"$(cat <<'WORD'` + newline, a body
1485
+ # that ends at the first line that is WORD, then `)"` with nothing but blanks before it. Its body is
1486
+ # literal. Anything looser (an unquoted delimiter, text after it, something after the body) is flagged x.
1487
+ case "$s" in '$(cat <<'\'*)
1488
+ c2="${s:9}"; w="${c2%%\'*}"
1489
+ case "$w" in ''|*[!A-Za-z0-9_]*) ;; *)
1490
+ c2="${c2:${#w}+1}"
1491
+ case "$c2" in "$nl"*) c2="${c2:1}"
1492
+ case "$c2" in
1493
+ "$w$nl"*) c2="${c2:${#w}+1}"; t=1 ;;
1494
+ *"$nl$w$nl"*) pre="${c2%%"$nl$w$nl"*}"; c2="${c2:${#pre}+${#w}+2}"; t=1 ;;
1495
+ esac
1496
+ if [ "$t" = 1 ]; then
1497
+ while :; do case "$c2" in [$' \t']*) c2="${c2:1}" ;; *) break ;; esac; done
1498
+ case "$c2" in ')"'*) ;; *) t=0 ;; esac
1499
+ fi ;;
1500
+ esac ;;
1501
+ esac ;;
1502
+ esac
1503
+ [ "$t" = 1 ] || _C47_F="${_C47_F}x"
1468
1504
  w="${s:8}"; w="${w#-}"; w="${w#[\'\"]}"; w="${w%%[!A-Za-z0-9_]*}"
1469
1505
  case "$s" in *"$nl$w$nl"*) [ -n "$w" ] && { pre="${s%%"$nl$w$nl"*}"; s="${s:${#pre}+${#w}+2}"; body='(a here-document)'; } ;; esac ;;
1470
1506
  esac
1471
1507
  while :; do # to the closing quote that is not escaped
1472
1508
  pre="${s%%[\"\\]*}"
1473
- [ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
1509
+ case "$pre" in *[\$\`\!]*) [ "$t" = 1 ] || _C47_F="${_C47_F}x" ;; esac
1510
+ [ "$pre" = "$s" ] && { body="$body$s"; s=""; _C47_F="${_C47_F}u"; break; }
1474
1511
  c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
1475
1512
  [ "$c2" = '"' ] && break
1513
+ _C47_F="${_C47_F}x"
1476
1514
  body="$body${s:0:1}"; s="${s:1}"
1477
1515
  done
1478
1516
  # "$@" and "${A[@]}" stay quoted and still become SEVERAL words: `set -- -n; git commit -m x "$@"`.
@@ -1480,7 +1518,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
1480
1518
  _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1481
1519
  \$) case "$s" in
1482
1520
  \'*) # $'…' : a quoted text in which a backslash escapes, so a quote after one does not close it
1483
- s="${s:1}"; body=""
1521
+ s="${s:1}"; body=""; _C47_F="${_C47_F}a"
1484
1522
  while :; do
1485
1523
  pre="${s%%[\'\\]*}"
1486
1524
  [ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
@@ -1535,7 +1573,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
1535
1573
  esac
1536
1574
  out="$out$c" ;;
1537
1575
  \#) 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
1576
+ ''|*[$' \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
1577
  *) out="$out#" ;;
1540
1578
  esac ;;
1541
1579
  esac
@@ -1557,6 +1595,30 @@ _c47_w(){ # $1 = one token of _C47_T -> _W: the word as the command receives it
1557
1595
  case "$t" in *[\$\*\?\[\{]*) _WX=1 ;; esac
1558
1596
  _W="$_W$t"
1559
1597
  }
1598
+ _c47_q(){ # _C47_T -> _C47_TQ: the same text with every quoted span and escaped character as the one placeholder Q
1599
+ # Split on the marker once (the fields are text, number, text, number, …) and joined: one pass. A first version
1600
+ # cut the markers off one at a time, and each cut scans the string: 9.6 s for 30 KB of escaped characters.
1601
+ # bash does not split on \001 itself (it is the character bash quotes with, measured: one field), so the marker
1602
+ # is first written as \037. A command that holds a \037 of its own is cut the slow way: its fields would shift.
1603
+ [ "${_C47_TQS-$'\002'}" = "$_C47_T" ] && return 0 # this text, already written so in this call
1604
+ local -a P=(); local f o="" b="" k=0 n=0 u=$'\037' m="$_C47_M" s="$_C47_T"
1605
+ case "$s" in
1606
+ *"$u"*) while :; do
1607
+ case "$s" in *"$m"*"$m"*) ;; *) break ;; esac
1608
+ b="${s%%"$m"*}"; s="${s:${#b}+1}"; s="${s#*"$m"}"; o="$o${b}Q"
1609
+ done
1610
+ o="$o$s" ;;
1611
+ *) # The joined text is gathered 64 fields at a time: appending each field to the whole text copies the whole text.
1612
+ _gsub "$s" "$m" "$u"
1613
+ IFS="$u" read -r -d '' -a P <<< "$_GS" || true
1614
+ for f in ${P[@]+"${P[@]}"}; do
1615
+ if [ "$k" = 0 ]; then b="$b$f"; k=1; else b="${b}Q"; k=0; fi
1616
+ n=$((n+1)); [ "$n" -ge 64 ] && { o="$o$b"; b=""; n=0; }
1617
+ done
1618
+ o="$o$b"; o="${o%$'\n'}" ;; # the newline the here-string added
1619
+ esac
1620
+ _C47_TQ="$o"; _C47_TQS="$_C47_T"
1621
+ }
1560
1622
  # 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
1623
  _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
1624
  # CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
@@ -1823,7 +1885,7 @@ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a
1823
1885
  t="$CMD_UQ"; _APN=0
1824
1886
  shopt -q nocasematch && nc=1; shopt -s nocasematch
1825
1887
  case "$t" in
1826
- *crewforth-appr*|*crew-approval-pending*) _APN=1 ;;
1888
+ *crewforth-appr*|*crew-approval-pending*|*crew-approval-asked*) _APN=1 ;;
1827
1889
  esac
1828
1890
  [ "$nc" = 0 ] && shopt -u nocasematch
1829
1891
  # FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
@@ -1929,6 +1991,12 @@ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
1929
1991
  fi
1930
1992
  [ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
1931
1993
  _mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
1994
+ # The refuter ledger (hooks/refuter-ledger.sh) by its file name: the agent's name alone is not refused, so a command
1995
+ # that greps for crew-refuter still runs. The same honest scope as the rule above.
1996
+ _rf_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
1997
+ case "$CMD_UQ" in *crew-refuter.tsv*) rc=0 ;; esac
1998
+ [ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
1999
+ _rf_named && block "the refuter ledger named in a command (only Crewforth's hook writes it)" "4.5" tamper
1932
2000
  _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
2001
 
1934
2002
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
@@ -2288,43 +2356,28 @@ _a44_collapse(){ # $1 = command, $2 = commit|push, $3 = tool -> 0 and _A44_S (q
2288
2356
  local LC_ALL=C
2289
2357
  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
2358
  fi
2359
+ # ONE READER. The pairing of quotes used to be done here a second time; the text is now read by _c47_read, and
2360
+ # this function only says which of the things that reader met an approved call may not hold. Blanks and newlines
2361
+ # around the call are no part of it (a newline INSIDE it is a second line, and is refused below as a separator).
2291
2362
  _gsub "$s" $'\r' ''; s="$_GS"
2363
+ while :; do case "$s" in [$' \t\n']*) s="${s:1}" ;; *) break ;; esac; done
2364
+ # A `2>&1` that ends the call goes with those blanks, on a line of its own too: it redirects nothing but the
2365
+ # call's own output (`git commit -m x` + newline + `2>&1` was accepted before the readers were one, and still is).
2292
2366
  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 ;;
2367
+ case "$s" in
2368
+ *[$' \t\n']) s="${s%?}" ;;
2369
+ *[$' \t\n']'2>&1') s="${s%'2>&1'}" ;;
2370
+ *) break ;;
2326
2371
  esac
2327
2372
  done
2373
+ _c47_read "$s"
2374
+ case "$_C47_F" in
2375
+ *u*) _A44_WHY="an unpaired quote"; return 1 ;;
2376
+ *b*) _A44_WHY="a backslash outside quotes"; return 1 ;;
2377
+ *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 ;;
2378
+ *[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 ;;
2379
+ esac
2380
+ _c47_q; out="$_C47_TQ" # a quoted span is one placeholder
2328
2381
  out="${out//$'\t'/ }"; out="${out// 2>&1/ }"
2329
2382
  while :; do case "$out" in [$' \n']*) out="${out:1}" ;; *) break ;; esac; done
2330
2383
  while :; do case "$out" in *[$' \n']) out="${out%?}" ;; *) break ;; esac; done
@@ -2463,12 +2516,130 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
2463
2516
  _APRW="read: ${seen:-no settings file found}"
2464
2517
  return 1
2465
2518
  }
2519
+ # IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
2520
+ # on PreToolUse, prompt-approval.sh on PostToolUse). Asked only to choose which way the refusal offers first.
2521
+ _q_wired(){
2522
+ local f t d="${CLAUDE_PROJECT_DIR:-.}"
2523
+ d="${d//\\//}"; d="${d%/}"
2524
+ for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
2525
+ [ -n "$f" ] && [ -f "$f" ] || continue
2526
+ t=""; IFS= read -r -d '' t < "$f" || true
2527
+ case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
2528
+ done
2529
+ return 1
2530
+ }
2531
+ # MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
2532
+ # from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
2533
+ # asks as it always has.
2534
+ _gb_prh(){ # 0 = a PermissionRequest hook may be wired; _PRH names where
2535
+ local here="${BASH_SOURCE%/*}"
2536
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
2537
+ [ -f "$here/prompt-approval.sh" ] || return 1
2538
+ declare -F _crew_prh_scan >/dev/null 2>&1 || . "$here/prompt-approval.sh"
2539
+ declare -F _crew_prh_scan >/dev/null 2>&1 || return 1
2540
+ _crew_prh_scan
2541
+ }
2466
2542
  allow_approved(){
2467
2543
  gatelog ALLOW 4.4 "the user's own approval message covers this command"
2468
2544
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
2469
2545
  "$(json_escape "§4.4: the user approved this in their own message, and what was approved is what this command does")"
2470
2546
  exit 0
2471
2547
  }
2548
+ # THE WRITTEN APPROVAL (and, in auto, the click). Reached where no prompt can be shown to a person - auto, dontAsk - and
2549
+ # in default / acceptEdits where a PermissionRequest hook may answer the prompt before the person does (3.2.0).
2550
+ _gate_written(){
2551
+ # No prompt reaches a person here, but their own message does: see _approval_ok above.
2552
+ _approval_ok && allow_approved
2553
+ [ -n "${_PRF:-}" ] || { _PRF=0; _gb_prh && _PRF=1; } # the default branch has already asked
2554
+ gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
2555
+ case "$PERM_MODE" in
2556
+ default|acceptEdits)
2557
+ 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 ;;
2558
+ *)
2559
+ 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
2560
+ [ "$_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 ;;
2561
+ esac
2562
+ echo "Not allowed now: $_APW." >&2
2563
+ if ! _appr_wired; then
2564
+ # The record is written by one hook. Where it is not wired, a message from the user records nothing, and
2565
+ # telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
2566
+ 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
2567
+ if [ "$_PRF" = 1 ]; then
2568
+ 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
2569
+ else
2570
+ 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
2571
+ fi
2572
+ echo "Do not create an approval any other way." >&2
2573
+ exit 2
2574
+ fi
2575
+ _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
2576
+ # WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
2577
+ # the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
2578
+ # THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
2579
+ # within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
2580
+ # and the name rule above): a session that could write it would choose what the user's approval covers.
2581
+ _pw=""
2582
+ if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
2583
+ _pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
2584
+ _json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
2585
+ case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
2586
+ if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
2587
+ 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
2588
+ _po=""; _ps=""; _pt=""
2589
+ if [ -f "$_pd/crew-approval-pending" ]; then
2590
+ 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"
2591
+ fi
2592
+ _pw="$_aps"
2593
+ case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
2594
+ if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
2595
+ case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
2596
+ fi
2597
+ { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
2598
+ fi
2599
+ fi
2600
+ if [ "$PERM_MODE" = auto ] && [ "$_PRF" = 0 ] && _q_wired && declare -F _crew_q_pre >/dev/null 2>&1 && [ -f "${BASH_SOURCE%/*}/guard-question.sh" ]; then
2601
+ echo "ASK THE USER WITH ONE CLICK (stage first: the approval covers what is staged at that moment): show the commit message whole, in ONE code block, then call AskUserQuestion with exactly this input, nothing added or changed:" >&2
2602
+ echo "{\"questions\":[$_CREW_Q_JSON]}" >&2
2603
+ echo "Their answer is recorded by Crewforth itself (the line it adds says so); then run the command it names. If they would rather type, the way below works as well." >&2
2604
+ fi
2605
+ 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
2606
+ echo " 1. the commit message, whole, in ONE code block;" >&2
2607
+ if [ -n "$_pw" ]; then
2608
+ 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
2609
+ 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
2610
+ else
2611
+ 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
2612
+ 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
2613
+ fi
2614
+ 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
2615
+ echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
2616
+ if [ "$_PRF" = 1 ]; then
2617
+ 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
2618
+ else
2619
+ echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
2620
+ fi
2621
+ echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
2622
+ exit 2
2623
+ }
2624
+ # WHO WROTE THE MESSAGE. The discipline gives the commit message to crew-commit-agent; for a long time nothing
2625
+ # called it and the main thread wrote every message itself. This says so, and never blocks (K7): the latest
2626
+ # crew-review-agent or crew-commit-agent call in the session's transcript, and when it is not the commit agent the
2627
+ # approval carries one line. A call made by a subagent (agent_id in the payload), a push, or a transcript that
2628
+ # cannot be read says nothing: not known is not "did not run". One grep over the transcript, on a commit only
2629
+ # (measured: 0.52 s on a 107 MB transcript, 0.03 s on a 3 MB one, macOS).
2630
+ _commit_agent_note(){
2631
+ _CAN=""
2632
+ git_has "$CMD_SEEN" 'commit' || return 0
2633
+ case "$INPUT" in *'"agent_id"'*) return 0 ;; esac
2634
+ local tp last
2635
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
2636
+ [ -n "$tp" ] && [ -f "$tp" ] || return 0
2637
+ last="$(grep -E '"subagent_type": ?"([a-z0-9-]+:)?crew-(review|commit)-agent"' "$tp" 2>/dev/null | tail -n 1)"
2638
+ case "$last" in *crew-commit-agent*) return 0 ;; esac
2639
+ _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).
2640
+
2641
+ "
2642
+ }
2472
2643
  # Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
2473
2644
  ask_user(){
2474
2645
  gatelog ASK 4.4 "commit/push approval prompt"
@@ -2669,7 +2840,7 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
2669
2840
  if [ "$_WX" = 1 ]; then _C47_CFG="a -c setting the shell expands first"
2670
2841
  else
2671
2842
  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
2843
+ case "$_W" in *hookspath*|*hookschain*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
2673
2844
  [ "$i" = 0 ] && shopt -u nocasematch
2674
2845
  fi ;;
2675
2846
  -C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix) shift ;;
@@ -2947,84 +3118,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
2947
3118
  # arguments.
2948
3119
  _c46_scan() {
2949
3120
  _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"
3121
+ # ONE READER. This scan used to pair the quotes itself (double quotes first, then single, an escaped quote
3122
+ # neutralised by hand, a newline turned into `;`), and so did the approval reader; three readings of the same
3123
+ # text could disagree, and one of them did: `git commit -m 'a"' -a ; echo 'b"'` hid `-a` inside quotes paired
3124
+ # the wrong way. It now walks what _c47_read reads (quotes as the shell pairs them, an escaped character as a
3125
+ # character, a here-document's body gone, every separator as ` ; `, a redirection set apart from its word).
3126
+ # A quoted span or an escaped character becomes the one placeholder `Q`: its CONTENT must not be read as an
3127
+ # option or a path, but the TOKEN has to survive, and it keeps its place (`-m"msg"` is `-mQ`, an attached
3128
+ # value; a quoted pathspec is still a word after `--`).
3129
+ local s
3130
+ _c47_read "$1"; _c47_q; s="$_C47_TQ"
3028
3131
  # Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
3029
3132
  # commit could be judged on whatever files happen to sit there.
3030
3133
  local unglob=0
@@ -3227,7 +3330,11 @@ if git_has "$CMD_SEEN" 'commit|push'; then
3227
3330
  # answered by the thing it is gating is not a gate.
3228
3331
  case "$PERM_MODE" in
3229
3332
  default|acceptEdits)
3230
- # A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
3333
+ # A prompt provably reaches the user in these modes: ask, and let them approve in one keypress. UNLESS a
3334
+ # PermissionRequest hook may be wired: it answers that prompt before the person does (measured on 2.1.294:
3335
+ # the Bash prompt this gate raises was approved by such a hook with nobody at the keyboard). Then the typed
3336
+ # approval is the only one left, exactly as in auto.
3337
+ _gb_prh && { _PRF=1; _gate_written; }
3231
3338
  SHORT="$CMD"
3232
3339
  [ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
3233
3340
  # §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
@@ -3240,65 +3347,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
3240
3347
 
3241
3348
  " ;;
3242
3349
  esac
3350
+ _commit_agent_note
3243
3351
  ask_user "§4.4 commit/push approval gate. Claude wants to run:
3244
3352
 
3245
3353
  $SHORT
3246
3354
 
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."
3355
+ ${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
3356
  ;;
3249
3357
  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 ;;
3358
+ _commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
3359
+ _gate_written ;;
3302
3360
  *)
3303
3361
  # bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
3304
3362
  # human, so we fail closed rather than let the gate silently evaporate.