bmad-plus 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +46 -26
- package/SECURITY.md +1 -1
- package/osint-agent-package/agents/osint-investigator.md +3 -3
- package/osint-agent-package/skills/bmad-osint-investigate/osint/SKILL.md +51 -51
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/content-extraction.md +7 -7
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/platforms.md +2 -2
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/tools.md +31 -41
- package/package.json +24 -5
- package/readme-international/README.de.md +46 -26
- package/readme-international/README.es.md +47 -27
- package/readme-international/README.fr.md +46 -26
- package/src/bmad-plus/agents/agent-maker/SKILL.md +39 -14
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
- package/src/bmad-plus/data/role-triggers.yaml +34 -0
- package/src/bmad-plus/module-help.csv +2 -0
- package/src/bmad-plus/module.yaml +26 -4
- package/src/bmad-plus/packs/pack-animated/animated-website-agent.md +40 -14
- package/src/bmad-plus/packs/pack-animated/templates/animated-website-workflow.md +15 -12
- package/src/bmad-plus/packs/pack-backup/backup-agent.md +2 -1
- package/src/bmad-plus/packs/pack-backup/templates/backup-workflow.md +2 -0
- package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
- package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
- package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
- package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
- package/src/bmad-plus/packs/pack-memory/zecher-agent.md +1 -1
- package/src/bmad-plus/packs/pack-shield/README.md +1 -1
- package/src/bmad-plus/packs/pack-shield/SKILL.md +3 -3
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/pci-dss-agent.md +1 -1
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +2 -2
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +7 -0
- package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
- package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
- package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
- package/tools/build/adapters.config.js +3 -1
- package/tools/build/generate-adapters.js +26 -2
- package/tools/build/generate.js +269 -7
- package/tools/build/intake-contract.js +522 -0
- package/tools/cli/bmad-plus-cli.js +2 -0
- package/tools/cli/commands/install.js +45 -11
- package/tools/cli/commands/intake.js +124 -0
- package/tools/cli/commands/nexus.js +2 -1
- package/tools/cli/commands/review.js +1 -1
- package/tools/cli/commands/swarm.js +119 -0
- package/tools/cli/commands/uninstall.js +17 -0
- package/tools/cli/commands/update.js +30 -1
- package/tools/cli/i18n.js +50 -30
- package/tools/cli/lib/intake.js +202 -0
- package/tools/cli/lib/pack-copy.js +95 -5
- package/tools/cli/lib/packs.js +102 -12
- package/tools/cli/lib/review.js +2 -1
- package/tools/cli/lib/swarm-deal.js +116 -0
- package/tools/cli/lib/swarm-defaults.js +6 -0
- package/tools/cli/lib/swarm-tally.js +195 -0
- package/tools/cli/lib/swarm-validate.js +589 -0
- package/tools/cli/lib/swarm.js +712 -0
- package/tools/cli/lib/update-dispatch.js +10 -2
- package/tools/cli/lib/update-transaction.js +9 -6
|
@@ -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(
|
|
@@ -547,15 +561,19 @@ module.exports = {
|
|
|
547
561
|
}
|
|
548
562
|
|
|
549
563
|
if (selectedPacks.includes('seo')) {
|
|
550
|
-
agentGuide.push(` ${i.guide_seo.padEnd(28)} → "/seo
|
|
564
|
+
agentGuide.push(` ${i.guide_seo.padEnd(28)} → "/seo full <url>"`);
|
|
551
565
|
}
|
|
552
566
|
|
|
553
567
|
if (selectedPacks.includes('backup')) {
|
|
554
|
-
agentGuide.push(` ${i.guide_backup.padEnd(28)} → "/backup
|
|
568
|
+
agentGuide.push(` ${i.guide_backup.padEnd(28)} → "/backup"`);
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
if (selectedPacks.includes('animated')) {
|
|
558
|
-
agentGuide.push(` ${i.guide_animated.padEnd(28)} → "
|
|
572
|
+
agentGuide.push(` ${i.guide_animated.padEnd(28)} → "animated website from <video.mp4>"`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (selectedPacks.includes('design')) {
|
|
576
|
+
agentGuide.push(` ${i.guide_design.padEnd(28)} → "/design read"`);
|
|
559
577
|
}
|
|
560
578
|
|
|
561
579
|
if (selectedPacks.includes('shield')) {
|
|
@@ -591,7 +609,7 @@ module.exports = {
|
|
|
591
609
|
'',
|
|
592
610
|
`📦 ${i.guide_cli_title || 'CLI Commands'}:`,
|
|
593
611
|
` npx bmad-plus install ${i.guide_cli_install || '— Install agents & skills'}`,
|
|
594
|
-
` npx bmad-plus update
|
|
612
|
+
` npx bmad-plus@latest update ${i.guide_cli_update || '— Update agents (keeps config)'}`,
|
|
595
613
|
` npx bmad-plus doctor ${i.guide_cli_doctor || '— Check installation health'}`,
|
|
596
614
|
` npx bmad-plus uninstall ${i.guide_cli_uninstall || '— Remove BMAD+ from project'}`
|
|
597
615
|
);
|
|
@@ -599,15 +617,20 @@ module.exports = {
|
|
|
599
617
|
// Add pack-specific examples
|
|
600
618
|
const examples = [];
|
|
601
619
|
if (selectedPacks.includes('seo')) {
|
|
602
|
-
examples.push(` ${i.guide_example_seo || '🔍 SEO: "/seo
|
|
620
|
+
examples.push(` ${i.guide_example_seo || '🔍 SEO: "/seo full https://example.com"'}`);
|
|
603
621
|
}
|
|
604
622
|
if (selectedPacks.includes('backup')) {
|
|
623
|
+
examples.push(` ${i.guide_example_backup || '🗂️ Backup: "/backup" → ZIP timestamped'}`);
|
|
624
|
+
}
|
|
625
|
+
if (selectedPacks.includes('animated')) {
|
|
605
626
|
examples.push(
|
|
606
|
-
` ${i.
|
|
627
|
+
` ${i.guide_example_animated || '🎬 Animated: "animated website from hero.mp4"'}`
|
|
607
628
|
);
|
|
608
629
|
}
|
|
609
|
-
if (selectedPacks.includes('
|
|
610
|
-
examples.push(
|
|
630
|
+
if (selectedPacks.includes('design')) {
|
|
631
|
+
examples.push(
|
|
632
|
+
` ${i.guide_example_design || '🎨 Design: "/design read" → Atelier interviews you, then builds'}`
|
|
633
|
+
);
|
|
611
634
|
}
|
|
612
635
|
if (selectedPacks.includes('osint')) {
|
|
613
636
|
examples.push(` ${i.guide_example_osint || '🔍 OSINT: "Shadow, investigate John Doe"'}`);
|
|
@@ -763,6 +786,13 @@ function mergeSettings(existing, defaults, explicit) {
|
|
|
763
786
|
return merged;
|
|
764
787
|
}
|
|
765
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Plain YAML under every supported js-yaml: forceQuotes is avoided because 5.2.x
|
|
791
|
+
* tags forced scalars (`!!bool 'true'`, `!!int '3'`) while 5.5 does not. Strings that
|
|
792
|
+
* would read as another type are still quoted by the dumper.
|
|
793
|
+
*/
|
|
794
|
+
const CONFIG_DUMP = { lineWidth: -1, quotingType: '"' };
|
|
795
|
+
|
|
766
796
|
/** Returns null when the existing file is not a YAML mapping (left untouched). */
|
|
767
797
|
function mergeConfigYaml(existingText, generatedText, explicit = {}) {
|
|
768
798
|
let existing;
|
|
@@ -778,7 +808,7 @@ function mergeConfigYaml(existingText, generatedText, explicit = {}) {
|
|
|
778
808
|
changed: true,
|
|
779
809
|
content:
|
|
780
810
|
'# BMAD+ Project Configuration\n# Generated by bmad-plus install; existing settings preserved\n\n' +
|
|
781
|
-
yaml.dump(merged,
|
|
811
|
+
yaml.dump(merged, CONFIG_DUMP),
|
|
782
812
|
};
|
|
783
813
|
}
|
|
784
814
|
|
|
@@ -819,8 +849,12 @@ function generateConfigYaml(
|
|
|
819
849
|
page_budget: { max_steps: 15, max_minutes: 30 },
|
|
820
850
|
dir: '_bmad-output/uat',
|
|
821
851
|
},
|
|
852
|
+
// Swarms of sub-agents. budget: how many one run may use before the host asks the
|
|
853
|
+
// user; wave: how many are launched together; media_credits: paid generation is
|
|
854
|
+
// never covered by a budget, the host always asks.
|
|
855
|
+
swarm: { ...SWARM_DEFAULTS },
|
|
822
856
|
},
|
|
823
|
-
|
|
857
|
+
CONFIG_DUMP
|
|
824
858
|
)
|
|
825
859
|
);
|
|
826
860
|
}
|