bmad-plus 0.22.0 → 0.23.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 (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +23 -13
  3. package/SECURITY.md +1 -1
  4. package/package.json +4 -1
  5. package/readme-international/README.de.md +23 -13
  6. package/readme-international/README.es.md +23 -13
  7. package/readme-international/README.fr.md +23 -13
  8. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
  9. package/src/bmad-plus/data/role-triggers.yaml +34 -0
  10. package/src/bmad-plus/module-help.csv +2 -0
  11. package/src/bmad-plus/module.yaml +21 -0
  12. package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
  13. package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
  14. package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
  15. package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
  16. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +7 -0
  17. package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
  18. package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
  19. package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
  20. package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
  21. package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
  22. package/tools/build/adapters.config.js +2 -0
  23. package/tools/build/generate-adapters.js +25 -1
  24. package/tools/build/generate.js +269 -7
  25. package/tools/build/intake-contract.js +522 -0
  26. package/tools/cli/bmad-plus-cli.js +2 -0
  27. package/tools/cli/commands/install.js +28 -1
  28. package/tools/cli/commands/intake.js +124 -0
  29. package/tools/cli/commands/swarm.js +119 -0
  30. package/tools/cli/commands/uninstall.js +17 -0
  31. package/tools/cli/i18n.js +20 -0
  32. package/tools/cli/lib/intake.js +202 -0
  33. package/tools/cli/lib/pack-copy.js +95 -5
  34. package/tools/cli/lib/packs.js +97 -7
  35. package/tools/cli/lib/swarm-deal.js +116 -0
  36. package/tools/cli/lib/swarm-defaults.js +6 -0
  37. package/tools/cli/lib/swarm-tally.js +195 -0
  38. package/tools/cli/lib/swarm-validate.js +589 -0
  39. package/tools/cli/lib/swarm.js +712 -0
  40. package/tools/cli/lib/update-transaction.js +8 -4
@@ -0,0 +1,712 @@
1
+ /**
2
+ * Swarm runtime: freezes a definition and its inputs into a run, deals the cards, renders one
3
+ * brief per unit, files and checks the answers, tallies them. Bookkeeping only: never a model
4
+ * call, never a network call, never a child process. Writes stay under .bmad-plus/swarm/.
5
+ */
6
+ 'use strict';
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const crypto = require('node:crypto');
11
+ const yaml = require('js-yaml');
12
+ const { safeTarget } = require('../../build/generate-adapters');
13
+ const contract = require('./swarm-validate');
14
+ const { dealCards, listCards } = require('./swarm-deal');
15
+ const { tally: computeTally, renderTally } = require('./swarm-tally');
16
+ const { SWARM_DEFAULTS } = require('./swarm-defaults');
17
+
18
+ const BASE = '.bmad-plus/swarm';
19
+ const RUNS = BASE + '/runs';
20
+ const REPORTS = BASE + '/reports';
21
+ const STATE_TREE = '.bmad-plus';
22
+ const CONFIG_FILE = '_bmad/config.yaml';
23
+ const MAX_INPUT_BYTES = 16 * 1024 * 1024;
24
+ const MAX_REPLY_BYTES = 1024 * 1024;
25
+ const MAX_REPLY_CANDIDATES = 20;
26
+ const MAX_REPLY_SCAN_FACTOR = 2;
27
+ const QUOTE = '"'.charCodeAt(0);
28
+ const BACKSLASH = '\\'.charCodeAt(0);
29
+ const OPEN_BRACE = '{'.charCodeAt(0);
30
+ const CLOSE_BRACE = '}'.charCodeAt(0);
31
+ const MAX_SEED = 2 ** 31 - 1;
32
+ const MAX_LIMIT = 1000;
33
+ const RUN_ID = /^[a-z0-9][a-z0-9-]{0,63}-[0-9]{8}-[a-z0-9]{4}$/;
34
+ const RUN_SUFFIX_LENGTH = '-yyyymmdd-abcd'.length;
35
+ const RESERVED_RUN_FILES = [
36
+ 'run.json',
37
+ 'cards.json',
38
+ 'definition.json',
39
+ 'template.md',
40
+ 'nexus-plan.json',
41
+ 'tally.json',
42
+ 'tally.md',
43
+ ];
44
+ const RUN_DIRECTORIES = ['answers', 'briefs', 'context', 'checks'];
45
+ const STATUSES = ['needs-approval', 'ready', 'briefed', 'tallied'];
46
+ const SUFFIX_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
47
+
48
+ const now = () => new Date().toISOString();
49
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
50
+ const fail = (message) => {
51
+ throw new Error('Swarm: ' + message);
52
+ };
53
+ const runDir = (runId) => RUNS + '/' + runId;
54
+ const runFile = (runId, ...parts) => [runDir(runId), ...parts].join('/');
55
+
56
+ function rootOf(directory) {
57
+ const resolved = path.resolve(directory);
58
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory())
59
+ fail('Project directory does not exist: ' + resolved);
60
+ return fs.realpathSync.native(resolved);
61
+ }
62
+
63
+ /** A project-relative path that stays inside the project, follows no link and never reaches .git. */
64
+ function projectTarget(root, file, label = 'Path') {
65
+ contract.portablePath(file, label);
66
+ if (file.split('/').some((part) => part.toLowerCase() === '.git'))
67
+ fail(label + ' may not reach .git: ' + file);
68
+ try {
69
+ return safeTarget(root, file);
70
+ } catch (error) {
71
+ return fail(label + ' refused: ' + file + ' (' + error.message + ')');
72
+ }
73
+ }
74
+
75
+ /** A file the user names: never one the runtime or Nexus keeps under .bmad-plus/. */
76
+ function userFile(file, label) {
77
+ contract.portablePath(file, label);
78
+ if (file.split('/')[0].toLowerCase() === STATE_TREE)
79
+ fail(label + ' may not come from the BMAD+ state tree: ' + file);
80
+ return file;
81
+ }
82
+
83
+ function readInput(root, file, label, limit = MAX_INPUT_BYTES) {
84
+ const target = projectTarget(root, file, label);
85
+ let stat;
86
+ try {
87
+ stat = fs.lstatSync(target);
88
+ } catch {
89
+ return fail(label + ' does not exist: ' + file);
90
+ }
91
+ if (!stat.isFile()) fail(label + ' is not a regular file: ' + file);
92
+ if (stat.size > limit) fail(label + ' exceeds ' + limit + ' bytes: ' + file);
93
+ const bytes = fs.readFileSync(target);
94
+ return { content: bytes.toString('utf8'), sha256: sha256(bytes) };
95
+ }
96
+
97
+ function readJsonInput(root, file, label) {
98
+ const { content, sha256: digest } = readInput(root, file, label);
99
+ try {
100
+ return { value: JSON.parse(content), sha256: digest };
101
+ } catch (error) {
102
+ return fail(label + ' is not valid JSON: ' + file + ' (' + error.message + ')');
103
+ }
104
+ }
105
+
106
+ function writeRunFile(root, file, content) {
107
+ if (!file.startsWith(BASE + '/')) fail('refusing to write outside ' + BASE + ': ' + file);
108
+ const target = projectTarget(root, file, 'Run file');
109
+ fs.mkdirSync(path.dirname(target), { recursive: true });
110
+ const temporaryFile = file + '.' + crypto.randomUUID() + '.tmp';
111
+ const temporary = projectTarget(root, temporaryFile, 'Run file');
112
+ let fd;
113
+ try {
114
+ fd = fs.openSync(temporary, 'wx', 0o600);
115
+ fs.writeFileSync(fd, content);
116
+ fs.fsyncSync(fd);
117
+ fs.closeSync(fd);
118
+ fd = undefined;
119
+ projectTarget(root, file, 'Run file');
120
+ fs.renameSync(temporary, target);
121
+ } finally {
122
+ if (fd !== undefined) fs.closeSync(fd);
123
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
124
+ }
125
+ return target;
126
+ }
127
+
128
+ const writeJson = (root, file, value) =>
129
+ writeRunFile(root, file, JSON.stringify(value, null, 2) + '\n');
130
+
131
+ function readConfig(root) {
132
+ const target = projectTarget(root, CONFIG_FILE, 'Configuration');
133
+ if (!fs.existsSync(target)) return { ...SWARM_DEFAULTS };
134
+ const { content } = readInput(root, CONFIG_FILE, 'Configuration', 1024 * 1024);
135
+ let config;
136
+ try {
137
+ config = yaml.load(content);
138
+ } catch (error) {
139
+ fail(CONFIG_FILE + ' is not valid YAML: ' + error.message);
140
+ }
141
+ if (config === null || config === undefined) return { ...SWARM_DEFAULTS };
142
+ if (!contract.isObject(config)) fail(CONFIG_FILE + ' must be a YAML mapping.');
143
+ if (config.swarm === undefined) return { ...SWARM_DEFAULTS };
144
+ const swarm = config.swarm;
145
+ if (!contract.isObject(swarm)) fail(CONFIG_FILE + ': swarm must be a mapping.');
146
+ const result = { ...SWARM_DEFAULTS };
147
+ for (const key of Object.keys(swarm)) {
148
+ if (key === 'budget' || key === 'wave') {
149
+ if (!Number.isInteger(swarm[key]) || swarm[key] < 1 || swarm[key] > MAX_LIMIT)
150
+ fail(CONFIG_FILE + ': swarm.' + key + ' must be an integer from 1 to ' + MAX_LIMIT + '.');
151
+ result[key] = swarm[key];
152
+ } else if (key === 'media_credits') {
153
+ if (swarm[key] !== 'ask') fail(CONFIG_FILE + ': swarm.media_credits must be "ask".');
154
+ } else fail(CONFIG_FILE + ': swarm has an unknown key "' + key + '".');
155
+ }
156
+ return result;
157
+ }
158
+
159
+ function count(value, label, min, max) {
160
+ const text = String(value);
161
+ if (!/^\d{1,10}$/.test(text) || Number(text) < min || Number(text) > max)
162
+ fail(label + ' must be an integer from ' + min + ' to ' + max + '.');
163
+ return Number(text);
164
+ }
165
+
166
+ function parseContexts(entries) {
167
+ const contexts = {};
168
+ for (const entry of entries ?? []) {
169
+ const at = String(entry).indexOf('=');
170
+ if (at < 1) fail('A context is written NAME=FILE: ' + entry);
171
+ const name = entry.slice(0, at);
172
+ if (Object.hasOwn(contexts, name)) fail('Context "' + name + '" is given twice.');
173
+ contexts[name] = entry.slice(at + 1);
174
+ }
175
+ return contexts;
176
+ }
177
+
178
+ const approvalHint = ({ n, budget, seed }) =>
179
+ `The run needs ${n} sub-agents and the budget is ${budget}: ask the user, then re-run init with --approved${seed === null ? '' : ` --seed ${seed}`}.`;
180
+
181
+ function newRunId(root, definitionId) {
182
+ const day = now().slice(0, 10).replace(/-/g, '');
183
+ for (let attempt = 0; attempt < 16; attempt += 1) {
184
+ let suffix = '';
185
+ for (let k = 0; k < 4; k += 1) suffix += SUFFIX_ALPHABET[crypto.randomInt(36)];
186
+ const id = definitionId + '-' + day + '-' + suffix;
187
+ if (!fs.existsSync(path.join(root, runDir(id)))) return id;
188
+ }
189
+ return fail('could not allocate a run id for ' + definitionId + '.');
190
+ }
191
+
192
+ function init(directory, options = {}) {
193
+ const root = rootOf(directory);
194
+ const config = readConfig(root);
195
+ if (!options.definition) fail('init needs --definition FILE.');
196
+ const definitionFile = readJsonInput(
197
+ root,
198
+ userFile(options.definition, 'Definition'),
199
+ 'Definition'
200
+ );
201
+ const definition = contract.normalizeDefinition(definitionFile.value);
202
+ if (definition.kind === 'fanout' && definition.id.length + RUN_SUFFIX_LENGTH > 64)
203
+ fail('A fanout definition id must leave room for a Nexus run id: at most 50 characters.');
204
+ const templatePath = path.posix.normalize(
205
+ path.posix.join(path.posix.dirname(options.definition), definition.brief.template)
206
+ );
207
+ const templateFile = readInput(root, templatePath, 'Brief template');
208
+ const supplied = parseContexts(options.context);
209
+ for (const name of Object.keys(supplied))
210
+ if (!definition.brief.context.includes(name))
211
+ fail('Context "' + name + '" is not declared by the definition.');
212
+ const contexts = {};
213
+ for (const name of definition.brief.context) {
214
+ if (!Object.hasOwn(supplied, name)) fail('The definition needs --context ' + name + '=FILE.');
215
+ contexts[name] = {
216
+ path: supplied[name],
217
+ ...readInput(root, userFile(supplied[name], 'Context ' + name), 'Context ' + name),
218
+ };
219
+ }
220
+
221
+ const { kind } = definition;
222
+ let cards;
223
+ let seed = null;
224
+ let profileFile = null;
225
+ let unitsFile = null;
226
+ if (kind === 'panel') {
227
+ if (!options.profile) fail('A panel needs --profile FILE.');
228
+ if (options.units) fail('A panel takes --profile, not --units.');
229
+ if (options.n === undefined) fail('A panel needs --n, the number of units to deal.');
230
+ profileFile = readJsonInput(root, userFile(options.profile, 'Profile'), 'Profile');
231
+ const profile = contract.normalizeProfile(profileFile.value);
232
+ const n = count(options.n, 'n', 1, contract.MAX_UNITS);
233
+ seed =
234
+ options.seed === undefined
235
+ ? crypto.randomInt(0, MAX_SEED + 1)
236
+ : count(options.seed, 'seed', 0, MAX_SEED);
237
+ cards = dealCards(profile, n, seed, definition.units.idPrefix);
238
+ } else {
239
+ if (options.profile) fail('Only a panel takes --profile.');
240
+ if (options.n !== undefined)
241
+ fail('A ' + kind + ' has as many units as it lists; --n does not apply.');
242
+ if (options.seed !== undefined) fail('A seed only applies to a dealt panel.');
243
+ if (kind === 'jury') {
244
+ if (options.units) fail('A jury lists its units in the definition, not in --units.');
245
+ cards = listCards(definition.units.list, definition.units.idPrefix);
246
+ } else {
247
+ if (!options.units) fail('A fanout needs --units FILE.');
248
+ unitsFile = readJsonInput(root, userFile(options.units, 'Units'), 'Units');
249
+ cards = contract.normalizeUnits(unitsFile.value);
250
+ }
251
+ }
252
+ contract.checkTemplate(templateFile.content, cards, definition.brief.context);
253
+ for (const field of definition.tally.groupBy) {
254
+ const missing = cards.find((card) => !Object.hasOwn(card, field));
255
+ if (missing)
256
+ fail('Tally groupBy field "' + field + '" is absent from unit ' + missing.id + '.');
257
+ }
258
+
259
+ const n = cards.length;
260
+ const approved = options.approved === true;
261
+ const needsApproval = n > config.budget && !approved;
262
+ const runId = newRunId(root, definition.id);
263
+ const stamp = now();
264
+ const run = {
265
+ schemaVersion: 1,
266
+ id: runId,
267
+ kind,
268
+ pack: definition.pack,
269
+ title: definition.title,
270
+ definition: { path: options.definition, sha256: definitionFile.sha256 },
271
+ template: { path: templatePath, sha256: templateFile.sha256 },
272
+ profile: profileFile ? { path: options.profile, sha256: profileFile.sha256 } : null,
273
+ units: unitsFile ? { path: options.units, sha256: unitsFile.sha256 } : null,
274
+ contexts: Object.fromEntries(
275
+ Object.entries(contexts).map(([name, file]) => [
276
+ name,
277
+ { path: file.path, sha256: file.sha256 },
278
+ ])
279
+ ),
280
+ n,
281
+ seed,
282
+ wave: config.wave,
283
+ budget: config.budget,
284
+ mediaCredits: config.media_credits,
285
+ approval: approved ? 'user' : needsApproval ? 'pending' : 'not-needed',
286
+ status: needsApproval ? 'needs-approval' : 'ready',
287
+ createdAt: stamp,
288
+ updatedAt: stamp,
289
+ };
290
+ writeJson(root, runFile(runId, 'definition.json'), definition);
291
+ writeRunFile(root, runFile(runId, 'template.md'), templateFile.content);
292
+ writeJson(root, runFile(runId, 'cards.json'), cards);
293
+ for (const [name, file] of Object.entries(contexts))
294
+ writeRunFile(root, runFile(runId, 'context', name + '.md'), file.content);
295
+ writeJson(root, runFile(runId, 'run.json'), run);
296
+ return {
297
+ schemaVersion: 1,
298
+ status: run.status,
299
+ run: runId,
300
+ kind,
301
+ n,
302
+ seed,
303
+ budget: config.budget,
304
+ wave: config.wave,
305
+ approval: run.approval,
306
+ units: cards.map((card) => card.id),
307
+ directory: path.join(root, runDir(runId)),
308
+ next: needsApproval ? approvalHint(run) : 'bmad-plus swarm briefs ' + runId,
309
+ };
310
+ }
311
+
312
+ function loadRun(directory, runId) {
313
+ const root = rootOf(directory);
314
+ if (typeof runId !== 'string' || !RUN_ID.test(runId))
315
+ fail('A run id reads <definition>-<yyyymmdd>-<4 characters>.');
316
+ const run = readJsonInput(root, runFile(runId, 'run.json'), 'Run').value;
317
+ if (
318
+ !contract.isObject(run) ||
319
+ run.schemaVersion !== 1 ||
320
+ run.id !== runId ||
321
+ !STATUSES.includes(run.status)
322
+ )
323
+ fail('Run record is not usable: ' + runId);
324
+ const definition = contract.normalizeDefinition(
325
+ readJsonInput(root, runFile(runId, 'definition.json'), 'Run definition').value
326
+ );
327
+ const cards = readJsonInput(root, runFile(runId, 'cards.json'), 'Run cards').value;
328
+ if (
329
+ !Array.isArray(cards) ||
330
+ !cards.length ||
331
+ cards.some((card) => !contract.isObject(card) || !contract.UNIT_ID.test(String(card.id)))
332
+ )
333
+ fail('Run cards are not usable: ' + runId);
334
+ const template = readInput(root, runFile(runId, 'template.md'), 'Run template').content;
335
+ const contexts = {};
336
+ for (const name of definition.brief.context)
337
+ contexts[name] = readInput(
338
+ root,
339
+ runFile(runId, 'context', name + '.md'),
340
+ 'Run context'
341
+ ).content;
342
+ return { root, run, definition, cards, template, contexts };
343
+ }
344
+
345
+ function saveRun(state, changes) {
346
+ Object.assign(state.run, changes, { updatedAt: now() });
347
+ writeJson(state.root, runFile(state.run.id, 'run.json'), state.run);
348
+ }
349
+
350
+ const answerFile = (state, unitId) => runFile(state.run.id, 'answers', unitId + '.json');
351
+ const absolute = (state, file) => path.join(state.root, file);
352
+
353
+ /** The run-tree file at `file` when every ancestor is a plain directory inside the project. */
354
+ function runTarget(state, file) {
355
+ try {
356
+ return { target: projectTarget(state.root, file, 'Run file') };
357
+ } catch (error) {
358
+ return { refused: error.message.replace(/^Swarm: /, '') };
359
+ }
360
+ }
361
+
362
+ function answerState(state, unitId) {
363
+ const { target, refused } = runTarget(state, answerFile(state, unitId));
364
+ if (refused) return { state: 'invalid', reason: 'the answer path is refused: ' + refused };
365
+ let stat;
366
+ try {
367
+ stat = fs.lstatSync(target);
368
+ } catch (error) {
369
+ if (error.code === 'ENOENT') return { state: 'missing', reason: 'no answer file' };
370
+ throw error;
371
+ }
372
+ if (!stat.isFile()) return { state: 'invalid', reason: 'the answer is not a regular file' };
373
+ if (stat.size > MAX_INPUT_BYTES) return { state: 'invalid', reason: 'the answer exceeds 16 MiB' };
374
+ let value;
375
+ try {
376
+ value = JSON.parse(fs.readFileSync(target, 'utf8'));
377
+ } catch {
378
+ return { state: 'invalid', reason: 'the answer is not valid JSON' };
379
+ }
380
+ const problems = contract.answerProblems(state.definition, unitId, value);
381
+ return problems.length
382
+ ? { state: 'invalid', reason: problems.join('; ') }
383
+ : { state: 'valid', reason: null, answer: value };
384
+ }
385
+
386
+ function writeBrief(state, card) {
387
+ // The sub-agent writes its answer with the host's own tools, which may not create parents.
388
+ fs.mkdirSync(absolute(state, runFile(state.run.id, 'answers')), { recursive: true });
389
+ const file = runFile(state.run.id, 'briefs', card.id + '.md');
390
+ const content = contract.renderBrief(state.template, {
391
+ card,
392
+ contexts: state.contexts,
393
+ answerPath: absolute(state, answerFile(state, card.id)),
394
+ runId: state.run.id,
395
+ disclosure: state.definition.disclosure,
396
+ });
397
+ writeRunFile(state.root, file, content);
398
+ return {
399
+ id: card.id,
400
+ brief: absolute(state, file),
401
+ answer: absolute(state, answerFile(state, card.id)),
402
+ };
403
+ }
404
+
405
+ function approvalRefusal(state) {
406
+ const { run } = state;
407
+ return {
408
+ schemaVersion: 1,
409
+ status: 'needs-approval',
410
+ run: run.id,
411
+ n: run.n,
412
+ budget: run.budget,
413
+ seed: run.seed,
414
+ next: approvalHint(run),
415
+ };
416
+ }
417
+
418
+ function briefs(directory, runId, options = {}) {
419
+ const state = loadRun(directory, runId);
420
+ if (state.run.status === 'needs-approval') return approvalRefusal(state);
421
+ const wave =
422
+ options.wave === undefined ? state.run.wave : count(options.wave, 'wave', 1, MAX_LIMIT);
423
+ const pending = [];
424
+ for (const card of state.cards) {
425
+ const current = answerState(state, card.id);
426
+ if (current.state !== 'valid')
427
+ pending.push({ ...writeBrief(state, card), state: current.state });
428
+ }
429
+ const waves = [];
430
+ for (let start = 0; start < pending.length; start += wave)
431
+ waves.push({ index: waves.length + 1, units: pending.slice(start, start + wave) });
432
+ saveRun(state, { status: 'briefed' });
433
+ return {
434
+ schemaVersion: 1,
435
+ status: 'briefed',
436
+ run: runId,
437
+ wave,
438
+ units: state.cards.length,
439
+ pending: pending.length,
440
+ waves,
441
+ instruction: 'Read the brief and follow it exactly; it is your whole instruction.',
442
+ disclosure: state.definition.disclosure,
443
+ };
444
+ }
445
+
446
+ function check(directory, runId) {
447
+ const state = loadRun(directory, runId);
448
+ const units = state.cards.map((card) => {
449
+ const current = answerState(state, card.id);
450
+ return { id: card.id, state: current.state, reason: current.reason };
451
+ });
452
+ const pending = units.filter((unit) => unit.state !== 'valid');
453
+ return {
454
+ schemaVersion: 1,
455
+ status: pending.length ? 'incomplete' : 'complete',
456
+ run: runId,
457
+ units: units.length,
458
+ valid: units.length - pending.length,
459
+ pending,
460
+ };
461
+ }
462
+
463
+ /**
464
+ * The first balanced top-level JSON object in a printed reply, scanned with string awareness.
465
+ * The work is bounded: at most 20 brace groups are tried and the characters scanned over all
466
+ * of them may not exceed twice the reply, so a flood of braces costs milliseconds.
467
+ */
468
+ function extractObject(text) {
469
+ let candidates = 0;
470
+ let scanned = 0;
471
+ const budget = MAX_REPLY_SCAN_FACTOR * text.length;
472
+ for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
473
+ if (++candidates > MAX_REPLY_CANDIDATES || scanned > budget) return null;
474
+ let depth = 0;
475
+ let quoted = false;
476
+ let end = -1;
477
+ let index = start;
478
+ for (; index < text.length && end === -1; index += 1) {
479
+ const code = text.charCodeAt(index);
480
+ if (quoted) {
481
+ if (code === BACKSLASH) index += 1;
482
+ else if (code === QUOTE) quoted = false;
483
+ } else if (code === QUOTE) quoted = true;
484
+ else if (code === OPEN_BRACE) depth += 1;
485
+ else if (code === CLOSE_BRACE && --depth === 0) end = index;
486
+ }
487
+ scanned += index - start;
488
+ if (end === -1) continue;
489
+ try {
490
+ const value = JSON.parse(text.slice(start, end + 1));
491
+ if (contract.isObject(value)) return value;
492
+ } catch {
493
+ // not an object: the next brace may open one
494
+ }
495
+ }
496
+ return null;
497
+ }
498
+
499
+ function save(directory, runId, unitId, options = {}) {
500
+ const state = loadRun(directory, runId);
501
+ if (state.run.status === 'needs-approval') return approvalRefusal(state);
502
+ if (!state.cards.some((card) => card.id === unitId))
503
+ fail('Unknown unit ' + unitId + ' in run ' + runId + '.');
504
+ if (answerState(state, unitId).state === 'valid')
505
+ fail('Unit ' + unitId + ' already has a valid answer; it is kept.');
506
+ if (!options.input) fail('save needs --input FILE, the reply the sub-agent printed.');
507
+ const { content } = readInput(
508
+ state.root,
509
+ userFile(options.input, 'Reply'),
510
+ 'Reply',
511
+ MAX_REPLY_BYTES
512
+ );
513
+ const value = extractObject(content);
514
+ if (!value) fail('No JSON object found in the reply filed for unit ' + unitId + '.');
515
+ const problems = contract.answerProblems(state.definition, unitId, value);
516
+ if (problems.length) fail('Reply of unit ' + unitId + ' refused: ' + problems.join('; ') + '.');
517
+ writeJson(state.root, answerFile(state, unitId), value);
518
+ return {
519
+ schemaVersion: 1,
520
+ status: 'saved',
521
+ run: runId,
522
+ unit: unitId,
523
+ answer: absolute(state, answerFile(state, unitId)),
524
+ };
525
+ }
526
+
527
+ /**
528
+ * Where --out may point: a report of this run under reports/<runId>/, or a file of this run's
529
+ * own tree that is neither a frozen input, a state file nor inside a directory the units own.
530
+ */
531
+ function outputFile(state, out, fallback) {
532
+ if (out === undefined) return fallback;
533
+ const file = contract.portablePath(String(out), '--out');
534
+ const runId = state.run.id;
535
+ const report = REPORTS + '/' + runId + '/';
536
+ const own = runDir(runId) + '/';
537
+ if (file.startsWith(report)) return file;
538
+ if (!file.startsWith(own)) fail('--out must stay under ' + report + ' or ' + own + ': ' + file);
539
+ const rest = file.slice(own.length);
540
+ const head = rest.split('/')[0];
541
+ if (RUN_DIRECTORIES.includes(head))
542
+ fail('--out may not write inside ' + own + head + '/, which belongs to the units: ' + file);
543
+ if (RESERVED_RUN_FILES.includes(path.posix.basename(rest)))
544
+ fail('--out may not replace the run file ' + path.posix.basename(rest) + ': ' + file);
545
+ return file;
546
+ }
547
+
548
+ function tally(directory, runId, options = {}) {
549
+ const state = loadRun(directory, runId);
550
+ if (state.run.status === 'needs-approval') return approvalRefusal(state);
551
+ const rows = [];
552
+ const skipped = [];
553
+ for (const card of state.cards) {
554
+ const current = answerState(state, card.id);
555
+ if (current.state === 'valid') rows.push({ card, answer: current.answer });
556
+ else skipped.push({ unit: card.id, reason: current.reason });
557
+ }
558
+ const result = computeTally({
559
+ run: state.run,
560
+ definition: state.definition,
561
+ cards: state.cards,
562
+ rows,
563
+ skipped,
564
+ });
565
+ const jsonFile = runFile(runId, 'tally.json');
566
+ const markdownFile = outputFile(state, options.out, runFile(runId, 'tally.md'));
567
+ writeJson(state.root, jsonFile, result);
568
+ writeRunFile(state.root, markdownFile, renderTally(result));
569
+ saveRun(state, { status: 'tallied' });
570
+ return {
571
+ schemaVersion: 1,
572
+ status: 'tallied',
573
+ run: runId,
574
+ files: { json: absolute(state, jsonFile), markdown: absolute(state, markdownFile) },
575
+ tally: result,
576
+ };
577
+ }
578
+
579
+ function inspect(directory, runId) {
580
+ const state = loadRun(directory, runId);
581
+ const counts = { units: state.cards.length, briefed: 0, valid: 0, invalid: 0, missing: 0 };
582
+ for (const card of state.cards) {
583
+ const brief = runTarget(state, runFile(runId, 'briefs', card.id + '.md'));
584
+ if (brief.target && fs.existsSync(brief.target)) counts.briefed += 1;
585
+ counts[answerState(state, card.id).state] += 1;
586
+ }
587
+ const { run } = state;
588
+ return {
589
+ schemaVersion: 1,
590
+ status: run.status,
591
+ run,
592
+ counts,
593
+ budget: {
594
+ n: run.n,
595
+ budget: run.budget,
596
+ approval: run.approval,
597
+ decision:
598
+ run.approval === 'user'
599
+ ? 'approved by the user'
600
+ : run.status === 'needs-approval'
601
+ ? 'needs approval: ' + run.n + ' units exceed the budget of ' + run.budget
602
+ : 'within budget',
603
+ },
604
+ disclosure: state.definition.disclosure,
605
+ };
606
+ }
607
+
608
+ const VERIFIER = `#!/usr/bin/env node
609
+ /** Generated by bmad-plus swarm plan. Verifies one unit's answer against the frozen definition. */
610
+ 'use strict';
611
+ const fs = require('node:fs');
612
+ const path = require('node:path');
613
+ const { answerProblems } = require('./swarm-validate.cjs');
614
+ const flag = process.argv.find((argument) => argument.startsWith('--unit='));
615
+ const unit = flag ? flag.slice('--unit='.length) : '';
616
+ const run = path.resolve(__dirname, '..');
617
+ const read = (file) => JSON.parse(fs.readFileSync(path.join(run, file), 'utf8'));
618
+ const refuse = (message) => {
619
+ console.error('swarm answer check: ' + message);
620
+ process.exit(1);
621
+ };
622
+ if (!unit) refuse('--unit=<id> is required');
623
+ if (!read('cards.json').some((card) => card.id === unit)) refuse('unknown unit ' + unit);
624
+ let answer;
625
+ try {
626
+ answer = read('answers/' + unit + '.json');
627
+ } catch (error) {
628
+ refuse('no readable answer for ' + unit + ': ' + error.message);
629
+ }
630
+ const problems = answerProblems(read('definition.json'), unit, answer);
631
+ if (problems.length) refuse(unit + ': ' + problems.join('; '));
632
+ console.log('swarm answer check: ' + unit + ' is valid');
633
+ `;
634
+
635
+ function plan(directory, runId, options = {}) {
636
+ const state = loadRun(directory, runId);
637
+ const { run, definition } = state;
638
+ if (definition.kind !== 'fanout')
639
+ fail('Only a fanout exports a Nexus plan; run ' + runId + ' is a ' + definition.kind + '.');
640
+ if (run.status === 'needs-approval') return approvalRefusal(state);
641
+ const base = runDir(runId);
642
+ const checks = [base + '/checks/answer.cjs', base + '/checks/swarm-validate.cjs'];
643
+ writeRunFile(state.root, checks[0], VERIFIER);
644
+ writeRunFile(state.root, checks[1], fs.readFileSync(path.join(__dirname, 'swarm-validate.js')));
645
+ const tasks = state.cards.map((card) => {
646
+ const brief = runFile(runId, 'briefs', card.id + '.md');
647
+ const answer = answerFile(state, card.id);
648
+ writeBrief(state, card);
649
+ return {
650
+ id: card.id,
651
+ objective:
652
+ 'Read ' +
653
+ brief +
654
+ ' and follow it exactly; it is your whole instruction. Unit ' +
655
+ card.id +
656
+ ' of the swarm "' +
657
+ definition.title +
658
+ '".',
659
+ acceptance: [answer + ' is a valid answer of unit ' + card.id + ' (check answer).'],
660
+ dependsOn: [],
661
+ scope: [...card.scope, answer],
662
+ artifacts: [answer],
663
+ resources: [brief, base + '/cards.json', base + '/definition.json', ...checks],
664
+ checks: [
665
+ // A flag, so a unit named like a project file is never mistaken for a file argument.
666
+ {
667
+ id: 'answer',
668
+ command: 'node',
669
+ args: [checks[0], '--unit=' + card.id],
670
+ cwd: '.',
671
+ timeoutMs: 30000,
672
+ },
673
+ ],
674
+ maxAttempts: 2,
675
+ deadlineMs: 3600000,
676
+ idempotent: true,
677
+ authorizations: ['Edit ' + card.scope.join(', ') + ' and write ' + answer + '.'],
678
+ };
679
+ });
680
+ const document = {
681
+ id: runId,
682
+ maxParallel: Math.min(run.wave, 32),
683
+ config: { swarm: runId, definition: definition.id },
684
+ tasks,
685
+ };
686
+ const file = outputFile(state, options.out, runFile(runId, 'nexus-plan.json'));
687
+ writeJson(state.root, file, document);
688
+ saveRun(state, { status: 'briefed' });
689
+ return {
690
+ schemaVersion: 1,
691
+ status: 'planned',
692
+ run: runId,
693
+ plan: absolute(state, file),
694
+ tasks: tasks.length,
695
+ maxParallel: document.maxParallel,
696
+ next: 'bmad-plus nexus create --plan ' + absolute(state, file),
697
+ };
698
+ }
699
+
700
+ module.exports = {
701
+ BASE,
702
+ SWARM_DEFAULTS,
703
+ readConfig,
704
+ extractObject,
705
+ init,
706
+ briefs,
707
+ check,
708
+ save,
709
+ tally,
710
+ inspect,
711
+ plan,
712
+ };