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.
- package/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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"
|
package/kit/hooks/guard-write.sh
CHANGED
|
@@ -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
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|