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,522 @@
1
+ /**
2
+ * BMAD+ Build — the intake contract (docs/specs/core-orchestration.md §4).
3
+ *
4
+ * An intake file lists the questions a pack must have answered before it acts; an
5
+ * answers file records what the user said, what was proposed by default and what
6
+ * stays unknown. Both are validated here, by pure functions shared by
7
+ * `generate.js --check` (every declared intake) and `bmad-plus intake` (the host).
8
+ * The plan (what to ask next, whether the pack may start) and the brief rendering
9
+ * live here too, so the CLI only reads and writes files.
10
+ *
11
+ * Author: Laurent Rochetta
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const KINDS = ['text', 'choice', 'number', 'boolean', 'list'];
17
+ const STATUSES = ['answered', 'defaulted', 'unknown'];
18
+ const UNKNOWN_MARK = 'UNKNOWN';
19
+ const BRIEF_MARKER = 'owned by bmad-plus intake';
20
+ const SLUG_RE = /^[a-z][a-z0-9-]*$/;
21
+ const LANGUAGE_RE = /^[a-z]{2}(?:-[A-Z]{2})?$/;
22
+ const FEED_RE = /^[A-Za-z0-9._/-]+(?:#[A-Za-z0-9._-]+)?$/;
23
+ const MAX_PER_MESSAGE = { min: 1, max: 20, fallback: 6 };
24
+ const MAX_TEXT = 4000;
25
+ const MAX_LIST_ITEMS = 50;
26
+
27
+ const INTAKE_KEYS = new Set([
28
+ 'schema_version',
29
+ 'id',
30
+ 'pack',
31
+ 'title',
32
+ 'language',
33
+ 'max_per_message',
34
+ 'sections',
35
+ ]);
36
+ const SECTION_KEYS = new Set(['id', 'title', 'questions']);
37
+ const QUESTION_KEYS = new Set([
38
+ 'id',
39
+ 'ask',
40
+ 'kind',
41
+ 'choices',
42
+ 'min',
43
+ 'max',
44
+ 'default',
45
+ 'must_have',
46
+ 'feeds',
47
+ 'risk_if_unknown',
48
+ ]);
49
+ const ENTRY_KEYS = new Set(['status', 'value', 'risk']);
50
+
51
+ const isPlainObject = (value) =>
52
+ value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ const isText = (value) => typeof value === 'string' && value.trim() !== '';
54
+
55
+ function unknownKeys(object, allowed) {
56
+ return Object.keys(object).filter((key) => !allowed.has(key));
57
+ }
58
+
59
+ // Line breaks and tabs are text; every other control character is not.
60
+ const hasControlCharacter = (text) =>
61
+ [...text].some((char) => {
62
+ const code = char.charCodeAt(0);
63
+ return (code < 32 && code !== 9 && code !== 10) || code === 127;
64
+ });
65
+ const isReservedName = (id) => id in Object.prototype;
66
+
67
+ /** Why `value` is not an acceptable answer to `question`, or null when it is. */
68
+ function valueProblem(question, value) {
69
+ switch (question.kind) {
70
+ case 'text':
71
+ if (!isText(value)) return 'needs a non-empty text';
72
+ if (value.length > MAX_TEXT) return `is longer than ${MAX_TEXT} characters`;
73
+ if (hasControlCharacter(value)) return 'holds a control character';
74
+ return null;
75
+ case 'choice':
76
+ return question.choices.includes(value)
77
+ ? null
78
+ : `must be one of ${question.choices.join(', ')}`;
79
+ case 'number':
80
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 'needs a finite number';
81
+ if (question.min !== undefined && value < question.min)
82
+ return `must be at least ${question.min}`;
83
+ if (question.max !== undefined && value > question.max)
84
+ return `must be at most ${question.max}`;
85
+ return null;
86
+ case 'boolean':
87
+ return typeof value === 'boolean' ? null : 'needs true or false';
88
+ case 'list':
89
+ if (!Array.isArray(value) || value.length === 0 || !value.every(isText))
90
+ return 'needs a non-empty list of texts';
91
+ if (value.length > MAX_LIST_ITEMS) return `has more than ${MAX_LIST_ITEMS} items`;
92
+ if (value.some((item) => hasControlCharacter(item))) return 'holds a control character';
93
+ return null;
94
+ default:
95
+ return 'has an unknown kind';
96
+ }
97
+ }
98
+
99
+ function validateQuestion(question, where, problems) {
100
+ if (!isPlainObject(question)) {
101
+ problems.push(`${where}: must be a mapping`);
102
+ return;
103
+ }
104
+ for (const key of unknownKeys(question, QUESTION_KEYS)) {
105
+ problems.push(`${where}: unknown field "${key}"`);
106
+ }
107
+ if (!isText(question.ask)) problems.push(`${where}: "ask" must be a non-empty text`);
108
+ if (!KINDS.includes(question.kind)) {
109
+ problems.push(`${where}: "kind" must be one of ${KINDS.join(', ')}`);
110
+ return;
111
+ }
112
+ if (question.kind === 'choice') {
113
+ const choices = question.choices;
114
+ if (
115
+ !Array.isArray(choices) ||
116
+ choices.length < 2 ||
117
+ !choices.every(isText) ||
118
+ new Set(choices).size !== choices.length
119
+ ) {
120
+ problems.push(`${where}: a choice needs at least two distinct non-empty "choices"`);
121
+ return;
122
+ }
123
+ } else if (question.choices !== undefined) {
124
+ problems.push(`${where}: "choices" belongs to a choice question only`);
125
+ }
126
+ for (const bound of ['min', 'max']) {
127
+ if (question[bound] === undefined) continue;
128
+ if (question.kind !== 'number') {
129
+ problems.push(`${where}: "${bound}" belongs to a number question only`);
130
+ } else if (typeof question[bound] !== 'number' || !Number.isFinite(question[bound])) {
131
+ problems.push(`${where}: "${bound}" must be a finite number`);
132
+ }
133
+ }
134
+ if (
135
+ typeof question.min === 'number' &&
136
+ typeof question.max === 'number' &&
137
+ question.min > question.max
138
+ ) {
139
+ problems.push(`${where}: "min" exceeds "max"`);
140
+ }
141
+ if (question.must_have !== undefined && typeof question.must_have !== 'boolean') {
142
+ problems.push(`${where}: "must_have" must be true or false`);
143
+ }
144
+ if (question.must_have === true && !isText(question.risk_if_unknown)) {
145
+ problems.push(`${where}: a must-have question needs "risk_if_unknown"`);
146
+ }
147
+ if (question.risk_if_unknown !== undefined && !isText(question.risk_if_unknown)) {
148
+ problems.push(`${where}: "risk_if_unknown" must be a non-empty text`);
149
+ }
150
+ if (question.default !== undefined) {
151
+ if (question.must_have === true) {
152
+ problems.push(`${where}: a must-have question takes no "default"; the user answers it`);
153
+ } else {
154
+ const problem = valueProblem(question, question.default);
155
+ if (problem) problems.push(`${where}: "default" ${problem}`);
156
+ }
157
+ }
158
+ if (question.feeds !== undefined) {
159
+ if (
160
+ !Array.isArray(question.feeds) ||
161
+ question.feeds.length === 0 ||
162
+ !question.feeds.every((feed) => typeof feed === 'string' && FEED_RE.test(feed))
163
+ ) {
164
+ problems.push(`${where}: "feeds" must list artifact anchors such as brief.md#surface`);
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Validate a parsed intake file against §4.1.
171
+ * @returns {{ ok: boolean, problems: string[] }}
172
+ */
173
+ function validateIntake(intake) {
174
+ const problems = [];
175
+ if (!isPlainObject(intake)) return { ok: false, problems: ['intake: must be a mapping'] };
176
+ for (const key of unknownKeys(intake, INTAKE_KEYS)) {
177
+ problems.push(`intake: unknown field "${key}"`);
178
+ }
179
+ if (intake.schema_version !== 1) problems.push('intake: schema_version must be 1');
180
+ for (const field of ['id', 'pack']) {
181
+ if (typeof intake[field] !== 'string' || !SLUG_RE.test(intake[field])) {
182
+ problems.push(`intake: "${field}" must be a lowercase slug`);
183
+ }
184
+ }
185
+ if (!isText(intake.title)) problems.push('intake: "title" must be a non-empty text');
186
+ if (
187
+ intake.language !== undefined &&
188
+ intake.language !== 'user' &&
189
+ !(typeof intake.language === 'string' && LANGUAGE_RE.test(intake.language))
190
+ ) {
191
+ problems.push('intake: "language" must be "user" or a language tag such as fr or en-GB');
192
+ }
193
+ if (
194
+ intake.max_per_message !== undefined &&
195
+ !(
196
+ Number.isInteger(intake.max_per_message) &&
197
+ intake.max_per_message >= MAX_PER_MESSAGE.min &&
198
+ intake.max_per_message <= MAX_PER_MESSAGE.max
199
+ )
200
+ ) {
201
+ problems.push(
202
+ `intake: "max_per_message" must be an integer from ${MAX_PER_MESSAGE.min} to ${MAX_PER_MESSAGE.max}`
203
+ );
204
+ }
205
+ if (!Array.isArray(intake.sections) || intake.sections.length === 0) {
206
+ problems.push('intake: "sections" must be a non-empty list');
207
+ return { ok: problems.length === 0, problems };
208
+ }
209
+ const sectionIds = new Set();
210
+ const questionIds = new Set();
211
+ intake.sections.forEach((section, index) => {
212
+ const where = `sections[${index}]`;
213
+ if (!isPlainObject(section)) {
214
+ problems.push(`${where}: must be a mapping`);
215
+ return;
216
+ }
217
+ for (const key of unknownKeys(section, SECTION_KEYS)) {
218
+ problems.push(`${where}: unknown field "${key}"`);
219
+ }
220
+ if (typeof section.id !== 'string' || !SLUG_RE.test(section.id)) {
221
+ problems.push(`${where}: "id" must be a lowercase slug`);
222
+ } else if (sectionIds.has(section.id)) {
223
+ problems.push(`${where}: duplicate section id "${section.id}"`);
224
+ } else sectionIds.add(section.id);
225
+ if (!isText(section.title)) problems.push(`${where}: "title" must be a non-empty text`);
226
+ if (!Array.isArray(section.questions) || section.questions.length === 0) {
227
+ problems.push(`${where}: "questions" must be a non-empty list`);
228
+ return;
229
+ }
230
+ section.questions.forEach((question, position) => {
231
+ const label = `${where}.questions[${position}]`;
232
+ if (isPlainObject(question)) {
233
+ if (typeof question.id !== 'string' || !/^[a-z][a-z0-9_]*$/.test(question.id)) {
234
+ problems.push(`${label}: "id" must be a snake_case identifier`);
235
+ } else if (isReservedName(question.id)) {
236
+ problems.push(`${label}: "id" ${question.id} is a reserved object property name`);
237
+ } else if (questionIds.has(question.id)) {
238
+ problems.push(`${label}: duplicate question id "${question.id}"`);
239
+ } else questionIds.add(question.id);
240
+ }
241
+ validateQuestion(question, label, problems);
242
+ });
243
+ });
244
+ return { ok: problems.length === 0, problems };
245
+ }
246
+
247
+ /** Every question of a valid intake, in file order, with its section. */
248
+ function listQuestions(intake) {
249
+ return intake.sections.flatMap((section) =>
250
+ section.questions.map((question) => ({ ...question, section: section.id }))
251
+ );
252
+ }
253
+
254
+ function entryProblem(question, entry) {
255
+ if (!isPlainObject(entry)) return 'must be a mapping';
256
+ const unknown = unknownKeys(entry, ENTRY_KEYS);
257
+ if (unknown.length) return `has unknown field "${unknown[0]}"`;
258
+ if (!STATUSES.includes(entry.status)) return `status must be one of ${STATUSES.join(', ')}`;
259
+ if (entry.status === 'unknown') {
260
+ if (entry.value !== undefined) return 'is unknown and carries a value';
261
+ if (question.risk_if_unknown !== undefined && !isText(entry.risk))
262
+ return 'is unknown without its risk';
263
+ if (entry.risk !== undefined && !isText(entry.risk)) return 'has an empty risk';
264
+ return null;
265
+ }
266
+ if (entry.risk !== undefined) return `is ${entry.status} and carries a risk`;
267
+ if (entry.status === 'defaulted') {
268
+ if (question.default === undefined) return 'is defaulted but the question has no default';
269
+ if (JSON.stringify(entry.value) !== JSON.stringify(question.default))
270
+ return 'is defaulted with a value other than the default';
271
+ return null;
272
+ }
273
+ const problem = valueProblem(question, entry.value);
274
+ return problem ? `value ${problem}` : null;
275
+ }
276
+
277
+ /**
278
+ * Validate a parsed answers file (§4.2) against its valid intake.
279
+ * @returns {{ ok: boolean, problems: string[] }}
280
+ */
281
+ function validateAnswers(answers, intake) {
282
+ const problems = [];
283
+ if (!isPlainObject(answers)) return { ok: false, problems: ['answers: must be an object'] };
284
+ for (const key of unknownKeys(answers, new Set(['schemaVersion', 'intake', 'answers']))) {
285
+ problems.push(`answers: unknown field "${key}"`);
286
+ }
287
+ if (answers.schemaVersion !== 1) problems.push('answers: schemaVersion must be 1');
288
+ if (answers.intake !== intake.id) {
289
+ problems.push(`answers: intake must be "${intake.id}"`);
290
+ }
291
+ if (!isPlainObject(answers.answers)) {
292
+ problems.push('answers: "answers" must be an object keyed by question id');
293
+ return { ok: false, problems };
294
+ }
295
+ const questions = new Map(listQuestions(intake).map((question) => [question.id, question]));
296
+ for (const [id, entry] of Object.entries(answers.answers)) {
297
+ const question = questions.get(id);
298
+ if (!question) {
299
+ problems.push(`answers.${id}: no such question in intake "${intake.id}"`);
300
+ continue;
301
+ }
302
+ const problem = entryProblem(question, entry);
303
+ if (problem) problems.push(`answers.${id}: ${problem}`);
304
+ }
305
+ return { ok: problems.length === 0, problems };
306
+ }
307
+
308
+ /** An empty answers file for an intake. */
309
+ function emptyAnswers(intake) {
310
+ return { schemaVersion: 1, intake: intake.id, answers: {} };
311
+ }
312
+
313
+ /** The recorded entry of a question, own properties only: a prototype name is no answer. */
314
+ function entryOf(recorded, id) {
315
+ return Object.hasOwn(recorded, id) ? recorded[id] : undefined;
316
+ }
317
+
318
+ function publicQuestion(question) {
319
+ return {
320
+ id: question.id,
321
+ section: question.section,
322
+ ask: question.ask,
323
+ kind: question.kind,
324
+ mustHave: question.must_have === true,
325
+ ...(question.choices ? { choices: [...question.choices] } : {}),
326
+ ...(question.min !== undefined ? { min: question.min } : {}),
327
+ ...(question.max !== undefined ? { max: question.max } : {}),
328
+ ...(question.default !== undefined ? { default: question.default } : {}),
329
+ ...(question.risk_if_unknown !== undefined ? { risk: question.risk_if_unknown } : {}),
330
+ };
331
+ }
332
+
333
+ /**
334
+ * What to ask next and whether the pack may act. Both inputs must be valid.
335
+ * `ready` holds once every must-have is answered or marked unknown with its risk.
336
+ */
337
+ function planIntake(intake, answers = emptyAnswers(intake)) {
338
+ const limit = intake.max_per_message ?? MAX_PER_MESSAGE.fallback;
339
+ const questions = listQuestions(intake);
340
+ const recorded = (question) => entryOf(answers.answers, question.id);
341
+ const pending = questions.filter((question) => recorded(question) === undefined);
342
+ const missing = pending.filter((question) => question.must_have === true);
343
+ const next = [...missing, ...pending.filter((question) => question.must_have !== true)]
344
+ .slice(0, limit)
345
+ .map(publicQuestion);
346
+ const unknowns = questions
347
+ .filter((question) => recorded(question)?.status === 'unknown')
348
+ .map((question) => ({
349
+ id: question.id,
350
+ mustHave: question.must_have === true,
351
+ ...(recorded(question).risk !== undefined ? { risk: recorded(question).risk } : {}),
352
+ }));
353
+ const count = (status) =>
354
+ questions.filter((question) => recorded(question)?.status === status).length;
355
+ return {
356
+ intake: intake.id,
357
+ pack: intake.pack,
358
+ language: intake.language ?? 'user',
359
+ maxPerMessage: limit,
360
+ ready: missing.length === 0,
361
+ missing: missing.map((question) => question.id),
362
+ next,
363
+ unknowns,
364
+ counts: {
365
+ questions: questions.length,
366
+ answered: count('answered'),
367
+ defaulted: count('defaulted'),
368
+ unknown: count('unknown'),
369
+ pending: pending.length,
370
+ },
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Turn the text of `--set id=VALUE` into a typed value for the question, or the
376
+ * unknown mark. Refuses anything the kind cannot hold.
377
+ */
378
+ function parseCliValue(question, raw) {
379
+ if (typeof raw !== 'string') throw new Error(`${question.id}: a value must be text`);
380
+ if (raw === UNKNOWN_MARK) return { unknown: true };
381
+ let value = raw;
382
+ switch (question.kind) {
383
+ case 'number':
384
+ value = raw.trim() === '' ? NaN : Number(raw);
385
+ break;
386
+ case 'boolean':
387
+ if (['true', 'yes', 'y', '1'].includes(raw.trim().toLowerCase())) value = true;
388
+ else if (['false', 'no', 'n', '0'].includes(raw.trim().toLowerCase())) value = false;
389
+ break;
390
+ case 'list':
391
+ value = raw
392
+ .split(',')
393
+ .map((item) => item.trim())
394
+ .filter((item) => item !== '');
395
+ break;
396
+ case 'text':
397
+ case 'choice':
398
+ value = raw.trim();
399
+ break;
400
+ default:
401
+ break;
402
+ }
403
+ const problem = valueProblem(question, value);
404
+ if (problem) throw new Error(`${question.id}: value ${problem}`);
405
+ return { value };
406
+ }
407
+
408
+ /**
409
+ * Apply recorded changes to a copy of the answers: `sets` maps question id to raw
410
+ * CLI text (or the unknown mark), `defaults` lists question ids to record as
411
+ * defaulted. Returns the new answers object; the caller writes it.
412
+ */
413
+ function recordAnswers(intake, answers, { sets = {}, defaults = [] } = {}) {
414
+ const questions = new Map(listQuestions(intake).map((question) => [question.id, question]));
415
+ const next = { ...answers, answers: { ...answers.answers } };
416
+ const touched = new Set();
417
+ const question = (id) => {
418
+ const found = questions.get(id);
419
+ if (!found) throw new Error(`no question "${id}" in intake "${intake.id}"`);
420
+ if (touched.has(id)) throw new Error(`question "${id}" is set twice in one call`);
421
+ touched.add(id);
422
+ return found;
423
+ };
424
+ for (const [id, raw] of Object.entries(sets)) {
425
+ const target = question(id);
426
+ const parsed = parseCliValue(target, raw);
427
+ next.answers[id] = parsed.unknown
428
+ ? {
429
+ status: 'unknown',
430
+ ...(target.risk_if_unknown !== undefined ? { risk: target.risk_if_unknown } : {}),
431
+ }
432
+ : { value: parsed.value, status: 'answered' };
433
+ }
434
+ for (const id of defaults) {
435
+ const target = question(id);
436
+ if (target.default === undefined) throw new Error(`question "${id}" has no default`);
437
+ next.answers[id] = { value: target.default, status: 'defaulted' };
438
+ }
439
+ return next;
440
+ }
441
+
442
+ /**
443
+ * A user's value, one line per item, each indented by four spaces so that a
444
+ * leading `#`, `-`, `>` or `|` stays text under the `Answer:` line.
445
+ */
446
+ function valueBlock(value) {
447
+ const items = Array.isArray(value) ? value : [value];
448
+ return items
449
+ .flatMap((item) =>
450
+ typeof item === 'boolean' ? [item ? 'yes' : 'no'] : String(item).split('\n')
451
+ )
452
+ .map((line) => ` ${line}`);
453
+ }
454
+
455
+ /** Second line of every brief; `render` only overwrites a file that starts with it. */
456
+ function briefMarker(intake) {
457
+ return `<!-- ${BRIEF_MARKER} ${intake.id}; written by bmad-plus intake render, do not edit -->`;
458
+ }
459
+
460
+ /** The brief: one section per intake section, every answer with its status. */
461
+ function renderBrief(intake, answers = emptyAnswers(intake)) {
462
+ const plan = planIntake(intake, answers);
463
+ const recorded = (question) => entryOf(answers.answers, question.id);
464
+ const lines = [
465
+ `# ${intake.title}`,
466
+ briefMarker(intake),
467
+ '',
468
+ `Intake \`${intake.id}\` of pack \`${intake.pack}\` — ${plan.counts.answered} answered, ` +
469
+ `${plan.counts.defaulted} defaulted, ${plan.counts.unknown} unknown, ` +
470
+ `${plan.counts.pending} not asked yet. ${plan.ready ? 'Ready.' : 'Not ready: a must-have is open.'}`,
471
+ '',
472
+ ];
473
+ for (const section of intake.sections) {
474
+ lines.push(`## ${section.title}`, '');
475
+ for (const question of section.questions) {
476
+ const entry = recorded(question);
477
+ lines.push(`### ${question.id}${question.must_have === true ? ' (must-have)' : ''}`, '');
478
+ lines.push(question.ask, '');
479
+ if (!entry) lines.push('Answer: not asked yet.');
480
+ else if (entry.status === 'answered') lines.push('Answer:', ...valueBlock(entry.value));
481
+ else if (entry.status === 'defaulted')
482
+ lines.push(
483
+ 'Answer (proposed default, not confirmed by the user):',
484
+ ...valueBlock(entry.value)
485
+ );
486
+ else lines.push(`Answer: unknown${entry.risk ? ` — risk: ${entry.risk}` : ''}`);
487
+ if (question.feeds) lines.push(`Feeds: ${question.feeds.join(', ')}`);
488
+ lines.push('');
489
+ }
490
+ }
491
+ lines.push('## Open questions', '');
492
+ const open = listQuestions(intake).filter(
493
+ (question) => recorded(question) === undefined || recorded(question).status === 'unknown'
494
+ );
495
+ if (open.length === 0) lines.push('None.');
496
+ for (const question of open) {
497
+ const entry = recorded(question);
498
+ const state = entry ? 'unknown' : 'not asked yet';
499
+ const risk = entry?.risk ?? question.risk_if_unknown;
500
+ lines.push(
501
+ `- ${question.id}${question.must_have === true ? ' (must-have)' : ''}: ${state}${risk ? ` — risk: ${risk}` : ''}`
502
+ );
503
+ }
504
+ lines.push('');
505
+ return lines.join('\n');
506
+ }
507
+
508
+ module.exports = {
509
+ KINDS,
510
+ STATUSES,
511
+ UNKNOWN_MARK,
512
+ BRIEF_MARKER,
513
+ briefMarker,
514
+ validateIntake,
515
+ validateAnswers,
516
+ listQuestions,
517
+ emptyAnswers,
518
+ planIntake,
519
+ parseCliValue,
520
+ recordAnswers,
521
+ renderBrief,
522
+ };
@@ -118,10 +118,12 @@ for (const modulePath of [
118
118
  './commands/update-policy',
119
119
  './commands/studio',
120
120
  './commands/nexus',
121
+ './commands/swarm',
121
122
  './commands/uat',
122
123
  './commands/review',
123
124
  './commands/assurance',
124
125
  './commands/ai-register',
126
+ './commands/intake',
125
127
  ]) {
126
128
  const command = require(modulePath);
127
129
  const configured = program.command(command.command).description(command.description);
@@ -15,7 +15,7 @@ const clack = require('@clack/prompts');
15
15
  const pc = require('picocolors');
16
16
  const { t, LANGUAGES, getLanguageOptions, getCommLanguageOptions } = require('../i18n');
17
17
  const { PACKS, DERIVED } = require('../lib/packs');
18
- const { copyPackFiles, copyManagedFile } = require('../lib/pack-copy');
18
+ const { copyPackFiles, copyManagedFile, listPackFiles } = require('../lib/pack-copy');
19
19
  const {
20
20
  acquireProjectLock,
21
21
  atomicWrite,
@@ -30,6 +30,7 @@ const { readInstallManifest } = require('../lib/install-manifest');
30
30
  const yaml = require('js-yaml');
31
31
  const { validateUserName } = require('../lib/validate');
32
32
  const { provisionPack } = require('../lib/python-provision');
33
+ const { SWARM_DEFAULTS } = require('../lib/swarm-defaults');
33
34
 
34
35
  const PYTHON_PACKS = DERIVED.pythonPacks;
35
36
 
@@ -287,6 +288,19 @@ module.exports = {
287
288
  const releaseLock = acquireProjectLock(projectDir);
288
289
  try {
289
290
  const { safeTarget } = require('../../build/generate-adapters');
291
+ // Every pack must enumerate before the first write: a missing or mismatched
292
+ // external module aborts here, not after Core files exist without a manifest.
293
+ for (const packId of selectedPacks) {
294
+ const pack = PACKS[packId];
295
+ if (!pack || pack.disabled) continue;
296
+ listPackFiles({
297
+ bmadSrc,
298
+ targetAgentsDir: path.join(projectDir, '.agents', 'skills'),
299
+ targetDataDir: path.join(projectDir, '.agents', 'data'),
300
+ projectRoot: path.join(bmadSrc, '..', '..'),
301
+ pack,
302
+ });
303
+ }
290
304
  const plannedFiles = collectManagedFiles({ projectDir, packs: selectedPacks });
291
305
  if (detectedIDEs.length)
292
306
  plannedFiles.push(
@@ -558,6 +572,10 @@ module.exports = {
558
572
  agentGuide.push(` ${i.guide_animated.padEnd(28)} → "/animated build <video>"`);
559
573
  }
560
574
 
575
+ if (selectedPacks.includes('design')) {
576
+ agentGuide.push(` ${i.guide_design.padEnd(28)} → "/design read"`);
577
+ }
578
+
561
579
  if (selectedPacks.includes('shield')) {
562
580
  agentGuide.push(
563
581
  ` ${(i.guide_shield || '🛡️ GRC Compliance').padEnd(28)} → "Shield, audit my SaaS for GDPR"`
@@ -609,6 +627,11 @@ module.exports = {
609
627
  if (selectedPacks.includes('animated')) {
610
628
  examples.push(` ${i.guide_example_animated || '🎬 Animated: "/animated build hero.mp4"'}`);
611
629
  }
630
+ if (selectedPacks.includes('design')) {
631
+ examples.push(
632
+ ` ${i.guide_example_design || '🎨 Design: "/design read" → Atelier interviews you, then builds'}`
633
+ );
634
+ }
612
635
  if (selectedPacks.includes('osint')) {
613
636
  examples.push(` ${i.guide_example_osint || '🔍 OSINT: "Shadow, investigate John Doe"'}`);
614
637
  }
@@ -819,6 +842,10 @@ function generateConfigYaml(
819
842
  page_budget: { max_steps: 15, max_minutes: 30 },
820
843
  dir: '_bmad-output/uat',
821
844
  },
845
+ // Swarms of sub-agents. budget: how many one run may use before the host asks the
846
+ // user; wave: how many are launched together; media_credits: paid generation is
847
+ // never covered by a budget, the host always asks.
848
+ swarm: { ...SWARM_DEFAULTS },
822
849
  },
823
850
  { lineWidth: -1, forceQuotes: true, quotingType: '"' }
824
851
  )