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 +14 -0
- package/VERSION +1 -1
- package/kit/eval/smoke-test.sh +53 -4
- package/kit/hooks/prompt-approval.sh +51 -3
- package/kit/settings.json +6 -0
- package/package.json +1 -1
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.
|
|
1
|
+
3.2.0-rc.5
|
package/kit/eval/smoke-test.sh
CHANGED
|
@@ -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" !=
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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