crewforth 3.2.0-rc.1 → 3.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -79,6 +79,17 @@ versioning follows [SemVer](https://semver.org/).
79
79
 
80
80
  ### Changed
81
81
 
82
+ - **A model is chosen to cost what the work needs.** Measured on a real project: 186 crew tasks, 0 on haiku, 73 (39%)
83
+ above their card's floor, and no verify command ever passing, so the cheaper path never opened. Now:
84
+ - the card's model is the default: a call above it runs on it (the gate rewrites the call's model), unless the
85
+ card adds `above: <why>`;
86
+ - outside the crew, a call asking for opus or fable runs on sonnet, and so does a general-purpose call that names
87
+ no model in a session that is not on haiku (it took the session's model; measured on Claude Code 2.1.299, the
88
+ project's settings `env` could not change that, so the gate does);
89
+ - a verify command in a sub-folder (`cd apps/x && <command>`, 41 of 71 refusals) is run in that folder;
90
+ `none (why)` is none; and `/crew-doctor` lists the commands that were never allowed to run, with the line that
91
+ allows each.
92
+
82
93
  - **A commit's command is read by one reader.** `guard-bash.sh` paired quotes in three places: the reader of a call,
83
94
  an older walk that looks for a path or `-a` in a commit, and the check of a call the user's approval covers. The
84
95
  last two now take their text from the first, and keep only their own question. What a call the user approved may
@@ -114,6 +125,28 @@ versioning follows [SemVer](https://semver.org/).
114
125
 
115
126
  ### Fixed
116
127
 
128
+ - **Studio keeps answering with many conversations open.** Each conversation pane held its own stream, and a
129
+ browser opens six connections to one host: at five panes a message sent and every request the page made waited
130
+ for a pane to close (on Windows a send did not arrive in 35 s; on macOS the page's own request timed out at 4 s).
131
+ Every open conversation now shares one stream, resumed from each session's last event when it reconnects.
132
+ - **Studio finds sessions under a moved config dir.** It read `~/.claude/projects` only, so sessions under
133
+ `CLAUDE_CONFIG_DIR` were not listed and read a cost of 0. It now reads `projects` there, as Claude Code writes.
134
+ - **Studio says when it needs a restart.** A running server keeps the code it started with, and an update
135
+ replaces the files the page is read from: on a real project the Map view of the new page asked a server from
136
+ before it and showed "Not read — HTTP 404". The server now says whether its code changed on disk since it
137
+ started; a page that hears so, or talks to a server too old to say, shows a note to restart it, and Map says the
138
+ same instead of the 404.
139
+ - **Two agents starting together no longer share one model record on Windows.** A record was taken by renaming
140
+ it, which is a lock on macOS and Linux; on Windows two hook processes both reported the rename as done (seen
141
+ twice on the Windows CI). Each record is now locked first with an exclusive create, a shell builtin that costs no
142
+ process, and only the hook that made the lock takes it.
143
+ - **An rc says it is one, and its final is offered.** A release candidate installed `.claude/VERSION` as the final's
144
+ number (3.2.0 for 3.2.0-rc.1), so the update check, doctor and the refresh's "what's new" all read it as the final,
145
+ and someone who tried the rc was never told the final shipped. The rc's archive and npm package now carry its own
146
+ version, and one definition (`eval/lib/version.sh`) orders an rc before its final. A version off the network is
147
+ still held to X.Y.Z.
148
+ - **`adopt.sh --help` prints its usage.** It ran the project detection instead; the usage now comes in English and
149
+ Turkish, before anything is read.
117
150
  - **The crew map reads what two real projects showed it missing.** Scanned before release, they put three rules
118
151
  well over a 20% false-positive rate, and one scan refused its own document. Read now: a route with an empty path,
119
152
  a decorator whose path is on the next line, `api.get<T>(` and a call split over lines, a client made in one file
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.2.0
1
+ 3.2.0-rc.2
package/adopt.sh CHANGED
@@ -11,7 +11,8 @@
11
11
  set -uo pipefail
12
12
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
13
13
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
14
- [ -f "$_crew_d/kit/eval/lib/crew-env.sh" ] && . "$_crew_d/kit/eval/lib/crew-env.sh"; unset _crew_d
14
+ [ -f "$_crew_d/kit/eval/lib/crew-env.sh" ] && . "$_crew_d/kit/eval/lib/crew-env.sh"
15
+ [ -f "$_crew_d/kit/eval/lib/version.sh" ] && . "$_crew_d/kit/eval/lib/version.sh"; unset _crew_d # an rc orders before its final
15
16
  HERE="$(CDPATH= cd "$(dirname "$0")" && pwd)"
16
17
 
17
18
  # --version (or -v) is answered first, before the payload check below: it reads only VERSION, so it works
@@ -23,6 +24,44 @@ for _a in "$@"; do
23
24
  fi
24
25
  done
25
26
 
27
+ # --help (or -h) is answered here too, before anything is read: an unknown flag is ignored further down, so without
28
+ # this `adopt.sh --help` ran the detection instead of saying what the script does. The language comes from --lang or
29
+ # CREW_LANG; the rest of the script asks for it later, which is too late for a help text.
30
+ _hl="${CREW_LANG:-}"; _hh=0; _ht=0
31
+ for _a in "$@"; do
32
+ if [ "$_ht" = 1 ]; then _ht=0; _hl="$_a"; continue; fi
33
+ case "$_a" in -h|--help) _hh=1 ;; --lang) _ht=1 ;; --lang=*) _hl="${_a#--lang=}" ;; esac
34
+ done
35
+ if [ "$_hh" = 1 ]; then
36
+ if [ "$_hl" = tr ]; then
37
+ cat <<'USAGE_TR'
38
+ Kullanım: bash adopt.sh [SEÇENEKLER] (ya da: npx crewforth adopt [SEÇENEKLER])
39
+ Crewforth'u MEVCUT bir projeye kurar ya da kurulu olanı yeniler. Önce projeyi okur ve ne yapacağını gösterir;
40
+ projenin kendi dosyalarına, hook'larına ve status line'ına dokunmaz. Değişiklikleri hazırlar (stage), commit atmaz.
41
+ --here değişiklikleri şu anki dalda yap
42
+ --new-branch değişiklikleri yeni bir inceleme dalında yap (ilk kurulumda varsayılan)
43
+ --yes, -y her soruya evet de (gözetimsiz kurulum)
44
+ --lang tr|en dil (verilmezse sorulur)
45
+ --version, -v Crewforth sürümünü yazdırıp çık
46
+ --help, -h bu metni yazdırıp çık
47
+ USAGE_TR
48
+ else
49
+ cat <<'USAGE'
50
+ Usage: bash adopt.sh [OPTIONS] (or: npx crewforth adopt [OPTIONS])
51
+ Installs Crewforth into an EXISTING project, or refreshes an install. It reads the project first and shows what it
52
+ will do; the project's own files, hooks and status line are left alone. Changes are staged, never committed.
53
+ --here make the changes on the current branch
54
+ --new-branch make the changes on a fresh review branch (the default for a first adopt)
55
+ --yes, -y answer yes at every gate (unattended)
56
+ --lang tr|en language (asked when not given)
57
+ --version, -v print Crewforth's version and exit
58
+ --help, -h print this text and exit
59
+ USAGE
60
+ fi
61
+ exit 0
62
+ fi
63
+ unset _hl _hh _ht
64
+
26
65
  SRC="$HERE/kit"
27
66
  [ -d "$SRC" ] || { echo "ERROR: kit/ not found (must be in the same directory as adopt.sh)."; exit 1; }
28
67
 
@@ -1540,14 +1579,15 @@ fi
1540
1579
  # A fresh adopt has no "before", so it gets no file (and a stale one from an earlier update is removed).
1541
1580
  rm -f .claude/.state/whats-new.md 2>/dev/null
1542
1581
  _kv="$(printf '%s' "$KIT_VER" | tr -d '\r')"
1543
- case "$_kv" in *[!0-9.]*|'') _kv="" ;; [0-9]*.[0-9]*.[0-9]*) ;; *) _kv="" ;; esac # an odd old version compares as 0.0.0 — every section would look new
1582
+ _kv="$(crew_local_version "$_kv" 2>/dev/null)" || _kv="" # an odd old version compares as 0.0.0 — every section would look new
1544
1583
  if [ "$KIT_PRESENT" = 1 ] && [ -n "$_kv" ] && [ -f "$HERE/CHANGELOG.md" ] && [ -f "$HERE/VERSION" ]; then
1545
1584
  mkdir -p .claude/.state 2>/dev/null
1546
- awk -v from="$_kv" -v to="$(head -1 "$HERE/VERSION" | tr -d '\r')" '
1547
- function cmp(a,b, x,y,i){ split(a,x,"."); split(b,y,"."); for(i=1;i<=3;i++){ if(x[i]+0>y[i]+0) return 1; if(x[i]+0<y[i]+0) return -1 } return 0 }
1585
+ # The upper bound is the target's X.Y.Z: an rc's notes are its final's section ("[Unreleased] — 3.2.0").
1586
+ awk -v from="$_kv" -v to="$(head -1 "$HERE/VERSION" | tr -d '\r')" "${CREW_VCMP_AWK:-}"'
1587
+ BEGIN { sub(/-.*/, "", to) }
1548
1588
  /^## \[/ { v=$0; sub(/^## \[/,"",v); w=v; sub(/\].*/,"",v)
1549
1589
  if (v=="Unreleased") { v=w; sub(/^[^—]*— */,"",v); sub(/[^0-9.].*/,"",v) }
1550
- keep = (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ && cmp(v,from)>0 && cmp(v,to)<=0) }
1590
+ keep = (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ && vcmp(v,from)>0 && vcmp(v,to)<=0) }
1551
1591
  keep' "$HERE/CHANGELOG.md" | tr -d '\r' > .claude/.state/whats-new.md 2>/dev/null
1552
1592
  [ -s .claude/.state/whats-new.md ] || rm -f .claude/.state/whats-new.md
1553
1593
  fi
package/kit/CLAUDE.md CHANGED
@@ -55,16 +55,16 @@ sequence. All are installed: "no owner" is an `ls`.
55
55
 
56
56
  **Model per call — RISK decides, not size.** A `crew-*` task opens with its card, and the call names `model`:
57
57
  `files:` files/globs · `change:` text|feature|fix-known|fix-unknown|refactor|migration|security|architecture|test-run|test-write|audit
58
- · `verify:` the command that proves it, or `none`. **crew-planner** writes the card; with no plan, you do.
58
+ · `verify:` the command that proves it, or `none`. **crew-planner** writes the card, or you do.
59
59
 
60
- | `model` | the card |
60
+ | `model` (`guard-agent-model.sh`: floor = default; `above: <why>` for more) | the card |
61
61
  |---|---|
62
- | `haiku` | text, test-run, mechanical work — only with a `verify:` command |
62
+ | `haiku` | text, test-run, mechanical work — only with `verify:` |
63
63
  | `sonnet` | feature, fix-known, refactor, test-write, audit, review; anything with `verify: none` |
64
- | `opus` | **critical**: migration, security, architecture, fix-unknown, or a file on a critical path (auth, payments, migrations…) — its test-write, audit and review too |
64
+ | `opus` | **critical**: migration, security, architecture, fix-unknown, or a critical-path file (auth, payments, migrations…) — its test-write, audit, review too |
65
65
 
66
66
  The referee follows the RISK, not the author's model. Verify fails → the agent fixes once; again → re-run ONCE one
67
- model up; again → AskUserQuestion. The same for a report that ends `confidence: low`. `guard-agent-model.sh` holds it.
67
+ model up; again → AskUserQuestion. The same for a report that ends `confidence: low`.
68
68
 
