crewforth 3.2.0-rc.2 → 3.2.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +118 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +10 -1
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +3 -3
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +13 -2
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-json.awk +4 -0
  28. package/kit/eval/lib/statusline.sh +1 -1
  29. package/kit/eval/lib/verify-cmd.sh +17 -0
  30. package/kit/eval/review-bench.sh +26 -0
  31. package/kit/eval/smoke-test.sh +208 -21
  32. package/kit/hooks/agent-outcome.sh +18 -0
  33. package/kit/hooks/board-sync.sh +2 -23
  34. package/kit/hooks/guard-agent-model.sh +72 -26
  35. package/kit/hooks/guard-bash.sh +49 -34
  36. package/kit/hooks/guard-write.sh +3 -7
  37. package/kit/hooks/precompact-snapshot.sh +75 -0
  38. package/kit/hooks/prompt-approval.sh +21 -0
  39. package/kit/hooks/session-rehydrate.sh +25 -2
  40. package/kit/hooks/trace-blocklist.txt +9 -0
  41. package/kit/settings.json +12 -0
  42. package/kit/skills/crew-code-review/SKILL.md +16 -0
  43. package/kit/skills/crew-map/SKILL.md +16 -5
  44. package/kit/skills/crew-review/SKILL.md +2 -1
  45. package/kit/skills/crew-ship/SKILL.md +3 -1
  46. package/kit/studio/README.md +21 -10
  47. package/kit/studio/server/index.js +74 -22
  48. package/kit/studio/server/lib/graph.js +9 -1
  49. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  50. package/kit/studio/server/lib/lan.js +87 -0
  51. package/kit/studio/server/lib/redact.js +60 -0
  52. package/kit/studio/server/lib/remote.js +104 -18
  53. package/kit/studio/server/lib/session.js +1 -1
  54. package/kit/studio/server/lib/terminal.js +51 -13
  55. package/kit/studio/server/lib/transcript.js +32 -0
  56. package/kit/studio/web/app.js +69 -7
  57. package/kit/studio/web/canvas.js +14 -2
  58. package/kit/studio/web/chat.js +32 -7
  59. package/kit/studio/web/graph-plan.js +3 -2
  60. package/kit/studio/web/map-plan.js +72 -3
  61. package/kit/studio/web/map.js +103 -10
  62. package/kit/studio/web/qr.js +200 -0
  63. package/kit/studio/web/style.css +34 -0
  64. package/kit/studio/web/tokens.css +6 -0
  65. package/package.json +1 -1
  66. package/start.sh +14 -1
@@ -14,10 +14,15 @@
14
14
  # * RISK CLASS. `critical` when the change is migration, security, architecture or fix-unknown, or a file is on a
15
15
  # critical path (auth, payments, billing, migrations, security, crypto, secrets, *.sql, schema.prisma;
16
16
  # .claude/crew-model-rules adds and removes paths). Otherwise `normal`.
17
- # * critical work runs on opus, whatever the agent. The one exception is test-run: running the tests is objective.
17
+ # * critical work runs on opus, whatever the agent. Two exceptions: test-run (running the tests is objective), and
18
+ # a text change whose every file is prose (.md .txt .rst .adoc), which runs on sonnet or above: a reworded
19
+ # paragraph under legal/ is read by a person, and opus for it is the cost this gate exists to stop. A project
20
+ # that wants opus there says so in .claude/crew-model-rules (floor * text critical opus).
18
21
  # * the referee is as strong as the risk: test-write and audit on sonnet or above (opus when critical, by the rule
19
22
  # above); review, privacy, planner and database agents on sonnet or above; the security agent on opus.
20
- # * work with no verify command runs on sonnet or above: haiku goes only to work a command can check.
23
+ # * work with no verify command runs on sonnet or above: haiku goes only to work a command can check. Not for
24
+ # change: text, nor for the commit agent: a reader is all prose ever has, and a commit message is checked by
25
+ # the commit-msg hook (seen on a real project: a commit message proposed on sonnet for that reason alone).
21
26
  # * a class that failed too often is held one model up (crew-model-floors.auto, written by hooks/agent-outcome.sh),
22
27
  # until the user lowers it again with /crew-loosen (one model, once for each raise).
23
28
  # * a card whose verify failed is not run again on the same or a lower model, and not a third time at all.
@@ -412,13 +417,58 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
412
417
  return "$hit"
413
418
  }
414
419
 
