crewforth 3.2.0-rc.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 (74) hide show
  1. package/CHANGELOG.md +190 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +37 -3
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +81 -3
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-default.sh +36 -0
  28. package/kit/eval/lib/settings-json.awk +14 -4
  29. package/kit/eval/lib/statusline.sh +4 -19
  30. package/kit/eval/lib/verify-cmd.sh +17 -0
  31. package/kit/eval/lib/version.sh +41 -0
  32. package/kit/eval/review-bench.sh +26 -0
  33. package/kit/eval/smoke-test.sh +424 -26
  34. package/kit/hooks/agent-outcome.sh +38 -9
  35. package/kit/hooks/board-sync.sh +2 -23
  36. package/kit/hooks/guard-agent-model.sh +163 -20
  37. package/kit/hooks/guard-bash.sh +69 -36
  38. package/kit/hooks/guard-commit-scan.sh +10 -2
  39. package/kit/hooks/guard-write.sh +3 -7
  40. package/kit/hooks/precompact-snapshot.sh +75 -0
  41. package/kit/hooks/prompt-approval.sh +74 -5
  42. package/kit/hooks/session-rehydrate.sh +25 -2
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/hooks/trace-blocklist.txt +9 -0
  45. package/kit/settings.json +18 -0
  46. package/kit/skills/crew-code-review/SKILL.md +16 -0
  47. package/kit/skills/crew-map/SKILL.md +16 -5
  48. package/kit/skills/crew-review/SKILL.md +2 -1
  49. package/kit/skills/crew-ship/SKILL.md +3 -1
  50. package/kit/studio/README.md +21 -10
  51. package/kit/studio/server/index.js +85 -22
  52. package/kit/studio/server/lib/code-stamp.js +35 -0
  53. package/kit/studio/server/lib/graph.js +9 -1
  54. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  55. package/kit/studio/server/lib/lan.js +87 -0
  56. package/kit/studio/server/lib/owned-stream.js +56 -0
  57. package/kit/studio/server/lib/projects.js +11 -4
  58. package/kit/studio/server/lib/redact.js +60 -0
  59. package/kit/studio/server/lib/remote.js +104 -18
  60. package/kit/studio/server/lib/session.js +1 -1
  61. package/kit/studio/server/lib/terminal.js +51 -13
  62. package/kit/studio/server/lib/transcript.js +32 -0
  63. package/kit/studio/web/app.js +79 -11
  64. package/kit/studio/web/canvas.js +14 -2
  65. package/kit/studio/web/chat.js +114 -24
  66. package/kit/studio/web/graph-plan.js +3 -2
  67. package/kit/studio/web/liveness.js +14 -0
  68. package/kit/studio/web/map-plan.js +72 -3
  69. package/kit/studio/web/map.js +103 -10
  70. package/kit/studio/web/qr.js +200 -0
  71. package/kit/studio/web/style.css +40 -0
  72. package/kit/studio/web/tokens.css +6 -0
  73. package/package.json +1 -1
  74. package/start.sh +14 -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
@@ -2431,7 +2437,7 @@ _p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u]
2431
2437
  return 0
2432
2438
  }
2433
2439
  _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
2434
- 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
2435
2441
  _APW="no approval from the user is on record"
2436
2442
  git_has "$CMD_SEEN" 'commit' && hc=0; git_has "$CMD_SEEN" 'push' && hp=0
2437
2443
  if [ "$hc" = 0 ] && [ "$hp" = 0 ]; then
@@ -2472,7 +2478,17 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
2472
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; }
2473
2479
  [ -n "$b" ] && [ -n "$r" ] && [ -n "$u" ] || { _APW="the approval record could not be read"; return 1; }
2474
2480
  if [ "$_P44_REMOTE" != "$r" ]; then _APW="the user approved a push to $r, not to $_P44_REMOTE"; return 1; fi
2475
- 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
2476
2492
  cur="$(git -C "$dir" symbolic-ref --short -q HEAD 2>/dev/null || true)"
2477
2493
  [ "$cur" = "$b" ] || { _APW="the user approved a push of $b, and the branch checked out now is ${cur:-<detached>}"; return 1; }
2478
2494
  # Where the remote pushes TO, with pushurl and pushInsteadOf applied: the name alone can be pointed elsewhere.
@@ -2485,8 +2501,16 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
2485
2501
  cur="$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
2486
2502
  if [ "$op" = push ]; then
2487
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
2488
2508
  return 0
2489
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
2490
2514
  # commit+push: HEAD has to be the commit the approved index became.
2491
2515
  cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^' 2>/dev/null || echo NONE)"
