@gredescerber/agent-crew 0.0.0-stage → 0.5.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 (92) hide show
  1. package/AGENTS.md +24 -0
  2. package/LICENSE +201 -0
  3. package/README.md +411 -2
  4. package/README.ru.md +414 -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 +43 -0
  39. package/src/commands/adapters.js +85 -0
  40. package/src/commands/check.js +130 -0
  41. package/src/commands/commit.js +105 -0
  42. package/src/commands/compliance.js +109 -0
  43. package/src/commands/doctor.js +48 -0
  44. package/src/commands/eval.js +507 -0
  45. package/src/commands/github.js +169 -0
  46. package/src/commands/guard.js +101 -0
  47. package/src/commands/init.js +76 -0
  48. package/src/commands/kickoff.js +275 -0
  49. package/src/commands/labels.js +28 -0
  50. package/src/commands/metrics.js +48 -0
  51. package/src/commands/pr.js +211 -0
  52. package/src/commands/shots.js +51 -0
  53. package/src/commands/status.js +48 -0
  54. package/src/commands/task.js +569 -0
  55. package/src/commands/up.js +355 -0
  56. package/src/commands/worker.js +322 -0
  57. package/src/core/config.js +77 -0
  58. package/src/core/exec.js +50 -0
  59. package/src/core/guard.js +248 -0
  60. package/src/core/issues.js +154 -0
  61. package/src/core/lease.js +48 -0
  62. package/src/core/metrics.js +232 -0
  63. package/src/core/output.js +56 -0
  64. package/src/core/prd.js +593 -0
  65. package/src/core/proc.js +52 -0
  66. package/src/core/queue.js +92 -0
  67. package/src/core/registry.js +138 -0
  68. package/src/core/shots.js +68 -0
  69. package/src/core/supervisor.js +56 -0
  70. package/src/core/version.js +44 -0
  71. package/src/mcp/server.js +71 -0
  72. package/templates/labels.json +18 -0
  73. package/templates/mcp.optional.json +23 -0
  74. package/templates/product/.github/ISSUE_TEMPLATE/epic.yml +22 -0
  75. package/templates/product/.github/ISSUE_TEMPLATE/feedback.yml +36 -0
  76. package/templates/product/.github/ISSUE_TEMPLATE/task.yml +62 -0
  77. package/templates/product/.github/pull_request_template.md +16 -0
  78. package/templates/product/.github/workflows/crew-guard.yml +45 -0
  79. package/templates/product/.github/workflows/crew.yml +108 -0
  80. package/templates/product/AGENTS.md +109 -0
  81. package/templates/product/contracts/README.md +8 -0
  82. package/templates/product/docs/architecture.md +41 -0
  83. package/templates/product/docs/brief-analysis.md +62 -0
  84. package/templates/product/docs/brief.md +25 -0
  85. package/templates/product/docs/playbooks/issues.md +91 -0
  86. package/templates/product/docs/playbooks/preflight.md +28 -0
  87. package/templates/product/docs/playbooks/timeline.md +52 -0
  88. package/templates/product/docs/prd.md +58 -0
  89. package/templates/product/events/event.example.json +10 -0
  90. package/templates/product/tests/acceptance/README.md +9 -0
  91. package/templates/stacks/web-fastapi.json +74 -0
  92. package/templates/stacks/web-node.json +70 -0