420
+ _am_prose(){ # $1 = a path or a glob -> 0 when it is prose a person reads (the exception for a text change)
421
+ case "$1" in *.md|*.MD|*.markdown|*.txt|*.TXT|*.rst|*.adoc) return 0 ;; esac
422
+ return 1
423
+ }
424
+ _am_all_prose(){ # after _am_card -> 0 when every file of the card is prose
425
+ local f r=0 unglob=0
426
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
427
+ for f in ${CARD_FILES//,/ }; do _am_prose "$f" || { r=1; break; }; done
428
+ [ "$unglob" = 1 ] && set +f
429
+ return "$r"
430
+ }
431
+
415
432
  # ---- THE CARD ---------------------------------------------------------------------------------------------------
416
433
  _AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
434
+ _am_trim(){ # $1 -> _AMT: no white space around it, and no separator or full stop left at its end (one-line cards)
435
+ local v="$1" o
436
+ while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
437
+ while :; do o="$v"
438
+ case "$v" in *[$' \t']) v="${v%?}" ;; *'·') v="${v%'·'}" ;; *'—') v="${v%'—'}" ;; *'–') v="${v%'–'}" ;; *[\|\;,.]) v="${v%?}" ;; esac
439
+ [ "$v" = "$o" ] && break
440
+ done
441
+ _AMT="$v"
442
+ }
443
+ # THE CARD ON ONE LINE. Sessions write it as `files: … · change: … · verify: …` (measured on this machine's transcripts,
444
+ # 2026-10-18: 17 of the 39 calls refused for their card, in 6 projects; the next call was the same card on three lines).
445
+ # It is read by its labels, in that order, after an optional `Card:` / `Card —`; `above: <why>` may close the line. The
446
+ # fields are then held to exactly the rules of the three-line card below, so nothing passes here that would not there.
447
+ _am_card_line(){ # $1 = the first line of the task -> 0 and CARD_* set (unchecked); 1 when it is not a one-line card
448
+ local l="$1" r f c v a=""
449
+ case "$l" in [Cc]ard*) l="${l#[Cc]ard}"; l="${l#\*\*}"
450
+ while :; do case "$l" in [$' \t:*']*|'—'*|'–'*|-*) case "$l" in '—'*) l="${l#'—'}" ;; '–'*) l="${l#'–'}" ;; *) l="${l:1}" ;; esac ;; *) break ;; esac; done ;;
451
+ esac
452
+ case "$l" in files:*) ;; *) return 1 ;; esac
453
+ r="${l#files:}"
454
+ case "$r" in *change:*verify:*) ;; *) return 1 ;; esac
455
+ f="${r%%change:*}"; r="${r#*change:}"; c="${r%%verify:*}"; v="${r#*verify:}"
456
+ case "$v" in *above:*) a="${v#*above:}"; v="${v%%above:*}" ;; esac
457
+ _am_trim "$f"; CARD_FILES="$_AMT"; _am_trim "$c"; CARD_CHANGE="$_AMT"; _am_trim "$v"; CARD_VERIFY="$_AMT"
458
+ [ -n "$a" ] && { _am_trim "$a"; CARD_ABOVE="${_AMT:0:200}"; }
459
+ return 0
460
+ }
417
461
  _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
418
- local s="$1" l k v n=0 seen=""
462
+ local s="$1" l k v n=0 seen="" one=0
419
463
  CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""; CARD_ABOVE=""
420
464
  s="${s//$'\r'/}"
421
- while [ "$n" -lt 3 ] && [ -n "$s" ]; do
465
+ # One line first: the first line that is not empty, read by its labels; the three-line reading otherwise.
466
+ l="$s"; while :; do case "$l" in [$' \t\n']*) l="${l:1}" ;; *) break ;; esac; done; l="${l%%$'\n'*}"
467
+ if _am_card_line "$l"; then
468
+ one=1; n=3
469
+ s="${s#*"$l"}"; s="${s#$'\n'}"
470
+ fi
471
+ while [ "$one" = 0 ] && [ "$n" -lt 3 ] && [ -n "$s" ]; do
422
472
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
423
473
  while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
424
474
  [ -n "$l" ] || continue
