crewforth 3.1.0-rc.6 → 3.1.0-rc.8

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
@@ -122,7 +122,11 @@ versioning follows [SemVer](https://semver.org/).
122
122
  - `guard-agent-model.sh` holds the rules at the tool level and never rewrites a call; `agent-outcome.sh` runs the
123
123
  verify command and keeps the record. Which kind of change a task is, is the caller's judgement.
124
124
  `CREW_MODEL_ROUTING=off` turns all of it off; `fable` needs `CREW_ALLOW_FABLE=1`.
125
- - **Known limits.** A test runner runs code the agent wrote (test files, `package.json` scripts, `conftest.py`, a
125
+ - **Known limits.** Model routing applies to `crew-*` agents only: Explore, general-purpose and a project's own
126
+ agents run on the session's model and are not in the record (a kit agent for documents is planned for 3.2.0).
127
+ The gate holds a floor, not a ceiling: nothing stops ordinary work from being given to Opus. Not measured on
128
+ Windows: Studio under a path that holds a space, and stopping a session from the panel; both were measured on
129
+ macOS. A test runner runs code the agent wrote (test files, `package.json` scripts, `conftest.py`, a
126
130
  `Makefile`): the list limits the command, not what the tests do. A failed card stays failed until the card
127
131
  changes. The shell rule that protects the routing files goes by their names. The list of critical paths is
128
132
  written at the first session, not at install; the gate reads the patterns, not the list. Managed policy is
@@ -184,6 +188,17 @@ versioning follows [SemVer](https://semver.org/).
184
188
  agent that reported before it was called, with the words `after Planner reported`; that wire is inferred from the
185
189
  order of events and is not a call from one agent to another, and the panel says so. Read from the transcript
186
190
  alone. Agents a workflow started are not called by the session, so they carry no place.
191
+ - **Studio: the Models view.** A fourth tab (key `m`), read from the record of outcomes: for each kind of task
192
+ (agent, change, risk) the model, the tasks, how often the first try passed, how often it was repeated one model
193
+ up, and an estimated cost. A class with no verified call shows `—`, not 0%. A class the record holds one model up
194
+ is offered for lowering once its latest ten verified calls all passed first time; the offer is one button, **Copy
195
+ command**, for `/crew-loosen …`. Studio writes nothing. A floor you lowered reads **lowered**, with what raised
196
+ it and when you lowered it. The inspector shows a task's card (files, change, verify) and its steps: each call
197
+ of the card, what it ran on, what its verify command said.
198
+ - **Studio: a session started in the panel can ask its question and have its plan approved, in the dock.** Such a
199
+ session had neither tool. It has both now, and the question or the plan waits in the strip where an approval
200
+ does; approving a plan can carry the mode the session goes on in.
201
+ - **Studio: Reset layout**, and **Report**: an agent's hand-back is read as its report.
187
202
  - **Studio: which model did which work.** Every agent card carries a badge with the model it ran on, and a third
188
203
  line with its duration, new tokens and estimated cost. Two facts are kept apart: the model the call **asked** for
189
204
  and the model the agent **ran on**; when they differ the badge carries `≠`. The inspector has a **Model** section.
@@ -201,6 +216,11 @@ versioning follows [SemVer](https://semver.org/).
201
216
 
202
217
  ### Changed
203
218
 
219
+ - **Studio: every agent that is not Crewforth's is a Claude Code agent.** A type the panel did not know carried a
220
+ `?`; there is no list to go stale now. Its tooltip says where it is defined (the project, the user's folder, a
221
+ plugin, or Claude Code itself). **Density: Auto** chooses what fits.
222
+ - **Studio: Expand all and Fold all reach every group**, the cards that stand for several agents of one type and
223
+ the groups inside an opened group included; what you open or fold by hand afterwards stays as you left it.
204
224
  - **Studio: agents are named by their role.** `crew-backend-expert` reads Backend, `general-purpose` General Purpose,
205
225
  in the graph, the Timeline, the List, the dock, the inspector and the conversation; the declared type is under the
206
226
  name in the inspector and on hover. A task cut short on a card is said in full on hover.
@@ -214,6 +234,12 @@ versioning follows [SemVer](https://semver.org/).
214
234
  - **Studio: a group of agents opens beside itself.** Several agents of one type are one card; in a window of 1024 px
215
235
  and up, clicking it puts its agents in the next column as ordinary cards, and opening another group folds the
216
236
  first. Below that width it opens downward as before.
237
+ - **`/crew-approve`, with nothing after it, approves what was just refused.** In a trial the session said "send
238
+ `/crew-approve commit+push`" and stood still until someone noticed. The gate now writes down what it refused
239
+ (the session, commit, push or both, the moment), and the bare command approves that: the same session's, within
240
+ 30 minutes, once. The refusal puts the command on a line of its own. What is approved is the gate's note of the
241
+ command it refused, never the model's choice: the note is closed to the session, and the bare command is refused
242
+ when scheduled or handed to another session, like the spelled-out forms, which still work.
217
243
  - **The approval of 3.1.0's release candidates was a text, `approve: commit` (`onay: commit`); it is the command
218
244
  `/crew-approve commit` now.** The same in every language, and recorded exactly as the text was. Only a command you
219
245
  type counts: measured on Claude Code 2.1.284, a typed command reaches the recording hook as typed, a skill the
@@ -317,6 +343,22 @@ versioning follows [SemVer](https://semver.org/).
317
343
 
318
344
  ### Fixed
319
345
 
346
+ - **On some Windows machines every commit was stopped: "PRIVATE-PATH SCANNER: … could not run".** The look for a
347
+ machine-private string used `grep -iF`, and Git Bash's grep 3.0 aborts for it (exit 134): in the C locale with
348
+ Git for Windows 2.53, under a Turkish locale with 2.55 (measured on one machine of each). The commit hook sets
349
+ the C locale, so on the first kind every look aborted, and the hook rightly stopped the commit. A private term
350
+ is now escaped and matched with `-iE`, which aborted in none of the 15 pairs of `LANG` and `LC_ALL` measured on
351
+ either machine. A term is still a literal: `.`, `\`, `[`, `(`, `+`, `|` and the rest match only themselves.
352
+ - **Critical work with a verify command was refused every time where the Agent tool has no `run_in_background`.**
353
+ The gate asked for the field to be `false`. Where background tasks are off the tool has no such field (measured on
354
+ Claude Code 2.1.296: the call arrives with description, prompt, subagent_type and model, and runs in the
355
+ foreground), so the model could not send it and each call was refused, four times in a row in one session. Now
356
+ only a call that says `run_in_background: true` is refused; one that leaves the field out passes. Where the tool
357
+ does have the field, a call that leaves it out starts in the background: the session is then told, when the
358
+ launch comes back, to run the verify command itself once the agent has finished.
359
+ - **Studio: no spool is left behind.** A server that stopped or was killed left its sessions' settings in the temp
360
+ directory, and they piled up; it removes them as it stops and sweeps when it starts. Two calls in plan mode (a
361
+ tool lookup, the plan file) no longer come to the dock.
320
362
  - **Studio: a card's text stays in the card.** `N in background` could push the session card's line past its edge;
321
363
  it has a line of its own, and every text part of a card is clipped inside it, whole on hover.
322
364
  - **Studio: the time moves in a live session.** The summary read the span of the session's own transcript, which
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center"><img src="https://raw.githubusercontent.com/Crewforth/crewforth/main/assets/logo.svg" alt="Crewforth" width="420"></p>
2
2
 
3
- **Crewforth is your engineering crew for Claude Code.** It adds subagents, skills, commands and hooks to Claude Code: 12 specialist agents that each own a domain, 40 skills that hold the method, 10 commands you start with `/crew-…`, and hooks that enforce the rules that matter.
3
+ **Crewforth is your engineering crew for Claude Code.** It adds subagents, skills, commands and hooks to Claude Code: 12 specialist agents that each own a domain, 40 skills that hold the method, 12 commands you start with `/crew-…`, and hooks that enforce the rules that matter.
4
4
 
5
5
  ```bash
6
6
  npx crewforth init # set up a new project
@@ -31,6 +31,7 @@ Then open Claude Code and run `/crew-doctor` to confirm the setup. Requires bash
31
31
  - **12 specialist agents**, from `crew-planner` to `crew-commit-agent`, each owning one domain.
32
32
  - **40 skills** holding the method: testing, migrations, API contracts, observability, accessibility, deployment.
33
33
  - **12 commands**, each started with its `/crew-…` name: `/crew-plan`, `/crew-review`, `/crew-ship`, `/crew-handoff`, `/crew-brainstorm`, `/crew-update`, `/crew-doctor`, `/crew-gates`, `/crew-skill`, `/crew-studio`, `/crew-approve`, `/crew-loosen`.
34
+ - In `auto` mode a commit or a push waits for you: type `/crew-approve` and what was just refused is approved, tied to what is staged.
34
35
  - **Safe adoption.** `adopt` lands everything on a branch, staged and uncommitted; `main` is never touched.
35
36
 
36
37
  ## Updating
@@ -96,8 +96,10 @@ write is refused because the file is on a critical path, do not reach it another
96
96
  approval prompt only the user can answer — so present the message FIRST, then run the commit yourself and let the user
97
97
  approve it at the prompt. Never hand the user a command to paste into their own terminal. In `auto` and `dontAsk`
98
98
  the prompt reaches nobody: stage, then ask in this shape and with nothing after it — the message in ONE code block,
99
- and under it one line, the sentence in the user's language and the command as it is: "If you approve, send only
100
- this: `/crew-approve commit`" (or `/crew-approve push`, `/crew-approve commit+push`). The user approves by sending
99
+ and under it one sentence in the user's language ("If you approve, send only this:") followed by the command
100
+ on a line of its own: `/crew-approve`. With nothing after it, it approves what the gate just refused in this
101
+ session (a commit, a push, or both when both were refused); spelled out it is `/crew-approve commit`,
102
+ `/crew-approve push` or `/crew-approve commit+push`. The user approves by sending
101
103
  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
102
104
  recording hook is not wired), say so and offer Shift+Tab or their own terminal instead.
103
105
  Then run `git commit -m '…'` alone in its call (single-quoted, or from a here-document with a quoted delimiter),
@@ -6951,7 +6951,7 @@ _pa_op(){ sed -n 's/^op=//p' "$_pa_rec" 2>/dev/null; }
6951
6951
  if [ "$UNITS" != 1 ]; then
6952
6952
  # The cases below feed payloads to the two hooks; like the other gate unit cases they belong to the source run.
6953
6953
  # An installed project keeps the two pins after them: the shared definitions and the wiring.
6954
- skip scope "approval by the user's own message: the unit cases run in the source checkout (scope=install)" 47
6954
+ skip scope "approval by the user's own message: the unit cases run in the source checkout (scope=install)" 49
6955
6955
  elif [ ! -f "$HOOKS/prompt-approval.sh" ]; then
6956
6956
  fail "hooks/prompt-approval.sh is missing — in auto and dontAsk nothing can turn the user's own yes into an approval"
6957
6957
  else
@@ -7180,13 +7180,87 @@ done <<'PC'
7180
7180
  0 @@ claude --version
7181
7181
  0 @@ echo \"/crew-approve commit\"
7182
7182
  0 @@ grep -rn \"approve: commit\" kit | head
7183
- 0 @@ claude -p /crew-approve commit
7183
+ 1 @@ claude -p /crew-approve commit
7184
7184
  PC
7185
7185
  if [ "$_pcn" != 18 ]; then fail "FIXTURE: the claude-call table has $_pcn rows, not 18"
7186
- elif [ -z "$_pcbad" ]; then pass "a command that hands a Claude Code session an approval is refused in auto and in default (12 forms: quoted, escaped, an option's value, a variable, a pipe, npx / bunx / node, interactive); an ordinary claude -p and a command without claude are not touched (6)"
7186
+ elif [ -z "$_pcbad" ]; then pass "a command that hands a Claude Code session an approval is refused in auto and in default (13 forms: quoted, escaped, an option's value, a variable, a pipe, npx / bunx / node, interactive, and the unquoted one whose prompt is the bare command); an ordinary claude -p and a command without claude are not touched (5)"
7187
7187
  else fail "claude-call table:$_pcbad"; fi
7188
7188
  _pa_new
7189
7189
 
7190
+ # `/crew-approve` WITH NOTHING AFTER IT (3.1.0). In the trial the session said "send /crew-approve commit+push" and
7191
+ # stood still until the user noticed. Now the gate writes down what it refused (.claude/state/crew-approval-pending:
7192
+ # the session, the operation, the second), and the bare command approves that: the same session's, not older than
7193
+ # 30 minutes, once. What is approved is the gate's record of the command it refused, never the message's and never
7194
+ # the model's choice; every proof a spelled-out approval needs is still asked.
7195
+ _pa_pend="$_paw/.claude/state/crew-approval-pending"
7196
+ _pa_pop(){ sed -n 's/^op=//p' "$_pa_pend" 2>/dev/null; }
7197
+ _pbb=""
7198
+ # One refused commit; one refused push; a commit and then a push refused: each approved by the bare command.
7199
+ _pa_new; _pa_run auto 'git commit -m x'; [ "$_par/$(_pa_pop)" = "2/commit" ] || _pbb="$_pbb [a refused commit left: $_par/$(_pa_pop)]"
7200
+ _po="$(_pa_say auto '/crew-approve')"; case "$_po" in *'approved: commit (what this session was refused)'*'approval recorded'*) ;; *) _pbb="$_pbb [bare, commit: ${_po:0:110}]" ;; esac
7201
+ [ "$(_pa_op)" = commit ] || _pbb="$_pbb [the record after a bare approval of a commit: $(_pa_op)]"
7202
+ _pa_run auto 'git commit -m x'; { [ "$_par" = 0 ] && [ "$(gdec "$_pao")" = allow ]; } || _pbb="$_pbb the-approved-commit-is-still-refused:$_par"
7203
+ [ -z "$(_pa_pop)" ] || _pbb="$_pbb what-was-waiting-is-still-there-after-it-was-approved"
7204
+ _pa_new; ( cd "$_paw" && git commit -qm second ) >/dev/null 2>&1
7205
+ _pa_run auto 'git push origin feat/x'; [ "$_par/$(_pa_pop)" = "2/push" ] || _pbb="$_pbb [a refused push left: $_par/$(_pa_pop)]"
7206
+ _pa_say auto '/crew-approve' >/dev/null; [ "$(_pa_op)" = push ] || _pbb="$_pbb [the record after a bare approval of a push: $(_pa_op)]"
7207
+ _pa_run auto 'git push origin feat/x'; [ "$_par" = 0 ] || _pbb="$_pbb the-approved-push-is-still-refused:$_par"
7208
+ _pa_new; _pa_run auto 'git commit -m x'; _pa_run auto 'git push origin feat/x'; [ "$(_pa_pop)" = "commit+push" ] || _pbb="$_pbb [a commit then a push refused left: $(_pa_pop)]"
7209
+ _po="$(_pa_say dontAsk '/crewforth:crew-approve')"; case "$_po" in *'approved: commit+push'*) ;; *) _pbb="$_pbb [bare in the plugin's spelling: ${_po:0:90}]" ;; esac
7210
+ [ "$(_pa_op)" = "commit+push" ] || _pbb="$_pbb [the record after a bare approval of both: $(_pa_op)]"
7211
+ # Used once: a second bare command has nothing to approve.
7212
+ _po="$(_pa_say auto '/crew-approve')"; { [ -z "$(_pa_op)" ] && case "$_po" in *'approval NOT recorded'*'nothing is waiting'*) true ;; *) false ;; esac; } || _pbb="$_pbb [a second bare approval: op $(_pa_op), ${_po:0:80}]"
7213
+ # Nothing refused at all; refused in another session; refused more than 30 minutes ago.
7214
+ _pa_new; rm -f "$_pa_pend"; _po="$(_pa_say auto '/crew-approve')"; { [ ! -s "$_pa_rec" ] && case "$_po" in *'nothing is waiting'*) true ;; *) false ;; esac; } || _pbb="$_pbb approved-with-nothing-waiting"
7215
+ _pa_run auto 'git commit -m x' Bash other-session; _po="$(_pa_say auto '/crew-approve')"; { [ ! -s "$_pa_rec" ] && case "$_po" in *'refused in another session'*) true ;; *) false ;; esac; } || _pbb="$_pbb another-sessions-refusal-was-approved"
7216
+ [ "$(_pa_pop)" = commit ] || _pbb="$_pbb the-other-sessions-refusal-was-used-up"
7217
+ printf 'sid=s\nop=commit+push\nts=%s\n' "$(( $(date +%s) - 1900 ))" > "$_pa_pend"; _po="$(_pa_say auto '/crew-approve')"; { [ ! -s "$_pa_rec" ] && case "$_po" in *'more than 30 minutes old'*) true ;; *) false ;; esac; } || _pbb="$_pbb a-refusal-31-minutes-old-was-approved"
7218
+ printf 'sid=s\nop=rm -rf\nts=%s\n' "$(date +%s)" > "$_pa_pend"; _pa_say auto '/crew-approve' >/dev/null; [ ! -s "$_pa_rec" ] || _pbb="$_pbb an-operation-that-is-not-one-was-approved"
7219
+ # The proofs a spelled-out approval needs are asked of the bare one: the mode, an attended session, a transcript.
7220
+ _pa_new; _pa_run auto 'git commit -m x'
7221
+ for _pm in default acceptEdits plan bypassPermissions; do _pa_say "$_pm" '/crew-approve' >/dev/null; [ -s "$_pa_rec" ] && _pbb="$_pbb bare-recorded-in-$_pm"; done
7222
+ [ "$(_pa_pop)" = commit ] || _pbb="$_pbb a-mode-that-records-nothing-used-up-what-was-waiting"
7223
+ _PA_ENV="CLAUDE_CODE_SESSION_ATTENDED=0" _pa_say auto '/crew-approve' >/dev/null; [ -s "$_pa_rec" ] && _pbb="$_pbb bare-recorded-in-a-headless-session"
7224
+ _pa_run auto 'git commit -m x'; _PA_TR="$_PA/never-written.jsonl" _pa_say auto '/crew-approve' >/dev/null; [ -s "$_pa_rec" ] && _pbb="$_pbb bare-recorded-as-a-sessions-first-message"
7225
+ for _pp in '/crew-approve now' 'please /crew-approve' '/crew-approved' '<task-notification>/crew-approve' 'Another Claude session sent a message:\n/crew-approve' '/crew-approve\n/crew-approve'; do
7226
+ _pa_new; _pa_run auto 'git commit -m x'; _pa_say auto "$_pp" >/dev/null; [ -s "$_pa_rec" ] && _pbb="$_pbb [recorded for: $_pp]"; done
7227
+ [ -z "$_pbb" ] && pass "/crew-approve with nothing after it approves what the gate last refused in this session: a commit, a push, and both when both were refused (also in the plugin's spelling), once; with nothing refused, a refusal of another session, one 31 minutes old or an operation that is not one, nothing is recorded and the reason is said; it records in auto and dontAsk only, not in a headless session, not as a session's first message, and not for six messages that are not the command alone" \
7228
+ || fail "bare /crew-approve:$_pbb"
7229
+ # The session cannot choose what the user's approval covers, nor have the bare command sent for it.
7230
+ _pbb=""; _pa_new; _pa_run auto 'git commit -m x'
7231
+ for _tk in 'Write|file_path' 'Edit|file_path' 'MultiEdit|file_path' 'NotebookEdit|notebook_path'; do
7232
+ printf '{"session_id":"s","cwd":"%s","permission_mode":"acceptEdits","hook_event_name":"PreToolUse","tool_name":"%s","tool_input":{"%s":"%s","content":"sid=s","old_string":"commit","new_string":"commit+push","edits":[],"new_source":"x"}}' "$_paw" "${_tk%|*}" "${_tk#*|}" "$_pa_pend" \
7233
+ | ( cd "$_paw" && CREW_NO_BOARD=1 CREW_GATE_LOG=/dev/null bash "$HOOKS/guard-write.sh" >/dev/null 2>&1 ); [ "$?" = 2 ] || _pbb="$_pbb [${_tk%|*} may write the pending record]"
7234
+ done
7235
+ while IFS= read -r _pl; do [ -z "$_pl" ] && continue
7236
+ _pw="${_pl%% @@ *}"; _pc="${_pl#* @@ }"
7237
+ _pa_run auto "$_pc"; [ "$_par" = "$_pw" ] || _pbb="$_pbb [$_pc → $_par, want $_pw]"
7238
+ done <<'PB'
7239
+ 2 @@ printf 'sid=s\\nop=commit+push\\nts=1\\n' > .claude/state/crew-approval-pending
7240
+ 2 @@ echo op=push >> .claude/state/crew-approval-pending
7241
+ 2 @@ tee .claude/state/crew-approval-pending
7242
+ 2 @@ cp /tmp/p .claude/state/crew-approval-pending
7243
+ 2 @@ mv /tmp/p .claude/state/crew-approval-pending
7244
+ 2 @@ sed -i s/commit/commit+push/ .claude/state/crew-approval-pending
7245
+ 2 @@ cp /tmp/p .claude/state/crew-approval-p*
7246
+ 2 @@ rm .claude/state/crew-approval-pending
7247
+ 2 @@ cat .claude/state/crew-approval-pending
7248
+ 2 @@ claude -p /crew-approve
7249
+ 2 @@ claude -p \"/crew-approve\"
7250
+ 2 @@ echo /crew-approve | claude -p
7251
+ 2 @@ npx @anthropic-ai/claude-code -p /crewforth:crew-approve
7252
+ 0 @@ grep -rn /crew-approve .claude/hooks
7253
+ 0 @@ echo /crew-approve
7254
+ 0 @@ claude -p \"explain what /crew-approve does\"
7255
+ PB
7256
+ [ "$(_pa_pop)" = commit ] || _pbb="$_pbb [what was waiting changed: $(_pa_pop)]"
7257
+ for _pt in CronCreate ScheduleWakeup; do
7258
+ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"%s","tool_input":{"cron":"* * * * *","delaySeconds":60,"prompt":"/crew-approve"}}' "$_pt" | bash "$HOOKS/guard-schedule.sh" >/dev/null 2>"$_PA/ps.err"
7259
+ { [ "$?" = 2 ] && grep -q 'the user can type /crew-approve when they agree' "$_PA/ps.err"; } || _pbb="$_pbb [a scheduled bare /crew-approve passed $_pt]"
7260
+ done
7261
+ [ -z "$_pbb" ] && pass "the session cannot choose or send the bare approval: Write, Edit, MultiEdit and NotebookEdit refuse the pending record; 9 shell commands that write, copy, move, remove or name it are refused; a claude call handed the bare command (4 forms) and a scheduled one (2 tools) are refused; a grep for the word, an echo of it and a prompt that mentions it are not" \
7262
+ || fail "bare /crew-approve cannot be arranged by the session:$_pbb"
7263
+
7190
7264
  # ---- which modes ----------------------------------------------------------------------------------------------
