crewforth 3.1.1 → 3.2.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -109,6 +109,10 @@ note(){ echo " · $1"; } # informational; never counts as a failure
109
109
  # Scope. Declared HERE rather than beside the block it first guards, because it now gates cases that run
110
110
  # EARLIER than that block — see §6h. Reading an env var costs nothing; the note stays where the big skip is.
111
111
  UNITS=1; [ "${CREW_SMOKE_SCOPE:-full}" = install ] && UNITS=0
112
+ # THE USER'S OWN CLAUDE SETTINGS ARE NOT AN INPUT OF THIS SUITE (3.2.0). Before a commit prompt the gates look for a
113
+ # PermissionRequest hook in the user's settings, skills, agents and plugins; on a machine that has one, every "the gate
114
+ # asks" case would read "the gate refuses". Cases that need a user directory set their own (HOME, CLAUDE_CONFIG_DIR).
115
+ _SMOKE_CCD="$(mktemp -d)"; export CLAUDE_CONFIG_DIR="$_SMOKE_CCD"
112
116
  # Trigger-phrases requirement: a GATE in the Crewforth repo, a note in an installed project (your skills, your call).
113
117
  need_trigger(){ if kit_owned "${2:-}"; then fail "$1"; else note "$1 (your own skill/agent; not gated in an install)"; fi; }
114
118
 
@@ -1670,8 +1674,8 @@ if [ "$IS_KIT" = 1 ]; then
1670
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")"
1671
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)"
1672
1676
  elif git -C "$KR" rev-parse --git-dir >/dev/null 2>&1; then
1673
- _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)"
1674
- [ -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)" \
1675
1679
  || fail "a tracked file still gives the old channel count (there are three): $(printf '%s\n' "$_ch4_hits" | head -3 | tr '\n' ' ')"
1676
1680
  else skip scope "channel-count check not run — not a git checkout of Crewforth's source"; fi
1677
1681
  # THE CLIENT SIDE IS STACK-NEUTRAL (3.0.1): the install summary, the help and the install page named the scope as
@@ -2328,7 +2332,7 @@ BUDGET_DISC=14975 # 3.1.0: 13986 → 14975 (+989): the task card (files, chan
2328
2332
  # and the wrong one winning silently. The only rule in this file that is about the OTHER
2329
2333
  # rules, so it cannot live in the README the way the compaction note does. Plus the Audit
2330
2334
  # row naming crew-performance-expert — an agent nothing routes to is an idle component.)
2331
- BUDGET_AGENTS=5596 # 5d.2: tightened to the measured sum (was 5800 with 204 B of slack). Before that: sum of agent frontmatter; currently 5582, measured 2026-09-23 (3.0: the backend and database
2335
+ BUDGET_AGENTS=5746 # 3.2.0: 5596 → 5746 (+150), the measured sum: crew-docs-agent is a 14th agent (195 B, its description cut to one line first; the other 13 sum to 5551). Raised on the user's decision, not to make room. 5d.2: tightened to the measured sum (was 5800 with 204 B of slack). Before that: sum of agent frontmatter; currently 5582, measured 2026-09-23 (3.0: the backend and database
2332
2336
  # agents' descriptions rewritten stack-agnostic, +55 B). Before that 5527, measured 2026-09-20 by reading this suite's
2333
2337
  # own printed line rather than a hand-rolled counter (a hand-rolled one answered 5503 and
2334
2338
  # was thrown away). Two corrections in one day: the note said 5765 against a measured 5407,
@@ -2521,20 +2525,34 @@ done
2521
2525
  # PROACTIVE-CUE GATE: Claude Code auto-delegates on the description field, and only fires reliably when it carries
2522
2526
  # an action cue ("use proactively" / "immediately after" / "use ... when"). A passive role description ("Senior X
2523
2527
  # expert. Handlers, endpoints.") rarely auto-invokes — the specialist stays dormant and Crewforth reads as inert.
2524
- # Every agent EXCEPT the two deliberately pull-only ones (invoked explicitly: a commit needs approval; session
2525
- # health is emitted by a hook) must carry a cue, or a future passive rewrite silently regresses delegation.
2526
- PULL_AGENTS=" crew-commit-agent crew-session-manager "
2528
+ # EVERY agent carries one. Two used to be exempt as "pull-only" (a commit needs approval; session health comes from
2529
+ # a hook), and nothing pulled them: the main thread wrote the commit message and the handover itself, and neither
2530
+ # agent was ever started. The exemption is gone; what pulls a chain-step agent is pinned just below.
2527
2531
  NO_CUE=""
2528
2532
  for f in $(agent_quality_files); do
2529
2533
  [ -e "$f" ] || continue
2530
2534
  a="$(basename "$f" .md)"
2531
- case "$PULL_AGENTS" in *" $a "*) continue ;; esac
2532
2535
  awk '/^---$/{c++; next} c==1' "$f" | grep -qiE "use proactively|immediately after|use [a-z ]*when" \
2533
2536
  || NO_CUE="$NO_CUE $a"
2534
2537
  done
2535
- if [ -z "$NO_CUE" ]; then pass "every non-pull agent carries an auto-delegation cue (proactive/immediately-after)"
2536
- elif [ "$IS_KIT" = 1 ]; then fail "agent(s) with a passive description — auto-delegation will rarely fire:$NO_CUE (add 'use proactively …', or add to PULL_AGENTS if pull-only)"
2538
+ if [ -z "$NO_CUE" ]; then pass "every agent carries an auto-delegation cue (proactive/immediately-after/use … when)"
2539
+ elif [ "$IS_KIT" = 1 ]; then fail "agent(s) with a passive description — auto-delegation will rarely fire:$NO_CUE (add 'use proactively …')"
2537
2540
  else pass "some agents lack a proactive cue:$NO_CUE (your project's own agents, not gated)"; fi
2541
+ # WHO PULLS A CHAIN-STEP AGENT. An agent whose work sits at a fixed step of the chain (metadata.stage close, handoff
2542
+ # or understand: the reviewer, the commit message, the handover, the plan) is not reached by a prompt's words; it is
2543
+ # reached because a command or the discipline CALLS it there. So each one is named in the call form, `@agent-<name>`
2544
+ # (or `subagent_type`), in a command skill or the discipline. Producers and auditors are pulled by route-hint.sh and
2545
+ # the cue above; the orphan check (3b) already holds that they are routed at all. Kit agents only.
2546
+ _cs=0; _csb=""; _cmdfiles="$(grep -l 'kind: command' "$SKILLS"/*/SKILL.md 2>/dev/null)"
2547
+ for f in "$AGENTS"/*.md; do
2548
+ [ -f "$f" ] || continue; a="$(basename "$f" .md)"; kit_owned "agents/$a.md" || continue
2549
+ case "$(awk '/^---$/{c++; next} c==1 && /^[[:space:]]*stage:/{print $2; exit}' "$f")" in close|handoff|understand) ;; *) continue ;; esac
2550
+ _cs=$((_cs+1))
2551
+ grep -qE "@agent-$a([^a-z0-9-]|\$)|subagent_type[^a-z]{0,6}$a([^a-z0-9-]|\$)" $_cmdfiles "$ROUTE_DOC" 2>/dev/null || _csb="$_csb $a"
2552
+ done
2553
+ if [ "$IS_KIT" = 1 ] && [ "$_cs" -lt 4 ]; then fail "FIXTURE: $_cs chain-step agents read (close/handoff/understand) — the stage field or the count broke, not the agents"
2554
+ elif [ -z "$_csb" ]; then pass "every chain-step agent ($_cs) is called as @agent-<name> by a command or the discipline"
2555
+ else fail "chain-step agent(s) nothing calls by name — the main thread does their work instead:$_csb"; fi
2538
2556
 
2539
2557
 
2540
2558
  # ---- gate UNIT cases: skipped under CREW_SMOKE_SCOPE=install ------------------------------------------------
@@ -2789,6 +2807,24 @@ r46rec "$R46_OID" "$R46_HEAD"
2789
2807
  o="$(gj default 'git commit -m x' | r46 2>/dev/null)"
2790
2808
  [ "$(gdec "$o")" = "ask" ] && pass "§4.6: a matching record lets the commit through to the §4.4 ask" \
2791
2809
  || fail "§4.6: a matching record did NOT pass the gate (out=$o)"
2810
+ # 2b. Who wrote the message (AJAN-1): the latest crew-review-agent / crew-commit-agent call in the transcript decides
2811
+ # one line in the approval. It never blocks, and it says nothing when it cannot know: a subagent's own call, or a
2812
+ # transcript it cannot read.
2813
+ _can_t="$R46/.t"; mkdir -p "$_can_t"
2814
+ _can_call(){ printf '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Agent","input":{"subagent_type":"%s","prompt":"x"}}]}}\n' "$1"; }
2815
+ { _can_call crew-review-agent; _can_call crew-commit-agent; } > "$_can_t/both.jsonl"
2816
+ _can_call crew-review-agent > "$_can_t/review.jsonl"
2817
+ { _can_call crew-commit-agent; _can_call crew-review-agent; } > "$_can_t/stale.jsonl"
2818
+ _canj(){ printf '{"cwd":"%s","permission_mode":"default","transcript_path":"%s",%s"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' "$R46" "$1" "${2:-}"; }
2819
+ _canb=""; _NOTE='crew-commit-agent has not run since the last review'
2820
+ o="$(_canj "$_can_t/both.jsonl" | r46 2>/dev/null)"; [ "$(gdec "$o")" = ask ] && [[ "$o" != *"$_NOTE"* ]] || _canb="$_canb agent-ran-but-noted"
2821
+ o="$(_canj "$_can_t/review.jsonl" | r46 2>/dev/null)"; [ "$(gdec "$o")" = ask ] && [[ "$o" == *"$_NOTE"* ]] || _canb="$_canb no-agent-not-noted"
2822
+ o="$(_canj "$_can_t/stale.jsonl" | r46 2>/dev/null)"; [[ "$o" == *"$_NOTE"* ]] || _canb="$_canb a-review-after-the-agent-not-noted"
2823
+ o="$(_canj "$_can_t/review.jsonl" '"agent_id":"a1",' | r46 2>/dev/null)"; [[ "$o" != *"$_NOTE"* ]] || _canb="$_canb noted-on-a-subagents-call"
2824
+ o="$(_canj "$_can_t/nope.jsonl" | r46 2>/dev/null)"; [ "$(gdec "$o")" = ask ] && [[ "$o" != *"$_NOTE"* ]] || _canb="$_canb noted-without-a-transcript"
2825
+ [ -z "$_canb" ] && pass "§4.4 approval names a commit message crew-commit-agent did not write (5 cases: ran, not run, review after it, subagent, no transcript); it never blocks" \
2826
+ || fail "commit-agent note:$_canb"
2827
+ rm -rf "$_can_t"
2792
2828
  # 3. Right diff, wrong base. This is the case a wall-clock TTL cannot see and the reason there is none.
2793
2829
  r46rec "$R46_OID" "0000000000000000000000000000000000000000"
2794
2830
  gj default 'git commit -m x' | r46 >/dev/null 2>&1; [ "$?" = 2 ] \
@@ -3371,6 +3407,46 @@ pass|rm -f file.txt
3371
3407
  pass|rm file.txt
3372
3408
  pass|rmdir empty/
3373
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"
3374
3450
  # §4.5 whole-tree revert. `reset --hard` was gated from the start; `git checkout -- .` does the same damage and
3375
3451
  # was not — a verification subagent used it over uncommitted work in this repo and the tree went with it. The
3376
3452
  # single-file form stays allowed (everyday, recoverable), and the two ways around a naive matcher — `git -C`
@@ -3476,6 +3552,27 @@ done
3476
3552
  # are now cut out and the rest goes to the old rule, so nothing unproven is skipped — all pinned above.
3477
3553
  [ -z "$_hp_bad" ] && pass "core.hooksPath: value-less reads pass, every write form stays blocked ($_hp_n of 48 shapes)" \
3478
3554
  || fail "core.hooksPath read/write split is wrong:$_hp_bad"
3555
+ # crewforth.hookschain IS JUDGED BY THE SAME READER. The key holds the path of a project hook chain that lives outside
3556
+ # the repository; the shim reads it on every commit. Reads pass, every write form is refused, exactly 0 or 2.
3557
+ _hc_bad=""; _hc_n=0
3558
+ for _c in 'git config crewforth.hookschain' 'git config --get crewforth.hookschain' 'git config --local crewforth.hookschain' \
3559
+ 'git -C /r config --get crewforth.hookschain' 'git config get crewforth.hookschain' 'git config --get crewforth.hookschain | cat' \
3560
+ 'git status; echo crewforth.hookschain' 'git config --remove-section crewforth.foo' 'git config crewforth.other x'; do
3561
+ gj auto "$_c" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _r=$?; _hc_n=$((_hc_n+1)); [ "$_r" = 0 ] || _hc_bad="$_hc_bad [$_c -> $_r, want 0]"
3562
+ done
3563
+ for _c in 'git config crewforth.hookschain /tmp/x' 'git config --local crewforth.hookschain x' 'git config --unset crewforth.hookschain' \
3564
+ 'git config --unset-all crewforth.hookschain' 'git config --add crewforth.hookschain x' 'git config --replace-all crewforth.hookschain x' \
3565
+ 'git config set crewforth.hookschain x' 'git config unset crewforth.hookschain' 'git config crewforth.hookschain \"\"' \
3566
+ 'git config --get crewforth.hookschain && git config crewforth.hookschain x' 'git config crewforth.HooksChain /tmp/x' \
3567
+ 'git config crewforth.hooks\"c\"hain x' 'git config crewforth.hookschain \r' 'git -C /r config crewforth.hookschain /tmp/x' \
3568
+ 'git config --remove-section crewforth' 'git config --rename-section crewforth x' \
3569
+ 'git -c crewforth.hookschain=/tmp/x commit -m x' "git -c 'crewforth.hookschain=/tmp/x' commit -m x" \
3570
+ 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=crewforth.hookschain GIT_CONFIG_VALUE_0=/tmp/x git commit -m x'; do
3571
+ gj auto "$_c" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _r=$?; _hc_n=$((_hc_n+1)); [ "$_r" = 2 ] || _hc_bad="$_hc_bad [$_c -> $_r, want 2]"
3572
+ done
3573
+ if [ "$_hc_n" != 28 ]; then fail "FIXTURE: the crewforth.hookschain table ran $_hc_n rows, not 28"
3574
+ elif [ -z "$_hc_bad" ]; then pass "crewforth.hookschain: 9 reads and neighbours pass, 19 write forms are refused — a value, unset, add, set, the section removed or renamed, -c, GIT_CONFIG_ ($_hc_n rows)"
3575
+ else fail "crewforth.hookschain read/write split is wrong:$_hc_bad"; fi
3479
3576
  # THE SAME SPLIT UNDER A WINDOWS PAYLOAD. A read is not proven when the payload holds an escape the reader drops
3480
3577
  # (`\r`, `\f`, `\b`, `\v`, `\u`). That was asked of the WHOLE payload, and a Windows `cwd` is full of them once
3481
3578
  # JSON-encoded: `C:\\repos\\app` holds a backslash and an r. So in such a directory `git config --get core.hooksPath`
@@ -3568,6 +3665,8 @@ if [ "$_gc1" != 5 ] || [ "$_gc2" != 7 ]; then
3568
3665
  else
3569
3666
  gcj(){ printf '{"tool_name":"%s","permission_mode":"default","tool_input":{"command":"%s"},"cwd":"%s"}' "$1" "$2" "$_gcp"; }
3570
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.
3571
3670
  GCT='guard-bash @@ 0 @@ 0 @@ Bash @@ git status --short
3572
3671
  guard-bash @@ 0 @@ 0 @@ Bash @@ ls -la
3573
3672
  guard-bash @@ 0 @@ 0 @@ Bash @@ git log --oneline -2 && git status --short | head; ls; cat CHANGELOG.md
@@ -3589,7 +3688,7 @@ guard-write @@ 0 @@ 0 @@ Write @@ .env
3589
3688
  guard-write @@ 2 @@ 0 @@ Write @@ .claude/hooks/guard-bash.sh
3590
3689
  guard-powershell @@ 0 @@ 0 @@ PowerShell @@ Get-ChildItem -Recurse src
3591
3690
  pre-commit @@ 0 @@ 22 @@ Script @@ -
3592
- commit-msg @@ 0 @@ 15 @@ Script @@ msg'
3691
+ commit-msg @@ 0 @@ 16 @@ Script @@ msg'
3593
3692
  _gcn=0; _gcbad=""; _gcrow=""
3594
3693
  while IFS= read -r _gl; do [ -z "$_gl" ] && continue
3595
3694
  _gh="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gr="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gb="${_gl%% @@ *}"; _gl="${_gl#* @@ }"; _gt="${_gl%% @@ *}"; _gc="${_gl#* @@ }"
@@ -4435,6 +4534,41 @@ else fail "session-rehydrate git-hooks states read '$_got' (want '--UO---': no h
4435
4534
  rm -rf "$RHG"
4436
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
4437
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
4438
4572
  # Asked of the entry that carries session-rehydrate, not of the file: skill-trust's entry has the same matcher.
4439
4573
  _rw(){ _n="$(awk -v op=len -v path=hooks.SessionStart -f "$ROOT/eval/lib/settings-json.awk" "$1" 2>/dev/null)"; _i=0
4440
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)"
@@ -4595,6 +4729,37 @@ DOC="$(mktemp -d)"
4595
4729
  chmod +x .claude/hooks/*.sh .claude/hooks/pre-commit .claude/hooks/commit-msg
4596
4730
  git config core.hooksPath .claude/hooks )
4597
4731
  bash "$ROOT/eval/doctor.sh" "$DOC" >/dev/null 2>&1 && pass "doctor: healthy install -> exit 0" || fail "doctor flagged a healthy install"
4732
+ # Two editions at once, and the advisor (doctor 9c). Driven through a stub `claude` that prints a plugin list, a
4733
+ # config dir of its own, and the provider and advisor variables cleared, so the machine running this decides
4734
+ # nothing. Advisory: every case keeps the healthy verdict (exit 0).
4735
+ _d9="$(mktemp -d)"; mkdir -p "$_d9/bin" "$_d9/cfg"
4736
+ _d9stub(){ printf '#!/bin/sh\ncase "$1" in --version) echo "2.1.299 (Claude Code)" ;; plugin) cat <<J\n%s\nJ\n;; esac\n' "$1" > "$_d9/bin/claude"; chmod +x "$_d9/bin/claude"; }
4737
+ _d9run(){ _d9o="$( cd "$DOC" && env -u CLAUDE_CODE_DISABLE_ADVISOR_TOOL -u CLAUDE_CODE_USE_BEDROCK -u CLAUDE_CODE_USE_VERTEX \
4738
+ CREW_LANG=en CLAUDE_CONFIG_DIR="$_d9/cfg" PATH="$_d9/bin:$PATH" "$@" bash "$ROOT/eval/doctor.sh" 2>&1 )"; _d9rc=$?; }
4739
+ _d9b=""
4740
+ _d9stub '[{"id":"crewforth@crewforth","enabled":true},{"id":"other@market","enabled":true}]'; _d9run
4741
+ case "$_d9o" in *"every hook runs twice"*) ;; *) _d9b="$_d9b both-on-not-seen" ;; esac; [ "$_d9rc" = 0 ] || _d9b="$_d9b both-on-changed-the-verdict($_d9rc)"
4742
+ # The 2.x plugin id, still enabled at user scope beside a 3.x install, wires the same hooks: measured in a real
4743
+ # project (the 2.13.0 plugin was claude-starter-kit@byerlikaya).
4744
+ _d9stub '[{"id":"claude-starter-kit@byerlikaya","enabled":true}]'; _d9run
4745
+ case "$_d9o" in *"claude-starter-kit@byerlikaya plugin is enabled"*"every hook runs twice"*) ;; *) _d9b="$_d9b legacy-plugin-not-seen" ;; esac
4746
+ _d9stub '[{"id": "crewforth@crewforth", "enabled": false}]'; _d9run
4747
+ case "$_d9o" in *"one edition: the Crewforth plugin is not enabled"*) ;; *) _d9b="$_d9b plugin-off-not-one-edition" ;; esac
4748
+ rm -f "$_d9/bin/claude"; _d9run env PATH="$_d9/bin:/usr/bin:/bin"
4749
+ case "$_d9o" in *"two editions at once: not measured (no claude CLI"*) ;; *) _d9b="$_d9b no-cli-not-said" ;; esac
4750
+ _d9stub '[]'
4751
+ _d9run; case "$_d9o" in *"advisor: off (no advisorModel set"*) ;; *) _d9b="$_d9b advisor-unset" ;; esac
4752
+ printf '{"advisorModel":"opus"}' > "$_d9/cfg/settings.json"; _d9run
4753
+ case "$_d9o" in *"advisor: on, opus"*) ;; *) _d9b="$_d9b advisor-opus" ;; esac; case "$_d9o" in *"below the critical tier"*) _d9b="$_d9b opus-called-below-critical" ;; esac
4754
+ printf '{"advisorModel":"sonnet"}' > "$_d9/cfg/settings.json"; _d9run
4755
+ case "$_d9o" in *"advisor (sonnet) is below the critical tier"*) ;; *) _d9b="$_d9b sonnet-not-below" ;; esac
4756
+ _d9run env CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1
4757
+ case "$_d9o" in *"CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 turns the advisor off"*) ;; *) _d9b="$_d9b env-off-not-seen" ;; esac
4758
+ _d9run env CLAUDE_CODE_USE_BEDROCK=1
4759
+ case "$_d9o" in *"needs the Anthropic API"*) ;; *) _d9b="$_d9b bedrock-not-seen" ;; esac; [ "$_d9rc" = 0 ] || _d9b="$_d9b bedrock-changed-the-verdict($_d9rc)"
4760
+ rm -rf "$_d9"
4761
+ [ -z "$_d9b" ] && pass "doctor 9c: both editions on is named, one is ok, no CLI is not measured; the advisor off, on, below the critical tier, env-disabled and on Bedrock each read right; none changes the verdict" \
4762
+ || fail "doctor 9c:$_d9b"
4598
4763
  # THE MANIFEST'S LINE ENDINGS MUST NOT CHANGE WHO OWNS A SKILL. Measured before the fix: the same install with
4599
4764
  # one project skill counted 1 on an LF manifest and 2 on a CRLF one, because `grep -qxF` wants a whole line and
4600
4765
  # `skills/handoff\r` is not `skills/handoff` — so every KIT skill read as project-owned. A Windows checkout with
@@ -4883,6 +5048,8 @@ sec "== 7u) update prompt: ASKS about a release, never installs one, never spend
4883
5048
  UPD="$(mktemp -d)"; mkdir -p "$UPD/.claude/.state" "$UPD/.claude/hooks"
4884
5049
  printf '3.0.0\n' > "$UPD/.claude/VERSION"
4885
5050
  UH="$HOOKS/session-update-check.sh"; cp "$UH" "$UPD/.claude/hooks/"
5051
+ # The hook reads and orders the install's version through eval/lib/version.sh, beside it in an install.
5052
+ mkdir -p "$UPD/.claude/eval/lib"; cp "$ROOT/eval/lib/version.sh" "$UPD/.claude/eval/lib/"
4886
5053
  uc(){ ( printf '{"hook_event_name":"SessionStart","source":"startup","cwd":"%s"}' "$UPD" \
4887
5054
  | env -u CI -u CREW_NO_UPDATE_CHECK -u CREW_LANG CLAUDE_CODE_SESSION_ATTENDED=1 LANG=C LC_ALL= LC_MESSAGES= \
4888
5055
  CLAUDE_PROJECT_DIR="$UPD" CREW_UPDATE_URL="${1:-http://10.255.255.1/blackhole}" bash "$UH" 2>/dev/null ); }
@@ -5011,10 +5178,25 @@ cp "$UH" "$UPD/uh-twin.sh"; printf 'npx --yes crewforth@latest update --here --y
5011
5178
  if [ "$_ni" = 0 ] && [ "$(_noinst "$UPD/uh-twin.sh")" -ge 1 ]; then pass "the update hook runs no installer (npx, npm, adopt.sh, start.sh): 0 lines; the twin with one is caught"
5012
5179
  else fail "the update hook contains an installer call ($_ni line(s)) — it must only ask, or the twin was not caught"; fi
5013
5180
 
5181
+ # 12b) AN INSTALLED RC IS OFFERED ITS FINAL. The installer copies VERSION into the project, and an rc's says
5182
+ # X.Y.Z-rc.N: 3.2.0 published over an installed 3.2.0-rc.1 is newer (it used to read as the same 3.2.0, and the
5183
+ # rc's user was never told the final shipped). An older final is not offered, and what the network answers is
5184
+ # still held to X.Y.Z: an rc off the network is no version.
5185
+ printf '3.2.0-rc.1\n' > "$UPD/.claude/VERSION"
5186
+ ucache 3.2.0
5187
+ case "$(uc)" in *'"Crewforth v3.2.0 is out (installed: v3.2.0-rc.1). Update now?"'*) pass "an installed 3.2.0-rc.1 is offered the final 3.2.0" ;;
5188
+ *) fail "an installed 3.2.0-rc.1 was not offered the final 3.2.0 (got: $(uc | head -c 120))" ;; esac
5189
+ ucache 3.1.1
5190
+ [ -z "$(uc)" ] && pass "an installed rc is not offered an older final (3.1.1 under 3.2.0-rc.1)" || fail "3.1.1 was offered over an installed 3.2.0-rc.1"
5191
+ ucache 3.2.0-rc.2
5192
+ [ -z "$(uc)" ] && pass "an rc answered off the network is no version: nothing asked" || fail "a pre-release from the network was put in a question"
5193
+ printf '3.0.0\n' > "$UPD/.claude/VERSION"; ustate
5194
+
5014
5195
  # 13) THE PLUGIN EDITION. Its version is its own manifest, its release is the marketplace repo's copy, its state is
5015
5196
  # user-level (a plugin serves every project), and its Update path is the plugin command — measured to work from
5016
5197
  # inside a session, applying on restart.
5017
- PLG="$UPD/plugin"; mkdir -p "$PLG/.claude-plugin" "$PLG/hooks"; cp "$UH" "$PLG/hooks/"
5198
+ PLG="$UPD/plugin"; mkdir -p "$PLG/.claude-plugin" "$PLG/hooks" "$PLG/eval/lib"; cp "$UH" "$PLG/hooks/"
5199
+ cp "$ROOT/eval/lib/version.sh" "$PLG/eval/lib/"
5018
5200
  printf '{"name":"crewforth","version":"3.0.0"}\n' > "$PLG/.claude-plugin/plugin.json"
5019
5201
  XDG="$UPD/xdg"; mkdir -p "$XDG/crewforth"
5020
5202
  printf '3.1.0 %s\n' "$(date +%s)" > "$XDG/crewforth/update-check"
@@ -5390,6 +5572,20 @@ $TRFX\""; then pass "multi-line AI trace in the commit message BLOCKED (§4.1)"
5390
5572
  if csblk "git commit -F $MFX"; then pass "-F <file> carrying an AI trace BLOCKED (§4.1)"
5391
5573
  else fail "-F <file> AI trace not blocked with rc=2 (§4.1 hole or the hook died)"; fi
5392
5574
  rm -f "$MFX"
5575
+ # Both halves in ONE refusal: a staged secret and a session-link trailer in the message. The message used to be
5576
+ # scanned only when the staged content was clean, so the refusal named the first problem and the commit was
5577
+ # retried with the trailer still in it (a real project, 2026-10-21). Assembled at run time, as above.
5578
+ STFX="Claude-""Session: https://claude.ai/code/""session_""0123456789abcdef"
5579
+ ( cd "$CS" && printf 'k = "%s"\n' "$SKFX" > c.txt && git add c.txt ) >/dev/null 2>&1
5580
+ _cso="$( cd "$CS" && printf '%s' "$(csj "git commit -m \"feat: z
5581
+
5582
+ $STFX\"")" | bash .claude/hooks/guard-commit-scan.sh 2>&1 >/dev/null )"; _csr=$?
5583
+ ( cd "$CS" && git rm -q --cached c.txt && rm -f c.txt ) >/dev/null 2>&1
5584
+ case "$_cso" in *SECRET-SCANNER*'TRACE-SCANNER (message)'*'does not override'*)
5585
+ [ "$_csr" = 2 ] && pass "a staged secret and a trailer in the message are named in ONE refusal, with the harness-reminder line (§4.1)" \
5586
+ || fail "both problems named but rc=$_csr, not 2" ;;
5587
+ *) fail "the refusal did not name both the staged secret and the message trailer (rc=$_csr): $(printf '%s' "$_cso" | tr '\n' ' ' | cut -c1-200)" ;;
5588
+ esac
5393
5589
  # A QUOTED -F path with a space. The sed extraction that ran wherever python3 did not stopped at the space, read
5394
5590
  # `-F "my msg.txt"` as `my`, found no such file and refused the commit — a clean one. The awk tokenizer is now
5395
5591
  # the only path; both directions are asserted so an extraction that reads NOTHING cannot pass as "clean".
@@ -6275,6 +6471,14 @@ GNI="$(mktemp -d)"; ( cd "$GNI" && git init -q . && mkdir -p .claude )
6275
6471
  ( cd "$GNI" && gjson 'git reset --hard' | bash "$ROOT/hooks/guard-bash.sh" >/dev/null 2>&1 )
6276
6472
  [ -e "$GNI/.claude/gate-log.tsv" ] && fail "gate log wrote into a repo where the path is not gitignored" \
6277
6473
  || pass "gate log declines a path git would track"
6474
+ # ...and since 3.2.0 writes inside the repository's .git instead, which is never committed: a project that tracks
6475
+ # .claude/ had no record of a refusal at all (seen on a real project: no 🛡, no note of a lowered model).
6476
+ # The readers find it there (eval/lib/gate-log.sh, the one rule), and `git status` stays clean.
6477
+ _gni_read="$( cd "$GNI" && . "$ROOT/eval/lib/gate-log.sh" && crew_gate_log_read && printf '%s' "$_CREW_GL" )"
6478
+ _gni_st="$( cd "$GNI" && git status --porcelain --untracked-files=all 2>/dev/null | grep -c gate-log )"
6479
+ { [ -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 ]; } \
6480
+ && pass "where git would track it, the gate log goes inside .git: written, found by the readers, and git status stays clean" \
6481
+ || 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}'"
6278
6482
  ( cd "$GNI" && echo '.claude/' > .gitignore && gjson 'git reset --hard' | bash "$ROOT/hooks/guard-bash.sh" >/dev/null 2>&1 )
6279
6483
  [ -s "$GNI/.claude/gate-log.tsv" ] && pass "gate log writes once the path is gitignored" \
6280
6484
  || fail "gate log stayed silent even though the path is ignored"
@@ -6610,8 +6814,10 @@ rm -rf "$_hsd"
6610
6814
  # A gate's timeout is not a comfort setting: a PreToolUse hook that reaches it does not block (Claude Code's hooks
6611
6815
  # reference: "doesn't block the tool call … don't count on a stalled hook to act as a gate"), and a field session on
6612
6816
  # 3.0.0 showed nine of them in one day — the commands ran with no gate. Crewforth had set 60 s where Claude Code's
6613
- # own default is 600, and the slowest machine measured needed 32-134 s for one guard-bash call. The seven PreToolUse
6614
- # gates (the four bash gates, the no-bash gate of 12e, guard-schedule.sh and guard-agent-model.sh) carry 600 now; every other hook keeps 60 — when one of those times out, nothing is left unguarded.
6817
+ # own default is 600, and the slowest machine measured needed 32-134 s for one guard-bash call. The eight PreToolUse
6818
+ # gates (the four bash gates, the no-bash gate of 12e, guard-schedule.sh, guard-agent-model.sh and prompt-approval.sh on
6819
+ # the approval question, whose `ask` is what keeps a hook from answering it) carry 600 now; every other hook keeps 60 —
6820
+ # when one of those times out, nothing is left unguarded.
6615
6821
  # Read per entry, in both editions; the twin lowers one gate back to 60 and must be seen.
6616
6822
  gate_timeouts(){ json_hooks "$1" | LC_ALL=C awk -F'\t' '$1 == "PreToolUse" { n++; t = 0
6617
6823
  if (match($3, /"timeout"[ \t]*:[ \t]*[0-9]+/)) { t = substr($3, RSTART, RLENGTH); sub(/.*:[ \t]*/, "", t) }
