crewforth 3.1.0-rc.5 → 3.1.0-rc.7

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +76 -12
  2. package/README.md +3 -2
  3. package/kit/AGENT_TEMPLATE.md +41 -21
  4. package/kit/CLAUDE.md +9 -7
  5. package/kit/agents/crew-backend-expert.md +4 -1
  6. package/kit/agents/crew-commit-agent.md +8 -3
  7. package/kit/agents/crew-database-expert.md +4 -1
  8. package/kit/agents/crew-devops-expert.md +4 -1
  9. package/kit/agents/crew-frontend-expert.md +4 -1
  10. package/kit/agents/crew-performance-expert.md +4 -1
  11. package/kit/agents/crew-planner.md +10 -1
  12. package/kit/agents/crew-privacy-agent.md +4 -1
  13. package/kit/agents/crew-review-agent.md +4 -1
  14. package/kit/agents/crew-security-expert.md +4 -1
  15. package/kit/agents/crew-session-manager.md +4 -1
  16. package/kit/agents/crew-test-expert.md +4 -1
  17. package/kit/eval/smoke-test.sh +860 -27
  18. package/kit/hooks/agent-outcome.sh +437 -0
  19. package/kit/hooks/context-usage.sh +1 -1
  20. package/kit/hooks/guard-agent-model.sh +494 -60
  21. package/kit/hooks/guard-bash.sh +69 -8
  22. package/kit/hooks/guard-schedule.sh +8 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +94 -7
  25. package/kit/settings.json +43 -0
  26. package/kit/skills/crew-approve/SKILL.md +4 -1
  27. package/kit/skills/crew-loosen/SKILL.md +28 -0
  28. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  29. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  30. package/kit/studio/server/index.js +85 -6
  31. package/kit/studio/server/lib/agent-origin.js +44 -0
  32. package/kit/studio/server/lib/graph.js +45 -7
  33. package/kit/studio/server/lib/model-outcomes.js +287 -0
  34. package/kit/studio/server/lib/palette.js +5 -18
  35. package/kit/studio/server/lib/permissions.js +260 -17
  36. package/kit/studio/server/lib/session.js +41 -1
  37. package/kit/studio/server/lib/usage.js +8 -1
  38. package/kit/studio/web/app.js +203 -32
  39. package/kit/studio/web/approvals.js +53 -2
  40. package/kit/studio/web/ask.js +137 -0
  41. package/kit/studio/web/canvas.js +91 -42
  42. package/kit/studio/web/chat.js +11 -1
  43. package/kit/studio/web/convo.js +9 -0
  44. package/kit/studio/web/dock.js +30 -9
  45. package/kit/studio/web/graph-plan.js +29 -9
  46. package/kit/studio/web/icons/builtin.svg +1 -1
  47. package/kit/studio/web/index.html +11 -4
  48. package/kit/studio/web/keys.js +29 -0
  49. package/kit/studio/web/list-plan.js +8 -3
  50. package/kit/studio/web/list.js +27 -5
  51. package/kit/studio/web/models-plan.js +167 -0
  52. package/kit/studio/web/models.js +127 -0
  53. package/kit/studio/web/names.js +30 -0
  54. package/kit/studio/web/style.css +85 -9
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +26 -7
  57. package/package.json +1 -1
@@ -1375,7 +1375,7 @@ fi
1375
1375
  # the shell is Turing-complete, so this is defence-in-depth — guard-write.sh covers the Write/Edit tools (the
1376
1376
  # model's natural path to a file), and install-time read-only hook files would be the airtight layer.
1377
1377
  _GP='[/\\]+(\.[/\\]+)*' # a path separator as the shell and the filesystem take it: `/`, `\`, doubled, with `/./` between
