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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +118 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +10 -1
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +3 -3
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +13 -2
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-json.awk +4 -0
  28. package/kit/eval/lib/statusline.sh +1 -1
  29. package/kit/eval/lib/verify-cmd.sh +17 -0
  30. package/kit/eval/review-bench.sh +26 -0
  31. package/kit/eval/smoke-test.sh +208 -21
  32. package/kit/hooks/agent-outcome.sh +18 -0
  33. package/kit/hooks/board-sync.sh +2 -23
  34. package/kit/hooks/guard-agent-model.sh +72 -26
  35. package/kit/hooks/guard-bash.sh +49 -34
  36. package/kit/hooks/guard-write.sh +3 -7
  37. package/kit/hooks/precompact-snapshot.sh +75 -0
  38. package/kit/hooks/prompt-approval.sh +21 -0
  39. package/kit/hooks/session-rehydrate.sh +25 -2
  40. package/kit/hooks/trace-blocklist.txt +9 -0
  41. package/kit/settings.json +12 -0
  42. package/kit/skills/crew-code-review/SKILL.md +16 -0
  43. package/kit/skills/crew-map/SKILL.md +16 -5
  44. package/kit/skills/crew-review/SKILL.md +2 -1
  45. package/kit/skills/crew-ship/SKILL.md +3 -1
  46. package/kit/studio/README.md +21 -10
  47. package/kit/studio/server/index.js +74 -22
  48. package/kit/studio/server/lib/graph.js +9 -1
  49. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  50. package/kit/studio/server/lib/lan.js +87 -0
  51. package/kit/studio/server/lib/redact.js +60 -0
  52. package/kit/studio/server/lib/remote.js +104 -18
  53. package/kit/studio/server/lib/session.js +1 -1
  54. package/kit/studio/server/lib/terminal.js +51 -13
  55. package/kit/studio/server/lib/transcript.js +32 -0
  56. package/kit/studio/web/app.js +69 -7
  57. package/kit/studio/web/canvas.js +14 -2
  58. package/kit/studio/web/chat.js +32 -7
  59. package/kit/studio/web/graph-plan.js +3 -2
  60. package/kit/studio/web/map-plan.js +72 -3
  61. package/kit/studio/web/map.js +103 -10
  62. package/kit/studio/web/qr.js +200 -0
  63. package/kit/studio/web/style.css +34 -0
  64. package/kit/studio/web/tokens.css +6 -0
  65. package/package.json +1 -1
  66. package/start.sh +14 -1