2492
2516
  if [ "$h" = NONE ] || [ "$cur" != "$h" ]; then
@@ -2518,16 +2542,7 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
2518
2542
  }
2519
2543
  # IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
2520
2544
  # 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
- }
2545
+ _q_wired(){ declare -F _crew_q_wired >/dev/null 2>&1 && _crew_q_wired; } # defined once, in prompt-approval.sh
2531
2546
  # MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
2532
2547
  # from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
2533
2548
  # asks as it always has.
@@ -2597,12 +2612,21 @@ _gate_written(){
2597
2612
  { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
2598
2613
  fi
2599
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
2600
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
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
2620
+ _QOFF=1
2621
+ echo "$_CREW_Q_ASK" >&2
2602
2622
  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
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
2604
2629
  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
2630
  echo " 1. the commit message, whole, in ONE code block;" >&2
2607
2631
  if [ -n "$_pw" ]; then
2608
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
@@ -3342,17 +3366,26 @@ if git_has "$CMD_SEEN" 'commit|push'; then
3342
3366
  BRANCH_WARN=""
3343
3367
  # symbolic-ref names the branch even before its first commit, where rev-parse answers "HEAD" and the very
3344
3368
  # first commit onto main went by without this line. A detached HEAD reads empty: not the default branch.
3345
- 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
3346
3375
  main|master) BRANCH_WARN="⚠️ This commits DIRECTLY to the default branch. Prefer a feature branch unless you meant to.
3347
3376
 
3348
3377
  " ;;
3349
3378
  esac
3350
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
3351
3382
  ask_user "§4.4 commit/push approval gate. Claude wants to run:
3352
3383
 
3353
3384
  $SHORT
3354
3385
 
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."
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."
3356
3389
  ;;
3357
3390
  auto|dontAsk)
3358
3391
  _commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
@@ -588,8 +588,11 @@ fi
588
588
 
589
589
  # The message carries its own trace risk (a co-author trailer lives there, not in the diff), and commit-msg is
590
590
  # what scans it. Reuse it on the -m value when there is one; an editor-composed message is not visible here and
591
- # stays the git hook's job.
592
- if [ "$FAILED" = 0 ] && [ -x "$DIR/commit-msg" ]; then
591
+ # stays the git hook's job. It is scanned even when the staged content already failed: a refusal that names one
592
+ # problem gets the commit retried with the other still in it (measured on a real project, 2026-10-21: a silenced
593
+ # lint check was named, the session-link trailer in the same message was not, and the message was shown to the
594
+ # user again).
595
+ if [ -x "$DIR/commit-msg" ]; then
593
596
  # The message is tokenized the way a shell does (crew_opt_values), which matters because a real commit message
594
597
  # is MULTI-LINE: a line-oriented `sed` extraction found the subject and stopped, so a co-author trailer on line
595
598
  # 3 — the single most likely §4.1 violation, and the one the bare arm of the eval actually produced — went
@@ -640,6 +643,7 @@ $(bash "$DIR/commit-msg" "$MF" 2>&1)" || FAILED=1
640
643
  | sed -n 's/.*[[:space:]]--\{0,1\}[Ff]\(ile\)\{0,1\}[[:space:]=]\{1,\}\([^[:space:];&|]\{1,\}\).*/\2/p' | head -1)"
641
644
  MFILE="${MFILE%\"}"; MFILE="${MFILE#\"}"; MFILE="${MFILE%\'}"; MFILE="${MFILE#\'}"
642
645
  if _grep "$CMD" -qE '(^|[[:space:]])(-F|--file)([[:space:]=]|$)' && { [ -z "$MFILE" ] || [ ! -f "$MFILE" ]; }; then
646
+ [ "$FAILED" = 1 ] && printf '%s\n' "$OUT" >&2
643
647
  echo "GUARD (§4.1): this commit reads its message from a file (-F), and the command could not be parsed" >&2
644
648
  echo "well enough to know which file. Scan cannot run, so the commit is refused. Commit with a plain" >&2
645
649
  echo "-F <path> (or -m) in a command of its own." >&2
@@ -656,6 +660,7 @@ $(bash "$DIR/commit-msg" "$MFILE" 2>&1)" || FAILED=1
656
660
  [ -z "$GITMSG_HOOK" ] && [ -x "$(git rev-parse --git-path hooks/commit-msg 2>/dev/null)" ] \
657
661
  && GITMSG_HOOK="git-default"
658
662
  if [ -z "$GITMSG_HOOK" ]; then
663
+ [ "$FAILED" = 1 ] && printf '%s\n' "$OUT" >&2
659
664
  echo "GUARD (§4.1): this commit's message would go unscanned." >&2