1378
- GATE='(\.(claude'"$_GP"'(hooks|git-shim|settings\.json|DISCIPLINE\.md|eval'"$_GP"'lib'"$_GP"'crew-env\.sh)|git'"$_GP"'hooks|git'"$_GP"'(config|worktrees'"$_GP"'[^/\\[:space:]]+'"$_GP"'config|modules'"$_GP"'[^[:space:]]+'"$_GP"'config))|\.gitconfig([^A-Za-z0-9_.-]|$)|\.config'"$_GP"'git'"$_GP"'config([^A-Za-z0-9_.-]|$))'
1378
+ GATE='(\.(claude'"$_GP"'(hooks|git-shim|settings\.json|settings\.local\.json|DISCIPLINE\.md|eval'"$_GP"'lib'"$_GP"'crew-env\.sh)|git'"$_GP"'hooks|git'"$_GP"'(config|worktrees'"$_GP"'[^/\\[:space:]]+'"$_GP"'config|modules'"$_GP"'[^[:space:]]+'"$_GP"'config))|\.gitconfig([^A-Za-z0-9_.-]|$)|\.config'"$_GP"'git'"$_GP"'config([^A-Za-z0-9_.-]|$))'
1379
1379
  # .git/config (with a worktree's and a submodule's own) is on the list because core.hooksPath LIVES there: the rules
1380
1380
  # above stop `git config core.hooksPath …`, and a plain `printf '[core]\n\thooksPath = /dev/null\n' >> .git/config`
1381
1381
  # walked past them — after it a commit from the user's own terminal skips the trace and secret scans (measured, 3.1.0
@@ -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; }
@@ -1897,6 +1904,31 @@ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names i
1897
1904
  [ "$inw" = 1 ] && _appr_word "$w" && return 0
1898
1905
  return 1
1899
1906
  }