@@ -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`
@@ -3625,6 +3665,8 @@ if [ "$_gc1" != 5 ] || [ "$_gc2" != 7 ]; then
3625
3665
  else
3626
3666
  gcj(){ printf '{"tool_name":"%s","permission_mode":"default","tool_input":{"command":"%s"},"cwd":"%s"}' "$1" "$2" "$_gcp"; }
3627
3667
  gwj(){ printf '{"tool_name":"Write","permission_mode":"default","tool_input":{"file_path":"%s/%s","content":"x"},"cwd":"%s"}' "$_gcp" "$1" "$_gcp"; }
3668
+ # commit-msg 15 -> 16 (2026-10-18): one more line in trace-blocklist.txt, the session-link trailer that reached
3669
+ # pushed commits; each line is one grep on every commit.
3628
3670
  GCT='guard-bash @@ 0 @@ 0 @@ Bash @@ git status --short
3629
3671
  guard-bash @@ 0 @@ 0 @@ Bash @@ ls -la
3630
3672
  guard-bash @@ 0 @@ 0 @@ Bash @@ git log --oneline -2 && git status --short | head; ls; cat CHANGELOG.md
@@ -3646,7 +3688,7 @@ guard-write @@ 0 @@ 0 @@ Write @@ .env
3646
3688
  guard-write @@ 2 @@ 0 @@ Write @@ .claude/hooks/guard-bash.sh
3647
3689
  guard-powershell @@ 0 @@ 0 @@ PowerShell @@ Get-ChildItem -Recurse src
3648
3690
  pre-commit @@ 0 @@ 22 @@ Script @@ -
3649
- commit-msg @@ 0 @@ 15 @@ Script @@ msg'
3691
+ commit-msg @@ 0 @@ 16 @@ Script @@ msg'
3650
3692
  _gcn=0; _gcbad=""; _gcrow=""
3651
3693
  while IFS= read -r _gl; do [ -z "$_gl" ] && continue
3652
3694
  _gh="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gr="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gb="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gt="${_gl%% @@ *}"; _gc="${_gl#* @@ }"
@@ -4492,6 +4534,41 @@ else fail "session-rehydrate git-hooks states read '$_got' (want '--UO---': no h
4492
4534
  rm -rf "$RHG"
4493
4535
  _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
4536
  [ "$_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"
4537
+ # V3: just before a compaction, precompact-snapshot.sh writes the state of the work from the files and the gates;
4538
+ # after it, session-rehydrate gives that state back, to the same session only. The snapshot never blocks the
4539
+ # compaction (PreCompact exit 2 would): rc 0 and silent in every case, a broken one included.
4540
+ PCS="$HOOKS/precompact-snapshot.sh"
4541
+ if [ ! -x "$PCS" ]; then fail "precompact-snapshot.sh missing/not executable"
4542
+ else
4543
+ PCD="$(mktemp -d)"; PCD="$(cd -P "$PCD" && pwd)"; _pco="$(mktemp)"; _pcr="$(mktemp)" # outside the repository: a file there is uncommitted
4544
+ ( 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
4545
+ mkdir -p "$PCD/.claude" "$PCD/.crewforth" "$PCD/docs"
4546
+ printf 'x\n' > "$PCD/plain.txt"; printf 'x\n' > "$PCD/odd\"na me.txt"; printf 'x\n' > "$PCD/ıİşğü.txt"
4547
+ printf '{\n "findings": [\n {\n "rule": "a"\n },\n {\n "rule": "b"\n }\n ]\n}\n' > "$PCD/.crewforth/review.json"
4548
+ printf 'BLOCK\t§4.2\tvendor name\t\n' > "$PCD/.git/crewforth-gate-log.tsv"
4549
+ 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=$?
4550
+ _snap="$PCD/.git/crewforth-session-snapshot.json"; _sb=""
4551
+ [ -f "$_snap" ] && IFS= read -r _sb < "$_snap"
4552
+ _rs(){ printf '{"session_id":"%s","source":"%s","cwd":"%s"}' "$1" "$2" "$PCD" | CLAUDE_PROJECT_DIR="$PCD" bash "$HOOKS/session-rehydrate.sh" 2>/dev/null; }
4553
+ _same="$(_rs s-1 compact)"; _other="$(_rs s-2 compact)"; _start="$(_rs s-1 startup)"
4554
+ printf '%s' "$_same" > "$_pcr"
4555
+ _b=""
4556
+ [ "$_pcrc" = 0 ] && [ ! -s "$_pco" ] || _b="$_b [rc $_pcrc, printed: $(head -c 120 "$_pco")]"
4557
+ 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}]"
4558
+ 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
4559
+ case "$_same" in *'Before this context was compacted'*'review 2'*) ;; *) _b="$_b [not given back after compaction: ${_same:0:120}]" ;; esac
4560
+ 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]"
4561
+ case "$_other$_start" in *'Before this context was compacted'*) _b="$_b [given to another session or on a plain start]" ;; esac
4562
+ # Fail-open: no repository, a cwd that is not there, no JSON at all, and the hook alone without eval/lib.
4563
+ _nr="$(mktemp -d)"; _alone="$(mktemp -d)"; cp "$PCS" "$_alone/precompact-snapshot.sh"
4564
+ 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 $?]"
4565
+ printf '{"session_id":"s-9","cwd":"/nonexistent/x"}' | CLAUDE_PROJECT_DIR= bash "$PCS" >/dev/null 2>&1 || _b="$_b [missing cwd rc $?]"
4566
+ printf 'not json' | CLAUDE_PROJECT_DIR="$_nr" bash "$PCS" >/dev/null 2>&1 || _b="$_b [garbage rc $?]"
4567
+ 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 $?]"
4568
+ [ -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" \
4569
+ || fail "precompact snapshot:$_b"
4570
+ rm -rf "$PCD" "$_nr" "$_alone" "$_pco" "$_pcr"
4571
+ fi
4495
4572
  # Asked of the entry that carries session-rehydrate, not of the file: skill-trust's entry has the same matcher.
4496
4573
  _rw(){ _n="$(awk -v op=len -v path=hooks.SessionStart -f "$ROOT/eval/lib/settings-json.awk" "$1" 2>/dev/null)"; _i=0
4497
4574
  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 +6457,14 @@ GNI="$(mktemp -d)"; ( cd "$GNI" && git init -q . && mkdir -p .claude )
6380
6457
  ( cd "$GNI" && gjson 'git reset --hard' | bash "$ROOT/hooks/guard-bash.sh" >/dev/null 2>&1 )
6381
6458
  [ -e "$GNI/.claude/gate-log.tsv" ] && fail "gate log wrote into a repo where the path is not gitignored" \
6382
6459
  || pass "gate log declines a path git would track"
6460
+ # ...and since 3.2.0 writes inside the repository's .git instead, which is never committed: a project that tracks
6461
+ # .claude/ had no record of a refusal at all (seen on a real project: no 🛡, no note of a lowered model).
6462
+ # The readers find it there (eval/lib/gate-log.sh, the one rule), and `git status` stays clean.
6463
+ _gni_read="$( cd "$GNI" && . "$ROOT/eval/lib/gate-log.sh" && crew_gate_log_read && printf '%s' "$_CREW_GL" )"
6464
+ _gni_st="$( cd "$GNI" && git status --porcelain --untracked-files=all 2>/dev/null | grep -c gate-log )"
6465
+ { [ -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 ]; } \
6466
+ && pass "where git would track it, the gate log goes inside .git: written, found by the readers, and git status stays clean" \
6467
+ || 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
6468
  ( cd "$GNI" && echo '.claude/' > .gitignore && gjson 'git reset --hard' | bash "$ROOT/hooks/guard-bash.sh" >/dev/null 2>&1 )
6384
6469
  [ -s "$GNI/.claude/gate-log.tsv" ] && pass "gate log writes once the path is gitignored" \
6385
6470
  || fail "gate log stayed silent even though the path is ignored"
@@ -7944,6 +8029,48 @@ _paq_pre auto '{"question":"Which colour?","header":"Colour","options":[{"label"
7944
8029
  if [ "$_paqn" != 18 ]; then fail "FIXTURE: the approval-question table ran $_paqn cases, not 18"
7945
8030
  elif [ -z "$_paqbad" ]; then pass "the approval question: asked (ask) only as the fixed text, in auto, attended, with no answers; its answer recorded only for the call that was asked, once, and only as one of the four; the record allows the commit (18 cases, 13 must-fail)"
7946
8031
  else fail "the approval question:$_paqbad"; fi
8032
+ # ...and the way to it. B-2 (a real `auto` session, 2026-10-17): /crew-ship asked for /crew-approve before any commit
8033
+ # was refused, so the refusal that prints the question never came and the click was never offered; telling the agent
8034
+ # to try the commit first did not help either (§4.4 says not to, and it did not). agent-outcome.sh now hands the
8035
+ # question over when crew-commit-agent is called; the text pinned here only says to ask it.
8036
+ _cqa="$AGENTS/crew-commit-agent.md"; _cqs="$SKILLS/crew-ship/SKILL.md"
8037
+ if [ ! -f "$_cqa" ] || [ ! -f "$_cqs" ]; then skip scope "the way to the approval question: crew-commit-agent or crew-ship is not installed here"
8038
+ elif grep -q 'question (below): ask it, with the message, instead of' "$_cqa" && grep -q 'one-click approval question when crew-commit-agent is called' "$_cqs"; then
8039
+ pass "in auto the commit agent and /crew-ship ask the one-click question Crewforth hands over, before asking for /crew-approve"
8040
+ else fail "the commit agent or /crew-ship asks for /crew-approve before the one-click question Crewforth hands over (B-2)"; fi
8041
+ # The hand-over itself, run: agent-outcome.sh on PostToolUse of an Agent call. In `auto`, for crew-commit-agent, with
8042
+ # both halves of the question wired and no PermissionRequest hook, it adds the fixed question (_CREW_Q_JSON, read from
8043
+ # prompt-approval.sh) to the session's context; in any other mode, for any other agent, or with such a hook, nothing.
8044
+ _aq="$(mktemp -d)"; _aq="$(cd -P "$_aq" && pwd)"; mkdir -p "$_aq/p/.claude/state" "$_aq/ccd" "$_aq/home"
8045
+ printf '%s' '{"hooks":{"PreToolUse":[{"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"bash .claude/hooks/guard-question.sh"}]}],"PostToolUse":[{"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}' > "$_aq/p/.claude/settings.json"
8046
+ _aq_run(){ printf '{"hook_event_name":"PostToolUse","tool_name":"Agent","permission_mode":"%s","session_id":"s","tool_use_id":"toolu_aq1","cwd":"%s","tool_input":{"subagent_type":"%s","description":"d","prompt":"p"},"tool_response":{"status":"async_launched","agentId":"aq123abc"}}' "$1" "$_aq/p" "$2" > "$_aq/pl.json"
8047
+ ( cd "$_aq/p" && env -u CREW_MODEL_ROUTING CLAUDE_PROJECT_DIR="$_aq/p" CLAUDE_CONFIG_DIR="$_aq/ccd" HOME="$_aq/home" CREW_GATE_LOG=/dev/null bash "$HOOKS/agent-outcome.sh" < "$_aq/pl.json" 2>/dev/null ); }
8048
+ _aqbad=""; _aqo="$(_aq_run auto crew-commit-agent)"
8049
+ case "$_aqo" in *"$_paq_text"*) ;; *) _aqbad="$_aqbad [auto, crew-commit-agent: the question was not handed over]" ;; esac
8050
+ if [ -n "$JSONQ" ] && [ -n "$_aqo" ]; then printf '%s' "$_aqo" | json_ok || _aqbad="$_aqbad [its output is not JSON]"; fi
8051
+ for _aqc in default:crew-commit-agent dontAsk:crew-commit-agent acceptEdits:crew-commit-agent auto:crew-review-agent; do
8052
+ [ -z "$(_aq_run "${_aqc%%:*}" "${_aqc#*:}")" ] || _aqbad="$_aqbad [${_aqc%%:*}, ${_aqc#*:}: handed the question over]"
8053
+ done
8054
+ printf '%s' '{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"x"}]}]}}' > "$_aq/ccd/settings.json"
8055
+ [ -z "$(_aq_run auto crew-commit-agent)" ] || _aqbad="$_aqbad [a PermissionRequest hook is wired: the question was still handed over]"
8056
+ rm -rf "$_aq"
8057
+ [ -z "$_aqbad" ] && pass "in auto, calling crew-commit-agent hands the session the fixed one-click question; not in default, acceptEdits or dontAsk, not for another agent, not with a PermissionRequest hook" \
8058
+ || fail "the one-click question hand-over:$_aqbad"
8059
+ # ONE WAY TO ASK in the refusal (2026-10-18, a real `auto` session on rc.3: the refusal offered the question and the
8060
+ # typed command as equals, and the session told the user to type 7 times in 9). With the question open the typed way is
8061
+ # printed only as the fallback for a question that cannot be asked; with it closed (dontAsk), only the typed way.
8062
+ _pa_new
8063
+ ( cd "$_paw" && printf '%s' '{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}],"PreToolUse":[{"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"bash .claude/hooks/guard-question.sh"}]}],"PostToolUse":[{"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}' > .claude/settings.json )
8064
+ _oabad=""
8065
+ _paq_gate auto 'git commit -m x'
8066
+ case "$_pae" in *'"questions":['*'do not also give the user a command to type.'*'ONLY IF THAT QUESTION CANNOT BE ASKED'*) ;; *) _oabad="$_oabad [auto, question open: not the click first and the typed way as its fallback]" ;; esac
8067
+ case "$_pae" in *'ASK THE USER NOW'*|*'the way below works as well'*) _oabad="$_oabad [auto, question open: the typed way is still offered as an equal]" ;; esac
8068
+ _paq_gate dontAsk 'git commit -m x'
8069
+ case "$_pae" in *'ASK THE USER NOW'*'/crew-approve'*) ;; *) _oabad="$_oabad [dontAsk: the typed way is not asked for]" ;; esac
8070
+ case "$_pae" in *'"questions":['*|*'ONLY IF THAT QUESTION'*) _oabad="$_oabad [dontAsk: the question is offered]" ;; esac
8071
+ _pa_new
8072
+ [ -z "$_oabad" ] && pass "a refused commit in auto offers the click and only falls back to the typed /crew-approve when the question cannot be asked; in dontAsk only the typed way" \
8073
+ || fail "one way to ask:$_oabad"
7947
8074
 
7948
8075
  # A PermissionRequest HOOK, wherever this can read one, closes the question AND the gate's own prompt. Each source is
7949
8076
  # planted alone in an otherwise clean fixture; the clean fixture itself must answer "none" (calibration).
@@ -8268,21 +8395,26 @@ done <<'V2'
8268
8395
  2|crew-backend-expert|sonnet|prisma/schema.prisma|feature|none|
8269
8396
  2|crew-backend-expert|sonnet|config/SECRETS_local.env|text|none|
8270
8397
  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|
8398
+ 0|crew-backend-expert|sonnet|legal/terms-of-service.md|text|none|
8399
+ 0|crew-backend-expert|sonnet|docs/privacy-policy.md|text|none|
8273
8400
  2|crew-backend-expert|sonnet|src/PrivacyController.cs|feature|none|
8274
8401
  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|
8402
+ 0|crew-backend-expert|sonnet|content/terms/index.md|text|none|
8403
+ 0|crew-backend-expert|sonnet|fastlane/metadata/en-US/description.txt|text|none|
8404
+ 0|crew-backend-expert|sonnet|store/listing.md|text|none|
8405
+ 0|crew-backend-expert|sonnet|metadata/android/en-US/full_description.txt|text|none|
8279
8406
  0|crew-backend-expert|opus|legal/terms-of-service.md|text|none|
8407
+ 2|crew-backend-expert|haiku|legal/terms-of-service.md|text|none|
8408
+ 2|crew-backend-expert|sonnet|legal/terms-of-service.md, src/legal/consent.ts|text|none|
8409
+ 2|crew-backend-expert|sonnet|legal/terms-of-service.md|feature|none|
8280
8410
  0|crew-backend-expert|sonnet|src/searchTerms.ts|feature|none|
8281
8411
  0|crew-backend-expert|sonnet|src/store/index.ts|feature|none|
8282
8412
  0|crew-backend-expert|sonnet|fastlane/metadata/en-US/images/icon.png|text|none|
8283
8413
  0|crew-backend-expert|sonnet|docs/legalese.md|text|none|
8284
8414
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|none|
8285
- 2|crew-backend-expert|haiku|src/ui/a.ts|text|none|
8415
+ 0|crew-backend-expert|haiku|src/ui/a.ts|text|none|
8416
+ 2|crew-backend-expert|haiku|src/ui/a.ts|fix-known|none|
8417
+ 0|crew-commit-agent|haiku|src/ui/a.ts|fix-known|none|
8286
8418
  0|crew-backend-expert|sonnet|src/ui/a.ts|text|none|
8287
8419
  0|crew-backend-expert|haiku|src/ui/a.ts|text|true|
8288
8420
  0|crew-test-expert|haiku|src/ui/**|test-run|true|
@@ -8314,8 +8446,8 @@ done <<'V2'
8314
8446
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|git commit -m x|
8315
8447
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|rm -rf /|
8316
8448
  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)"
8449
+ if [ "$_v2n" != 65 ]; then fail "FIXTURE: the card table has $_v2n rows, not 65"
8450
+ 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
8451
  else fail "card table:$_v2b"; fi
8320
8452
  # 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
8453
  # replaced, PreToolUse updatedInput), unless the card's next line says `above: <why>`. Measured on a real project:
@@ -8396,20 +8528,46 @@ for _pr in 'Do the work.' 'Please do this:\nfiles: a\nchange: feature\nverify: t
8396
8528
  done
8397
8529
  [ -z "$_v2b" ] && pass "a crew call with no card, a card that is not at the top, a card with a line missing or one named twice is refused, with the three lines to write" \
8398
8530
  || fail "a task without a proper card passed:$_v2b"
8531
+ # The card on one line (2026-10-18: 17 of 39 card refusals on this machine were `files: … · change: … · verify: …`). It is
8532
+ # read by its labels and held to the same rules: what passes on three lines passes on one, and what fails there fails.
8533
+ _v2b=""; _v2one(){ printf '{"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"prompt":"%s\\n\\nDo it.","subagent_type":"crew-backend-expert","model":"%s"}}' "$1" "$2" > "$_v2/one.json"
8534
+ ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/one.json" >"$_v2/oneout" 2>"$_v2/err" ); _rc=$?; }
8535
+ for _pr in 'files: src/ui/a.ts · change: feature · verify: true' 'files: src/ui/a.ts; change: feature; verify: true' 'files: src/ui/a.ts | change: feature | verify: true' \
8536
+ 'Card: files: src/ui/a.ts, src/ui/b.ts · change: feature · verify: true.' 'Card — files: src/ui/a.ts · change: text · verify: none'; do
8537
+ _v2one "$_pr" sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [refused: $_pr → $_rc $(head -c 120 "$_v2/err")]"
8538
+ done
8539
+ _v2one 'files: src/payments/p.ts · change: feature · verify: true' sonnet
8540
+ { [ "$_rc" = 2 ] && ! grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [a one-line card on a critical path was not read as one (rc $_rc)]"
8541
+ _v2one 'files: src/ui/a.ts · change: feature · verify: true · above: the parser is subtle' opus
8542
+ { [ "$_rc" = 0 ] && ! grep -q 'runs on sonnet' "$_v2/oneout"; } || _v2b="$_v2b [above: on the same line was not read (rc $_rc)]"
8543
+ _v2one 'files: src/ui/a.ts · change: feature (a small one) · verify: true' sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [a kind with a note in parentheses, one line → $_rc]"
8544
+ _v2one 'files: src/ui/a.ts · change: architecture (planning only) · verify: none' sonnet
8545
+ { [ "$_rc" = 2 ] && grep -q 'runs on opus' "$_v2/err"; } || _v2b="$_v2b [architecture (planning only) was not read as architecture, which runs on opus (rc $_rc)]"
8546
+ _v2one 'files: src/ui/a.ts\nchange: refactor (no behaviour change)\nverify: true' sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [a kind with a note in parentheses, three lines → $_rc]"
8547
+ for _pr in 'files: a · change: archi (planning only) · verify: none' 'files: a · change: feature extra · verify: none' 'files: a · verify: true · change: text' 'files: a · change: feature' 'Do this. files: a · change: feature · verify: true'; do
8548
+ _v2one "$_pr" sonnet; { [ "$_rc" = 2 ] && grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [passed: $_pr → $_rc]"
8549
+ done
8550
+ [ -z "$_v2b" ] && pass "a card on one line (· ; | separators, a Card: lead, above: on the line) is read by its labels and held to the three-line rules: a critical path still needs opus; a kind with a note in parentheses is the kind, on one line or three; a bad change, labels out of order, a missing verify or prose first are refused" \
8551
+ || fail "the one-line card:$_v2b"
8399
8552
 
8400
8553
  # ---- at write time: the card can leave a file out --------------------------------------------------------------
8401
8554
  _v2new; _v2b=""
8402
8555
  _v2call crew-backend-expert sonnet 'src/ui/a.ts' refactor true; _v2start ag-son
8403
8556
  _v2call crew-backend-expert opus 'src/payments/p.ts' feature none; _v2start ag-op
8557
+ _v2call crew-docs-agent sonnet 'legal/terms.md' text none; _v2start ag-txt crew-docs-agent
8404
8558
  _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
8559
  _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
8560
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-op","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b opus-agent-refused:$_v2wrc"
8407
8561
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-unknown","agent_type":"general-purpose"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b unknown-model-wrote-payments:$_v2wrc"
8408
8562
  _v2write "$_v2p/src/payments/p.ts"; [ "$_v2wrc" = 0 ] || _v2b="$_v2b the-session-refused:$_v2wrc"
8563
+ # A text change on sonnet writes prose on a critical path; not code there, and not prose for any other change.
8564
+ _v2write "$_v2p/legal/terms.md" ',"agent_id":"ag-txt","agent_type":"crew-docs-agent"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b sonnet-text-refused-legal-prose:$_v2wrc"
8565
+ _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"
8566
+ _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
8567
  mkdir -p "$_v2/t/subagents"; printf '{"type":"assistant","message":{"model":"claude-opus-5-5"}}\n' > "$_v2/t/subagents/agent-ag-gp.jsonl"
8410
8568
  _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
8569
  _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" \
8570
+ [ -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
8571
  || fail "write-time check:$_v2b"
8414
8572
  # 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
8573
  # log naming the path and the rule. A write elsewhere, one outside the project, and one with routing off add none.
@@ -8443,6 +8601,32 @@ _v2call crew-backend-expert sonnet 'docs/security.md' text none; [ "$_v2rc" = 0
8443
8601
  _v2call crew-backend-expert sonnet 'Api/Services/AuthorService.cs' feature none; [ "$_v2rc" = 0 ] || _v2b="$_v2b AuthorService-read-as-auth"
8444
8602
  [ -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
8603
  || fail "critical paths:$_v2b"
8604
+ # V5: an organisation preset (eval/lib/preset.sh) is a block at the top of crew-model-rules. The project's own lines
8605
+ # stay below it and win where the file's rule lets them; applying again changes nothing; an invalid one writes nothing.
8606
+ _v2new; _v2b=""; PSH="$ROOT/eval/lib/preset.sh"; _psr="$_v2p/.claude/crew-model-rules"; _psx="$(mktemp -d)"
8607
+ printf '# ours\n- infra\n' > "$_psr"
8608
+ ( cd "$_v2p" && bash "$PSH" --apply strict ) >/dev/null 2>&1 || _v2b="$_v2b [strict did not apply]"
8609
+ { IFS= read -r _l1 < "$_psr"; case "$_l1" in '# ---- preset: strict '*) ;; *) false ;; esac; } || _v2b="$_v2b [first line: ${_l1:-none}]"
8610
+ 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]"
8611
+ [ "$(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]"
8612
+ 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]"
8613
+ [ "$( cd "$_v2p" && bash "$PSH" --show )" = strict ] || _v2b="$_v2b [--show]"
8614
+ # The gate follows it: review on sonnet is refused under strict, and the project's `- infra` undoes the preset's `+ infra`.
8615
+ _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]"
8616
+ _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]"
8617
+ _v2call crew-backend-expert sonnet 'deploy/run.sh' feature 'test -d src'; [ "$_v2rc" = 2 ] || _v2b="$_v2b [deploy is not critical under strict: rc $_v2rc]"
8618
+ ( 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]"
8619
+ # Invalid presets: each refused with rc 1, the file untouched.
8620
+ cp "$_psr" "$_psx/b"; _bad=0
8621
+ 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
8622
+ printf '%s' "$_bj" > "$_psx/bad.json"; ( cd "$_v2p" && bash "$PSH" --apply "$_psx/bad.json" ) >/dev/null 2>&1; _rc=$?
8623
+ { [ "$_rc" = 1 ] && cmp -s "$_psr" "$_psx/b"; } || { _bad=$((_bad+1)); _v2b="$_v2b [accepted or wrote: $_bj rc $_rc]"; }
8624
+ done
8625
+ ( cd "$_v2p" && bash "$PSH" --apply no-such-preset ) >/dev/null 2>&1 && _v2b="$_v2b [a preset that is not there applied]"
8626
+ _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]"
8627
+ [ -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" \
8628
+ || fail "presets:$_v2b"
8629
+ rm -rf "$_psx"
8446
8630
 
8447
8631
  # ---- 3. verify, one fix inside the agent, then one model up, then the user -----------------------------------
8448
8632
  _v2new; _v2b=""
@@ -8773,13 +8957,16 @@ grep -q '^verify=none$' "$(ls -t "$_v2p/.claude/state/crew-model/pending/"* 2>/d
8773
8957
  mkdir -p "$_v2p/.claude/state/crew-model/results"
8774
8958
  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
8959
  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"
8960
+ # The other way of saying "nothing allows it" (default mode), for a command in a sub-folder: counted, and offered as
8961
+ # the rule that matches it, the command without its `cd` (measured on Windows: the line with the cd in it did not).
8962
+ 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
8963
  if [ -f "$ROOT/eval/doctor.sh" ]; then
8777
8964
  mkdir -p "$_v2p/.claude/eval"; cp -R "$ROOT/eval/." "$_v2p/.claude/eval/" 2>/dev/null
8778
8965
  _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 ;;
8966
+ 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
8967
  *) _v2b="$_v2b [doctor: ${_dd:-no line}]" ;; esac
8781
8968
  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" \
8969
+ [ -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
8970
  || fail "verify in a sub-folder:$_v2b"
8784
8971
  # Which record is whose. Two calls of one type made together are matched by order, which can be wrong; the card in
8785
8972
  # the agent's own transcript settles it.
@@ -8853,7 +9040,7 @@ _kn="$(awk -F'\t' '$2 == "k2" { print $5 "/" $13 }' "$_v2st/../model-outcomes.ts
8853
9040
  # 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
9041
  # the write-time check; run_in_background named twice is refused.
8855
9042
  _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
9043
+ _v2call crew-database-expert sonnet 'src/payments/p.ts' feature none; _v2call crew-backend-expert haiku 'src/ui/a.ts' fix-known none
8857
9044
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"nobody-known","agent_type":"x"'
8858
9045
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"","agent_type":"x"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b an-empty-agent-id-opened-the-check:$_v2wrc"
8859
9046
  _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 +9184,11 @@ _v2call crew-backend-expert sonnet 'src/payments/p.ts' feature none; [ "$_v2rc"
8997
9184
  # What a lowering cannot reach: the floors the card and the agent carry. Two raised floors are planted and lowered;
8998
9185
  # the model below still fails where another rule holds it, and passes where only the lowered floor did.
8999
9186
  _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')"
9187
+ 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"
9188
+ _o="$(_v2say '/crew-loosen crew-backend-expert fix-known normal')$(_v2say '/crew-loosen crew-database-expert feature normal')"
9002
9189
  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"
9190
+ _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"
9191
+ _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
9192
  _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
9193
  _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
9194
  _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 +11434,7 @@ sec "== 14c) the 3.0 rename left no old name behind — outside history and the
11247
11434
  if [ -n "$SGR" ] && [ -d "$SGR/packaging" ] && [ -f "$SGR/VERSION" ] && [ -d "$SGR/kit" ] && [ -f "$SGR/packaging/build-plugin.sh" ]; then
11248
11435
  RN_ALLOW='CHANGELOG.md 196 history: every entry before 3.0 keeps the name it shipped under
11249
11436
  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
11437
+ 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
11438
  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
11439
  bin/cli.js 4 migration: add accepts a typed <x>-csk and moves an add record written under the old names
11253
11440
  evals/run.sh 4 compat: reads the 2.x trusted eval parent when the 3.0 one is absent — removed in 4.0
@@ -404,6 +404,24 @@ case "$EV" in
404
404
  fi
405
405
  fi
406
406
  fi
407
+ # THE ONE-CLICK APPROVAL, OFFERED WHEN THE COMMIT MESSAGE IS ASKED FOR (B-2). In `auto` the click used to reach the
408
+ # user only inside a refused commit, and a session that asked for /crew-approve first never tried one (§4.4 says
409
+ # not to), so it was never offered. A call to crew-commit-agent is when the message is on its way: the session is
410
+ # told then, in foreground and background alike (a background launch answers here at once). Whether the question
411
+ # is open, and its words, come from prompt-approval.sh, as the refusal's do. Its verify is `none` (change: text),
412
+ # so no outcome below is lost by answering here; the model it ran on is already kept above.
413
+ _json_slice "$INPUT" subagent_type >/dev/null
414
+ if [ "$_JS" = crew-commit-agent ] && [ -f "$_ao_here/prompt-approval.sh" ]; then
415
+ _json_slice "$INPUT" permission_mode >/dev/null; _AOPM="$_JS"
416
+ if [ "$_AOPM" = auto ]; then
417
+ declare -F _crew_q_open >/dev/null 2>&1 || . "$_ao_here/prompt-approval.sh"
418
+ if declare -F _crew_q_open >/dev/null 2>&1 && _crew_q_open auto; then
419
+ _ao_json "Crewforth: when you show the user the commit message this agent proposes, ask with one click before anything else. $_CREW_Q_ASK {\"questions\":[$_CREW_Q_JSON]} Their answer is recorded by Crewforth itself (the line it adds says so); then run the commit it names. If that question is refused, the user can type /crew-approve instead, as the refusal says."
420
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$_AOJ"
421
+ exit 0
422
+ fi
423
+ fi
424
+ fi
407
425
  R="$_AMD/results/$AID"
408
426
  if [ ! -f "$R" ]; then
409
427
  # No result yet: the agent was launched in the background (measured, 2.1.294 and 2.1.296: an Agent call that
@@ -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