69
69
  **Naming an agent in prose is a hope; `@agent-<name>` is a guarantee** — measured here: 0/3 vs 3/3. Use that form
70
70
  whenever an agent must run, and tell the user they can too.
@@ -7,7 +7,8 @@
7
7
  set -uo pipefail
8
8
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
9
9
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
10
- [ -f "$_crew_d/lib/crew-env.sh" ] && . "$_crew_d/lib/crew-env.sh"; unset _crew_d
10
+ [ -f "$_crew_d/lib/crew-env.sh" ] && . "$_crew_d/lib/crew-env.sh"
11
+ [ -f "$_crew_d/lib/version.sh" ] && . "$_crew_d/lib/version.sh"; unset _crew_d # an rc orders before its final
11
12
  ROOT="${1:-.}"
12
13
  cd "$ROOT" 2>/dev/null || { echo "doctor: cannot enter '$ROOT'"; exit 2; }
13
14
 
@@ -106,6 +107,7 @@ _mt() {
106
107
  'advisor: off (CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1)') s='advisor: kapalı (CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1)' ;;
107
108
  "status line: Crewforth's (from %s)") s="durum satırı: Crewforth'unki (%s içinden)" ;;
108
109
  "status line: your own (from %s); Crewforth's is not used") s="durum satırı: seninki (%s içinden); Crewforth'unki kullanılmıyor" ;;
110
+ "verify commands never run, %s time(s), because nothing allows them; to let the hook run one, add its line to .claude/crew-model-rules: %s") s="doğrulama komutları %s kez hiç koşmadı, çünkü hiçbir şey izin vermiyor; hook'un koşturması için satırını .claude/crew-model-rules dosyasına ekle: %s" ;;
109
111
  "status line: none; Crewforth's: bash .claude/eval/lib/statusline.sh --install (Bash tool, not PowerShell)") s="durum satırı: yok; Crewforth'unki: bash .claude/eval/lib/statusline.sh --install (Bash tool, not PowerShell)" ;;
110
112
  "advisor: off (no advisorModel set; Crewforth never turns it on)") s='advisor: kapalı (advisorModel ayarlı değil; Crewforth onu hiç açmaz)' ;;
111
113
  'advisorModel is %s in %s, but CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 turns the advisor off') s="advisorModel %s (%s içinde), ama CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 advisor'ı kapatıyor" ;;
@@ -218,8 +220,7 @@ if [ -f .claude/VERSION ] && [ -f .claude/.state/update-check ]; then
218
220
  read -r DLATEST _ < .claude/.state/update-check 2>/dev/null || DLATEST=""
219
221
  DLATEST="$(printf '%s' "${DLATEST:-}" | tr -cd '0-9A-Za-z.-')"
220
222
  DCUR="$(head -1 .claude/VERSION 2>/dev/null | tr -cd '0-9A-Za-z.-')"
221
- if [ -n "$DLATEST" ] && awk -v a="$DLATEST" -v b="$DCUR" 'BEGIN{split(a,x,".");split(b,y,".");
222
- for(i=1;i<=3;i++){if(x[i]+0>y[i]+0)exit 0; if(x[i]+0<y[i]+0)exit 1} exit 1}'; then
223
+ if [ -n "$DLATEST" ] && awk -v a="$DLATEST" -v b="$DCUR" "${CREW_VCMP_AWK:-}"' BEGIN { exit !(vcmp(a, b) > 0) }' 2>/dev/null; then
223
224
  warn "Crewforth v%s installed, v%s published — update with /crew-update" "$DCUR" "$DLATEST"
224
225
  fi
225
226
  elif [ -f .claude/VERSION ]; then
@@ -772,6 +773,28 @@ case "$_sl" in
772
773
  *) [ -f .claude/eval/lib/statusline.sh ] && ok "status line: none; Crewforth's: bash .claude/eval/lib/statusline.sh --install (Bash tool, not PowerShell)" ;;
773
774
  esac
774
775
 
776
+ # 9e) What model routing costs. Advisory. A verify command the hook was never allowed to run cannot vouch for
777
+ # cheaper work: the ones refused for that reason are listed with the line that allows them (measured on one
778
+ # project: 23 of 71 refusals).
779
+ _vbn=0; _vbl=""
780
+ for _f in .claude/state/crew-model/results/*; do
781
+ [ -f "$_f" ] || continue
782
+ _vb=0; _vw=""; _vc=""
783
+ while IFS= read -r _l || [ -n "$_l" ]; do
784
+ case "$_l" in verify=blocked) _vb=1 ;; whynot=*) _vw="${_l#whynot=}" ;; cmd=*) _vc="${_l#cmd=}" ;; esac
785
+ done < "$_f"
786
+ [ "$_vb" = 1 ] || continue
787
+ case "$_vw" in *"not a test or build command on this hook's list"*) ;; *) continue ;; esac
788
+ _vc="${_vc:0:80}"; [ -n "$_vc" ] || continue
789
+ _vbn=$((_vbn+1))
790
+ case "$_vc" in *'('*|*' + '*) continue ;; esac # a sentence, not a command: counted, not offered as a rule
791
+ case "$_vbl" in *"| verify $_vc |"*) ;; *) _vbl="$_vbl| verify $_vc |" ;; esac
792
+ done
793
+ if [ "$_vbn" -gt 0 ]; then
794
+ _vsep=' || '; _vdot=' · '; _vbs="${_vbl//"$_vsep"/$_vdot}"; _vbs="${_vbs#| }"; _vbs="${_vbs% |}"; _vbs="${_vbs:0:300}"
795
+ warn "verify commands never run, %s time(s), because nothing allows them; to let the hook run one, add its line to .claude/crew-model-rules: %s" "$_vbn" "$_vbs"
796
+ fi
797
+
775
798
  # 10) Gate activity. The suite proves the gates CAN fire; this reports whether anything actually tripped them.
776
799
  # Recording is on by default (rule names only, never the command), so "no log" here means no gate has
777
800
  # fired yet — a measured zero, not a gap. Never a failure either way.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # settings-default.sh — put one of Crewforth's defaults into the project's .claude/settings.json, only where nothing
3
+ # sets it yet. The project's settings win over the user's, so writing a default over a user's own choice would
4
+ # silently replace it: a value set in the project's settings, its local settings or the user's own is kept.
5
+ #
6
+ # bash settings-default.sh --install <path> <defaults.json> [ENV_VAR] from the project root
7
+ # <path> the setting, as a dot path: statusLine, env.CLAUDE_CODE_SUBAGENT_MODEL
8
+ # <defaults.json> a JSON object holding just that setting (data beside the caller: one definition)
9
+ # ENV_VAR also kept when this variable is set in the environment (a value exported in the shell)
10
+ #
11
+ # Prints one word: installed · kept (it is set) · none (no settings.json) · failed (the file is left as it was).
12
+ # A managed settings layer is not read. The merge is Crewforth's own JSON reader.
13
+ set -u
14
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
15
+
16
+ [ "${1:-}" = --install ] && [ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "usage: settings-default.sh --install <path> <defaults.json> [ENV_VAR]" >&2; exit 2; }
17
+ KEY="$2"; K="$3"; VAR="${4:-}"
18
+ P=".claude/settings.json"; SJ="$_here/settings-json.awk"
19
+ [ -f "$P" ] && [ -f "$SJ" ] || { echo none; exit 0; }
20
+ case "$VAR" in '') ;; [A-Za-z_]*) case "$VAR" in *[!A-Za-z0-9_]*) echo failed; exit 0 ;; esac; [ -n "${!VAR:-}" ] && { echo kept; exit 0; } ;; *) echo failed; exit 0 ;; esac
21
+ for f in "$P" ".claude/settings.local.json" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; do
22
+ [ -f "$f" ] && awk -v op=get -v path="$KEY" -f "$SJ" "$f" >/dev/null 2>&1 && { echo kept; exit 0; }
23
+ done
24
+ [ -f "$K" ] || { echo failed; exit 0; }
25
+ t="$P.default.$$"
26
+ r=failed
27
+ # The project's file goes FIRST: the merge refreshes the first file's .claude/hooks/ entries from the first file,
28
+ # so put second it would lose every Crewforth hook (measured: the installed project's own smoke failed 30 checks).
29
+ # The second file holds only the default, which the first lacks: that is the one change (an object such as env is
30
+ # merged member by member, the project's own members kept). Written through the file, so a symlink and its mode
31
+ # survive.
32
+ if awk -v op=merge -v retired= -f "$SJ" "$P" "$K" > "$t" 2>/dev/null && [ -s "$t" ] \
33
+ && awk -v op=validate -f "$SJ" "$t" 2>/dev/null && awk -v op=get -v path="$KEY" -f "$SJ" "$t" >/dev/null 2>&1; then
34
+ cat "$t" > "$P" && r=installed
35
+ fi
36
+ rm -f "$t"; echo "$r"
@@ -9,6 +9,9 @@
9
9
  # line, quotes off, escapes as written (rc 1 = not an array)
10
10
  # path = dot-separated keys, a number indexes an array (0-based), empty = the root. rc 1 = no such path
11
11
  # (or, for len, a scalar there), 10 = FILE is not a JSON object.
12
+ # awk -v op=get -v path=P -v setk=M -v setv=RAWJSON -f settings-json.awk FILE the object at P with member M set
13
+ # to RAWJSON (added when absent): a payload rewritten in one field, every other field and escape kept
14
+ # (rc 1 = nothing at P, or not an object)
12
15
  # awk -v op=overlay -v key=K [-v setk=M -v setv=RAWJSON] -f settings-json.awk FILE1 FILE2
13
16
  # FILE1 with FILE1.K shallow-merged from FILE2.K (FILE2 wins, FILE1's other members and order kept), then
14
17
  # member M set to RAWJSON. rc 10 = FILE1 (or its K) is not an object, 11 = FILE2 has no object at K.
@@ -70,6 +73,10 @@ function at(id,path, n,sg,i){ n=split(path,sg,".")
70
73
  return id }
71
74
  function mk(t){ NN++; T[NN]=t; N[NN]=0; return NN }
72
75
  function add(o,k,c){ N[o]++; K[o,N[o]]=k; C[o,N[o]]=c }
76
+ # Member k of object o set to the raw JSON v, added at the end when o has none (overlay and get share it).
77
+ function setm(o,k,v, sn,i,x){ sn=mk("s"); V[sn]=v; x=0
78
+ for(i=1;i<=N[o];i++) if(K[o,i]=="\"" k "\""){ C[o,i]=sn; x=1 }
79
+ if(!x) add(o,"\"" k "\"",sn) }
73
80
  function canon(id, i,j,n,s,t,ord){
74
81
  if(T[id]=="s") return V[id]
75
82
  if(T[id]=="a"){ s="["; for(i=1;i<=N[id];i++) s=s (i>1 ? "," : "") canon(C[id,i]); return s "]" }
@@ -130,7 +137,8 @@ END{
130
137
  if(op=="gets"){ if(!(r=doc(1))) exit 10; n=split(path,pp,"|"); o=""
131
138
  for(i=1;i<=n;i++){ x=at(r,pp[i]); o=o (i>1?"\t":"") (x ? put(x,"") : "") } print o; exit 0 }
132
139
  if(op=="get" || op=="len"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1
133
- if(op=="get"){ print put(r,""); exit 0 } if(T[r]=="s") exit 1; print N[r]; exit 0 }
140
+ if(op=="get"){ if(setk!=""){ if(T[r]!="o") exit 1; setm(r,setk,setv) } print put(r,""); exit 0 }
141
+ if(T[r]=="s") exit 1; print N[r]; exit 0 }
134
142
  if(op=="strings"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1; if(T[r]!="a") exit 1
135
143
  for(i=1;i<=N[r];i++){ c=C[r,i]; if(T[c]=="s" && substr(V[c],1,1)=="\"") print substr(V[c],2,length(V[c])-2) }
136
144
  exit 0 }
@@ -138,9 +146,7 @@ END{
138
146
  qv=get(q,kk); if(T[qv]!="o") exit 11; tv=get(t,kk); if(tv && T[tv]!="o") exit 10
139
147
  nv=mk("o"); for(i=1;i<=N[tv];i++){ x=get(qv,K[tv,i]); add(nv,K[tv,i], x ? x : C[tv,i]) }
140
148
  for(i=1;i<=N[qv];i++) if(!get(nv,K[qv,i])) add(nv,K[qv,i],C[qv,i])
141
- if(setk!=""){ sn=mk("s"); V[sn]=setv; x=0
142
- for(i=1;i<=N[nv];i++) if(K[nv,i]=="\"" setk "\""){ C[nv,i]=sn; x=1 }
143
- if(!x) add(nv,"\"" setk "\"",sn) }
149
+ if(setk!="") setm(nv,setk,setv)
144
150
  x=0; for(i=1;i<=N[t];i++) if(K[t,i]==kk){ C[t,i]=nv; x=1 }
145
151
  if(!x) add(t,kk,nv)
146
152
  print put(t,""); exit 0 }
@@ -25,24 +25,9 @@ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
25
25
  # the project's setting would win over it. Prints one word: installed · kept (one is set) · none (no settings.json)
26
26
  # · failed (the file is left as it was). A managed settings layer is not read. The merge is Crewforth's own JSON reader.
27
27
  if [ "${1:-}" = --install ]; then
28
- P=".claude/settings.json"; SJ="$_here/settings-json.awk"
29
- [ -f "$P" ] && [ -f "$SJ" ] || { echo none; exit 0; }
30
- for f in "$P" ".claude/settings.local.json" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; do
31
- [ -f "$f" ] && awk -v op=get -v path=statusLine -f "$SJ" "$f" >/dev/null 2>&1 && { echo kept; exit 0; }
32
- done
33
- # The setting itself is data beside this file (statusline.settings.json): one definition, read here and nowhere else.
34
- K="$_here/statusline.settings.json"; [ -f "$K" ] || { echo failed; exit 0; }
35
- t="$P.statusline.$$"
36
- r=failed
37
- # The project's file goes FIRST: the merge refreshes the first file's .claude/hooks/ entries from the first file,
38
- # so put second it would lose every Crewforth hook (measured: the installed project's own smoke failed 30
39
- # checks). The second file has one member, statusLine, which the first lacks: that is the one change. Written
40
- # through the file, so a symlink and its mode survive.
41
- if awk -v op=merge -v retired= -f "$SJ" "$P" "$K" > "$t" 2>/dev/null && [ -s "$t" ] \
42
- && awk -v op=validate -f "$SJ" "$t" 2>/dev/null && awk -v op=get -v path=statusLine -f "$SJ" "$t" >/dev/null 2>&1; then
43
- cat "$t" > "$P" && r=installed
44
- fi
45
- rm -f "$t"; echo "$r"; exit 0
28
+ # The setting itself is data beside this file (statusline.settings.json); the rule for putting a default in is
29
+ # one helper, shared with the subagent model's default.
30
+ exec bash "$_here/settings-default.sh" --install statusLine "$_here/statusline.settings.json"
46
31
  fi
47
32
 
48
33
  IFS= read -r -d '' IN || true
@@ -0,0 +1,41 @@
1
+ # Sourced, not run. The ONE definition of how Crewforth reads and orders its own version numbers.
2
+ #
3
+ # An install's VERSION holds X.Y.Z, or X.Y.Z-rc.N when it came from a release candidate. An rc comes before its
4
+ # final (3.2.0-rc.1 < 3.2.0), so someone who tried the rc is offered the final when it ships. Read by the update
5
+ # check (hooks/session-update-check.sh), doctor and adopt.sh; each used to split on "." and read 3.2.0-rc.1 as 3.2.0.
6
+ #
7
+ # Builtins only: sourcing this costs no process, which matters in hooks on Git Bash.
8
+
9
+ # The version an install may hold in its own VERSION file: X.Y.Z or X.Y.Z-rc.N, nothing else; printed, or rc 1.
10
+ # A value off the network is not read with this: the update check holds that to X.Y.Z alone.
11
+ crew_local_version(){
12
+ local v="${1:-}" base n
13
+ v="${v//[!0-9A-Za-z.-]/}"
14
+ base="${v%%-rc.*}"
15
+ case "$base" in
16
+ *[!0-9.]*|'') return 1 ;;
17
+ [0-9]*.[0-9]*.[0-9]*.*) return 1 ;;
18
+ [0-9]*.[0-9]*.[0-9]*) ;;
19
+ *) return 1 ;;
20
+ esac
21
+ if [ "$base" != "$v" ]; then
22
+ n="${v#"$base"-rc.}"
23
+ case "$n" in ''|*[!0-9]*) return 1 ;; esac
24
+ fi
25
+ printf '%s' "$v"
26
+ }
27
+
28
+ # vcmp(a, b): 1 when a is newer, -1 when older, 0 when the same. Prepended to an awk program:
29
+ # awk -v a="$X" -v b="$Y" "$CREW_VCMP_AWK"' BEGIN { exit !(vcmp(a, b) > 0) }'
30
+ CREW_VCMP_AWK='
31
+ function vrc(v) { return match(v, /-rc\.[0-9]+$/) ? substr(v, RSTART + 4) + 0 : -1 }
32
+ function vcmp(a, b, x, y, i, ra, rb) {
33
+ ra = vrc(a); rb = vrc(b); sub(/-.*/, "", a); sub(/-.*/, "", b)
34
+ split(a, x, "."); split(b, y, ".")
35
+ for (i = 1; i <= 3; i++) { if (x[i] + 0 > y[i] + 0) return 1; if (x[i] + 0 < y[i] + 0) return -1 }
36
+ if (ra == rb) return 0
37
+ if (ra < 0) return 1
38
+ if (rb < 0) return -1
39
+ return ra > rb ? 1 : -1
40
+ }
41
+ '
@@ -4971,6 +4971,8 @@ sec "== 7u) update prompt: ASKS about a release, never installs one, never spend
4971
4971
  UPD="$(mktemp -d)"; mkdir -p "$UPD/.claude/.state" "$UPD/.claude/hooks"
