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
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Review checklists chosen by path. Built-in rules ship with the CLI; a project adds,
3
- * replaces or disables rules in `_bmad/review-rules.yaml`. Every rule whose patterns match
4
- * a file applies to it — rules add up, so a broad project rule never silences the built-in
5
- * ones by accident. The resolved set is hashed into the review scope.
2
+ * Review checklists chosen by path. Built-in rules ship with the CLI; an installed pack may
3
+ * add its own (Shield's compliance rules); a project adds, replaces or disables rules in
4
+ * `_bmad/review-rules.yaml`. Every rule whose patterns match a file applies to it — rules
5
+ * add up, so a broad project rule never silences the built-in ones by accident. A rule may
6
+ * name the compliance controls it examines. The resolved set is hashed into the review scope.
6
7
  */
7
8
  'use strict';
8
9
 
@@ -11,12 +12,16 @@ const path = require('node:path');
11
12
  const crypto = require('node:crypto');
12
13
  const yaml = require('js-yaml');
13
14
  const { compile, matchesAny } = require('./glob');
15
+ const { parseControls } = require('./control-refs');
16
+ const { PACKS } = require('./packs');
14
17
 
15
18
  const SCHEMA = 'bmad-plus/review-rules/1';
16
19
  const BUILTIN_DIR = path.join(__dirname, '..', 'review-rules');
17
20
  const PROJECT_FILE = path.join('_bmad', 'review-rules.yaml');
21
+ /** Where an installed pack keeps its rules, below its own folder in `.agents/skills`. */
22
+ const PACK_RULES_DIR = 'review-rules';
18
23
  const RULE_ID = /^[a-z0-9][a-z0-9-]{0,60}$/;
19
- const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc'];
24
+ const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc', 'controls'];
20
25
  const MAX_DOC_BYTES = 64 * 1024;
21
26
  const MAX_INDEX_BYTES = 256 * 1024;
22
27
 
@@ -47,8 +52,8 @@ function parseLayer(root, indexName, layer) {
47
52
  if (!doc || doc.schema !== SCHEMA) throw new Error(`${where}: schema must be "${SCHEMA}"`);
48
53
  const extra = Object.keys(doc).filter((key) => !['schema', 'rules', 'disable'].includes(key));
49
54
  if (extra.length) throw new Error(`${where}: unknown key(s) ${extra.join(', ')}`);
50
- if (layer === 'builtin' && doc.disable)
51
- throw new Error(`${where}: built-in rules cannot disable`);
55
+ if (layer !== 'project' && doc.disable)
56
+ throw new Error(`${where}: only a project's rules can disable`);
52
57
  const disable = doc.disable || [];
53
58
  if (!Array.isArray(disable) || !disable.every((id) => typeof id === 'string'))
54
59
  throw new Error(`${where}: disable must be a list of rule ids`);
@@ -74,6 +79,7 @@ function parseLayer(root, indexName, layer) {
74
79
  )
75
80
  throw new Error(`${at}: globs must be a non-empty list of patterns`);
76
81
  for (const glob of rule.globs) compile(glob);
82
+ const controls = rule.controls === undefined ? [] : parseControls(rule.controls, at);
77
83
  if (!/\.md$/i.test(String(rule.doc))) throw new Error(`${at}: doc must be a Markdown file`);
78
84
  const text = readConfined(root, rule.doc, MAX_DOC_BYTES, at).trim();
79
85
  if (!text) throw new Error(`${at}: ${rule.doc} is empty`);
@@ -83,6 +89,7 @@ function parseLayer(root, indexName, layer) {
83
89
  // A rule family reviewers can split by; a rule without one is a family of its own.
84
90
  group: rule.group === undefined ? rule.id : rule.group,
85
91
  globs: [...rule.globs],
92
+ controls,
86
93
  layer,
87
94
  source: rule.doc,
88
95
  text,
@@ -91,13 +98,35 @@ function parseLayer(root, indexName, layer) {
91
98
  return { rules: parsed, disable };
92
99
  }
93
100
 
101
+ /** Rule indexes of the packs installed in the project, in pack order. */
102
+ function installedPackLayers(projectDir) {
103
+ const layers = [];
104
+ for (const [pack, { packDir }] of Object.entries(PACKS)) {
105
+ if (!packDir) continue;
106
+ const relative = ['.agents', 'skills', packDir, PACK_RULES_DIR, 'index.yaml'];
107
+ if (fs.existsSync(path.join(projectDir, ...relative)))
108
+ layers.push({ pack, file: relative.join('/') });
109
+ }
110
+ return layers;
111
+ }
112
+
94
113
  /**
95
- * The effective rule set for a project: built-in rules, then the project's own. A project
96
- * rule with a built-in id replaces it; `disable` removes rules by id and is reported.
114
+ * The effective rule set for a project: built-in rules, then those of installed packs, then
115
+ * the project's own. A pack only adds; a project rule with an existing id replaces it, and
116
+ * `disable` removes rules by id and is reported.
97
117
  */
98
118
  function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
99
119
  const builtin = parseLayer(builtinDir, 'index.yaml', 'builtin');
100
120
  const ordered = new Map(builtin.rules.map((rule) => [rule.id, rule]));
121
+ const packFiles = installedPackLayers(projectDir);
122
+ for (const { pack, file } of packFiles) {
123
+ const layer = parseLayer(path.join(projectDir, path.dirname(file)), 'index.yaml', 'pack');
124
+ for (const rule of layer.rules) {
125
+ if (ordered.has(rule.id))
126
+ throw new Error(`${pack} review rules: rule ${rule.id} already exists; a pack only adds`);
127
+ ordered.set(rule.id, { ...rule, pack });
128
+ }
129
+ }
101
130
  let disabled = [];
102
131
  const projectIndex = path.join(projectDir, PROJECT_FILE);
103
132
  let project = null;
@@ -111,16 +140,20 @@ function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
111
140
  for (const id of disabled) ordered.delete(id);
112
141
  }
113
142
  const list = [...ordered.values()];
143
+ // Controls join the fingerprint only when a rule names some, so a rule set without any
144
+ // keeps the hash it had before rules could carry controls.
114
145
  const fingerprint = list.map((rule) => [
115
146
  rule.id,
116
147
  rule.group,
117
148
  rule.layer,
118
149
  rule.globs,
119
150
  sha256(rule.text),
151
+ ...(rule.controls.length ? [rule.controls] : []),
120
152
  ]);
121
153
  return {
122
154
  rules: list,
123
155
  disabled,
156
+ packFiles: packFiles.map(({ file }) => file),
124
157
  projectFile: project ? PROJECT_FILE.split(path.sep).join('/') : null,
125
158
  sha256: sha256(JSON.stringify({ rules: fingerprint, disabled })),
126
159
  };
@@ -142,35 +175,68 @@ function groupsOf(ruleset, ids) {
142
175
  }
143
176
 
144
177
  /**
145
- * The reviewer's checklist for a scope: each applicable rule once, with the files it
146
- * covers. Rules that match nothing in the scope are left out.
178
+ * The compliance controls a set of rule ids examines, each with the rules that name it, in
179
+ * rule order. A control touched is a prompt to look at its requirement, not a finding.
180
+ */
181
+ function controlsOf(ruleset, ids) {
182
+ const controls = new Map();
183
+ for (const rule of ruleset.rules) {
184
+ if (!ids.includes(rule.id)) continue;
185
+ for (const control of rule.controls)
186
+ controls.set(control, [...(controls.get(control) || []), rule.id]);
187
+ }
188
+ return [...controls].map(([id, rules]) => ({ id, rules }));
189
+ }
190
+
191
+ /**
192
+ * The reviewer's checklist for a scope: the controls the change touches, then each
193
+ * applicable rule once, with the files it covers. Rules that match nothing are left out.
147
194
  */
148
195
  function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
149
196
  const files = new Map();
150
197
  for (const [file, ids] of Object.entries(byPath)) {
151
198
  for (const id of ids) files.set(id, [...(files.get(id) || []), file]);
152
199
  }
153
- const sections = ruleset.rules
154
- .filter((rule) => files.has(rule.id))
155
- .map((rule) =>
156
- [
157
- `## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}`,
200
+ const applied = ruleset.rules.filter((rule) => files.has(rule.id));
201
+ const controls = controlsOf(
202
+ ruleset,
203
+ applied.map((rule) => rule.id)
204
+ );
205
+ const touched = controls.length
206
+ ? [
207
+ '## Controls this change touches',
158
208
  '',
159
- `Group: \`${rule.group}\``,
209
+ ...controls.map(
210
+ ({ id, rules }) => `- \`${id}\` — ${rules.map((rule) => `\`${rule}\``).join(', ')}`
211
+ ),
160
212
  '',
161
- `Applies to: ${files
162
- .get(rule.id)
163
- .map((file) => `\`${file}\``)
164
- .join(', ')}`,
213
+ 'List in the `controls` of a finding the control it breaks; a control listed here is not a finding.',
165
214
  '',
166
- rule.text,
167
- ].join('\n')
168
- );
215
+ ]
216
+ : [];
217
+ const sections = applied.map((rule) =>
218
+ [
219
+ `## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}${rule.pack ? ` (${rule.pack} rule)` : ''}`,
220
+ '',
221
+ `Group: \`${rule.group}\``,
222
+ ...(rule.controls.length
223
+ ? ['', `Controls: ${rule.controls.map((control) => `\`${control}\``).join(', ')}`]
224
+ : []),
225
+ '',
226
+ `Applies to: ${files
227
+ .get(rule.id)
228
+ .map((file) => `\`${file}\``)
229
+ .join(', ')}`,
230
+ '',
231
+ rule.text,
232
+ ].join('\n')
233
+ );
169
234
  return [
170
235
  `# ${title}`,
171
236
  '',
172
237
  `Rule set ${ruleset.sha256.slice(0, 12)}. Report a finding only with its quoted code; a rule is a prompt to look, not a finding.`,
173
238
  '',
239
+ ...touched,
174
240
  ...sections.flatMap((section) => [section, '']),
175
241
  ].join('\n');
176
242
  }
@@ -179,8 +245,10 @@ module.exports = {
179
245
  SCHEMA,
180
246
  BUILTIN_DIR,
181
247
  PROJECT_FILE,
248
+ PACK_RULES_DIR,
182
249
  loadRuleset,
183
250
  rulesFor,
184
251
  groupsOf,
252
+ controlsOf,
185
253
  checklist,
186
254
  };
@@ -295,6 +295,9 @@ function buildScope(projectDir, options = {}) {
295
295
  const ruleset = options.ruleset || rules.loadRuleset(projectDir);
296
296
  for (const item of selected) {
297
297
  item.rules = rules.rulesFor(ruleset, item.path);
298
+ // Kept only when a rule names controls, so a scope without any keeps its digest.
299
+ const controls = rules.controlsOf(ruleset, item.rules).map(({ id }) => id);
300
+ if (controls.length) item.controls = controls;
298
301
  // A commit fixes the content under review; a working tree does not, so its bytes are sealed.
299
302
  if (options.workspace) item.sha256 = contentDigest(projectDir, item.path);
300
303
  }
@@ -312,8 +315,14 @@ function buildScope(projectDir, options = {}) {
312
315
  };
313
316
  const units = planUnits(selected, options.unitLimits).map((unit) => {
314
317
  const ids = [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))];
318
+ const controls = rules.controlsOf(ruleset, ids).map(({ id }) => id);
315
319
  // Rule groups let parallel reviewers split one unit by rule family.
316
- return { ...unit, rules: ids, groups: rules.groupsOf(ruleset, ids) };
320
+ return {
321
+ ...unit,
322
+ rules: ids,
323
+ groups: rules.groupsOf(ruleset, ids),
324
+ ...(controls.length ? { controls } : {}),
325
+ };
317
326
  });
318
327
  const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
319
328
  const plan = reviewPlan(effort, lines, units.length);
@@ -349,6 +358,7 @@ const FINDING_KEYS = [
349
358
  'refutation',
350
359
  'fix',
351
360
  'rule',
361
+ 'controls',
352
362
  ];
353
363
 
354
364
  /** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
@@ -395,6 +405,21 @@ function validateFindings(doc, scope) {
395
405
  // A finding may name the checklist rule that led to it; the rule must apply to that file.
396
406
  if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
397
407
  errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
408
+ // It may name the controls it breaks: only those the rules for that file examine.
409
+ if (finding.controls !== undefined) {
410
+ if (
411
+ !Array.isArray(finding.controls) ||
412
+ !finding.controls.length ||
413
+ !finding.controls.every((control) => typeof control === 'string')
414
+ )
415
+ errors.push(`${at}: controls must be a non-empty list of control ids`);
416
+ else if (entry)
417
+ for (const control of finding.controls)
418
+ if (!(entry.controls || []).includes(control))
419
+ errors.push(
420
+ `${at}: control "${control}" is not examined by the rules for ${finding.path}`
421
+ );
422
+ }
398
423
  }
399
424
  return errors;
400
425
  }
@@ -755,6 +780,7 @@ function checkResult({ id, scope, verdict, anchored }) {
755
780
  confidence: f.confidence,
756
781
  disposition: f.disposition,
757
782
  ...(f.rule !== undefined ? { rule: f.rule } : {}),
783
+ ...(f.controls !== undefined ? { controls: f.controls } : {}),
758
784
  anchor: f.location.status,
759
785
  lineStart: f.location.status === 'located' ? f.location.lineStart : null,
760
786
  lineEnd: f.location.status === 'located' ? f.location.lineEnd : null,
@@ -934,6 +960,7 @@ function remainingWork({ scope, coverage, anchored, findingErrors = [] }) {
934
960
  id: unit.id,
935
961
  paths: unit.paths.filter((file) => pending.has(file)),
936
962
  rules: unit.rules,
963
+ ...(unit.controls ? { controls: unit.controls } : {}),
937
964
  // Rule groups another reviewer already completed are not handed out again.
938
965
  ...(unit.groups
939
966
  ? { groups: unit.groups.filter((group) => !done.has(`${unit.id}/${group.id}`)) }
@@ -0,0 +1,116 @@
1
+ /** Deterministic dealing: the same profile, n and seed give the same cards, on any platform. */
2
+ 'use strict';
3
+
4
+ const crypto = require('node:crypto');
5
+
6
+ const WORD = 2 ** 32;
7
+
8
+ /** A counter-mode generator over SHA-256: the stream depends on the seed alone. */
9
+ class Dealer {
10
+ constructor(seed) {
11
+ this.seed = String(seed);
12
+ this.block = 0;
13
+ this.words = [];
14
+ }
15
+
16
+ next() {
17
+ if (!this.words.length) {
18
+ const digest = crypto
19
+ .createHash('sha256')
20
+ .update(this.seed + ':' + this.block)
21
+ .digest();
22
+ this.block += 1;
23
+ for (let offset = 0; offset < digest.length; offset += 4)
24
+ this.words.push(digest.readUInt32BE(offset));
25
+ }
26
+ return this.words.shift();
27
+ }
28
+
29
+ /** Uniform integer in [0, n): values above the last full cycle are rejected, never folded. */
30
+ below(n) {
31
+ const limit = WORD - (WORD % n);
32
+ let value = this.next();
33
+ while (value >= limit) value = this.next();
34
+ return value % n;
35
+ }
36
+
37
+ between(low, high) {
38
+ if (Number.isInteger(low) && Number.isInteger(high)) return low + this.below(high - low + 1);
39
+ return Math.round((low + (this.next() / WORD) * (high - low)) * 100) / 100;
40
+ }
41
+
42
+ shuffle(items) {
43
+ for (let index = items.length - 1; index > 0; index -= 1) {
44
+ const other = this.below(index + 1);
45
+ [items[index], items[other]] = [items[other], items[index]];
46
+ }
47
+ return items;
48
+ }
49
+ }
50
+
51
+ /** Largest-remainder quotas: the counts add up to n exactly and follow the shares. */
52
+ function quotas(shares, n) {
53
+ const exact = shares.map((share) => share * n);
54
+ const counts = exact.map((value) => Math.floor(value));
55
+ let left = n - counts.reduce((sum, count) => sum + count, 0);
56
+ const order = exact
57
+ .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
58
+ .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
59
+ for (const { index } of order) {
60
+ if (left === 0) break;
61
+ counts[index] += 1;
62
+ left -= 1;
63
+ }
64
+ return counts;
65
+ }
66
+
67
+ function unitId(prefix, position, n) {
68
+ return prefix + String(position).padStart(Math.max(3, String(n).length), '0');
69
+ }
70
+
71
+ function dealCards(profile, n, seed, idPrefix) {
72
+ const dealer = new Dealer(seed);
73
+ const slots = profile.dimensions.map((dimension) => {
74
+ const counts = quotas(
75
+ dimension.values.map((entry) => entry.share),
76
+ n
77
+ );
78
+ const column = [];
79
+ counts.forEach((count, index) => {
80
+ for (let k = 0; k < count; k += 1) column.push(index);
81
+ });
82
+ return dealer.shuffle(column);
83
+ });
84
+ const cards = [];
85
+ for (let position = 0; position < n; position += 1) {
86
+ const card = { id: unitId(idPrefix, position + 1, n) };
87
+ profile.dimensions.forEach((dimension, index) => {
88
+ const entry = dimension.values[slots[index][position]];
89
+ card[dimension.name] = entry.value;
90
+ // A note is a field of the whole dimension, so every card carries it, empty or not.
91
+ if (dimension.values.some((value) => value.note !== null))
92
+ card[dimension.name + '_note'] = entry.note ?? '';
93
+ for (const [name, [low, high]] of Object.entries(entry.ranges))
94
+ card[name] = dealer.between(low, high);
95
+ });
96
+ for (const [name, [low, high]] of Object.entries(profile.ranges))
97
+ card[name] = dealer.between(low, high);
98
+ for (const [name, pool] of Object.entries(profile.pools))
99
+ card[name] = pool[dealer.below(pool.length)];
100
+ cards.push(card);
101
+ }
102
+ return cards;
103
+ }
104
+
105
+ function listCards(units, idPrefix) {
106
+ const cards = units.map((unit, index) => ({
107
+ ...unit,
108
+ id: unit.id ?? unitId(idPrefix, index + 1, units.length),
109
+ }));
110
+ // Answer files are named by id, and some file systems fold case.
111
+ if (new Set(cards.map((card) => card.id.toLowerCase())).size !== cards.length)
112
+ throw new Error('Swarm: jury unit ids must be distinct once dealt, whatever their case.');
113
+ return cards;
114
+ }
115
+
116
+ module.exports = { Dealer, quotas, unitId, dealCards, listCards };
@@ -0,0 +1,6 @@
1
+ /** The swarm block bmad-plus install writes; an absent block in a project means these values. */
2
+ 'use strict';
3
+
4
+ const SWARM_DEFAULTS = Object.freeze({ budget: 26, wave: 10, media_credits: 'ask' });
5
+
6
+ module.exports = { SWARM_DEFAULTS };
@@ -0,0 +1,195 @@
1
+ /** Counts valid answers by group; quotes stay verbatim and the disclosure comes first. */
2
+ 'use strict';
3
+
4
+ const round = (value) => Math.round(value * 10000) / 10000;
5
+
6
+ function stats(values) {
7
+ if (!values.length) return { count: 0, median: null, mean: null, min: null, max: null };
8
+ const sorted = [...values].sort((a, b) => a - b);
9
+ const middle = sorted.length >> 1;
10
+ const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
11
+ return {
12
+ count: sorted.length,
13
+ median: round(median),
14
+ mean: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
15
+ min: sorted[0],
16
+ max: sorted[sorted.length - 1],
17
+ };
18
+ }
19
+
20
+ function rateOf(rows, field) {
21
+ const of = rows.length;
22
+ const yes = rows.filter(({ answer }) => answer[field] === true).length;
23
+ return { yes, of, rate: of ? round(yes / of) : null };
24
+ }
25
+
26
+ function countsOf(rows, field, values) {
27
+ const counts = Object.fromEntries(values.map((value) => [value, 0]));
28
+ for (const { answer } of rows) if (Object.hasOwn(answer, field)) counts[answer[field]] += 1;
29
+ return counts;
30
+ }
31
+
32
+ function quotesOf(rows, field, keep) {
33
+ return rows
34
+ .filter(({ answer }) => typeof answer[field] === 'string' && answer[field].trim())
35
+ .slice(0, keep)
36
+ .map(({ card, answer }) => ({ unit: card.id, text: answer[field] }));
37
+ }
38
+
39
+ function numbersOf(rows, field) {
40
+ return stats(
41
+ rows.map(({ answer }) => answer[field]).filter((value) => typeof value === 'number')
42
+ );
43
+ }
44
+
45
+ /**
46
+ * @param rows valid answers in unit order: [{ card, answer }]
47
+ * @param skipped units without a valid answer: [{ unit, reason }]
48
+ */
49
+ function tally({ run, definition, cards, rows, skipped }) {
50
+ const { groupBy } = definition.tally;
51
+ const groupValues = Object.fromEntries(
52
+ groupBy.map((field) => [field, [...new Set(cards.map((card) => String(card[field])))]])
53
+ );
54
+ const partition = (compute) => ({
55
+ all: compute(rows),
56
+ groups: Object.fromEntries(
57
+ groupBy.map((field) => [
58
+ field,
59
+ Object.fromEntries(
60
+ groupValues[field].map((value) => [
61
+ value,
62
+ compute(rows.filter(({ card }) => String(card[field]) === value)),
63
+ ])
64
+ ),
65
+ ])
66
+ ),
67
+ });
68
+ const { rate, counts, quotes, numbers } = definition.tally;
69
+ return {
70
+ schemaVersion: 1,
71
+ run: run.id,
72
+ definition: definition.id,
73
+ kind: definition.kind,
74
+ title: definition.title,
75
+ disclosure: definition.disclosure,
76
+ units: { total: cards.length, valid: rows.length, skipped },
77
+ groupBy,
78
+ rate: rate ? { field: rate, ...partition((set) => rateOf(set, rate)) } : null,
79
+ counts: Object.fromEntries(
80
+ counts.map((field) => [
81
+ field,
82
+ partition((set) => countsOf(set, field, definition.answer.fields[field].values)),
83
+ ])
84
+ ),
85
+ quotes: Object.fromEntries(
86
+ Object.entries(quotes).map(([field, keep]) => [
87
+ field,
88
+ { keep, ...partition((set) => quotesOf(set, field, keep)) },
89
+ ])
90
+ ),
91
+ numbers: Object.fromEntries(
92
+ numbers.map((field) => [field, partition((set) => numbersOf(set, field))])
93
+ ),
94
+ };
95
+ }
96
+
97
+ const percent = (rate) => (rate === null ? 'n/a' : (rate * 100).toFixed(1) + '%');
98
+ const cell = (value) => (value === null ? 'n/a' : String(value));
99
+
100
+ function sections(section, render) {
101
+ const lines = render('all', section.all);
102
+ for (const [field, values] of Object.entries(section.groups))
103
+ for (const [value, subset] of Object.entries(values))
104
+ lines.push(...render(field + ' = ' + value, subset));
105
+ return lines;
106
+ }
107
+
108
+ function renderTally(result) {
109
+ const lines = [
110
+ '> ' + result.disclosure,
111
+ '',
112
+ '# ' + result.title + ' (run ' + result.run + ')',
113
+ '',
114
+ ];
115
+ const skipped = result.units.skipped;
116
+ lines.push(
117
+ result.units.valid +
118
+ ' of ' +
119
+ result.units.total +
120
+ ' units gave a valid answer; ' +
121
+ skipped.length +
122
+ ' skipped' +
123
+ (skipped.length
124
+ ? ': ' + skipped.map((entry) => entry.unit + ' (' + entry.reason + ')').join(', ')
125
+ : '') +
126
+ '.'
127
+ );
128
+ if (result.rate) {
129
+ lines.push(
130
+ '',
131
+ '## Rate: ' + result.rate.field,
132
+ '',
133
+ '| group | yes | of | rate |',
134
+ '|---|---|---|---|'
135
+ );
136
+ lines.push(
137
+ ...sections(result.rate, (name, value) => [
138
+ '| ' + name + ' | ' + value.yes + ' | ' + value.of + ' | ' + percent(value.rate) + ' |',
139
+ ])
140
+ );
141
+ }
142
+ for (const [field, section] of Object.entries(result.counts)) {
143
+ const values = Object.keys(section.all);
144
+ lines.push(
145
+ '',
146
+ '## Counts: ' + field,
147
+ '',
148
+ '| group | ' + values.join(' | ') + ' |',
149
+ '|---|' + values.map(() => '---|').join('')
150
+ );
151
+ lines.push(
152
+ ...sections(section, (name, value) => [
153
+ '| ' + name + ' | ' + values.map((key) => value[key]).join(' | ') + ' |',
154
+ ])
155
+ );
156
+ }
157
+ for (const [field, section] of Object.entries(result.quotes)) {
158
+ lines.push('', '## Quotes: ' + field + ' (up to ' + section.keep + ' per group, verbatim)');
159
+ lines.push(
160
+ ...sections(section, (name, value) => [
161
+ '',
162
+ '**' + name + '**',
163
+ ...(value.length
164
+ ? value.map((quote) => '- ' + quote.unit + ': ' + JSON.stringify(quote.text))
165
+ : ['- (no quote)']),
166
+ ])
167
+ );
168
+ }
169
+ for (const [field, section] of Object.entries(result.numbers)) {
170
+ lines.push(
171
+ '',
172
+ '## Numbers: ' + field,
173
+ '',
174
+ '| group | count | median | mean | min | max |',
175
+ '|---|---|---|---|---|---|'
176
+ );
177
+ lines.push(
178
+ ...sections(section, (name, value) => [
179
+ '| ' +
180
+ [
181
+ name,
182
+ value.count,
183
+ cell(value.median),
184
+ cell(value.mean),
185
+ cell(value.min),
186
+ cell(value.max),
187
+ ].join(' | ') +
188
+ ' |',
189
+ ])
190
+ );
191
+ }
192
+ return lines.join('\n') + '\n';
193
+ }
194
+
195
+ module.exports = { tally, renderTally, stats };