@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.
- package/AGENTS.md +25 -0
- package/LICENSE +201 -0
- package/README.md +418 -2
- package/README.ru.md +421 -0
- package/adapters/claude/adapter.js +107 -0
- package/adapters/codex/adapter.js +101 -0
- package/bin/crew.js +60 -0
- package/docs/AGENT-SETUP.md +138 -0
- package/ecc/profile.json +16 -0
- package/evals/README.md +123 -0
- package/evals/fixtures/mini-app/README.md +10 -0
- package/evals/fixtures/mini-app/contracts/health.json +11 -0
- package/evals/fixtures/mini-app/package.json +10 -0
- package/evals/fixtures/mini-app/src/cart.js +14 -0
- package/evals/fixtures/mini-app/src/handlers.js +16 -0
- package/evals/fixtures/mini-app/src/pager.js +7 -0
- package/evals/fixtures/mini-app/test/cart.test.js +10 -0
- package/evals/fixtures/mini-app/test/handlers.test.js +13 -0
- package/evals/fixtures/mini-app/test/pager.test.js +8 -0
- package/evals/tasks/add-input-validation.json +15 -0
- package/evals/tasks/fix-off-by-one.json +14 -0
- package/evals/tasks/handler-per-contract.json +15 -0
- package/evals/tasks/judges/add-input-validation.judge.mjs +28 -0
- package/evals/tasks/judges/fix-off-by-one.judge.mjs +22 -0
- package/evals/tasks/judges/handler-per-contract.judge.mjs +18 -0
- package/package.json +46 -4
- package/roles/analyst.md +11 -0
- package/roles/builder.md +16 -0
- package/roles/implementer.md +17 -0
- package/roles/integrator.md +12 -0
- package/roles/planner.md +19 -0
- package/roles/reviewer.md +16 -0
- package/roles/scout.md +15 -0
- package/skills/crew-brief-analysis/SKILL.md +32 -0
- package/skills/crew-kickoff/SKILL.md +77 -0
- package/skills/crew-kickoff/task-format.md +215 -0
- package/skills/crew-review/SKILL.md +22 -0
- package/skills/crew-workflow/SKILL.md +46 -0
- package/src/commands/adapters.js +85 -0
- package/src/commands/ask.js +123 -0
- package/src/commands/board.js +88 -0
- package/src/commands/check.js +148 -0
- package/src/commands/commit.js +105 -0
- package/src/commands/compliance.js +109 -0
- package/src/commands/digest.js +64 -0
- package/src/commands/doctor.js +48 -0
- package/src/commands/eval.js +507 -0
- package/src/commands/github.js +169 -0
- package/src/commands/guard.js +101 -0
- package/src/commands/init.js +76 -0
- package/src/commands/kickoff.js +275 -0
- package/src/commands/labels.js +28 -0
- package/src/commands/merge-ready.js +33 -0
- package/src/commands/metrics.js +48 -0
- package/src/commands/pr.js +273 -0
- package/src/commands/shots.js +51 -0
- package/src/commands/status.js +48 -0
- package/src/commands/task.js +590 -0
- package/src/commands/up.js +355 -0
- package/src/commands/upgrade.js +178 -0
- package/src/commands/worker.js +322 -0
- package/src/core/board.js +127 -0
- package/src/core/config.js +83 -0
- package/src/core/digest.js +67 -0
- package/src/core/exec.js +50 -0
- package/src/core/guard.js +273 -0
- package/src/core/issues.js +154 -0
- package/src/core/lease.js +48 -0
- package/src/core/metrics.js +232 -0
- package/src/core/output.js +56 -0
- package/src/core/prd.js +612 -0
- package/src/core/proc.js +52 -0
- package/src/core/questions.js +51 -0
- package/src/core/queue.js +94 -0
- package/src/core/registry.js +143 -0
- package/src/core/session.js +61 -0
- package/src/core/shots.js +68 -0
- package/src/core/supervisor.js +56 -0
- package/src/core/version.js +44 -0
- package/src/mcp/server.js +81 -0
- package/templates/labels.json +23 -0
- package/templates/mcp.optional.json +23 -0
- package/templates/product/.github/ISSUE_TEMPLATE/epic.yml +22 -0
- package/templates/product/.github/ISSUE_TEMPLATE/feedback.yml +36 -0
- package/templates/product/.github/ISSUE_TEMPLATE/task.yml +62 -0
- package/templates/product/.github/pull_request_template.md +16 -0
- package/templates/product/.github/workflows/crew-guard.yml +45 -0
- package/templates/product/.github/workflows/crew.yml +118 -0
- package/templates/product/AGENTS.md +109 -0
- package/templates/product/contracts/README.md +8 -0
- package/templates/product/docs/architecture.md +41 -0
- package/templates/product/docs/brief-analysis.md +62 -0
- package/templates/product/docs/brief.md +25 -0
- package/templates/product/docs/playbooks/issues.md +91 -0
- package/templates/product/docs/playbooks/preflight.md +28 -0
- package/templates/product/docs/playbooks/timeline.md +52 -0
- package/templates/product/docs/prd.md +58 -0
- package/templates/product/events/event.example.json +10 -0
- package/templates/product/tests/acceptance/README.md +9 -0
- package/templates/stacks/web-fastapi.json +74 -0
- 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,127 @@
|
|
|
1
|
+
// Task board for humans (crew board): see the queue and steer it by hand without spending agent tokens.
|
|
2
|
+
// Pure functions: the model shown on the page, and the gh arguments for each action the page can request.
|
|
3
|
+
import { labelValue } from './issues.js';
|
|
4
|
+
import { prQueue } from './queue.js';
|
|
5
|
+
import { LOGIN_RE } from './prd.js';
|
|
6
|
+
|
|
7
|
+
export const COLUMNS = ['ready', 'claimed', 'review', 'waiting', 'blocked', 'needs-spec'];
|
|
8
|
+
export const PRIORITIES = ['high', 'normal', 'low'];
|
|
9
|
+
/** Statuses a human may set from the board. claimed/review belong to the agent's lease and PR flow. */
|
|
10
|
+
export const BOARD_STATUSES = ['ready', 'blocked', 'needs-spec', 'waiting'];
|
|
11
|
+
|
|
12
|
+
const prio = (i) => labelValue(i, 'priority') ?? 'normal';
|
|
13
|
+
|
|
14
|
+
export function boardModel({ issues = [], prs = [], members = [], config = {}, now = Date.now() } = {}) {
|
|
15
|
+
const columns = Object.fromEntries(COLUMNS.map((c) => [c, []]));
|
|
16
|
+
const other = [];
|
|
17
|
+
for (const i of issues) {
|
|
18
|
+
const status = labelValue(i, 'status');
|
|
19
|
+
if ((i.labels ?? []).some((l) => l.name === 'epic')) continue;
|
|
20
|
+
const card = {
|
|
21
|
+
number: i.number,
|
|
22
|
+
title: i.title,
|
|
23
|
+
status,
|
|
24
|
+
area: labelValue(i, 'area'),
|
|
25
|
+
tier: labelValue(i, 'tier'),
|
|
26
|
+
risk: labelValue(i, 'risk'),
|
|
27
|
+
priority: prio(i),
|
|
28
|
+
assignees: (i.assignees ?? []).map((a) => a.login),
|
|
29
|
+
idle_min: Math.max(0, Math.round((now - Date.parse(i.updatedAt ?? '')) / 60000)) || 0,
|
|
30
|
+
};
|
|
31
|
+
(columns[status] ?? other).push(card);
|
|
32
|
+
}
|
|
33
|
+
const order = { high: 0, normal: 1, low: 2 };
|
|
34
|
+
for (const c of COLUMNS) columns[c].sort((a, b) => order[a.priority] - order[b.priority] || a.number - b.number);
|
|
35
|
+
const queue = prQueue(prs, { me: 'board', login: null, reviewers: [], config, now }).map((x) => ({
|
|
36
|
+
kind: x.kind, number: x.number, title: x.title, risk: x.risk, age_min: x.age, reason: x.reason ?? null, have: x.have ?? null, need: x.need ?? null,
|
|
37
|
+
}));
|
|
38
|
+
return { columns, untracked: other.length, prs: queue, members: [...new Set(members)].sort((a, b) => a.localeCompare(b)), generated_at: new Date(now).toISOString() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* gh arguments for one board action, or { error }. Everything is validated here because the page
|
|
43
|
+
* is only a convenience: the server never passes page input to gh unchecked.
|
|
44
|
+
* current: the issue as listed ({ number, labels, assignees }).
|
|
45
|
+
*/
|
|
46
|
+
export function actionArgs({ op, value }, current, { members = [] } = {}) {
|
|
47
|
+
if (!current || !Number.isInteger(current.number)) return { error: 'unknown issue' };
|
|
48
|
+
const n = String(current.number);
|
|
49
|
+
const labels = (current.labels ?? []).map((l) => l.name);
|
|
50
|
+
if (op === 'assign') {
|
|
51
|
+
const to = String(value ?? '');
|
|
52
|
+
const now = (current.assignees ?? []).map((a) => a.login);
|
|
53
|
+
if (to && (!LOGIN_RE.test(to) || !members.some((m) => m.toLowerCase() === to.toLowerCase()))) return { error: `${to} is not a member of this repo` };
|
|
54
|
+
const args = ['issue', 'edit', n];
|
|
55
|
+
for (const a of now) if (a.toLowerCase() !== to.toLowerCase()) args.push('--remove-assignee', a);
|
|
56
|
+
if (to && !now.some((a) => a.toLowerCase() === to.toLowerCase())) args.push('--add-assignee', to);
|
|
57
|
+
return args.length > 3 ? { args } : { noop: true };
|
|
58
|
+
}
|
|
59
|
+
if (op === 'status') {
|
|
60
|
+
const to = String(value ?? '');
|
|
61
|
+
if (!BOARD_STATUSES.includes(to)) return { error: `status must be one of ${BOARD_STATUSES.join(', ')}` };
|
|
62
|
+
const from = labels.filter((l) => l.startsWith('status:')).map((l) => l.slice(7));
|
|
63
|
+
if (from.some((s) => s === 'claimed' || s === 'review')) {
|
|
64
|
+
return { error: `#${n} is ${from.join(',')}: an agent holds it — use crew task release ${n} --force first` };
|
|
65
|
+
}
|
|
66
|
+
if (from.length === 1 && from[0] === to) return { noop: true };
|
|
67
|
+
const args = ['issue', 'edit', n, '--add-label', `status:${to}`];
|
|
68
|
+
for (const f of from) if (f !== to) args.push('--remove-label', `status:${f}`);
|
|
69
|
+
return { args };
|
|
70
|
+
}
|
|
71
|
+
if (op === 'priority') {
|
|
72
|
+
const to = String(value ?? '');
|
|
73
|
+
if (!PRIORITIES.includes(to)) return { error: `priority must be one of ${PRIORITIES.join(', ')}` };
|
|
74
|
+
const args = ['issue', 'edit', n];
|
|
75
|
+
for (const l of labels.filter((x) => x.startsWith('priority:') && x !== `priority:${to}`)) args.push('--remove-label', l);
|
|
76
|
+
if (to !== 'normal' && !labels.includes(`priority:${to}`)) args.push('--add-label', `priority:${to}`);
|
|
77
|
+
return args.length > 3 ? { args } : { noop: true };
|
|
78
|
+
}
|
|
79
|
+
return { error: `unknown action ${op}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
83
|
+
|
|
84
|
+
/** The board page. Data comes from /api/board; actions go to /api/action with the per-run token. */
|
|
85
|
+
export function renderPage({ token, repo }) {
|
|
86
|
+
return `<!doctype html>
|
|
87
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
88
|
+
<title>crew board — ${esc(repo)}</title>
|
|
89
|
+
<style>
|
|
90
|
+
:root{--bg:#f6f7f9;--card:#fff;--ink:#1d2433;--muted:#667085;--line:#e3e6ec;--hi:#d92d20}
|
|
91
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111418;--card:#1a1f26;--ink:#e6e9ef;--muted:#98a2b3;--line:#2a313b}}
|
|
92
|
+
*{box-sizing:border-box}body{margin:0;font:14px/1.4 system-ui,sans-serif;background:var(--bg);color:var(--ink)}
|
|
93
|
+
header{display:flex;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
94
|
+
header h1{font-size:16px;margin:0}.muted{color:var(--muted)}
|
|
95
|
+
main{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;padding:12px 16px}
|
|
96
|
+
section h2,#prs h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:4px 0 8px}
|
|
97
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:8px}
|
|
98
|
+
.card a{color:var(--ink);text-decoration:none;font-weight:600}.meta{color:var(--muted);font-size:12px;margin:4px 0 6px}
|
|
99
|
+
.prio-high{border-left:4px solid var(--hi)}.prio-low{opacity:.75}
|
|
100
|
+
.row{display:flex;gap:6px;flex-wrap:wrap}select{font:inherit;font-size:12px;padding:2px 4px;border-radius:6px;border:1px solid var(--line);background:var(--card);color:var(--ink)}
|
|
101
|
+
#prs{padding:0 16px 16px}#prs a{color:var(--ink)}#prs li{margin:4px 0}#msg{color:var(--hi)}
|
|
102
|
+
</style></head><body>
|
|
103
|
+
<header><h1>crew board</h1><span class="muted">${esc(repo)}</span><span class="muted" id="time"></span><span id="msg"></span></header>
|
|
104
|
+
<main id="cols"></main>
|
|
105
|
+
<div id="prs"><h2>Pull requests waiting</h2><ul id="prlist"></ul></div>
|
|
106
|
+
<script>
|
|
107
|
+
const TOKEN=${JSON.stringify(token)};const REPO=${JSON.stringify(repo)};
|
|
108
|
+
const COLS=${JSON.stringify(COLUMNS)};const ST=${JSON.stringify(BOARD_STATUSES)};const PR=${JSON.stringify(PRIORITIES)};
|
|
109
|
+
const e=(t,a={},...k)=>{const n=document.createElement(t);for(const[x,y]of Object.entries(a)){if(x==='on')for(const[ev,f]of Object.entries(y))n.addEventListener(ev,f);else n.setAttribute(x,y)}for(const c of k)n.append(c);return n};
|
|
110
|
+
const sel=(opts,cur,on)=>{const s=e('select',{on:{change:()=>on(s.value)}});for(const[v,l]of opts){const o=e('option',{value:v},l);if(v===cur)o.selected=true;s.append(o)}return s};
|
|
111
|
+
async function act(issue,op,value){const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json','x-crew-token':TOKEN},body:JSON.stringify({issue,op,value})});const j=await r.json();document.getElementById('msg').textContent=j.error||'';load()}
|
|
112
|
+
async function load(){const r=await fetch('/api/board',{headers:{'x-crew-token':TOKEN}});const m=await r.json();if(m.error){document.getElementById('msg').textContent=m.error;return}
|
|
113
|
+
document.getElementById('time').textContent='updated '+new Date(m.generated_at).toLocaleTimeString();
|
|
114
|
+
const cols=document.getElementById('cols');cols.replaceChildren();
|
|
115
|
+
for(const c of COLS){const s=e('section',{},e('h2',{},c+' ('+m.columns[c].length+')'));
|
|
116
|
+
for(const t of m.columns[c]){const who=[['','unassigned'],...m.members.map(x=>[x,x])];
|
|
117
|
+
const ctl=e('div',{class:'row'},sel(who,t.assignees[0]||'',v=>act(t.number,'assign',v)),sel(PR.map(p=>[p,p]),t.priority,v=>act(t.number,'priority',v)));
|
|
118
|
+
if(ST.includes(t.status))ctl.append(sel(ST.map(x=>[x,x]),t.status,v=>act(t.number,'status',v)));
|
|
119
|
+
s.append(e('div',{class:'card prio-'+t.priority},e('a',{href:'https://github.com/'+REPO+'/issues/'+t.number,target:'_blank',rel:'noopener'},'#'+t.number+' '+t.title),
|
|
120
|
+
e('div',{class:'meta'},[t.area&&('area:'+t.area),t.risk&&('risk:'+t.risk),t.tier&&('tier:'+t.tier),t.assignees.length?('@'+t.assignees.join(' @')):'no owner',t.idle_min+' min'].filter(Boolean).join(' · ')),ctl))}
|
|
121
|
+
cols.append(s)}
|
|
122
|
+
const ul=document.getElementById('prlist');ul.replaceChildren();
|
|
123
|
+
for(const p of m.prs)ul.append(e('li',{},e('a',{href:'https://github.com/'+REPO+'/pull/'+p.number,target:'_blank',rel:'noopener'},'#'+p.number+' '+p.title),' — '+(p.kind==='review'?('waiting for review '+p.age_min+' min ('+p.have+'/'+p.need+')'):p.kind==='merge'?'ready to merge':('waiting for the author: '+p.reason))));
|
|
124
|
+
if(!m.prs.length)ul.append(e('li',{class:'muted'},'none'))}
|
|
125
|
+
load();setInterval(load,30000);
|
|
126
|
+
</script></body></html>`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { pinnedId } from './session.js';
|
|
4
|
+
import { join, dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const HARNESS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONFIG = {
|
|
10
|
+
// Areas = directories. An agent with --area web only touches web/**.
|
|
11
|
+
// e2e: acceptance and E2E tests, including tests/pending/** (test tasks for risk:high).
|
|
12
|
+
areas: {
|
|
13
|
+
web: ['web/**'],
|
|
14
|
+
api: ['api/**'],
|
|
15
|
+
contracts: ['contracts/**'],
|
|
16
|
+
e2e: ['tests/e2e/**', 'tests/acceptance/**', 'tests/pending/**'],
|
|
17
|
+
infra: ['infra/**', '.github/**'],
|
|
18
|
+
// Not code: designer mockups and styles, researcher notes. Styles web/src/styles/** are shared with web.
|
|
19
|
+
design: ['design/**', 'web/src/styles/**'],
|
|
20
|
+
research: ['docs/research/**'],
|
|
21
|
+
},
|
|
22
|
+
// What `crew check` runs (in order). An empty string or missing entry means the step is skipped.
|
|
23
|
+
check: [],
|
|
24
|
+
// Task lease: how many minutes without activity before the task returns to the queue.
|
|
25
|
+
lease_minutes: 45,
|
|
26
|
+
// Model tiers -> concrete models per vendor (crew worker passes them to --model). Codex: Sol -> Terra -> Luna.
|
|
27
|
+
tiers: {
|
|
28
|
+
high: { claude: 'opus', codex: 'gpt-6.1-sol' },
|
|
29
|
+
mid: { claude: 'sonnet', codex: 'gpt-6.1-terra' },
|
|
30
|
+
low: { claude: 'haiku', codex: 'gpt-6-luna' },
|
|
31
|
+
},
|
|
32
|
+
hosts: ['claude', 'codex'],
|
|
33
|
+
// Agent review instead of a human approve button (checked by crew guard / the crew-guard status).
|
|
34
|
+
// Who reviews first is the review area in the PRD crew-team block (usually the captain); crew task next gives them PRs before tasks.
|
|
35
|
+
// require: other-vendor means a reviewer from another vendor; other-agent means any other agent (team with Claude only).
|
|
36
|
+
// Default is other-vendor when there is more than one host. If no other-vendor reviewer shows up within fallback_minutes,
|
|
37
|
+
// any other agent counts (so PRs do not hang).
|
|
38
|
+
review: { approvals: 1, approvals_high: 1, fallback_minutes: 30 },
|
|
39
|
+
event: null, // path to events/<name>.json when a hackathon is running
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Find crew.config.json walking up from cwd. Returns { config, root } (root = project root). */
|
|
43
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
44
|
+
let dir = resolve(cwd);
|
|
45
|
+
for (;;) {
|
|
46
|
+
const p = join(dir, 'crew.config.json');
|
|
47
|
+
if (existsSync(p)) {
|
|
48
|
+
const user = JSON.parse(readFileSync(p, 'utf8'));
|
|
49
|
+
return { config: { ...DEFAULT_CONFIG, ...user }, root: dir, path: p };
|
|
50
|
+
}
|
|
51
|
+
const up = dirname(dir);
|
|
52
|
+
if (up === dir) return { config: { ...DEFAULT_CONFIG }, root: resolve(cwd), path: null };
|
|
53
|
+
dir = up;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadEvent(config, root) {
|
|
58
|
+
if (!config.event) return null;
|
|
59
|
+
const p = resolve(root, config.event);
|
|
60
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Short machine name for the agent id: "petya-laptop". */
|
|
64
|
+
export const machineName = () =>
|
|
65
|
+
(hostname().split('.')[0] || 'host').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'host';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Agent id: <vendor>[/<model>]@<machine>, e.g. claude@petya-laptop, codex/gpt-x@masha-pc.
|
|
69
|
+
* CREW_AGENT sets vendor/model (adapters set it; for all Claude agents it is just "claude"),
|
|
70
|
+
* the machine is appended automatically, otherwise three Claudes on three laptops would be one agent
|
|
71
|
+
* (another agent's lease would be taken for your own, and review by "another agent" would be impossible).
|
|
72
|
+
* For several sessions on one machine, give each its own CREW_AGENT_ID (claude@petya-2).
|
|
73
|
+
*/
|
|
74
|
+
export function agentId(env = process.env) {
|
|
75
|
+
if (env.CREW_AGENT_ID) return env.CREW_AGENT_ID;
|
|
76
|
+
// A task worktree remembers who claimed it (crew task claim), so CLI calls there act as the same session.
|
|
77
|
+
if (env === process.env) {
|
|
78
|
+
const pinned = pinnedId();
|
|
79
|
+
if (pinned) return pinned;
|
|
80
|
+
}
|
|
81
|
+
const base = env.CREW_AGENT || 'human';
|
|
82
|
+
return base.includes('@') ? base : `${base}@${machineName()}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Team digest without an LLM: what waits for a human, stale leases, PRs, idle members. `crew digest` prints it;
|
|
2
|
+
// the sweep job posts it to one pinned issue every 15 minutes, so no agent session has to poll the repo.
|
|
3
|
+
import { labelValue } from './issues.js';
|
|
4
|
+
import { prQueue } from './queue.js';
|
|
5
|
+
import { questionMeta, HUMAN_LABEL, QUESTION_LABEL } from './questions.js';
|
|
6
|
+
|
|
7
|
+
export const DIGEST_LABEL = 'crew:digest';
|
|
8
|
+
export const DIGEST_TITLE = 'crew digest: team status';
|
|
9
|
+
export const DIGEST_MARKER = '<!-- crew:digest -->';
|
|
10
|
+
|
|
11
|
+
const minsSince = (now, iso) => Math.max(0, Math.round((now - Date.parse(iso ?? '')) / 60000)) || 0;
|
|
12
|
+
const names = (xs) => (xs ?? []).map((x) => x.name);
|
|
13
|
+
|
|
14
|
+
/** -> { sections: [{ key, title, lines }], counts } (pure, no network). */
|
|
15
|
+
export function digestModel({ issues = [], prs = [], questions = [], team = null, config = {}, now = Date.now() } = {}) {
|
|
16
|
+
const lease = config.lease_minutes ?? 45;
|
|
17
|
+
const reviewWait = config.review?.fallback_minutes ?? 30;
|
|
18
|
+
const st = (i) => labelValue(i, 'status');
|
|
19
|
+
const who = (i) => (i.assignees ?? []).map((a) => `@${a.login}`).join(' ') || 'no owner';
|
|
20
|
+
const tasks = issues.filter((i) => !names(i.labels).some((n) => ['epic', QUESTION_LABEL, DIGEST_LABEL].includes(n)));
|
|
21
|
+
const ready = tasks.filter((i) => st(i) === 'ready');
|
|
22
|
+
const claimed = tasks.filter((i) => st(i) === 'claimed');
|
|
23
|
+
|
|
24
|
+
const human = questions.filter((q) => names(q.labels).includes(HUMAN_LABEL))
|
|
25
|
+
.map((q) => `#${q.number} ${q.title} (asked by ${questionMeta(q)?.from ?? 'an agent'})`);
|
|
26
|
+
const blocked = tasks.filter((i) => ['blocked', 'needs-spec'].includes(st(i))).map((i) => `#${i.number} ${i.title} (${st(i)}, ${who(i)})`);
|
|
27
|
+
const stale = claimed.filter((i) => minsSince(now, i.updatedAt) > lease)
|
|
28
|
+
.map((i) => `#${i.number} ${i.title} (${who(i)}, quiet ${minsSince(now, i.updatedAt)} min, lease ${lease})`);
|
|
29
|
+
const prLines = prQueue(prs, { me: 'crew-digest', login: null, reviewers: [], config, now }).map((x) =>
|
|
30
|
+
x.kind === 'fix' ? `#${x.number} ${x.title}: waits for its author (${x.reason})`
|
|
31
|
+
: x.kind === 'merge' ? `#${x.number} ${x.title}: ready to merge`
|
|
32
|
+
: `#${x.number} ${x.title}: waits for review ${x.age} min${x.age > reviewWait ? ' (slow)' : ''}`);
|
|
33
|
+
const unanswered = questions.filter((q) => !names(q.labels).includes(HUMAN_LABEL))
|
|
34
|
+
.map((q) => `#${q.number} ${q.title} (to ${who(q)}, ${minsSince(now, q.createdAt ?? q.updatedAt)} min)`);
|
|
35
|
+
const idle = [];
|
|
36
|
+
for (const m of team?.members ?? []) {
|
|
37
|
+
const lc = m.login.toLowerCase();
|
|
38
|
+
const mine = (i) => (i.assignees ?? []).some((a) => a.login.toLowerCase() === lc);
|
|
39
|
+
if (claimed.some(mine)) continue;
|
|
40
|
+
const waiting = ready.filter(mine).length;
|
|
41
|
+
idle.push(`@${m.login}: nothing in progress, ${waiting ? `${waiting} ready task(s) assigned` : 'no ready tasks assigned'}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sections = [
|
|
45
|
+
{ key: 'human', title: 'Needs a human', lines: [...human, ...blocked] },
|
|
46
|
+
{ key: 'stale', title: 'Stale leases', lines: stale },
|
|
47
|
+
{ key: 'prs', title: 'Pull requests', lines: prLines },
|
|
48
|
+
{ key: 'questions', title: 'Open questions between agents', lines: unanswered },
|
|
49
|
+
{ key: 'idle', title: 'Idle members', lines: idle },
|
|
50
|
+
];
|
|
51
|
+
return {
|
|
52
|
+
sections,
|
|
53
|
+
counts: { ready: ready.length, claimed: claimed.length, human: human.length + blocked.length, stale: stale.length, prs: prLines.length, questions: unanswered.length, idle: idle.length },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Markdown for the pinned issue (and the terminal). */
|
|
58
|
+
export function renderDigest(model, { now = Date.now() } = {}) {
|
|
59
|
+
const c = model.counts;
|
|
60
|
+
const stamp = new Date(now).toISOString().replace('T', ' ').slice(0, 16);
|
|
61
|
+
const out = [DIGEST_MARKER, `_Updated ${stamp} UTC by \`crew digest\` (no LLM). Edits here are overwritten._`, '',
|
|
62
|
+
`**${c.ready}** ready · **${c.claimed}** in progress · **${c.human}** need a human · **${c.stale}** stale · **${c.prs}** PRs waiting`, ''];
|
|
63
|
+
// The issue is rewritten every 15 minutes: break @mentions (ours and any in titles) so nobody gets pinged each time.
|
|
64
|
+
const quiet = (l) => l.replace(/@(?=[\w-])/g, '@');
|
|
65
|
+
for (const s of model.sections) out.push(`### ${s.title}`, ...(s.lines.length ? s.lines.map((l) => `- ${quiet(l)}`) : ['- none']), '');
|
|
66
|
+
return out.join('\n');
|
|
67
|
+
}
|
package/src/core/exec.js
ADDED
|
@@ -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');
|