@@ -6620,13 +6826,13 @@ gate_timeouts(){ json_hooks "$1" | LC_ALL=C awk -F'\t' '$1 == "PreToolUse" { n++
6620
6826
  _gtd="$(mktemp -d)"
6621
6827
  tr -d '\n' < "$ROOT/settings.json" | sed 's/guard-bash\.sh\([^}]*\)"timeout": 600/guard-bash.sh\1"timeout": 60/' > "$_gtd/twin.json"
6622
6828
  _gtw="$(gate_timeouts "$_gtd/twin.json")"
6623
- if [ "$_gtw" != "7 1" ]; then fail "gate timeout pin: the twin with guard-bash.sh back at 60 s read '$_gtw', want '7 1' — the pin sees nothing"
6829
+ if [ "$_gtw" != "8 1" ]; then fail "gate timeout pin: the twin with guard-bash.sh back at 60 s read '$_gtw', want '8 1' — the pin sees nothing"
6624
6830
  else for _gf in $_gpw_f; do
6625
6831
  [ -f "$_gf" ] || continue
6626
6832
  set -- $(gate_timeouts "$_gf")
6627
- if [ "${1:-0}" != 7 ]; then fail "${_gf##*/}: $1 PreToolUse gate hooks read, want 7 — the pin did not read the file as written"
6628
- elif [ "${2:-9}" = 0 ]; then pass "${_gf##*/}: all 7 PreToolUse gates have a 600 s timeout (a gate that times out does not block)"
6629
- else fail "${_gf##*/}: $2 of 7 PreToolUse gates time out before 600 s — a timed-out gate does not block"; fi
6833
+ if [ "${1:-0}" != 8 ]; then fail "${_gf##*/}: $1 PreToolUse gate hooks read, want 8 — the pin did not read the file as written"
6834
+ elif [ "${2:-9}" = 0 ]; then pass "${_gf##*/}: all 8 PreToolUse gates have a 600 s timeout (a gate that times out does not block)"
6835
+ else fail "${_gf##*/}: $2 of 8 PreToolUse gates time out before 600 s — a timed-out gate does not block"; fi
6630
6836
  done; fi
6631
6837
  rm -rf "$_gtd"
6632
6838
 
@@ -7394,6 +7600,22 @@ PCT='0 @@ git commit -m x
7394
7600
  2 @@ git commit -m x
7395
7601
  2 @@ git commit -m x;touch
7396
7602
  2 @@ git commit -m #\x27\ntouch ../PWNED\n#\x27
7603
+ 2 @@ git commit -m x \\
7604
+ 2 @@ git commit -m a\\ b
7605
+ 2 @@ git commit -m $\x27a\x27
7606
+ 2 @@ git commit -m \"a
7607
+ 2 @@ git commit -m \x27a
7608
+ 2 @@ git commit -m \"a\\\\b\"
7609
+ 2 @@ git commit -m \"a!b\"
7610
+ 0 @@ git commit -q -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27 -m \x27p\x27
7611
+ 0 @@ \n git commit -m \x27a\x27 \t\n
7612
+ 0 @@ git commit -m x \\\n
7613
+ 0 @@ git commit -m x\n 2>&1
7614
+ 2 @@ git commit -m x\n2>&1 -a
7615
+ 2 @@ git commit -m \"$(cat <<\x27E-F\x27\nmsg\nE-F\n)\"
7616
+ 2 @@ git commit -m \"$(cat <<\x27EOF\x27\nmsg\nEOF\ntouch ../PWNED\nEOF\n)\"
7617
+ 2 @@ git commit -m a*b
7618
+ 2 @@ git commit -m x2>&1
7397
7619
  2 @@ git commit -m \\\x27a ; touch ../PWNED ; echo b\\\x27'
7398
7620
  _pa_say auto 'approve: commit' >/dev/null
7399
7621
  _pan=0; _pabad=""
@@ -7401,8 +7623,8 @@ while IFS= read -r _pl; do [ -z "$_pl" ] && continue
7401
7623
  _pw="${_pl%% @@ *}"; _pc="${_pl#* @@ }"; _pc="${_pc//\\x27/$_pq}"; _pan=$((_pan+1))
7402
7624
  _pa_run auto "$_pc"; [ "$_par" = "$_pw" ] || _pabad="$_pabad [$_pc → $_par, want $_pw]"
7403
7625
  done <<< "$PCT"
7404
- if [ "$_pan" != 50 ]; then fail "FIXTURE: the approved-commit table has $_pan rows, not 50"
7405
- elif [ -z "$_pabad" ]; then pass "an approved commit is 'git commit -m …' alone in its call: 7 spellings allowed (any single-quoted message, a here-document with a quoted delimiter), 43 refused — a cd or a variable in front, a second command however it is quoted, a substitution, a redirection, -a or a path past a redirection, -n, an abbreviated --no-verify or --amend, any other option (50 rows)"
7626
+ if [ "$_pan" != 66 ]; then fail "FIXTURE: the approved-commit table has $_pan rows, not 66"
7627
+ elif [ -z "$_pabad" ]; then pass "an approved commit is 'git commit -m …' alone in its call: 10 spellings allowed (any single-quoted message, a here-document with a quoted delimiter, forty messages in one call, blank lines around the call, a continued line with nothing after it, a 2>&1 on a line of its own), 56 refused — a cd or a variable in front, a second command however it is quoted, a substitution, a redirection, -a or a path past a redirection, -n, an abbreviated --no-verify or --amend, any other option, a backslash outside quotes (the last character of the call too), a quote never closed, a \$'…' text, a here-document whose delimiter is not a plain word or whose body holds the delimiter twice, a glob in the message (66 rows)"
7406
7628
  else fail "approved-commit table:$_pabad"; fi
7407
7629
 
7408
7630
  # What is staged, HEAD, the clock: each one moved on its own, with the others still matching.
@@ -7481,6 +7703,25 @@ _pa_say auto 'approve: push' >/dev/null; ( cd "$_paw" && git checkout -q -b othe
7481
7703
  _pa_run auto 'git push origin feat/x'; [ "$_par" = 2 ] || _pabad="$_pabad other-branch-checked-out:rc=$_par"
7482
7704
  [ -z "$_pabad" ] && pass "a push approval is for the commit HEAD was on and the branch that was checked out: one more commit, or another branch, and it is refused" \
7483
7705
  || fail "push binding:$_pabad"
7706
+ # A tag on the approved commit is part of that push (a release: `/crew-approve push`, then `git push origin v2.0.0`
7707
+ # was refused on a real project). A tag elsewhere, a name that is also a branch, or a full ref is not.
7708
+ _pa_new; _pabad=""; ( cd "$_paw" && git commit -qm second && git tag old HEAD^ && git tag v1 && git tag -a v1a -m v1a && git tag both && git branch both ) >/dev/null 2>&1
7709
+ _pa_say auto 'approve: commit' >/dev/null
7710
+ _pa_run auto 'git push origin v1'; [ "$_par" = 2 ] || _pabad="$_pabad [a commit approval pushed a tag: rc=$_par]"
7711
+ _pa_say auto 'approve: push' >/dev/null
7712
+ for _pc in 'git push origin v1' 'git push origin v1a' 'git push -q origin v1'; do
7713
+ _pa_run auto "$_pc"; [ "$_par" = 0 ] || _pabad="$_pabad [$_pc → $_par $(head -c 120 "$_PA/err")]"
7714
+ done
7715
+ _pa_run auto 'git push origin old'; { [ "$_par" = 2 ] && grep -q 'the tag old is on' "$_PA/err"; } || _pabad="$_pabad [a tag on another commit → $_par]"
7716
+ for _pc in 'git push origin both' 'git push origin refs/tags/v1' 'git push origin nosuch' 'git push origin v1:v2' 'git push other v1'; do
7717
+ _pa_run auto "$_pc"; [ "$_par" = 2 ] || _pabad="$_pabad [$_pc → $_par]"
7718
+ done
7719
+ _pa_new; ( cd "$_paw" && git tag before ) >/dev/null 2>&1; _pa_say auto 'approve: commit+push' >/dev/null
7720
+ _pa_run auto 'git commit -m x' >/dev/null; ( cd "$_paw" && git commit -qm approved && git tag after ) >/dev/null 2>&1
7721
+ _pa_run auto 'git push origin after'; [ "$_par" = 0 ] || _pabad="$_pabad [commit+push: a tag on the new commit → $_par $(head -c 120 "$_PA/err")]"
7722
+ _pa_run auto 'git push origin before'; { [ "$_par" = 2 ] && grep -q 'the tag before is on' "$_PA/err"; } || _pabad="$_pabad [commit+push: a tag on the old commit → $_par]"
7723
+ [ -z "$_pabad" ] && pass "a push approval covers a tag on the approved commit (lightweight or annotated; after commit+push, on the new commit): a tag on another commit, a name that is also a branch, a full ref, a refspec, another remote or a commit-only approval is refused" \
7724
+ || fail "tag push:$_pabad"
7484
7725
 
7485
7726
  # ---- commit+push: the push is for the commit the approved diff became -----------------------------------------
7486
7727
  _pa_new; _pabad=""
@@ -7772,6 +8013,204 @@ _pa_say auto 'approve: commit' >/dev/null; _pa_gate auto 'git commit -m x'; _pav
7772
8013
  _pa_new; _pa_gate auto 'git commit -m x'; _pav2="$_par"
7773
8014
  [ "$_pav1/$_pav2" = 0/2 ] && pass "the wiring check moves no verdict: a recorded approval is allowed (rc 0) and its absence refused (rc 2) with no settings file" \
7774
8015
  || fail "the wiring check moved a verdict: approved $_pav1 (want 0), unapproved $_pav2 (want 2)"
8016
+ # ---- THE APPROVAL QUESTION (3.2.0): one click in auto, recorded as the typed approval is -------------------------
8017
+ # guard-question.sh on PreToolUse for AskUserQuestion answers `ask` to the fixed question (a hook's `ask` beats another
8018
+ # hook's `allow` with filled-in answers: measured on Claude Code 2.1.294, 34 runs and by hand), PostToolUse records
8019
+ # (prompt-approval.sh, on PostToolUse) the user's answer when THIS call was asked. Before either, and before guard-bash.sh raises its own prompt in
8020
+ # default / acceptEdits, a PermissionRequest hook is looked for: such a hook answers a prompt before the person does.
8021
+ # Every case runs the hooks against a real repository; what is asserted is a verdict or a record.
8022
+ _pa_new; mkdir -p "$_PA/ccd" "$_PA/home"
8023
+ _PAQ_ENV="CLAUDE_PROJECT_DIR=$_paw CLAUDE_CONFIG_DIR=$_PA/ccd HOME=$_PA/home CREW_GATE_LOG=/dev/null"
8024
+ _paq_canon="$(bash -c '. "$1"; printf "%s" "$_CREW_Q_JSON"' _ "$HOOKS/prompt-approval.sh")"
8025
+ _paq_text="$(bash -c '. "$1"; printf "%s" "$_CREW_Q_TEXT"' _ "$HOOKS/prompt-approval.sh")"
8026
+ # $1 = mode, $2 = the questions array's content, $3 = more tool_input (",..."), $4 = tool_use_id -> rc in _par, stdout _pao
8027
+ _paq_pre(){ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"%s","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s]%s},"tool_use_id":"%s"}' "$_patr" "$_paw" "$1" "$2" "${3:-}" "${4:-tu1}" > "$_PA/q.json"
8028
+ if [ -n "$JSONQ" ]; then json_ok < "$_PA/q.json" || _paqbad="$_paqbad [pre payload is not JSON]"; fi
8029
+ _pao="$( cd "$_paw" && env -u CLAUDE_CODE_SESSION_ATTENDED $_PAQ_ENV ${_PAQ_X:-} bash "$HOOKS/guard-question.sh" < "$_PA/q.json" 2>"$_PA/qerr" )"; _par=$?; }
8030
+ # $1 = mode, $2 = the answer, $3 = tool_use_id, $4 = the questions array's content as Claude Code hands it back (default: as asked)
8031
+ _paq_post(){ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"%s","hook_event_name":"PostToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s],"answers":{"%s":"%s"}},"tool_response":{"questions":[%s],"answers":{"%s":"%s"}},"tool_use_id":"%s"}' "$_patr" "$_paw" "$1" "${4:-$_paq_canon}" "$_paq_text" "$2" "${4:-$_paq_canon}" "$_paq_text" "$2" "${3:-tu1}" > "$_PA/q.json"
8032
+ if [ -n "$JSONQ" ]; then json_ok < "$_PA/q.json" || _paqbad="$_paqbad [post payload is not JSON]"; fi
8033
+ _pao="$( cd "$_paw" && env -u CLAUDE_CODE_SESSION_ATTENDED $_PAQ_ENV ${_PAQ_X:-} bash "$HOOKS/prompt-approval.sh" < "$_PA/q.json" 2>"$_PA/qerr" )"; _par=$?; }
8034
+ _paq_gate(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"%s","tool_name":"Bash","tool_input":{"command":"%s"}}' "$_paw" "$1" "$2" > "$_PA/pl.json"
8035
+ _pao="$( cd "$_paw" && env $_PAQ_ENV ${_PAQ_X:-} bash "$HOOKS/guard-bash.sh" < "$_PA/pl.json" 2>"$_PA/err" )"; _par=$?; _pae="$(cat "$_PA/err")"; }
8036
+ _paq_rec(){ [ -s "$_pa_rec" ] && _pa_op || echo none; }
8037
+ _paqbad=""; _paqn=0
8038
+ _paq_is(){ _paqn=$((_paqn+1)); [ "$2" = "$3" ] || _paqbad="$_paqbad [$1: got '$2', want '$3']"; }
8039
+ # the round trip, and what it opens
8040
+ : > "$_pa_rec"; _paq_pre auto "$_paq_canon"; _paq_is 'fixed question in auto is asked' "$_par/$(gdec "$_pao")" "0/ask"
8041
+ _paq_post auto 'Commit only'; _paq_is 'its answer is recorded' "$(_paq_rec)" commit
8042
+ _paq_gate auto 'git commit -m x'; _paq_is 'the recorded click allows the commit' "$_par" 0
8043
+ _pa_new; : > "$_pa_rec"; _paq_pre auto "$_paq_canon" '' tu2; _paq_post auto 'Commit + push' tu2; _paq_is 'commit + push' "$(_paq_rec)" commit+push
8044
+ # Claude Code may hand the answered question back with its keys sorted and multiSelect gone: 7 of 7 such answers were
8045
+ # refused on a byte match (real `auto` sessions, 2026-10-18). This is that shape, as it was handed back.
8046
+ _paq_sorted="{\"header\":\"Approval\",\"options\":[{\"description\":\"Commit exactly what is staged now. No push.\",\"label\":\"Commit only\"},{\"description\":\"Commit what is staged now, then push that commit on this branch.\",\"label\":\"Commit + push\"},{\"description\":\"Push this branch as it is now. No new commit.\",\"label\":\"Push only\"},{\"description\":\"Neither commit nor push.\",\"label\":\"No\"}],\"question\":\"$_paq_text\"}"
8047
+ _pa_new; : > "$_pa_rec"; _paq_pre auto "$_paq_canon" '' tu10; _paq_post auto 'Commit + push' tu10 "$_paq_sorted"
8048
+ _paq_is 'the answered question handed back with its keys sorted' "$(_paq_rec)" commit+push
8049
+ # must-fail twins: each differs from the round trip in one thing
8050
+ _pa_new; : > "$_pa_rec"; _paq_post auto 'Commit only' tu3; _paq_is 'an answer no PreToolUse asked for' "$(_paq_rec)" none
8051
+ _paq_pre auto "$_paq_canon" '' tu4; _paq_post auto 'Commit only' tu5; _paq_is 'an answer to another call' "$(_paq_rec)" none
8052
+ _paq_pre auto "$_paq_canon" '' tu6; _paq_post auto 'Commit only' tu6; : > "$_pa_rec"; _paq_post auto 'Commit only' tu6
8053
+ _paq_is 'the same call answered twice' "$(_paq_rec)" none
8054
+ _paq_pre auto "$_paq_canon" '' tu7; _paq_post auto 'HOOK-CHOSE-Commit only' tu7; _paq_is 'an answer that is none of the four' "$(_paq_rec)" none
8055
+ _paq_pre auto "$_paq_canon" '' tu8; _paq_post auto 'No' tu8; _paq_is 'No' "$(_paq_rec)" none
8056
+ _paq_pre auto "$_paq_canon" '' tu9; _paq_post default 'Commit only' tu9; _paq_is 'an answer recorded outside auto' "$(_paq_rec)" none
8057
+ _paq_pre auto "$_paq_canon" '' tu11; _paq_post auto 'Commit only' tu11 "${_paq_sorted/No new commit./Then force-push.}"
8058
+ _paq_is 'sorted, with one text of the question changed' "$(_paq_rec)" none
8059
+ _paq_pre auto '{"question":"Crewforth approval: commit?","header":"Approval","options":[{"label":"Commit only","description":"x"}],"multiSelect":false}'
8060
+ _paq_is 'another wording is refused' "$_par" 2
8061
+ _paq_pre auto "$_paq_canon" ',"answers":{"x":"Commit only"}'; _paq_is 'a call with answers is refused' "$_par" 2
8062
+ _paq_pre auto "$_paq_canon,$_paq_canon"; _paq_is 'the question twice is refused' "$_par" 2
8063
+ _paq_pre dontAsk "$_paq_canon"; _paq_is 'dontAsk: refused' "$_par" 2
8064
+ _paq_pre default "$_paq_canon"; _paq_is 'default: refused' "$_par" 2
8065
+ _paq_pre bypassPermissions "$_paq_canon"; _paq_is 'bypassPermissions: refused' "$_par" 2
8066
+ _PAQ_X="CLAUDE_CODE_SESSION_ATTENDED=0"; _paq_pre auto "$_paq_canon"; unset _PAQ_X; _paq_is 'a headless session: refused' "$_par" 2
8067
+ _paq_pre auto '{"question":"Which colour?","header":"Colour","options":[{"label":"Red","description":"r"}],"multiSelect":false}'
8068
+ _paq_is 'any other question: no decision' "$_par/$_pao" "0/"
8069
+ if [ "$_paqn" != 20 ]; then fail "FIXTURE: the approval-question table ran $_paqn cases, not 20"
8070
+ 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, in whatever key order Claude Code hands it back; the record allows the commit (20 cases, 14 must-fail)"
8071
+ else fail "the approval question:$_paqbad"; fi
8072
+ # ...and the way to it. B-2 (a real `auto` session, 2026-10-17): /crew-ship asked for /crew-approve before any commit
8073
+ # was refused, so the refusal that prints the question never came and the click was never offered; telling the agent
8074
+ # to try the commit first did not help either (§4.4 says not to, and it did not). agent-outcome.sh now hands the
8075
+ # question over when crew-commit-agent is called; the text pinned here only says to ask it.
8076
+ _cqa="$AGENTS/crew-commit-agent.md"; _cqs="$SKILLS/crew-ship/SKILL.md"
8077
+ if [ ! -f "$_cqa" ] || [ ! -f "$_cqs" ]; then skip scope "the way to the approval question: crew-commit-agent or crew-ship is not installed here"
8078
+ 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
8079
+ pass "in auto the commit agent and /crew-ship ask the one-click question Crewforth hands over, before asking for /crew-approve"
8080
+ else fail "the commit agent or /crew-ship asks for /crew-approve before the one-click question Crewforth hands over (B-2)"; fi
8081
+ # The hand-over itself, run: agent-outcome.sh on PostToolUse of an Agent call. In `auto`, for crew-commit-agent, with
8082
+ # both halves of the question wired and no PermissionRequest hook, it adds the fixed question (_CREW_Q_JSON, read from
8083
+ # prompt-approval.sh) to the session's context; in any other mode, for any other agent, or with such a hook, nothing.
8084
+ _aq="$(mktemp -d)"; _aq="$(cd -P "$_aq" && pwd)"; mkdir -p "$_aq/p/.claude/state" "$_aq/ccd" "$_aq/home"
8085
+ 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"
8086
+ _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"
8087
+ ( 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 ); }
8088
+ _aqbad=""; _aqo="$(_aq_run auto crew-commit-agent)"
8089
+ case "$_aqo" in *"$_paq_text"*) ;; *) _aqbad="$_aqbad [auto, crew-commit-agent: the question was not handed over]" ;; esac
8090
+ if [ -n "$JSONQ" ] && [ -n "$_aqo" ]; then printf '%s' "$_aqo" | json_ok || _aqbad="$_aqbad [its output is not JSON]"; fi
8091
+ for _aqc in default:crew-commit-agent dontAsk:crew-commit-agent acceptEdits:crew-commit-agent auto:crew-review-agent; do
8092
+ [ -z "$(_aq_run "${_aqc%%:*}" "${_aqc#*:}")" ] || _aqbad="$_aqbad [${_aqc%%:*}, ${_aqc#*:}: handed the question over]"
8093
+ done
8094
+ printf '%s' '{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"x"}]}]}}' > "$_aq/ccd/settings.json"
8095
+ [ -z "$(_aq_run auto crew-commit-agent)" ] || _aqbad="$_aqbad [a PermissionRequest hook is wired: the question was still handed over]"
8096
+ rm -rf "$_aq"
8097
+ [ -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" \
8098
+ || fail "the one-click question hand-over:$_aqbad"
8099
+ # THE CLICK BEFORE THE TYPED WAY, at the turn's end (2026-10-18: 9 of 18 typed asks in two real `auto` sessions on rc.4
8100
+ # came in a turn where no commit was tried, so neither the refusal nor the commit agent's hand-over offered the click).
8101
+ # prompt-approval.sh on Stop: the reply names /crew-approve, auto, the question open, not asked since the user's last
8102
+ # message -> the session is told once to ask the click. Every other case is a twin that differs in one thing.
8103
+ _sq="$(mktemp -d)"; _sq="$(cd -P "$_sq" && pwd)"; mkdir -p "$_sq/p/.claude/state" "$_sq/ccd" "$_sq/home"
8104
+ 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"}]}]}}' > "$_sq/p/.claude/settings.json"
8105
+ _SQ_ENV="CLAUDE_PROJECT_DIR=$_sq/p CLAUDE_CONFIG_DIR=$_sq/ccd HOME=$_sq/home CREW_GATE_LOG=/dev/null"
8106
+ # $1 = mode, $2 = the reply, $3 = stop_hook_active, $4 = session id
8107
+ _sq_stop(){ printf '{"session_id":"%s","transcript_path":"%s/t.jsonl","cwd":"%s","permission_mode":"%s","hook_event_name":"Stop","stop_hook_active":%s,"last_assistant_message":"%s"}' "${4:-s1}" "$_sq" "$_sq/p" "$1" "${3:-false}" "$2" > "$_sq/pl.json"
8108
+ ( cd "$_sq/p" && env $_SQ_ENV bash "$HOOKS/prompt-approval.sh" < "$_sq/pl.json" 2>/dev/null ); }
8109
+ _sq_ask='Review is clean. If you approve, send only this:\n/crew-approve commit+push'
8110
+ _sqbad=""; _sqn=0
8111
+ _sq_says(){ _sqn=$((_sqn+1)); case "$2" in *'ASK THE USER WITH ONE CLICK'*"$_paq_text"*) ;; *) _sqbad="$_sqbad [$1: the click was not asked for]" ;; esac
8112
+ if [ -n "$JSONQ" ]; then printf '%s' "$2" | json_ok || _sqbad="$_sqbad [$1: the output is not JSON]"; fi; }
8113
+ _sq_quiet(){ _sqn=$((_sqn+1)); [ -z "$2" ] || _sqbad="$_sqbad [$1: said something: ${2:0:80}]"; }
8114
+ _sq_says 'auto, a typed ask, not asked this turn' "$(_sq_stop auto "$_sq_ask")"
8115
+ _sq_quiet 'stop_hook_active' "$(_sq_stop auto "$_sq_ask" true)"
8116
+ _sq_quiet 'dontAsk' "$(_sq_stop dontAsk "$_sq_ask")"
8117
+ _sq_quiet 'default' "$(_sq_stop default "$_sq_ask")"
8118
+ _sq_quiet 'a reply that names no /crew-approve' "$(_sq_stop auto 'Done: the tests pass.')"
8119
+ # asked this turn: the PreToolUse half writes it down, even when it refuses the question
8120
+ printf '{"session_id":"s1","cwd":"%s","permission_mode":"dontAsk","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[%s]},"tool_use_id":"tq1"}' "$_sq/p" "$_paq_canon" > "$_sq/pre.json"
8121
+ ( cd "$_sq/p" && env $_SQ_ENV bash "$HOOKS/guard-question.sh" < "$_sq/pre.json" >/dev/null 2>&1 ); _sqr=$?
8122
+ [ "$_sqr" = 2 ] || _sqbad="$_sqbad [FIXTURE: the dontAsk question was not refused (rc $_sqr), so 'refused but asked' is not what runs]"
8123
+ _sq_quiet 'asked this turn (and refused)' "$(_sq_stop auto "$_sq_ask")"
8124
+ _sq_says 'asked in another session' "$(_sq_stop auto "$_sq_ask" false s2)"
8125
+ # the user's next message starts a new turn
8126
+ printf '{"session_id":"s1","cwd":"%s","permission_mode":"auto","hook_event_name":"UserPromptSubmit","prompt":"next"}' "$_sq/p" > "$_sq/ups.json"
8127
+ ( cd "$_sq/p" && env $_SQ_ENV bash "$HOOKS/prompt-approval.sh" < "$_sq/ups.json" >/dev/null 2>&1 )
8128
+ _sq_says 'the user wrote since it was asked' "$(_sq_stop auto "$_sq_ask")"
8129
+ printf '%s' '{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"x"}]}]}}' > "$_sq/ccd/settings.json"
8130
+ _sq_quiet 'a PermissionRequest hook is wired' "$(_sq_stop auto "$_sq_ask")"
8131
+ rm -rf "$_sq"
8132
+ # A Stop is not the user's next message: an approval recorded by a click survives the turn's end, even when the reply
8133
+ # quotes the tool's name (which sends the payload past the AskUserQuestion test).
8134
+ _pa_new; : > "$_pa_rec"; _paq_pre auto "$_paq_canon" '' ts1; _paq_post auto 'Commit only' ts1
8135
+ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"auto","hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"I asked with \\"AskUserQuestion\\"; run /crew-approve if not."}' "$_patr" "$_paw" > "$_PA/st.json"
8136
+ ( cd "$_paw" && env $_PAQ_ENV bash "$HOOKS/prompt-approval.sh" < "$_PA/st.json" >/dev/null 2>&1 )
8137
+ _sqn=$((_sqn+1)); [ "$(_paq_rec)" = commit ] || _sqbad="$_sqbad [a Stop ended the recorded approval: '$(_paq_rec)']"
8138
+ if [ "$_sqn" != 10 ]; then fail "FIXTURE: the Stop-half table ran $_sqn cases, not 10"
8139
+ elif [ -z "$_sqbad" ]; then pass "at the turn's end a reply asking for /crew-approve, in auto with the question open and not asked since the user's last message, is sent back once to ask the click; not after a Stop continuation, outside auto, without the command, once asked (even refused), or with a PermissionRequest hook; a Stop leaves a recorded approval alone (10 cases, 6 must-stay-quiet)"
8140
+ else fail "the Stop half of the approval question:$_sqbad"; fi
8141
+ # ONE WAY TO ASK in the refusal (2026-10-18, a real `auto` session on rc.3: the refusal offered the question and the
8142
+ # typed command as equals, and the session told the user to type 7 times in 9). With the question open the typed way is
8143
+ # printed only as the fallback for a question that cannot be asked; with it closed (dontAsk), only the typed way.
8144
+ _pa_new
8145
+ ( 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 )
8146
+ _oabad=""
8147
+ _paq_gate auto 'git commit -m x'
8148
+ 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
8149
+ 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
8150
+ _paq_gate dontAsk 'git commit -m x'
8151
+ case "$_pae" in *'ASK THE USER NOW'*'/crew-approve'*) ;; *) _oabad="$_oabad [dontAsk: the typed way is not asked for]" ;; esac
8152
+ case "$_pae" in *'"questions":['*|*'ONLY IF THAT QUESTION'*) _oabad="$_oabad [dontAsk: the question is offered]" ;; esac
8153
+ _pa_new
8154
+ [ -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" \
8155
+ || fail "one way to ask:$_oabad"
8156
+
8157
+ # A PermissionRequest HOOK, wherever this can read one, closes the question AND the gate's own prompt. Each source is
8158
+ # planted alone in an otherwise clean fixture; the clean fixture itself must answer "none" (calibration).
8159
+ _paq_scan(){ ( cd "$_paw" && env $_PAQ_ENV ${_PAQ_X:-} bash -c '. "$1"; if _crew_prh_scan; then printf "found %s" "$_PRH"; else printf none; fi' _ "$HOOKS/prompt-approval.sh" ) 2>/dev/null; }
8160
+ _paqbad=""; _paqn=0
8161
+ _paq_clean(){ rm -rf "$_PA/ccd" "$_PA/home" "$_paw/.claude/skills" "$_paw/.claude/agents" "$_paw/.claude/settings.local.json" "$_PA/mng.json"; mkdir -p "$_PA/ccd" "$_PA/home"; }
8162
+ _paq_found(){ _paqn=$((_paqn+1)); case "$(_paq_scan)" in "found "*"$2"*) ;; *) _paqbad="$_paqbad [$1: $(_paq_scan)]" ;; esac; _paq_clean; }
8163
+ _paq_none(){ _paqn=$((_paqn+1)); [ "$(_paq_scan)" = none ] || _paqbad="$_paqbad [$1 wrongly: $(_paq_scan)]"; _paq_clean; }
8164
+ _PRQ='{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"x"}]}]}}'
8165
+ _paq_clean; _paq_none 'a clean fixture'
8166
+ printf '%s' "$_PRQ" > "$_paw/.claude/settings.local.json"; _paq_found 'project settings.local.json' settings.local.json
8167
+ printf '%s' "$_PRQ" > "$_PA/ccd/settings.json"; _paq_found 'user settings (CLAUDE_CONFIG_DIR)' ccd/settings.json
8168
+ printf '%s' "$_PRQ" > "$_PA/mng.json"; _PAQ_X="CREW_MANAGED_SETTINGS=$_PA/mng.json"; _paq_found 'a managed file' mng.json; unset _PAQ_X
8169
+ printf '%s' '{"hooks":{"Permission\u0052equest":[]}}' > "$_paw/.claude/settings.local.json"; _paq_found 'the name spelled with a \u escape' settings.local.json
8170
+ mkdir -p "$_paw/.claude/skills/s"; printf -- '---\nname: s\nhooks:\n PermissionRequest:\n - hooks: []\n---\nbody\n' > "$_paw/.claude/skills/s/SKILL.md"; _paq_found 'skill front matter' SKILL.md
8171
+ mkdir -p "$_paw/.claude/agents"; printf -- '---\nname: a\nhooks:\n "Permission\\x52equest": []\n---\n' > "$_paw/.claude/agents/a.md"; _paq_found 'agent front matter, escaped' a.md
8172
+ mkdir -p "$_PA/ccd/plugins/cache/m/p/1/hooks"; printf '%s' "$_PRQ" > "$_PA/ccd/plugins/cache/m/p/1/hooks/hooks.json"; _paq_found 'an installed plugin' hooks.json
8173
+ mkdir -p "$_paw/.claude/skills/s"; printf -- '---\nname: s\ndescription: about hooks\n---\nA PermissionRequest hook answers a prompt.\n' > "$_paw/.claude/skills/s/SKILL.md"; _paq_none 'the name in a skill body, not its front matter'
8174
+ printf '%s' '{"hooks":{"PreToolUse":[]},"x":"c:\\users\\me"}' > "$_paw/.claude/settings.local.json"; _paq_none 'an escaped backslash before u'
8175
+ if [ "$_paqn" != 10 ]; then fail "FIXTURE: the PermissionRequest scan table ran $_paqn cases, not 10"
8176
+ elif [ -z "$_paqbad" ]; then pass "a PermissionRequest hook is found in project, user and managed settings, an escaped spelling, skill and agent front matter and an installed plugin (7); a clean fixture, a skill body and an escaped backslash are not (3)"
8177
+ else fail "the PermissionRequest scan:$_paqbad"; fi
8178
+
8179
+ # What it closes: the question, the recording of an answer, and in default the gate's prompt. What it leaves: the typed
8180
+ # approval, in default too. Twin: the same fixture without the hook asks (default) and offers the question (auto).
8181
+ _paqbad=""
8182
+ _pa_new; _paq_clean
8183
+ 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"}]}]}}' > "$_paw/.claude/settings.json"
8184
+ _paq_gate default 'git commit -m x'; [ "$_par/$(gdec "$_pao")" = "0/ask" ] || _paqbad="$_paqbad [no hook, default: $_par/$(gdec "$_pao"), want 0/ask]"
8185
+ _paq_gate auto 'git commit -m x'; case "$_par/$_pae" in 2/*AskUserQuestion*"$_paq_text"*) ;; *) _paqbad="$_paqbad [no hook, auto: the refusal does not offer the question]" ;; esac
8186
+ printf '%s' "$_PRQ" > "$_paw/.claude/settings.local.json"
8187
+ _paq_gate default 'git commit -m x'; case "$_par/$_pae" in 2/*PermissionRequest*settings.local.json*/crew-approve*) ;; *) _paqbad="$_paqbad [hook, default: $_par, want 2 naming the file and /crew-approve]" ;; esac
8188
+ case "$_pae" in *'Shift+Tab to default/acceptEdits (this gate then asks'*) _paqbad="$_paqbad [hook, default: offers Shift+Tab]" ;; esac
8189
+ _paq_gate auto 'git commit -m x'; case "$_par/$_pae" in 2/*"approval question is closed"*) case "$_pae" in *AskUserQuestion*) _paqbad="$_paqbad [hook, auto: still offers the question]" ;; esac ;; *) _paqbad="$_paqbad [hook, auto: $_par, the question is not said to be closed]" ;; esac
8190
+ _paq_pre auto "$_paq_canon" '' tuh; [ "$_par" = 2 ] || _paqbad="$_paqbad [hook: the question was asked ($_par)]"
8191
+ rm -f "$_paw/.claude/settings.local.json"; _paq_pre auto "$_paq_canon" '' tuh2; printf '%s' "$_PRQ" > "$_paw/.claude/settings.local.json"
8192
+ : > "$_pa_rec"; _paq_post auto 'Commit only' tuh2; [ "$(_paq_rec)" = none ] || _paqbad="$_paqbad [a hook added after the question: its answer was recorded]"
8193
+ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"/crew-approve commit"}' "$_patr" "$_paw" \
8194
+ | ( cd "$_paw" && env -u CLAUDE_CODE_SESSION_ATTENDED $_PAQ_ENV bash "$HOOKS/prompt-approval.sh" ) >/dev/null 2>&1
8195
+ [ "$(_paq_rec)" = commit ] || _paqbad="$_paqbad [hook, default: /crew-approve commit was not recorded]"
8196
+ _paq_gate default 'git commit -m x'; [ "$_par" = 0 ] || _paqbad="$_paqbad [hook, default: the typed approval did not allow the commit ($_par)]"
8197
+ rm -f "$_paw/.claude/settings.local.json"; _pa_new
8198
+ printf '{"session_id":"s","transcript_path":"%s","cwd":"%s","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"/crew-approve commit"}' "$_patr" "$_paw" \
8199
+ | ( cd "$_paw" && env -u CLAUDE_CODE_SESSION_ATTENDED $_PAQ_ENV bash "$HOOKS/prompt-approval.sh" ) >/dev/null 2>&1
8200
+ [ "$(_paq_rec)" = none ] || _paqbad="$_paqbad [no hook, default: /crew-approve recorded although the gate asks there]"
8201
+ if [ -z "$_paqbad" ]; then pass "a PermissionRequest hook closes the question, the recording of its answer and the gate's own prompt in default, and leaves /crew-approve (which then allows the commit); without one, default asks, auto offers the question, and a typed approval in default records nothing (9 readings)"
8202
+ else fail "what a PermissionRequest hook closes:$_paqbad"; fi
8203
+
8204
+ # The record of the question is the gate's own: closed to the session like the pending note.
8205
+ _paqbad=""
8206
+ for _pqc in 'cat .claude/state/crew-approval-asked' 'printf x > .claude/state/crew-approval-asked' 'rm -f .claude/state/crew-approval-asked'; do
8207
+ _paq_gate auto "$_pqc"; [ "$_par" = 2 ] || _paqbad="$_paqbad [$_pqc: rc $_par]"
8208
+ done
8209
+ printf '{"cwd":"%s","permission_mode":"auto","tool_name":"Write","tool_input":{"file_path":"%s/.claude/state/crew-approval-asked","content":"x"}}' "$_paw" "$_paw" \
8210
+ | ( cd "$_paw" && env $_PAQ_ENV bash "$HOOKS/guard-write.sh" ) >/dev/null 2>&1; [ $? = 2 ] || _paqbad="$_paqbad [Write: not refused]"
8211
+ [ -z "$_paqbad" ] && pass "the question's own note (.claude/state/crew-approval-asked) is closed to the session: three shell forms and the Write tool refused" \
8212
+ || fail "the question's note is open:$_paqbad"
8213
+ rm -f "$_paw/.claude/settings.json"; _pa_new
7775
8214
  fi # prompt-approval.sh present
7776
8215
 
7777
8216
  # ---- the two definitions that live in two files, and the wiring -----------------------------------------------
@@ -7800,6 +8239,22 @@ if [ "$IS_KIT" = 1 ]; then
7800
8239
  elif [ -z "$_phm" ]; then pass "every script the plugin's hooks.json runs is in plugin/hooks ($_phn scripts)"
7801
8240
  else fail "the plugin's hooks.json runs a script the plugin does not carry:$_phm (add it to the list in packaging/build-plugin.sh)"; fi
7802
8241
  fi
8242
+ # ...and the approval question: guard-question.sh asks it (PreToolUse), prompt-approval.sh records its answer
8243
+ # (PostToolUse), each once on AskUserQuestion, in both editions.
8244
+ for _pf in $_pawf; do
8245
+ _pn="$(json_hooks "$_pf" | LC_ALL=C awk -F'\t' '$2 == "AskUserQuestion" { if ($1 == "PreToolUse" && $3 ~ /guard-question\.sh/) a++; if ($1 == "PostToolUse" && $3 ~ /prompt-approval\.sh/) b++ } END { printf "%d %d", a, b }')"
8246
+ [ "$_pn" = "1 1" ] && pass "${_pf##*/}: the approval question is wired on AskUserQuestion: guard-question.sh once on PreToolUse, prompt-approval.sh once on PostToolUse" \
8247
+ || fail "${_pf##*/}: the approval question is not wired once each (guard-question.sh on PreToolUse, prompt-approval.sh on PostToolUse; found: $_pn)"
8248
+ done
8249
+ # The managed settings files are one list: the ones model routing reads its rules from are the ones the approval
8250
+ # question looks for a PermissionRequest hook in. Compared as the lines that name a managed-settings.json.
8251
+ _pml(){ grep -oE '"[^"]*managed-settings\.json"|CREW_MANAGED_SETTINGS' "$1" 2>/dev/null | sort -u | tr '\n' ' '; }
8252
+ _pm1="$(_pml "$HOOKS/prompt-approval.sh")"; _pm2="$(_pml "$HOOKS/guard-agent-model.sh")"
8253
+ case "$_pm1" in *managed-settings.json*managed-settings.json*managed-settings.json*CREW_MANAGED_SETTINGS*)
8254
+ [ "$_pm1" = "$_pm2" ] && pass "the managed settings files are one list in prompt-approval.sh and guard-agent-model.sh ($(printf '%s' "$_pm1" | tr ' ' '\n' | grep -c .) entries)" \
8255
+ || fail "the managed settings lists differ: prompt-approval.sh [$_pm1] guard-agent-model.sh [$_pm2]" ;;
8256
+ *) fail "FIXTURE: the managed settings list could not be read from prompt-approval.sh ([$_pm1])" ;;
8257
+ esac
7803
8258
  # ...and on PreToolUse for the tools that schedule a prompt, in both editions, with one matcher.
