crewforth 3.2.0-rc.9 → 3.2.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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,33 @@ Crewforth was named Claude Starter Kit until 3.0.0.
5
5
  Notable changes to this project are recorded here. Format follows [Keep a Changelog](https://keepachangelog.com/en/),
6
6
  versioning follows [SemVer](https://semver.org/).
7
7
 
8
- ## [Unreleased] — 3.2.0
8
+ ## [3.2.1] — 2026-10-22
9
+
10
+ ### Added
11
+
12
+ - **Doctor and Studio say when Node is missing a published security fix.** Node's October 2026 security releases fix,
13
+ among others, a Brotli decoder write that a crafted HTTP response can reach: Studio's `fetch` decodes a `br`
14
+ response even though it does not ask for one (measured), so a Studio with `--peer` over plain HTTP on a network
15
+ someone else can answer on was exposed on an unpatched Node. `/crew-doctor` and Studio's start now read one table
16
+ of fixed releases (22.23.4, 24.22.1, 26.12.1) and warn below them, or on a line that gets no fixes (20.x); neither
17
+ refuses anything. `ensure-node.sh`'s fallback, used only when nodejs.org cannot be asked for the current LTS, moves
18
+ from 24.21.0 to 24.22.1.
19
+
20
+ ### Fixed
21
+
22
+ - **Studio says what the model gate does to an agent that is not crew-\*.** Its inspector said the gate chose nothing
23
+ for such an agent and that it ran on the model its call asked for, while the same panel showed it had asked for opus
24
+ and run on sonnet. The gate does hold the expensive end there: a call that asks for opus or fable, or names no model
25
+ and would take the session's, runs on sonnet unless its task carries an `above: <why>` line. The inspector and the
26
+ Studio page now say that.
27
+ - **Critical work sent to an agent that is not crew-\* is no longer moved to a cheaper model.** Outside the crew the
28
+ model gate holds the expensive end: opus or fable, or a session model the call would inherit, runs on sonnet unless
29
+ the task carries `above: <why>`. It did that even when the task opened with a card naming critical work (`change:
30
+ fix-unknown`, `security`, `migration`, `architecture`, or a file on a critical path), which a crew agent would run on
31
+ opus at least, so going round the crew was the cheaper way. Such a call now keeps the model it asked for; nothing is
32
+ raised. An `above:` at the end of a one-line card is now read there too, and Studio's inspector and page say so.
33
+
34
+ ## [3.2.0] — 2026-10-21
9
35
 
10
36
  ### Added
11
37
 
@@ -233,6 +259,16 @@ versioning follows [SemVer](https://semver.org/).
233
259
  - **`change: review` is read as `audit`.** The model table of the working rules names review with audit, and
234
260
  `crew-review-agent` cards that said `change: review` were refused. It now takes the audit floor, on a critical path
235
261
  too; a kind that is not on the list is still refused.
262
+ - **An install or an update never loosens the trace gate by itself.** The installer suggested exempting
263
+ the co-author trailer whenever the last 80 commits carried one, and with no terminal to ask in it took its own
264
+ suggestion: an update wrote the line into `.trace-allowlist.txt` and staged it, undoing a removal the team had
265
+ committed. The suggestion is now always to keep the gate, and the exemption is written only when you type
266
+ `loosen` in the interactive review.
267
+ - **A commit refusal names a trace in the message even when the staged content also fails.** The content gate
268
+ scanned the `-m` message only when the staged change was clean, so a refusal for a silenced lint check said nothing about
269
+ the session-link trailer in the same message, and the session showed the user the traced message again. Both are
270
+ named in one refusal now, and a trace in the message adds that a harness reminder to end a message with a
271
+ trailer does not override §4.1. The git `commit-msg` hook still stops the trailer at the commit itself.
236
272
  - **A one-line task card may open with `Kart`.** Sessions that write in Turkish lead the card with `Kart —` as
237
273
  others write `Card —`, and the call was refused as having no card. The Turkish word is read like the English one;
238
274
  the fields after it are held to the same rules.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.2.0-rc.9
1
+ 3.2.1
package/adopt.sh CHANGED
@@ -199,8 +199,7 @@ _mt() { # $1 = English text (the key); further args fill %s; result in _M
199
199
  "project wins (fixed)") s='proje önde (sabit)' ;;
200
200
  "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)") s="çakışmada projenin kuralı geçerli; 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); Crewforth boşlukları doldurur (değiştirilemez)" ;;
201
201
  "3 Trace gate") s='3 İz kapısı' ;;
202
- "loosen (.trace-allowlist)") s='gevşet (.trace-allowlist)' ;;
203
- "co-author/sign-off present in git log — may be a convention") s='git geçmişinde co-author/sign-off var — ekibin alışkanlığı olabilir' ;;
202
+ "co-author/sign-off present in git log — type loosen in the review to exempt it; never assumed") s='git geçmişinde co-author/sign-off var — muaf tutmak için incelemede loosen yazın; kendiliğinden yapılmaz' ;;
204
203
  "keep") s='koru' ;;
