bmad-plus 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +23 -13
  3. package/SECURITY.md +1 -1
  4. package/package.json +4 -1
  5. package/readme-international/README.de.md +23 -13
  6. package/readme-international/README.es.md +23 -13
  7. package/readme-international/README.fr.md +23 -13
  8. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
  9. package/src/bmad-plus/data/role-triggers.yaml +34 -0
  10. package/src/bmad-plus/module-help.csv +2 -0
  11. package/src/bmad-plus/module.yaml +21 -0
  12. package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
  13. package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
  14. package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
  15. package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
  16. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +7 -0
  17. package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
  18. package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
  19. package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
  20. package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
  21. package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
  22. package/tools/build/adapters.config.js +2 -0
  23. package/tools/build/generate-adapters.js +25 -1
  24. package/tools/build/generate.js +269 -7
  25. package/tools/build/intake-contract.js +522 -0
  26. package/tools/cli/bmad-plus-cli.js +2 -0
  27. package/tools/cli/commands/install.js +28 -1
  28. package/tools/cli/commands/intake.js +124 -0
  29. package/tools/cli/commands/swarm.js +119 -0
  30. package/tools/cli/commands/uninstall.js +17 -0
  31. package/tools/cli/i18n.js +20 -0
  32. package/tools/cli/lib/intake.js +202 -0
  33. package/tools/cli/lib/pack-copy.js +95 -5
  34. package/tools/cli/lib/packs.js +97 -7
  35. package/tools/cli/lib/swarm-deal.js +116 -0
  36. package/tools/cli/lib/swarm-defaults.js +6 -0
  37. package/tools/cli/lib/swarm-tally.js +195 -0
  38. package/tools/cli/lib/swarm-validate.js +589 -0
  39. package/tools/cli/lib/swarm.js +712 -0
  40. package/tools/cli/lib/update-transaction.js +8 -4
@@ -8,11 +8,90 @@ const crypto = require('node:crypto');
8
8
  const { safeTarget } = require('../../build/generate-adapters');
9
9
 
10
10
  const fileHash = (content) => crypto.createHash('sha256').update(content).digest('hex');
11
+ const MAX_MANIFEST = 1024 * 1024;
11
12
 
