crewforth 3.2.0-rc.4 → 3.2.0-rc.5

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.
package/CHANGELOG.md CHANGED
@@ -153,6 +153,13 @@ versioning follows [SemVer](https://semver.org/).
153
153
 
154
154
  ### Changed
155
155
 
156
+ - **A typed approval is caught at the turn's end.** The one-click question reached the user only from a refused
157
+ commit or a call to the commit agent, and a session that wrote the message itself asked the user to type
158
+ `/crew-approve` without trying either: 9 of 18 typed asks in two real `auto` sessions. Now, when a reply asks for
159
+ `/crew-approve` in `auto` with the question open and it was not asked since the user's last message, the session
160
+ is told once to ask the click. It never blocks the turn; a question that was refused or dismissed leaves the typed
161
+ way, and a turn's end does not end an approval.
162
+
156
163
  - **A refused commit asks one way.** In `auto`, with the approval question open, the refusal offered the click and
157
164
  the typed `/crew-approve` side by side, and a session picked the typed one 7 times in 9. The click is now the way
158
165
  to ask, and the typed command is only the fallback for a question that cannot be asked.
@@ -213,6 +220,13 @@ versioning follows [SemVer](https://semver.org/).
213
220
 
214
221
  ### Fixed
215
222
 
223
+ - **A click on the approval question is recorded whatever order its keys come back in.** Claude Code hands the
224
+ answered question back either as it was asked or with its keys sorted, and the answer side matched it byte for
225
+ byte: in real `auto` sessions 30 of 30 answers in the asked order were recorded and 7 of 7 sorted ones refused
226
+ ("the question answered is not the fixed one"), so the user clicked and was then told to type `/crew-approve`. The
227
+ answer side now checks each text of the fixed question wherever it sits; the exact match stays where the question
228
+ is asked, and the answer is still bound to that call.
229
+
216
230
  - **The Crew Map says what a project is made of.** A Flutter project read as a pile of Gradle modules and its
217
231
  server as an external API (seen on a real project). The scanner now reads Dart: one module per `pubspec.yaml`,
218
232
  its packages and `path:` references, and a Flutter app's `android/` and `ios/` folders as platforms of the app.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.2.0-rc.4
1
+ 3.2.0-rc.5
@@ -7994,8 +7994,8 @@ _paq_text="$(bash -c '. "$1"; printf "%s" "$_CREW_Q_TEXT"' _ "$HOOKS/prompt-appr
7994
7994
  _paq_pre(){ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"%s","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s]%s},"tool_use_id":"%s"}' "$_patr" "$_paw" "$1" "$2" "${3:-}" "${4:-tu1}" > "$_PA/q.json"
7995
7995
  if [ -n "$JSONQ" ]; then json_ok < "$_PA/q.json" || _paqbad="$_paqbad [pre payload is not JSON]"; fi
7996
7996
  _pao="$( cd "$_paw" && env -u CLAUDE_CODE_SESSION_ATTENDED $_PAQ_ENV ${_PAQ_X:-} bash "$HOOKS/guard-question.sh" < "$_PA/q.json" 2>"$_PA/qerr" )"; _par=$?; }
7997
- # $1 = mode, $2 = the answer, $3 = tool_use_id
7998
- _paq_post(){ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"%s","hook_event_name":"PostToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s],"answers":{"%s":"%s"}},"tool_response":{"questions":[%s],"answers":{"%s":"%s"}},"tool_use_id":"%s"}' "$_patr" "$_paw" "$1" "$_paq_canon" "$_paq_text" "$2" "$_paq_canon" "$_paq_text" "$2" "${3:-tu1}" > "$_PA/q.json"
7997
+ # $1 = mode, $2 = the answer, $3 = tool_use_id, $4 = the questions array's content as Claude Code hands it back (default: as asked)
7998
+ _paq_post(){ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"%s","hook_event_name":"PostToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s],"answers":{"%s":"%s"}},"tool_response":{"questions":[%s],"answers":{"%s":"%s"}},"tool_use_id":"%s"}' "$_patr" "$_paw" "$1" "${4:-$_paq_canon}" "$_paq_text" "$2" "${4:-$_paq_canon}" "$_paq_text" "$2" "${3:-tu1}" > "$_PA/q.json"
7999
7999
  if [ -n "$JSONQ" ]; then json_ok < "$_PA/q.json" || _paqbad="$_paqbad [post payload is not JSON]"; fi
8000
8000
  _pao="$( cd "$_paw" && env -u CLAUDE_CODE_SESSION_ATTENDED $_PAQ_ENV ${_PAQ_X:-} bash "$HOOKS/prompt-approval.sh" < "$_PA/q.json" 2>"$_PA/qerr" )"; _par=$?; }
8001
8001
  _paq_gate(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"%s","tool_name":"Bash","tool_input":{"command":"%s"}}' "$_paw" "$1" "$2" > "$_PA/pl.json"
@@ -8008,6 +8008,11 @@ _paq_is(){ _paqn=$((_paqn+1)); [ "$2" = "$3" ] || _paqbad="$_paqbad [$1: got '$2
8008
8008
  _paq_post auto 'Commit only'; _paq_is 'its answer is recorded' "$(_paq_rec)" commit
8009
8009
  _paq_gate auto 'git commit -m x'; _paq_is 'the recorded click allows the commit' "$_par" 0
8010
8010
  _pa_new; : > "$_pa_rec"; _paq_pre auto "$_paq_canon" '' tu2; _paq_post auto 'Commit + push' tu2; _paq_is 'commit + push' "$(_paq_rec)" commit+push
8011
+ # Claude Code may hand the answered question back with its keys sorted and multiSelect gone: 7 of 7 such answers were
8012
+ # refused on a byte match (real `auto` sessions, 2026-10-18). This is that shape, as it was handed back.
8013
+ _paq_sorted="{\"header\":\"Approval\",\"options\":[{\"description\":\"Commit exactly what is staged now. No push.\",\"label\":\"Commit only\"},{\"description\":\"Commit what is staged now, then push that commit on this branch.\",\"label\":\"Commit + push\"},{\"description\":\"Push this branch as it is now. No new commit.\",\"label\":\"Push only\"},{\"description\":\"Neither commit nor push.\",\"label\":\"No\"}],\"question\":\"$_paq_text\"}"
8014
+ _pa_new; : > "$_pa_rec"; _paq_pre auto "$_paq_canon" '' tu10; _paq_post auto 'Commit + push' tu10 "$_paq_sorted"
8015
+ _paq_is 'the answered question handed back with its keys sorted' "$(_paq_rec)" commit+push
8011
8016
  # must-fail twins: each differs from the round trip in one thing
8012
8017
  _pa_new; : > "$_pa_rec"; _paq_post auto 'Commit only' tu3; _paq_is 'an answer no PreToolUse asked for' "$(_paq_rec)" none
8013
8018
  _paq_pre auto "$_paq_canon" '' tu4; _paq_post auto 'Commit only' tu5; _paq_is 'an answer to another call' "$(_paq_rec)" none
@@ -8016,6 +8021,8 @@ _paq_pre auto "$_paq_canon" '' tu6; _paq_post auto 'Commit only' tu6; : > "$_pa_
8016
8021
  _paq_pre auto "$_paq_canon" '' tu7; _paq_post auto 'HOOK-CHOSE-Commit only' tu7; _paq_is 'an answer that is none of the four' "$(_paq_rec)" none
8017
8022
  _paq_pre auto "$_paq_canon" '' tu8; _paq_post auto 'No' tu8; _paq_is 'No' "$(_paq_rec)" none
8018
8023
  _paq_pre auto "$_paq_canon" '' tu9; _paq_post default 'Commit only' tu9; _paq_is 'an answer recorded outside auto' "$(_paq_rec)" none
8024
+ _paq_pre auto "$_paq_canon" '' tu11; _paq_post auto 'Commit only' tu11 "${_paq_sorted/No new commit./Then force-push.}"
8025
+ _paq_is 'sorted, with one text of the question changed' "$(_paq_rec)" none
8019
8026
  _paq_pre auto '{"question":"Crewforth approval: commit?","header":"Approval","options":[{"label":"Commit only","description":"x"}],"multiSelect":false}'
8020
8027
  _paq_is 'another wording is refused' "$_par" 2
8021
8028
  _paq_pre auto "$_paq_canon" ',"answers":{"x":"Commit only"}'; _paq_is 'a call with answers is refused' "$_par" 2
@@ -8026,8 +8033,8 @@ _paq_pre bypassPermissions "$_paq_canon"; _paq_is 'bypassPermiss
8026
8033
  _PAQ_X="CLAUDE_CODE_SESSION_ATTENDED=0"; _paq_pre auto "$_paq_canon"; unset _PAQ_X; _paq_is 'a headless session: refused' "$_par" 2
8027
8034
  _paq_pre auto '{"question":"Which colour?","header":"Colour","options":[{"label":"Red","description":"r"}],"multiSelect":false}'
8028
8035
  _paq_is 'any other question: no decision' "$_par/$_pao" "0/"
8029
- if [ "$_paqn" != 18 ]; then fail "FIXTURE: the approval-question table ran $_paqn cases, not 18"
8030
- elif [ -z "$_paqbad" ]; then pass "the approval question: asked (ask) only as the fixed text, in auto, attended, with no answers; its answer recorded only for the call that was asked, once, and only as one of the four; the record allows the commit (18 cases, 13 must-fail)"
8036
+ if [ "$_paqn" != 20 ]; then fail "FIXTURE: the approval-question table ran $_paqn cases, not 20"
8037
+ elif [ -z "$_paqbad" ]; then pass "the approval question: asked (ask) only as the fixed text, in auto, attended, with no answers; its answer recorded only for the call that was asked, once, and only as one of the four, in whatever key order Claude Code hands it back; the record allows the commit (20 cases, 14 must-fail)"
8031
8038
  else fail "the approval question:$_paqbad"; fi
8032
8039
  # ...and the way to it. B-2 (a real `auto` session, 2026-10-17): /crew-ship asked for /crew-approve before any commit
8033
8040
  # was refused, so the refusal that prints the question never came and the click was never offered; telling the agent
@@ -8056,6 +8063,48 @@ printf '%s' '{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command
8056
8063
  rm -rf "$_aq"
8057
8064
  [ -z "$_aqbad" ] && pass "in auto, calling crew-commit-agent hands the session the fixed one-click question; not in default, acceptEdits or dontAsk, not for another agent, not with a PermissionRequest hook" \
8058
8065
  || fail "the one-click question hand-over:$_aqbad"
8066
+ # THE CLICK BEFORE THE TYPED WAY, at the turn's end (2026-10-18: 9 of 18 typed asks in two real `auto` sessions on rc.4
8067
+ # came in a turn where no commit was tried, so neither the refusal nor the commit agent's hand-over offered the click).
8068
+ # prompt-approval.sh on Stop: the reply names /crew-approve, auto, the question open, not asked since the user's last
8069
+ # message -> the session is told once to ask the click. Every other case is a twin that differs in one thing.
8070
+ _sq="$(mktemp -d)"; _sq="$(cd -P "$_sq" && pwd)"; mkdir -p "$_sq/p/.claude/state" "$_sq/ccd" "$_sq/home"
8071
+ printf '%s' '{"hooks":{"PreToolUse":[{"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"bash .claude/hooks/guard-question.sh"}]}],"PostToolUse":[{"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}' > "$_sq/p/.claude/settings.json"
8072
+ _SQ_ENV="CLAUDE_PROJECT_DIR=$_sq/p CLAUDE_CONFIG_DIR=$_sq/ccd HOME=$_sq/home CREW_GATE_LOG=/dev/null"
8073
+ # $1 = mode, $2 = the reply, $3 = stop_hook_active, $4 = session id
8074
+ _sq_stop(){ printf '{"session_id":"%s","transcript_path":"%s/t.jsonl","cwd":"%s","permission_mode":"%s","hook_event_name":"Stop","stop_hook_active":%s,"last_assistant_message":"%s"}' "${4:-s1}" "$_sq" "$_sq/p" "$1" "${3:-false}" "$2" > "$_sq/pl.json"
8075
+ ( cd "$_sq/p" && env $_SQ_ENV bash "$HOOKS/prompt-approval.sh" < "$_sq/pl.json" 2>/dev/null ); }
8076
+ _sq_ask='Review is clean. If you approve, send only this:\n/crew-approve commit+push'
8077
+ _sqbad=""; _sqn=0
8078
+ _sq_says(){ _sqn=$((_sqn+1)); case "$2" in *'ASK THE USER WITH ONE CLICK'*"$_paq_text"*) ;; *) _sqbad="$_sqbad [$1: the click was not asked for]" ;; esac
8079
+ if [ -n "$JSONQ" ]; then printf '%s' "$2" | json_ok || _sqbad="$_sqbad [$1: the output is not JSON]"; fi; }
8080
+ _sq_quiet(){ _sqn=$((_sqn+1)); [ -z "$2" ] || _sqbad="$_sqbad [$1: said something: ${2:0:80}]"; }
8081
+ _sq_says 'auto, a typed ask, not asked this turn' "$(_sq_stop auto "$_sq_ask")"
8082
+ _sq_quiet 'stop_hook_active' "$(_sq_stop auto "$_sq_ask" true)"
8083
+ _sq_quiet 'dontAsk' "$(_sq_stop dontAsk "$_sq_ask")"
8084
+ _sq_quiet 'default' "$(_sq_stop default "$_sq_ask")"
8085
+ _sq_quiet 'a reply that names no /crew-approve' "$(_sq_stop auto 'Done: the tests pass.')"
8086
+ # asked this turn: the PreToolUse half writes it down, even when it refuses the question
8087
+ printf '{"session_id":"s1","cwd":"%s","permission_mode":"dontAsk","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s]},"tool_use_id":"tq1"}' "$_sq/p" "$_paq_canon" > "$_sq/pre.json"
8088
+ ( cd "$_sq/p" && env $_SQ_ENV bash "$HOOKS/guard-question.sh" < "$_sq/pre.json" >/dev/null 2>&1 ); _sqr=$?
8089
+ [ "$_sqr" = 2 ] || _sqbad="$_sqbad [FIXTURE: the dontAsk question was not refused (rc $_sqr), so 'refused but asked' is not what runs]"
8090
+ _sq_quiet 'asked this turn (and refused)' "$(_sq_stop auto "$_sq_ask")"
8091
+ _sq_says 'asked in another session' "$(_sq_stop auto "$_sq_ask" false s2)"
8092
+ # the user's next message starts a new turn
8093
+ printf '{"session_id":"s1","cwd":"%s","permission_mode":"auto","hook_event_name":"UserPromptSubmit","prompt":"next"}' "$_sq/p" > "$_sq/ups.json"
8094
+ ( cd "$_sq/p" && env $_SQ_ENV bash "$HOOKS/prompt-approval.sh" < "$_sq/ups.json" >/dev/null 2>&1 )
8095
+ _sq_says 'the user wrote since it was asked' "$(_sq_stop auto "$_sq_ask")"
8096
+ printf '%s' '{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"x"}]}]}}' > "$_sq/ccd/settings.json"
8097
+ _sq_quiet 'a PermissionRequest hook is wired' "$(_sq_stop auto "$_sq_ask")"
8098
+ rm -rf "$_sq"
8099
+ # A Stop is not the user's next message: an approval recorded by a click survives the turn's end, even when the reply
8100
+ # quotes the tool's name (which sends the payload past the AskUserQuestion test).
8101
+ _pa_new; : > "$_pa_rec"; _paq_pre auto "$_paq_canon" '' ts1; _paq_post auto 'Commit only' ts1
8102
+ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"auto","hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"I asked with \\"AskUserQuestion\\"; run /crew-approve if not."}' "$_patr" "$_paw" > "$_PA/st.json"
8103
+ ( cd "$_paw" && env $_PAQ_ENV bash "$HOOKS/prompt-approval.sh" < "$_PA/st.json" >/dev/null 2>&1 )
8104
+ _sqn=$((_sqn+1)); [ "$(_paq_rec)" = commit ] || _sqbad="$_sqbad [a Stop ended the recorded approval: '$(_paq_rec)']"
8105
+ if [ "$_sqn" != 10 ]; then fail "FIXTURE: the Stop-half table ran $_sqn cases, not 10"
8106
+ elif [ -z "$_sqbad" ]; then pass "at the turn's end a reply asking for /crew-approve, in auto with the question open and not asked since the user's last message, is sent back once to ask the click; not after a Stop continuation, outside auto, without the command, once asked (even refused), or with a PermissionRequest hook; a Stop leaves a recorded approval alone (10 cases, 6 must-stay-quiet)"
8107
+ else fail "the Stop half of the approval question:$_sqbad"; fi
8059
8108
  # ONE WAY TO ASK in the refusal (2026-10-18, a real `auto` session on rc.3: the refusal offered the question and the
8060
8109
  # typed command as equals, and the session told the user to type 7 times in 9). With the question open the typed way is
8061
8110
  # printed only as the fallback for a question that cannot be asked; with it closed (dontAsk), only the typed way.
@@ -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="" ;;
@@ -540,6 +553,10 @@ _CREW_Q_ASK='ASK THE USER WITH ONE CLICK (stage first: the approval covers what
540
553
  # PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
541
554
  # instead of letting the question through with no `ask`; this file also answers UserPromptSubmit, where a refusal
542
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
+ }
543
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
544
561
  _json_keycount "$INPUT" hook_event_name; [ "$_KC" = 1 ] || exit 0
545
562
  _json_keycount "$INPUT" tool_name; [ "$_KC" = 1 ] || exit 0
@@ -552,11 +569,14 @@ _crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no de
552
569
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
553
570
  _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
554
571
  _json_slice "$INPUT" tool_use_id >/dev/null; _QTU="$_JS"
555
- _QAD="${CLAUDE_PROJECT_DIR:-$CWD}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"; ASKED="$_QAD/crew-approval-asked"
572
+ _crew_q_dir "$CWD"
556
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
557
574
  }