205
204
  "no co-author/sign-off convention seen") s='co-author/sign-off alışkanlığı görülmedi' ;;
206
205
  "4 Share/hide") s='4 Paylaş/gizle' ;;
@@ -789,7 +788,7 @@ else
789
788
  fi
790
789
  propm '2 Precedence' 'project wins (fixed)' "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)"
791
790
  if [ "$COAUTHOR" = 1 ]; then
792
- propm '3 Trace gate' 'loosen (.trace-allowlist)' 'co-author/sign-off present in git log — may be a convention'
791
+ propm '3 Trace gate' 'keep' 'co-author/sign-off present in git log — type loosen in the review to exempt it; never assumed'
793
792
  else
794
793
  propm '3 Trace gate' 'keep' 'no co-author/sign-off convention seen'
795
794
  fi
@@ -817,7 +816,12 @@ fi
817
816
  # ============ COMPILE DECISIONS + OVERRIDE (Stage B) ============
818
817
  DEC1="$([ "$N_PAGENTS" != 0 ] && echo keep || echo none)"
819
818
  DEC2="project" # precedence is FIXED to project-wins (not overridable — reflected in the @import comment)
820
- DEC3="$([ "$COAUTHOR" = 1 ] && echo loosen || echo keep)"
819
+ # The trace gate is loosened only when a person types `loosen` in the review below. Never a default: old co-author
820
+ # lines in the history are not a decision, and an install that cannot ask (no terminal) or an update has no person
821
+ # to make one. Measured on a real project, 2026-10-21: a non-interactive update found five old trailers in the last
822
+ # 80 commits, wrote the co-author trailer into .trace-allowlist.txt and staged it, undoing a removal the team had
823
+ # committed.
824
+ DEC3=keep
821
825
  DEC4="$([ "$TRACKED" = 1 ] && echo share || { [ "$IGN" = 1 ] && echo hide || echo kit-default; })"
822
826
  DEC6="baseline"
823
827
  DEC7="$([ "$OFFREPO" = 1 ] && echo transfer || echo local)"
@@ -154,6 +154,9 @@ _mt() {
154
154
  'CLAUDE.md does not import .claude/DISCIPLINE.md — the discipline is on disk but never loaded') s="CLAUDE.md .claude/DISCIPLINE.md'yi içe aktarmıyor — disiplin diskte duruyor ama hiç yüklenmiyor" ;;
155
155
  'add this as its own line at the top of CLAUDE.md: @.claude/DISCIPLINE.md') s="CLAUDE.md'nin en üstüne şunu ayrı bir satır olarak ekleyin: @.claude/DISCIPLINE.md" ;;
156
156
  ' · panel needs Node 18+ — .claude/studio/ensure-node.sh --plan fetches one') s=' · panel Node 18+ ister — .claude/studio/ensure-node.sh --plan bir tane indirir' ;;
157
+ 'Node %s is on a line that no longer gets security fixes (%s fixes %s) — update Node') s="Node %s artık güvenlik düzeltmesi almayan bir hatta (%s şunları düzeltiyor: %s) — Node'u güncelleyin" ;;
158
+ 'Node %s is below %s, the release of its line with the fixes of %s — update Node') s="Node %s, %s altında; o hattın %s düzeltmelerini taşıyan sürümü bu — Node'u güncelleyin" ;;
159
+ 'Node %s carries the security fixes on record') s='Node %s kayıttaki güvenlik düzeltmelerini taşıyor' ;;
157
160
  '%s is set — its 3.0 name is CREW_%s (the old name works until 4.0)') s="%s ayarlı — 3.0'daki adı CREW_%s (eski ad 4.0'a kadar çalışır)" ;;
158
161
  '%s is set but no longer read — set CREW_%s instead') s='%s ayarlı ama artık okunmuyor — yerine CREW_%s ayarlayın' ;;
159
162
  'DOCTOR: healthy ✅%s') s='DOCTOR: sağlıklı ✅%s' ;;
@@ -661,6 +664,30 @@ for _v in $(compgen -e); do
661
664
  esac ;;
662
665
  esac
663
666
  done
