crewforth 3.2.0-rc.1 → 3.2.0-rc.10
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 +190 -5
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +55 -6
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +6 -6
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +37 -3
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/lib/crew-map/narrative.mjs +42 -8
- package/kit/eval/lib/crew-map/project.mjs +3 -2
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +56 -4
- package/kit/eval/lib/crew-map/stacks.mjs +114 -8
- package/kit/eval/lib/crew-map/surface.mjs +81 -3
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +14 -4
- package/kit/eval/lib/statusline.sh +4 -19
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +424 -26
- package/kit/hooks/agent-outcome.sh +38 -9
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +163 -20
- package/kit/hooks/guard-bash.sh +69 -36
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +74 -5
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +18 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-map/SKILL.md +16 -5
- package/kit/skills/crew-review/SKILL.md +2 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +85 -22
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +104 -18
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/web/app.js +79 -11
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +72 -3
- package/kit/studio/web/map.js +103 -10
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +40 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- package/start.sh +14 -1
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -148,7 +148,14 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
|
|
|
148
148
|
# ---- /CREW-JOIN
|
|
149
149
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
150
150
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
151
|
-
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
151
|
+
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
152
|
+
# Where the gate log goes: one rule for every gate and reader (eval/lib/gate-log.sh).
|
|
153
|
+
[ -f "$_crew_d/../eval/lib/gate-log.sh" ] && . "$_crew_d/../eval/lib/gate-log.sh"
|
|
154
|
+
# What a stopped or asked command would have touched (eval/lib/preview.sh), read only when one is stopped or asked.
|
|
155
|
+
_CREW_PV_LIB="$_crew_d/../eval/lib/preview.sh"; unset _crew_d
|
|
156
|
+
# The preview of a command. ALWAYS called as "$(_pv_line …)": the command substitution is the subshell that keeps an
|
|
157
|
+
# exit or a failure in the library from ending this hook or changing its decision (smoke plants both).
|
|
158
|
+
_pv_line(){ [ -f "$_CREW_PV_LIB" ] || return 0; . "$_CREW_PV_LIB" 2>/dev/null && crew_preview "$1" "$2" 2>/dev/null && printf '%s' "${_CREW_PV:-}"; return 0; }
|
|
152
159
|
# Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
|
|
153
160
|
# of this hook. `read` returns 1 at end of input; the text is read regardless.
|
|
154
161
|
IFS= read -r -d '' INPUT || true
|
|
@@ -168,20 +175,17 @@ IFS= read -r -d '' INPUT || true
|
|
|
168
175
|
# token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
|
|
169
176
|
# buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
|
|
170
177
|
# positive, which is the only thing it is good for.
|
|
171
|
-
# Where the
|
|
172
|
-
#
|
|
173
|
-
# the
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
+
# Where the log goes. An explicit CREW_GATE_LOG is the operator's call and is used as given. .claude/gate-log.tsv
|
|
179
|
+
# only where writing there cannot surprise anyone: outside a git repo, or where git ignores it (a kit install
|
|
180
|
+
# ignores .claude/; the plugin edition drops into repos the installer never touched, and this repo proved the
|
|
181
|
+
# failure: the suite left a gate-log.tsv in `git status` waiting to be committed). Elsewhere it goes inside the
|
|
182
|
+
# repository's .git, which is never committed. git is asked only when a decision is logged (a block, an approval
|
|
183
|
+
# prompt or a CLAUDE_GIT_OK allow); ordinary commands never reach it.
|
|
184
|
+
# Since 3.2.0 a repository that tracks .claude/ gets the log inside its .git (eval/lib/gate-log.sh), where it was
|
|
185
|
+
# not written at all before.
|
|
178
186
|
_gatelog_path(){
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
182
|
-
git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
|
|
183
|
-
fi
|
|
184
|
-
printf '%s' ".claude/gate-log.tsv"
|
|
187
|
+
declare -F crew_gate_log_write >/dev/null || return 0
|
|
188
|
+
crew_gate_log_write; printf '%s' "$_CREW_GL"
|
|
185
189
|
}
|
|
186
190
|
gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
|
|
187
191
|
# Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
|
|
@@ -720,6 +724,8 @@ block(){
|
|
|
720
724
|
gatelog BLOCK "$2" "$1"
|
|
721
725
|
echo "GUARD (§$2): '$1' stopped AT THE TOOL LEVEL." >&2
|
|
722
726
|
[ -z "${_BLOCK_NOTE:-}" ] || echo "$_BLOCK_NOTE" >&2
|
|
727
|
+
# What it would have touched, when the rule names a kind (_PV_KIND): read only, after the decision is made.
|
|
728
|
+
if [ -n "${_PV_KIND:-}" ]; then _pvo="$(_pv_line "$_PV_KIND" "${_PV_CMD:-$CMD}")"; [ -n "$_pvo" ] && echo "$_pvo" >&2; fi
|
|
723
729
|
case "${3:-}" in
|
|
724
730
|
loss) echo "Nothing here is undone by retrying — but the refusal is not the end of the task. Do the same job the reversible way: name the paths instead of sweeping, and look first (git clean -n, ls) so you act on what you can see. Only if the broad form is genuinely required does the user run it in their own terminal." >&2 ;;
|
|
725
731
|
history) echo "This rewrites or discards work that is already committed. Ask the person who shares the branch; a new commit usually reaches the same end without rewriting." >&2 ;;
|
|
@@ -865,15 +871,15 @@ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag
|
|
|
865
871
|
_git_d45(){ # $1 = command text; block() exits on the first rule it breaks
|
|
866
872
|
local CMD="$1" HAS_GIT=0 _PUSHSEG _seg # has() reads CMD: this one
|
|
867
873
|
case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; esac
|
|
868
|
-
{ git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
|
|
874
|
+
{ git_has "$CMD" 'reset' && has '--hard'; } && { _PV_KIND=reset; _PV_CMD="$CMD"; block "git reset --hard" "4.5" history; }
|
|
869
875
|
if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
|
|
870
876
|
_grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
|
|
871
877
|
while IFS= read -r _seg; do
|
|
872
878
|
[ -n "$_seg" ] || continue
|
|
873
|
-
_push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
|
|
879
|
+
_push_forces "$_seg" && { _PV_KIND=push; _PV_CMD="$_seg"; block "git push --force" "4.5" history; break; }
|
|
874
880
|
done <<< "$_PUSHSEG"
|
|
875
881
|
fi
|
|
876
|
-
{ git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
|
|
882
|
+
{ git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && { _PV_KIND=clean; _PV_CMD="$CMD"; block "git clean -f" "4.5" loss; }
|
|
877
883
|
return 0
|
|
878
884
|
}
|
|
879
885
|
# ---- the quoted argument of a command that does not run it ---------------------------------------------------
|
|
@@ -1109,7 +1115,7 @@ case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
|
|
|
1109
1115
|
# short cluster, so `rm -Rf /`, `rm -fR /`, `rm -f -r /` and `rm --recursive --force /` all walked past while
|
|
1110
1116
|
# `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
|
|
1111
1117
|
# another reaching the identical state. Case, flag order and the long form are all the same command.
|
|
1112
|
-
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && block "destructive rm -rf" "4.5" loss
|
|
1118
|
+
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && { _PV_KIND=rm; _PV_CMD="$CMD"; block "destructive rm -rf" "4.5" loss; }
|
|
1113
1119
|
# ADDED (3.1.0): `.` and `..` as a target carry none of those three characters and are the widest targets there are —
|
|
1114
1120
|
# `rm -rf .` at a project root takes the whole tree, `..` its parent (both passed). Narrower than the test above on
|
|
1115
1121
|
# purpose, so that it adds no noise: `rm` must stand at a command position, `sudo` or not (`git rm -r --cached .`, `npm rm`,
|
|
@@ -1121,7 +1127,7 @@ case "$CMD" in *[Rr][Mm]*)
|
|
|
1121
1127
|
_rmc="$CMD"
|
|
1122
1128
|
# a backslash-newline continues the line (`rm -rf \⏎.`); folded only when one is there and the command is short
|
|
1123
1129
|
case "$CMD" in *\\"$_RMNL"*) [ "${#CMD}" -le 4096 ] && _rmc="${CMD//\\$_RMNL/ }" ;; esac
|
|
1124
|
-
[[ $_rmc =~ $_RMDOT ]] && block "destructive rm -rf" "4.5" loss ;; esac
|
|
1130
|
+
[[ $_rmc =~ $_RMDOT ]] && { _PV_KIND=rm; _PV_CMD="$_rmc"; block "destructive rm -rf" "4.5" loss; } ;; esac
|
|
1125
1131
|
# A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
|
|
1126
1132
|
# undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
|
|
1127
1133
|
# Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
|
|
@@ -2431,7 +2437,7 @@ _p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u]
|
|
|
2431
2437
|
return 0
|
|
2432
2438
|
}
|
|
2433
2439
|
_approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
|
|
2434
|
-
local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool
|
|
2440
|
+
local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool tag _h2
|
|
2435
2441
|
_APW="no approval from the user is on record"
|
|
2436
2442
|
git_has "$CMD_SEEN" 'commit' && hc=0; git_has "$CMD_SEEN" 'push' && hp=0
|
|
2437
2443
|
if [ "$hc" = 0 ] && [ "$hp" = 0 ]; then
|
|
@@ -2472,7 +2478,17 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
|
|
|
2472
2478
|
_p44_scan "$_A44_S" || { _APW="the push is not in the one form an approval covers, git push <remote> <branch> ($_P44_WHY)"; return 1; }
|
|
2473
2479
|
[ -n "$b" ] && [ -n "$r" ] && [ -n "$u" ] || { _APW="the approval record could not be read"; return 1; }
|
|
2474
2480
|
if [ "$_P44_REMOTE" != "$r" ]; then _APW="the user approved a push to $r, not to $_P44_REMOTE"; return 1; fi
|
|
2475
|
-
|
|
2481
|
+
# A TAG ON THE APPROVED COMMIT is part of that push (a release is tagged on what was approved): `git push <remote>
|
|
2482
|
+
# <tag>` passes when <tag> is a tag and not also a branch, and it points at the commit the approval covers. A tag
|
|
2483
|
+
# on any other commit, --tags, and a refspec are refused as before.
|
|
2484
|
+
tag=""
|
|
2485
|
+
case "$_P44_REF" in
|
|
2486
|
+
"$b"|HEAD) ;;
|
|
2487
|
+
*[!A-Za-z0-9._/-]*|-*|*..*|*/) _APW="the user approved a push of $b, not of $_P44_REF"; return 1 ;;
|
|
2488
|
+
*) if git -C "$dir" show-ref --verify --quiet "refs/tags/$_P44_REF" 2>/dev/null \
|
|
2489
|
+
&& ! git -C "$dir" show-ref --verify --quiet "refs/heads/$_P44_REF" 2>/dev/null; then tag="$_P44_REF"
|
|
2490
|
+
else _APW="the user approved a push of $b, not of $_P44_REF"; return 1; fi ;;
|
|
2491
|
+
esac
|
|
2476
2492
|
cur="$(git -C "$dir" symbolic-ref --short -q HEAD 2>/dev/null || true)"
|
|
2477
2493
|
[ "$cur" = "$b" ] || { _APW="the user approved a push of $b, and the branch checked out now is ${cur:-<detached>}"; return 1; }
|
|
2478
2494
|
# Where the remote pushes TO, with pushurl and pushInsteadOf applied: the name alone can be pointed elsewhere.
|
|
@@ -2485,8 +2501,16 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
|
|
|
2485
2501
|
cur="$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
2486
2502
|
if [ "$op" = push ]; then
|
|
2487
2503
|
[ "$cur" = "$h" ] || { _APW="HEAD moved after the user approved the push (approved ${h:0:7}, now ${cur:0:7})"; return 1; }
|
|
2504
|
+
if [ -n "$tag" ]; then
|
|
2505
|
+
cur="$(git -C "$dir" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || echo NONE)"
|
|
2506
|
+
[ "$cur" = "$h" ] || { _APW="the tag $tag is on ${cur:0:7}, not on the commit the user approved for push (${h:0:7})"; return 1; }
|
|
2507
|
+
fi
|
|
2488
2508
|
return 0
|
|
2489
2509
|
fi
|
|
2510
|
+
if [ -n "$tag" ]; then
|
|
2511
|
+
_h2="$cur"; cur="$(git -C "$dir" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || echo NONE)"
|
|
2512
|
+
[ "$cur" = "$_h2" ] || { _APW="the tag $tag is on ${cur:0:7}, not on the commit the user approved for push (${_h2:0:7})"; return 1; }
|
|
2513
|
+
fi
|
|
2490
2514
|
# commit+push: HEAD has to be the commit the approved index became.
|
|
2491
2515
|
cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^' 2>/dev/null || echo NONE)"
|
|
2492
2516
|
if [ "$h" = NONE ] || [ "$cur" != "$h" ]; then
|
|
@@ -2518,16 +2542,7 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
|
|
|
2518
2542
|
}
|
|
2519
2543
|
# IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
|
|
2520
2544
|
# on PreToolUse, prompt-approval.sh on PostToolUse). Asked only to choose which way the refusal offers first.
|
|
2521
|
-
_q_wired(){
|
|
2522
|
-
local f t d="${CLAUDE_PROJECT_DIR:-.}"
|
|
2523
|
-
d="${d//\\//}"; d="${d%/}"
|
|
2524
|
-
for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
|
|
2525
|
-
[ -n "$f" ] && [ -f "$f" ] || continue
|
|
2526
|
-
t=""; IFS= read -r -d '' t < "$f" || true
|
|
2527
|
-
case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
|
|
2528
|
-
done
|
|
2529
|
-
return 1
|
|
2530
|
-
}
|
|
2545
|
+
_q_wired(){ declare -F _crew_q_wired >/dev/null 2>&1 && _crew_q_wired; } # defined once, in prompt-approval.sh
|
|
2531
2546
|
# MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
|
|
2532
2547
|
# from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
|
|
2533
2548
|
# asks as it always has.
|
|
@@ -2597,12 +2612,21 @@ _gate_written(){
|
|
|
2597
2612
|
{ printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
|
|
2598
2613
|
fi
|
|
2599
2614
|
fi
|
|
2615
|
+
# ONE WAY TO ASK. With the question open, the refusal used to print it and then the typed way as an equal choice,
|
|
2616
|
+
# and the session picked the typed one 7 times in 9 (a real `auto` session on rc.3, 2026-10-18): the user was told
|
|
2617
|
+
# to type a command for what one click does. The typed way is now the fallback, said as one.
|
|
2618
|
+
_QOFF=0
|
|
2600
2619
|
if [ "$PERM_MODE" = auto ] && [ "$_PRF" = 0 ] && _q_wired && declare -F _crew_q_pre >/dev/null 2>&1 && [ -f "${BASH_SOURCE%/*}/guard-question.sh" ]; then
|
|
2601
|
-
|
|
2620
|
+
_QOFF=1
|
|
2621
|
+
echo "$_CREW_Q_ASK" >&2
|
|
2602
2622
|
echo "{\"questions\":[$_CREW_Q_JSON]}" >&2
|
|
2603
|
-
echo "Their answer is recorded by Crewforth itself (the line it adds says so); then run the command it names.
|
|
2623
|
+
echo "Their answer is recorded by Crewforth itself (the line it adds says so); then run the command it names. Ask it this way only: do not also give the user a command to type." >&2
|
|
2624
|
+
fi
|
|
2625
|
+
if [ "$_QOFF" = 1 ]; then
|
|
2626
|
+
echo "ONLY IF THAT QUESTION CANNOT BE ASKED (the tool call is refused, or the user dismisses it), ask instead, in their language, in this shape and with nothing after it:" >&2
|
|
2627
|
+
else
|
|
2628
|
+
echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
|
|
2604
2629
|
fi
|
|
2605
|
-
echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
|
|
2606
2630
|
echo " 1. the commit message, whole, in ONE code block;" >&2
|
|
2607
2631
|
if [ -n "$_pw" ]; then
|
|
2608
2632
|
echo " 2. under it one sentence in their language (If you approve, send only this:), and then the command on a line of its own, exactly as it is:" >&2
|
|
@@ -3342,17 +3366,26 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3342
3366
|
BRANCH_WARN=""
|
|
3343
3367
|
# symbolic-ref names the branch even before its first commit, where rev-parse answers "HEAD" and the very
|
|
3344
3368
|
# first commit onto main went by without this line. A detached HEAD reads empty: not the default branch.
|
|
3345
|
-
|
|
3369
|
+
# Read from HEAD with builtins (the gate log's crew_git_dir finds the git dir, a linked worktree's too): the
|
|
3370
|
+
# preview below pays one subshell to keep this hook's decision out of its reach, and this pays it back.
|
|
3371
|
+
_hb=""; if declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && [ -f "$_CREW_GD/HEAD" ]; then
|
|
3372
|
+
IFS= read -r _hb < "$_CREW_GD/HEAD"; _hb="${_hb%$'\r'}"; case "$_hb" in "ref: refs/heads/"*) _hb="${_hb#ref: refs/heads/}" ;; *) _hb="" ;; esac
|
|
3373
|
+
else _hb="$(git symbolic-ref --short -q HEAD 2>/dev/null)"; fi
|
|
3374
|
+
case "$_hb" in
|
|
3346
3375
|
main|master) BRANCH_WARN="⚠️ This commits DIRECTLY to the default branch. Prefer a feature branch unless you meant to.
|
|
3347
3376
|
|
|
3348
3377
|
" ;;
|
|
3349
3378
|
esac
|
|
3350
3379
|
_commit_agent_note
|
|
3380
|
+
# What it takes or sends: the staged change for a commit, the commits for a push (read only).
|
|
3381
|
+
_PVA=""; case "$CMD" in *push*) _PVA="$(_pv_line push-ask "$CMD")" ;; *) _PVA="$(_pv_line commit "$CMD")" ;; esac
|
|
3351
3382
|
ask_user "§4.4 commit/push approval gate. Claude wants to run:
|
|
3352
3383
|
|
|
3353
3384
|
$SHORT
|
|
3354
3385
|
|
|
3355
|
-
${
|
|
3386
|
+
${_PVA:+$_PVA
|
|
3387
|
+
|
|
3388
|
+
}${BRANCH_WARN}${_CAN}Approve only if the commit message above was shown to you and you agree with it. Approving lets Claude run the command itself."
|
|
3356
3389
|
;;
|
|
3357
3390
|
auto|dontAsk)
|
|
3358
3391
|
_commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
|
|
@@ -588,8 +588,11 @@ fi
|
|
|
588
588
|
|
|
589
589
|
# The message carries its own trace risk (a co-author trailer lives there, not in the diff), and commit-msg is
|
|
590
590
|
# what scans it. Reuse it on the -m value when there is one; an editor-composed message is not visible here and
|
|
591
|
-
# stays the git hook's job.
|
|
592
|
-
|
|
591
|
+
# stays the git hook's job. It is scanned even when the staged content already failed: a refusal that names one
|
|
592
|
+
# problem gets the commit retried with the other still in it (measured on a real project, 2026-10-21: a silenced
|
|
593
|
+
# lint check was named, the session-link trailer in the same message was not, and the message was shown to the
|
|
594
|
+
# user again).
|
|
595
|
+
if [ -x "$DIR/commit-msg" ]; then
|
|
593
596
|
# The message is tokenized the way a shell does (crew_opt_values), which matters because a real commit message
|
|
594
597
|
# is MULTI-LINE: a line-oriented `sed` extraction found the subject and stopped, so a co-author trailer on line
|
|
595
598
|
# 3 — the single most likely §4.1 violation, and the one the bare arm of the eval actually produced — went
|
|
@@ -640,6 +643,7 @@ $(bash "$DIR/commit-msg" "$MF" 2>&1)" || FAILED=1
|
|
|
640
643
|
| sed -n 's/.*[[:space:]]--\{0,1\}[Ff]\(ile\)\{0,1\}[[:space:]=]\{1,\}\([^[:space:];&|]\{1,\}\).*/\2/p' | head -1)"
|
|
641
644
|
MFILE="${MFILE%\"}"; MFILE="${MFILE#\"}"; MFILE="${MFILE%\'}"; MFILE="${MFILE#\'}"
|
|
642
645
|
if _grep "$CMD" -qE '(^|[[:space:]])(-F|--file)([[:space:]=]|$)' && { [ -z "$MFILE" ] || [ ! -f "$MFILE" ]; }; then
|
|
646
|
+
[ "$FAILED" = 1 ] && printf '%s\n' "$OUT" >&2
|
|
643
647
|
echo "GUARD (§4.1): this commit reads its message from a file (-F), and the command could not be parsed" >&2
|
|
644
648
|
echo "well enough to know which file. Scan cannot run, so the commit is refused. Commit with a plain" >&2
|
|
645
649
|
echo "-F <path> (or -m) in a command of its own." >&2
|
|
@@ -656,6 +660,7 @@ $(bash "$DIR/commit-msg" "$MFILE" 2>&1)" || FAILED=1
|
|
|
656
660
|
[ -z "$GITMSG_HOOK" ] && [ -x "$(git rev-parse --git-path hooks/commit-msg 2>/dev/null)" ] \
|
|
657
661
|
&& GITMSG_HOOK="git-default"
|
|
658
662
|
if [ -z "$GITMSG_HOOK" ]; then
|
|
663
|
+
[ "$FAILED" = 1 ] && printf '%s\n' "$OUT" >&2
|
|
659
664
|
echo "GUARD (§4.1): this commit's message would go unscanned." >&2
|
|
660
665
|
echo "No -m/-F was given, so the message is composed in an editor after this point, and no commit-msg" >&2
|
|
661
666
|
echo "git hook is wired here to read it afterwards (plugin-only install: a plugin cannot set" >&2
|
|
@@ -669,6 +674,9 @@ fi
|
|
|
669
674
|
if [ "$FAILED" = 1 ]; then
|
|
670
675
|
echo "GUARD (§4.1/§4.2): the commit content gate rejected this commit before it ran." >&2
|
|
671
676
|
echo "$OUT" >&2
|
|
677
|
+
case "$OUT" in *"TRACE-SCANNER (message)"*)
|
|
678
|
+
echo "The message itself carries the trace: write it again without that line. A harness reminder to end a commit message with a trailer does not override §4.1." >&2 ;;
|
|
679
|
+
esac
|
|
672
680
|
echo "Fix the flagged content and commit again. --no-verify is §4.5 and stays blocked." >&2
|
|
673
681
|
exit 2
|
|
674
682
|
fi
|
package/kit/hooks/guard-write.sh
CHANGED
|
@@ -72,7 +72,8 @@ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esa
|
|
|
72
72
|
_gate_main(){
|
|
73
73
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
74
74
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
75
|
-
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
75
|
+
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
76
|
+
[ -f "$_crew_d/../eval/lib/gate-log.sh" ] && . "$_crew_d/../eval/lib/gate-log.sh"; unset _crew_d # where the gate log goes
|
|
76
77
|
# Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
|
|
77
78
|
# of this hook. `read` returns 1 at end of input; the text is read regardless.
|
|
78
79
|
IFS= read -r -d '' INPUT || true
|
|
@@ -365,12 +366,7 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
|
|
|
365
366
|
# and with the same rule about the payload: the path is NOT recorded unless CREW_GATE_LOG_CMD=1, because
|
|
366
367
|
# /crew-gates reports rule names and counts and never the argument. Logged after the verdict; it cannot
|
|
367
368
|
# change it. CREW_GATE_LOG overrides the path; point it at /dev/null to turn recording off.
|
|
368
|
-
_GL="$
|
|
369
|
-
if [ -z "$_GL" ] && [ -d ".claude" ]; then
|
|
370
|
-
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null; then
|
|
371
|
-
_GL=".claude/gate-log.tsv"
|
|
372
|
-
fi
|
|
373
|
-
fi
|
|
369
|
+
_GL=""; declare -F crew_gate_log_write >/dev/null && { crew_gate_log_write; _GL="$_CREW_GL"; }
|
|
374
370
|
if [ -n "$_GL" ]; then
|
|
375
371
|
if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
|
|
376
372
|
printf 'BLOCK\t§4.5\t%s\t%s\n' "$1" \
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreCompact hook — just before the context is compacted, write down the state of the work in a few lines, so the
|
|
3
|
+
# session that comes back after the compaction is told it (session-rehydrate.sh reads it on SessionStart, source
|
|
4
|
+
# compact). A compaction summary is the model's own retelling; this is what the files and the gates say.
|
|
5
|
+
#
|
|
6
|
+
# What it writes, at most ~2 KB: the branch, the uncommitted files (five named, the rest counted), the last refusal in
|
|
7
|
+
# the gate log, how many findings each document under .crewforth/ holds, and whether a handover
|
|
8
|
+
# (docs/SESSION_STATE.md) is there. With the session id and the trigger, so a snapshot is only read back into the
|
|
9
|
+
# session that wrote it.
|
|
10
|
+
#
|
|
11
|
+
# Where: inside the repository's git directory (crewforth-session-snapshot.json), so it is never committed even in a
|
|
12
|
+
# project that tracks .claude/; outside a repository, .claude/state/session-snapshot.json.
|
|
13
|
+
#
|
|
14
|
+
# NEVER BLOCKS. PreCompact exit 2 blocks the compaction (docs: hooks, PreCompact), and blocking an automatic one at
|
|
15
|
+
# the context limit fails the request that needed it. Every path here ends in exit 0, and nothing is printed: a
|
|
16
|
+
# PreCompact hook's output does not reach the summary.
|
|
17
|
+
set -u
|
|
18
|
+
IN="$(cat 2>/dev/null || true)"
|
|
19
|
+
_here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
|
|
20
|
+
LIB="$_here/../eval/lib"
|
|
21
|
+
|
|
22
|
+
ROOT="${CLAUDE_PROJECT_DIR:-}"
|
|
23
|
+
[ -n "$ROOT" ] || ROOT="$(printf '%s' "$IN" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
|
|
24
|
+
[ -n "$ROOT" ] || ROOT="$PWD"
|
|
25
|
+
ROOT="${ROOT//\\\\//}"; ROOT="${ROOT//\\//}"
|
|
26
|
+
cd "$ROOT" 2>/dev/null || exit 0
|
|
27
|
+
|
|
28
|
+
field(){ local v="${IN#*\"$1\"}"; [ "$v" = "$IN" ] && return 1; v="${v#*:}"; v="${v#"${v%%[![:space:]]*}"}"; case "$v" in \"*) v="${v#\"}"; printf '%s' "${v%%\"*}" ;; *) printf '%s' "${v%%[,\}]*}" ;; esac; }
|
|
29
|
+
SID="$(field session_id)" || SID=""
|
|
30
|
+
TRIG="$(field trigger)" || TRIG=""
|
|
31
|
+
case "$SID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac # a snapshot no session can claim is not written
|
|
32
|
+
|
|
33
|
+
# ---- what the files and the gates say ----
|
|
34
|
+
LINES=()
|
|
35
|
+
GD=""
|
|
36
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
37
|
+
GD="$(git rev-parse --absolute-git-dir 2>/dev/null)" || GD=""
|
|
38
|
+
BR="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || BR=""
|
|
39
|
+
[ -n "$BR" ] && LINES+=("branch $BR")
|
|
40
|
+
# quotePath off: a name in Turkish (or any non-ASCII) is given as written, not as octal escapes (seen on Windows).
|
|
41
|
+
CH="$(git -c core.quotePath=false status --porcelain 2>/dev/null)" || CH=""
|
|
42
|
+
if [ -n "$CH" ]; then
|
|
43
|
+
n=0; names=""
|
|
44
|
+
while IFS= read -r l; do n=$((n+1)); [ "$n" -le 5 ] && names="${names:+$names, }${l:3}"; done <<< "$CH"
|
|
45
|
+
LINES+=("$n uncommitted file(s): $names$([ "$n" -gt 5 ] && printf ', and %d more' $((n-5)))")
|
|
46
|
+
else LINES+=("nothing uncommitted"); fi
|
|
47
|
+
fi
|
|
48
|
+
if [ -f "$LIB/gate-log.sh" ]; then
|
|
49
|
+
. "$LIB/gate-log.sh"; crew_gate_log_read
|
|
50
|
+
if [ -n "${_CREW_GL:-}" ] && [ -f "$_CREW_GL" ]; then
|
|
51
|
+
last=""; while IFS= read -r l; do case "$l" in BLOCK$'\t'*) last="$l" ;; esac; done < "$_CREW_GL"
|
|
52
|
+
if [ -n "$last" ]; then IFS=$'\t' read -r _ rule why _ <<< "$last"; LINES+=("the last refusal in the gate log: ${rule} ${why:0:120}"); fi
|
|
53
|
+
fi
|
|
54
|
+
fi
|
|
55
|
+
fnd=""
|
|
56
|
+
for f in .crewforth/*.json; do
|
|
57
|
+
[ -f "$f" ] || continue
|
|
58
|
+
c=0; while IFS= read -r l; do case "$l" in *'"rule": '*) c=$((c+1)) ;; esac; done < "$f"
|
|
59
|
+
[ "$c" -gt 0 ] && { b="${f##*/}"; fnd="${fnd:+$fnd, }${b%.json} $c"; }
|
|
60
|
+
done
|
|
61
|
+
[ -n "$fnd" ] && LINES+=("findings on record: $fnd")
|
|
62
|
+
[ -s docs/SESSION_STATE.md ] && LINES+=("a handover is at docs/SESSION_STATE.md")
|
|
63
|
+
[ "${#LINES[@]}" -gt 0 ] || exit 0
|
|
64
|
+
|
|
65
|
+
SUM=""; for l in "${LINES[@]}"; do SUM="${SUM:+$SUM; }$l"; done
|
|
66
|
+
SUM="${SUM:0:1800}"
|
|
67
|
+
|
|
68
|
+
# ---- where it goes ----
|
|
69
|
+
if [ -n "$GD" ] && [ -d "$GD" ]; then OUT="$GD/crewforth-session-snapshot.json"
|
|
70
|
+
else mkdir -p .claude/state 2>/dev/null || exit 0; OUT=".claude/state/session-snapshot.json"; fi
|
|
71
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v NOW '%(%Y-%m-%dT%H:%M:%SZ)T' -1; else NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; fi
|
|
72
|
+
ESC="$(printf '%s\n' "$SUM" | LC_ALL=C awk -f "$LIB/json-string.awk" 2>/dev/null)" || exit 0
|
|
73
|
+
printf '{"session_id":"%s","trigger":"%s","at":"%s","summary":"%s"}\n' "$SID" "${TRIG//[!a-z]/}" "$NOW" "$ESC" > "$OUT.$$" 2>/dev/null && mv -f "$OUT.$$" "$OUT" 2>/dev/null
|
|
74
|
+
rm -f "$OUT.$$" 2>/dev/null
|
|
75
|
+
exit 0
|
|
@@ -74,7 +74,7 @@ export LC_ALL=C
|
|
|
74
74
|
_PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
|
|
75
75
|
if [ "$_PA_LIB" = 0 ]; then
|
|
76
76
|
IFS= read -r -d '' INPUT || true
|
|
77
|
-
case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*|*'"hook_event_name"'*'"AskUserQuestion"'*|*'"AskUserQuestion"'*'"hook_event_name"'*) ;; *) exit 0 ;; esac
|
|
77
|
+
case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*|*'"hook_event_name"'*'"AskUserQuestion"'*|*'"AskUserQuestion"'*'"hook_event_name"'*|*'"hook_event_name"'*'"Stop"'*) ;; *) exit 0 ;; esac
|
|
78
78
|
fi
|
|
79
79
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
80
80
|
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
@@ -426,6 +426,19 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pe
|
|
|
426
426
|
# same four answers and what each one does. guard-bash.sh prints it from here for the model to ask.
|
|
427
427
|
_CREW_Q_TEXT='Crewforth approval: may Claude commit what is staged now, push this branch, or both?'
|
|
428
428
|
_CREW_Q_JSON='{"question":"'"$_CREW_Q_TEXT"'","header":"Approval","options":[{"label":"Commit only","description":"Commit exactly what is staged now. No push."},{"label":"Commit + push","description":"Commit what is staged now, then push that commit on this branch."},{"label":"Push only","description":"Push this branch as it is now. No new commit."},{"label":"No","description":"Neither commit nor push."}],"multiSelect":false}'
|
|
429
|
+
# THE ANSWERED QUESTION, IN ANY KEY ORDER. Claude Code hands PostToolUse the question back either as it was asked or
|
|
430
|
+
# re-serialised with its keys sorted (`header,options[description,label],question`, sometimes without multiSelect):
|
|
431
|
+
# 30 of 30 answers in the asked order were recorded and 7 of 7 sorted ones refused, on a byte match (real sessions,
|
|
432
|
+
# 2.1.299 and 2.1.302, 2026-10-18). So the answer side checks every "key":"text" pair of the fixed question, wherever
|
|
433
|
+
# it sits; the exact match stays on PreToolUse, and the tool id binds the answer to the call that passed it.
|
|
434
|
+
_crew_q_is(){ # $1 = a payload -> 0 when it carries every "key":"text" pair of _CREW_Q_JSON
|
|
435
|
+
local rest="$_CREW_Q_JSON" re='("[A-Za-z]+":"[^"]*")' p
|
|
436
|
+
while [[ $rest =~ $re ]]; do
|
|
437
|
+
p="${BASH_REMATCH[1]}"
|
|
438
|
+
case "$1" in *"$p"*) ;; *) return 1 ;; esac
|
|
439
|
+
rest="${rest#*"$p"}"
|
|
440
|
+
done
|
|
441
|
+
}
|
|
429
442
|
_crew_q_op(){ # $1 = the label the user chose -> OP: commit | commit+push | push | no, or "" for anything else
|
|
430
443
|
case "$1" in
|
|
431
444
|
'Commit only') OP=commit ;; 'Commit + push') OP=commit+push ;; 'Push only') OP=push ;; 'No') OP=no ;; *) OP="" ;;
|
|
@@ -514,11 +527,36 @@ _crew_prh_scan(){ # -> 0 when a PermissionRequest hook may be wired; _PRH names
|
|
|
514
527
|
return "$r"
|
|
515
528
|
}
|
|
516
529
|
|
|
530
|
+
# ---- IS THE QUESTION OPEN HERE? One answer for every hook that offers it ------------------------------------------
|
|
531
|
+
# guard-bash.sh offers it in a refusal; agent-outcome.sh offers it when crew-commit-agent is called in `auto`, so the
|
|
532
|
+
# click is on offer before any commit is tried (B-2, a real session: with the refusal as the only way to it, a session
|
|
533
|
+
# that asked for /crew-approve first never showed it). The text the user is asked stays _CREW_Q_JSON, here only.
|
|
534
|
+
_CREW_Q_HERE="${BASH_SOURCE[0]%/*}"; [ "$_CREW_Q_HERE" = "${BASH_SOURCE[0]}" ] && _CREW_Q_HERE=.
|
|
535
|
+
_crew_q_wired(){ # 0 = a settings file wires both halves on AskUserQuestion (guard-question.sh, then this file)
|
|
536
|
+
local f t d="${CLAUDE_PROJECT_DIR:-.}"
|
|
537
|
+
d="${d//\\//}"; d="${d%/}"
|
|
538
|
+
for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
|
|
539
|
+
[ -n "$f" ] && [ -f "$f" ] || continue
|
|
540
|
+
t=""; IFS= read -r -d '' t < "$f" || true
|
|
541
|
+
case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
|
|
542
|
+
done
|
|
543
|
+
return 1
|
|
544
|
+
}
|
|
545
|
+
_crew_q_open(){ # $1 = permission mode -> 0 when the click may be offered: auto, both halves wired and beside this file, no PermissionRequest hook
|
|
546
|
+
[ "$1" = auto ] && [ -f "$_CREW_Q_HERE/guard-question.sh" ] && _crew_q_wired && ! _crew_prh_scan
|
|
547
|
+
}
|
|
548
|
+
# What the session is told to do, the same words wherever it is offered.
|
|
549
|
+
_CREW_Q_ASK='ASK THE USER WITH ONE CLICK (stage first: the approval covers what is staged at that moment): show the commit message whole, in ONE code block, then call AskUserQuestion with exactly this input, nothing added or changed:'
|
|
550
|
+
|
|
517
551
|
# ---- the approval question's context, and its PreToolUse half -----------------------------------------------
|
|
518
552
|
# Shared by prompt-approval.sh (PostToolUse: it records the answer) and guard-question.sh (PreToolUse: it asks). The
|
|
519
553
|
# PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
|
|
520
554
|
# instead of letting the question through with no `ask`; this file also answers UserPromptSubmit, where a refusal
|
|
521
555
|
# would erase the user's message.
|
|
556
|
+
_crew_q_dir(){ # $1 = cwd -> _QAD (the state folder), ASKED (the call PreToolUse let through), TRIED (asked since the user's last message)
|
|
557
|
+
_QAD="${CLAUDE_PROJECT_DIR:-$1}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"
|
|
558
|
+
ASKED="$_QAD/crew-approval-asked"; TRIED="$_QAD/crew-approval-tried"
|
|
559
|
+
}
|
|
522
560
|
_crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no decision) when the call is not the approval question
|
|
523
561
|
_json_keycount "$INPUT" hook_event_name; [ "$_KC" = 1 ] || exit 0
|
|
524
562
|
_json_keycount "$INPUT" tool_name; [ "$_KC" = 1 ] || exit 0
|
|
@@ -531,11 +569,14 @@ _crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no de
|
|
|
531
569
|
_json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
|
|
532
570
|
_json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
|
|
533
571
|
_json_slice "$INPUT" tool_use_id >/dev/null; _QTU="$_JS"
|
|
534
|
-
|
|
572
|
+
_crew_q_dir "$CWD"
|
|
535
573
|
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _QNOW '%(%s)T' -1; else _QNOW="$(date +%s)"; fi
|
|
536
574
|
}
|
|
537
575
|
_crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that says why
|
|
538
576
|
_crew_q_ctx
|
|
577
|
+
# Asked at all, whatever the decision below: a question refused here, or dismissed by the user, leaves the typed
|
|
578
|
+
# way as the one that is left, and the Stop half (_pa_stop) must not send the session back to ask it again.
|
|
579
|
+
[ -n "$SID" ] && [ -d "$_QAD" ] && { printf 'sid=%s\n' "$SID" > "$TRIED"; } 2>/dev/null
|
|
539
580
|
qno(){ # $1 = why, for the model. The call does not run; where it applies, the typed approval is named as the way left.
|
|
540
581
|
echo "GUARD (§4.4): the approval question was not asked: $1" >&2
|
|
541
582
|
[ "${2:-}" = nohint ] || echo "The way that always works is the user's own: the user can type /crew-approve (or /crew-approve commit, push, commit+push) as their whole message." >&2
|
|
@@ -666,9 +707,9 @@ _pa_record(){
|
|
|
666
707
|
commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
|
|
667
708
|
HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
|
|
668
709
|
push) WHAT="push of $BR at $_h to $RM"
|
|
669
|
-
HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
|
|
710
|
+
HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained). A tag on that commit goes the same way: git push $RM <tag>." ;;
|
|
670
711
|
commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
|
|
671
|
-
HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
|
|
712
|
+
HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR (a tag on the new commit: git push $RM <tag>)" ;;
|
|
672
713
|
esac
|
|
673
714
|
[ -n "$_u" ] && _u=" ($RM pushes to $_u)"
|
|
674
715
|
_bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
|
|
@@ -693,7 +734,7 @@ _pa_question(){
|
|
|
693
734
|
: > "$ASKED" 2>/dev/null # one question, one answer
|
|
694
735
|
case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
|
|
695
736
|
[ $((_QNOW - _at)) -ge 0 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
|
|
696
|
-
|
|
737
|
+
_crew_q_is "$INPUT" || no "the question answered is not the fixed one."
|
|
697
738
|
[ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
|
|
698
739
|
_crew_prh_scan && no "a PermissionRequest hook may be wired ($_PRH), and it can answer the question before you do. The approval that still works is typed: the user can type /crew-approve instead."
|
|
699
740
|
_json_keycount "$INPUT" "$_CREW_Q_TEXT"; _an=$_KC
|
|
@@ -711,6 +752,32 @@ _pa_question(){
|
|
|
711
752
|
BARE=0
|
|
712
753
|
_pa_record
|
|
713
754
|
}
|
|
755
|
+
# ---- THE CLICK BEFORE THE TYPED WAY (Stop) -------------------------------------------------------------------
|
|
756
|
+
# The click reached the user only from a refused commit or a call to crew-commit-agent, and a session that wrote the
|
|
757
|
+
# message itself asked the user to type /crew-approve without trying either: 9 of 18 typed asks in two real `auto`
|
|
758
|
+
# sessions on rc.4 (2026-10-18) came in a turn where no commit was tried, most right after crew-review-agent. So the
|
|
759
|
+
# turn's end is where it is caught: the reply names /crew-approve, the question is open, and it was not asked since
|
|
760
|
+
# the user's last message. Then the session is told, once, to ask the click; it never blocks, and stop_hook_active
|
|
761
|
+
# lets the turn end the next time whatever was said.
|
|
762
|
+
_pa_stop(){
|
|
763
|
+
case "$INPUT" in *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) exit 0 ;; esac
|
|
764
|
+
_json_slice "$INPUT" permission_mode >/dev/null; [ "$_JS" = auto ] || exit 0
|
|
765
|
+
_json_slice "$INPUT" last_assistant_message >/dev/null
|
|
766
|
+
case "$_JS" in *'/crew-approve'*) ;; *) exit 0 ;; esac
|
|
767
|
+
_json_slice "$INPUT" session_id >/dev/null; SID="$_JS"; [ -n "$SID" ] || exit 0
|
|
768
|
+
_json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; _crew_q_dir "${_JU:-.}"
|
|
769
|
+
_crew_q_open auto || exit 0
|
|
770
|
+
_al=""; [ -f "$TRIED" ] && { IFS= read -r _al < "$TRIED"; } 2>/dev/null
|
|
771
|
+
[ "$_al" = "sid=$SID" ] && exit 0
|
|
772
|
+
_ao="Crewforth: your reply asks for a typed approval, and the one-click approval question was not asked since the user's last message. If that is a commit or a push waiting for approval, ask with one click now instead: $_CREW_Q_ASK {\"questions\":[$_CREW_Q_JSON]} Their answer is recorded by Crewforth itself (the line it adds says so). If the question is refused or dismissed, the user can type /crew-approve instead. If you only mentioned the command, end your turn."
|
|
773
|
+
_ao="${_ao//\\/\\\\}"; _ao="${_ao//\"/\\\"}" # fixed ASCII text on one line: a backslash and a quote are all it holds
|
|
774
|
+
printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"%s"}}\n' "$_ao"
|
|
775
|
+
exit 0
|
|
776
|
+
}
|
|
777
|
+
_json_keycount "$INPUT" hook_event_name
|
|
778
|
+
if [ "$_KC" = 1 ]; then _json_slice "$INPUT" hook_event_name >/dev/null; [ "$_JS" = Stop ] && _pa_stop; fi
|
|
779
|
+
case "$INPUT" in *'"hook_event_name"'*'"Stop"'*) exit 0 ;; esac # a Stop is never a prompt: it must not end an approval below
|
|
780
|
+
|
|
714
781
|
case "$INPUT" in *'"AskUserQuestion"'*)
|
|
715
782
|
_json_slice "$INPUT" hook_event_name >/dev/null
|
|
716
783
|
case "$_JS" in PostToolUse) EVN="$_JS"; _pa_question ;; esac ;;
|
|
@@ -745,6 +812,8 @@ _crew_appr_path "$CWD"; REC="$_AP"
|
|
|
745
812
|
# THE USER'S NEXT MESSAGE ENDS THE APPROVAL, whatever it says. Emptied with a redirection, which is a builtin:
|
|
746
813
|
# `rm` would be a process on a hook that runs on every prompt.
|
|
747
814
|
if [ -n "$REC" ] && [ -s "$REC" ]; then : > "$REC" 2>/dev/null || true; fi
|
|
815
|
+
# ...and starts a new turn for the Stop half: the question has not been asked since this message.
|
|
816
|
+
_crew_q_dir "$CWD"; [ -s "$TRIED" ] && { : > "$TRIED"; } 2>/dev/null
|
|
748
817
|
|
|
749
818
|
# ---- is this message an approval? ------------------------------------------------------------------------
|
|
750
819
|
[ "$_n_prompt" = 1 ] || exit 0
|
|
@@ -68,11 +68,34 @@ if [ -f "$ROOT/.claude/hooks/pre-commit" ] && git -C "$ROOT" rev-parse --git-dir
|
|
|
68
68
|
esac
|
|
69
69
|
fi
|
|
70
70
|
[ -n "$HOOKMSG" ] && MSG="${MSG:+$MSG }$HOOKMSG"
|
|
71
|
-
|
|
71
|
+
|
|
72
|
+
# THE STATE BEFORE THE COMPACTION. precompact-snapshot.sh wrote it just before this context was compacted: the
|
|
73
|
+
# branch, the uncommitted files, the last gate refusal, the findings on record. Read back only after a compaction and
|
|
74
|
+
# only into the session that wrote it (same session id); its summary is already JSON-escaped, so it is put into the
|
|
75
|
+
# output as it is, and the rest of MSG stays fixed text that needs no escaping and no extra process.
|
|
76
|
+
SNAP=""
|
|
77
|
+
_rh="${BASH_SOURCE%[/\\]*}"; [ "$_rh" = "${BASH_SOURCE}" ] && _rh=.
|
|
78
|
+
if [ "$SRC" = compact ] && [ -f "$_rh/../eval/lib/gate-log.sh" ]; then
|
|
79
|
+
. "$_rh/../eval/lib/gate-log.sh"
|
|
80
|
+
_sf=""
|
|
81
|
+
if crew_git_dir "$ROOT" && [ -f "$_CREW_GD/crewforth-session-snapshot.json" ]; then _sf="$_CREW_GD/crewforth-session-snapshot.json"
|
|
82
|
+
elif [ -f "$ROOT/.claude/state/session-snapshot.json" ]; then _sf="$ROOT/.claude/state/session-snapshot.json"; fi
|
|
83
|
+
_sid(){ _SID=""; local v="${1#*\"session_id\"}"; [ "$v" = "$1" ] && return 0; v="${v#*\"}"; _SID="${v%%\"*}"; }
|
|
84
|
+
if [ -n "$_sf" ] && IFS= read -r _sj < "$_sf" 2>/dev/null; then
|
|
85
|
+
_sid "$IN"; _mine="$_SID"; _sid "$_sj"
|
|
86
|
+
case "$_sj" in *'"summary":"'*)
|
|
87
|
+
if [ -n "$_mine" ] && [ "$_mine" = "$_SID" ]; then
|
|
88
|
+
_sum="${_sj#*\"summary\":\"}"; _sum="${_sum%\"\}*}"
|
|
89
|
+
SNAP="Before this context was compacted, Crewforth wrote down the state of the work from the files and the gates: $_sum. It is a record of that moment; check the files before relying on it."
|
|
90
|
+
fi ;;
|
|
91
|
+
esac
|
|
92
|
+
fi
|
|
93
|
+
fi
|
|
94
|
+
[ -n "$MSG$SNAP" ] || exit 0 # nothing to offer -> stay silent
|
|
72
95
|
|
|
73
96
|
# hookSpecificOutput.additionalContext is the documented channel that injects text into the model's context.
|
|
74
97
|
# ONE PATH, no jq: MSG is built from fixed constants with no quote, backslash, control character or newline, so it needs no
|
|
75
98
|
# escaping and every machine emits the same bytes. If you ever make MSG dynamic, escape it the way board-sync.sh
|
|
76
99
|
# does (jq-identical, measured) — a raw quote or tab here makes JSON the CLI silently drops.
|
|
77
|
-
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$MSG"
|
|
100
|
+
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s%s%s"}}\n' "$MSG" "${MSG:+${SNAP:+ }}" "$SNAP"
|
|
78
101
|
exit 0
|
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
set -uo pipefail
|
|
45
45
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
46
46
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
47
|
-
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
47
|
+
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
48
|
+
# How an install's own version is read and ordered (an rc before its final): one definition, eval/lib/version.sh.
|
|
49
|
+
[ -f "$_crew_d/../eval/lib/version.sh" ] && . "$_crew_d/../eval/lib/version.sh"; unset _crew_d
|
|
48
50
|
|
|
49
51
|
URL="${CREW_UPDATE_URL:-https://registry.npmjs.org/-/package/crewforth/dist-tags}"
|
|
50
52
|
# plugin-stable, not main: the marketplace installs the plugin from that branch, and only an approved release
|
|
@@ -139,7 +141,10 @@ PJSON="$PR_ROOT/.claude-plugin/plugin.json"
|
|
|
139
141
|
# plugin's copy steps aside whenever a project install is present, instead of asking the project's question too.
|
|
140
142
|
if [ "$IS_PLUGIN" = 1 ] && [ -f "$CL/VERSION" ]; then exit 0; fi
|
|
141
143
|
if [ -f "$CL/VERSION" ]; then
|
|
142
|
-
|
|
144
|
+
# The install's own file, so an rc (X.Y.Z-rc.N) is a version here; what npm answers is still held to X.Y.Z.
|
|
145
|
+
read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""
|
|
146
|
+
declare -F crew_local_version >/dev/null || exit 0
|
|
147
|
+
CUR="$(crew_local_version "$_cv")" || exit 0
|
|
143
148
|
STATE="$CL/.state" # repo-local: the install lives in the repo
|
|
144
149
|
FEED="${CREW_UPDATE_URL:-$URL}"; KEY=latest; EDITION=project
|
|
145
150
|
ANSWER='bash .claude/hooks/session-update-check.sh --answer'
|
|
@@ -186,12 +191,8 @@ fi
|
|
|
186
191
|
[ -n "$LATEST" ] || exit 0
|
|
187
192
|
LATEST="$(sane_version "$LATEST")" || exit 0
|
|
188
193
|
|
|
189
|
-
#
|
|
190
|
-
|
|
191
|
-
awk -v a="$LATEST" -v b="$CUR" 'BEGIN{
|
|
192
|
-
split(a,x,"."); split(b,y,".");
|
|
193
|
-
for(i=1;i<=3;i++){ if(x[i]+0 > y[i]+0) exit 0; if(x[i]+0 < y[i]+0) exit 1 }
|
|
194
|
-
exit 1 }' || exit 0
|
|
194
|
+
# One awk, eval/lib/version.sh's order: 3.2.0 is newer than an installed 3.2.0-rc.1, so the rc's user is asked.
|
|
195
|
+
awk -v a="$LATEST" -v b="$CUR" "${CREW_VCMP_AWK:-}"' BEGIN { exit !(vcmp(a, b) > 0) }' 2>/dev/null || exit 0
|
|
195
196
|
|
|
196
197
|
# Skipped for good? Asked (or answered "later") within the last day?
|
|
197
198
|
if [ -f "$STATE/update-skip" ]; then read -r _sk < "$STATE/update-skip" 2>/dev/null || _sk=""; [ "${_sk//[!0-9.]/}" = "$LATEST" ] && exit 0; fi
|
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
Co-Authored-By
|
|
14
14
|
#test: Co-Authored-By: An Assistant <noreply@example.com>
|
|
15
|
+
# The session-link trailer Claude Code asks the model to end a commit message with: it reached six pushed commits of a
|
|
16
|
+
# project with Crewforth installed (2026-10-18), because nothing here named it. The trailer and the address are both
|
|
17
|
+
# caught, so a message that keeps only one of them is still stopped. One line, not two: each line is one grep on every
|
|
18
|
+
# commit (a process, 62-135 ms on Git Bash).
|
|
19
|
+
Claude-Session:|claude\.ai/code/session_
|
|
20
|
+
#test: Claude-Session: https://claude.ai/code/session_0123456789abcdef
|
|
21
|
+
#test: see https://claude.ai/code/session_0123456789abcdef for the run
|
|
22
|
+
#test-clean: the claude session limit is reached
|
|
23
|
+
#test-clean: https://claude.ai/code is the web app
|
|
15
24
|
# "Generated by/with" alone matched every machine-written header in existence — a pubspec.lock, an EF Core
|
|
16
25
|
# scaffold, protoc, openapi-generator. A live --dotnet install could not make its FIRST commit because the
|
|
17
26
|
# cloned base carries a Dart lockfile saying "Generated by pub". §4.1 is about AI AUTHORSHIP, so the pattern
|