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,203 @@
1
+ #!/usr/bin/env node
2
+ // prepass.mjs โ€” the review's deterministic first pass: cheap checks over the lines a change ADDS, run before the
3
+ // review agent reads the diff, written as a findings document (crewforth.findings/1) the agent starts from.
4
+ //
5
+ // node prepass.mjs [--root DIR] [--base REF] [--stdout]
6
+ // default the staged change (git diff --cached), what the next commit will hold
7
+ // --base the working tree against REF (a branch under review: --base origin/next)
8
+ // --stdout print the document, write nothing; otherwise .crewforth/review-prepass.json, through findings.mjs
9
+ // rc 0 = written (or printed), whatever it found ยท 1 = the document did not verify, nothing written ยท
10
+ // 2 = usage, or not a git repository
11
+ //
12
+ // The checks are chosen by the file, and kept narrow on purpose: a first pass that cries wolf trains the reader to
13
+ // skip it. Each one either reads the added line alone (proven) or one fact next to it (inferred):
14
+ //
15
+ // prepass-secret a secret-blocklist.txt pattern on an added line (the pre-commit gate's own list and
16
+ // .secret-allowlist.txt). Cited by sha256 only: the value is never quoted.
17
+ // prepass-debug-call console.log / debugger (JS, TS), breakpoint() / pdb (Python), print( (Dart), outside a
18
+ // test and outside a command-line script (a shebang, or a bin/, scripts/ or tool/ folder).
19
+ // prepass-todo a TODO, FIXME, XXX or HACK opening a comment added in code, outside tests
20
+ // (Markdown is prose, not a promise; the word in a string or a pattern is not a marker).
21
+ // prepass-shellcheck shellcheck's own findings on a shell file, kept to the added lines. Without shellcheck
22
+ // the shell files are listed as not measured: nothing is installed.
23
+ // prepass-lockfile a lock file changed and the manifest beside it did not (inferred: a regenerated lock
24
+ // can be on purpose).
25
+ // prepass-large-change one file gains more than 500 lines (generated files, locks and snapshots aside).
26
+ //
27
+ // What it does not do: judge. A finding here says a line is there; whether it matters is the reviewer's call, and
28
+ // the reviewer writes its own findings beside these (.crewforth/review.json), in the same shape.
29
+ // Host-independent like findings.mjs: git, Node's standard library, and shellcheck when it is there.
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import crypto from 'node:crypto';
35
+ import { spawnSync } from 'node:child_process';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { loadSecretPatterns, isTestPath } from '../crew-map/project.mjs';
38
+ import { SCHEMA_ID, validate, write, serialize } from '../findings/findings.mjs';
39
+
40
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
41
+ export const NAME = 'review-prepass';
42
+ const LARGE = 500;
43
+ const QUOTE = 200;
44
+
45
+ const DEBUG = [
46
+ { files: /\.(m?[jt]sx?|cjs|cts|vue|svelte)$/, re: /\bconsole\.(log|debug|trace)\(|^\s*debugger\s*;?\s*$/, what: 'a console call or debugger statement' },
47
+ { files: /\.py$/, re: /\bbreakpoint\(\)|\bpdb\.set_trace\(|^\s*import\s+pdb\b/, what: 'a debugger breakpoint' },
48
+ { files: /\.dart$/, re: /^\s*print\(/, what: 'a print call' },
49
+ ];
50
+ const SCRIPT_DIR = /(^|\/)(bin|scripts?|tools?|tool)\//;
51
+ // A marker opens a comment; the word inside a string, a pattern or prose that names it is not one.
52
+ const TODO = /(?:\/\/|#|\/\*|^\s*\*|--|<!--)\s*(TODO|FIXME|XXX|HACK)\b/;
53
+ const PROSE = /\.(md|markdown|txt|rst|adoc)$/i;
54
+ const SHELL = /\.(sh|bash)$/;
55
+ const SECRET_LISTS = /(^|\/)secret-(block|allow)list\.txt$/;
56
+ // A lock file and the manifest that declares what it pins, in the same folder.
57
+ const LOCKS = {
58
+ 'package-lock.json': 'package.json', 'npm-shrinkwrap.json': 'package.json', 'yarn.lock': 'package.json',
59
+ 'pnpm-lock.yaml': 'package.json', 'bun.lockb': 'package.json', 'pubspec.lock': 'pubspec.yaml',
60
+ 'poetry.lock': 'pyproject.toml', 'uv.lock': 'pyproject.toml', 'Pipfile.lock': 'Pipfile', 'go.sum': 'go.mod',
61
+ 'Cargo.lock': 'Cargo.toml', 'composer.lock': 'composer.json', 'Gemfile.lock': 'Gemfile',
62
+ };
63
+ const GENERATED = /(^|\/)(dist|build|vendor|node_modules|__snapshots__)\/|\.(g|freezed|gr|pb)\.dart$|\.min\.(js|css)$|\.snap$|\.lock$|-lock\.(json|yaml)$/;
64
+
65
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
66
+ const git = (root, args) => spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
67
+
68
+ // The added lines of a unified diff with no context: { file, line, text }[] and the files it touches.
69
+ export function addedLines(diff) {
70
+ const added = []; const files = new Set(); const binary = new Set();
71
+ let file = null; let n = 0;
72
+ for (const raw of diff.split('\n')) {
73
+ if (raw.startsWith('diff --git ')) { file = null; continue; }
74
+ if (raw.startsWith('+++ ')) { file = raw === '+++ /dev/null' ? null : raw.slice(6).replace(/\t$/, ''); if (file) files.add(file); continue; }
75
+ if (raw.startsWith('Binary files ')) { const b = / and b\/(.*) differ$/.exec(raw); if (b) binary.add(b[1]); continue; }
76
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
77
+ if (h) { n = Number(h[1]); continue; }
78
+ if (file && raw.startsWith('+')) { added.push({ file, line: n, text: raw.slice(1).replace(/\r$/, '') }); n++; }
79
+ }
80
+ return { added, files: [...files].sort(), binary: [...binary].sort() };
81
+ }
82
+
83
+ // A finding id from a rule, a file and a line: lower case, the schema's characters only, unique in the document.
84
+ function idOf(seen, rule, file, line) {
85
+ let id = `${rule}:${file.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}:${line}`.slice(0, 190);
86
+ if (seen.has(id)) { let k = 2; while (seen.has(`${id}.${k}`)) k++; id = `${id}.${k}`; }
87
+ seen.add(id); return id;
88
+ }
89
+
90
+ export function prepass(root = '.', { base = null } = {}) {
91
+ const rootReal = fs.realpathSync(root);
92
+ const top = git(rootReal, ['rev-parse', '--show-toplevel']);
93
+ if (top.status !== 0) throw new Error('not a git repository');
94
+ const diffArgs = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '-U0', '--diff-filter=AMR'];
95
+ const d = git(rootReal, base ? [...diffArgs, base, '--'] : [...diffArgs, '--cached', '--']);
96
+ if (d.status !== 0) throw new Error(`git diff failed: ${(d.stderr || '').trim().split('\n')[0]}`);
97
+ const { added, files, binary } = addedLines(d.stdout);
98
+
99
+ const doc = { schema: SCHEMA_ID, producer: { name: NAME, version: '1' }, findings: [], notMeasured: [] };
100
+ const seen = new Set();
101
+ const machine = [rootReal, os.homedir()].filter((p) => p && p.length > 1);
102
+ const add = (rule, severity, confidence, title, at, detail, quote = true) => {
103
+ // A line that carries this machine's directory, or must not be quoted, is cited by its hash.
104
+ const ev = { file: at.file, line: at.line };
105
+ if (quote && !machine.some((m) => at.text.includes(m)) && at.text.trim()) ev.text = at.text.trim().slice(0, QUOTE);
106
+ else ev.sha256 = sha(at.text);
107
+ doc.findings.push({ id: idOf(seen, rule, at.file, at.line), rule, title, confidence, severity, ...(detail ? { detail } : {}), evidence: [ev] });
108
+ };
109
+
110
+ // A staged line the working tree no longer has cannot be cited (the citation is checked against the tree).
111
+ const tree = new Map();
112
+ const linesOf = (f) => { if (!tree.has(f)) { let t = null; try { t = fs.readFileSync(path.join(rootReal, f), 'utf8').split('\n').map((l) => l.replace(/\r$/, '')); } catch { /* gone */ } tree.set(f, t); } return tree.get(f); };
113
+ const stale = new Set();
114
+ const lines = added.filter((a) => { const t = linesOf(a.file); if (t && t[a.line - 1] === a.text) return true; stale.add(a.file); return false; });
115
+ for (const f of [...stale].sort()) doc.notMeasured.push({ what: f, reason: 'edited again after it was staged: its staged lines are not the ones on disk, so they were not read' });
116
+ for (const f of binary) doc.notMeasured.push({ what: f, reason: 'a binary file: no line to read' });
117
+
118
+ // ---- secrets: the pre-commit gate's list, minus the project's allowlist ----
119
+ let allow = new Set();
120
+ try { allow = new Set(fs.readFileSync(path.join(rootReal, '.secret-allowlist.txt'), 'utf8').split('\n').map((l) => l.replace(/\r$/, '')).filter(Boolean)); } catch { /* none */ }
121
+ const patterns = loadSecretPatterns(path.join(HERE, '..', 'crew-map'), allow);
122
+ if (!patterns) doc.notMeasured.push({ what: 'prepass-secret', reason: 'secret-blocklist.txt was not found beside the hooks, so no line was checked for a secret' });
123
+ const byFile = new Map();
124
+ for (const a of lines) { if (!byFile.has(a.file)) byFile.set(a.file, []); byFile.get(a.file).push(a); }
125
+
126
+ for (const [f, list] of [...byFile].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
127
+ const test = isTestPath(f);
128
+ const first = linesOf(f)?.[0] ?? '';
129
+ const script = first.startsWith('#!') || SCRIPT_DIR.test(f);
130
+ const debug = DEBUG.find((x) => x.files.test(f));
131
+ for (const a of list) {
132
+ if (patterns && !SECRET_LISTS.test(f) && patterns.some((re) => re.test(a.text))) {
133
+ add('prepass-secret', 'high', 'proven', 'A secret pattern on an added line', a, 'It matches a pattern of secret-blocklist.txt, the list the pre-commit gate refuses; the value is not quoted here.', false);
134
+ continue;
135
+ }
136
+ if (debug && !test && !script && debug.re.test(a.text)) add('prepass-debug-call', 'low', 'proven', `An added ${debug.what}`, a);
137
+ if (!PROSE.test(f) && !test && TODO.test(a.text)) add('prepass-todo', 'info', 'proven', `An added ${TODO.exec(a.text)[1]}`, a);
138
+ }
139
+ if (list.length > LARGE && !GENERATED.test(f)) add('prepass-large-change', 'low', 'proven', `${list.length} lines added to one file`, list[0], 'A change this size is hard to review in one pass; split it, or say in the description what to read first.');
140
+ }
141
+
142
+ // ---- lock files: the manifest beside them should have changed too ----
143
+ const changed = new Set(files);
144
+ for (const f of files) {
145
+ const want = LOCKS[path.posix.basename(f)]; if (!want) continue;
146
+ const dir = path.posix.dirname(f); const manifest = dir === '.' ? want : `${dir}/${want}`;
147
+ if (changed.has(manifest) || !fs.existsSync(path.join(rootReal, manifest))) continue;
148
+ const at = byFile.get(f)?.[0]; if (!at) continue;
149
+ add('prepass-lockfile', 'low', 'inferred', `${path.posix.basename(f)} changed and ${want} did not`, at, 'A lock file that moves on its own pins different versions than the manifest asks for; regenerate it from the manifest, or say why it moved.');
150
+ }
151
+
152
+ // ---- shell: shellcheck's findings on the added lines ----
153
+ const shells = [...byFile.keys()].filter((f) => SHELL.test(f) || /^#!.*\b(ba|z|da)?sh\b/.test(linesOf(f)?.[0] ?? '')).sort();
154
+ if (shells.length) {
155
+ // CREW_SHELLCHECK names another shellcheck. One ending in .mjs is run with this Node: the self-check's stand-in,
156
+ // which a Windows runner could not start as a `#!/bin/sh` script (spawnSync runs no interpreter line there).
157
+ const sc = process.env.CREW_SHELLCHECK || 'shellcheck';
158
+ const run = (args, opts) => (sc.endsWith('.mjs') ? spawnSync(process.execPath, [sc, ...args], opts) : spawnSync(sc, args, opts));
159
+ const v = run(['--version'], { encoding: 'utf8' });
160
+ if (v.status !== 0) doc.notMeasured.push({ what: `prepass-shellcheck (${shells.length} shell file${shells.length === 1 ? '' : 's'})`, reason: 'shellcheck is not installed here, so the shell files were not linted; nothing was installed' });
161
+ else {
162
+ for (const f of shells) {
163
+ const r = run(['-f', 'json1', '-x', f], { cwd: rootReal, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
164
+ let out; try { out = JSON.parse(r.stdout || '{}'); } catch { doc.notMeasured.push({ what: f, reason: 'shellcheck gave no output this pass can read' }); continue; }
165
+ const at = new Map(byFile.get(f).map((a) => [a.line, a]));
166
+ for (const c of out.comments ?? []) {
167
+ const a = at.get(c.line); if (!a) continue;
168
+ const sev = { error: 'medium', warning: 'low' }[c.level] ?? 'info';
169
+ add('prepass-shellcheck', sev, 'proven', `SC${c.code}: ${String(c.message).slice(0, 180)}`, a);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ doc.findings.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
175
+ return doc;
176
+ }
177
+
178
+ function main(argv) {
179
+ let root = '.'; let base = null; let stdout = false;
180
+ for (let i = 0; i < argv.length; i++) {
181
+ const a = argv[i];
182
+ if (a === '--root') root = argv[++i];
183
+ else if (a === '--base') base = argv[++i];
184
+ else if (a === '--stdout') stdout = true;
185
+ else { console.error(`prepass: unknown argument ${a}\nusage: prepass.mjs [--root DIR] [--base REF] [--stdout]`); return 2; }
186
+ if (root === undefined || base === undefined) { console.error('prepass: --root and --base need a value'); return 2; }
187
+ }
188
+ let doc;
189
+ try { doc = prepass(root, { base }); } catch (e) { console.error(`prepass: ${e.message}`); return 2; }
190
+ const count = (s) => doc.findings.filter((f) => f.severity === s).length;
191
+ const line = `review-prepass: ${doc.findings.length} finding(s) ยท high ${count('high')} ยท medium ${count('medium')} ยท low ${count('low')} ยท info ${count('info')} ยท ${doc.notMeasured.length} not measured`;
192
+ if (stdout) {
193
+ const r = validate(doc, root);
194
+ if (!r.ok) { for (const p of r.problems) console.error(`prepass: ${p.at} ${p.code}: ${p.message}`); return 1; }
195
+ process.stdout.write(serialize(doc)); console.error(line); return 0;
196
+ }
197
+ const r = write(doc, root, NAME);
198
+ if (!r.ok) { for (const p of r.problems) console.error(`prepass: ${p.at} ${p.code}: ${p.message}`); return 1; }
199
+ console.log(`${line} โ†’ ${r.file}`);
200
+ return 0;
201
+ }
202
+
203
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # prepass.sh โ€” the shell entry to prepass.mjs, the review's deterministic first pass. It finds a Node that RUNS
3
+ # and hands over; the arguments and exit codes are prepass.mjs's (0, 1, 2).
4
+ #
5
+ # bash prepass.sh [--root DIR] [--base REF] [--stdout]
6
+ # rc 3 = no usable Node here, so nothing was checked. Not a pass: the review then reads the diff without it.
7
+ #
8
+ # The Node is found the way findings.sh finds it (one rule): asked for its version, never trusted by name.
9
+ set -u
10
+ d="${BASH_SOURCE%[/\\]*}"; [ "$d" = "${BASH_SOURCE}" ] && d=.
11
+ FLOOR=18
12
+ usable(){ local v; v="$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null)" || return 1
13
+ case "$v" in ''|*[!0-9]*) return 1 ;; esac; [ "$v" -ge "$FLOOR" ]; }
14
+ NODE=""
15
+ if usable node; then NODE=node
16
+ elif [ -f "$d/../../../studio/ensure-node.sh" ]; then
17
+ n="$(bash "$d/../../../studio/ensure-node.sh" 2>/dev/null)" && [ -n "$n" ] && usable "$n" && NODE="$n"
18
+ fi
19
+ if [ -z "$NODE" ]; then
20
+ echo "review-prepass: no Node $FLOOR+ that runs was found, so nothing was checked (this is not a pass)" >&2
21
+ exit 3
22
+ fi
23
+ exec "$NODE" "$d/prepass.mjs" "$@"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # settings-default.sh โ€” put one of Crewforth's defaults into the project's .claude/settings.json, only where nothing
3
+ # sets it yet. The project's settings win over the user's, so writing a default over a user's own choice would
4
+ # silently replace it: a value set in the project's settings, its local settings or the user's own is kept.
5
+ #
6
+ # bash settings-default.sh --install <path> <defaults.json> [ENV_VAR] from the project root
7
+ # <path> the setting, as a dot path: statusLine, env.CLAUDE_CODE_SUBAGENT_MODEL
8
+ # <defaults.json> a JSON object holding just that setting (data beside the caller: one definition)
9
+ # ENV_VAR also kept when this variable is set in the environment (a value exported in the shell)
10
+ #
11
+ # Prints one word: installed ยท kept (it is set) ยท none (no settings.json) ยท failed (the file is left as it was).
12
+ # A managed settings layer is not read. The merge is Crewforth's own JSON reader.
13
+ set -u
14
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
15
+
16
+ [ "${1:-}" = --install ] && [ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "usage: settings-default.sh --install <path> <defaults.json> [ENV_VAR]" >&2; exit 2; }
17
+ KEY="$2"; K="$3"; VAR="${4:-}"
18
+ P=".claude/settings.json"; SJ="$_here/settings-json.awk"
19
+ [ -f "$P" ] && [ -f "$SJ" ] || { echo none; exit 0; }
20
+ case "$VAR" in '') ;; [A-Za-z_]*) case "$VAR" in *[!A-Za-z0-9_]*) echo failed; exit 0 ;; esac; [ -n "${!VAR:-}" ] && { echo kept; exit 0; } ;; *) echo failed; exit 0 ;; esac
21
+ for f in "$P" ".claude/settings.local.json" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; do
22
+ [ -f "$f" ] && awk -v op=get -v path="$KEY" -f "$SJ" "$f" >/dev/null 2>&1 && { echo kept; exit 0; }
23
+ done
24
+ [ -f "$K" ] || { echo failed; exit 0; }
25
+ t="$P.default.$$"
26
+ r=failed
27
+ # The project's file goes FIRST: the merge refreshes the first file's .claude/hooks/ entries from the first file,
28
+ # so put second it would lose every Crewforth hook (measured: the installed project's own smoke failed 30 checks).
29
+ # The second file holds only the default, which the first lacks: that is the one change (an object such as env is
30
+ # merged member by member, the project's own members kept). Written through the file, so a symlink and its mode
31
+ # survive.
32
+ if awk -v op=merge -v retired= -f "$SJ" "$P" "$K" > "$t" 2>/dev/null && [ -s "$t" ] \
33
+ && awk -v op=validate -f "$SJ" "$t" 2>/dev/null && awk -v op=get -v path="$KEY" -f "$SJ" "$t" >/dev/null 2>&1; then
34
+ cat "$t" > "$P" && r=installed
35
+ fi
36
+ rm -f "$t"; echo "$r"
@@ -4,10 +4,16 @@
4
4
  # awk -v op=validate -f settings-json.awk FILE rc 0 = FILE is a JSON object, 1 = it is not
5
5
  # awk -v op=get -v path=hooks.Stop.0 -f settings-json.awk FILE the value there, as the merge would print it
6
6
  # awk -v op=len -v path=hooks.Stop -f settings-json.awk FILE how many entries an array/object holds
7
+ # awk -v op=gets -v path='a.b|c.d' -f settings-json.awk FILE several values in one read, tab-separated, "" if absent
7
8
  # awk -v op=strings -v path=permissions.deny -f settings-json.awk FILE each string element of that array, one per
8
9
  # line, quotes off, escapes as written (rc 1 = not an array)
10
+ # awk -v op=keys [-v path=P] -f settings-json.awk FILE the member names of the object there, one per line, quotes
11
+ # off, in file order (rc 1 = not an object)
9
12
  # path = dot-separated keys, a number indexes an array (0-based), empty = the root. rc 1 = no such path
10
13
  # (or, for len, a scalar there), 10 = FILE is not a JSON object.
14
+ # awk -v op=get -v path=P -v setk=M -v setv=RAWJSON -f settings-json.awk FILE the object at P with member M set
15
+ # to RAWJSON (added when absent): a payload rewritten in one field, every other field and escape kept
16
+ # (rc 1 = nothing at P, or not an object)
11
17
  # awk -v op=overlay -v key=K [-v setk=M -v setv=RAWJSON] -f settings-json.awk FILE1 FILE2
12
18
  # FILE1 with FILE1.K shallow-merged from FILE2.K (FILE2 wins, FILE1's other members and order kept), then
13
19
  # member M set to RAWJSON. rc 10 = FILE1 (or its K) is not an object, 11 = FILE2 has no object at K.
@@ -69,6 +75,10 @@ function at(id,path, n,sg,i){ n=split(path,sg,".")
69
75
  return id }
70
76
  function mk(t){ NN++; T[NN]=t; N[NN]=0; return NN }
71
77
  function add(o,k,c){ N[o]++; K[o,N[o]]=k; C[o,N[o]]=c }
78
+ # Member k of object o set to the raw JSON v, added at the end when o has none (overlay and get share it).
79
+ function setm(o,k,v, sn,i,x){ sn=mk("s"); V[sn]=v; x=0
80
+ for(i=1;i<=N[o];i++) if(K[o,i]=="\"" k "\""){ C[o,i]=sn; x=1 }
81
+ if(!x) add(o,"\"" k "\"",sn) }
72
82
  function canon(id, i,j,n,s,t,ord){
73
83
  if(T[id]=="s") return V[id]
74
84
  if(T[id]=="a"){ s="["; for(i=1;i<=N[id];i++) s=s (i>1 ? "," : "") canon(C[id,i]); return s "]" }
@@ -124,18 +134,23 @@ END{
124
134
  print (it && !isnull(it) ? V[it] : 0) + V[cr] + (cc && !isnull(cc) ? V[cc] : 0); exit 0 }
125
135
  exit 0 }
126
136
  if(op=="validate") exit !doc(1)
137
+ # gets: several paths in one read (path = "a.b|c.d"), their values on one line, tab-separated; an absent one is
138
+ # empty. One process for what a hot caller (the status line) would otherwise read with one awk per field.
139
+ if(op=="gets"){ if(!(r=doc(1))) exit 10; n=split(path,pp,"|"); o=""
140
+ for(i=1;i<=n;i++){ x=at(r,pp[i]); o=o (i>1?"\t":"") (x ? put(x,"") : "") } print o; exit 0 }
127
141
  if(op=="get" || op=="len"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1
128
- if(op=="get"){ print put(r,""); exit 0 } if(T[r]=="s") exit 1; print N[r]; exit 0 }
142
+ if(op=="get"){ if(setk!=""){ if(T[r]!="o") exit 1; setm(r,setk,setv) } print put(r,""); exit 0 }
143
+ if(T[r]=="s") exit 1; print N[r]; exit 0 }
129
144
  if(op=="strings"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1; if(T[r]!="a") exit 1
130
145
  for(i=1;i<=N[r];i++){ c=C[r,i]; if(T[c]=="s" && substr(V[c],1,1)=="\"") print substr(V[c],2,length(V[c])-2) }
131
146
  exit 0 }
147
+ if(op=="keys"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1; if(T[r]!="o") exit 1
148
+ for(i=1;i<=N[r];i++){ k=K[r,i]; print substr(k,2,length(k)-2) } exit 0 }
132
149
  if(op=="overlay"){ if(!(t=doc(1))) exit 10; if(!(q=doc(2))) exit 11; kk="\"" key "\""
133
150
  qv=get(q,kk); if(T[qv]!="o") exit 11; tv=get(t,kk); if(tv && T[tv]!="o") exit 10
134
151
  nv=mk("o"); for(i=1;i<=N[tv];i++){ x=get(qv,K[tv,i]); add(nv,K[tv,i], x ? x : C[tv,i]) }
135
152
  for(i=1;i<=N[qv];i++) if(!get(nv,K[qv,i])) add(nv,K[qv,i],C[qv,i])
136
- if(setk!=""){ sn=mk("s"); V[sn]=setv; x=0
137
- for(i=1;i<=N[nv];i++) if(K[nv,i]=="\"" setk "\""){ C[nv,i]=sn; x=1 }
138
- if(!x) add(nv,"\"" setk "\"",sn) }
153
+ if(setk!="") setm(nv,setk,setv)
139
154
  x=0; for(i=1;i<=N[t];i++) if(K[t,i]==kk){ C[t,i]=nv; x=1 }
140
155
  if(!x) add(t,kk,nv)
141
156
  print put(t,""); exit 0 }
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # The one "leave a star" line. Single source of its URL, its text in both languages, and when it stays quiet.
3
3
  #
4
- # star.sh --once <project> ONCE PER KIT VERSION per clone. The installers (first install, and every update)
5
- # and doctor.sh on a healthy verdict all call it this way and share one marker, so a
6
- # version shows the line once โ€” whichever of them reaches it first โ€” and never again
7
- # until the Crewforth version changes. The marker holds the version it was shown for.
4
+ # star.sh --once <project> [doctor]
5
+ # ONCE PER KIT VERSION per clone and per caller. The installers (first install, and
6
+ # every update) share one marker; doctor (doctor.sh on a healthy verdict, and the
7
+ # plugin's /crew-doctor) keeps its own, `doctor`. So a version shows the line at most
8
+ # twice, once where it was installed and once where it was checked, and never again
9
+ # until the Crewforth version changes. A marker holds the version it was shown for.
10
+ # /crew-update runs its doctor with CREW_NO_STAR=1, so one command never shows it twice.
8
11
  # star.sh unconditional (tests, and anyone who wants to see it).
9
12
  #
10
13
  # Nothing else prints it: not a hook, not a session start. Cost: one line (~30 tokens) per version, and only when
@@ -31,11 +34,21 @@ if [ "${1:-}" = --once ]; then
31
34
  # Builtin reads, not `head | tr` in a $( ): on Git Bash those two pipelines were 4 of ~11 processes and ~250 ms
32
35
  # of ~395 ms per call (measured on stock Windows). `read` takes the first line; the CR is stripped by expansion.
33
36
  # The braces matter: a failed `<` reports before a trailing 2>/dev/null applies (measured: 450 bytes of stderr).
34
- VER=""; { IFS= read -r VER < "$_dir/.claude/VERSION"; } 2>/dev/null; VER="${VER%$'\r'}"; VER="${VER:-unknown}"
37
+ VER=""; { IFS= read -r VER < "$_dir/.claude/VERSION"; } 2>/dev/null; VER="${VER%$'\r'}"
38
+ # The plugin edition has no .claude/VERSION: its version is the manifest's, beside this file at ../../.
39
+ if [ -z "$VER" ]; then
40
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
41
+ [ -f "$_here/../../.claude-plugin/plugin.json" ] && [ -f "$_here/settings-json.awk" ] && \
42
+ VER="$(awk -v op=get -v path=version -f "$_here/settings-json.awk" "$_here/../../.claude-plugin/plugin.json" 2>/dev/null)"
43
+ VER="${VER#\"}"; VER="${VER%\"}" # the reader prints a JSON string with its quotes
44
+ case "$VER" in *[!0-9A-Za-z.-]*) VER="" ;; esac
45
+ fi
46
+ VER="${VER:-unknown}"
47
+ _key=crewforth-star; [ "${3:-}" = doctor ] && _key=crewforth-star-doctor
35
48
  # GIT_DIR/GIT_WORK_TREE dropped: exported by a caller (a hook), they would point the marker at another repo.
36
- MARK="$(cd "$_dir" 2>/dev/null && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --git-path crewforth-star 2>/dev/null)" || MARK=""
49
+ MARK="$(cd "$_dir" 2>/dev/null && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --git-path "$_key" 2>/dev/null)" || MARK=""
37
50
  case "$MARK" in
38
- '') MARK="$_dir/.claude/star-shown" ;;
51
+ '') MARK="$_dir/.claude/star-shown"; [ "$_key" = crewforth-star ] || MARK="$MARK-doctor" ;;
39
52
  /*|[A-Za-z]:*) ;; # absolute (POSIX or a Windows drive path from git)
40
53
  *) MARK="$_dir/$MARK" ;; # git answers relative to the directory it was asked in
41
54
  esac
@@ -65,6 +78,9 @@ _mt() {
65
78
  _mt 'โญ If Crewforth saves you a review round, a star helps others find it: %s' "$CREW_REPO_URL"
66
79
  # The marker only after the line actually went out: a closed stdout must not record "shown".
67
80
  if printf '%s\n' "$_M" && [ -n "$MARK" ]; then
81
+ # A plugin project may have no .claude/ yet: without one the marker would never be written and the line would
82
+ # come back on every run.
83
+ [ -d "${MARK%/*}" ] || mkdir -p "${MARK%/*}" 2>/dev/null
68
84
  { printf '%s\n' "$VER" > "$MARK"; } 2>/dev/null
69
85
  fi
70
86
  exit 0
@@ -0,0 +1,6 @@
1
+ {
2
+ "statusLine": {
3
+ "type": "command",
4
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/eval/lib/statusline.sh"
5
+ }
6
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # statusline.sh โ€” Crewforth's status line: one line, from what Claude Code hands it on stdin and what the hooks
3
+ # have already written. It decides nothing and blocks nothing; it only says.
4
+ #
5
+ # ๐Ÿ›ก BLOCK ยง4.2 ยท โœ“ commit approved 24m ยท โš™ 2 crew agents ยท ๐Ÿ”‹ 34% ยท ~$1.20
6
+ #
7
+ # Each part is shown only when there is something to say, and only for THIS session:
8
+ # ๐Ÿ›ก the gate's last word in this session: the last line of the gate log, if it was written after the session's
9
+ # first status line (the log carries no time, so a line from last week must not read as now)
10
+ # โœ“ an approval the user gave for a commit or a push that still holds (the record prompt-approval.sh keeps in
11
+ # the git directory, 30 minutes, emptied at the user's next message)
12
+ # โš™ crew agents started in this session that have not stopped yet (model routing's records)
13
+ # ๐Ÿ”‹ the context fill Claude Code measured (context_window.used_percentage), never an estimate
14
+ # ~$ what Claude Code puts the session's cost at (cost.total_cost_usd), at API list price
15
+ #
16
+ # Wired by the installers only where no status line is set already (theirs is never replaced), from
17
+ # statusline.settings.json beside this file.
18
+ # COST. Claude Code runs this after every change, debounced at 300 ms. Measured: awk (the stdin JSON) and tail (the
19
+ # gate log), and on bash older than 4.2 one date; the rest is builtins. No git, no jq, no python.
20
+ set -u
21
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
22
+
23
+ # --install, from the project root: put this status line into .claude/settings.json only where NO status line is
24
+ # set (the project's settings, its local settings, or the user's own); a user's line is never replaced, because
25
+ # the project's setting would win over it. Prints one word: installed ยท kept (one is set) ยท none (no settings.json)
26
+ # ยท failed (the file is left as it was). A managed settings layer is not read. The merge is Crewforth's own JSON reader.
27
+ if [ "${1:-}" = --install ]; then
28
+ # The setting itself is data beside this file (statusline.settings.json); the rule for putting a default in is
29
+ # one helper, shared with the subagent model's default.
30
+ exec bash "$_here/settings-default.sh" --install statusLine "$_here/statusline.settings.json"
31
+ fi
32
+
33
+ IFS= read -r -d '' IN || true
34
+ [ -n "$IN" ] || exit 0
35
+ # One read of the four values the line needs.
36
+ F="$(printf '%s' "$IN" | awk -v op=gets -v 'path=session_id|context_window.used_percentage|cost.total_cost_usd|workspace.project_dir' -f "$_here/settings-json.awk" - 2>/dev/null)" || F=""
37
+ IFS=$'\t' read -r SID PCT COST PDIR <<< "$F"
38
+ # The reader prints a string with its quotes; taken off by expansion, not a subshell (this runs every few seconds).
39
+ SID="${SID:-}"; SID="${SID#\"}"; SID="${SID%\"}"; PDIR="${PDIR:-}"; PDIR="${PDIR#\"}"; PDIR="${PDIR%\"}"
40
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) SID="" ;; esac
41
+ [ -n "$PDIR" ] && [ -d "$PDIR/.claude" ] && cd "$PDIR" 2>/dev/null
42
+
43
+ OUT=()
44
+ # The session's baseline: made on its first status line. What is newer than it happened in this session.
45
+ BASE=""
46
+ if [ -n "$SID" ] && [ -d .claude ]; then
47
+ BASE=".claude/state/statusline/$SID"
48
+ if [ ! -e "$BASE" ]; then
49
+ mkdir -p .claude/state/statusline 2>/dev/null
50
+ : > "$BASE" 2>/dev/null || BASE=""
51
+ fi
52
+ fi
53
+
54
+ # ๐Ÿ›ก The gate's last word in this session.
55
+ GL=""; [ -f "$_here/gate-log.sh" ] && { . "$_here/gate-log.sh"; crew_gate_log_read; GL="$_CREW_GL"; } # one rule (gate-log.sh)
56
+ if [ -n "$BASE" ] && [ -f "$GL" ] && [ "$GL" -nt "$BASE" ]; then
57
+ last="$(tail -n 1 "$GL" 2>/dev/null)"
58
+ IFS=$'\t' read -r verdict section _ <<< "$last"
59
+ case "$verdict" in BLOCK|ASK|ALLOW|NOTE) OUT+=("๐Ÿ›ก $verdict ${section:-}") ;; esac
60
+ fi
61
+
62
+ # โœ“ An approval that still holds: the record is in the git directory (a worktree's .git is a file naming it).
63
+ G=""
64
+ if [ -d .git ]; then G=".git"
65
+ elif [ -f .git ]; then IFS= read -r l < .git 2>/dev/null; case "$l" in "gitdir: "*) G="${l#gitdir: }"; G="${G%$'\r'}" ;; esac; fi
66
+ if [ -n "$G" ] && [ -s "$G/crewforth-approval" ]; then
67
+ op=""; ts=""
68
+ while IFS='=' read -r k v; do v="${v%$'\r'}"; case "$k" in op) op="$v" ;; ts) ts="$v" ;; esac; done < "$G/crewforth-approval"
69
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v now '%(%s)T' -1; else now="$(date +%s)"; fi
70
+ case "$ts" in ''|*[!0-9]*) ;; *)
71
+ left=$(( (1800 - (now - ts)) / 60 ))
72
+ case "$op" in commit|push|commit+push) [ "$left" -ge 0 ] && [ "$((now - ts))" -le 1800 ] && OUT+=("โœ“ $op approved ${left}m") ;; esac ;;
73
+ esac
74
+ fi
75
+
76
+ # โš™ Crew agents started in this session and not stopped: a record under agents/ with no result beside it.
77
+ if [ -n "$BASE" ] && [ -d .claude/state/crew-model/agents ]; then
78
+ n=0
79
+ for a in .claude/state/crew-model/agents/*; do
80
+ [ -f "$a" ] || continue
81
+ case "$a" in *.claim|*.used) continue ;; esac
82
+ [ "$a" -nt "$BASE" ] || continue
83
+ [ -e ".claude/state/crew-model/results/${a##*/}" ] && continue
84
+ n=$((n + 1))
85
+ done
86
+ pl=s; [ "$n" -eq 1 ] && pl=""
87
+ [ "$n" -gt 0 ] && OUT+=("โš™ $n crew agent$pl")
88
+ fi
89
+
90
+ # ๐Ÿ”‹ and ~$: Claude Code's own numbers, shown as they are.
91
+ case "${PCT:-}" in ''|null|*[!0-9.]*) ;; *) OUT+=("๐Ÿ”‹ ${PCT%%.*}%") ;; esac
92
+ case "${COST:-}" in ''|null|*[!0-9.]*) ;; *)
93
+ printf -v c '%.2f' "$COST" 2>/dev/null && OUT+=("~\$$c") ;;
94
+ esac
95
+
96
+ [ "${#OUT[@]}" -gt 0 ] || exit 0
97
+ line="${OUT[0]}"
98
+ for p in "${OUT[@]:1}"; do line="$line ยท $p"; done
99
+ printf '%s\n' "$line"
100
+ exit 0
@@ -0,0 +1,17 @@
1
+ # Sourced, not run. How a card's verify command is read: one definition, for the gate that runs it
2
+ # (hooks/guard-agent-model.sh) and for doctor, which offers the line that would allow it.
3
+ #
4
+ # A verify command in a sub-folder: `cd apps/mobile && flutter test`, the usual shape in a repository of several
5
+ # projects (measured on one: 41 of 71 refused verify commands had it). Split into the folder and the one command,
6
+ # so the command is judged alone and run in that folder with no chain; a `verify <command>` rule names the command
7
+ # alone. Only a relative folder inside the project, with no .. step, that exists; anything else is left whole, and
8
+ # is refused as a chain.
9
+ crew_verify_split(){ # $1 = the command, $2 = the project root -> _AMVD the folder ("" = the root), _AMVC the command
10
+ local c="$1" d r root="${2:-.}"
11
+ _AMVD=""; _AMVC="$c"
12
+ case "$c" in 'cd '*' && '*) ;; *) return 0 ;; esac
13
+ d="${c#cd }"; d="${d%% && *}"; r="${c#cd "$d" && }"
14
+ case "$d" in ''|/*|~*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 0 ;; esac
15
+ [ -n "$r" ] && [ -d "$root/$d" ] || return 0
16
+ _AMVD="$d"; _AMVC="$r"
17
+ }
@@ -0,0 +1,41 @@
1
+ # Sourced, not run. The ONE definition of how Crewforth reads and orders its own version numbers.
2
+ #
3
+ # An install's VERSION holds X.Y.Z, or X.Y.Z-rc.N when it came from a release candidate. An rc comes before its
4
+ # final (3.2.0-rc.1 < 3.2.0), so someone who tried the rc is offered the final when it ships. Read by the update
5
+ # check (hooks/session-update-check.sh), doctor and adopt.sh; each used to split on "." and read 3.2.0-rc.1 as 3.2.0.
6
+ #
7
+ # Builtins only: sourcing this costs no process, which matters in hooks on Git Bash.
8
+
9
+ # The version an install may hold in its own VERSION file: X.Y.Z or X.Y.Z-rc.N, nothing else; printed, or rc 1.
10
+ # A value off the network is not read with this: the update check holds that to X.Y.Z alone.
11
+ crew_local_version(){
12
+ local v="${1:-}" base n
13
+ v="${v//[!0-9A-Za-z.-]/}"
14
+ base="${v%%-rc.*}"
15
+ case "$base" in
16
+ *[!0-9.]*|'') return 1 ;;
17
+ [0-9]*.[0-9]*.[0-9]*.*) return 1 ;;
18
+ [0-9]*.[0-9]*.[0-9]*) ;;
19
+ *) return 1 ;;
20
+ esac
21
+ if [ "$base" != "$v" ]; then
22
+ n="${v#"$base"-rc.}"
23
+ case "$n" in ''|*[!0-9]*) return 1 ;; esac
24
+ fi
25
+ printf '%s' "$v"
26
+ }
27
+
28
+ # vcmp(a, b): 1 when a is newer, -1 when older, 0 when the same. Prepended to an awk program:
29
+ # awk -v a="$X" -v b="$Y" "$CREW_VCMP_AWK"' BEGIN { exit !(vcmp(a, b) > 0) }'
30
+ CREW_VCMP_AWK='
31
+ function vrc(v) { return match(v, /-rc\.[0-9]+$/) ? substr(v, RSTART + 4) + 0 : -1 }
32
+ function vcmp(a, b, x, y, i, ra, rb) {
33
+ ra = vrc(a); rb = vrc(b); sub(/-.*/, "", a); sub(/-.*/, "", b)
34
+ split(a, x, "."); split(b, y, ".")
35
+ for (i = 1; i <= 3; i++) { if (x[i] + 0 > y[i] + 0) return 1; if (x[i] + 0 < y[i] + 0) return -1 }
36
+ if (ra == rb) return 0
37
+ if (ra < 0) return 1
38
+ if (rb < 0) return -1
39
+ return ra > rb ? 1 : -1
40
+ }
41
+ '
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # review-bench.sh โ€” how right the review is, on labelled cases: precision, recall and every false alarm by name.
3
+ # The shell entry to eval/lib/review-bench/bench.mjs; it finds a Node that RUNS and hands over.
4
+ #
5
+ # bash review-bench.sh --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]
7
+ # [--out FILE] [--no-tools]
8
+ #
9
+ # --arm prepass (the default) runs no model and costs nothing. --arm agent runs Claude Code headless as
10
+ # crew-review-agent on every case and SPENDS TOKENS: each case is capped by --case-budget (US dollars) and the run
11
+ # by --max-cost; ask the person paying before running it. rc 3 = no usable Node here (not a pass).
12
+ set -u
13
+ d="${BASH_SOURCE%[/\\]*}"; [ "$d" = "${BASH_SOURCE}" ] && d=.
14
+ FLOOR=18
15
+ usable(){ local v; v="$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null)" || return 1
16
+ case "$v" in ''|*[!0-9]*) return 1 ;; esac; [ "$v" -ge "$FLOOR" ]; }
17
+ NODE=""
18
+ if usable node; then NODE=node
19
+ elif [ -f "$d/../studio/ensure-node.sh" ]; then
20
+ n="$(bash "$d/../studio/ensure-node.sh" 2>/dev/null)" && [ -n "$n" ] && usable "$n" && NODE="$n"
21
+ fi
22
+ if [ -z "$NODE" ]; then
23
+ echo "review-bench: no Node $FLOOR+ that runs was found, so nothing was measured (this is not a pass)" >&2
24
+ exit 3
25
+ fi
26
+ exec "$NODE" "$d/lib/review-bench/bench.mjs" "$@"