crewforth 3.2.0-rc.3 → 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
@@ -84,8 +84,10 @@ versioning follows [SemVer](https://semver.org/).
84
84
  own findings to `.crewforth/review.json`, blockers through the refuter. What it could not check (no shellcheck,
85
85
  a file edited after staging, no Node) is said, never read as clean.
86
86
 
87
- - **In `auto`, approve a commit or a push with one click.** The refusal now also offers a fixed question, asked with
88
- AskUserQuestion: Commit only, Commit + push, Push only, No. A new gate, `guard-question.sh`, answers `ask` on it,
87
+ - **In `auto`, approve a commit or a push with one click.** A fixed question, asked with AskUserQuestion: Commit
88
+ only, Commit + push, Push only, No. It is handed to the session when `crew-commit-agent` is called, so `/crew-ship`
89
+ asks it with the message, and a refused commit offers it too (measured in real sessions: offered by the refusal
90
+ alone, it never came up, since the session asks before trying a commit). A new gate, `guard-question.sh`, answers `ask` on it,
89
91
  so a hook that fills in the answer cannot answer it for you (measured in 34 runs and by hand in the terminal), and
90
92
  `prompt-approval.sh` records your click exactly as it records `/crew-approve`: tied to what is staged, `HEAD`, the
91
93
  session, for 30 minutes.
@@ -151,6 +153,23 @@ versioning follows [SemVer](https://semver.org/).
151
153
 
152
154
  ### Changed
153
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
+
163
+ - **A refused commit asks one way.** In `auto`, with the approval question open, the refusal offered the click and
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
165
+ to ask, and the typed command is only the fallback for a question that cannot be asked.
166
+
167
+ - **A task card on one line is read.** `files: … · change: … · verify: …` (with `;` or `|` too, after an optional
168
+ `Card:`, and `above:` on the same line) is read by its labels and held to the rules of the three-line card, and a
169
+ kind with a note in parentheses (`architecture (planning only)`) is that kind, on either form. Measured on one
170
+ machine's sessions: 37 calls had been refused for their card, every one followed by a second call; 17 of them now
171
+ pass on the first.
172
+
154
173
  - **A model is chosen to cost what the work needs.** Measured on a real project: 186 crew tasks, 0 on haiku, 73 (39%)
155
174
  above their card's floor, and no verify command ever passing, so the cheaper path never opened. Now:
156
175
  - the card's model is the default: a call above it runs on it (the gate rewrites the call's model), unless the
@@ -183,6 +202,10 @@ versioning follows [SemVer](https://semver.org/).
183
202
 
184
203
  ### Security
185
204
 
205
+ - **The session-link trailer is a trace.** Claude Code asks the model to end a commit message with a line linking the
206
+ session on claude.ai; the trace scan had no pattern for it, and six commits of a project with Crewforth installed
207
+ carried it to a remote. The trailer and its address are both caught now, in the message and in staged files.
208
+
186
209
  - **A `PermissionRequest` hook could approve a commit with nobody at the keyboard.** Such a hook answers a
187
210
  permission prompt before the person does, and the answer cannot be told from a click: measured, it approved the
188
211
  Bash prompt `guard-bash.sh` raises for a commit in `default`, and it answered the new question too. Before it
@@ -197,6 +220,13 @@ versioning follows [SemVer](https://semver.org/).
197
220
 
198
221
  ### Fixed
199
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
+
200
230
  - **The Crew Map says what a project is made of.** A Flutter project read as a pile of Gradle modules and its
201
231
  server as an external API (seen on a real project). The scanner now reads Dart: one module per `pubspec.yaml`,
202
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.3
1
+ 3.2.0-rc.5
@@ -94,16 +94,18 @@ write is refused because the file is on a critical path, do not reach it another
94
94
  proceed" is not approval (§4.4).
95
95
  The tool-level gate `guard-bash.sh` intercepts commit/push in **every** permission mode: in normal modes it raises an
96
96
  approval prompt only the user can answer — so present the message FIRST, then run the commit yourself and let the user
97
- approve it at the prompt. Never hand the user a command to paste into their own terminal. In `auto` and `dontAsk`
98
- the prompt reaches nobody: stage, then ask in this shape and with nothing after it — the message in ONE code block,
97
+ approve it at the prompt. Never hand the user a command to paste into their own terminal. In `auto`, when this agent
98
+ is called Crewforth hands the session the one-click approval question (below): ask it, with the message, instead of
99
+ asking for `/crew-approve`. In `dontAsk`, and in `auto` when Crewforth gives no question or says it is closed, the
100
+ prompt reaches nobody: ask in this shape and with nothing after it — the message in ONE code block,
99
101
  and under it one sentence in the user's language ("If you approve, send only this:") followed by the command
100
102
  on a line of its own: `/crew-approve`. With nothing after it, it approves what the gate just refused in this
101
103
  session (a commit, a push, or both when both were refused); spelled out it is `/crew-approve commit`,
102
104
  `/crew-approve push` or `/crew-approve commit+push`. The user approves by sending
103
105
  that command alone; a sentence that says yes is not an approval, and you cannot run `/crew-approve` for them. It covers what is staged on that HEAD. If the gate says the approval path is closed in this session (the
104
106
  recording hook is not wired), say so and offer Shift+Tab or their own terminal instead.
105
- In `auto` the refusal may also offer the approval question: show the message, then call AskUserQuestion with
106
- exactly the input the refusal prints (the fixed question and its four answers, no answers of your own). The
107
+ The approval question: call AskUserQuestion with exactly the input Crewforth prints (the fixed question and its four
108
+ answers, no answers of your own; never write the question yourself). The
107
109
  user's click is recorded the same way; read the line Crewforth adds and run what it names. Where a
108
110
  `PermissionRequest` hook may be wired the gate closes the question, and in `default` / `acceptEdits` its own
109
111
  prompt too, because such a hook can answer either before the user does: then `/crew-approve` is the way.
@@ -3665,6 +3665,8 @@ if [ "$_gc1" != 5 ] || [ "$_gc2" != 7 ]; then
3665
3665
  else
3666
3666
  gcj(){ printf '{"tool_name":"%s","permission_mode":"default","tool_input":{"command":"%s"},"cwd":"%s"}' "$1" "$2" "$_gcp"; }
3667
3667
  gwj(){ printf '{"tool_name":"Write","permission_mode":"default","tool_input":{"file_path":"%s/%s","content":"x"},"cwd":"%s"}' "$_gcp" "$1" "$_gcp"; }
3668
+ # commit-msg 15 -> 16 (2026-10-18): one more line in trace-blocklist.txt, the session-link trailer that reached
3669
+ # pushed commits; each line is one grep on every commit.
3668
3670
  GCT='guard-bash @@ 0 @@ 0 @@ Bash @@ git status --short
3669
3671
  guard-bash @@ 0 @@ 0 @@ Bash @@ ls -la
3670
3672
  guard-bash @@ 0 @@ 0 @@ Bash @@ git log --oneline -2 && git status --short | head; ls; cat CHANGELOG.md
@@ -3686,7 +3688,7 @@ guard-write @@ 0 @@ 0 @@ Write @@ .env
3686
3688
  guard-write @@ 2 @@ 0 @@ Write @@ .claude/hooks/guard-bash.sh
3687
3689
  guard-powershell @@ 0 @@ 0 @@ PowerShell @@ Get-ChildItem -Recurse src
3688
3690
  pre-commit @@ 0 @@ 22 @@ Script @@ -
3689
- commit-msg @@ 0 @@ 15 @@ Script @@ msg'
3691
+ commit-msg @@ 0 @@ 16 @@ Script @@ msg'
3690
3692
  _gcn=0; _gcbad=""; _gcrow=""
3691
3693
  while IFS= read -r _gl; do [ -z "$_gl" ] && continue
3692
3694
  _gh="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gr="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gb="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gt="${_gl%% @@ *}"; _gc="${_gl#* @@ }"
@@ -7992,8 +7994,8 @@ _paq_text="$(bash -c '. "$1"; printf "%s" "$_CREW_Q_TEXT"' _ "$HOOKS/prompt-appr
7992
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"
7993
7995
  if [ -n "$JSONQ" ]; then json_ok < "$_PA/q.json" || _paqbad="$_paqbad [pre payload is not JSON]"; fi
7994
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=$?; }
7995
- # $1 = mode, $2 = the answer, $3 = tool_use_id
7996
- _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"
7997
7999
  if [ -n "$JSONQ" ]; then json_ok < "$_PA/q.json" || _paqbad="$_paqbad [post payload is not JSON]"; fi
7998
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=$?; }
7999
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"
@@ -8006,6 +8008,11 @@ _paq_is(){ _paqn=$((_paqn+1)); [ "$2" = "$3" ] || _paqbad="$_paqbad [$1: got '$2
8006
8008
  _paq_post auto 'Commit only'; _paq_is 'its answer is recorded' "$(_paq_rec)" commit
8007
8009
  _paq_gate auto 'git commit -m x'; _paq_is 'the recorded click allows the commit' "$_par" 0
8008
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
8009
8016
  # must-fail twins: each differs from the round trip in one thing
8010
8017
  _pa_new; : > "$_pa_rec"; _paq_post auto 'Commit only' tu3; _paq_is 'an answer no PreToolUse asked for' "$(_paq_rec)" none
8011
8018
  _paq_pre auto "$_paq_canon" '' tu4; _paq_post auto 'Commit only' tu5; _paq_is 'an answer to another call' "$(_paq_rec)" none
@@ -8014,6 +8021,8 @@ _paq_pre auto "$_paq_canon" '' tu6; _paq_post auto 'Commit only' tu6; : > "$_pa_
8014
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
8015
8022
  _paq_pre auto "$_paq_canon" '' tu8; _paq_post auto 'No' tu8; _paq_is 'No' "$(_paq_rec)" none
8016
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
8017
8026
  _paq_pre auto '{"question":"Crewforth approval: commit?","header":"Approval","options":[{"label":"Commit only","description":"x"}],"multiSelect":false}'
8018
8027
  _paq_is 'another wording is refused' "$_par" 2
8019
8028
  _paq_pre auto "$_paq_canon" ',"answers":{"x":"Commit only"}'; _paq_is 'a call with answers is refused' "$_par" 2
@@ -8024,9 +8033,93 @@ _paq_pre bypassPermissions "$_paq_canon"; _paq_is 'bypassPermiss
8024
8033
  _PAQ_X="CLAUDE_CODE_SESSION_ATTENDED=0"; _paq_pre auto "$_paq_canon"; unset _PAQ_X; _paq_is 'a headless session: refused' "$_par" 2
8025
8034
  _paq_pre auto '{"question":"Which colour?","header":"Colour","options":[{"label":"Red","description":"r"}],"multiSelect":false}'
8026
8035
  _paq_is 'any other question: no decision' "$_par/$_pao" "0/"
8027
- if [ "$_paqn" != 18 ]; then fail "FIXTURE: the approval-question table ran $_paqn cases, not 18"
8028
- 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)"
8029
8038
  else fail "the approval question:$_paqbad"; fi
8039
+ # ...and the way to it. B-2 (a real `auto` session, 2026-10-17): /crew-ship asked for /crew-approve before any commit
8040
+ # was refused, so the refusal that prints the question never came and the click was never offered; telling the agent
8041
+ # to try the commit first did not help either (§4.4 says not to, and it did not). agent-outcome.sh now hands the
8042
+ # question over when crew-commit-agent is called; the text pinned here only says to ask it.
8043
+ _cqa="$AGENTS/crew-commit-agent.md"; _cqs="$SKILLS/crew-ship/SKILL.md"
8044
+ if [ ! -f "$_cqa" ] || [ ! -f "$_cqs" ]; then skip scope "the way to the approval question: crew-commit-agent or crew-ship is not installed here"
8045
+ elif grep -q 'question (below): ask it, with the message, instead of' "$_cqa" && grep -q 'one-click approval question when crew-commit-agent is called' "$_cqs"; then
8046
+ pass "in auto the commit agent and /crew-ship ask the one-click question Crewforth hands over, before asking for /crew-approve"
8047
+ else fail "the commit agent or /crew-ship asks for /crew-approve before the one-click question Crewforth hands over (B-2)"; fi
8048
+ # The hand-over itself, run: agent-outcome.sh on PostToolUse of an Agent call. In `auto`, for crew-commit-agent, with
8049
+ # both halves of the question wired and no PermissionRequest hook, it adds the fixed question (_CREW_Q_JSON, read from
8050
+ # prompt-approval.sh) to the session's context; in any other mode, for any other agent, or with such a hook, nothing.
8051
+ _aq="$(mktemp -d)"; _aq="$(cd -P "$_aq" && pwd)"; mkdir -p "$_aq/p/.claude/state" "$_aq/ccd" "$_aq/home"
8052
+ 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"}]}]}}' > "$_aq/p/.claude/settings.json"
8053
+ _aq_run(){ printf '{"hook_event_name":"PostToolUse","tool_name":"Agent","permission_mode":"%s","session_id":"s","tool_use_id":"toolu_aq1","cwd":"%s","tool_input":{"subagent_type":"%s","description":"d","prompt":"p"},"tool_response":{"status":"async_launched","agentId":"aq123abc"}}' "$1" "$_aq/p" "$2" > "$_aq/pl.json"
8054
+ ( cd "$_aq/p" && env -u CREW_MODEL_ROUTING CLAUDE_PROJECT_DIR="$_aq/p" CLAUDE_CONFIG_DIR="$_aq/ccd" HOME="$_aq/home" CREW_GATE_LOG=/dev/null bash "$HOOKS/agent-outcome.sh" < "$_aq/pl.json" 2>/dev/null ); }
8055
+ _aqbad=""; _aqo="$(_aq_run auto crew-commit-agent)"
8056
+ case "$_aqo" in *"$_paq_text"*) ;; *) _aqbad="$_aqbad [auto, crew-commit-agent: the question was not handed over]" ;; esac
8057
+ if [ -n "$JSONQ" ] && [ -n "$_aqo" ]; then printf '%s' "$_aqo" | json_ok || _aqbad="$_aqbad [its output is not JSON]"; fi
8058
+ for _aqc in default:crew-commit-agent dontAsk:crew-commit-agent acceptEdits:crew-commit-agent auto:crew-review-agent; do
8059
+ [ -z "$(_aq_run "${_aqc%%:*}" "${_aqc#*:}")" ] || _aqbad="$_aqbad [${_aqc%%:*}, ${_aqc#*:}: handed the question over]"
8060
+ done
8061
+ printf '%s' '{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"x"}]}]}}' > "$_aq/ccd/settings.json"
8062
+ [ -z "$(_aq_run auto crew-commit-agent)" ] || _aqbad="$_aqbad [a PermissionRequest hook is wired: the question was still handed over]"
8063
+ rm -rf "$_aq"
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" \
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
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
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
8110
+ # printed only as the fallback for a question that cannot be asked; with it closed (dontAsk), only the typed way.
8111
+ _pa_new
8112
+ ( cd "$_paw" && printf '%s' '{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}],"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"}]}]}}' > .claude/settings.json )
8113
+ _oabad=""
8114
+ _paq_gate auto 'git commit -m x'
8115
+ case "$_pae" in *'"questions":['*'do not also give the user a command to type.'*'ONLY IF THAT QUESTION CANNOT BE ASKED'*) ;; *) _oabad="$_oabad [auto, question open: not the click first and the typed way as its fallback]" ;; esac
8116
+ case "$_pae" in *'ASK THE USER NOW'*|*'the way below works as well'*) _oabad="$_oabad [auto, question open: the typed way is still offered as an equal]" ;; esac
8117
+ _paq_gate dontAsk 'git commit -m x'
8118
+ case "$_pae" in *'ASK THE USER NOW'*'/crew-approve'*) ;; *) _oabad="$_oabad [dontAsk: the typed way is not asked for]" ;; esac
8119
+ case "$_pae" in *'"questions":['*|*'ONLY IF THAT QUESTION'*) _oabad="$_oabad [dontAsk: the question is offered]" ;; esac
8120
+ _pa_new
8121
+ [ -z "$_oabad" ] && pass "a refused commit in auto offers the click and only falls back to the typed /crew-approve when the question cannot be asked; in dontAsk only the typed way" \
8122
+ || fail "one way to ask:$_oabad"
8030
8123
 
