crewforth 3.2.0-rc.1 → 3.2.0-rc.3

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 (69) hide show
  1. package/CHANGELOG.md +133 -3
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-review-agent.md +2 -0
  8. package/kit/eval/doctor.sh +37 -3
  9. package/kit/eval/gate-report.sh +2 -0
  10. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  11. package/kit/eval/lib/crew-map/project.mjs +3 -2
  12. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  13. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  14. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  15. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  16. package/kit/eval/lib/gate-log.sh +52 -0
  17. package/kit/eval/lib/json-string.awk +27 -0
  18. package/kit/eval/lib/preset.sh +109 -0
  19. package/kit/eval/lib/presets/default.json +7 -0
  20. package/kit/eval/lib/presets/solo.json +7 -0
  21. package/kit/eval/lib/presets/strict.json +11 -0
  22. package/kit/eval/lib/preview.sh +150 -0
  23. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  24. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  25. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  26. package/kit/eval/lib/settings-default.sh +36 -0
  27. package/kit/eval/lib/settings-json.awk +14 -4
  28. package/kit/eval/lib/statusline.sh +4 -19
  29. package/kit/eval/lib/verify-cmd.sh +17 -0
  30. package/kit/eval/lib/version.sh +41 -0
  31. package/kit/eval/review-bench.sh +26 -0
  32. package/kit/eval/smoke-test.sh +251 -21
  33. package/kit/hooks/agent-outcome.sh +20 -9
  34. package/kit/hooks/board-sync.sh +2 -23
  35. package/kit/hooks/guard-agent-model.sh +114 -15
  36. package/kit/hooks/guard-bash.sh +36 -21
  37. package/kit/hooks/guard-write.sh +3 -7
  38. package/kit/hooks/precompact-snapshot.sh +75 -0
  39. package/kit/hooks/session-rehydrate.sh +25 -2
  40. package/kit/hooks/session-update-check.sh +9 -8
  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/studio/README.md +21 -10
  46. package/kit/studio/server/index.js +85 -22
  47. package/kit/studio/server/lib/code-stamp.js +35 -0
  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/owned-stream.js +56 -0
  52. package/kit/studio/server/lib/projects.js +11 -4
  53. package/kit/studio/server/lib/redact.js +60 -0
  54. package/kit/studio/server/lib/remote.js +104 -18
  55. package/kit/studio/server/lib/session.js +1 -1
  56. package/kit/studio/server/lib/terminal.js +51 -13
  57. package/kit/studio/server/lib/transcript.js +32 -0
  58. package/kit/studio/web/app.js +79 -11
  59. package/kit/studio/web/canvas.js +14 -2
  60. package/kit/studio/web/chat.js +114 -24
  61. package/kit/studio/web/graph-plan.js +3 -2
  62. package/kit/studio/web/liveness.js +14 -0
  63. package/kit/studio/web/map-plan.js +72 -3
  64. package/kit/studio/web/map.js +103 -10
  65. package/kit/studio/web/qr.js +200 -0
  66. package/kit/studio/web/style.css +40 -0
  67. package/kit/studio/web/tokens.css +6 -0
  68. package/package.json +1 -1
  69. 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,11 +417,23 @@ _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 '
417
- _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1 and CARD_WHY
434
+ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
418
435
  local s="$1" l k v n=0 seen=""
419
- CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""
436
+ CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""; CARD_ABOVE=""
420
437
  s="${s//$'\r'/}"
421
438
  while [ "$n" -lt 3 ] && [ -n "$s" ]; do
422
439
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
@@ -440,7 +457,16 @@ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1
440
457
  [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
441
458
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
442
459
  [ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
443
- case "$CARD_VERIFY" in none|None|NONE|-) CARD_VERIFY=none ;; esac
460
+ case "$CARD_VERIFY" in none|None|NONE|-|'none '*|'none('*|'None '*|'NONE '*) CARD_VERIFY=none ;; esac # "none (the author ran it)" is none
461
+ # Optional, right after the three: `above: <why>` keeps a model above the card's floor. Without it a call above
462
+ # the floor is run on the floor (the cost of a model nobody needed is what this gate exists to stop).
463
+ while [ -n "$s" ]; do
464
+ l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
465
+ while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
466
+ [ -n "$l" ] || continue
467
+ case "$l" in above:*) v="${l#above:}"; while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done; CARD_ABOVE="${v:0:200}" ;; esac
468
+ break
469
+ done
444
470
  return 0