4972
4972
  printf '3.0.0\n' > "$UPD/.claude/VERSION"
4973
4973
  UH="$HOOKS/session-update-check.sh"; cp "$UH" "$UPD/.claude/hooks/"
4974
+ # The hook reads and orders the install's version through eval/lib/version.sh, beside it in an install.
4975
+ mkdir -p "$UPD/.claude/eval/lib"; cp "$ROOT/eval/lib/version.sh" "$UPD/.claude/eval/lib/"
4974
4976
  uc(){ ( printf '{"hook_event_name":"SessionStart","source":"startup","cwd":"%s"}' "$UPD" \
4975
4977
  | env -u CI -u CREW_NO_UPDATE_CHECK -u CREW_LANG CLAUDE_CODE_SESSION_ATTENDED=1 LANG=C LC_ALL= LC_MESSAGES= \
4976
4978
  CLAUDE_PROJECT_DIR="$UPD" CREW_UPDATE_URL="${1:-http://10.255.255.1/blackhole}" bash "$UH" 2>/dev/null ); }
@@ -5099,10 +5101,25 @@ cp "$UH" "$UPD/uh-twin.sh"; printf 'npx --yes crewforth@latest update --here --y
5099
5101
  if [ "$_ni" = 0 ] && [ "$(_noinst "$UPD/uh-twin.sh")" -ge 1 ]; then pass "the update hook runs no installer (npx, npm, adopt.sh, start.sh): 0 lines; the twin with one is caught"
5100
5102
  else fail "the update hook contains an installer call ($_ni line(s)) — it must only ask, or the twin was not caught"; fi
5101
5103
 
5104
+ # 12b) AN INSTALLED RC IS OFFERED ITS FINAL. The installer copies VERSION into the project, and an rc's says
5105
+ # X.Y.Z-rc.N: 3.2.0 published over an installed 3.2.0-rc.1 is newer (it used to read as the same 3.2.0, and the
5106
+ # rc's user was never told the final shipped). An older final is not offered, and what the network answers is
5107
+ # still held to X.Y.Z: an rc off the network is no version.
5108
+ printf '3.2.0-rc.1\n' > "$UPD/.claude/VERSION"
5109
+ ucache 3.2.0
5110
+ case "$(uc)" in *'"Crewforth v3.2.0 is out (installed: v3.2.0-rc.1). Update now?"'*) pass "an installed 3.2.0-rc.1 is offered the final 3.2.0" ;;
5111
+ *) fail "an installed 3.2.0-rc.1 was not offered the final 3.2.0 (got: $(uc | head -c 120))" ;; esac
5112
+ ucache 3.1.1
5113
+ [ -z "$(uc)" ] && pass "an installed rc is not offered an older final (3.1.1 under 3.2.0-rc.1)" || fail "3.1.1 was offered over an installed 3.2.0-rc.1"
5114
+ ucache 3.2.0-rc.2
5115
+ [ -z "$(uc)" ] && pass "an rc answered off the network is no version: nothing asked" || fail "a pre-release from the network was put in a question"
5116
+ printf '3.0.0\n' > "$UPD/.claude/VERSION"; ustate
5117
+
5102
5118
  # 13) THE PLUGIN EDITION. Its version is its own manifest, its release is the marketplace repo's copy, its state is
5103
5119
  # user-level (a plugin serves every project), and its Update path is the plugin command — measured to work from
5104
5120
  # inside a session, applying on restart.
5105
- PLG="$UPD/plugin"; mkdir -p "$PLG/.claude-plugin" "$PLG/hooks"; cp "$UH" "$PLG/hooks/"
5121
+ PLG="$UPD/plugin"; mkdir -p "$PLG/.claude-plugin" "$PLG/hooks" "$PLG/eval/lib"; cp "$UH" "$PLG/hooks/"
5122
+ cp "$ROOT/eval/lib/version.sh" "$PLG/eval/lib/"
5106
5123
  printf '{"name":"crewforth","version":"3.0.0"}\n' > "$PLG/.claude-plugin/plugin.json"
5107
5124
  XDG="$UPD/xdg"; mkdir -p "$XDG/crewforth"
5108
5125
  printf '3.1.0 %s\n' "$(date +%s)" > "$XDG/crewforth/update-check"
@@ -8218,7 +8235,7 @@ _v2new(){ rm -rf "$_v2p" "$_v2/home"; mkdir -p "$_v2/home" "$_v2p/.claude" "$_v2
8218
8235
  _v2e(){ env -u CREW_MODEL_ROUTING -u CREW_ALLOW_FABLE -u CREW_MODEL_CAL_N -u CREW_MODEL_CAL_PCT -u CREW_VERIFY_NO_SETSID -u CREW_VERIFY_TIMEOUT -u CLAUDE_CONFIG_DIR -u CREW_MANAGED_SETTINGS HOME="$_v2/home" CLAUDE_PROJECT_DIR="$_v2p" CREW_GATE_LOG=/dev/null ${_V2ENV:-} "$@"; }
8219
8236
  # $1 agent, $2 model, $3 files, $4 change, $5 verify, $6 extra fields -> _v2rc, stderr in $_v2/err
8220
8237
  _v2call(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"description":"d","prompt":"files: %s\\nchange: %s\\nverify: %s\\n\\nDo the work.","subagent_type":"%s","model":"%s"%s},"tool_use_id":"tu%s"}' "$3" "$4" "$5" "$1" "$2" "${6:-}" "$RANDOM$RANDOM" > "$_v2/c.json"