667
+ # A Node missing a published security fix (eval/lib/node-advisory.tsv, the table the Studio server reads too).
668
+ # Warned, never counted: the gates are bash, Node runs the panel and the map, and the user updates it.
669
+ _na="$(dirname "$0")/lib/node-advisory.tsv"; [ -f "$_na" ] || _na=".claude/eval/lib/node-advisory.tsv"
670
+ if [ -f "$_na" ] && _nv="$(node -p 'process.versions.node' 2>/dev/null)"; then
671
+ _nu=""; _nfix=""; _nall=""; _ntop=0; _nmaj="${_nv%%.*}"
672
+ while IFS=$'\t' read -r _k _f || [ -n "$_k" ]; do
673
+ _f="${_f%$'\r'}"
674
+ case "$_k" in advisory) _nu="$_f" ;; *[!0-9]*|'') ;;
675
+ *) _nall="${_nall:+$_nall, }$_f"; [ "$_k" -gt "$_ntop" ] && _ntop="$_k"; [ "$_k" = "$_nmaj" ] && _nfix="$_f" ;; esac
676
+ done < "$_na"
677
+ case "$_nv" in
678
+ [0-9]*.[0-9]*.[0-9]*)
679
+ if [ "$_ntop" -gt 0 ] && [ "$_nmaj" -le "$_ntop" ]; then
680
+ if [ -z "$_nfix" ]; then
681
+ warn "Node %s is on a line that no longer gets security fixes (%s fixes %s) — update Node" "$_nv" "$_nu" "$_nall"
682
+ else
683
+ IFS=. read -r _a1 _a2 _a3 <<< "$_nv"; IFS=. read -r _b1 _b2 _b3 <<< "$_nfix"; _a3="${_a3%%[!0-9]*}"
684
+ if [ "$_a2" -lt "$_b2" ] || { [ "$_a2" = "$_b2" ] && [ "${_a3:-0}" -lt "$_b3" ]; }; then
685
+ warn "Node %s is below %s, the release of its line with the fixes of %s — update Node" "$_nv" "$_nfix" "$_nu"
686
+ else ok "Node %s carries the security fixes on record" "$_nv"; fi
687
+ fi
688
+ fi ;;
689
+ esac
690
+ fi
664
691
  if [ "$FAIL" -eq 0 ]; then _mt "DOCTOR: healthy ✅%s" "$PANEL_NOTE"; echo "$_M"
665
692
  # Healthy verdict: the star line, once per kit version under doctor's own marker (the installers keep theirs).
666
693
  # /crew-update runs this with CREW_NO_STAR=1, so an update shows it once, not twice. Text/URL/silence: lib/star.sh.
@@ -0,0 +1,9 @@
1
+ # Node release lines and the first release of each that carries the fixes of Node's latest security advisory.
2
+ # Read by eval/doctor.sh and the Studio server (studio/server/lib/node-advisory.js); one table, two readers.
3
+ # A Node below its line's release, or on a line older than the newest listed that is not listed (it gets no fixes),
4
+ # is warned about, never refused: the gates are bash, Node runs the panel and the map, and the user updates it.
5
+ # A line newer than every listed one is not judged. Update this file when Node publishes a new advisory.
6
+ advisory https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
7
+ 22 22.23.4
8
+ 24 24.22.1
9
+ 26 26.12.1
@@ -4760,6 +4760,28 @@ case "$_d9o" in *"needs the Anthropic API"*) ;; *) _d9b="$_d9b bedrock-not-seen"
4760
4760
  rm -rf "$_d9"
4761
4761
  [ -z "$_d9b" ] && pass "doctor 9c: both editions on is named, one is ok, no CLI is not measured; the advisor off, on, below the critical tier, env-disabled and on Bedrock each read right; none changes the verdict" \
4762
4762
  || fail "doctor 9c:$_d9b"
