bmad-plus 0.21.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.
- package/CHANGELOG.md +39 -0
- package/README.md +24 -14
- package/SECURITY.md +63 -1
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +4 -1
- package/readme-international/README.de.md +24 -14
- package/readme-international/README.es.md +24 -14
- package/readme-international/README.fr.md +24 -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 +21 -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-dev-studio/categories/implementation/code-review.md +3 -1
- package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
- package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
- package/src/bmad-plus/packs/pack-shield/README.md +12 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
- package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
- 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/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
- package/tools/build/adapters.config.js +2 -0
- package/tools/build/generate-adapters.js +32 -1
- package/tools/build/generate.js +283 -7
- package/tools/build/intake-contract.js +522 -0
- package/tools/cli/bmad-plus-cli.js +4 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/install.js +28 -1
- package/tools/cli/commands/intake.js +124 -0
- package/tools/cli/commands/review.js +10 -3
- package/tools/cli/commands/swarm.js +119 -0
- package/tools/cli/commands/uninstall.js +17 -0
- package/tools/cli/i18n.js +20 -0
- package/tools/cli/lib/ai-register.js +393 -0
- package/tools/cli/lib/assurance.js +822 -0
- package/tools/cli/lib/control-refs.js +132 -0
- package/tools/cli/lib/installation-health.js +17 -0
- package/tools/cli/lib/intake.js +202 -0
- package/tools/cli/lib/pack-copy.js +95 -5
- package/tools/cli/lib/packs.js +156 -8
- package/tools/cli/lib/page-origins.js +582 -0
- package/tools/cli/lib/review-rules.js +92 -24
- package/tools/cli/lib/review.js +28 -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/uat.js +22 -5
- 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,8 +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',
|
|
124
|
+
'./commands/assurance',
|
|
125
|
+
'./commands/ai-register',
|
|
126
|
+
'./commands/intake',
|
|
123
127
|
]) {
|
|
124
128
|
const command = require(modulePath);
|
|
125
129
|
const configured = program.command(command.command).description(command.description);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI processing register: start it from the Shield template, warn about AI integrations
|
|
3
|
+
* it does not cover.
|
|
4
|
+
*/
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const register = require('../lib/ai-register');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
command: 'ai-register <action>',
|
|
12
|
+
description:
|
|
13
|
+
'AI processing register: start it from the Shield template, check which AI integrations it does not cover',
|
|
14
|
+
options: [
|
|
15
|
+
['-d, --directory <path>', 'Project directory'],
|
|
16
|
+
['--json', 'Machine-readable output'],
|
|
17
|
+
],
|
|
18
|
+
action: (action, options = {}) => {
|
|
19
|
+
const projectDir = path.resolve(options.directory || process.cwd());
|
|
20
|
+
if (action === 'init') {
|
|
21
|
+
try {
|
|
22
|
+
const file = register.initRegister(projectDir);
|
|
23
|
+
if (options.json) console.log(JSON.stringify({ schemaVersion: 1, action, file }, null, 2));
|
|
24
|
+
else
|
|
25
|
+
console.log(
|
|
26
|
+
`${file}: started from the Shield template. Replace the example entries, then run bmad-plus ai-register check.`
|
|
27
|
+
);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(`ai-register: ${error.message}`);
|
|
30
|
+
process.exitCode = 3;
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (action !== 'check') {
|
|
35
|
+
console.error(`ai-register: unknown action "${action}" (init, check)`);
|
|
36
|
+
process.exitCode = 3;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const result = register.checkRegister(projectDir);
|
|
40
|
+
// A soft gate: warnings never fail the command; only an unreadable register does.
|
|
41
|
+
process.exitCode = result.errors.length ? 1 : 0;
|
|
42
|
+
if (options.json) {
|
|
43
|
+
console.log(JSON.stringify({ schemaVersion: 1, ...result }, null, 2));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const covered = result.integrations.filter((item) => item.coveredBy);
|
|
47
|
+
console.log(
|
|
48
|
+
`${result.registerFile}: ${result.register ? `reviewed ${result.register.reviewed}` : 'absent'} — ${covered.length}/${result.integrations.length} AI integration(s) registered`
|
|
49
|
+
);
|
|
50
|
+
for (const item of covered)
|
|
51
|
+
console.log(` registered ${item.ids.join(' or ')} → ${item.coveredBy}`);
|
|
52
|
+
for (const warning of result.warnings) console.log(` warning ${warning}`);
|
|
53
|
+
for (const error of result.errors) console.error(` error ${error}`);
|
|
54
|
+
if (result.unmatched.length)
|
|
55
|
+
console.log(
|
|
56
|
+
` not found here (may be used outside the repository): ${result.unmatched.join(', ')}`
|
|
57
|
+
);
|
|
58
|
+
if (result.warnings.length && !result.errors.length)
|
|
59
|
+
console.log(
|
|
60
|
+
' Add each tool with its purpose, data, legal basis, retention and transfers; see the Shield template shared/ai-processing-register-template.yaml.'
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
};
|