8221
- ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/c.json" >/dev/null 2>"$_v2/err" ); _v2rc=$?; }
8238
+ ( cd "$_v2p" && _v2e bash "$_V2G" < "$_v2/c.json" >"$_v2/out" 2>"$_v2/err" ); _v2rc=$?; }
8222
8239
  _v2start(){ printf '{"session_id":"s","hook_event_name":"SubagentStart","agent_id":"%s","agent_type":"%s"}' "$1" "${2:-crew-backend-expert}" | ( cd "$_v2p" && _v2e bash "$_V2O" >/dev/null 2>&1 ); }
8223
8240
  _v2stop(){ printf '{"session_id":"s","permission_mode":"%s","hook_event_name":"SubagentStop","agent_id":"%s","agent_type":"x","stop_hook_active":false,"agent_transcript_path":"%s/none.jsonl","last_assistant_message":"did it\\n%s"}' "${_V2PM:-auto}" "$1" "$_v2" "${2:-confidence: high}" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
8224
8241
  _v2post(){ printf '{"session_id":"s","hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"p"},"tool_response":{"status":"completed","agentId":"%s","content":"x"}}' "$1" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
@@ -8300,6 +8317,45 @@ V2
8300
8317
  if [ "$_v2n" != 60 ]; then fail "FIXTURE: the card table has $_v2n rows, not 60"
8301
8318
  elif [ -z "$_v2b" ]; then pass "the card decides the model: critical work (payments/, migrations, a .sql file, AuthController, userAuth, schema.prisma, and the four critical kinds of change) is refused below opus and passes on opus; verify none is refused on haiku; test-run passes on haiku, also on a critical path; test-write, audit and review are held to sonnet in normal work and to opus in critical work; critical work with a verify command is refused when the call says run_in_background true, and passes with false and with the field left out (a session whose Agent tool has no such field cannot send it); a card that is not one is refused; a verify command never refuses the call, whatever it is (the hook decides whether to run it) (47 rows)"
8302
8319
  else fail "card table:$_v2b"; fi
8320
+ # The card's floor is also the default: a call above it runs ON it (the gate hands back the same input with `model`
8321
+ # replaced, PreToolUse updatedInput), unless the card's next line says `above: <why>`. Measured on a real project:
8322
+ # 73 of 186 crew tasks ran above their floor and none on haiku; measured on Claude Code 2.1.299: the rewrite holds.
8323
+ _v2new; _v2b=""
8324
+ _v2last(){ ls -t "$_v2p/.claude/state/crew-model/pending/"* 2>/dev/null | head -1; }
8325
+ _v2call crew-review-agent opus 'src/ui/a.ts' audit none ',"run_in_background":true,"isolation":"worktree"'
8326
+ _o="$(cat "$_v2/out" 2>/dev/null)"; _r="$(_v2last)"
8327
+ case "$_o" in *'"permissionDecision":"allow"'*'"updatedInput":'*) ;; *) _v2b="$_v2b [above the floor, no above: -> no rewrite: rc=$_v2rc ${_o:0:80}]" ;; esac
8328
+ _u="$(printf '%s' "$_o" | awk -v op=get -v path=hookSpecificOutput.updatedInput -f "$ROOT/eval/lib/settings-json.awk" - 2>/dev/null | tr -d ' \n')"
8329
+ case "$_u" in *'"model":"sonnet"'*'"run_in_background":true'*'"isolation":"worktree"'*|*'"run_in_background":true'*'"isolation":"worktree"'*) ;; *) _v2b="$_v2b [the rewrite lost a field: ${_u:0:120}]" ;; esac
8330
+ case "$_u" in *'"model":"sonnet"'*) ;; *) _v2b="$_v2b [the rewrite is not to the floor: ${_u:0:120}]" ;; esac
8331
+ { grep -q '^tier=sonnet$' "$_r" && grep -q '^lowered=opus$' "$_r"; } 2>/dev/null || _v2b="$_v2b [record: $(tr '\n' ' ' < "$_r" 2>/dev/null | cut -c1-80)]"
8332
+ rm -f "$_v2p/.claude/state/crew-model/pending/"*
8333
+ _v2call crew-review-agent opus 'src/ui/a.ts' audit 'none\nabove: the audit covers the whole session layer'
8334
+ { [ "$_v2rc" = 0 ] && [ ! -s "$_v2/out" ] && grep -q '^tier=opus$' "$(_v2last)"; } 2>/dev/null || _v2b="$_v2b [above: did not keep opus: rc=$_v2rc $(head -c 60 "$_v2/out")]"
8335
+ rm -f "$_v2p/.claude/state/crew-model/pending/"*
8336
+ _v2call crew-review-agent sonnet 'src/ui/a.ts' audit none
8337
+ { [ "$_v2rc" = 0 ] && [ ! -s "$_v2/out" ]; } || _v2b="$_v2b [a call on its floor was rewritten: $(head -c 60 "$_v2/out")]"
8338
+ _v2call crew-frontend-expert sonnet 'src/ui/a.ts' text 'test -d src'
8339
+ case "$(tr -d ' ' < "$_v2/out")" in *'"model":"haiku"'*) ;; *) _v2b="$_v2b [work a command checks did not go to haiku: $(head -c 80 "$_v2/out")]" ;; esac
8340
+ rm -f "$_v2p/.claude/state/crew-model/pending/"*
8341
+ # Not a crew agent: no card and no floor, only the expensive end is held. opus or fable with no `above:` line in the
8342
+ # task runs on sonnet; with one it stays; a call that names no model, or sonnet, is not touched.
8343
+ _v2o(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"description":"d","prompt":"Find where X is defined.%s","subagent_type":"general-purpose"%s},"tool_use_id":"tu%s"}' "$1" "$2" "$RANDOM" | ( cd "$_v2p" && _v2e bash "$_V2G" 2>/dev/null ) | tr -d ' '; }
8344
+ case "$(_v2o '' ',"model":"opus"')" in *'"updatedInput":'*'"model":"sonnet"'*) ;; *) _v2b="$_v2b [general-purpose on opus was not run on sonnet]" ;; esac
8345
+ [ -z "$(_v2o '\nabove: a design question across the codebase' ',"model":"opus"')" ] || _v2b="$_v2b [above: did not keep opus for general-purpose]"
8346
+ [ -z "$(_v2o '' ',"model":"sonnet"')" ] || _v2b="$_v2b [a call naming sonnet was rewritten]"
8347
+ # Naming no model: general-purpose takes the session's model, so it is given sonnet (CLAUDE_CODE_SUBAGENT_MODEL in
8348
+ # the project's settings env did not do it, measured on 2.1.299), unless the session is on haiku; an agent whose
8349
+ # definition names its own model is left to it; a project agent with no model line is treated like general-purpose.
8350
+ _v2ot(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"PreToolUse","tool_name":"Agent","transcript_path":"%s","tool_input":{"description":"d","prompt":"Find X.","subagent_type":"%s"},"tool_use_id":"tu%s"}' "$2" "$1" "$RANDOM" | ( cd "$_v2p" && _v2e bash "$_V2G" 2>/dev/null ) | tr -d ' '; }
8351
+ printf '{"type":"assistant","message":{"model":"claude-opus-5-5"}}\n' > "$_v2/tr-opus.jsonl"; printf '{"type":"assistant","message":{"model":"claude-haiku-5-5"}}\n' > "$_v2/tr-haiku.jsonl"
8352
+ mkdir -p "$_v2p/.claude/agents"; printf -- '---\nname: mine\ndescription: x\n---\nDo it.\n' > "$_v2p/.claude/agents/mine.md"; printf -- '---\nname: cheap\nmodel: haiku\n---\nDo it.\n' > "$_v2p/.claude/agents/cheap.md"
8353
+ case "$(_v2ot general-purpose "$_v2/tr-opus.jsonl")" in *'"updatedInput":'*'"model":"sonnet"'*) ;; *) _v2b="$_v2b [general-purpose naming no model under opus was not given sonnet]" ;; esac
8354
+ [ -z "$(_v2ot general-purpose "$_v2/tr-haiku.jsonl")" ] || _v2b="$_v2b [a haiku session's general-purpose was raised to sonnet]"
8355
+ [ -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]"
8356
+ case "$(_v2ot mine "$_v2/tr-opus.jsonl")" in *'"model":"sonnet"'*) ;; *) _v2b="$_v2b [a project agent with no model line was not given sonnet]" ;; esac
8357
+ [ -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" \
8358
+ || fail "model on the floor:$_v2b"
8303
8359
  # The refusal names the way forward that exists (the field was sent, so it can be sent as false), and a call that
8304
8360
  # left the field out and was launched in the background is told so when the launch comes back.
8305
8361
  _v2new; _v2b=""
@@ -8699,12 +8755,38 @@ if command -v iconv >/dev/null 2>&1; then printf '%s' "$_o" | iconv -f UTF-8 -t
8699
8755
  [ "${#_o}" -le 2600 ] || _v2b="$_v2b [the reason is ${#_o} bytes]"
8700
8756
  [ -z "$_v2b" ] && pass "the one fix is asked once and only where it can be counted: a red verify under stop_hook_active ends as fail with no block, and so does one whose counter cannot be written (both recorded as fail with no fix); the output handed to the agent is cut at 1500 bytes without splitting a character" \
8701
8757
  || fail "the stop hook's one fix:$_v2b"
8758
+ # A verify command in a sub-folder, `cd <folder> && <command>` (41 of 71 refusals on one project): judged as the
8759
+ # command and run in the folder with no chain. A folder outside the project, one that does not exist, or a second
8760
+ # chain after it stays refused. A card that says `none (why)` is none.
8761
+ _v2new; _v2b=""; mkdir -p "$_v2p/.claude/state/crew-model/agents" "$_v2p/sub"; : > "$_v2p/sub/ok.flag"
8762
+ printf 'verify test -f ok.flag\n' > "$_v2p/.claude/crew-model-rules"
8763
+ _v2plant c1 'cd sub && test -f ok.flag'; _v2stop c1 >/dev/null
8764
+ [ "$(_v2row c1 | cut -d/ -f2)" = pass ] || _v2b="$_v2b [cd sub && test: $(_v2row c1)]"
8765
+ for _cc in 'c2:cd ../sub && test -f ok.flag' 'c3:cd nowhere && test -f ok.flag' 'c4:cd sub && test -f ok.flag && touch x' 'c5:cd /tmp && test -f ok.flag'; do
8766
+ _v2plant "${_cc%%:*}" "${_cc#*:}"; _v2stop "${_cc%%:*}" >/dev/null
8767
+ [ "$(_v2row "${_cc%%:*}" | cut -d/ -f2)" = blocked ] || _v2b="$_v2b [${_cc#*:} was not refused: $(_v2row "${_cc%%:*}")]"
8768
+ done
8769
+ [ ! -e "$_v2p/sub/x" ] || _v2b="$_v2b [a chain after the folder ran]"
8770
+ _v2call crew-backend-expert sonnet 'src/ui/a.ts' text 'none (the author ran it)'
8771
+ grep -q '^verify=none$' "$(ls -t "$_v2p/.claude/state/crew-model/pending/"* 2>/dev/null | head -1)" 2>/dev/null || _v2b="$_v2b [none (why) was not none]"
8772
+ # doctor lists the commands refused for not being allowed, with the line that allows them, and not a sentence
8773
+ mkdir -p "$_v2p/.claude/state/crew-model/results"
8774
+ printf 'verify=blocked\nwhynot=it is not a test or build command on this hook'"'"'s list, and no rule names it\ncmd=node tools/build.mjs --check\n' > "$_v2p/.claude/state/crew-model/results/r1"
8775
+ printf 'verify=blocked\nwhynot=it is not a test or build command on this hook'"'"'s list, and no rule names it\ncmd=actionlint (if installed)\n' > "$_v2p/.claude/state/crew-model/results/r2"
8776
+ if [ -f "$ROOT/eval/doctor.sh" ]; then
8777
+ mkdir -p "$_v2p/.claude/eval"; cp -R "$ROOT/eval/." "$_v2p/.claude/eval/" 2>/dev/null
8778
+ _dd="$( cd "$_v2p" && env -u CLAUDE_CODE_SUBAGENT_MODEL CREW_NO_STAR=1 CREW_LANG=en CLAUDE_CONFIG_DIR="$_v2/home" bash .claude/eval/doctor.sh 2>/dev/null | grep 'verify commands never run' )"
8779
+ case "$_dd" in *'2 time(s)'*'verify node tools/build.mjs --check'*) case "$_dd" in *actionlint*) _v2b="$_v2b [doctor offered a sentence as a rule]" ;; esac ;;
8780
+ *) _v2b="$_v2b [doctor: ${_dd:-no line}]" ;; esac
8781
+ fi
8782
+ [ -z "$_v2b" ] && pass "a verify command in a sub-folder runs there: 'cd sub && <command>' passes, judged as the command alone; a folder outside the project or missing, an absolute one, and a chain after it stay refused; 'none (why)' is none; doctor offers the refused commands as rules, not a sentence" \
8783
+ || fail "verify in a sub-folder:$_v2b"
8702
8784
  # Which record is whose. Two calls of one type made together are matched by order, which can be wrong; the card in