660
665
  echo "No -m/-F was given, so the message is composed in an editor after this point, and no commit-msg" >&2
661
666
  echo "git hook is wired here to read it afterwards (plugin-only install: a plugin cannot set" >&2
@@ -669,6 +674,9 @@ fi
669
674
  if [ "$FAILED" = 1 ]; then
670
675
  echo "GUARD (§4.1/§4.2): the commit content gate rejected this commit before it ran." >&2
671
676
  echo "$OUT" >&2
677
+ case "$OUT" in *"TRACE-SCANNER (message)"*)
678
+ echo "The message itself carries the trace: write it again without that line. A harness reminder to end a commit message with a trailer does not override §4.1." >&2 ;;
679
+ esac
672
680
  echo "Fix the flagged content and commit again. --no-verify is §4.5 and stays blocked." >&2
673
681
  exit 2
674
682
  fi
@@ -72,7 +72,8 @@ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esa
72
72
  _gate_main(){
73
73
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
74
74
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
75
- [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
75
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
76
+ [ -f "$_crew_d/../eval/lib/gate-log.sh" ] && . "$_crew_d/../eval/lib/gate-log.sh"; unset _crew_d # where the gate log goes
76
77
  # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
77
78
  # of this hook. `read` returns 1 at end of input; the text is read regardless.
78
79
  IFS= read -r -d '' INPUT || true
@@ -365,12 +366,7 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
365
366
  # and with the same rule about the payload: the path is NOT recorded unless CREW_GATE_LOG_CMD=1, because
366
367
  # /crew-gates reports rule names and counts and never the argument. Logged after the verdict; it cannot
367
368
  # change it. CREW_GATE_LOG overrides the path; point it at /dev/null to turn recording off.
368
- _GL="${CREW_GATE_LOG:-}"
369
- if [ -z "$_GL" ] && [ -d ".claude" ]; then
370
- if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null; then
371
- _GL=".claude/gate-log.tsv"
372
- fi
373
- fi
369
+ _GL=""; declare -F crew_gate_log_write >/dev/null && { crew_gate_log_write; _GL="$_CREW_GL"; }
374
370
  if [ -n "$_GL" ]; then
375
371
  if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
376
372
  printf 'BLOCK\t§4.5\t%s\t%s\n' "$1" \
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # PreCompact hook — just before the context is compacted, write down the state of the work in a few lines, so the
3
+ # session that comes back after the compaction is told it (session-rehydrate.sh reads it on SessionStart, source
4
+ # compact). A compaction summary is the model's own retelling; this is what the files and the gates say.
5
+ #
6
+ # What it writes, at most ~2 KB: the branch, the uncommitted files (five named, the rest counted), the last refusal in
7
+ # the gate log, how many findings each document under .crewforth/ holds, and whether a handover
8
+ # (docs/SESSION_STATE.md) is there. With the session id and the trigger, so a snapshot is only read back into the
9
+ # session that wrote it.
10
+ #
11
+ # Where: inside the repository's git directory (crewforth-session-snapshot.json), so it is never committed even in a
12
+ # project that tracks .claude/; outside a repository, .claude/state/session-snapshot.json.
13
+ #
14
+ # NEVER BLOCKS. PreCompact exit 2 blocks the compaction (docs: hooks, PreCompact), and blocking an automatic one at
15
+ # the context limit fails the request that needed it. Every path here ends in exit 0, and nothing is printed: a
16
+ # PreCompact hook's output does not reach the summary.
17
+ set -u
18
+ IN="$(cat 2>/dev/null || true)"
19
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
20
+ LIB="$_here/../eval/lib"
21
+
22
+ ROOT="${CLAUDE_PROJECT_DIR:-}"
23
+ [ -n "$ROOT" ] || ROOT="$(printf '%s' "$IN" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
24
+ [ -n "$ROOT" ] || ROOT="$PWD"
25
+ ROOT="${ROOT//\\\\//}"; ROOT="${ROOT//\\//}"
26
+ cd "$ROOT" 2>/dev/null || exit 0
27
+
28
+ field(){ local v="${IN#*\"$1\"}"; [ "$v" = "$IN" ] && return 1; v="${v#*:}"; v="${v#"${v%%[![:space:]]*}"}"; case "$v" in \"*) v="${v#\"}"; printf '%s' "${v%%\"*}" ;; *) printf '%s' "${v%%[,\}]*}" ;; esac; }
29
+ SID="$(field session_id)" || SID=""
30
+ TRIG="$(field trigger)" || TRIG=""
31
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac # a snapshot no session can claim is not written
32
+
33
+ # ---- what the files and the gates say ----
34
+ LINES=()
35
+ GD=""
36
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
37
+ GD="$(git rev-parse --absolute-git-dir 2>/dev/null)" || GD=""
38
+ BR="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || BR=""
39
+ [ -n "$BR" ] && LINES+=("branch $BR")
40
+ # quotePath off: a name in Turkish (or any non-ASCII) is given as written, not as octal escapes (seen on Windows).
41
+ CH="$(git -c core.quotePath=false status --porcelain 2>/dev/null)" || CH=""
42
+ if [ -n "$CH" ]; then
43
+ n=0; names=""
44
+ while IFS= read -r l; do n=$((n+1)); [ "$n" -le 5 ] && names="${names:+$names, }${l:3}"; done <<< "$CH"
45
+ LINES+=("$n uncommitted file(s): $names$([ "$n" -gt 5 ] && printf ', and %d more' $((n-5)))")
46
+ else LINES+=("nothing uncommitted"); fi
47
+ fi
48
+ if [ -f "$LIB/gate-log.sh" ]; then
49
+ . "$LIB/gate-log.sh"; crew_gate_log_read
50
+ if [ -n "${_CREW_GL:-}" ] && [ -f "$_CREW_GL" ]; then
51
+ last=""; while IFS= read -r l; do case "$l" in BLOCK$'\t'*) last="$l" ;; esac; done < "$_CREW_GL"
52
+ if [ -n "$last" ]; then IFS=$'\t' read -r _ rule why _ <<< "$last"; LINES+=("the last refusal in the gate log: ${rule} ${why:0:120}"); fi
53
+ fi
54
+ fi
55
+ fnd=""
56
+ for f in .crewforth/*.json; do
57
+ [ -f "$f" ] || continue
58
+ c=0; while IFS= read -r l; do case "$l" in *'"rule": '*) c=$((c+1)) ;; esac; done < "$f"
59
+ [ "$c" -gt 0 ] && { b="${f##*/}"; fnd="${fnd:+$fnd, }${b%.json} $c"; }
60
+ done
61
+ [ -n "$fnd" ] && LINES+=("findings on record: $fnd")
62
+ [ -s docs/SESSION_STATE.md ] && LINES+=("a handover is at docs/SESSION_STATE.md")
63
+ [ "${#LINES[@]}" -gt 0 ] || exit 0
64
+
65
+ SUM=""; for l in "${LINES[@]}"; do SUM="${SUM:+$SUM; }$l"; done
66
+ SUM="${SUM:0:1800}"
67
+
68
+ # ---- where it goes ----
69
+ if [ -n "$GD" ] && [ -d "$GD" ]; then OUT="$GD/crewforth-session-snapshot.json"
70
+ else mkdir -p .claude/state 2>/dev/null || exit 0; OUT=".claude/state/session-snapshot.json"; fi
71
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v NOW '%(%Y-%m-%dT%H:%M:%SZ)T' -1; else NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; fi
72
+ ESC="$(printf '%s\n' "$SUM" | LC_ALL=C awk -f "$LIB/json-string.awk" 2>/dev/null)" || exit 0
73
+ printf '{"session_id":"%s","trigger":"%s","at":"%s","summary":"%s"}\n' "$SID" "${TRIG//[!a-z]/}" "$NOW" "$ESC" > "$OUT.$$" 2>/dev/null && mv -f "$OUT.$$" "$OUT" 2>/dev/null
74
+ rm -f "$OUT.$$" 2>/dev/null
75
+ exit 0
@@ -74,7 +74,7 @@ export LC_ALL=C
74
74
  _PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
75
75
  if [ "$_PA_LIB" = 0 ]; then
76
76
  IFS= read -r -d '' INPUT || true
77
- case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*|*'"hook_event_name"'*'"AskUserQuestion"'*|*'"AskUserQuestion"'*'"hook_event_name"'*) ;; *) exit 0 ;; esac
77
+ case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*|*'"hook_event_name"'*'"AskUserQuestion"'*|*'"AskUserQuestion"'*'"hook_event_name"'*|*'"hook_event_name"'*'"Stop"'*) ;; *) exit 0 ;; esac
78
78
  fi