4763
+ # A Node missing a published security fix is warned about, never counted (eval/lib/node-advisory.tsv, read by the
4764
+ # Studio server too). Driven through a stub `node` per version; the minor is compared as a number (22.9 < 22.23),
4765
+ # a line older than the table's newest that it does not list gets no fixes, a newer line is not judged, and a node
4766
+ # that cannot run says nothing. No case changes the verdict.
4767
+ _dn="$(mktemp -d)"; _dnb=""; _dnn=0
4768
+ _dnrun(){ printf '#!/bin/sh\ncase "$*" in *split*) echo "%s" | cut -d. -f1 ;; *) echo "%s" ;; esac\n' "$1" "$1" > "$_dn/node"; chmod +x "$_dn/node"
4769
+ _dno="$( cd "$DOC" && CREW_LANG="${2:-en}" PATH="$_dn:$PATH" bash "$ROOT/eval/doctor.sh" 2>&1 )"; _dnrc=$?; _dnn=$((_dnn+1))
4770
+ [ "$_dnrc" = 0 ] || _dnb="$_dnb [$1 changed the verdict: rc $_dnrc]"; }
4771
+ _dnrun 24.21.0; case "$_dno" in *"Node 24.21.0 is below 24.22.1, the release of its line with the fixes of https://nodejs.org/"*) ;; *) _dnb="$_dnb [24.21.0 not warned]" ;; esac
4772
+ _dnrun 22.9.10; case "$_dno" in *"Node 22.9.10 is below 22.23.4"*) ;; *) _dnb="$_dnb [22.9.10 not warned: minor compared as text?]" ;; esac
4773
+ _dnrun 26.12.0; case "$_dno" in *"Node 26.12.0 is below 26.12.1"*) ;; *) _dnb="$_dnb [26.12.0 not warned]" ;; esac
4774
+ _dnrun 20.19.0; case "$_dno" in *"Node 20.19.0 is on a line that no longer gets security fixes"*"22.23.4, 24.22.1, 26.12.1"*) ;; *) _dnb="$_dnb [20.x not warned]" ;; esac
4775
+ for _v in 24.22.1 24.23.0 26.12.1; do _dnrun "$_v"; case "$_dno" in *"Node $_v carries the security fixes on record"*) ;; *) _dnb="$_dnb [$_v not ok]" ;; esac
4776
+ case "$_dno" in *"is below"*|*"no longer gets"*) _dnb="$_dnb [$_v warned]" ;; esac; done
4777
+ _dnrun 28.0.0; case "$_dno" in *"Node 28.0.0"*) _dnb="$_dnb [a newer line was judged]" ;; esac
4778
+ _dnrun 24.21.0 tr; case "$_dno" in *"Node 24.21.0, 24.22.1 altında"*) ;; *) _dnb="$_dnb [no Turkish line]" ;; esac
4779
+ printf '#!/bin/sh\nexit 127\n' > "$_dn/node"; _dno="$( cd "$DOC" && CREW_LANG=en PATH="$_dn:$PATH" bash "$ROOT/eval/doctor.sh" 2>&1 )"; _dnn=$((_dnn+1))
4780
+ case "$_dno" in *"Node "*"security fixes"*|*"is below"*) _dnb="$_dnb [a node that cannot run was judged]" ;; esac
4781
+ rm -rf "$_dn"
4782
+ if [ "$_dnn" != 10 ]; then fail "FIXTURE: the Node advisory block ran $_dnn doctor runs, not 10"
4783
+ elif [ -z "$_dnb" ]; then pass "doctor warns about a Node missing a published security fix: below its line's release (24.21.0, 26.12.0, 22.9.10 with the minor compared as a number), on a line that gets none (20.x); says ok on or above it; judges no newer line and no node that cannot run; Turkish too; never changes the verdict (10 runs)"
4784
+ else fail "doctor Node advisory:$_dnb"; fi
4763
4785
  # THE MANIFEST'S LINE ENDINGS MUST NOT CHANGE WHO OWNS A SKILL. Measured before the fix: the same install with
4764
4786
  # one project skill counted 1 on an LF manifest and 2 on a CRLF one, because `grep -qxF` wants a whole line and
4765
4787
  # `skills/handoff\r` is not `skills/handoff` — so every KIT skill read as project-owned. A Windows checkout with
@@ -5572,6 +5594,20 @@ $TRFX\""; then pass "multi-line AI trace in the commit message BLOCKED (§4.1)"
5572
5594
  if csblk "git commit -F $MFX"; then pass "-F <file> carrying an AI trace BLOCKED (§4.1)"
5573
5595
  else fail "-F <file> AI trace not blocked with rc=2 (§4.1 hole or the hook died)"; fi
5574
5596
  rm -f "$MFX"
5597
+ # Both halves in ONE refusal: a staged secret and a session-link trailer in the message. The message used to be
5598
+ # scanned only when the staged content was clean, so the refusal named the first problem and the commit was
5599
+ # retried with the trailer still in it (a real project, 2026-10-21). Assembled at run time, as above.
5600
+ STFX="Claude-""Session: https://claude.ai/code/""session_""0123456789abcdef"
5601
+ ( cd "$CS" && printf 'k = "%s"\n' "$SKFX" > c.txt && git add c.txt ) >/dev/null 2>&1
5602
+ _cso="$( cd "$CS" && printf '%s' "$(csj "git commit -m \"feat: z
5603
+
5604
+ $STFX\"")" | bash .claude/hooks/guard-commit-scan.sh 2>&1 >/dev/null )"; _csr=$?
5605
+ ( cd "$CS" && git rm -q --cached c.txt && rm -f c.txt ) >/dev/null 2>&1
5606
+ case "$_cso" in *SECRET-SCANNER*'TRACE-SCANNER (message)'*'does not override'*)
5607
+ [ "$_csr" = 2 ] && pass "a staged secret and a trailer in the message are named in ONE refusal, with the harness-reminder line (§4.1)" \
5608
+ || fail "both problems named but rc=$_csr, not 2" ;;
5609
+ *) fail "the refusal did not name both the staged secret and the message trailer (rc=$_csr): $(printf '%s' "$_cso" | tr '\n' ' ' | cut -c1-200)" ;;
5610
+ esac
5575
5611
  # A QUOTED -F path with a space. The sed extraction that ran wherever python3 did not stopped at the space, read
5576
5612
  # `-F "my msg.txt"` as `my`, found no such file and refused the commit — a clean one. The awk tokenizer is now
5577
5613
  # the only path; both directions are asserted so an extraction that reads NOTHING cannot pass as "clean".
@@ -8554,7 +8590,25 @@ case "$(_v2ot general-purpose "$_v2/tr-opus.jsonl")" in *'"updatedInput":'*'"mod
8554
8590
  [ -z "$(_v2ot general-purpose "$_v2/tr-haiku.jsonl")" ] || _v2b="$_v2b [a haiku session's general-purpose was raised to sonnet]"