8703
8785
  # the agent's own transcript settles it.
8704
8786
  _v2new; _v2b=""; _v2st="$_v2p/.claude/state/crew-model"
8705
8787
  _v2tr(){ printf '{"type":"user","message":{"role":"user","content":"files: %s\\nchange: %s\\nverify: %s\\n\\nDo the work."}}\n' "$2" "$3" "$4" > "$_v2/tr-$1.jsonl"; }
8706
8788
  _v2stopt(){ printf '{"session_id":"s","permission_mode":"auto","hook_event_name":"SubagentStop","agent_id":"%s","agent_type":"x","stop_hook_active":false,"agent_transcript_path":"%s/tr-%s.jsonl","last_assistant_message":"did it\\nconfidence: high"}' "$1" "$_v2" "$1" | ( cd "$_v2p" && _v2e bash "$_V2O" 2>/dev/null ); }
8707
- _v2call crew-backend-expert haiku 'src/ui/one.ts' text 'test -f one.ok'; _v2call crew-backend-expert opus 'src/ui/two.ts' feature 'test -f two.ok'
8789
+ _v2call crew-backend-expert haiku 'src/ui/one.ts' text 'test -f one.ok'; _v2call crew-backend-expert opus 'src/ui/two.ts' feature 'test -f two.ok\nabove: two models pending for one agent type'
8708
8790
  # The order the two records are taken in is made known: the haiku call's first, so each agent gets the OTHER's below.
8709
8791
  for _pf in "$_v2st/pending"/*; do if grep -q 'one.ok' "$_pf"; then mv "$_pf" "$_v2st/pending/1-a"; else mv "$_pf" "$_v2st/pending/2-b"; fi; done
8710
8792
  _pt="$(date +%s)"; mv "$_v2st/pending/1-a" "$_v2st/pending/$_pt-a"; mv "$_v2st/pending/2-b" "$_v2st/pending/$_pt-b"
@@ -8739,6 +8821,16 @@ rm -f "$_v2st"/pending/*
8739
8821
  _v2call crew-backend-expert sonnet 'src/ui/race.ts' text true
8740
8822
  _v2start r1 & _v2start r2 & wait
8741
8823
  _rn=0; [ -f "$_v2st/agents/r1" ] && _rn=$((_rn+1)); [ -f "$_v2st/agents/r2" ] && _rn=$((_rn+1)); [ "$_rn" = 1 ] || _v2b="$_v2b [one record, two agents: $_rn got it]"
8824
+ ls "$_v2st/agents" 2>/dev/null | grep -qE '\.(take|claim)$' && _v2b="$_v2b a-lock-or-claim-left-behind"
8825
+ # A record another hook holds the lock on is not taken: the agent goes on to the next one (the lock is what keeps
8826
+ # two agents off one record where a rename alone did not, on Windows).
8827
+ rm -rf "$_v2st/agents"; rm -f "$_v2st"/pending/*
8828
+ _v2call crew-backend-expert sonnet 'src/ui/l1.ts' text 'test -f l1'; _pl1="$(ls "$_v2st/pending")"
8829
+ sleep 1; _v2call crew-backend-expert sonnet 'src/ui/l2.ts' text 'test -f l2'
8830
+ mkdir -p "$_v2st/agents"; : > "$_v2st/agents/$_pl1.take"
8831
+ _v2start k1
8832
+ { grep -q '^verify=test -f l2' "$_v2st/agents/k1" 2>/dev/null && [ -f "$_v2st/pending/$_pl1" ]; } || _v2b="$_v2b a-locked-record-was-taken-or-the-next-one-was-not"
8833
+ rm -f "$_v2st/agents/$_pl1.take" "$_v2st"/pending/*
8742
8834
  # A record nothing claimed within five minutes is nobody's.
8743
8835
  mkdir -p "$_v2st/pending"; printf 'agent=crew-backend-expert\ntier=opus\nchange=text\nrisk=normal\ncard=old\nbg=unset\nesc=-\nverify=none\n' > "$_v2st/pending/1000000000-tuold"
8744
8836
  _v2start o1; { [ ! -f "$_v2st/agents/o1" ] && [ ! -f "$_v2st/pending/1000000000-tuold" ]; } || _v2b="$_v2b a-stale-record-was-given-to-an-agent-or-kept"
@@ -11375,6 +11467,22 @@ $(printf '%s\n%s\n%s\n' "$_ka" "$_kb" "$_kc" | grep . | head -n 6 | sed "s|$KR/|
11375
11467
  [ -n "$(_dn "$_kd/done-twin.sh")" ] && pass "the installers' closing lines name no kit (EN key and TR value); a planted 'kit/ deleted' is caught" \
11376
11468
  || fail "twin: the closing-line check missed a planted 'kit/ deleted' — it reads nothing"
11377
11469
  fi
11470
+ # adopt.sh --help answers before it reads anything. An unknown flag is ignored further down, so --help used to run
11471
+ # the detection (seen on Windows in the 3.2.0-rc.1 trial). Read in a git repo holding only adopt.sh: no kit/, both
11472
+ # languages, and nothing written. Twin: the same script without its --help branch must not print the usage.
11473
+ _ah_run(){ ( cd "$1" && CREW_LANG=en bash adopt.sh --help; echo "rc=$?"; bash adopt.sh -h --lang tr; echo "rc=$?" ) 2>&1; }
11474
+ mkdir -p "$_kd/ah" "$_kd/ah-twin"; git init -q "$_kd/ah" 2>/dev/null; git init -q "$_kd/ah-twin" 2>/dev/null
11475
+ cp "$KR/adopt.sh" "$_kd/ah/"; sed 's/-h|--help) _hh=1 ;;//' "$KR/adopt.sh" > "$_kd/ah-twin/adopt.sh"
11476
+ _ah="$(_ah_run "$_kd/ah")"; _aht="$(_ah_run "$_kd/ah-twin")"
11477
+ _ahw="$(git -C "$_kd/ah" status --porcelain 2>/dev/null | grep -vx '?? adopt.sh')"
11478
+ case "$_ah" in
11479
+ *'Usage: bash adopt.sh'*'rc=0'*'Kullanım: bash adopt.sh'*'rc=0'*)
11480
+ if [ -n "$_ahw" ]; then fail "adopt.sh --help wrote into the project: $(printf '%s' "$_ahw" | head -n 2 | tr '\n' ' ')"
11481
+ else case "$_aht" in *'Usage: bash adopt.sh'*) fail "twin: adopt.sh without its --help branch still printed the usage — the check reads nothing" ;;
11482
+ *) pass "adopt.sh --help prints its usage in English and Turkish, rc 0, with no kit/ and nothing written (a twin without it does not)" ;; esac
11483
+ fi ;;
11484
+ *) fail "adopt.sh --help did not print its usage in both languages with rc 0: $(printf '%s' "$_ah" | head -n 3 | tr '\n' ' ')" ;;
11485
+ esac
11378
11486
  rm -rf "$_kd"
11379
11487
  else
11380
11488
  skip scope "printed-text wording check skipped (installed project — the installers live in the source repository)"
@@ -204,27 +204,36 @@ case "$EV" in
204
204
  low=9; cands=""; if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v NOWS '%(%s)T' -1; else NOWS="$(date +%s)"; fi
205
205
  for f in "$_AMD/pending"/*; do
206
206
  [ -f "$f" ] || continue
207
- t="${f##*/}"; t="${t%%-*}"; case "$t" in ''|*[!0-9]*) ;; *) if [ $((NOWS - t)) -gt 300 ]; then rm -f "$f" 2>/dev/null; continue; fi ;; esac
207
+ t="${f##*/}"; t="${t%%-*}"; case "$t" in ''|*[!0-9]*) ;; *) if [ $((NOWS - t)) -gt 300 ]; then rm -f "$f" "$_AMD/agents/${f##*/}.take" 2>/dev/null; continue; fi ;; esac
208
208
  _am_rec_get "$f" agent || continue; [ "$_AMV" = "$AT" ] || continue
209
209
  cands="$cands$f"$'\n'
210
210
  _am_rec_get "$f" tier && { _am_rank "$_AMV"; [ "$_AMR" -lt "$low" ] && low="$_AMR"; }
211
211
  done
212
212
  [ -n "$cands" ] || exit 0
213
213
  mkdir -p "$_AMD/agents" 2>/dev/null || exit 0
214
- # TAKEN BY RENAME. Two agents of one type start at the same moment, each in its own hook process, and both see
215
- # the same oldest record. A rename either happens or fails, so exactly one of them gets it; the other goes on
216
- # to the next record. (Reading it and deleting it afterwards gave both agents the same record.)
217
- claim="$_AMD/agents/$AID.claim"; got=0
214
+ # TAKEN UNDER A LOCK, THEN BY RENAME. Two agents of one type start at the same moment, each in its own hook
215
+ # process, and both see the same oldest record. (Reading it and deleting it afterwards gave both the same record.)
216
+ # A rename alone was the lock on POSIX, and was not on Windows: two hook processes both reported the same rename
217
+ # as done (smoke, windows-latest, "2 got it", twice). Why is not measured; a rename there goes through an open
218
+ # handle, which two processes can both hold. So each record is first locked by creating <record>.take with
219
+ # O_EXCL (bash's noclobber, a builtin: no process); only the one that made the lock renames, the others go on to
220
+ # the next record.
221
+ claim="$_AMD/agents/$AID.claim"; got=0; take=""
222
+ set -C
218
223
  while IFS= read -r f; do
219
224
  [ -n "$f" ] || continue
225
+ take="$_AMD/agents/${f##*/}.take"
226
+ { : > "$take"; } 2>/dev/null || { take=""; continue; }
220
227
  if mv "$f" "$claim" 2>/dev/null; then got=1; break; fi
228
+ rm -f "$take" 2>/dev/null; take="" # locked, but the record had gone (dropped as stale): let it be
221
229
  done <<< "$cands"
230
+ set +C
222
231
  [ "$got" = 1 ] || exit 0
223
232
  _am_rec_get "$claim" tier; _am_rank "$_AMV"
224
233
  if [ "$low" -lt "$_AMR" ]; then _am_name "$low"