1907
+ # What model routing decides from is written by the user and by Crewforth's own hooks (guard-write.sh has the same rule
1908
+ # for the file tools): the user's rules, the floors, the list of critical paths, and the records of calls and results.
1909
+ # A command that NAMES one is refused, reading included; model-outcomes.tsv, the plain record, may be read. The
1910
+ # name is `crew-model…` for all of them (the rules, the floors, the user's lowerings, the records of calls).
1911
+ # HONEST SCOPE: this stops an accident and a plain attempt, not a determined one. A command that builds the name
1912
+ # from pieces (`f=crew-mo; … ${f}del-loosened.tsv`), writes through an interpreter (`python3 -c`), or moves a
1913
+ # folder above it (`cd .claude && mv state x`) spells none of what is matched here and is past this rule.
1914
+ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
1915
+ case "$CMD_UQ" in *crew-model*|*crew-critical-paths*) rc=0 ;; esac
1916
+ # ...and by a glob that could reach one, or the folder itself in a command that moves, copies or removes: a word
1917
+ # that holds `.claude/state` and a wildcard, or that IS that folder beside rm, mv, cp, rmdir, rsync, tar or ln.
1918
+ if [ "$rc" = 1 ]; then
1919
+ local w unglob=0 verb=0
1920
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
1921
+ for w in $CMD_UQ; do case "$w" in rm|mv|cp|rmdir|rsync|tar|ln|*/rm|*/mv|*/cp) verb=1 ;; esac; done
1922
+ for w in $CMD_UQ; do
1923
+ case "$w" in
1924
+ *.claude[/\\]state[/\\]*[\*\?\[]*|*.claude[/\\]state[\*\?\[]*) rc=0 ;;
1925
+ *.claude[/\\]state|*.claude[/\\]state[/\\]) [ "$verb" = 1 ] && rc=0 ;;
1926
+ esac
1927
+ done
1928
+ [ "$unglob" = 1 ] && set +f
1929
+ fi
1930
+ [ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
1931
+ _mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
1900
1932
  _appr_in_claude_call && block "a command that hands a Claude Code session an approval as its prompt (only the user's own message approves)" "4.4" tamper
1901
1933
 
1902
1934
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
@@ -3229,10 +3261,39 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3229
3261
  exit 2
3230
3262
  fi
3231
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
3232
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
3233
3289
  echo " 1. the commit message, whole, in ONE code block;" >&2
3234
- 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
3235
- 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
3236
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
3237
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
3238
3299
  echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
@@ -81,8 +81,14 @@ while IFS= read -r sv; do
81
81
  [ -n "$sv" ] || continue
82
82
  _json_unescape "$sv" >/dev/null; _crew_appr_op "$_JU"
83
83
  if [ -n "$OP" ]; then
84
- 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
85
- 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
84
+ if [ "$OP" = loosen ]; then
85
+ echo "GUARD (§4.4): this call schedules a prompt that lowers a model floor. Only the user can type /crew-loosen; a prompt a tool schedules would lower the floor in their place, so the call is refused." >&2
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
+ else
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
+ _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
91
+ fi
86
92
  exit 2
87
93
  fi
88
94
  done <<< "$strs"
@@ -387,6 +387,7 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
387
387
  WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
388
388
  WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
389
389
  WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
390
+ WHY_MODEL="This file is what model routing decides from: the user's rules, a class's floor, the critical paths, or the record of a call and its result. Only the user and Crewforth's own hooks write it, so a session cannot lower its own floor or erase its own failure."
390
391
  WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
391
392
  WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
392
393
  WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
@@ -544,6 +545,13 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
544
545
  # Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
545
546
  */[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
546
547
  GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
548
+ # What model routing decides from (hooks/guard-agent-model.sh, hooks/agent-outcome.sh): the user's own rules,
549
+ # the floors raised from the record of outcomes, the list of critical paths, and the records of calls and
550
+ # results. A session that writes one lowers its own floor or wipes its own failure.
551
+ */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Cc][Rr][Ee][Ww]-[Mm][Oo][Dd][Ee][Ll]-[Rr][Uu][Ll][Ee][Ss]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Cc][Rr][Ee][Ww]-[Mm][Oo][Dd][Ee][Ll]-[Rr][Uu][Ll][Ee][Ss]\
552
+ |*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-*|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-*\
553
+ |*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Mm][Oo][Dd][Ee][Ll]-[Oo][Uu][Tt][Cc][Oo][Mm][Ee][Ss].[Tt][Ss][Vv]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Mm][Oo][Dd][Ee][Ll]-[Oo][Uu][Tt][Cc][Oo][Mm][Ee][Ss].[Tt][Ss][Vv])
554
+ GATE_RULE="gate-file edit (model routing record)"; GATE_WHY="$WHY_MODEL"; return 0 ;;
547
555
  esac
548
556
  # core.hooksPath lives in git's configuration files: the repository's (.git/config, with a linked worktree's and a
549
557
  # submodule's own) and the user's (~/.gitconfig, ~/.config/git/config). Writing one switches the git hooks off without
@@ -602,6 +610,29 @@ while case "$_anc" in */*) true ;; *) false ;; esac; do
602
610
  fi
603
611
  done
604
612
 
613
+ # ---- model routing: an agent that is not on opus does not write to a critical path ------------------------------
614
+ # The card a crew agent is called with can leave a file out, so the files it names are not the last word: the
615
+ # write itself is checked. Asked only of a call that comes from inside an agent (the payload then carries agent_id;
616
+ # measured on Claude Code 2.1.294), so the session's own writes pay one test of the payload's text and nothing else.
617
+ # One definition of "critical path" and of "which model is this agent on": hooks/guard-agent-model.sh, sourced.
618
+ case "$INPUT" in *'"agent_id"'*)
619
+ if [ "${CREW_MODEL_ROUTING:-}" != off ]; then
620
+ _gwm="${BASH_SOURCE%/*}"; [ "$_gwm" = "${BASH_SOURCE}" ] && _gwm=.
621
+ if [ -f "$_gwm/guard-agent-model.sh" ]; then
622
+ . "$_gwm/guard-agent-model.sh"
623
+ declare -F _am_write_check >/dev/null 2>&1 && _am_write_check "$INPUT" "$FP"
624
+ # An agent does not write the settings files either: a Bash(...) rule in permissions.allow is one of the two
625
+ # ways the USER allows a verify command by name, and an agent that could add one would allow its own.
626
+ case "${FP//\\//}" in
627
+ */.claude/settings.json|.claude/settings.json|*/.claude/settings.local.json|.claude/settings.local.json)
628
+ declare -F _am_log >/dev/null 2>&1 && _am_log "a settings file written from inside an agent"
629
+ echo "GUARD (agent model): an agent does not write ${FP##*/}: a permission rule there decides which verify command a hook may run, and that is the user's to give. Stop, say what you wanted to change, and end your report with confidence: low" >&2
630
+ exit 2 ;;
631
+ esac
632
+ fi
633
+ fi ;;
634
+ esac
635
+
605
636
  # ---- team board: you may not start work nobody knows you started -----------------------------------------------