445
471
  }
446
472
  _am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
@@ -460,6 +486,53 @@ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "th
460
486
  case "$t" in ''|*[!0-9a-f]*) t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | cksum 2>/dev/null)"; t="${t%% *}"; printf -v t '%012x' "${t:-0}" 2>/dev/null || t=000000000000 ;; esac
461
487
  CARD_ID="${t:0:12}"
462
488
  }
489
+ # A call to an agent that is not Crewforth's (general-purpose, a project's own): no card and no floor, so only the
490
+ # expensive end is held, the same rewrite as a crew call above its floor.
491
+ # * It names opus or fable, and its task has no `above: <why>` line: it runs on sonnet.
492
+ # * It names no model, and the agent takes the session's (general-purpose, or a project or user agent whose
493
+ # definition has no model of its own): it runs on sonnet, unless the session itself is on haiku, read from the
494
+ # end of its transcript. An agent whose definition names a model (Explore, claude-code-guide) is left to it:
495
+ # a model in the call would override a cheaper one there.
496
+ # Measured on 2.1.299: a general-purpose call that named no model ran on the session's model; CLAUDE_CODE_SUBAGENT_MODEL
497
+ # in the project's settings `env` did not change that (in the shell's environment it did), so the hook sets it.
498
+ _am_outside(){ # $1 = the tool_input text, $2 = the agent's name
499
+ local m pr to=sonnet ui name="$2" f d l ln inherit=0 tp last
500
+ case "$name" in ''|*[!A-Za-z0-9._-]*) name="an agent" ;; esac
501
+ _json_keycount "$1" prompt; [ "$_KC" = 1 ] || return 0
502
+ _json_slice "$1" prompt >/dev/null; pr="${_JS:0:6000}"; _json_unescape "$pr" >/dev/null; pr=$'\n'"${_JU//$'\r'/}"
503
+ case "$pr" in *$'\n'above:*|*$'\n'[$' \t']above:*) return 0 ;; esac
504
+ _json_keycount "$1" model
505
+ if [ "$_KC" = 0 ]; then
506
+ case "$name" in
507
+ general-purpose) inherit=1 ;;
508
+ 'an agent') ;;
509
+ *) for d in "${CLAUDE_PROJECT_DIR:-.}/.claude/agents" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/agents"; do
510
+ f="$d/$name.md"; [ -f "$f" ] || continue
511
+ inherit=1; ln=0
512
+ while IFS= read -r l || [ -n "$l" ]; do
513
+ ln=$((ln+1)); [ "$ln" -le 40 ] || break
514
+ case "$l" in ---*) [ "$ln" -gt 1 ] && break ;; model:*) case "${l#model:}" in *inherit*|'') ;; *) inherit=0 ;; esac ;; esac
515
+ done < "$f"
516
+ break
517
+ done ;;
518
+ esac
519
+ [ "$inherit" = 1 ] || return 0
520
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="$_JU"
521
+ if [ -n "$tp" ] && [ -f "$tp" ]; then
522
+ last="$(tail -c 65536 "$tp" 2>/dev/null)"; last="${last##*\"model\":\"}"; last="${last%%\"*}"
523
+ case "$last" in *haiku*) return 0 ;; esac
524
+ fi
525
+ m="the session's model"
526
+ else
527
+ [ "$_KC" = 1 ] || return 0
528
+ _json_slice "$1" model >/dev/null; _json_unescape "$_JS" >/dev/null; _am_tier "$_JU"; m="$_AMT"
529
+ case "$m" in opus|fable) ;; *) return 0 ;; esac
530
+ fi
531
+ ui="$(printf '%s' "$INPUT" | awk -v op=get -v path=tool_input -v setk=model -v setv="\"$to\"" -f "$_AM_HERE/../eval/lib/settings-json.awk" - 2>/dev/null)" || return 0
532
+ case "$ui" in '{'*'}') ;; *) return 0 ;; esac
533
+ _am_log "note: $name (not a crew agent) asked for $m, run on $to; add 'above: <why>' to its task to keep a model above it"
534
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"Crewforth: %s asked for %s and runs on %s (a line above: <why> in its task keeps a model above it)","updatedInput":%s}}\n' "$name" "$m" "$to" "$ui"
535
+ }
463
536
  _am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