12
- function listPackFiles({ bmadSrc, targetAgentsDir, targetDataDir, projectRoot, pack }) {
13
+ /** The package name comes from the generated registry data, never from user input. */
14
+ function resolveModule(name, projectRoot) {
15
+ return require.resolve(name + '/package.json', { paths: [projectRoot] });
16
+ }
17
+
18
+ /** The node_modules directories Node searches from the BMAD+ package root upwards. */
19
+ function moduleChain(projectRoot) {
20
+ const chain = [];
21
+ let current = fs.realpathSync(projectRoot);
22
+ for (;;) {
23
+ if (path.basename(current) !== 'node_modules') chain.push(path.join(current, 'node_modules'));
24
+ const parent = path.dirname(current);
25
+ if (parent === current) return chain;
26
+ current = parent;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The root of an `external_module` package installed next to BMAD+, once its
32
+ * version is the one the registry pinned. Node resolves through symlinks, so the
33
+ * resolved package must sit in the node_modules chain itself: a link or a global
34
+ * module path is refused.
35
+ */
36
+ function externalModuleRoot({ externalModule, projectRoot, resolve = resolveModule }) {
37
+ const { package: name, version } = externalModule;
38
+ let manifestPath;
39
+ try {
40
+ manifestPath = resolve(name, projectRoot);
41
+ } catch (error) {
42
+ throw new Error(
43
+ 'External module ' + name + ' is not installed next to BMAD+: ' + error.message,
44
+ { cause: error }
45
+ );
46
+ }
47
+ const expected = moduleChain(projectRoot).map((dir) =>
48
+ path.join(dir, ...name.split('/'), 'package.json')
49
+ );
50
+ if (!expected.includes(manifestPath)) {
51
+ throw new Error(
52
+ 'External module ' +
53
+ name +
54
+ ' resolved to ' +
55
+ manifestPath +
56
+ ', outside the node_modules chain of this BMAD+. A symlinked package, a global module path and the isolated layout of pnpm (node_modules/.pnpm) are refused: install bmad-plus with npm so its pinned dependency sits in that chain.'
57
+ );
58
+ }
59
+ const root = path.dirname(manifestPath);
60
+ safeTarget(root, 'package.json');
61
+ const stat = fs.lstatSync(manifestPath);
62
+ if (!stat.isFile() || stat.size > MAX_MANIFEST)
63
+ throw new Error('External module ' + name + ' has no readable package.json');
64
+ let manifest;
65
+ try {
66
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
67
+ } catch (error) {
68
+ throw new Error('External module ' + name + ': ' + error.message, { cause: error });
69
+ }
70
+ if (!manifest || manifest.name !== name || manifest.version !== version) {
71
+ throw new Error(
72
+ 'External module ' +
73
+ name +
74
+ ' is ' +
75
+ String(manifest && manifest.version) +
76
+ ' but this BMAD+ expects exactly ' +
77
+ version +
78
+ '; reinstall bmad-plus so its pinned dependency matches.'
79
+ );
80
+ }
81
+ return root;
82
+ }
83
+
84
+ function listPackFiles({
85
+ bmadSrc,
86
+ targetAgentsDir,
87
+ targetDataDir,
88
+ projectRoot,
89
+ pack,
90
+ resolveModule: resolve,
91
+ }) {
13
92
  const result = { copiedAgents: 0, copiedSkills: 0, copiedFiles: 0, files: [] };
14
93
  if (!pack) return result;
15
- function walk(source, target) {
94
+ function walk(source, target, root) {
16
95
  // Local Python execution must not turn interpreter caches into owned/shipped
17
96
  // framework files. Keep this aligned with the explicit npm files exclusions.
18
97
  if (path.basename(source) === '__pycache__' || /\.py[co]$/i.test(source)) return false;
@@ -26,9 +105,9 @@ function listPackFiles({ bmadSrc, targetAgentsDir, targetDataDir, projectRoot, p
26
105
  if (stat.isSymbolicLink()) throw new Error('Pack source is a symbolic link: ' + source);
27
106
  if (stat.isDirectory()) {
28
107
  for (const name of fs.readdirSync(source).sort())
29
- walk(path.join(source, name), path.join(target, name));
108
+ walk(path.join(source, name), path.join(target, name), root);
30
109
  } else if (stat.isFile()) {
31
- result.files.push({ source, target });
110
+ result.files.push(root ? { source, target, root } : { source, target });
32
111
  } else throw new Error('Pack source is not a regular file: ' + source);
33
112
  return true;
34
113
  }
@@ -49,6 +128,17 @@ function listPackFiles({ bmadSrc, targetAgentsDir, targetDataDir, projectRoot, p
49
128
  walk(path.join(projectRoot, pack.externalPackage, 'skills'), targetAgentsDir)
50
129
  )
51
130
  result.copiedSkills++;
131
+ if (pack.externalModule) {
132
+ const root = externalModuleRoot({ externalModule: pack.externalModule, projectRoot, resolve });
133
+ for (const name of pack.externalModule.skills) {
134
+ if (!walk(path.join(root, 'skills', name), path.join(targetAgentsDir, name), root)) {
135
+ throw new Error(
136
+ 'External module ' + pack.externalModule.package + ' does not ship skill ' + name
137
+ );
138
+ }
139
+ result.copiedSkills++;
140
+ }
141
+ }
52
142
  if (
53
143
  pack.packDir &&
54
144
  walk(
@@ -105,4 +195,4 @@ function copyPackFiles(options) {
105
195
  return counts;
106
196
  }
107
197
 
108
- module.exports = { copyPackFiles, listPackFiles, copyManagedFile, fileHash };
198
+ module.exports = { copyPackFiles, listPackFiles, copyManagedFile, externalModuleRoot, fileHash };
@@ -18,8 +18,10 @@ const PACKS = {
18
18
  ],
19
19
  "skills": [
20
20
  "bmad-plus-autopilot",
21
+ "bmad-plus-intake",
21
22
  "bmad-plus-parallel",
22
23
  "bmad-plus-sync",
24
+ "bmad-plus-swarm",
23
25
  "bmad-plus-uat"
24
26
  ],
25
27
  "data": [
@@ -103,6 +105,25 @@ const PACKS = {
103
105
  "packDir": "pack-animated",
104
106
  "packSrcDir": "packs",
105
107
  "desc": "Animated website agents"
108
+ },
109
+ "design": {
110
+ "name": "Design",
111
+ "icon": "d",
112
+ "agents": [],
113
+ "skills": [],
114
+ "externalModule": {
115
+ "package": "design-skill",
116
+ "version": "0.2.1",
117
+ "skills": [
118
+ "design",
119
+ "design-review",
120
+ "redesign"
121
+ ]
122
+ },
123
+ "packDir": "pack-design",
124
+ "packSrcDir": "packs",
125
+ "intake": "intake.yaml",
126
+ "desc": "Design engine: build, review and redesign interfaces"
106
127
  }
107
128
  };
108
129
 
@@ -115,7 +136,8 @@ const PACK_ORDER = [
115
136
  "memory",
116
137
  "dev-studio",
117
138
  "backup",
118
- "animated"
139
+ "animated",
140
+ "design"
119
141
  ];
120
142
 
121
143
  const EXPECTED_AGENTS = {
@@ -184,6 +206,13 @@ const EXPECTED_AGENTS = {
184
206
  "packAgents": [
185
207
  "animated-website-agent.md"
186
208
  ]
209
+ },
210
+ "design": {
211
+ "agents": [],
212
+ "packDir": "pack-design",
213
+ "packAgents": [
214
+ "design-engine.md"
215
+ ]
187
216
  }
188
217
  };
189
218
 
@@ -191,7 +220,7 @@ const DERIVED = {
191
220
  "product": {
192
221
  "code": "bmad-plus",
193
222
  "displayName": "BMAD+",
194
- "version": "0.22.0",
223
+ "version": "0.23.0",
195
224
  "derivedFrom": "BMAD-METHOD v6.6.0"
196
225
  },
197
226
  "packOrder": [
@@ -203,11 +232,12 @@ const DERIVED = {
203
232
  "memory",
204
233
  "dev-studio",
205
234
  "backup",
206
- "animated"
235
+ "animated",
236
+ "design"
207
237
  ],
208
- "packCount": 9,
209
- "installerAgents": 14,
210
- "totalAgents": 47,
238
+ "packCount": 10,
239
+ "installerAgents": 15,
240
+ "totalAgents": 48,
211
241
  "languages": [
212
242
  "en",
213
243
  "fr",
@@ -235,7 +265,7 @@ const DERIVED = {
235
265
  "workflowCount": 0,
236
266
  "frameworkCount": 0,
237
267
  "categoryCount": 0,
238
- "skillCount": 4,
268
+ "skillCount": 6,
239
269
  "referenceFiles": 0,
240
270
  "categoryAgentCounts": [],
241
271
  "runtimes": [
@@ -247,8 +277,10 @@ const DERIVED = {
247
277
  ".agents/skills/agent-quality/SKILL.md",
248
278
  ".agents/skills/agent-orchestrator/SKILL.md",
249
279
  ".agents/skills/bmad-plus-autopilot/SKILL.md",
280
+ ".agents/skills/bmad-plus-intake/SKILL.md",
250
281
  ".agents/skills/bmad-plus-parallel/SKILL.md",
251
282
  ".agents/skills/bmad-plus-sync/SKILL.md",
283
+ ".agents/skills/bmad-plus-swarm/SKILL.md",
252
284
  ".agents/skills/bmad-plus-uat/SKILL.md",
253
285
  ".agents/data/role-triggers.yaml"
254
286
  ],
@@ -481,6 +513,7 @@ const DERIVED = {
481
513
  "alias": "זכר, Memory Guardian"
482
514
  }
483
515
  ],
516
+ "cohabitationWarning": "Detects and links existing brains (_brain/, ~/.claude/memory/); NEVER overwrites memory files.",
484
517
  "summary": "Cross-session memory + project scanner + Karpathy guardrails.",
485
518
  "desc": "Persistent cross-session memory",
486
519
  "description": "Persistent cross-session memory"
@@ -564,6 +597,7 @@ const DERIVED = {
564
597
  "alias": "אהליאב"
565
598
  }
566
599
  ],
600
+ "cohabitationWarning": "Dev Studio agents overlap with Core (Atlas/Forge/Sentinel/Nexus); both can coexist.",
567
601
  "summary": "6 specialized agents + 38 workflows: Analysis → Planning → Architecture → Implementation → Review.",
568
602
  "desc": "SDLC automation (6 agents, specialized workflows)",
569
603
  "description": "SDLC automation (6 agents, specialized workflows)"
@@ -637,6 +671,62 @@ const DERIVED = {
637
671
  "summary": "Luxury scroll-driven website from video.",
638
672
  "desc": "Animated website agents",
639
673
  "description": "Animated website agents"
674
+ },
675
+ "design": {
676
+ "id": "design",
677
+ "order": 9,
678
+ "name": "Design",
679
+ "displayName": "Design",
680
+ "iconEmoji": "🎨",
681
+ "required": false,
682
+ "installerAgentCount": 1,
683
+ "categoryAgentCount": 0,
684
+ "agentCount": 1,
685
+ "subAgentCount": 0,
686
+ "workflowCount": 0,
687
+ "frameworkCount": 0,
688
+ "categoryCount": 0,
689
+ "skillCount": 0,
690
+ "referenceFiles": 0,
691
+ "categoryAgentCounts": [],
692
+ "runtimes": [
693
+ "node"
694
+ ],
695
+ "requiredResources": [
696
+ ".agents/skills/pack-design/design-engine.md",
697
+ ".agents/skills/design/SKILL.md",
698
+ ".agents/skills/design-review/SKILL.md",
699
+ ".agents/skills/redesign/SKILL.md",
700
+ ".agents/skills/pack-design/SKILL.md",
701
+ ".agents/skills/pack-design/intake.yaml"
702
+ ],
703
+ "personas": [
704
+ {
705
+ "id": "design-engine",
706
+ "name": "Atelier",
707
+ "role": "Design Engine",
708
+ "description": "Senior product designer and design engineer: surfaces, tokens, accessibility, audits, redesigns",
709
+ "usesSkills": [
710
+ "design",
711
+ "design-review",
712
+ "redesign"
713
+ ]
714
+ }
715
+ ],
716
+ "externalModule": {
717
+ "package": "design-skill",
718
+ "version": "0.2.1",
719
+ "skills": [
720
+ "design",
721
+ "design-review",
722
+ "redesign"
723
+ ]
724
+ },
725
+ "intake": "intake.yaml",
726
+ "cohabitationWarning": "Overlaps Sentinel's UX-review role and Dev Studio's Rachel (UX Designer); when this pack is installed its rules govern visual design and accessibility, and the others hand those over.",
727
+ "summary": "Atelier builds, audits and redesigns interfaces under checkable rules: intake first, semantic tokens, WCAG AA, every state covered, pre-flight before delivery.",
728
+ "desc": "Design engine: build, review and redesign interfaces",
729
+ "description": "Design engine: build, review and redesign interfaces"
640
730
  }
641
731
  },
642
732
  "pythonPacks": {
@@ -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 };