crewforth 3.1.1 → 3.2.0-rc.10
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 +323 -0
- package/README.md +6 -3
- package/VERSION +1 -1
- package/adopt.sh +66 -6
- package/bin/cli.js +3 -2
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +15 -14
- package/kit/agents/crew-commit-agent.md +11 -4
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +128 -6
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +132 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +164 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +266 -0
- package/kit/eval/lib/crew-map/stacks.mjs +547 -0
- package/kit/eval/lib/crew-map/surface.mjs +535 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +19 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +878 -53
- package/kit/hooks/agent-outcome.sh +44 -12
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +177 -24
- package/kit/hooks/guard-bash.sh +295 -204
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +24 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +389 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +51 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +55 -0
- package/kit/skills/crew-review/SKILL.md +4 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +25 -2
- package/kit/studio/server/index.js +187 -21
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +185 -0
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +287 -17
- package/kit/studio/web/canvas.js +58 -122
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +13 -6
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +410 -0
- package/kit/studio/web/map.js +596 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +165 -0
- package/kit/studio/web/tokens.css +6 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +24 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# preset.sh — an organisation's settings for Crewforth in one file, applied to a project.
|
|
3
|
+
#
|
|
4
|
+
# bash preset.sh --apply <name|file.json> from the project root; a name is one of eval/lib/presets/
|
|
5
|
+
# bash preset.sh --check <name|file.json> is it a valid preset? Nothing is written (the installers ask first)
|
|
6
|
+
# bash preset.sh --show the preset in force here, or "none"
|
|
7
|
+
# bash preset.sh --list the presets that ship
|
|
8
|
+
#
|
|
9
|
+
# A preset is plain JSON (see presets/default.json for every key):
|
|
10
|
+
# critical.add / critical.remove paths made critical, or not (globs, as `+ glob` / `- glob`)
|
|
11
|
+
# floors [agent, change, risk, model]: a model a class of work runs on at least
|
|
12
|
+
# verify commands the gate may run as a verify command
|
|
13
|
+
# lang en | tr, the language of what Crewforth says (only when the project has none)
|
|
14
|
+
#
|
|
15
|
+
# Where it goes: .claude/crew-model-rules, the file every reader of these rules already reads (the model gate,
|
|
16
|
+
# agent-outcome's scan, doctor, Studio), as one marked block at the top. The project's own lines stay below it, so
|
|
17
|
+
# the rule is the file's own: for a critical path the last line that matches wins, so a project's `- glob` undoes a
|
|
18
|
+
# preset's `+ glob`; a floor only ever raises, so a preset's floor is a minimum a project can raise and not lower.
|
|
19
|
+
# Applying again replaces the block and nothing else; the same preset twice gives the same bytes.
|
|
20
|
+
#
|
|
21
|
+
# rc 0 = applied (or shown) · 1 = the preset is not valid, or names nothing; nothing was written · 2 = usage
|
|
22
|
+
# Every JSON read is Crewforth's own reader (settings-json.awk): no jq, no python.
|
|
23
|
+
set -u
|
|
24
|
+
_here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
|
|
25
|
+
SJ="$_here/settings-json.awk"; DIR="$_here/presets"
|
|
26
|
+
RULES=.claude/crew-model-rules; CONF=.claude/kit.conf
|
|
27
|
+
BEGIN_MARK='# ---- preset:'; END_MARK='# ---- end of preset (lines below are this project'"'"'s own and win over it, see preset.sh) ----'
|
|
28
|
+
|
|
29
|
+
die(){ echo "preset: $*" >&2; exit 1; }
|
|
30
|
+
jget(){ awk -v op=get -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
|
|
31
|
+
jstrings(){ awk -v op=strings -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
|
|
32
|
+
jlen(){ local n; n="$(awk -v op=len -v path="$1" -f "$SJ" "$2" 2>/dev/null)"; printf '%s' "${n:-0}"; }
|
|
33
|
+
|
|
34
|
+
case "${1:-}" in
|
|
35
|
+
--list) for f in "$DIR"/*.json; do [ -f "$f" ] || continue; n="${f##*/}"; printf '%s\n' "${n%.json}"; done; exit 0 ;;
|
|
36
|
+
--show)
|
|
37
|
+
[ -f "$RULES" ] || { echo none; exit 0; }
|
|
38
|
+
while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$BEGIN_MARK "*) l="${l#"$BEGIN_MARK "}"; echo "${l%% *}"; exit 0 ;; esac; done < "$RULES"
|
|
39
|
+
echo none; exit 0 ;;
|
|
40
|
+
--apply|--check) [ -n "${2:-}" ] || { echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2; } ;;
|
|
41
|
+
*) echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2 ;;
|
|
42
|
+
esac
|
|
43
|
+
MODE="$1"
|
|
44
|
+
[ "$MODE" = --check ] || [ -d .claude ] || die "no .claude/ here: run it from the root of a project Crewforth is installed in"
|
|
45
|
+
|
|
46
|
+
# ---- which file ----
|
|
47
|
+
P="$2"
|
|
48
|
+
case "$P" in *.json|*/*) ;; *) P="$DIR/$P.json" ;; esac
|
|
49
|
+
[ -f "$P" ] || die "no preset at $P (the ones that ship: $(bash "$0" --list 2>/dev/null | tr '\n' ' '))"
|
|
50
|
+
awk -v op=validate -f "$SJ" "$P" 2>/dev/null || die "$P is not a JSON object"
|
|
51
|
+
NAME="$(jget name "$P")"; NAME="${NAME#\"}"; NAME="${NAME%\"}"
|
|
52
|
+
case "$NAME" in ''|*[!a-z0-9-]*) die "$P: name must be lower case letters, digits and -" ;; esac
|
|
53
|
+
|
|
54
|
+
# ---- what it says, checked before anything is written ----
|
|
55
|
+
LINES=()
|
|
56
|
+
for k in add remove; do
|
|
57
|
+
sign='+'; [ "$k" = remove ] && sign='-'
|
|
58
|
+
while IFS= read -r g; do
|
|
59
|
+
[ -n "$g" ] || continue
|
|
60
|
+
case "$g" in *[[:space:]\#]*) die "$P: critical.$k holds '$g', which has a space or a #" ;; esac
|
|
61
|
+
LINES+=("$sign $g")
|
|
62
|
+
done < <(jstrings "critical.$k" "$P")
|
|
63
|
+
done
|
|
64
|
+
nf="$(jlen floors "$P")"; i=0
|
|
65
|
+
while [ "$i" -lt "$nf" ]; do
|
|
66
|
+
f=(); while IFS= read -r w; do f+=("$w"); done < <(jstrings "floors.$i" "$P")
|
|
67
|
+
[ "${#f[@]}" = 4 ] || die "$P: floors[$i] is not [agent, change, risk, model]"
|
|
68
|
+
case "${f[3]}" in haiku|sonnet|opus) ;; *) die "$P: floors[$i] names the model '${f[3]}', not haiku, sonnet or opus" ;; esac
|
|
69
|
+
for w in "${f[@]}"; do case "$w" in ''|*[[:space:]\#]*) die "$P: floors[$i] has an empty word, a space or a #" ;; esac; done
|
|
70
|
+
LINES+=("floor ${f[0]} ${f[1]} ${f[2]} ${f[3]}"); i=$((i+1))
|
|
71
|
+
done
|
|
72
|
+
while IFS= read -r c; do
|
|
73
|
+
[ -n "$c" ] || continue
|
|
74
|
+
case "$c" in *[\;\&\|\<\>\`\$\#]*) die "$P: verify '$c' has a shell operator or a #; the gate runs a verify command as one command" ;; esac
|
|
75
|
+
LINES+=("verify $c")
|
|
76
|
+
done < <(jstrings verify "$P")
|
|
77
|
+
LANGV="$(jget lang "$P")"; LANGV="${LANGV#\"}"; LANGV="${LANGV%\"}"
|
|
78
|
+
case "$LANGV" in ''|null|en|tr) ;; *) die "$P: lang is '$LANGV', not en or tr" ;; esac
|
|
79
|
+
for k in $(awk -v op=keys -f "$SJ" "$P" 2>/dev/null); do
|
|
80
|
+
case "$k" in about|name|critical|floors|verify|lang) ;; *) die "$P: unknown key '$k' (about, name, critical, floors, verify, lang)" ;; esac
|
|
81
|
+
done
|
|
82
|
+
|
|
83
|
+
[ "$MODE" = --check ] && { echo "preset: $NAME is valid — ${#LINES[@]} line(s)"; exit 0; }
|
|
84
|
+
|
|
85
|
+
# ---- the block, at the top of the rules file; the project's own lines kept below it ----
|
|
86
|
+
OWN=""
|
|
87
|
+
if [ -f "$RULES" ]; then
|
|
88
|
+
skip=0
|
|
89
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
90
|
+
case "$l" in "$BEGIN_MARK "*) skip=1; continue ;; "$END_MARK") skip=0; continue ;; esac
|
|
91
|
+
[ "$skip" = 1 ] || OWN+="$l"$'\n'
|
|
92
|
+
done < "$RULES"
|
|
93
|
+
fi
|
|
94
|
+
T="$RULES.preset.$$"
|
|
95
|
+
{
|
|
96
|
+
printf '%s %s (written by preset.sh; apply again to change it, edit your own lines below) ----\n' "$BEGIN_MARK" "$NAME"
|
|
97
|
+
for l in ${LINES[@]+"${LINES[@]}"}; do printf '%s\n' "$l"; done
|
|
98
|
+
printf '%s\n' "$END_MARK"
|
|
99
|
+
printf '%s' "$OWN"
|
|
100
|
+
} > "$T" && mv -f "$T" "$RULES" || { rm -f "$T"; die "could not write $RULES"; }
|
|
101
|
+
|
|
102
|
+
# ---- kit.conf: the preset's language, only where the project has none. Which preset is in force is said once, by
|
|
103
|
+
# the block's own first line (--show reads it): kit.conf is rewritten by every update, the rules file is not.
|
|
104
|
+
if [ -f "$CONF" ] && case "$LANGV" in en|tr) true ;; *) false ;; esac; then
|
|
105
|
+
hadlang=0; while IFS= read -r l || [ -n "$l" ]; do case "$l" in lang=*) hadlang=1 ;; esac; done < "$CONF"
|
|
106
|
+
[ "$hadlang" = 0 ] && printf 'lang=%s\n' "$LANGV" >> "$CONF"
|
|
107
|
+
fi
|
|
108
|
+
echo "preset: $NAME applied — ${#LINES[@]} line(s) in $RULES; your own lines below the block were kept"
|
|
109
|
+
exit 0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "Crewforth as it ships: no line of its own. Every key a preset may hold is here, empty. critical.add and critical.remove are globs made critical or not (a critical path runs on opus and only an agent on opus writes there); floors are [agent, change, risk, model] with * for any, a model a class of work runs on at least; verify are commands the model gate may run as a card's verify command; lang (en or tr) is used only where the project has no language yet.",
|
|
3
|
+
"name": "default",
|
|
4
|
+
"critical": { "add": [], "remove": [] },
|
|
5
|
+
"floors": [],
|
|
6
|
+
"verify": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "For one person on their own project: the common test commands may run as a card's verify command, so cheap work can be checked and run on haiku; no path is added.",
|
|
3
|
+
"name": "solo",
|
|
4
|
+
"critical": { "add": [], "remove": [] },
|
|
5
|
+
"floors": [],
|
|
6
|
+
"verify": ["npm test", "pnpm test", "yarn test", "pytest", "go test ./...", "cargo test", "dotnet test", "flutter test"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "For a team that ships to production from this repository: what deploys it is critical too, and the referees of the work run on opus whatever the risk.",
|
|
3
|
+
"name": "strict",
|
|
4
|
+
"critical": { "add": ["infra", "deploy", "terraform", "k8s", "helm", ".github/workflows"], "remove": [] },
|
|
5
|
+
"floors": [
|
|
6
|
+
["crew-review-agent", "*", "*", "opus"],
|
|
7
|
+
["*", "test-write", "*", "opus"],
|
|
8
|
+
["*", "audit", "*", "opus"]
|
|
9
|
+
],
|
|
10
|
+
"verify": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Sourced, not run. What a risky command would have touched, in one line, for the gate that stops or asks about it:
|
|
2
|
+
# the user reads "it would delete 7 uncommitted changes", not only "git reset --hard".
|
|
3
|
+
#
|
|
4
|
+
# crew_preview <kind> <command> -> _CREW_PV ("" when there is nothing to say)
|
|
5
|
+
# reset git reset --hard: the uncommitted changes to tracked files it discards, and the commits it drops
|
|
6
|
+
# from the branch when it names a target (git reset --hard HEAD~3)
|
|
7
|
+
# clean git clean -f…: the same command as a dry run (-n, with its -d / -x / -X), and how many paths it lists
|
|
8
|
+
# push git push --force: the commits on the remote branch the local one does not have, as of the last fetch
|
|
9
|
+
# rm rm -r…: the files under each named target inside the project; a target outside it, or a pattern, is
|
|
10
|
+
# not counted and the line says so
|
|
11
|
+
# commit an approved commit: the files and lines it takes (the staged ones, the tracked changes too for -a)
|
|
12
|
+
# push-ask an approved push: the commits it sends, the first three by subject
|
|
13
|
+
#
|
|
14
|
+
# READ ONLY. Every command here only reads (status, diff --stat, rev-list, log, clean -n, find), nothing fetches,
|
|
15
|
+
# and a count of files stops at 10 000. The caller runs this in a subshell and prints what comes back: a preview
|
|
16
|
+
# that fails, or ends the shell, leaves the gate's decision as it was. It never decides anything.
|
|
17
|
+
|
|
18
|
+
_PV_MAX=10000
|
|
19
|
+
|
|
20
|
+
# Is this a repository, without a process where the gate log's helper is loaded (it is, in the gates).
|
|
21
|
+
_pv_repo(){ if declare -F crew_git_dir >/dev/null 2>&1; then crew_git_dir .; else git rev-parse --is-inside-work-tree >/dev/null 2>&1; fi; }
|
|
22
|
+
|
|
23
|
+
# The current branch's upstream as remote/branch, read from HEAD and the config with builtins: _PV_UP ("" = none).
|
|
24
|
+
# A push is asked about on the hot path of a gate whose cost is pinned per call, so the common case (no upstream
|
|
25
|
+
# yet) opens no process at all. A linked worktree keeps HEAD in its own git dir and the config in the common one.
|
|
26
|
+
_pv_upstream(){
|
|
27
|
+
_PV_UP=""; local gd="" cd l br="" sec="" rem="" mer=""
|
|
28
|
+
declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && gd="$_CREW_GD"
|
|
29
|
+
[ -n "$gd" ] && [ -f "$gd/HEAD" ] || return 1
|
|
30
|
+
IFS= read -r l < "$gd/HEAD" || return 1; l="${l%$'\r'}"
|
|
31
|
+
case "$l" in "ref: refs/heads/"*) br="${l#ref: refs/heads/}" ;; *) return 1 ;; esac
|
|
32
|
+
cd="$gd"; if [ -f "$gd/commondir" ]; then IFS= read -r l < "$gd/commondir"; l="${l%$'\r'}"; case "$l" in /*|[A-Za-z]:*) cd="$l" ;; *) cd="$gd/$l" ;; esac; fi
|
|
33
|
+
[ -f "$cd/config" ] || return 1
|
|
34
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
35
|
+
l="${l%$'\r'}"; l="${l#"${l%%[![:space:]]*}"}"
|
|
36
|
+
case "$l" in
|
|
37
|
+
"[branch \"$br\"]") sec=1 ;;
|
|
38
|
+
"["*) sec="" ;;
|
|
39
|
+
remote*=*) [ -n "$sec" ] && { rem="${l#*=}"; rem="${rem#"${rem%%[![:space:]]*}"}"; } ;;
|
|
40
|
+
merge*=*) [ -n "$sec" ] && { mer="${l#*=}"; mer="${mer#"${mer%%[![:space:]]*}"}"; } ;;
|
|
41
|
+
esac
|
|
42
|
+
done < "$cd/config"
|
|
43
|
+
[ -n "$rem" ] && [ -n "$mer" ] && [ "$rem" != . ] && _PV_UP="$rem/${mer#refs/heads/}"
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_pv_words(){ # $1 = command -> _PV_W (the words after the git subcommand $2, quotes dropped)
|
|
48
|
+
local s="$1" sub="$2"
|
|
49
|
+
s="${s#*git}"; s="${s#*"$sub"}"; s="${s%%[;&|]*}"; s="${s//\"/}"; s="${s//\'/}"
|
|
50
|
+
_PV_W="$s"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# With pathname expansion off for the whole preview: the words of the command are split, never expanded here, so
|
|
54
|
+
# `rm -rf build/*` is said as a pattern instead of each file the hook's own shell would find (measured on Windows).
|
|
55
|
+
crew_preview(){
|
|
56
|
+
local _pvf=0; case "$-" in *f*) _pvf=1 ;; esac
|
|
57
|
+
set -f; _crew_preview "$@"; [ "$_pvf" = 1 ] || set +f
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
_crew_preview(){
|
|
61
|
+
_CREW_PV=""
|
|
62
|
+
local kind="$1" cmd="$2" n t w a flags line
|
|
63
|
+
case "$kind" in
|
|
64
|
+
reset|clean|push|commit|push-ask)
|
|
65
|
+
_pv_repo || { _CREW_PV="Preview: not a git work tree here, nothing counted."; return 0; } ;;
|
|
66
|
+
esac
|
|
67
|
+
case "$kind" in
|
|
68
|
+
reset)
|
|
69
|
+
n="$(git status --porcelain --untracked-files=no 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
|
|
70
|
+
_pv_words "$cmd" reset; t=""
|
|
71
|
+
for w in $_PV_W; do case "$w" in -*) ;; *) t="$w"; break ;; esac; done
|
|
72
|
+
a=""
|
|
73
|
+
if [ -n "$t" ] && git rev-parse -q --verify "$t^{commit}" >/dev/null 2>&1; then
|
|
74
|
+
a="$(git rev-list --count "$t..HEAD" 2>/dev/null)"; a="${a//[!0-9]/}"
|
|
75
|
+
fi
|
|
76
|
+
_CREW_PV="Preview: it would discard ${n:-0} uncommitted change(s) to tracked files${a:+ and drop $a commit(s) from the branch (back to $t)}."
|
|
77
|
+
;;
|
|
78
|
+
clean)
|
|
79
|
+
_pv_words "$cmd" clean; flags="-n"
|
|
80
|
+
for w in $_PV_W; do case "$w" in
|
|
81
|
+
--) break ;;
|
|
82
|
+
-*) case "$w" in --*) ;; *) case "$w" in *d*) flags="$flags -d" ;; esac; case "$w" in *x*) flags="$flags -x" ;; *X*) flags="$flags -X" ;; esac ;; esac ;;
|
|
83
|
+
esac; done
|
|
84
|
+
# shellcheck disable=SC2086 # the flags are words of our own
|
|
85
|
+
n="$(git clean $flags 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
|
|
86
|
+
_CREW_PV="Preview: a dry run (git clean $flags) lists ${n:-0} path(s) it would delete."
|
|
87
|
+
;;
|
|
88
|
+
push)
|
|
89
|
+
_pv_words "$cmd" push; local rem="" ref="" up=""
|
|
90
|
+
for w in $_PV_W; do case "$w" in -*) ;; *) if [ -z "$rem" ]; then rem="$w"; elif [ -z "$ref" ]; then ref="$w"; fi ;; esac; done
|
|
91
|
+
ref="${ref#+}"; ref="${ref##*:}"
|
|
92
|
+
if [ -n "$rem" ] && [ -n "$ref" ]; then up="refs/remotes/$rem/$ref"
|
|
93
|
+
else up="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; fi
|
|
94
|
+
if [ -n "$up" ] && git rev-parse -q --verify "$up^{commit}" >/dev/null 2>&1; then
|
|
95
|
+
n="$(git rev-list --count "HEAD..$up" 2>/dev/null)"; n="${n//[!0-9]/}"; up="${up#refs/remotes/}"
|
|
96
|
+
if [ "${n:-0}" -gt 0 ]; then _CREW_PV="Preview: $up has $n commit(s) this branch does not, as of the last fetch; a forced push would overwrite them."
|
|
97
|
+
else _CREW_PV="Preview: as of the last fetch, $up has no commit this branch lacks; a fetch now could show otherwise."; fi
|
|
98
|
+
else
|
|
99
|
+
_CREW_PV="Preview: no remote branch known for it here (nothing fetched), so what it would overwrite is not counted."
|
|
100
|
+
fi
|
|
101
|
+
;;
|
|
102
|
+
rm)
|
|
103
|
+
local root out="" one c
|
|
104
|
+
root="$(pwd -P 2>/dev/null)"
|
|
105
|
+
_pv_words "x $cmd" rm
|
|
106
|
+
for w in $_PV_W; do
|
|
107
|
+
case "$w" in -*|'') continue ;; *'*'*|*'?'*|'~'*|'$'*) out="${out:+$out; }$w: a pattern, not counted"; continue ;; esac
|
|
108
|
+
# A drive-letter path (D:/x, as Git Bash passes it) is absolute too: measured on Windows, it read as relative
|
|
109
|
+
# and the line said "not there" for a folder that was.
|
|
110
|
+
one="$w"; case "$one" in /*|[A-Za-z]:[/\\]*) ;; *) one="$root/$one" ;; esac
|
|
111
|
+
if [ ! -e "$one" ]; then out="${out:+$out; }$w: not there"; continue; fi
|
|
112
|
+
c="$(cd "$one" 2>/dev/null && pwd -P)" || c="$(cd "$(dirname "$one")" 2>/dev/null && pwd -P)/${one##*/}"
|
|
113
|
+
case "$c/" in "$root"/*) ;; *) out="${out:+$out; }$w: outside the project, not counted"; continue ;; esac
|
|
114
|
+
n="$(find "$one" -type f 2>/dev/null | head -n $((_PV_MAX + 1)) | wc -l)"; n="${n//[!0-9]/}"
|
|
115
|
+
[ "${n:-0}" -gt "$_PV_MAX" ] && n="more than $_PV_MAX"
|
|
116
|
+
out="${out:+$out; }$w: ${n:-0} file(s)"
|
|
117
|
+
done
|
|
118
|
+
[ -n "$out" ] && _CREW_PV="Preview: $out."
|
|
119
|
+
;;
|
|
120
|
+
commit)
|
|
121
|
+
case " $cmd " in *' -a'*|*' --all'*|*' -[A-Za-z]*a'*) line="$(git diff HEAD --shortstat 2>/dev/null)" ;; *) line="$(git diff --cached --shortstat 2>/dev/null)" ;; esac
|
|
122
|
+
line="${line# }"
|
|
123
|
+
_CREW_PV="Preview: ${line:-nothing staged}."
|
|
124
|
+
;;
|
|
125
|
+
push-ask)
|
|
126
|
+
local subj="" k=0 rem="" ref="" src="" tip=HEAD
|
|
127
|
+
# A push that names its remote and branch (git push origin feat/x) sends to that branch, not the upstream; and
|
|
128
|
+
# what it sends is the named LOCAL branch's commits (src of src:dst, or the name itself), not the current one's.
|
|
129
|
+
_pv_words "$cmd" push
|
|
130
|
+
for w in $_PV_W; do case "$w" in -*) ;; *) if [ -z "$rem" ]; then rem="$w"; elif [ -z "$ref" ]; then ref="$w"; fi ;; esac; done
|
|
131
|
+
ref="${ref#+}"; src="${ref%%:*}"; ref="${ref##*:}"
|
|
132
|
+
if [ -n "$src" ] && [ "$src" != HEAD ]; then
|
|
133
|
+
if [ -f "${_CREW_GD:-.git}/refs/heads/$src" ] || git rev-parse -q --verify "refs/heads/$src" >/dev/null 2>&1; then tip="refs/heads/$src"; fi
|
|
134
|
+
fi
|
|
135
|
+
if [ -n "$rem" ] && [ -n "$ref" ] && [ "$ref" != HEAD ]; then
|
|
136
|
+
if [ -f "${_CREW_GD:-.git}/refs/remotes/$rem/$ref" ] || git rev-parse -q --verify "refs/remotes/$rem/$ref" >/dev/null 2>&1; then _PV_UP="$rem/$ref"
|
|
137
|
+
else _CREW_PV="Preview: $rem/$ref is not known here; the push would create it on the remote."; return 0; fi
|
|
138
|
+
else _pv_upstream; fi
|
|
139
|
+
if [ -n "$_PV_UP" ]; then
|
|
140
|
+
# One process: the subjects of what it sends, counted here and the first three named.
|
|
141
|
+
while IFS= read -r line; do k=$((k+1)); [ "$k" -le 3 ] && subj="${subj:+$subj; }${line:0:72}"; done < <(git log --format=%s "$_PV_UP..$tip" 2>/dev/null)
|
|
142
|
+
[ "$k" -gt 3 ] && subj="$subj …"
|
|
143
|
+
_CREW_PV="Preview: it sends $k commit(s) to $_PV_UP${subj:+ — $subj}."
|
|
144
|
+
else
|
|
145
|
+
_CREW_PV="Preview: the branch has no upstream set here; what it sends is not counted."
|
|
146
|
+
fi
|
|
147
|
+
;;
|
|
148
|
+
esac
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bench.mjs — how right the review is, in numbers: run labelled cases, match what was found to where the defect
|
|
3
|
+
// is, and say the precision, the recall and every false alarm by name.
|
|
4
|
+
//
|
|
5
|
+
// node bench.mjs --cases DIR [--arm prepass|agent] [--split all|train|holdout] [--only id,id]
|
|
6
|
+
// [--model sonnet] [--advisor opus] [--case-budget 0.75] [--max-cost 12] [--floors FILE] [--out FILE]
|
|
7
|
+
// [--no-tools]
|
|
8
|
+
// --no-tools the pre-pass runs without shellcheck, so its numbers do not depend on what the machine has
|
|
9
|
+
// installed (the gate in CI uses it; shellcheck's own precision is not part of the floors)
|
|
10
|
+
// rc 0 = ran (and, with --floors, at or above every floor) · 1 = below a floor, or the agent arm could not run a
|
|
11
|
+
// case · 2 = usage
|
|
12
|
+
//
|
|
13
|
+
// A CASE is a directory: case.json, before/ and after/ (the touched files only, with their modes in case.json).
|
|
14
|
+
// It is built as a git repository with `before` committed and `after` staged, so a reviewer meets it the way it
|
|
15
|
+
// meets real work: `git diff --cached`. case.json says what the change claims to do (`intent`, the only thing the
|
|
16
|
+
// reviewer is told) and where the defect is (`expect`: file and line range in the after side; a null range is a
|
|
17
|
+
// file-level defect, a mode bit). A clean case expects nothing.
|
|
18
|
+
//
|
|
19
|
+
// SCORING. A finding matches an expected location when it cites the same file within three lines of the range
|
|
20
|
+
// (any line, for a file-level defect). A defect case is CAUGHT when any finding matches any of its locations:
|
|
21
|
+
// one defect is one defect, however many hunks its fix took. Recall = caught / defect cases. Precision =
|
|
22
|
+
// matching findings / all findings of severity low or above (info is a note, not a claim). Every finding that
|
|
23
|
+
// matches nothing is listed as a false alarm with its case, line and title.
|
|
24
|
+
//
|
|
25
|
+
// A defect case made by undoing a fix can show the fix's own explanation among the removed lines. That is what a
|
|
26
|
+
// reviewer of a real revert would see too, but it is a hint, so such cases are marked and recall is reported for
|
|
27
|
+
// both halves.
|
|
28
|
+
//
|
|
29
|
+
// ARMS. prepass: kit/eval/lib/review-prepass, in process, no model. agent: Claude Code headless, the session run
|
|
30
|
+
// AS crew-review-agent (--agent) with Crewforth's review agent, skills and eval/lib copied into the case's .claude/,
|
|
31
|
+
// on --model, each case capped by --case-budget and the run by --max-cost (it stops before a case that could pass
|
|
32
|
+
// it). The agent arm scores what it wrote to .crewforth/review.json together with the pre-pass it was told to carry
|
|
33
|
+
// over; when it wrote no valid file, its final answer's `file:line · severity` lines are read instead, and the
|
|
34
|
+
// result says so. It runs one review pass: the refuter is not installed in the case, so nothing is --verified.
|
|
35
|
+
//
|
|
36
|
+
// --advisor MODEL runs the agent arm with that advisor (the `advisorModel` setting, passed with --settings; the user's
|
|
37
|
+
// own settings are not read). What a run cost is the result's own total, which counts the advisor's calls; each row
|
|
38
|
+
// also carries the cost and tokens by model (the result's modelUsage) and how many advisor calls were made.
|
|
39
|
+
//
|
|
40
|
+
// A TASK is a case of `kind: "task"`: an implementation, not a review. before/ is the repository, `intent` is what
|
|
41
|
+
// the session is asked to do, check/ is copied in only after the session has ended and `check` (argv, run in the
|
|
42
|
+
// repository) grades it: rc 0 is done. The session runs as itself, not as the review agent.
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import os from 'node:os';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { spawnSync } from 'node:child_process';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { prepass } from '../review-prepass/prepass.mjs';
|
|
49
|
+
import { validate } from '../findings/findings.mjs';
|
|
50
|
+
|
|
51
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const KIT = path.resolve(HERE, '..', '..', '..'); // kit/ (source) or .claude/ (installed)
|
|
53
|
+
const NEAR = 3;
|
|
54
|
+
const RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
55
|
+
const TOKEN = `ghp_${'R3v1'.repeat(9)}`;
|
|
56
|
+
|
|
57
|
+
function args(argv) {
|
|
58
|
+
const o = { arm: 'prepass', split: 'all', model: 'sonnet', advisor: null, caseBudget: 0.75, maxCost: 12 };
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const a = argv[i]; const v = () => { const x = argv[++i]; if (x === undefined) throw new Error(`${a} needs a value`); return x; };
|
|
61
|
+
if (a === '--cases') o.cases = v(); else if (a === '--arm') o.arm = v(); else if (a === '--split') o.split = v();
|
|
62
|
+
else if (a === '--only') o.only = v().split(','); else if (a === '--model') o.model = v(); else if (a === '--advisor') o.advisor = v();
|
|
63
|
+
else if (a === '--case-budget') o.caseBudget = Number(v()); else if (a === '--max-cost') o.maxCost = Number(v());
|
|
64
|
+
else if (a === '--floors') o.floors = v(); else if (a === '--out') o.out = v(); else if (a === '--no-tools') o.noTools = true;
|
|
65
|
+
else throw new Error(`unknown argument ${a}`);
|
|
66
|
+
}
|
|
67
|
+
if (!o.cases || !['prepass', 'agent'].includes(o.arm) || !['all', 'train', 'holdout'].includes(o.split)) throw new Error('--cases DIR is required; --arm prepass|agent; --split all|train|holdout');
|
|
68
|
+
return o;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadCases(dir, { split = 'all', only = null } = {}) {
|
|
72
|
+
return fs.readdirSync(dir).sort().filter((d) => fs.existsSync(path.join(dir, d, 'case.json')))
|
|
73
|
+
.map((d) => ({ ...JSON.parse(fs.readFileSync(path.join(dir, d, 'case.json'), 'utf8')), dir: path.join(dir, d) }))
|
|
74
|
+
.filter((c) => (split === 'all' || (split === 'holdout') === !!c.holdout) && (!only || only.includes(c.id)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ENV = (tmp) => ({ ...process.env, GIT_CONFIG_GLOBAL: path.join(tmp, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' });
|
|
78
|
+
function git(root, env, ...a) { const r = spawnSync('git', ['-C', root, ...a], { encoding: 'utf8', env }); if (r.status !== 0) throw new Error(`git ${a.join(' ')}: ${r.stderr}`); return r.stdout; }
|
|
79
|
+
// A folder a build writes (bin, dist, …) is stored with a leading '=' inside a case, so a repository's own commit
|
|
80
|
+
// gates do not read a case's past source file as a build artifact; the case is built under the real name.
|
|
81
|
+
const BUILD_DIRS = new Set(['node_modules', 'bower_components', 'dist', 'build', 'out', 'target', 'bin', 'obj', 'vendor', 'coverage']);
|
|
82
|
+
export const storedPath = (p) => p.split('/').map((s, i, a) => (i < a.length - 1 && BUILD_DIRS.has(s) ? `=${s}` : s)).join('/');
|
|
83
|
+
export const realPath = (p) => p.split('/').map((s) => (s.startsWith('=') && BUILD_DIRS.has(s.slice(1)) ? s.slice(1) : s)).join('/');
|
|
84
|
+
function tree(dir) { const out = []; const walk = (d) => { if (!fs.existsSync(d)) return; for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else out.push(path.relative(dir, p).split(path.sep).join('/')); } }; walk(dir); return out.sort(); }
|
|
85
|
+
|
|
86
|
+
// The case as a repository: before committed, after staged. Returns { root, env, diff }.
|
|
87
|
+
export function build(c, tmp) {
|
|
88
|
+
const root = path.join(tmp, c.id); fs.mkdirSync(root, { recursive: true });
|
|
89
|
+
fs.writeFileSync(path.join(tmp, 'gitconfig'), '[user]\n\tname = bench\n\temail = bench@bench.invalid\n[core]\n\tautocrlf = false\n\tfileMode = false\n[init]\n\tdefaultBranch = main\n');
|
|
90
|
+
const env = ENV(tmp);
|
|
91
|
+
const put = (side) => {
|
|
92
|
+
for (const s of tree(path.join(c.dir, side))) {
|
|
93
|
+
const f = realPath(s);
|
|
94
|
+
const out = path.join(root, f); fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
95
|
+
fs.writeFileSync(out, fs.readFileSync(path.join(c.dir, side, s), 'utf8').replaceAll('{{TOKEN}}', TOKEN));
|
|
96
|
+
fs.chmodSync(out, c.modes?.[side]?.[f] === '100755' ? 0o755 : 0o644);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
// A file's mode is written into the index, not read from the disk: Windows has no executable bit, so a case whose
|
|
100
|
+
// whole diff is a mode (mode-bit) built as no diff at all there. `git init` probes the disk and writes its own
|
|
101
|
+
// core.filemode into the repository (true on macOS and Linux, false on Windows), so it is set after init, to false,
|
|
102
|
+
// and every platform takes this one path.
|
|
103
|
+
const modes = (side) => { for (const [f, m] of Object.entries(c.modes?.[side] ?? {})) git(root, env, 'update-index', `--chmod=${m === '100755' ? '+x' : '-x'}`, '--', f); };
|
|
104
|
+
git(root, env, 'init', '-q', '.'); git(root, env, 'config', 'core.fileMode', 'false');
|
|
105
|
+
put('before'); git(root, env, 'add', '-A'); modes('before'); git(root, env, 'commit', '-qm', 'base', '--allow-empty');
|
|
106
|
+
if (c.kind !== 'task') {
|
|
107
|
+
for (const s of tree(path.join(c.dir, 'before'))) if (!fs.existsSync(path.join(c.dir, 'after', s))) fs.rmSync(path.join(root, realPath(s)));
|
|
108
|
+
put('after'); git(root, env, 'add', '-A'); modes('after');
|
|
109
|
+
}
|
|
110
|
+
fs.appendFileSync(path.join(root, '.git', 'info', 'exclude'), '.claude/\n.crewforth/\n');
|
|
111
|
+
const diff = git(root, env, 'diff', '--cached', '--no-ext-diff', '-U3');
|
|
112
|
+
return { root, env, diff };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whether the removed lines of the diff carry a comment: the fix's own explanation, visible to the reviewer.
|
|
116
|
+
export const hintInDiff = (diff) => diff.split('\n').some((l) => /^-(?!--)\s*(#|\/\/|\/\*|\*)\s*\S/.test(l) && !/^-\s*#!/.test(l));
|
|
117
|
+
|
|
118
|
+
export function score(c, findings) {
|
|
119
|
+
const claims = findings.filter((f) => (RANK[f.severity] ?? 1) >= RANK.low);
|
|
120
|
+
const hits = (f) => (c.expect ?? []).some((e) => e.file === f.file && (e.from == null || (f.line >= e.from - NEAR && f.line <= e.to + NEAR)));
|
|
121
|
+
const matched = claims.filter(hits);
|
|
122
|
+
return { caught: c.kind === 'defect' ? findings.some(hits) : null, claims: claims.length, matched: matched.length,
|
|
123
|
+
falseAlarms: claims.filter((f) => !hits(f)).map((f) => ({ case: c.id, file: f.file, line: f.line, severity: f.severity, title: f.title })) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const flat = (doc) => (doc?.findings ?? []).map((f) => ({ file: f.evidence?.[0]?.file, line: f.evidence?.[0]?.line, severity: f.severity ?? 'low', title: f.title, rule: f.rule }));
|
|
127
|
+
// `file:line · severity · …` lines of a final answer, for a review that wrote no file.
|
|
128
|
+
export const fromText = (text) => [...String(text).matchAll(/([\w./-]+\.[\w]+):(\d+)\s*[·|-]\s*(critical|high|medium|low|info)\b[^\n]*/gi)]
|
|
129
|
+
.map((m) => ({ file: m[1].replace(/^\.\//, ''), line: Number(m[2]), severity: m[3].toLowerCase(), title: m[0].slice(0, 160) }));
|
|
130
|
+
|
|
131
|
+
function copyKit(root) {
|
|
132
|
+
const dst = path.join(root, '.claude');
|
|
133
|
+
const parts = [['agents/crew-review-agent.md'], ['skills/crew-code-review'], ['skills/docs-writer'], ['eval/lib/review-prepass'], ['eval/lib/findings'], ['eval/lib/crew-map'], ['hooks/secret-blocklist.txt']];
|
|
134
|
+
for (const [p] of parts) { const s = path.join(KIT, p); if (fs.existsSync(s)) fs.cpSync(s, path.join(dst, p), { recursive: true }); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The flags every headless run shares. The bench measures the model and Crewforth, not the machine it runs on: no
|
|
138
|
+
// user settings (an advisor model, hooks), no MCP server of the user's, no session written. Seen without them: a run
|
|
139
|
+
// took the user's advisor model, and the process stayed alive after its answer. An advisor is the arm's own choice.
|
|
140
|
+
function claudeArgs(prompt, o, tools) {
|
|
141
|
+
return ['-p', prompt, '--model', o.model, '--output-format', 'stream-json', '--verbose',
|
|
142
|
+
'--permission-mode', 'bypassPermissions', '--allowedTools', tools, '--max-budget-usd', String(o.caseBudget),
|
|
143
|
+
'--setting-sources', 'project,local', '--strict-mcp-config', '--no-session-persistence',
|
|
144
|
+
...(o.advisor ? ['--settings', JSON.stringify({ advisorModel: o.advisor })] : [])];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The result event, what each model cost (modelUsage counts the advisor's calls under the advisor's model), and the
|
|
148
|
+
// advisor calls themselves. Measured on a headless run: an assistant event's usage carries no `iterations`; a call is
|
|
149
|
+
// a `server_tool_use` block named `advisor` in the message's content, so those are counted, once per block id.
|
|
150
|
+
export function readRun(stdout) {
|
|
151
|
+
let res = null; const seen = new Set();
|
|
152
|
+
for (const l of String(stdout || '').split('\n')) {
|
|
153
|
+
let e; try { e = JSON.parse(l); } catch { continue; }
|
|
154
|
+
if (e.type === 'result') res = e;
|
|
155
|
+
if (e.type === 'assistant' && Array.isArray(e.message?.content)) {
|
|
156
|
+
for (const c of e.message.content) if (c?.type === 'server_tool_use' && c.name === 'advisor' && c.id) seen.add(c.id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const advisorCalls = seen.size;
|
|
160
|
+
const byModel = Object.fromEntries(Object.entries(res?.modelUsage ?? {}).map(([k, u]) => [k, { cost: Number(u.costUSD ?? 0),
|
|
161
|
+
input: u.inputTokens ?? 0, output: u.outputTokens ?? 0, cacheRead: u.cacheReadInputTokens ?? 0, cacheWrite: u.cacheCreationInputTokens ?? 0 }]));
|
|
162
|
+
return { res, byModel, advisorCalls };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const runFields = ({ res, byModel, advisorCalls }) => ({
|
|
166
|
+
cost: Number(res?.total_cost_usd ?? 0), turns: res?.num_turns ?? null, ms: res?.duration_ms ?? null, byModel, advisorCalls,
|
|
167
|
+
tokens: res?.usage ? { input: res.usage.input_tokens, output: res.usage.output_tokens, cacheRead: res.usage.cache_read_input_tokens, cacheWrite: res.usage.cache_creation_input_tokens } : null });
|
|
168
|
+
const runError = (res, r) => (!res ? `no result event (rc ${r.status}${r.error ? `, ${r.error.code}` : ''})` : (res.is_error ? (res.subtype || 'error') : null));
|
|
169
|
+
// The CLI. CREW_BENCH_CLAUDE names a node script that stands in for it (the selfcheck's fake, on every platform: a
|
|
170
|
+
// script called `claude` cannot be spawned on Windows without a shell).
|
|
171
|
+
const claude = (args, opts) => (process.env.CREW_BENCH_CLAUDE
|
|
172
|
+
? spawnSync(process.execPath, [process.env.CREW_BENCH_CLAUDE, ...args], opts) : spawnSync('claude', args, opts));
|
|
173
|
+
const SPAWN = (b) => ({ cwd: b.root, encoding: 'utf8', env: b.env, maxBuffer: 256 << 20, timeout: 12 * 60 * 1000, killSignal: 'SIGKILL', input: '' });
|
|
174
|
+
|
|
175
|
+
// A task: the session does the work, then the check it never saw grades it.
|
|
176
|
+
function runTask(c, b, o) {
|
|
177
|
+
const r = claude(claudeArgs(c.intent, o, 'Bash Read Grep Glob Edit Write'), SPAWN(b));
|
|
178
|
+
const run = readRun(r.stdout);
|
|
179
|
+
if (fs.existsSync(path.join(c.dir, 'check'))) fs.cpSync(path.join(c.dir, 'check'), b.root, { recursive: true });
|
|
180
|
+
const [cmd, ...rest] = c.check ?? [];
|
|
181
|
+
const k = cmd ? spawnSync(cmd, rest, { cwd: b.root, encoding: 'utf8', env: b.env, timeout: 120000 }) : null;
|
|
182
|
+
const tail = `${k?.stdout ?? ''}${k?.stderr ?? ''}`.trim().split('\n').slice(-3).join(' | ');
|
|
183
|
+
return { findings: [], source: 'check', done: !!k && k.status === 0, check: k ? `rc ${k.status}${tail ? ` · ${tail.slice(0, 200)}` : ''}` : 'no check',
|
|
184
|
+
...runFields(run), error: runError(run.res, r) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function runAgent(c, b, o) {
|
|
188
|
+
copyKit(b.root);
|
|
189
|
+
const prompt = `files: the staged change (git diff --cached)\nchange: audit\nverify: none\n\n`
|
|
190
|
+
+ `Review the staged change. Its author says: "${c.intent}". Follow crew-code-review: run the pre-pass first `
|
|
191
|
+
+ `(bash .claude/eval/lib/review-prepass/prepass.sh), carry its findings over, then write your own findings to `
|
|
192
|
+
+ `.crewforth/review.json with bash .claude/eval/lib/findings/findings.sh write review <file> (the refuter is `
|
|
193
|
+
+ `not installed here, so write without --verified). Change no other file. End with your findings in the format `
|
|
194
|
+
+ `file:line · severity · category · observation · what to do instead.`;
|
|
195
|
+
const r = claude(['--agent', 'crew-review-agent', ...claudeArgs(prompt, o, 'Bash Read Grep Glob Write')], SPAWN(b));
|
|
196
|
+
const run = readRun(r.stdout); const { res } = run;
|
|
197
|
+
const read = (n) => { try { return JSON.parse(fs.readFileSync(path.join(b.root, '.crewforth', `${n}.json`), 'utf8')); } catch { return null; } };
|
|
198
|
+
const review = read('review'); const pre = read('review-prepass');
|
|
199
|
+
const reviewOk = !!review && validate(review, b.root).ok;
|
|
200
|
+
const own = reviewOk ? flat(review) : fromText(res?.result ?? '');
|
|
201
|
+
const seen = new Set(); const all = [];
|
|
202
|
+
for (const f of [...own, ...flat(pre)]) { const k = `${f.file}:${f.line}:${f.rule ?? f.title}`; if (f.file && f.line && !seen.has(k)) { seen.add(k); all.push(f); } }
|
|
203
|
+
return { findings: all, source: reviewOk ? 'review.json' : 'final answer', prepassRan: !!pre, ...runFields(run), error: runError(res, r) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function run(o) {
|
|
207
|
+
const cases = loadCases(o.cases, o);
|
|
208
|
+
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'crew-bench-')));
|
|
209
|
+
const rows = []; let spent = 0;
|
|
210
|
+
try {
|
|
211
|
+
for (const c of cases) {
|
|
212
|
+
if (o.arm === 'agent' && spent + o.caseBudget > o.maxCost) { rows.push({ id: c.id, skipped: `the run's cap ($${o.maxCost}) would be passed` }); continue; }
|
|
213
|
+
const b = build(c, tmp);
|
|
214
|
+
let out;
|
|
215
|
+
if (c.kind === 'task') {
|
|
216
|
+
if (o.arm !== 'agent') { rows.push({ id: c.id, kind: c.kind, skipped: 'a task needs the agent arm' }); continue; }
|
|
217
|
+
out = runTask(c, b, o); spent += out.cost;
|
|
218
|
+
}
|
|
219
|
+
else if (o.arm === 'prepass') {
|
|
220
|
+
const keep = process.env.CREW_SHELLCHECK; if (o.noTools) process.env.CREW_SHELLCHECK = path.join(tmp, 'no-shellcheck');
|
|
221
|
+
try { out = { findings: flat(prepass(b.root)), source: 'prepass' }; } finally { if (o.noTools) { if (keep === undefined) delete process.env.CREW_SHELLCHECK; else process.env.CREW_SHELLCHECK = keep; } }
|
|
222
|
+
}
|
|
223
|
+
else { out = runAgent(c, b, o); spent += out.cost; }
|
|
224
|
+
rows.push({ id: c.id, kind: c.kind, stratum: c.stratum, holdout: !!c.holdout, hint: c.kind === 'defect' ? hintInDiff(b.diff) : null, ...out, ...score(c, out.findings) });
|
|
225
|
+
}
|
|
226
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
227
|
+
return summarise(rows, o, spent);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function summarise(rows, o, spent = 0) {
|
|
231
|
+
const done = rows.filter((r) => !r.skipped);
|
|
232
|
+
const defects = done.filter((r) => r.kind === 'defect'); const tasks = done.filter((r) => r.kind === 'task');
|
|
233
|
+
const pct = (a, b) => (b ? Math.round((1000 * a) / b) / 10 : null);
|
|
234
|
+
const rec = (list) => ({ caught: list.filter((r) => r.caught).length, of: list.length, pct: pct(list.filter((r) => r.caught).length, list.length) });
|
|
235
|
+
const claims = done.reduce((s, r) => s + r.claims, 0); const matched = done.reduce((s, r) => s + r.matched, 0);
|
|
236
|
+
const sum = (k) => done.reduce((n, r) => n + (Number(r[k]) || 0), 0);
|
|
237
|
+
return { arm: o.arm, model: o.arm === 'agent' ? o.model : null, advisor: o.arm === 'agent' ? o.advisor ?? null : null, split: o.split, cases: rows.length, ran: done.length,
|
|
238
|
+
tasks: { done: tasks.filter((r) => r.done).length, of: tasks.length }, ms: sum('ms'), advisorCalls: sum('advisorCalls'),
|
|
239
|
+
recall: rec(defects), recallNoHint: rec(defects.filter((r) => !r.hint)), recallHint: rec(defects.filter((r) => r.hint)),
|
|
240
|
+
byStratum: Object.fromEntries(['pattern', 'judgement'].map((s) => [s, rec(defects.filter((r) => r.stratum === s))])),
|
|
241
|
+
precision: { matched, of: claims, pct: pct(matched, claims) },
|
|
242
|
+
falseAlarms: done.flatMap((r) => r.falseAlarms), cost: Math.round(spent * 100) / 100, errors: done.filter((r) => r.error).map((r) => `${r.id}: ${r.error}`), rows };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function main(argv) {
|
|
246
|
+
let o; try { o = args(argv); } catch (e) { console.error(`review-bench: ${e.message}`); return 2; }
|
|
247
|
+
if (o.arm === 'agent' && claude(['--version'], { encoding: 'utf8' }).status !== 0) { console.error('review-bench: the claude CLI does not run here; the agent arm needs it'); return 2; }
|
|
248
|
+
const s = run(o);
|
|
249
|
+
const line = (n, x) => `${n} ${x.caught}/${x.of}${x.pct === null ? '' : ` (${x.pct}%)`}`;
|
|
250
|
+
console.log(`review-bench · arm ${s.arm}${s.model ? ` (${s.model}${s.advisor ? ` + ${s.advisor} advisor` : ''})` : ''} · split ${s.split} · ${s.ran}/${s.cases} cases`);
|
|
251
|
+
console.log(` ${line('recall', s.recall)} · ${line('without a hint', s.recallNoHint)} · ${line('with one', s.recallHint)}`);
|
|
252
|
+
console.log(` ${line('pattern', s.byStratum.pattern)} · ${line('judgement', s.byStratum.judgement)}`);
|
|
253
|
+
console.log(` precision ${s.precision.matched}/${s.precision.of}${s.precision.pct === null ? ' (no claim made)' : ` (${s.precision.pct}%)`} · ${s.falseAlarms.length} false alarm(s)${s.arm === 'agent' ? ` · cost $${s.cost}` : ''}`);
|
|
254
|
+
if (s.tasks.of) console.log(` tasks done ${s.tasks.done}/${s.tasks.of}`);
|
|
255
|
+
if (s.arm === 'agent') console.log(` time ${Math.round(s.ms / 1000)} s · advisor calls ${s.advisorCalls}`);
|
|
256
|
+
for (const f of s.falseAlarms) console.log(` FALSE ALARM ${f.case} ${f.file}:${f.line} ${f.severity} ${String(f.title).slice(0, 100)}`);
|
|
257
|
+
for (const e of s.errors) console.log(` ERROR ${e}`);
|
|
258
|
+
for (const r of s.rows.filter((x) => x.skipped)) console.log(` NOT RUN ${r.id}: ${r.skipped}`);
|
|
259
|
+
if (o.out) fs.writeFileSync(o.out, JSON.stringify(s, null, 2) + '\n');
|
|
260
|
+
let rc = s.errors.length && o.arm === 'agent' ? 1 : 0;
|
|
261
|
+
if (o.floors) {
|
|
262
|
+
const fl = JSON.parse(fs.readFileSync(o.floors, 'utf8'))[o.arm] ?? {};
|
|
263
|
+
for (const [k, v] of Object.entries(fl)) {
|
|
264
|
+
const got = k === 'precision' ? s.precision.pct : k === 'falseAlarms' ? s.falseAlarms.length : (k === 'recall' ? s.recall : s.byStratum[k.replace(/^recall-/, '')])?.pct;
|
|
265
|
+
const ok = k === 'falseAlarms' ? got <= v : got !== null && got !== undefined && got >= v;
|
|
266
|
+
console.log(` floor ${k}: ${got} ${k === 'falseAlarms' ? '≤' : '≥'} ${v} ${ok ? 'ok' : 'BELOW THE FLOOR'}`);
|
|
267
|
+
if (!ok) rc = 1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return rc;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|