crewforth 3.2.0-rc.2 → 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 (61) hide show
  1. package/CHANGELOG.md +100 -3
  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-review-agent.md +2 -0
  8. package/kit/eval/doctor.sh +13 -2
  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-json.awk +4 -0
  27. package/kit/eval/lib/statusline.sh +1 -1
  28. package/kit/eval/lib/verify-cmd.sh +17 -0
  29. package/kit/eval/review-bench.sh +26 -0
  30. package/kit/eval/smoke-test.sh +142 -20
  31. package/kit/hooks/board-sync.sh +2 -23
  32. package/kit/hooks/guard-agent-model.sh +32 -23
  33. package/kit/hooks/guard-bash.sh +36 -21
  34. package/kit/hooks/guard-write.sh +3 -7
  35. package/kit/hooks/precompact-snapshot.sh +75 -0
  36. package/kit/hooks/session-rehydrate.sh +25 -2
  37. package/kit/settings.json +12 -0
  38. package/kit/skills/crew-code-review/SKILL.md +16 -0
  39. package/kit/skills/crew-map/SKILL.md +16 -5
  40. package/kit/skills/crew-review/SKILL.md +2 -1
  41. package/kit/studio/README.md +21 -10
  42. package/kit/studio/server/index.js +74 -22
  43. package/kit/studio/server/lib/graph.js +9 -1
  44. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  45. package/kit/studio/server/lib/lan.js +87 -0
  46. package/kit/studio/server/lib/redact.js +60 -0
  47. package/kit/studio/server/lib/remote.js +104 -18
  48. package/kit/studio/server/lib/session.js +1 -1
  49. package/kit/studio/server/lib/terminal.js +51 -13
  50. package/kit/studio/server/lib/transcript.js +32 -0
  51. package/kit/studio/web/app.js +69 -7
  52. package/kit/studio/web/canvas.js +14 -2
  53. package/kit/studio/web/chat.js +32 -7
  54. package/kit/studio/web/graph-plan.js +3 -2
  55. package/kit/studio/web/map-plan.js +72 -3
  56. package/kit/studio/web/map.js +103 -10
  57. package/kit/studio/web/qr.js +200 -0
  58. package/kit/studio/web/style.css +34 -0
  59. package/kit/studio/web/tokens.css +6 -0
  60. package/package.json +1 -1
  61. package/start.sh +14 -1
@@ -1674,8 +1674,8 @@ if [ "$IS_KIT" = 1 ]; then
1674
1674
  _ch4_t="$(printf '%s\n' "all fo""ur channels carry it" "Dö""rt kanal da taşıyor" "of fo""ur distribution channels" "three channels" "four channel types of RGB" | grep -ciE "$_ch4_re")"
1675
1675
  if [ "$_ch4_t" != 3 ]; then fail "channel-count check cannot tell its twins apart (caught $_ch4_t of the 3 planted, want 3 and none of the 2 clean)"
1676
1676
  elif git -C "$KR" rev-parse --git-dir >/dev/null 2>&1; then
1677
- _ch4_hits="$(git -C "$KR" grep -niIE "$_ch4_re" -- . ':!CHANGELOG.md' ':!site/src/content/docs/changelog.md' ':!site/src/content/docs/tr/changelog.md' 2>/dev/null)"
1678
- [ -z "$_ch4_hits" ] && pass "no tracked file gives the old channel count — three ship Crewforth (CHANGELOG exempt; twins 3/3 caught, 0/2 flagged)" \
1677
+ _ch4_hits="$(git -C "$KR" grep -niIE "$_ch4_re" -- . ':!CHANGELOG.md' ':!site/src/content/docs/changelog.md' ':!site/src/content/docs/tr/changelog.md' ':!packaging/review-bench/cases' 2>/dev/null)"
1678
+ [ -z "$_ch4_hits" ] && pass "no tracked file gives the old channel count — three ship Crewforth (CHANGELOG and the review bench's past-version cases exempt; twins 3/3 caught, 0/2 flagged)" \
1679
1679
  || fail "a tracked file still gives the old channel count (there are three): $(printf '%s\n' "$_ch4_hits" | head -3 | tr '\n' ' ')"
1680
1680
  else skip scope "channel-count check not run — not a git checkout of Crewforth's source"; fi
1681
1681
  # THE CLIENT SIDE IS STACK-NEUTRAL (3.0.1): the install summary, the help and the install page named the scope as
@@ -3407,6 +3407,46 @@ pass|rm -f file.txt
3407
3407
  pass|rm file.txt
3408
3408
  pass|rmdir empty/
3409
3409
  RMCASES