464
537
  local l; _AMV=""
465
538
  [ -f "$1" ] || return 1
@@ -501,13 +574,10 @@ _am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor,
501
574
 
502
575
  # ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
503
576
  _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog; the call's text is never recorded.
504
- local gl="${CREW_GATE_LOG:-}" v=BLOCK
577
+ local gl="" v=BLOCK
505
578
  case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
506
- if [ -z "$gl" ]; then
507
- [ -d ".claude" ] || return 0
508
- 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
509
- gl=".claude/gate-log.tsv"
510
- fi
579
+ declare -F crew_gate_log_write >/dev/null || return 0
580
+ crew_gate_log_write; gl="$_CREW_GL"; [ -n "$gl" ] || return 0
511
581
  printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
512
582
  }
513
583
 
@@ -538,6 +608,7 @@ _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog
538
608
  # 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
539
609
  # explicit allow passes.
540
610
  _AM_HERE="${BASH_SOURCE%/*}"; [ "$_AM_HERE" = "${BASH_SOURCE}" ] && _AM_HERE=.
611
+ [ -f "$_AM_HERE/../eval/lib/gate-log.sh" ] && . "$_AM_HERE/../eval/lib/gate-log.sh" # where the gate log goes
541
612
  # CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
542
613
  _AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
543
614
  'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
@@ -609,6 +680,9 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
609
680
  done <<< "$_AMSF"
610
681
  return 1
611
682
  }
683
+ # How a verify command is read (the `cd <folder> && <command>` split): eval/lib/verify-cmd.sh, shared with doctor.
684
+ [ -f "$_AM_HERE/../eval/lib/verify-cmd.sh" ] && . "$_AM_HERE/../eval/lib/verify-cmd.sh"
685
+ _am_verify_split(){ _AMVD=""; _AMVC="$1"; declare -F crew_verify_split >/dev/null && crew_verify_split "$1" "${_AMP:-.}"; return 0; }
612
686
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
613
687
  local c="$1" pm="${2:-default}" f r rc k g
614
688
  _AMVW=""; _AMVB=""
@@ -690,6 +764,8 @@ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> ret
690
764
  if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
691
765
  fi
692
766
  case "$tier" in opus|fable) return 0 ;; esac
767
+ # A text change on sonnet may write prose there: the call was let through on that floor for exactly that.
768
+ if [ "$tier" = sonnet ] && _am_prose "$_AMRP" && _am_rec_get "$_AMD/agents/$aid" change && [ "$_AMV" = text ]; then return 0; fi
693
769
  _am_log "critical path: write by an agent not on opus"
694
770
  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
695
771
  exit 2
@@ -729,7 +805,7 @@ _json_keycount "$ti" subagent_type
729
805
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names subagent_type more than once, so which agent it starts cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
730
806
  _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
731
807
  st="${st##*:}" # a plugin names its agents <plugin>:crew-…
732
- case "$st" in crew-*) ;; *) exit 0 ;; esac
808
+ case "$st" in crew-*) ;; *) _am_outside "$ti" "$st"; exit 0 ;; esac
733
809
  case "$st" in *[!A-Za-z0-9_-]*) echo "GUARD (agent model): the agent's name holds a character this gate does not read, so the call is refused." >&2; _am_log "an agent name it cannot read"; exit 2 ;; esac