8031
8124
  # A PermissionRequest HOOK, wherever this can read one, closes the question AND the gate's own prompt. Each source is
8032
8125
  # planted alone in an otherwise clean fixture; the clean fixture itself must answer "none" (calibration).
@@ -8484,6 +8577,27 @@ for _pr in 'Do the work.' 'Please do this:\nfiles: a\nchange: feature\nverify: t
8484
8577
  done
8485
8578
  [ -z "$_v2b" ] && pass "a crew call with no card, a card that is not at the top, a card with a line missing or one named twice is refused, with the three lines to write" \
8486
8579
  || fail "a task without a proper card passed:$_v2b"
8580
+ # The card on one line (2026-10-18: 17 of 39 card refusals on this machine were `files: … · change: … · verify: …`). It is
8581
+ # read by its labels and held to the same rules: what passes on three lines passes on one, and what fails there fails.
8582
+ _v2b=""; _v2one(){ printf '{"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"prompt":"%s\\n\\nDo it.","subagent_type":"crew-backend-expert","model":"%s"}}' "$1" "$2" > "$_v2/one.json"
8583
+ ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/one.json" >"$_v2/oneout" 2>"$_v2/err" ); _rc=$?; }
8584
+ for _pr in 'files: src/ui/a.ts · change: feature · verify: true' 'files: src/ui/a.ts; change: feature; verify: true' 'files: src/ui/a.ts | change: feature | verify: true' \
8585
+ 'Card: files: src/ui/a.ts, src/ui/b.ts · change: feature · verify: true.' 'Card — files: src/ui/a.ts · change: text · verify: none'; do
8586
+ _v2one "$_pr" sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [refused: $_pr → $_rc $(head -c 120 "$_v2/err")]"
8587
+ done
8588
+ _v2one 'files: src/payments/p.ts · change: feature · verify: true' sonnet
8589
+ { [ "$_rc" = 2 ] && ! grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [a one-line card on a critical path was not read as one (rc $_rc)]"
8590
+ _v2one 'files: src/ui/a.ts · change: feature · verify: true · above: the parser is subtle' opus
8591
+ { [ "$_rc" = 0 ] && ! grep -q 'runs on sonnet' "$_v2/oneout"; } || _v2b="$_v2b [above: on the same line was not read (rc $_rc)]"
8592
+ _v2one 'files: src/ui/a.ts · change: feature (a small one) · verify: true' sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [a kind with a note in parentheses, one line → $_rc]"
8593
+ _v2one 'files: src/ui/a.ts · change: architecture (planning only) · verify: none' sonnet
8594
+ { [ "$_rc" = 2 ] && grep -q 'runs on opus' "$_v2/err"; } || _v2b="$_v2b [architecture (planning only) was not read as architecture, which runs on opus (rc $_rc)]"
8595
+ _v2one 'files: src/ui/a.ts\nchange: refactor (no behaviour change)\nverify: true' sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [a kind with a note in parentheses, three lines → $_rc]"
8596
+ for _pr in 'files: a · change: archi (planning only) · verify: none' 'files: a · change: feature extra · verify: none' 'files: a · verify: true · change: text' 'files: a · change: feature' 'Do this. files: a · change: feature · verify: true'; do
8597
+ _v2one "$_pr" sonnet; { [ "$_rc" = 2 ] && grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [passed: $_pr → $_rc]"
8598
+ done
8599
+ [ -z "$_v2b" ] && pass "a card on one line (· ; | separators, a Card: lead, above: on the line) is read by its labels and held to the three-line rules: a critical path still needs opus; a kind with a note in parentheses is the kind, on one line or three; a bad change, labels out of order, a missing verify or prose first are refused" \
8600
+ || fail "the one-line card:$_v2b"
8487
8601
 
8488
8602
  # ---- at write time: the card can leave a file out --------------------------------------------------------------
8489
8603
  _v2new; _v2b=""
@@ -404,6 +404,24 @@ case "$EV" in
404
404
  fi
405
405
  fi
406
406
  fi
407
+ # THE ONE-CLICK APPROVAL, OFFERED WHEN THE COMMIT MESSAGE IS ASKED FOR (B-2). In `auto` the click used to reach the
408
+ # user only inside a refused commit, and a session that asked for /crew-approve first never tried one (§4.4 says
409
+ # not to), so it was never offered. A call to crew-commit-agent is when the message is on its way: the session is
410
+ # told then, in foreground and background alike (a background launch answers here at once). Whether the question
411
+ # is open, and its words, come from prompt-approval.sh, as the refusal's do. Its verify is `none` (change: text),
412
+ # so no outcome below is lost by answering here; the model it ran on is already kept above.
413
+ _json_slice "$INPUT" subagent_type >/dev/null
414
+ if [ "$_JS" = crew-commit-agent ] && [ -f "$_ao_here/prompt-approval.sh" ]; then
415
+ _json_slice "$INPUT" permission_mode >/dev/null; _AOPM="$_JS"
416
+ if [ "$_AOPM" = auto ]; then
417
+ declare -F _crew_q_open >/dev/null 2>&1 || . "$_ao_here/prompt-approval.sh"
418
+ if declare -F _crew_q_open >/dev/null 2>&1 && _crew_q_open auto; then
419
+ _ao_json "Crewforth: when you show the user the commit message this agent proposes, ask with one click before anything else. $_CREW_Q_ASK {\"questions\":[$_CREW_Q_JSON]} Their answer is recorded by Crewforth itself (the line it adds says so); then run the commit it names. If that question is refused, the user can type /crew-approve instead, as the refusal says."
420
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$_AOJ"
421
+ exit 0
422
+ fi
423
+ fi
424
+ fi
407
425
  R="$_AMD/results/$AID"
408
426
  if [ ! -f "$R" ]; then
409
427
  # No result yet: the agent was launched in the background (measured, 2.1.294 and 2.1.296: an Agent call that
@@ -431,11 +431,44 @@ _am_all_prose(){ # after _am_card -> 0 when every file of the card is prose
431
431
 
432
432
  # ---- THE CARD ---------------------------------------------------------------------------------------------------
433
433
  _AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
434
+ _am_trim(){ # $1 -> _AMT: no white space around it, and no separator or full stop left at its end (one-line cards)
435
+ local v="$1" o
436
+ while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
437
+ while :; do o="$v"
438
+ case "$v" in *[$' \t']) v="${v%?}" ;; *'·') v="${v%'·'}" ;; *'—') v="${v%'—'}" ;; *'–') v="${v%'–'}" ;; *[\|\;,.]) v="${v%?}" ;; esac
439
+ [ "$v" = "$o" ] && break
440
+ done
441
+ _AMT="$v"
442
+ }
443
+ # THE CARD ON ONE LINE. Sessions write it as `files: … · change: … · verify: …` (measured on this machine's transcripts,
444
+ # 2026-10-18: 17 of the 39 calls refused for their card, in 6 projects; the next call was the same card on three lines).
445
+ # It is read by its labels, in that order, after an optional `Card:` / `Card —`; `above: <why>` may close the line. The
446
+ # fields are then held to exactly the rules of the three-line card below, so nothing passes here that would not there.
447
+ _am_card_line(){ # $1 = the first line of the task -> 0 and CARD_* set (unchecked); 1 when it is not a one-line card
448
+ local l="$1" r f c v a=""
449
+ case "$l" in [Cc]ard*) l="${l#[Cc]ard}"; l="${l#\*\*}"
450
+ while :; do case "$l" in [$' \t:*']*|'—'*|'–'*|-*) case "$l" in '—'*) l="${l#'—'}" ;; '–'*) l="${l#'–'}" ;; *) l="${l:1}" ;; esac ;; *) break ;; esac; done ;;
451
+ esac
452
+ case "$l" in files:*) ;; *) return 1 ;; esac
453
+ r="${l#files:}"
454
+ case "$r" in *change:*verify:*) ;; *) return 1 ;; esac
455
+ f="${r%%change:*}"; r="${r#*change:}"; c="${r%%verify:*}"; v="${r#*verify:}"
456
+ case "$v" in *above:*) a="${v#*above:}"; v="${v%%above:*}" ;; esac
457
+ _am_trim "$f"; CARD_FILES="$_AMT"; _am_trim "$c"; CARD_CHANGE="$_AMT"; _am_trim "$v"; CARD_VERIFY="$_AMT"
458
+ [ -n "$a" ] && { _am_trim "$a"; CARD_ABOVE="${_AMT:0:200}"; }
459
+ return 0
460
+ }
434
461
  _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
