crewforth 3.0.0 → 3.0.1-rc.2

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 (44) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +1 -1
  3. package/VERSION +1 -1
  4. package/adopt.sh +222 -19
  5. package/kit/CLAUDE.md +5 -4
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-frontend-expert.md +6 -6
  8. package/kit/agents/crew-session-manager.md +1 -1
  9. package/kit/eval/doctor.sh +77 -18
  10. package/kit/eval/golden-routing.txt +15 -0
  11. package/kit/eval/lib/git-bash.sh +108 -0
  12. package/kit/eval/preflight.sh +2 -2
  13. package/kit/eval/smoke-test.sh +761 -29
  14. package/kit/hooks/board-sync.sh +1 -1
  15. package/kit/hooks/board.sh +4 -3
  16. package/kit/hooks/guard-bash.sh +195 -21
  17. package/kit/hooks/guard-commit-scan.sh +2 -2
  18. package/kit/hooks/guard-powershell.sh +328 -0
  19. package/kit/hooks/guard-write.sh +28 -3
  20. package/kit/hooks/route-hint.sh +5 -1
  21. package/kit/hooks/session-update-check.sh +2 -2
  22. package/kit/hooks/skill-trust.sh +57 -14
  23. package/kit/legacy-blobs.tsv +208 -0
  24. package/kit/settings.json +21 -0
  25. package/kit/skills/automode-policy/SKILL.md +1 -1
  26. package/kit/skills/automode-policy/scripts/check.sh +2 -2
  27. package/kit/skills/crew-board/SKILL.md +1 -1
  28. package/kit/skills/crew-doctor/SKILL.md +13 -5
  29. package/kit/skills/crew-gates/SKILL.md +2 -1
  30. package/kit/skills/crew-handoff/SKILL.md +1 -1
  31. package/kit/skills/crew-skill/SKILL.md +2 -2
  32. package/kit/skills/crew-studio/SKILL.md +3 -2
  33. package/kit/skills/crew-update/SKILL.md +3 -3
  34. package/kit/skills/frontend/SKILL.md +26 -2
  35. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  36. package/kit/skills/handoff/SKILL.md +2 -2
  37. package/kit/skills/reflect/SKILL.md +2 -2
  38. package/kit/skills/teamboard/SKILL.md +1 -1
  39. package/kit/skills/token-budget/SKILL.md +2 -2
  40. package/kit/skills/trace-scan/SKILL.md +4 -3
  41. package/kit/studio/README.md +1 -1
  42. package/kit/studio/server/lib/permissions.js +3 -0
  43. package/package.json +5 -4
  44. package/start.sh +15 -8
@@ -705,11 +705,16 @@ else
705
705
  && pass "a decision recorded by one teammate arrives in another's clone" \
706
706
  || fail "the decision never reached the second clone — decisions stay as local as the ADRs they replace"
707
707
  rm -f "$BD/ayse/.git/crew-board-seen"
708
- ( cd "$BD/ayse" && bash ../board.sh cache 2>/dev/null | grep -q "recorded since you last looked" ) \
708
+ # Read into a variable first: `board.sh cache | grep -q` under pipefail is a race — grep exits on its match, the
709
+ # writer takes SIGPIPE, and the pipeline reads false. It failed once on windows-latest (3.0.1, PR 7), where the
710
+ # slower process start lets the writer still be printing when grep leaves.
711
+ _bdc="$(cd "$BD/ayse" && bash ../board.sh cache 2>/dev/null)"
712
+ case "$_bdc" in *"recorded since you last looked"*) true ;; *) false ;; esac \
709
713
  && pass "an unread decision announces itself at session start" \
710
714
  || fail "an unread decision is silent at session start — it arrives after the work it should have changed"
711
715
  ( cd "$BD/ayse" && bash ../board.sh decisions ) >/dev/null 2>&1
712
- ( cd "$BD/ayse" && bash ../board.sh cache 2>/dev/null | grep -q "recorded since you last looked" ) \
716
+ _bdc="$(cd "$BD/ayse" && bash ../board.sh cache 2>/dev/null)"
717
+ case "$_bdc" in *"recorded since you last looked"*) true ;; *) false ;; esac \
713
718
  && fail "the decision keeps announcing itself after being read — a permanent alert is an ignored one" \
714
719
  || pass "once read, the decision stops being announced"
715
720
  # First read must not leak a shell error: the marker file does not exist yet, and an input redirect from a
@@ -1518,6 +1523,36 @@ if [ -f "$ROOT/CLAUDE.md" ]; then
1518
1523
  P_HALF="$(awk 'f{print} /^<!-- KIT:DISCIPLINE-END/{f=1}' "$ROOT/CLAUDE.md")"
1519
1524
  case "$D_HALF" in *'<PROJECT NAME>'*) fail "discipline half swallows the project template" ;; *) pass "discipline half excludes the project template" ;; esac
1520
1525
  case "$D_HALF" in *'Four working principles'*) pass "discipline half carries the four principles" ;; *) fail "discipline half lost the four principles" ;; esac
1526
+ # Where a project's own CLAUDE.md sits against §4 (field: two projects wrote a stricter §4.4 and read the discipline's
1527
+ # as a contradiction), and what a § number points at. Stated on the ladder, and at the top where "project wins" is.
1528
+ case "$D_HALF" in *'A project `CLAUDE.md` can tighten §4, never loosen it (§4.1 names the one exception). The § numbers in this file refer only to this file.'*)
1529
+ pass "the ladder says a project CLAUDE.md can tighten §4, never loosen it, and that § numbers are this file's" ;;
1530
+ *) fail "the ladder lost the project-vs-§4 sentence or the § numbers sentence" ;; esac
1531
+ case "$D_HALF" in *'where they win on conflict; in §4 they can only tighten, never loosen (§4.1 names the one exception).'*) pass "the opening's 'project wins' carries the §4 limit and names where its one exception is" ;;
1532
+ *) fail "the opening says the project wins on conflict without the §4 limit" ;; esac
1533
+ case "$P_HALF" in *'on conflict the rules here win; in DISCIPLINE.md §4 they can only tighten, never loosen (its §4.1 names the one exception).'*)
1534
+ pass "the project template's 'rules here win' carries the §4 limit" ;; *) fail "the project template says its rules win without the §4 limit" ;; esac
1535
+ # The same meaning where the updater and the installed README say it (decided wording: the project wins; in §4 it can
1536
+ # only tighten; the §4.1 trace allowlist chosen at adoption is the one exception). Each is a fixed string, so each
1537
+ # can revert silently — pinned here, with the twin that proves the pin reads the file.
1538
+ _pw_kr="$(cd "$ROOT/.." && pwd)"; _pw_f=""
1539
+ while IFS='|' read -r _pwf _pws; do [ -n "$_pwf" ] || continue
1540
+ grep -qF -- "$_pws" "$_pw_kr/$_pwf" 2>/dev/null || _pw_f="$_pw_f | $_pwf: $_pws"
1541
+ done <<'PWEOF'
1542
+ kit/README.md|which win on conflict; in DISCIPLINE.md §4 they can only tighten, never loosen (its §4.1 names the one exception).
1543
+ adopt.sh|"on conflict the project's rules win; in DISCIPLINE.md §4 they can only tighten, never loosen (the one exception: the §4.1 trace allowlist you choose at adoption); Crewforth fills gaps (not overridable)"
1544
+ adopt.sh|DISCIPLINE.md §4'te proje yalnız sıkılaştırabilir, gevşetemez (tek istisna: kurulumda sizin seçtiğiniz §4.1 iz izin listesi)
1545
+ adopt.sh|- On rule conflicts the PROJECT wins (axis-by-axis); in DISCIPLINE.md §4 it can only tighten, never loosen — the one exception is the §4.1 trace allowlist chosen at adoption (decision 3).
1546
+ adopt.sh|| 2 | Precedence | $D2 (axis-by-axis; DISCIPLINE.md §4 tighten-only, except the §4.1 trace allowlist of decision 3) |
1547
+ PWEOF
1548
+ if [ -f "$_pw_kr/adopt.sh" ]; then
1549
+ [ -z "$_pw_f" ] && pass "the updater (EN, TR, ADR, handover) and the installed README say the project wins, tightens §4 only, and name the §4.1 exception" \
1550
+ || fail "a 'project wins' statement lost the §4 limit or its exception:$_pw_f"
1551
+ _pwt="$(mktemp)"; sed 's/ (its §4.1 names the one exception)\.$/./' "$ROOT/README.md" > "$_pwt"
1552
+ grep -qF -- 'which win on conflict; in DISCIPLINE.md §4 they can only tighten, never loosen (its §4.1 names the one exception).' "$_pwt" \
1553
+ && fail "the pin twin did not change — the README pin reads nothing" || pass "the pin catches a README that drops the §4.1 exception (twin)"
1554
+ rm -f "$_pwt"
1555
+ else skip scope "the updater's precedence wording is checked in the Crewforth repository (adopt.sh is not installed)"; fi
1521
1556
  case "$P_HALF" in *'<PROJECT NAME>'*) pass "project half carries the project template" ;; *) fail "project half lost the project template" ;; esac
1522
1557
  case "$P_HALF" in *'Four working principles'*) fail "project half duplicates the discipline" ;; *) pass "project half does not duplicate the discipline" ;; esac
1523
1558
  fi
@@ -1616,6 +1651,104 @@ if [ "$IS_KIT" = 1 ]; then
1616
1651
  else
1617
1652
  skip scope "Homebrew-channel check not run — not a git checkout of Crewforth's source"
1618
1653
  fi