@@ -0,0 +1,322 @@
1
+ // crew worker — the agent runs the task loop on its own, without a UI.
2
+ // task next → task claim → headless agent in the task worktree → check the issue status → next task.
3
+ // A new agent process per task = a context reset at the phase boundary.
4
+ // Started by a human (agent: false): the agent does not need to be able to spawn other agents via MCP.
5
+ import { spawn as nodeSpawn } from 'node:child_process';
6
+ import { createWriteStream, mkdirSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { ok, warn, fail, CrewError } from '../core/output.js';
9
+ import { run as exec } from '../core/exec.js';
10
+ import { killTree } from '../core/proc.js';
11
+ import { recordCommand } from '../core/metrics.js';
12
+ import { loadConfig, machineName } from '../core/config.js';
13
+ import { viewIssue, labelValue, TIERS } from '../core/issues.js';
14
+ import { loadAdapter } from './adapters.js';
15
+ import taskCommands from './task.js';
16
+
17
+ const WAIT_MS = 5 * 60_000;
18
+ const SUCCESS = ['review', 'done'];
19
+ const HANDOFF = ['blocked', 'needs-spec'];
20
+
21
+ /**
22
+ * Prompt for a single task. One line, without the characters " & | < > ^ % —
23
+ * on Windows the argument goes through cmd /c, where a newline and these characters break the command.
24
+ */
25
+ export function taskPrompt({ id, branch }) {
26
+ return [
27
+ `You are an agent of a crew team. Task issue #${id} is already claimed by you: status claimed, branch ${branch ?? `crew/${id}-...`}, the current folder is its worktree.`,
28
+ `Work only on issue #${id} and only in this folder, strictly following the crew-workflow skill, starting with the Understand step: gh issue view ${id}.`,
29
+ 'Do not call crew task next or crew task claim — the worker will hand out the next task.',
30
+ `For long work, run crew task heartbeat ${id} about every 30 minutes.`,
31
+ `Finish with exactly one of: crew pr open — the task goes to review; crew task status ${id} blocked --note with a description; crew task status ${id} needs-spec --note with a question.`,
32
+ 'Then end your work.',
33
+ ].join(' ');
34
+ }
35
+
36
+ /** Call a crew command as a function: CrewError → a crew/v1 result, not an exception. */
37
+ async function call(fn, opts) {
38
+ try {
39
+ return await fn(opts);
40
+ } catch (e) {
41
+ return e instanceof CrewError ? e.toResult() : fail('internal', e.message, { stop_when: 'it keeps happening — tell the human' });
42
+ }
43
+ }
44
+
45
+ const fmtDuration = (ms) => {
46
+ const s = Math.round(ms / 1000);
47
+ return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
48
+ };
49
+
50
+
51
+ /** Run the agent: stdout/stderr → log file, prompt → stdin, killed on timeout. */
52
+ export function spawnAgent(spec, { cwd, env, timeoutMs, logFile }) {
53
+ return new Promise((done) => {
54
+ let out = 'ignore';
55
+ if (logFile) {
56
+ mkdirSync(dirname(logFile), { recursive: true });
57
+ out = createWriteStream(logFile);
58
+ }
59
+ let child;
60
+ try {
61
+ child = nodeSpawn(spec.cmd, spec.args, {
62
+ cwd,
63
+ env: { ...process.env, ...env },
64
+ stdio: [spec.input != null ? 'pipe' : 'ignore', 'pipe', 'pipe'],
65
+ shell: false,
66
+ windowsHide: true,
67
+ detached: process.platform !== 'win32', // own process group → the whole tree can be killed
68
+ });
69
+ } catch (e) {
70
+ done({ code: -1, timedOut: false, error: e.message });
71
+ return;
72
+ }
73
+ if (out !== 'ignore') { child.stdout.pipe(out, { end: false }); child.stderr.pipe(out, { end: false }); }
74
+ else { child.stdout.resume(); child.stderr.resume(); }
75
+ if (spec.input != null) child.stdin.end(spec.input);
76
+ let timedOut = false;
77
+ const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
78
+ let settled = false;
79
+ const finish = (r) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ if (out !== 'ignore') out.end();
84
+ done(r);
85
+ };
86
+ child.on('error', (e) => finish({ code: -1, timedOut, error: e.message }));
87
+ child.on('close', (code, signal) => finish({ code: code ?? -1, signal, timedOut }));
88
+ });
89
+ }
90
+
91
+ /**
92
+ * A crew command as a function + recording the event in .crew/metrics.jsonl (as in the CLI and MCP).
93
+ * Without this, the worker claim/release are invisible in crew metrics and the completion rate of worker agents is wrong.
94
+ */
95
+ export function recorded(cmd) {
96
+ return async (o) => {
97
+ const started = Date.now();
98
+ try {
99
+ const result = await cmd.run(o);
100
+ recordCommand({ command: cmd.name, opts: o, result, started });
101
+ return result;
102
+ } catch (e) {
103
+ const result = e instanceof CrewError ? e.toResult() : fail('internal', e.message);
104
+ recordCommand({ command: cmd.name, opts: o, result, started });
105
+ throw e;
106
+ }
107
+ };
108
+ }
109
+
110
+ function defaultDeps() {
111
+ const byName = Object.fromEntries(taskCommands.map((c) => [c.name, c]));
112
+ return {
113
+ next: recorded(byName['task next']),
114
+ claim: recorded(byName['task claim']),
115
+ release: recorded(byName['task release']),
116
+ viewIssue,
117
+ spawn: spawnAgent,
118
+ adapters: { load: loadAdapter },
119
+ loadConfig,
120
+ gitCommonDir: (cwd) => {
121
+ const r = exec('git', ['rev-parse', '--git-common-dir'], { cwd, allowFail: true });
122
+ return r.code === 0 && r.stdout ? resolve(cwd, r.stdout) : null;
123
+ },
124
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
125
+ log: (line) => process.stderr.write(`[crew worker] ${line}\n`),
126
+ now: () => Date.now(),
127
+ };
128
+ }
129
+
130
+ async function getAdapter(adapters, host) {
131
+ if (!adapters) return null;
132
+ if (typeof adapters.load === 'function') return adapters.load(host);
133
+ return adapters[host] ?? null;
134
+ }
135
+
136
+ /** The worker loop. deps are stubbed in tests: { next, claim, release, viewIssue, spawn, adapters, ... }. */
137
+ export async function runWorker(opts, deps = {}) {
138
+ const d = { ...defaultDeps(), ...deps };
139
+ const {
140
+ host, area, tier = 'high', dry_run, wait,
141
+ max_tasks = 5, max_failures = 3, task_timeout_min = 60,
142
+ } = opts;
143
+
144
+ const adapter = await getAdapter(d.adapters, host);
145
+ if (!adapter?.headless) {
146
+ return fail('unknown_host', `No adapter with headless() for: ${host}`, {
147
+ cause_hint: 'adapters live in adapters/<host>/adapter.js',
148
+ retry: 'crew worker --host codex (or claude)',
149
+ stop_when: 'the required host is not among the adapters',
150
+ });
151
+ }
152
+ const { config, root } = d.loadConfig();
153
+ const model = opts.model || config.tiers?.[tier]?.[host] || undefined;
154
+ const agent = `${host}/${model || 'default'}`;
155
+
156
+ const done = [];
157
+ const failed = [];
158
+ const handed = [];
159
+ let failStreak = 0;
160
+ let conflicts = 0;
161
+ let stopped_reason = null;
162
+ let started = 0;
163
+
164
+ const prevAgent = process.env.CREW_AGENT;
165
+ const prevAgentId = process.env.CREW_AGENT_ID;
166
+ process.env.CREW_AGENT = agent; // the worker claim/release run as the same agent as the child process
167
+ // Unique id: two workers of the same model on one machine are different agents (lease, review).
168
+ const agentUid = `${agent}@${machineName()}-w${process.pid}`;
169
+ process.env.CREW_AGENT_ID = agentUid;
170
+ const result = (extra = {}) => ({ done, failed, blocked: handed, stopped_reason, agent, ...extra });
171
+
172
+ try {
173
+ while (started < max_tasks) {
174
+ const nx = await call(d.next, { area, tier, tasks_only: true });
175
+ if (nx.status === 'error') {
176
+ if (nx.error?.code === 'paused') { stopped_reason = 'paused'; break; }
177
+ return fail(nx.error?.code ?? 'next_failed', `Worker stopped: ${nx.summary}`, {
178
+ ...(nx.error ?? {}),
179
+ artifacts: result({ stopped_reason: 'next_failed' }),
180
+ });
181
+ }
182
+ if (nx.status === 'warning' || !nx.artifacts?.issue) {
183
+ if (wait) {
184
+ d.log(`queue is empty, waiting ${WAIT_MS / 60_000} min`);
185
+ await d.sleep(WAIT_MS);
186
+ continue;
187
+ }
188
+ stopped_reason = 'no_tasks';
189
+ break;
190
+ }
191
+ const id = nx.artifacts.issue;
192
+
193
+ if (dry_run) {
194
+ const spec = adapter.headless({ prompt: taskPrompt({ id }), model, cwd: '<worktree>', writableDirs: [] });
195
+ return ok(`Dry run: would take #${id} and start ${host}`, {
196
+ artifacts: { issue: id, agent, command: [spec.cmd, ...spec.args].join(' '), prompt: taskPrompt({ id }) },
197
+ next_actions: [`crew worker --host ${host}${area ? ` --area ${area}` : ''} — run for real`],
198
+ });
199
+ }
200
+
201
+ const cl = await call(d.claim, { id });
202
+ if (cl.status === 'error') {
203
+ const code = cl.error?.code;
204
+ if (code === 'paused') { stopped_reason = 'paused'; break; }
205
+ if (code === 'claim_conflict' || code === 'not_ready') {
206
+ if (++conflicts >= 3) { stopped_reason = 'claim_conflicts'; break; }
207
+ d.log(`#${id}: ${cl.summary} — taking the next one`);
208
+ continue;
209
+ }
210
+ if (code === 'workspace_failed') {
211
+ // the claim has already been rolled back (the task is ready again) — we count it as a failure and do not stop the worker.
212
+ failed.push(id);
213
+ failStreak++;
214
+ d.log(`#${id}: ✖ ${cl.summary}, failures in a row: ${failStreak}/${max_failures}`);
215
+ if (failStreak >= max_failures) { stopped_reason = 'max_failures'; break; }
216
+ continue;
217
+ }
218
+ return fail(code ?? 'claim_failed', `Worker stopped: ${cl.summary}`, {
219
+ ...(cl.error ?? {}),
220
+ artifacts: result({ stopped_reason: 'claim_failed', issue: id }),
221
+ });
222
+ }
223
+ conflicts = 0;
224
+ started++;
225
+ const worktree = cl.artifacts?.worktree ?? root;
226
+ const commonDir = d.gitCommonDir?.(worktree);
227
+ const spec = adapter.headless({
228
+ prompt: taskPrompt({ id, branch: cl.artifacts?.branch }),
229
+ model,
230
+ cwd: worktree,
231
+ writableDirs: commonDir ? [commonDir] : [],
232
+ });
233
+ const logFile = join(root, '.crew', 'logs', `worker-${id}-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
234
+ d.log(`#${id}: starting ${agent} in ${worktree}`);
235
+ const t0 = d.now();
236
+ const res = await d.spawn(spec, {
237
+ cwd: worktree,
238
+ env: { ...spec.env, CREW_AGENT: agent, CREW_AGENT_ID: agentUid },
239
+ timeoutMs: task_timeout_min * 60_000,
240
+ logFile,
241
+ });
242
+ const took = fmtDuration(d.now() - t0);
243
+
244
+ if (res.error) {
245
+ await call(d.release, { id, note: `crew worker: could not start ${host}: ${res.error}` });
246
+ failed.push(id);
247
+ d.log(`#${id}: ✖ agent failed to start (${res.error})`);
248
+ return fail('tool_missing', `Could not start ${host}: ${res.error}`, {
249
+ cause_hint: `${spec.cmd} was not found or does not start; task #${id} was returned to the queue`,
250
+ retry: 'crew doctor',
251
+ stop_when: `${host} is not installed`,
252
+ artifacts: result({ stopped_reason: 'spawn_failed' }),
253
+ });
254
+ }
255
+
256
+ const issue = await call(d.viewIssue, id);
257
+ const state = issue?.labels ? labelValue(issue, 'status') : null;
258
+ const closed = issue?.state === 'CLOSED';
259
+
260
+ if (closed || SUCCESS.includes(state)) {
261
+ done.push(id);
262
+ failStreak = 0;
263
+ d.log(`#${id}: ✔ ${closed ? 'closed' : state} in ${took}`);
264
+ } else if (HANDOFF.includes(state)) {
265
+ handed.push({ issue: id, status: state });
266
+ failStreak = 0;
267
+ d.log(`#${id}: ! ${state} in ${took} — handoff to the issue`);
268
+ } else {
269
+ const why = res.timedOut ? `timeout ${task_timeout_min} min` : `agent exited with code ${res.code}, status ${state ?? 'unknown'}`;
270
+ if (state === 'claimed') await call(d.release, { id, note: `crew worker: ${why}. Log: ${logFile}` });
271
+ failed.push(id);
272
+ failStreak++;
273
+ d.log(`#${id}: ✖ ${why} (${took}), failures in a row: ${failStreak}/${max_failures}`);
274
+ if (failStreak >= max_failures) { stopped_reason = 'max_failures'; break; }
275
+ }
276
+ }
277
+ if (!stopped_reason) stopped_reason = 'max_tasks';
278
+ } finally {
279
+ if (prevAgent === undefined) delete process.env.CREW_AGENT;
280
+ else process.env.CREW_AGENT = prevAgent;
281
+ if (prevAgentId === undefined) delete process.env.CREW_AGENT_ID;
282
+ else process.env.CREW_AGENT_ID = prevAgentId;
283
+ }
284
+
285
+ const reasonText = {
286
+ paused: 'paused (issue crew:pause)',
287
+ no_tasks: 'queue is empty',
288
+ max_tasks: `task limit reached (${max_tasks})`,
289
+ max_failures: `${max_failures} failures in a row`,
290
+ claim_conflicts: '3 claim conflicts in a row',
291
+ }[stopped_reason];
292
+ const summary = `Worker ${agent} stopped: ${reasonText}. Delivered: ${done.length}, failures: ${failed.length}, handoffs: ${handed.length}`;
293
+ const next_actions = {
294
+ paused: ['wait until crew:pause is lifted and run crew worker again'],
295
+ no_tasks: ['crew status — what is blocked', `crew worker --host ${host} --wait — wait for new tasks`],
296
+ max_tasks: [`crew worker --host ${host} — next batch`],
297
+ max_failures: ['read the logs in .crew/logs/worker-*.log and the comments on the tasks', 'crew status'],
298
+ claim_conflicts: ['crew status — who else is claiming tasks', 'tell the human'],
299
+ }[stopped_reason];
300
+ const bad = stopped_reason === 'max_failures' || stopped_reason === 'claim_conflicts';
301
+ return (bad ? warn : ok)(summary, { artifacts: result(), next_actions });
302
+ }
303
+
304
+ export default {
305
+ name: 'worker',
306
+ summary: 'Run an agent (claude/codex) without a UI: it takes tasks one at a time, a new session per task',
307
+ agent: false,
308
+ args: [
309
+ { name: 'host', type: 'string', required: true, description: 'which agent: claude, codex' },
310
+ { name: 'area', type: 'string', description: 'task area: web, api, ... (empty = any)' },
311
+ { name: 'tier', type: 'string', enum: TIERS, description: 'model tier (default high); the model comes from crew.config.json → tiers' },
312
+ { name: 'model', type: 'string', description: 'a specific model (overrides tier)' },
313
+ { name: 'max_tasks', type: 'number', description: 'maximum number of tasks to take (default 5)' },
314
+ { name: 'max_failures', type: 'number', description: 'stop after N failures in a row (default 3)' },
315
+ { name: 'task_timeout_min', type: 'number', description: 'kill the agent after N minutes on a task (default 60)' },
316
+ { name: 'wait', type: 'boolean', description: 'if the queue is empty — wait for new tasks (polls every 5 min)' },
317
+ { name: 'dry_run', type: 'boolean', description: 'only show which task it would take and with which command' },
318
+ ],
319
+ run(opts) {
320
+ return runWorker(opts);
321
+ },
322
+ };
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const HARNESS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
7
+
8
+ export const DEFAULT_CONFIG = {
9
+ // Areas = directories. An agent with --area web only touches web/**.
10
+ // e2e: acceptance and E2E tests, including tests/pending/** (test tasks for risk:high).
11
+ areas: {
12
+ web: ['web/**'],
13
+ api: ['api/**'],
14
+ contracts: ['contracts/**'],
15
+ e2e: ['tests/e2e/**', 'tests/acceptance/**', 'tests/pending/**'],
16
+ infra: ['infra/**', '.github/**'],
17
+ // Not code: designer mockups and styles, researcher notes. Styles web/src/styles/** are shared with web.
18
+ design: ['design/**', 'web/src/styles/**'],
19
+ research: ['docs/research/**'],
20
+ },
21
+ // What `crew check` runs (in order). An empty string or missing entry means the step is skipped.
22
+ check: [],
23
+ // Task lease: how many minutes without activity before the task returns to the queue.
24
+ lease_minutes: 45,
25
+ // Model tiers -> concrete models per vendor (crew worker passes them to --model). Codex: Sol -> Terra -> Luna.
26
+ tiers: {
27
+ high: { claude: 'opus', codex: 'gpt-6.1-sol' },
28
+ mid: { claude: 'sonnet', codex: 'gpt-6.1-terra' },
29
+ low: { claude: 'haiku', codex: 'gpt-6-luna' },
30
+ },
31
+ hosts: ['claude', 'codex'],
32
+ // Agent review instead of a human approve button (checked by crew guard / the crew-guard status).
33
+ // Who reviews first is the review area in the PRD crew-team block (usually the captain); crew task next gives them PRs before tasks.
34
+ // require: other-vendor means a reviewer from another vendor; other-agent means any other agent (team with Claude only).
35
+ // Default is other-vendor when there is more than one host. If no other-vendor reviewer shows up within fallback_minutes,
36
+ // any other agent counts (so PRs do not hang).
37
+ review: { approvals: 1, approvals_high: 1, fallback_minutes: 30 },
38
+ event: null, // path to events/<name>.json when a hackathon is running
39
+ };
40
+
41
+ /** Find crew.config.json walking up from cwd. Returns { config, root } (root = project root). */
42
+ export function loadConfig(cwd = process.cwd()) {
43
+ let dir = resolve(cwd);
44
+ for (;;) {
45
+ const p = join(dir, 'crew.config.json');
46
+ if (existsSync(p)) {
47
+ const user = JSON.parse(readFileSync(p, 'utf8'));
48
+ return { config: { ...DEFAULT_CONFIG, ...user }, root: dir, path: p };
49
+ }
50
+ const up = dirname(dir);
51
+ if (up === dir) return { config: { ...DEFAULT_CONFIG }, root: resolve(cwd), path: null };
52
+ dir = up;
53
+ }
54
+ }
55
+
56
+ export function loadEvent(config, root) {
57
+ if (!config.event) return null;
58
+ const p = resolve(root, config.event);
59
+ return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
60
+ }
61
+
62
+ /** Short machine name for the agent id: "petya-laptop". */
63
+ export const machineName = () =>
64
+ (hostname().split('.')[0] || 'host').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'host';
65
+
66
+ /**
67
+ * Agent id: <vendor>[/<model>]@<machine>, e.g. claude@petya-laptop, codex/gpt-x@masha-pc.
68
+ * CREW_AGENT sets vendor/model (adapters set it; for all Claude agents it is just "claude"),
69
+ * the machine is appended automatically, otherwise three Claudes on three laptops would be one agent
70
+ * (another agent's lease would be taken for your own, and review by "another agent" would be impossible).
71
+ * For several sessions on one machine, give each its own CREW_AGENT_ID (claude@petya-2).
72
+ */
73
+ export function agentId(env = process.env) {
74
+ if (env.CREW_AGENT_ID) return env.CREW_AGENT_ID;
75
+ const base = env.CREW_AGENT || 'human';
76
+ return base.includes('@') ? base : `${base}@${machineName()}`;
77
+ }
@@ -0,0 +1,50 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { CrewError } from './output.js';
3
+ import { runSupervised } from './proc.js';
4
+
5
+ /** Run an external command without a shell (cross-platform, no injection). */
6
+ export function run(cmd, args = [], { cwd, allowFail = false, input } = {}) {
7
+ const res = spawnSync(cmd, args, {
8
+ cwd,
9
+ input,
10
+ encoding: 'utf8',
11
+ shell: false,
12
+ windowsHide: true,
13
+ maxBuffer: 64 * 1024 * 1024,
14
+ });
15
+ if (res.error) {
16
+ if (allowFail) return { code: -1, stdout: '', stderr: String(res.error.message) };
17
+ throw new CrewError('tool_missing', `failed to run ${cmd}`, {
18
+ cause_hint: res.error.message,
19
+ retry: 'crew doctor',
20
+ stop_when: `${cmd} is not installed`,
21
+ });
22
+ }
23
+ const out = { code: res.status ?? -1, stdout: res.stdout?.trim() ?? '', stderr: res.stderr?.trim() ?? '' };
24
+ if (out.code !== 0 && !allowFail) {
25
+ throw new CrewError('command_failed', `${cmd} ${args.join(' ')} exited with code ${out.code}`, {
26
+ cause_hint: out.stderr.split('\n').slice(0, 5).join(' | '),
27
+ stop_when: 'the error repeats twice',
28
+ });
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * Run a config string (the user's check commands) through the shell.
35
+ * timeout_s is how many seconds to wait; after that the process is killed (SIGKILL), timedOut: true.
36
+ * Without a timeout a hung test or dev server blocks the agent forever.
37
+ */
38
+ export function runShell(line, { cwd, timeout_s } = {}) {
39
+ const timeoutMs = Number(timeout_s) > 0 ? Math.round(Number(timeout_s) * 1000) : undefined;
40
+ // Via supervisor: on timeout the whole tree is killed (npm test, dev server), not just the shell.
41
+ const r = runSupervised({ cmd: line, shell: true, cwd, timeoutMs });
42
+ return { code: r.code, stdout: r.stdout ?? '', stderr: r.stderr || r.error || '', timedOut: !!r.timedOut };
43
+ }
44
+
45
+ export const git = (args, opts) => run('git', args, opts);
46
+
47
+ /** Current branch; also works in a new repo with no commits (unborn HEAD). */
48
+ export const currentBranch = (cwd) => git(['symbolic-ref', '--short', 'HEAD'], { cwd, allowFail: true }).stdout || 'HEAD';
49
+ export const gh = (args, opts) => run('gh', args, opts);
50
+ export const ghJson = (args, opts) => JSON.parse(gh(args, opts).stdout || 'null');