79
79
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
80
80
  _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
@@ -426,6 +426,19 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pe
426
426
  # same four answers and what each one does. guard-bash.sh prints it from here for the model to ask.
427
427
  _CREW_Q_TEXT='Crewforth approval: may Claude commit what is staged now, push this branch, or both?'
428
428
  _CREW_Q_JSON='{"question":"'"$_CREW_Q_TEXT"'","header":"Approval","options":[{"label":"Commit only","description":"Commit exactly what is staged now. No push."},{"label":"Commit + push","description":"Commit what is staged now, then push that commit on this branch."},{"label":"Push only","description":"Push this branch as it is now. No new commit."},{"label":"No","description":"Neither commit nor push."}],"multiSelect":false}'
429
+ # THE ANSWERED QUESTION, IN ANY KEY ORDER. Claude Code hands PostToolUse the question back either as it was asked or
430
+ # re-serialised with its keys sorted (`header,options[description,label],question`, sometimes without multiSelect):
431
+ # 30 of 30 answers in the asked order were recorded and 7 of 7 sorted ones refused, on a byte match (real sessions,
432
+ # 2.1.299 and 2.1.302, 2026-10-18). So the answer side checks every "key":"text" pair of the fixed question, wherever
433
+ # it sits; the exact match stays on PreToolUse, and the tool id binds the answer to the call that passed it.
434
+ _crew_q_is(){ # $1 = a payload -> 0 when it carries every "key":"text" pair of _CREW_Q_JSON
435
+ local rest="$_CREW_Q_JSON" re='("[A-Za-z]+":"[^"]*")' p
436
+ while [[ $rest =~ $re ]]; do
437
+ p="${BASH_REMATCH[1]}"
438
+ case "$1" in *"$p"*) ;; *) return 1 ;; esac
439
+ rest="${rest#*"$p"}"
440
+ done
441
+ }
429
442
  _crew_q_op(){ # $1 = the label the user chose -> OP: commit | commit+push | push | no, or "" for anything else
430
443
  case "$1" in
431
444
  'Commit only') OP=commit ;; 'Commit + push') OP=commit+push ;; 'Push only') OP=push ;; 'No') OP=no ;; *) OP="" ;;