8555
8591
  [ -z "$(_v2ot Explore "$_v2/tr-opus.jsonl")$(_v2ot cheap "$_v2/tr-opus.jsonl")" ] || _v2b="$_v2b [an agent with a model of its own was given one]"
8556
8592
  case "$(_v2ot mine "$_v2/tr-opus.jsonl")" in *'"model":"sonnet"'*) ;; *) _v2b="$_v2b [a project agent with no model line was not given sonnet]" ;; esac
8557
- [ -z "$_v2b" ] && pass "above the floor runs on the floor: opus for a sonnet card is rewritten to sonnet with every other field kept and recorded as lowered; 'above: <why>' keeps opus; a call on its floor is untouched; checkable text work goes to haiku; an agent outside the crew asking for opus, or inheriting an opus session's model, runs on sonnet unless its task says why; a haiku session and an agent with its own model are left alone" \
8593
+ # A task that opens with a card naming critical work is not lowered outside the crew either: a crew agent would have
8594
+ # had an opus floor for it. Seen on a real session: a one-line card with change: fix-unknown to general-purpose on opus
8595
+ # ran on sonnet. Nothing is raised, and a normal card, or a card that is not one, is lowered as before.
8596
+ _v2oc(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","transcript_path":"%s","tool_input":{"description":"d","prompt":"%s\\n\\nFind X.","subagent_type":"general-purpose"%s},"tool_use_id":"tu%s"}' "$_v2/tr-opus.jsonl" "$1" "${2:-}" "$RANDOM" | ( cd "$_v2p" && _v2e bash "$_V2G" 2>/dev/null ) | tr -d ' '; }
8597
+ _v2kept=0
8598
+ for _c in 'files: none in repo (scratch scripts only)\nchange: fix-unknown\nverify: none' \
8599
+ 'Card — files: none in repo (scratch scripts only) · change: fix-unknown · verify: none' \
8600
+ 'files: db/migrations/001.sql\nchange: feature\nverify: none' \
8601
+ 'files: src/payments/p.ts\nchange: feature\nverify: none' \
8602
+ 'files: src/ui/a.ts · change: feature · verify: none · above: a design question'; do
8603
+ [ -z "$(_v2oc "$_c" ',"model":"opus"')" ] && _v2kept=$((_v2kept+1)) || _v2b="$_v2b [kept on opus: ${_c:0:50}]"
8604
+ done
8605
+ [ -z "$(_v2oc 'files: x\nchange: security\nverify: none')" ] && _v2kept=$((_v2kept+1)) || _v2b="$_v2b [critical card naming no model was lowered]"
8606
+ [ -z "$(_v2oc 'files: x\nchange: security\nverify: none' ',"model":"sonnet"')" ] && _v2kept=$((_v2kept+1)) || _v2b="$_v2b [critical card on sonnet was raised]"
8607
+ for _c in 'files: src/ui/a.ts\nchange: feature\nverify: none' 'files: src/ui/a.ts\nchange: fix-unknown' 'change: fix-unknown is what this is'; do
8608
+ case "$(_v2oc "$_c" ',"model":"opus"')" in *'"updatedInput":'*'"model":"sonnet"'*) _v2kept=$((_v2kept+1)) ;; *) _v2b="$_v2b [not lowered: ${_c:0:50}]" ;; esac
8609
+ done
8610
+ [ "$_v2kept" = 10 ] || _v2b="$_v2b [critical-card cases: $_v2kept of 10]"
8611
+ [ -z "$_v2b" ] && pass "above the floor runs on the floor: opus for a sonnet card is rewritten to sonnet with every other field kept and recorded as lowered; 'above: <why>' keeps opus; a call on its floor is untouched; checkable text work goes to haiku; an agent outside the crew asking for opus, or inheriting an opus session's model, runs on sonnet unless its task says why; a haiku session and an agent with its own model are left alone; a task whose card names critical work (fix-unknown, a migration, a payments file, on one line or three, or naming no model) is not lowered and not raised, while a normal card, a broken card and an inline above: behave as before (10 cases)" \
8558
8612
  || fail "model on the floor:$_v2b"
8559
8613
  # The refusal names the way forward that exists (the field was sent, so it can be sent as false), and a call that
8560
8614
  # left the field out and was launched in the background is told so when the launch comes back.
@@ -533,6 +533,9 @@ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "th
533
533
  # A call to an agent that is not Crewforth's (general-purpose, a project's own): no card and no floor, so only the
534
534
  # expensive end is held, the same rewrite as a crew call above its floor.
535
535
  # * It names opus or fable, and its task has no `above: <why>` line: it runs on sonnet.