7191
7265
  _pabad=""
7192
7266
  for _pm in auto dontAsk; do : > "$_pa_rec"; _pa_say "$_pm" 'approve: commit' >/dev/null; [ "$(_pa_op)" = commit ] || _pabad="$_pabad $_pm:no-record"; done
@@ -7674,11 +7748,12 @@ _parow=""
7674
7748
  _pa_want(){ # $1 = label, $2 = text that must be there, $3 = text that must not
7675
7749
  case "$_par/$_pae" in 2/*"$2"*) case "$_pae" in *"$3"*) _parow="$_parow [$1: says '$3']" ;; esac ;; *) _parow="$_parow [$1: rc $_par, no '$2']" ;; esac; }
7676
7750
  _pa_wire "$_PAW_ON"
7677
- _pa_gate auto 'git commit -m x'; _pa_want 'wired, commit' 'send only this: /crew-approve commit' 'APPROVAL PATH IS CLOSED'
7678
- _pa_want 'wired, commit, one block' 'in ONE code block' 'send only this: /crew-approve push'
7679
- _pa_gate dontAsk 'git push origin feat/x'; _pa_want 'wired, push' 'send only this: /crew-approve push' 'APPROVAL PATH IS CLOSED'
7680
- _pa_gate auto 'git commit -m x'; _pa_want 'wired, no text form' 'the command as it is' 'approve: '
7681
- _pa_want 'wired, no Turkish alias' 'the command as it is' 'onay:'
7751
+ _pa_gate auto 'git commit -m x'; _pa_want 'wired, commit' 'approves what was just refused here: commit.' 'APPROVAL PATH IS CLOSED'
7752
+ _pa_want 'wired, commit, one block' 'in ONE code block' 'refused here: push'
7753
+ _pa_gate dontAsk 'git push origin feat/x'; _pa_want 'wired, push after the commit' 'approves what was just refused here: commit+push.' 'APPROVAL PATH IS CLOSED'
7754
+ grep -qx '/crew-approve' "$_PA/err" || _parow="$_parow [the command is not on a line of its own]"
7755
+ _pa_gate auto 'git commit -m x'; _pa_want 'wired, no text form' 'exactly as it is' 'approve: '
7756
+ _pa_want 'wired, no Turkish alias' 'exactly as it is' 'onay:'
7682
7757
  rm -f "$_paw/.claude/settings.json"
7683
7758
  _pa_gate auto 'git commit -m x'; _pa_want 'no settings' 'APPROVAL PATH IS CLOSED' 'send only this'
7684
7759
  _pa_want 'no settings, the ways' 'Shift+Tab' 'ONE code block'
@@ -7687,7 +7762,7 @@ _pa_gate auto 'git push origin feat/x'; _pa_want 'named before the event only'
7687
7762
  _pa_wire '{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"bash .claude/hooks/guard-bash.sh"}]}]}}'
7688
7763
  _pa_gate auto 'git commit -m x'; _pa_want 'other events only' 'APPROVAL PATH IS CLOSED' 'send only this'
7689
7764
  rm -f "$_paw/.claude/settings.json"; _pa_wire "$_PAW_ON"; mv "$_paw/.claude/settings.json" "$_paw/.claude/settings.local.json"
7690
- _pa_gate auto 'git commit -m x'; _pa_want 'wired in settings.local.json' 'send only this: /crew-approve commit' 'APPROVAL PATH IS CLOSED'
7765
+ _pa_gate auto 'git commit -m x'; _pa_want 'wired in settings.local.json' 'approves what was just refused here' 'APPROVAL PATH IS CLOSED'
7691
7766
  rm -f "$_paw/.claude/settings.local.json"
7692
7767
  if [ -z "$_parow" ]; then pass "the auto-mode refusal asks in one shape when the approval hook is wired, and says the path is closed when it is not (10 readings, each refused; the text form 'approve:' / 'onay:' is named in none)"
7693
7768
  else fail "the auto-mode refusal does not say what it should:$_parow"; fi
@@ -7902,9 +7977,11 @@ sec "== 12h3) model routing v2: the card, the risk class, verify-then-escalate,
7902
7977
  # three events it is wired to. Measured on Claude Code 2.1.294 before any of it was written: an agent's Edit carries
7903
7978
  # agent_id; it is the id SubagentStart was given and the agentId PostToolUse(Agent) returns; a SubagentStop that
7904
7979
  # answers "block" keeps the agent working; PostToolUse's additionalContext reaches the session; an Agent call that
7905
- # leaves run_in_background out started in the background.
7980
+ # leaves run_in_background out started in the background. Measured on 2.1.296 as well: with background tasks off
7981
+ # (CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1) the Agent tool has no run_in_background at all, the call arrives with
7982
+ # description, prompt and subagent_type, and it runs in the foreground (status completed).
7906
7983
  _V2G="$HOOKS/guard-agent-model.sh"; _V2O="$HOOKS/agent-outcome.sh"
7907
- if [ "$UNITS" != 1 ]; then skip scope "model routing v2: the unit cases run in the source checkout (scope=install)" 26
7984
+ if [ "$UNITS" != 1 ]; then skip scope "model routing v2: the unit cases run in the source checkout (scope=install)" 27
7908
7985
  elif [ ! -f "$_V2O" ] || [ ! -f "$_V2G" ]; then fail "hooks/agent-outcome.sh or guard-agent-model.sh is missing — nothing verifies an agent's work or holds the model to the risk"
7909
7986
  else
7910
7987
  _v2="$(mktemp -d)"; _v2="$(cd -P "$_v2" && pwd)"; _v2p="$_v2/p"
@@ -7965,8 +8042,10 @@ done <<'V2'
7965
8042
  0|crew-review-agent|opus|src/payments/**|audit|none|
7966
8043
  2|crew-security-expert|sonnet|src/ui/**|audit|none|
7967
8044
  0|crew-security-expert|opus|src/ui/**|audit|none|
7968
- 2|crew-backend-expert|opus|src/payments/p.ts|feature|true|
8045
+ 0|crew-backend-expert|opus|src/payments/p.ts|feature|true|
8046
+ 0|crew-backend-expert|opus|src/payments/p.ts|feature|true|fg
7969
8047
  2|crew-backend-expert|opus|src/payments/p.ts|feature|true|bg
8048
+ 0|crew-backend-expert|opus|src/a.ts|security|true|
7970
8049
  0|crew-backend-expert|opus|src/payments/p.ts|feature|none|bg
7971
8050
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|true|bg
7972
8051
  2|crew-backend-expert|sonnet|src/ui/a.ts|tidy|true|
@@ -7976,9 +8055,37 @@ done <<'V2'
7976
8055
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|git commit -m x|
7977
8056
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|rm -rf /|
7978
8057
  V2
7979
- if [ "$_v2n" != 45 ]; then fail "FIXTURE: the card table has $_v2n rows, not 45"
7980
- elif [ -z "$_v2b" ]; then pass "the card decides the model: critical work (payments/, migrations, a .sql file, AuthController, userAuth, schema.prisma, and the four critical kinds of change) is refused below opus and passes on opus; verify none is refused on haiku; test-run passes on haiku, also on a critical path; test-write, audit and review are held to sonnet in normal work and to opus in critical work; critical work with a verify command is refused in the background and when the field is left out; a card that is not one is refused; a verify command never refuses the call, whatever it is (the hook decides whether to run it) (45 rows)"
8058
+ if [ "$_v2n" != 47 ]; then fail "FIXTURE: the card table has $_v2n rows, not 47"
8059
+ elif [ -z "$_v2b" ]; then pass "the card decides the model: critical work (payments/, migrations, a .sql file, AuthController, userAuth, schema.prisma, and the four critical kinds of change) is refused below opus and passes on opus; verify none is refused on haiku; test-run passes on haiku, also on a critical path; test-write, audit and review are held to sonnet in normal work and to opus in critical work; critical work with a verify command is refused when the call says run_in_background true, and passes with false and with the field left out (a session whose Agent tool has no such field cannot send it); a card that is not one is refused; a verify command never refuses the call, whatever it is (the hook decides whether to run it) (47 rows)"
7981
8060
  else fail "card table:$_v2b"; fi
8061
+ # The refusal names the way forward that exists (the field was sent, so it can be sent as false), and a call that
8062
+ # left the field out and was launched in the background is told so when the launch comes back.
8063
+ _v2new; _v2b=""
8064
+ _v2call crew-backend-expert opus 'src/payments/p.ts' feature true ',"run_in_background":true'
8065
+ { [ "$_v2rc" = 2 ] && grep -q 'is not sent to the background' "$_v2/err" && grep -q 'run_in_background set to false' "$_v2/err"; } || _v2b="$_v2b [true: rc $_v2rc, $(cut -c1-80 "$_v2/err")]"
8066
+ grep -q 'left out' "$_v2/err" && _v2b="$_v2b [the refusal still tells the session what happens when the field is left out]"
8067
+ _v2call crew-backend-expert opus 'src/payments/p.ts' feature true ',"run_in_background": true'; [ "$_v2rc" = 2 ] || _v2b="$_v2b [true after a space: rc $_v2rc]"
8068
+ _v2call crew-backend-expert opus 'src/payments/p.ts' feature true ',"run_in_background":"true"'; [ "$_v2rc" = 0 ] || _v2b="$_v2b [a string, which is not the boolean: rc $_v2rc]"
8069
+ # $1 agent id, $2 status -> what the hook prints
8070
+ # $3 = the call's tool_use_id (default: one no record is named after)
8071
+ _v2launch(){ printf '{"session_id":"s","hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"p"},"tool_response":{"status":"%s","agentId":"%s","isAsync":true},"tool_use_id":"%s"}' "$2" "$1" "${3:-tu-none}" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
8072
+ # The order a real session has (measured on Claude Code 2.1.296): the launch is answered BEFORE SubagentStart, so the
8073
+ # record is still the call's own, named after its tool_use_id.
8074
+ _v2new; _v2call crew-backend-expert opus 'src/payments/p.ts' feature true
8075
+ _tid="$(sed -n 's/.*"tool_use_id":"\([^"]*\)".*/\1/p' "$_v2/c.json")"
8076
+ _o="$(_v2launch la0 async_launched "$_tid")"; case "$_o" in *'launched in the background'*'(true)'*) ;; *) _v2b="$_v2b [a launch answered before the agent started is not told: ${_o:0:80}]" ;; esac
8077
+ _o="$(_v2launch la0 async_launched tu-of-another-call)"; [ -z "$_o" ] || _v2b="$_v2b [told from a record of another call]"
8078
+ _v2start la0; _v2stop la0 >/dev/null; [ -n "$(_v2row la0)" ] || _v2b="$_v2b [the launch notice used up the call's record: no line for the agent]"
8079
+ # ...and the other order: the agent has its record already.
8080
+ _v2new; _v2call crew-backend-expert opus 'src/payments/p.ts' feature true; _v2start la1
8081
+ _o="$(_v2launch la1 async_launched)"; case "$_o" in *'"additionalContext":"Crewforth: this critical work was launched in the background'*'run the verify command yourself with the Bash tool (true)'*) ;; *) _v2b="$_v2b [a background launch of critical work is not told: ${_o:0:80}]" ;; esac
8082
+ printf '%s' "$_o" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{JSON.parse(s)})' 2>/dev/null || { command -v node >/dev/null 2>&1 && _v2b="$_v2b [what it prints is not JSON]"; }
8083
+ _o="$(_v2launch la1 completed)"; [ -z "$_o" ] || _v2b="$_v2b [a call that is not a launch is told it was one]"
8084
+ _v2new; _v2call crew-backend-expert opus 'src/payments/p.ts' feature none; _v2start la2; _o="$(_v2launch la2 async_launched)"; [ -z "$_o" ] || _v2b="$_v2b [told for a card with no verify command]"
8085
+ _v2new; _v2call crew-backend-expert sonnet 'src/ui/a.ts' feature true; _v2start la3; _o="$(_v2launch la3 async_launched)"; [ -z "$_o" ] || _v2b="$_v2b [told for normal work]"
8086
+ _o="$(_v2launch nobody async_launched)"; [ -z "$_o" ] || _v2b="$_v2b [told for an agent with no record]"
8087
+ [ -z "$_v2b" ] && pass "run_in_background on critical work with a verify command: true is refused with the way forward (set it to false), in both spellings of the JSON; a string is not the boolean; a call that left the field out and came back as a background launch is told to run the verify command itself when the agent ends, whether the launch is answered before the agent starts (what Claude Code 2.1.296 does) or after, and the agent still gets its line; nothing is said for a finished call, a card with no verify command, normal work or an agent with no record" \
8088
+ || fail "run_in_background, critical work:$_v2b"
7982
8089
  # "In normal work an author on Opus does not pull the review up": the one deliberate change of an instruction.
