bmad-plus 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +24 -14
- package/SECURITY.md +63 -1
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +4 -1
- package/readme-international/README.de.md +24 -14
- package/readme-international/README.es.md +24 -14
- package/readme-international/README.fr.md +24 -14
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
- package/src/bmad-plus/data/role-triggers.yaml +34 -0
- package/src/bmad-plus/module-help.csv +2 -0
- package/src/bmad-plus/module.yaml +21 -0
- package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
- package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
- package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
- package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +3 -1
- package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
- package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
- package/src/bmad-plus/packs/pack-shield/README.md +12 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
- package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +7 -0
- package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
- package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
- package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
- package/tools/build/adapters.config.js +2 -0
- package/tools/build/generate-adapters.js +32 -1
- package/tools/build/generate.js +283 -7
- package/tools/build/intake-contract.js +522 -0
- package/tools/cli/bmad-plus-cli.js +4 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/install.js +28 -1
- package/tools/cli/commands/intake.js +124 -0
- package/tools/cli/commands/review.js +10 -3
- package/tools/cli/commands/swarm.js +119 -0
- package/tools/cli/commands/uninstall.js +17 -0
- package/tools/cli/i18n.js +20 -0
- package/tools/cli/lib/ai-register.js +393 -0
- package/tools/cli/lib/assurance.js +822 -0
- package/tools/cli/lib/control-refs.js +132 -0
- package/tools/cli/lib/installation-health.js +17 -0
- package/tools/cli/lib/intake.js +202 -0
- package/tools/cli/lib/pack-copy.js +95 -5
- package/tools/cli/lib/packs.js +156 -8
- package/tools/cli/lib/page-origins.js +582 -0
- package/tools/cli/lib/review-rules.js +92 -24
- package/tools/cli/lib/review.js +28 -1
- package/tools/cli/lib/swarm-deal.js +116 -0
- package/tools/cli/lib/swarm-defaults.js +6 -0
- package/tools/cli/lib/swarm-tally.js +195 -0
- package/tools/cli/lib/swarm-validate.js +589 -0
- package/tools/cli/lib/swarm.js +712 -0
- package/tools/cli/lib/uat.js +22 -5
- package/tools/cli/lib/update-transaction.js +8 -4
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance control references: `FRAMEWORK:ID`, e.g. `ISO27001:A.8.28`, `SOC2:CC8.1`,
|
|
3
|
+
* `GDPR:Art.32(1)(b)`. Framework names are the Shield pack's compliance tags. Each framework
|
|
4
|
+
* has a closed grammar built from its published numbering, so a mistyped or invented control
|
|
5
|
+
* is refused instead of travelling into a checklist or an assurance case as if it were real.
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const range = (prefix, last) => Array.from({ length: last }, (_, i) => `${prefix}${i + 1}`);
|
|
10
|
+
|
|
11
|
+
/** ISO/IEC 27001:2022 Annex A: themes 5 to 8 and their control counts. */
|
|
12
|
+
const ISO27001 = new Set([
|
|
13
|
+
...range('A.5.', 37),
|
|
14
|
+
...range('A.6.', 8),
|
|
15
|
+
...range('A.7.', 14),
|
|
16
|
+
...range('A.8.', 34),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** SOC 2 Trust Services Criteria (2017, revised points of focus 2022). */
|
|
20
|
+
const SOC2 = new Set(
|
|
21
|
+
Object.entries({
|
|
22
|
+
CC1: 5,
|
|
23
|
+
CC2: 3,
|
|
24
|
+
CC3: 4,
|
|
25
|
+
CC4: 2,
|
|
26
|
+
CC5: 3,
|
|
27
|
+
CC6: 8,
|
|
28
|
+
CC7: 5,
|
|
29
|
+
CC8: 1,
|
|
30
|
+
CC9: 2,
|
|
31
|
+
A1: 3,
|
|
32
|
+
C1: 2,
|
|
33
|
+
PI1: 5,
|
|
34
|
+
P1: 1,
|
|
35
|
+
P2: 1,
|
|
36
|
+
P3: 2,
|
|
37
|
+
P4: 3,
|
|
38
|
+
P5: 2,
|
|
39
|
+
P6: 7,
|
|
40
|
+
P7: 1,
|
|
41
|
+
P8: 1,
|
|
42
|
+
}).flatMap(([series, last]) => range(`${series}.`, last))
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** NIST SP 800-53 Rev. 5: control count per family; enhancements are written `AC-2(1)`. */
|
|
46
|
+
const NIST_800_53 = {
|
|
47
|
+
AC: 25,
|
|
48
|
+
AT: 6,
|
|
49
|
+
AU: 16,
|
|
50
|
+
CA: 9,
|
|
51
|
+
CM: 14,
|
|
52
|
+
CP: 13,
|
|
53
|
+
IA: 13,
|
|
54
|
+
IR: 10,
|
|
55
|
+
MA: 7,
|
|
56
|
+
MP: 8,
|
|
57
|
+
PE: 23,
|
|
58
|
+
PL: 11,
|
|
59
|
+
PM: 32,
|
|
60
|
+
PS: 9,
|
|
61
|
+
PT: 8,
|
|
62
|
+
RA: 10,
|
|
63
|
+
SA: 23,
|
|
64
|
+
SC: 51,
|
|
65
|
+
SI: 23,
|
|
66
|
+
SR: 12,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** `Art.N`, optionally a paragraph `(n)` and a point `(x)`, within the act's article count. */
|
|
70
|
+
function article(last) {
|
|
71
|
+
const form = /^Art\.([1-9]\d{0,2})(?:\(([1-9]\d?)\)(?:\(([a-z])\))?)?$/;
|
|
72
|
+
return (id) => {
|
|
73
|
+
const match = form.exec(id);
|
|
74
|
+
return Boolean(match) && Number(match[1]) <= last;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const FRAMEWORKS = {
|
|
79
|
+
ISO27001: { example: 'A.8.28', valid: (id) => ISO27001.has(id) },
|
|
80
|
+
SOC2: { example: 'CC8.1', valid: (id) => SOC2.has(id) },
|
|
81
|
+
GDPR: { example: 'Art.32(1)(b)', valid: article(99) },
|
|
82
|
+
'EU-AI-Act': { example: 'Art.50', valid: article(113) },
|
|
83
|
+
NIS2: { example: 'Art.21(2)(d)', valid: article(46) },
|
|
84
|
+
'NIST-800-53': {
|
|
85
|
+
example: 'AC-6(1)',
|
|
86
|
+
valid: (id) => {
|
|
87
|
+
const match = /^([A-Z]{2})-([1-9]\d?)(?:\(([1-9]\d?)\))?$/.exec(id);
|
|
88
|
+
return Boolean(match) && Number(match[2]) <= (NIST_800_53[match[1]] || 0);
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const REF = /^([A-Za-z0-9-]{2,20}):(\S{1,40})$/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check one reference. Returns the reference split into framework and id, or throws with
|
|
97
|
+
* the reason and the accepted form.
|
|
98
|
+
*/
|
|
99
|
+
function parseControl(ref) {
|
|
100
|
+
const match = typeof ref === 'string' ? REF.exec(ref) : null;
|
|
101
|
+
if (!match) throw new Error(`"${ref}" is not a control reference (FRAMEWORK:ID)`);
|
|
102
|
+
const [, framework, id] = match;
|
|
103
|
+
const grammar = FRAMEWORKS[framework];
|
|
104
|
+
if (!grammar)
|
|
105
|
+
throw new Error(
|
|
106
|
+
`"${ref}": no control catalog for ${framework} (${Object.keys(FRAMEWORKS).join(', ')})`
|
|
107
|
+
);
|
|
108
|
+
if (!grammar.valid(id))
|
|
109
|
+
throw new Error(
|
|
110
|
+
`"${ref}" names no ${framework} control (e.g. ${framework}:${grammar.example})`
|
|
111
|
+
);
|
|
112
|
+
return { framework, id };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A list of references: each valid, none repeated. Returns the list unchanged. */
|
|
116
|
+
function parseControls(list, where) {
|
|
117
|
+
if (!Array.isArray(list) || !list.length)
|
|
118
|
+
throw new Error(`${where}: controls must be a non-empty list of FRAMEWORK:ID references`);
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const ref of list) {
|
|
121
|
+
try {
|
|
122
|
+
parseControl(ref);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(`${where}: ${error.message}`, { cause: error });
|
|
125
|
+
}
|
|
126
|
+
if (seen.has(ref)) throw new Error(`${where}: control ${ref} is listed twice`);
|
|
127
|
+
seen.add(ref);
|
|
128
|
+
}
|
|
129
|
+
return [...list];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { FRAMEWORKS, parseControl, parseControls };
|
|
@@ -155,6 +155,23 @@ function collectInstallationHealth({
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
if (selectedPacks.includes('shield')) {
|
|
159
|
+
// Soft by design: an AI tool missing from the register is reported, never an error.
|
|
160
|
+
const { checkRegister } = require('./ai-register');
|
|
161
|
+
const result = checkRegister(projectDir);
|
|
162
|
+
const problems = [...result.errors, ...result.warnings];
|
|
163
|
+
check(
|
|
164
|
+
'shield.ai-register',
|
|
165
|
+
problems.length ? 'warning' : 'pass',
|
|
166
|
+
problems.length
|
|
167
|
+
? 'AI processing register: ' +
|
|
168
|
+
problems.join('; ') +
|
|
169
|
+
'. Run `bmad-plus ai-register check` for details.'
|
|
170
|
+
: `AI processing register covers the ${result.integrations.length} AI integration(s) found.`,
|
|
171
|
+
{ path: result.registerFile }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
158
175
|
const inventory = inventoryOf(manifest);
|
|
159
176
|
const validInventory =
|
|
160
177
|
inventory &&
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Read, plan, record and render a pack intake inside one project; the contract decides. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const yaml = require('js-yaml');
|
|
8
|
+
const { safeTarget } = require('../../build/generate-adapters');
|
|
9
|
+
const contract = require('../../build/intake-contract');
|
|
10
|
+
|
|
11
|
+
const MAX_FILE = 1024 * 1024;
|
|
12
|
+
// Trees the intake never writes into, wherever they sit in the project.
|
|
13
|
+
const RESERVED_DIRS = new Set(['.git', '_bmad', '.bmad-plus', 'node_modules']);
|
|
14
|
+
|
|
15
|
+
/** A user-supplied path, kept inside the project and free of symlinks. */
|
|
16
|
+
function projectFile(projectDir, file) {
|
|
17
|
+
if (typeof file !== 'string' || file.trim() === '') throw new Error('a file path is required');
|
|
18
|
+
const root = path.resolve(projectDir);
|
|
19
|
+
const relative = path.relative(root, path.resolve(root, file)).split(path.sep).join('/');
|
|
20
|
+
if (!relative || relative === '..' || relative.startsWith('../'))
|
|
21
|
+
throw new Error(`${file}: outside the project directory ${root}`);
|
|
22
|
+
return { relative, absolute: safeTarget(root, relative) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A path the intake may write: outside the reserved trees, and never through a link. */
|
|
26
|
+
function writableFile(projectDir, file) {
|
|
27
|
+
const target = projectFile(projectDir, file);
|
|
28
|
+
const reserved = target.relative.split('/').find((part) => RESERVED_DIRS.has(part));
|
|
29
|
+
if (reserved) throw new Error(`${target.relative}: the intake never writes under ${reserved}/`);
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readBounded(absolute, label) {
|
|
34
|
+
const stat = fs.lstatSync(absolute);
|
|
35
|
+
if (!stat.isFile()) throw new Error(`${label}: not a regular file`);
|
|
36
|
+
if (stat.size > MAX_FILE) throw new Error(`${label}: larger than ${MAX_FILE} bytes`);
|
|
37
|
+
return fs.readFileSync(absolute, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function describe(problems) {
|
|
41
|
+
return problems.map((problem) => ` - ${problem}`).join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse and validate an intake file; throws listing every problem. */
|
|
45
|
+
function loadIntake(projectDir, file) {
|
|
46
|
+
const target = projectFile(projectDir, file);
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = yaml.load(readBounded(target.absolute, target.relative), { schema: yaml.JSON_SCHEMA });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(`${target.relative}: ${error.message}`, { cause: error });
|
|
52
|
+
}
|
|
53
|
+
const result = contract.validateIntake(parsed);
|
|
54
|
+
if (!result.ok)
|
|
55
|
+
throw new Error(`${target.relative} is not a valid intake:\n${describe(result.problems)}`);
|
|
56
|
+
return { file: target.relative, intake: parsed };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The parsed JSON of an existing answers file when it belongs to this intake; else throws. */
|
|
60
|
+
function ownedAnswers(target, intake) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(readBounded(target.absolute, target.relative));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${target.relative}: exists and is not an answers file of intake "${intake.id}" (${error.message}); move it away or pick another path`,
|
|
67
|
+
{ cause: error }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== 'object' || parsed.intake !== intake.id) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${target.relative}: exists and belongs to ${JSON.stringify(parsed && parsed.intake)}, not to intake "${intake.id}"; move it away or pick another path`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return parsed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse and validate an answers file; an absent file means no answer yet. */
|
|
79
|
+
function loadAnswers(projectDir, file, intake) {
|
|
80
|
+
const target = writableFile(projectDir, file);
|
|
81
|
+
if (!fs.existsSync(target.absolute)) {
|
|
82
|
+
return { file: target.relative, answers: contract.emptyAnswers(intake), exists: false };
|
|
83
|
+
}
|
|
84
|
+
const parsed = ownedAnswers(target, intake);
|
|
85
|
+
const result = contract.validateAnswers(parsed, intake);
|
|
86
|
+
if (!result.ok)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${target.relative} does not answer this intake:\n${describe(result.problems)}\nRecord again with --reset to start over.`
|
|
89
|
+
);
|
|
90
|
+
return { file: target.relative, answers: parsed, exists: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function atomicWrite(projectDir, relative, content) {
|
|
94
|
+
const target = safeTarget(projectDir, relative);
|
|
95
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
96
|
+
const temporaryFile = `${relative}.${crypto.randomUUID()}.tmp`;
|
|
97
|
+
const temporary = safeTarget(projectDir, temporaryFile);
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = fs.openSync(temporary, 'wx');
|
|
101
|
+
fs.writeFileSync(fd, content);
|
|
102
|
+
fs.fsyncSync(fd);
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
fd = undefined;
|
|
105
|
+
safeTarget(projectDir, relative);
|
|
106
|
+
fs.renameSync(temporary, target);
|
|
107
|
+
} finally {
|
|
108
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
109
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function check({ projectDir, file }) {
|
|
114
|
+
const target = projectFile(projectDir, file);
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = yaml.load(readBounded(target.absolute, target.relative), { schema: yaml.JSON_SCHEMA });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { file: target.relative, ok: false, problems: [error.message] };
|
|
120
|
+
}
|
|
121
|
+
const result = contract.validateIntake(parsed);
|
|
122
|
+
const summary = result.ok
|
|
123
|
+
? {
|
|
124
|
+
id: parsed.id,
|
|
125
|
+
pack: parsed.pack,
|
|
126
|
+
questions: contract.listQuestions(parsed).length,
|
|
127
|
+
mustHave: contract.listQuestions(parsed).filter((q) => q.must_have === true).length,
|
|
128
|
+
}
|
|
129
|
+
: {};
|
|
130
|
+
return { file: target.relative, ...result, ...summary };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function plan({ projectDir, intakeFile, answersFile }) {
|
|
134
|
+
const { intake } = loadIntake(projectDir, intakeFile);
|
|
135
|
+
const { answers, file } = loadAnswers(projectDir, answersFile, intake);
|
|
136
|
+
return { answersFile: file, ...contract.planIntake(intake, answers) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `sets` is a list of "id=VALUE" texts, `defaults` a list of question ids. With
|
|
141
|
+
* `reset`, the file starts again empty for this intake (an existing file must
|
|
142
|
+
* already belong to it), then takes the sets and defaults of the same call.
|
|
143
|
+
*/
|
|
144
|
+
function record({ projectDir, intakeFile, answersFile, sets = [], defaults = [], reset = false }) {
|
|
145
|
+
const { intake } = loadIntake(projectDir, intakeFile);
|
|
146
|
+
let loaded;
|
|
147
|
+
if (reset) {
|
|
148
|
+
const target = writableFile(projectDir, answersFile);
|
|
149
|
+
if (fs.existsSync(target.absolute)) ownedAnswers(target, intake);
|
|
150
|
+
loaded = { file: target.relative, answers: contract.emptyAnswers(intake) };
|
|
151
|
+
} else loaded = loadAnswers(projectDir, answersFile, intake);
|
|
152
|
+
const pairs = {};
|
|
153
|
+
for (const assignment of sets) {
|
|
154
|
+
const at = typeof assignment === 'string' ? assignment.indexOf('=') : -1;
|
|
155
|
+
if (at < 1) throw new Error(`--set expects id=VALUE, got "${assignment}"`);
|
|
156
|
+
const id = assignment.slice(0, at);
|
|
157
|
+
if (Object.hasOwn(pairs, id)) throw new Error(`question "${id}" is set twice in one call`);
|
|
158
|
+
pairs[id] = assignment.slice(at + 1);
|
|
159
|
+
}
|
|
160
|
+
if (!reset && Object.keys(pairs).length === 0 && defaults.length === 0)
|
|
161
|
+
throw new Error('nothing to record: pass --set id=VALUE, --default id or --reset');
|
|
162
|
+
const answers = contract.recordAnswers(intake, loaded.answers, { sets: pairs, defaults });
|
|
163
|
+
const result = contract.validateAnswers(answers, intake);
|
|
164
|
+
if (!result.ok) throw new Error(`recorded answers are invalid:\n${describe(result.problems)}`);
|
|
165
|
+
atomicWrite(projectDir, loaded.file, JSON.stringify(answers, null, 2) + '\n');
|
|
166
|
+
return {
|
|
167
|
+
answersFile: loaded.file,
|
|
168
|
+
reset,
|
|
169
|
+
recorded: [...Object.keys(pairs), ...defaults],
|
|
170
|
+
...contract.planIntake(intake, answers),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The brief overwrites only a file it wrote before for the same intake. */
|
|
175
|
+
function render({ projectDir, intakeFile, answersFile, out }) {
|
|
176
|
+
const { intake } = loadIntake(projectDir, intakeFile);
|
|
177
|
+
const { answers } = loadAnswers(projectDir, answersFile, intake);
|
|
178
|
+
const target = writableFile(projectDir, out);
|
|
179
|
+
if (fs.existsSync(target.absolute)) {
|
|
180
|
+
const marker = contract.briefMarker(intake);
|
|
181
|
+
const head = readBounded(target.absolute, target.relative).split(/\r?\n/, 2);
|
|
182
|
+
if (head[1] !== marker)
|
|
183
|
+
throw new Error(
|
|
184
|
+
`${target.relative}: exists and is not a brief of intake "${intake.id}" (its second line is not the ownership marker); move it away or pick another path`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const brief = contract.renderBrief(intake, answers);
|
|
188
|
+
atomicWrite(projectDir, target.relative, brief);
|
|
189
|
+
const planned = contract.planIntake(intake, answers);
|
|
190
|
+
return { out: target.relative, bytes: Buffer.byteLength(brief), ready: planned.ready };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
projectFile,
|
|
195
|
+
writableFile,
|
|
196
|
+
loadIntake,
|
|
197
|
+
loadAnswers,
|
|
198
|
+
check,
|
|
199
|
+
plan,
|
|
200
|
+
record,
|
|
201
|
+
render,
|
|
202
|
+
};
|
|
@@ -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 };
|