@@ -514,11 +527,36 @@ _crew_prh_scan(){ # -> 0 when a PermissionRequest hook may be wired; _PRH names
514
527
  return "$r"
515
528
  }
516
529
 
530
+ # ---- IS THE QUESTION OPEN HERE? One answer for every hook that offers it ------------------------------------------
531
+ # guard-bash.sh offers it in a refusal; agent-outcome.sh offers it when crew-commit-agent is called in `auto`, so the
532
+ # click is on offer before any commit is tried (B-2, a real session: with the refusal as the only way to it, a session
533
+ # that asked for /crew-approve first never showed it). The text the user is asked stays _CREW_Q_JSON, here only.
534
+ _CREW_Q_HERE="${BASH_SOURCE[0]%/*}"; [ "$_CREW_Q_HERE" = "${BASH_SOURCE[0]}" ] && _CREW_Q_HERE=.
535
+ _crew_q_wired(){ # 0 = a settings file wires both halves on AskUserQuestion (guard-question.sh, then this file)
536
+ local f t d="${CLAUDE_PROJECT_DIR:-.}"
537
+ d="${d//\\//}"; d="${d%/}"
538
+ 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
539
+ [ -n "$f" ] && [ -f "$f" ] || continue
540
+ t=""; IFS= read -r -d '' t < "$f" || true
541
+ case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
542
+ done
543
+ return 1
544
+ }
545
+ _crew_q_open(){ # $1 = permission mode -> 0 when the click may be offered: auto, both halves wired and beside this file, no PermissionRequest hook
546
+ [ "$1" = auto ] && [ -f "$_CREW_Q_HERE/guard-question.sh" ] && _crew_q_wired && ! _crew_prh_scan
547
+ }
548
+ # What the session is told to do, the same words wherever it is offered.
549
+ _CREW_Q_ASK='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:'
550
+
517
551
  # ---- the approval question's context, and its PreToolUse half -----------------------------------------------
518
552
  # Shared by prompt-approval.sh (PostToolUse: it records the answer) and guard-question.sh (PreToolUse: it asks). The
519
553
  # PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
520
554
  # instead of letting the question through with no `ask`; this file also answers UserPromptSubmit, where a refusal
521
555
  # would erase the user's message.
556
+ _crew_q_dir(){ # $1 = cwd -> _QAD (the state folder), ASKED (the call PreToolUse let through), TRIED (asked since the user's last message)
557
+ _QAD="${CLAUDE_PROJECT_DIR:-$1}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"
558
+ ASKED="$_QAD/crew-approval-asked"; TRIED="$_QAD/crew-approval-tried"
559
+ }
522
560
  _crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no decision) when the call is not the approval question
523
561
  _json_keycount "$INPUT" hook_event_name; [ "$_KC" = 1 ] || exit 0
524
562
  _json_keycount "$INPUT" tool_name; [ "$_KC" = 1 ] || exit 0
