crewforth 3.1.0-rc.4 → 3.1.0-rc.6

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 (58) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +48 -19
  4. package/kit/CLAUDE.md +13 -0
  5. package/kit/agents/crew-backend-expert.md +8 -0
  6. package/kit/agents/crew-commit-agent.md +8 -0
  7. package/kit/agents/crew-database-expert.md +8 -0
  8. package/kit/agents/crew-devops-expert.md +8 -0
  9. package/kit/agents/crew-frontend-expert.md +8 -0
  10. package/kit/agents/crew-performance-expert.md +8 -0
  11. package/kit/agents/crew-planner.md +14 -0
  12. package/kit/agents/crew-privacy-agent.md +10 -2
  13. package/kit/agents/crew-review-agent.md +8 -0
  14. package/kit/agents/crew-security-expert.md +11 -4
  15. package/kit/agents/crew-session-manager.md +10 -3
  16. package/kit/agents/crew-test-expert.md +8 -0
  17. package/kit/eval/doctor.sh +16 -0
  18. package/kit/eval/smoke-test.sh +898 -11
  19. package/kit/hooks/agent-outcome.sh +416 -0
  20. package/kit/hooks/guard-agent-model.sh +819 -0
  21. package/kit/hooks/guard-bash.sh +26 -1
  22. package/kit/hooks/guard-schedule.sh +7 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +55 -5
  25. package/kit/settings.json +54 -0
  26. package/kit/skills/crew-loosen/SKILL.md +28 -0
  27. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  28. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  29. package/kit/studio/server/index.js +86 -7
  30. package/kit/studio/server/lib/agent-origin.js +44 -0
  31. package/kit/studio/server/lib/graph.js +142 -10
  32. package/kit/studio/server/lib/model-outcomes.js +287 -0
  33. package/kit/studio/server/lib/palette.js +5 -18
  34. package/kit/studio/server/lib/permissions.js +260 -17
  35. package/kit/studio/server/lib/session.js +41 -1
  36. package/kit/studio/server/lib/usage.js +103 -25
  37. package/kit/studio/web/app.js +251 -37
  38. package/kit/studio/web/approvals.js +53 -2
  39. package/kit/studio/web/ask.js +137 -0
  40. package/kit/studio/web/canvas.js +220 -59
  41. package/kit/studio/web/chat.js +19 -2
  42. package/kit/studio/web/convo.js +14 -1
  43. package/kit/studio/web/dock.js +30 -9
  44. package/kit/studio/web/graph-plan.js +66 -18
  45. package/kit/studio/web/icons/builtin.svg +1 -1
  46. package/kit/studio/web/index.html +11 -4
  47. package/kit/studio/web/inspect.js +33 -1
  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 +35 -7
  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 +84 -0
  54. package/kit/studio/web/style.css +134 -15
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +36 -10
  57. package/kit/studio/web/usage.js +53 -12
  58. 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
@@ -1897,6 +1897,31 @@ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names i
1897
1897
  [ "$inw" = 1 ] && _appr_word "$w" && return 0
1898
1898
  return 1
1899
1899
  }