558
575
  _crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that says why
559
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
560
580
  qno(){ # $1 = why, for the model. The call does not run; where it applies, the typed approval is named as the way left.
561
581
  echo "GUARD (§4.4): the approval question was not asked: $1" >&2
562
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
@@ -714,7 +734,7 @@ _pa_question(){
714
734
  : > "$ASKED" 2>/dev/null # one question, one answer
715
735
  case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
716
736
  [ $((_QNOW - _at)) -ge 0 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
717
- 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."
718
738
  [ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
719
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."
720
740
  _json_keycount "$INPUT" "$_CREW_Q_TEXT"; _an=$_KC
@@ -732,6 +752,32 @@ _pa_question(){
732
752
  BARE=0
733
753
  _pa_record
734
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
+
735
781
  case "$INPUT" in *'"AskUserQuestion"'*)
736
782
  _json_slice "$INPUT" hook_event_name >/dev/null
737
783
  case "$_JS" in PostToolUse) EVN="$_JS"; _pa_question ;; esac ;;
@@ -766,6 +812,8 @@ _crew_appr_path "$CWD"; REC="$_AP"
766
812
  # THE USER'S NEXT MESSAGE ENDS THE APPROVAL, whatever it says. Emptied with a redirection, which is a builtin:
767
813
  # `rm` would be a process on a hook that runs on every prompt.
768
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
769
817
 
770
818
  # ---- is this message an approval? ------------------------------------------------------------------------
771
819
  [ "$_n_prompt" = 1 ] || exit 0
package/kit/settings.json CHANGED
@@ -153,6 +153,12 @@
153
153
  "shell": "bash",
154
154
  "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/session-guard.sh",
155
155
  "timeout": 60
156
+ },
157
+ {
158
+ "type": "command",
159
+ "shell": "bash",
160
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/prompt-approval.sh",
161
+ "timeout": 60
156
162
  }
157
163
  ]
158
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.2.0-rc.4",
3
+ "version": "3.2.0-rc.5",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"