crewforth 3.0.0 → 3.0.1-rc.1

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 (41) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/VERSION +1 -1
  3. package/adopt.sh +186 -19
  4. package/kit/CLAUDE.md +5 -4
  5. package/kit/README.md +1 -1
  6. package/kit/agents/crew-frontend-expert.md +6 -6
  7. package/kit/agents/crew-session-manager.md +1 -1
  8. package/kit/eval/doctor.sh +72 -18
  9. package/kit/eval/golden-routing.txt +15 -0
  10. package/kit/eval/preflight.sh +2 -2
  11. package/kit/eval/smoke-test.sh +625 -29
  12. package/kit/hooks/board-sync.sh +1 -1
  13. package/kit/hooks/board.sh +4 -3
  14. package/kit/hooks/guard-bash.sh +195 -21
  15. package/kit/hooks/guard-commit-scan.sh +2 -2
  16. package/kit/hooks/guard-powershell.sh +328 -0
  17. package/kit/hooks/guard-write.sh +28 -3
  18. package/kit/hooks/route-hint.sh +5 -1
  19. package/kit/hooks/session-update-check.sh +2 -2
  20. package/kit/hooks/skill-trust.sh +22 -11
  21. package/kit/legacy-blobs.tsv +208 -0
  22. package/kit/settings.json +10 -0
  23. package/kit/skills/automode-policy/SKILL.md +1 -1
  24. package/kit/skills/automode-policy/scripts/check.sh +2 -2
  25. package/kit/skills/crew-board/SKILL.md +1 -1
  26. package/kit/skills/crew-doctor/SKILL.md +3 -3
  27. package/kit/skills/crew-gates/SKILL.md +2 -1
  28. package/kit/skills/crew-handoff/SKILL.md +1 -1
  29. package/kit/skills/crew-skill/SKILL.md +2 -2
  30. package/kit/skills/crew-studio/SKILL.md +3 -2
  31. package/kit/skills/crew-update/SKILL.md +3 -3
  32. package/kit/skills/frontend/SKILL.md +26 -2
  33. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  34. package/kit/skills/handoff/SKILL.md +2 -2
  35. package/kit/skills/reflect/SKILL.md +2 -2
  36. package/kit/skills/teamboard/SKILL.md +1 -1
  37. package/kit/skills/token-budget/SKILL.md +2 -2
  38. package/kit/skills/trace-scan/SKILL.md +4 -3
  39. package/kit/studio/README.md +1 -1
  40. package/package.json +5 -4
  41. 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,6 +4615,22 @@ 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"
@@ -4397,7 +4680,7 @@ uans(){ ( cd "$UPD" && bash .claude/hooks/session-update-check.sh --answer "$@"
4397
4680
  ucache 3.1.0; o="$(uc)"
4398
4681
  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
4682
  *) 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" ;;
4683
+ 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
4684
  *) fail "the question does not tell Claude how to record Later/Skip: $o" ;; esac
4402
4685
  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
4686
  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 +4909,9 @@ fi
4626
4909
  # hooks run there as on resume — the fork continues work the parent had; the update question does not — a fork
4627
4910
  # is not a new opening. Asked of the wiring (both editions) AND of the hooks themselves with a fork payload.
4628
4911
  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)"
4912
+ # The plugin copy is Crewforth's only in this repository: an installed project may have a plugin/ of its own, and
4913
+ # reading that one turned the project's smoke red (review of 3.0.1).
4914
+ FK_FILES="$ROOT/settings.json"; FKR=""; [ "$IS_KIT" = 1 ] && FKR="$(cd "$ROOT/.." && pwd)"
4630
4915
  [ -n "$FKR" ] && [ -f "$FKR/plugin/hooks/hooks.json" ] && FK_FILES="$FK_FILES $FKR/plugin/hooks/hooks.json"
4631
4916
  for _ff in $FK_FILES; do
4632
4917
  _fl="${_ff##*/}"
@@ -5157,6 +5442,8 @@ SILENT|<task-notification>Agent crew-database-expert finished: wrote the migrati
5157
5442
  SILENT|[SYSTEM NOTIFICATION] the background agent finished its migration and seed report
5158
5443
  SILENT|<cross-session-message>report: the migration and the seed are written, an endpoint was added</cross-session-message>
5159
5444
  crew-database-expert|the system notification code needs a migration for the invoices table
5445
+ 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.
5446
+ crew-database-expert|Wrote the migration and an index for the invoices table.
5160
5447
  RHCASES
5161
5448
 
5162
5449
  # --- the field name, which is the way this hook dies quietly -------------------------------------
@@ -5501,7 +5788,7 @@ if [ -f "$GR" ]; then
5501
5788
  *) fail "doctor did not report on the shell matcher" ;; esac
5502
5789
  sed 's/"Bash|PowerShell"/"Bash"/' "$DTMP/.claude/settings.json" > "$DTMP/s.tmp" && mv "$DTMP/s.tmp" "$DTMP/.claude/settings.json"
5503
5790
  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" ;;
5791
+ 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
5792
  *) fail "doctor stayed quiet on a Bash-only matcher — the gap is invisible to an upgrader" ;; esac
5506
5793
  grep -q 'command not found' "$DTMP/err" && fail "doctor.sh calls a helper before it is defined (see stderr)" \
5507
5794
  || pass "doctor.sh runs with no undefined-helper errors"
@@ -5688,6 +5975,284 @@ $PSOK
5688
5975
  PSEOF2
5689
5976
  [ -z "$PSFP" ] && pass "everyday PowerShell stays allowed ($PSM cases, no false positives)" \
5690
5977
  || fail "PowerShell false positive(s):$PSFP"