1654
+ # THE CHANNEL COUNT. Three channels ship Crewforth (the release archive, npm, the Claude Code plugin); comments and
1655
+ # pages still said four after Homebrew went. A number a reader takes as the total is a claim like any other. The
1656
+ # CHANGELOG is history. The pattern is split so this file does not match itself.
1657
+ _ch4_re='fo''ur (distribution )?channels|dö''rt (dağıtım )?kanal'
1658
+ _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")"
1659
+ 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)"
1660
+ elif git -C "$KR" rev-parse --git-dir >/dev/null 2>&1; then
1661
+ _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)"
1662
+ [ -z "$_ch4_hits" ] && pass "no tracked file gives the old channel count — three ship Crewforth (CHANGELOG exempt; twins 3/3 caught, 0/2 flagged)" \
1663
+ || fail "a tracked file still gives the old channel count (there are three): $(printf '%s\n' "$_ch4_hits" | head -3 | tr '\n' ' ')"
1664
+ else skip scope "channel-count check not run — not a git checkout of Crewforth's source"; fi
1665
+ # THE CLIENT SIDE IS STACK-NEUTRAL (3.0.1): the install summary, the help and the install page named the scope as
1666
+ # "backend, web and mobile (RN/Expo)", and a user about to pick a non-RN mobile architecture read it as Crewforth
1667
+ # steering them to React Native (field: a new mobile project). A scope line — one that names backend AND web AND
1668
+ # mobile — never names a client stack, and says the stack is read from the project. RN/Expo stays an optional layer.
1669
+ _cs_scope(){ grep -hiE 'backend[^|]*web[^|]*mobi' "$@" 2>/dev/null | grep -vE '^[[:space:]]*#'; }
1670
+ _cs_f="$KR/start.sh $KR/site/content/en/install.md $KR/site/content/tr/install.md"
1671
+ _cs_l="$(_cs_scope $_cs_f)"; _cs_n="$(printf '%s\n' "$_cs_l" | grep -c .)"
1672
+ _cs_bad="$(printf '%s\n' "$_cs_l" | grep -iE 'RN/Expo|React Native|Flutter|SwiftUI|Compose')"
1673
+ _cs_nost="$(printf '%s\n' "$_cs_l" | grep . | grep -viE 'stack|yığın')"
1674
+ _cs_tw="$(printf '%s\n' "backend, web and mobile (R""N/Expo) together" | grep -ciE 'RN/Expo|React Native')"
1675
+ if [ "$_cs_tw" != 1 ]; then fail "client-stack scope check cannot see its twin — it reads nothing"
1676
+ elif [ "${_cs_n:-0}" -lt 5 ]; then fail "FIXTURE: only ${_cs_n:-0} scope line(s) found in start.sh and the install pages (want ≥5: EN/TR table, both usages, the summary, both pages)"
1677
+ elif [ -n "$_cs_bad$_cs_nost" ]; then fail "a scope line names a client stack or does not say the stack is read from the project:$(printf '%s\n%s\n' "$_cs_bad" "$_cs_nost" | grep . | head -n 3 | cut -c1-140 | sed 's/^/\n /')"
1678
+ else pass "the install summary, help and install pages name backend, web and mobile without a client stack, and say the stack is read from the project ($_cs_n scope lines)"; fi
1679
+ # THE SITE DEPLOY IS VERIFIED ON WHAT PAGES SERVES (3.0.1). A second deploy under the same deployment name reported
1680
+ # success while Pages kept the previous artefact (the 3.0.0 launch: the rc.3 changelog for 30 minutes). site.yml now
1681
+ # runs packaging/check-pages-served.sh after the deploy. Its four outcomes, on file:// fixtures (no network needed):
1682
+ # the new heading → 0 · an old page with an old heading, older than the deploy → 1 (stale) · the same content
1683
+ # re-deployed (right heading, old time) → 0 · nothing to fetch → 3 (not measured, never a pass).
1684
+ _pg="$KR/packaging/check-pages-served.sh"
1685
+ if [ -f "$_pg" ]; then
1686
+ _pgd="$(mktemp -d)"; mkdir -p "$_pgd/new/changelog" "$_pgd/old/changelog"
1687
+ printf '<h2>[3.9.1] — 2026-10-01</h2><h2>[3.9.0]</h2>' > "$_pgd/new/changelog/index.html"
1688
+ printf '<h2>[3.9.0] — 2026-09-28</h2>' > "$_pgd/old/changelog/index.html"
1689
+ touch -t 202001010000 "$_pgd/new/changelog/index.html" "$_pgd/old/changelog/index.html"
1690
+ _pgn="$(date +%s)"; _pgr=""
1691
+ # A file:// URL must carry the path curl can open: on Git Bash that is the native one (`pwd -W` → D:/a/…), not the
1692
+ # POSIX /tmp/… — measured on windows-latest, all three fixtures read as "not measured" (rc 3) with the POSIX form.
1693
+ _pgu="$(cd "$_pgd" && { pwd -W 2>/dev/null || pwd; })"; case "$_pgu" in /*) _pgu="file://$_pgu" ;; *) _pgu="file:///$_pgu" ;; esac
1694
+ for _c in "new [3.9.1] 0" "old [3.9.1] 1" "old [3.9.0] 0" "missing [3.9.1] 3"; do
1695
+ set -- $_c; PAGES_PATH=changelog/index.html bash "$_pg" "$_pgu/$1" "$2" "$_pgn" 2 0 >/dev/null 2>&1; _r=$?
1696
+ [ "$_r" = "$3" ] || _pgr="$_pgr | $1 want $2: rc $_r (expected $3)"
1697
+ done
1698
+ rm -rf "$_pgd"
1699
+ [ -z "$_pgr" ] && pass "the Pages check tells served (0), stale (1), re-deployed same content (0) and unreachable (3, not measured) apart" \
1700
+ || fail "the Pages check misreads its fixtures:$_pgr"
1701
+ _sy="$KR/.github/workflows/site.yml"
1702
+ _dl="$(grep -n 'uses: actions/deploy-pages' "$_sy" | cut -d: -f1 | head -1)"; _vl="$(grep -n 'check-pages-served.sh' "$_sy" | cut -d: -f1 | head -1)"
1703
+ if [ -n "$_dl" ] && [ -n "$_vl" ] && [ "$_vl" -gt "$_dl" ] && grep -q 'bash _verify/packaging/check-pages-served.sh' "$_sy"; then
1704
+ pass "site.yml verifies what Pages serves after the deploy, with the check from its own commit (an old tag lacks it)"
1705
+ else fail "site.yml does not run the Pages check after deploy-pages, from the workflow's own checkout"; fi
1706
+ else fail "packaging/check-pages-served.sh is missing — site.yml has nothing to verify a deploy with"; fi
1707
+ # THE LISTING BUDGET, SAID AS MEASURED: a skill whose description Claude Code drops is picked LESS OFTEN on its own,
1708
+ # not never (the name stays listed, and a request that names it still reaches it). doctor and /crew-doctor used to
1709
+ # say it stops being picked / stops matching requests. Old claims 0, the measured one present in English and Turkish.
1710
+ _lb_old='stop being picked|stop'' matching requests|isteklerle eşleşmez'' olur|stop''[[:space:]]+being[[:space:]]+picked'
1711
+ _lb_f="$ROOT/eval/doctor.sh $ROOT/skills/crew-doctor/SKILL.md"
1712
+ _lb_o="$(cat $_lb_f 2>/dev/null | tr '\n' ' ' | grep -ciE "$_lb_old")"
1713
+ _lb_en="$(cat $_lb_f 2>/dev/null | tr '\n' ' ' | grep -oiE 'less likely to be picked on their own' | wc -l | tr -d ' ')"
1714
+ _lb_tr="$(grep -c 'kendiliğinden seçilmesi zorlaşır' "$ROOT/eval/doctor.sh" 2>/dev/null)" || true
1715
+ _lb_tw="$(printf '%s\n' "and those stop"" matching requests" | grep -ciE "$_lb_old")"
1716
+ if [ "$_lb_tw" != 1 ]; then fail "listing-budget wording check cannot see its twin — it reads nothing"
1717
+ elif [ "${_lb_o:-0}" = 0 ] && [ "${_lb_en:-0}" -ge 3 ] && [ "${_lb_tr:-0}" -ge 1 ]; then
1718
+ pass "the listing-budget warning says 'less likely to be picked on their own' (EN $_lb_en, TR $_lb_tr), never that a skill stops being picked"
1719
+ else fail "listing-budget wording: old claim $_lb_o (want 0), new EN $_lb_en (want ≥3: doctor key, doctor warn, /crew-doctor), TR ${_lb_tr:-0} (want ≥1)"; fi
1720
+ # kit/legacy-blobs.tsv is how the updater tells Crewforth's own, untouched old files from a user's: a stale or
1721
+ # hand-edited row would move a user's edit aside, or leave Crewforth's leftovers in place. It is generated from the
1722
+ # release tags, so it must equal what the generator prints now, byte for byte. The twin drops one row from the
1723
+ # shipped copy: a comparison that cannot see that measures nothing. No tags (a shallow clone) is a fixture skip —
1724
+ # red under CREW_VERIFY_STRICT, because CI checks out with its history.
1725
+ if [ -f "$KR/packaging/gen-legacy-blobs.sh" ] && git -C "$KR" rev-parse --git-dir >/dev/null 2>&1; then
1726
+ if ! git -C "$KR" rev-parse -q --verify refs/tags/v2.13.0 >/dev/null 2>&1; then
1727
+ skip fixture "legacy blob list not checked — this clone has no v2.13.0 tag (shallow?), and the list is generated from tags"
1728
+ else
1729
+ # Compared as FILES with cmp: a `$( )` capture drops trailing newlines, and a list missing its last newline or
1730
+ # carrying extra blank lines at the end would compare equal that way (found in review).
1731
+ _LBT="$(mktemp -d)"
1732
+ bash "$KR/packaging/gen-legacy-blobs.sh" --stdout > "$_LBT/gen" 2>/dev/null; _lb_rc=$?
1733
+ sed '4d' "$KR/kit/legacy-blobs.tsv" > "$_LBT/twin" 2>/dev/null
1734
+ _lb_rows="$(grep -v '^#' "$KR/kit/legacy-blobs.tsv" 2>/dev/null | grep -c .)"
1735
+ _lb_comp="$(grep -v '^#' "$KR/kit/legacy-blobs.tsv" 2>/dev/null | cut -f1 | sort -u | grep -c .)"
1736
+ if [ "$_lb_rc" != 0 ]; then
1737
+ fail "packaging/gen-legacy-blobs.sh failed (rc $_lb_rc) — the legacy blob list cannot be checked"
1738
+ elif cmp -s "$_LBT/twin" "$_LBT/gen"; then
1739
+ fail "legacy blob check cannot see a missing row (twin with a data row removed compared equal) — it measures nothing"
1740
+ elif [ "${_lb_rows:-0}" -lt 150 ]; then
1741
+ fail "kit/legacy-blobs.tsv has ${_lb_rows:-0} row(s) — the shipped list is missing or truncated; regenerate it: bash packaging/gen-legacy-blobs.sh"
1742
+ elif cmp -s "$_LBT/gen" "$KR/kit/legacy-blobs.tsv"; then
1743
+ pass "kit/legacy-blobs.tsv equals what the tags generate ($_lb_rows rows, $_lb_comp components; twin with one row removed differs)"
1744
+ else
1745
+ fail "kit/legacy-blobs.tsv is stale — regenerate it: bash packaging/gen-legacy-blobs.sh (and commit the result)"
1746
+ fi
1747
+ rm -rf "$_LBT"
1748
+ fi
1749
+ else
1750
+ skip scope "legacy blob list not checked — not a git checkout of Crewforth's source"
1751
+ fi
1619
1752
  # The npm wrapper prints its own usage, and it advertised --backend/--frontend/--mobile/--fullstack as the
1620
1753
  # primary form for a release that no longer has profiles. A user reads `--help` before the README.
1621
1754
  if [ -f "$KR/bin/cli.js" ]; then
@@ -2100,7 +2233,7 @@ sec "== 6f) always-on token budget =="
2100
2233
  # for that cost, and a gate rather than a reminder — a verbose new description fails the suite instead of
2101
2234
  # quietly taxing every future session. Budgets sit just above the current sizes: raising one is allowed, but
2102
2235
  # only as a deliberate edit here.
2103
- BUDGET_DISC=13741 # RC-1: 13711 → 13741 (+30): "Reply in the user's language even when a skill's text is English" — a Turkish session answered /crew-review in English, because every skill body it had just read was English; the rule is stated nowhere else. evals/cases/reply-language measures it. 5R.3: 13712 → 13711 (the old-name phrases rewritten, net −1). 5d.2 prompt audit: tightened to the measured size (13719 → 13712: format-to-content style line, one reload
2236
+ BUDGET_DISC=13986 # 3.0.1: 13741 → 13986 (+245): the ladder states that a project CLAUDE.md can tighten §4, never loosen it — naming §4.1 as the one exception, since adopt may loosen the trace gate — and that § numbers point into this file (two field projects read their stricter §4.4 as a contradiction), the opening's "project wins" carries the same limit, and the context-usage command names the Bash tool (a Windows session ran the scripts in PowerShell). RC-1: 13711 → 13741 (+30): "Reply in the user's language even when a skill's text is English" — a Turkish session answered /crew-review in English, because every skill body it had just read was English; the rule is stated nowhere else. evals/cases/reply-language measures it. 5R.3: 13712 → 13711 (the old-name phrases rewritten, net −1). 5d.2 prompt audit: tightened to the measured size (13719 → 13712: format-to-content style line, one reload
2104
2237
  # answer, the orphaned background-warning line removed). Before that: 3.0 rename (suffix → crew- prefix): +23 B (23 occurrences), not content — measured 13696 → 13719.
2105
2238
  # DISCIPLINE.md (the discipline half of CLAUDE.md); before 3.0 the ceiling was 13700, currently 13601. (2026-09-18, a second
2106
2239
  # +100 B on top of the raise below, and the whole of it went into ONE sentence of §4.6: a commit
@@ -3279,6 +3412,97 @@ done
3279
3412
  gj auto 'chmod +x build.sh' | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1 && pass "chmod +x NOT over-blocked" || fail "chmod +x wrongly blocked (gate too strict)"
3280
3413
  # §4.5 gate-tampering (shell side) — disarming the gates is itself gated
3281
3414
  gj auto 'git config core.hooksPath /tmp/x' | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "core.hooksPath redirect BLOCKED (§4.5)" || fail "core.hooksPath redirect PASSED (§4.5 hole)"
3415
+ # Reading core.hooksPath is how a person checks the gate is armed; only the write forms disarm it. A value-less
3416
+ # `git config [--local|--global|--system] core.hooksPath` prints the setting — it used to be refused as tampering
3417
+ # while `--get` passed. Both directions are pinned, and the result must be exactly 0 or 2: a 1 is the hook crashing,
3418
+ # and a crashed PreToolUse hook lets the command through (the first version of this rule did exactly that).
3419
+ _hp_bad=""; _hp_n=0
3420
+ for _c in 'git config core.hooksPath' 'git config --local core.hooksPath' 'git config --global core.hooksPath' \
3421
+ 'git config --system core.hooksPath' 'git config --get core.hooksPath' 'git config --get-regexp core.hooksPath x' \
3422
+ 'git config --file .git/config core.hooksPath' 'git -C /r config core.hooksPath' 'git config core.hookspath' \
3423
+ 'git config core.hooksPath | cat' 'git status; echo core.hooksPath' 'git config --remove-section core.foo' \
3424
+ 'git config get --all core.hooksPath'; do
3425
+ gj auto "$_c" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _r=$?; _hp_n=$((_hp_n+1)); [ "$_r" = 0 ] || _hp_bad="$_hp_bad [$_c -> $_r, want 0]"
3426
+ done
3427
+ for _c in 'git config core.hooksPath .githooks' 'git config --local core.hooksPath x' 'git config --global core.hooksPath /tmp/x' \
3428
+ 'git config --system core.hooksPath x' 'git config --unset core.hooksPath' 'git config --unset-all core.hooksPath' \
3429
+ 'git config --add core.hooksPath x' 'git config --replace-all core.hooksPath x' 'git config set core.hooksPath x' \
3430
+ 'git config unset core.hooksPath' 'git config core.hooksPath \"\"' 'git config core.hooksPath; git config core.hooksPath x' \
3431
+ 'git config --get core.hooksPath && git config core.hooksPath x' \
3432
+ 'git config --comment get core.hooksPath /tmp/x' 'git config --comment -l core.hooksPath /tmp/x' \
3433
+ 'echo /tmp/x | xargs git config core.hooksPath' 'git config alias.hp \"config core.hooksPath\" && git hp /tmp/x' \
3434
+ "git config alias.hp '!git config core.hooksPath'" "bash -c 'git config core.hooksPath'" \
3435
+ 'git config core.hooksPath \r' 'git config core.hooksPath \f' 'git config core.hookspath /tmp/x' \
3436
+ 'git config \\\ncore.hooksPath /tmp/x' 'git config --remove-section core' 'git config --rename-section core x' \
3437
+ 'git -C /r config core.hooksPath /tmp/x' 'git config core.hooksPath; git config\"\" core.hooksPath /dev/A' \
3438
+ 'git config core.hooksPath && git config${IFS}core.hooksPath${IFS}/dev/B' \
3439
+ 'git config core.hooksPath; git config --comment=\"a;\" core.hooksPath /dev/C' 'git config core.hooks\"P\"ath x' \
3440
+ 'git config --remove-section core;echo' '(git config --remove-section core)' 'git -C \"a;b\" config core.hooksPath /dev/null' \
3441
+ 'git config -l --unset core.hooksPath' 'git config --get core.hooksPath --replace-all x'; do
3442
+ gj auto "$_c" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _r=$?; _hp_n=$((_hp_n+1)); [ "$_r" = 2 ] || _hp_bad="$_hp_bad [$_c -> $_r, want 2]"
3443
+ done
3444
+ # A read has to LOOK like one (the exact `git [-C d] config [flags] [read verb] core.hooksPath` shape): the review of the
3445
+ # first reader broke it with a read verb as another flag's value, a value from xargs or an alias, a CR / form-feed the
3446
+ # payload reader drops, a backslash-newline, a lower-case key, and removing the [core] section; a second round broke
3447
+ # the reader by making segments it could not parse get SKIPPED (`config""`, `config${IFS}`, a quoted `;`). Proven reads
3448
+ # are now cut out and the rest goes to the old rule, so nothing unproven is skipped — all pinned above.
3449
+ [ -z "$_hp_bad" ] && pass "core.hooksPath: value-less reads pass, every write form stays blocked ($_hp_n of 48 shapes)" \
3450
+ || fail "core.hooksPath read/write split is wrong:$_hp_bad"
3451
+ # An exemption belongs to the command it sits in. The IaC, .env and credential rules exempted the WHOLE line when a
3452
+ # safe marker (--help, .env.example, .pub, …) appeared anywhere in it, so a harmless command chained in front of a
3453
+ # forbidden one carried it through: 72 such shapes were open (every operator: && ; || | ( ) $( )). Chained -> 2;
3454
+ # the same marker in the forbidden command's own segment keeps its exemption -> 0. Exactly 0 or 2: a 1 is a crash.
3455
+ _ch_bad=""; _ch_n=0
3456
+ for _c in 'terraform --help && terraform destroy -auto-approve' 'terraform plan --dry-run; terraform apply -auto-approve' \
3457
+ 'pulumi preview --dry-run || pulumi up --yes' 'kubectl auth can-i delete pods | kubectl delete namespace prod' \
3458
+ '(helm --help); (helm uninstall app)' 'echo $(terraform --help) && terraform destroy' \
3459
+ 'cat .env.example && cat .env' 'cat .env.sample; grep x < .env' '(cat .env.example); (cat .env)' \
3460
+ '(cat .env)' 'x=$(cat .env)' 'cat ~/.ssh/id_rsa.pub; cat ~/.ssh/id_rsa' 'cat config.example && cat ~/.aws/credentials' \
3461
+ 'cat cert.template || cat server.pem' 'git config --list && git config core.hooksPath /tmp/x' \
3462
+ 'cat .env.example $(cat .env)' 'cat .env.example `cat .env`' 'terraform --help $(terraform destroy -auto-approve)'; do
3463
+ gj auto "$_c" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _r=$?; _ch_n=$((_ch_n+1)); [ "$_r" = 2 ] || _ch_bad="$_ch_bad [$_c -> $_r, want 2]"
3464
+ done
3465
+ for _c in 'terraform destroy --help' 'terraform plan -h' 'kubectl auth can-i delete pods' 'helm uninstall --help' \
3466
+ 'cat .env.example' 'echo $(cat .env.example)' '(cat .env.example)' 'cat ~/.ssh/id_rsa.pub | ssh-keygen -lf -' \
3467
+ 'cp .env.sample .env.local'; do
3468
+ gj auto "$_c" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _r=$?; _ch_n=$((_ch_n+1)); [ "$_r" = 0 ] || _ch_bad="$_ch_bad [$_c -> $_r, want 0]"
3469
+ done
3470
+ [ -z "$_ch_bad" ] && pass "chained exemptions: a safe command in front no longer carries a forbidden one ($_ch_n of 27 shapes; same-segment exemptions still pass)" \
3471
+ || fail "exemption leaks across a chain, or a legitimate exemption broke:$_ch_bad"
3472
+ # The read-only path costs processes, and on Git Bash a process is the price: measured on a loaded Windows 11
3473
+ # machine, one bare `bash` took 1.3-3.9 s and `git status` walked this hook through 14 of them (13 greps + the
3474
+ # stdin cat) — 37-66 s per tool call. The rules now run their regexes in the shell; what is left is the one `cat`
3475
+ # that reads stdin. Counted, not timed (a fork costs ~2 ms on macOS, so a regression is invisible to a clock).
3476
+ # The twin plants one `printf | grep` into a copy of the hook: a counter that cannot see it measures nothing.
3477
+ _gbfc(){ printf '%s' "$2" | bash -x "$1" >/dev/null 2>"$3"; grep -cE '^\++ (grep|sed|awk|tr|cat|head|tail|cut|sort|find|wc|mktemp|basename|dirname|git|jq|python3?|node|perl)( |$)' "$3" | tr -cd '0-9'; }
3478
+ _GBT="$(mktemp -d)"; _gbmax=0; _gbrow=""
3479
+ for _c in 'git status --short' 'git log --oneline -2 && git status --short | head; ls; cat CHANGELOG.md' 'ls -la' 'git diff --stat'; do
3480
+ _n="$(_gbfc "$HOOKS/guard-bash.sh" "$(gj default "$_c")" "$_GBT/t")"; _n="${_n:-0}"
3481
+ _gbrow="$_gbrow $_n"; [ "$_n" -gt "$_gbmax" ] && _gbmax="$_n"
3482
+ done
3483
+ sed '/^INPUT="\$(cat)"/a\
3484
+ printf x | grep -q x
3485
+ ' "$HOOKS/guard-bash.sh" > "$_GBT/twin.sh"
3486
+ _tw="$(_gbfc "$_GBT/twin.sh" "$(gj default 'git status --short')" "$_GBT/t2")"; _tw="${_tw:-0}"
3487
+ if [ "$_gbmax" = 0 ]; then
3488
+ fail "guard-bash read-path cost: the trace recorded no external command at all — the measurement is broken, not the hook"
3489
+ elif [ "$_tw" -le "$_gbmax" ]; then
3490
+ fail "guard-bash read-path cost: the planted grep did not raise the count ($_tw) — the counter measures nothing"
3491
+ elif [ "$_gbmax" -le 1 ]; then
3492
+ pass "guard-bash read-only path: at most 1 external process per call (git status · read chain · ls · diff:$_gbrow; twin with one planted grep: $_tw)"
3493
+ else
3494
+ fail "guard-bash read-only path spawns $_gbmax processes per call (per command:$_gbrow) — budget 1; Git Bash pays up to seconds for each"
3495
+ fi
3496
+ # Coarse and SECONDARY: the first version of the in-shell matcher compiled its regex once per line per rule and a
3497
+ # 2,000-line heredoc took 70 s (0.2 s before). Nothing forks more in that case, so the count above cannot see it.
3498
+ # Measured after the fix: 0.2 s on macOS. The bound is 100x that, so a slow runner does not trip it.
3499
+ _hd="$(i=0; printf 'cat > f <<EOF\n'; while [ "$i" -lt 2000 ]; do printf 'const x = 1;\n'; i=$((i+1)); done; printf 'EOF\ngit status')"
3500
+ _hdj="$(printf '%s' "$_hd" | awk 'BEGIN{ORS=""} {gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if (NR>1) print "\\n"; print}')"
3501
+ _hs=$SECONDS; printf '{"tool_name":"Bash","permission_mode":"default","tool_input":{"command":"%s"}}' "$_hdj" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; _hr=$?; _hel=$((SECONDS - _hs))
3502
+ if [ "$_hr" != 0 ]; then fail "guard-bash on a 2,000-line heredoc answered $_hr, not 0 — the payload or the hook is broken"
3503
+ elif [ "$_hel" -le 20 ]; then pass "guard-bash on a 2,000-line heredoc: ${_hel}s (bound 20s; a regcomp-per-line matcher took 70s)"
3504
+ else fail "guard-bash took ${_hel}s on a 2,000-line heredoc (bound 20s) — something in the hook is per line again"; fi
3505
+ rm -rf "$_GBT"
3282
3506
  gj auto 'rm .claude/hooks/pre-commit' | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "rm of a .claude gate file BLOCKED (§4.5)" || fail "rm of a gate file PASSED (§4.5 hole)"
3283
3507
  # The rulebook is a gate file too — measured against 2.6.0, all three of these passed. Reading it must stay free.
3284
3508
  gj auto "sed -i 's/x/y/' .claude/DISCIPLINE.md" | bash "$HOOKS/guard-bash.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "in-place edit of DISCIPLINE.md BLOCKED (§4.5)" || fail "sed -i on the discipline document PASSED (§4.5 hole)"
@@ -3394,7 +3618,7 @@ done
3394
3618
  wj Write '/p/.claude/DISCIPLINE.md ' | bash "$HOOKS/guard-write.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "a trailing space does not hide DISCIPLINE.md" || fail "trailing space defeated the DISCIPLINE.md rule"
3395
3619
  wj Write '/p/.claude./hooks/x.sh' | bash "$HOOKS/guard-write.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "a trailing dot on a component does not hide a gate path" || fail "trailing dot defeated the hooks rule"
3396
3620
  # THE PLUGIN EDITION ships the same gate scripts at $CLAUDE_PLUGIN_ROOT/hooks/, which is not `.claude/hooks/`:
3397
- # one of Crewforth's four channels was shipping an unguarded copy of its own gates. Matched by Crewforth's own
3621
+ # one of Crewforth's three channels was shipping an unguarded copy of its own gates. Matched by Crewforth's own
3398
3622
  # filenames, so a project's unrelated `hooks/` directory keeps working.
3399
3623
  wj Write '/Users/dev/.claude/plugins/crewforth/hooks/guard-write.sh' | bash "$HOOKS/guard-write.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "the plugin edition's own gate script is BLOCKED too" || fail "the plugin edition ships unguarded gate scripts (§4.5 hole)"
3400
3624
  wj Write '/opt/crew/hooks/session-guard.sh' | bash "$HOOKS/guard-write.sh" >/dev/null 2>&1; [ "$?" = 2 ] && pass "a kit gate script is BLOCKED wherever it sits" || fail "a kit gate script outside .claude/ PASSED"
@@ -4062,7 +4286,7 @@ if ( cd "$BSD" && git init -q . ) >/dev/null 2>&1; then
4062
4286
  printf '%s\n' $'#1 "Fix\tlogin" C:\\app\r\x01 ok\nsecond' > "$BSD/.git/crew-board-cache"
4063
4287
  date -u +%s > "$BSD/.git/crew-board-cache.at"
4064
4288
  o="$(printf '{}' | CLAUDE_PROJECT_DIR="$BSD" bash "$HOOKS/board-sync.sh" 2>/dev/null)"
4065
- want='{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"#1 \"Fix\tlogin\" C:\\app\r\u0001 ok\nsecond\nBoard state above is a cached snapshot; bash .claude/hooks/board.sh sync (or the user can type /crew-board sync) refreshes it."}}'
4289
+ want='{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"#1 \"Fix\tlogin\" C:\\app\r\u0001 ok\nsecond\nBoard state above is a cached snapshot; bash .claude/hooks/board.sh sync (Bash tool, not PowerShell — or the user can type /crew-board sync) refreshes it."}}'
4066
4290
  [ "$o" = "$want" ] && pass "board-sync escapes tab, CR, control bytes, quote and backslash exactly as jq does (no jq needed)" \
4067
4291
  || fail "board-sync JSON differs from jq's for a cache with a tab/CR/control byte — got: ${o:-<silence>}"
4068
4292
  # A CRLF cache: the line-ending CR is dropped on every OS (MSYS gawk drops it on read, BSD awk does not — the
@@ -4131,6 +4355,49 @@ elif [ -f "$PHJ" ]; then
4131
4355
  [ -x "$PLUGIN/hooks/$h" ] && grep -q "$h" "$PHJ" || { fail "plugin hook $h missing or not wired"; break; }
4132
4356
  done
4133
4357
  pass "5 Claude Code hooks shipped + wired in plugin"
4358
+ # The commands the model is told to run, as the plugin edition names them. The file-install path `.claude/hooks/…`
4359
+ # does not exist in a plugin install, so every one exited 127 there (measured: 12 lines in 8 files). build-plugin.sh
4360
+ # rewrites them to the plugin root, which Claude Code substitutes in skill and agent bodies. Three claims: every
4361
+ # rewritten path is a file this edition ships; no shipped script is still named by the file-install path; and one
4362
+ # rewritten command, run the way the Bash tool runs it (root substituted, NOT exported), from an empty project, exits 0.
4363
+ _ppn=0; _ppbad=""
4364
+ while IFS= read -r _pc; do _ppn=$((_ppn+1)); _pf="${_pc#\"\$\{CLAUDE_PLUGIN_ROOT\}/}"; _pf="${_pf%\"}"
4365
+ [ -f "$PLUGIN/$_pf" ] || _ppbad="$_ppbad $_pf"
4366
+ done < <(grep -rhoE '"\$\{CLAUDE_PLUGIN_ROOT\}/(hooks|skills)/[A-Za-z0-9_./-]+\.sh"' "$PLUGIN/agents" "$PLUGIN/skills" --include='*.md' 2>/dev/null | sort -u)
4367
+ _ppleft="$(grep -rhoE 'bash \.claude/(hooks|skills)/[A-Za-z0-9_./-]+\.sh' "$PLUGIN/agents" "$PLUGIN/skills" --include='*.md' 2>/dev/null | sort -u \
4368
+ | while IFS= read -r _pc; do [ -f "$PLUGIN/${_pc#bash .claude/}" ] && printf ' %s' "$_pc"; done)" || true
4369
+ # A shipped script still named by the file-install path is the product defect itself, so it is judged first — an
4370
+ # un-rewritten plugin/ has zero plugin-root commands, and calling that a FIXTURE would blame the test for the bug.
4371
+ # The expected number comes from kit/: every distinct `bash .claude/<x>.sh` there (off a `# full install` line) whose
4372
+ # <x> this edition ships. A fixed floor blamed the build for a legitimate edit that removed a command.
4373
+ _ppw="$(grep -rhE 'bash \.claude/' "$ROOT/agents" "$ROOT/skills" --include='*.md' 2>/dev/null | grep -v '# full install' \
4374
+ | grep -oE 'bash \.claude/[A-Za-z0-9_./-]+\.sh([^A-Za-z0-9_./-]|$)' | sed -E 's/[^A-Za-z0-9_./-]$//' | sort -u \
4375
+ | while IFS= read -r _pc; do [ -f "$PLUGIN/${_pc#bash .claude/}" ] && echo x; done | wc -l | tr -d ' ')"
4376
+ if [ -z "$_ppbad$_ppleft" ] && [ "$_ppn" != "$_ppw" ]; then fail "kit/ names $_ppw script(s) the plugin ships, plugin/ carries $_ppn plugin-root path(s) — run packaging/build-plugin.sh"
4377
+ elif [ -n "$_ppbad$_ppleft" ]; then fail "plugin edition names a script it cannot run —${_ppbad:+ missing:$_ppbad}${_ppleft:+ still the file-install path (127 there):$_ppleft}"
4378
+ else pass "plugin edition: all $_ppn of $_ppw script paths the model is told to run resolve under the plugin root, none left on .claude/"; fi
4379
+ _ppd="$(mktemp -d)"; mkdir -p "$_ppd/proj"
4380
+ printf '%s\n' '{"type":"assistant","isSidechain":false,"message":{"usage":{"input_tokens":1,"cache_read_input_tokens":2000,"cache_creation_input_tokens":1,"output_tokens":1}}}' > "$_ppd/t.jsonl"
4381
+ _ppc="$(grep -rhoE 'bash "\$\{CLAUDE_PLUGIN_ROOT\}/hooks/context-usage\.sh"' "$PLUGIN/skills" --include='*.md' 2>/dev/null | head -1)"
4382
+ if [ -z "$_ppc" ] && [ -n "$_ppleft" ]; then fail "no plugin-root context-usage command to run — plugin/ still names it by the file-install path (127 in a plugin install)"
4383
+ elif [ -z "$_ppc" ]; then fail "FIXTURE: no rewritten context-usage command in plugin/skills to run"
4384
+ else
4385
+ _ppc="${_ppc//\$\{CLAUDE_PLUGIN_ROOT\}/$PLUGIN}"
4386
+ _pprc="$(cd "$_ppd/proj" && env -u CLAUDE_PLUGIN_ROOT bash -c "$_ppc \"$_ppd/t.jsonl\"" >/dev/null 2>&1; echo "$?")"
4387
+ [ "$_pprc" = 0 ] && pass "a rewritten plugin command runs from an empty project with the root substituted (rc 0)" \
4388
+ || fail "the rewritten plugin command exited $_pprc from an empty project: $_ppc"
4389
+ fi
4390
+ rm -rf "$_ppd"
4391
+ # board.sh names ITSELF in what it prints, and in the plugin edition that must be its absolute path. Run by a bare
4392
+ # name from its own directory (`cd hooks && bash board.sh`), `${BASH_SOURCE%/*}` is the name itself, the `cd` failed,
4393
+ # and it printed the file-install path — 127 in a plugin install (measured). A plugin-shaped copy in an empty repo.
4394
+ _bsd="$(mktemp -d)"; mkdir -p "$_bsd/p/hooks" "$_bsd/p/.claude-plugin"; : > "$_bsd/p/.claude-plugin/plugin.json"
4395
+ cp "$HOOKS/board.sh" "$_bsd/p/hooks/"; ( cd "$_bsd/p" && git init -q . ) >/dev/null 2>&1
4396
+ _bsb="$(cd "$_bsd/p/hooks" && bash board.sh status 2>&1)"; _bsp="$(cd "$_bsd/p" && bash hooks/board.sh status 2>&1)"
4397
+ _bsa="$(cd "$_bsd/p/hooks" && pwd)"
4398
+ case "$_bsb|$_bsp" in *"bash \"$_bsa/board.sh\""*"|"*"bash \"$_bsa/board.sh\""*) pass "board.sh names its own plugin path whether run by bare name or by path" ;;
4399
+ *) fail "board.sh printed a path a plugin install cannot run — bare name: ${_bsb:-<silence>} · by path: ${_bsp:-<silence>}" ;; esac
4400
+ rm -rf "$_bsd"
4134
4401
  # The git hooks now DO ship, and must: guard-commit-scan.sh runs them from PreToolUse, which is the only way
4135
4402
  # the plugin edition gets the commit CONTENT gates at all (a plugin cannot set core.hooksPath). They ship as
4136
4403
  # data for that hook, never wired as git hooks. Shipping them WITHOUT the caller would be worse than not
@@ -4348,10 +4615,44 @@ O="$(st)"
4348
4615
  case "$O" in *skills/mine*) pass "flags a component Crewforth never shipped" ;; *) fail "an unshipped skill was not flagged: $O" ;; esac
4349
4616
  case "$O" in *skills/handoff*) fail "flagged a KIT skill — the manifest is being ignored" ;; *) pass "a kit-shipped skill is not re-litigated" ;; esac
4350
4617
  case "$O" in *"REVIEW/DANGER"*) pass "runs the supply-chain scanner and reports its verdict" ;; *) fail "no scanner verdict on a malicious skill: $O" ;; esac
4618
+ # ONE COMMAND PER COMPONENT, and the user's message first (field report P4b/P5). The notice used to offer the bulk
4619
+ # `--trust`, which accepts every foreign component at once, including ones the user never looked at; and it told the
4620
+ # model to speak BEFORE anything else, which pushed a user's urgent first message behind a security question. The
4621
+ # properties pinned: each listed component carries its own `--trust-one <path>` with the Bash tool named, the bulk
4622
+ # `--trust` is never offered, the question comes at the END of the first reply, and nothing is used until answered.
4623
+ _stn="$(printf '%s\n' "$O" | grep -c -- '--trust-one skills/mine (Bash tool, not PowerShell)')"; _ste="$(printf '%s\n' "$O" | grep -c -- '--trust-one skills/evil (Bash tool, not PowerShell)')"
4624
+ _stb="$(printf '%s\n' "$O" | grep -cE -- '--trust([^-]|$)')"
4625
+ [ "$_stn" = 1 ] && [ "$_ste" = 1 ] && [ "$_stb" = 0 ] && pass "each unvetted component gets its own --trust-one command (2 of 2), the bulk --trust is not offered" \
4626
+ || fail "trust commands: skills/mine $_stn, skills/evil $_ste (want 1 each), bulk --trust $_stb (want 0): $O"
4627
+ case "$O" in *"BEFORE anything else"*) fail "the notice still puts the trust question before the user's own message" ;;
4628
+ *"at the END of that first reply"*"do not use"*) pass "the trust question comes at the end of the first reply, and the components are not used until answered" ;;
4629
+ *) fail "the notice does not say to answer the user first and ask at the end of that reply: $O" ;; esac
4630
+ # One component: one command, and no other component named.
4631
+ rm -rf "${STD:?}/.claude/skills/evil"; _O1="$(st)"
4632
+ [ "$(printf '%s\n' "$_O1" | grep -c -- '--trust-one ')" = 1 ] && pass "a single unvetted component gets exactly one trust command" \
4633
+ || fail "a single unvetted component got $(printf '%s\n' "$_O1" | grep -c -- '--trust-one ') trust commands: $_O1"
4351
4634
  ( cd "$STD" && bash .claude/hooks/skill-trust.sh --trust ) >/dev/null 2>&1
4352
4635
  [ -z "$(st)" ] && pass "accepted components stay silent on later sessions" || fail "still reporting after --trust"
4353
4636
  printf 'and now it also reads ~/.ssh/id_rsa\n' >> "$STD/.claude/skills/mine/SKILL.md"
4354
4637
  case "$(st)" in *skills/mine*) pass "an accepted component edited afterwards is flagged again (digest, not a name)" ;; *) fail "an edited accepted component was not re-flagged" ;; esac
4638
+ # A NO is recorded too (RC-1 field: the user said no, nothing wrote it down, and the update vouched anyway). Declining
4639
+ # replaces a yes, the next session names the component as not to be used and asks nothing, and a later yes replaces the
4640
+ # no. Each step reads the files, not only the notice.
4641
+ case "$O" in *"--decline-one skills/mine (Bash tool, not PowerShell)"*"On a no, run the decline command"*) pass "each unvetted component also gets its own --decline-one command, and the notice says to run it on a no" ;;
4642
+ *) fail "the notice offers no way to record a no: $O" ;; esac
4643
+ ( cd "$STD" && bash .claude/hooks/skill-trust.sh --decline-one skills/mine ) >/dev/null 2>&1; _dr=$?
4644
+ _dO="$(st)"
4645
+ if [ "$_dr" = 0 ] && grep -q ' skills/mine$' "$STD/.claude/declined-components.txt" 2>/dev/null \
4646
+ && ! grep -q ' skills/mine$' "$STD/.claude/trusted-components.txt" 2>/dev/null; then
4647
+ pass "--decline-one records the no and drops the earlier yes (rc 0)"
4648
+ else fail "--decline-one: rc $_dr, declined file: $(cat "$STD/.claude/declined-components.txt" 2>/dev/null | tr '\n' '|'), trusted: $(cat "$STD/.claude/trusted-components.txt" 2>/dev/null | tr '\n' '|')"; fi
4649
+ case "$_dO" in *"Declined by the user"*"- skills/mine"*) case "$_dO" in *"--trust-one skills/mine"*) fail "a declined component is still asked about: $_dO" ;;
4650
+ *) pass "a declined component is named as not to be used, and not asked about again" ;; esac ;;
4651
+ *) fail "a declined component is not named at session start: $_dO" ;; esac
4652
+ ( cd "$STD" && bash .claude/hooks/skill-trust.sh --trust-one skills/mine ) >/dev/null 2>&1
4653
+ if ! grep -q ' skills/mine$' "$STD/.claude/declined-components.txt" 2>/dev/null && grep -q ' skills/mine$' "$STD/.claude/trusted-components.txt" 2>/dev/null \
4654
+ && [ -z "$(st)" ]; then pass "a later yes replaces the no: recorded once, and the session is quiet"
4655
+ else fail "--trust-one after --decline-one did not replace the answer"; fi
4355
4656
  # A manifest with CRLF line endings still identifies kit components. `grep -qxF "skills/handoff"` does NOT match
4356
4657
  # the line "skills/handoff\r", so on Windows every kit component read as unshipped and the session opened by
4357
4658
  # declaring the entire payload unvetted — a wall of warnings about Crewforth's own files, which teaches the reader
@@ -4397,7 +4698,7 @@ uans(){ ( cd "$UPD" && bash .claude/hooks/session-update-check.sh --answer "$@"
4397
4698
  ucache 3.1.0; o="$(uc)"
4398
4699
  case "$o" in *'"Crewforth v3.1.0 is out (installed: v3.0.0). Update now?"'*'"Update" · "Later" · "Skip this version"'*) pass "a newer release becomes one three-way question naming both versions" ;;
4399
4700
  *) fail "no three-way update question for a cached newer version (got: ${o:-<silence>})" ;; esac
4400
- case "$o" in *"--answer later 3.1.0"*"--answer skip 3.1.0"*) pass "the question carries the exact --answer commands for Later and Skip" ;;
4701
+ case "$o" in *"--answer later 3.1.0\` (Bash tool, not PowerShell)"*"--answer skip 3.1.0\` (Bash tool, not PowerShell)"*) pass "the question carries the exact --answer commands for Later and Skip, each with the Bash tool named" ;;
4401
4702
  *) fail "the question does not tell Claude how to record Later/Skip: $o" ;; esac
4402
4703
  case "$o" in *AskUserQuestion*) pass "the question is asked with the question tool, not as prose" ;; *) fail "the hook does not name the question tool" ;; esac
4403
4704
  case "$o" in *"if their first message is an error or an urgent fix, answer that first and ask this question at the end of that reply"*) pass "an urgent first message is answered first; the question moves to the end of that reply" ;;
@@ -4626,7 +4927,9 @@ fi
4626
4927
  # hooks run there as on resume — the fork continues work the parent had; the update question does not — a fork
4627
4928
  # is not a new opening. Asked of the wiring (both editions) AND of the hooks themselves with a fork payload.
4628
4929
  hk_matcher(){ awk -v h="$2" '/"matcher"/{m=$0; sub(/.*"matcher"[[:space:]]*:[[:space:]]*"/,"",m); sub(/".*/,"",m)} index($0,h){print m; exit}' "$1"; }