606
637
  # The claim lock already makes it impossible for two people to HOLD the same item — a losing claim is refused in
607
638
  # under a second, before any code exists. The hole this closes is the other one: somebody who never claims at all.
@@ -358,20 +358,28 @@ _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, or "" when the text is not an approval
362
- local P="$1" _k _v LC_ALL=C
363
- OP=""
361
+ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pending | loosen, or "" when the text is none
362
+ # `loosen` is the other thing only the user's own message does: `/crew-loosen <agent> <change> <risk>` lowers a
363
+ # floor that model routing raised (3.1.0). It is matched HERE so that everything that refuses to hand a session
364
+ # an approval (a scheduled prompt, a `claude -p` call) refuses to hand it this as well: one question, one answer.
365
+ local P="$1" _k _v _vr LC_ALL=C
366
+ OP=""; LO_AGENT=""; LO_CHANGE=""; LO_RISK=""
364
367
  P="${P//$'\r'/}"
365
368
  while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
366
369
  while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
367
- [ "${#P}" -le 40 ] || return 0
370
+ [ "${#P}" -le 90 ] || return 0
371
+ _vr=""
368
372
  case "$P" in
369
373
  *$'\n'*) return 0 ;;
370
374
  # THE COMMAND: `/crew-approve <what>`, or `/<plugin>:crew-approve <what>` as a plugin names its commands. Measured
371
375
  # on Claude Code 2.1.284: a command the user types reaches UserPromptSubmit with `prompt` as typed, and a skill
372
376
  # the model starts with the Skill tool raises PreToolUse and PostToolUse only, never that event.