7983
8090
  _v2call crew-backend-expert opus 'src/ui/a.ts' feature true; _r1=$_v2rc; _v2call crew-review-agent sonnet 'src/ui/a.ts' audit none; _r2=$_v2rc
7984
8091
  [ "$_r1/$_r2" = 0/0 ] && pass "normal work written on opus is reviewed on sonnet: the gate allows both calls (the instruction 'audit ≥ author' is gone; no gate held it)" \
@@ -9995,8 +10102,10 @@ printf 'acme-internal-host\n' > "$_TH/r/.private-terms.txt"
9995
10102
  _thbad=""
9996
10103
  _th_hook "$_TH/r" pre-commit ""; { [ "$_thr" = 1 ] && grep -q 'PRIVATE-PATH SCANNER: a staged line contains' "$_TH/out"; } \
9997
10104
  || _thbad="$_thbad [FIXTURE: with a working grep the private string is not reported: rc=$_thr]"
9998
- _th_hook "$_TH/r" pre-commit "" "$_TH/bin"; { [ "$_thr" = 1 ] && grep -q 'could not run' "$_TH/out"; } \
9999
- || _thbad="$_thbad [grep aborting for -iF: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
10105
+ # A grep that aborts for -iF changes nothing: the look for a private string does not use it (measured on two
10106
+ # Windows machines: Git Bash's grep 3.0 aborts for -iF in the C locale on one and under tr_TR on the other).
10107
+ _th_hook "$_TH/r" pre-commit "" "$_TH/bin"; { [ "$_thr" = 1 ] && grep -q 'PRIVATE-PATH SCANNER: a staged line contains' "$_TH/out" && ! grep -q 'could not run' "$_TH/out"; } \
10108
+ || _thbad="$_thbad [grep aborting for -iF: rc=$_thr, want 1 and the private string reported — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
10000
10109
  # the same for a scan by pattern: an abort is not "this pattern does not compile"
