@gredescerber/agent-crew 0.0.0-stage → 0.6.0

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 (101) hide show
  1. package/AGENTS.md +25 -0
  2. package/LICENSE +201 -0
  3. package/README.md +418 -2
  4. package/README.ru.md +421 -0
  5. package/adapters/claude/adapter.js +107 -0
  6. package/adapters/codex/adapter.js +101 -0
  7. package/bin/crew.js +60 -0
  8. package/docs/AGENT-SETUP.md +138 -0
  9. package/ecc/profile.json +16 -0
  10. package/evals/README.md +123 -0
  11. package/evals/fixtures/mini-app/README.md +10 -0
  12. package/evals/fixtures/mini-app/contracts/health.json +11 -0
  13. package/evals/fixtures/mini-app/package.json +10 -0
  14. package/evals/fixtures/mini-app/src/cart.js +14 -0
  15. package/evals/fixtures/mini-app/src/handlers.js +16 -0
  16. package/evals/fixtures/mini-app/src/pager.js +7 -0
  17. package/evals/fixtures/mini-app/test/cart.test.js +10 -0
  18. package/evals/fixtures/mini-app/test/handlers.test.js +13 -0
  19. package/evals/fixtures/mini-app/test/pager.test.js +8 -0
  20. package/evals/tasks/add-input-validation.json +15 -0
  21. package/evals/tasks/fix-off-by-one.json +14 -0
  22. package/evals/tasks/handler-per-contract.json +15 -0
  23. package/evals/tasks/judges/add-input-validation.judge.mjs +28 -0
  24. package/evals/tasks/judges/fix-off-by-one.judge.mjs +22 -0
  25. package/evals/tasks/judges/handler-per-contract.judge.mjs +18 -0
  26. package/package.json +46 -4
  27. package/roles/analyst.md +11 -0
  28. package/roles/builder.md +16 -0
  29. package/roles/implementer.md +17 -0
  30. package/roles/integrator.md +12 -0
  31. package/roles/planner.md +19 -0
  32. package/roles/reviewer.md +16 -0
  33. package/roles/scout.md +15 -0
  34. package/skills/crew-brief-analysis/SKILL.md +32 -0
  35. package/skills/crew-kickoff/SKILL.md +77 -0
  36. package/skills/crew-kickoff/task-format.md +215 -0
  37. package/skills/crew-review/SKILL.md +22 -0
  38. package/skills/crew-workflow/SKILL.md +46 -0
  39. package/src/commands/adapters.js +85 -0
  40. package/src/commands/ask.js +123 -0
  41. package/src/commands/board.js +88 -0
  42. package/src/commands/check.js +148 -0
  43. package/src/commands/commit.js +105 -0
  44. package/src/commands/compliance.js +109 -0
  45. package/src/commands/digest.js +64 -0
  46. package/src/commands/doctor.js +48 -0
  47. package/src/commands/eval.js +507 -0
  48. package/src/commands/github.js +169 -0
  49. package/src/commands/guard.js +101 -0
  50. package/src/commands/init.js +76 -0
  51. package/src/commands/kickoff.js +275 -0
  52. package/src/commands/labels.js +28 -0
  53. package/src/commands/merge-ready.js +33 -0
  54. package/src/commands/metrics.js +48 -0
  55. package/src/commands/pr.js +273 -0
  56. package/src/commands/shots.js +51 -0
  57. package/src/commands/status.js +48 -0
  58. package/src/commands/task.js +590 -0
  59. package/src/commands/up.js +355 -0
  60. package/src/commands/upgrade.js +178 -0
  61. package/src/commands/worker.js +322 -0
  62. package/src/core/board.js +127 -0
  63. package/src/core/config.js +83 -0
  64. package/src/core/digest.js +67 -0
  65. package/src/core/exec.js +50 -0
  66. package/src/core/guard.js +273 -0
  67. package/src/core/issues.js +154 -0
  68. package/src/core/lease.js +48 -0
  69. package/src/core/metrics.js +232 -0
  70. package/src/core/output.js +56 -0
  71. package/src/core/prd.js +612 -0
  72. package/src/core/proc.js +52 -0
  73. package/src/core/questions.js +51 -0
  74. package/src/core/queue.js +94 -0
  75. package/src/core/registry.js +143 -0
  76. package/src/core/session.js +61 -0
  77. package/src/core/shots.js +68 -0
  78. package/src/core/supervisor.js +56 -0
  79. package/src/core/version.js +44 -0
  80. package/src/mcp/server.js +81 -0
  81. package/templates/labels.json +23 -0
  82. package/templates/mcp.optional.json +23 -0
  83. package/templates/product/.github/ISSUE_TEMPLATE/epic.yml +22 -0
  84. package/templates/product/.github/ISSUE_TEMPLATE/feedback.yml +36 -0
  85. package/templates/product/.github/ISSUE_TEMPLATE/task.yml +62 -0
  86. package/templates/product/.github/pull_request_template.md +16 -0
  87. package/templates/product/.github/workflows/crew-guard.yml +45 -0
  88. package/templates/product/.github/workflows/crew.yml +118 -0
  89. package/templates/product/AGENTS.md +109 -0
  90. package/templates/product/contracts/README.md +8 -0
  91. package/templates/product/docs/architecture.md +41 -0
  92. package/templates/product/docs/brief-analysis.md +62 -0
  93. package/templates/product/docs/brief.md +25 -0
  94. package/templates/product/docs/playbooks/issues.md +91 -0
  95. package/templates/product/docs/playbooks/preflight.md +28 -0
  96. package/templates/product/docs/playbooks/timeline.md +52 -0
  97. package/templates/product/docs/prd.md +58 -0
  98. package/templates/product/events/event.example.json +10 -0
  99. package/templates/product/tests/acceptance/README.md +9 -0
  100. package/templates/stacks/web-fastapi.json +74 -0
  101. package/templates/stacks/web-node.json +70 -0
