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.
Files changed (44) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +36 -8
  3. package/package.json +6 -4
  4. package/readme-international/README.de.md +37 -8
  5. package/readme-international/README.es.md +38 -9
  6. package/readme-international/README.fr.md +37 -8
  7. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +2 -0
  8. package/src/bmad-plus/module.yaml +270 -220
  9. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +8 -8
  10. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +1 -2
  11. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +0 -1
  12. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +1 -1
  13. package/tools/bmad-plus-npx.js +4 -2
  14. package/tools/build/adapters.config.js +60 -51
  15. package/tools/build/check-counts.js +52 -54
  16. package/tools/build/check-install-contract.js +298 -0
  17. package/tools/build/generate-adapters.js +252 -56
  18. package/tools/build/generate.js +187 -10
  19. package/tools/build/generated-adapters/.codex/AGENTS.md +20 -7
  20. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +20 -7
  21. package/tools/build/generated-adapters/.opencode/AGENTS.md +20 -7
  22. package/tools/build/generated-adapters/AGENTS.md +20 -7
  23. package/tools/build/generated-adapters/CLAUDE.md +20 -7
  24. package/tools/build/generated-adapters/CONVENTIONS.md +20 -7
  25. package/tools/build/generated-adapters/GEMINI.md +20 -7
  26. package/tools/build/module.template.yaml +82 -0
  27. package/tools/cli/bmad-plus-cli.js +16 -1
  28. package/tools/cli/commands/doctor.js +12 -40
  29. package/tools/cli/commands/install.js +108 -163
  30. package/tools/cli/commands/uninstall.js +173 -65
  31. package/tools/cli/commands/update-check.js +31 -0
  32. package/tools/cli/commands/update-policy.js +39 -0
  33. package/tools/cli/commands/update.js +102 -113
  34. package/tools/cli/i18n.js +60 -0
  35. package/tools/cli/lib/ide-config.js +4 -261
  36. package/tools/cli/lib/install-manifest.js +17 -0
  37. package/tools/cli/lib/installed-adapters.js +89 -0
  38. package/tools/cli/lib/npm-runner.js +177 -0
  39. package/tools/cli/lib/pack-copy.js +62 -66
  40. package/tools/cli/lib/packs.js +437 -3
  41. package/tools/cli/lib/update-check.js +153 -0
  42. package/tools/cli/lib/update-dispatch.js +182 -0
  43. package/tools/cli/lib/update-policy.js +90 -0
  44. 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
- * - Agents roster (personas + Hebrew names + pack qualifiers) from all three
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. That is why the Shield line below carries no agent count.
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 PROJECT_INSTRUCTIONS = [
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
- module.exports = { PROJECT_INSTRUCTIONS };
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 packs = registry.packs;
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 = sumCategories(shield, 'agents');
132
- const shieldWorkflows = (shield.workflows || []).length;
133
- const shieldCategories = (shield.categories || []).length;
134
- const shieldFrameworks = (shield.compliance_tags || []).length;
135
-
136
- const installerAgents = Object.values(packs).reduce((n, p) => n + p.agents.length, 0);
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((shield.categories || []).map((c) => (c.agents || []).length));
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.agents.length));
145
+ const perPackInstallerSizes = new Set(Object.values(packs).map((p) => p.installerAgentCount));
163
146
 
164
147
  return {
165
- packCount: Object.keys(packs).length,
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: deriveLanguages(),
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([shieldWorkflows, devStudioWorkflows]),
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
- * `-` and `_` are deliberately NOT separators. This repo is full of agent
232
- * IDENTIFIERS `iso27001-agent`, `nist-800-53-agent`, `section508-agent`,
233
- * `iso42001-agent` and a hyphen-tolerant gap would read every one of them as
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
- * How many qualifiers a claim may carry. The bound is load-bearing: it is what
244
- * stops a match from walking across a whole sentence and inventing a claim
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 `\b` is load-bearing: it forbids the number from starting inside
283
- * a word. Without it a qualifier run happily bridges the stray digit in "E2E
284
- * tests" into a claim of "2 tests", and reads `section508-agent` as a count.
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
- `\\b(\\d+)${GAP}(\\+)?${GAP}((?:${WORD}${GAP}){0,${MAX_QUALIFIERS}}?)(?:${noun})\\b`,
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
- * Matches whose qualifier run crosses a NOT_A_QUALIFIER word are dropped: the
306
- * number belongs to a different dimension (or a different sentence).
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) => NOT_A_QUALIFIER.has(w.toLowerCase()))) continue;
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[0].replace(/[*`]+/g, ' ').replace(/\s+/g, ' ').trim(),
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 };