7804
8259
  for _pf in $_pawf; do
7805
8260
  _pn="$(json_hooks "$_pf" | LC_ALL=C awk -F'\t' '$1 == "PreToolUse" && $3 ~ /guard-schedule\.sh/ { n++; m = $2 } END { printf "%d %s", n, m }')"
@@ -7989,7 +8444,7 @@ _v2new(){ rm -rf "$_v2p" "$_v2/home"; mkdir -p "$_v2/home" "$_v2p/.claude" "$_v2
7989
8444
  _v2e(){ env -u CREW_MODEL_ROUTING -u CREW_ALLOW_FABLE -u CREW_MODEL_CAL_N -u CREW_MODEL_CAL_PCT -u CREW_VERIFY_NO_SETSID -u CREW_VERIFY_TIMEOUT -u CLAUDE_CONFIG_DIR -u CREW_MANAGED_SETTINGS HOME="$_v2/home" CLAUDE_PROJECT_DIR="$_v2p" CREW_GATE_LOG=/dev/null ${_V2ENV:-} "$@"; }
7990
8445
  # $1 agent, $2 model, $3 files, $4 change, $5 verify, $6 extra fields -> _v2rc, stderr in $_v2/err
7991
8446
  _v2call(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"description":"d","prompt":"files: %s\\nchange: %s\\nverify: %s\\n\\nDo the work.","subagent_type":"%s","model":"%s"%s},"tool_use_id":"tu%s"}' "$3" "$4" "$5" "$1" "$2" "${6:-}" "$RANDOM$RANDOM" > "$_v2/c.json"