1900
+ # What model routing decides from is written by the user and by Crewforth's own hooks (guard-write.sh has the same rule
1901
+ # for the file tools): the user's rules, the floors, the list of critical paths, and the records of calls and results.
1902
+ # A command that NAMES one is refused, reading included; model-outcomes.tsv, the plain record, may be read. The
1903
+ # name is `crew-model…` for all of them (the rules, the floors, the user's lowerings, the records of calls).
1904
+ # HONEST SCOPE: this stops an accident and a plain attempt, not a determined one. A command that builds the name
1905
+ # from pieces (`f=crew-mo; … ${f}del-loosened.tsv`), writes through an interpreter (`python3 -c`), or moves a
1906
+ # folder above it (`cd .claude && mv state x`) spells none of what is matched here and is past this rule.
1907
+ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
1908
+ case "$CMD_UQ" in *crew-model*|*crew-critical-paths*) rc=0 ;; esac
1909
+ # ...and by a glob that could reach one, or the folder itself in a command that moves, copies or removes: a word
1910
+ # that holds `.claude/state` and a wildcard, or that IS that folder beside rm, mv, cp, rmdir, rsync, tar or ln.
1911
+ if [ "$rc" = 1 ]; then
1912
+ local w unglob=0 verb=0
1913
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
1914
+ for w in $CMD_UQ; do case "$w" in rm|mv|cp|rmdir|rsync|tar|ln|*/rm|*/mv|*/cp) verb=1 ;; esac; done
1915
+ for w in $CMD_UQ; do
1916
+ case "$w" in
1917
+ *.claude[/\\]state[/\\]*[\*\?\[]*|*.claude[/\\]state[\*\?\[]*) rc=0 ;;
1918
+ *.claude[/\\]state|*.claude[/\\]state[/\\]) [ "$verb" = 1 ] && rc=0 ;;
1919
+ esac
1920
+ done
1921
+ [ "$unglob" = 1 ] && set +f
1922
+ fi
1923
+ [ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
1924
+ _mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
1900
1925
  _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
1926
 
1902
1927
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
@@ -81,8 +81,13 @@ 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
+ 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
90
+ fi
86
91
  exit 2
87
92
  fi
88
93
  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,24 @@ _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 | loosen, or "" when the text is neither
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
377
  /*) _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
378
+ _v="${P:${#_k}}"; _vr="$_v"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
375
379
  # The text form of 3.1.0's release candidates, kept: `approve: <what>` and `onay: <what>`.
376
380
  *:*) _k="${P%%:*}"; _v="${P#*:}" ;;
377
381
  *) return 0 ;;
@@ -384,6 +388,15 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push, or
384
388
  case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
385
389
  esac
386
390
  shopt -u nocasematch
391
+ # The second command is read as it is written, lower case, with exactly three words after it.
392
+ if [ "$_k" = "@crew-loosen" ]; then
393
+ local a b c d
394
+ IFS=$' \t' read -r a b c d <<< "$_vr"
395
+ [ -n "${c:-}" ] && [ -z "${d:-}" ] || return 0
396
+ case "$a" in crew-*) ;; *) return 0 ;; esac; case "$a$b" in *[!a-z0-9-]*) return 0 ;; esac
397
+ case "$c" in critical|normal) ;; *) return 0 ;; esac
398
+ OP=loosen; LO_AGENT="$a"; LO_CHANGE="$b"; LO_RISK="$c"
399
+ fi
387
400
  }
388
401
 
389
402
  [ "$_PA_LIB" = 1 ] && return 0
@@ -433,6 +446,43 @@ say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text
433
446
  }
434
447
  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
448
 
449
+ # ---- /crew-loosen: a floor model routing raised is lowered by the user, and by nobody else ----------------------
450
+ # hooks/agent-outcome.sh raises a class's floor when its first try fails too often, and nothing lowers it again but
451
+ # this. The same proof as for an approval that a person is typing into their own session: the turn is not a
452
+ # notice or another session's message (above), the session is not a headless one, and it has a transcript already,
453
+ # so this is not the prompt a command or a schedule started a session with. In every permission mode: this is not
454
+ # a commit. ONE model down, ONCE for each time the floor was raised; the record of outcomes is counted afresh for
455
+ # that class from here, so the old failures do not raise it straight back.
456
+ if [ "$OP" = loosen ]; then
457
+ 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."; }
458
+ 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
459
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
460
+ { [ -n "$TP" ] && [ -s "$TP" ]; } || nol "this is the first message of this session, or the session keeps no transcript."
461
+ _lh="${BASH_SOURCE%/*}"; [ "$_lh" = "${BASH_SOURCE}" ] && _lh=.
462
+ [ -f "$_lh/guard-agent-model.sh" ] || nol "model routing is not installed beside this hook."
463
+ [ "${CREW_MODEL_ROUTING:-}" = off ] && nol "model routing is switched off (CREW_MODEL_ROUTING=off), so no floor is in force."
464
+ CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$CWD}"; . "$_lh/guard-agent-model.sh"
465
+ declare -F _am_floor_now >/dev/null 2>&1 || nol "model routing beside this hook is an older one."
466
+ _am_state
467
+ _am_floor_now "$LO_AGENT" "$LO_CHANGE" "$LO_RISK"
468
+ [ -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)."
469
+ [ "$_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."
470
+ _am_rank "$_AMF_TIER"; [ "$_AMR" -ge 2 ] || nol "the floor on record is $_AMF_TIER, and there is no model below it."
471
+ _am_name $((_AMR-1)); LTO="$_AMN"
472
+ 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
473
+ LF="$_AMS/crew-model-loosened.tsv"
474
+ mkdir -p "$_AMS" 2>/dev/null || nol "the state folder could not be made."
475
+ [ -s "$LF" ] || { printf 'ts\tagent\tchange\trisk\tfrom\tto\n' > "$LF"; } 2>/dev/null || nol "the record could not be written."
476
+ LFROM="$_AMF_TIER"
477
+ { 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."
478
+ # Said only when it is TRUE: the floor is read back the way the gate will read it. A line that was written and
479
+ # does not count (a clock that went backwards, a floors file that changed underneath) is not a lowering.
480
+ _am_floor_now "$LO_AGENT" "$LO_CHANGE" "$LO_RISK"
481
+ { [ "$_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?)."
482
+ say "Crewforth: floor lowered - $LO_AGENT / $LO_CHANGE / $LO_RISK from $LFROM to $LTO. Its outcomes are counted afresh from now." \
483
+ "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
+ fi
485
+
436
486
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
437
487
  case "$PM" in
438
488
  auto|dontAsk) ;;
package/kit/settings.json CHANGED
@@ -97,6 +97,17 @@
97
97
  "timeout": 600
98
98
  }
99
99
  ]
100
+ },
101
+ {
102
+ "matcher": "Agent|Task",
103
+ "hooks": [
104
+ {
105
+ "type": "command",
106
+ "shell": "bash",
107
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/guard-agent-model.sh",
108
+ "timeout": 600
109
+ }
110
+ ]
100
111
  }
101
112
  ],
102
113
  "UserPromptSubmit": [
@@ -135,6 +146,43 @@
135
146
  ]
136
147
  }
137
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
+ ],
138
186
  "SessionStart": [
139
187
  {
140
188
  "matcher": "startup|resume|clear|compact|fork",
@@ -161,6 +209,12 @@
161
209
  "shell": "bash",
162
210
  "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/board-sync.sh",
163
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
164
218
  }
165
219
  ]
166
220
  },
@@ -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
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // The permission host of a session Studio starts: an MCP server over stdio with one tool, `approve`, named to
3
+ // Claude Code with --permission-prompt-tool.
4
+ //
5
+ // WHY IT EXISTS. In a `-p` run Claude Code offers the two tools that need a person, AskUserQuestion and
6
+ // ExitPlanMode, only when the run has a permission host. Measured on Claude Code 2.1.294: with no host neither
7
+ // is in the session's tool list; with this one both are. A session without them asked its question as prose and
8
+ // could not leave plan mode at all.
9
+ //
10
+ // WHAT IT ANSWERS. Nothing, with one exception. Every prompt that reaches it is DENIED: the place a call is
11
+ // allowed in Studio is the dock, through the PreToolUse hook (studio-gate.sh), before the call gets here. A call
12
+ // that reaches this host is one the hook said nothing about, and before there was a host Claude Code refused
13
+ // those itself, because nobody could answer. Denying here keeps that exactly.
14
+ //
15
+ // The exception is a plan the viewer approved WITH a change of mode. The hook cannot change the mode; the
16
+ // permission host can, in its answer (measured: `updatedPermissions: [{ type: "setMode", … }]` moved a session
17
+ // from plan to acceptEdits). So for that one case the hook stays silent, and this host finds what the viewer
18
+ // decided in the spool — `host/<tool_use_id>.json`, written by the server when the answer was given — and
19
+ // answers with it. The mode is one of two words, checked here as well as where it was written.
20
+ //
21
+ // No dependency: the protocol is three JSON-RPC methods on newline-delimited stdio.
22
+ //
23
+ // Usage: studio-host.mjs <spool-dir>
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+
28
+ const SPOOL = process.argv[2] ?? '';
29
+ const MODES = ['acceptEdits', 'default'];
30
+ const DENIED = 'Crewforth Studio: nobody answers this prompt here. A call is allowed in the Studio dock before it runs, or it is refused.';
31
+
32
+ const send = (o) => process.stdout.write(`${JSON.stringify(o)}\n`);
33
+ const text = (o) => ({ content: [{ type: 'text', text: JSON.stringify(o) }] });
34
+
35
+ /** What the viewer decided for this call, when the decision is one only the host can deliver. Read once. */
36
+ export function decisionFor(spool, toolName, toolUseId) {
37
+ if (toolName !== 'ExitPlanMode' || typeof toolUseId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(toolUseId) || !spool) return null;
38
+ const file = path.join(spool, 'host', `${toolUseId}.json`);
39
+ let said;
40
+ try { said = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
41
+ try { fs.rmSync(file); } catch { /* answered once either way: the hook asks again for a new call */ }
42
+ return MODES.includes(said?.mode) ? said.mode : null;
43
+ }
44
+
45
+ export function answer(spool, args) {
46
+ const mode = decisionFor(spool, args?.tool_name, args?.tool_use_id);
47
+ if (!mode) return { behavior: 'deny', message: DENIED };
48
+ return {
49
+ behavior: 'allow',
50
+ updatedInput: args.input ?? {},
51
+ updatedPermissions: [{ type: 'setMode', mode, destination: 'session' }],
52
+ };
53
+ }
54
+
55
+ function serve() {
56
+ let buf = '';
57
+ process.stdin.on('data', (d) => {
58
+ buf += d;
59
+ let i;
60
+ while ((i = buf.indexOf('\n')) !== -1) {
61
+ const line = buf.slice(0, i).trim();
62
+ buf = buf.slice(i + 1);
63
+ if (!line) continue;
64
+ let m;
65
+ try { m = JSON.parse(line); } catch { continue; }
66
+ if (m.method === 'initialize') {
67
+ send({ jsonrpc: '2.0', id: m.id, result: { protocolVersion: m.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'crew-studio-host', version: '1' } } });
68
+ } else if (m.method === 'tools/list') {
69
+ send({
70
+ jsonrpc: '2.0',
71
+ id: m.id,
72
+ result: { tools: [{
73
+ name: 'approve',
74
+ description: 'Answers a permission prompt of a session Crewforth Studio started.',
75
+ inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name', 'input'] },
76
+ }] },
77
+ });
78
+ } else if (m.method === 'tools/call') {
79
+ send({ jsonrpc: '2.0', id: m.id, result: text(answer(SPOOL, m.params?.arguments)) });
80
+ } else if (m.id !== undefined) {
81
+ // Anything else that expects an answer gets an empty one; a notification gets none.
82
+ send({ jsonrpc: '2.0', id: m.id, result: {} });
83
+ }
84
+ }
85
+ });
86
+ }
87
+
88
+ // Imported by the selfcheck for `answer`; run by Claude Code as the server.
89
+ // Node names the main module by its real path, so the path it was started with is resolved the same way.
90
+ let main = false;
91
+ try { main = pathToFileURL(fs.realpathSync(process.argv[1])).href === import.meta.url; } catch { /* not started as a script */ }
92
+ if (main) serve();