435
- local s="$1" l k v n=0 seen=""
462
+ local s="$1" l k v n=0 seen="" one=0
436
463
  CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""; CARD_ABOVE=""
437
464
  s="${s//$'\r'/}"
438
- while [ "$n" -lt 3 ] && [ -n "$s" ]; do
465
+ # One line first: the first line that is not empty, read by its labels; the three-line reading otherwise.
466
+ l="$s"; while :; do case "$l" in [$' \t\n']*) l="${l:1}" ;; *) break ;; esac; done; l="${l%%$'\n'*}"
467
+ if _am_card_line "$l"; then
468
+ one=1; n=3
469
+ s="${s#*"$l"}"; s="${s#$'\n'}"
470
+ fi
471
+ while [ "$one" = 0 ] && [ "$n" -lt 3 ] && [ -n "$s" ]; do
439
472
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
440
473
  while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
441
474
  [ -n "$l" ] || continue
@@ -455,12 +488,16 @@ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (C
455
488
  done
456
489
  [ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
457
490
  [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
491
+ # A note in parentheses after the kind is the session's own comment, as `none (…)` is for verify: `architecture
492
+ # (planning only)` is architecture (4 of the cards refused on this machine, 2026-10-18). Only a kind on the list, and
493
+ # only a note that is the whole rest of the value in parentheses.
494
+ case "$CARD_CHANGE" in *' ('*')') v="${CARD_CHANGE%% (*}"; case "$_AM_CHANGES" in *" $v "*) CARD_CHANGE="$v" ;; esac ;; esac
458
495
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
459
496
  [ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
460
497
  case "$CARD_VERIFY" in none|None|NONE|-|'none '*|'none('*|'None '*|'NONE '*) CARD_VERIFY=none ;; esac # "none (the author ran it)" is none
461
498
  # Optional, right after the three: `above: <why>` keeps a model above the card's floor. Without it a call above
462
499
  # the floor is run on the floor (the cost of a model nobody needed is what this gate exists to stop).
463
- while [ -n "$s" ]; do
500
+ while [ -z "$CARD_ABOVE" ] && [ -n "$s" ]; do
464
501
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
465
502
  while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
466
503
  [ -n "$l" ] || continue
@@ -2524,16 +2524,7 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
2524
2524
  }