7992
- ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/c.json" >/dev/null 2>"$_v2/err" ); _v2rc=$?; }
8447
+ ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/c.json" >"$_v2/out" 2>"$_v2/err" ); _v2rc=$?; }
7993
8448
  _v2start(){ printf '{"session_id":"s","hook_event_name":"SubagentStart","agent_id":"%s","agent_type":"%s"}' "$1" "${2:-crew-backend-expert}" | ( cd "$_v2p" && _v2e bash "$_V2O" >/dev/null 2>&1 ); }
7994
8449
  _v2stop(){ printf '{"session_id":"s","permission_mode":"%s","hook_event_name":"SubagentStop","agent_id":"%s","agent_type":"x","stop_hook_active":false,"agent_transcript_path":"%s/none.jsonl","last_assistant_message":"did it\\n%s"}' "${_V2PM:-auto}" "$1" "$_v2" "${2:-confidence: high}" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
7995
8450
  _v2post(){ printf '{"session_id":"s","hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"p"},"tool_response":{"status":"completed","agentId":"%s","content":"x"}}' "$1" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
@@ -8022,8 +8477,26 @@ done <<'V2'
8022
8477
  2|crew-backend-expert|sonnet|prisma/schema.prisma|feature|none|
8023
8478
  2|crew-backend-expert|sonnet|config/SECRETS_local.env|text|none|
8024
8479
  0|crew-backend-expert|sonnet|docs/author.md, src/cryptography/hash.ts|feature|none|
8480
+ 0|crew-backend-expert|sonnet|legal/terms-of-service.md|text|none|
8481
+ 0|crew-backend-expert|sonnet|docs/privacy-policy.md|text|none|
8482
+ 2|crew-backend-expert|sonnet|src/PrivacyController.cs|feature|none|
8483
+ 2|crew-backend-expert|sonnet|src/consent/banner.ts|feature|none|
8484
+ 0|crew-backend-expert|sonnet|content/terms/index.md|text|none|
8485
+ 0|crew-backend-expert|sonnet|fastlane/metadata/en-US/description.txt|text|none|
8486
+ 0|crew-backend-expert|sonnet|store/listing.md|text|none|
8487
+ 0|crew-backend-expert|sonnet|metadata/android/en-US/full_description.txt|text|none|
8488
+ 0|crew-backend-expert|opus|legal/terms-of-service.md|text|none|
8489
+ 2|crew-backend-expert|haiku|legal/terms-of-service.md|text|none|
8490
+ 2|crew-backend-expert|sonnet|legal/terms-of-service.md, src/legal/consent.ts|text|none|
8491
+ 2|crew-backend-expert|sonnet|legal/terms-of-service.md|feature|none|
8492
+ 0|crew-backend-expert|sonnet|src/searchTerms.ts|feature|none|
8493
+ 0|crew-backend-expert|sonnet|src/store/index.ts|feature|none|
8494
+ 0|crew-backend-expert|sonnet|fastlane/metadata/en-US/images/icon.png|text|none|
8495
+ 0|crew-backend-expert|sonnet|docs/legalese.md|text|none|
8025
8496
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|none|
8026
- 2|crew-backend-expert|haiku|src/ui/a.ts|text|none|
8497
+ 0|crew-backend-expert|haiku|src/ui/a.ts|text|none|
8498
+ 2|crew-backend-expert|haiku|src/ui/a.ts|fix-known|none|
8499
+ 0|crew-commit-agent|haiku|src/ui/a.ts|fix-known|none|
8027
8500
  0|crew-backend-expert|sonnet|src/ui/a.ts|text|none|
8028
8501
  0|crew-backend-expert|haiku|src/ui/a.ts|text|true|
8029
8502
  0|crew-test-expert|haiku|src/ui/**|test-run|true|
@@ -8055,9 +8528,48 @@ done <<'V2'
8055
8528
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|git commit -m x|
8056
8529
  0|crew-backend-expert|sonnet|src/ui/a.ts|feature|rm -rf /|
8057
8530
  V2
8058
- if [ "$_v2n" != 47 ]; then fail "FIXTURE: the card table has $_v2n rows, not 47"
8059
- 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)"
8531
+ if [ "$_v2n" != 65 ]; then fail "FIXTURE: the card table has $_v2n rows, not 65"
8532
+ 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)"
8060
8533
  else fail "card table:$_v2b"; fi
8534
+ # The card's floor is also the default: a call above it runs ON it (the gate hands back the same input with `model`
8535
+ # replaced, PreToolUse updatedInput), unless the card's next line says `above: <why>`. Measured on a real project:
8536
+ # 73 of 186 crew tasks ran above their floor and none on haiku; measured on Claude Code 2.1.299: the rewrite holds.
8537
+ _v2new; _v2b=""
8538
+ _v2last(){ ls -t "$_v2p/.claude/state/crew-model/pending/"* 2>/dev/null | head -1; }
8539
+ _v2call crew-review-agent opus 'src/ui/a.ts' audit none ',"run_in_background":true,"isolation":"worktree"'
8540
+ _o="$(cat "$_v2/out" 2>/dev/null)"; _r="$(_v2last)"
8541
+ case "$_o" in *'"permissionDecision":"allow"'*'"updatedInput":'*) ;; *) _v2b="$_v2b [above the floor, no above: -> no rewrite: rc=$_v2rc ${_o:0:80}]" ;; esac
8542
+ _u="$(printf '%s' "$_o" | awk -v op=get -v path=hookSpecificOutput.updatedInput -f "$ROOT/eval/lib/settings-json.awk" - 2>/dev/null | tr -d ' \n')"
8543
+ case "$_u" in *'"model":"sonnet"'*'"run_in_background":true'*'"isolation":"worktree"'*|*'"run_in_background":true'*'"isolation":"worktree"'*) ;; *) _v2b="$_v2b [the rewrite lost a field: ${_u:0:120}]" ;; esac
8544
+ case "$_u" in *'"model":"sonnet"'*) ;; *) _v2b="$_v2b [the rewrite is not to the floor: ${_u:0:120}]" ;; esac
8545
+ { grep -q '^tier=sonnet$' "$_r" && grep -q '^lowered=opus$' "$_r"; } 2>/dev/null || _v2b="$_v2b [record: $(tr '\n' ' ' < "$_r" 2>/dev/null | cut -c1-80)]"
8546
+ rm -f "$_v2p/.claude/state/crew-model/pending/"*
8547
+ _v2call crew-review-agent opus 'src/ui/a.ts' audit 'none\nabove: the audit covers the whole session layer'
8548
+ { [ "$_v2rc" = 0 ] && [ ! -s "$_v2/out" ] && grep -q '^tier=opus$' "$(_v2last)"; } 2>/dev/null || _v2b="$_v2b [above: did not keep opus: rc=$_v2rc $(head -c 60 "$_v2/out")]"
8549
+ rm -f "$_v2p/.claude/state/crew-model/pending/"*
8550
+ _v2call crew-review-agent sonnet 'src/ui/a.ts' audit none
8551
+ { [ "$_v2rc" = 0 ] && [ ! -s "$_v2/out" ]; } || _v2b="$_v2b [a call on its floor was rewritten: $(head -c 60 "$_v2/out")]"
8552
+ _v2call crew-frontend-expert sonnet 'src/ui/a.ts' text 'test -d src'
8553
+ case "$(tr -d ' ' < "$_v2/out")" in *'"model":"haiku"'*) ;; *) _v2b="$_v2b [work a command checks did not go to haiku: $(head -c 80 "$_v2/out")]" ;; esac
8554
+ rm -f "$_v2p/.claude/state/crew-model/pending/"*
8555
+ # Not a crew agent: no card and no floor, only the expensive end is held. opus or fable with no `above:` line in the
8556
+ # task runs on sonnet; with one it stays; a call that names no model, or sonnet, is not touched.
8557
+ _v2o(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"description":"d","prompt":"Find where X is defined.%s","subagent_type":"general-purpose"%s},"tool_use_id":"tu%s"}' "$1" "$2" "$RANDOM" | ( cd "$_v2p" && _v2e bash "$_V2G" 2>/dev/null ) | tr -d ' '; }
8558
+ case "$(_v2o '' ',"model":"opus"')" in *'"updatedInput":'*'"model":"sonnet"'*) ;; *) _v2b="$_v2b [general-purpose on opus was not run on sonnet]" ;; esac
8559
+ [ -z "$(_v2o '\nabove: a design question across the codebase' ',"model":"opus"')" ] || _v2b="$_v2b [above: did not keep opus for general-purpose]"
8560
+ [ -z "$(_v2o '' ',"model":"sonnet"')" ] || _v2b="$_v2b [a call naming sonnet was rewritten]"
8561
+ # Naming no model: general-purpose takes the session's model, so it is given sonnet (CLAUDE_CODE_SUBAGENT_MODEL in
8562
+ # the project's settings env did not do it, measured on 2.1.299), unless the session is on haiku; an agent whose
8563
+ # definition names its own model is left to it; a project agent with no model line is treated like general-purpose.
8564
+ _v2ot(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","transcript_path":"%s","tool_input":{"description":"d","prompt":"Find X.","subagent_type":"%s"},"tool_use_id":"tu%s"}' "$2" "$1" "$RANDOM" | ( cd "$_v2p" && _v2e bash "$_V2G" 2>/dev/null ) | tr -d ' '; }
8565
+ printf '{"type":"assistant","message":{"model":"claude-opus-5-5"}}\n' > "$_v2/tr-opus.jsonl"; printf '{"type":"assistant","message":{"model":"claude-haiku-5-5"}}\n' > "$_v2/tr-haiku.jsonl"
8566
+ mkdir -p "$_v2p/.claude/agents"; printf -- '---\nname: mine\ndescription: x\n---\nDo it.\n' > "$_v2p/.claude/agents/mine.md"; printf -- '---\nname: cheap\nmodel: haiku\n---\nDo it.\n' > "$_v2p/.claude/agents/cheap.md"
8567
+ case "$(_v2ot general-purpose "$_v2/tr-opus.jsonl")" in *'"updatedInput":'*'"model":"sonnet"'*) ;; *) _v2b="$_v2b [general-purpose naming no model under opus was not given sonnet]" ;; esac
8568
+ [ -z "$(_v2ot general-purpose "$_v2/tr-haiku.jsonl")" ] || _v2b="$_v2b [a haiku session's general-purpose was raised to sonnet]"
8569
+ [ -z "$(_v2ot Explore "$_v2/tr-opus.jsonl")$(_v2ot cheap "$_v2/tr-opus.jsonl")" ] || _v2b="$_v2b [an agent with a model of its own was given one]"
8570
+ case "$(_v2ot mine "$_v2/tr-opus.jsonl")" in *'"model":"sonnet"'*) ;; *) _v2b="$_v2b [a project agent with no model line was not given sonnet]" ;; esac
8571
+ [ -z "$_v2b" ] && pass "above the floor runs on the floor: opus for a sonnet card is rewritten to sonnet with every other field kept and recorded as lowered; 'above: <why>' keeps opus; a call on its floor is untouched; checkable text work goes to haiku; an agent outside the crew asking for opus, or inheriting an opus session's model, runs on sonnet unless its task says why; a haiku session and an agent with its own model are left alone" \
8572
+ || fail "model on the floor:$_v2b"
8061
8573
  # The refusal names the way forward that exists (the field was sent, so it can be sent as false), and a call that
8062
8574
  # left the field out and was launched in the background is told so when the launch comes back.
8063
8575
  _v2new; _v2b=""
@@ -8098,21 +8610,79 @@ for _pr in 'Do the work.' 'Please do this:\nfiles: a\nchange: feature\nverify: t
8098
8610
  done
8099
8611
  [ -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" \
8100
8612
  || fail "a task without a proper card passed:$_v2b"
8613
+ # The card on one line (2026-10-18: 17 of 39 card refusals on this machine were `files: … · change: … · verify: …`). It is
8614
+ # read by its labels and held to the same rules: what passes on three lines passes on one, and what fails there fails.
8615
+ _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"
8616
+ ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/one.json" >"$_v2/oneout" 2>"$_v2/err" ); _rc=$?; }
8617
+ 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' \
8618
+ 'Card: files: src/ui/a.ts, src/ui/b.ts · change: feature · verify: true.' 'Card — files: src/ui/a.ts · change: text · verify: none' \
8619
+ 'Kart — files: src/ui/a.ts · change: text · verify: none' 'Kart: files: src/ui/a.ts · change: feature · verify: true'; do
8620
+ _v2one "$_pr" sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [refused: $_pr → $_rc $(head -c 120 "$_v2/err")]"
8621
+ done
8622
+ _v2one 'files: src/payments/p.ts · change: feature · verify: true' sonnet
8623
+ { [ "$_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)]"
8624
+ _v2one 'files: src/ui/a.ts · change: feature · verify: true · above: the parser is subtle' opus
8625
+ { [ "$_rc" = 0 ] && ! grep -q 'runs on sonnet' "$_v2/oneout"; } || _v2b="$_v2b [above: on the same line was not read (rc $_rc)]"
8626
+ _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]"
8627
+ _v2one 'files: src/ui/a.ts · change: architecture (planning only) · verify: none' sonnet
8628
+ { [ "$_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)]"
8629
+ _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]"
8630
+ 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' 'Kartal files: a · change: feature · verify: true'; do
8631
+ _v2one "$_pr" sonnet; { [ "$_rc" = 2 ] && grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [passed: $_pr → $_rc]"
8632
+ done
8633
+ [ -z "$_v2b" ] && pass "a card on one line (· ; | separators, a Card: or Kart: 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" \
8634
+ || fail "the one-line card:$_v2b"
8635
+ # `above:` among the three lines, not only after them (2026-10-19: 2 of 19 line refusals had it between change: and
8636
+ # verify:; the retry moved it down and passed). It keeps the model the same way; the three are still all required.
8637
+ _v2b=""
8638
+ for _pr in 'files: src/ui/a.ts\nchange: feature\nabove: the parser is subtle\nverify: true' 'above: the parser is subtle\nfiles: src/ui/a.ts\nchange: feature\nverify: true'; do
8639
+ _v2one "$_pr" opus; { [ "$_rc" = 0 ] && [ ! -s "$_v2/oneout" ]; } || _v2b="$_v2b [above: among the three did not keep opus: $_pr → $_rc $(head -c 100 "$_v2/oneout")]"
8640
+ done
8641
+ _v2one 'files: src/ui/c.ts\nchange: feature\nverify: true' opus
8642
+ grep -q '"updatedInput":' "$_v2/oneout" || _v2b="$_v2b [without above: opus was not lowered (rc $_rc)]"
8643
+ for _pr in 'files: src/ui/a.ts\nchange: feature\nabove: x\nDo it.' 'files: src/ui/a.ts\nabove: x\nabove: y\nverify: true'; do
8644
+ _v2one "$_pr" sonnet; { [ "$_rc" = 2 ] && grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [passed: $_pr → $_rc]"
8645
+ done
8646
+ # `change: review` is an audit (the discipline's table lists it with them): same floor, a near miss still refused.
8647
+ _v2one 'files: src/ui/a.ts\nchange: review\nverify: none' haiku; { [ "$_rc" = 2 ] && grep -q 'runs on sonnet' "$_v2/err"; } || _v2b="$_v2b [review on haiku was not held to sonnet as audit (rc $_rc)]"
8648
+ for _pr in 'files: src/ui/a.ts\nchange: review\nverify: none' 'files: src/ui/a.ts · change: review (the staged diff) · verify: none'; do
8649
+ _v2one "$_pr" sonnet; [ "$_rc" = 0 ] || _v2b="$_v2b [refused: $_pr → $_rc $(head -c 100 "$_v2/err")]"
8650
+ done
8651
+ _v2one 'files: src/payments/p.ts\nchange: review\nverify: none' sonnet; { [ "$_rc" = 2 ] && grep -q 'runs on opus' "$_v2/err"; } || _v2b="$_v2b [review on a critical path was not held to opus (rc $_rc)]"
8652
+ _v2one 'files: src/ui/a.ts\nchange: reviews\nverify: none' sonnet; { [ "$_rc" = 2 ] && grep -q 'opens with its task card' "$_v2/err"; } || _v2b="$_v2b [change: reviews passed (rc $_rc)]"
8653
+ [ -z "$_v2b" ] && pass "above: may stand anywhere among the three card lines and keeps the model; without it the model is lowered; above: does not stand in for files:, change: or verify:; change: review is an audit, held to the same floors, and a near miss is still refused" \
8654
+ || fail "above: in the card:$_v2b"
8101
8655
 
8102
8656
  # ---- at write time: the card can leave a file out --------------------------------------------------------------
8103
8657
  _v2new; _v2b=""
8104
8658
  _v2call crew-backend-expert sonnet 'src/ui/a.ts' refactor true; _v2start ag-son
8105
8659
  _v2call crew-backend-expert opus 'src/payments/p.ts' feature none; _v2start ag-op
8660
+ _v2call crew-docs-agent sonnet 'legal/terms.md' text none; _v2start ag-txt crew-docs-agent
8106
8661
  _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"
8107
8662
  _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"
8108
8663
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-op","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b opus-agent-refused:$_v2wrc"
8109
8664
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-unknown","agent_type":"general-purpose"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b unknown-model-wrote-payments:$_v2wrc"
8110
8665
  _v2write "$_v2p/src/payments/p.ts"; [ "$_v2wrc" = 0 ] || _v2b="$_v2b the-session-refused:$_v2wrc"
8666
+ # A text change on sonnet writes prose on a critical path; not code there, and not prose for any other change.
8667
+ _v2write "$_v2p/legal/terms.md" ',"agent_id":"ag-txt","agent_type":"crew-docs-agent"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b sonnet-text-refused-legal-prose:$_v2wrc"
8668
+ _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"
8669
+ _v2write "$_v2p/legal/terms.md" ',"agent_id":"ag-son","agent_type":"crew-backend-expert"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b sonnet-refactor-wrote-legal-prose:$_v2wrc"
8111
8670
  mkdir -p "$_v2/t/subagents"; printf '{"type":"assistant","message":{"model":"claude-opus-5-5"}}\n' > "$_v2/t/subagents/agent-ag-gp.jsonl"
8112
8671
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"ag-gp","agent_type":"general-purpose"'; [ "$_v2wrc" = 0 ] || _v2b="$_v2b opus-by-its-transcript-refused:$_v2wrc"
8113
8672
  _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"
8114
- [ -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" \
8673
+ [ -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" \
8115
8674
  || fail "write-time check:$_v2b"
8675
+ # B2 (K7): the session's own write to a critical path is not refused, and it is recorded: one NOTE line in the gate
8676
+ # log naming the path and the rule. A write elsewhere, one outside the project, and one with routing off add none.
8677
+ _v2b=""; _b2l="$_v2/b2.tsv"
8678
+ _b2(){ : > "$_b2l"; _V2ENV="CREW_GATE_LOG=$_b2l ${2:-}" _v2write "$1"; _b2n="$(grep -c '^NOTE' "$_b2l" 2>/dev/null)"; }
8679
+ _b2 "$_v2p/src/payments/p.ts"; [ "$_v2wrc/$_b2n" = 0/1 ] || _v2b="$_v2b [critical: rc $_v2wrc, $_b2n notes]"
8680
+ grep -q "^NOTE"$'\t'"§model"$'\t'"note: the session wrote a critical path itself, not through an agent: src/payments/p.ts (payments)" "$_b2l" || _v2b="$_v2b [line: $(head -1 "$_b2l")]"
8681
+ _b2 "$_v2p/src/ui/a.ts"; [ "$_v2wrc/$_b2n" = 0/0 ] || _v2b="$_v2b [plain: rc $_v2wrc, $_b2n notes]"
8682
+ _b2 "/tmp/elsewhere/payments/p.ts"; [ "$_v2wrc/$_b2n" = 0/0 ] || _v2b="$_v2b [outside: rc $_v2wrc, $_b2n notes]"
8683
+ _b2 "$_v2p/src/payments/p.ts" CREW_MODEL_ROUTING=off; [ "$_v2wrc/$_b2n" = 0/0 ] || _v2b="$_v2b [routing off: rc $_v2wrc, $_b2n notes]"
8684
+ [ -z "$_v2b" ] && pass "the session's own write to a critical path is allowed and recorded as a NOTE with the path and the rule; a plain write, one outside the project and one with routing off are not recorded" \
8685
+ || fail "the session's critical write (B2):$_v2b"
8116
8686
 
8117
8687
  # ---- 2. the critical paths of a project with no settings file: a Flutter app and a .NET service ---------------
8118
8688
  _v2new; _v2b=""
@@ -8134,6 +8704,32 @@ _v2call crew-backend-expert sonnet 'docs/security.md' text none; [ "$_v2rc" = 0
8134
8704
  _v2call crew-backend-expert sonnet 'Api/Services/AuthorService.cs' feature none; [ "$_v2rc" = 0 ] || _v2b="$_v2b AuthorService-read-as-auth"
8135
8705
  [ -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" \
8136
8706
  || fail "critical paths:$_v2b"
8707
+ # V5: an organisation preset (eval/lib/preset.sh) is a block at the top of crew-model-rules. The project's own lines
8708
+ # stay below it and win where the file's rule lets them; applying again changes nothing; an invalid one writes nothing.
8709
+ _v2new; _v2b=""; PSH="$ROOT/eval/lib/preset.sh"; _psr="$_v2p/.claude/crew-model-rules"; _psx="$(mktemp -d)"
8710
+ printf '# ours\n- infra\n' > "$_psr"
8711
+ ( cd "$_v2p" && bash "$PSH" --apply strict ) >/dev/null 2>&1 || _v2b="$_v2b [strict did not apply]"
8712
+ { IFS= read -r _l1 < "$_psr"; case "$_l1" in '# ---- preset: strict '*) ;; *) false ;; esac; } || _v2b="$_v2b [first line: ${_l1:-none}]"
8713
+ 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]"
8714
+ [ "$(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]"
8715
+ 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]"
8716
+ [ "$( cd "$_v2p" && bash "$PSH" --show )" = strict ] || _v2b="$_v2b [--show]"
8717
+ # The gate follows it: review on sonnet is refused under strict, and the project's `- infra` undoes the preset's `+ infra`.
8718
+ _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]"
8719
+ _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]"
8720
+ _v2call crew-backend-expert sonnet 'deploy/run.sh' feature 'test -d src'; [ "$_v2rc" = 2 ] || _v2b="$_v2b [deploy is not critical under strict: rc $_v2rc]"
8721
+ ( 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]"
8722
+ # Invalid presets: each refused with rc 1, the file untouched.
8723
+ cp "$_psr" "$_psx/b"; _bad=0
8724
+ 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
8725
+ printf '%s' "$_bj" > "$_psx/bad.json"; ( cd "$_v2p" && bash "$PSH" --apply "$_psx/bad.json" ) >/dev/null 2>&1; _rc=$?
8726
+ { [ "$_rc" = 1 ] && cmp -s "$_psr" "$_psx/b"; } || { _bad=$((_bad+1)); _v2b="$_v2b [accepted or wrote: $_bj rc $_rc]"; }
8727
+ done
8728
+ ( cd "$_v2p" && bash "$PSH" --apply no-such-preset ) >/dev/null 2>&1 && _v2b="$_v2b [a preset that is not there applied]"
8729
+ _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]"
8730
+ [ -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" \
8731
+ || fail "presets:$_v2b"
8732
+ rm -rf "$_psx"
8137
8733
 
8138
8734
  # ---- 3. verify, one fix inside the agent, then one model up, then the user -----------------------------------
8139
8735
  _v2new; _v2b=""
@@ -8170,7 +8766,7 @@ _v2call crew-backend-expert sonnet 'src/ui/tz.ts' feature none; _v2start tz1; _V
8170
8766
  _tzg="$(awk -F'\t' '$2 == "tz1" { print substr($1, 1, 13) }' "$_v2p/.claude/state/model-outcomes.tsv")"; _tzn="$(date -u +%Y-%m-%dT%H)"; _tzp="$(date -u -v-1H +%Y-%m-%dT%H 2>/dev/null || date -u -d '1 hour ago' +%Y-%m-%dT%H 2>/dev/null)"
8171
8767
  { [ -n "$_tzg" ] && { [ "$_tzg" = "$_tzn" ] || [ "$_tzg" = "$_tzp" ]; }; } || _v2b="$_v2b [the time is not UTC: wrote $_tzg, UTC is $_tzn]"
8172
8768
  IFS= read -r _h < "$_v2p/.claude/state/model-outcomes.tsv"
8173
- [ "$_h" = "ts"$'\t'"agent_id"$'\t'"agent"$'\t'"change"$'\t'"risk"$'\t'"card"$'\t'"model"$'\t'"verify"$'\t'"fixes"$'\t'"escalated_from"$'\t'"ran_on"$'\t'"confidence" ] || _v2b="$_v2b [header: $_h]"
8769
+ [ "$_h" = "ts"$'\t'"agent_id"$'\t'"agent"$'\t'"change"$'\t'"risk"$'\t'"card"$'\t'"model"$'\t'"verify"$'\t'"fixes"$'\t'"escalated_from"$'\t'"ran_on"$'\t'"confidence"$'\t'"risk_why" ] || _v2b="$_v2b [header: $_h]"
8174
8770
  [ -z "$_v2b" ] && pass "verify then escalate: red once keeps the agent running with the failing command; green after it is a pass with one fix; red twice ends the agent as a fail, recorded once; the session is told to repeat one model up; the same card is refused on the same model and allowed one up; red there too is the user's question and a third run is refused; a changed card is a new task; confidence: low is told the same way; a background launch and an agent with no record are left alone; the record's twelve columns are the ones published, and its time is UTC under a zone fourteen hours ahead" \
8175
8771
  || fail "verify-then-escalate:$_v2b"
8176
8772
 
@@ -8446,12 +9042,41 @@ if command -v iconv >/dev/null 2>&1; then printf '%s' "$_o" | iconv -f UTF-8 -t
8446
9042
  [ "${#_o}" -le 2600 ] || _v2b="$_v2b [the reason is ${#_o} bytes]"
8447
9043
  [ -z "$_v2b" ] && pass "the one fix is asked once and only where it can be counted: a red verify under stop_hook_active ends as fail with no block, and so does one whose counter cannot be written (both recorded as fail with no fix); the output handed to the agent is cut at 1500 bytes without splitting a character" \
8448
9044
  || fail "the stop hook's one fix:$_v2b"
9045
+ # A verify command in a sub-folder, `cd <folder> && <command>` (41 of 71 refusals on one project): judged as the
9046
+ # command and run in the folder with no chain. A folder outside the project, one that does not exist, or a second
9047
+ # chain after it stays refused. A card that says `none (why)` is none.
9048
+ _v2new; _v2b=""; mkdir -p "$_v2p/.claude/state/crew-model/agents" "$_v2p/sub"; : > "$_v2p/sub/ok.flag"
9049
+ printf 'verify test -f ok.flag\n' > "$_v2p/.claude/crew-model-rules"
9050
+ _v2plant c1 'cd sub && test -f ok.flag'; _v2stop c1 >/dev/null
9051
+ [ "$(_v2row c1 | cut -d/ -f2)" = pass ] || _v2b="$_v2b [cd sub && test: $(_v2row c1)]"
9052
+ for _cc in 'c2:cd ../sub && test -f ok.flag' 'c3:cd nowhere && test -f ok.flag' 'c4:cd sub && test -f ok.flag && touch x' 'c5:cd /tmp && test -f ok.flag'; do
9053
+ _v2plant "${_cc%%:*}" "${_cc#*:}"; _v2stop "${_cc%%:*}" >/dev/null
9054
+ [ "$(_v2row "${_cc%%:*}" | cut -d/ -f2)" = blocked ] || _v2b="$_v2b [${_cc#*:} was not refused: $(_v2row "${_cc%%:*}")]"
9055
+ done
9056
+ [ ! -e "$_v2p/sub/x" ] || _v2b="$_v2b [a chain after the folder ran]"
9057
+ _v2call crew-backend-expert sonnet 'src/ui/a.ts' text 'none (the author ran it)'
9058
+ grep -q '^verify=none$' "$(ls -t "$_v2p/.claude/state/crew-model/pending/"* 2>/dev/null | head -1)" 2>/dev/null || _v2b="$_v2b [none (why) was not none]"
9059
+ # doctor lists the commands refused for not being allowed, with the line that allows them, and not a sentence
9060
+ mkdir -p "$_v2p/.claude/state/crew-model/results"
9061
+ 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"
9062
+ 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"
9063
+ # The other way of saying "nothing allows it" (default mode), for a command in a sub-folder: counted, and offered as
9064
+ # the rule that matches it, the command without its `cd` (measured on Windows: the line with the cd in it did not).
9065
+ 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"
9066
+ if [ -f "$ROOT/eval/doctor.sh" ]; then
9067
+ mkdir -p "$_v2p/.claude/eval"; cp -R "$ROOT/eval/." "$_v2p/.claude/eval/" 2>/dev/null
9068
+ _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' )"
9069
+ 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 ;;
9070
+ *) _v2b="$_v2b [doctor: ${_dd:-no line}]" ;; esac
9071
+ fi
9072
+ [ -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" \
9073
+ || fail "verify in a sub-folder:$_v2b"
8449
9074
  # Which record is whose. Two calls of one type made together are matched by order, which can be wrong; the card in
8450
9075
  # the agent's own transcript settles it.
8451
9076
  _v2new; _v2b=""; _v2st="$_v2p/.claude/state/crew-model"
8452
9077
  _v2tr(){ printf '{"type":"user","message":{"role":"user","content":"files: %s\\nchange: %s\\nverify: %s\\n\\nDo the work."}}\n' "$2" "$3" "$4" > "$_v2/tr-$1.jsonl"; }
8453
9078
  _v2stopt(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"SubagentStop","agent_id":"%s","agent_type":"x","stop_hook_active":false,"agent_transcript_path":"%s/tr-%s.jsonl","last_assistant_message":"did it\\nconfidence: high"}' "$1" "$_v2" "$1" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
8454
- _v2call crew-backend-expert haiku 'src/ui/one.ts' text 'test -f one.ok'; _v2call crew-backend-expert opus 'src/ui/two.ts' feature 'test -f two.ok'
9079
+ _v2call crew-backend-expert haiku 'src/ui/one.ts' text 'test -f one.ok'; _v2call crew-backend-expert opus 'src/ui/two.ts' feature 'test -f two.ok\nabove: two models pending for one agent type'
8455
9080
  # The order the two records are taken in is made known: the haiku call's first, so each agent gets the OTHER's below.
8456
9081
  for _pf in "$_v2st/pending"/*; do if grep -q 'one.ok' "$_pf"; then mv "$_pf" "$_v2st/pending/1-a"; else mv "$_pf" "$_v2st/pending/2-b"; fi; done
8457
9082
  _pt="$(date +%s)"; mv "$_v2st/pending/1-a" "$_v2st/pending/$_pt-a"; mv "$_v2st/pending/2-b" "$_v2st/pending/$_pt-b"
@@ -8486,15 +9111,39 @@ rm -f "$_v2st"/pending/*
8486
9111
  _v2call crew-backend-expert sonnet 'src/ui/race.ts' text true
8487
9112
  _v2start r1 & _v2start r2 & wait
8488
9113
  _rn=0; [ -f "$_v2st/agents/r1" ] && _rn=$((_rn+1)); [ -f "$_v2st/agents/r2" ] && _rn=$((_rn+1)); [ "$_rn" = 1 ] || _v2b="$_v2b [one record, two agents: $_rn got it]"
9114
+ ls "$_v2st/agents" 2>/dev/null | grep -qE '\.(take|claim)$' && _v2b="$_v2b a-lock-or-claim-left-behind"
9115
+ # A record another hook holds the lock on is not taken: the agent goes on to the next one (the lock is what keeps
9116
+ # two agents off one record where a rename alone did not, on Windows).
9117
+ rm -rf "$_v2st/agents"; rm -f "$_v2st"/pending/*
9118
+ _v2call crew-backend-expert sonnet 'src/ui/l1.ts' text 'test -f l1'; _pl1="$(ls "$_v2st/pending")"
9119
+ sleep 1; _v2call crew-backend-expert sonnet 'src/ui/l2.ts' text 'test -f l2'
9120
+ mkdir -p "$_v2st/agents"; : > "$_v2st/agents/$_pl1.take"
9121
+ _v2start k1
9122
+ { grep -q '^verify=test -f l2' "$_v2st/agents/k1" 2>/dev/null && [ -f "$_v2st/pending/$_pl1" ]; } || _v2b="$_v2b a-locked-record-was-taken-or-the-next-one-was-not"
9123
+ rm -f "$_v2st/agents/$_pl1.take" "$_v2st"/pending/*
8489
9124
  # A record nothing claimed within five minutes is nobody's.
8490
9125
  mkdir -p "$_v2st/pending"; printf 'agent=crew-backend-expert\ntier=opus\nchange=text\nrisk=normal\ncard=old\nbg=unset\nesc=-\nverify=none\n' > "$_v2st/pending/1000000000-tuold"
8491
9126
  _v2start o1; { [ ! -f "$_v2st/agents/o1" ] && [ ! -f "$_v2st/pending/1000000000-tuold" ]; } || _v2b="$_v2b a-stale-record-was-given-to-an-agent-or-kept"
8492
9127
  [ -z "$_v2b" ] && pass "which record is whose: with two models pending for one agent type the agent is recorded on the lower one; a record is taken by rename, so of two agents starting together one gets it; an agent whose transcript carries the other card is judged by that card's record (a call nobody has taken yet is looked at first), a task given as a list of blocks is read, a transcript with no card is noted in the gate log and the record used as matched, an agent whose card no record carries is not judged, and a record older than five minutes is dropped" \
8493
9128
  || fail "matching a call to its agent:$_v2b"
9129
+ # Why the work was critical is kept with it, in the gate's own words, so a reader shows the reason and does not work
9130
+ # it out again: a card on a critical path carries the path and the rule that matched; normal work carries "-".
9131
+ _v2new; _v2b=""; _v2st="$_v2p/.claude/state/crew-model"
9132
+ _v2call crew-backend-expert opus 'src/payments/p.ts' feature 'test -d src' ',"run_in_background":false'
9133
+ [ "$_v2rc" = 0 ] || _v2b="$_v2b FIXTURE:[the critical call was refused, rc=$_v2rc: $(head -c 300 "$_v2/err")]"
9134
+ grep -q '^why=src/payments/p.ts is on a critical path: ' "$_v2st"/pending/* 2>/dev/null || _v2b="$_v2b [pending record: $(grep -h '^why=' "$_v2st"/pending/* 2>/dev/null)]"
9135
+ _v2start k1; _v2tr k1 'src/payments/p.ts' feature 'test -d src'; _v2stopt k1 >/dev/null
9136
+ _kw="$(awk -F'\t' '$2 == "k1" { print $13 }' "$_v2st/../model-outcomes.tsv" 2>/dev/null)"
9137
+ case "$_kw" in 'src/payments/p.ts is on a critical path: '?*) ;; *) _v2b="$_v2b [critical row: risk_why='$_kw']" ;; esac
9138
+ _v2call crew-backend-expert sonnet 'src/ui/a.ts' feature 'test -d src'; _v2start k2; _v2tr k2 'src/ui/a.ts' feature 'test -d src'; _v2stopt k2 >/dev/null
9139
+ _kn="$(awk -F'\t' '$2 == "k2" { print $5 "/" $13 }' "$_v2st/../model-outcomes.tsv" 2>/dev/null)"
9140
+ [ "$_kn" = "normal/-" ] || _v2b="$_v2b [normal row: '$_kn']"
9141
+ [ -z "$_v2b" ] && pass "the gate's reason for critical work reaches the record: the pending record and the outcome line carry the path and the rule that matched; normal work carries -" \
9142
+ || fail "risk reason recorded:$_v2b"
8494
9143
  # 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
8495
9144
  # the write-time check; run_in_background named twice is refused.
8496
9145
  _v2new; _v2b=""; _V2ENV="CREW_GATE_LOG=$_v2/gl.tsv"; : > "$_v2/gl.tsv"
8497
- _v2call crew-database-expert sonnet 'src/payments/p.ts' feature none; _v2call crew-backend-expert haiku 'src/ui/a.ts' text none
9146
+ _v2call crew-database-expert sonnet 'src/payments/p.ts' feature none; _v2call crew-backend-expert haiku 'src/ui/a.ts' fix-known none
8498
9147
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"nobody-known","agent_type":"x"'
8499
9148
  _v2write "$_v2p/src/payments/p.ts" ',"agent_id":"","agent_type":"x"'; [ "$_v2wrc" = 2 ] || _v2b="$_v2b an-empty-agent-id-opened-the-check:$_v2wrc"
8500
9149
  _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"
@@ -8523,7 +9172,7 @@ _v2call crew-backend-expert sonnet 'src/ui/m3.ts' feature none; _v2start no1; _v
8523
9172
  [ "$(_v2ran no1)" = "-" ] || _v2b="$_v2b [no model named: $(_v2ran no1)]"
8524
9173
  _v2call crew-backend-expert sonnet 'src/ui/m4.ts' feature none; _v2start od1; _v2stop od1 >/dev/null; _v2postm od1 completed ',"resolvedModel":"a b\tc"'
8525
9174
  [ "$(_v2ran od1)" = "-" ] || _v2b="$_v2b [a model that is not a name was written: $(_v2ran od1)]"
8526
- [ "$(_v2ran fg1)/$(awk 'END { print NR }' "$_v2p/.claude/state/model-outcomes.tsv")/$(awk -F'\t' 'NF != 12' "$_v2p/.claude/state/model-outcomes.tsv" | wc -l | tr -d ' ')" = "claude-sonnet-5-5/5/0" ] || _v2b="$_v2b [the other lines changed: $(_v2ran fg1), $(awk 'END { print NR }' "$_v2p/.claude/state/model-outcomes.tsv") lines]"
9175
+ [ "$(_v2ran fg1)/$(awk 'END { print NR }' "$_v2p/.claude/state/model-outcomes.tsv")/$(awk -F'\t' 'NF != 13' "$_v2p/.claude/state/model-outcomes.tsv" | wc -l | tr -d ' ')" = "claude-sonnet-5-5/5/0" ] || _v2b="$_v2b [the other lines changed: $(_v2ran fg1), $(awk 'END { print NR }' "$_v2p/.claude/state/model-outcomes.tsv") lines]"
8527
9176
  [ -d "$_v2p/.claude/state/.outcomes.lock" ] && _v2b="$_v2b the-lock-was-left-behind"
8528
9177
  [ -z "$_v2b" ] && pass "ran_on is the model the answer to the Agent call names: written into the agent's line when the answer follows the stop (a foreground call), there already when it came at the launch (a background call), '-' when the answer names none or names something that is not a model; a decoy of the field inside the task's text is not read, the other lines and the twelve columns stay as they were, and no lock is left behind" \
8529
9178
  || fail "ran_on:$_v2b"
@@ -8638,11 +9287,11 @@ _v2call crew-backend-expert sonnet 'src/payments/p.ts' feature none; [ "$_v2rc"
8638
9287
  # What a lowering cannot reach: the floors the card and the agent carry. Two raised floors are planted and lowered;
8639
9288
  # the model below still fails where another rule holds it, and passes where only the lowered floor did.
8640
9289
  _v2new; _v2b=""; mkdir -p "$_v2p/.claude/state"
8641
- 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"
8642
- _o="$(_v2say '/crew-loosen crew-backend-expert text normal')$(_v2say '/crew-loosen crew-database-expert feature normal')"
9290
+ 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"
9291
+ _o="$(_v2say '/crew-loosen crew-backend-expert fix-known normal')$(_v2say '/crew-loosen crew-database-expert feature normal')"
8643
9292
  case "$_o" in *'from sonnet to haiku'*'from opus to sonnet'*) ;; *) _v2b="$_v2b FIXTURE:the-two-floors-were-not-lowered" ;; esac
8644
- _v2call crew-backend-expert haiku 'src/ui/f1.ts' text true; [ "$_v2rc" = 0 ] || _v2b="$_v2b the-lowered-class-is-still-refused-on-haiku:$_v2rc"
8645
- _v2call crew-backend-expert haiku 'src/ui/f1.ts' text none; [ "$_v2rc" = 2 ] || _v2b="$_v2b verify-none-went-to-haiku-after-a-lowering"
9293
+ _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"
9294
+ _v2call crew-backend-expert haiku 'src/ui/f1.ts' fix-known none; [ "$_v2rc" = 2 ] || _v2b="$_v2b verify-none-went-to-haiku-after-a-lowering"
8646
9295
  _v2call crew-database-expert sonnet 'src/ui/f2.ts' feature true; [ "$_v2rc" = 0 ] || _v2b="$_v2b the-lowered-class-is-still-refused-on-sonnet:$_v2rc"
8647
9296
  _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"
8648
9297
  _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"
@@ -8719,6 +9368,61 @@ rm -rf "$_v2"
8719
9368
  fi
8720
9369
  # Wired where it has to be, in both editions: the four events of agent-outcome.sh, and the gate on Agent.
8721
9370
  _pawf2="$ROOT/settings.json"; [ "$IS_KIT" = 1 ] && _pawf2="$_pawf2 $(cd "$ROOT/.." && pwd)/plugin/hooks/hooks.json"
9371
+ # ---- the refuter ledger: what crew-refuter concluded, written by the hook from the agent's own task and answer ----
9372
+ # findings.mjs --verified trusts this file, so what writes it is the gate: a line only when the TASK (the first record
9373
+ # of the agent's transcript) and the ANSWER (last_assistant_message) name the same finding and lens, only for a
9374
+ # crew-refuter (plugin-namespaced too), once per agent, whatever CREW_MODEL_ROUTING says, and never a refusal.
9375
+ _RL="$HOOKS/refuter-ledger.sh"
9376
+ if [ ! -f "$_RL" ]; then fail "hooks/refuter-ledger.sh is missing — no verdict of crew-refuter is ever recorded, so --verified can back nothing"
9377
+ else
9378
+ _rlt="$(mktemp -d)"; mkdir -p "$_rlt/p"
9379
+ printf '%s\n' '{"type":"user","content":"files: src/a.js\nchange: audit\nverify: none\nfinding: sec-idor-1\nlens: protection\ncite: src/a.js:3\nclaim: anyone reads any order"}' > "$_rlt/t.jsonl"
9380
+ _rl(){ # $1 agent id, $2 agent type, $3 answer (JSON-escaped), [$4 extra env] -> rows in the ledger after
9381
+ printf '{"hook_event_name":"SubagentStop","session_id":"s1","agent_id":"%s","agent_type":"%s","agent_transcript_path":"%s","last_assistant_message":"%s"}' \
9382
+ "$1" "$2" "$_rlt/t.jsonl" "$3" | env CLAUDE_PROJECT_DIR="$_rlt/p" ${4:-} bash "$_RL" >/dev/null 2>&1
9383
+ _rlrc=$?
9384
+ _rln=0; [ -f "$_rlt/p/.claude/state/crew-refuter.tsv" ] && _rln=$(($(wc -l < "$_rlt/p/.claude/state/crew-refuter.tsv") - 1))
9385
+ }
9386
+ _rlb=""
9387
+ _rl a1 crew-refuter 'checked\nFINDING: sec-idor-1\nLENS: protection\nVERDICT: FALSE_POSITIVE\nREASON: guard at src/a.js:2\nconfidence: high'
9388
+ [ "$_rlrc" = 0 ] && [ "$_rln" = 1 ] || _rlb="$_rlb match(rc=$_rlrc,rows=$_rln)"
9389
+ _rl a1 crew-refuter 'FINDING: sec-idor-1\nLENS: protection\nVERDICT: TRUE_POSITIVE'
9390
+ [ "$_rln" = 1 ] || _rlb="$_rlb resumed-agent-counted-twice"
9391
+ _rl a2 crew-refuter 'FINDING: sec-idor-1\nLENS: reachability\nVERDICT: TRUE_POSITIVE'
9392
+ [ "$_rln" = 1 ] || _rlb="$_rlb answer-on-another-lens-recorded"
9393
+ _rl a3 crew-refuter 'FINDING: sec-other\nLENS: protection\nVERDICT: TRUE_POSITIVE'
9394
+ [ "$_rln" = 1 ] || _rlb="$_rlb answer-on-another-finding-recorded"
9395
+ _rl a4 crew-refuter 'FINDING: sec-idor-1\nLENS: protection\nVERDICT: PROBABLY'
9396
+ [ "$_rln" = 1 ] || _rlb="$_rlb verdict-outside-the-three-recorded"
9397
+ _rl a5 crew-security-expert 'FINDING: sec-idor-1\nLENS: protection\nVERDICT: TRUE_POSITIVE'
9398
+ [ "$_rln" = 1 ] || _rlb="$_rlb another-agent-type-recorded"
9399
+ _rl a6 crewforth:crew-refuter 'FINDING: sec-idor-1\nLENS: protection\nVERDICT: CANNOT_VERIFY'
9400
+ [ "$_rln" = 2 ] || _rlb="$_rlb plugin-namespaced-type-not-recorded"
9401
+ _rl a7 crew-refuter 'FINDING: sec-idor-1\nLENS: protection\nVERDICT: TRUE_POSITIVE' CREW_MODEL_ROUTING=off
9402
+ [ "$_rln" = 3 ] || _rlb="$_rlb not-recorded-with-routing-off"
9403
+ _o="$(printf 'not json' | env CLAUDE_PROJECT_DIR="$_rlt/p" bash "$_RL" 2>/dev/null; echo "rc=$?")"
9404
+ [ "$_o" = rc=0 ] || _rlb="$_rlb unreadable-payload:$_o"
9405
+ _rlrow="$(sed -n 2p "$_rlt/p/.claude/state/crew-refuter.tsv" 2>/dev/null | cut -f3-6 | tr '\t' ' ')"
9406
+ [ "$_rlrow" = "a1 sec-idor-1 protection FALSE_POSITIVE" ] || _rlb="$_rlb row:[$_rlrow]"
9407
+ [ -z "$_rlb" ] && pass "refuter-ledger.sh: one line when task and answer agree (9 cases: resumed, other lens, other finding, bad verdict, other agent, plugin name, routing off, bad payload)" \
9408
+ || fail "refuter-ledger.sh:$_rlb"
9409
+ # Its guards: the ledger's file name is refused to the shell and to the file tools; the agent's name is not.
9410
+ _rlg(){ printf '{"hook_event_name":"PreToolUse","tool_name":"%s","permission_mode":"default","tool_input":%s}' "$1" "$2" \
9411
+ | env CLAUDE_PROJECT_DIR="$_rlt/p" bash "$HOOKS/$3" >/dev/null 2>&1; echo $?; }
9412
+ _g1="$(_rlg Bash '{"command":"echo x >> .claude/state/crew-refuter.tsv"}' guard-bash.sh)"
9413
+ _g2="$(_rlg Bash '{"command":"grep -rn crew-refuter agents"}' guard-bash.sh)"
9414
+ _g3="$(_rlg Write "{\"file_path\":\"$_rlt/p/.claude/state/crew-refuter.tsv\",\"content\":\"x\"}" guard-write.sh)"
9415
+ _g4="$(_rlg Write "{\"file_path\":\"$_rlt/p/notes.md\",\"content\":\"x\"}" guard-write.sh)"
9416
+ [ "$_g1" = 2 ] && [ "$_g2" = 0 ] && [ "$_g3" = 2 ] && [ "$_g4" = 0 ] \
9417
+ && pass "the refuter ledger is refused by name to the shell (rc 2) and the file tools (rc 2); a grep for the agent and a plain file are not (rc 0)" \
9418
+ || fail "refuter ledger guards: shell write $_g1 (want 2), grep $_g2 (want 0), Write $_g3 (want 2), plain Write $_g4 (want 0)"
9419
+ rm -rf "$_rlt"
9420
+ fi
9421
+ # Wired in both editions: SubagentStop, matched on the agent type.
9422
+ _rlw=""; for _pf in $_pawf2; do
9423
+ json_hooks "$_pf" | LC_ALL=C awk -F'\t' '$1 == "SubagentStop" && $2 ~ /crew-refuter/ && $3 ~ /refuter-ledger\.sh/ { f = 1 } END { exit !f }' || _rlw="$_rlw ${_pf##*/}"
9424
+ done
9425
+ [ -z "$_rlw" ] && pass "refuter-ledger.sh is wired to SubagentStop, matched on crew-refuter, in every edition read" || fail "refuter-ledger.sh is not wired to SubagentStop on crew-refuter in:$_rlw"
8722
9426
  for _pf in $_pawf2; do
8723
9427
  _wo=""; for _ev in SubagentStart SubagentStop PostToolUse SessionStart; do
8724
9428
  json_hooks "$_pf" | LC_ALL=C awk -F'\t' -v e="$_ev" '$1 == e && $3 ~ /agent-outcome\.sh/ { f = 1 } END { exit !f }' || _wo="$_wo $_ev"; done
@@ -8938,21 +9642,24 @@ CPT='0/ask 0/allow @@ git commit -m x
8938
9642
  0 0 @@ bash -c \x27git log --grep=commit\x27
8939
9643
  0 0 @@ grep -rn \"git commit -n\" docs/
8940
9644
  0/ask 0/allow @@ bash build.sh && git commit -m x
8941
- 0/ask 0/allow @@ cat > notes.md <<\x27EOF\x27\nrun: bash -c \x27git commit -am x\x27 and it\x27s gone\nEOF\ngit commit -m x'
9645
+ 0/ask 0/allow @@ cat > notes.md <<\x27EOF\x27\nrun: bash -c \x27git commit -am x\x27 and it\x27s gone\nEOF\ngit commit -m x
9646
+ 0/ask 0/allow @@ git commit -m x # trailing words -a'
8942
9647
  _cf_table "$CPT"
8943
- if [ "$_cfn" != 47 ]; then fail "FIXTURE: the everyday-commit table has $_cfn rows, not 47"
8944
- elif [ -z "$_cfbad" ]; then pass "47 everyday calls are judged as before (asked in default, allowed when pre-authorised): chained after git add, a message that holds flags and separators, a here-document message, -F, -S, -uno, redirections, a pipe, a comment, a cd into this same repository, GIT_AUTHOR_*, a script beside the commit"
9648
+ if [ "$_cfn" != 48 ]; then fail "FIXTURE: the everyday-commit table has $_cfn rows, not 48"
9649
+ elif [ -z "$_cfbad" ]; then pass "48 everyday calls are judged as before (asked in default, allowed when pre-authorised): chained after git add, a message that holds flags and separators, a here-document message, -F, -S, -uno, redirections, a pipe, a comment, a cd into this same repository, GIT_AUTHOR_*, a script beside the commit"
8945
9650
  else fail "everyday commit calls that changed verdict:$_cfbad"; fi
8946
9651
 
8947
- # Three everyday calls the OLDER scan refuses, and still does: it reads any a/o/i/p in a short token as a flag
8948
- # (`-uno`, a key id after -S) and reads past a comment sign. The new reading parses all three correctly; they are
8949
- # kept refused because this change only adds refusals — letting them through is a loosening, to be decided on its own.
9652
+ # Two everyday calls the OLDER scan refuses, and still does: it reads any a/o/i/p in a short token as a flag
9653
+ # (`-uno`, a key id after -S). The newer scan parses both correctly; they are kept refused because letting them
9654
+ # through is a loosening of that scan's option table, to be decided on its own. A third row stood here,
9655
+ # `git commit -m x # trailing words -a`: the older scan paired quotes and read on past the comment sign by itself.
9656
+ # It walks the one reader's text now, where a comment is left out, so that call is asked about like any commit
9657
+ # from the index (the row is in the table above).
8950
9658
  CKT='2 0/allow @@ git commit -Skeyid -qm x
8951
- 2 0/allow @@ git commit -uno -m x
8952
- 2 0/allow @@ git commit -m x # trailing words -a'
9659
+ 2 0/allow @@ git commit -uno -m x'
8953
9660
  _cf_table "$CKT"
8954
- if [ "$_cfn" != 3 ]; then fail "FIXTURE: the kept-over-blocks table has $_cfn rows, not 3"
8955
- elif [ -z "$_cfbad" ]; then pass "3 known over-blocks of the older scan are unchanged (-Skeyid, -uno, a flag after a comment sign): nothing was loosened"
9661
+ if [ "$_cfn" != 2 ]; then fail "FIXTURE: the kept-over-blocks table has $_cfn rows, not 2"
9662
+ elif [ -z "$_cfbad" ]; then pass "2 known over-blocks of the older scan are unchanged (-Skeyid, -uno)"
8956
9663
  else fail "the older scan's verdict changed on:$_cfbad"; fi
8957
9664
 
8958
9665
  # ---- the same commit, written for PowerShell --------------------------------------------------------------------
@@ -10312,7 +11019,9 @@ if [ -n "$SGR" ] && [ -f "$SGR/.gitattributes" ] && [ -d "$SGR/packaging" ] && [
10312
11019
  # An empty answer must mean "nothing unpinned", never "git listed nothing": two files every checkout has must
10313
11020
  # be in the listing first. Symlinks and submodules are not regular files; git leaves their i/ field empty.
10314
11021
  SLIST="$(git -C "$SGR" ls-files -- start.sh adopt.sh VERSION LICENSE README.md bin kit plugin 2>/dev/null)"
10315
- if ! printf '%s\n' "$SLIST" | grep -qx 'start.sh' || ! printf '%s\n' "$SLIST" | grep -qx 'kit/CLAUDE.md'; then
11022
+ # Here-strings, not a pipe: `grep -q` exits on its first match, a printf still writing gets SIGPIPE, and under
11023
+ # pipefail that read as "not listed" once the list grew long enough to outlast the match (Linux runner).
11024
+ if ! grep -qx 'start.sh' <<< "$SLIST" || ! grep -qx 'kit/CLAUDE.md' <<< "$SLIST"; then
10316
11025
  fail "git did not list Crewforth's shipped files (start.sh and kit/CLAUDE.md are missing), so the eol pin check measured nothing"
10317
11026
  else
10318
11027
  UNPIN="$(git -C "$SGR" ls-files --eol -- start.sh adopt.sh VERSION LICENSE README.md bin kit plugin 2>/dev/null \
@@ -10368,6 +11077,15 @@ if [ -n "$SGR" ] && [ -f "$SGR/.gitattributes" ] && [ -d "$SGR/packaging" ] && [
10368
11077
  # gate that only runs when someone remembers to run it, which is how the catalogue check went unnoticed.
10369
11078
  [ -z "$UNRUN" ] && pass "every gate verify.sh defines is wired into ci.yml" \
10370
11079
  || fail "verify.sh defines gates ci.yml never runs: $UNRUN — they hold only when run by hand"
11080
+ # The Windows e2e runs as shards of packaging/e2e.sh's map. A shard the matrix does not list is a third of the
11081
+ # rehearsals that runs nowhere while every listed job is green; e2e.sh checks its own map, not the matrix. So
11082
+ # the matrix lists exactly 1/N … N/N, N read from e2e.sh.
11083
+ _e2n="$(sed -n 's/^E2E_SHARDS=\([0-9][0-9]*\)$/\1/p' "$SGR/packaging/e2e.sh" 2>/dev/null)"
11084
+ _e2m="$(grep -oE "shard: [0-9]+/[0-9]+" "$SGR/.github/workflows/ci.yml" | awk '{print $2}' | LC_ALL=C sort -u | tr '\n' ' ')"
11085
+ _e2w=""; if [ -n "$_e2n" ]; then _i=1; while [ "$_i" -le "$_e2n" ]; do _e2w="$_e2w$_i/$_e2n "; _i=$((_i+1)); done; fi
11086
+ if [ -z "$_e2n" ]; then fail "FIXTURE: E2E_SHARDS was not read from packaging/e2e.sh — the shard pin cannot compare"
11087
+ elif [ "$_e2m" = "$_e2w" ]; then pass "ci.yml runs every e2e shard the map has ($_e2w)"
11088
+ else fail "ci.yml's e2e matrix lists shards [$_e2m], packaging/e2e.sh has [$_e2w] — a shard it does not list runs nowhere"; fi
10371
11089
  fi
10372
11090
 
10373
11091
  # EVERY JOB OF EVERY WORKFLOW HAS A TIME LIMIT. A job without one runs for GitHub's default of 360 minutes: a
@@ -10437,7 +11155,7 @@ if [ -n "$SGR" ] && [ -f "$SGR/.gitattributes" ] && [ -d "$SGR/packaging" ] && [
10437
11155
  # starts with a wildcard (which reaches every directory, kit/ included).
10438
11156
  cdo_unsafe(){ while IFS= read -r _p; do case "$_p" in *.sh*|kit*|\**) printf '%s\n' "$_p" ;; esac; done; }
10439
11157
  _cp="$(cdo_pats "$CDO" | tr '\n' ' ' | sed 's/ $//')"
10440
- _want='README*.md site/content/* evals/README.md evals/results/* CHANGELOG.md'
11158
+ _want='README*.md site/* evals/README.md evals/results/* CHANGELOG.md'
10441
11159
  [ "$_cp" = "$_want" ] && pass "the docs-only list is exactly: $_want" \
10442
11160
  || fail "the docs-only list changed — it reads '$_cp', pinned '$_want'. Widening it lets a change skip macOS and Windows; update this pin deliberately"
10443
11161
  _cu="$(cdo_pats "$CDO" | cdo_unsafe)"
@@ -10454,9 +11172,11 @@ if [ -n "$SGR" ] && [ -f "$SGR/.gitattributes" ] && [ -d "$SGR/packaging" ] && [
10454
11172
  [ "$(cdo evals/run.sh)" = code ] || _cv="$_cv evals/run.sh"
10455
11173
  [ "$(cdo kit/README.md)" = code ] || _cv="$_cv kit/README.md"
10456
11174
  [ "$(cdo .github/workflows/ci.yml)" = code ] || _cv="$_cv ci.yml"
11175
+ [ "$(cdo site/scripts/check.mjs site/package-lock.json)" = docs ] || _cv="$_cv site-source"
11176
+ [ "$(cdo site/scripts/build.sh)" = code ] || _cv="$_cv site-script"
10457
11177
  [ "$(printf '' | bash "$CDO")" = code ] || _cv="$_cv empty"
10458
11178
  [ "$(printf 'README.md\r\n' | bash "$CDO")" = docs ] || _cv="$_cv crlf"
10459
- [ -z "$_cv" ] && pass "ci-docs-only.sh: documentation reads docs; a hook, evals/run.sh, kit/README.md, ci.yml or an empty diff reads code" \
11179
+ [ -z "$_cv" ] && pass "ci-docs-only.sh: documentation and the site's sources read docs; a hook, a .sh under site/, evals/run.sh, kit/README.md, ci.yml or an empty diff reads code" \
10460
11180
  || fail "ci-docs-only.sh gave the wrong verdict for:$_cv"
10461
11181
  # The third answer, `text`: a pull request that changes only the text of skills, agents or commands skips the
10462
11182
  # macOS and Windows jobs as a documentation one does, and still runs the Linux verify job. That list is pinned
@@ -10482,10 +11202,13 @@ if [ -n "$SGR" ] && [ -f "$SGR/.gitattributes" ] && [ -d "$SGR/packaging" ] && [
10482
11202
  [ -z "$_cv" ] && pass "ci-docs-only.sh: skill, agent and command text reads text, with documentation too; with a hook, a skill's script, kit/CLAUDE.md or a blocklist it reads code" \
10483
11203
  || fail "ci-docs-only.sh gave the wrong text verdict for:$_cv"
10484
11204
  # The Linux verify job is gated on nothing: it holds the checks a text change can break.
10485
- _cyv="$(awk '/^ verify:$/{f=1;next} f && /^ [a-z-]+:$/{f=0} f' "$SGR/.github/workflows/ci.yml" | grep -cE '^ (if|needs):')"
11205
+ # One condition is allowed, and only this one: it skips the run a label other than full-ci starts, and says
11206
+ # nothing about what the pull request changed.
11207
+ _cyv="$(awk '/^ verify:$/{f=1;next} f && /^ [a-z-]+:$/{f=0} f' "$SGR/.github/workflows/ci.yml" | grep -E '^ (if|needs):' \
11208
+ | grep -vxF " if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'" | grep -c .)"
10486
11209
  _cyn="$(awk '/^ verify:$/{f=1;next} f && /^ [a-z-]+:$/{f=0} f' "$SGR/.github/workflows/ci.yml" | grep -c 'bash packaging/verify.sh')"
10487
11210
  [ "$_cyv" = 0 ] && [ "$_cyn" -ge 1 ] \
10488
- && pass "ci.yml runs the Linux verify job on every pull request, a text-only one included (no if:, no needs:)" \
11211
+ && pass "ci.yml runs the Linux verify job on every pull request, a text-only one included (no needs:, and no if: but the one that skips an unrelated label)" \
10489
11212
  || fail "ci.yml's verify job is conditional ($_cyv if:/needs: line(s)) or was not found ($_cyn verify.sh call(s)): a text-only pull request would skip the budget, pointer, routing and plugin checks"
10490
11213
  # ...and ci.yml really uses it: both cross-platform jobs are gated on its answer, nothing else is.
10491
11214
  _cy="$SGR/.github/workflows/ci.yml"
@@ -10604,6 +11327,63 @@ sec "== 14b) the star line (once, on a first install) and the front page it poin
10604
11327
  # One file owns the line — its URL, both languages, and when it stays quiet — so these checks drive THAT file,
10605
11328
  # with CI and CREW_NO_STAR set or cleared by each case itself: a runner exports CI=true, and a check that read it
10606
11329
  # from the environment would pass here and assert the opposite there.
11330
+ # ---- the status line: what it says, and where it is set ----------------------------------------------------------
11331
+ # eval/lib/statusline.sh reads Claude Code's stdin and the hooks' own records. What it must never do: show a gate
11332
+ # line or an agent from before the session (the gate log carries no time), or replace a status line of the user's.
11333
+ SLS="$ROOT/eval/lib/statusline.sh"
11334
+ if [ -f "$SLS" ]; then
11335
+ _sl="$(mktemp -d)"; mkdir -p "$_sl/p/.claude/state/crew-model/agents" "$_sl/p/.claude/state/crew-model/results" "$_sl/home"
11336
+ ( cd "$_sl/p" && git init -q . ) >/dev/null 2>&1
11337
+ printf 'ASK\t§4.4\tcommit\t\n' > "$_sl/p/.claude/gate-log.tsv"; : > "$_sl/p/.claude/state/crew-model/agents/before"
11338
+ _slj='{"session_id":"sl1","context_window":{"used_percentage":34.6},"cost":{"total_cost_usd":1.2034},"rate_limits":{"five_hour":{"used_percentage":90}},"workspace":{"project_dir":"'"$_sl/p"'"}}'
11339
+ sleep 1
11340
+ _o1="$(printf '%s' "$_slj" | (cd "$_sl" && bash "$SLS") 2>&1)"
11341
+ sleep 1
11342
+ printf 'BLOCK\t§4.2\tforce push\t\n' >> "$_sl/p/.claude/gate-log.tsv"
11343
+ : > "$_sl/p/.claude/state/crew-model/agents/run1"; : > "$_sl/p/.claude/state/crew-model/agents/done1"; : > "$_sl/p/.claude/state/crew-model/results/done1"
11344
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ]; then printf -v _now '%(%s)T' -1; else _now="$(date +%s)"; fi
11345
+ # 330 s old: 24 minutes left for the next 30 s, so a slow machine does not read 23 (measured in an install: 23).
11346
+ printf 'v=1\nop=commit\nts=%s\n' "$((_now - 330))" > "$_sl/p/.git/crewforth-approval"
11347
+ _o2="$(printf '%s' "$_slj" | (cd "$_sl" && bash "$SLS") 2>&1)"
11348
+ _o3="$(printf '' | bash "$SLS" 2>&1)"
11349
+ [ "$_o1" = "🔋 34% · ~\$1.20" ] && [ "$_o2" = "🛡 BLOCK §4.2 · ✓ commit approved 24m · ⚙ 1 crew agent · 🔋 34% · ~\$1.20" ] && [ -z "$_o3" ] \
11350
+ && pass "status line: the session's own gate line, a standing approval and its running crew agents; nothing from before the session; the context fill, not a rate limit's; silent on no input" \
11351
+ || fail "status line said: first='$_o1' · later='$_o2' · no input='$_o3'"
11352
+ # --install: only where no status line is set, and never through a file it cannot read.
11353
+ # The project's settings carry a hook of the user's own beside Crewforth's: both must come through whole.
11354
+ # Built from Crewforth's hooks alone: in an installed project settings.json already carries the status line.
11355
+ if [ -f "$ROOT/settings.json" ]; then
11356
+ printf '{"hooks":%s}\n' "$(awk -v op=get -v path=hooks -f "$ROOT/eval/lib/settings-json.awk" "$ROOT/settings.json" 2>/dev/null)" > "$_sl/base.json"
11357
+ awk -v op=merge -v retired= -f "$ROOT/eval/lib/settings-json.awk" "$_sl/base.json" /dev/stdin > "$_sl/p/.claude/settings.json" 2>/dev/null <<'JSON'
11358
+ {"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my-own-stop-hook"}]}]}}
11359
+ JSON
11360
+ fi
11361
+ [ -s "$_sl/p/.claude/settings.json" ] || printf '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my-own-stop-hook"}]}]}}\n' > "$_sl/p/.claude/settings.json"
11362
+ _hb="$(awk -v op=get -v path=hooks -f "$ROOT/eval/lib/settings-json.awk" "$_sl/p/.claude/settings.json" 2>/dev/null | cksum)"
11363
+ _i(){ (cd "$_sl/p" && env CLAUDE_CONFIG_DIR="$_sl/home" bash "$SLS" --install 2>&1); }
11364
+ _hk0="$(awk -v op=len -v path=hooks -f "$ROOT/eval/lib/settings-json.awk" "$_sl/p/.claude/settings.json" 2>/dev/null)"
11365
+ _r1="$(_i)"; _c1="$(awk -v op=get -v path=statusLine.command -f "$ROOT/eval/lib/settings-json.awk" "$_sl/p/.claude/settings.json" 2>/dev/null)"
11366
+ _hk1="$(awk -v op=len -v path=hooks -f "$ROOT/eval/lib/settings-json.awk" "$_sl/p/.claude/settings.json" 2>/dev/null)"
11367
+ _ha="$(awk -v op=get -v path=hooks -f "$ROOT/eval/lib/settings-json.awk" "$_sl/p/.claude/settings.json" 2>/dev/null | cksum)"
11368
+ grep -q 'my-own-stop-hook' "$_sl/p/.claude/settings.json" || _ha="lost the user's own hook"
11369
+ _r2="$(_i)"
11370
+ cp "$_sl/base.json" "$_sl/p/.claude/settings.json" 2>/dev/null || printf '{"hooks":{}}\n' > "$_sl/p/.claude/settings.json"
11371
+ printf '{"statusLine":{"type":"command","command":"mine"}}\n' > "$_sl/home/settings.json"
11372
+ _sum0="$(cksum < "$_sl/p/.claude/settings.json")"; _r3="$(_i)"; _sum1="$(cksum < "$_sl/p/.claude/settings.json")"
11373
+ rm -f "$_sl/home/settings.json"; printf '{bad' > "$_sl/p/.claude/settings.json"; _r4="$(_i)"; _b4="$(cat "$_sl/p/.claude/settings.json")"
11374
+ case "$_c1" in *eval/lib/statusline.sh*) _c1=ok ;; esac
11375
+ [ "$_r1/$_c1/$_r2/$_r3/$_r4" = "installed/ok/kept/kept/failed" ] && [ "$_hk0" = "$_hk1" ] && [ -n "$_hk0" ] && [ "$_hb" = "$_ha" ] && [ "$_sum0" = "$_sum1" ] && [ "$_b4" = "{bad" ] \
11376
+ && pass "status line --install: set where none is, every hook kept byte for byte, Crewforth's and the user's own ($_hk1 events); kept when set; a user's own left untouched; a file it cannot read left as it was" \
11377
+ || fail "status line --install: $_r1/$_c1/$_r2/$_r3/$_r4 (want installed/ok/kept/kept/failed), hooks $_hk0 -> $_hk1 (content $([ "$_hb" = "$_ha" ] && echo same || echo "CHANGED: $_ha")), user's case changed the file: $([ "$_sum0" = "$_sum1" ] || echo yes), invalid left as '$_b4'"
11378
+ # The reader's gets: several values in one read, an absent one empty, a string with its quotes.
11379
+ _g="$(printf '{"a":{"b":1},"c":"x y"}' | awk -v op=gets -v 'path=a.b|nope|c' -f "$ROOT/eval/lib/settings-json.awk" - 2>&1)"
11380
+ [ "$_g" = "1"$'\t\t'"\"x y\"" ] && pass "settings-json.awk gets: several paths in one read, tab-separated, an absent one empty" \
11381
+ || fail "settings-json.awk gets printed '$_g'"
11382
+ rm -rf "$_sl"
11383
+ else
11384
+ skip scope "status line checks skipped (this project has no eval/lib/statusline.sh)" 3
11385
+ fi
11386
+
10607
11387
  STAR="$ROOT/eval/lib/star.sh"