734
810
  _json_keycount "$ti" model
735
811
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
@@ -763,9 +839,11 @@ if ! _am_card "$prompt"; then
763
839
  fi
764
840
  _am_state; _am_risk; _am_digest
765
841
  # ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
766
- if [ "$CARD_VERIFY" = none ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
842
+ 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
767
843
  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
768
- if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
844
+ if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" = text ] && _am_all_prose; then
845
+ if [ "$floor" -lt 2 ]; then floor=2; why="a text change on a critical path: $CARD_RISKWHY"; fi
846
+ elif [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
769
847
  # A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
770
848
  _am_rules
771
849
  for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
@@ -802,6 +880,23 @@ if [ "$rank" -lt "$floor" ]; then
802
880
  echo "GUARD (agent model): $st runs on $fname or above for this card ($why), and this call asks for $m. Repeat the same call with model $fname." >&2
803
881
  _am_log "below the floor of the card"; exit 2
804
882
  fi
883
+ # ---- above the floor: run on the floor, unless the card says why not ----
884
+ # The floor is what the work needs; a model above it is cost nobody asked for (measured on a real project: 73 of 186
885
+ # crew tasks ran above their floor, 0 on haiku). Unless the card carries `above: <why>`, the call is let through ON
886
+ # THE FLOOR: the hook hands Claude Code the same input with `model` replaced (PreToolUse updatedInput; measured on
887
+ # 2.1.299: a call asking for opus ran on haiku alone). The rewrite is one awk over the payload, on this path only.
888
+ lowered=""
889
+ low=$floor; [ "$low" -lt 1 ] && low=1
890
+ if [ "$rank" -gt "$low" ] && [ -z "$CARD_ABOVE" ]; then
891
+ _am_name "$low"; to="$_AMN"
892
+ ui="$(printf '%s' "$INPUT" | awk -v op=get -v path=tool_input -v setk=model -v setv="\"$to\"" -f "$_AM_HERE/../eval/lib/settings-json.awk" - 2>/dev/null)" || ui=""
893
+ case "$ui" in '{'*'}') ;; *) ui="" ;; esac
894
+ if [ -n "$ui" ]; then
895
+ lowered="$m"
896
+ _am_log "note: $st asked for $m, run on $to, the floor of this card (${why:-no floor above haiku}); add 'above: <why>' to the card to keep $m"
897
+ m="$to"
898
+ fi
899
+ fi
805
900
  # ---- where it runs, and what its verify command is ----
806
901
  _json_keycount "$ti" run_in_background
807
902
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names run_in_background more than once, so where it runs cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
@@ -828,7 +923,11 @@ if mkdir -p "$_AMD/pending" 2>/dev/null; then
828
923
  # Why the work counts as critical, as the gate decided it, so a reader shows the reason and does not work it out
829
924
  # again: one line, no tab (it ends up in a column of model-outcomes.tsv).
830
925
  rw="${CARD_RISKWHY:--}"; rw="${rw//$'\t'/ }"; rw="${rw//$'\r'/ }"; rw="${rw//$'\n'/ }"
831
- { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\nwhy=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" "$rw" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
926
+ { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\nlowered=%s\nwhy=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" "${lowered:--}" "$rw" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
927
+ fi
928
+ # The call, on the floor: the same input with `model` replaced, and allowed. Nothing else is printed on this path.
929
+ if [ -n "$lowered" ]; then
930
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"Crewforth: %s asked for %s and runs on %s, the floor of its card (add above: <why> to the card to keep %s)","updatedInput":%s}}\n' "$st" "$lowered" "$m" "$lowered" "$ui"
832
931
  fi
833
932
  exit 0
834
933
  }
@@ -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
@@ -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
@@ -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
@@ -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"; unset _crew_d
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
- read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""; CUR="$(sane_version "$_cv")" || exit 0
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
- # Numeric field compare, one awk. Pre-release suffixes degrade to their numeric prefix (`1-rc2` -> 1), which is
190
- # the harmless direction: it can suppress a question, never invent one.
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
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.