2525
2525
  # IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
2526
2526
  # on PreToolUse, prompt-approval.sh on PostToolUse). Asked only to choose which way the refusal offers first.
2527
- _q_wired(){
2528
- local f t d="${CLAUDE_PROJECT_DIR:-.}"
2529
- d="${d//\\//}"; d="${d%/}"
2530
- 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
2531
- [ -n "$f" ] && [ -f "$f" ] || continue
2532
- t=""; IFS= read -r -d '' t < "$f" || true
2533
- case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
2534
- done
2535
- return 1
2536
- }
2527
+ _q_wired(){ declare -F _crew_q_wired >/dev/null 2>&1 && _crew_q_wired; } # defined once, in prompt-approval.sh
2537
2528
  # MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
2538
2529
  # from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
2539
2530
  # asks as it always has.
@@ -2603,12 +2594,21 @@ _gate_written(){
2603
2594
  { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
2604
2595
  fi
2605
2596
  fi
2597
+ # ONE WAY TO ASK. With the question open, the refusal used to print it and then the typed way as an equal choice,
2598
+ # 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
2599
+ # to type a command for what one click does. The typed way is now the fallback, said as one.
2600
+ _QOFF=0
2606
2601
  if [ "$PERM_MODE" = auto ] && [ "$_PRF" = 0 ] && _q_wired && declare -F _crew_q_pre >/dev/null 2>&1 && [ -f "${BASH_SOURCE%/*}/guard-question.sh" ]; then
2607
- echo "ASK THE USER WITH ONE CLICK (stage first: the approval covers what is staged at that moment): show the commit message whole, in ONE code block, then call AskUserQuestion with exactly this input, nothing added or changed:" >&2
2602
+ _QOFF=1
2603
+ echo "$_CREW_Q_ASK" >&2
2608
2604
  echo "{\"questions\":[$_CREW_Q_JSON]}" >&2
2609
- 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
2605
+ 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
2606
+ fi
2607
+ if [ "$_QOFF" = 1 ]; then
2608
+ 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
2609
+ else
2610
+ 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
2610
2611
  fi
2611
- 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
2612
2612
  echo " 1. the commit message, whole, in ONE code block;" >&2
2613
2613
  if [ -n "$_pw" ]; then
2614
2614
  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
@@ -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
@@ -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
@@ -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
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
  }
@@ -9,7 +9,9 @@ Closure flow — in order:
9
9
  1. **DoD gate:** was `/simplify` applied · are tests green (@agent-crew-test-expert) · (if SonarQube is used) `sonarqube-check` 0/0/0/0.
10
10
  If any one is red, **STOP**, report what is missing; no deferral.
11
11
  2. If clean, @agent-crew-commit-agent (commit-message) **proposes** an atomic Conventional Commit from the staged diff.
12
- 3. Commit runs ONLY if the user says "commit" (§4.4). "Done" is not approval.
12
+ 3. Commit runs ONLY if the user says "commit" (§4.4). "Done" is not approval. In `auto`, Crewforth hands the session the
13
+ one-click approval question when crew-commit-agent is called: ask it with the message; the typed `/crew-approve` is
14
+ for when no question is offered.
13
15
  4. **Close the board item too** (`teamboard`), when one is held. Closing work is one movement: ask what shipped,
14
16
  run `board.sh done <id> "<note>"`, name the items it unblocked, and offer the next one as options. Do not
15
17
  leave this to the user to remember — a claim nobody closes reads to the whole team as still in progress, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.2.0-rc.3",
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"