10001
10110
  mkdir -p "$_TH/bin2"
10002
10111
  printf '#!/bin/sh\ncase " $* " in *" -qiE "*) kill -ABRT $$ ;; esac\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin2/grep"; chmod +x "$_TH/bin2/grep"
@@ -10005,6 +10114,41 @@ _th_hook "$_TH/e" pre-commit "" "$_TH/bin2"; { [ "$_thr" = 1 ] && grep -q 'TRACE
10005
10114
  || _thbad="$_thbad [pre-commit, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
10006
10115
  _th_hook "$_TH/e" commit-msg "" "$_TH/bin2" msg.txt; { [ "$_thr" = 1 ] && grep -q 'could not run' "$_TH/out"; } \
10007
10116
  || _thbad="$_thbad [commit-msg, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
10117
+ # ...and the look for a private string, which is a scan by pattern now: an abort stops the commit and says so
10118
+ _th_hook "$_TH/r" pre-commit "" "$_TH/bin2"; { [ "$_thr" = 1 ] && grep -q 'PRIVATE-PATH SCANNER: the look for a machine-private string could not run' "$_TH/out"; } \
10119
+ || _thbad="$_thbad [the private-string look, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(grep -m1 PRIVATE "$_TH/out" | cut -c1-120)]"
10120
+ # A term is a literal, whatever it holds: each is found as written and in another case, and none of its characters
10121
+ # works as a pattern (the line on the right is what it would match if one did).
10122
+ _thl=0
10123
+ while IFS= read -r _tl; do [ -z "$_tl" ] && continue
10124
+ _tt="${_tl%% @@ *}"; _tr="${_tl#* @@ }"; _th1="${_tr%% @@ *}"; _th0="${_tr#* @@ }"; _thl=$((_thl+1))
10125
+ _th_repo "$_TH/l"; printf '%s\n' "$_tt" > "$_TH/l/.private-terms.txt"
10126
+ ( cd "$_TH/l" && printf 'x %s y\n' "$_th1" > n.md && git add n.md ) >/dev/null 2>&1
10127
+ _th_hook "$_TH/l" pre-commit ""; { [ "$_thr" = 1 ] && grep -q 'a staged line contains' "$_TH/out"; } || _thbad="$_thbad [the term $_tt is not found in: $_th1 (rc=$_thr)]"
10128
+ ( cd "$_TH/l" && printf 'x %s y\n' "$_th0" > n.md && git add n.md ) >/dev/null 2>&1
10129
+ _th_hook "$_TH/l" pre-commit ""; [ "$_thr" = 0 ] || _thbad="$_thbad [the term $_tt is read as a pattern: it matched $_th0 (rc=$_thr, $(sed -n 1p "$_TH/out" | cut -c1-80))]"
10130
+ done <<'TL'
10131
+ acme.internal @@ ACME.Internal @@ acmeXinternal
10132
+ C:\Users\ada.l @@ c:\users\ADA.L @@ C:Usersada.l
10133
+ build[7] @@ BUILD[7] @@ build7
10134
+ a+b(c) @@ A+B(C) @@ aab(c)
10135
+ host|other @@ HOST|OTHER @@ host
10136
+ cost$ ^x* @@ COST$ ^X* @@ cost ^
10137
+ q{2}? @@ Q{2}? @@ qq
10138
+ TL
10139
+ [ "$_thl" = 7 ] || _thbad="$_thbad [FIXTURE: the literal-term table has $_thl rows, not 7]"
10140
+ # No hook looks for anything with -iF: a code line of a gate that calls grep with both flags is the defect itself.
10141
+ _thf="$(for _f in "$HOOKS"/*.sh "$HOOKS/pre-commit" "$HOOKS/commit-msg"; do grep -nE '^[^#]*(grep[[:space:]][^|;#]*-[A-Za-z]*(i[A-Za-z]*F|F[A-Za-z]*i)|_crew_(scan|any)[^#]*[[:space:]](iF|Fi)([[:space:]]|$))' "$_f" 2>/dev/null | sed "s#^#${_f##*/}:#"; done)"
10142
+ [ -z "$_thf" ] || _thbad="$_thbad [a hook calls grep with -i and -F together: $(printf '%s' "$_thf" | head -2 | cut -c1-140)]"
10143
+ # ...and the look itself names the C locale on the call (3 calls: _crew_scan in both git hooks, _crew_any): under
10144
+ # tr_TR `grep -iE` answers "no match" for i against I without aborting, so a look left to the session's locale
10145
+ # would miss a term in capitals, silently.
10146
+ _thc="$(grep -cE '^[[:space:]]*LC_ALL=C grep -q' "$HOOKS/pre-commit" "$HOOKS/commit-msg" | tr '\n' ' ')"
10147
+ _thu="$(grep -nE '^[[:space:]]*grep -q("\$1"|\$3) -- ' "$HOOKS/pre-commit" "$HOOKS/commit-msg" | head -2)"
10148
+ case "$_thc" in *"pre-commit:2 "*"commit-msg:1 "*) ;; *) _thbad="$_thbad [the first look does not name the C locale on the call: $_thc]" ;; esac
10149
+ [ -z "$_thu" ] || _thbad="$_thbad [a first look runs in whatever locale is exported: $(printf '%s' "$_thu" | cut -c1-120)]"
10150
+ printf 'x() { grep -qiF -- "$a" "$b"; }\n_crew_scan iF "$t" "$f"\n# grep -iF in a comment\n' > "$_TH/pin.sh"
10151
+ [ "$(grep -cE '^[^#]*(grep[[:space:]][^|;#]*-[A-Za-z]*(i[A-Za-z]*F|F[A-Za-z]*i)|_crew_(scan|any)[^#]*[[:space:]](iF|Fi)([[:space:]]|$))' "$_TH/pin.sh")" = 2 ] || _thbad="$_thbad [FIXTURE: the -iF pin does not see the two lines it is for]"
10008
10152
  # and for the diff itself: the grep that picks the added lines aborting must not leave an empty file to scan