5978
+
5979
+ sec "== 12b) Crewforth's own scripts sent through PowerShell go back to the Bash tool =="
5980
+ # Field session: six PowerShell attempts at `bash .claude/hooks/…`, every one an error from WSL's bash, while the same
5981
+ # commands through the Bash tool exit 0. guard-powershell.sh stops that one call (rc 2) and names the Bash tool.
5982
+ # Each case is a PAIR on one line, `positive ||| negative`: the negative differs from its positive only where the rule
5983
+ # looks, so a JSON escape that went wrong in the fixture shows up as a positive that did not block — a mis-escaped
5984
+ # negative alone would pass silently, because an unreadable payload is let through. A negative must also print NOTHING.
5985
+ GPS="$HOOKS/guard-powershell.sh"
5986
+ gpsj(){ printf '{"tool_name":"%s","tool_input":{"command":"%s"},"permission_mode":"default"}' "$1" "$2"; }
5987
+ gpsrc(){ gpsj "$1" "$2" | bash "$GPS" >/dev/null 2>&1; echo "$?"; }
5988
+ GPSN=0; GPSF=""
5989
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
5990
+ _pos="${_l%% ||| *}"; _neg="${_l#* ||| }"
5991
+ _rp="$(gpsrc PowerShell "$_pos")"; _on="$(gpsj PowerShell "$_neg" | bash "$GPS" 2>&1)"; _rn=$?
5992
+ [ "$_rp" = 2 ] || GPSF="$GPSF | rc $_rp (want 2): $_pos"
5993
+ { [ "$_rn" = 0 ] && [ -z "$_on" ]; } || GPSF="$GPSF | rc $_rn${_on:+ + output} (want 0, silent): $_neg"
5994
+ GPSN=$((GPSN+1))
5995
+ done <<'GPSEOF'
5996
+ bash .claude/hooks/context-usage.sh --verbose ||| Get-Content .claude/hooks/context-usage.sh
5997
+ bash .claude/hooks/context-usage.sh --verbose 2>&1 | Select-Object -Last 5 ||| Select-String -Pattern bash -Path .claude/hooks/context-usage.sh
5998
+ & bash .claude/hooks/board.sh status ||| & mybash .claude/hooks/board.sh status
5999
+ & bash.exe .claude/hooks/board.sh status ||| & bashful .claude/hooks/board.sh status
6000
+ if (Test-Path .\\.claude\\hooks\\x.sh) { bash .claude/hooks/x.sh --verbose } ||| Test-Path .\\.claude\\hooks\\x.sh
6001
+ bash .claude/hooks/board.sh 2>&1 | Select-Object -First 15 ||| git config --get core.hooksPath
6002
+ & \"C:\\Program Files\\Git\\bin\\bash.exe\" .claude/hooks/x.sh ||| & \"C:\\Program Files\\Git\\bin\\bash.exe\" scripts/build.sh
6003
+ bash \"C:\\Users\\John Doe\\repo\\.claude\\hooks\\board.sh\" status ||| bash \"C:\\Users\\John Doe\\repo\\scripts\\board.sh\" status
6004
+ bash .claude\\hooks\\x.sh ||| Get-ChildItem .claude\\hooks
6005
+ bash ./.claude/hooks/board.sh status ||| bash ./scripts/board.sh status
6006
+ bash C:\\repo\\.claude\\hooks\\board.sh ||| bash C:\\repo\\scripts\\board.sh
6007
+ bash .claude/eval/doctor.sh ||| rg -n bash .claude/eval/
6008
+ wsl bash .claude/hooks/board.sh status ||| Write-Output \"Next: bash .claude/hooks/board.sh status\"
6009
+ cmd /c \"bash .claude\\hooks\\board.sh status\" ||| git commit -m \"docs: bash .claude/hooks/board.sh runs in the Bash tool\"
6010
+ Start-Process bash -ArgumentList '.claude/hooks/x.sh' ||| Start-Process notepad -ArgumentList '.claude/hooks/x.sh'
6011
+ bash .claude/skills/automode-policy/scripts/check.sh ||| bash .claude/skills/automode-policy/README.md
6012
+ bash .claude/studio/ensure-node.sh --explain ||| Get-Content .claude/studio/ensure-node.sh
6013
+ GPSEOF
6014
+ [ "$GPSN" -ge 17 ] || fail "FIXTURE: only $GPSN case pairs were read — the heredoc broke, not the hook"
6015
+ [ -z "$GPSF" ] && pass "PowerShell: $GPSN shapes of Crewforth's scripts refused, their $GPSN negative twins untouched and silent" \
6016
+ || fail "guard-powershell.sh:$GPSF"
6017
+ _gpe="$(gpsj PowerShell 'bash .claude/hooks/board.sh status' | bash "$GPS" 2>&1 >/dev/null)"
6018
+ case "$_gpe" in *"Bash tool"*) pass "the refusal names the Bash tool" ;; *) fail "the refusal does not name the Bash tool: ${_gpe:-<silence>}" ;; esac
6019
+ # Plugin edition: scripts named by absolute path under the plugin root, which the harness exports to hooks — in any of
6020
+ # the spellings Windows produces (native `C:\`, and Git Bash's `/c/`, which is what board.sh prints from `pwd`).
6021
+ _gpr='C:\Users\u\.claude\plugins\cache\crewforth\crewforth\3.0.1'; _gpf=""
6022
+ for _gpc in 'bash \"C:\\Users\\u\\.claude\\plugins\\cache\\crewforth\\crewforth\\3.0.1\\hooks\\board.sh\" status' \
6023
+ 'bash \"/c/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1/hooks/board.sh\" sync'; do
6024
+ _r1="$(gpsj PowerShell "$_gpc" | CLAUDE_PLUGIN_ROOT="$_gpr" bash "$GPS" >/dev/null 2>&1; echo "$?")"
6025
+ _r0="$(gpsj PowerShell "$_gpc" | env -u CLAUDE_PLUGIN_ROOT bash "$GPS" >/dev/null 2>&1; echo "$?")"
6026
+ [ "$_r1" = 2 ] && [ "$_r0" = 0 ] || _gpf="$_gpf | rc $_r1 with the root (want 2), rc $_r0 without (want 0): $_gpc"
6027
+ done
6028
+ [ -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" \
6029
+ || fail "plugin-root case:$_gpf"
6030
+ # Scope: a skill the install manifest does not list is the user's own — its scripts are not Crewforth's to redirect.
6031
+ _gpd="$(mktemp -d)"; mkdir -p "$_gpd/.claude/hooks"; cp "$GPS" "$_gpd/.claude/hooks/"; printf 'skills/automode-policy\n' > "$_gpd/.claude/kit-manifest.txt"
6032
+ _gpk="$(gpsj PowerShell 'bash .claude/skills/automode-policy/scripts/check.sh' | ( cd "$_gpd" && bash .claude/hooks/guard-powershell.sh >/dev/null 2>&1 ); echo "$?")"
6033
+ _gpu="$(gpsj PowerShell 'bash .claude/skills/my-own/scripts/run.sh' | ( cd "$_gpd" && bash .claude/hooks/guard-powershell.sh >/dev/null 2>&1 ); echo "$?")"
6034
+ rm -rf "$_gpd"
6035
+ [ "$_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)" \
6036
+ || fail "manifest scope: listed skill rc $_gpk (want 2), the user's own skill rc $_gpu (want 0)"
6037
+ # Never on the Bash tool: the same command there is exactly what the docs tell the model to run.
6038
+ [ "$(gpsrc Bash 'bash .claude/hooks/board.sh status')" = 0 ] && pass "a Bash-tool payload is never refused by guard-powershell.sh" \
6039
+ || fail "guard-powershell.sh refused a Bash-tool payload — it would block the commands the docs point to"
6040
+ # A PowerShell command with no `bash` in it opens NO process: the hook runs on every PowerShell call, and on Git Bash
6041
+ # a process costs 62-135 ms. Counted from the xtrace, not timed: an external command, or a subshell (a `++` line).
6042
+ # The twin reads stdin the old way (`INPUT="$(cat)"`): a counter that cannot see that one fork measures nothing.
6043
+ _gpz(){ printf '%s' "$2" | bash -x "$1" >/dev/null 2>"$3"
6044
+ printf '%s' "$(( $(grep -cE '^\+ (grep|sed|awk|tr|cat|head|tail|cut|sort|find|wc|mktemp|basename|dirname|git)( |$)' "$3") + $(grep -c '^++' "$3") ))"; }
6045
+ _GPZ="$(mktemp -d)"; _gpzj="$(gpsj PowerShell 'Get-ChildItem -Recurse src')"
6046
+ _gpz0="$(_gpz "$GPS" "$_gpzj" "$_GPZ/t")"
6047
+ sed "s/^IFS= read -r -d '' INPUT || true\$/INPUT=\"\$(cat)\"/" "$GPS" > "$_GPZ/twin.sh"
6048
+ _gpz1="$(_gpz "$_GPZ/twin.sh" "$_gpzj" "$_GPZ/t2")"
6049
+ 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"
6050
+ elif [ "${_gpz1:-0}" -lt 1 ]; then fail "guard-powershell cost: the twin that reads with \$(cat) counted $_gpz1 — the counter sees nothing"
6051
+ 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)"
6052
+ else fail "guard-powershell.sh opens $_gpz0 process(es) on a PowerShell command with no 'bash' in it — budget 0"; fi
6053
+ rm -rf "$_GPZ"
6054
+ # Wired once, and only on PowerShell: a second wiring under Bash would cost a process on every Bash call. Every line
6055
+ # naming the hook is read, not the first. The plugin copy is Crewforth's only in this repository.
6056
+ gps_wired(){ awk -v h=guard-powershell.sh '/"matcher"/{m=$0; sub(/.*"matcher"[[:space:]]*:[[:space:]]*"/,"",m); sub(/".*/,"",m)} index($0,h){print m}' "$1"; }
6057
+ _gpw_f="$ROOT/settings.json"; [ "$IS_KIT" = 1 ] && _gpw_f="$_gpw_f $(cd "$ROOT/.." && pwd)/plugin/hooks/hooks.json"
6058
+ for _gf in $_gpw_f; do
6059
+ [ -f "$_gf" ] || { fail "${_gf##*/} is missing — nothing wires guard-powershell.sh"; continue; }
6060
+ _gm="$(gps_wired "$_gf" | tr '\n' ' ' | sed 's/ $//')"
6061
+ [ "$_gm" = PowerShell ] && pass "${_gf##*/}: guard-powershell.sh is wired exactly once, under the PowerShell matcher" \
6062
+ || fail "${_gf##*/}: guard-powershell.sh is wired under '${_gm:-<nothing>}' — it must be exactly one PowerShell entry"
6063
+ done
6064
+
6065
+ sec "== 12c) every command the model is told to run says: the Bash tool, not PowerShell =="
6066
+ # SCOPE, one rule (decided): Crewforth's own scripts — every `.sh` the payload ships, read from the payload itself, so a
6067
+ # script a later version adds anywhere is covered without editing this list. WHERE: every text the model reads — the
6068
+ # discipline, AGENT_TEMPLATE, agents, skills (markdown), and what the scripts and installers print (shell).
6069
+ # Markdown unit: the paragraph, ended by a blank line, a frontmatter fence, or the start of a list item; a code fence
6070
+ # belongs to the paragraph right above it. Shell unit: the line — a command quoted as TEXT (at its own `$( )` level;
6071
+ # executed or piped is code, not text), or the line that expands a variable holding one (`X='bash …'`) — the command
6072
+ # is often stored in one place and printed in another. A file that defines pass() and fail() is a test harness: its
6073
+ # strings are fixtures for other code, not output, and it is not scanned.
6074
+ # Checked in the Crewforth repository, where these texts are written: an install carries the same bytes plus the
6075
+ # user's own files, which the rule does not cover.
6076
+ if [ "$IS_KIT" != 1 ]; then
6077
+ skip scope "Bash-tool inventory runs in the Crewforth repository — an installed project also holds the user's own skills and scripts"
6078
+ else
6079
+ BT_PH='Bash tool, not PowerShell'
6080
+ BT_SET="$(cd "$ROOT" && find . -name '*.sh' -type f | sed 's#^\./##' | LC_ALL=C sort)"
6081
+ BT_CMD='bash[[:space:]]+("?\$\{?(CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_ROOT)\}?"?/|\./)?\.claude/[A-Za-z0-9_./-]+\.sh'
6082
+ BT_PLG='bash[[:space:]]+"?\$\{CLAUDE_PLUGIN_ROOT\}/[A-Za-z0-9_./-]+\.sh'
6083
+ bt_md(){ # $1 = markdown -> offending "file:line: text"; stderr = number of command lines seen
6084
+ BT_SET="$BT_SET" BT_CMD="$BT_CMD" BT_PLG="$BT_PLG" BT_PH="$BT_PH" awk -v f="$1" '
6085
+ BEGIN { ns = split(ENVIRON["BT_SET"], a, "\n"); for (i = 1; i <= ns; i++) S[a[i]] = 1 }
6086
+ function cmdline(s, r, m, hit) { hit = 0
6087
+ while (match(s, ENVIRON["BT_CMD"]) || match(s, ENVIRON["BT_PLG"])) {
6088
+ m = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
6089
+ r = m; sub(/^.*(\.claude|CLAUDE_PLUGIN_ROOT\})\//, "", r); if (r in S) hit = 1 }
6090
+ return hit }
6091
+ function flush() { if (hit != "" && index(blk, ENVIRON["BT_PH"]) == 0) printf "%s", hit; blk = ""; hit = "" }
6092
+ FNR == 1 && /^---[[:space:]]*$/ { fm = 1; next }
6093
+ fm { if (/^---[[:space:]]*$/) fm = 0; next }
6094
+ /^[[:space:]]*$/ && !fence { pend = 1; next }
6095
+ { isf = ($0 ~ /^[[:space:]]*```/)
6096
+ item = !fence && ($0 ~ /^[[:space:]]{0,3}([0-9]+\.|[-*])[[:space:]]/)
6097
+ if ((pend && !(isf && !fence)) || item) flush(); pend = 0
6098
+ if (isf) fence = !fence
6099
+ blk = blk "\n" $0
6100
+ if (cmdline($0)) { hit = hit f ":" FNR ": " $0 "\n"; n++ } }
6101
+ END { flush(); print n + 0 > "/dev/stderr" }' "$1"; }
6102
+ bt_sh(){ # $1 = shell file -> offending "file:line: text"; stderr = number of lines checked
6103
+ BT_SET="$BT_SET" BT_CMD="$BT_CMD" BT_PH="$BT_PH" awk -v f="$1" '
6104
+ BEGIN { ns = split(ENVIRON["BT_SET"], a, "\n"); for (i = 1; i <= ns; i++) S[a[i]] = 1 }
6105
+ # quoted(p): is position p of line L inside a quote at its own $( ) level?
6106
+ function quoted(L, p, i, c, d, sq, dq, ss, ds) { d = 0; sq = 0; dq = 0
6107
+ for (i = 1; i < p; i++) { c = substr(L, i, 1)
6108
+ if (sq) { if (c == "\047") sq = 0; continue }
6109
+ if (c == "\\") { i++; continue }
6110
+ if (c == "$" && substr(L, i + 1, 1) == "(") { ss[++d] = sq; ds[d] = dq; sq = 0; dq = 0; i++; continue }
6111
+ if (c == ")" && !dq && d > 0) { sq = ss[d]; dq = ds[d]; d--; continue }
6112
+ if (c == "\"") dq = !dq; else if (c == "\047" && !dq) sq = 1 }
6113
+ return sq || dq }
6114
+ /^[[:space:]]*#/ { next }
6115
+ { L = $0; s = L; off = 0; text = 0; carrier = ""
6116
+ while (match(s, ENVIRON["BT_CMD"])) {
6117
+ m = substr(s, RSTART, RLENGTH); p = off + RSTART; s = substr(s, RSTART + RLENGTH); off = p + RLENGTH - 1
6118
+ r = m; sub(/^.*\.claude\//, "", r); if (!(r in S)) continue
6119
+ if (!quoted(L, p)) continue
6120
+ pre = substr(L, 1, p - 1)
6121
+ if (match(pre, /[A-Za-z_][A-Za-z0-9_]*=["\047]$/)) { v = substr(pre, RSTART, RLENGTH - 2); C[v] = 1; carrier = v }
6122
+ else text = 1 }
6123
+ if (text) { n++; if (index(L, ENVIRON["BT_PH"]) == 0) print f ":" FNR ": " L }
6124
+ 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 } } }
6125
+ END { print n + 0 > "/dev/stderr" }' "$1"; }
6126
+ BT_MISS=""; _btn=0; _btf=0; _bte=0; _BTT="$(mktemp)"
6127
+ { for _bf in "$ROOT/CLAUDE.md" "$ROOT/AGENT_TEMPLATE.md" "$ROOT"/agents/*.md; do [ -f "$_bf" ] && printf '%s\n' "$_bf"; done
6128
+ find "$ROOT/skills" -name '*.md' -type f 2>/dev/null | LC_ALL=C sort; } > "$_BTT"
6129
+ while IFS= read -r _bf; do _btf=$((_btf+1))
6130
+ _o="$(bt_md "$_bf" 2>"$_BTT.n")"; _btn=$((_btn + $(cat "$_BTT.n")))
6131
+ [ -n "$_o" ] && BT_MISS="$BT_MISS
6132
+ $_o"
6133
+ done < "$_BTT"
6134
+ # The independent count the markdown number must equal: a plain grep of the same files for the same command shape.
6135
+ _bte="$(while IFS= read -r _bf; do grep -hoE "$BT_CMD|$BT_PLG" "$_bf" 2>/dev/null; done < "$_BTT" \
6136
+ | 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 ' ')"
6137
+ _bts=0; _btsf=0; _btr="$(cd "$ROOT/.." && pwd)"
6138
+ for _bf in $(cd "$ROOT" && find . -name '*.sh' -type f | LC_ALL=C sort | sed "s#^\./#$ROOT/#") "$_btr/adopt.sh" "$_btr/start.sh"; do
6139
+ [ -f "$_bf" ] || continue
6140
+ grep -qE '^[[:space:]]*pass\(\)[[:space:]]*\{' "$_bf" && grep -qE '^[[:space:]]*fail\(\)[[:space:]]*\{' "$_bf" && continue # a test harness
6141
+ _btsf=$((_btsf+1)); _o="$(bt_sh "$_bf" 2>"$_BTT.n")"; _bts=$((_bts + $(cat "$_BTT.n")))
6142
+ [ -n "$_o" ] && BT_MISS="$BT_MISS
6143
+ $_o"
6144
+ done
6145
+ rm -f "$_BTT" "$_BTT.n"
6146
+ if [ "$_btf" -lt 50 ] || [ "$_btn" -lt 15 ] || [ "$_bts" -lt 8 ]; then
6147
+ 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"
6148
+ elif [ "$_btn" != "$_bte" ]; then
6149
+ fail "the markdown scan saw $_btn command line(s), a plain grep of the same files finds $_bte — the scan is skipping some"
6150
+ elif [ -n "$BT_MISS" ]; then
6151
+ 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 /')"
6152
+ else
6153
+ 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)"
6154
+ fi
6155
+ # Must-fail twins, on copies: the phrase removed from one markdown paragraph, one list item beside a kept one, one
6156
+ # printed hook line, and a variable-carried command printed without it.
6157
+ _btt="$(mktemp -d)"
6158
+ sed 's/ (Bash tool, not PowerShell)//' "$ROOT/skills/crew-doctor/SKILL.md" > "$_btt/a.md"
6159
+ sed 's/ (Bash tool, not PowerShell) and show/ and show/' "$ROOT/skills/crew-update/SKILL.md" > "$_btt/b.md"
6160
+ sed 's/--trust-one %s (Bash tool, not PowerShell)/--trust-one %s/' "$HOOKS/skill-trust.sh" > "$_btt/c.sh"
6161
+ 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"
6162
+ _bt1="$(bt_md "$_btt/a.md" 2>/dev/null)"; _bt2="$(bt_md "$_btt/b.md" 2>/dev/null)"
6163
+ _bt3="$(bt_sh "$_btt/c.sh" 2>/dev/null)"; _bt4="$(bt_sh "$_btt/d.sh" 2>/dev/null)"
6164
+ [ -n "$_bt1" ] && [ -n "$_bt2" ] && [ -n "$_bt3" ] && [ -n "$_bt4" ] \
6165
+ && 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)" \
6166
+ || fail "the inventory missed a removed phrase — paragraph:${_bt1:+ caught} list item:${_bt2:+ caught} printed:${_bt3:+ caught} stored:${_bt4:+ caught}"
6167
+ rm -rf "$_btt"
6168
+ fi
6169
+ sec "== 12d) the plugin's own gate files are guarded like the project's =="
6170
+ # The plugin edition keeps its gate scripts and its hook wiring under the plugin root, not under .claude/, and the
6171
+ # project-path rules never matched there: `rm <plugin>/hooks/guard-bash.sh` returned rc 0 while
6172
+ # `rm .claude/hooks/guard-bash.sh` returned rc 2 (measured, 3.0.1 review). Same rule now: writing, deleting or renaming
6173
+ # anything under <plugin>/hooks/ or <plugin>/.claude-plugin/ is refused — and under the same two folders of every
6174
+ # other version of this plugin in Claude Code's cache, since an older copy is one path away. Reading stays free.
6175
+ # Pairs, `positive ||| negative`: @R@ is this version's root, @O@ another cached version. The negative reads the same
6176
+ # file, so a fixture that broke shows up as a positive that passed.
6177
+ PGR='/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.1'; PGO='/Users/u/.claude/plugins/cache/crewforth/crewforth/3.0.0'
6178
+ PGRW='C:\Users\u\.claude\plugins\cache\crewforth\crewforth\3.0.1'
6179
+ pgrc(){ # $1 = hook, $2 = plugin root, $3 = payload -> rc
6180
+ printf '%s' "$3" | CLAUDE_PLUGIN_ROOT="$2" bash "$HOOKS/$1" >/dev/null 2>&1; echo "$?"; }
6181
+ pgpair(){ # $1 = hook, $2 = root, $3 = payload builder, $4 = pos, $5 = neg -> appends to PGF, counts PGN
6182
+ local p n
6183
+ # An empty payload is the fixture breaking (a helper missing in this scope), never the hook: say so, do not grade.
6184
+ [ -n "$($3 "$4" 2>/dev/null)" ] || { PGF="$PGF | FIXTURE: $3 built no payload"; PGN=$((PGN+1)); return 0; }
6185
+ p="$(pgrc "$1" "$2" "$($3 "$4")")"; n="$(pgrc "$1" "$2" "$($3 "$5")")"
6186
+ [ "$p" = 2 ] || PGF="$PGF | $1 rc $p (want 2): $4"; [ "$n" = 0 ] || PGF="$PGF | $1 rc $n (want 0): $5"; PGN=$((PGN+1)); }
6187
+ # Own builders: this section also runs in an installed project's smoke (install scope), where the unit section that
6188
+ # defines the shared ones is skipped — `wj` there was "command not found" and every Write pair read as a hook failure.
6189
+ pgbash(){ printf '{"tool_name":"Bash","permission_mode":"default","tool_input":{"command":"%s"}}' "$1"; }
6190
+ pgps(){ printf '{"tool_name":"PowerShell","tool_input":{"command":"%s"},"permission_mode":"default"}' "$1"; }
6191
+ pgwr(){ printf '{"tool_name":"Write","tool_input":{"file_path":"%s"}}' "$1"; }
6192
+ PGN=0; PGF=""
6193
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6194
+ _l="${_l//@R@/$PGR}"; _l="${_l//@O@/$PGO}"
6195
+ pgpair guard-bash.sh "$PGR" pgbash "${_l%% ||| *}" "${_l#* ||| }"
6196
+ done <<'PGEOF'
6197
+ rm @R@/hooks/guard-bash.sh ||| cat @R@/hooks/guard-bash.sh
6198
+ echo x > \"@R@/hooks/guard-bash.sh\" ||| grep -n block \"@R@/hooks/guard-bash.sh\"
6199
+ mv @R@/hooks/guard-write.sh /tmp/gw ||| ls -la @R@/hooks
6200
+ cp /tmp/x @R@/hooks/hooks.json ||| wc -l @R@/hooks/hooks.json
6201
+ sed -i.bak s/2/0/ @R@/hooks/guard-bash.sh ||| sed -n 1p @R@/hooks/guard-bash.sh
6202
+ rm @R@/.claude-plugin/plugin.json ||| cat @R@/.claude-plugin/plugin.json
6203
+ rm @O@/hooks/guard-bash.sh ||| cat @O@/hooks/guard-bash.sh
6204
+ truncate -s 0 @O@/hooks/context-usage.sh ||| head -3 @O@/hooks/context-usage.sh
6205
+ ln -s @R@ cfg ||| ln -s @R@/skills/teamboard skill-link
6206
+ rm \"@R@/hooks/board.sh\" ||| bash \"@R@/hooks/board.sh\" status
6207
+ 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
6208
+ 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\"
6209
+ 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
6210
+ 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
6211
+ rm @R@/../3.0.1/hooks/guard-bash.sh ||| cat @R@/../3.0.1/hooks/guard-bash.sh
6212
+ 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
6213
+ 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
6214
+ ln -s ~/.claude/plugins/cache/crewforth c ||| ln -s ~/.claude/plugins/cache/crewforth/crewforth/3.0.1/skills c
6215
+ echo x > p/crewforth/crewforth/3.0.1/hooks/guard-bash.sh ||| cat p/crewforth/crewforth/3.0.1/hooks/guard-bash.sh
6216
+ echo 'exit 0' > @R@/eval/lib/crew-env.sh ||| cat @R@/eval/lib/crew-env.sh
6217
+ echo 'exit 0' > .claude/eval/lib/crew-env.sh ||| cat .claude/eval/lib/crew-env.sh
6218
+ rm /Users/b/Projects/crewforth/crewforth/3.0.1/hooks/x.sh ||| rm /Users/b/Projects/crewforth/plugin/hooks/x.sh
6219
+ PGEOF
6220
+ # Windows spellings: the root as the harness exports it (native), the command as PowerShell and Git Bash write it.
6221
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6222
+ pgpair guard-bash.sh "$PGRW" pgps "${_l%% ||| *}" "${_l#* ||| }"
6223
+ done <<'PGEOF2'
6224
+ 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
6225
+ 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
6226
+ 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
6227
+ 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
6228
+ PGEOF2
6229
+ 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'
6230
+ 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'
6231
+ 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'
6232
+ # A root outside the cache (a --plugin-dir checkout) is matched as a whole path: at a token start, after a drive or
6233
+ # Git Bash's /c — not inside a longer path, and its dots are literal.
6234
+ pgpair guard-bash.sh '/opt/cr.w/plugin' pgbash 'rm /opt/cr.w/plugin/hooks/a' 'rm ~/w/opt/cr.w/plugin/hooks/a'
6235
+ pgpair guard-bash.sh '/opt/cr.w/plugin' pgbash 'rm /c/opt/cr.w/plugin/hooks/a' 'rm /opt/crXw/plugin/hooks/a'
6236
+ # The Write/Edit side: every file under the two folders, not only the gate scripts it knew by name.
6237
+ while IFS= read -r _l; do [ -z "$_l" ] && continue
6238
+ _l="${_l//@R@/$PGR}"; _l="${_l//@O@/$PGO}"
6239
+ pgpair guard-write.sh "$PGR" pgwr "${_l%% ||| *}" "${_l#* ||| }"
6240
+ done <<'PGEOF3'
6241
+ @R@/hooks/hooks.json ||| @R@/skills/teamboard/notes.md
6242
+ @R@/.claude-plugin/plugin.json ||| @R@/README.md
6243
+ @O@/hooks/context-usage.sh ||| /Users/u/project/hooks/context-usage.sh
6244
+ @R@/hooks/trace-blocklist.txt ||| /Users/u/project/.claude-plugin-notes/plugin.json
6245
+ @R@/eval/lib/crew-env.sh ||| @R@/eval/lib/skill-listing.awk
6246
+ /p/.claude/eval/lib/crew-env.sh ||| /p/.claude/eval/lib/settings-json.awk
6247
+ PGEOF3
6248
+ [ "$PGN" -ge 37 ] || fail "FIXTURE: only $PGN plugin-gate pair(s) were read — the heredocs broke, not the hooks"
6249
+ # Reading and running stay free, and another plugin in the same cache is not Crewforth's to guard.
6250
+ _pgok=""
6251
+ 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
6252
+ [ "$(pgrc guard-bash.sh "$PGR" "$(pgbash "$_c")")" = 0 ] || _pgok="$_pgok | $_c"
6253
+ done
6254
+ [ -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" \
6255
+ || fail "plugin gate files:$PGF${_pgok:+ | wrongly refused:$_pgok}"
5691
6256
  sec "== 13) pre-commit cost — the gate people route around is the one that is slow =="
5692
6257
  # Measured on a 373-file merge: the old file loop spawned ~7 processes per file (three `printf | grep` pairs and
5693
6258
  # 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 +6625,47 @@ fi
6060
6625
  # The front page. Kit repo only: an installed project has no README of ours.
6061
6626
  if [ "$IS_KIT" = 1 ]; then
6062
6627
  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" '
6628
+ # THE FRONT PAGE'S ORDER, pinned (3.0 rewrite, Barış's decision): the definition sentence, then the overview
6629
+ # (the video, and under it the one line linking crewforth.com/#overview), then the quick start with
6630
+ # `npx crewforth init` — the overview link only once. The pin is the #overview link, not the video URL, so
6631
+ # swapping the video does not touch this check.
6632
+ # The npm page has no overview; its definition still comes before the install line. Order is read by line number,
6633
+ # so a page that has all three pieces in the wrong order is red, not green.
6634
+ FP_OV="https://crewforth.com/#overview"
6635
+ fp_order(){ # $1 file $2 definition sentence $3 1 = the overview must sit between the two -> problems, ;-separated
6636
+ awk -v d="$2" -v g="$3" -v o="$FP_OV" '
6069
6637
  !dl && index($0,d) {dl=NR}
6070
- index($0,"overview-poster.jpg") {if(!gl) gl=NR; gn++}
6638
+ index($0,o) {if(!gl) gl=NR; gn++}
6071
6639
  !ql && index($0,"npx crewforth init") {ql=NR}
6072
6640
  END { if(!dl) print "no definition sentence"; if(!ql) print "no npx crewforth init"
6073
6641
  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"
6642
+ if(g==1) { if(!gl) print "no " o " link"; else if(gn>1) print o " linked " gn " times"
6075
6643
  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
6644
  FP_EN="Crewforth is your engineering crew for Claude Code."; FP_TR="Crewforth, Claude Code için mühendislik ekibinizdir."
6077
6645
  _fp=""
6078
6646
  _o="$(fp_order "$KR/README.md" "$FP_EN" 1)"; [ -z "$_o" ] || _fp="$_fp README.md($_o)"
6079
6647
  _o="$(fp_order "$KR/README.tr.md" "$FP_TR" 1)"; [ -z "$_o" ] || _fp="$_fp README.tr.md($_o)"
6080
6648
  _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"
6649
+ # Must-fail twin: the same README with the overview link moved to the end must be read as out of order.
6650
+ _fpt="$(mktemp)"; grep -vF "$FP_OV" "$KR/README.md" > "$_fpt"; grep -F "$FP_OV" "$KR/README.md" >> "$_fpt"
6083
6651
  if [ -n "$_fp" ]; then fail "front page order is wrong:$_fp"
6084
6652
  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
6653
  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
6654
+ # Two strings that must never reach a README: VIDEO_URL, the placeholder the video link is written over, so a
6655
+ # placeholder cannot be merged by accident; and overview-poster.jpg, the image the video replaced — the file
6656
+ # stays in assets/, so nothing else would notice it coming back. Each has a must-fail twin: the real README
6657
+ # with the string planted must be caught, or the check is reading nothing.
6658
+ fp_forbid(){ # $1 fixed string, rest files -> " name(count)" for every file that holds it
6659
+ local n="$1" f c o=""; shift
6660
+ for f in "$@"; do c="$(grep -cF -- "$n" "$f" 2>/dev/null)"; [ "${c:-0}" -gt 0 ] && o="$o ${f##*/}($c)"; done
6661
+ printf '%s' "$o"; }
6662
+ for _fs in VIDEO_URL overview-poster.jpg; do
6663
+ _hit="$(fp_forbid "$_fs" "$KR/README.md" "$KR/README.tr.md" "$KR/README.npm.md")"
6664
+ grep -vF -- "$_fs" "$KR/README.md" > "$_fpt"; printf '%s\n' "$_fs" >> "$_fpt"
6665
+ if [ -n "$_hit" ]; then fail "$_fs is in a README:$_hit — it must not be merged"
6666
+ elif [ -z "$(fp_forbid "$_fs" "$_fpt")" ]; then fail "the $_fs check passed a README with $_fs planted in it — it reads nothing"
6667
+ else pass "$_fs is in none of the 3 READMEs; one planted is caught"; fi
6668
+ done
6086
6669
  rm -f "$_fpt"
6087
6670
  # Every assets/ file a README points at exists — src=, srcset= and the npm README's absolute raw URL alike.
6088
6671
  # The count is printed: "no broken image" means nothing unless it says how many references it looked at.
@@ -6094,7 +6677,7 @@ if [ "$IS_KIT" = 1 ]; then
6094
6677
  elif [ -z "$_miss" ]; then pass "every README and site-page asset reference resolves ($_nref of $_nref distinct files exist)"
6095
6678
  else fail "README points at missing assets:$_miss"; fi
6096
6679
  else
6097
- skip scope "front-page checks skipped (installed project — the READMEs live in the Crewforth repo)" 2
6680
+ skip scope "front-page checks skipped (installed project — the READMEs live in the Crewforth repo)" 4
6098
6681
  fi
6099
6682
 
6100
6683
  sec "== 14c) the 3.0 rename left no old name behind — outside history and the code that reads the old names =="
@@ -6109,7 +6692,7 @@ if [ -n "$SGR" ] && [ -d "$SGR/packaging" ] && [ -f "$SGR/VERSION" ] && [ -d "$S
6109
6692
  RN_ALLOW='CHANGELOG.md 196 history: every entry before 3.0 keeps the name it shipped under
6110
6693
  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
6694
  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)
6695
+ 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
6696
  bin/cli.js 4 migration: add accepts a typed <x>-csk and moves an add record written under the old names
6114
6697
  evals/run.sh 4 compat: reads the 2.x trusted eval parent when the 3.0 one is absent — removed in 4.0
6115
6698
  site/scripts/check.mjs 7 tests: the old-name pattern of the built-site gate, and its twins
@@ -6120,7 +6703,7 @@ site/scripts/check.mjs 7 tests: the old-name pattern of the built-site gate, and
6120
6703
  */studio/web/storage-migrate.js 4 migration: moves the panel'"'"'s saved layout to the new keys — removed in 4.0
6121
6704
  kit/eval/smoke-test.sh 36 tests: this gate'"'"'s own pattern, and that the 2.x names still work
6122
6705
  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'
6706
+ 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
6707
  # A line may name several globs, separated by spaces, when one reason covers them all; its pin is their sum. Split
6125
6708
  # with `read -a`, never a bare `for g in $globs`, which would expand each pattern against the working directory.
6126
6709
  _rn_match(){ # $1 = a path -> the allow-list line (its glob field) that covers it, or nothing
@@ -6224,6 +6807,10 @@ $(printf '%s\n' "$_nh" | head -n 5 | sed 's/^/ /')"
6224
6807
  echo x >> "$_kd/n"
6225
6808
  case "$_l" in
6226
6809
  https://crewforth.com|https://crewforth.com/|https://crewforth.com/tr|https://crewforth.com/tr/) ;;
6810
+ # The home page with an anchor: no content page to look for, so the anchor itself must be on it.
6811
+ https://crewforth.com/\#*|https://crewforth.com/tr/\#*)
6812
+ grep -qF "id=\"${_l##*#}\"" "$KR/site/src/components/Home.astro" 2>/dev/null \
6813
+ || printf '%s: %s has no id="%s" on the home page\n' "${_f#$KR/}" "$_l" "${_l##*#}" ;;
6227
6814
  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
6815
  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
6816
  http://*|https://*|mailto:*|\#*) ;;
@@ -6231,15 +6818,15 @@ $(printf '%s\n' "$_nh" | head -n 5 | sed 's/^/ /')"
6231
6818
  esac
6232
6819
  done
6233
6820
  done; }
6234
- printf '[a](missing.md) [b](https://crewforth.com/nope) [c](https://crewforth.com/tr/gates)\n' > "$_kd/link-bad.md"
6821
+ 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
6822
  mkdir -p "$_kd/site/content/tr"
6236
6823
  # shellcheck disable=SC2086
6237
6824
  _lh="$(link_hits $FP_PAGES)"; _ln="$(grep -c . "$_kd/n")"
6238
6825
  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
6826
  elif [ -n "$_lh" ]; then fail "a front page links to something that is not there:
6240
6827
  $(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
6828
+ 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"
6829
+ 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
6830
  rm -rf "$_kd"
6244
6831
  else
6245
6832
  skip scope "front-page name, number and link checks skipped (installed project — the READMEs live in the source repository)" 3
@@ -6287,10 +6874,17 @@ if [ "$IS_KIT" = 1 ]; then
6287
6874
  _ka="$(kw_lines $KW_A)"; _kb="$(kw_printed "$KR/start.sh" "$KR/adopt.sh" "$KR/bin/cli.js")"
6288
6875
  # C: the real output. start.sh refuses to run from its own checkout, so its help is read from a copy.
6289
6876
  _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
6877
+ # Each source is captured and COUNTED on its own. They used to share one file and one threshold (30 lines), and
6878
+ # `start.sh --help` in a copy without kit/ printed only its 2-line "kit/ not found" error — so the check passed on
6879
+ # the cli.js lines where node exists, and failed as a FIXTURE on a machine without node (3.0.1, PR 10): the help
6880
+ # text itself was read nowhere. Now start.sh answers --help without kit/, and each part has its own floor.
6881
+ ( cd "$_kd" && CREW_LANG=en bash start.sh --help; CREW_LANG=tr bash start.sh --help ) > "$_kd/start.txt" 2>&1; _ksr=$?
6882
+ { CREW_LANG=en bash "$ROOT/eval/preflight.sh"; CREW_LANG=tr bash "$ROOT/eval/preflight.sh"; } > "$_kd/pre.txt" 2>&1
6883
+ _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")"
6884
+ 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
6885
+ cat "$_kd/start.txt" "$_kd/pre.txt" "$_kd/cli.txt" > "$_kd/out.txt"
6293
6886
  _kc="$(kw_lines "$_kd/out.txt" | sed "s|^$_kd/||")"; _kn="$(grep -c . "$_kd/out.txt")"
6887
+ _ksn="$(grep -c . "$_kd/start.txt")"; _kpn="$(grep -c . "$_kd/pre.txt")"
6294
6888
  # Twins. Must fail: a start.sh whose table says "full kit" again, and output that says "installing Crewforth.".
6295
6889
  # Must pass: output that only names paths — .claude/kit.conf, the folder in the archive's own error, kit-manifest.txt,
6296
6890
  # 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 +6894,13 @@ if [ "$IS_KIT" = 1 ]; then
6300
6894
  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
6895
  if [ -n "$_ka$_kb$_kc" ]; then fail "a printed line still says kit — say Crewforth:
6302
6896
  $(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"
6897
+ 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' ' ')"
6898
+ elif [ "$_kpn" -lt 6 ]; then fail "FIXTURE: preflight printed only $_kpn line(s) in EN+TR — the run broke, not the wording"
6899
+ elif [ -n "$_kcl" ] && [ "$_kcl" -lt 10 ]; then fail "FIXTURE: cli.js --help printed only $_kcl line(s) — the run broke, not the wording"
6304
6900
  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
6901
  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
6902
  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
6903
+ 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
6904
  # 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
6905
  # the key and its Turkish value are both read, and a planted "kit/ deleted" in a copy must be caught.
6310
6906
  _dn(){ grep -hE '"Done\.|'"'"'Done\.' "$@" 2>/dev/null | grep -iE '(^|[^a-z_.-])kit([^a-z_-]|$)'; }