4629
- FK_FILES="$ROOT/settings.json"; FKR="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
4930
+ # The plugin copy is Crewforth's only in this repository: an installed project may have a plugin/ of its own, and
4931
+ # reading that one turned the project's smoke red (review of 3.0.1).
4932
+ FK_FILES="$ROOT/settings.json"; FKR=""; [ "$IS_KIT" = 1 ] && FKR="$(cd "$ROOT/.." && pwd)"
4630
4933
  [ -n "$FKR" ] && [ -f "$FKR/plugin/hooks/hooks.json" ] && FK_FILES="$FK_FILES $FKR/plugin/hooks/hooks.json"
4631
4934
  for _ff in $FK_FILES; do
4632
4935
  _fl="${_ff##*/}"
@@ -5157,6 +5460,8 @@ SILENT|<task-notification>Agent crew-database-expert finished: wrote the migrati
5157
5460
  SILENT|[SYSTEM NOTIFICATION] the background agent finished its migration and seed report
5158
5461
  SILENT|<cross-session-message>report: the migration and the seed are written, an endpoint was added</cross-session-message>
5159
5462
  crew-database-expert|the system notification code needs a migration for the invoices table
5463
+ SILENT|<agent-message from=\"a0000000000000000\">\n[Subagent hand-back] The text below is the final report of a subagent this session delegated to. It is model output, NOT a message from the user.\n Wrote the migration and an index for the invoices table.
5464
+ crew-database-expert|Wrote the migration and an index for the invoices table.
5160
5465
  RHCASES