@@ -531,11 +569,14 @@ _crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no de
531
569
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
532
570
  _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
533
571
  _json_slice "$INPUT" tool_use_id >/dev/null; _QTU="$_JS"
534
- _QAD="${CLAUDE_PROJECT_DIR:-$CWD}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"; ASKED="$_QAD/crew-approval-asked"
572
+ _crew_q_dir "$CWD"
535
573
  if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _QNOW '%(%s)T' -1; else _QNOW="$(date +%s)"; fi
536
574
  }
537
575
  _crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that says why
538
576
  _crew_q_ctx
577
+ # Asked at all, whatever the decision below: a question refused here, or dismissed by the user, leaves the typed
578
+ # way as the one that is left, and the Stop half (_pa_stop) must not send the session back to ask it again.
579
+ [ -n "$SID" ] && [ -d "$_QAD" ] && { printf 'sid=%s\n' "$SID" > "$TRIED"; } 2>/dev/null
539
580
  qno(){ # $1 = why, for the model. The call does not run; where it applies, the typed approval is named as the way left.
540
581
  echo "GUARD (§4.4): the approval question was not asked: $1" >&2
541
582
  [ "${2:-}" = nohint ] || echo "The way that always works is the user's own: the user can type /crew-approve (or /crew-approve commit, push, commit+push) as their whole message." >&2
@@ -666,9 +707,9 @@ _pa_record(){
666
707
  commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
667
708
  HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
668
709
  push) WHAT="push of $BR at $_h to $RM"
669
- HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
710
+ HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained). A tag on that commit goes the same way: git push $RM <tag>." ;;
670
711
  commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
671
- HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
712
+ HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR (a tag on the new commit: git push $RM <tag>)" ;;
672
713
  esac
673
714
  [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
674
715
  _bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
@@ -693,7 +734,7 @@ _pa_question(){
693
734
  : > "$ASKED" 2>/dev/null # one question, one answer
694
735
  case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
695
736
  [ $((_QNOW - _at)) -ge 0 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
696
- case "$INPUT" in *'"questions":['"$_CREW_Q_JSON"']'*) ;; *) no "the question answered is not the fixed one." ;; esac
737
+ _crew_q_is "$INPUT" || no "the question answered is not the fixed one."
697
738
  [ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
698
739
  _crew_prh_scan && no "a PermissionRequest hook may be wired ($_PRH), and it can answer the question before you do. The approval that still works is typed: the user can type /crew-approve instead."
699
740
  _json_keycount "$INPUT" "$_CREW_Q_TEXT"; _an=$_KC
@@ -711,6 +752,32 @@ _pa_question(){
711
752
  BARE=0
712
753
  _pa_record
713
754
  }
755
+ # ---- THE CLICK BEFORE THE TYPED WAY (Stop) -------------------------------------------------------------------
756
+ # The click reached the user only from a refused commit or a call to crew-commit-agent, and a session that wrote the
757
+ # message itself asked the user to type /crew-approve without trying either: 9 of 18 typed asks in two real `auto`
758
+ # sessions on rc.4 (2026-10-18) came in a turn where no commit was tried, most right after crew-review-agent. So the
759
+ # turn's end is where it is caught: the reply names /crew-approve, the question is open, and it was not asked since
760
+ # the user's last message. Then the session is told, once, to ask the click; it never blocks, and stop_hook_active
761
+ # lets the turn end the next time whatever was said.
762
+ _pa_stop(){
763
+ case "$INPUT" in *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) exit 0 ;; esac
764
+ _json_slice "$INPUT" permission_mode >/dev/null; [ "$_JS" = auto ] || exit 0
765
+ _json_slice "$INPUT" last_assistant_message >/dev/null
766
+ case "$_JS" in *'/crew-approve'*) ;; *) exit 0 ;; esac
767
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"; [ -n "$SID" ] || exit 0
768
+ _json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; _crew_q_dir "${_JU:-.}"
769
+ _crew_q_open auto || exit 0
770
+ _al=""; [ -f "$TRIED" ] && { IFS= read -r _al < "$TRIED"; } 2>/dev/null
771
+ [ "$_al" = "sid=$SID" ] && exit 0
772
+ _ao="Crewforth: your reply asks for a typed approval, and the one-click approval question was not asked since the user's last message. If that is a commit or a push waiting for approval, ask with one click now instead: $_CREW_Q_ASK {\"questions\":[$_CREW_Q_JSON]} Their answer is recorded by Crewforth itself (the line it adds says so). If the question is refused or dismissed, the user can type /crew-approve instead. If you only mentioned the command, end your turn."
773
+ _ao="${_ao//\\/\\\\}"; _ao="${_ao//\"/\\\"}" # fixed ASCII text on one line: a backslash and a quote are all it holds
774
+ printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"%s"}}\n' "$_ao"
775
+ exit 0
776
+ }
777
+ _json_keycount "$INPUT" hook_event_name
778
+ if [ "$_KC" = 1 ]; then _json_slice "$INPUT" hook_event_name >/dev/null; [ "$_JS" = Stop ] && _pa_stop; fi
779
+ case "$INPUT" in *'"hook_event_name"'*'"Stop"'*) exit 0 ;; esac # a Stop is never a prompt: it must not end an approval below
780
+
714
781
  case "$INPUT" in *'"AskUserQuestion"'*)