10009
10153
  mkdir -p "$_TH/bin3"
10010
10154
  printf '#!/bin/sh\n[ "$1" = -E ] && [ "$2" = "^\\+" ] && kill -ABRT $$\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin3/grep"; chmod +x "$_TH/bin3/grep"
@@ -10017,7 +10161,7 @@ _th_hook "$_TH/b" pre-commit ""; { [ "$_thr" = 0 ] && grep -q 'is not a valid re
10017
10161
  || _thbad="$_thbad [pre-commit with a pattern that does not compile: rc=$_thr, want 0 and the warning]"
10018
10162
  _th_hook "$_TH/b" commit-msg "" "" msg.txt; { [ "$_thr" = 0 ] && grep -q 'is not a valid regular expression' "$_TH/out"; } \
10019
10163
  || _thbad="$_thbad [commit-msg with a pattern that does not compile: rc=$_thr, want 0 and the warning]"
10020
- [ -z "$_thbad" ] && pass "a scan that could not run stops the commit: with grep aborting for -iF (what Git Bash's grep does under a Turkish locale) or for -iE, or while the added lines are collected, pre-commit and commit-msg exit 1 and say so; a pattern that does not compile still only warns, in both" \
10164
+ [ -z "$_thbad" ] && pass "a scan that could not run stops the commit: with grep aborting for -iE (the scan by pattern and the look for a private string), or while the added lines are collected, pre-commit and commit-msg exit 1 and say so; a pattern that does not compile still only warns, in both; a grep that aborts for -iF changes nothing, since no hook calls it (pinned, with a twin the pin sees); the first look names the C locale on the call, in both git hooks; a private term is a literal: 7 terms with . \\ [ ] + ( ) | \$ ^ * { } ? are found as written, in another case, and match nothing else" \
10021
10165
  || fail "a scan that could not run:$_thbad"
10022
10166
 
10023
10167
  # The command shown in the approval prompt is cut to 300 bytes a line and goes into JSON. `cut -c` did it: GNU cut
@@ -10100,7 +10244,7 @@ else
10100
10244
  ( cd "$_TH/n" && printf 'see ŞİRKET-GİZLİ\n' > n.md && git add n.md ) >/dev/null 2>&1
10101
10245
  _th_hook "$_TH/n" pre-commit "$_th"; _thna="$_thr"
10102
10246
  # required where this platform's grep folds such a letter under the locale at all (GNU grep does, BSD grep does not)
10103
- if ( printf 'ŞİRKET\n' | LC_ALL=$_th grep -qiF 'şirket' ) >/dev/null 2>&1; then
10247
+ if ( printf 'ŞİRKET\n' | LC_ALL=$_th grep -qiE 'şirket' ) >/dev/null 2>&1; then
10104
10248
  [ "$_thna" = 1 ] || _thbad="$_thbad [pre-commit, a private string of non-ASCII letters in another case: rc=$_thna, want 1 (this grep folds them under $_th)]"
10105
10249
  fi
10106
10250
  [ -z "$_thbad" ] && pass "under $_th the git hooks stop what they stop under C: a vendor name in another case in an added line and in the message, a staged key that holds an I, a private string in another case; an ordinary line and message pass (a private string of non-ASCII letters in another case: exit $_thna, 1 = caught by the second look)" \
@@ -24,8 +24,9 @@
24
24
  # Only ever up: lowering a floor is the user's decision and nothing here does it.
25
25
  # PostToolUse (Agent) a foreground call has come back. If its verify failed, or its report closed with
26
26
  # confidence: low, the session is told to repeat the task once, one model up, and to ask
27
- # the user if that fails too. A background call comes back before its agent has finished,
28
- # so nothing is said then; the record and the gate still hold.
27
+ # the user if that fails too. A background call comes back before its agent has finished:
28
+ # for critical work with a verify command the session is told to run that command itself
29
+ # when the agent ends; the record and the gate still hold.
29
30
  # SessionStart, --scan the project's critical paths are listed in .claude/state/crew-critical-paths.auto, when the
30
31
  # list of files has changed. A generated file: the gate does not read it, it reads the paths.
31
32
  #
@@ -389,7 +390,27 @@ case "$EV" in
389
390
  fi
390
391
  fi
391
392
  fi
392
- R="$_AMD/results/$AID"; [ -f "$R" ] || exit 0
393
+ R="$_AMD/results/$AID"
394
+ if [ ! -f "$R" ]; then
395
+ # No result yet: the agent was launched in the background (measured, 2.1.294 and 2.1.296: an Agent call that
396
+ # leaves run_in_background out, where the tool has the field, answers status async_launched). For critical
397
+ # work with a verify command the session is told now, since this hook will not be asked again when it ends.
398
+ # Measured on 2.1.296: the answer to a launch comes BEFORE SubagentStart, so the call's record is still where the
399
+ # gate left it, named after the call (tool_use_id); after SubagentStart it is under the agent's id.
400
+ A="$_AMD/agents/$AID"
401
+ if [ ! -f "$A" ]; then
402
+ _json_slice "$INPUT" tool_use_id >/dev/null; TID="$_JS"; A=""
403
+ case "$TID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac
404
+ for f in "$_AMD/pending"/*-"$TID"; do [ -f "$f" ] && A="$f"; done
405
+ [ -n "$A" ] || exit 0
406
+ fi
407
+ case "$INPUT" in *'"status":"async_launched"'*|*'"status": "async_launched"'*) ;; *) exit 0 ;; esac
408
+ _am_rec_get "$A" risk; [ "$_AMV" = critical ] || exit 0
409
+ _am_rec_get "$A" verify; VCMD="$_AMV"; case "$VCMD" in ''|none) exit 0 ;; esac
410
+ _ao_json "Crewforth: this critical work was launched in the background, so whether its verify command passed will not be told to you here. When the agent has finished, run the verify command yourself with the Bash tool ($VCMD) and judge the result before you build on the work or report it done."
411
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$_AOJ"
412
+ exit 0
413
+ fi
393
414
  _am_rec_get "$R" verify; RES="$_AMV"; _am_rec_get "$R" confidence; CONF="$_AMV"; _am_rec_get "$R" tier; TI="$_AMV"
394
415
  _am_rec_get "$R" esc; ESC="$_AMV"; _am_rec_get "$R" cmd; VCMD="$_AMV"
395
416
  case "$RES" in fail|blocked|timeout) ;; *) [ "$CONF" = low ] || exit 0 ;; esac
@@ -36,7 +36,9 @@ export LC_ALL=C
36
36
  # the first look finds nothing and the match ignores case: in the C locale such a letter has no other case.
37
37
  _crew_scan(){ # $1 = grep's flags after -q (iE, E, iF) · $2 = pattern · $3 = file
38
38
  local r=0
39
- grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$?
39
+ # The first look is in the C locale, SAID ON THE CALL and not left to the export at the top of the file: under
40
+ # tr_TR `grep -iE` does not abort, it answers "no match" for i against I (measured in Git Bash), which is silent.
41
+ LC_ALL=C grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$?
40
42
  if [ "$r" = 1 ] && [ -n "$_CREW_LOCALE" ]; then
41
43
  case "$1" in *i*) case "$2" in *[!$'\t'\ -~]*) r=0; LC_ALL="$_CREW_LOCALE" grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$? ;; esac ;; esac
42
44
  fi
@@ -103,7 +103,7 @@ if [ -n "${IN:-}" ] && [ -n "${PSID:-}" ]; then
103
103
  # In auto and dontAsk the user's own message is the approval (hooks/prompt-approval.sh); plan and
104
104
  # bypassPermissions have no such route.
105
105
  case "$PM" in
106
- auto|dontAsk) PMHOW="stage the change, show the message in ONE code block and under it one line in the user's language, 'If you approve, send only this: /crew-approve commit' (or /crew-approve push, /crew-approve commit+push; the command stays as it is in every language, and only the user can type it); the user approves by sending that command alone. It covers what is staged at that moment, for 30 minutes, until their next message; never write it for them" ;;
106
+ auto|dontAsk) PMHOW="stage the change, show the message in ONE code block and under it one line in the user's language, 'If you approve, send only this:' and then, on a line of its own, the command the gate's refusal names: /crew-approve (it approves what the gate just refused; spelled out it is /crew-approve commit, push or commit+push; the command stays as it is in every language, and only the user can type it); the user approves by sending that command alone. It covers what is staged at that moment, for 30 minutes, until their next message; never write it for them" ;;
107
107
  *) PMHOW="a real yes still comes first, then the user switches mode (or exports CLAUDE_GIT_OK=1 in headless/CI)" ;;
108
108
  esac
109
109
  echo "🔒 Permission mode: $PM. git commit/push fail closed in this mode (§4.4). Only when a commit or push comes up: say so before you ask for approval; $PMHOW. Otherwise do not mention it."
@@ -21,7 +21,8 @@
21
21
  # * a class that failed too often is held one model up (crew-model-floors.auto, written by hooks/agent-outcome.sh),
22
22
  # until the user lowers it again with /crew-loosen (one model, once for each raise).
23
23
  # * a card whose verify failed is not run again on the same or a lower model, and not a third time at all.
24
- # * critical work with a verify command runs in the foreground, where its result reaches the session.
24
+ # * critical work with a verify command is not sent to the background (run_in_background true), where its
25
+ # result does not reach the session. A call that leaves the field out passes: not every Agent tool has it.
25
26
  # THE WORDING IS READ BY ANOTHER PROGRAM (the Studio panel): a refusal is one line that begins
26
27
  # `GUARD (agent model):`, and a floor is said as ` runs on <model> or above`. The suite pins both.
27
28
  #
@@ -799,9 +800,15 @@ _json_keycount "$ti" run_in_background
799
800
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names run_in_background more than once, so where it runs cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
800
801
  _json_find "$ti" '"run_in_background"'; bg=unset
801
802
  if [ "$_KC" = 1 ] && [ "$_JF" -ge 0 ]; then f="${ti:_JF+19:12}"; f="${f//[$' \t\n\r']/}"; case "$f" in :false*) bg=false ;; :true*) bg=true ;; esac; fi
802
- if [ "$CARD_RISK" = critical ] && [ "$CARD_VERIFY" != none ] && [ "$bg" != false ]; then
803
- echo "GUARD (agent model): critical work with a verify command runs in the foreground, where its result comes back to you: repeat the same call with run_in_background set to false (left out, an agent starts in the background)." >&2
804
- _am_log "critical work not in the foreground"; exit 2
803
+ # Measured on Claude Code 2.1.294 and 2.1.296. Where the Agent tool has run_in_background, a call that leaves it out
804
+ # starts in the background. Where background tasks are off the tool has NO such parameter: the model cannot send it
805
+ # and the call runs in the foreground. The payload of the two is the same, so a call that leaves the field out is
806
+ # let through. (Asking for `false` refused every critical call with a verify command in a session whose tool has
807
+ # no such field, each time.) Only a call that SAYS background is refused; agent-outcome.sh tells the session when a
808
+ # call that left the field out turned out to be launched in the background.
809
+ if [ "$CARD_RISK" = critical ] && [ "$CARD_VERIFY" != none ] && [ "$bg" = true ]; then
810
+ echo "GUARD (agent model): critical work with a verify command is not sent to the background, where its result does not come back to you: repeat the same call with run_in_background set to false." >&2
811
+ _am_log "critical work sent to the background"; exit 2
805
812
  fi
806
813
  # The verify command is NOT judged here. Whether the hook runs it is decided when it would start (_am_verify_ok, in
807
814
  # hooks/agent-outcome.sh): a command it will not run is recorded as blocked and the session is told to run it
@@ -1823,7 +1823,7 @@ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a
1823
1823
  t="$CMD_UQ"; _APN=0
1824
1824
  shopt -q nocasematch && nc=1; shopt -s nocasematch
1825
1825
  case "$t" in
1826
- *crewforth-appr*) _APN=1 ;;
1826
+ *crewforth-appr*|*crew-approval-pending*) _APN=1 ;;
1827
1827
  esac
1828
1828
  [ "$nc" = 0 ] && shopt -u nocasematch
1829
1829
  # FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
@@ -1857,9 +1857,13 @@ _appr_named
1857
1857
  _appr_word(){ # $1 = a word of the command -> 0 when it, or what follows its first `=`, is an approval
1858
1858
  local w="$1"
1859
1859
  [ "${#w}" -le 160 ] || return 1
1860
- _crew_appr_op "$w"; [ -n "$OP" ] && return 0
1861
- case "$w" in *=*) _crew_appr_op "${w#*=}"; [ -n "$OP" ] && return 0 ;; esac
1862
- return 1
1860
+ _crew_appr_op "$w"; [ -z "$OP" ] && case "$w" in *=*) _crew_appr_op "${w#*=}" ;; esac
1861
+ [ -n "$OP" ] || return 1
1862
+ # `/crew-approve` with nothing after it is a single word, and a single word turns up where nothing is started:
1863
+ # `grep -rn /crew-approve .claude/hooks`. It counts only in a command one of whose words IS claude (also
1864
+ # claude.exe, a path that ends in it, the package's name): then it is a prompt, or about to be piped into one.
1865
+ if [ "$OP" = pending ] && [ "$_AWC" != 1 ]; then OP=""; return 1; fi
1866
+ return 0
1863
1867
  }
1864
1868
  _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names it)
1865
1869
  # The command AS IT WAS SENT: the prompt of a plain `claude -p "…"` has been taken out of CMD by now (it is text for
@@ -1871,7 +1875,10 @@ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names i
1871
1875
  [ -f "$here/prompt-approval.sh" ] || return 1 # no recording hook: no approval can be recorded at all
1872
1876
  declare -F _crew_appr_op >/dev/null 2>&1 || . "$here/prompt-approval.sh"
1873
1877
  declare -F _crew_appr_op >/dev/null 2>&1 || return 1
1874
- OP=""
1878
+ OP=""; _AWC=0
1879
+ case " ${CMD_UQ//[;|&()]/ } " in
1880
+ *[[:space:]]claude[[:space:]]*|*[[:space:]]claude.exe[[:space:]]*|*[[:space:]]claude.cmd[[:space:]]*|*/claude[[:space:]]*|*/claude.exe[[:space:]]*|*claude-code*) _AWC=1 ;;
1881
+ esac
1875
1882
  while :; do