3410
+ # V4: a stopped or asked command says what it would have touched (eval/lib/preview.sh): the changes a reset discards,
3411
+ # the paths a clean deletes, the files under an rm target inside the project, what a commit takes and a push sends.
3412
+ # Read only, and after the decision: a preview library that fails or ends its shell leaves the decision as it was.
3413
+ PVR="$(mktemp -d)"; PVR="$(cd -P "$PVR" && pwd)"; PVO="$(mktemp)"; PVE="$(mktemp)"; PVX="$(mktemp -d)"
3414
+ ( cd "$PVR" && git init -q . && git -c user.email=a@b -c user.name=a commit -q --allow-empty -m base \
3415
+ && printf '1\n' > a.txt && printf '2\n' > b.txt && git add . && git -c user.email=a@b -c user.name=a commit -q -m two \
3416
+ && printf 'x\n' >> a.txt && printf 'y\n' >> b.txt && mkdir -p out/sub && : > out/sub/1 && : > out/2 && : > junk.log ) >/dev/null 2>&1
3417
+ _pvrun(){ printf '{"session_id":"s","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"%s"},"cwd":"%s"}' "$1" "$PVR" \
3418
+ | ( cd "$PVR" && CLAUDE_PROJECT_DIR="$PVR" CREW_GATE_LOG=/dev/null bash "${2:-$HOOKS}/guard-bash.sh" ) > "$PVO" 2> "$PVE"; _pvrc=$?; }
3419
+ _pvb=""
3420
+ _pvrun "git reset --hard"; { [ "$_pvrc" = 2 ] && grep -q 'Preview: it would discard 2 uncommitted change' "$PVE"; } || _pvb="$_pvb [reset: rc $_pvrc · $(grep Preview "$PVE")]"
3421
+ _pvrun "git reset --hard HEAD~1"; grep -q 'and drop 1 commit(s) from the branch' "$PVE" || _pvb="$_pvb [reset HEAD~1: $(grep Preview "$PVE")]"
3422
+ _pvrun "git clean -fd"; { [ "$_pvrc" = 2 ] && grep -q 'lists 2 path(s) it would delete' "$PVE"; } || _pvb="$_pvb [clean: $(grep Preview "$PVE")]"
3423
+ _pvrun "rm -rf out/"; { [ "$_pvrc" = 2 ] && grep -q 'Preview: out/: 2 file(s)' "$PVE"; } || _pvb="$_pvb [rm inside: $(grep Preview "$PVE")]"
3424
+ _pvrun "rm -rf $PVX/"; { [ "$_pvrc" = 2 ] && grep -q 'outside the project, not counted' "$PVE"; } || _pvb="$_pvb [rm outside: $(grep Preview "$PVE")]"
3425
+ # The asked ones: a commit (past §4.6, with the review record) and a push to a remote of its own.
3426
+ ( cd "$PVR" && git add a.txt && mkdir -p .claude && D=$(git diff --cached --no-ext-diff --no-textconv | git hash-object --stdin) && H=$(git rev-parse HEAD) \
3427
+ && printf '{"diff_oid":"%s","head":"%s","ts":"%s"}\n' "$D" "$H" "2026-01-01T00:00:00Z" > .claude/review-pass.json ) >/dev/null 2>&1
3428
+ _pvrun "git commit -m wip"; { [ "$_pvrc" = 0 ] && grep -q 'Preview: 1 file changed, 1 insertion' "$PVO"; } || _pvb="$_pvb [commit ask: rc $_pvrc · $(grep -o 'Preview[^\\]*' "$PVO")]"
3429
+ ( cd "$PVR" && git init -q --bare "$PVX/r.git" && git remote add origin "$PVX/r.git" && git push -q -u origin HEAD:main && git branch -q -u origin/main \
3430
+ && git -c user.email=a@b -c user.name=a commit -q --allow-empty -m "one ahead" ) >/dev/null 2>&1
3431
+ _pvrun "git push"; { [ "$_pvrc" = 0 ] && grep -q 'Preview: it sends 1 commit(s) to origin/main' "$PVO"; } || _pvb="$_pvb [push ask: rc $_pvrc · $(grep -o 'Preview[^\\]*' "$PVO")]"
3432
+ # A push that names its branch sends to that one: an existing one is counted, an unknown one would be created.
3433
+ _pvrun "git push origin main"; grep -q 'Preview: it sends 1 commit(s) to origin/main' "$PVO" || _pvb="$_pvb [named push: $(grep -o 'Preview[^\\]*' "$PVO")]"
3434
+ _pvrun "git push origin feat/new"; grep -q 'Preview: origin/feat/new is not known here; the push would create it' "$PVO" || _pvb="$_pvb [push to a new branch: $(grep -o 'Preview[^\\]*' "$PVO")]"
3435
+ # A push of another local branch counts that branch's commits, not the current one's (current: 1 ahead; side: 3).
3436
+ ( cd "$PVR" && _cur="$(git rev-parse --abbrev-ref HEAD)" && git checkout -q -b side && git -c user.email=a@b -c user.name=a commit -q --allow-empty -m s1 \
3437
+ && git -c user.email=a@b -c user.name=a commit -q --allow-empty -m s2 && git checkout -q "$_cur" ) >/dev/null 2>&1
3438
+ _pvrun "git push origin side:main"; grep -q 'Preview: it sends 3 commit(s) to origin/main' "$PVO" || _pvb="$_pvb [push of another branch: $(grep -o 'Preview[^\\]*' "$PVO")]"
3439
+ # A pattern is said, not expanded by the hook's own shell into the files it matches.
3440
+ _pvrun "rm -rf out/*"; { [ "$_pvrc" = 2 ] && grep -q 'Preview: out/\*: a pattern, not counted' "$PVE"; } || _pvb="$_pvb [rm pattern: rc $_pvrc · $(grep Preview "$PVE")]"
3441
+ # A broken preview library: one that exits, one that does not parse. The decision stays rc 2 and nothing is said.
3442
+ _pvh="$(mktemp -d)"; cp -R "$HOOKS" "$_pvh/hooks"; mkdir -p "$_pvh/eval"; cp -R "$ROOT/eval/lib" "$_pvh/eval/lib"
3443
+ printf 'crew_preview(){ exit 0; }\n' > "$_pvh/eval/lib/preview.sh"; _pvrun "git reset --hard" "$_pvh/hooks"
3444
+ { [ "$_pvrc" = 2 ] && ! grep -q Preview "$PVE"; } || _pvb="$_pvb [a preview that exits changed the decision: rc $_pvrc]"
3445
+ printf 'crew_preview(){ ( \n' > "$_pvh/eval/lib/preview.sh"; _pvrun "rm -rf out/" "$_pvh/hooks"
3446
+ [ "$_pvrc" = 2 ] || _pvb="$_pvb [a preview that does not parse changed the decision: rc $_pvrc]"
3447
+ [ -z "$_pvb" ] && pass "a stopped command says what it would have touched: reset (2 changes, and 1 commit for HEAD~1), clean (2 paths, dry run), rm (2 files inside the project; a target outside it not counted); an asked commit and push say what they take and send (a push that names a new branch says it would create it, one of another local branch counts that branch's commits); a pattern is said, not expanded; a preview library that exits or does not parse leaves the decision at rc 2" \
3448
+ || fail "risk preview:$_pvb"
3449
+ rm -rf "$PVR" "$PVX" "$_pvh"; rm -f "$PVO" "$PVE"
3410
3450
  # §4.5 whole-tree revert. `reset --hard` was gated from the start; `git checkout -- .` does the same damage and
3411
3451
  # was not — a verification subagent used it over uncommitted work in this repo and the tree went with it. The
