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.
- package/CHANGELOG.md +19 -0
- package/README.md +23 -13
- package/SECURITY.md +1 -1
- package/package.json +4 -1
- package/readme-international/README.de.md +23 -13
- package/readme-international/README.es.md +23 -13
- package/readme-international/README.fr.md +23 -13
- 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/skills/bmad-plus-autopilot/SKILL.md +7 -0
- package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
- package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
- package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
- package/tools/build/adapters.config.js +2 -0
- package/tools/build/generate-adapters.js +25 -1
- package/tools/build/generate.js +269 -7
- package/tools/build/intake-contract.js +522 -0
- package/tools/cli/bmad-plus-cli.js +2 -0
- package/tools/cli/commands/install.js +28 -1
- package/tools/cli/commands/intake.js +124 -0
- 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/intake.js +202 -0
- package/tools/cli/lib/pack-copy.js +95 -5
- package/tools/cli/lib/packs.js +97 -7
- package/tools/cli/lib/swarm-deal.js +116 -0
- package/tools/cli/lib/swarm-defaults.js +6 -0
- package/tools/cli/lib/swarm-tally.js +195 -0
- package/tools/cli/lib/swarm-validate.js +589 -0
- package/tools/cli/lib/swarm.js +712 -0
- package/tools/cli/lib/update-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
|
-
|
|
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 };
|
package/tools/cli/lib/packs.js
CHANGED
|
@@ -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.
|
|
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":
|
|
209
|
-
"installerAgents":
|
|
210
|
-
"totalAgents":
|
|
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":
|
|
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,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 };
|