373
- /*) _k="${P%%[$' \t']*}"; [ "$_k" != "$P" ] || return 0
374
- _v="${P:${#_k}}"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
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
382
+ _v="${P:${#_k}}"; _vr="$_v"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
375
383
  # The text form of 3.1.0's release candidates, kept: `approve: <what>` and `onay: <what>`.
376
384
  *:*) _k="${P%%:*}"; _v="${P#*:}" ;;
377
385
  *) return 0 ;;
@@ -384,6 +392,15 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push, or
384
392
  case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
385
393
  esac
386
394
  shopt -u nocasematch
395
+ # The second command is read as it is written, lower case, with exactly three words after it.
396
+ if [ "$_k" = "@crew-loosen" ]; then
397
+ local a b c d
398
+ IFS=$' \t' read -r a b c d <<< "$_vr"
399
+ [ -n "${c:-}" ] && [ -z "${d:-}" ] || return 0
400
+ case "$a" in crew-*) ;; *) return 0 ;; esac; case "$a$b" in *[!a-z0-9-]*) return 0 ;; esac
401
+ case "$c" in critical|normal) ;; *) return 0 ;; esac
402
+ OP=loosen; LO_AGENT="$a"; LO_CHANGE="$b"; LO_RISK="$c"
403
+ fi
387
404
  }
388
405
 
389
406
  [ "$_PA_LIB" = 1 ] && return 0
@@ -433,6 +450,72 @@ say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text
433
450
  }
434
451
  no(){ say "Crewforth: approval NOT recorded - $1" "The user's message is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
435
452
 
453
+ # ---- /crew-loosen: a floor model routing raised is lowered by the user, and by nobody else ----------------------
454
+ # hooks/agent-outcome.sh raises a class's floor when its first try fails too often, and nothing lowers it again but
455
+ # this. The same proof as for an approval that a person is typing into their own session: the turn is not a
456
+ # notice or another session's message (above), the session is not a headless one, and it has a transcript already,
457
+ # so this is not the prompt a command or a schedule started a session with. In every permission mode: this is not
458
+ # a commit. ONE model down, ONCE for each time the floor was raised; the record of outcomes is counted afresh for
459
+ # that class from here, so the old failures do not raise it straight back.
460
+ if [ "$OP" = loosen ]; then
461
+ nol(){ say "Crewforth: floor NOT lowered - $1" "The user sent the command only the user can type /crew-loosen, for $LO_AGENT $LO_CHANGE $LO_RISK, and Crewforth did not lower anything: $1 Tell the user exactly that."; }
462
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) nol "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
463
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
464
+ { [ -n "$TP" ] && [ -s "$TP" ]; } || nol "this is the first message of this session, or the session keeps no transcript."
465
+ _lh="${BASH_SOURCE%/*}"; [ "$_lh" = "${BASH_SOURCE}" ] && _lh=.
466
+ [ -f "$_lh/guard-agent-model.sh" ] || nol "model routing is not installed beside this hook."
467
+ [ "${CREW_MODEL_ROUTING:-}" = off ] && nol "model routing is switched off (CREW_MODEL_ROUTING=off), so no floor is in force."
468
+ CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$CWD}"; . "$_lh/guard-agent-model.sh"
469
+ declare -F _am_floor_now >/dev/null 2>&1 || nol "model routing beside this hook is an older one."
470
+ _am_state
471
+ _am_floor_now "$LO_AGENT" "$LO_CHANGE" "$LO_RISK"
472
+ [ -n "$_AMF_TIER" ] || nol "no raised floor is on record for $LO_AGENT / $LO_CHANGE / $LO_RISK (.claude/state/crew-model-floors.auto has no line for it)."
473
+ [ "$_AMF_LOOSE" = 0 ] || nol "that floor was already lowered once ($_AMF_TIER to $_AMF_TO, $_AMF_LTS). It is lowered once for each time it is raised."
474
+ _am_rank "$_AMF_TIER"; [ "$_AMR" -ge 2 ] || nol "the floor on record is $_AMF_TIER, and there is no model below it."
475
+ _am_name $((_AMR-1)); LTO="$_AMN"
476
+ LTS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"; case "$LTS" in [0-9][0-9][0-9][0-9]-*Z) ;; *) nol "the clock could not be read." ;; esac
477
+ LF="$_AMS/crew-model-loosened.tsv"
478
+ mkdir -p "$_AMS" 2>/dev/null || nol "the state folder could not be made."
479
+ [ -s "$LF" ] || { printf 'ts\tagent\tchange\trisk\tfrom\tto\n' > "$LF"; } 2>/dev/null || nol "the record could not be written."
480
+ LFROM="$_AMF_TIER"
481
+ { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$LTS" "$LO_AGENT" "$LO_CHANGE" "$LO_RISK" "$LFROM" "$LTO" >> "$LF"; } 2>/dev/null || nol "the record could not be written."
482
+ # Said only when it is TRUE: the floor is read back the way the gate will read it. A line that was written and
483
+ # does not count (a clock that went backwards, a floors file that changed underneath) is not a lowering.
484
+ _am_floor_now "$LO_AGENT" "$LO_CHANGE" "$LO_RISK"
485
+ { [ "$_AMF_LOOSE" = 1 ] && [ "$_AMF_EFF" = "$LTO" ]; } || nol "the line was written but the floor read back is still ${_AMF_EFF:-$LFROM}, so it is not in force (is the clock behind the time the floor was raised, $_AMF_WHEN?)."
486
+ say "Crewforth: floor lowered - $LO_AGENT / $LO_CHANGE / $LO_RISK from $LFROM to $LTO. Its outcomes are counted afresh from now." \
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."
488
+ fi
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
+
436
519
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
437
520
  case "$PM" in
438
521
  auto|dontAsk) ;;
@@ -509,6 +592,9 @@ case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
509
592
 
510
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 \
511
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
512
598
 
513
599
  _h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
514
600
  # THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
@@ -527,5 +613,6 @@ case "$OP" in
527
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" ;;
528
614
  esac
529
615
  [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
530
- 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." \
531
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."
package/kit/settings.json CHANGED
@@ -146,6 +146,43 @@
146
146
  ]
147
147
  }
148
148
  ],
149
+ "SubagentStart": [
150
+ {
151
+ "hooks": [
152
+ {
153
+ "type": "command",
154
+ "shell": "bash",
155
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
156
+ "timeout": 60
157
+ }
158
+ ]
159
+ }
160
+ ],
161
+ "SubagentStop": [
162
+ {
163
+ "hooks": [
164
+ {
165
+ "type": "command",
166
+ "shell": "bash",
167
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
168
+ "timeout": 600
169
+ }
170
+ ]
171
+ }
172
+ ],
173
+ "PostToolUse": [
174
+ {
175
+ "matcher": "Agent|Task",
176
+ "hooks": [
177
+ {
178
+ "type": "command",
179
+ "shell": "bash",
180
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
181
+ "timeout": 60
182
+ }
183
+ ]
184
+ }
185
+ ],
149
186
  "SessionStart": [
150
187
  {
151
188
  "matcher": "startup|resume|clear|compact|fork",
@@ -172,6 +209,12 @@
172
209
  "shell": "bash",
173
210
  "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/board-sync.sh",
174
211
  "timeout": 60
212
+ },
213
+ {
214
+ "type": "command",
215
+ "shell": "bash",
216
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
217
+ "timeout": 60
175
218
  }
176
219
  ]
177
220
  },
@@ -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.
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: crew-loosen
3
+ description: Lower a model floor that Crewforth raised for a class of work (one model, once).
4
+ argument-hint: "<agent> <change> <risk>"
5
+ disable-model-invocation: true
6
+ metadata:
7
+ kind: command
8
+ ---
9
+ # /crew-loosen
10
+ Argument: $ARGUMENTS
11
+
12
+ The user typed this command. Typing it is the decision: `hooks/prompt-approval.sh` read the message itself and
13
+ lowered the calibration floor of that class of work (agent × change × risk) by one model, in
14
+ `.claude/state/crew-model-loosened.tsv`. Nothing in this text lowers anything, and you cannot do this for the user:
15
+ the file is closed to you, and a prompt you schedule or hand to another session is refused.
16
+
17
+ Read the line Crewforth added to this turn and say what it says:
18
+
19
+ - **"floor lowered"**: tell the user from which model to which, and that the class is counted afresh: if its first
20
+ try fails too often again, the floor rises again.
21
+ - **"floor NOT lowered"**: tell the user the reason it gives (no raised floor on record for that class, already
22
+ lowered once, the first message of a session, a headless session).
23
+ - **No such line**: the arguments were not `<agent> <change> <risk>`: a `crew-…` agent, a kind of change from the
24
+ card (`feature`, `fix-known`, `test-write` …) and `critical` or `normal`. Say that, with the three as they stand
25
+ in `.claude/state/crew-model-floors.auto`.
26
+
27
+ What this does not touch: critical work still runs on opus, work with no verify command on sonnet or above, and
28
+ an agent's own floor stays. Only the floor that the record of outcomes raised is lowered.
@@ -51,8 +51,17 @@ _field() { # _field <key> -> value on stdout
51
51
  #
52
52
  # This approves the call the viewer was shown. It does not unblock anything:
53
53
  # another hook that exits 2 still blocks, and this hook's own denials are exit 2.
54
+ # The mode is the spool's `mode` file when there is one, and the mode the hook was configured with otherwise. The
55
+ # file starts as the mode the session started in and changes in exactly one way: the viewer approves a plan, in
56
+ # the panel, into acceptEdits or default (permissions.js `decide`). A session that started in plan mode could not
57
+ # otherwise ever have an allowance said out loud, plan approved or not. The check against what the harness itself
58
+ # reports, below, stays: a file that says `default` approves nothing while the session is still in `plan`.
54
59
  _grant() {
55
- case "${CREW_GATE_MODE:-}" in
60
+ local gm="${CREW_GATE_MODE:-}"
61
+ if [ -f "$SPOOL/mode" ]; then
62
+ read -r gm < "$SPOOL/mode" 2>/dev/null || gm=""
63
+ fi
64
+ case "$gm" in
56
65
  acceptEdits|default) ;;
57
66
  *) exit 0 ;;
58
67
  esac
@@ -69,6 +78,57 @@ TUID="$(_field tool_use_id || true)"
69
78
  SID="$(_field session_id || true)"
70
79
  [ -n "$TOOL" ] || exit 0 # nothing to ask about
71
80
 
81
+ # ---- two calls the viewer is not asked about ---------------------------------
82
+ # Silence here is not an approval. The hook says nothing, and Claude Code decides the call as it would with no
83
+ # panel: its own mode rules and every other hook still apply.
84
+ #
85
+ # ToolSearch looks up a tool's definition and changes nothing. A session reaches for it before the tools it has
86
+ # to load first, ExitPlanMode among them, and asking the viewer to allow a lookup taught them to click through.
87
+ [ "$TOOL" = "ToolSearch" ] && exit 0
88
+
89
+ # The plan file. In plan mode Claude Code writes its plan to a file of its own before it asks to leave the mode,
90
+ # and plan mode lets it write that file and no other. The viewer could only refuse it, and a plan they have not
91
+ # been shown yet is not something to refuse: the plan itself comes to the panel with ExitPlanMode.
92
+ #
93
+ # Every condition has to hold, and anything that cannot be checked is asked about as before:
94
+ # - the session is in plan mode, by Claude Code's own report in this call
95
+ # - the tool is Write (an Edit is asked about)
96
+ # - the file is named *.md (as written: not .MD, not "x.md/" or "x.md."), and the path has no ".." in it
97
+ # - the path is spelled the way this platform spells one: "\" separates directories on Windows (MSYS, Cygwin)
98
+ # and nowhere else, so on any other system a path with a "\" in it is asked about
99
+ # - the file is not a symbolic link
100
+ # - the directory it is in, resolved, IS Claude Code's plans directory, resolved: not a directory under it
101
+ # The two directories are resolved by the same builtin (cd -P) and compared as it reports them, so how a path is
102
+ # spelled (C:\… or /c/…, a link on the way) does not matter. No process is started.
103
+ _plan_file() {
104
+ [ "$TOOL" = "Write" ] || return 1
105
+ [ "$(_field permission_mode || true)" = "plan" ] || return 1
106
+ local fp; fp="$(_field file_path || true)"
107
+ [ -n "$fp" ] || return 1
108
+ case "$fp" in *..*) return 1 ;; esac
109
+ case "$fp" in *.md) ;; *) return 1 ;; esac
110
+ local dir
111
+ case "${OSTYPE:-}" in
112
+ msys*|cygwin*)
113
+ fp="${fp//\\\\/\\}" # JSON writes one backslash as two
114
+ dir="${fp%[/\\]*}" ;;
115
+ *)
116
+ case "$fp" in *\\*) return 1 ;; esac # not a separator here: a name with one in it is not the plan file
117
+ dir="${fp%/*}" ;;
118
+ esac
119
+ [ "$dir" != "$fp" ] && [ -n "$dir" ] || return 1
120
+ [ -L "$fp" ] && return 1
121
+ [ -e "$fp" ] && [ ! -f "$fp" ] && return 1
122
+ local plans="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/plans" was="$PWD" at want
123
+ cd -P -- "$dir" 2>/dev/null || return 1
124
+ at="$PWD"
125
+ if ! cd -P -- "$plans" 2>/dev/null; then cd -- "$was" 2>/dev/null; return 1; fi
126
+ want="$PWD"
127
+ cd -- "$was" 2>/dev/null
128
+ [ -n "$at" ] && [ "$at" = "$want" ]
129
+ }
130
+ _plan_file && exit 0
131
+
72
132
  # Ids become filenames; anything unexpected is refused rather than sanitised
73
133
  # into something that might escape the spool.
74
134
  case "$TUID" in
@@ -76,6 +136,21 @@ case "$TUID" in
76
136
  esac
77
137
 
78
138
  REQ="$SPOOL/req"; ANS="$SPOOL/ans"; ALWAYS="$SPOOL/always"
139
+
140
+ # The two tools that need a person. Claude Code offers them in a headless session only because Studio names a
141
+ # permission host for it (studio-host.mjs); what answers them is this hook, from what the viewer chose in the
142
+ # panel. A question and a plan take longer to read than one command, so they wait longer before the denial.
143
+ ASK=0
144
+ case "$TOOL" in
145
+ AskUserQuestion|ExitPlanMode) ASK=1; WAIT="${CREW_GATE_WAIT_ASK:-$WAIT}" ;;
146
+ esac
147
+ # The hook is wired twice: once for every tool, with the harness's ordinary limit, and once for these two alone,
148
+ # with the longer one they need. Each copy answers its own and leaves the other's at once, in silence: silence is
149
+ # no decision, so the copy that does answer decides. With no role (a settings file from before there were two)
150
+ # this copy answers everything.
151
+ case "${CREW_GATE_ROLE:-}:$ASK" in
152
+ general:1|ask:0) exit 0 ;;
153
+ esac
79
154
  mkdir -p "$REQ" "$ANS" "$ALWAYS" 2>/dev/null || exit 0
80
155
 
81
156
  # A tool the panel already blanket-approved for this session skips the round trip.
@@ -187,10 +262,21 @@ esac
187
262
  START=$SECONDS
188
263
  while :; do
189
264
  if [ -f "$ANS/$TUID" ]; then
190
- read -r VERDICT < "$ANS/$TUID" 2>/dev/null || VERDICT=deny
265
+ VERDICT=deny; OUT=""
266
+ # Two lines at most: the verdict, and for an answered question or an approved plan the output to print.
267
+ { IFS= read -r VERDICT; IFS= read -r OUT; } < "$ANS/$TUID" 2>/dev/null || :
191
268
  rm -f "$ANS/$TUID" "$REQ/$TUID.json" 2>/dev/null
192
- case "$VERDICT" in
193
- allow|always) _grant ;;
269
+ case "$ASK:$VERDICT" in
270
+ 0:allow|0:always) _grant ;;
271
+ # The viewer's answers, or their approval of the plan, exactly as the server wrote them: `allow` with the
272
+ # tool's input, which is how the hooks reference says a hook answers these two tools. Printed only for them,
273
+ # and only when there is something to print.
274
+ 1:output) [ -n "$OUT" ] || { printf 'studio: the answer could not be read — denied\n' >&2; exit 2; }
275
+ printf '%s\n' "$OUT"; exit 0 ;;
276
+ # A plan approved with a change of mode. The hook cannot change the mode, so it says nothing and the
277
+ # permission host, which can, answers from what the viewer chose.
278
+ 1:host) [ "$TOOL" = ExitPlanMode ] && exit 0
279
+ printf 'studio: denied in the panel\n' >&2; exit 2 ;;
194
280
  *) printf 'studio: denied in the panel\n' >&2; exit 2 ;;
195
281
  esac
196
282
  fi