715
782
  _json_slice "$INPUT" hook_event_name >/dev/null
716
783
  case "$_JS" in PostToolUse) EVN="$_JS"; _pa_question ;; esac ;;
@@ -745,6 +812,8 @@ _crew_appr_path "$CWD"; REC="$_AP"
745
812
  # THE USER'S NEXT MESSAGE ENDS THE APPROVAL, whatever it says. Emptied with a redirection, which is a builtin:
746
813
  # `rm` would be a process on a hook that runs on every prompt.
747
814
  if [ -n "$REC" ] && [ -s "$REC" ]; then : > "$REC" 2>/dev/null || true; fi
815
+ # ...and starts a new turn for the Stop half: the question has not been asked since this message.
816
+ _crew_q_dir "$CWD"; [ -s "$TRIED" ] && { : > "$TRIED"; } 2>/dev/null
748
817
 
749
818
  # ---- is this message an approval? ------------------------------------------------------------------------
750
819
  [ "$_n_prompt" = 1 ] || exit 0
@@ -68,11 +68,34 @@ if [ -f "$ROOT/.claude/hooks/pre-commit" ] && git -C "$ROOT" rev-parse --git-dir
68
68
  esac
69
69
  fi
70
70
  [ -n "$HOOKMSG" ] && MSG="${MSG:+$MSG }$HOOKMSG"
71
- [ -n "$MSG" ] || exit 0 # no handover to offer and nothing to say about language -> stay silent
71
+
72
+ # THE STATE BEFORE THE COMPACTION. precompact-snapshot.sh wrote it just before this context was compacted: the
73
+ # branch, the uncommitted files, the last gate refusal, the findings on record. Read back only after a compaction and
74
+ # only into the session that wrote it (same session id); its summary is already JSON-escaped, so it is put into the
75
+ # output as it is, and the rest of MSG stays fixed text that needs no escaping and no extra process.
76
+ SNAP=""
77
+ _rh="${BASH_SOURCE%[/\\]*}"; [ "$_rh" = "${BASH_SOURCE}" ] && _rh=.
78
+ if [ "$SRC" = compact ] && [ -f "$_rh/../eval/lib/gate-log.sh" ]; then
79
+ . "$_rh/../eval/lib/gate-log.sh"
80
+ _sf=""
81
+ if crew_git_dir "$ROOT" && [ -f "$_CREW_GD/crewforth-session-snapshot.json" ]; then _sf="$_CREW_GD/crewforth-session-snapshot.json"
82
+ elif [ -f "$ROOT/.claude/state/session-snapshot.json" ]; then _sf="$ROOT/.claude/state/session-snapshot.json"; fi
83
+ _sid(){ _SID=""; local v="${1#*\"session_id\"}"; [ "$v" = "$1" ] && return 0; v="${v#*\"}"; _SID="${v%%\"*}"; }
84
+ if [ -n "$_sf" ] && IFS= read -r _sj < "$_sf" 2>/dev/null; then
85
+ _sid "$IN"; _mine="$_SID"; _sid "$_sj"
86
+ case "$_sj" in *'"summary":"'*)
87
+ if [ -n "$_mine" ] && [ "$_mine" = "$_SID" ]; then
88
+ _sum="${_sj#*\"summary\":\"}"; _sum="${_sum%\"\}*}"
89
+ SNAP="Before this context was compacted, Crewforth wrote down the state of the work from the files and the gates: $_sum. It is a record of that moment; check the files before relying on it."
90
+ fi ;;
91
+ esac
92
+ fi
93
+ fi
94
+ [ -n "$MSG$SNAP" ] || exit 0 # nothing to offer -> stay silent
72
95
 
73
96
  # hookSpecificOutput.additionalContext is the documented channel that injects text into the model's context.
74
97
  # ONE PATH, no jq: MSG is built from fixed constants with no quote, backslash, control character or newline, so it needs no
75
98
  # escaping and every machine emits the same bytes. If you ever make MSG dynamic, escape it the way board-sync.sh