225
234
  { grep -v '^tier=' "$claim"; printf 'asked=%s\ntier=%s\nambiguous=1\n' "$_AMV" "$_AMN"; } > "$_AMD/agents/$AID" 2>/dev/null
226
235
  else { cat "$claim"; printf 'asked=%s\n' "$_AMV"; } > "$_AMD/agents/$AID" 2>/dev/null; fi
227
- rm -f "$claim" 2>/dev/null
236
+ rm -f "$claim" "$take" 2>/dev/null
228
237
  exit 0 ;;
229
238
 
230
239
  SubagentStop)
@@ -290,7 +299,9 @@ case "$EV" in
290
299
  RES=none; WHYNOT=""
291
300
  if [ -n "$VCMD" ] && [ "$VCMD" != none ]; then
292
301
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
293
- if ! _am_verify_ok "$VCMD" "$PM"; then
302
+ # `cd <folder> && <command>` is judged as the command and run in the folder (guard-agent-model.sh).
303
+ _am_verify_split "$VCMD"; VDIR="$_AMVD"; VRUN="$_AMVC"
304
+ if ! _am_verify_ok "$VRUN" "$PM"; then
294
305
  # Judged again here, at the moment it would run: the gate judged it when the agent was called, and this
295
306
  # is the place the command actually starts. Not run; nothing is concluded about the work.
296
307
  RES=blocked; WHYNOT="$_AMVW"; VRC=0
@@ -305,11 +316,11 @@ case "$EV" in
305
316
  # control, which gives a background job its own group in bash (macOS ships no setsid). On Windows the tree
306
317
  # is ended by its Windows process id.
307
318
  if [ -z "${CREW_VERIFY_NO_SETSID:-}" ] && command -v setsid >/dev/null 2>&1; then
308
- ( cd "$_AMP" 2>/dev/null && exec setsid bash -c "$VCMD" ) > "$VF" 2>&1 </dev/null &
319
+ ( cd "$_AMP/$VDIR" 2>/dev/null && exec setsid bash -c "$VRUN" ) > "$VF" 2>&1 </dev/null &
309
320
  VPID=$!
310
321
  else
311
322
  set -m
312
- ( cd "$_AMP" 2>/dev/null && exec bash -c "$VCMD" ) > "$VF" 2>&1 </dev/null &
323
+ ( cd "$_AMP/$VDIR" 2>/dev/null && exec bash -c "$VRUN" ) > "$VF" 2>&1 </dev/null &
313
324
  VPID=$!
314
325
  set +m
315
326
  fi
@@ -414,9 +414,9 @@ _am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 n
414
414
 
415
415
  # ---- THE CARD ---------------------------------------------------------------------------------------------------
416
416
  _AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
417
- _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1 and CARD_WHY
417
+ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY (CARD_ABOVE); 1 and CARD_WHY
418
418
  local s="$1" l k v n=0 seen=""
419
- CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""
419
+ CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""; CARD_ABOVE=""
420
420
  s="${s//$'\r'/}"
421
421
  while [ "$n" -lt 3 ] && [ -n "$s" ]; do
422
422
  l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
@@ -440,7 +440,16 @@ _am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1
440
440
  [ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
441
441
  case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
442
442
  [ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
443
- case "$CARD_VERIFY" in none|None|NONE|-) CARD_VERIFY=none ;; esac
443
+ case "$CARD_VERIFY" in none|None|NONE|-|'none '*|'none('*|'None '*|'NONE '*) CARD_VERIFY=none ;; esac # "none (the author ran it)" is none
444
+ # Optional, right after the three: `above: <why>` keeps a model above the card's floor. Without it a call above
445
+ # the floor is run on the floor (the cost of a model nobody needed is what this gate exists to stop).
446
+ while [ -n "$s" ]; do
447
+ l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
448
+ while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
449
+ [ -n "$l" ] || continue
450
+ case "$l" in above:*) v="${l#above:}"; while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done; CARD_ABOVE="${v:0:200}" ;; esac
451
+ break
452
+ done
444
453
  return 0
445
454
  }
446
455
  _am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
@@ -460,6 +469,53 @@ _am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "th
460
469
  case "$t" in ''|*[!0-9a-f]*) t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | cksum 2>/dev/null)"; t="${t%% *}"; printf -v t '%012x' "${t:-0}" 2>/dev/null || t=000000000000 ;; esac
461
470
  CARD_ID="${t:0:12}"
462
471
  }
472
+ # A call to an agent that is not Crewforth's (general-purpose, a project's own): no card and no floor, so only the
473
+ # expensive end is held, the same rewrite as a crew call above its floor.
474
+ # * It names opus or fable, and its task has no `above: <why>` line: it runs on sonnet.
475
+ # * It names no model, and the agent takes the session's (general-purpose, or a project or user agent whose
476
+ # definition has no model of its own): it runs on sonnet, unless the session itself is on haiku, read from the
477
+ # end of its transcript. An agent whose definition names a model (Explore, claude-code-guide) is left to it:
478
+ # a model in the call would override a cheaper one there.
479
+ # Measured on 2.1.299: a general-purpose call that named no model ran on the session's model; CLAUDE_CODE_SUBAGENT_MODEL
480
+ # in the project's settings `env` did not change that (in the shell's environment it did), so the hook sets it.
481
+ _am_outside(){ # $1 = the tool_input text, $2 = the agent's name
482
+ local m pr to=sonnet ui name="$2" f d l ln inherit=0 tp last
483
+ case "$name" in ''|*[!A-Za-z0-9._-]*) name="an agent" ;; esac
484
+ _json_keycount "$1" prompt; [ "$_KC" = 1 ] || return 0
485
+ _json_slice "$1" prompt >/dev/null; pr="${_JS:0:6000}"; _json_unescape "$pr" >/dev/null; pr=$'\n'"${_JU//$'\r'/}"
486
+ case "$pr" in *$'\n'above:*|*$'\n'[$' \t']above:*) return 0 ;; esac
487
+ _json_keycount "$1" model
488
+ if [ "$_KC" = 0 ]; then
489
+ case "$name" in
490
+ general-purpose) inherit=1 ;;
491
+ 'an agent') ;;
492
+ *) for d in "${CLAUDE_PROJECT_DIR:-.}/.claude/agents" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/agents"; do
493
+ f="$d/$name.md"; [ -f "$f" ] || continue
494
+ inherit=1; ln=0
495
+ while IFS= read -r l || [ -n "$l" ]; do
496
+ ln=$((ln+1)); [ "$ln" -le 40 ] || break
497
+ case "$l" in ---*) [ "$ln" -gt 1 ] && break ;; model:*) case "${l#model:}" in *inherit*|'') ;; *) inherit=0 ;; esac ;; esac
498
+ done < "$f"
499
+ break
500
+ done ;;
501
+ esac
502
+ [ "$inherit" = 1 ] || return 0
503
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="$_JU"
504
+ if [ -n "$tp" ] && [ -f "$tp" ]; then
505
+ last="$(tail -c 65536 "$tp" 2>/dev/null)"; last="${last##*\"model\":\"}"; last="${last%%\"*}"
506
+ case "$last" in *haiku*) return 0 ;; esac
507
+ fi
508
+ m="the session's model"
509
+ else
510
+ [ "$_KC" = 1 ] || return 0
511
+ _json_slice "$1" model >/dev/null; _json_unescape "$_JS" >/dev/null; _am_tier "$_JU"; m="$_AMT"
512
+ case "$m" in opus|fable) ;; *) return 0 ;; esac
513
+ fi
514
+ 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
515
+ case "$ui" in '{'*'}') ;; *) return 0 ;; esac
516
+ _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"
517
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"Crewforth: %s asked for %s and runs on %s (a line above: <why> in its task keeps a model above it)","updatedInput":%s}}\n' "$name" "$m" "$to" "$ui"
518
+ }
463
519
  _am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
464
520
  local l; _AMV=""
465
521
  [ -f "$1" ] || return 1
@@ -609,6 +665,19 @@ _am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of th
609
665
  done <<< "$_AMSF"
610
666
  return 1
611
667
  }