3412
3452
  # single-file form stays allowed (everyday, recoverable), and the two ways around a naive matcher — `git -C`
@@ -4492,6 +4532,41 @@ else fail "session-rehydrate git-hooks states read '$_got' (want '--UO---': no h
4492
4532
  rm -rf "$RHG"
4493
4533
  _rj=0; for _o in "$_o1" "$_o2" "$_o4" "$_o6"; do printf '%s' "$_o" > "$RHD/o.json"; awk -v op=validate -f "$ROOT/eval/lib/settings-json.awk" "$RHD/o.json" 2>/dev/null || _rj=$((_rj+1)); done
4494
4534
  [ "$_rj" = 0 ] && pass "each language/handover output is valid JSON (Crewforth's reader, 4 of 4)" || fail "$_rj session-rehydrate output(s) are not valid JSON"
4535
+ # V3: just before a compaction, precompact-snapshot.sh writes the state of the work from the files and the gates;
4536
+ # after it, session-rehydrate gives that state back, to the same session only. The snapshot never blocks the
4537
+ # compaction (PreCompact exit 2 would): rc 0 and silent in every case, a broken one included.
4538
+ PCS="$HOOKS/precompact-snapshot.sh"
4539
+ if [ ! -x "$PCS" ]; then fail "precompact-snapshot.sh missing/not executable"
4540
+ else
4541
+ PCD="$(mktemp -d)"; PCD="$(cd -P "$PCD" && pwd)"; _pco="$(mktemp)"; _pcr="$(mktemp)" # outside the repository: a file there is uncommitted
4542
+ ( cd "$PCD" && git init -q . && git -c user.email=a@b -c user.name=a commit -q --allow-empty -m base ) >/dev/null 2>&1
4543
+ mkdir -p "$PCD/.claude" "$PCD/.crewforth" "$PCD/docs"
4544
+ printf 'x\n' > "$PCD/plain.txt"; printf 'x\n' > "$PCD/odd\"na me.txt"; printf 'x\n' > "$PCD/ıİşğü.txt"
4545
+ printf '{\n "findings": [\n {\n "rule": "a"\n },\n {\n "rule": "b"\n }\n ]\n}\n' > "$PCD/.crewforth/review.json"
4546
+ printf 'BLOCK\t§4.2\tvendor name\t\n' > "$PCD/.git/crewforth-gate-log.tsv"
4547
+ printf '{"session_id":"s-1","cwd":"%s","hook_event_name":"PreCompact","trigger":"auto","custom_instructions":null}' "$PCD" | CLAUDE_PROJECT_DIR="$PCD" bash "$PCS" > "$_pco" 2>&1; _pcrc=$?
4548
+ _snap="$PCD/.git/crewforth-session-snapshot.json"; _sb=""
4549
+ [ -f "$_snap" ] && IFS= read -r _sb < "$_snap"
4550
+ _rs(){ printf '{"session_id":"%s","source":"%s","cwd":"%s"}' "$1" "$2" "$PCD" | CLAUDE_PROJECT_DIR="$PCD" bash "$HOOKS/session-rehydrate.sh" 2>/dev/null; }
4551
+ _same="$(_rs s-1 compact)"; _other="$(_rs s-2 compact)"; _start="$(_rs s-1 startup)"
4552
+ printf '%s' "$_same" > "$_pcr"
4553
+ _b=""
4554
+ [ "$_pcrc" = 0 ] && [ ! -s "$_pco" ] || _b="$_b [rc $_pcrc, printed: $(head -c 120 "$_pco")]"
4555
+ awk -v op=validate -f "$ROOT/eval/lib/settings-json.awk" "$_snap" 2>/dev/null || _b="$_b [the snapshot is not valid JSON: ${_sb:0:160}]"
4556
+ case "$_sb" in *'"session_id":"s-1"'*'"trigger":"auto"'*'branch '*'4 uncommitted file(s)'*'ıİşğü.txt'*'§4.2 vendor name'*'review 2'*) ;; *) _b="$_b [snapshot: ${_sb:0:200}]" ;; esac
4557
+ case "$_same" in *'Before this context was compacted'*'review 2'*) ;; *) _b="$_b [not given back after compaction: ${_same:0:120}]" ;; esac
4558
+ awk -v op=validate -f "$ROOT/eval/lib/settings-json.awk" "$_pcr" 2>/dev/null || _b="$_b [the SessionStart output with an odd file name is not valid JSON]"
4559
+ case "$_other$_start" in *'Before this context was compacted'*) _b="$_b [given to another session or on a plain start]" ;; esac
4560
+ # Fail-open: no repository, a cwd that is not there, no JSON at all, and the hook alone without eval/lib.
4561
+ _nr="$(mktemp -d)"; _alone="$(mktemp -d)"; cp "$PCS" "$_alone/precompact-snapshot.sh"
4562
+ printf '{"session_id":"s-9","cwd":"%s","trigger":"manual"}' "$_nr" | CLAUDE_PROJECT_DIR="$_nr" bash "$PCS" >/dev/null 2>&1 || _b="$_b [non-repo rc $?]"
4563
+ printf '{"session_id":"s-9","cwd":"/nonexistent/x"}' | CLAUDE_PROJECT_DIR= bash "$PCS" >/dev/null 2>&1 || _b="$_b [missing cwd rc $?]"
4564
+ printf 'not json' | CLAUDE_PROJECT_DIR="$_nr" bash "$PCS" >/dev/null 2>&1 || _b="$_b [garbage rc $?]"
4565
+ printf '{"session_id":"s-9","cwd":"%s"}' "$PCD" | CLAUDE_PROJECT_DIR="$PCD" bash "$_alone/precompact-snapshot.sh" >/dev/null 2>&1 || _b="$_b [no eval/lib rc $?]"
4566
+ [ -z "$_b" ] && pass "PreCompact writes the state of the work (branch, uncommitted files, the last gate refusal, the findings on record) as valid JSON, even for a file name with a quote and a tab, and names a Turkish file name as written; SessionStart gives it back after a compaction to the same session only, never to another or on a plain start; the hook is rc 0 and silent outside a repository, with no cwd, with no JSON and without eval/lib" \
4567
+ || fail "precompact snapshot:$_b"
4568
+ rm -rf "$PCD" "$_nr" "$_alone" "$_pco" "$_pcr"
4569
+ fi
4495
4570
  # Asked of the entry that carries session-rehydrate, not of the file: skill-trust's entry has the same matcher.