10608
11388
  if [ -f "$STAR" ]; then
10609
11389
  _SURL="$(sed -n 's/^CREW_REPO_URL="\(.*\)"$/\1/p' "$STAR" | head -1)"
@@ -10643,6 +11423,26 @@ if [ -f "$STAR" ]; then
10643
11423
  && pass "--once: once per version (shown · same version silent · new version shown), marker holds the version; a silenced run writes nothing" \
10644
11424
  || fail "--once broken: v1='${_o1:+shown}' v1-again='${_o2:+shown}' v2='${_o3:+shown}' marker='$_mv' silenced='${_o4:+shown}' marker-after='$_mv4'"
10645
11425
  rm -rf "$_SP"
11426
+ # doctor keeps its own marker: the line an installer showed for a version does not silence doctor's, and doctor
11427
+ # shows it once per version too. Before 3.2.0 they shared one, so doctor almost never showed it.
11428
+ _SD="$(mktemp -d)"; mkdir -p "$_SD/.claude"; printf '9.9.0\n' > "$_SD/.claude/VERSION"
11429
+ _r(){ env -u CI -u CREW_NO_STAR -u CSK_NO_STAR bash "$STAR" --once "$_SD" "$@" 2>&1 | grep -c '⭐'; }
11430
+ _d="$(_r)$(_r)$(_r doctor)$(_r doctor)"
11431
+ _dn="$(env -u CI -u CSK_NO_STAR CREW_NO_STAR=1 bash "$STAR" --once "$_SD" doctor 2>&1)"
11432
+ [ "$_d" = 1010 ] && [ -z "$_dn" ] && [ -f "$_SD/.claude/star-shown-doctor" ] \
11433
+ && pass "--once doctor: its own marker (install shown, again silent, doctor shown, again silent); CREW_NO_STAR silences it" \
11434
+ || fail "--once doctor: install/install/doctor/doctor printed $_d (want 1010), silenced='${_dn:+shown}'"
11435
+ rm -rf "$_SD"
11436
+ # The plugin edition has no .claude/VERSION: the version is read from the manifest beside star.sh (../../).
11437
+ _SPL="$(mktemp -d)"; mkdir -p "$_SPL/plug/eval/lib" "$_SPL/plug/.claude-plugin" "$_SPL/proj"
11438
+ cp "$STAR" "$ROOT/eval/lib/settings-json.awk" "$_SPL/plug/eval/lib/" 2>/dev/null
11439
+ [ -f "$ROOT/eval/lib/crew-env.sh" ] && cp "$ROOT/eval/lib/crew-env.sh" "$_SPL/plug/eval/lib/"
11440
+ printf '{\n "name": "crewforth",\n "version": "9.8.7"\n}\n' > "$_SPL/plug/.claude-plugin/plugin.json"
11441
+ env -u CI -u CREW_NO_STAR -u CSK_NO_STAR bash "$_SPL/plug/eval/lib/star.sh" --once "$_SPL/proj" doctor >/dev/null 2>&1
11442
+ _pv="$(head -1 "$_SPL/proj/.claude/star-shown-doctor" 2>/dev/null)"
11443
+ [ "$_pv" = 9.8.7 ] && pass "a plugin install's star marker holds the plugin manifest's version (9.8.7)" \
11444
+ || fail "a plugin install's star marker holds '${_pv:-nothing}', not the manifest's 9.8.7"
11445
+ rm -rf "$_SPL"
10646
11446
  if command -v git >/dev/null 2>&1; then