1876
1883
  pre="${s%%[\"\'\\$' \t\n;|&()<>']*}"
1877
1884
  [ -n "$pre" ] && { w="$w$pre"; inw=1; }
@@ -3254,10 +3261,39 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3254
3261
  exit 2
3255
3262
  fi
3256
3263
  _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
3264
+ # WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
3265
+ # the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
3266
+ # THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
3267
+ # within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
3268
+ # and the name rule above): a session that could write it would choose what the user's approval covers.
3269
+ _pw=""
3270
+ if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
3271
+ _pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
3272
+ _json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
3273
+ case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
3274
+ if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
3275
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _pn '%(%s)T' -1; else _pn="$(date +%s)"; fi
3276
+ _po=""; _ps=""; _pt=""
3277
+ if [ -f "$_pd/crew-approval-pending" ]; then
3278
+ while IFS= read -r _pl || [ -n "$_pl" ]; do case "$_pl" in sid=*) _ps="${_pl#sid=}" ;; op=*) _po="${_pl#op=}" ;; ts=*) _pt="${_pl#ts=}" ;; esac; done < "$_pd/crew-approval-pending"
3279
+ fi
3280
+ _pw="$_aps"
3281
+ case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
3282
+ if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
3283
+ case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
3284
+ fi
3285
+ { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
3286
+ fi
3287
+ fi
3257
3288
  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
3258
3289
  echo " 1. the commit message, whole, in ONE code block;" >&2
3259
- echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
3260
- echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
3290
+ if [ -n "$_pw" ]; then
3291
+ 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
3292
+ echo "/crew-approve"$'\n'" That line is the whole message, and only the user can type it. It approves what was just refused here: $_pw. (Spelled out, the user can type /crew-approve commit, push or commit+push instead.)" >&2
3293
+ else
3294
+ echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
3295
+ echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
3296
+ fi
3261
3297
  echo "Their message has to be that command alone, typed by them: a sentence such as 'go ahead and commit' is not an approval, and you cannot run the command for them. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3262
3298
  echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
3263
3299
  echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
@@ -86,7 +86,8 @@ while IFS= read -r sv; do
86
86
  echo "Schedule the work without it. If a floor looks too high, say so: the user can type /crew-loosen $LO_AGENT $LO_CHANGE $LO_RISK themselves." >&2
87
87
  else
88
88
  echo "GUARD (§4.4): this call schedules a prompt that is an approval for a commit or a push ($OP). An approval is what the user types; a prompt a tool schedules would approve in their place, so the call is refused." >&2
89
- echo "Schedule the work without it. For the approval itself, show the commit message and ask: the user can type /crew-approve $OP when they agree." >&2
89
+ _so=" $OP"; [ "$OP" = pending ] && _so=""
90
+ echo "Schedule the work without it. For the approval itself, show the commit message and ask: the user can type /crew-approve$_so when they agree." >&2
90
91
  fi
91
92
  exit 2
92
93
  fi
@@ -37,7 +37,9 @@ export LC_ALL=C
37
37
  # the first look finds nothing and the match ignores case: in the C locale such a letter has no other case.
38
38
  _crew_scan(){ # $1 = grep's flags after -q (iE, E, iF) · $2 = pattern · $3 = file
39
39
  local r=0
40
- grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$?
40
+ # The first look is in the C locale, SAID ON THE CALL and not left to the export at the top of the file: under
41
+ # tr_TR `grep -iE` does not abort, it answers "no match" for i against I (measured in Git Bash), which is silent.
42
+ LC_ALL=C grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$?
41
43
  if [ "$r" = 1 ] && [ -n "$_CREW_LOCALE" ]; then
42
44
  case "$1" in *i*) case "$2" in *[!$'\t'\ -~]*) r=0; LC_ALL="$_CREW_LOCALE" grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$? ;; esac ;; esac