668
+ # A verify command in a sub-folder: `cd apps/mobile && flutter test`, the usual shape in a repository of several
669
+ # projects (measured on one: 41 of 71 refused verify commands had it). Split into the folder and the one command,
670
+ # so the command is judged alone and run in that folder with no chain. Only a relative folder inside the project,
671
+ # with no .. step, that exists; anything else is left whole, and is refused as a chain as before.
672
+ _am_verify_split(){ # $1 = the command -> _AMVD the folder ("" = the project root), _AMVC the command
673
+ local c="$1" d r
674
+ _AMVD=""; _AMVC="$c"
675
+ case "$c" in 'cd '*' && '*) ;; *) return 0 ;; esac
676
+ d="${c#cd }"; d="${d%% && *}"; r="${c#cd "$d" && }"
677
+ case "$d" in ''|/*|~*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 0 ;; esac
678
+ [ -n "$r" ] && [ -d "${_AMP:-.}/$d" ] || return 0
679
+ _AMVD="$d"; _AMVC="$r"
680
+ }
612
681
  _am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
613
682
  local c="$1" pm="${2:-default}" f r rc k g
614
683
  _AMVW=""; _AMVB=""
@@ -729,7 +798,7 @@ _json_keycount "$ti" subagent_type
729
798
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names subagent_type more than once, so which agent it starts cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
730
799
  _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
731
800
  st="${st##*:}" # a plugin names its agents <plugin>:crew-…
732
- case "$st" in crew-*) ;; *) exit 0 ;; esac
801
+ case "$st" in crew-*) ;; *) _am_outside "$ti" "$st"; exit 0 ;; esac
733
802
  case "$st" in *[!A-Za-z0-9_-]*) echo "GUARD (agent model): the agent's name holds a character this gate does not read, so the call is refused." >&2; _am_log "an agent name it cannot read"; exit 2 ;; esac
734
803
  _json_keycount "$ti" model
735
804
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
@@ -802,6 +871,23 @@ if [ "$rank" -lt "$floor" ]; then
802
871
  echo "GUARD (agent model): $st runs on $fname or above for this card ($why), and this call asks for $m. Repeat the same call with model $fname." >&2
803
872
  _am_log "below the floor of the card"; exit 2
804
873
  fi
874
+ # ---- above the floor: run on the floor, unless the card says why not ----
875
+ # The floor is what the work needs; a model above it is cost nobody asked for (measured on a real project: 73 of 186
876
+ # crew tasks ran above their floor, 0 on haiku). Unless the card carries `above: <why>`, the call is let through ON
877
+ # THE FLOOR: the hook hands Claude Code the same input with `model` replaced (PreToolUse updatedInput; measured on
878
+ # 2.1.299: a call asking for opus ran on haiku alone). The rewrite is one awk over the payload, on this path only.
879
+ lowered=""
880
+ low=$floor; [ "$low" -lt 1 ] && low=1
881
+ if [ "$rank" -gt "$low" ] && [ -z "$CARD_ABOVE" ]; then
882
+ _am_name "$low"; to="$_AMN"
883
+ 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)" || ui=""
884
+ case "$ui" in '{'*'}') ;; *) ui="" ;; esac
885
+ if [ -n "$ui" ]; then
886
+ lowered="$m"
887
+ _am_log "note: $st asked for $m, run on $to, the floor of this card (${why:-no floor above haiku}); add 'above: <why>' to the card to keep $m"
888
+ m="$to"
889
+ fi
890
+ fi
805
891
  # ---- where it runs, and what its verify command is ----
806
892
  _json_keycount "$ti" run_in_background
807
893
  if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names run_in_background more than once, so where it runs cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
@@ -828,7 +914,11 @@ if mkdir -p "$_AMD/pending" 2>/dev/null; then
828
914
  # Why the work counts as critical, as the gate decided it, so a reader shows the reason and does not work it out
829
915
  # again: one line, no tab (it ends up in a column of model-outcomes.tsv).
830
916
  rw="${CARD_RISKWHY:--}"; rw="${rw//$'\t'/ }"; rw="${rw//$'\r'/ }"; rw="${rw//$'\n'/ }"
831
- { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\nwhy=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" "$rw" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
917
+ { printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\nlowered=%s\nwhy=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" "${lowered:--}" "$rw" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
918
+ fi
919
+ # The call, on the floor: the same input with `model` replaced, and allowed. Nothing else is printed on this path.
920
+ if [ -n "$lowered" ]; then
921
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"Crewforth: %s asked for %s and runs on %s, the floor of its card (add above: <why> to the card to keep %s)","updatedInput":%s}}\n' "$st" "$lowered" "$m" "$lowered" "$ui"
832
922
  fi
833
923
  exit 0
834
924
  }
@@ -44,7 +44,9 @@
44
44
  set -uo pipefail
45
45
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
46
46
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
47
- [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
47
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
48
+ # How an install's own version is read and ordered (an rc before its final): one definition, eval/lib/version.sh.
49
+ [ -f "$_crew_d/../eval/lib/version.sh" ] && . "$_crew_d/../eval/lib/version.sh"; unset _crew_d
48
50
 
49
51
  URL="${CREW_UPDATE_URL:-https://registry.npmjs.org/-/package/crewforth/dist-tags}"
50
52
  # plugin-stable, not main: the marketplace installs the plugin from that branch, and only an approved release
@@ -139,7 +141,10 @@ PJSON="$PR_ROOT/.claude-plugin/plugin.json"
139
141
  # plugin's copy steps aside whenever a project install is present, instead of asking the project's question too.
140
142
  if [ "$IS_PLUGIN" = 1 ] && [ -f "$CL/VERSION" ]; then exit 0; fi
141
143
  if [ -f "$CL/VERSION" ]; then
142
- read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""; CUR="$(sane_version "$_cv")" || exit 0
144
+ # The install's own file, so an rc (X.Y.Z-rc.N) is a version here; what npm answers is still held to X.Y.Z.
145
+ read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""
146
+ declare -F crew_local_version >/dev/null || exit 0
147
+ CUR="$(crew_local_version "$_cv")" || exit 0
143
148
  STATE="$CL/.state" # repo-local: the install lives in the repo
144
149
  FEED="${CREW_UPDATE_URL:-$URL}"; KEY=latest; EDITION=project
145
150
  ANSWER='bash .claude/hooks/session-update-check.sh --answer'
@@ -186,12 +191,8 @@ fi
186
191
  [ -n "$LATEST" ] || exit 0
187
192
  LATEST="$(sane_version "$LATEST")" || exit 0
188
193
 
189
- # Numeric field compare, one awk. Pre-release suffixes degrade to their numeric prefix (`1-rc2` -> 1), which is
190
- # the harmless direction: it can suppress a question, never invent one.
191
- awk -v a="$LATEST" -v b="$CUR" 'BEGIN{
192
- split(a,x,"."); split(b,y,".");
193
- for(i=1;i<=3;i++){ if(x[i]+0 > y[i]+0) exit 0; if(x[i]+0 < y[i]+0) exit 1 }
194
- exit 1 }' || exit 0
194
+ # One awk, eval/lib/version.sh's order: 3.2.0 is newer than an installed 3.2.0-rc.1, so the rc's user is asked.
195
+ awk -v a="$LATEST" -v b="$CUR" "${CREW_VCMP_AWK:-}"' BEGIN { exit !(vcmp(a, b) > 0) }' 2>/dev/null || exit 0
195
196
 
196
197
  # Skipped for good? Asked (or answered "later") within the last day?
197
198
  if [ -f "$STATE/update-skip" ]; then read -r _sk < "$STATE/update-skip" 2>/dev/null || _sk=""; [ "${_sk//[!0-9.]/}" = "$LATEST" ] && exit 0; fi
@@ -21,6 +21,8 @@ import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentM
21
21
  import { agentOrigins } from './lib/agent-origin.js';
22
22
  import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
23
23
  import { cachedUsage } from './lib/usage.js';
24
+ import { streamOwned } from './lib/owned-stream.js';
25
+ import { stampAtStart } from './lib/code-stamp.js';
24
26
  import { readModels, readRecord, readCosts, readAboveCosts } from './lib/model-outcomes.js';
25
27
  import { readMap, behindOf, compareMaps, mapSummary, MAP_FILE } from './lib/crew-map.js';
26
28
 
@@ -42,6 +44,8 @@ import { spawn } from 'node:child_process';
42
44
 
43
45
  const HERE = path.dirname(fileURLToPath(import.meta.url));
44
46
  const WEB_ROOT = path.resolve(HERE, '..', 'web');
47
+ // The server's own code as it was when it started: an update on disk since then is said to the page (code-stamp.js).
48
+ const codeChanged = stampAtStart(HERE);
45
49
  const LOOPBACK = '127.0.0.1';
46
50
 
47
51
  const MIME = {
@@ -261,6 +265,9 @@ export async function handle(req, res) {
261
265
  node: process.version,
262
266
  pid: process.pid,
263
267
  uptimeMs: Math.round(process.uptime() * 1000),
268
+ // `changed`: the server's files on disk are not the ones it started with. A page that finds no `code` at all
269
+ // is talking to a server older than this field, which is the same thing.
270
+ code: codeChanged(),
264
271
  });
265
272
  }
266
273
 
@@ -603,6 +610,10 @@ export async function handle(req, res) {
603
610
  return sendJson(res, out.ok ? 200 : 400, out);
604
611
  }
605
612
 
613
+ // Every open conversation on ONE connection (lib/owned-stream.js). Reading, so it sits beside /events, not
614
+ // behind the write gate.
615
+ if (url.pathname === '/api/owned-events') return streamOwned(req, res, url, { getSession, stateAfter: STATE_AFTER });
616
+
606
617
  const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
607
618
  if (ownedMatch) {
608
619
  const s = getSession(decodeURIComponent(ownedMatch[1]));
@@ -0,0 +1,35 @@
1
+ // code-stamp.js — is the code this server runs still the code on disk?
2
+ //
3
+ // A server keeps the code it started with. An update replaces the files on disk, the page is served fresh from
4
+ // them, and the two disagree: measured in a real project, a server started before 3.2.0 answered the new Map
5
+ // view's /api/map with 404, which the view showed as "Not read — HTTP 404". The stamp is the size and mtime of
6
+ // every file of the server, taken at start and again when asked; when they differ, the page says to restart.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ /** Size and mtime of every .js/.mjs/.sh file under `dir` (the server's own code), as one string. */
11
+ export function codeStamp(dir) {
12
+ const parts = [];
13
+ const walk = (d) => {
14
+ let entries;
15
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
16
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
17
+ const p = path.join(d, e.name);
18
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); continue; }
19
+ if (!/\.(m?js|sh)$/.test(e.name)) continue;
20
+ try { const st = fs.statSync(p); parts.push(`${path.relative(dir, p)}:${st.size}:${Math.round(st.mtimeMs)}`); } catch { /* gone */ }
21
+ }
22
+ };
23
+ walk(dir);
24
+ return parts.join('|');
25
+ }
26
+
27
+ /** Stamp the code now, and answer later whether it has changed on disk (re-read at most every `everyMs`). */
28
+ export function stampAtStart(dir, { everyMs = 5000, now = () => Date.now() } = {}) {
29
+ const boot = codeStamp(dir);
30
+ let last = boot; let at = now();
31
+ return () => {
32
+ if (now() - at >= everyMs) { last = codeStamp(dir); at = now(); }
33
+ return { changed: last !== boot };
34
+ };
35
+ }
@@ -0,0 +1,56 @@
1
+ // owned-stream.js — the conversations of several owned sessions, replayed then followed, on ONE connection.
2
+ //
3
+ // A browser holds six connections to one host over HTTP/1.1, and a pane per stream reached them at five panes
4
+ // beside the page's own: the panel kept painting, but a message sent and every request the page made itself waited
5
+ // until a pane was closed (measured on Windows, Claude Code 2.1.301: a send did not arrive in 35 s, then in 8 s once
6
+ // two panes closed). Each event and state carries the session id it belongs to.
7
+ // ids=a,b,c the sessions (at most MAX_STREAM_IDS; an unknown id is answered with `gone`)
8
+ // after=a:12,b:3 resume each where the client last was, so a reconnect neither replays nor drops
9
+
10
+ export const MAX_STREAM_IDS = 32;
11
+
12
+ /** Read `ids` and `after` from a URL: the ids, de-duplicated and capped, and each one's resume point. */
13
+ export function parseOwnedQuery(url) {
14
+ const ids = [...new Set((url.searchParams.get('ids') ?? '').split(',').filter(Boolean))].slice(0, MAX_STREAM_IDS);
15
+ const after = new Map();
16
+ for (const part of (url.searchParams.get('after') ?? '').split(',')) {
17
+ const at = part.lastIndexOf(':');
18
+ if (at > 0) after.set(part.slice(0, at), Number(part.slice(at + 1)) || 0);
19
+ }
20
+ return { ids, after };
21
+ }
22
+
23
+ /**
24
+ * @param getSession id -> an owned session ({ summary(), subscribe(fn, after) }) or null
25
+ * @param stateAfter the record types after which a session's summary is sent again
26
+ * @param beatMs how often every summary is sent regardless
27
+ */
28
+ export function streamOwned(req, res, url, { getSession, stateAfter = new Set(), beatMs = 15000 }) {
29
+ const { ids, after } = parseOwnedQuery(url);
30
+ res.writeHead(200, {
31
+ 'content-type': 'text/event-stream; charset=utf-8',
32
+ 'cache-control': 'no-store',
33
+ connection: 'keep-alive',
34
+ 'x-accel-buffering': 'no',
35
+ });
36
+ const write = (event, data) => {
37
+ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
38
+ };
39
+ const unsubscribes = [];
40
+ for (const id of ids) {
41
+ const s = getSession(id);
42
+ if (!s) { write('gone', { id }); continue; }
43
+ write('state', { id, session: s.summary() });
44
+ unsubscribes.push(s.subscribe((ev) => {
45
+ write('event', { id, ev });
46
+ if (stateAfter.has(ev.rec?.type)) write('state', { id, session: s.summary() });
47
+ }, after.get(id) ?? 0));
48
+ }
49
+ const beat = setInterval(() => {
50
+ for (const id of ids) { const s = getSession(id); if (s) write('state', { id, session: s.summary() }); }
51
+ }, beatMs);
52
+ const stop = () => { clearInterval(beat); for (const u of unsubscribes) u(); try { res.end(); } catch { /* gone */ } };
53
+ req.on('close', stop);
54
+ req.on('error', stop);
55
+ return stop;
56
+ }
@@ -24,10 +24,17 @@ import fsp from 'node:fs/promises';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
 
27
- // Overridable so a second instance can be pointed at a different transcript
28
- // store — which is also the only way to exercise the peer relay on one machine.
29
- export const PROJECTS_ROOT = process.env.CREW_STUDIO_PROJECTS_ROOT
30
- || path.join(os.homedir(), '.claude', 'projects');
27
+ // Where Claude Code writes its transcripts: <config dir>/projects, the config dir being CLAUDE_CONFIG_DIR when it is
28
+ // set and ~/.claude otherwise. Reading ~/.claude alone, sessions started under a moved config dir (Studio's own
29
+ // included) were not listed and read a cost of 0 (measured on Windows). The studio gate hook reads it the same way.
30
+ // CREW_STUDIO_PROJECTS_ROOT still wins: a second instance pointed at a different store, which is also the only way to
31
+ // exercise the peer relay on one machine.
32
+ export function projectsRoot(env = process.env, home = os.homedir()) {
33
+ if (env.CREW_STUDIO_PROJECTS_ROOT) return env.CREW_STUDIO_PROJECTS_ROOT;
34
+ const config = env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
35
+ return path.join(config, 'projects');
36
+ }
37
+ export const PROJECTS_ROOT = projectsRoot();
31
38
 