536
+ # * Either way, a task that opens with a card naming critical work (the same reading as a crew call: change:
537
+ # fix-unknown, security, migration, architecture, or a file on a critical path) is not lowered: a crew agent
538
+ # would have had an opus floor for it, so the way round the crew must not be the cheaper one. Nothing is raised.
536
539
  # * It names no model, and the agent takes the session's (general-purpose, or a project or user agent whose
537
540
  # definition has no model of its own): it runs on sonnet, unless the session itself is on haiku, read from the
538
541
  # end of its transcript. An agent whose definition names a model (Explore, claude-code-guide) is left to it:
@@ -572,6 +575,11 @@ _am_outside(){ # $1 = the tool_input text, $2 = the agent's name
572
575
  _json_slice "$1" model >/dev/null; _json_unescape "$_JS" >/dev/null; _am_tier "$_JU"; m="$_AMT"
573
576
  case "$m" in opus|fable) ;; *) return 0 ;; esac
574
577
  fi
578
+ if _am_card "$pr"; then
579
+ [ -z "$CARD_ABOVE" ] || return 0
580
+ _am_risk
581
+ if [ "$CARD_RISK" = critical ]; then _am_log "note: $name (not a crew agent) kept $m: its card names critical work ($CARD_RISKWHY)"; return 0; fi
582
+ fi
575
583
  ui="$(printf '%s' "$INPUT" | awk -v op=get -v path=tool_input -v setk=model -v setv="\"$to\"" -f "$_AM_HERE/../eval/lib/settings-json.awk" - 2>/dev/null)" || return 0
576
584
  case "$ui" in '{'*'}') ;; *) return 0 ;; esac
577
585
  _am_log "note: $name (not a crew agent) asked for $m, run on $to; add 'above: <why>' to its task to keep a model above it"
@@ -588,8 +588,11 @@ fi
588
588
 
589
589
  # The message carries its own trace risk (a co-author trailer lives there, not in the diff), and commit-msg is
590
590
  # what scans it. Reuse it on the -m value when there is one; an editor-composed message is not visible here and
591
- # stays the git hook's job.
592
- if [ "$FAILED" = 0 ] && [ -x "$DIR/commit-msg" ]; then
591
+ # stays the git hook's job. It is scanned even when the staged content already failed: a refusal that names one
592
+ # problem gets the commit retried with the other still in it (measured on a real project, 2026-10-21: a silenced
593
+ # lint check was named, the session-link trailer in the same message was not, and the message was shown to the
594
+ # user again).
595
+ if [ -x "$DIR/commit-msg" ]; then
593
596
  # The message is tokenized the way a shell does (crew_opt_values), which matters because a real commit message
594
597
  # is MULTI-LINE: a line-oriented `sed` extraction found the subject and stopped, so a co-author trailer on line
595
598
  # 3 — the single most likely §4.1 violation, and the one the bare arm of the eval actually produced — went
@@ -640,6 +643,7 @@ $(bash "$DIR/commit-msg" "$MF" 2>&1)" || FAILED=1
640
643
  | sed -n 's/.*[[:space:]]--\{0,1\}[Ff]\(ile\)\{0,1\}[[:space:]=]\{1,\}\([^[:space:];&|]\{1,\}\).*/\2/p' | head -1)"
641
644
  MFILE="${MFILE%\"}"; MFILE="${MFILE#\"}"; MFILE="${MFILE%\'}"; MFILE="${MFILE#\'}"
642
645
  if _grep "$CMD" -qE '(^|[[:space:]])(-F|--file)([[:space:]=]|$)' && { [ -z "$MFILE" ] || [ ! -f "$MFILE" ]; }; then
646
+ [ "$FAILED" = 1 ] && printf '%s\n' "$OUT" >&2
643
647
  echo "GUARD (§4.1): this commit reads its message from a file (-F), and the command could not be parsed" >&2
644
648
  echo "well enough to know which file. Scan cannot run, so the commit is refused. Commit with a plain" >&2
645
649
  echo "-F <path> (or -m) in a command of its own." >&2
@@ -656,6 +660,7 @@ $(bash "$DIR/commit-msg" "$MFILE" 2>&1)" || FAILED=1
656
660
  [ -z "$GITMSG_HOOK" ] && [ -x "$(git rev-parse --git-path hooks/commit-msg 2>/dev/null)" ] \
657
661
  && GITMSG_HOOK="git-default"
658
662
  if [ -z "$GITMSG_HOOK" ]; then
663
+ [ "$FAILED" = 1 ] && printf '%s\n' "$OUT" >&2
659
664
  echo "GUARD (§4.1): this commit's message would go unscanned." >&2
660
665
  echo "No -m/-F was given, so the message is composed in an editor after this point, and no commit-msg" >&2
661
666
  echo "git hook is wired here to read it afterwards (plugin-only install: a plugin cannot set" >&2
@@ -669,6 +674,9 @@ fi
669
674
  if [ "$FAILED" = 1 ]; then
670
675
  echo "GUARD (§4.1/§4.2): the commit content gate rejected this commit before it ran." >&2