43
45
  fi
@@ -352,18 +354,32 @@ done < <({ _floor_diff --cached; [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ] && _floor_di
352
354
  # escapes to top level): 8 mismatches, in BOTH directions — three clean messages newly rejected and two real
353
355
  # secrets newly passed. Also checked structurally: no pattern in either shipped blocklist carries a bare
354
356
  # top-level `|` (every one is inside a group) and none uses a back-reference, so joining cannot reshape them.
355
- _crew_any(){ # $1 = pattern list $2 = corpus $3 = grep flags $4 = 1 for newline-joined fixed strings
357
+ # A LITERAL TERM IS LOOKED FOR AS A PATTERN, NEVER WITH `grep -iF`. Measured on two Windows machines, both GNU grep
358
+ # 3.0 in Git Bash, and they fail in opposite places:
359
+ # Git for Windows 2.53 (msys bash 5.2) grep -iF aborts (exit 134) when the locale in effect is C or POSIX
360
+ # Git for Windows 2.55 (cygwin bash 5.3) grep -iF aborts (exit 134) when the locale in effect is tr_TR
361
+ # This file sets the C locale (above), so on the first of them every look for a private string aborted and every
362
+ # commit was stopped; with no locale set, the second one aborted for a Turkish user. No locale is safe for both.
363
+ # On both, -iE and -i did not abort in any of 15 pairs of LANG and LC_ALL. So the term is escaped and matched with
364
+ # -iE: _crew_lit puts a backslash before each character that means something in a pattern.
365
+ _crew_lit(){ # $1 = a literal string -> _CL, the pattern that matches exactly it
366
+ local s="$1" c; _CL=""
367
+ while [ -n "$s" ]; do
368
+ c="${s:0:1}"; s="${s:1}"
369
+ case "$c" in '\'|.|'['|']'|'^'|'$'|'*'|+|'?'|'('|')'|'{'|'}'|'|') _CL="$_CL\\$c" ;; *) _CL="$_CL$c" ;; esac
370
+ done
371
+ }
372
+ _crew_any(){ # $1 = pattern list $2 = corpus $3 = grep flags $4 = 1 when the list holds literal terms, not patterns
356
373
  local pat alt="" sep="|" na=0 r=0
357
- [ "$4" = 1 ] && sep="
358
- "
359
374
  while IFS= read -r pat || [ -n "$pat" ]; do
360
375
  pat="${pat%$'\r'}"
361
376
  case "$pat" in ''|\#*) continue;; esac
362
- alt="${alt:+$alt$sep}$pat"
363
377
  case "$pat" in *[!$'\t'\ -~]*) na=1 ;; esac
378
+ if [ "$4" = 1 ]; then _crew_lit "$pat"; pat="$_CL"; fi
379
+ alt="${alt:+$alt$sep}$pat"
364
380
  done < "$1"
365
381
  [ -n "$alt" ] || return 1
366
- grep -q$3 -- "$alt" "$2" 2>/dev/null || r=$?
382
+ LC_ALL=C grep -q$3 -- "$alt" "$2" 2>/dev/null || r=$?
367
383
  # Nothing found, but a pattern holds a letter outside ASCII and the match ignores case: the per-pattern loop looks
368
384
  # again under the session's own locale (_crew_scan), so send the caller there.
369
385
  if [ "$r" = 1 ] && [ "$na" = 1 ] && [ -n "$_CREW_LOCALE" ]; then case "$3" in *i*) return 4 ;; esac; fi
@@ -472,21 +488,21 @@ PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$
472
488
  fi
473
489
  [ -n "$PTF" ] && sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$PTF"
474
490
  } > "$PTERM_F" 2>/dev/null || true
475
- if _crew_any "$PTERM_F" "$SECRET_F" iF 1; then _dec=0; else _dec=$?; fi
491
+ if _crew_any "$PTERM_F" "$SECRET_F" iE 1; then _dec=0; else _dec=$?; fi
476
492
  if [ -s "$PTERM_F" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
477
493
  while IFS= read -r term || [ -n "$term" ]; do
478
494
  term="${term%$'\r'}"
479
495
  [ -n "$term" ] || continue
480
496
  if [ -n "$PAL" ] && grep -qxF -- "$term" "$PAL"; then continue; fi
481
- _gr=0; _crew_scan iF "$term" "$SECRET_F" || _gr=$?
497
+ _crew_lit "$term"; _gr=0; _crew_scan iE "$_CL" "$SECRET_F" || _gr=$?
482
498
  if [ "$_gr" = 0 ]; then
483
499
  # The term is echoed back deliberately: unlike a secret, the author has to see WHICH private string to
484
500
  # replace, and it is already sitting in their own working tree.
485
501
  echo "PRIVATE-PATH SCANNER: a staged line contains a machine-private string -> '$term' (§4.3)"
486
502
  HIT=1
487
503
  elif [ "$_gr" -ge 2 ]; then
488
- # This loop used to read every exit status but 0 as "no match": under a Turkish locale in Git Bash grep -iF
489
- # aborted (134) for each term and the commit went through with both private strings in it (measured).
504
+ # This loop used to read every exit status but 0 as "no match": where grep aborted (134) for each term the
505
+ # commit went through with both private strings in it (measured).
490
506
  echo "PRIVATE-PATH SCANNER: the look for a machine-private string could not run, so the staged lines are not known to be clean and the commit is stopped (§4.3)."
491
507
  HIT=1
492
508
  fi
@@ -358,7 +358,7 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
358
358
 
359
359
  # ---- WHICH TEXT IS AN APPROVAL: one function, asked here of the user's prompt and by guard-schedule.sh of a
360
360
  # prompt a tool is about to schedule ---------------------------------------------------------------------------
361
- _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | loosen, or "" when the text is neither
361
+ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pending | loosen, or "" when the text is none
362
362
  # `loosen` is the other thing only the user's own message does: `/crew-loosen <agent> <change> <risk>` lowers a
363
363
  # floor that model routing raised (3.1.0). It is matched HERE so that everything that refuses to hand a session
364
364
  # an approval (a scheduled prompt, a `claude -p` call) refuses to hand it this as well: one question, one answer.
@@ -374,7 +374,11 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | lo
374
374
  # THE COMMAND: `/crew-approve <what>`, or `/<plugin>:crew-approve <what>` as a plugin names its commands. Measured
375
375
  # on Claude Code 2.1.284: a command the user types reaches UserPromptSubmit with `prompt` as typed, and a skill
376
376
  # the model starts with the Skill tool raises PreToolUse and PostToolUse only, never that event.
377
- /*) _k="${P%%[$' \t']*}"; [ "$_k" != "$P" ] || return 0
377
+ # With NOTHING after it, `/crew-approve` approves what this session was last refused (3.1.0): OP is `pending`
378
+ # here, and the hook's own flow looks up what that is. It is an approval like the others to everything that
379
+ # asks this function, so a scheduled or handed-over `/crew-approve` is refused the same way.
380
+ /*) _k="${P%%[$' \t']*}"
381
+ if [ "$_k" = "$P" ]; then _k="${_k#/}"; [ "${_k##*:}" = crew-approve ] && OP=pending; return 0; fi
378
382
  _v="${P:${#_k}}"; _vr="$_v"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
379
383
  # The text form of 3.1.0's release candidates, kept: `approve: <what>` and `onay: <what>`.
380
384
  *:*) _k="${P%%:*}"; _v="${P#*:}" ;;
@@ -483,6 +487,35 @@ if [ "$OP" = loosen ]; then
483
487
  "The user lowered the calibration floor of $LO_AGENT / $LO_CHANGE / $LO_RISK from $LFROM to $LTO with their own command. The card's own rules still hold (critical work on opus, no verify command on sonnet or above, the agent's floor). If the class fails its first try too often again, the floor rises again."
484
488
  fi
485
489
 
490
+ # ---- `/crew-approve` with nothing after it: what this session was last refused ---------------------------------
491
+ # guard-bash.sh, when it refuses a commit or a push in auto / dontAsk, writes down what it refused
492
+ # (.claude/state/crew-approval-pending: the session, commit | push | commit+push, the second). The bare command
493
+ # approves THAT and nothing else: the same session's, not older than 30 minutes. What is approved is therefore
494
+ # never read from the message and never from the model: the gate wrote it, from the command it refused. From here
495
+ # on the flow is the one a spelled-out approval takes, with every proof it asks for (the mode, a session that is
496
+ # attended, a transcript on disk) and the same record at the end.
497
+ BARE=0
498
+ if [ "$OP" = pending ]; then
499
+ BARE=1
500
+ _json_slice "$INPUT" permission_mode >/dev/null
501
+ case "$_JS" in auto|dontAsk) ;; *) exit 0 ;; esac # elsewhere the gate asks the user itself; nothing waits here
502
+ _json_slice "$INPUT" session_id >/dev/null; _bsid="$_JS"
503
+ _bp="${CLAUDE_PROJECT_DIR:-$CWD}"; _bp="${_bp//\\//}"; _bp="${_bp%/}/.claude/state/crew-approval-pending"
504
+ _bs=""; _bo=""; _bt=""
505
+ if [ -f "$_bp" ]; then
506
+ while IFS= read -r _bl || [ -n "$_bl" ]; do
507
+ case "$_bl" in sid=*) _bs="${_bl#sid=}" ;; op=*) _bo="${_bl#op=}" ;; ts=*) _bt="${_bl#ts=}" ;; esac
508
+ done < "$_bp"
509
+ fi
510
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _bn '%(%s)T' -1; else _bn="$(date +%s)"; fi
511
+ OP="a commit or a push"
512
+ case "$_bo" in commit|push|commit+push) ;; *) no "nothing is waiting for approval in this session: no commit or push was refused. Have the commit message shown and the commit tried first; or name it: the user can type /crew-approve commit, push or commit+push." ;; esac
513
+ { [ -n "$_bsid" ] && [ "$_bs" = "$_bsid" ]; } || no "what is waiting for approval was refused in another session, not this one. Nothing is approved; have it tried here first."
514
+ case "$_bt$_bn" in *[!0-9]*|'') no "the time of what is waiting could not be read." ;; esac
515
+ [ $((_bn - _bt)) -ge 0 ] && [ $((_bn - _bt)) -le 1800 ] || no "what was waiting for approval ($_bo) is more than 30 minutes old. Nothing is approved; have it tried again first."
516
+ OP="$_bo" # used up further down, once the approval is on record
517
+ fi
518
+
486
519
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
487
520
  case "$PM" in
488
521
  auto|dontAsk) ;;
@@ -559,6 +592,9 @@ case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
559
592
 
560
593
  { printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
561
594
  || no "the record could not be written in the git directory."
595
+ # What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
596
+ # itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
597
+ [ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
562
598
 
563
599
  _h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
564
600
  # THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
@@ -577,5 +613,6 @@ case "$OP" in
577
613
  HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
578
614
  esac
579
615
  [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
580
- say "Crewforth: approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
616
+ _bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
617
+ say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
581
618
  "The user approved this in their own message and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: crew-approve
3
3
  description: Approve the commit or push Claude just showed you (auto and dontAsk modes).
4
- argument-hint: "[commit | push | commit+push]"
4
+ argument-hint: "[commit | push | commit+push] (nothing: what was just refused)"
5
5
  disable-model-invocation: true
6
6
  metadata:
7
7
  kind: command
@@ -19,6 +19,9 @@ Read the line Crewforth added to this turn and do what it says:
19
19
  `git commit -m '…'` with the message you showed (single-quoted, or from a here-document with a quoted delimiter);
20
20
  a push is `git push <remote> <branch>`. The review record of §4.6 is still required.
21
21
  - **"approval NOT recorded"**: tell the user the reason it gives, and run nothing.
22
+ - **With no argument** the command approves what the gate last refused in this session (the line says which:
23
+ "approved: commit+push"); when nothing was refused, or it was more than 30 minutes ago, nothing is recorded and
24
+ the line says so.
22
25
  - **No such line**: the session is in a mode where the gate asks the user itself (`default`, `acceptEdits`), or
23
26
  the argument was not `commit`, `push` or `commit+push`. Say which, and in the first case run the command and let
24
27
  the gate's own prompt ask.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.1.0-rc.6",
3
+ "version": "3.1.0-rc.8",
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"
@@ -44,6 +44,7 @@
44
44
  "git-hooks"
45
45
  ],
46
46
  "author": "Barış Yerlikaya",
47
+ "homepage": "https://crewforth.com/",
47
48
  "repository": {
48
49
  "type": "git",
49
50
  "url": "git+https://github.com/Crewforth/crewforth.git"