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 +32 -2
- package/VERSION +1 -1
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/eval/smoke-test.sh +119 -5
- package/kit/hooks/agent-outcome.sh +18 -0
- package/kit/hooks/guard-agent-model.sh +40 -3
- package/kit/hooks/guard-bash.sh +13 -13
- package/kit/hooks/prompt-approval.sh +72 -3
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +6 -0
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/package.json +1 -1
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.**
|
|
88
|
-
|
|
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.
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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.
|
package/kit/eval/smoke-test.sh
CHANGED
|
@@ -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 @@
|
|
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" !=
|
|
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 (
|
|
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
|
-
|
|
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
|
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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