@@ -0,0 +1,507 @@
1
+ // crew eval — compare agents (Claude, Codex, and their models) on the same tasks.
2
+ // The task format is close to agent-eval (name, description, repo, files, prompt, judge, commit), but in JSON —
3
+ // with no YAML dependency. The format mapping is in evals/README.md.
4
+ //
5
+ // For each task × agent × run: a fresh copy of the repo fixture in a temp folder →
6
+ // adapter.headless({ prompt, model, cwd }) → deterministic judges (command / grep) → pass/fail,
7
+ // time, cost (if the agent reports it). Report: pass@1, pass@k, consistency, average time.
8
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { basename, dirname, join, relative, resolve } from 'node:path';
11
+ import { ok, warn, fail } from '../core/output.js';
12
+ import { run } from '../core/exec.js';
13
+ import { runSupervised } from '../core/proc.js';
14
+ import { loadConfig, HARNESS_ROOT } from '../core/config.js';
15
+ import { humanMs, table } from '../core/metrics.js';
16
+ import { loadAdapter } from './adapters.js';
17
+
18
+ export const JUDGE_TYPES = ['command', 'grep', 'pytest']; // pytest is a synonym for command, as in agent-eval
19
+ export const JUDGE_DIR = '.crew-eval'; // where the hidden judge files (judge_files) are placed after the agent finishes
20
+ export const BASE_TAG = 'crew-eval-base'; // git tag of the initial state of the working copy
21
+ const DEFAULT_TIMEOUT_S = 900;
22
+ const JUDGE_TIMEOUT_S = 300;
23
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
24
+
25
+ // ---------- tasks ----------
26
+
27
+ /** Validate one task. Returns a list of errors (empty — the task is valid). */
28
+ export function validateTask(t, { dir = '.' } = {}) {
29
+ const errs = [];
30
+ if (!t || typeof t !== 'object' || Array.isArray(t)) return ['a task must be a JSON object'];
31
+ if (typeof t.name !== 'string' || !NAME_RE.test(t.name)) errs.push('name: a string of a-z, 0-9, ".", "_", "-"');
32
+ if (typeof t.prompt !== 'string' || !t.prompt.trim()) errs.push('prompt: a non-empty string');
33
+ if (t.description !== undefined && typeof t.description !== 'string') errs.push('description: a string');
34
+ if (typeof t.repo !== 'string' || !t.repo) errs.push('repo: path to the repo fixture (relative to the task file)');
35
+ else if (!existsSync(resolve(dir, t.repo))) errs.push(`repo: folder ${t.repo} does not exist`);
36
+ if (t.commit !== undefined && t.commit !== null && !(typeof t.commit === 'string' && /^[0-9a-f]{7,40}$/i.test(t.commit))) {
37
+ errs.push('commit: a sha (7–40 hex) or absent');
38
+ }
39
+ if (t.files !== undefined && !(Array.isArray(t.files) && t.files.every((f) => typeof f === 'string'))) errs.push('files: an array of strings');
40
+ if (t.judge_files !== undefined) {
41
+ if (!(Array.isArray(t.judge_files) && t.judge_files.every((f) => typeof f === 'string'))) errs.push('judge_files: an array of strings');
42
+ else for (const f of t.judge_files) if (!existsSync(resolve(dir, f))) errs.push(`judge_files: file ${f} does not exist`);
43
+ }
44
+ if (t.timeout_s !== undefined && !(Number.isFinite(t.timeout_s) && t.timeout_s > 0)) errs.push('timeout_s: a number > 0');
45
+ if (!Array.isArray(t.judge) || !t.judge.length) errs.push('judge: a non-empty array of judges');
46
+ else {
47
+ t.judge.forEach((j, i) => {
48
+ const at = `judge[${i}]`;
49
+ if (j?.type === 'llm') return errs.push(`${at}.type: llm judges are not deterministic and not supported — use command or grep`);
50
+ if (!j || !JUDGE_TYPES.includes(j.type)) return errs.push(`${at}.type: one of ${JUDGE_TYPES.join(', ')}`);
51
+ if (j.type !== 'grep' && (typeof j.command !== 'string' || !j.command.trim())) errs.push(`${at}.command: a string`);
52
+ if (j.expect_exit !== undefined && !Number.isInteger(j.expect_exit)) errs.push(`${at}.expect_exit: an integer`);
53
+ if (j.type === 'grep') {
54
+ if (typeof j.pattern !== 'string' || !j.pattern) errs.push(`${at}.pattern: a string (regular expression)`);
55
+ else {
56
+ try {
57
+ new RegExp(j.pattern, j.flags ?? '');
58
+ } catch (e) {
59
+ errs.push(`${at}.pattern: ${e.message}`);
60
+ }
61
+ }
62
+ const files = j.files ?? t.files;
63
+ if (!(Array.isArray(files) ? files.length && files.every((f) => typeof f === 'string') : typeof files === 'string')) {
64
+ errs.push(`${at}.files: a string or an array (or files on the task)`);
65
+ }
66
+ }
67
+ });
68
+ }
69
+ return errs;
70
+ }
71
+
72
+ /** Find and validate tasks: a folder with *.json or a single file. → { tasks, errors } */
73
+ export function loadTasks(path, { only } = {}) {
74
+ if (!existsSync(path)) return { tasks: [], errors: [{ file: path, errors: ['no such file or folder'] }] };
75
+ const files = statSync(path).isDirectory()
76
+ ? readdirSync(path).filter((f) => f.endsWith('.json')).sort().map((f) => join(path, f))
77
+ : [path];
78
+ const tasks = [];
79
+ const errors = [];
80
+ const seen = new Set();
81
+ for (const file of files) {
82
+ let t;
83
+ try {
84
+ t = JSON.parse(readFileSync(file, 'utf8'));
85
+ } catch (e) {
86
+ errors.push({ file, errors: [`not JSON: ${e.message}`] });
87
+ continue;
88
+ }
89
+ const dir = dirname(file);
90
+ const errs = validateTask(t, { dir });
91
+ if (!errs.length && seen.has(t.name)) errs.push(`name: "${t.name}" already exists in another file`);
92
+ if (errs.length) {
93
+ errors.push({ file, errors: errs });
94
+ continue;
95
+ }
96
+ seen.add(t.name);
97
+ if (only && !only.includes(t.name)) continue;
98
+ tasks.push({ ...t, file, dir, repoPath: resolve(dir, t.repo) });
99
+ }
100
+ return { tasks, errors };
101
+ }
102
+
103
+ /** "claude,codex:gpt-5,claude:opus" → [{ key, host, model }] */
104
+ export function parseAgents(spec) {
105
+ return String(spec ?? '')
106
+ .split(',')
107
+ .map((s) => s.trim())
108
+ .filter(Boolean)
109
+ .map((key) => {
110
+ const [host, ...rest] = key.split(':');
111
+ return { key, host, model: rest.join(':') || undefined };
112
+ });
113
+ }
114
+
115
+ // ---------- run ----------
116
+
117
+ const gitq = (args, cwd) => run('git', ['-c', 'user.email=crew-eval@localhost', '-c', 'user.name=crew eval', '-c', 'core.autocrlf=false', ...args], { cwd });
118
+
119
+ /**
120
+ * A fresh copy of the repo fixture. With commit — a clone of the repo containing the fixture, at that commit;
121
+ * without commit — a copy of the current folder state + git init (agents need a clean git repo).
122
+ * → { workdir, tmp, cleanup }
123
+ */
124
+ export function prepareRepo(task, { keep = false } = {}) {
125
+ const tmp = mkdtempSync(join(tmpdir(), `crew-eval-${task.name}-`));
126
+ const cleanup = () => {
127
+ if (!keep) rmSync(tmp, { recursive: true, force: true, maxRetries: 3 });
128
+ };
129
+ try {
130
+ const dest = join(tmp, 'repo');
131
+ let workdir = dest;
132
+ if (task.commit) {
133
+ const top = run('git', ['rev-parse', '--show-toplevel'], { cwd: task.repoPath }).stdout;
134
+ gitq(['clone', '-q', '--no-checkout', top, dest], tmp);
135
+ gitq(['checkout', '-q', '--detach', task.commit], dest);
136
+ workdir = join(dest, relative(realpathSync.native(top), realpathSync.native(task.repoPath))); // Windows: 8.3 names
137
+ } else {
138
+ cpSync(task.repoPath, dest, { recursive: true, filter: (src) => !['node_modules', '.git', JUDGE_DIR].includes(basename(src)) });
139
+ gitq(['init', '-q'], dest);
140
+ gitq(['add', '-A'], dest);
141
+ gitq(['commit', '-q', '--no-verify', '-m', `eval: ${task.name}`], dest);
142
+ }
143
+ // judges compare against the initial state: git diff crew-eval-base -- <path> (also sees what the agent committed)
144
+ gitq(['tag', BASE_TAG], dest);
145
+ return { workdir, tmp, cleanup };
146
+ } catch (e) {
147
+ cleanup();
148
+ throw e;
149
+ }
150
+ }
151
+
152
+ /** Environment for the agent and judges: without NODE_TEST_CONTEXT (otherwise a nested node --test is always "green"). */
153
+ function cleanEnv(extra = {}) {
154
+ const env = { ...process.env, CREW_METRICS: 'off', ...extra };
155
+ delete env.NODE_TEST_CONTEXT;
156
+ return env;
157
+ }
158
+
159
+ // Judges and the agent run through a supervisor: on timeout the whole process tree is killed.
160
+ const shellRun = (line, cwd, timeoutS) => {
161
+ const r = runSupervised({ cmd: line, shell: true, cwd, env: cleanEnv(), timeoutMs: timeoutS * 1000 });
162
+ return { code: r.code, out: `${r.stdout ?? ''}\n${r.stderr ?? ''}`.trim(), timedOut: !!r.timedOut };
163
+ };
164
+
165
+ const lastLines = (s, n = 8) => s.split(/\r?\n/).slice(-n).join('\n');
166
+
167
+ /** Glob to regex: ** — any folders, * and ? — within a single name. */
168
+ export function globToRegExp(glob) {
169
+ let re = '';
170
+ for (let i = 0; i < glob.length; i++) {
171
+ const ch = glob[i];
172
+ if (ch === '*' && glob[i + 1] === '*') {
173
+ re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*';
174
+ i += glob[i + 2] === '/' ? 2 : 1;
175
+ } else if (ch === '*') re += '[^/]*';
176
+ else if (ch === '?') re += '[^/]';
177
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
178
+ }
179
+ return new RegExp(`^${re}$`);
180
+ }
181
+
182
+ /** Working copy files (paths with /) matching the glob; excluding .git and node_modules. */
183
+ function globFiles(workdir, glob) {
184
+ const re = globToRegExp(glob.replaceAll('\\', '/'));
185
+ const out = [];
186
+ const walk = (rel) => {
187
+ for (const e of readdirSync(join(workdir, rel), { withFileTypes: true })) {
188
+ if (['.git', 'node_modules'].includes(e.name)) continue;
189
+ const p = rel ? `${rel}/${e.name}` : e.name;
190
+ if (e.isDirectory()) walk(p);
191
+ else if (re.test(p)) out.push(p);
192
+ }
193
+ };
194
+ walk('');
195
+ return out.sort();
196
+ }
197
+
198
+ /** Run the task judges in the working copy. → [{ type, pass, detail }] */
199
+ export function runJudges(task, workdir) {
200
+ if (task.judge_files?.length) {
201
+ mkdirSync(join(workdir, JUDGE_DIR), { recursive: true });
202
+ for (const f of task.judge_files) cpSync(resolve(task.dir, f), join(workdir, JUDGE_DIR, basename(f)));
203
+ }
204
+ return task.judge.map((j) => {
205
+ if (j.type !== 'grep') {
206
+ const r = shellRun(j.command, workdir, j.timeout_s ?? JUDGE_TIMEOUT_S);
207
+ const pass = r.code === (j.expect_exit ?? 0);
208
+ return { type: j.type, command: j.command, pass, detail: r.timedOut ? 'timeout' : pass ? `exit ${r.code}` : `exit ${r.code}: ${lastLines(r.out)}` };
209
+ }
210
+ const files = [].concat(j.files ?? task.files).flatMap((f) => (/[*?]/.test(f) ? globFiles(workdir, f) : [f]));
211
+ const re = new RegExp(j.pattern, j.flags ?? '');
212
+ const hits = files.filter((f) => {
213
+ try {
214
+ return re.test(readFileSync(join(workdir, f), 'utf8'));
215
+ } catch {
216
+ return false; // no file = no match
217
+ }
218
+ });
219
+ const pass = j.absent ? hits.length === 0 : hits.length > 0;
220
+ return { type: 'grep', pattern: j.pattern, pass, detail: hits.length ? `found in ${hits.join(', ')}` : 'not found' };
221
+ });
222
+ }
223
+
224
+ /** Cost from the agent output (claude --output-format json → total_cost_usd). None — null. */
225
+ export function extractCost(stdout) {
226
+ const pick = (o) => {
227
+ if (!o || typeof o !== 'object') return null;
228
+ for (const k of ['total_cost_usd', 'cost_usd', 'costUsd']) if (Number.isFinite(o[k])) return o[k];
229
+ return pick(o.usage) ?? pick(o.result && typeof o.result === 'object' ? o.result : null);
230
+ };
231
+ const text = String(stdout ?? '').trim();
232
+ if (!text) return null;
233
+ try {
234
+ const whole = JSON.parse(text);
235
+ const c = Array.isArray(whole) ? whole.map(pick).filter((x) => x !== null).at(-1) ?? null : pick(whole);
236
+ if (c !== null) return c;
237
+ } catch {
238
+ /* not a single JSON — try JSONL */
239
+ }
240
+ for (const line of text.split(/\r?\n/).reverse()) {
241
+ if (!line.trim().startsWith('{')) continue;
242
+ try {
243
+ const c = pick(JSON.parse(line));
244
+ if (c !== null) return c;
245
+ } catch {
246
+ /* not a JSON line */
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** Run the agent in the working copy as described by the adapter. */
253
+ function runAgent(agent, task, workdir) {
254
+ const spec = agent.adapter.headless({ prompt: task.prompt, model: agent.model, cwd: workdir });
255
+ const timeoutS = task.timeout_s ?? DEFAULT_TIMEOUT_S;
256
+ const r = runSupervised({
257
+ cmd: spec.cmd,
258
+ args: spec.args ?? [],
259
+ shell: false,
260
+ cwd: spec.cwd ?? workdir,
261
+ env: cleanEnv(spec.env ?? {}),
262
+ input: spec.input,
263
+ timeoutMs: timeoutS * 1000,
264
+ });
265
+ if (r.error && !r.timedOut) {
266
+ return { spawnError: `${spec.cmd}: ${r.error}`, exit: -1, stdout: '' };
267
+ }
268
+ return { exit: r.code, timedOut: !!r.timedOut, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
269
+ }
270
+
271
+ /**
272
+ * Run: tasks × agents × runs. agents: [{ key, model?, adapter }] — an adapter with headless().
273
+ * If the agent fails to start (no binary), its remaining runs are skipped with the same error.
274
+ * → [{ task, agent, run, pass, judges, duration_ms, cost_usd, exit, error }]
275
+ */
276
+ export function runEval({ tasks, agents, runs = 1, keep = false, log = () => {} }) {
277
+ const results = [];
278
+ for (const agent of agents) {
279
+ let broken = null;
280
+ for (const task of tasks) {
281
+ for (let i = 1; i <= runs; i++) {
282
+ const base = { task: task.name, agent: agent.key, run: i };
283
+ if (broken) {
284
+ results.push({ ...base, pass: false, judges: [], duration_ms: null, cost_usd: null, exit: null, error: broken });
285
+ continue;
286
+ }
287
+ log(`→ ${agent.key} · ${task.name} · run ${i}/${runs}`);
288
+ let repo;
289
+ try {
290
+ repo = prepareRepo(task, { keep });
291
+ } catch (e) {
292
+ results.push({ ...base, pass: false, judges: [], duration_ms: null, cost_usd: null, exit: null, error: `prepare: ${e.message}` });
293
+ continue;
294
+ }
295
+ try {
296
+ const t0 = Date.now();
297
+ const r = runAgent(agent, task, repo.workdir);
298
+ const duration_ms = Date.now() - t0;
299
+ if (r.spawnError) {
300
+ broken = `agent_failed: ${r.spawnError}`;
301
+ results.push({ ...base, pass: false, judges: [], duration_ms, cost_usd: null, exit: -1, error: broken });
302
+ continue;
303
+ }
304
+ const judges = runJudges(task, repo.workdir);
305
+ const pass = !r.timedOut && judges.every((j) => j.pass);
306
+ results.push({
307
+ ...base,
308
+ pass,
309
+ judges,
310
+ duration_ms,
311
+ cost_usd: extractCost(r.stdout),
312
+ exit: r.exit,
313
+ error: r.timedOut ? `timeout: ${task.timeout_s ?? DEFAULT_TIMEOUT_S}s` : null,
314
+ ...(keep ? { workdir: repo.workdir } : {}),
315
+ });
316
+ log(` ${pass ? 'PASS' : 'FAIL'} in ${humanMs(duration_ms)}`);
317
+ } finally {
318
+ repo.cleanup();
319
+ }
320
+ }
321
+ }
322
+ }
323
+ return results;
324
+ }
325
+
326
+ // ---------- scoring ----------
327
+
328
+ /** Unbiased pass@k estimate (Chen et al., 2021): n runs, c successful. */
329
+ export function passAtK(n, c, k) {
330
+ if (!n || k <= 0) return null;
331
+ k = Math.min(k, n);
332
+ if (n - c < k) return 1;
333
+ let miss = 1;
334
+ for (let i = n - c + 1; i <= n; i++) miss *= 1 - k / i;
335
+ return 1 - miss;
336
+ }
337
+
338
+ const r2 = (x) => (x == null ? null : Math.round(x * 100) / 100);
339
+ const mean = (xs) => (xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : null);
340
+
341
+ /**
342
+ * Pure scoring of the run results.
343
+ * pass_at_1 share of successful runs
344
+ * pass_at_k at least one success out of k = number of runs (unbiased estimate)
345
+ * consistency share of runs matching the majority (1 — the agent behaves identically)
346
+ * → { by_task: [{ agent, task, ... }], by_agent: [{ agent, ... }] }
347
+ */
348
+ export function score(results) {
349
+ const groups = new Map();
350
+ for (const r of results) {
351
+ const key = `${r.agent}\u0000${r.task}`;
352
+ if (!groups.has(key)) groups.set(key, { agent: r.agent, task: r.task, list: [] });
353
+ groups.get(key).list.push(r);
354
+ }
355
+ const by_task = [...groups.values()].map(({ agent, task, list }) => {
356
+ const n = list.length;
357
+ const c = list.filter((r) => r.pass).length;
358
+ const times = list.map((r) => r.duration_ms).filter(Number.isFinite);
359
+ const costs = list.map((r) => r.cost_usd).filter(Number.isFinite);
360
+ return {
361
+ agent,
362
+ task,
363
+ runs: n,
364
+ passes: c,
365
+ k: n,
366
+ pass_at_1: r2(c / n),
367
+ pass_at_k: r2(passAtK(n, c, n)),
368
+ consistency: r2(Math.max(c, n - c) / n),
369
+ avg_time_ms: times.length ? Math.round(mean(times)) : null,
370
+ avg_cost_usd: costs.length ? Math.round(mean(costs) * 10_000) / 10_000 : null,
371
+ errors: list.filter((r) => r.error).length,
372
+ };
373
+ });
374
+ const agents = [...new Set(by_task.map((t) => t.agent))];
375
+ const by_agent = agents.map((agent) => {
376
+ const rows = by_task.filter((t) => t.agent === agent);
377
+ const runsOf = results.filter((r) => r.agent === agent);
378
+ const times = runsOf.map((r) => r.duration_ms).filter(Number.isFinite);
379
+ const costs = runsOf.map((r) => r.cost_usd).filter(Number.isFinite);
380
+ return {
381
+ agent,
382
+ tasks: rows.length,
383
+ runs: runsOf.length,
384
+ // the per-task average is computed from exact values, not rounded ones
385
+ pass_at_1: r2(mean(rows.map((t) => t.passes / t.runs))),
386
+ pass_at_k: r2(mean(rows.map((t) => passAtK(t.runs, t.passes, t.k)))),
387
+ consistency: r2(mean(rows.map((t) => Math.max(t.passes, t.runs - t.passes) / t.runs))),
388
+ avg_time_ms: times.length ? Math.round(mean(times)) : null,
389
+ total_cost_usd: costs.length ? Math.round(costs.reduce((s, x) => s + x, 0) * 10_000) / 10_000 : null,
390
+ };
391
+ });
392
+ return { by_task, by_agent };
393
+ }
394
+
395
+ const fmt = (x) => (x == null ? null : x.toFixed(2));
396
+ const usd = (x) => (x == null ? null : `$${x}`);
397
+
398
+ export function reportTable({ by_task, by_agent }) {
399
+ const k = by_task[0]?.k ?? 1;
400
+ const tasks = table(
401
+ ['agent', 'task', 'pass@1', `pass@${k}`, 'consistency', 'time', 'cost'],
402
+ by_task.map((t) => [t.agent, t.task, fmt(t.pass_at_1), fmt(t.pass_at_k), fmt(t.consistency), humanMs(t.avg_time_ms), usd(t.avg_cost_usd)]),
403
+ );
404
+ const agents = table(
405
+ ['agent', 'tasks', 'pass@1', `pass@${k}`, 'consistency', 'avg time', 'total $'],
406
+ by_agent.map((a) => [a.agent, a.tasks, fmt(a.pass_at_1), fmt(a.pass_at_k), fmt(a.consistency), humanMs(a.avg_time_ms), usd(a.total_cost_usd)]),
407
+ );
408
+ return `${tasks}\n\nTotals per agent:\n${agents}`;
409
+ }
410
+
411
+ // ---------- command ----------
412
+
413
+ /** Path to the tasks: as given (relative to cwd), otherwise inside the harness (the built-in evals/tasks). */
414
+ function resolveTasksPath(p) {
415
+ const own = resolve(p);
416
+ if (existsSync(own)) return own;
417
+ const inHarness = resolve(HARNESS_ROOT, p);
418
+ return existsSync(inHarness) ? inHarness : own;
419
+ }
420
+
421
+ export default {
422
+ name: 'eval',
423
+ summary: 'Compare agents on reference tasks: pass@1, pass@k, consistency, time, cost',
424
+ agent: false,
425
+ args: [
426
+ { name: 'agents', type: 'string', description: 'who to compare: claude,codex or with a model claude:opus,codex:gpt-5 (default — hosts from the config)' },
427
+ { name: 'tasks', type: 'string', description: 'folder with *.json tasks or a single file (default evals/tasks)' },
428
+ { name: 'task', type: 'string', description: 'only these tasks (comma-separated)' },
429
+ { name: 'runs', type: 'number', description: 'runs per task and agent (default 3)' },
430
+ { name: 'dry_run', type: 'boolean', description: 'only validate tasks and show the plan; do not run agents' },
431
+ { name: 'keep', type: 'boolean', description: 'do not delete working copies (for analyzing failures)' },
432
+ ],
433
+ async run({ agents: agentSpec, tasks: tasksPath = 'evals/tasks', task, runs = 3, dry_run, keep } = {}) {
434
+ const { config, root } = loadConfig();
435
+ if (!Number.isInteger(runs) || runs < 1) {
436
+ return fail('bad_input', '--runs: an integer ≥ 1', { retry: 'crew eval --runs 3' });
437
+ }
438
+ const dir = resolveTasksPath(tasksPath);
439
+ const only = task ? String(task).split(',').map((s) => s.trim()).filter(Boolean) : null;
440
+ const { tasks, errors } = loadTasks(dir, { only });
441
+ if (errors.length) {
442
+ return fail('bad_task', `Errors in eval tasks: ${errors.length}`, {
443
+ cause_hint: errors.map((e) => `${relative(process.cwd(), e.file) || e.file}: ${e.errors.join('; ')}`).join(' | '),
444
+ retry: 'fix the task files and run crew eval --dry-run again',
445
+ stop_when: 'the format is unclear — see evals/README.md',
446
+ artifacts: { errors },
447
+ });
448
+ }
449
+ if (!tasks.length) {
450
+ return fail('no_tasks', only ? `No tasks named: ${only.join(', ')}` : `No *.json tasks in ${dir}`, {
451
+ cause_hint: 'wrong folder or --task filter',
452
+ retry: 'crew eval --tasks evals/tasks --dry-run',
453
+ });
454
+ }
455
+
456
+ const agentList = parseAgents(agentSpec === undefined || agentSpec === true ? (config.hosts ?? []).join(',') : agentSpec);
457
+ if (!agentList.length) return fail('bad_input', 'No agents specified', { retry: 'crew eval --agents claude,codex' });
458
+ const missing = [];
459
+ for (const a of agentList) {
460
+ a.adapter = await loadAdapter(a.host);
461
+ if (typeof a.adapter?.headless !== 'function') missing.push(a.host);
462
+ }
463
+ if (missing.length) {
464
+ return fail('unknown_agent', `No adapter with headless() for: ${[...new Set(missing)].join(', ')}`, {
465
+ cause_hint: 'the host is written as a folder name in adapters/: claude, codex',
466
+ retry: 'crew eval --agents claude,codex --dry-run',
467
+ stop_when: 'a new host is needed — add adapters/<host>/adapter.js',
468
+ });
469
+ }
470
+
471
+ const total = tasks.length * agentList.length * runs;
472
+ if (dry_run) {
473
+ const plan = tasks.map((t) => ({
474
+ task: t.name,
475
+ repo: (relative(process.cwd(), t.repoPath) || '.').replaceAll('\\', '/'),
476
+ commit: t.commit ?? 'current state',
477
+ judges: t.judge.map((j) => (j.type !== 'grep' ? `${j.type}: ${j.command}` : `grep${j.absent ? ' (absent)' : ''}: ${j.pattern}`)),
478
+ }));
479
+ const t = table(['task', 'repo', 'commit', 'judges'], plan.map((p) => [p.task, p.repo, p.commit, p.judges.length]));
480
+ return ok(`Eval plan: ${tasks.length} tasks × ${agentList.length} agents × ${runs} runs = ${total} launches (tasks are valid)\n${t}`, {
481
+ artifacts: { tasks: plan, agents: agentList.map((a) => a.key), runs, total },
482
+ next_actions: [`crew eval --agents ${agentList.map((a) => a.key).join(',')} --runs ${runs}`],
483
+ });
484
+ }
485
+
486
+ const results = runEval({ tasks, agents: agentList, runs, keep, log: (s) => process.stderr.write(s + '\n') });
487
+ const scored = score(results);
488
+ const outDir = join(root, '.crew', 'evals');
489
+ mkdirSync(outDir, { recursive: true });
490
+ const report = join(outDir, `eval-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
491
+ writeFileSync(report, JSON.stringify({ at: new Date().toISOString(), runs, agents: agentList.map((a) => a.key), ...scored, raw: results }, null, 2));
492
+
493
+ const broken = [...new Set(results.filter((r) => r.error?.startsWith('agent_failed')).map((r) => r.agent))];
494
+ const summary = `Eval: ${total} launches, ${results.filter((r) => r.pass).length} successful\n${reportTable(scored)}`;
495
+ const artifacts = { results: scored.by_task, agents: scored.by_agent, report };
496
+ if (broken.length) {
497
+ return warn(summary, {
498
+ artifacts,
499
+ next_actions: [
500
+ `failed to start: ${broken.join(', ')} — check crew doctor and that the binary is on PATH`,
501
+ 'on Windows .cmd wrappers (npm i -g) do not run without a shell — a native binary or WSL/CI is needed',
502
+ ],
503
+ });
504
+ }
505
+ return ok(summary, { artifacts, next_actions: [`analyze the failures: ${report}`, 'crew eval --keep --task <name> — keep the working copies'] });
506
+ },
507
+ };
@@ -0,0 +1,169 @@
1
+ // crew github setup [--repo owner/name] [--apply] — configure the product repo on GitHub with one command:
2
+ // main protection (PRs only + green check and crew-guard), auto-merge, the CREW_HARNESS variable, labels.
3
+ // There is no manual PR approval: an agent does the review (crew pr review) and crew-guard counts it.
4
+ // Dry-run by default: only shows the plan of `gh api` calls. Re-running is safe (idempotent).
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { ok, warn, fail } from '../core/output.js';
8
+ import { run } from '../core/exec.js';
9
+ import { loadConfig } from '../core/config.js';
10
+ import { allLabels } from './labels.js';
11
+ import { HARNESS_ROOT } from '../core/config.js';
12
+ import { GUARD_CONTEXT } from '../core/guard.js';
13
+
14
+ export const PKG = JSON.parse(readFileSync(join(HARNESS_ROOT, 'package.json'), 'utf8'));
15
+ /** The npm package with a pinned version: CI installs exactly this version (public registry, no token). */
16
+ export const HARNESS_REPO = 'GredesCerber/agent-crew';
17
+ export const DEFAULT_HARNESS = `${PKG.name}@${PKG.version}`;
18
+ // check — the name of the check run job from crew.yml (in branch protection — just "check").
19
+ // crew-guard — a commit status set by the crew-guard.yml workflow via GITHUB_TOKEN.
20
+ export const REQUIRED_CHECKS = ['check', GUARD_CONTEXT];
21
+ // GitHub App "GitHub Actions": a status with the same name set by a human or agent token will not count.
22
+ export const ACTIONS_APP_ID = 15368;
23
+
24
+ export const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
25
+
26
+ /**
27
+ * Pure function: the plan of `gh api` calls, with no network access.
28
+ * Every step is idempotent: PUT/PATCH overwrite the state; if the object is missing (404) — `if_missing` (POST).
29
+ */
30
+ export function planSetup({ repo, harness = DEFAULT_HARNESS, labels = [], branch = 'main' }) {
31
+ const variable = { name: 'CREW_HARNESS', value: harness };
32
+ return [
33
+ {
34
+ id: 'repo-settings',
35
+ summary: 'repo settings: PR auto-merge (continuous merge for low/med), regular merge commit only (squash/rebase disabled — they lose trailers), delete branches after merge',
36
+ method: 'PATCH',
37
+ path: `repos/${repo}`,
38
+ // squash and rebase are disabled: squash loses the Agent/Issue trailers and Closes #N, and compliance and crew-guard depend on them.
39
+ body: { allow_auto_merge: true, allow_merge_commit: true, allow_squash_merge: false, allow_rebase_merge: false, delete_branch_on_merge: true },
40
+ },
41
+ {
42
+ id: 'variable:CREW_HARNESS',
43
+ summary: `variable CREW_HARNESS = ${harness} (CI installs it with npm install -g from the public GitHub repo at this tag)`,
44
+ method: 'PATCH',
45
+ path: `repos/${repo}/actions/variables/CREW_HARNESS`,
46
+ body: variable,
47
+ if_missing: { method: 'POST', path: `repos/${repo}/actions/variables`, body: variable },
48
+ },
49
+ ...labels.map((l) => ({
50
+ id: `label:${l.name}`,
51
+ summary: `label ${l.name}`,
52
+ method: 'PATCH',
53
+ path: `repos/${repo}/labels/${encodeURIComponent(l.name)}`,
54
+ body: { new_name: l.name, color: l.color, description: l.description },
55
+ if_missing: { method: 'POST', path: `repos/${repo}/labels`, body: { name: l.name, color: l.color, description: l.description } },
56
+ })),
57
+ {
58
+ id: `branch-protection:${branch}`,
59
+ summary: `protection for ${branch}: PRs only, green ${REQUIRED_CHECKS.join(' and ')} (crew-guard counts the agent review), no force-push`,
60
+ method: 'PUT',
61
+ path: `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`,
62
+ body: {
63
+ // strict: false — do not require a rebase onto fresh main before every merge (there are many PRs at a hackathon)
64
+ required_status_checks: { strict: false, checks: REQUIRED_CHECKS.map((context) => ({ context, app_id: ACTIONS_APP_ID })) },
65
+ // An admin (the harness developer) can merge bypassing this — that is how agent rules and CI are updated,
66
+ // which crew-guard does not let agents change. Agents are forbidden from --admin in AGENTS.md.
67
+ enforce_admins: false,
68
+ required_pull_request_reviews: {
69
+ // 0 manual approvals: the human is not an "approve button". The agent review is checked by crew-guard
70
+ // (a crew:review marker from another agent on the current commit). Agents work under human
71
+ // accounts, and GitHub does not allow approving your own PR — hence no native GitHub reviews.
72
+ required_approving_review_count: 0,
73
+ require_code_owner_reviews: false,
74
+ dismiss_stale_reviews: false,
75
+ },
76
+ restrictions: null,
77
+ allow_force_pushes: false,
78
+ allow_deletions: false,
79
+ },
80
+ },
81
+ ];
82
+ }
83
+
84
+ const is404 = (r) => /HTTP 404|Not Found/i.test(r.stderr + r.stdout);
85
+
86
+ function call({ method, path, body }, cwd, sh = run) {
87
+ return sh('gh', ['api', '-X', method, path, '-H', 'Accept: application/vnd.github+json', '--input', '-'], {
88
+ cwd,
89
+ input: JSON.stringify(body),
90
+ allowFail: true,
91
+ });
92
+ }
93
+
94
+ /** Execute the plan. Returns a result per step; does not stop at the first error. */
95
+ export function applyPlan(plan, cwd, sh = run) {
96
+ return plan.map((step) => {
97
+ let r = call(step, cwd, sh);
98
+ let action = step.method;
99
+ if (r.code !== 0 && step.if_missing && is404(r)) {
100
+ r = call(step.if_missing, cwd, sh);
101
+ action = step.if_missing.method;
102
+ }
103
+ return { id: step.id, ok: r.code === 0, action, error: r.code === 0 ? null : (r.stderr || r.stdout).split('\n')[0] };
104
+ });
105
+ }
106
+
107
+ function detectRepo(cwd, sh = run) {
108
+ const r = sh('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { cwd, allowFail: true });
109
+ return r.code === 0 && REPO_RE.test(r.stdout) ? r.stdout : null;
110
+ }
111
+
112
+ export default {
113
+ name: 'github setup',
114
+ summary: 'Configure the repo on GitHub: main protection, the CREW_HARNESS variable, labels (dry-run; --apply to execute)',
115
+ agent: false,
116
+ args: [
117
+ { name: 'repo', type: 'string', description: 'owner/name (default — the current folder repo per gh repo view)' },
118
+ { name: 'harness', type: 'string', description: `CREW_HARNESS value (default ${DEFAULT_HARNESS})` },
119
+ { name: 'branch', type: 'string', description: 'branch to protect (default main)' },
120
+ { name: 'apply', type: 'boolean', description: 'execute the plan (without the flag — only show it)' },
121
+ ],
122
+ // cwd and deps.run — for calls from crew up (project folder and a gh stub in tests); not passed from the CLI.
123
+ run({ repo, harness = DEFAULT_HARNESS, branch = 'main', apply = false, cwd }, deps = {}) {
124
+ const sh = deps.run ?? run;
125
+ const { config, root } = loadConfig(cwd);
126
+ if (repo !== undefined && !REPO_RE.test(String(repo))) {
127
+ return fail('bad_input', `repo must be in the form owner/name, not "${repo}"`, { retry: 'crew github setup --repo <owner>/<name>' });
128
+ }
129
+ const resolved = repo ?? detectRepo(root, sh);
130
+ if (!resolved && apply) {
131
+ return fail('repo_unknown', 'Could not determine the GitHub repo', {
132
+ cause_hint: 'the folder is not linked to a GitHub repo or gh is not authenticated',
133
+ retry: 'crew github setup --repo <owner>/<name> --apply',
134
+ stop_when: 'the repo has not been created yet — run gh repo create first',
135
+ });
136
+ }
137
+ const labels = allLabels(config);
138
+ const plan = planSetup({ repo: resolved ?? '<owner>/<name>', harness, labels, branch });
139
+
140
+ if (!apply) {
141
+ const summary = `Setup plan for ${resolved ?? '<owner>/<name>'}: ${plan.length} gh api calls (dry-run, nothing changed)`;
142
+ const opts = {
143
+ artifacts: { repo: resolved, apply: false, plan },
144
+ next_actions: [resolved ? `crew github setup --repo ${resolved} --apply` : 'crew github setup --repo <owner>/<name> --apply'],
145
+ };
146
+ return resolved ? ok(summary, opts) : warn(summary + ' — repo not determined, pass --repo', opts);
147
+ }
148
+
149
+ if (sh('gh', ['auth', 'status'], { cwd: root, allowFail: true }).code !== 0) {
150
+ return fail('gh_auth', 'gh is not authenticated', { retry: 'gh auth login', stop_when: 'no admin rights on the repo — ask the owner' });
151
+ }
152
+ const results = applyPlan(plan, root, sh);
153
+ const failed = results.filter((r) => !r.ok);
154
+ if (failed.length) {
155
+ return fail('github_setup_failed', `Not applied: ${failed.length} of ${results.length}: ${failed.map((f) => f.id).join(', ')}`, {
156
+ cause_hint: failed[0].error,
157
+ retry: `crew github setup --repo ${resolved} --apply (re-running is safe)`,
158
+ stop_when: 'HTTP 403 — no admin rights, or branch protection is unavailable on the plan for a private repo',
159
+ artifacts: { repo: resolved, results },
160
+ });
161
+ }
162
+ return ok(`Repo ${resolved} configured: ${results.length} steps`, {
163
+ artifacts: { repo: resolved, results },
164
+ next_actions: [
165
+ 'crew status',
166
+ ],
167
+ });
168
+ },
169
+ };