4496
4571
  _rw(){ _n="$(awk -v op=len -v path=hooks.SessionStart -f "$ROOT/eval/lib/settings-json.awk" "$1" 2>/dev/null)"; _i=0
4497
4572
  while [ "$_i" -lt "${_n:-0}" ]; do _e="$(awk -v op=get -v path="hooks.SessionStart.$_i" -f "$ROOT/eval/lib/settings-json.awk" "$1" 2>/dev/null)"
@@ -6380,6 +6455,14 @@ GNI="$(mktemp -d)"; ( cd "$GNI" && git init -q . && mkdir -p .claude )
6380
6455
  ( cd "$GNI" && gjson 'git reset --hard' | bash "$ROOT/hooks/guard-bash.sh" >/dev/null 2>&1 )
6381
6456
  [ -e "$GNI/.claude/gate-log.tsv" ] && fail "gate log wrote into a repo where the path is not gitignored" \
6382
6457
  || pass "gate log declines a path git would track"
6458
+ # ...and since 3.2.0 writes inside the repository's .git instead, which is never committed: a project that tracks
6459
+ # .claude/ had no record of a refusal at all (seen on a real project: no 🛡, no note of a lowered model).
6460
+ # The readers find it there (eval/lib/gate-log.sh, the one rule), and `git status` stays clean.
6461
+ _gni_read="$( cd "$GNI" && . "$ROOT/eval/lib/gate-log.sh" && crew_gate_log_read && printf '%s' "$_CREW_GL" )"
6462
+ _gni_st="$( cd "$GNI" && git status --porcelain --untracked-files=all 2>/dev/null | grep -c gate-log )"
6463
+ { [ -s "$GNI/.git/crewforth-gate-log.tsv" ] && grep -q '^BLOCK' "$GNI/.git/crewforth-gate-log.tsv" && [ "$_gni_st" = 0 ] && [ "$_gni_read" = ./.git/crewforth-gate-log.tsv ]; } \
6464
+ && pass "where git would track it, the gate log goes inside .git: written, found by the readers, and git status stays clean" \
6465
+ || fail "gate log inside .git: file $([ -s "$GNI/.git/crewforth-gate-log.tsv" ] && echo written || echo MISSING), git status shows it ${_gni_st}x, reader found '${_gni_read}'"
6383
6466
  ( cd "$GNI" && echo '.claude/' > .gitignore && gjson 'git reset --hard' | bash "$ROOT/hooks/guard-bash.sh" >/dev/null 2>&1 )
6384
6467
  [ -s "$GNI/.claude/gate-log.tsv" ] && pass "gate log writes once the path is gitignored" \
6385
6468
  || fail "gate log stayed silent even though the path is ignored"
@@ -8268,21 +8351,26 @@ done <<'V2'
8268
8351
  2|crew-backend-expert|sonnet|prisma/schema.prisma|feature|none|
8269
8352
  2|crew-backend-expert|sonnet|config/SECRETS_local.env|text|none|
8270
8353
  0|crew-backend-expert|sonnet|docs/author.md, src/cryptography/hash.ts|feature|none|
8271
- 2|crew-backend-expert|sonnet|legal/terms-of-service.md|text|none|
8272
- 2|crew-backend-expert|sonnet|docs/privacy-policy.md|text|none|
8354
+ 0|crew-backend-expert|sonnet|legal/terms-of-service.md|text|none|
8355
+ 0|crew-backend-expert|sonnet|docs/privacy-policy.md|text|none|
8273
8356
  2|crew-backend-expert|sonnet|src/PrivacyController.cs|feature|none|
8274
8357
  2|crew-backend-expert|sonnet|src/consent/banner.ts|feature|none|
8275
- 2|crew-backend-expert|sonnet|content/terms/index.md|text|none|
8276
- 2|crew-backend-expert|sonnet|fastlane/metadata/en-US/description.txt|text|none|
8277
- 2|crew-backend-expert|sonnet|store/listing.md|text|none|
8278
- 2|crew-backend-expert|sonnet|metadata/android/en-US/full_description.txt|text|none|
8358
+ 0|crew-backend-expert|sonnet|content/terms/index.md|text|none|
8359
+ 0|crew-backend-expert|sonnet|fastlane/metadata/en-US/description.txt|text|none|
8360
+ 0|crew-backend-expert|sonnet|store/listing.md|text|none|
8361
+ 0|crew-backend-expert|sonnet|metadata/android/en-US/full_description.txt|text|none|
8279
8362
  0|crew-backend-expert|opus|legal/terms-of-service.md|text|none|
8363
+ 2|crew-backend-expert|haiku|legal/terms-of-service.md|text|none|
8364
+ 2|crew-backend-expert|sonnet|legal/terms-of-service.md, src/legal/consent.ts|text|none|
8365
+ 2|crew-backend-expert|sonnet|legal/terms-of-service.md|feature|none|
8280
8366
  0|crew-backend-expert|sonnet|src/searchTerms.ts|feature|none|
8281
8367
  0|crew-backend-expert|sonnet|src/store/index.ts|feature|none|
8282
8368
  0|crew-backend-expert|sonnet|fastlane/metadata/en-US/images/icon.png|text|none|
8283
8369
  0|crew-backend-expert|sonnet|docs/legalese.md|text|none|
8284
8370
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|none|
8285
- 2|crew-backend-expert|haiku|src/ui/a.ts|text|none|
8371
+ 0|crew-backend-expert|haiku|src/ui/a.ts|text|none|
8372
+ 2|crew-backend-expert|haiku|src/ui/a.ts|fix-known|none|
8373
+ 0|crew-commit-agent|haiku|src/ui/a.ts|fix-known|none|
8286
8374
  0|crew-backend-expert|sonnet|src/ui/a.ts|text|none|
8287
8375
  0|crew-backend-expert|haiku|src/ui/a.ts|text|true|
8288
8376
  0|crew-test-expert|haiku|src/ui/**|test-run|true|
@@ -8314,8 +8402,8 @@ done <<'V2'
8314
8402
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|git commit -m x|
8315
8403
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|rm -rf /|
8316
8404
  V2
8317
- if [ "$_v2n" != 60 ]; then fail "FIXTURE: the card table has $_v2n rows, not 60"
8318
- elif [ -z "$_v2b" ]; then pass "the card decides the model: critical work (payments/, migrations, a .sql file, AuthController, userAuth, schema.prisma, and the four critical kinds of change) is refused below opus and passes on opus; verify none is refused on haiku; test-run passes on haiku, also on a critical path; test-write, audit and review are held to sonnet in normal work and to opus in critical work; critical work with a verify command is refused when the call says run_in_background true, and passes with false and with the field left out (a session whose Agent tool has no such field cannot send it); a card that is not one is refused; a verify command never refuses the call, whatever it is (the hook decides whether to run it) (47 rows)"
8405
+ if [ "$_v2n" != 65 ]; then fail "FIXTURE: the card table has $_v2n rows, not 65"
8406
+ elif [ -z "$_v2b" ]; then pass "the card decides the model: critical work (payments/, migrations, a .sql file, AuthController, userAuth, schema.prisma, and the four critical kinds of change) is refused below opus and passes on opus; a text change whose every file is prose (legal, privacy, terms, a store listing) passes on sonnet and is refused on haiku, and one card file that is not prose holds it to opus; verify none is refused on haiku, except for a text change and the commit agent; test-run passes on haiku, also on a critical path; test-write, audit and review are held to sonnet in normal work and to opus in critical work; critical work with a verify command is refused when the call says run_in_background true, and passes with false and with the field left out (a session whose Agent tool has no such field cannot send it); a card that is not one is refused; a verify command never refuses the call, whatever it is (the hook decides whether to run it) ($_v2n rows)"
8319
8407
  else fail "card table:$_v2b"; fi
8320
8408
  # The card's floor is also the default: a call above it runs ON it (the gate hands back the same input with `model`
8321
8409
  # replaced, PreToolUse updatedInput), unless the card's next line says `above: <why>`. Measured on a real project:
@@ -8401,15 +8489,20 @@ done
8401
8489
  _v2new; _v2b=""
8402
8490
  _v2call crew-backend-expert sonnet 'src/ui/a.ts' refactor true; _v2start ag-son
8403
8491
  _v2call crew-backend-expert opus 'src/payments/p.ts' feature none; _v2start ag-op
8492
+ _v2call crew-docs-agent sonnet 'legal/terms.md' text none; _v2start ag-txt crew-docs-agent
8404
8493
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-son","agent_type":"crew-backend-expert"'; { [ "$_v2wrc" = 2 ] && grep -q 'escalate: src/payments/p.ts' "$_v2/werr" && grep -q '^GUARD (agent model): ' "$_v2/werr"; } || _v2b="$_v2b sonnet-agent-wrote-payments:$_v2wrc"
8405
8494
  _v2write "$_v2p/src/ui/a.ts" ',"agent_id":"ag-son","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b sonnet-agent-refused-on-ui:$_v2wrc"
8406
8495
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-op","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b opus-agent-refused:$_v2wrc"
8407
8496
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-unknown","agent_type":"general-purpose"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b unknown-model-wrote-payments:$_v2wrc"
8408
8497
  _v2write "$_v2p/src/payments/p.ts"; [ "$_v2wrc" = 0 ] || _v2b="$_v2b the-session-refused:$_v2wrc"
8498
+ # A text change on sonnet writes prose on a critical path; not code there, and not prose for any other change.
8499
+ _v2write "$_v2p/legal/terms.md" ',"agent_id":"ag-txt","agent_type":"crew-docs-agent"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b sonnet-text-refused-legal-prose:$_v2wrc"
8500
+ _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-txt","agent_type":"crew-docs-agent"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b sonnet-text-wrote-payments-code:$_v2wrc"
8501
+ _v2write "$_v2p/legal/terms.md" ',"agent_id":"ag-son","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b sonnet-refactor-wrote-legal-prose:$_v2wrc"
8409
8502
  mkdir -p "$_v2/t/subagents"; printf '{"type":"assistant","message":{"model":"claude-opus-5-5"}}\n' > "$_v2/t/subagents/agent-ag-gp.jsonl"
8410
8503
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-gp","agent_type":"general-purpose"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b opus-by-its-transcript-refused:$_v2wrc"
8411
8504
  _V2ENV="CREW_MODEL_ROUTING=off" _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-son","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b routing-off-refused:$_v2wrc"
8412
- [ -z "$_v2b" ] && pass "an agent on sonnet is refused a write to a critical path its card did not name, and told to report escalate; the same agent writes elsewhere; an agent on opus (by its call, or by its own transcript) writes there; an agent whose model cannot be read does not; the session's own write is not asked; routing off asks nothing" \
8505
+ [ -z "$_v2b" ] && pass "an agent on sonnet is refused a write to a critical path its card did not name, and told to report escalate; the same agent writes elsewhere; an agent on opus (by its call, or by its own transcript) writes there; an agent whose model cannot be read does not; the session's own write is not asked; routing off asks nothing; an agent on sonnet with a text card writes prose there (legal/terms.md) and not code, and one with any other card writes neither" \
8413
8506
  || fail "write-time check:$_v2b"
8414
8507
  # B2 (K7): the session's own write to a critical path is not refused, and it is recorded: one NOTE line in the gate
8415
8508
  # log naming the path and the rule. A write elsewhere, one outside the project, and one with routing off add none.
@@ -8443,6 +8536,32 @@ _v2call crew-backend-expert sonnet 'docs/security.md' text none; [ "$_v2rc" = 0
8443
8536
  _v2call crew-backend-expert sonnet 'Api/Services/AuthorService.cs' feature none; [ "$_v2rc" = 0 ] || _v2b="$_v2b AuthorService-read-as-auth"
8444
8537
  [ -z "$_v2b" ] && pass "with no settings file the scan finds the critical paths of a Flutter app and a .NET service (a folder once, not per file; AuthorService and author.md are not auth); crew-model-rules adds and removes paths, at SessionStart too, and the gate follows it" \
8445
8538
  || fail "critical paths:$_v2b"
8539
+ # V5: an organisation preset (eval/lib/preset.sh) is a block at the top of crew-model-rules. The project's own lines
8540
+ # stay below it and win where the file's rule lets them; applying again changes nothing; an invalid one writes nothing.
8541
+ _v2new; _v2b=""; PSH="$ROOT/eval/lib/preset.sh"; _psr="$_v2p/.claude/crew-model-rules"; _psx="$(mktemp -d)"
8542
+ printf '# ours\n- infra\n' > "$_psr"
8543
+ ( cd "$_v2p" && bash "$PSH" --apply strict ) >/dev/null 2>&1 || _v2b="$_v2b [strict did not apply]"
8544
+ { IFS= read -r _l1 < "$_psr"; case "$_l1" in '# ---- preset: strict '*) ;; *) false ;; esac; } || _v2b="$_v2b [first line: ${_l1:-none}]"
8545
+ grep -qx '+ deploy' "$_psr" && grep -qx 'floor crew-review-agent \* \* opus' "$_psr" && grep -qx -- '- infra' "$_psr" && grep -qx '# ours' "$_psr" || _v2b="$_v2b [block or own lines missing]"
8546
+ [ "$(grep -n -- '^- infra$' "$_psr" | cut -d: -f1)" -gt "$(grep -n '^+ infra$' "$_psr" | cut -d: -f1)" ] || _v2b="$_v2b [the project's own line is not below the block]"
8547
+ cp "$_psr" "$_psx/a"; ( cd "$_v2p" && bash "$PSH" --apply strict ) >/dev/null 2>&1; cmp -s "$_psr" "$_psx/a" || _v2b="$_v2b [a second apply changed the file]"
8548
+ [ "$( cd "$_v2p" && bash "$PSH" --show )" = strict ] || _v2b="$_v2b [--show]"
8549
+ # The gate follows it: review on sonnet is refused under strict, and the project's `- infra` undoes the preset's `+ infra`.
8550
+ _v2call crew-review-agent sonnet 'src/ui/a.ts' audit none; { [ "$_v2rc" = 2 ] && grep -q 'floor crew-review-agent \* \* opus' "$_v2/err"; } || _v2b="$_v2b [review on sonnet under strict: rc $_v2rc]"
8551
+ _v2call crew-backend-expert sonnet 'infra/main.tf' feature 'test -d src'; [ "$_v2rc" = 0 ] || _v2b="$_v2b [infra stayed critical though the project removed it: rc $_v2rc]"
8552
+ _v2call crew-backend-expert sonnet 'deploy/run.sh' feature 'test -d src'; [ "$_v2rc" = 2 ] || _v2b="$_v2b [deploy is not critical under strict: rc $_v2rc]"
8553
+ ( cd "$_v2p" && bash "$PSH" --apply solo ) >/dev/null 2>&1; ! grep -q '^+ deploy' "$_psr" && grep -qx 'verify npm test' "$_psr" && grep -qx -- '- infra' "$_psr" || _v2b="$_v2b [switching to solo left the old block or lost the own line]"
8554
+ # Invalid presets: each refused with rc 1, the file untouched.
8555
+ cp "$_psr" "$_psx/b"; _bad=0
8556
+ for _bj in '{"name":"x","oops":1}' '{"name":"x","floors":[["a","b","c","gpt"]]}' '{"name":"x","verify":["npm test; rm -rf /"]}' '{"name":"X Y"}' 'not json'; do
8557
+ printf '%s' "$_bj" > "$_psx/bad.json"; ( cd "$_v2p" && bash "$PSH" --apply "$_psx/bad.json" ) >/dev/null 2>&1; _rc=$?
8558
+ { [ "$_rc" = 1 ] && cmp -s "$_psr" "$_psx/b"; } || { _bad=$((_bad+1)); _v2b="$_v2b [accepted or wrote: $_bj rc $_rc]"; }
8559
+ done
8560
+ ( cd "$_v2p" && bash "$PSH" --apply no-such-preset ) >/dev/null 2>&1 && _v2b="$_v2b [a preset that is not there applied]"
8561
+ _dl="$(cd "$_v2p" && bash "$ROOT/eval/doctor.sh" . 2>/dev/null | grep -c 'organisation preset: solo')"; [ "$_dl" = 1 ] || _v2b="$_v2b [doctor did not name the preset: $_dl]"
8562
+ [ -z "$_v2b" ] && pass "an organisation preset is a block at the top of crew-model-rules: the project's own lines stay below it and win (its '- infra' undoes the preset's), the gate follows it (review held to opus under strict), applying again changes no byte, switching replaces the block, five invalid presets and a missing one write nothing, and doctor names the one in force" \
8563
+ || fail "presets:$_v2b"
8564
+ rm -rf "$_psx"
8446
8565
 
8447
8566
  # ---- 3. verify, one fix inside the agent, then one model up, then the user -----------------------------------
8448
8567
  _v2new; _v2b=""
@@ -8773,13 +8892,16 @@ grep -q '^verify=none$' "$(ls -t "$_v2p/.claude/state/crew-model/pending/"* 2>/d
8773
8892
  mkdir -p "$_v2p/.claude/state/crew-model/results"
8774
8893
  printf 'verify=blocked\nwhynot=it is not a test or build command on this hook'"'"'s list, and no rule names it\ncmd=node tools/build.mjs --check\n' > "$_v2p/.claude/state/crew-model/results/r1"
8775
8894
  printf 'verify=blocked\nwhynot=it is not a test or build command on this hook'"'"'s list, and no rule names it\ncmd=actionlint (if installed)\n' > "$_v2p/.claude/state/crew-model/results/r2"
8895
+ # The other way of saying "nothing allows it" (default mode), for a command in a sub-folder: counted, and offered as
8896
+ # the rule that matches it, the command without its `cd` (measured on Windows: the line with the cd in it did not).
8897
+ printf "verify=blocked\nwhynot=in 'default' mode Claude Code does not run a command the user has not allowed, so neither does a hook\ncmd=cd sub && ./check.sh\n" > "$_v2p/.claude/state/crew-model/results/r3"
8776
8898
  if [ -f "$ROOT/eval/doctor.sh" ]; then
8777
8899
  mkdir -p "$_v2p/.claude/eval"; cp -R "$ROOT/eval/." "$_v2p/.claude/eval/" 2>/dev/null
8778
8900
  _dd="$( cd "$_v2p" && env -u CLAUDE_CODE_SUBAGENT_MODEL CREW_NO_STAR=1 CREW_LANG=en CLAUDE_CONFIG_DIR="$_v2/home" bash .claude/eval/doctor.sh 2>/dev/null | grep 'verify commands never run' )"
8779
- case "$_dd" in *'2 time(s)'*'verify node tools/build.mjs --check'*) case "$_dd" in *actionlint*) _v2b="$_v2b [doctor offered a sentence as a rule]" ;; esac ;;
8901
+ case "$_dd" in *'3 time(s)'*'verify node tools/build.mjs --check'*'verify ./check.sh'*) case "$_dd" in *actionlint*|*'verify cd '*) _v2b="$_v2b [doctor offered a sentence, or a rule with its cd, as a rule: ${_dd}]" ;; esac ;;
8780
8902
  *) _v2b="$_v2b [doctor: ${_dd:-no line}]" ;; esac
8781
8903
  fi
8782
- [ -z "$_v2b" ] && pass "a verify command in a sub-folder runs there: 'cd sub && <command>' passes, judged as the command alone; a folder outside the project or missing, an absolute one, and a chain after it stay refused; 'none (why)' is none; doctor offers the refused commands as rules, not a sentence" \
8904
+ [ -z "$_v2b" ] && pass "a verify command in a sub-folder runs there: 'cd sub && <command>' passes, judged as the command alone; a folder outside the project or missing, an absolute one, and a chain after it stay refused; 'none (why)' is none; doctor offers the refused commands, in either mode, as the rule that matches them (no cd prefix), not a sentence" \
8783
8905
  || fail "verify in a sub-folder:$_v2b"
8784
8906
  # Which record is whose. Two calls of one type made together are matched by order, which can be wrong; the card in
8785
8907
  # the agent's own transcript settles it.
@@ -8853,7 +8975,7 @@ _kn="$(awk -F'\t' '$2 == "k2" { print $5 "/" $13 }' "$_v2st/../model-outcomes.ts
8853
8975
  # The gate's and the write-time check's refusals are in the gate log; an agent id that cannot be read does not open
8854
8976
  # the write-time check; run_in_background named twice is refused.
8855
8977
  _v2new; _v2b=""; _V2ENV="CREW_GATE_LOG=$_v2/gl.tsv"; : > "$_v2/gl.tsv"
8856
- _v2call crew-database-expert sonnet 'src/payments/p.ts' feature none; _v2call crew-backend-expert haiku 'src/ui/a.ts' text none
8978
+ _v2call crew-database-expert sonnet 'src/payments/p.ts' feature none; _v2call crew-backend-expert haiku 'src/ui/a.ts' fix-known none
8857
8979
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"nobody-known","agent_type":"x"'
8858
8980
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"","agent_type":"x"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b an-empty-agent-id-opened-the-check:$_v2wrc"
8859
8981
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"a b/../c","agent_type":"x"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b an-unreadable-agent-id-opened-the-check:$_v2wrc"
@@ -8997,11 +9119,11 @@ _v2call crew-backend-expert sonnet 'src/payments/p.ts' feature none; [ "$_v2rc"
8997
9119
  # What a lowering cannot reach: the floors the card and the agent carry. Two raised floors are planted and lowered;
8998
9120
  # the model below still fails where another rule holds it, and passes where only the lowered floor did.
8999
9121
  _v2new; _v2b=""; mkdir -p "$_v2p/.claude/state"
9000
- printf 'crew-backend-expert\ttext\tnormal\tsonnet\t6\t3\t2026-01-01T00:00:00Z\ncrew-database-expert\tfeature\tnormal\topus\t6\t3\t2026-01-01T00:00:00Z\n' > "$_v2fl"
9001
- _o="$(_v2say '/crew-loosen crew-backend-expert text normal')$(_v2say '/crew-loosen crew-database-expert feature normal')"
9122
+ printf 'crew-backend-expert\tfix-known\tnormal\tsonnet\t6\t3\t2026-01-01T00:00:00Z\ncrew-database-expert\tfeature\tnormal\topus\t6\t3\t2026-01-01T00:00:00Z\n' > "$_v2fl"
9123
+ _o="$(_v2say '/crew-loosen crew-backend-expert fix-known normal')$(_v2say '/crew-loosen crew-database-expert feature normal')"
9002
9124
  case "$_o" in *'from sonnet to haiku'*'from opus to sonnet'*) ;; *) _v2b="$_v2b FIXTURE:the-two-floors-were-not-lowered" ;; esac
9003
- _v2call crew-backend-expert haiku 'src/ui/f1.ts' text true; [ "$_v2rc" = 0 ] || _v2b="$_v2b the-lowered-class-is-still-refused-on-haiku:$_v2rc"
9004
- _v2call crew-backend-expert haiku 'src/ui/f1.ts' text none; [ "$_v2rc" = 2 ] || _v2b="$_v2b verify-none-went-to-haiku-after-a-lowering"
9125
+ _v2call crew-backend-expert haiku 'src/ui/f1.ts' fix-known true; [ "$_v2rc" = 0 ] || _v2b="$_v2b the-lowered-class-is-still-refused-on-haiku:$_v2rc"
9126
+ _v2call crew-backend-expert haiku 'src/ui/f1.ts' fix-known none; [ "$_v2rc" = 2 ] || _v2b="$_v2b verify-none-went-to-haiku-after-a-lowering"
9005
9127
  _v2call crew-database-expert sonnet 'src/ui/f2.ts' feature true; [ "$_v2rc" = 0 ] || _v2b="$_v2b the-lowered-class-is-still-refused-on-sonnet:$_v2rc"
9006
9128
  _v2call crew-database-expert haiku 'src/ui/f2.ts' feature true; { [ "$_v2rc" = 2 ] && grep -q 'runs on sonnet or above' "$_v2/err"; } || _v2b="$_v2b an-agents-own-floor-was-lowered"
9007
9129
  _v2call crew-database-expert sonnet 'db/migrations/1.sql' feature true ',"run_in_background":false'; [ "$_v2rc" = 2 ] || _v2b="$_v2b critical-work-went-below-opus-after-a-lowering"
@@ -11247,7 +11369,7 @@ sec "== 14c) the 3.0 rename left no old name behind — outside history and the
11247
11369
  if [ -n "$SGR" ] && [ -d "$SGR/packaging" ] && [ -f "$SGR/VERSION" ] && [ -d "$SGR/kit" ] && [ -f "$SGR/packaging/build-plugin.sh" ]; then
11248
11370
  RN_ALLOW='CHANGELOG.md 196 history: every entry before 3.0 keeps the name it shipped under
11249
11371
  README*.md site/content/*/install.md 4 migration: the 2.x plugin note names the plugin to uninstall — one line in each README and install page, EN + TR
11250
- evals/results/* 6 history: recorded eval runs stay byte-for-byte
11372
+ evals/results/* packaging/review-bench/cases/* packaging/review-bench/results/* packaging/advisor-bench/results/* 729 history: recorded eval runs, and the review bench'"'"'s cases (past versions of Crewforth'"'"'s own files) and results, and the advisor bench'"'"'s results on them, stay byte-for-byte
11251
11373
  adopt.sh kit/legacy-blobs.tsv 347 migration: finds and moves 2.x names (components, CLAUDE.md, board, auto-mode rules, variables), and the generated list of the 1.x/2.x files it may move
11252
11374
  bin/cli.js 4 migration: add accepts a typed <x>-csk and moves an add record written under the old names
11253
11375
  evals/run.sh 4 compat: reads the 2.x trusted eval parent when the 3.0 one is absent — removed in 4.0
@@ -79,28 +79,7 @@ MSG="$(cat "$CACHE")
79
79
  Board state above is a cached snapshot; $_BS (Bash tool, not PowerShell — or the user can type /crew-board sync) refreshes it."
80
80
 
81
81
  # ONE PATH, no jq. The JSON is built here on every machine, so a Mac with jq and a Windows box without it emit the
82
- # same bytes. The escaper is jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL,
83
- # UTF-8, leading/trailing newlines, 5000 chars): 15/15 equal to `jq -cn --arg m`. The one it replaced escaped only
84
- # the quote, the backslash and the newline, so a tab in an item title produced JSON the CLI cannot parse
85
- # (measured: jq rc=5) and the board silently vanished from the session — on exactly the no-jq machine this
86
- # branch existed for. The trailing newline on the printf closes the last record, so a value ending in a newline
87
- # keeps it. One awk, no subshell per character. A CR that ENDS a line is dropped on purpose: it is a CRLF line
88
- # ending, not content, and MSYS gawk already drops it on read (text mode) while BSD awk keeps it — measured on
89
- # Windows. Stripping it here makes every OS emit the same bytes; a CR inside a line is still escaped as \r.
90
- ESC="$(printf '%s\n' "$MSG" | LC_ALL=C awk 'BEGIN { ORS = ""
91
- for (i = 1; i < 32; i++) ctl[i] = sprintf("%c", i)
92
- nm[8] = "\\b"; nm[9] = "\\t"; nm[12] = "\\f"; nm[13] = "\\r" }
93
- NR > 1 { print "\\n" }
94
- { s = $0; sub(/\r$/, "", s)
95
- gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
96
- for (i = 1; i < 32; i++) {
97
- if (i == 10 || !index(s, ctl[i])) continue
98
- r = (i in nm) ? nm[i] : sprintf("\\u%04x", i)
99
- out = ""; while ((p = index(s, ctl[i])) > 0) { out = out substr(s, 1, p - 1) r; s = substr(s, p + 1) }
100
- s = out s
101
- }
102
- d = sprintf("%c", 127)
103
- if (index(s, d)) { out = ""; while ((p = index(s, d)) > 0) { out = out substr(s, 1, p - 1) "\\u007f"; s = substr(s, p + 1) }; s = out s }
104
- print s }')"
82
+ # same bytes, through Crewforth's one escaper (eval/lib/json-string.awk: jq-identical, measured; why, in its header).
83
+ ESC="$(printf '%s\n' "$MSG" | LC_ALL=C awk -f "$HERE/../eval/lib/json-string.awk")"
105
84
  printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$ESC"
106
85
  exit 0
@@ -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,6 +417,18 @@ _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
434
  _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
@@ -557,13 +574,10 @@ _am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor,
557
574
 
558
575
  # ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
559
576
  _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
577
+ local gl="" v=BLOCK
561
578
  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
579
+ declare -F crew_gate_log_write >/dev/null || return 0
580
+ crew_gate_log_write; gl="$_CREW_GL"; [ -n "$gl" ] || return 0
567
581
  printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
568
582
  }
569
583
 
@@ -594,6 +608,7 @@ _am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog
594
608
  # 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
595
609
  # explicit allow passes.
596
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
597
612
  # CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
598
613
  _AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
599
614
  'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
@@ -665,19 +680,9 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
665
680
  done <<< "$_AMSF"
666
681
  return 1
667
682
  }
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
- }
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; }
681
686
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
682
687
  local c="$1" pm="${2:-default}" f r rc k g
683
688
  _AMVW=""; _AMVB=""
@@ -759,6 +764,8 @@ _am_write_check(){ # $1 = the payload, $2 = the file about to be written -> ret
759
764
  if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
760
765
  fi
761
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
762
769
  _am_log "critical path: write by an agent not on opus"
763
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
764
771
  exit 2
@@ -832,9 +839,11 @@ if ! _am_card "$prompt"; then
832
839
  fi
833
840
  _am_state; _am_risk; _am_digest
834
841
  # ---- 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
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
836
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
837
- 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
838
847
  # A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
839
848
  _am_rules
840
849
  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
@@ -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" \