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.
Files changed (75) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +24 -14
  3. package/SECURITY.md +63 -1
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
  5. package/package.json +4 -1
  6. package/readme-international/README.de.md +24 -14
  7. package/readme-international/README.es.md +24 -14
  8. package/readme-international/README.fr.md +24 -14
  9. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
  10. package/src/bmad-plus/data/role-triggers.yaml +34 -0
  11. package/src/bmad-plus/module-help.csv +2 -0
  12. package/src/bmad-plus/module.yaml +21 -0
  13. package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
  14. package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
  15. package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
  16. package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
  17. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +3 -1
  18. package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
  19. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
  20. package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
  21. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
  22. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
  23. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
  24. package/src/bmad-plus/packs/pack-shield/README.md +12 -0
  25. package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
  26. package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
  27. package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
  28. package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
  29. package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
  30. package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
  31. package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
  32. package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
  33. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
  34. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
  35. package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
  36. package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
  37. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
  38. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +7 -0
  39. package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
  40. package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
  41. package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
  42. package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
  43. package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
  44. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
  45. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
  46. package/tools/build/adapters.config.js +2 -0
  47. package/tools/build/generate-adapters.js +32 -1
  48. package/tools/build/generate.js +283 -7
  49. package/tools/build/intake-contract.js +522 -0
  50. package/tools/cli/bmad-plus-cli.js +4 -0
  51. package/tools/cli/commands/ai-register.js +63 -0
  52. package/tools/cli/commands/assurance.js +162 -0
  53. package/tools/cli/commands/install.js +28 -1
  54. package/tools/cli/commands/intake.js +124 -0
  55. package/tools/cli/commands/review.js +10 -3
  56. package/tools/cli/commands/swarm.js +119 -0
  57. package/tools/cli/commands/uninstall.js +17 -0
  58. package/tools/cli/i18n.js +20 -0
  59. package/tools/cli/lib/ai-register.js +393 -0
  60. package/tools/cli/lib/assurance.js +822 -0
  61. package/tools/cli/lib/control-refs.js +132 -0
  62. package/tools/cli/lib/installation-health.js +17 -0
  63. package/tools/cli/lib/intake.js +202 -0
  64. package/tools/cli/lib/pack-copy.js +95 -5
  65. package/tools/cli/lib/packs.js +156 -8
  66. package/tools/cli/lib/page-origins.js +582 -0
  67. package/tools/cli/lib/review-rules.js +92 -24
  68. package/tools/cli/lib/review.js +28 -1
  69. package/tools/cli/lib/swarm-deal.js +116 -0
  70. package/tools/cli/lib/swarm-defaults.js +6 -0
  71. package/tools/cli/lib/swarm-tally.js +195 -0
  72. package/tools/cli/lib/swarm-validate.js +589 -0
  73. package/tools/cli/lib/swarm.js +712 -0
  74. package/tools/cli/lib/uat.js +22 -5
  75. package/tools/cli/lib/update-transaction.js +8 -4
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Security assurance cases: start one from the Shield template, run the checks that produce
3
+ * evidence, verify a case against them.
4
+ */
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const assurance = require('../lib/assurance');
10
+
11
+ const ACTIONS = ['init', 'run', 'verify'];
12
+
13
+ function print(json, payload, lines) {
14
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
15
+ else for (const line of lines) console.log(line);
16
+ }
17
+
18
+ /** `--emit-check` writes a JSON file atomically, never over the case or its ledger. */
19
+ function writeCheck(projectDir, value, kase, ledger, result) {
20
+ const file = path.resolve(projectDir, value);
21
+ if (!/\.json$/i.test(file)) throw new Error('--emit-check writes a .json file');
22
+ const protectedFiles = [path.resolve(projectDir, kase.file), ledger];
23
+ if (protectedFiles.some((taken) => path.relative(taken, file) === ''))
24
+ throw new Error('--emit-check must not overwrite the case or its ledger');
25
+ if (fs.existsSync(file) && !fs.statSync(file).isFile())
26
+ throw new Error(`--emit-check: ${file} is not a file`);
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ const temporary = `${file}.${process.pid}.tmp`;
29
+ fs.writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
30
+ try {
31
+ fs.renameSync(temporary, file);
32
+ } catch (error) {
33
+ fs.rmSync(temporary, { force: true });
34
+ throw error;
35
+ }
36
+ return file;
37
+ }
38
+
39
+ function initAction(projectDir, caseFile, json) {
40
+ const { file, id } = assurance.initCase(projectDir, caseFile);
41
+ print(json, { action: 'init', case: id, file }, [
42
+ `${file}: case ${id} started from the Shield template.`,
43
+ 'Replace every example claim and check with those of the project, commit it, then run it.',
44
+ ]);
45
+ }
46
+
47
+ async function runAction(projectDir, kase, options, json) {
48
+ const { file, results, head, authenticated } = await assurance.runChecks(projectDir, kase, {
49
+ dir: options.dir,
50
+ only: options.check,
51
+ });
52
+ const failed = results.filter((result) => result.failures.length);
53
+ print(
54
+ json,
55
+ {
56
+ action: 'run',
57
+ case: kase.id,
58
+ ledger: file,
59
+ head,
60
+ authenticated,
61
+ results: results.map(({ check, failures, record }) => ({
62
+ check,
63
+ status: failures.length ? 'failed' : 'passed',
64
+ failures,
65
+ sequence: record.sequence,
66
+ exitCode: record.exitCode,
67
+ revision: record.revision,
68
+ dirty: record.dirty,
69
+ sha256: record.sha256,
70
+ })),
71
+ },
72
+ [
73
+ `${file}`,
74
+ ...results.map(
75
+ ({ check, failures, record }) =>
76
+ ` ${failures.length ? 'failed' : 'passed'} ${check.padEnd(24)} exit ${record.exitCode ?? '-'} in ${Math.round(record.durationMs / 100) / 10} s${failures.length ? ` — ${failures.join('; ')}` : ''}`
77
+ ),
78
+ ...(results.some(({ record }) => record.dirty !== false)
79
+ ? [
80
+ ' Ran on uncommitted changes, untracked files or outside git: a case bound to a commit will not accept these runs.',
81
+ ]
82
+ : []),
83
+ `Recorded ${results.length} run(s)${authenticated ? ', authenticated by key' : ''}; ledger head ${head}.`,
84
+ `Keep the head outside the ledger and verify with bmad-plus assurance verify ${kase.file} --ledger-head ${head}.`,
85
+ ]
86
+ );
87
+ process.exitCode = failed.length ? 1 : 0;
88
+ }
89
+
90
+ function verifyAction(projectDir, kase, options, json) {
91
+ const verdict = assurance.verifyCase(projectDir, kase, {
92
+ dir: options.dir,
93
+ head: options.ledgerHead ?? null,
94
+ });
95
+ let check = null;
96
+ if (options.emitCheck)
97
+ check = writeCheck(
98
+ projectDir,
99
+ options.emitCheck,
100
+ kase,
101
+ assurance.ledgerFile(projectDir, options.dir, kase.id),
102
+ assurance.checkResult(verdict)
103
+ );
104
+ print(
105
+ json,
106
+ { action: 'verify', ...verdict, check },
107
+ [
108
+ `${kase.id}: ${verdict.status} — ${verdict.claims.filter((c) => c.status === 'supported').length}/${verdict.claims.length} claim(s) supported, ${verdict.ledger.runs} recorded run(s)`,
109
+ ...verdict.reasons.map((reason) => ` - ${reason}`),
110
+ ...Object.entries(verdict.checks)
111
+ .filter(([, judged]) => judged.status !== 'passed')
112
+ .map(([id, judged]) => ` ${judged.status.padEnd(7)} ${id}: ${judged.reasons.join('; ')}`),
113
+ ...verdict.claims
114
+ .filter((claim) => claim.status !== 'supported')
115
+ .map((claim) => ` unsupported claim ${claim.id}: ${claim.reasons.join('; ')}`),
116
+ ...(verdict.controls.supported.length
117
+ ? [` controls with executed evidence: ${verdict.controls.supported.join(', ')}`]
118
+ : []),
119
+ check ? ` check: ${check}` : '',
120
+ ].filter(Boolean)
121
+ );
122
+ process.exitCode = verdict.exitCode;
123
+ }
124
+
125
+ module.exports = {
126
+ command: 'assurance <action> <case>',
127
+ description: 'Security assurance case bound to executed checks: init, run, verify',
128
+ options: [
129
+ ['-d, --directory <path>', 'Project directory'],
130
+ ['--dir <path>', 'Evidence folder inside the project', assurance.DEFAULT_DIR],
131
+ ['--check <id>', 'run: only this check (repeatable)', (v, all) => [...all, v], []],
132
+ ['--ledger-head <sha256>', 'verify: fail unless the ledger still holds this record'],
133
+ ['--emit-check <file>', 'verify: also write the verdict as a JSON check result for CI'],
134
+ ['--json', 'Machine-readable output'],
135
+ ],
136
+ action: async (action, caseFile, options = {}) => {
137
+ const projectDir = path.resolve(options.directory || process.cwd());
138
+ const json = Boolean(options.json);
139
+ const settings = { dir: options.dir || assurance.DEFAULT_DIR, check: options.check || [] };
140
+ try {
141
+ if (!ACTIONS.includes(action))
142
+ throw new Error(`unknown action "${action}" (${ACTIONS.join(', ')})`);
143
+ if (action === 'init') return initAction(projectDir, caseFile, json);
144
+ const kase = assurance.loadCase(projectDir, caseFile);
145
+ if (action === 'run') await runAction(projectDir, kase, settings, json);
146
+ else
147
+ verifyAction(
148
+ projectDir,
149
+ kase,
150
+ { ...settings, emitCheck: options.emitCheck, ledgerHead: options.ledgerHead },
151
+ json
152
+ );
153
+ } catch (error) {
154
+ if (json)
155
+ console.log(
156
+ JSON.stringify({ schemaVersion: 1, status: 'error', message: error.message }, null, 2)
157
+ );
158
+ else console.error(`assurance: ${error.message}`);
159
+ process.exitCode = 3;
160
+ }
161
+ },
162
+ };
@@ -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
  )