671
676
  echo "$OUT" >&2
677
+ case "$OUT" in *"TRACE-SCANNER (message)"*)
678
+ echo "The message itself carries the trace: write it again without that line. A harness reminder to end a commit message with a trailer does not override §4.1." >&2 ;;
679
+ esac
672
680
  echo "Fix the flagged content and commit again. --no-verify is §4.5 and stays blocked." >&2
673
681
  exit 2
674
682
  fi
@@ -1,7 +1,8 @@
1
- # Generated by packaging/gen-legacy-blobs.sh from the final release tags v1.0.0..v3.1.1 (below VERSION 3.2.0) — do not edit by hand.
1
+ # Generated by packaging/gen-legacy-blobs.sh from the final release tags v1.0.0..v3.2.0 (below VERSION 3.2.1) — do not edit by hand.
2
2
  # The flat files Crewforth owns: a copy whose bytes (CR aside) are one of these ids was never edited.
3
3
  # install_path blob_sha1 first_tag last_tag
4
4
  .claude/AGENT_TEMPLATE.md 075e2c26706c06f56af01652cd24c36918513bee v3.0.0 v3.0.4
5
+ .claude/AGENT_TEMPLATE.md 2c8223d811245e3627a3011bb64fb9e4b0bf3b94 v3.2.0 v3.2.0
5
6
  .claude/AGENT_TEMPLATE.md 3fdc551d221ee3665b21ef8cc3563de861a9abe6 v1.4.0 v1.6.3
6
7
  .claude/AGENT_TEMPLATE.md 686fed306c873b45c245c77a95a8e6de6ee383d5 v1.7.0 v2.11.0
7
8
  .claude/AGENT_TEMPLATE.md 7ab19417b6ff3ac732e2c894be17ffb3ec091f7d v3.1.0 v3.1.1
@@ -29,6 +30,7 @@
29
30
  .claude/DISCIPLINE.md b90a067ee323a08146862b889293f8feb49f8e7e v3.1.0 v3.1.1
30
31
  .claude/DISCIPLINE.md bf062c2b51a9cf3cc4d0caac389c148cf6fdc977 v2.12.0 v2.12.0
31
32
  .claude/DISCIPLINE.md c693f1bd5832ce570058c6eedcacb5dff13d89e8 v1.10.0 v1.10.0
33
+ .claude/DISCIPLINE.md cf881a5229d85850194a98b8d0d0438a8eaaa9d6 v3.2.0 v3.2.0
32
34
  .claude/DISCIPLINE.md d63a01a183c9a80d739c840a511d338dc2765820 v1.4.0 v1.4.4
33
35
  .claude/DISCIPLINE.md dc1c41baa3a8b7d637214bc78553a78d94d14ac9 v1.6.2 v1.6.3
34
36
  .claude/DISCIPLINE.md e42aefc9c1ffaeace8e5712d92df780959212280 v2.0.0 v2.1.0
@@ -43,4 +45,4 @@
43
45
  .claude/README.md 97d4dd1f7f1695052cc1d06cc43f5bcdc603c989 v1.0.0 v1.0.4
44
46
  .claude/README.md a3419891cfda7385564008e4befc1007bc5364cb v1.1.1 v1.7.0
45
47
  .claude/README.md a3b76e0fa31267610d28b742c122a962f6db59c4 v3.0.1 v3.0.4
46
- .claude/README.md b6c9b57a67d25b7e4781e4eb61ff9637e6abe5ef v3.1.0 v3.1.1
48
+ .claude/README.md b6c9b57a67d25b7e4781e4eb61ff9637e6abe5ef v3.1.0 v3.2.0
@@ -29,7 +29,7 @@ _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
29
29
  FLOOR=18
30
30
  DIST=https://nodejs.org/dist
31
31
  # Used only when nodejs.org cannot be reached to ask which release is current.
32
- FALLBACK_VERSION=v24.21.0
32
+ FALLBACK_VERSION=v24.22.1 # carries the fixes of eval/lib/node-advisory.tsv; raise it with that table
33
33
 
34
34
  # Windows caps a full file path at 260 characters and PowerShell's Expand-Archive enforces it.
35
35
  # Measured: a 141-character target failed with PathTooLongException at __generated__\google\api\…
@@ -43,6 +43,7 @@ import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, noteR
43
43
  import { redact, transcriptOnly } from './lib/redact.js';
44
44
  import { lanListener } from './lib/lan.js';
45
45
  import { randomUUID } from 'node:crypto';
46
+ import { nodeAdvisoryNote } from './lib/node-advisory.js';
46
47
  import { spawn } from 'node:child_process';
47
48
 