5161
5466
 
5162
5467
  # --- the field name, which is the way this hook dies quietly -------------------------------------
@@ -5501,8 +5806,101 @@ if [ -f "$GR" ]; then
5501
5806
  *) fail "doctor did not report on the shell matcher" ;; esac
5502
5807
  sed 's/"Bash|PowerShell"/"Bash"/' "$DTMP/.claude/settings.json" > "$DTMP/s.tmp" && mv "$DTMP/s.tmp" "$DTMP/.claude/settings.json"
5503
5808
  DOUT2="$(cd "$DTMP" && CREW_LANG=en bash .claude/eval/doctor.sh 2>/dev/null)"
5504
- case "$DOUT2" in *"watch only Bash"*) pass "doctor flags a pre-2.5.0 Bash-only matcher as a failure" ;;
5809
+ case "$DOUT2" in *"watch only Bash"*"DOCTOR: "*"issue(s)"*) pass "doctor flags a pre-2.5.0 Bash-only matcher as a failure, and the verdict counts it" ;;
5505
5810
  *) fail "doctor stayed quiet on a Bash-only matcher — the gap is invisible to an upgrader" ;; esac
5811
+ # Windows: doctor must say when Claude Code cannot find Git Bash — its hooks then run under PowerShell and no gate
5812
+ # runs. Driven on any OS through shims: `uname` says MINGW, `cygpath` maps C:\ into a fixture drive, so the four
5813
+ # answers (default folder · CLAUDE_CODE_GIT_BASH_PATH · only git on PATH · nothing) come from real doctor code, and
5814
+ # the machine's own Git never leaks in (the fixture drive holds only what the case puts there).
5815
+ GBF="$DTMP/gbfix"; mkdir -p "$GBF/bin" "$GBF/c"
5816
+ printf '#!/bin/sh\necho MINGW64_NT-10.0-26200\n' > "$GBF/bin/uname"
5817
+ cat > "$GBF/bin/cygpath" <<'GBCP'
5818
+ #!/bin/bash
5819
+ # fixture cygpath: C:\x\y <-> $GBC/c/x/y; anything else is not on the fixture drive
5820
+ case "$1" in
5821
+ -u) p="$2"; case "$p" in [Cc]:\\*) p="${p:3}"; printf '%s/c/%s\n' "$GBC" "${p//\\//}" ;; *) exit 1 ;; esac ;;
5822
+ -w) p="$2"; case "$p" in "$GBC"/c/*) p="${p#"$GBC"/c/}"; printf 'C:\\%s\n' "${p//\//\\}" ;; *) exit 1 ;; esac ;;
5823
+ esac
5824
+ GBCP
5825
+ chmod +x "$GBF/bin/uname" "$GBF/bin/cygpath"
5826
+ gbdoc(){ ( cd "$DTMP" && env -u CLAUDE_CODE_GIT_BASH_PATH -u LOCALAPPDATA GBC="$GBF" PATH="$GBF/bin:$PATH" CREW_LANG=en "$@" bash .claude/eval/doctor.sh 2>/dev/null ); }
5827
+ _gb0="$(gbdoc)"
5828
+ case "$_gb0" in *"cannot find Git Bash"*"<Git>\\bin\\bash.exe"*) pass "doctor: no Git Bash where Claude Code looks → a failure that names the fix" ;;
5829
+ *) fail "doctor stayed quiet with no Git Bash on Windows — the gates are dead and nothing says so" ;; esac
5830
+ mkdir -p "$GBF/c/Tools/Git/cmd" "$GBF/c/Tools/Git/bin"; : > "$GBF/c/Tools/Git/bin/bash.exe"
5831
+ printf '#!/bin/sh\n' > "$GBF/c/Tools/Git/cmd/git"; chmod +x "$GBF/c/Tools/Git/cmd/git"
5832
+ _gb1="$(gbdoc env PATH="$GBF/c/Tools/Git/cmd:$GBF/bin:$PATH")"
5833
+ case "$_gb1" in *"only through git on PATH (C:\\Tools\\Git\\bin\\bash.exe)"*) pass "doctor: Git Bash found only through git on PATH → a warning naming it" ;;
5834
+ *) fail "doctor did not report a Git Bash reachable only through PATH" ;; esac
5835
+ _gb2="$(gbdoc env CLAUDE_CODE_GIT_BASH_PATH='C:\Tools\Git\bin\bash.exe')"
5836
+ case "$_gb2" in *"finds Git Bash (C:\\Tools\\Git\\bin\\bash.exe)"*) pass "doctor: CLAUDE_CODE_GIT_BASH_PATH to an existing bash → found" ;;
5837
+ *) fail "doctor did not honour CLAUDE_CODE_GIT_BASH_PATH" ;; esac
5838
+ _gb3="$(gbdoc env CLAUDE_CODE_GIT_BASH_PATH='C:\Tools\Git\bin\git.exe')"
5839
+ case "$_gb3" in *"CLAUDE_CODE_GIT_BASH_PATH (C:\\Tools\\Git\\bin\\git.exe) is not a bash"*"cannot find Git Bash"*) pass "doctor: a CLAUDE_CODE_GIT_BASH_PATH that is not bash is named as ignored" ;;
5840
+ *) fail "doctor accepted a CLAUDE_CODE_GIT_BASH_PATH that Claude Code ignores" ;; esac
5841
+ mkdir -p "$GBF/c/Users/u/AppData/Local/Programs/Git/bin"; : > "$GBF/c/Users/u/AppData/Local/Programs/Git/bin/bash.exe"
5842
+ _gbu="$(gbdoc env LOCALAPPDATA='C:\Users\u\AppData\Local')"
5843
+ case "$_gbu" in *"installed for this user only (C:\\Users\\u\\AppData\\Local\\Programs\\Git\\bin\\bash.exe)"*"CLAUDE_CODE_GIT_BASH_PATH to \"C:\\Users\\u\\AppData\\Local\\Programs\\Git\\bin\\bash.exe\""*)
5844
+ pass "doctor: a per-user Git install Claude Code does not look in → a failure naming the measured path to set" ;;
5845
+ *) fail "doctor did not name the per-user Git Bash for CLAUDE_CODE_GIT_BASH_PATH" ;; esac
5846
+ _gbx="$(gbdoc env LOCALAPPDATA='C:\Users\nobody\AppData\Local')"
5847
+ case "$_gbx" in *"installed for this user only"*) fail "doctor named a per-user Git Bash that does not exist — the path must be measured" ;;
5848
+ *"cannot find Git Bash"*) pass "doctor: no per-user Git either → names no path it did not find" ;;
5849
+ *) fail "doctor: an absent per-user Git gave neither answer" ;; esac
5850
+ mkdir -p "$GBF/c/Program Files/Git/bin"; : > "$GBF/c/Program Files/Git/bin/bash.exe"
5851
+ _gb4="$(gbdoc)"
5852
+ case "$_gb4" in *"finds Git Bash (C:\\Program Files\\Git\\bin\\bash.exe)"*) pass "doctor: Git Bash in its default folder → found" ;;
5853
+ *) fail "doctor did not find Git Bash in C:\\Program Files\\Git" ;; esac
5854
+ # The fixture has issues of its own; what counts is that the missing Git Bash adds exactly one.
5855
+ gbn(){ printf '%s\n' "$1" | sed -n 's/^DOCTOR: \([0-9][0-9]*\) issue.*/\1/p'; }
5856
+ [ "$(gbn "$_gb0")" = "$(( $(gbn "$_gb4" | grep . || echo 0) + 1 ))" ] \
5857
+ && pass "doctor: the missing Git Bash is one more issue in the verdict ($(gbn "$_gb4" | grep . || echo 0) → $(gbn "$_gb0"))" \
5858
+ || fail "doctor: the verdict did not count the missing Git Bash (with: '$(gbn "$_gb4")', without: '$(gbn "$_gb0")')"
5859
+ _gb5="$(cd "$DTMP" && env PATH="$GBF/c/Tools/Git/cmd:$PATH" CREW_LANG=en bash .claude/eval/doctor.sh 2>/dev/null)"
5860
+ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) skip platform "doctor off Windows says nothing about Git Bash — this runner IS Windows" ;;
5861
+ *) case "$_gb5" in *"Git Bash"*) fail "doctor spoke about Git Bash on a non-Windows machine" ;;
5862
+ *"DOCTOR: "*) pass "doctor says nothing about Git Bash off Windows" ;;
5863
+ *) fail "doctor printed no verdict in the off-Windows case — the silence proves nothing" ;; esac ;; esac
5864
+ # Third state: a Windows shell without cygpath is said to be unchecked, not passed. Only reachable where the
5865
+ # machine itself has no cygpath (a Windows runner has it in /usr/bin, next to everything else).
5866
+ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) skip platform "doctor's no-cygpath branch — this Windows runner has cygpath" ;;
5867
+ *) mkdir -p "$GBF/nocp"; cp "$GBF/bin/uname" "$GBF/nocp/uname"
5868
+ _gb6="$(cd "$DTMP" && PATH="$GBF/nocp:$PATH" CREW_LANG=en bash .claude/eval/doctor.sh 2>/dev/null)"
5869
+ case "$_gb6" in *"Git Bash lookup not checked (no cygpath"*) pass "doctor: Windows without cygpath → says the Git Bash lookup was not checked" ;;
5870
+ *) fail "doctor: Windows without cygpath did not say the lookup went unchecked" ;; esac ;; esac
5871
+ # ONE copy: the plugin edition's /crew-doctor runs eval/lib/git-bash.sh directly (doctor.sh does not ship there), so
5872
+ # the script alone must give doctor's answer and exit code, doctor must hold no second copy, and the plugin must
5873
+ # carry these bytes.
5874
+ _gbs="$DTMP/.claude/eval/lib/git-bash.sh"
5875
+ _gbd="$(cd "$DTMP" && env -u CLAUDE_CODE_GIT_BASH_PATH -u LOCALAPPDATA GBC="$GBF/empty" PATH="$GBF/bin:$PATH" CREW_LANG=en bash "$_gbs" 2>/dev/null; echo "rc=$?")"
5876
+ _gbd0="$(printf '%s\n' "$_gb0" | grep -F 'cannot find Git Bash')"
5877
+ case "$_gbd" in *"$_gbd0"*"rc=1") [ -n "$_gbd0" ] && pass "git-bash.sh alone gives doctor's line and exits 1 — the plugin's /crew-doctor gets the same answer" \
5878
+ || fail "git-bash.sh: doctor's reference line is empty — the comparison proves nothing" ;;
5879
+ *) fail "git-bash.sh alone did not give doctor's line with exit 1: $(printf '%s' "$_gbd" | tr '\n' ' ')" ;; esac
5880
+ grep -q 'finds Git Bash' "$ROOT/eval/doctor.sh" && fail "doctor.sh holds its own copy of the Git Bash check — the plugin's would drift" \
5881
+ || pass "the Git Bash check lives only in eval/lib/git-bash.sh"
5882
+ if [ "$IS_KIT" = 1 ]; then
5883
+ cmp -s "$ROOT/eval/lib/git-bash.sh" "$(cd "$ROOT/.." && pwd)/plugin/eval/lib/git-bash.sh" \
5884
+ && pass "the plugin edition ships the same git-bash.sh" || fail "plugin/eval/lib/git-bash.sh is missing or differs — run packaging/build-plugin.sh"
5885
+ fi
5886
+ # Every branch, in Turkish, on a drive of its own (the one above has a default-folder Git by now, which would
5887
+ # answer "found" for all of them and leave the other keys untested).
5888
+ _gbt="$GBF/tr"; mkdir -p "$_gbt/c/Tools/Git/cmd" "$_gbt/c/Tools/Git/bin" "$_gbt/c/Users/u/AppData/Local/Programs/Git/bin"
5889
+ : > "$_gbt/c/Tools/Git/bin/bash.exe"; : > "$_gbt/c/Users/u/AppData/Local/Programs/Git/bin/bash.exe"
5890
+ printf '#!/bin/sh\n' > "$_gbt/c/Tools/Git/cmd/git"; chmod +x "$_gbt/c/Tools/Git/cmd/git"
5891
+ : > "$DTMP/gbmiss"; _gbtn=""
5892
+ for _gbe in "X=1" "CLAUDE_CODE_GIT_BASH_PATH=C:\\Tools\\Git\\bin\\bash.exe" "CLAUDE_CODE_GIT_BASH_PATH=C:\\nope\\git.exe" \
5893
+ "LOCALAPPDATA=C:\\Users\\u\\AppData\\Local" "PATH=$_gbt/c/Tools/Git/cmd:$GBF/bin:$PATH"; do
5894
+ ( cd "$DTMP" && env -u CLAUDE_CODE_GIT_BASH_PATH -u LOCALAPPDATA GBC="$_gbt" PATH="$GBF/bin:$PATH" CREW_LANG=tr CREW_I18N_MISS="$DTMP/gbmiss" "$_gbe" bash "$_gbs" >/dev/null 2>&1 )
5895
+ _gbtn="$_gbtn$?"
5896
+ done
5897
+ [ -s "$DTMP/gbmiss" ] && fail "git-bash.sh prints English under CREW_LANG=tr: $(sort -u "$DTMP/gbmiss" | tr '\n' '|')" \
5898
+ || { [ "$_gbtn" = 10113 ] && pass "git-bash.sh speaks Turkish on all five branches (no missing key; exits $_gbtn)" \
5899
+ || fail "git-bash.sh Turkish sweep exited '$_gbtn', want 10113 (none · found · wrong variable · per-user · PATH only) — it did not reach every branch"; }
5900
+ # A bash named without .exe: MSYS says `[ -f …/bash ]` when only bash.exe exists; Claude Code does not.
5901
+ _gb7="$(gbdoc env CLAUDE_CODE_GIT_BASH_PATH='C:\Tools\Git\bin\bash')"
5902
+ case "$_gb7" in *"CLAUDE_CODE_GIT_BASH_PATH (C:\\Tools\\Git\\bin\\bash) is not a bash"*) pass "doctor: CLAUDE_CODE_GIT_BASH_PATH naming bash without .exe, where only bash.exe exists → ignored, as Claude Code does" ;;
5903
+ *) fail "doctor accepted a CLAUDE_CODE_GIT_BASH_PATH that exists only through the .exe suffix" ;; esac
5506
5904
  grep -q 'command not found' "$DTMP/err" && fail "doctor.sh calls a helper before it is defined (see stderr)" \