@@ -0,0 +1,124 @@
1
+ /** Validate, plan, record and render a pack intake; the host asks, the user answers. */
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const intake = require('../lib/intake');
6
+
7
+ const collect = (value, list) => [...list, value];
8
+
9
+ function describePlan(result) {
10
+ const lines = [
11
+ `${result.intake} (${result.pack}): ${result.ready ? 'ready' : 'not ready'} — ` +
12
+ `${result.counts.answered} answered, ${result.counts.defaulted} defaulted, ` +
13
+ `${result.counts.unknown} unknown, ${result.counts.pending} not asked yet.`,
14
+ ];
15
+ if (result.missing.length) lines.push(`Must-haves still open: ${result.missing.join(', ')}`);
16
+ if (result.next.length) {
17
+ lines.push(`Ask next (at most ${result.maxPerMessage} per message):`);
18
+ for (const question of result.next) {
19
+ const hints = [];
20
+ if (question.mustHave) hints.push('must-have');
21
+ if (question.choices) hints.push(`choices: ${question.choices.join(', ')}`);
22
+ if (question.default !== undefined)
23
+ hints.push(`default: ${JSON.stringify(question.default)}`);
24
+ lines.push(
25
+ ` - ${question.id} [${question.kind}] ${question.ask}${hints.length ? ` (${hints.join('; ')})` : ''}`
26
+ );
27
+ }
28
+ }
29
+ for (const unknown of result.unknowns) {
30
+ lines.push(`Unknown: ${unknown.id}${unknown.risk ? ` — risk: ${unknown.risk}` : ''}`);
31
+ }
32
+ return lines;
33
+ }
34
+
35
+ module.exports = {
36
+ command: 'intake <action> [file]',
37
+ description:
38
+ 'Pack intake: check a file, plan the next questions, record answers, render the brief',
39
+ options: [
40
+ ['-d, --directory <path>', 'Project directory (default: current directory)'],
41
+ ['--intake <file>', 'Intake file, relative to the project'],
42
+ ['--answers <file>', 'Answers file, relative to the project (created by record)'],
43
+ [
44
+ '--set <id=VALUE>',
45
+ 'Record an answer, or id=UNKNOWN to mark it unknown (repeatable)',
46
+ collect,
47
+ [],
48
+ ],
49
+ ['--default <id>', 'Record the proposed default of a question (repeatable)', collect, []],
50
+ ['--reset', 'Start the answers file again, empty, for this intake (record)'],
51
+ ['--out <file>', 'Brief to write (render)'],
52
+ ['--json', 'Machine-readable output'],
53
+ ],
54
+ action: (action, file, options = {}) => {
55
+ const projectDir = path.resolve(options.directory || process.cwd());
56
+ const needs = (name) => {
57
+ if (!options[name]) throw new Error(`--${name} is required for intake ${action}`);
58
+ return options[name];
59
+ };
60
+ try {
61
+ let result;
62
+ let lines;
63
+ if (action === 'check') {
64
+ if (!file) throw new Error('Use intake check FILE.');
65
+ result = intake.check({ projectDir, file });
66
+ process.exitCode = result.ok ? 0 : 1;
67
+ lines = result.ok
68
+ ? [
69
+ `${result.file}: valid intake ${result.id} (${result.pack}), ${result.questions} questions, ${result.mustHave} must-have.`,
70
+ ]
71
+ : [
72
+ `${result.file}: not a valid intake`,
73
+ ...result.problems.map((problem) => ` - ${problem}`),
74
+ ];
75
+ result = { status: result.ok ? 'valid' : 'invalid', ...result };
76
+ } else if (action === 'plan') {
77
+ result = intake.plan({
78
+ projectDir,
79
+ intakeFile: needs('intake'),
80
+ answersFile: needs('answers'),
81
+ });
82
+ process.exitCode = result.ready ? 0 : 2;
83
+ lines = describePlan(result);
84
+ result = { status: result.ready ? 'ready' : 'needs-answers', ...result };
85
+ } else if (action === 'record') {
86
+ result = intake.record({
87
+ projectDir,
88
+ intakeFile: needs('intake'),
89
+ answersFile: needs('answers'),
90
+ sets: options.set,
91
+ defaults: options.default,
92
+ reset: options.reset === true,
93
+ });
94
+ process.exitCode = 0;
95
+ lines = [
96
+ result.reset
97
+ ? `Reset ${result.answersFile}${result.recorded.length ? `, recorded ${result.recorded.join(', ')}` : ''}.`
98
+ : `Recorded ${result.recorded.join(', ')} in ${result.answersFile}.`,
99
+ ...describePlan(result),
100
+ ];
101
+ result = { status: 'recorded', ...result };
102
+ } else if (action === 'render') {
103
+ result = intake.render({
104
+ projectDir,
105
+ intakeFile: needs('intake'),
106
+ answersFile: needs('answers'),
107
+ out: needs('out'),
108
+ });
109
+ process.exitCode = 0;
110
+ lines = [
111
+ `Brief written to ${result.out} (${result.bytes} bytes); ${result.ready ? 'the intake is ready' : 'a must-have is still open'}.`,
112
+ ];
113
+ result = { status: 'rendered', ...result };
114
+ } else throw new Error('Use intake check FILE, plan, record or render.');
115
+ if (options.json) console.log(JSON.stringify({ schemaVersion: 1, ...result }, null, 2));
116
+ else for (const line of lines) console.log(line);
117
+ } catch (error) {
118
+ process.exitCode = 1;
119
+ if (options.json)
120
+ console.log(JSON.stringify({ schemaVersion: 1, status: 'error', error: error.message }));
121
+ else console.error('Intake: ' + error.message);
122
+ }
123
+ },
124
+ };
@@ -169,23 +169,30 @@ function rulesAction(projectDir, target, json) {
169
169
  action: 'rules',
170
170
  path: file,
171
171
  sha256: ruleset.sha256,
172
+ packFiles: ruleset.packFiles,
172
173
  projectFile: ruleset.projectFile,
173
174
  disabled: ruleset.disabled,
174
- rules: listed.map(({ id, title, group, layer, globs, source }) => ({
175
+ rules: listed.map(({ id, title, group, layer, pack, globs, controls, source }) => ({
175
176
  id,
176
177
  title,
177
178
  group,
178
179
  layer,
180
+ ...(pack ? { pack } : {}),
179
181
  globs,
182
+ controls,
180
183
  source,
181
184
  })),
185
+ controls: reviewRules.controlsOf(
186
+ ruleset,
187
+ listed.map((rule) => rule.id)
188
+ ),
182
189
  },
183
190
  [
184
- `rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ? ` (built-in + ${ruleset.projectFile})` : ' (built-in)'}`,
191
+ `rule set ${ruleset.sha256.slice(0, 12)} (${['built-in', ...ruleset.packFiles, ...(ruleset.projectFile ? [ruleset.projectFile] : [])].join(' + ')})`,
185
192
  ...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
186
193
  ...listed.map(
187
194
  (rule) =>
188
- ` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}`
195
+ ` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}${rule.controls.length ? `\n ${''.padEnd(24)} controls: ${rule.controls.join(' ')}` : ''}`
189
196
  ),
190
197
  ...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
191
198
  ]
@@ -0,0 +1,119 @@
1
+ /** Run a swarm of sub-agents under a budget: deal, brief, check, file, tally; the host launches. */
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const swarm = require('../lib/swarm');
6
+
7
+ const USAGE =
8
+ 'Use swarm init --definition FILE [--profile FILE | --units FILE] [--context NAME=FILE] [--n N] [--seed N] [--approved], ' +
9
+ 'briefs RUN [--wave N], check RUN, save RUN UNIT --input FILE, tally RUN [--out FILE], inspect RUN, or plan RUN [--out FILE].';
10
+
11
+ function lines(action, result) {
12
+ if (result.status === 'needs-approval') return [`${result.run}: needs approval. ${result.next}`];
13
+ switch (action) {
14
+ case 'init':
15
+ return [
16
+ `${result.run}: ${result.kind} of ${result.n} units, budget ${result.budget}, approval ${result.approval}.`,
17
+ result.seed === null
18
+ ? 'Units listed, no seed.'
19
+ : `Seed ${result.seed} (same seed, same cards).`,
20
+ `Next: ${result.next}`,
21
+ ];
22
+ case 'briefs':
23
+ return [
24
+ `${result.run}: ${result.pending} of ${result.units} units need a sub-agent, in waves of ${result.wave}.`,
25
+ ...result.waves.flatMap((wave) => [
26
+ `Wave ${wave.index}:`,
27
+ ...wave.units.map((unit) => ` ${unit.id} ${unit.brief}`),
28
+ ]),
29
+ `Each sub-agent: ${result.instruction}`,
30
+ `Disclosure: ${result.disclosure}`,
31
+ ];
32
+ case 'check':
33
+ return [
34
+ `${result.run}: ${result.valid} of ${result.units} valid answers.`,
35
+ ...result.pending.map(
36
+ (unit) => ` ${unit.id} ${unit.state}${unit.reason ? `: ${unit.reason}` : ''}`
37
+ ),
38
+ ];
39
+ case 'save':
40
+ return [`${result.run}: answer of ${result.unit} filed at ${result.answer}`];
41
+ case 'tally': {
42
+ const { valid, total, skipped } = result.tally.units;
43
+ return [
44
+ result.files.markdown,
45
+ `${valid} of ${total} units counted; ${skipped.length} skipped.`,
46
+ `Disclosure: ${result.tally.disclosure}`,
47
+ ];
48
+ }
49
+ case 'inspect': {
50
+ const { counts } = result;
51
+ return [
52
+ `${result.run.id}: ${result.status} (${result.run.kind}, ${result.budget.decision})`,
53
+ `units ${counts.units}, briefed ${counts.briefed}, valid ${counts.valid}, invalid ${counts.invalid}, missing ${counts.missing}`,
54
+ `Disclosure: ${result.disclosure}`,
55
+ ];
56
+ }
57
+ case 'plan':
58
+ return [
59
+ `${result.plan}: ${result.tasks} tasks, ${result.maxParallel} in parallel.`,
60
+ `Next: ${result.next}`,
61
+ ];
62
+ default:
63
+ return [JSON.stringify(result)];
64
+ }
65
+ }
66
+
67
+ module.exports = {
68
+ command: 'swarm <action> [run] [unit]',
69
+ description:
70
+ 'Deal, brief, check, file and tally a swarm of sub-agents within the configured budget',
71
+ options: [
72
+ ['-d, --directory <path>', 'Project directory'],
73
+ ['--definition <file>', 'Swarm definition (project-relative JSON) for init'],
74
+ ['--profile <file>', 'Panel profile (project-relative JSON) for init'],
75
+ ['--units <file>', 'Fanout units (project-relative JSON) for init'],
76
+ [
77
+ '--context <name=file>',
78
+ 'Context file included verbatim in the briefs (repeatable)',
79
+ (value, all) => [...all, value],
80
+ [],
81
+ ],
82
+ ['--n <count>', 'Units to deal for a panel'],
83
+ ['--seed <number>', 'Dealing seed; drawn once and recorded when absent'],
84
+ ['--approved', 'Record that the user agreed to exceed the budget'],
85
+ ['--wave <count>', 'Sub-agents launched together (default: the configured wave)'],
86
+ ['--input <file>', 'Printed reply to file with save (project-relative)'],
87
+ ['--out <file>', 'Output file for tally or plan, under .bmad-plus/swarm/'],
88
+ ['--json', 'Machine-readable output'],
89
+ ],
90
+ action: (action, runId, unitId, options = {}) => {
91
+ const directory = path.resolve(options.directory || process.cwd());
92
+ try {
93
+ let result;
94
+ if (action === 'init' && !runId) result = swarm.init(directory, options);
95
+ else if (action === 'briefs' && runId && !unitId)
96
+ result = swarm.briefs(directory, runId, options);
97
+ else if (action === 'check' && runId && !unitId) result = swarm.check(directory, runId);
98
+ else if (action === 'save' && runId && unitId)
99
+ result = swarm.save(directory, runId, unitId, options);
100
+ else if (action === 'tally' && runId && !unitId)
101
+ result = swarm.tally(directory, runId, options);
102
+ else if (action === 'inspect' && runId && !unitId) result = swarm.inspect(directory, runId);
103
+ else if (action === 'plan' && runId && !unitId)
104
+ result = swarm.plan(directory, runId, options);
105
+ else throw new Error(USAGE);
106
+ process.exitCode =
107
+ result.status === 'needs-approval' ? 2 : result.status === 'incomplete' ? 1 : 0;
108
+ if (options.json) console.log(JSON.stringify(result, null, 2));
109
+ else for (const line of lines(action, result)) console.log(line);
110
+ } catch (error) {
111
+ process.exitCode = 1;
112
+ if (options.json)
113
+ console.log(
114
+ JSON.stringify({ schemaVersion: 1, status: 'error', error: error.message }, null, 2)
115
+ );
116
+ else console.error('Swarm: ' + error.message.replace(/^Swarm: /, ''));
117
+ }
118
+ },
119
+ };
@@ -11,6 +11,7 @@ const pc = require('picocolors');
11
11
  const { t } = require('../i18n');
12
12
  const { PACKS, DERIVED } = require('../lib/packs');
13
13
  const { readInstallManifest } = require('../lib/install-manifest');
14
+ const { externalModuleRoot } = require('../lib/pack-copy');
14
15
  const { generateUserFiles, USER_CONFIG_MARKER } = require('../../build/generate-adapters');
15
16
  const { contentHash } = require('../lib/installed-adapters');
16
17
 
@@ -44,6 +45,16 @@ function safeStat(projectDir, target) {
44
45
  return stat;
45
46
  }
46
47
 
48
+ // The skills copied from an npm package are matched against that package. When it is gone
49
+ // or no longer the pinned version, nothing can be compared and they stay.
50
+ function resolvedExternalModule(externalModule, packageRoot) {
51
+ try {
52
+ return externalModuleRoot({ externalModule, projectRoot: packageRoot });
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
47
58
  function removalPlan(projectDir, manifest, manifestPath) {
48
59
  const packageRoot = path.join(__dirname, '..', '..', '..');
49
60
  const sourceRoot = path.join(packageRoot, 'src', 'bmad-plus');
@@ -91,6 +102,12 @@ function removalPlan(projectDir, manifest, manifestPath) {
91
102
  path.join(packageRoot, pack.externalPackage, 'skills'),
92
103
  targetPath('.agents', 'skills')
93
104
  );
105
+ if (pack.externalModule) {
106
+ const moduleRoot = resolvedExternalModule(pack.externalModule, packageRoot);
107
+ if (moduleRoot)
108
+ for (const name of pack.externalModule.skills)
109
+ matchSource(path.join(moduleRoot, 'skills', name), targetPath('.agents', 'skills', name));
110
+ }
94
111
  }
95
112
  for (const name of ['module.yaml', 'module-help.csv'])
96
113
  matchSource(path.join(sourceRoot, name), targetPath('_bmad', name));