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 +33 -0
- package/VERSION +1 -1
- package/adopt.sh +45 -5
- package/kit/CLAUDE.md +5 -5
- package/kit/eval/doctor.sh +26 -3
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +10 -4
- package/kit/eval/lib/statusline.sh +3 -18
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +111 -3
- package/kit/hooks/agent-outcome.sh +20 -9
- package/kit/hooks/guard-agent-model.sh +95 -5
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/studio/server/index.js +11 -0
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/web/app.js +10 -4
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/style.css +6 -0
- package/package.json +1 -1
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"
|
|
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
|
-
|
|
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
|
-
|
|
1547
|
-
|
|
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]+$/ &&
|
|
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
|
|
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
|
|
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
|
|
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`.
|
|
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.
|
package/kit/eval/doctor.sh
CHANGED
|
@@ -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"
|
|
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{
|
|
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"){
|
|
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!="")
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
'
|
package/kit/eval/smoke-test.sh
CHANGED
|
@@ -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"
|
|
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
|
|
215
|
-
# the same oldest record.
|
|
216
|
-
#
|
|
217
|
-
|
|
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
|
-
|
|
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 "$
|
|
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 "$
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
#
|
|
190
|
-
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
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) {
|
package/kit/studio/web/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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';
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -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.
|
|
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.
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
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.
|
|
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
|
+
}
|
package/kit/studio/web/style.css
CHANGED
|
@@ -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