5507
5905
  || pass "doctor.sh runs with no undefined-helper errors"
5508
5906
  case "$DOUT" in *"gate activity"*) pass "doctor actually prints a gate-activity line" ;;
@@ -5688,6 +6086,309 @@ $PSOK
5688
6086
  PSEOF2
5689
6087
  [ -z "$PSFP" ] && pass "everyday PowerShell stays allowed ($PSM cases, no false positives)" \
5690
6088
  || fail "PowerShell false positive(s):$PSFP"
6089
+
6090
+ sec "== 12b) Crewforth's own scripts sent through PowerShell go back to the Bash tool =="
6091
+ # Field session: six PowerShell attempts at `bash .claude/hooks/…`, every one an error from WSL's bash, while the same
6092
+ # commands through the Bash tool exit 0. guard-powershell.sh stops that one call (rc 2) and names the Bash tool.
6093
+ # Each case is a PAIR on one line, `positive ||| negative`: the negative differs from its positive only where the rule
6094
+ # looks, so a JSON escape that went wrong in the fixture shows up as a positive that did not block — a mis-escaped
6095
+ # negative alone would pass silently, because an unreadable payload is let through. A negative must also print NOTHING.
6096
+ GPS="$HOOKS/guard-powershell.sh"
6097
+ gpsj(){ printf '{"tool_name":"%s","tool_input":{"command":"%s"},"permission_mode":"default"}' "$1" "$2"; }
6098
+ gpsrc(){ gpsj "$1" "$2" | bash "$GPS" >/dev/null 2>&1; echo "$?"; }
6099
+ GPSN=0; GPSF=""
6100
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6101
+ _pos="${_l%% ||| *}"; _neg="${_l#* ||| }"
6102
+ _rp="$(gpsrc PowerShell "$_pos")"; _on="$(gpsj PowerShell "$_neg" | bash "$GPS" 2>&1)"; _rn=$?
6103
+ [ "$_rp" = 2 ] || GPSF="$GPSF | rc $_rp (want 2): $_pos"
6104
+ { [ "$_rn" = 0 ] && [ -z "$_on" ]; } || GPSF="$GPSF | rc $_rn${_on:+ + output} (want 0, silent): $_neg"
6105
+ GPSN=$((GPSN+1))
6106
+ done <<'GPSEOF'
6107
+ bash .claude/hooks/context-usage.sh --verbose ||| Get-Content .claude/hooks/context-usage.sh
6108
+ bash .claude/hooks/context-usage.sh --verbose 2>&1 | Select-Object -Last 5 ||| Select-String -Pattern bash -Path .claude/hooks/context-usage.sh
6109
+ & bash .claude/hooks/board.sh status ||| & mybash .claude/hooks/board.sh status
6110
+ & bash.exe .claude/hooks/board.sh status ||| & bashful .claude/hooks/board.sh status
6111
+ if (Test-Path .\\.claude\\hooks\\x.sh) { bash .claude/hooks/x.sh --verbose } ||| Test-Path .\\.claude\\hooks\\x.sh
6112
+ bash .claude/hooks/board.sh 2>&1 | Select-Object -First 15 ||| git config --get core.hooksPath
6113
+ & \"C:\\Program Files\\Git\\bin\\bash.exe\" .claude/hooks/x.sh ||| & \"C:\\Program Files\\Git\\bin\\bash.exe\" scripts/build.sh
6114
+ bash \"C:\\Users\\John Doe\\repo\\.claude\\hooks\\board.sh\" status ||| bash \"C:\\Users\\John Doe\\repo\\scripts\\board.sh\" status
6115
+ bash .claude\\hooks\\x.sh ||| Get-ChildItem .claude\\hooks
6116
+ bash ./.claude/hooks/board.sh status ||| bash ./scripts/board.sh status
6117
+ bash C:\\repo\\.claude\\hooks\\board.sh ||| bash C:\\repo\\scripts\\board.sh
6118
+ bash .claude/eval/doctor.sh ||| rg -n bash .claude/eval/
6119
+ wsl bash .claude/hooks/board.sh status ||| Write-Output \"Next: bash .claude/hooks/board.sh status\"
6120
+ cmd /c \"bash .claude\\hooks\\board.sh status\" ||| git commit -m \"docs: bash .claude/hooks/board.sh runs in the Bash tool\"
6121
+ Start-Process bash -ArgumentList '.claude/hooks/x.sh' ||| Start-Process notepad -ArgumentList '.claude/hooks/x.sh'
6122
+ bash .claude/skills/automode-policy/scripts/check.sh ||| bash .claude/skills/automode-policy/README.md
6123
+ bash .claude/studio/ensure-node.sh --explain ||| Get-Content .claude/studio/ensure-node.sh
6124
+ GPSEOF
6125
+ [ "$GPSN" -ge 17 ] || fail "FIXTURE: only $GPSN case pairs were read — the heredoc broke, not the hook"
6126
+ [ -z "$GPSF" ] && pass "PowerShell: $GPSN shapes of Crewforth's scripts refused, their $GPSN negative twins untouched and silent" \
6127
+ || fail "guard-powershell.sh:$GPSF"
6128
+ _gpe="$(gpsj PowerShell 'bash .claude/hooks/board.sh status' | bash "$GPS" 2>&1 >/dev/null)"
6129
+ case "$_gpe" in *"Bash tool"*) pass "the refusal names the Bash tool" ;; *) fail "the refusal does not name the Bash tool: ${_gpe:-<silence>}" ;; esac
6130
+ # Plugin edition: scripts named by absolute path under the plugin root, which the harness exports to hooks — in any of
6131
+ # the spellings Windows produces (native `C:\`, and Git Bash's `/c/`, which is what board.sh prints from `pwd`).
6132
+ _gpr='C:\Users\u\.claude\plugins\cache\crewforth\crewforth\3.0.1'; _gpf=""
6133
+ for _gpc in 'bash \"C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\hooks\\board.sh\" status' \
6134
+ 'bash \"/c/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/board.sh\" sync'; do
6135
+ _r1="$(gpsj PowerShell "$_gpc" | CLAUDE_PLUGIN_ROOT="$_gpr" bash "$GPS" >/dev/null 2>&1; echo "$?")"
6136
+ _r0="$(gpsj PowerShell "$_gpc" | env -u CLAUDE_PLUGIN_ROOT bash "$GPS" >/dev/null 2>&1; echo "$?")"
6137
+ [ "$_r1" = 2 ] && [ "$_r0" = 0 ] || _gpf="$_gpf | rc $_r1 with the root (want 2), rc $_r0 without (want 0): $_gpc"
6138
+ done
6139
+ [ -z "$_gpf" ] && pass "plugin edition: a script under the plugin root is refused in both Windows spellings; with no plugin root the same path passes" \
6140
+ || fail "plugin-root case:$_gpf"
6141
+ # Scope: a skill the install manifest does not list is the user's own — its scripts are not Crewforth's to redirect.
6142
+ _gpd="$(mktemp -d)"; mkdir -p "$_gpd/.claude/hooks"; cp "$GPS" "$_gpd/.claude/hooks/"; printf 'skills/automode-policy\n' > "$_gpd/.claude/kit-manifest.txt"
6143
+ _gpk="$(gpsj PowerShell 'bash .claude/skills/automode-policy/scripts/check.sh' | ( cd "$_gpd" && bash .claude/hooks/guard-powershell.sh >/dev/null 2>&1 ); echo "$?")"
6144
+ _gpu="$(gpsj PowerShell 'bash .claude/skills/my-own/scripts/run.sh' | ( cd "$_gpd" && bash .claude/hooks/guard-powershell.sh >/dev/null 2>&1 ); echo "$?")"
6145
+ rm -rf "$_gpd"
6146
+ [ "$_gpk" = 2 ] && [ "$_gpu" = 0 ] && pass "a listed skill's script is refused (rc 2), a skill the manifest does not list is the user's and passes (rc 0)" \
6147
+ || fail "manifest scope: listed skill rc $_gpk (want 2), the user's own skill rc $_gpu (want 0)"
6148
+ # Never on the Bash tool: the same command there is exactly what the docs tell the model to run.
6149
+ [ "$(gpsrc Bash 'bash .claude/hooks/board.sh status')" = 0 ] && pass "a Bash-tool payload is never refused by guard-powershell.sh" \
6150
+ || fail "guard-powershell.sh refused a Bash-tool payload — it would block the commands the docs point to"
6151
+ # A PowerShell command with no `bash` in it opens NO process: the hook runs on every PowerShell call, and on Git Bash
6152
+ # a process costs 62-135 ms. Counted from the xtrace, not timed: an external command, or a subshell (a `++` line).
6153
+ # The twin reads stdin the old way (`INPUT="$(cat)"`): a counter that cannot see that one fork measures nothing.
6154
+ _gpz(){ printf '%s' "$2" | bash -x "$1" >/dev/null 2>"$3"
6155
+ printf '%s' "$(( $(grep -cE '^\+ (grep|sed|awk|tr|cat|head|tail|cut|sort|find|wc|mktemp|basename|dirname|git)( |$)' "$3") + $(grep -c '^++' "$3") ))"; }
6156
+ _GPZ="$(mktemp -d)"; _gpzj="$(gpsj PowerShell 'Get-ChildItem -Recurse src')"
6157
+ _gpz0="$(_gpz "$GPS" "$_gpzj" "$_GPZ/t")"
6158
+ sed "s/^IFS= read -r -d '' INPUT || true\$/INPUT=\"\$(cat)\"/" "$GPS" > "$_GPZ/twin.sh"
6159
+ _gpz1="$(_gpz "$_GPZ/twin.sh" "$_gpzj" "$_GPZ/t2")"
6160
+ if ! grep -q 'read -r -d' "$_GPZ/t"; then fail "guard-powershell cost: the trace shows no stdin read — the measurement is broken, not the hook"
6161
+ elif [ "${_gpz1:-0}" -lt 1 ]; then fail "guard-powershell cost: the twin that reads with \$(cat) counted $_gpz1 — the counter sees nothing"
6162
+ elif [ "$_gpz0" = 0 ]; then pass "a PowerShell command with no 'bash' in it opens no process in guard-powershell.sh (0; the \$(cat) twin: $_gpz1)"
6163
+ else fail "guard-powershell.sh opens $_gpz0 process(es) on a PowerShell command with no 'bash' in it — budget 0"; fi
6164
+ rm -rf "$_GPZ"
6165
+ # Wired once, and only on PowerShell: a second wiring under Bash would cost a process on every Bash call. Every line
6166
+ # naming the hook is read, not the first. The plugin copy is Crewforth's only in this repository.
6167
+ gps_wired(){ awk -v h=guard-powershell.sh '/"matcher"/{m=$0; sub(/.*"matcher"[[:space:]]*:[[:space:]]*"/,"",m); sub(/".*/,"",m)} index($0,h){print m}' "$1"; }
6168
+ _gpw_f="$ROOT/settings.json"; [ "$IS_KIT" = 1 ] && _gpw_f="$_gpw_f $(cd "$ROOT/.." && pwd)/plugin/hooks/hooks.json"
6169
+ for _gf in $_gpw_f; do
6170
+ [ -f "$_gf" ] || { fail "${_gf##*/} is missing — nothing wires guard-powershell.sh"; continue; }
6171
+ _gm="$(gps_wired "$_gf" | tr '\n' ' ' | sed 's/ $//')"
6172
+ [ "$_gm" = PowerShell ] && pass "${_gf##*/}: guard-powershell.sh is wired exactly once, under the PowerShell matcher" \
6173
+ || fail "${_gf##*/}: guard-powershell.sh is wired under '${_gm:-<nothing>}' — it must be exactly one PowerShell entry"
6174
+ done
6175
+
6176
+ # Every Crewforth hook names its shell. Left to the default, Claude Code runs a hook through PowerShell on Windows when
6177
+ # it does not detect Git Bash: `bash …` still starts, but the line's bash redirections become PowerShell ones, the hook
6178
+ # exits 0 and no gate runs (measured in the field on 3.0.1-rc.1). Paired per entry: each command object whose command
6179
+ # runs a Crewforth hook must carry "shell": "bash" itself. A user's own hook or a command statusLine in the same file is
6180
+ # theirs, not counted. Prints "<crewforth hooks> <with shell bash>".
6181
+ hook_shells(){ awk '{ buf = buf $0 "\n" } END {
6182
+ while (match(buf, /\{[^{}]*\}/)) { o = substr(buf, RSTART, RLENGTH); buf = substr(buf, RSTART + RLENGTH)
6183
+ if (o !~ /"type"[[:space:]]*:[[:space:]]*"command"/) continue
6184
+ if (!index(o, ".claude/hooks/") && !(index(o, "CLAUDE_PLUGIN_ROOT") && index(o, "/hooks/"))) continue
6185
+ c++; if (o ~ /"shell"[[:space:]]*:[[:space:]]*"bash"/) b++ }
6186
+ printf "%d %d", c, b }' "$1"; }
6187
+ _hsd="$(mktemp -d)"
6188
+ printf '%s\n' '{"statusLine":{"type":"command","command":"my-status"},"hooks":{"Stop":[{"hooks":[' \
6189
+ '{"type":"command","shell":"bash","command":"bash .claude/hooks/a.sh"},{"type":"command","command":"bash .claude/hooks/b.sh"},' \
6190
+ '{"type":"command","command":"my-own-hook"}]}]}}' > "$_hsd/t.json"
6191
+ if [ "$(hook_shells "$_hsd/t.json")" != "2 1" ]; then fail "hook shell pin: the counter read '$(hook_shells "$_hsd/t.json")' on a fixture with 2 Crewforth hooks (1 with shell) beside a user hook and a statusLine — want '2 1'; the measurement is broken, not the settings"
6192
+ else for _gf in $_gpw_f; do
6193
+ [ -f "$_gf" ] || continue
6194
+ set -- $(hook_shells "$_gf")
6195
+ if [ "${1:-0}" -lt 1 ]; then fail "${_gf##*/}: no Crewforth command hook counted — the pin read nothing"
6196
+ elif [ "$1" = "$2" ]; then pass "${_gf##*/}: all $1 Crewforth command hooks run under \"shell\": \"bash\""
6197
+ else fail "${_gf##*/}: $2 of $1 Crewforth command hooks say \"shell\": \"bash\" — the rest run under PowerShell when Claude Code finds no Git Bash, and fail open"; fi
6198
+ done; fi
6199
+ rm -rf "$_hsd"
6200
+
6201
+ sec "== 12c) every command the model is told to run says: the Bash tool, not PowerShell =="
6202
+ # SCOPE, one rule (decided): Crewforth's own scripts — every `.sh` the payload ships, read from the payload itself, so a
6203
+ # script a later version adds anywhere is covered without editing this list. WHERE: every text the model reads — the
6204
+ # discipline, AGENT_TEMPLATE, agents, skills (markdown), and what the scripts and installers print (shell).
6205
+ # Markdown unit: the paragraph, ended by a blank line, a frontmatter fence, or the start of a list item; a code fence
6206
+ # belongs to the paragraph right above it. Shell unit: the line — a command quoted as TEXT (at its own `$( )` level;
6207
+ # executed or piped is code, not text), or the line that expands a variable holding one (`X='bash …'`) — the command
6208
+ # is often stored in one place and printed in another. A file that defines pass() and fail() is a test harness: its
6209
+ # strings are fixtures for other code, not output, and it is not scanned.
6210
+ # Checked in the Crewforth repository, where these texts are written: an install carries the same bytes plus the
6211
+ # user's own files, which the rule does not cover.
6212
+ if [ "$IS_KIT" != 1 ]; then
6213
+ skip scope "Bash-tool inventory runs in the Crewforth repository — an installed project also holds the user's own skills and scripts"
6214
+ else
6215
+ BT_PH='Bash tool, not PowerShell'
6216
+ BT_SET="$(cd "$ROOT" && find . -name '*.sh' -type f | sed 's#^\./##' | LC_ALL=C sort)"
6217
+ BT_CMD='bash[[:space:]]+("?\$\{?(CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_ROOT)\}?"?/|\./)?\.claude/[A-Za-z0-9_./-]+\.sh'
6218
+ BT_PLG='bash[[:space:]]+"?\$\{CLAUDE_PLUGIN_ROOT\}/[A-Za-z0-9_./-]+\.sh'
6219
+ bt_md(){ # $1 = markdown -> offending "file:line: text"; stderr = number of command lines seen
6220
+ BT_SET="$BT_SET" BT_CMD="$BT_CMD" BT_PLG="$BT_PLG" BT_PH="$BT_PH" awk -v f="$1" '
6221
+ BEGIN { ns = split(ENVIRON["BT_SET"], a, "\n"); for (i = 1; i <= ns; i++) S[a[i]] = 1 }
6222
+ function cmdline(s, r, m, hit) { hit = 0
6223
+ while (match(s, ENVIRON["BT_CMD"]) || match(s, ENVIRON["BT_PLG"])) {
6224
+ m = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
6225
+ r = m; sub(/^.*(\.claude|CLAUDE_PLUGIN_ROOT\})\//, "", r); if (r in S) hit = 1 }
6226
+ return hit }
6227
+ function flush() { if (hit != "" && index(blk, ENVIRON["BT_PH"]) == 0) printf "%s", hit; blk = ""; hit = "" }
6228
+ FNR == 1 && /^---[[:space:]]*$/ { fm = 1; next }
6229
+ fm { if (/^---[[:space:]]*$/) fm = 0; next }
6230
+ /^[[:space:]]*$/ && !fence { pend = 1; next }
6231
+ { isf = ($0 ~ /^[[:space:]]*```/)
6232
+ item = !fence && ($0 ~ /^[[:space:]]{0,3}([0-9]+\.|[-*])[[:space:]]/)
6233
+ if ((pend && !(isf && !fence)) || item) flush(); pend = 0
6234
+ if (isf) fence = !fence
6235
+ blk = blk "\n" $0
6236
+ if (cmdline($0)) { hit = hit f ":" FNR ": " $0 "\n"; n++ } }
6237
+ END { flush(); print n + 0 > "/dev/stderr" }' "$1"; }
6238
+ bt_sh(){ # $1 = shell file -> offending "file:line: text"; stderr = number of lines checked
6239
+ BT_SET="$BT_SET" BT_CMD="$BT_CMD" BT_PH="$BT_PH" awk -v f="$1" '
6240
+ BEGIN { ns = split(ENVIRON["BT_SET"], a, "\n"); for (i = 1; i <= ns; i++) S[a[i]] = 1 }
6241
+ # quoted(p): is position p of line L inside a quote at its own $( ) level?
6242
+ function quoted(L, p, i, c, d, sq, dq, ss, ds) { d = 0; sq = 0; dq = 0
6243
+ for (i = 1; i < p; i++) { c = substr(L, i, 1)
6244
+ if (sq) { if (c == "\047") sq = 0; continue }
6245
+ if (c == "\\") { i++; continue }
6246
+ if (c == "$" && substr(L, i + 1, 1) == "(") { ss[++d] = sq; ds[d] = dq; sq = 0; dq = 0; i++; continue }
6247
+ if (c == ")" && !dq && d > 0) { sq = ss[d]; dq = ds[d]; d--; continue }
6248
+ if (c == "\"") dq = !dq; else if (c == "\047" && !dq) sq = 1 }
6249
+ return sq || dq }
6250
+ /^[[:space:]]*#/ { next }
6251
+ { L = $0; s = L; off = 0; text = 0; carrier = ""
6252
+ while (match(s, ENVIRON["BT_CMD"])) {
6253
+ m = substr(s, RSTART, RLENGTH); p = off + RSTART; s = substr(s, RSTART + RLENGTH); off = p + RLENGTH - 1
6254
+ r = m; sub(/^.*\.claude\//, "", r); if (!(r in S)) continue
6255
+ if (!quoted(L, p)) continue
6256
+ pre = substr(L, 1, p - 1)
6257
+ if (match(pre, /[A-Za-z_][A-Za-z0-9_]*=["\047]$/)) { v = substr(pre, RSTART, RLENGTH - 2); C[v] = 1; carrier = v }
6258
+ else text = 1 }
6259
+ if (text) { n++; if (index(L, ENVIRON["BT_PH"]) == 0) print f ":" FNR ": " L }
6260
+ else if (carrier == "") { for (v in C) if (index(L, "$" v) || index(L, "${" v "}")) { n++; if (index(L, ENVIRON["BT_PH"]) == 0) print f ":" FNR ": " L; break } } }
6261
+ END { print n + 0 > "/dev/stderr" }' "$1"; }
6262
+ BT_MISS=""; _btn=0; _btf=0; _bte=0; _BTT="$(mktemp)"
6263
+ { for _bf in "$ROOT/CLAUDE.md" "$ROOT/AGENT_TEMPLATE.md" "$ROOT"/agents/*.md; do [ -f "$_bf" ] && printf '%s\n' "$_bf"; done
6264
+ find "$ROOT/skills" -name '*.md' -type f 2>/dev/null | LC_ALL=C sort; } > "$_BTT"
6265
+ while IFS= read -r _bf; do _btf=$((_btf+1))
6266
+ _o="$(bt_md "$_bf" 2>"$_BTT.n")"; _btn=$((_btn + $(cat "$_BTT.n")))
6267
+ [ -n "$_o" ] && BT_MISS="$BT_MISS
6268
+ $_o"
6269
+ done < "$_BTT"
6270
+ # The independent count the markdown number must equal: a plain grep of the same files for the same command shape.
6271
+ _bte="$(while IFS= read -r _bf; do grep -hoE "$BT_CMD|$BT_PLG" "$_bf" 2>/dev/null; done < "$_BTT" \
6272
+ | sed -E 's#^.*(\.claude|CLAUDE_PLUGIN_ROOT\})/##' | while IFS= read -r _r; do printf '%s\n' "$BT_SET" | grep -qxF "$_r" && echo x; done | wc -l | tr -d ' ')"
6273
+ _bts=0; _btsf=0; _btr="$(cd "$ROOT/.." && pwd)"
6274
+ for _bf in $(cd "$ROOT" && find . -name '*.sh' -type f | LC_ALL=C sort | sed "s#^\./#$ROOT/#") "$_btr/adopt.sh" "$_btr/start.sh"; do
6275
+ [ -f "$_bf" ] || continue
6276
+ grep -qE '^[[:space:]]*pass\(\)[[:space:]]*\{' "$_bf" && grep -qE '^[[:space:]]*fail\(\)[[:space:]]*\{' "$_bf" && continue # a test harness
6277
+ _btsf=$((_btsf+1)); _o="$(bt_sh "$_bf" 2>"$_BTT.n")"; _bts=$((_bts + $(cat "$_BTT.n")))
6278
+ [ -n "$_o" ] && BT_MISS="$BT_MISS
6279
+ $_o"
6280
+ done
6281
+ rm -f "$_BTT" "$_BTT.n"
6282
+ if [ "$_btf" -lt 50 ] || [ "$_btn" -lt 15 ] || [ "$_bts" -lt 8 ]; then
6283
+ fail "FIXTURE: the Bash-tool inventory read $_btf markdown file(s), $_btn markdown and $_bts shell command line(s) — the scan broke, not the text"
6284
+ elif [ "$_btn" != "$_bte" ]; then
6285
+ fail "the markdown scan saw $_btn command line(s), a plain grep of the same files finds $_bte — the scan is skipping some"
6286
+ elif [ -n "$BT_MISS" ]; then
6287
+ fail "a command the model is told to run does not say '$BT_PH' in its paragraph (markdown) or on its line (shell):$(printf '%s\n' "$BT_MISS" | sed '/^$/d' | head -n 8 | sed 's/^/\n /')"
6288
+ else
6289
+ pass "every one of Crewforth's $(printf '%s\n' "$BT_SET" | grep -c .) shipped scripts, wherever the model is told to run it, says '$BT_PH': $_btn of $_bte markdown line(s) in $_btf files, $_bts printed line(s) in $_btsf script(s)"
6290
+ fi
6291
+ # Must-fail twins, on copies: the phrase removed from one markdown paragraph, one list item beside a kept one, one
6292
+ # printed hook line, and a variable-carried command printed without it.
6293
+ _btt="$(mktemp -d)"
6294
+ sed 's/ (Bash tool, not PowerShell)//' "$ROOT/skills/crew-doctor/SKILL.md" > "$_btt/a.md"
6295
+ sed 's/ (Bash tool, not PowerShell) and show/ and show/' "$ROOT/skills/crew-update/SKILL.md" > "$_btt/b.md"
6296
+ sed 's/\(--[a-z]*-one %s\) (Bash tool, not PowerShell)/\1/g' "$HOOKS/skill-trust.sh" > "$_btt/c.sh"
6297
+ sed 's/ (Bash tool, not PowerShell — or the user can type \/crew-board sync)/ (or the user can type \/crew-board sync)/' "$HOOKS/guard-write.sh" > "$_btt/d.sh"
6298
+ _bt1="$(bt_md "$_btt/a.md" 2>/dev/null)"; _bt2="$(bt_md "$_btt/b.md" 2>/dev/null)"
6299
+ _bt3="$(bt_sh "$_btt/c.sh" 2>/dev/null)"; _bt4="$(bt_sh "$_btt/d.sh" 2>/dev/null)"
6300
+ [ -n "$_bt1" ] && [ -n "$_bt2" ] && [ -n "$_bt3" ] && [ -n "$_bt4" ] \
6301
+ && pass "the inventory catches a removed phrase in a paragraph, in one list item of several, on a printed line, and where a stored command is printed (4 twins)" \
6302
+ || fail "the inventory missed a removed phrase — paragraph:${_bt1:+ caught} list item:${_bt2:+ caught} printed:${_bt3:+ caught} stored:${_bt4:+ caught}"
6303
+ rm -rf "$_btt"
6304
+ fi
6305
+ sec "== 12d) the plugin's own gate files are guarded like the project's =="
6306
+ # The plugin edition keeps its gate scripts and its hook wiring under the plugin root, not under .claude/, and the
6307
+ # project-path rules never matched there: `rm <plugin>/hooks/guard-bash.sh` returned rc 0 while
6308
+ # `rm .claude/hooks/guard-bash.sh` returned rc 2 (measured, 3.0.1 review). Same rule now: writing, deleting or renaming
6309
+ # anything under <plugin>/hooks/ or <plugin>/.claude-plugin/ is refused — and under the same two folders of every
6310
+ # other version of this plugin in Claude Code's cache, since an older copy is one path away. Reading stays free.
6311
+ # Pairs, `positive ||| negative`: @R@ is this version's root, @O@ another cached version. The negative reads the same
6312
+ # file, so a fixture that broke shows up as a positive that passed.
6313
+ PGR='/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1'; PGO='/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.0'
6314
+ PGRW='C:\Users\u\.claude\plugins\cache\crewforth\crewforth\3.0.1'
6315
+ pgrc(){ # $1 = hook, $2 = plugin root, $3 = payload -> rc
6316
+ printf '%s' "$3" | CLAUDE_PLUGIN_ROOT="$2" bash "$HOOKS/$1" >/dev/null 2>&1; echo "$?"; }
6317
+ pgpair(){ # $1 = hook, $2 = root, $3 = payload builder, $4 = pos, $5 = neg -> appends to PGF, counts PGN
6318
+ local p n
6319
+ # An empty payload is the fixture breaking (a helper missing in this scope), never the hook: say so, do not grade.
6320
+ [ -n "$($3 "$4" 2>/dev/null)" ] || { PGF="$PGF | FIXTURE: $3 built no payload"; PGN=$((PGN+1)); return 0; }
6321
+ p="$(pgrc "$1" "$2" "$($3 "$4")")"; n="$(pgrc "$1" "$2" "$($3 "$5")")"
6322
+ [ "$p" = 2 ] || PGF="$PGF | $1 rc $p (want 2): $4"; [ "$n" = 0 ] || PGF="$PGF | $1 rc $n (want 0): $5"; PGN=$((PGN+1)); }
6323
+ # Own builders: this section also runs in an installed project's smoke (install scope), where the unit section that
6324
+ # defines the shared ones is skipped — `wj` there was "command not found" and every Write pair read as a hook failure.
6325
+ pgbash(){ printf '{"tool_name":"Bash","permission_mode":"default","tool_input":{"command":"%s"}}' "$1"; }
6326
+ pgps(){ printf '{"tool_name":"PowerShell","tool_input":{"command":"%s"},"permission_mode":"default"}' "$1"; }
6327
+ pgwr(){ printf '{"tool_name":"Write","tool_input":{"file_path":"%s"}}' "$1"; }
6328
+ PGN=0; PGF=""
6329
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6330
+ _l="${_l//@R@/$PGR}"; _l="${_l//@O@/$PGO}"
6331
+ pgpair guard-bash.sh "$PGR" pgbash "${_l%% ||| *}" "${_l#* ||| }"
6332
+ done <<'PGEOF'
6333
+ rm @R@/hooks/guard-bash.sh ||| cat @R@/hooks/guard-bash.sh
6334
+ echo x > \"@R@/hooks/guard-bash.sh\" ||| grep -n block \"@R@/hooks/guard-bash.sh\"
6335
+ mv @R@/hooks/guard-write.sh /tmp/gw ||| ls -la @R@/hooks
6336
+ cp /tmp/x @R@/hooks/hooks.json ||| wc -l @R@/hooks/hooks.json
6337
+ sed -i.bak s/2/0/ @R@/hooks/guard-bash.sh ||| sed -n 1p @R@/hooks/guard-bash.sh
6338
+ rm @R@/.claude-plugin/plugin.json ||| cat @R@/.claude-plugin/plugin.json
6339
+ rm @O@/hooks/guard-bash.sh ||| cat @O@/hooks/guard-bash.sh
6340
+ truncate -s 0 @O@/hooks/context-usage.sh ||| head -3 @O@/hooks/context-usage.sh
6341
+ ln -s @R@ cfg ||| ln -s @R@/skills/teamboard skill-link
6342
+ rm \"@R@/hooks/board.sh\" ||| bash \"@R@/hooks/board.sh\" status
6343
+ rm ~/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh ||| cat ~/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh
6344
+ echo 'exit 0' > \"$HOME/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh\" ||| cat \"$HOME/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh\"
6345
+ rm /Users/*/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh ||| ls /Users/*/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks
6346
+ rm /Users/u//.claude/plugins/cache/crewforth//./crewforth/3.0.1/hooks/guard-bash.sh ||| cat /Users/u//.claude/plugins/cache/crewforth//./crewforth/3.0.1/hooks/guard-bash.sh
6347
+ rm @R@/../3.0.1/hooks/guard-bash.sh ||| cat @R@/../3.0.1/hooks/guard-bash.sh
6348
+ rm /Users/u/.claude/plugins/cache/crewforth/crewforth/\"3.0.1\"/hooks/guard-bash.sh ||| cat /Users/u/.claude/plugins/cache/crewforth/crewforth/\"3.0.1\"/hooks/guard-bash.sh
6349
+ cd ~/.claude/plugins/cache && rm crewforth/crewforth/3.0.1/hooks/guard-bash.sh ||| cd ~/.claude/plugins/cache && cat crewforth/crewforth/3.0.1/hooks/guard-bash.sh
6350
+ ln -s ~/.claude/plugins/cache/crewforth c ||| ln -s ~/.claude/plugins/cache/crewforth/crewforth/3.0.1/skills c
6351
+ echo x > p/crewforth/crewforth/3.0.1/hooks/guard-bash.sh ||| cat p/crewforth/crewforth/3.0.1/hooks/guard-bash.sh
6352
+ echo 'exit 0' > @R@/eval/lib/crew-env.sh ||| cat @R@/eval/lib/crew-env.sh
6353
+ echo 'exit 0' > .claude/eval/lib/crew-env.sh ||| cat .claude/eval/lib/crew-env.sh
6354
+ rm /Users/b/Projects/crewforth/crewforth/3.0.1/hooks/x.sh ||| rm /Users/b/Projects/crewforth/plugin/hooks/x.sh
6355
+ PGEOF
6356
+ # Windows spellings: the root as the harness exports it (native), the command as PowerShell and Git Bash write it.
6357
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6358
+ pgpair guard-bash.sh "$PGRW" pgps "${_l%% ||| *}" "${_l#* ||| }"
6359
+ done <<'PGEOF2'
6360
+ Remove-Item C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\hooks\\guard-bash.sh ||| Get-Content C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\hooks\\guard-bash.sh
6361
+ Rename-Item C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.0\\hooks\\hooks.json x.json ||| Test-Path C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.0\\hooks\\hooks.json
6362
+ Remove-Item $env:USERPROFILE\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\hooks\\guard-bash.sh ||| Get-Content $env:USERPROFILE\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\hooks\\guard-bash.sh
6363
+ cmd /c mklink /J cfg C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1 ||| cmd /c mklink /J cfg C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\skills
6364
+ PGEOF2
6365
+ pgpair guard-bash.sh "$PGRW" pgbash 'rm /c/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh' 'cat /c/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/guard-bash.sh'
6366
+ pgpair guard-bash.sh "$PGRW" pgbash 'ln -s C:/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1 cfg' 'ln -s C:/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1/skills cfg'
6367
+ pgpair guard-bash.sh "$PGRW" pgbash 'ln -s /c/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1 cfg' 'ln -s /c/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1/skills cfg'
6368
+ # A root outside the cache (a --plugin-dir checkout) is matched as a whole path: at a token start, after a drive or
6369
+ # Git Bash's /c — not inside a longer path, and its dots are literal.
6370
+ pgpair guard-bash.sh '/opt/cr.w/plugin' pgbash 'rm /opt/cr.w/plugin/hooks/a' 'rm ~/w/opt/cr.w/plugin/hooks/a'
6371
+ pgpair guard-bash.sh '/opt/cr.w/plugin' pgbash 'rm /c/opt/cr.w/plugin/hooks/a' 'rm /opt/crXw/plugin/hooks/a'
6372
+ # The Write/Edit side: every file under the two folders, not only the gate scripts it knew by name.
6373
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6374
+ _l="${_l//@R@/$PGR}"; _l="${_l//@O@/$PGO}"
6375
+ pgpair guard-write.sh "$PGR" pgwr "${_l%% ||| *}" "${_l#* ||| }"
6376
+ done <<'PGEOF3'
6377
+ @R@/hooks/hooks.json ||| @R@/skills/teamboard/notes.md
6378
+ @R@/.claude-plugin/plugin.json ||| @R@/README.md
6379
+ @O@/hooks/context-usage.sh ||| /Users/u/project/hooks/context-usage.sh
6380
+ @R@/hooks/trace-blocklist.txt ||| /Users/u/project/.claude-plugin-notes/plugin.json
6381
+ @R@/eval/lib/crew-env.sh ||| @R@/eval/lib/skill-listing.awk
6382
+ /p/.claude/eval/lib/crew-env.sh ||| /p/.claude/eval/lib/settings-json.awk
6383
+ PGEOF3
6384
+ [ "$PGN" -ge 37 ] || fail "FIXTURE: only $PGN plugin-gate pair(s) were read — the heredocs broke, not the hooks"
6385
+ # Reading and running stay free, and another plugin in the same cache is not Crewforth's to guard.
6386
+ _pgok=""
6387
+ for _c in "rm /Users/u/.claude/plugins/cache/acme/tool/1.0/hooks/x.sh" "diff $PGO/hooks/guard-bash.sh $PGR/hooks/guard-bash.sh"; do
6388
+ [ "$(pgrc guard-bash.sh "$PGR" "$(pgbash "$_c")")" = 0 ] || _pgok="$_pgok | $_c"
6389
+ done
6390
+ [ -z "$PGF$_pgok" ] && pass "plugin gate files: $PGN writes, deletes, renames and links refused (this version, another cached version, ~/\$HOME/glob/..//. spellings, Windows spellings, a root outside the cache, the sourced crew-env.sh, Write tool); each negative twin — reading, running board.sh, a source tree, another plugin — stays free" \
6391
+ || fail "plugin gate files:$PGF${_pgok:+ | wrongly refused:$_pgok}"
5691
6392
  sec "== 13) pre-commit cost — the gate people route around is the one that is slow =="
5692
6393
  # Measured on a 373-file merge: the old file loop spawned ~7 processes per file (three `printf | grep` pairs and
5693
6394
  # a `git cat-file`), 2,643 in total. At the 62-135 ms a Git Bash process was measured to cost on a Windows 11
@@ -6060,29 +6761,47 @@ fi
6060
6761
  # The front page. Kit repo only: an installed project has no README of ours.
6061
6762
  if [ "$IS_KIT" = 1 ]; then
6062
6763
  KR="$(cd "$ROOT/.." && pwd)"
6063
- # THE FRONT PAGE'S ORDER, pinned (3.0 rewrite, Barış's decision): the definition sentence, then the panel GIF,
6064
- # then the quick start with `npx crewforth init` — and the GIF only once, since the Studio section is text.
6065
- # The npm page has no GIF; its definition still comes before the install line. Order is read by line number, so
6066
- # a page that has all three pieces in the wrong order is red, not green.
6067
- fp_order(){ # $1 file $2 definition sentence $3 1 = the GIF must sit between the two -> problems, ;-separated
6068
- awk -v d="$2" -v g="$3" '
6764
+ # THE FRONT PAGE'S ORDER, pinned (3.0 rewrite, Barış's decision): the definition sentence, then the overview
6765
+ # (the video, and under it the one line linking crewforth.com/#overview), then the quick start with
6766
+ # `npx crewforth init` — the overview link only once. The pin is the #overview link, not the video URL, so
6767
+ # swapping the video does not touch this check.
6768
+ # The npm page has no overview; its definition still comes before the install line. Order is read by line number,
6769
+ # so a page that has all three pieces in the wrong order is red, not green.
6770
+ FP_OV="https://crewforth.com/#overview"
6771
+ fp_order(){ # $1 file $2 definition sentence $3 1 = the overview must sit between the two -> problems, ;-separated
6772
+ awk -v d="$2" -v g="$3" -v o="$FP_OV" '
6069
6773
  !dl && index($0,d) {dl=NR}
6070
- index($0,"overview-poster.jpg") {if(!gl) gl=NR; gn++}
6774
+ index($0,o) {if(!gl) gl=NR; gn++}
6071
6775
  !ql && index($0,"npx crewforth init") {ql=NR}
6072
6776
  END { if(!dl) print "no definition sentence"; if(!ql) print "no npx crewforth init"
6073
6777
  if(dl && ql && ql<dl) print "quick start above the definition"
6074
- if(g==1) { if(!gl) print "no overview-poster.jpg"; else if(gn>1) print "overview-poster.jpg shown " gn " times"
6778
+ if(g==1) { if(!gl) print "no " o " link"; else if(gn>1) print o " linked " gn " times"
6075
6779
  else if(dl && ql && (gl<dl || gl>ql)) print "overview not between the definition and the quick start" } }' "$1" 2>/dev/null | tr '\n' ';'; }
6076
6780
  FP_EN="Crewforth is your engineering crew for Claude Code."; FP_TR="Crewforth, Claude Code için mühendislik ekibinizdir."
6077
6781
  _fp=""
6078
6782
  _o="$(fp_order "$KR/README.md" "$FP_EN" 1)"; [ -z "$_o" ] || _fp="$_fp README.md($_o)"
6079
6783
  _o="$(fp_order "$KR/README.tr.md" "$FP_TR" 1)"; [ -z "$_o" ] || _fp="$_fp README.tr.md($_o)"
6080
6784
  _o="$(fp_order "$KR/README.npm.md" "$FP_EN" 0)"; [ -z "$_o" ] || _fp="$_fp README.npm.md($_o)"
6081
- # Must-fail twin: the same README with the GIF moved to the end must be read as out of order.
6082
- _fpt="$(mktemp)"; grep -v 'overview-poster.jpg' "$KR/README.md" > "$_fpt"; grep 'overview-poster.jpg' "$KR/README.md" >> "$_fpt"
6785
+ # Must-fail twin: the same README with the overview link moved to the end must be read as out of order.
6786
+ _fpt="$(mktemp)"; grep -vF "$FP_OV" "$KR/README.md" > "$_fpt"; grep -F "$FP_OV" "$KR/README.md" >> "$_fpt"
6083
6787
  if [ -n "$_fp" ]; then fail "front page order is wrong:$_fp"
6084
6788
  elif [ -z "$(fp_order "$_fpt" "$FP_EN" 1)" ]; then fail "the front-page check passed a README whose overview was moved below the quick start — it reads nothing"
6085
6789
  else pass "front page: definition → overview → npx crewforth init on both GitHub READMEs (overview once), definition before install on npm; an overview moved below is caught"; fi
6790
+ # Two strings that must never reach a README: VIDEO_URL, the placeholder the video link is written over, so a
6791
+ # placeholder cannot be merged by accident; and overview-poster.jpg, the image the video replaced — the file
6792
+ # stays in assets/, so nothing else would notice it coming back. Each has a must-fail twin: the real README
6793
+ # with the string planted must be caught, or the check is reading nothing.
6794
+ fp_forbid(){ # $1 fixed string, rest files -> " name(count)" for every file that holds it
6795
+ local n="$1" f c o=""; shift
6796
+ for f in "$@"; do c="$(grep -cF -- "$n" "$f" 2>/dev/null)"; [ "${c:-0}" -gt 0 ] && o="$o ${f##*/}($c)"; done
6797
+ printf '%s' "$o"; }
6798
+ for _fs in VIDEO_URL overview-poster.jpg; do
6799
+ _hit="$(fp_forbid "$_fs" "$KR/README.md" "$KR/README.tr.md" "$KR/README.npm.md")"
6800
+ grep -vF -- "$_fs" "$KR/README.md" > "$_fpt"; printf '%s\n' "$_fs" >> "$_fpt"
6801
+ if [ -n "$_hit" ]; then fail "$_fs is in a README:$_hit — it must not be merged"
6802
+ elif [ -z "$(fp_forbid "$_fs" "$_fpt")" ]; then fail "the $_fs check passed a README with $_fs planted in it — it reads nothing"
6803
+ else pass "$_fs is in none of the 3 READMEs; one planted is caught"; fi
6804
+ done
6086
6805
  rm -f "$_fpt"
6087
6806
  # Every assets/ file a README points at exists — src=, srcset= and the npm README's absolute raw URL alike.
6088
6807
  # The count is printed: "no broken image" means nothing unless it says how many references it looked at.
@@ -6094,7 +6813,7 @@ if [ "$IS_KIT" = 1 ]; then
6094
6813
  elif [ -z "$_miss" ]; then pass "every README and site-page asset reference resolves ($_nref of $_nref distinct files exist)"
6095
6814
  else fail "README points at missing assets:$_miss"; fi
6096
6815
  else
6097
- skip scope "front-page checks skipped (installed project — the READMEs live in the Crewforth repo)" 2
6816
+ skip scope "front-page checks skipped (installed project — the READMEs live in the Crewforth repo)" 4
6098
6817
  fi
6099
6818
 
6100
6819
  sec "== 14c) the 3.0 rename left no old name behind — outside history and the code that reads the old names =="
@@ -6109,7 +6828,7 @@ if [ -n "$SGR" ] && [ -d "$SGR/packaging" ] && [ -f "$SGR/VERSION" ] && [ -d "$S
6109
6828
  RN_ALLOW='CHANGELOG.md 196 history: every entry before 3.0 keeps the name it shipped under
6110
6829
  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
6111
6830
  evals/results/* 6 history: recorded eval runs stay byte-for-byte
6112
- adopt.sh 48 migration: finds and moves 2.x names (components, CLAUDE.md, board, auto-mode rules, variables)
6831
+ 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
6113
6832
  bin/cli.js 4 migration: add accepts a typed <x>-csk and moves an add record written under the old names
6114
6833
  evals/run.sh 4 compat: reads the 2.x trusted eval parent when the 3.0 one is absent — removed in 4.0
6115
6834
  site/scripts/check.mjs 7 tests: the old-name pattern of the built-site gate, and its twins
@@ -6120,7 +6839,7 @@ site/scripts/check.mjs 7 tests: the old-name pattern of the built-site gate, and
6120
6839
  */studio/web/storage-migrate.js 4 migration: moves the panel'"'"'s saved layout to the new keys — removed in 4.0
6121
6840
  kit/eval/smoke-test.sh 36 tests: this gate'"'"'s own pattern, and that the 2.x names still work
6122
6841
  packaging/legacy-npm/* 10 the 2.x package name'"'"'s 3.0.0: a forwarder to crewforth, published once by hand
6123
- packaging/* 136 tests: the migration rehearsal on the real v2.13.0 tree, the legacy token and layout checks'
6842
+ 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'
6124
6843
  # A line may name several globs, separated by spaces, when one reason covers them all; its pin is their sum. Split
6125
6844
  # with `read -a`, never a bare `for g in $globs`, which would expand each pattern against the working directory.
6126
6845
  _rn_match(){ # $1 = a path -> the allow-list line (its glob field) that covers it, or nothing
@@ -6224,6 +6943,10 @@ $(printf '%s\n' "$_nh" | head -n 5 | sed 's/^/ /')"
6224
6943
  echo x >> "$_kd/n"
6225
6944
  case "$_l" in
6226
6945
  https://crewforth.com|https://crewforth.com/|https://crewforth.com/tr|https://crewforth.com/tr/) ;;
6946
+ # The home page with an anchor: no content page to look for, so the anchor itself must be on it.
6947
+ https://crewforth.com/\#*|https://crewforth.com/tr/\#*)
6948
+ grep -qF "id=\"${_l##*#}\"" "$KR/site/src/components/Home.astro" 2>/dev/null \
6949
+ || printf '%s: %s has no id="%s" on the home page\n' "${_f#$KR/}" "$_l" "${_l##*#}" ;;
6227
6950
  https://crewforth.com/tr/*) _pg="${_l#https://crewforth.com/tr/}"; [ -f "$KR/site/content/tr/${_pg%%[#?]*}.md" ] || printf '%s: %s has no site/content/tr page\n' "${_f#$KR/}" "$_l" ;;
6228
6951
  https://crewforth.com/*) _pg="${_l#https://crewforth.com/}"; [ -f "$KR/site/content/en/${_pg%%[#?]*}.md" ] || printf '%s: %s has no site/content/en page\n' "${_f#$KR/}" "$_l" ;;
6229
6952
  http://*|https://*|mailto:*|\#*) ;;
@@ -6231,15 +6954,15 @@ $(printf '%s\n' "$_nh" | head -n 5 | sed 's/^/ /')"
6231
6954
  esac
6232
6955
  done
6233
6956
  done; }
6234
- printf '[a](missing.md) [b](https://crewforth.com/nope) [c](https://crewforth.com/tr/gates)\n' > "$_kd/link-bad.md"
6957
+ printf '[a](missing.md) [b](https://crewforth.com/nope) [c](https://crewforth.com/tr/gates) [d](https://crewforth.com/#nope) [e](https://crewforth.com/#overview)\n' > "$_kd/link-bad.md"
6235
6958
  mkdir -p "$_kd/site/content/tr"
6236
6959
  # shellcheck disable=SC2086
6237
6960
  _lh="$(link_hits $FP_PAGES)"; _ln="$(grep -c . "$_kd/n")"
6238
6961
  if [ "$_ln" -lt 20 ]; then fail "the link check looked at only $_ln link(s) across the pages — the extractor is broken, not the pages"
6239
6962
  elif [ -n "$_lh" ]; then fail "a front page links to something that is not there:
6240
6963
  $(printf '%s\n' "$_lh" | head -n 5 | sed 's/^/ /')"
6241
- elif [ "$(link_hits "$_kd/link-bad.md" | grep -c .)" != 2 ]; then fail "the link check did not catch both planted breaks (a missing file, a crewforth.com page with no source) — it reads nothing"
6242
- else pass "every relative link and crewforth.com page on the READMEs and site pages resolves ($_ln links); two planted breaks are caught, a real site page is not"; fi
6964
+ elif [ "$(link_hits "$_kd/link-bad.md" | grep -c .)" != 3 ]; then fail "the link check did not catch exactly the three planted breaks (a missing file, a crewforth.com page with no source, a home-page anchor that is not there) — it reads nothing, or it flagged a real page or anchor"
6965
+ else pass "every relative link, crewforth.com page and home-page anchor on the READMEs and site pages resolves ($_ln links); three planted breaks are caught, a real site page and a real anchor are not"; fi
6243
6966
  rm -rf "$_kd"
6244
6967
  else
6245
6968
  skip scope "front-page name, number and link checks skipped (installed project — the READMEs live in the source repository)" 3
@@ -6287,10 +7010,17 @@ if [ "$IS_KIT" = 1 ]; then
6287
7010
  _ka="$(kw_lines $KW_A)"; _kb="$(kw_printed "$KR/start.sh" "$KR/adopt.sh" "$KR/bin/cli.js")"
6288
7011
  # C: the real output. start.sh refuses to run from its own checkout, so its help is read from a copy.
6289
7012
  _kd="$(mktemp -d)"; cp "$KR/start.sh" "$KR/VERSION" "$_kd/" 2>/dev/null
6290
- { ( cd "$_kd" && CREW_LANG=en bash start.sh --help ) ; ( cd "$_kd" && CREW_LANG=tr bash start.sh --help )
6291
- CREW_LANG=en bash "$ROOT/eval/preflight.sh"; CREW_LANG=tr bash "$ROOT/eval/preflight.sh"
6292
- command -v node >/dev/null 2>&1 && node "$KR/bin/cli.js" --help; } > "$_kd/out.txt" 2>&1
7013
+ # Each source is captured and COUNTED on its own. They used to share one file and one threshold (30 lines), and
7014
+ # `start.sh --help` in a copy without kit/ printed only its 2-line "kit/ not found" error — so the check passed on
7015
+ # the cli.js lines where node exists, and failed as a FIXTURE on a machine without node (3.0.1, PR 10): the help
7016
+ # text itself was read nowhere. Now start.sh answers --help without kit/, and each part has its own floor.
7017
+ ( cd "$_kd" && CREW_LANG=en bash start.sh --help; CREW_LANG=tr bash start.sh --help ) > "$_kd/start.txt" 2>&1; _ksr=$?
7018
+ { CREW_LANG=en bash "$ROOT/eval/preflight.sh"; CREW_LANG=tr bash "$ROOT/eval/preflight.sh"; } > "$_kd/pre.txt" 2>&1
7019
+ _kcl=""; if command -v node >/dev/null 2>&1; then node "$KR/bin/cli.js" --help > "$_kd/cli.txt" 2>&1; _kcl="$(grep -c . "$_kd/cli.txt")"
7020
+ else : > "$_kd/cli.txt"; skip tool "node bin/cli.js --help not read — no node on this machine (start.sh --help and preflight still are)"; fi
7021
+ cat "$_kd/start.txt" "$_kd/pre.txt" "$_kd/cli.txt" > "$_kd/out.txt"
6293
7022
  _kc="$(kw_lines "$_kd/out.txt" | sed "s|^$_kd/||")"; _kn="$(grep -c . "$_kd/out.txt")"
7023
+ _ksn="$(grep -c . "$_kd/start.txt")"; _kpn="$(grep -c . "$_kd/pre.txt")"
6294
7024
  # Twins. Must fail: a start.sh whose table says "full kit" again, and output that says "installing Crewforth.".
6295
7025
  # Must pass: output that only names paths — .claude/kit.conf, the folder in the archive's own error, kit-manifest.txt,
6296
7026
  # drizzle-kit, KIT_X. ("kit/ deleted" used to sit here as a path; it was the last line of every install, and the
@@ -6300,11 +7030,13 @@ if [ "$IS_KIT" = 1 ]; then
6300
7030
  printf 'wrote .claude/kit.conf\nERROR: kit/ not found\nsee .claude/kit-manifest.txt and drizzle-kit status\nKIT_X=1\n' > "$_kd/good.txt"
6301
7031
  if [ -n "$_ka$_kb$_kc" ]; then fail "a printed line still says kit — say Crewforth:
6302
7032
  $(printf '%s\n%s\n%s\n' "$_ka" "$_kb" "$_kc" | grep . | head -n 6 | sed "s|$KR/||; s|^| |")"
6303
- elif [ "$_kn" -lt 30 ]; then fail "FIXTURE: the captured help and preflight output is only $_kn line(s) — the run broke, not the wording"
7033
+ elif [ "$_ksr" != 0 ] || [ "$_ksn" -lt 20 ]; then fail "start.sh --help, run where kit/ is absent, exited $_ksr with $_ksn line(s) (EN+TR, want rc 0 and ≥20): $(head -n 2 "$_kd/start.txt" | tr '\n' ' ')"
7034
+ elif [ "$_kpn" -lt 6 ]; then fail "FIXTURE: preflight printed only $_kpn line(s) in EN+TR — the run broke, not the wording"
7035
+ elif [ -n "$_kcl" ] && [ "$_kcl" -lt 10 ]; then fail "FIXTURE: cli.js --help printed only $_kcl line(s) — the run broke, not the wording"
6304
7036
  elif [ -z "$(kw_printed "$_kd/mut-start.sh")" ]; then fail "the printed-text check missed a start.sh table entry reverted to 'full kit' — it reads nothing"
6305
7037
  elif [ "$(kw_lines "$_kd/bad.txt" | grep -c .)" != 2 ]; then fail "Crewforth-word matcher missed a planted old-name word (English or Turkish) in the output"
6306
7038
  elif [ -n "$(kw_lines "$_kd/good.txt")" ]; then fail "Crewforth-word matcher flagged a path or identifier: $(kw_lines "$_kd/good.txt" | head -n 2 | tr '\n' ' ')"
6307
- else pass "no printed line says kit: terminal-only scripts, the installers' tables/echo/usage and cli --help, plus $_kn lines of real help and preflight output (EN+TR); a reverted table entry and planted words are caught, paths are not"; fi
7039
+ else pass "no printed line says kit: terminal-only scripts, the installers' tables/echo/usage and cli --help, plus $_kn lines of real output (start.sh --help $_ksn, preflight $_kpn, cli.js ${_kcl:-not read}; EN+TR); a reverted table entry and planted words are caught, paths are not"; fi
6308
7040
  # The closing line of an install is the one line every user reads. It may name no kit at all, not even as a path:
6309
7041
  # the key and its Turkish value are both read, and a planted "kit/ deleted" in a copy must be caught.
6310
7042
  _dn(){ grep -hE '"Done\.|'"'"'Done\.' "$@" 2>/dev/null | grep -iE '(^|[^a-z_.-])kit([^a-z_-]|$)'; }