32
39
  /** Fold a working directory into the directory name Claude Code uses. */
33
40
  export function encodeCwd(cwd) {
@@ -14,7 +14,7 @@ import {
14
14
  summaryChips, sessionStatus, sessionName, sessionSub, ago, matchesSession, highlight, badgeFor,
15
15
  liveSessions, machines, seenAgo, isFirstRun,
16
16
  } from './nav.js';
17
- import { liveness, offlineNote } from './liveness.js';
17
+ import { liveness, offlineNote, staleServerNote } from './liveness.js';
18
18
  import { attention, settle, AUTO } from './graph-plan.js';
19
19
  import { summaryRows, usageLine, costNote } from './usage.js';
20
20
  import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
@@ -715,7 +715,8 @@ async function loadMap(force = false) {
715
715
  mapBusy = true;
716
716
  let data;
717
717
  const q = `session=${encodeURIComponent(asked)}${mapCompare ? `&compare=${encodeURIComponent(mapCompare)}` : ''}`;
718
- try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: e.message, others: [] }; }
718
+ // A server older than this page has no Map route: say that, not its 404.
719
+ try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: restartNote ?? e.message, others: [] }; }
719
720
  finally { mapBusy = false; }
720
721
  if (asked !== current) return;
721
722
  mapFor = asked;
@@ -2658,6 +2659,7 @@ chat.onPermissions = () => pollOwned();
2658
2659
  view is being shown in a window too narrow for it. Offline is said first. */
2659
2660
 
2660
2661
  let nextPollAt = null;
2662
+ let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
2661
2663
  const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
2662
2664
  stageNote.button.type = 'button';
2663
2665
  stageNote.button.addEventListener('click', () => retryNow());
@@ -2666,11 +2668,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
2666
2668
  function paintStageNote() {
2667
2669
  const l = liveness(Date.now(), heard.okAt, heard.failed);
2668
2670
  const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
2669
- const warn = narrowWarning(view, phoneBand.matches);
2671
+ // Offline first, then a server older than this page, then a window too narrow for the view.
2672
+ const warn = restartNote ?? narrowWarning(view, phoneBand.matches);
2670
2673
  // The last picture stays on screen while the server is away, and is drawn as old.
2671
2674
  el.stage.dataset.offline = String(Boolean(off));
2672
2675
  el.stageNote.hidden = !off && !warn;
2673
- el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
2676
+ el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
2674
2677
  stageNote.text.textContent = off ? off.text : (warn ?? '');
2675
2678
  stageNote.retry.textContent = off ? off.retry : '';
2676
2679
  stageNote.retry.hidden = !off;
@@ -2815,6 +2818,9 @@ pollFleet(); pollSessions(); pollOwned();
2815
2818
  // Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
2816
2819
  // would refuse it either way.
2817
2820
  getJson('/api/health').then((h) => {
2821
+ restartNote = staleServerNote(h);
2822
+ // A Map read before this answer showed the old server's 404; read it again so it says why.
2823
+ if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
2818
2824
  role = h.role === 'viewer' ? 'viewer' : 'owner';
2819
2825
  document.body.dataset.role = role;
2820
2826
  document.getElementById('role').hidden = role !== 'viewer';
@@ -48,10 +48,76 @@ export function quickReplies(text) {
48
48
  return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
49
49
  }
50
50
 
51
+ /* ============================================== every open conversation === */
52
+
53
+ /**
54
+ * One connection for every open conversation. A pane per EventSource reached the browser's six connections to a
55
+ * host at five panes beside the page's own stream, and from there a message sent and every request the page made
56
+ * waited for a pane to close (measured on Windows). The panes register here; the stream is opened again, with
57
+ * each session's last event number, whenever the set changes or the connection drops, so nothing is replayed
58
+ * twice and nothing is missed.
59
+ */
60
+ export class OwnedStream {
61
+ constructor(api, { EventSourceImpl = globalThis.EventSource, retryMs = 1500 } = {}) {
62
+ this.api = api;
63
+ this.ES = EventSourceImpl;
64
+ this.retryMs = retryMs;
65
+ this.panes = new Map(); // session id -> pane
66
+ this.seq = new Map(); // session id -> the last event number taken
67
+ this.source = null;
68
+ this.timer = null;
69
+ }
70
+
71
+ attach(pane) { this.panes.set(pane.id, pane); this.#soon(0); }
72
+
73
+ detach(pane) {
74
+ if (this.panes.get(pane.id) !== pane) return;
75
+ this.panes.delete(pane.id); this.seq.delete(pane.id); this.#soon(0);
76
+ }
77
+
78
+ /** The URL the stream is opened at now: what a reconnect resumes from. */
79
+ url() {
80
+ const ids = [...this.panes.keys()];
81
+ const after = ids.map((id) => `${id}:${this.seq.get(id) ?? 0}`).join(',');
82
+ return this.api(`/api/owned-events?ids=${encodeURIComponent(ids.join(','))}&after=${encodeURIComponent(after)}`);
83
+ }
84
+
85
+ // Panes opened together (a page restoring its tabs) cost one reconnect, not one each.
86
+ #soon(ms) { clearTimeout(this.timer); this.timer = setTimeout(() => this.#open(), ms); }
87
+
88
+ #open() {
89
+ if (this.source) { this.source.close(); this.source = null; }
90
+ if (!this.panes.size) return;
91
+ const src = new this.ES(this.url());
92
+ const data = (e) => { try { return JSON.parse(e.data); } catch { return null; } };
93
+ src.addEventListener('state', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.takeState(d.session); });
94
+ src.addEventListener('event', (e) => {
95
+ const d = data(e); const pane = d && this.panes.get(d.id);
96
+ if (!pane || !d.ev) return;
97
+ if (Number.isFinite(d.ev.seq)) {
98
+ if (d.ev.seq <= (this.seq.get(d.id) ?? 0)) return; // already taken before a reconnect
99
+ this.seq.set(d.id, d.ev.seq);
100
+ }
101
+ pane.takeEvent(d.ev);
102
+ });
103
+ src.addEventListener('gone', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.streamLost(); });
104
+ // The browser would reconnect at the same URL, from the same event numbers, and replay what was taken since.
105
+ // So the stream is closed and opened again here, from where each session is now.
106
+ src.onerror = () => {
107
+ if (this.source !== src) return;
108
+ for (const pane of this.panes.values()) pane.streamLost();
109
+ this.#soon(this.retryMs);
110
+ };
111
+ this.source = src;
112
+ }
113
+
114
+ close() { clearTimeout(this.timer); if (this.source) { this.source.close(); this.source = null; } this.panes.clear(); this.seq.clear(); }
115
+ }
116
+
51
117
  /* ========================================================== one session === */
52
118
 
53
119
  class Pane {
54
- constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
120
+ constructor(session, { api, headers, onChange, onPermissions, hub = null, hooks = {}, readOnly = false }) {
55
121
  this.api = api;
56
122
  this.headers = headers;
57
123
  this.onChange = onChange;
@@ -62,7 +128,8 @@ class Pane {
62
128
  this.readOnly = readOnly;
63
129
  this.session = session;
64
130
  this.id = session.sessionId;
65
- this.source = null;
131
+ this.hub = hub;
132
+ this.connected = false;
66
133
  this.messages = [];
67
134
  this.streaming = null;
68
135
  this.permissions = [];
@@ -163,25 +230,22 @@ class Pane {
163
230
  }
164
231
 
165
232
  connect() {
166
- if (this.source) return;
167
- this.source = new EventSource(this.api(`/api/owned/${encodeURIComponent(this.id)}/events`));
168
- this.source.addEventListener('state', (e) => {
169
- try { this.session = JSON.parse(e.data); } catch { return; }
170
- this.paintState();
171
- this.onChange(this);
172
- });
173
- this.source.addEventListener('event', (e) => {
174
- let ev;
175
- try { ev = JSON.parse(e.data); } catch { return; }
176
- this.absorb(ev.rec);
177
- });
178
- this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
233
+ if (this.connected || !this.hub) return;
234
+ this.connected = true;
235
+ this.hub.attach(this);
179
236
  }
180
237
 
181
238
  disconnect() {
182
- if (this.source) { this.source.close(); this.source = null; }
239
+ if (!this.connected) return;
240
+ this.connected = false;
241
+ this.hub.detach(this);
183
242
  }
184
243
 
244
+ // What the shared stream hands this pane.
245
+ takeState(session) { if (!session) return; this.session = session; this.streamBroken = false; this.paintState(); this.onChange(this); }
246
+ takeEvent(ev) { this.streamBroken = false; this.absorb(ev.rec); }
247
+ streamLost() { this.streamBroken = true; this.onChange(this); }
248
+
185
249
  /* --------------------------------------------------------- ingestion */
186
250
 
187
251
  absorb(rec) {
@@ -591,6 +655,7 @@ export class Chat {
591
655
  this.headers = headers;
592
656
  this.hooks = hooks;
593
657
  this.panes = new Map();
658
+ this.hub = new OwnedStream(api);
594
659
  this.activeId = null;
595
660
  this.onActivate = () => {};
596
661
  // (pane, { pending }) — a session's waiting requests changed.
@@ -654,7 +719,7 @@ export class Chat {
654
719
  }
655
720
  if (!pane) {
656
721
  pane = new Pane(session, {
657
- api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
722
+ api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), hub: this.hub,
658
723
  onPermissions: (p, rec) => this.onPermissions(p, rec),
659
724
  });
660
725
  this.panesEl.append(pane.root);
@@ -64,3 +64,17 @@ function clock(ms) {
64
64
  const two = (n) => String(n).padStart(2, '0');
65
65
  return `${two(d.getHours())}:${two(d.getMinutes())}`;
66
66
  }
67
+
68
+ /**
69
+ * Is the server older than the files it serves? The page is read from disk on every load; the server keeps the
70
+ * code it started with. After an update the two disagree, and a view the old server has no route for answered
71
+ * 404 (seen as "Not read — HTTP 404" on the Map view). /api/health says `code.changed` when the server's files
72
+ * changed since it started; a server with no `code` at all predates the field, and is older than this page.
73
+ * Returns the sentence to show, or null.
74
+ */
75
+ export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
76
+ export function staleServerNote(health) {
77
+ if (!health || typeof health !== 'object') return null;
78
+ if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
79
+ return health.code.changed === true ? RESTART_NOTE : null;
80
+ }
@@ -543,6 +543,12 @@ body[data-role="viewer"] #new-session { display: none; }
543
543
  border-bottom-color: color-mix(in srgb, var(--status-fail) 45%, var(--line));
544
544
  }
545
545
  .stage-note-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
546
+ /* A server running older code than the page: the whole sentence is the instruction, so it wraps, not ellipsizes. */
547
+ .stage-note[data-kind="restart"] {
548
+ color: var(--ink); background: color-mix(in srgb, var(--status-waiting) 12%, var(--surface));
549
+ border-bottom-color: color-mix(in srgb, var(--status-waiting) 45%, var(--line));
550
+ }
551
+ .stage-note[data-kind="restart"] .stage-note-text { white-space: normal; }
546
552
  /* The last picture stays while the server is away, and is drawn as what it is: old. */
547
553
  .stage[data-offline="true"] .canvas,
548
554
  .stage[data-offline="true"] .tl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.2.0-rc.1",
3
+ "version": "3.2.0-rc.2",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"