@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,612 @@
1
+ // docs/prd.md -> tasks and epics. Pure functions, no network: parser, linter, ordering, plan, issue bodies.
2
+ // The LLM writes only the PRD with ```crew-task / ```crew-epic blocks, this code does everything else (crew task lint, crew kickoff).
3
+ // Format: skills/crew-kickoff/task-format.md.
4
+ import { createHash } from 'node:crypto';
5
+ import { TIERS, parseMarkers } from './issues.js';
6
+
7
+ export const RISKS = ['low', 'med', 'high'];
8
+ export const DEFAULT_RISK = 'med';
9
+ export const TEST_KINDS = ['unit', 'contract', 'e2e'];
10
+ /** Areas where a mistake is expensive: risk: low is forbidden there. */
11
+ export const HIGH_STAKES_AREAS = ['contracts', 'infra'];
12
+ export const MAX_TASKS = 25;
13
+ export const DEFAULT_PRD = 'docs/prd.md';
14
+
15
+ const TASK_KEYS = ['id', 'title', 'epic', 'area', 'tier', 'risk', 'after', 'paths', 'contract', 'goal', 'acceptance', 'out_of_scope', 'tests', 'req', 'changes_acceptance', 'tests_for', 'skeleton', 'ready', 'independent'];
16
+ /**
17
+ * Areas whose tasks do not wait for the skeleton (no code: mockups, research, docs). At the hackathon the
18
+ * designer idled until the skeleton merged. A task elsewhere can opt out with `independent: true`.
19
+ */
20
+ export const INDEPENDENT_AREAS = ['design', 'research'];
21
+ const EPIC_KEYS = ['id', 'title', 'scenario', 'demo'];
22
+ const TASK_REQUIRED = ['id', 'title', 'area', 'tier', 'goal', 'acceptance'];
23
+ const EPIC_REQUIRED = ['id', 'title', 'scenario'];
24
+ const LIST_KEYS = ['acceptance', 'after', 'paths', 'out_of_scope', 'tests', 'req', 'changes_acceptance'];
25
+ const BOOL_KEYS = ['skeleton', 'ready', 'independent'];
26
+
27
+ const err = (line, id, rule, message) => ({ line, id: id ?? null, rule, message });
28
+
29
+ const unquote = (s) => {
30
+ const t = s.trim();
31
+ return t.length >= 2 && ((t[0] === '"' && t.at(-1) === '"') || (t[0] === "'" && t.at(-1) === "'")) ? t.slice(1, -1) : t;
32
+ };
33
+
34
+ function parseValue(raw) {
35
+ const v = raw.trim();
36
+ if (v.startsWith('[')) {
37
+ if (!v.endsWith(']')) return { error: 'list is not closed: missing "]"' };
38
+ return { value: v.slice(1, -1).split(',').map(unquote).filter((x) => x !== '') };
39
+ }
40
+ return { value: unquote(v) };
41
+ }
42
+
43
+ /** Body of one block (lines without fences) -> { fields, lines, errors }. */
44
+ function parseBlock(kind, body, firstLine) {
45
+ const allowed = kind === 'task' ? TASK_KEYS : EPIC_KEYS;
46
+ const fields = {};
47
+ const lines = {};
48
+ const errors = [];
49
+ let listKey = null;
50
+ body.forEach((text, k) => {
51
+ const lineNo = firstLine + k;
52
+ if (!text.trim() || /^\s*#/.test(text)) return;
53
+ const item = /^\s*-\s+(.*)$/.exec(text) ?? (/^\s*-$/.test(text) ? [text, ''] : null);
54
+ if (item) {
55
+ if (!listKey) return errors.push(err(lineNo, fields.id, 'syntax', 'list item "- ..." without a key above (needs "key:" on its own line)'));
56
+ const v = unquote(item[1].replace(/^\[[ xX]\]\s+/, ''));
57
+ if (v) fields[listKey].push(v);
58
+ return;
59
+ }
60
+ const kv = /^([A-Za-z_][\w-]*)\s*:(?:\s+(.*))?$/.exec(text);
61
+ if (!kv) {
62
+ listKey = null;
63
+ return errors.push(err(lineNo, fields.id, 'syntax', `cannot parse line: "${text.trim()}" (expected "key: value", "key: [a, b]" or " - item")`));
64
+ }
65
+ const key = kv[1];
66
+ listKey = null;
67
+ if (!allowed.includes(key)) return errors.push(err(lineNo, fields.id, 'unknown_key', `unknown key "${key}" in crew-${kind} (allowed: ${allowed.join(', ')})`));
68
+ if (key in fields) return errors.push(err(lineNo, fields.id, 'duplicate_key', `key "${key}" is repeated`));
69
+ lines[key] = lineNo;
70
+ const raw = kv[2]?.trim() ?? '';
71
+ if (raw === '') {
72
+ fields[key] = [];
73
+ listKey = key;
74
+ return;
75
+ }
76
+ const pv = parseValue(raw);
77
+ if (pv.error) return errors.push(err(lineNo, fields.id, 'syntax', `${key}: ${pv.error}`));
78
+ fields[key] = pv.value;
79
+ });
80
+ // Type normalization: lists are always arrays, flags are boolean, the rest are strings.
81
+ for (const [key, v] of Object.entries(fields)) {
82
+ if (LIST_KEYS.includes(key)) {
83
+ if (!Array.isArray(v)) fields[key] = [v];
84
+ } else if (BOOL_KEYS.includes(key)) {
85
+ if (v === 'true' || v === 'false') fields[key] = v === 'true';
86
+ else errors.push(err(lines[key], fields.id, 'type', `${key}: only true or false`));
87
+ } else if (Array.isArray(v)) {
88
+ if (v.length === 0) delete fields[key]; // "key:" without items = empty
89
+ else if (key === 'area') errors.push(err(lines[key], fields.id, 'one_area', 'one task, one area: area must be a single string'));
90
+ else errors.push(err(lines[key], fields.id, 'type', `${key}: expected a single string, not a list`));
91
+ }
92
+ }
93
+ return { fields, lines, errors };
94
+ }
95
+
96
+ /**
97
+ * Markdown -> { tasks, epics, errors }. Only fenced ```crew-task and ```crew-epic blocks count, the rest is text.
98
+ * Block: { kind, line (line of the opening ```), id, fields, lines: { key: line number } }.
99
+ */
100
+ export function parsePrd(text = '') {
101
+ const src = text.split(/\r?\n/);
102
+ const tasks = [];
103
+ const epics = [];
104
+ const errors = [];
105
+ let team = null;
106
+ for (let i = 0; i < src.length; i++) {
107
+ if (/^\s*```crew-team\s*$/.test(src[i])) {
108
+ let end = i + 1;
109
+ while (end < src.length && !/^\s*```\s*$/.test(src[end])) end++;
110
+ if (end >= src.length) {
111
+ errors.push(err(i + 1, null, 'syntax', 'crew-team block is not closed with a ``` line'));
112
+ break;
113
+ }
114
+ if (team) errors.push(err(i + 1, null, 'team_many', `only one crew-team block is allowed (already one on line ${team.line})`));
115
+ else team = parseTeam(src.slice(i + 1, end), i + 2, errors);
116
+ i = end;
117
+ continue;
118
+ }
119
+ const open = /^\s*```crew-(task|epic)\s*$/.exec(src[i]);
120
+ if (!open) continue;
121
+ const kind = open[1];
122
+ let end = i + 1;
123
+ while (end < src.length && !/^\s*```\s*$/.test(src[end])) end++;
124
+ if (end >= src.length) {
125
+ errors.push(err(i + 1, null, 'syntax', `crew-${kind} block is not closed with a \`\`\` line`));
126
+ break;
127
+ }
128
+ const { fields, lines, errors: e } = parseBlock(kind, src.slice(i + 1, end), i + 2);
129
+ errors.push(...e);
130
+ const block = { kind, line: i + 1, id: typeof fields.id === 'string' ? fields.id : null, fields, lines };
131
+ (kind === 'task' ? tasks : epics).push(block);
132
+ i = end;
133
+ }
134
+ return { tasks, epics, errors, team };
135
+ }
136
+
137
+ /** GitHub login: letters, digits, hyphens (no -- and no hyphen at the ends). */
138
+ export const LOGIN_RE = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}$/;
139
+ /** Special "area" in crew-team: this person (their agent) reviews PRs before their own tasks. */
140
+ export const REVIEW_ROLE = 'review';
141
+
142
+ /**
143
+ * Team block: who is responsible for which areas. Lines `login: area1, area2 # role/note`.
144
+ * The `review` area is the team reviewer (usually the captain).
145
+ * -> { line, members: [{ login, areas, review, note, line }] }
146
+ */
147
+ function parseTeam(body, firstLine, errors) {
148
+ const members = [];
149
+ body.forEach((raw, k) => {
150
+ const line = firstLine + k;
151
+ const [main, ...rest] = raw.split('#');
152
+ const note = rest.join('#').trim();
153
+ if (!main.trim()) return;
154
+ const m = /^\s*@?([^\s:]+)\s*:\s*(.*)$/.exec(main);
155
+ if (!m) {
156
+ errors.push(err(line, null, 'team_syntax', `expected "login: area1, area2", not "${raw.trim()}"`));
157
+ return;
158
+ }
159
+ const all = m[2].trim().replace(/^\[|\]$/g, '').split(',').map((a) => unquote(a)).filter(Boolean);
160
+ members.push({ login: m[1], areas: all.filter((a) => a !== REVIEW_ROLE), review: all.includes(REVIEW_ROLE), note, line });
161
+ });
162
+ return { line: firstLine - 1, members };
163
+ }
164
+
165
+ /** Area owner from the crew-team block: { web: 'alice', ... }. */
166
+ export function areaOwners(team) {
167
+ const out = {};
168
+ for (const m of team?.members ?? []) for (const a of m.areas) out[a] ??= m.login;
169
+ return out;
170
+ }
171
+
172
+ /** Team reviewers (review area in crew-team). */
173
+ export const teamReviewers = (team) => (team?.members ?? []).filter((m) => m.review).map((m) => m.login);
174
+
175
+ /** PRD approval header: <!-- crew:prd status: approved by: <login|auto-timeout> at: ISO -->. */
176
+ export const PRD_HEADER_RE = /^\s*<!--\s*(?:crew|hk):prd\s+status:\s*([\w-]+)(?:\s+by:\s*(\S+))?(?:\s+at:\s*(\S+))?\s*-->\s*$/m;
177
+
178
+ export function prdApproval(text = '') {
179
+ const m = PRD_HEADER_RE.exec(text);
180
+ if (!m) return { status: null, by: null, at: null, approved: false };
181
+ return { status: m[1], by: m[2] ?? null, at: m[3] ?? null, approved: m[1] === 'approved' };
182
+ }
183
+
184
+ // Keywords as standalone words (Unicode: \b in JS does not work with Cyrillic).
185
+ const kw = (w) => `(?<![\\p{L}\\p{N}_])${w}(?![\\p{L}\\p{N}_])[\\s:,—-]*\\S`;
186
+ const GWT = [
187
+ new RegExp(`^${kw('given')}.*?${kw('when')}.*?${kw('then')}`, 'iu'),
188
+ new RegExp(`^${kw('дано')}.*?${kw('когда')}.*?${kw('тогда')}`, 'iu'),
189
+ ];
190
+ /** Acceptance criterion: Given ... When ... Then ... or its Russian equivalent (case-insensitive, order matters). */
191
+ export const isAcceptance = (s) => GWT.some((re) => re.test(String(s).trim()));
192
+
193
+ const riskOf = (t) => t.fields.risk ?? DEFAULT_RISK;
194
+ const skeletonOf = (tasks) => tasks.find((t) => t.fields.skeleton === true) ?? null;
195
+
196
+ /**
197
+ * Task dependencies: explicit after + implicit on the skeleton (for all except the skeleton itself)
198
+ * + the task for its acceptance tests (tests_for: <this task>), since tests are written before code.
199
+ */
200
+ export function depsOf(task, skeletonId, tasks = []) {
201
+ const out = [...(task.fields.after ?? [])];
202
+ const independent = task.fields.independent === true || INDEPENDENT_AREAS.includes(task.fields.area);
203
+ if (skeletonId && task.id !== skeletonId && !independent && !out.includes(skeletonId)) out.unshift(skeletonId);
204
+ for (const t of tasks) if (t.fields.tests_for === task.id && t.id && !out.includes(t.id)) out.push(t.id);
205
+ return out;
206
+ }
207
+
208
+ /** Folder where a tests task puts tests for task <id>: code does not see them yet, CI does not run them. */
209
+ export const pendingDir = (id) => `tests/pending/${id}/`;
210
+
211
+ /** Cycle in explicit after -> path ['T2', 'T4', 'T2'] or null. */
212
+ export function findCycle(tasks) {
213
+ const byId = new Map(tasks.filter((t) => t.id).map((t) => [t.id, t]));
214
+ const state = new Map(); // 1 = on the stack, 2 = done
215
+ const stack = [];
216
+ const dfs = (id) => {
217
+ state.set(id, 1);
218
+ stack.push(id);
219
+ for (const d of byId.get(id).fields.after ?? []) {
220
+ if (!byId.has(d)) continue;
221
+ if (state.get(d) === 1) return [...stack.slice(stack.indexOf(d)), d];
222
+ if (!state.get(d)) {
223
+ const c = dfs(d);
224
+ if (c) return c;
225
+ }
226
+ }
227
+ stack.pop();
228
+ state.set(id, 2);
229
+ return null;
230
+ };
231
+ for (const id of byId.keys()) {
232
+ if (!state.get(id)) {
233
+ const c = dfs(id);
234
+ if (c) return c;
235
+ }
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Topological order of tasks (dependency before dependent), ties keep PRD order.
242
+ * Unknown dependencies are ignored (the linter catches them). On a cycle: { order: partial, cycle: [...] }.
243
+ */
244
+ export function topoOrder(tasks) {
245
+ const skel = skeletonOf(tasks)?.id ?? null;
246
+ const ids = new Set(tasks.map((t) => t.id));
247
+ const placed = new Set();
248
+ const order = [];
249
+ let progress = true;
250
+ while (order.length < tasks.length && progress) {
251
+ progress = false;
252
+ for (const t of tasks) {
253
+ if (placed.has(t.id)) continue;
254
+ if (depsOf(t, skel, tasks).every((d) => !ids.has(d) || d === t.id || placed.has(d))) {
255
+ order.push(t);
256
+ placed.add(t.id);
257
+ progress = true;
258
+ break; // restart from the beginning to keep PRD order
259
+ }
260
+ }
261
+ }
262
+ return { order, cycle: order.length < tasks.length ? findCycle(tasks) ?? tasks.filter((t) => !placed.has(t.id)).map((t) => t.id) : null };
263
+ }
264
+
265
+ /** Path inside an area: the static path prefix starts with the static prefix of one of the area's globs. */
266
+ export function pathInArea(path, globs = []) {
267
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '');
268
+ const p = norm(path);
269
+ return globs.some((g0) => {
270
+ const g = norm(g0);
271
+ const wild = g.search(/[*?[{]/);
272
+ if (wild === -1) return p === g || p.startsWith(g.replace(/\/$/, '') + '/');
273
+ const prefix = g.slice(0, wild);
274
+ if (!prefix) return true;
275
+ return p.startsWith(prefix) || (prefix.endsWith('/') && p === prefix.slice(0, -1));
276
+ });
277
+ }
278
+
279
+ /**
280
+ * PRD check. Collects ALL errors at once. -> { errors, warnings, order } (item: { line, id, rule, message }).
281
+ * areas is crew.config.json -> areas ({ web: ['web/**'], ... }).
282
+ */
283
+ export function lintPrd(parsed, { areas = {} } = {}) {
284
+ const errors = [...parsed.errors];
285
+ const warnings = [];
286
+ const { tasks, epics } = parsed;
287
+ if (!tasks.length) errors.push(err(1, null, 'no_blocks', 'the PRD has no ```crew-task block'));
288
+
289
+ const seen = new Map();
290
+ const checkId = (b, re, what) => {
291
+ const id = b.fields.id;
292
+ if (typeof id !== 'string' || !id) return;
293
+ if (!re.test(id)) errors.push(err(b.lines.id, id, 'bad_id', `id "${id}": expected ${what}`));
294
+ else if (seen.has(id)) errors.push(err(b.lines.id, id, 'duplicate_id', `id ${id} already exists (line ${seen.get(id)})`));
295
+ else seen.set(id, b.lines.id);
296
+ };
297
+ const required = (b, keys) => {
298
+ for (const k of keys) {
299
+ const v = b.fields[k];
300
+ if (v === undefined || v === '' || (Array.isArray(v) && !v.length)) errors.push(err(b.line, b.id, 'required', `missing required field "${k}"`));
301
+ }
302
+ };
303
+
304
+ for (const e of epics) {
305
+ checkId(e, /^E\d+$/, 'E<number>, e.g. E1');
306
+ required(e, EPIC_REQUIRED);
307
+ }
308
+ const taskIds = new Set();
309
+ const epicIds = new Set(epics.map((e) => e.id).filter(Boolean));
310
+ for (const t of tasks) {
311
+ checkId(t, /^T\d+$/, 'T<number>, e.g. T2');
312
+ if (t.id) taskIds.add(t.id);
313
+ }
314
+
315
+ const skeletons = tasks.filter((t) => t.fields.skeleton === true);
316
+ if (tasks.length && tasks[0].fields.skeleton !== true) {
317
+ errors.push(err(tasks[0].line, tasks[0].id, 'skeleton_first', 'the first crew-task block must be the skeleton: skeleton: true'));
318
+ }
319
+ for (const s of skeletons.slice(1)) errors.push(err(s.lines.skeleton ?? s.line, s.id, 'skeleton_many', `only one skeleton is allowed (already ${skeletons[0].id})`));
320
+ const skel = skeletons[0] ?? null;
321
+ if (skel) {
322
+ if (skel.fields.tier !== 'high') errors.push(err(skel.lines.tier ?? skel.line, skel.id, 'skeleton_tier', 'the skeleton must be tier: high'));
323
+ if (riskOf(skel) !== 'high') errors.push(err(skel.lines.risk ?? skel.line, skel.id, 'skeleton_risk', 'the skeleton must be risk: high'));
324
+ if ((skel.fields.after ?? []).length) errors.push(err(skel.lines.after, skel.id, 'skeleton_after', 'the skeleton cannot have after: it comes first'));
325
+ }
326
+
327
+ const titles = new Map();
328
+ const byId = new Map(tasks.filter((t) => t.id).map((t) => [t.id, t]));
329
+ const testsFor = new Map(); // target -> tests task
330
+ for (const t of tasks) {
331
+ const f = t.fields;
332
+ const L = (k) => t.lines[k] ?? t.line;
333
+ // A tests task takes its target and criteria from the tests_for task.
334
+ required(t, f.tests_for ? TASK_REQUIRED.filter((k) => !['goal', 'acceptance'].includes(k)) : TASK_REQUIRED);
335
+ if (f.tests_for !== undefined) {
336
+ const target = byId.get(f.tests_for);
337
+ if (f.tests_for === t.id) errors.push(err(L('tests_for'), t.id, 'self_ref', 'tests_for refers to the task itself'));
338
+ else if (!target) errors.push(err(L('tests_for'), t.id, 'unknown_ref', `tests_for: no task ${f.tests_for}`));
339
+ else {
340
+ if (target === skel) errors.push(err(L('tests_for'), t.id, 'tests_for_skeleton', 'the skeleton comes first, so a separate tests task for it is impossible; the skeleton writes its own tests'));
341
+ if (target.fields.tests_for) errors.push(err(L('tests_for'), t.id, 'tests_for_tests', `${f.tests_for} is itself a tests task`));
342
+ if ((f.after ?? []).includes(f.tests_for)) errors.push(err(L('after'), t.id, 'cycle', `tests are written BEFORE ${f.tests_for}, not after: remove ${f.tests_for} from after`));
343
+ if (testsFor.has(f.tests_for)) errors.push(err(L('tests_for'), t.id, 'tests_for_duplicate', `${f.tests_for} already has tests task ${testsFor.get(f.tests_for)}`));
344
+ else testsFor.set(f.tests_for, t.id);
345
+ }
346
+ if (typeof f.area === 'string' && areas[f.area] && !pathInArea(`${pendingDir(f.tests_for)}x`, areas[f.area])) {
347
+ errors.push(err(L('area'), t.id, 'tests_area', `area ${f.area} does not include tests/pending/**: a tests task needs an area with tests (e.g. e2e)`));
348
+ }
349
+ }
350
+ if (typeof f.area === 'string' && f.area) {
351
+ if (!(f.area in areas)) errors.push(err(L('area'), t.id, 'unknown_area', `area "${f.area}" is not in crew.config.json -> areas (${Object.keys(areas).join(', ')})`));
352
+ }
353
+ if (f.tier !== undefined && !TIERS.includes(f.tier)) errors.push(err(L('tier'), t.id, 'enum', `tier: one of ${TIERS.join(', ')}`));
354
+ if (f.risk !== undefined && !RISKS.includes(f.risk)) errors.push(err(L('risk'), t.id, 'enum', `risk: one of ${RISKS.join(', ')}`));
355
+ for (const k of f.tests ?? []) if (!TEST_KINDS.includes(k)) errors.push(err(L('tests'), t.id, 'enum', `tests: "${k}" is not valid, allowed: ${TEST_KINDS.join(', ')}`));
356
+ for (const r of f.req ?? []) if (!/^R\d+$/.test(r)) errors.push(err(L('req'), t.id, 'req_format', `req: "${r}": expected R<number> from docs/brief-analysis.md`));
357
+ for (const p of f.changes_acceptance ?? []) {
358
+ if (!/^tests\/acceptance\/\S+$/.test(p)) errors.push(err(L('changes_acceptance'), t.id, 'changes_acceptance_path', `changes_acceptance: "${p}": expected a path inside tests/acceptance/`));
359
+ }
360
+ for (const a of f.acceptance ?? []) {
361
+ if (!isAcceptance(a)) errors.push(err(L('acceptance'), t.id, 'acceptance_format', `criterion does not match "Given ... When ... Then ..." (or its Russian equivalent): "${a}"`));
362
+ }
363
+ for (const d of f.after ?? []) {
364
+ if (d === t.id) errors.push(err(L('after'), t.id, 'self_ref', 'after refers to the task itself'));
365
+ else if (!taskIds.has(d)) errors.push(err(L('after'), t.id, 'unknown_ref', `after: no task ${d}`));
366
+ }
367
+ if (f.epic !== undefined && !epicIds.has(f.epic)) errors.push(err(L('epic'), t.id, 'unknown_ref', `epic: no epic ${f.epic}`));
368
+ if (f.risk === 'low' && t !== skel && HIGH_STAKES_AREAS.includes(f.area)) {
369
+ errors.push(err(L('risk'), t.id, 'risk_too_low', `risk: low is not allowed for area ${f.area}: minimum is med`));
370
+ }
371
+ if (t !== skel && typeof f.area === 'string' && areas[f.area]) {
372
+ const outside = (f.paths ?? []).filter((p) => !pathInArea(p, areas[f.area]));
373
+ if (outside.length) {
374
+ errors.push(err(L('paths'), t.id, 'paths_outside_area', `one task, one area: paths outside ${f.area} (${areas[f.area].join(', ')}): ${outside.join(', ')}. Only the skeleton may go beyond its area`));
375
+ }
376
+ }
377
+ if (riskOf(t) === 'high' && !(f.tests ?? []).includes('e2e')) {
378
+ warnings.push(err(L('risk'), t.id, 'high_without_e2e', 'risk: high without e2e in tests: E2E is mandatory for high'));
379
+ }
380
+ if (typeof f.title === 'string' && f.title) {
381
+ const key = f.title.trim().toLowerCase();
382
+ if (titles.has(key)) warnings.push(err(L('title'), t.id, 'duplicate_title', `same title as ${titles.get(key)}`));
383
+ else titles.set(key, t.id);
384
+ }
385
+ }
386
+ // risk:high (except the skeleton): acceptance tests are written by another agent, as a separate tests_for task.
387
+ for (const t of tasks) {
388
+ if (t !== skel && !t.fields.tests_for && riskOf(t) === 'high' && t.id && !testsFor.has(t.id)) {
389
+ errors.push(err(t.lines.risk ?? t.line, t.id, 'high_without_tests_task', `risk: high needs a separate acceptance tests task: a crew-task block with tests_for: ${t.id} (tests and code are written by different agents)`));
390
+ }
391
+ }
392
+ const cycle = findCycle(tasks.filter((t) => t.id && taskIds.has(t.id)));
393
+ if (cycle) {
394
+ const first = tasks.find((t) => t.id === cycle[0]);
395
+ errors.push(err(first?.lines.after ?? first?.line ?? 1, cycle[0], 'cycle', `dependency cycle: ${cycle.join(' -> ')}`));
396
+ }
397
+ if (tasks.length > MAX_TASKS) warnings.push(err(1, null, 'too_many_tasks', `${tasks.length} tasks > ${MAX_TASKS}: cut the scope`));
398
+ for (const e of epics) {
399
+ if (e.id && !tasks.some((t) => t.fields.epic === e.id)) warnings.push(err(e.line, e.id, 'empty_epic', `epic ${e.id} contains no tasks`));
400
+ }
401
+ lintTeam(parsed, { areas, errors, warnings });
402
+ errors.sort((a, b) => a.line - b.line);
403
+ return { errors, warnings, order: cycle ? [] : topoOrder(tasks).order.map((t) => t.id) };
404
+ }
405
+
406
+ /** crew-team block: every area with tasks has exactly one owner, every member has work. */
407
+ function lintTeam({ tasks, team }, { areas, errors, warnings }) {
408
+ if (!team) {
409
+ if (tasks.length) warnings.push(err(1, null, 'no_team', 'no crew-team block: tasks will not be assigned to people (who owns which area, who reviews)'));
410
+ return;
411
+ }
412
+ if (!team.members.length) errors.push(err(team.line, null, 'team_empty', 'the crew-team block is empty: lines "login: area1, area2"'));
413
+ const owner = new Map();
414
+ const logins = new Set();
415
+ for (const m of team.members) {
416
+ if (!LOGIN_RE.test(m.login)) errors.push(err(m.line, null, 'team_login', `"${m.login}" does not look like a GitHub login`));
417
+ const key = m.login.toLowerCase();
418
+ if (logins.has(key)) errors.push(err(m.line, null, 'team_duplicate', `${m.login} is already in the crew-team block: put all of a person's areas on one line`));
419
+ logins.add(key);
420
+ if (!m.areas.length && !m.review) errors.push(err(m.line, null, 'team_no_areas', `${m.login} has no areas: at least one (or review)`));
421
+ for (const a of m.areas) {
422
+ if (!(a in areas)) errors.push(err(m.line, null, 'unknown_area', `area "${a}" is not in crew.config.json -> areas (${Object.keys(areas).join(', ')}) or review`));
423
+ else if (owner.has(a)) errors.push(err(m.line, null, 'area_two_owners', `area ${a} already has owner ${owner.get(a)}: an area has one owner`));
424
+ else owner.set(a, m.login);
425
+ }
426
+ }
427
+ if (team.members.length && !team.members.some((m) => m.review)) {
428
+ warnings.push(err(team.line, null, 'no_reviewer', 'crew-team has no reviewer (review area, usually the captain): PRs will wait for a random review'));
429
+ }
430
+ const load = new Map(team.members.map((m) => [m.login, 0]));
431
+ const noOwner = new Map();
432
+ for (const t of tasks) {
433
+ const a = t.fields.area;
434
+ if (typeof a !== 'string' || !(a in areas)) continue;
435
+ if (owner.has(a)) load.set(owner.get(a), load.get(owner.get(a)) + 1);
436
+ else if (!noOwner.has(a)) noOwner.set(a, t);
437
+ }
438
+ for (const [a, t] of noOwner) errors.push(err(t.lines.area ?? t.line, t.id, 'area_no_owner', `area ${a} has no owner in the crew-team block: assign it to someone on the team`));
439
+ for (const m of team.members) {
440
+ if (load.get(m.login) === 0 && !m.review) warnings.push(err(m.line, null, 'member_idle', `${m.login} has no tasks in their areas (${m.areas.join(', ') || '-'}): give them an area with tasks`));
441
+ }
442
+ // Nobody should wait for someone else's task at the start: each member needs a task that is ready right after kickoff.
443
+ const skel = tasks.find((t) => t.fields.skeleton === true);
444
+ const startable = new Map(team.members.map((m) => [m.login, 0]));
445
+ for (const t of tasks) {
446
+ const o = owner.get(t.fields.area);
447
+ // ready at kickoff, or right after the skeleton (which everyone waits for briefly)
448
+ if (o && (t === skel || depsOf(t, skel?.id ?? null, tasks).every((d) => d === skel?.id))) startable.set(o, startable.get(o) + 1);
449
+ }
450
+ for (const m of team.members) {
451
+ if (load.get(m.login) > 0 && startable.get(m.login) === 0) {
452
+ warnings.push(err(m.line, null, 'member_waits', `every task of ${m.login} waits for other people's tasks: add one that can start right away (area ${INDEPENDENT_AREAS.join('/')}, docs, or independent: true)`));
453
+ }
454
+ }
455
+ const counts = [...load.values()].filter((n) => n > 0);
456
+ if (counts.length > 1 && Math.max(...counts) >= 3 && Math.max(...counts) > 2 * Math.min(...counts)) {
457
+ warnings.push(err(team.line, null, 'team_imbalance', `uneven load: ${[...load].map(([l, n]) => `${l} ${n}`).join(', ')}: redistribute areas or tasks`));
458
+ }
459
+ }
460
+
461
+ /** Stable content hash of a block (12 hex): changes when fields change, not whitespace. */
462
+ export function taskHash(block) {
463
+ const canon = Object.fromEntries(Object.keys(block.fields).sort().map((k) => [k, block.fields[k]]));
464
+ return createHash('sha256').update(JSON.stringify({ kind: block.kind, ...canon })).digest('hex').slice(0, 12);
465
+ }
466
+
467
+ const ref = (n) => (n ? `#${n}` : '#?');
468
+ const section = (title, body) => `### ${title}\n\n${body}\n`;
469
+
470
+ /**
471
+ * Task issue body: the same sections as .github/ISSUE_TEMPLATE/task.yml, plus dependencies and markers.
472
+ * issueOf: { T1: 12, E1: 30 } are the numbers of already created issues.
473
+ */
474
+ export function renderTaskBody(task, { issueOf = {}, prd = DEFAULT_PRD, skeletonId = null, epics = [], tasks = [] } = {}) {
475
+ const f = task.fields;
476
+ const deps = depsOf(task, skeletonId, tasks);
477
+ const target = f.tests_for ? tasks.find((t) => t.id === f.tests_for) : null;
478
+ const testsTask = tasks.find((t) => t.fields.tests_for === task.id) ?? null;
479
+ const parts = [section('Goal', f.goal ?? (f.tests_for ? `Acceptance tests for ${f.tests_for}${target?.fields.title ? ` - ${target.fields.title}` : ''}` : ''))];
480
+ const criteria = f.tests_for && !(f.acceptance ?? []).length ? target?.fields.acceptance ?? [] : f.acceptance ?? [];
481
+ parts.push(section(f.tests_for ? `Criteria of task ${f.tests_for}: cover each with a test` : 'Acceptance criteria', criteria.map((a) => `- [ ] ${a}`).join('\n')));
482
+ if (f.tests_for) {
483
+ parts.push(section('Tests task', [
484
+ `Write acceptance tests for ${ref(issueOf[f.tests_for])} (${f.tests_for}) **only** in \`${pendingDir(f.tests_for)}\`. Do not write product code.`,
485
+ 'The tests must fail on code without the implementation and pass once the criteria are met. CI does not run them yet.',
486
+ `The code for ${f.tests_for} is written by **another agent**: they will move these files to \`tests/acceptance/\` unchanged (crew-guard checks this).`,
487
+ ].join('\n')));
488
+ }
489
+ if (testsTask) {
490
+ parts.push(section('Acceptance tests', [
491
+ `Written by a separate task ${ref(issueOf[testsTask.id])} (${testsTask.id}) by another agent, before you. You do not take it.`,
492
+ `Move them unchanged in the first commit: \`git mv ${pendingDir(task.id)}* tests/acceptance/\`. Do not edit these tests: the PR will not pass crew-guard.`,
493
+ ].join('\n')));
494
+ }
495
+ if (f.contract) parts.push(section('Contract', f.contract));
496
+ if (f.paths?.length) parts.push(section('Allowed paths', f.paths.map((p) => `\`${p}\``).join(', ')));
497
+ if (f.out_of_scope?.length) parts.push(section('Out of scope', f.out_of_scope.map((x) => `- ${x}`).join('\n')));
498
+ parts.push(section('Risk', riskOf(task)));
499
+ if (f.tests?.length) parts.push(section('Required tests', f.tests.map((x) => `- [ ] ${x}`).join('\n')));
500
+ if (f.req?.length) parts.push(section('Brief requirements', f.req.map((r) => `- ${r}`).join('\n')));
501
+ // Read by crew guard: the task's PR may change only the existing acceptance tests listed here.
502
+ if (f.changes_acceptance?.length) parts.push(section('Changes acceptance', f.changes_acceptance.map((p) => `- \`${p}\``).join('\n')));
503
+ if (f.epic) {
504
+ const e = epics.find((x) => x.id === f.epic);
505
+ parts.push(section('Epic', `${issueOf[f.epic] ? `#${issueOf[f.epic]} ` : ''}${f.epic}${e?.fields.title ? ` - ${e.fields.title}` : ''}`));
506
+ }
507
+ if (deps.length) parts.push(section('Dependencies', `After ${deps.map((d) => ref(issueOf[d])).join(', ')} (${deps.join(', ')})`));
508
+ if (f.ready === false) parts.push('> ⚠ Marked `ready: false` in the PRD: the spec needs more work, agents do not take it.\n');
509
+ const markers = [`<!-- crew:task id=${task.id} hash=${taskHash(task)} prd=${prd} -->`];
510
+ if (deps.length) markers.push(`<!-- crew:after issues=${deps.map((d) => issueOf[d] ?? '?').join(',')} -->`);
511
+ // Read by crew guard (what may be touched) and crew task claim (tests and code are by different agents).
512
+ if (f.tests_for) markers.push(`<!-- crew:tests-for task=${f.tests_for} -->`);
513
+ if (testsTask) markers.push(`<!-- crew:tests issue=${issueOf[testsTask.id] ?? '?'} task=${testsTask.id} -->`);
514
+ return `${parts.join('\n')}\n${markers.join('\n')}\n`;
515
+ }
516
+
517
+ /** Epic body: scenario, demo, task list with numbers, marker. */
518
+ export function renderEpicBody(epic, tasks, { issueOf = {}, prd = DEFAULT_PRD } = {}) {
519
+ const members = tasks.filter((t) => t.fields.epic === epic.id);
520
+ const parts = [section('User scenario', epic.fields.scenario ?? '')];
521
+ parts.push(section('Tasks', members.length ? members.map((t) => `- [ ] ${ref(issueOf[t.id])} ${t.fields.title} (${t.id})`).join('\n') : '-'));
522
+ if (epic.fields.demo) parts.push(section('How to demo', epic.fields.demo));
523
+ return `${parts.join('\n')}\n<!-- crew:epic id=${epic.id} hash=${epicHash(epic, members)} prd=${prd} -->\n`;
524
+ }
525
+
526
+ const epicHash = (epic, members) => taskHash({ kind: 'epic', fields: { ...epic.fields, tasks: members.map((t) => t.id) } });
527
+
528
+ /** Task labels. Status: ready: false -> needs-spec; open dependencies -> waiting; otherwise ready. */
529
+ export function taskLabels(task, { waiting }) {
530
+ const status = task.fields.ready === false ? 'needs-spec' : waiting ? 'waiting' : 'ready';
531
+ return [`status:${status}`, `area:${task.fields.area}`, `tier:${task.fields.tier}`, `risk:${riskOf(task)}`];
532
+ }
533
+
534
+ /** crew:task / crew:epic markers in the bodies of existing issues -> Map(id -> { number, hash, state, prd }). */
535
+ export function indexExisting(existing = []) {
536
+ const tasks = new Map();
537
+ const epics = new Map();
538
+ const dropped = [];
539
+ for (const i of existing) {
540
+ // Closed as "not planned" (the plan was replaced): the task is gone, so create it anew.
541
+ if (String(i.state ?? '').toUpperCase() === 'CLOSED' && String(i.stateReason ?? '').toUpperCase() === 'NOT_PLANNED') {
542
+ for (const m of parseMarkers([{ body: i.body ?? '' }])) if (m.id && (m.kind === 'task' || m.kind === 'epic')) dropped.push({ id: m.id, number: i.number });
543
+ continue;
544
+ }
545
+ for (const m of parseMarkers([{ body: i.body ?? '' }])) {
546
+ const entry = { number: i.number, hash: m.hash ?? null, prd: m.prd ?? null, state: String(i.state ?? 'OPEN').toUpperCase(), title: i.title };
547
+ if (m.kind === 'task' && m.id && !tasks.has(m.id)) tasks.set(m.id, entry);
548
+ if (m.kind === 'epic' && m.id && !epics.has(m.id)) epics.set(m.id, entry);
549
+ }
550
+ }
551
+ return { tasks, epics, dropped };
552
+ }
553
+
554
+ /**
555
+ * Kickoff plan (pure function). existing is the repo's issues: [{ number, title, body, labels, state }].
556
+ * -> { ops, warnings, map }. ops in execution order: tasks topologically, epics last.
557
+ * op: 'create' | 'skip' (marker exists, same block) | 'changed' (marker exists, block changed: report only)
558
+ * | 'orphan' (issue with a marker that is no longer in the PRD: report only).
559
+ * No syncing: existing issues are not changed (rerun is idempotent).
560
+ */
561
+ export function planKickoff(parsed, existing = [], { prd = DEFAULT_PRD } = {}) {
562
+ const { tasks: have, epics: haveEpics, dropped } = indexExisting(existing);
563
+ const skel = skeletonOf(parsed.tasks)?.id ?? null;
564
+ const owners = areaOwners(parsed.team);
565
+ const ops = [];
566
+ const warnings = [];
567
+ const map = {};
568
+ for (const t of topoOrder(parsed.tasks).order) {
569
+ const deps = depsOf(t, skel, parsed.tasks);
570
+ const found = have.get(t.id);
571
+ if (found) {
572
+ map[t.id] = found.number;
573
+ const changed = found.hash && found.hash !== taskHash(t);
574
+ ops.push({ op: changed ? 'changed' : 'skip', kind: 'task', id: t.id, title: t.fields.title, number: found.number, after: deps });
575
+ if (changed) warnings.push(found.state === 'CLOSED'
576
+ ? `${t.id} is already done in closed #${found.number}, but the PRD block differs: give the task a new id (T...) for new work`
577
+ : `${t.id} (#${found.number}) changed in the PRD after the issue was created: leaving the issue alone, edit it by hand`);
578
+ continue;
579
+ }
580
+ map[t.id] = null;
581
+ // The dependency is already closed (repeat kickoff): nothing to wait for.
582
+ const waiting = deps.some((d) => have.get(d)?.state !== 'CLOSED');
583
+ const assignee = owners[t.fields.area] ?? null;
584
+ ops.push({ op: 'create', kind: 'task', id: t.id, title: t.fields.title, labels: taskLabels(t, { waiting }), after: deps, ...(assignee ? { assignee } : {}) });
585
+ }
586
+ for (const e of parsed.epics) {
587
+ const found = haveEpics.get(e.id);
588
+ const members = parsed.tasks.filter((t) => t.fields.epic === e.id).map((t) => t.id);
589
+ if (found) {
590
+ map[e.id] = found.number;
591
+ const changed = found.hash && found.hash !== epicHash(e, parsed.tasks.filter((t) => t.fields.epic === e.id));
592
+ ops.push({ op: changed ? 'changed' : 'skip', kind: 'epic', id: e.id, title: e.fields.title, number: found.number, tasks: members });
593
+ if (changed) warnings.push(`epic ${e.id} (#${found.number}) changed in the PRD: leaving the issue alone, edit it by hand`);
594
+ continue;
595
+ }
596
+ map[e.id] = null;
597
+ ops.push({ op: 'create', kind: 'epic', id: e.id, title: e.fields.title, labels: ['epic'], tasks: members });
598
+ }
599
+ const ids = new Set([...parsed.tasks, ...parsed.epics].map((b) => b.id));
600
+ for (const d of dropped) {
601
+ if (ids.has(d.id) && !have.has(d.id) && !haveEpics.has(d.id)) warnings.push(`${d.id}: the old #${d.number} was closed as "not planned": creating a new issue`);
602
+ }
603
+ for (const [id, found] of [...have, ...haveEpics]) {
604
+ if (ids.has(id) || (found.prd && found.prd !== prd)) continue;
605
+ ops.push({ op: 'orphan', kind: id.startsWith('E') ? 'epic' : 'task', id, title: found.title, number: found.number });
606
+ warnings.push(`${id} (#${found.number}) is in issues but not in the PRD: leaving the issue alone, close it by hand if not needed`);
607
+ }
608
+ return { ops, warnings, map };
609
+ }
610
+
611
+ /** Short linter error text for a human/agent. */
612
+ export const formatLintItem = (e) => `line ${e.line}${e.id ? ` [${e.id}]` : ''} ${e.rule}: ${e.message}`;
@@ -0,0 +1,52 @@
1
+ // Run external commands with a timeout that kills the WHOLE process tree.
2
+ // spawnSync with timeout only kills the direct child (cmd.exe / sh), while grandchildren (npm test, dev server,
3
+ // claude under cmd /c) keep the pipes open: spawnSync hangs and the processes stay alive.
4
+ // So the synchronous call goes through supervisor.js: it launches the command itself, kills the
5
+ // tree on timeout and exits, so the pipes to us are guaranteed to close.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const SUPERVISOR = fileURLToPath(new URL('./supervisor.js', import.meta.url));
10
+ const MAX_BUFFER = 64 * 1024 * 1024;
11
+
12
+ /** Kill a process along with its children. On POSIX the process must be started with detached: true (own group). */
13
+ export function killTree(child, { graceMs = 10_000 } = {}) {
14
+ if (!child?.pid) return;
15
+ if (process.platform === 'win32') {
16
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true });
17
+ return;
18
+ }
19
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* already exited */ } }
20
+ setTimeout(() => {
21
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already exited */ }
22
+ }, graceMs);
23
+ }
24
+
25
+ /**
26
+ * Run a command synchronously with a timeout covering the whole process tree.
27
+ * @param {{cmd: string, args?: string[], shell?: boolean, cwd?: string, env?: object, input?: string, timeoutMs?: number}} spec
28
+ * @returns {{code: number, stdout: string, stderr: string, timedOut: boolean, error: string|null}}
29
+ */
30
+ export function runSupervised({ cmd, args = [], shell = false, cwd, env, input, timeoutMs }) {
31
+ const job = JSON.stringify({ cmd, args, shell, cwd, input: input ?? null, timeoutMs: timeoutMs ?? null });
32
+ const r = spawnSync(process.execPath, [SUPERVISOR], {
33
+ input: job,
34
+ env: env ?? process.env,
35
+ encoding: 'utf8',
36
+ windowsHide: true,
37
+ maxBuffer: MAX_BUFFER * 2 + 1024 * 1024,
38
+ // safety net in case the supervisor itself hangs
39
+ timeout: timeoutMs ? timeoutMs + 30_000 : undefined,
40
+ });
41
+ try {
42
+ return JSON.parse(r.stdout);
43
+ } catch {
44
+ return {
45
+ code: r.status ?? -1,
46
+ stdout: '',
47
+ stderr: r.stderr ?? '',
48
+ timedOut: r.error?.code === 'ETIMEDOUT',
49
+ error: r.error ? r.error.code ?? r.error.message : 'supervisor: no result',
50
+ };
51
+ }
52
+ }