76
99
  # does (jq-identical, measured) — a raw quote or tab here makes JSON the CLI silently drops.
77
- printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$MSG"
100
+ printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s%s%s"}}\n' "$MSG" "${MSG:+${SNAP:+ }}" "$SNAP"
78
101
  exit 0
@@ -44,7 +44,9 @@
44
44
  set -uo pipefail
45
45
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
46
46
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
47
- [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
47
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
48
+ # How an install's own version is read and ordered (an rc before its final): one definition, eval/lib/version.sh.
49
+ [ -f "$_crew_d/../eval/lib/version.sh" ] && . "$_crew_d/../eval/lib/version.sh"; unset _crew_d
48
50
 
49
51
  URL="${CREW_UPDATE_URL:-https://registry.npmjs.org/-/package/crewforth/dist-tags}"
50
52
  # plugin-stable, not main: the marketplace installs the plugin from that branch, and only an approved release
@@ -139,7 +141,10 @@ PJSON="$PR_ROOT/.claude-plugin/plugin.json"
139
141
  # plugin's copy steps aside whenever a project install is present, instead of asking the project's question too.
140
142
  if [ "$IS_PLUGIN" = 1 ] && [ -f "$CL/VERSION" ]; then exit 0; fi
141
143
  if [ -f "$CL/VERSION" ]; then
142
- read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""; CUR="$(sane_version "$_cv")" || exit 0
144
+ # The install's own file, so an rc (X.Y.Z-rc.N) is a version here; what npm answers is still held to X.Y.Z.
145
+ read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""
146
+ declare -F crew_local_version >/dev/null || exit 0
147
+ CUR="$(crew_local_version "$_cv")" || exit 0
143
148
  STATE="$CL/.state" # repo-local: the install lives in the repo
144
149
  FEED="${CREW_UPDATE_URL:-$URL}"; KEY=latest; EDITION=project
145
150
  ANSWER='bash .claude/hooks/session-update-check.sh --answer'
@@ -186,12 +191,8 @@ fi
186
191
  [ -n "$LATEST" ] || exit 0
187
192
  LATEST="$(sane_version "$LATEST")" || exit 0
188
193
 
189
- # Numeric field compare, one awk. Pre-release suffixes degrade to their numeric prefix (`1-rc2` -> 1), which is
190
- # the harmless direction: it can suppress a question, never invent one.
191
- awk -v a="$LATEST" -v b="$CUR" 'BEGIN{
192
- split(a,x,"."); split(b,y,".");
193
- for(i=1;i<=3;i++){ if(x[i]+0 > y[i]+0) exit 0; if(x[i]+0 < y[i]+0) exit 1 }
194
- exit 1 }' || exit 0
194
+ # One awk, eval/lib/version.sh's order: 3.2.0 is newer than an installed 3.2.0-rc.1, so the rc's user is asked.
195
+ awk -v a="$LATEST" -v b="$CUR" "${CREW_VCMP_AWK:-}"' BEGIN { exit !(vcmp(a, b) > 0) }' 2>/dev/null || exit 0
195
196
 
196
197
  # Skipped for good? Asked (or answered "later") within the last day?
197
198
  if [ -f "$STATE/update-skip" ]; then read -r _sk < "$STATE/update-skip" 2>/dev/null || _sk=""; [ "${_sk//[!0-9.]/}" = "$LATEST" ] && exit 0; fi
@@ -12,6 +12,15 @@
12
12
 
13
13
  Co-Authored-By
14
14
  #test: Co-Authored-By: An Assistant <noreply@example.com>
15
+ # The session-link trailer Claude Code asks the model to end a commit message with: it reached six pushed commits of a
16
+ # project with Crewforth installed (2026-10-18), because nothing here named it. The trailer and the address are both
17
+ # caught, so a message that keeps only one of them is still stopped. One line, not two: each line is one grep on every
18
+ # commit (a process, 62-135 ms on Git Bash).
19
+ Claude-Session:|claude\.ai/code/session_
20
+ #test: Claude-Session: https://claude.ai/code/session_0123456789abcdef
21
+ #test: see https://claude.ai/code/session_0123456789abcdef for the run
22
+ #test-clean: the claude session limit is reached
23
+ #test-clean: https://claude.ai/code is the web app
15
24
  # "Generated by/with" alone matched every machine-written header in existence — a pubspec.lock, an EF Core
16
25
  # scaffold, protoc, openapi-generator. A live --dotnet install could not make its FIRST commit because the
17
26
  # cloned base carries a Dart lockfile saying "Generated by pub". §4.1 is about AI AUTHORSHIP, so the pattern