48
49
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -1104,6 +1105,8 @@ async function main() {
1104
1105
  process.stdout.write(TOKEN_GENERATED
1105
1106
  ? ' (loopback only; token generated for this run)\n'
1106
1107
  : ' (loopback only; token from CREW_STUDIO_TOKEN)\n');
1108
+ const nodeNote = nodeAdvisoryNote();
1109
+ if (nodeNote) process.stdout.write(` warning: ${nodeNote}\n`);
1107
1110
 
1108
1111
  // Recorded only after listen() succeeds, so the file never claims a port this process did not get. Failure
1109
1112
  // to write is not fatal: the panel works, the next session simply cannot find it, which is where we started.
@@ -0,0 +1,46 @@
1
+ // Is the Node running this panel missing a published security fix? The table is eval/lib/node-advisory.tsv, the
2
+ // same file eval/doctor.sh reads, so the two cannot disagree. A warning only: the panel still starts.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const TABLE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'eval', 'lib', 'node-advisory.tsv');
8
+
9
+ /** The table's text as { url, lines: Map(major -> 'x.y.z') }; null when it holds no release line. */
10
+ export function parseAdvisory(text) {
11
+ let url = '';
12
+ const lines = new Map();
13
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
14
+ if (!raw || raw.startsWith('#')) continue;
15
+ const [k, v] = raw.split('\t');
16
+ if (k === 'advisory') url = v ?? '';
17
+ else if (/^\d+$/.test(k) && /^\d+\.\d+\.\d+$/.test(v ?? '')) lines.set(Number(k), v);
18
+ }
19
+ return lines.size ? { url, lines } : null;
20
+ }
21
+
22
+ const parts = (v) => String(v).replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10) || 0);
23
+ const below = (a, b) => { const x = parts(a), y = parts(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i]; return false; };
24
+
25
+ /**
26
+ * The sentence to print for `version` (process.versions.node), or null when there is nothing to say: it carries
27
+ * its line's fixes, its line is newer than the table, or the table could not be read.
28
+ */
29
+ export function judgeNode(version, advisory) {
30
+ if (!advisory || !/^\d+\.\d+\.\d+/.test(String(version ?? ''))) return null;
31
+ const major = parts(version)[0];
32
+ const newest = Math.max(...advisory.lines.keys());
33
+ if (major > newest) return null;
34
+ const fixed = advisory.lines.get(major);
35
+ if (fixed && !below(version, fixed)) return null;
36
+ return fixed
37
+ ? `Node ${version} is below ${fixed}, the release of its line with the fixes of ${advisory.url} — update Node`
38
+ : `Node ${version} is on a line that no longer gets security fixes (${advisory.url} fixes ${[...advisory.lines.values()].join(', ')}) — update Node`;
39
+ }
40
+
41
+ /** judgeNode for the running process, reading the table from disk; null when the table is not there. */
42
+ export function nodeAdvisoryNote(version = process.versions.node) {
43
+ let text;
44
+ try { text = fs.readFileSync(TABLE, 'utf8'); } catch { return null; }
45
+ return judgeNode(version, parseAdvisory(text));
46
+ }
@@ -65,12 +65,12 @@ export function workSteps(row) {
65
65
  }
66
66
 
67
67
  /**
68
- * What is said about an agent the model gate does not look at: one whose type is not crew-* (hooks/guard-agent-model.sh
69
- * leaves it alone), so there is no card to read and no line in the record. Null for a crew agent or an unknown type.
68
+ * What is said about an agent that is not crew-*: it has no floor and no line in the record, but the model gate still
69
+ * holds its expensive end, except for a card naming critical work (_am_outside in hooks/guard-agent-model.sh). Null for a crew agent or an unknown type.
70
70
  */
71
71
  export function outsideRouting(agentType) {
72
72
  if (typeof agentType !== 'string' || !agentType || agentType.startsWith('crew-')) return null;
73
- return 'Outside model routing: not a crew agent, so the gate chose nothing for it and Crewforth kept no record of it. It ran on the model its call asked for, or else the session\u2019s.';
73
+ return 'Outside model routing: not a crew agent, so it has no floor and Crewforth keeps no outcome line for it. The gate only holds the expensive end: a call that asks for opus or fable, or names no model and would take the session\u2019s, is held at sonnet unless its task carries an above: <why> line or opens with a card naming critical work. An agent whose own definition names a model keeps it.';
74
74
  }
75
75
 
76
76
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.2.0-rc.9",
4
- "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
3
+ "version": "3.2.1",
4
+ "description": "Crewforth \u2014 your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"
7
7
  },
@@ -15,8 +15,7 @@
15
15
  "kit/",
16
16
  "VERSION",
17
17
  "LICENSE",
18
- "README.md",
19
- "CHANGELOG.md"
18
+ "README.md", "CHANGELOG.md"
20
19
  ],
21
20
  "engines": {
22
21
  "node": ">=20"
@@ -43,7 +42,7 @@
43
42
  "code-review",
44
43
  "git-hooks"
45
44
  ],
46
- "author": "Barış Yerlikaya",
45
+ "author": "Bar\u0131\u015f Yerlikaya",
47
46
  "homepage": "https://crewforth.com/",
48
47
  "repository": {
49
48
  "type": "git",