10647
11447
  _SG="$(mktemp -d)"; ( cd "$_SG" && git init -q . ) >/dev/null 2>&1; mkdir -p "$_SG/.claude"; printf '9.9.0\n' > "$_SG/.claude/VERSION"
10648
11448
  env -u CI -u CREW_NO_STAR -u CSK_NO_STAR bash "$STAR" --once "$_SG" >/dev/null 2>&1
@@ -10666,7 +11466,7 @@ if [ -f "$STAR" ]; then
10666
11466
  [ -z "$_dup$_nocall" ] && pass "the repo URL lives only in lib/star.sh; $(printf '%s\n' $_callers | grep -c .) caller(s) call it" \
10667
11467
  || fail "star single source broken — URL copied into:${_dup:- none} · not calling star.sh:${_nocall:- none}"
10668
11468
  else
10669
- skip scope "star line checks skipped (this project has no eval/lib/star.sh)" 7
11469
+ skip scope "star line checks skipped (this project has no eval/lib/star.sh)" 9
10670
11470
  fi
10671
11471
  # The front page. Kit repo only: an installed project has no README of ours.
10672
11472
  if [ "$IS_KIT" = 1 ]; then
@@ -10737,17 +11537,17 @@ sec "== 14c) the 3.0 rename left no old name behind — outside history and the
10737
11537
  if [ -n "$SGR" ] && [ -d "$SGR/packaging" ] && [ -f "$SGR/VERSION" ] && [ -d "$SGR/kit" ] && [ -f "$SGR/packaging/build-plugin.sh" ]; then