@@ -438,12 +488,16 @@ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (C
438
488
  done
439
489
  [ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
440
490
  [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
491
+ # A note in parentheses after the kind is the session's own comment, as `none (…)` is for verify: `architecture
492
+ # (planning only)` is architecture (4 of the cards refused on this machine, 2026-10-18). Only a kind on the list, and
493
+ # only a note that is the whole rest of the value in parentheses.
494
+ case "$CARD_CHANGE" in *' ('*')') v="${CARD_CHANGE%% (*}"; case "$_AM_CHANGES" in *" $v "*) CARD_CHANGE="$v" ;; esac ;; esac
441
495
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
442
496
  [ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
443
497
  case "$CARD_VERIFY" in none|None|NONE|-|'none '*|'none('*|'None '*|'NONE '*) CARD_VERIFY=none ;; esac # "none (the author ran it)" is none
444
498
  # Optional, right after the three: `above: <why>` keeps a model above the card's floor. Without it a call above
445
499
  # the floor is run on the floor (the cost of a model nobody needed is what this gate exists to stop).
446
- while [ -n "$s" ]; do
500
+ while [ -z "$CARD_ABOVE" ] && [ -n "$s" ]; do
447
501
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
448
502
  while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
449
503
  [ -n "$l" ] || continue
@@ -557,13 +611,10 @@ _am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor,
557
611
 
558
612
  # ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
559
613
  _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog; the call's text is never recorded.
560
- local gl="${CREW_GATE_LOG:-}" v=BLOCK
614
+ local gl="" v=BLOCK
561
615
  case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
562
- if [ -z "$gl" ]; then
563
- [ -d ".claude" ] || return 0
564
- if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0; fi
565
- gl=".claude/gate-log.tsv"
566
- fi
616
+ declare -F crew_gate_log_write >/dev/null || return 0
617
+ crew_gate_log_write; gl="$_CREW_GL"; [ -n "$gl" ] || return 0
567
618
  printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
568
619
  }
569
620
 
@@ -594,6 +645,7 @@ _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog
594
645
  # 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
595
646
  # explicit allow passes.
596
647
  _AM_HERE="${BASH_SOURCE%/*}"; [ "$_AM_HERE" = "${BASH_SOURCE}" ] && _AM_HERE=.
648
+ [ -f "$_AM_HERE/../eval/lib/gate-log.sh" ] && . "$_AM_HERE/../eval/lib/gate-log.sh" # where the gate log goes
597
649
  # CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
598
650
  _AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
599
651
  'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
@@ -665,19 +717,9 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
665
717
  done <<< "$_AMSF"
666
718
  return 1
667
719
  }
