crewforth 3.2.1 → 3.4.0
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 +115 -0
- package/README.md +6 -2
- package/VERSION +1 -1
- package/adopt.sh +7 -3
- package/bin/cli.js +55 -0
- package/kit/AGENT_TEMPLATE.md +1 -0
- package/kit/CLAUDE.md +9 -8
- package/kit/agents/crew-session-manager.md +5 -3
- package/kit/eval/doctor.sh +99 -45
- package/kit/eval/golden-routing.txt +3 -0
- package/kit/eval/lib/verify-cmd.sh +72 -0
- package/kit/eval/smoke-test.sh +460 -83
- package/kit/hooks/context-usage.sh +6 -3
- package/kit/hooks/guard-agent-model.sh +29 -8
- package/kit/hooks/guard-bash.sh +127 -2
- package/kit/hooks/guard-schedule.sh +4 -1
- package/kit/hooks/pre-commit +21 -0
- package/kit/hooks/prompt-approval.sh +80 -3
- package/kit/hooks/route-hint.sh +18 -1
- package/kit/hooks/session-guard.sh +41 -14
- package/kit/hooks/session-rehydrate.sh +6 -2
- package/kit/hooks/worktree-provision.sh +91 -0
- package/kit/owned-blobs.tsv +6 -4
- package/kit/settings.json +22 -0
- package/kit/skills/commit-message/SKILL.md +5 -0
- package/kit/skills/crew-allow-verify/SKILL.md +30 -0
- package/kit/skills/crew-doctor/SKILL.md +3 -1
- package/kit/skills/handoff/SKILL.md +3 -2
- package/kit/skills/trace-scan/SKILL.md +4 -3
- package/kit/studio/server/lib/model-outcomes.js +7 -1
- package/package.json +1 -1
- package/start.sh +6 -2
|
@@ -262,9 +262,12 @@ PCT="$((PCT_T / 10)).$((PCT_T % 10))"
|
|
|
262
262
|
# ago: the expression above rounds. A comment that describes a tool the code no longer calls is worse than
|
|
263
263
|
# no comment, because the next reader trusts it instead of the line.
|
|
264
264
|
PCTI="${PCT%%.*}"; case "$PCTI" in ''|*[!0-9]*) PCTI=0 ;; esac
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
265
|
+
# The form the discipline's status line uses, word for word: `→ <low|medium|high> fill · Recommendation: <…>`.
|
|
266
|
+
# It used to print the recommendation alone for two bands and the level for the third (`continue`, `medium`,
|
|
267
|
+
# `handoff+clear`), which left the model to work the level word out of the thresholds itself.
|
|
268
|
+
if [ "$PCTI" -lt 50 ]; then LEVEL="low fill · Recommendation: continue"
|
|
269
|
+
elif [ "$PCTI" -lt 75 ]; then LEVEL="medium fill · Recommendation: continue"
|
|
270
|
+
else LEVEL="high fill · Recommendation: handoff+clear"
|
|
268
271
|
fi
|
|
269
272
|
# The '%<number>' shape is a contract: session-guard.sh reads the percentage back out of this line.
|
|
270
273
|
if [ "$VERBOSE" = 1 ]; then
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
# change: text | feature | fix-known | fix-unknown | refactor | migration | security | architecture |
|
|
10
10
|
# test-run | test-write | audit
|
|
11
11
|
# verify: the command that says the work is right, or `none`
|
|
12
|
-
# and this gate reads the card. What it holds,
|
|
12
|
+
# and this gate reads the card. What it holds, by refusing a call below its floor (and running one above it ON the
|
|
13
|
+
# floor, unless the card says `above: <why>`):
|
|
13
14
|
# * the call names a model, and carries a card;
|
|
14
15
|
# * RISK CLASS. `critical` when the change is migration, security, architecture or fix-unknown, or a file is on a
|
|
15
16
|
# critical path (auth, payments, billing, migrations, security, crypto, secrets, *.sql, schema.prisma;
|
|
@@ -20,6 +21,8 @@
|
|
|
20
21
|
# that wants opus there says so in .claude/crew-model-rules (floor * text critical opus).
|
|
21
22
|
# * the referee is as strong as the risk: test-write and audit on sonnet or above (opus when critical, by the rule
|
|
22
23
|
# above); review, privacy, planner and database agents on sonnet or above; the security agent on opus.
|
|
24
|
+
# * feature, fix-known and refactor run on sonnet or above, the table's sonnet row (not the commit agent, whose
|
|
25
|
+
# work is the message).
|
|
23
26
|
# * work with no verify command runs on sonnet or above: haiku goes only to work a command can check. Not for
|
|
24
27
|
# change: text, nor for the commit agent: a reader is all prose ever has, and a commit message is checked by
|
|
25
28
|
# the commit-msg hook (seen on a real project: a commit message proposed on sonnet for that reason alone).
|
|
@@ -359,7 +362,8 @@ _am_tier(){ # $1 = a model as a call or a transcript names it -> _AMT haiku|son
|
|
|
359
362
|
# of every file of the project (the scan in agent-outcome.sh). A path is critical when one of its parts is one of
|
|
360
363
|
# these WORDS, standing as a word: after a separator or at a capital (`auth/`, `user_auth.py`, `AuthController.cs`,
|
|
361
364
|
# `userAuth.ts`, `AUTH_KEYS`) and not running on in lower case (`author.md`, `cryptography/` are not). And by name:
|
|
362
|
-
# a .sql file, schema.prisma.
|
|
365
|
+
# a .sql file, schema.prisma. And the migration folders whose names carry no such word: alembic/versions/,
|
|
366
|
+
# Rails' db/migrate/. The path is read from the project's root down, so the name of the folder the project
|
|
363
367
|
# sits in decides nothing. .claude/crew-model-rules, which only the user writes, adds (`+ glob`) and removes
|
|
364
368
|
# (`- glob`); the last line that matches wins.
|
|
365
369
|
# Legal and personal-data texts are critical too (crew-docs-agent's work): legal, privacy, consent in every form, and
|
|
@@ -404,6 +408,7 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
|
|
|
404
408
|
_am_rel "$1"; p="$_AMRP"; _AMW=""
|
|
405
409
|
if [[ "$p" =~ $_AM_RE_L ]] || [[ "$p" =~ $_AM_RE_C ]] || [[ "$p" =~ $_AM_RE_U ]]; then hit=0; _AMW="${BASH_REMATCH[2]}"
|
|
406
410
|
else case "$p" in *.sql|*.SQL) hit=0; _AMW="a .sql file" ;; schema.prisma|*/schema.prisma) hit=0; _AMW="schema.prisma" ;;
|
|
411
|
+
alembic/versions/*|*/alembic/versions/*|db/migrate/*|*/db/migrate/*) hit=0; _AMW="migrations" ;;
|
|
407
412
|
store/*|*/store/*|store-listing/*|*/store-listing/*|fastlane/metadata/*|*/fastlane/metadata/*|metadata/android/*|*/metadata/android/*|metadata/ios/*|*/metadata/ios/*)
|
|
408
413
|
case "$p" in *.txt|*.md|*.xml|*.yml|*.yaml) hit=0; _AMW="a store listing text" ;; esac ;;
|
|
409
414
|
esac; fi
|
|
@@ -822,6 +827,26 @@ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> ret
|
|
|
822
827
|
echo "GUARD (agent model): $_AMRP is on a critical path ($_AMW) and this agent runs on ${tier:-a model that could not be read}; only an agent on opus writes there. Do not reach the file another way. Stop, and end your report with the two lines: escalate: $_AMRP / confidence: low" >&2
|
|
823
828
|
exit 2
|
|
824
829
|
}
|
|
830
|
+
# THE FLOOR OF A CLASS, before any card, record or rule: the agent's own, then the card's change. One definition, for
|
|
831
|
+
# the gate and for /crew-loosen (prompt-approval.sh), which must not report a lowering below it: a raise on record for
|
|
832
|
+
# a class whose own floor is the same model is not lowered by anything (field review, 3.2.1).
|
|
833
|
+
_am_agent_floor(){ # $1 = agent -> floor (rank) and why. The agent's own floor, whatever the card says.
|
|
834
|
+
floor=0; why=""
|
|
835
|
+
case "$1" in
|
|
836
|
+
crew-security-expert) floor=3; why="the security audit" ;;
|
|
837
|
+
crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; why="this agent's floor" ;;
|
|
838
|
+
esac
|
|
839
|
+
return 0
|
|
840
|
+
}
|
|
841
|
+
_am_change_floor(){ # $1 = change, $2 = agent -> raises floor and why
|
|
842
|
+
case "$1" in test-write|audit) if [ "$floor" -lt 2 ]; then floor=2; why="change: $1, the referee of the work"; fi ;; esac
|
|
843
|
+
# The table's sonnet row: feature, fix-known and refactor run on sonnet with a verify command too. They had no floor
|
|
844
|
+
# here, so with the rule that a call runs ON its floor, a call that asked for sonnet or opus by the table ran on haiku
|
|
845
|
+
# (field report, 3.2.1: a fix-known on a PII file, asked for opus, ran 106 turns on haiku and came back
|
|
846
|
+
# `confidence: low`). haiku stays the floor of text, test-run and the commit agent, whose work is the message.
|
|
847
|
+
[ "$2" != crew-commit-agent ] && case "$1" in feature|fix-known|refactor) if [ "$floor" -lt 2 ]; then floor=2; why="change: $1, the table's sonnet row"; fi ;; esac
|
|
848
|
+
return 0
|
|
849
|
+
}
|
|
825
850
|
[ "$_AM_LIB" = 1 ] && return 0
|
|
826
851
|
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
827
852
|
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
@@ -863,11 +888,7 @@ _json_keycount "$ti" model
|
|
|
863
888
|
if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
|
|
864
889
|
_json_slice "$ti" model >/dev/null; _json_unescape "$_JS" >/dev/null; m="$_JU"
|
|
865
890
|
if [ -z "$m" ]; then rank=0; else _am_tier "$m"; if [ -n "$_AMT" ]; then m="$_AMT"; _am_rank "$m"; rank="$_AMR"; else rank=-1; fi; fi
|
|
866
|
-
|
|
867
|
-
case "$st" in
|
|
868
|
-
crew-security-expert) floor=3; why="the security audit" ;;
|
|
869
|
-
crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; why="this agent's floor" ;;
|
|
870
|
-
esac
|
|
891
|
+
_am_agent_floor "$st"
|
|
871
892
|
_am_name "$floor"; fname="$_AMN"
|
|
872
893
|
if [ "$rank" = 0 ]; then
|
|
873
894
|
echo "GUARD (agent model): a call to $st has to name its model. Choose haiku, sonnet or opus by the table in CLAUDE.md (risk decides, not size) and repeat the same call with model set.${fname:+ $st runs on $fname or above.}" >&2
|
|
@@ -892,7 +913,7 @@ fi
|
|
|
892
913
|
_am_state; _am_risk; _am_digest
|
|
893
914
|
# ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
|
|
894
915
|
if [ "$CARD_VERIFY" = none ] && [ "$CARD_CHANGE" != text ] && [ "$st" != crew-commit-agent ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
|
|
895
|
-
|
|
916
|
+
_am_change_floor "$CARD_CHANGE" "$st"
|
|
896
917
|
if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" = text ] && _am_all_prose; then
|
|
897
918
|
if [ "$floor" -lt 2 ]; then floor=2; why="a text change on a critical path: $CARD_RISKWHY"; fi
|
|
898
919
|
elif [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
|
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -1996,7 +1996,10 @@ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
|
1996
1996
|
[ "$unglob" = 1 ] && set +f
|
|
1997
1997
|
fi
|
|
1998
1998
|
[ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
|
|
1999
|
-
|
|
1999
|
+
# Reading one is open through the Read tool, which no gate here matches: the rule is about a command, which cannot be
|
|
2000
|
+
# told reading from writing by its text. Said in the refusal, since a session that did not know it told the user it
|
|
2001
|
+
# could not even show them the file (field report, 3.2.1).
|
|
2002
|
+
_mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it; to show it to the user, read it with the Read tool)" "4.5" tamper
|
|
2000
2003
|
# The refuter ledger (hooks/refuter-ledger.sh) by its file name: the agent's name alone is not refused, so a command
|
|
2001
2004
|
# that greps for crew-refuter still runs. The same honest scope as the rule above.
|
|
2002
2005
|
_rf_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
@@ -2436,6 +2439,29 @@ _p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u]
|
|
|
2436
2439
|
[ "$npos" = 2 ] || { _P44_WHY="the remote and the branch are not both written out"; return 1; }
|
|
2437
2440
|
return 0
|
|
2438
2441
|
}
|
|
2442
|
+
# A PUSH NO APPROVAL CAN COVER: one that deletes or mirrors a remote ref (`--delete`, `-d`, `--mirror`, `--prune`,
|
|
2443
|
+
# `--all`, `:<ref>`). An approval records one form, `git push <remote> <branch>`, and the form was checked only after
|
|
2444
|
+
# the record was read, so with no record the refusal offered the approval question and /crew-approve for it; in the
|
|
2445
|
+
# field (3.2.1) the user approved twice and typed the command once, and each time it was refused again. Asked before
|
|
2446
|
+
# the record, so the refusal can say at once that this one is the user's to run.
|
|
2447
|
+
_p44_never(){ # 0 = this call is a push no approval covers; _P44N names the token that makes it so
|
|
2448
|
+
# Read from the text with its quotes taken out (CMD_UQ), after the word push: `git -C . push origin :x` and
|
|
2449
|
+
# `git push origin "":old` are deleting pushes too, and a reading that gave up on a form it could not collapse let
|
|
2450
|
+
# them fall through to the approval offer (found in review).
|
|
2451
|
+
local tok unglob=0 after=0
|
|
2452
|
+
_P44N=""
|
|
2453
|
+
git_has "$CMD_SEEN" 'push' || return 1
|
|
2454
|
+
git_has "$CMD_SEEN" 'commit' && return 1
|
|
2455
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
2456
|
+
set -- ${CMD_UQ//[;&|]/ ; }
|
|
2457
|
+
[ "$unglob" = 1 ] && set +f
|
|
2458
|
+
for tok in "$@"; do
|
|
2459
|
+
case "$tok" in \;) after=0; continue ;; push) after=1; continue ;; esac
|
|
2460
|
+
[ "$after" = 1 ] || continue
|
|
2461
|
+
case "$tok" in -d|--delete|--mirror|--prune|--all|:*|+:*) _P44N="$tok"; return 0 ;; esac
|
|
2462
|
+
done
|
|
2463
|
+
return 1
|
|
2464
|
+
}
|
|
2439
2465
|
_approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
|
|
2440
2466
|
local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool tag _h2
|
|
2441
2467
|
_APW="no approval from the user is on record"
|
|
@@ -2565,6 +2591,13 @@ allow_approved(){
|
|
|
2565
2591
|
_gate_written(){
|
|
2566
2592
|
# No prompt reaches a person here, but their own message does: see _approval_ok above.
|
|
2567
2593
|
_approval_ok && allow_approved
|
|
2594
|
+
if _p44_never; then
|
|
2595
|
+
gatelog BLOCK 4.4 "a push form no approval covers"
|
|
2596
|
+
echo "GUARD (§4.4): this push ($_P44N) deletes or mirrors a remote ref, and no approval given in a session covers it: an approval records one form, git push <remote> <branch>." >&2
|
|
2597
|
+
echo "Do not ask the user to approve it in this session, by question or by a typed command: they would approve and this command would be refused again." >&2
|
|
2598
|
+
echo "Tell the user, in their language, that this one is theirs to run: in their own terminal, or on GitHub (a merged pull request's branch has a Delete branch button there)." >&2
|
|
2599
|
+
exit 2
|
|
2600
|
+
fi
|
|
2568
2601
|
[ -n "${_PRF:-}" ] || { _PRF=0; _gb_prh && _PRF=1; } # the default branch has already asked
|
|
2569
2602
|
gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
|
|
2570
2603
|
case "$PERM_MODE" in
|
|
@@ -2765,13 +2798,14 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
|
|
|
2765
2798
|
_C47_VARS=" "
|
|
2766
2799
|
_C47_SHRE='(^|[^A-Za-z0-9_-])git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*commit([[:space:]]|$)'
|
|
2767
2800
|
_C47_N=0; _C47_WT=""; _C47_NV=""; _C47_AM=""; _C47_UNK=""; _C47_EXP=""; _C47_ENV=""; _C47_SH=""; _C47_CFG=""; _C47_UNR=""; _C47_SUB=""; _C47_ARGS=""; _C47_CD=0; _C47_CDT=""
|
|
2801
|
+
_C47_MK=""; _mk_sub=""
|
|
2768
2802
|
_c47_read "$1"; m="$_C47_M"
|
|
2769
2803
|
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
2770
2804
|
set -- $_C47_T
|
|
2771
2805
|
[ "$unglob" = 1 ] && set +f
|
|
2772
2806
|
while [ $# -gt 0 ]; do
|
|
2773
2807
|
raw="$1"; shift
|
|
2774
|
-
[ "$raw" = ";" ] && { ph=cmd; wrapped=0; vc=0; continue; }
|
|
2808
|
+
[ "$raw" = ";" ] && { [ "$ph" = mk ] && _c47_mk_close; ph=cmd; wrapped=0; vc=0; continue; }
|
|
2775
2809
|
_WAT=0
|
|
2776
2810
|
case "$raw" in "$m"*|*"$m"*) _c47_w "$raw" ;; *) _W="$raw"; _WQ=0; case "$raw" in *[\$\*\?\[\{]*) _WX=1 ;; *) _WX=0 ;; esac ;; esac
|
|
2777
2811
|
tok="$_W"
|
|
@@ -2878,8 +2912,23 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
|
|
|
2878
2912
|
_C47_SUB="${tok:0:40}"; ph=skip ;;
|
|
2879
2913
|
commit) ph=commit; _C47_N=$((_C47_N+1))
|
|
2880
2914
|
if [ "$rcd" != 0 ] && [ "$_C47_CD" = 0 ]; then _C47_CD=$rcd; _C47_CDT="$rcdt"; fi ;;
|
|
2915
|
+
merge|cherry-pick|revert|am|commit-tree|pull) ph=mk; _mk_sub="$tok"; _mk_nc=0; _mk_sq=0; _mk_ffo=0; _mk_ffs=0; _mk_rb=0; _mk_ctl=0 ;;
|
|
2881
2916
|
*) ph=skip ;;
|
|
2882
2917
|
esac ;;
|
|
2918
|
+
mk) # §4.4: a git command that makes a commit (see _c47_mk_close). The value of an option that takes one is
|
|
2919
|
+
# skipped, so `-m --squash` is a message and not a flag; of two opposite flags git takes the last, and so
|
|
2920
|
+
# does this (`--no-commit --commit` commits).
|
|
2921
|
+
case "$tok" in
|
|
2922
|
+
-m|-F|-s|-X|-o|-j|--message|--file|--strategy|--strategy-option|--into-name|--cleanup|--mainline|--server-option|--depth|--deepen|--shallow-since|--shallow-exclude|--upload-pack|--jobs|--whitespace|--directory|--exclude|--include|--resolvemsg)
|
|
2923
|
+
case "${1:-}" in ''|\;) ;; *) shift ;; esac ;;
|
|
2924
|
+
--no-commit) _mk_nc=1 ;; --commit) _mk_nc=0 ;;
|
|
2925
|
+
-n) case "$_mk_sub" in cherry-pick|revert) _mk_nc=1 ;; esac ;;
|
|
2926
|
+
--squash) _mk_sq=1 ;; --no-squash) _mk_sq=0 ;;
|
|
2927
|
+
--ff-only) _mk_ffo=1; _mk_ffs=1 ;; --ff|--no-ff) _mk_ffo=0; _mk_ffs=1 ;;
|
|
2928
|
+
-r|--rebase|--rebase=*) _mk_rb=1 ;; --no-rebase) _mk_rb=0 ;;
|
|
2929
|
+
--abort|--quit|--skip|--show-current-patch|--show-current-patch=*) _mk_ctl=1 ;;
|
|
2930
|
+
--continue) _mk_ctl=0 ;;
|
|
2931
|
+
esac ;;
|
|
2883
2932
|
commit)
|
|
2884
2933
|
case "$raw" in
|
|
2885
2934
|
'<<H') continue ;; # a here-document; arguments may follow it
|
|
@@ -2928,9 +2977,29 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
|
|
|
2928
2977
|
esac ;;
|
|
2929
2978
|
esac
|
|
2930
2979
|
done
|
|
2980
|
+
[ "$ph" = mk ] && _c47_mk_close
|
|
2931
2981
|
[ "$envf" = 1 ] && [ "$_C47_N" != 0 ] && _C47_ENV="a variable that changes what git reads"
|
|
2932
2982
|
return 0
|
|
2933
2983
|
}
|
|
2984
|
+
# The end of one `git merge|cherry-pick|revert|am|commit-tree|pull`: does it make a commit? The forms that make none
|
|
2985
|
+
# stay free. pull.ff=only frees a plain pull only when the command line overrides neither it nor pull.rebase.
|
|
2986
|
+
_c47_mk_close(){
|
|
2987
|
+
local free=0
|
|
2988
|
+
case "$_mk_sub" in
|
|
2989
|
+
merge) { [ "$_mk_ctl" = 1 ] || [ "$_mk_nc" = 1 ] || [ "$_mk_sq" = 1 ] || [ "$_mk_ffo" = 1 ]; } && free=1 ;;
|
|
2990
|
+
cherry-pick|revert) { [ "$_mk_ctl" = 1 ] || [ "$_mk_nc" = 1 ]; } && free=1 ;;
|
|
2991
|
+
am) [ "$_mk_ctl" = 1 ] && free=1 ;;
|
|
2992
|
+
pull)
|
|
2993
|
+
if [ "$_mk_rb" = 0 ]; then
|
|
2994
|
+
{ [ "$_mk_nc" = 1 ] || [ "$_mk_sq" = 1 ] || [ "$_mk_ffo" = 1 ]; } && free=1
|
|
2995
|
+
if [ "$free" = 0 ] && [ "$_mk_ffs" = 0 ] && [ "$(git -C "${_CWD:-.}" config --get pull.ff 2>/dev/null)" = only ]; then
|
|
2996
|
+
case "$(git -C "${_CWD:-.}" config --get pull.rebase 2>/dev/null)" in ''|false) free=1 ;; esac
|
|
2997
|
+
fi
|
|
2998
|
+
fi ;;
|
|
2999
|
+
esac
|
|
3000
|
+
[ "$free" = 1 ] || _C47_MK="${_C47_MK:-git $_mk_sub}"
|
|
3001
|
+
_mk_sub=""
|
|
3002
|
+
}
|
|
2934
3003
|
|
|
2935
3004
|
# The §4.5 half of what that reading finds is judged HERE, before the pre-authorised branch below: with CLAUDE_GIT_OK
|
|
2936
3005
|
# nobody is asked, so a commit that skips its hooks or rewrites the last commit must not reach that allow. (§4.6 is
|
|
@@ -3101,6 +3170,48 @@ if git_has "$CMD_SEEN" 'add|commit|push|checkout|switch'; then
|
|
|
3101
3170
|
1|yes|true|on|YES|TRUE|ON) allow_preauthorised ;; # pre-authorised session (headless/CI)
|
|
3102
3171
|
esac
|
|
3103
3172
|
fi
|
|
3173
|
+
# §4.4 — A COMMIT MADE BY ANOTHER COMMAND. `git merge`, `cherry-pick`, `revert`, `am`, `commit-tree` and `pull` make a
|
|
3174
|
+
# commit as surely as `git commit`, and none of them reached this gate: measured in auto mode, each ran with rc 0, and
|
|
3175
|
+
# in the field (3.2.1) a merge commit landed with nobody asked. The forms that make none stay free (_c47_mk_close). The
|
|
3176
|
+
# approval a session records is for a staged tree under `git commit`, which these do not commit, so where no prompt
|
|
3177
|
+
# reaches a person the call is refused and the user runs it; in default and acceptEdits the user is asked.
|
|
3178
|
+
# READ BY THE COMMIT GATE'S OWN READER (_c47_scan), not by a second one: the first version split the text itself, and
|
|
3179
|
+
# review found `'git' merge`, `(git merge …)`, `$(git merge …)`, `git -C "a b" merge` and `git merge -m --squash` all
|
|
3180
|
+
# passing. The reader pairs quotes as the shell does and skips an option's value. Run only when the text, with quotes
|
|
3181
|
+
# and backslashes taken out, holds git and one of the words: builtins up to there, so an ordinary call pays nothing.
|
|
3182
|
+
_mk_try=0
|
|
3183
|
+
# A quote or a backslash can split the word git itself (`g\it merge`, `"g"it merge`: found in review), so a call that
|
|
3184
|
+
# holds one is taken out of its quoting before either word is looked for. Builtins only.
|
|
3185
|
+
case "$CMD_SEEN" in *[Gg][Ii][Tt]*|*[\\\"\']*)
|
|
3186
|
+
_gsub "$CMD_SEEN" '\\'$'\n' '' bs; _unquoted "$_GS"; _t="$_GS"
|
|
3187
|
+
case "$_t" in *[Gg][Ii][Tt]*) ;; *) _t="" ;; esac
|
|
3188
|
+
case "$_t" in *[Mm][Ee][Rr][Gg][Ee]*|*[Cc][Hh][Ee][Rr][Rr][Yy]-[Pp][Ii][Cc][Kk]*|*[Rr][Ee][Vv][Ee][Rr][Tt]*|*[Cc][Oo][Mm][Mm][Ii][Tt]-[Tt][Rr][Ee][Ee]*|*[Pp][Uu][Ll][Ll]*|*[[:space:]][Aa][Mm][[:space:]]*|*[[:space:]][Aa][Mm]|*[Aa][Ll][Ii][Aa][Ss].*) _mk_try=1 ;; esac ;;
|
|
3189
|
+
esac
|
|
3190
|
+
if [ "$_mk_try" = 1 ]; then
|
|
3191
|
+
# The reader resets what the commit rules read from it; this call holds the same text, so they are put back as read.
|
|
3192
|
+
_mk_s1="${_C47_N:-0}" _mk_s2="${_C47_WT:-}" _mk_s3="${_C47_CD:-0}" _mk_s4="${_C47_CDT:-}" _mk_s5="${_C47_CFG:-}"
|
|
3193
|
+
_c47_scan "$CMD"
|
|
3194
|
+
_MKC="$_C47_MK"
|
|
3195
|
+
# An alias given for this one call can stand for any of them: `git -c alias.mg=merge mg` (found in review).
|
|
3196
|
+
case "$_C47_CFG" in *[Aa][Ll][Ii][Aa][Ss].*) [ -z "$_MKC" ] && _MKC="a git alias given in the command ($_C47_CFG)" ;; esac
|
|
3197
|
+
_C47_N="$_mk_s1"; _C47_WT="$_mk_s2"; _C47_CD="$_mk_s3"; _C47_CDT="$_mk_s4"; _C47_CFG="$_mk_s5"
|
|
3198
|
+
if [ -n "$_MKC" ]; then
|
|
3199
|
+
case "${CLAUDE_GIT_OK:-}" in 1|yes|true|on|YES|TRUE|ON) allow_preauthorised ;; esac
|
|
3200
|
+
_mkp=0; case "$PERM_MODE" in default|acceptEdits) _gb_prh || _mkp=1 ;; esac
|
|
3201
|
+
if [ "$_mkp" = 1 ]; then
|
|
3202
|
+
SHORT="$CMD"; [ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
|
|
3203
|
+
ask_user "§4.4 commit approval gate. Claude wants to run a command that makes a commit ($_MKC):
|
|
3204
|
+
|
|
3205
|
+
$SHORT
|
|
3206
|
+
|
|
3207
|
+
Approve only if you want that commit made. Approving lets Claude run the command itself."
|
|
3208
|
+
fi
|
|
3209
|
+
gatelog BLOCK 4.4 "a commit made by another git command with no approval"
|
|
3210
|
+
echo "GUARD (§4.4): '$_MKC' makes a commit, and in '${PERM_MODE:-unknown}' no prompt reaches a person. The approval a session records is for a staged tree under git commit, and does not cover a commit this command makes." >&2
|
|
3211
|
+
echo "Tell the user, in their language, that this one is theirs to run: in their own terminal, or after Shift+Tab to default mode, where this gate asks them. A form that makes no commit needs no approval (merge --ff-only, pull --ff-only, --no-commit, --squash)." >&2
|
|
3212
|
+
exit 2
|
|
3213
|
+
fi
|
|
3214
|
+
fi
|
|
3104
3215
|
if git_has "$CMD_SEEN" 'commit|push'; then
|
|
3105
3216
|
# §4.6 — A COMMIT NEEDS A CLEAN REVIEW OF THIS DIFF.
|
|
3106
3217
|
# crew-review-agent records what it cleared in .claude/review-pass.json; this reads it back and compares two
|
|
@@ -3274,6 +3385,10 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3274
3385
|
echo "GUARD (§4.6): this call changes directory before it commits, and the commit would not run in the repository" >&2
|
|
3275
3386
|
echo "this session is in — or the target cannot be read (a variable, 'cd -', more than one). The review record" >&2
|
|
3276
3387
|
echo "describes what is staged HERE. Run the commit from this directory, or run it yourself in your terminal." >&2
|
|
3388
|
+
# The way through, said (field report, 3.2.1: a session in the root tried `cd <worktree> && git commit` and stopped
|
|
3389
|
+
# there). The gate stays closed to the cd; the session moves into the worktree instead.
|
|
3390
|
+
echo "For another worktree: move the session into it first (EnterWorktree, or start the session there), have the" >&2
|
|
3391
|
+
echo "review run there, and commit from it; or the user commits there in their own terminal." >&2
|
|
3277
3392
|
exit 2
|
|
3278
3393
|
fi
|
|
3279
3394
|
fi
|
|
@@ -3297,6 +3412,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3297
3412
|
# git queries move too, not just the path: a staged diff read in the wrong worktree is the same defect
|
|
3298
3413
|
# wearing different clothes. `_CWD` is "" when the payload carried none, and `-C .` is then a no-op.
|
|
3299
3414
|
_RPD="${_CWD:-.}"
|
|
3415
|
+
# ...and at the TOP of that work tree: a cwd that moved into a sub-folder (a subagent's run leaves the Bash tool
|
|
3416
|
+
# in packages/api) looked for packages/api/.claude/review-pass.json and refused a commit whose record sat at the
|
|
3417
|
+
# root (field report, 3.2.1). The top is the nearest folder above that holds `.git` (a directory, or a linked
|
|
3418
|
+
# worktree's file), walked with builtins: a `git rev-parse` here cost the refusal path two processes over the
|
|
3419
|
+
# gate's ceiling (smoke's cost table). Only when the record is not where the cwd is.
|
|
3420
|
+
if [ ! -f "$_RPD/.claude/review-pass.json" ]; then
|
|
3421
|
+
_RPT="$_RPD"
|
|
3422
|
+
while [ ! -e "$_RPT/.git" ]; do case "$_RPT" in */*) _RPT="${_RPT%/*}" ;; *) _RPT=""; break ;; esac; done
|
|
3423
|
+
[ -n "$_RPT" ] && _RPD="$_RPT" # found or not, the record belongs there, and the refusal names that path
|
|
3424
|
+
fi
|
|
3300
3425
|
RP="$_RPD/.claude/review-pass.json"
|
|
3301
3426
|
if [ ! -f "$RP" ]; then
|
|
3302
3427
|
gatelog BLOCK 4.6 "no review-pass record"
|
|
@@ -81,7 +81,10 @@ 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
|
-
if [ "$OP" =
|
|
84
|
+
if [ "$OP" = allowverify ]; then
|
|
85
|
+
echo "GUARD (§4.4): this call schedules a prompt that allows a verify command. Only the user can type /crew-allow-verify; a prompt a tool schedules would grant the hook a command in their place, so the call is refused." >&2
|
|
86
|
+
echo "Schedule the work without it. If a verify command should run, say which: the user can type /crew-allow-verify <command> themselves." >&2
|
|
87
|
+
elif [ "$OP" = loosen ]; then
|
|
85
88
|
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
89
|
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
90
|
else
|
package/kit/hooks/pre-commit
CHANGED
|
@@ -471,6 +471,27 @@ fi
|
|
|
471
471
|
# escape : .private-allowlist.txt, a term per full line, for the rare case a term is legitimately public.
|
|
472
472
|
PRIV_F="$(mktemp)"; PTERM_F="$(mktemp)"
|
|
473
473
|
PTF=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-terms.txt" ] && PTF="$GITROOT/.private-terms.txt"
|
|
474
|
+
# A linked worktree has no copy of a gitignored file, so its own root never holds the terms: committing from one
|
|
475
|
+
# skipped them (measured: the same commit rc=1 in the checkout, rc=0 in its worktree). Read the checkout's list
|
|
476
|
+
# then. Only this list falls back — it tightens; an allowlist missing there only makes the gate stricter.
|
|
477
|
+
# Builtins only: `.git` is a file in a worktree ("gitdir: <dir>"), and <dir>/commondir names the shared .git.
|
|
478
|
+
if [ -z "$PTF" ] && [ -n "$GITROOT" ] && [ -f "$GITROOT/.git" ]; then
|
|
479
|
+
_gd=""; IFS= read -r _gd < "$GITROOT/.git" || true; _gd="${_gd#gitdir: }"; _gd="${_gd%$'\r'}"
|
|
480
|
+
case "$_gd" in /*|[A-Za-z]:*) ;; *) _gd="$GITROOT/$_gd" ;; esac
|
|
481
|
+
_cd=""; [ -f "$_gd/commondir" ] && { IFS= read -r _cd < "$_gd/commondir" || true; _cd="${_cd%$'\r'}"; }
|
|
482
|
+
if [ -n "$_cd" ]; then
|
|
483
|
+
case "$_cd" in /*|[A-Za-z]:*) ;; *) _cd="$_gd/$_cd" ;; esac
|
|
484
|
+
# Only a shared dir named .git sits in its checkout; a bare or separate git dir's parent is not the checkout
|
|
485
|
+
# (found in review: it read whatever list happened to sit beside the git dir).
|
|
486
|
+
# A relative commondir reads <gitdir>/../..: drop each trailing "/<name>/.." before asking for the name.
|
|
487
|
+
_cd="${_cd%/}"; _up=0
|
|
488
|
+
while :; do case "$_cd" in */..) _cd="${_cd%/..}"; _up=$((_up+1)) ;; */.) _cd="${_cd%/.}" ;; *) break ;; esac; done
|
|
489
|
+
while [ "$_up" -gt 0 ]; do _cd="${_cd%/*}"; _up=$((_up-1)); done
|
|
490
|
+
case "$_cd" in */.git) [ -f "${_cd%/.git}/.private-terms.txt" ] && PTF="${_cd%/.git}/.private-terms.txt" ;; esac
|
|
491
|
+
unset _up
|
|
492
|
+
fi
|
|
493
|
+
unset _gd _cd
|
|
494
|
+
fi
|
|
474
495
|
PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$GITROOT/.private-allowlist.txt"
|
|
475
496
|
{
|
|
476
497
|
# A home path shorter than this cannot be distinguishing (`/root`, `/`), and matching it would flag everything.
|
|
@@ -376,15 +376,24 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
|
|
|
376
376
|
|
|
377
377
|
# ---- WHICH TEXT IS AN APPROVAL: one function, asked here of the user's prompt and by guard-schedule.sh of a
|
|
378
378
|
# prompt a tool is about to schedule ---------------------------------------------------------------------------
|
|
379
|
-
_crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pending | loosen, or "" when the text is none
|
|
379
|
+
_crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pending | loosen | allowverify, or "" when the text is none
|
|
380
380
|
# `loosen` is the other thing only the user's own message does: `/crew-loosen <agent> <change> <risk>` lowers a
|
|
381
381
|
# floor that model routing raised (3.1.0). It is matched HERE so that everything that refuses to hand a session
|
|
382
382
|
# an approval (a scheduled prompt, a `claude -p` call) refuses to hand it this as well: one question, one answer.
|
|
383
383
|
local P="$1" _k _v _vr LC_ALL=C
|
|
384
|
-
OP=""; LO_AGENT=""; LO_CHANGE=""; LO_RISK=""
|
|
384
|
+
OP=""; LO_AGENT=""; LO_CHANGE=""; LO_RISK=""; AV_CMD=""
|
|
385
385
|
P="${P//$'\r'/}"
|
|
386
386
|
while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
|
|
387
387
|
while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
|
|
388
|
+
# `/crew-allow-verify [<command>]` (3.2.2): the user allows a verify command by name, the third thing only their own
|
|
389
|
+
# message does. The message is at most 120 bytes before it reaches this function (the limit every reader of an
|
|
390
|
+
# approval shares, guard-schedule.sh's included); a longer one is answered before that limit, not here.
|
|
391
|
+
_k="${P%%[$' \t']*}"
|
|
392
|
+
case "$_k" in /*) case "${_k##*[/:]}" in crew-allow-verify)
|
|
393
|
+
case "$P" in *$'\n'*) return 0 ;; esac
|
|
394
|
+
AV_CMD="${P:${#_k}}"; while :; do case "$AV_CMD" in [$' \t']*) AV_CMD="${AV_CMD:1}" ;; *) break ;; esac; done
|
|
395
|
+
OP=allowverify; return 0 ;; esac ;;
|
|
396
|
+
esac
|
|
388
397
|
[ "${#P}" -le 90 ] || return 0
|
|
389
398
|
_vr=""
|
|
390
399
|
case "$P" in
|
|
@@ -664,7 +673,13 @@ _pa_record(){
|
|
|
664
673
|
case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
|
|
665
674
|
if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
|
|
666
675
|
else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
|
|
667
|
-
|
|
676
|
+
# A commit+push with nothing staged is most often a push alone, answered with the option people pick first (field
|
|
677
|
+
# report, 3.2.1: twice in a row). It is not turned into a push approval, which the user did not give; the line
|
|
678
|
+
# names the one they can.
|
|
679
|
+
if [ "$TREE" = "$_ht" ]; then
|
|
680
|
+
[ "$OP" = commit+push ] && no "nothing is staged, so there is no commit to approve. For a push of the branch as it is, the answer is Push only (or the user can type /crew-approve push); for a commit, stage it with git add, show the commit message, then ask again."
|
|
681
|
+
no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again."
|
|
682
|
+
fi ;;
|
|
668
683
|
esac
|
|
669
684
|
case "$OP" in *push)
|
|
670
685
|
[ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
|
|
@@ -817,6 +832,15 @@ _crew_q_dir "$CWD"; [ -s "$TRIED" ] && { : > "$TRIED"; } 2>/dev/null
|
|
|
817
832
|
|
|
818
833
|
# ---- is this message an approval? ------------------------------------------------------------------------
|
|
819
834
|
[ "$_n_prompt" = 1 ] || exit 0
|
|
835
|
+
# A /crew-allow-verify longer than that is answered, not dropped in silence (found in review): it writes nothing, and
|
|
836
|
+
# the limit stays the one guard-schedule.sh reads a scheduled prompt by, so a long one cannot be scheduled past it.
|
|
837
|
+
if [ "${#RAW}" -gt 120 ]; then
|
|
838
|
+
_lk="${RAW%%[ \\]*}"
|
|
839
|
+
case "$_lk" in /*) case "${_lk##*[/:]}" in crew-allow-verify)
|
|
840
|
+
EVN=UserPromptSubmit
|
|
841
|
+
say "Crewforth: verify command NOT allowed - the message is longer than this command reads (120 characters). Write the line into .claude/crew-model-rules by hand, as verify <command>." "The user sent the command only the user can type, /crew-allow-verify, with a command longer than it reads, and Crewforth added no rule. Tell the user to write that line into .claude/crew-model-rules by hand, as verify <command>." ;; esac ;;
|
|
842
|
+
esac
|
|
843
|
+
fi
|
|
820
844
|
[ "${#RAW}" -le 120 ] || exit 0 # an approval is a few words; a pasted page is not decoded to find that out
|
|
821
845
|
_json_unescape "$RAW" >/dev/null
|
|
822
846
|
_crew_appr_op "$_JU"
|
|
@@ -848,6 +872,14 @@ if [ "$OP" = loosen ]; then
|
|
|
848
872
|
[ "$_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."
|
|
849
873
|
_am_rank "$_AMF_TIER"; [ "$_AMR" -ge 2 ] || nol "the floor on record is $_AMF_TIER, and there is no model below it."
|
|
850
874
|
_am_name $((_AMR-1)); LTO="$_AMN"
|
|
875
|
+
# Not below the class's own floor (the agent's, then the card's change): a lowering there would be written, read
|
|
876
|
+
# back as in force, and refused by the gate all the same (found in review: feature, raised to sonnet by the record
|
|
877
|
+
# of the haiku runs before its own floor was sonnet, was "lowered to haiku" and still refused on haiku).
|
|
878
|
+
if declare -F _am_change_floor >/dev/null 2>&1; then
|
|
879
|
+
floor=0; why=""; _am_agent_floor "$LO_AGENT"; _am_change_floor "$LO_CHANGE" "$LO_AGENT"
|
|
880
|
+
_am_rank "$LTO"
|
|
881
|
+
if [ "$_AMR" -lt "$floor" ]; then _am_name "$floor"; nol "the class's own floor is $_AMN ($why), which nothing lowers, so a lowering of the raise on record to $LTO would not be in force."; fi
|
|
882
|
+
fi
|
|
851
883
|
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
|
|
852
884
|
LF="$_AMS/crew-model-loosened.tsv"
|
|
853
885
|
mkdir -p "$_AMS" 2>/dev/null || nol "the state folder could not be made."
|
|
@@ -862,6 +894,51 @@ if [ "$OP" = loosen ]; then
|
|
|
862
894
|
"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."
|
|
863
895
|
fi
|
|
864
896
|
|
|
897
|
+
# ---- /crew-allow-verify: a verify command the hook may run, allowed by the user and by nobody else --------------
|
|
898
|
+
# .claude/crew-model-rules is closed to the session (guard-write.sh, guard-bash.sh), so doctor's advice "add its line"
|
|
899
|
+
# left the user to find and edit the file by hand (field report, 3.2.1). With nothing after it the command lists the
|
|
900
|
+
# verify commands refused for want of a rule (the reading doctor uses, eval/lib/verify-cmd.sh); with a command after
|
|
901
|
+
# it, that command gets its `verify <command>` line. Only a plain one: one command in letters, digits and . _ / = : , @
|
|
902
|
+
# + % - and spaces, so it can neither chain another (the gate would refuse a chain anyway) nor need escaping here.
|
|
903
|
+
if [ "$OP" = allowverify ]; then
|
|
904
|
+
noa(){ say "Crewforth: verify command NOT allowed - $1" "The user sent the command only the user can type /crew-allow-verify, and Crewforth added no rule: $1 Tell the user exactly that."; }
|
|
905
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) noa "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
|
|
906
|
+
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
907
|
+
{ [ -n "$TP" ] && [ -s "$TP" ]; } || noa "this is the first message of this session, or the session keeps no transcript."
|
|
908
|
+
_lh="${BASH_SOURCE%/*}"; [ "$_lh" = "${BASH_SOURCE}" ] && _lh=.
|
|
909
|
+
[ -f "$_lh/../eval/lib/verify-cmd.sh" ] || noa "Crewforth's verify reading is not installed beside this hook (eval/lib/verify-cmd.sh)."
|
|
910
|
+
. "$_lh/../eval/lib/verify-cmd.sh"
|
|
911
|
+
_avr="${CLAUDE_PROJECT_DIR:-$CWD}"; _avr="${_avr//\\//}"; _avr="${_avr%/}"
|
|
912
|
+
_avok(){ case "$1" in ''|*[!A-Za-z0-9\ ._/=:,@+%-]*|-*) return 1 ;; esac; return 0; }
|
|
913
|
+
if [ -z "$AV_CMD" ]; then
|
|
914
|
+
crew_verify_blocked "$_avr"
|
|
915
|
+
_avl=""; _avn=0; _avx=0; _avk='/crew-allow-verify'; _avwho='each is the whole message that allows it, and only the user can type it'
|
|
916
|
+
while IFS= read -r _avc; do [ -n "$_avc" ] || continue
|
|
917
|
+
if _avok "$_avc"; then _avn=$((_avn+1)); _avl="$_avl $_avk $_avc ·"; else _avx=$((_avx+1)); fi
|
|
918
|
+
done <<< "$_VB_CMDS"
|
|
919
|
+
_avl="${_avl% ·}"
|
|
920
|
+
[ "$_avn$_avx" = 00 ] && say "Crewforth: no verify command on record was refused for want of a rule." "The user sent the command only the user can type, /crew-allow-verify, with nothing after it, and no verify command on record was refused for want of a rule: tell them so, in one line."
|
|
921
|
+
_avm=""; [ "$_avx" -gt 0 ] && _avm=" $_avx more hold a character this command does not write; those lines go into .claude/crew-model-rules by hand, as verify <command>."
|
|
922
|
+
say "Crewforth: verify commands refused for want of a rule:${_avl:- none in a form this command writes}.$_avm" "The user sent the command only the user can type, /crew-allow-verify, with nothing after it. Show them, in their language, the lines in Crewforth's message, one per line, as they stand: $_avwho. Do not type one for them."
|
|
923
|
+
fi
|
|
924
|
+
case "$AV_CMD" in *[\;\&\|\<\>\`]*|*'$('*) noa "this is more than one command (a ; & | < > or a substitution): the hook runs a verify command alone, so no rule makes it run a chain. Give the card a single command, or a script that holds the chain." ;; esac
|
|
925
|
+
_avok "$AV_CMD" || noa "'${AV_CMD//[!A-Za-z0-9 ._\/=:,@+%-]/?}' is not one plain command (letters, digits, . _ / = : , @ + % - and spaces only, not starting with -). Write such a line into .claude/crew-model-rules by hand, as verify <command>."
|
|
926
|
+
case " $AV_CMD " in *" git commit "*|*" git push "*) noa "a git commit or push is never a verify command: the hook does not commit or push." ;; esac
|
|
927
|
+
crew_verify_open_ended "$AV_CMD" && noa "a rule '$AV_CMD' would let the hook run any command that begins with it (an interpreter, a shell, a wrapper or a package runner, with nothing after it that names the file or the task). Name the script or the test: bash tools/check.sh, npm test, pytest tests/."
|
|
928
|
+
_avf="$_avr/.claude/crew-model-rules"
|
|
929
|
+
{ [ -L "$_avr/.claude" ] || [ -L "$_avf" ]; } && noa "the rules file, or the .claude folder, is a symbolic link: Crewforth writes the rules only into the project's own file."
|
|
930
|
+
if [ -f "$_avf" ]; then
|
|
931
|
+
while IFS= read -r _avl || [ -n "$_avl" ]; do [ "${_avl%$'\r'}" = "verify $AV_CMD" ] && say "Crewforth: verify $AV_CMD is already allowed." "The user sent the command only the user can type, /crew-allow-verify $AV_CMD, and .claude/crew-model-rules already holds that line: tell them so, in one line."; done < "$_avf"
|
|
932
|
+
fi
|
|
933
|
+
mkdir -p "$_avr/.claude" 2>/dev/null || noa "the .claude folder could not be made."
|
|
934
|
+
if [ -s "$_avf" ] && [ "$(tail -c 1 "$_avf" | od -An -tx1 | tr -d ' \n')" != 0a ]; then printf '\n' >> "$_avf" || noa "the rules file could not be written."; fi
|
|
935
|
+
{ printf 'verify %s\n' "$AV_CMD" >> "$_avf"; } 2>/dev/null || noa "the rules file could not be written."
|
|
936
|
+
# Said only when it is TRUE: the line is read back from the file.
|
|
937
|
+
_avh=0; while IFS= read -r _avl || [ -n "$_avl" ]; do [ "${_avl%$'\r'}" = "verify $AV_CMD" ] && _avh=1; done < "$_avf"
|
|
938
|
+
[ "$_avh" = 1 ] || noa "the line was written but is not read back from .claude/crew-model-rules."
|
|
939
|
+
say "Crewforth: verify command allowed - the hook may now run: $AV_CMD (the line is in .claude/crew-model-rules)." "The user allowed the verify command $AV_CMD with their own command; Crewforth added the line verify $AV_CMD to .claude/crew-model-rules. From the next agent call, the hook runs it when a card names it, and judges the result. Tell the user that in one line. A deny rule of the settings still stops it, and it is still run alone, with no chain."
|
|
940
|
+
fi
|
|
941
|
+
|
|
865
942
|
# ---- `/crew-approve` with nothing after it: what this session was last refused ---------------------------------
|
|
866
943
|
# guard-bash.sh, when it refuses a commit or a push in auto / dontAsk, writes down what it refused
|
|
867
944
|
# (.claude/state/crew-approval-pending: the session, commit | push | commit+push, the second). The bare command
|
package/kit/hooks/route-hint.sh
CHANGED
|
@@ -133,7 +133,24 @@ function norm(s) {
|
|
|
133
133
|
sub(/^ +/,"",s); sub(/ +$/,"",s)
|
|
134
134
|
return s
|
|
135
135
|
}
|
|
136
|
-
|
|
136
|
+
# A request to READ is not work for an owner. "read the notes at the end of the README" named crew-docs-agent, an
|
|
137
|
+
# agent that writes docs, because the scorer saw `README` and never the verb (field report, 3.2.1). Such a prompt
|
|
138
|
+
# is left to the main thread, and only when the read verb is the REQUEST: the first word of an English prompt
|
|
139
|
+
# (after please / can you / could you), the last word of a Turkish one (before mısın / musun / lütfen), since
|
|
140
|
+
# Turkish puts the verb last. Anywhere else it is a bug report or part of a larger task — "the button does not
|
|
141
|
+
# show the spinner" routed to the frontend expert before this, and must still (found in review). A second clause
|
|
142
|
+
# (and, then, ve, sonra) or one action word anywhere and the hint is scored as before. `show` and `göster` are not
|
|
143
|
+
# on the list: "show the avatar in the navbar" asks for UI work, and routed to the frontend expert (found in review).
|
|
144
|
+
function hasw(re) { return np ~ ("(^| )(" re ")( |$)") }
|
|
145
|
+
function readonly( w, n, f, l) {
|
|
146
|
+
n = split(np, w, " "); if (n == 0) return 0
|
|
147
|
+
f = 1; while (f < n && w[f] ~ /^(please|pls|can|could|would|you)$/) f++
|
|
148
|
+
l = n; while (l > 1 && w[l] ~ /^(misin|musun|misiniz|musunuz|lutfen|bakalim)$/) l--
|
|
149
|
+
if (w[f] !~ /^(read|explain|summari[sz]e|describe)$/ &&
|
|
150
|
+
w[l] !~ /^(oku|okur|okuyun|okuyabilir|anlat|anlatir|anlatin|acikla|aciklar|aciklayin|ozetle|ozetler|ozetleyin)$/) return 0
|
|
151
|
+
return !hasw("and|then|ve|sonra|(fix|add|updat|writ|wrote|chang|implement|creat|remov|delet|refactor|build|buil|mak|renam|edit|migrat|mov|deploy|ship|instal|upgrad|review|audit|check|test|debug|investigat|optimi[sz]|improv|translat|draft|generat|setup|set|harden|resolv|run|merg|publish|configur|propos|analy[sz])[a-z]?[a-z]?[a-z]?[a-z]?|why|bug|bugs|crash[a-z]*|fail[a-z]*|error[a-z]*|broken|duzelt[a-z]*|ekle[a-z]*|guncelle[a-z]*|yaz[a-z]*|degistir[a-z]*|olustur[a-z]*|sil[a-z]*|kaldir[a-z]*|yap[a-z]*|kur[a-z]*|tasi[a-z]*|incele[a-z]*|denetle[a-z]*|cevir[a-z]*|duzenle[a-z]*|iyilestir[a-z]*|yayinla[a-z]*|cikar[a-z]*|hizlandir[a-z]*|uygula[a-z]*|coz[a-z]*|gelistir[a-z]*|sadelestir[a-z]*|analiz[a-z]*|neden|hata[a-z]*|cokuyor|bozuk[a-z]*")
|
|
152
|
+
}
|
|
153
|
+
BEGIN { np = " " norm(ENVIRON["CREW_PROMPT"]) " "; bestA=0; bestS=0; nameA=""; nameS=""; if (readonly()) exit }
|
|
137
154
|
seen[FILENAME] { next } # one Trigger-phrases line per component, the first
|
|
138
155
|
{
|
|
139
156
|
if (tolower($0) !~ /trigger phrases:/) next
|
|
@@ -117,6 +117,31 @@ if [ -z "$PCT" ]; then
|
|
|
117
117
|
[ -n "$PCT" ] || exit 0
|
|
118
118
|
fi
|
|
119
119
|
|
|
120
|
+
# The reply's own 🔋 line, held against the reading. The discipline says that figure is copied from the hook's
|
|
121
|
+
# line and never guessed, and nothing checked it: a session whose hook line had stopped arriving went on to print
|
|
122
|
+
# 45, 51 … 86 while its transcript measured 30.3, and the user handed the session off on that number. A figure
|
|
123
|
+
# more than CREW_FILL_TOLERANCE points (default 5) from the measurement is reported to the user, every turn it
|
|
124
|
+
# happens. The tolerance covers the one honest gap: a slow-path reading is taken at the END of the turn, so it
|
|
125
|
+
# includes output the model had not produced when it copied the start-of-turn line. No 🔋 line, or none with a
|
|
126
|
+
# number after its '%' (a quoted template, `%NN`), says nothing. Pure expansion: no process.
|
|
127
|
+
WRONG=""
|
|
128
|
+
_said="${IN##*🔋}"
|
|
129
|
+
if [ "$_said" != "$IN" ]; then
|
|
130
|
+
_said="${_said%%\\n*}" # that line only: the payload escapes a newline as \n
|
|
131
|
+
_num="${_said#*%}"
|
|
132
|
+
if [ "$_num" != "$_said" ]; then
|
|
133
|
+
_num="${_num%%[!0-9.,]*}"; _num="${_num/,/.}" # a Turkish reply writes %30,3
|
|
134
|
+
_si="${_num%%.*}"; _sf="${_num#"$_si"}"; _sf="${_sf#.}"; _sf="${_sf:0:1}"
|
|
135
|
+
_mi="${PCT%%.*}"; _mf="${PCT#"$_mi"}"; _mf="${_mf#.}"; _mf="${_mf:0:1}"
|
|
136
|
+
case "$_si$_sf$_mi$_mf" in ''|*[!0-9]*) ;; *)
|
|
137
|
+
[ -n "$_si" ] && [ -n "$_mi" ] && {
|
|
138
|
+
_d=$(( (10#$_si * 10 + 10#${_sf:-0}) - (10#$_mi * 10 + 10#${_mf:-0}) )); [ "$_d" -lt 0 ] && _d=$(( -_d ))
|
|
139
|
+
[ "$_d" -gt $(( ${CREW_FILL_TOLERANCE:-5} * 10 )) ] && WRONG="%$_num"
|
|
140
|
+
} ;;
|
|
141
|
+
esac
|
|
142
|
+
fi
|
|
143
|
+
fi
|
|
144
|
+
|
|
120
145
|
# Highest threshold crossed. Compared under LC_ALL=C so '77.2' parses identically everywhere.
|
|
121
146
|
# Compared on the integer part with shell arithmetic rather than two `awk` calls. Two spawns at the end of every
|
|
122
147
|
# turn bought nothing here: the thresholds are whole numbers, so truncating is exactly equivalent — 89.9 is below
|
|
@@ -126,24 +151,26 @@ case "$INT" in ''|*[!0-9]*) exit 0 ;; esac
|
|
|
126
151
|
TIER=""
|
|
127
152
|
[ "$INT" -ge 90 ] && TIER=90
|
|
128
153
|
[ -n "$TIER" ] || { [ "$INT" -ge 75 ] && TIER=75; }
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
MSG=""
|
|
131
155
|
# Already warned at this tier this session? Stay silent — one alert per threshold, not one per turn.
|
|
132
|
-
[ -e "$(marker "$TIER")" ]
|
|
133
|
-
# Stamp this tier AND every lower one: jumping 60 -> 92 in a single turn must not let the 75 warning fire
|
|
134
|
-
# later (e.g. once /compact drops the fill back under 90).
|
|
135
|
-
for t in 75 90; do
|
|
136
|
-
|
|
137
|
-
done
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
if [ -n "$TIER" ] && [ ! -e "$(marker "$TIER")" ]; then
|
|
157
|
+
# Stamp this tier AND every lower one: jumping 60 -> 92 in a single turn must not let the 75 warning fire
|
|
158
|
+
# later (e.g. once /compact drops the fill back under 90).
|
|
159
|
+
for t in 75 90; do
|
|
160
|
+
[ "$t" -le "$TIER" ] && { : > "$(marker "$t")" 2>/dev/null || true; }
|
|
161
|
+
done
|
|
162
|
+
if [ "$TIER" = 90 ]; then
|
|
163
|
+
MSG="CRITICAL >90% — hand off NOW: apply the handoff skill, write docs/SESSION_STATE.md, then /clear. Auto-compaction is close and it will cost you the handover."
|
|
164
|
+
else
|
|
165
|
+
MSG=">75% threshold. Recommended: apply the handoff skill, write docs/SESSION_STATE.md, then /clear. Manual; your call."
|
|
166
|
+
fi
|
|
143
167
|
fi
|
|
168
|
+
[ -n "$MSG$WRONG" ] || exit 0
|
|
144
169
|
|
|
145
170
|
# JSON-escape backslash + double-quote in the measured line before embedding it.
|
|
146
171
|
SAFE="$(printf '%s' "$LINE" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
172
|
+
OUT="${MSG:+$SAFE | $MSG}"
|
|
173
|
+
[ -n "$WRONG" ] && OUT="⚠️ The 🔋 line in this reply says $WRONG, but the fill measured from the transcript is: $SAFE. The reply's figure is not a measurement — do not decide a handoff on it.${OUT:+ · $OUT}"
|
|
147
174
|
# exit 0 + systemMessage: a neutral, user-facing warning (NOT a hook error, does NOT force an extra turn).
|
|
148
|
-
printf '{"systemMessage":"%s
|
|
175
|
+
printf '{"systemMessage":"%s"}\n' "$OUT"
|
|
149
176
|
exit 0
|
|
@@ -62,8 +62,12 @@ HOOKMSG=""
|
|
|
62
62
|
if [ -f "$ROOT/.claude/hooks/pre-commit" ] && git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
|
63
63
|
_hp="$(git -C "$ROOT" config --get core.hooksPath 2>/dev/null || true)"; _hp="${_hp%$'\r'}"; _hp="${_hp//\\//}"
|
|
64
64
|
case "${_hp%/}" in
|
|
65
|
-
.claude/hooks
|
|
66
|
-
|
|
65
|
+
.claude/hooks|.claude/git-shim) ;;
|
|
66
|
+
# Absolute, as the installers now write it (a relative one runs no hook in a linked worktree): it has to be THERE.
|
|
67
|
+
# A repository that was moved keeps the old path, and git then runs no hook at all, with nothing said.
|
|
68
|
+
*/.claude/hooks|*/.claude/git-shim)
|
|
69
|
+
[ -d "$_hp" ] || HOOKMSG="Crewforth's git hooks are not connected in this repository: core.hooksPath points at a directory that does not exist (the repository was moved?), so no commit is scanned for traces, secrets or size. Tell the user once, in one line. The fix is theirs to run in their own terminal, not yours: the Crewforth update (npx crewforth update) writes the path again. /crew-doctor shows the state." ;;
|
|
70
|
+
'') HOOKMSG="Crewforth's git hooks are not connected in this repository: core.hooksPath is not set, so a commit made outside Claude Code is not scanned for traces, secrets or size. Tell the user once, in one line. The fix is theirs to run in their own terminal, not yours: the Crewforth update (npx crewforth update), which arms it and keeps any hooks the repository has of its own. /crew-doctor shows the state." ;;
|
|
67
71
|
*) HOOKMSG="Crewforth's git hooks are not connected in this repository: core.hooksPath points at another hook directory, so a commit made outside Claude Code is not scanned for traces, secrets or size. Tell the user once, in one line. The fix is theirs to run in their own terminal, not yours: the Crewforth update keeps that directory's hooks and runs Crewforth's before them. /crew-doctor shows the state." ;;
|
|
68
72
|
esac
|
|
69
73
|
fi
|