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.
Files changed (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. 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));