668
- # A verify command in a sub-folder: `cd apps/mobile && flutter test`, the usual shape in a repository of several
669
- # projects (measured on one: 41 of 71 refused verify commands had it). Split into the folder and the one command,
670
- # so the command is judged alone and run in that folder with no chain. Only a relative folder inside the project,
671
- # with no .. step, that exists; anything else is left whole, and is refused as a chain as before.
672
- _am_verify_split(){ # $1 = the command -> _AMVD the folder ("" = the project root), _AMVC the command
673
- local c="$1" d r
674
- _AMVD=""; _AMVC="$c"
675
- case "$c" in 'cd '*' && '*) ;; *) return 0 ;; esac
676
- d="${c#cd }"; d="${d%% && *}"; r="${c#cd "$d" && }"
677
- case "$d" in ''|/*|~*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 0 ;; esac
678
- [ -n "$r" ] && [ -d "${_AMP:-.}/$d" ] || return 0
679
- _AMVD="$d"; _AMVC="$r"
680
- }
720
+ # How a verify command is read (the `cd <folder> && <command>` split): eval/lib/verify-cmd.sh, shared with doctor.
721
+ [ -f "$_AM_HERE/../eval/lib/verify-cmd.sh" ] && . "$_AM_HERE/../eval/lib/verify-cmd.sh"
722
+ _am_verify_split(){ _AMVD=""; _AMVC="$1"; declare -F crew_verify_split >/dev/null && crew_verify_split "$1" "${_AMP:-.}"; return 0; }
681
723
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
682
724
  local c="$1" pm="${2:-default}" f r rc k g
683
725
  _AMVW=""; _AMVB=""
@@ -759,6 +801,8 @@ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> ret
759
801
  if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
760
802
  fi
761
803
  case "$tier" in opus|fable) return 0 ;; esac
804
+ # A text change on sonnet may write prose there: the call was let through on that floor for exactly that.
805
+ if [ "$tier" = sonnet ] && _am_prose "$_AMRP" && _am_rec_get "$_AMD/agents/$aid" change && [ "$_AMV" = text ]; then return 0; fi
762
806
  _am_log "critical path: write by an agent not on opus"
763
807
  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
764
808
  exit 2
@@ -832,9 +876,11 @@ if ! _am_card "$prompt"; then
832
876
  fi
833
877
  _am_state; _am_risk; _am_digest
834
878
  # ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
835
- if [ "$CARD_VERIFY" = none ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
879
+ 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
836
880
  case "$CARD_CHANGE" in test-write|audit) if [ "$floor" -lt 2 ]; then floor=2; why="change: $CARD_CHANGE, the referee of the work"; fi ;; esac
837
- if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
881
+ if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" = text ] && _am_all_prose; then
882
+ if [ "$floor" -lt 2 ]; then floor=2; why="a text change on a critical path: $CARD_RISKWHY"; fi
883
+ elif [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
838
884
  # A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
839
885
  _am_rules
840
886
  for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
@@ -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"; unset _crew_d
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 default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
172
- # DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
173
- # the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
174
- # edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
175
- # a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
176
- # runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
177
- # never reach it.
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
- if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
180
- [ -d ".claude" ] || return 0
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
@@ -2518,16 +2524,7 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
2518
2524
  }
2519
2525
  # IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
2520
2526
  # 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
- }
2527
+ _q_wired(){ declare -F _crew_q_wired >/dev/null 2>&1 && _crew_q_wired; } # defined once, in prompt-approval.sh
2531
2528
  # MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
2532
2529
  # from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
2533
2530
  # asks as it always has.
@@ -2597,12 +2594,21 @@ _gate_written(){
2597
2594
  { printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
2598
2595
  fi
2599
2596
  fi
2597
+ # ONE WAY TO ASK. With the question open, the refusal used to print it and then the typed way as an equal choice,
2598
+ # 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
2599
+ # to type a command for what one click does. The typed way is now the fallback, said as one.
2600
+ _QOFF=0
2600
2601
  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
- echo "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:" >&2
2602
+ _QOFF=1
2603
+ echo "$_CREW_Q_ASK" >&2
2602
2604
  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. If they would rather type, the way below works as well." >&2
2605
+ 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
2606
+ fi
2607
+ if [ "$_QOFF" = 1 ]; then
2608
+ 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
2609
+ else
2610
+ 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
2611
  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
2612
  echo " 1. the commit message, whole, in ONE code block;" >&2
2607
2613
  if [ -n "$_pw" ]; then
2608
2614
  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 +3348,26 @@ if git_has "$CMD_SEEN" 'commit|push'; then
3342
3348
  BRANCH_WARN=""
3343
3349
  # symbolic-ref names the branch even before its first commit, where rev-parse answers "HEAD" and the very
3344
3350
  # first commit onto main went by without this line. A detached HEAD reads empty: not the default branch.
3345
- case "$(git symbolic-ref --short -q HEAD 2>/dev/null)" in
3351
+ # Read from HEAD with builtins (the gate log's crew_git_dir finds the git dir, a linked worktree's too): the
3352
+ # preview below pays one subshell to keep this hook's decision out of its reach, and this pays it back.
3353
+ _hb=""; if declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && [ -f "$_CREW_GD/HEAD" ]; then
3354
+ IFS= read -r _hb < "$_CREW_GD/HEAD"; _hb="${_hb%$'\r'}"; case "$_hb" in "ref: refs/heads/"*) _hb="${_hb#ref: refs/heads/}" ;; *) _hb="" ;; esac
3355
+ else _hb="$(git symbolic-ref --short -q HEAD 2>/dev/null)"; fi
3356
+ case "$_hb" in
3346
3357
  main|master) BRANCH_WARN="⚠️ This commits DIRECTLY to the default branch. Prefer a feature branch unless you meant to.
3347
3358
 
3348
3359
  " ;;
3349
3360
  esac
3350
3361
  _commit_agent_note
3362
+ # What it takes or sends: the staged change for a commit, the commits for a push (read only).
3363
+ _PVA=""; case "$CMD" in *push*) _PVA="$(_pv_line push-ask "$CMD")" ;; *) _PVA="$(_pv_line commit "$CMD")" ;; esac
3351
3364
  ask_user "§4.4 commit/push approval gate. Claude wants to run:
3352
3365
 
3353
3366
  $SHORT
3354
3367
 
3355
- ${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."
3368
+ ${_PVA:+$_PVA
3369
+
3370
+ }${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
3371
  ;;
3357
3372
  auto|dontAsk)
3358
3373
  _commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
@@ -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"; unset _crew_d
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="${CREW_GATE_LOG:-}"
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
@@ -514,6 +514,27 @@ _crew_prh_scan(){ # -> 0 when a PermissionRequest hook may be wired; _PRH names
514
514
  return "$r"
515
515
  }
516
516
 
517
+ # ---- IS THE QUESTION OPEN HERE? One answer for every hook that offers it ------------------------------------------
518
+ # guard-bash.sh offers it in a refusal; agent-outcome.sh offers it when crew-commit-agent is called in `auto`, so the
519
+ # 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
520
+ # that asked for /crew-approve first never showed it). The text the user is asked stays _CREW_Q_JSON, here only.
521
+ _CREW_Q_HERE="${BASH_SOURCE[0]%/*}"; [ "$_CREW_Q_HERE" = "${BASH_SOURCE[0]}" ] && _CREW_Q_HERE=.
522
+ _crew_q_wired(){ # 0 = a settings file wires both halves on AskUserQuestion (guard-question.sh, then this file)
523
+ local f t d="${CLAUDE_PROJECT_DIR:-.}"
524
+ d="${d//\\//}"; d="${d%/}"
525
+ 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
526
+ [ -n "$f" ] && [ -f "$f" ] || continue
527
+ t=""; IFS= read -r -d '' t < "$f" || true
528
+ case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
529
+ done
530
+ return 1
531
+ }
532
+ _crew_q_open(){ # $1 = permission mode -> 0 when the click may be offered: auto, both halves wired and beside this file, no PermissionRequest hook
533
+ [ "$1" = auto ] && [ -f "$_CREW_Q_HERE/guard-question.sh" ] && _crew_q_wired && ! _crew_prh_scan
534
+ }
535
+ # What the session is told to do, the same words wherever it is offered.
536
+ _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:'
537
+
517
538
  # ---- the approval question's context, and its PreToolUse half -----------------------------------------------
518
539
  # Shared by prompt-approval.sh (PostToolUse: it records the answer) and guard-question.sh (PreToolUse: it asks). The
519
540
  # PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
@@ -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
- [ -n "$MSG" ] || exit 0 # no handover to offer and nothing to say about language -> stay silent
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
@@ -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
package/kit/settings.json CHANGED
@@ -216,6 +216,18 @@
216
216
  ]
217
217
  }
218
218
  ],
219
+ "PreCompact": [
220
+ {
221
+ "hooks": [
222
+ {
223
+ "type": "command",
224
+ "shell": "bash",
225
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/precompact-snapshot.sh",
226
+ "timeout": 30
227
+ }
228
+ ]
229
+ }
230
+ ],
219
231
  "SessionStart": [
220
232
  {
221
233
  "matcher": "startup|resume|clear|compact|fork",
@@ -17,6 +17,21 @@ Trigger phrases: "code-review", "review the code", "review the PR", "review my c
17
17
  A review runs in four steps — **group, plan, review, fact-check** — and reports few findings that are right rather
18
18
  than many that might be. Every step below says what it produces.
19
19
 
20
+ ## 0. The pre-pass first, and the findings in one shape
21
+ Before reading the diff, run the deterministic pre-pass (`eval/lib/review-prepass/` in either edition):
22
+ `bash <dir>/prepass.sh` for the staged change, `--base <ref>` for a branch. It writes
23
+ `.crewforth/review-prepass.json`: secret patterns, a debug call or TODO left in code, shellcheck on shell files, a
24
+ lock file that moved without its manifest, a very large change. **Carry those over, do not re-derive them**; your
25
+ time goes to what a pattern cannot see. rc 3 (no Node) or a `notMeasured` line means that part was NOT checked:
26
+ say so in the review, never read it as clean.
27
+
28
+ Write your own findings in the same shape (`crewforth.findings/1`: `rule` `review-<category>`, `severity`,
29
+ `confidence`, `evidence` with `file`, `line` and a `text` quote, `detail` = what to do instead) and store them with
30
+ `bash <dir>/../findings/findings.sh write review <file>`. A `critical` or `high` finding first goes to
31
+ **crew-refuter** (claim + `file:line`, one call per lens, in parallel) and the document is written with
32
+ `--verified`, which then refuses any `medium`-or-higher finding without its verifiers. This is model discipline:
33
+ no hook runs the pre-pass or reads `review.json`.
34
+
20
35
  ## 1. Scope
21
36
  - **Review the lines this change adds or edits.** Deleted code is context for understanding them, not a target.
22
37
  - Code that is correct, or that the change did not touch, gets no comment.
@@ -142,6 +157,7 @@ five questions that decide *answer in the thread* vs *edit the code*: **`referen
142
157
  item leaves with a disposition from the triage table above — a reasoned refusal is an answer, silence is not.
143
158
 
144
159
  ## DoD (this skill's contribution)
160
+ - The pre-pass ran (or its rc 3 / `notMeasured` is reported as unchecked), and the findings are in `.crewforth/review.json`.
145
161
  - The plan (what changed, the risks) came before the findings; every file in each group was read.
146
162
  - Every finding has a severity, a category and a reason, in the finding format above.
147
163
  - Scope creep and hidden complexity are flagged.