bmad-plus 0.12.2 → 0.13.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 +25 -0
- package/README.md +36 -8
- package/package.json +6 -4
- package/readme-international/README.de.md +37 -8
- package/readme-international/README.es.md +38 -9
- package/readme-international/README.fr.md +37 -8
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +2 -0
- package/src/bmad-plus/module.yaml +270 -220
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +8 -8
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +1 -2
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +0 -1
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +1 -1
- package/tools/bmad-plus-npx.js +4 -2
- package/tools/build/adapters.config.js +60 -51
- package/tools/build/check-counts.js +52 -54
- package/tools/build/check-install-contract.js +298 -0
- package/tools/build/generate-adapters.js +252 -56
- package/tools/build/generate.js +187 -10
- package/tools/build/generated-adapters/.codex/AGENTS.md +20 -7
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +20 -7
- package/tools/build/generated-adapters/.opencode/AGENTS.md +20 -7
- package/tools/build/generated-adapters/AGENTS.md +20 -7
- package/tools/build/generated-adapters/CLAUDE.md +20 -7
- package/tools/build/generated-adapters/CONVENTIONS.md +20 -7
- package/tools/build/generated-adapters/GEMINI.md +20 -7
- package/tools/build/module.template.yaml +82 -0
- package/tools/cli/bmad-plus-cli.js +16 -1
- package/tools/cli/commands/doctor.js +12 -40
- package/tools/cli/commands/install.js +108 -163
- package/tools/cli/commands/uninstall.js +173 -65
- package/tools/cli/commands/update-check.js +31 -0
- package/tools/cli/commands/update-policy.js +39 -0
- package/tools/cli/commands/update.js +102 -113
- package/tools/cli/i18n.js +60 -0
- package/tools/cli/lib/ide-config.js +4 -261
- package/tools/cli/lib/install-manifest.js +17 -0
- package/tools/cli/lib/installed-adapters.js +89 -0
- package/tools/cli/lib/npm-runner.js +177 -0
- package/tools/cli/lib/pack-copy.js +62 -66
- package/tools/cli/lib/packs.js +437 -3
- package/tools/cli/lib/update-check.js +153 -0
- package/tools/cli/lib/update-dispatch.js +182 -0
- package/tools/cli/lib/update-policy.js +90 -0
- package/tools/cli/lib/update-transaction.js +334 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* hand-written instruction they contained was folded into THIS file — the UNION
|
|
7
7
|
* of all three pre-adoption root files, nothing dropped:
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Agent personas now live in registry.yaml; only repository-specific prose is here.
|
|
10
10
|
* - Skills paths (installed `.agents/` layout AND repo `src/bmad-plus/` layout)
|
|
11
11
|
* - Project Structure — from GEMINI/AGENTS
|
|
12
12
|
* - Communication (user name + language policy) — from CLAUDE
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Numbers policy: NO counts are hand-typed here (they drift — see audit DOC-06).
|
|
25
25
|
* All counts live in the generated "Registry facts" section, derived from
|
|
26
|
-
* registry.yaml.
|
|
26
|
+
* registry.yaml. Agent personas and their pack membership live there too.
|
|
27
27
|
*
|
|
28
28
|
* Author: Laurent Rochetta
|
|
29
29
|
*/
|
|
@@ -34,54 +34,7 @@
|
|
|
34
34
|
* Markdown lines (LF-joined by the generator). Headings are `##` level so the
|
|
35
35
|
* block nests directly under each generated file's H1.
|
|
36
36
|
*/
|
|
37
|
-
const
|
|
38
|
-
'## Agents',
|
|
39
|
-
'',
|
|
40
|
-
'To activate an agent, say its name or persona:',
|
|
41
|
-
'',
|
|
42
|
-
'- **Atlas** (Strategist) — Business analysis + Product management',
|
|
43
|
-
'- **Forge** (Architect-Dev) — Architecture + Development + Documentation',
|
|
44
|
-
'- **Sentinel** (Quality) — QA + UX review',
|
|
45
|
-
'- **Nexus** (Orchestrator) — Sprint management + Autopilot + Parallel execution',
|
|
46
|
-
'- **Shadow** (OSINT) — Investigation + Scraping + Psychoprofiling (if OSINT pack installed)',
|
|
47
|
-
'- **Maker** (Agent Creator) — Design, build, validate, and package new BMAD+ agents',
|
|
48
|
-
'- **Shield** (GRC) — Compliance agents for GDPR, ISO 27001, SOC 2, HIPAA, EU AI Act, DORA, NIS2 and more (derived counts: see Registry facts below)',
|
|
49
|
-
'- **Miriam** (מרים) — Business Analyst — Strategic analysis, research, product briefs (Dev Studio)',
|
|
50
|
-
'- **Huldah** (חולדה) — Technical Writer — Documentation, diagrams, editorial review (Dev Studio)',
|
|
51
|
-
'- **Yosef** (יוסף) — Product Manager — PRD, requirements, feature prioritization (Dev Studio)',
|
|
52
|
-
'- **Rachel** (רחל) — UX Designer — User experience, wireframes, empathy mapping (Dev Studio)',
|
|
53
|
-
'- **Bezalel** (בצלאל) — System Architect — Architecture, ADRs, epics & stories (Dev Studio)',
|
|
54
|
-
'- **Oholiab** (אהליאב) — Senior Engineer — TDD, sprint, code review, implementation (Dev Studio)',
|
|
55
|
-
'- **Zecher** (זכר, Memory Guardian) — Memory Archivist — Persistent cross-session memory, consolidation, project scanning, context recall, session handoffs',
|
|
56
|
-
'',
|
|
57
|
-
'## Skills',
|
|
58
|
-
'',
|
|
59
|
-
'- Installed projects load skills from `.agents/skills/`; in this repository the sources live in `src/bmad-plus/skills/` and `src/bmad-plus/agents/`.',
|
|
60
|
-
'- Each agent has a SKILL.md with capabilities, activation protocol, and role-switching rules.',
|
|
61
|
-
'- Auto-activation triggers: `.agents/data/role-triggers.yaml` (source: `src/bmad-plus/data/role-triggers.yaml`).',
|
|
62
|
-
'',
|
|
63
|
-
'## Project Structure',
|
|
64
|
-
'',
|
|
65
|
-
'- `src/bmad-plus/` — Custom module (agents, skills, data)',
|
|
66
|
-
'- `monitor/` — Upstream monitoring system (VPS)',
|
|
67
|
-
'- `mcp-server/` — Audit 360° MCP Server',
|
|
68
|
-
'- `osint-agent-package/` — OSINT package',
|
|
69
|
-
'- `upstream/` — BMAD-METHOD reference clone',
|
|
70
|
-
'',
|
|
71
|
-
'## Communication',
|
|
72
|
-
'',
|
|
73
|
-
'- User name: laurent',
|
|
74
|
-
'- Default language: Français for user-facing content, English for code and technical docs.',
|
|
75
|
-
'',
|
|
76
|
-
'## Commit Rules',
|
|
77
|
-
'',
|
|
78
|
-
'- NEVER add "Co-Authored-By: Claude" or any AI co-author attribution.',
|
|
79
|
-
'- The sole author is Laurent Rochetta.',
|
|
80
|
-
'',
|
|
81
|
-
'## Repository Maintenance Rule',
|
|
82
|
-
'',
|
|
83
|
-
'When updating the main README.md (English), you MUST synchronously update all translations in the readme-international/ directory (fr, es, de).',
|
|
84
|
-
'',
|
|
37
|
+
const MEMORY_INSTRUCTIONS = [
|
|
85
38
|
'## Memory Protocol (Karpathy Guardrails)',
|
|
86
39
|
'',
|
|
87
40
|
'Agents MUST follow these behavioral principles:',
|
|
@@ -114,4 +67,60 @@ const PROJECT_INSTRUCTIONS = [
|
|
|
114
67
|
'- `.agents/memory/context.md` — Update at session end with project state',
|
|
115
68
|
];
|
|
116
69
|
|
|
117
|
-
|
|
70
|
+
/** Shared once-per-session update instruction for source and installed projects. */
|
|
71
|
+
const UPDATE_INSTRUCTIONS = [
|
|
72
|
+
'## Framework updates',
|
|
73
|
+
'',
|
|
74
|
+
'Once per session, check the installed framework before starting project work. If this is the BMAD+ source checkout, use `node tools/cli/bmad-plus-cli.js update-check --json`; otherwise use `npx --no-install bmad-plus update-check --json` from the project root.',
|
|
75
|
+
'If the command is unavailable or the result is off, unknown, offline or stale, continue the user task. Do not install a CLI just to perform this check or claim that an offline version is current.',
|
|
76
|
+
'When `updateAvailable` is true and `canAutoApply` is true, run the same CLI with `update --latest --auto`. This follows the project update policy; it does not grant permission to expand that policy.',
|
|
77
|
+
'When an update is available but automatic application is not eligible, explain the version and reason. An explicit request to update can be fulfilled with `update --latest --yes`; do not change the policy, overwrite customized files, or resolve conflicts without the user request covering that action.',
|
|
78
|
+
'After a successful update, reread the project spine and relevant installed skills, or start a fresh session if the client cannot reload them. Respect preserved local instructions. Check only once even when several adapters include this section.',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const PROJECT_INSTRUCTIONS = [
|
|
82
|
+
'## Skills',
|
|
83
|
+
'',
|
|
84
|
+
'- Installed projects load skills from `.agents/skills/`; in this repository the sources live in `src/bmad-plus/skills/` and `src/bmad-plus/agents/`.',
|
|
85
|
+
'- Each agent has a SKILL.md with capabilities, activation protocol, and role-switching rules.',
|
|
86
|
+
'- Auto-activation triggers: `.agents/data/role-triggers.yaml` (source: `src/bmad-plus/data/role-triggers.yaml`).',
|
|
87
|
+
'',
|
|
88
|
+
'## Project Structure',
|
|
89
|
+
'',
|
|
90
|
+
'- `src/bmad-plus/` — Custom module (agents, skills, data)',
|
|
91
|
+
'- `monitor/` — Upstream monitoring system (VPS)',
|
|
92
|
+
'- `mcp-server/` — Audit 360° MCP Server',
|
|
93
|
+
'- `osint-agent-package/` — OSINT package',
|
|
94
|
+
'- `upstream/` — BMAD-METHOD reference clone',
|
|
95
|
+
'',
|
|
96
|
+
'## Communication',
|
|
97
|
+
'',
|
|
98
|
+
'- User name: laurent',
|
|
99
|
+
'- Default language: Français for user-facing content, English for code and technical docs.',
|
|
100
|
+
'',
|
|
101
|
+
'## Commit Rules',
|
|
102
|
+
'',
|
|
103
|
+
'- NEVER add "Co-Authored-By: Claude" or any AI co-author attribution.',
|
|
104
|
+
'- The sole author is Laurent Rochetta.',
|
|
105
|
+
'',
|
|
106
|
+
'## Repository Maintenance Rule',
|
|
107
|
+
'',
|
|
108
|
+
'When updating the main README.md (English), you MUST synchronously update all translations in the readme-international/ directory (fr, es, de).',
|
|
109
|
+
'',
|
|
110
|
+
...MEMORY_INSTRUCTIONS,
|
|
111
|
+
'',
|
|
112
|
+
...UPDATE_INSTRUCTIONS,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** Tool display/detection metadata; installable tools and paths come from DERIVED. */
|
|
116
|
+
const TOOL_METADATA = {
|
|
117
|
+
'claude-code': { name: 'Claude Code', detect: ['.claude'] },
|
|
118
|
+
'gemini-cli': { name: 'Gemini CLI', detect: ['.gemini'] },
|
|
119
|
+
antigravity: { name: 'Antigravity', detect: ['.gemini/antigravity'] },
|
|
120
|
+
cursor: { name: 'Cursor', detect: ['.cursor'] },
|
|
121
|
+
'codex-cli': { name: 'Codex CLI', detect: ['.codex'] },
|
|
122
|
+
opencode: { name: 'OpenCode', detect: ['.opencode'] },
|
|
123
|
+
aider: { name: 'Aider', detect: ['.aider.conf.yml'] },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
module.exports = { PROJECT_INSTRUCTIONS, MEMORY_INSTRUCTIONS, UPDATE_INSTRUCTIONS, TOOL_METADATA };
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
const fs = require('node:fs');
|
|
43
43
|
const path = require('node:path');
|
|
44
44
|
const { execFileSync } = require('node:child_process');
|
|
45
|
-
const { loadRegistry, DEFAULT_REGISTRY_PATH } = require('./generate');
|
|
45
|
+
const { loadRegistry, buildDerived, DEFAULT_REGISTRY_PATH } = require('./generate');
|
|
46
46
|
|
|
47
47
|
const REPO_ROOT = path.join(__dirname, '..', '..');
|
|
48
48
|
|
|
@@ -84,12 +84,6 @@ function isFrozenHistoryRow(line) {
|
|
|
84
84
|
|
|
85
85
|
/* ── Derivation ─────────────────────────────────────────────────────────── */
|
|
86
86
|
|
|
87
|
-
/** Sum of list lengths for a given key across a pack's categories. */
|
|
88
|
-
function sumCategories(pack, key) {
|
|
89
|
-
if (!Array.isArray(pack.categories)) return 0;
|
|
90
|
-
return pack.categories.reduce((n, c) => n + (Array.isArray(c[key]) ? c[key].length : 0), 0);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
87
|
/** Count *.md agent files on disk for a packaged pack's categories/ tree. */
|
|
94
88
|
function countAgentFilesOnDisk(packDir) {
|
|
95
89
|
const root = path.join(REPO_ROOT, 'src', 'bmad-plus', 'packs', packDir, 'categories');
|
|
@@ -111,36 +105,28 @@ function countWorkflowFilesOnDisk(packDir) {
|
|
|
111
105
|
return fs.readdirSync(dir).filter((f) => f.endsWith('.md')).length;
|
|
112
106
|
}
|
|
113
107
|
|
|
114
|
-
/** Installer languages, read from the CLI's own i18n module (its SSOT). */
|
|
115
|
-
function deriveLanguages() {
|
|
116
|
-
const { LANGUAGES } = require(path.join(REPO_ROOT, 'tools', 'cli', 'i18n.js'));
|
|
117
|
-
return Object.keys(LANGUAGES);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
108
|
/**
|
|
121
109
|
* Derive every count this gate enforces, from registry.yaml + the filesystem.
|
|
122
110
|
* Cross-checks the registry against the files on disk: if they disagree, the
|
|
123
111
|
* derivation itself is untrustworthy and that is reported as a drift.
|
|
124
112
|
*/
|
|
125
|
-
function deriveTruth(registryPath = DEFAULT_REGISTRY_PATH) {
|
|
126
|
-
const registry = loadRegistry(registryPath);
|
|
127
|
-
const
|
|
113
|
+
function deriveTruth(registryPath = DEFAULT_REGISTRY_PATH, options = {}) {
|
|
114
|
+
const registry = typeof registryPath === 'string' ? loadRegistry(registryPath) : registryPath;
|
|
115
|
+
const derived = buildDerived(registry, options);
|
|
116
|
+
const packs = derived.packs;
|
|
128
117
|
const shield = packs.shield;
|
|
129
118
|
const devStudio = packs['dev-studio'];
|
|
130
|
-
|
|
131
|
-
const shieldAgents =
|
|
132
|
-
const shieldWorkflows =
|
|
133
|
-
const shieldCategories =
|
|
134
|
-
const shieldFrameworks =
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const devStudioSubAgents = (devStudio.sub_agents || []).length;
|
|
138
|
-
const devStudioWorkflows =
|
|
139
|
-
(devStudio.workflows || []).length + sumCategories(devStudio, 'workflows');
|
|
119
|
+
const { installerAgents, totalAgents } = derived;
|
|
120
|
+
const shieldAgents = shield.categoryAgentCount;
|
|
121
|
+
const shieldWorkflows = shield.workflowCount;
|
|
122
|
+
const shieldCategories = shield.categoryCount;
|
|
123
|
+
const shieldFrameworks = shield.frameworkCount;
|
|
124
|
+
const devStudioSubAgents = devStudio.subAgentCount;
|
|
125
|
+
const devStudioWorkflows = devStudio.workflowCount;
|
|
140
126
|
|
|
141
127
|
const mismatches = [];
|
|
142
|
-
const onDiskAgents = countAgentFilesOnDisk(shield.pack_dir);
|
|
143
|
-
const onDiskWorkflows = countWorkflowFilesOnDisk(shield.pack_dir);
|
|
128
|
+
const onDiskAgents = countAgentFilesOnDisk(registry.packs.shield.pack_dir);
|
|
129
|
+
const onDiskWorkflows = countWorkflowFilesOnDisk(registry.packs.shield.pack_dir);
|
|
144
130
|
if (onDiskAgents !== null && onDiskAgents !== shieldAgents) {
|
|
145
131
|
mismatches.push(
|
|
146
132
|
`registry.yaml declares ${shieldAgents} shield category agents but ${onDiskAgents} agent files exist on disk`
|
|
@@ -152,26 +138,24 @@ function deriveTruth(registryPath = DEFAULT_REGISTRY_PATH) {
|
|
|
152
138
|
);
|
|
153
139
|
}
|
|
154
140
|
|
|
155
|
-
// Every distinct agent a user can talk to: the installer entry points plus
|
|
156
|
-
// the specialists the two packaged orchestrators route to.
|
|
157
|
-
const totalAgents = installerAgents + shieldAgents + devStudioSubAgents;
|
|
158
141
|
// Sizes of the declared per-category agent groups ("5 agents", "6 agents"…),
|
|
159
142
|
// which are legitimate claims wherever a category is described.
|
|
160
|
-
const perCategorySizes = new Set(
|
|
143
|
+
const perCategorySizes = new Set(shield.categoryAgentCounts);
|
|
161
144
|
// Sizes of the per-pack installer rosters ("3 agents (Scout, Chief, Judge)").
|
|
162
|
-
const perPackInstallerSizes = new Set(Object.values(packs).map((p) => p.
|
|
145
|
+
const perPackInstallerSizes = new Set(Object.values(packs).map((p) => p.installerAgentCount));
|
|
163
146
|
|
|
164
147
|
return {
|
|
165
|
-
packCount:
|
|
148
|
+
packCount: derived.packCount,
|
|
166
149
|
installerAgents,
|
|
167
150
|
shieldAgents,
|
|
168
151
|
shieldWorkflows,
|
|
169
152
|
shieldCategories,
|
|
170
153
|
shieldFrameworks,
|
|
154
|
+
shieldReferenceFiles: shield.referenceFiles,
|
|
171
155
|
devStudioSubAgents,
|
|
172
156
|
devStudioWorkflows,
|
|
173
157
|
totalAgents,
|
|
174
|
-
languages:
|
|
158
|
+
languages: derived.languages,
|
|
175
159
|
perCategorySizes,
|
|
176
160
|
// The COMPLETE vocabulary of numbers that may legitimately precede "agents"
|
|
177
161
|
// anywhere in the product's own copy. Default-deny: a value outside this set
|
|
@@ -188,7 +172,7 @@ function deriveTruth(registryPath = DEFAULT_REGISTRY_PATH) {
|
|
|
188
172
|
]),
|
|
189
173
|
// Same idea for the workflow dimension. Anchored lines get graded against
|
|
190
174
|
// the one pack they name; unanchored ones must still be *some* real total.
|
|
191
|
-
workflowCounts: new Set(
|
|
175
|
+
workflowCounts: new Set(Object.values(packs).map((p) => p.workflowCount).filter(Boolean)),
|
|
192
176
|
derivationMismatches: mismatches,
|
|
193
177
|
};
|
|
194
178
|
}
|
|
@@ -228,21 +212,18 @@ const FOREIGN_TABLE_HEADER_RE = /^\s*\|\s*BMAD-METHOD\s*\|/i;
|
|
|
228
212
|
* `\s` already covers U+00A0/U+202F, which the French copy uses before
|
|
229
213
|
* punctuation.
|
|
230
214
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* a claim of "27001 agents". A qualifier may still *contain* a hyphen
|
|
235
|
-
* ("multi-role", "GRC-Compliance-"); it just may not START with one.
|
|
215
|
+
* Hyphens allow compounds such as "12-language". The leading token guard in
|
|
216
|
+
* claimRe prevents digits inside identifiers such as `nist-800-53-agent` from
|
|
217
|
+
* becoming counts; the separator itself need not suppress legitimate claims.
|
|
236
218
|
*/
|
|
237
|
-
const GAP = '[\\s
|
|
219
|
+
const GAP = '[\\s*`-]*';
|
|
238
220
|
|
|
239
221
|
/** One qualifier word: hyphenated and accented forms included. */
|
|
240
222
|
const WORD = '[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ-]*';
|
|
241
223
|
|
|
242
224
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* ("5 reasons why you should hire agents").
|
|
225
|
+
* Maximum qualifiers echoed in diagnostics. Longer adjective runs are still
|
|
226
|
+
* graded; clause/dimension boundaries below prevent cross-sentence claims.
|
|
246
227
|
*/
|
|
247
228
|
const MAX_QUALIFIERS = 4;
|
|
248
229
|
|
|
@@ -263,6 +244,10 @@ const NOT_A_QUALIFIER = new Set(
|
|
|
263
244
|
and or plus with across of in on for to the a an
|
|
264
245
|
et ou des les la le du dans sur pour avec y con de en und oder mit für`.split(/\s+/)
|
|
265
246
|
);
|
|
247
|
+
const DIMENSION_WORDS = new Set(
|
|
248
|
+
'agent agents agente agentes agenten workflow workflows pack packs test tests category categories catégorie catégories categoría categorías kategorie kategorien framework frameworks language languages langue langues idioma idiomas sprache sprachen skill skills phase phases level levels reference files'.split(' ')
|
|
249
|
+
);
|
|
250
|
+
const CLAUSE_WORDS = new Set('why you we they should would could must can will'.split(' '));
|
|
266
251
|
|
|
267
252
|
/**
|
|
268
253
|
* Quantity approximators sitting in front of the number. "over 47 agents" is
|
|
@@ -279,14 +264,13 @@ const APPROX_PREFIX_RE = new RegExp(
|
|
|
279
264
|
/**
|
|
280
265
|
* Build a /g/ claim regex for one dimension's noun alternatives.
|
|
281
266
|
*
|
|
282
|
-
* The leading
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* A claim's number is always a whole token.
|
|
267
|
+
* The leading guard forbids a number inside a word, version or identifier.
|
|
268
|
+
* Qualifier tokens require separators, so an unbounded adjective run does not
|
|
269
|
+
* create ambiguous character-by-character partitions when no noun follows.
|
|
286
270
|
*/
|
|
287
271
|
function claimRe(noun) {
|
|
288
272
|
return new RegExp(
|
|
289
|
-
|
|
273
|
+
`(?<![\\w.-])(\\d+)${GAP}(\\+)?${GAP}((?:${WORD}[\\s*\u0060-]+)*?)(${noun})\\b`,
|
|
290
274
|
'gi'
|
|
291
275
|
);
|
|
292
276
|
}
|
|
@@ -298,12 +282,13 @@ const PACKS_RE = claimRe('packs?');
|
|
|
298
282
|
const TESTS_RE = claimRe('tests?');
|
|
299
283
|
const FRAMEWORKS_RE = claimRe('frameworks?');
|
|
300
284
|
const LANGUAGES_RE = claimRe('languages?|langues?|idiomas?|Sprachen?');
|
|
285
|
+
const REFERENCE_FILES_RE = claimRe('reference files?');
|
|
301
286
|
|
|
302
287
|
/**
|
|
303
288
|
* Collect every claim `re` finds on `line`.
|
|
304
289
|
* `re` must be a /g/ regex; it is reset before use so it is safe to reuse.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
290
|
+
* Dimension/clause crossings are discarded. Connectives truncate the echoed
|
|
291
|
+
* qualifiers without discarding a claim such as "38 compliance and GRC agents".
|
|
307
292
|
* @returns {{value:number, plus:boolean, approx:boolean, text:string}[]}
|
|
308
293
|
*/
|
|
309
294
|
function matchAll(re, line) {
|
|
@@ -312,14 +297,16 @@ function matchAll(re, line) {
|
|
|
312
297
|
let m;
|
|
313
298
|
while ((m = re.exec(line)) !== null) {
|
|
314
299
|
const qualifiers = (m[3] || '').split(/[^A-Za-zÀ-ÖØ-öø-ÿ]+/).filter(Boolean);
|
|
315
|
-
if (qualifiers.some((w) =>
|
|
300
|
+
if (qualifiers.some((w) => DIMENSION_WORDS.has(w.toLowerCase()) || CLAUSE_WORDS.has(w.toLowerCase()))) continue;
|
|
301
|
+
const connective = qualifiers.findIndex((w) => NOT_A_QUALIFIER.has(w.toLowerCase()));
|
|
302
|
+
const bounded = qualifiers.slice(0, Math.min(connective < 0 ? qualifiers.length : connective, MAX_QUALIFIERS));
|
|
316
303
|
out.push({
|
|
317
304
|
value: Number(m[1]),
|
|
318
305
|
plus: m[2] === '+',
|
|
319
306
|
approx: APPROX_PREFIX_RE.test(line.slice(0, m.index)),
|
|
320
307
|
// Markers are dropped from the echoed text so a bolded claim reports as
|
|
321
308
|
// `38 compliance agents`, not `38** compliance agents`. file:line locates it.
|
|
322
|
-
text: m[
|
|
309
|
+
text: [m[1] + (m[2] || ''), ...bounded, m[4]].join(' '),
|
|
323
310
|
});
|
|
324
311
|
}
|
|
325
312
|
return out;
|
|
@@ -334,6 +321,11 @@ function matchAll(re, line) {
|
|
|
334
321
|
* @returns {string[]} human-readable drift descriptions (empty when the line is fine)
|
|
335
322
|
*/
|
|
336
323
|
function checkLine(line, truth, { foreignSubject = false } = {}) {
|
|
324
|
+
// In a comparison table only the first cell describes the upstream product.
|
|
325
|
+
if (foreignSubject && line.trimStart().startsWith('|')) {
|
|
326
|
+
const ownCells = line.trim().split('|').slice(2, -1).join(' | ');
|
|
327
|
+
return checkLine(ownCells, truth);
|
|
328
|
+
}
|
|
337
329
|
const problems = [];
|
|
338
330
|
const shieldLine = SHIELD_ANCHOR.test(line);
|
|
339
331
|
const devStudioLine = DEV_STUDIO_ANCHOR.test(line);
|
|
@@ -466,6 +458,12 @@ function checkLine(line, truth, { foreignSubject = false } = {}) {
|
|
|
466
458
|
}
|
|
467
459
|
}
|
|
468
460
|
|
|
461
|
+
for (const { value, plus, approx, text } of matchAll(REFERENCE_FILES_RE, line)) {
|
|
462
|
+
if (value !== truth.shieldReferenceFiles || plus || approx) {
|
|
463
|
+
problems.push(`"${text}" — Shield has ${truth.shieldReferenceFiles} reference files on disk`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
469
467
|
return problems;
|
|
470
468
|
}
|
|
471
469
|
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Verify that real source and npm-package installs deliver the adapter contract.
|
|
4
|
+
* Uses existing dependencies, temporary projects and an isolated brain; no
|
|
5
|
+
* lifecycle scripts, dependency installation or Python provisioning are run.
|
|
6
|
+
*
|
|
7
|
+
* Run: node tools/build/check-install-contract.js
|
|
8
|
+
* Author: Laurent Rochetta
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const { spawnSync } = require('node:child_process');
|
|
18
|
+
|
|
19
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
20
|
+
const TEMP_PREFIX = 'bmad-install-contract-';
|
|
21
|
+
|
|
22
|
+
function resolveWithin(root, relative) {
|
|
23
|
+
assert(typeof relative === 'string' && relative.length > 0, 'Expected a relative file path');
|
|
24
|
+
assert(
|
|
25
|
+
!path.win32.isAbsolute(relative) && !path.posix.isAbsolute(relative),
|
|
26
|
+
`Absolute path: ${relative}`
|
|
27
|
+
);
|
|
28
|
+
const target = path.resolve(root, relative);
|
|
29
|
+
const fromRoot = path.relative(root, target);
|
|
30
|
+
assert(
|
|
31
|
+
fromRoot &&
|
|
32
|
+
!path.isAbsolute(fromRoot) &&
|
|
33
|
+
!fromRoot.startsWith(`..${path.sep}`) &&
|
|
34
|
+
fromRoot !== '..',
|
|
35
|
+
`Path escapes project: ${relative}`
|
|
36
|
+
);
|
|
37
|
+
return target;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameSelection(actual, expected, label) {
|
|
41
|
+
assert(Array.isArray(actual), `Manifest ${label} must be an array`);
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
[...actual].sort(),
|
|
44
|
+
[...expected].sort(),
|
|
45
|
+
`Manifest ${label} differs from requested selection`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compare the installed bytes, normalizing only Windows/Unix line endings. */
|
|
50
|
+
function verifyInstallation(projectDir, { packs, tools, derived, ideConfigs, renderFiles, requireInventory = false }) {
|
|
51
|
+
const manifest = JSON.parse(
|
|
52
|
+
fs.readFileSync(path.join(projectDir, '_bmad', '.bmad-plus-install.json'), 'utf8')
|
|
53
|
+
);
|
|
54
|
+
sameSelection(manifest.packs, packs, 'packs');
|
|
55
|
+
sameSelection(manifest.ides, tools, 'ides');
|
|
56
|
+
assert(typeof manifest.user === 'string' && manifest.user, 'Manifest user is missing');
|
|
57
|
+
assert(
|
|
58
|
+
typeof manifest.language === 'string' && manifest.language,
|
|
59
|
+
'Manifest language is missing'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const files = renderFiles(derived, {
|
|
63
|
+
packs,
|
|
64
|
+
userName: manifest.user,
|
|
65
|
+
language: manifest.language,
|
|
66
|
+
tools: manifest.ides,
|
|
67
|
+
});
|
|
68
|
+
const expectedPaths = new Set(files.map(({ file }) => file));
|
|
69
|
+
assert.equal(expectedPaths.size, files.length, 'Renderer returned duplicate output paths');
|
|
70
|
+
assert.equal(
|
|
71
|
+
derived.targets.spine,
|
|
72
|
+
'AGENTS.md',
|
|
73
|
+
'Install contract requires the root AGENTS.md spine'
|
|
74
|
+
);
|
|
75
|
+
assert(
|
|
76
|
+
expectedPaths.has(derived.targets.spine),
|
|
77
|
+
'Renderer must include the root AGENTS.md spine'
|
|
78
|
+
);
|
|
79
|
+
for (const tool of tools) {
|
|
80
|
+
assert(expectedPaths.has(ideConfigs[tool].configFile), `Renderer omitted the ${tool} adapter`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const { file, content } of files) {
|
|
84
|
+
const target = resolveWithin(projectDir, file);
|
|
85
|
+
assert(fs.existsSync(target), `Missing installed file: ${file}`);
|
|
86
|
+
const actual = fs.readFileSync(target, 'utf8').replace(/\r\n/g, '\n');
|
|
87
|
+
const expected = content.replace(/\r\n/g, '\n');
|
|
88
|
+
assert(actual === expected, `Installed adapter differs from renderer: ${file}`);
|
|
89
|
+
}
|
|
90
|
+
for (const { configFile } of Object.values(ideConfigs)) {
|
|
91
|
+
assert(
|
|
92
|
+
expectedPaths.has(configFile) || !fs.existsSync(resolveWithin(projectDir, configFile)),
|
|
93
|
+
`Unselected adapter was installed: ${configFile}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (requireInventory) {
|
|
97
|
+
assert.equal(manifest.fileInventory?.schemaVersion, 1, 'Missing file inventory');
|
|
98
|
+
const { collectManagedFiles } = require('../cli/lib/update-transaction');
|
|
99
|
+
const managedFiles = collectManagedFiles({ projectDir, packs });
|
|
100
|
+
assert.deepEqual(Object.keys(manifest.fileInventory.files).sort(), managedFiles.map(entry => entry.file).sort(),
|
|
101
|
+
'Inventory does not cover exactly the selected framework files');
|
|
102
|
+
for (const { file } of managedFiles) {
|
|
103
|
+
const actualHash = crypto.createHash('sha256').update(fs.readFileSync(resolveWithin(projectDir, file))).digest('hex');
|
|
104
|
+
assert.equal(manifest.fileInventory.files[file], actualHash, `Inventory differs from installed bytes: ${file}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return files.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function run(command, args, { cwd, env, timeout = 60000 }) {
|
|
111
|
+
const result = spawnSync(command, args, {
|
|
112
|
+
cwd,
|
|
113
|
+
env,
|
|
114
|
+
timeout,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
119
|
+
});
|
|
120
|
+
if (result.error || result.status !== 0) {
|
|
121
|
+
const reason = result.error
|
|
122
|
+
? result.error.message
|
|
123
|
+
: `exit ${result.status}, signal ${result.signal || 'none'}`;
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${path.basename(command)} ${args.join(' ')}: ${reason}\n${result.stdout || ''}${result.stderr || ''}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return result.stdout;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Invoke npm without a shell, including Windows where npm.cmd is not executable. */
|
|
132
|
+
function npmCommand(env) {
|
|
133
|
+
const candidates = [
|
|
134
|
+
env.npm_execpath,
|
|
135
|
+
path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
136
|
+
...(env.PATH || env.Path || '')
|
|
137
|
+
.split(path.delimiter)
|
|
138
|
+
.map((dir) => path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js')),
|
|
139
|
+
];
|
|
140
|
+
const cli = candidates.find(
|
|
141
|
+
(candidate) => candidate && /npm-cli\.js$/i.test(candidate) && fs.existsSync(candidate)
|
|
142
|
+
);
|
|
143
|
+
if (cli) return { command: process.execPath, args: [cli] };
|
|
144
|
+
assert(process.platform !== 'win32', 'Could not locate npm-cli.js beside Node or on PATH');
|
|
145
|
+
return { command: 'npm', args: [] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function packAndExtract(tempRoot, env) {
|
|
149
|
+
const destination = path.join(tempRoot, 'distribution');
|
|
150
|
+
fs.mkdirSync(destination);
|
|
151
|
+
const npm = npmCommand(env);
|
|
152
|
+
const output = run(
|
|
153
|
+
npm.command,
|
|
154
|
+
[
|
|
155
|
+
...npm.args,
|
|
156
|
+
'pack',
|
|
157
|
+
'--ignore-scripts',
|
|
158
|
+
'--offline',
|
|
159
|
+
'--json',
|
|
160
|
+
'--pack-destination',
|
|
161
|
+
destination,
|
|
162
|
+
'--cache',
|
|
163
|
+
path.join(tempRoot, 'npm-cache'),
|
|
164
|
+
],
|
|
165
|
+
{ cwd: REPO_ROOT, env, timeout: 120000 }
|
|
166
|
+
);
|
|
167
|
+
const packed = JSON.parse(output);
|
|
168
|
+
assert(Array.isArray(packed) && packed.length === 1, 'npm pack must produce exactly one package');
|
|
169
|
+
const archive = resolveWithin(destination, packed[0].filename);
|
|
170
|
+
const entries = run('tar', ['-tzf', archive], { cwd: destination, env }).trim().split(/\r?\n/);
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
assert(entry.startsWith('package/'), `Unexpected npm archive entry: ${entry}`);
|
|
173
|
+
resolveWithin(destination, entry);
|
|
174
|
+
}
|
|
175
|
+
run('tar', ['-xzf', archive, '-C', destination], { cwd: destination, env });
|
|
176
|
+
const packageRoot = path.join(destination, 'package');
|
|
177
|
+
assert(
|
|
178
|
+
!fs.existsSync(path.join(packageRoot, 'registry.yaml')),
|
|
179
|
+
'npm contract must work without registry.yaml'
|
|
180
|
+
);
|
|
181
|
+
assert(fs.statSync(packageRoot).isDirectory(), 'npm package directory is missing');
|
|
182
|
+
return packageRoot;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function runMatrix(packageRoot, variant, tempRoot, env, reference) {
|
|
186
|
+
const allPacks = Object.keys(reference.packDefinitions).filter(
|
|
187
|
+
(id) => !reference.packDefinitions[id].disabled
|
|
188
|
+
);
|
|
189
|
+
const allTools = Object.keys(reference.ideConfigs);
|
|
190
|
+
assert(allTools.length > 0, 'IDE_CONFIGS must expose at least one target');
|
|
191
|
+
const selections = [
|
|
192
|
+
{ argument: 'core', packs: ['core'] },
|
|
193
|
+
{ argument: 'core,osint', packs: ['core', 'osint'] },
|
|
194
|
+
{ argument: 'core,memory', packs: ['core', 'memory'] },
|
|
195
|
+
{ argument: 'all', packs: allPacks },
|
|
196
|
+
];
|
|
197
|
+
const toolSelections = [...allTools.map((tool) => [tool]), allTools];
|
|
198
|
+
let installations = 0;
|
|
199
|
+
let files = 0;
|
|
200
|
+
for (const selection of selections) {
|
|
201
|
+
for (const tools of toolSelections) {
|
|
202
|
+
const label = `${variant}/${selection.argument}/${tools.join(',')}`;
|
|
203
|
+
const projectDir = path.join(tempRoot, `${variant}-project-${++installations}`);
|
|
204
|
+
fs.mkdirSync(projectDir);
|
|
205
|
+
try {
|
|
206
|
+
run(
|
|
207
|
+
process.execPath,
|
|
208
|
+
[
|
|
209
|
+
path.join(packageRoot, 'tools', 'cli', 'bmad-plus-cli.js'),
|
|
210
|
+
'install',
|
|
211
|
+
'--yes',
|
|
212
|
+
'--lang',
|
|
213
|
+
'en',
|
|
214
|
+
'--directory',
|
|
215
|
+
projectDir,
|
|
216
|
+
'--packs',
|
|
217
|
+
selection.argument,
|
|
218
|
+
'--tools',
|
|
219
|
+
tools.join(','),
|
|
220
|
+
],
|
|
221
|
+
{ cwd: projectDir, env }
|
|
222
|
+
);
|
|
223
|
+
files += verifyInstallation(projectDir, { ...reference, packs: selection.packs, tools });
|
|
224
|
+
if (tools.length === allTools.length) {
|
|
225
|
+
const output = run(process.execPath, [
|
|
226
|
+
path.join(packageRoot, 'tools', 'cli', 'bmad-plus-cli.js'),
|
|
227
|
+
'update-check', '--offline', '--json', '--directory', projectDir,
|
|
228
|
+
], { cwd: projectDir, env });
|
|
229
|
+
const checked = JSON.parse(output);
|
|
230
|
+
assert.equal(checked.status, 'unknown', 'Offline check must not claim the latest release');
|
|
231
|
+
assert.equal(checked.updateAvailable, null);
|
|
232
|
+
assert.equal(checked.canAutoApply, false);
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new Error(`${label}: ${error.message}`, { cause: error });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
console.log(
|
|
239
|
+
`OK ${variant}: ${selection.argument} across ${toolSelections.length} tool selections`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return { installations, files };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function main() {
|
|
246
|
+
const { PACKS, DERIVED } = require('../cli/lib/packs');
|
|
247
|
+
const { IDE_CONFIGS } = require('../cli/lib/ide-config');
|
|
248
|
+
const { generateUserFiles } = require('./generate-adapters');
|
|
249
|
+
assert(
|
|
250
|
+
DERIVED && typeof generateUserFiles === 'function',
|
|
251
|
+
'Generated install contract exports are missing'
|
|
252
|
+
);
|
|
253
|
+
const reference = {
|
|
254
|
+
packDefinitions: PACKS,
|
|
255
|
+
derived: DERIVED,
|
|
256
|
+
ideConfigs: IDE_CONFIGS,
|
|
257
|
+
renderFiles: generateUserFiles,
|
|
258
|
+
requireInventory: true,
|
|
259
|
+
};
|
|
260
|
+
const tempParent = fs.realpathSync(os.tmpdir());
|
|
261
|
+
const tempRoot = fs.mkdtempSync(path.join(tempParent, TEMP_PREFIX));
|
|
262
|
+
try {
|
|
263
|
+
const brain = path.join(tempRoot, 'brain');
|
|
264
|
+
fs.mkdirSync(brain);
|
|
265
|
+
const env = {
|
|
266
|
+
...process.env,
|
|
267
|
+
BMAD_PLUS_BRAIN: brain,
|
|
268
|
+
NODE_PATH: path.join(REPO_ROOT, 'node_modules'),
|
|
269
|
+
NO_COLOR: '1',
|
|
270
|
+
NPM_CONFIG_UPDATE_NOTIFIER: 'false',
|
|
271
|
+
};
|
|
272
|
+
const source = runMatrix(REPO_ROOT, 'source', tempRoot, env, reference);
|
|
273
|
+
const packageRoot = packAndExtract(tempRoot, env);
|
|
274
|
+
const packaged = runMatrix(packageRoot, 'npm', tempRoot, env, reference);
|
|
275
|
+
console.log(
|
|
276
|
+
`Install contract passed: ${source.installations + packaged.installations} installs, ${source.files + packaged.files} files compared (source + npm without registry.yaml).`
|
|
277
|
+
);
|
|
278
|
+
return 0;
|
|
279
|
+
} finally {
|
|
280
|
+
// Delete only this mkdtemp-owned direct child of the verified temp directory.
|
|
281
|
+
assert(
|
|
282
|
+
path.dirname(tempRoot) === tempParent && path.basename(tempRoot).startsWith(TEMP_PREFIX)
|
|
283
|
+
);
|
|
284
|
+
assert(fs.realpathSync(tempRoot) === tempRoot && !fs.lstatSync(tempRoot).isSymbolicLink());
|
|
285
|
+
fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (require.main === module) {
|
|
290
|
+
try {
|
|
291
|
+
process.exitCode = main();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(`Install contract failed: ${error.message}`);
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = { main, resolveWithin, verifyInstallation };
|