10738
11538
  RN_ALLOW='CHANGELOG.md 196 history: every entry before 3.0 keeps the name it shipped under
10739
11539
  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
10740
- evals/results/* 6 history: recorded eval runs stay byte-for-byte
11540
+ 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
10741
11541
  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
10742
11542
  bin/cli.js 4 migration: add accepts a typed <x>-csk and moves an add record written under the old names
10743
11543
  evals/run.sh 4 compat: reads the 2.x trusted eval parent when the 3.0 one is absent — removed in 4.0
10744
11544
  site/scripts/check.mjs 7 tests: the old-name pattern of the built-site gate, and its twins
10745
- */eval/doctor.sh 6 migration: PROOF-5 and the variable notice name what is still on the 2.x spelling
11545
+ */eval/doctor.sh 8 migration: PROOF-5 and the variable notice name what is still on the 2.x spelling; 9c names the 2.x plugin id
10746
11546
  */crew-env.* 18 compat layer (bash + Node): reads CSK_* when CREW_* is unset — removed in 4.0
10747
11547
  */hooks/board.sh 42 compat layer: reads the 2.x board ref and settings and folds them in — removed in 4.0
10748
11548
  */skills/automode-policy/scripts/check.sh 4 compat layer: counts auto-mode rules still named by 2.x — removed in 4.0
10749
11549
  */studio/web/storage-migrate.js 4 migration: moves the panel'"'"'s saved layout to the new keys — removed in 4.0
10750
- kit/eval/smoke-test.sh 36 tests: this gate'"'"'s own pattern, and that the 2.x names still work
11550
+ kit/eval/smoke-test.sh 42 tests: this gate'"'"'s own pattern, and that the 2.x names still work (doctor 9c: the 2.x plugin id; the star line'"'"'s doctor marker)
10751
11551
  packaging/legacy-npm/* 10 the 2.x package name'"'"'s 3.0.0: a forwarder to crewforth, published once by hand
10752
11552
  packaging/* 154 tests: the migration rehearsal on the real v2.13.0 tree, the legacy token and layout checks; the legacy blob list'"'"'s generator and its real-installer check'
10753
11553
  # A line may name several globs, separated by spaces, when one reason covers them all; its pin is their sum. Split
@@ -10957,6 +11757,22 @@ $(printf '%s\n%s\n%s\n' "$_ka" "$_kb" "$_kc" | grep . | head -n 6 | sed "s|$KR/|
10957
11757
  [ -n "$(_dn "$_kd/done-twin.sh")" ] && pass "the installers' closing lines name no kit (EN key and TR value); a planted 'kit/ deleted' is caught" \
10958
11758
  || fail "twin: the closing-line check missed a planted 'kit/ deleted' — it reads nothing"
10959
11759
  fi
11760
+ # adopt.sh --help answers before it reads anything. An unknown flag is ignored further down, so --help used to run
11761
+ # the detection (seen on Windows in the 3.2.0-rc.1 trial). Read in a git repo holding only adopt.sh: no kit/, both
11762
+ # languages, and nothing written. Twin: the same script without its --help branch must not print the usage.
11763
+ _ah_run(){ ( cd "$1" && CREW_LANG=en bash adopt.sh --help; echo "rc=$?"; bash adopt.sh -h --lang tr; echo "rc=$?" ) 2>&1; }
11764
+ mkdir -p "$_kd/ah" "$_kd/ah-twin"; git init -q "$_kd/ah" 2>/dev/null; git init -q "$_kd/ah-twin" 2>/dev/null
11765
+ cp "$KR/adopt.sh" "$_kd/ah/"; sed 's/-h|--help) _hh=1 ;;//' "$KR/adopt.sh" > "$_kd/ah-twin/adopt.sh"
11766
+ _ah="$(_ah_run "$_kd/ah")"; _aht="$(_ah_run "$_kd/ah-twin")"
11767
+ _ahw="$(git -C "$_kd/ah" status --porcelain 2>/dev/null | grep -vx '?? adopt.sh')"
11768
+ case "$_ah" in
11769
+ *'Usage: bash adopt.sh'*'rc=0'*'Kullanım: bash adopt.sh'*'rc=0'*)
11770
+ if [ -n "$_ahw" ]; then fail "adopt.sh --help wrote into the project: $(printf '%s' "$_ahw" | head -n 2 | tr '\n' ' ')"
11771
+ else case "$_aht" in *'Usage: bash adopt.sh'*) fail "twin: adopt.sh without its --help branch still printed the usage — the check reads nothing" ;;
11772
+ *) pass "adopt.sh --help prints its usage in English and Turkish, rc 0, with no kit/ and nothing written (a twin without it does not)" ;; esac
11773
+ fi ;;
11774
+ *) fail "adopt.sh --help did not print its usage in both languages with rc 0: $(printf '%s' "$_ah" | head -n 3 | tr '\n' ' ')" ;;
11775
+ esac
10960
11776
  rm -rf "$_kd"
10961
11777
  else
10962
11778
  skip scope "printed-text wording check skipped (installed project — the installers live in the source repository)"
@@ -11170,7 +11986,7 @@ _EVR="$(cd "$(dirname "$0")/../.." && pwd)/evals/run.sh"
11170
11986
  _PY3OK=0
11171
11987
  printf '' | python3 -c 'import sys,json' >/dev/null 2>&1 && _PY3OK=1
11172
11988
  if [ -f "$_EVR" ] && [ "$_PY3OK" = 0 ]; then
11173
- skip tool "evals metric not calibrated (no working python3 — eval_trace_metrics is a python heredoc)" 6
11989
+ skip tool "evals metric not calibrated (no working python3 — eval_trace_metrics is a python heredoc)" 7
11174
11990
  elif [ -f "$_EVR" ]; then
11175
11991
  _EVD="$(mktemp -d)"
11176
11992
  # NOT IN A SUBSHELL, and that was a real defect in the first draft of this block: the five rows below ran
@@ -11219,6 +12035,15 @@ $_res")"
11219
12035
  [ "$(_f "$_p" 19)" = 2 ] \
11220
12036
  && pass "evals metric: two id-less messages stay two, not one" \
11221
12037
  || fail "evals metric: id-less messages collapsed [$_p] — parallel_msgs would undercount"
12038
+ # agents_by_type (column 21): WHICH agent the main thread started. A count of calls looked healthy while two agents
12039
+ # were never started; this names them. A plugin's namespace is dropped, a call with no type is general-purpose, and
12040
+ # a subagent's own call is not the main thread's.
12041
+ _p="$(_mk typed "$(printf '%s' '{"type":"assistant","message":{"id":"m1","content":[{"type":"tool_use","id":"x1","name":"Agent","input":{"subagent_type":"crew-commit-agent"}},{"type":"tool_use","id":"x2","name":"Agent","input":{"subagent_type":"crewforth:crew-review-agent"}},{"type":"tool_use","id":"x3","name":"Agent","input":{}}]}}')
12042
+ $(_msg s1 1 n1)
12043
+ $_res")"
12044
+ { [ "$(_f "$_p" 21)" = "crew-commit-agent:1,crew-review-agent:1,general-purpose:1" ] && [ "$(_f "$(_mk none_t "$_res")" 21)" = "-" ]; } \
12045
+ && pass "evals metric: agents_by_type names the main thread's agents (namespace dropped, untyped is general-purpose, nested not counted, none is -)" \
12046
+ || fail "evals metric: agents_by_type read [$(_f "$_p" 21)]"
11222
12047
  _p="$(_mk none "$_res")"
11223
12048
  { [ "$(_f "$_p" 19)" = 0 ] && [ "$(_f "$_p" 20)" = 0 ]; } \
11224
12049
  && pass "evals metric: a stream with no Agent call reports 0, not empty" \
@@ -11387,7 +12212,7 @@ if [ -s "$SECLOG" ]; then
11387
12212
  ' "$SECLOG"
11388
12213
  echo "---"
11389
12214
  fi
11390
- rm -f "$SECLOG"
12215
+ rm -f "$SECLOG"; rm -rf "$_SMOKE_CCD"
11391
12216
  if [ "$SKIPN" -gt 0 ]; then
11392
12217
  echo "SKIPPED (nothing was checked here):$SKIP_LIST"
11393
12218
  echo "---"