ma-agents 3.5.1 → 3.5.2

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 (29) hide show
  1. package/README.md +20 -10
  2. package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +4 -3
  3. package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
  4. package/lib/bmad-cache/bmb/_git_preserved/logs/HEAD +1 -1
  5. package/lib/bmad-cache/bmb/_git_preserved/logs/refs/heads/main +1 -1
  6. package/lib/bmad-cache/bmb/_git_preserved/logs/refs/remotes/origin/HEAD +1 -1
  7. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-4b395d030ca386fc5748f1b670dcf8c0ef41c94c.idx +0 -0
  8. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-5e915049d8459481e4aa7be8d5959643fa68a981.pack → pack-4b395d030ca386fc5748f1b670dcf8c0ef41c94c.pack} +0 -0
  9. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-4b395d030ca386fc5748f1b670dcf8c0ef41c94c.rev +0 -0
  10. package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
  11. package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
  12. package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
  13. package/lib/bmad-cache/bmb/samples/sample-module-setup/assets/module-help.csv +16 -0
  14. package/lib/bmad-cache/bmb/samples/sample-module-setup/assets/module.yaml +13 -0
  15. package/lib/bmad-cache/bmb/samples/sample-module-setup/scripts/cleanup-legacy.py +259 -0
  16. package/lib/bmad-cache/bmb/samples/sample-module-setup/scripts/merge-config.py +408 -0
  17. package/lib/bmad-cache/bmb/samples/sample-module-setup/scripts/merge-help-csv.py +218 -0
  18. package/lib/bmad-cache/cache-manifest.json +3 -3
  19. package/lib/bmad-extension/module-help.csv +0 -1
  20. package/lib/bmad-extension/skills/bmad-ma-agent-sqa/SKILL.md +21 -9
  21. package/lib/bmad-extension/skills/bmad-ma-agent-sqa/bmad-skill-manifest.yaml +6 -6
  22. package/package.json +1 -1
  23. package/test/extension-module-restructure.test.js +6 -6
  24. package/test/migration-validation.test.js +20 -16
  25. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-5e915049d8459481e4aa7be8d5959643fa68a981.idx +0 -0
  26. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-5e915049d8459481e4aa7be8d5959643fa68a981.rev +0 -0
  27. package/lib/bmad-extension/skills/bmad-ma-agent-mil498/.gitkeep +0 -0
  28. package/lib/bmad-extension/skills/bmad-ma-agent-mil498/SKILL.md +0 -54
  29. package/lib/bmad-extension/skills/bmad-ma-agent-mil498/bmad-skill-manifest.yaml +0 -11
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.9"
4
+ # dependencies = ["pyyaml"]
5
+ # ///
6
+ """Merge module configuration into shared _bmad/config.yaml and config.user.yaml.
7
+
8
+ Reads a module.yaml definition and a JSON answers file, then writes or updates
9
+ the shared config.yaml (core values at root + module section) and config.user.yaml
10
+ (user_name, communication_language, plus any module variable with user_setting: true).
11
+ Uses an anti-zombie pattern for the module section in config.yaml.
12
+
13
+ Legacy migration: when --legacy-dir is provided, reads old per-module config files
14
+ from {legacy-dir}/{module-code}/config.yaml and {legacy-dir}/core/config.yaml.
15
+ Matching values serve as fallback defaults (answers override them). After a
16
+ successful merge, the legacy config.yaml files are deleted. Only the current
17
+ module and core directories are touched — other module directories are left alone.
18
+
19
+ Exit codes: 0=success, 1=validation error, 2=runtime error
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ try:
28
+ import yaml
29
+ except ImportError:
30
+ print("Error: pyyaml is required (PEP 723 dependency)", file=sys.stderr)
31
+ sys.exit(2)
32
+
33
+
34
+ def parse_args():
35
+ parser = argparse.ArgumentParser(
36
+ description="Merge module config into shared _bmad/config.yaml with anti-zombie pattern."
37
+ )
38
+ parser.add_argument(
39
+ "--config-path",
40
+ required=True,
41
+ help="Path to the target _bmad/config.yaml file",
42
+ )
43
+ parser.add_argument(
44
+ "--module-yaml",
45
+ required=True,
46
+ help="Path to the module.yaml definition file",
47
+ )
48
+ parser.add_argument(
49
+ "--answers",
50
+ required=True,
51
+ help="Path to JSON file with collected answers",
52
+ )
53
+ parser.add_argument(
54
+ "--user-config-path",
55
+ required=True,
56
+ help="Path to the target _bmad/config.user.yaml file",
57
+ )
58
+ parser.add_argument(
59
+ "--legacy-dir",
60
+ help="Path to _bmad/ directory to check for legacy per-module config files. "
61
+ "Matching values are used as fallback defaults, then legacy files are deleted.",
62
+ )
63
+ parser.add_argument(
64
+ "--verbose",
65
+ action="store_true",
66
+ help="Print detailed progress to stderr",
67
+ )
68
+ return parser.parse_args()
69
+
70
+
71
+ def load_yaml_file(path: str) -> dict:
72
+ """Load a YAML file, returning empty dict if file doesn't exist."""
73
+ file_path = Path(path)
74
+ if not file_path.exists():
75
+ return {}
76
+ with open(file_path, "r", encoding="utf-8") as f:
77
+ content = yaml.safe_load(f)
78
+ return content if content else {}
79
+
80
+
81
+ def load_json_file(path: str) -> dict:
82
+ """Load a JSON file."""
83
+ with open(path, "r", encoding="utf-8") as f:
84
+ return json.load(f)
85
+
86
+
87
+ # Keys that live at config root (shared across all modules)
88
+ _CORE_KEYS = frozenset(
89
+ {"user_name", "communication_language", "document_output_language", "output_folder"}
90
+ )
91
+
92
+
93
+ def load_legacy_values(
94
+ legacy_dir: str, module_code: str, module_yaml: dict, verbose: bool = False
95
+ ) -> tuple[dict, dict, list]:
96
+ """Read legacy per-module config files and return core/module value dicts.
97
+
98
+ Reads {legacy_dir}/core/config.yaml and {legacy_dir}/{module_code}/config.yaml.
99
+ Only returns values whose keys match the current schema (core keys or module.yaml
100
+ variable definitions). Other modules' directories are not touched.
101
+
102
+ Returns:
103
+ (legacy_core, legacy_module, files_found) where files_found lists paths read.
104
+ """
105
+ legacy_core: dict = {}
106
+ legacy_module: dict = {}
107
+ files_found: list = []
108
+
109
+ # Read core legacy config
110
+ core_path = Path(legacy_dir) / "core" / "config.yaml"
111
+ if core_path.exists():
112
+ core_data = load_yaml_file(str(core_path))
113
+ files_found.append(str(core_path))
114
+ for k, v in core_data.items():
115
+ if k in _CORE_KEYS:
116
+ legacy_core[k] = v
117
+ if verbose:
118
+ print(f"Legacy core config: {list(legacy_core.keys())}", file=sys.stderr)
119
+
120
+ # Read module legacy config
121
+ mod_path = Path(legacy_dir) / module_code / "config.yaml"
122
+ if mod_path.exists():
123
+ mod_data = load_yaml_file(str(mod_path))
124
+ files_found.append(str(mod_path))
125
+ for k, v in mod_data.items():
126
+ if k in _CORE_KEYS:
127
+ # Core keys duplicated in module config — only use if not already set
128
+ if k not in legacy_core:
129
+ legacy_core[k] = v
130
+ elif k in module_yaml and isinstance(module_yaml[k], dict):
131
+ # Module-specific key that matches a current variable definition
132
+ legacy_module[k] = v
133
+ if verbose:
134
+ print(
135
+ f"Legacy module config: {list(legacy_module.keys())}", file=sys.stderr
136
+ )
137
+
138
+ return legacy_core, legacy_module, files_found
139
+
140
+
141
+ def apply_legacy_defaults(answers: dict, legacy_core: dict, legacy_module: dict) -> dict:
142
+ """Apply legacy values as fallback defaults under the answers.
143
+
144
+ Legacy values fill in any key not already present in answers.
145
+ Explicit answers always win.
146
+ """
147
+ merged = dict(answers)
148
+
149
+ if legacy_core:
150
+ core = merged.get("core", {})
151
+ filled_core = dict(legacy_core) # legacy as base
152
+ filled_core.update(core) # answers override
153
+ merged["core"] = filled_core
154
+
155
+ if legacy_module:
156
+ mod = merged.get("module", {})
157
+ filled_mod = dict(legacy_module) # legacy as base
158
+ filled_mod.update(mod) # answers override
159
+ merged["module"] = filled_mod
160
+
161
+ return merged
162
+
163
+
164
+ def cleanup_legacy_configs(
165
+ legacy_dir: str, module_code: str, verbose: bool = False
166
+ ) -> list:
167
+ """Delete legacy config.yaml files for this module and core only.
168
+
169
+ Returns list of deleted file paths.
170
+ """
171
+ deleted = []
172
+ for subdir in (module_code, "core"):
173
+ legacy_path = Path(legacy_dir) / subdir / "config.yaml"
174
+ if legacy_path.exists():
175
+ if verbose:
176
+ print(f"Deleting legacy config: {legacy_path}", file=sys.stderr)
177
+ legacy_path.unlink()
178
+ deleted.append(str(legacy_path))
179
+ return deleted
180
+
181
+
182
+ def extract_module_metadata(module_yaml: dict) -> dict:
183
+ """Extract non-variable metadata fields from module.yaml."""
184
+ meta = {}
185
+ for k in ("name", "description"):
186
+ if k in module_yaml:
187
+ meta[k] = module_yaml[k]
188
+ meta["version"] = module_yaml.get("module_version") # null if absent
189
+ if "default_selected" in module_yaml:
190
+ meta["default_selected"] = module_yaml["default_selected"]
191
+ return meta
192
+
193
+
194
+ def apply_result_templates(
195
+ module_yaml: dict, module_answers: dict, verbose: bool = False
196
+ ) -> dict:
197
+ """Apply result templates from module.yaml to transform raw answer values.
198
+
199
+ For each answer, if the corresponding variable definition in module.yaml has
200
+ a 'result' field, replaces {value} in that template with the answer. Skips
201
+ the template if the answer already contains '{project-root}' to prevent
202
+ double-prefixing.
203
+ """
204
+ transformed = {}
205
+ for key, value in module_answers.items():
206
+ var_def = module_yaml.get(key)
207
+ if (
208
+ isinstance(var_def, dict)
209
+ and "result" in var_def
210
+ and "{project-root}" not in str(value)
211
+ ):
212
+ template = var_def["result"]
213
+ transformed[key] = template.replace("{value}", str(value))
214
+ if verbose:
215
+ print(
216
+ f"Applied result template for '{key}': {value} → {transformed[key]}",
217
+ file=sys.stderr,
218
+ )
219
+ else:
220
+ transformed[key] = value
221
+ return transformed
222
+
223
+
224
+ def merge_config(
225
+ existing_config: dict,
226
+ module_yaml: dict,
227
+ answers: dict,
228
+ verbose: bool = False,
229
+ ) -> dict:
230
+ """Merge answers into config, applying anti-zombie pattern.
231
+
232
+ Args:
233
+ existing_config: Current config.yaml contents (may be empty)
234
+ module_yaml: The module definition
235
+ answers: JSON with 'core' and/or 'module' keys
236
+ verbose: Print progress to stderr
237
+
238
+ Returns:
239
+ Updated config dict ready to write
240
+ """
241
+ config = dict(existing_config)
242
+ module_code = module_yaml.get("code")
243
+
244
+ if not module_code:
245
+ print("Error: module.yaml must have a 'code' field", file=sys.stderr)
246
+ sys.exit(1)
247
+
248
+ # Migrate legacy core: section to root
249
+ if "core" in config and isinstance(config["core"], dict):
250
+ if verbose:
251
+ print("Migrating legacy 'core' section to root", file=sys.stderr)
252
+ config.update(config.pop("core"))
253
+
254
+ # Strip user-only keys from config — they belong exclusively in config.user.yaml
255
+ for key in _CORE_USER_KEYS:
256
+ if key in config:
257
+ if verbose:
258
+ print(f"Removing user-only key '{key}' from config (belongs in config.user.yaml)", file=sys.stderr)
259
+ del config[key]
260
+
261
+ # Write core values at root (global properties, not nested under "core")
262
+ # Exclude user-only keys — those belong exclusively in config.user.yaml
263
+ core_answers = answers.get("core")
264
+ if core_answers:
265
+ shared_core = {k: v for k, v in core_answers.items() if k not in _CORE_USER_KEYS}
266
+ if shared_core:
267
+ if verbose:
268
+ print(f"Writing core config at root: {list(shared_core.keys())}", file=sys.stderr)
269
+ config.update(shared_core)
270
+
271
+ # Anti-zombie: remove existing module section
272
+ if module_code in config:
273
+ if verbose:
274
+ print(
275
+ f"Removing existing '{module_code}' section (anti-zombie)",
276
+ file=sys.stderr,
277
+ )
278
+ del config[module_code]
279
+
280
+ # Build module section: metadata + variable values
281
+ module_section = extract_module_metadata(module_yaml)
282
+ module_answers = apply_result_templates(
283
+ module_yaml, answers.get("module", {}), verbose
284
+ )
285
+ module_section.update(module_answers)
286
+
287
+ if verbose:
288
+ print(
289
+ f"Writing '{module_code}' section with keys: {list(module_section.keys())}",
290
+ file=sys.stderr,
291
+ )
292
+
293
+ config[module_code] = module_section
294
+
295
+ return config
296
+
297
+
298
+ # Core keys that are always written to config.user.yaml
299
+ _CORE_USER_KEYS = ("user_name", "communication_language")
300
+
301
+
302
+ def extract_user_settings(module_yaml: dict, answers: dict) -> dict:
303
+ """Collect settings that belong in config.user.yaml.
304
+
305
+ Includes user_name and communication_language from core answers, plus any
306
+ module variable whose definition contains user_setting: true.
307
+ """
308
+ user_settings = {}
309
+
310
+ core_answers = answers.get("core", {})
311
+ for key in _CORE_USER_KEYS:
312
+ if key in core_answers:
313
+ user_settings[key] = core_answers[key]
314
+
315
+ module_answers = answers.get("module", {})
316
+ for var_name, var_def in module_yaml.items():
317
+ if isinstance(var_def, dict) and var_def.get("user_setting") is True:
318
+ if var_name in module_answers:
319
+ user_settings[var_name] = module_answers[var_name]
320
+
321
+ return user_settings
322
+
323
+
324
+ def write_config(config: dict, config_path: str, verbose: bool = False) -> None:
325
+ """Write config dict to YAML file, creating parent dirs as needed."""
326
+ path = Path(config_path)
327
+ path.parent.mkdir(parents=True, exist_ok=True)
328
+
329
+ if verbose:
330
+ print(f"Writing config to {path}", file=sys.stderr)
331
+
332
+ with open(path, "w", encoding="utf-8") as f:
333
+ yaml.dump(
334
+ config,
335
+ f,
336
+ default_flow_style=False,
337
+ allow_unicode=True,
338
+ sort_keys=False,
339
+ )
340
+
341
+
342
+ def main():
343
+ args = parse_args()
344
+
345
+ # Load inputs
346
+ module_yaml = load_yaml_file(args.module_yaml)
347
+ if not module_yaml:
348
+ print(f"Error: Could not load module.yaml from {args.module_yaml}", file=sys.stderr)
349
+ sys.exit(1)
350
+
351
+ answers = load_json_file(args.answers)
352
+ existing_config = load_yaml_file(args.config_path)
353
+
354
+ if args.verbose:
355
+ exists = Path(args.config_path).exists()
356
+ print(f"Config file exists: {exists}", file=sys.stderr)
357
+ if exists:
358
+ print(f"Existing sections: {list(existing_config.keys())}", file=sys.stderr)
359
+
360
+ # Legacy migration: read old per-module configs as fallback defaults
361
+ legacy_files_found = []
362
+ if args.legacy_dir:
363
+ module_code = module_yaml.get("code", "")
364
+ legacy_core, legacy_module, legacy_files_found = load_legacy_values(
365
+ args.legacy_dir, module_code, module_yaml, args.verbose
366
+ )
367
+ if legacy_core or legacy_module:
368
+ answers = apply_legacy_defaults(answers, legacy_core, legacy_module)
369
+ if args.verbose:
370
+ print("Applied legacy values as fallback defaults", file=sys.stderr)
371
+
372
+ # Merge and write config.yaml
373
+ updated_config = merge_config(existing_config, module_yaml, answers, args.verbose)
374
+ write_config(updated_config, args.config_path, args.verbose)
375
+
376
+ # Merge and write config.user.yaml
377
+ user_settings = extract_user_settings(module_yaml, answers)
378
+ existing_user_config = load_yaml_file(args.user_config_path)
379
+ updated_user_config = dict(existing_user_config)
380
+ updated_user_config.update(user_settings)
381
+ if user_settings:
382
+ write_config(updated_user_config, args.user_config_path, args.verbose)
383
+
384
+ # Legacy cleanup: delete old per-module config files
385
+ legacy_deleted = []
386
+ if args.legacy_dir:
387
+ legacy_deleted = cleanup_legacy_configs(
388
+ args.legacy_dir, module_yaml["code"], args.verbose
389
+ )
390
+
391
+ # Output result summary as JSON
392
+ module_code = module_yaml["code"]
393
+ result = {
394
+ "status": "success",
395
+ "config_path": str(Path(args.config_path).resolve()),
396
+ "user_config_path": str(Path(args.user_config_path).resolve()),
397
+ "module_code": module_code,
398
+ "core_updated": bool(answers.get("core")),
399
+ "module_keys": list(updated_config.get(module_code, {}).keys()),
400
+ "user_keys": list(user_settings.keys()),
401
+ "legacy_configs_found": legacy_files_found,
402
+ "legacy_configs_deleted": legacy_deleted,
403
+ }
404
+ print(json.dumps(result, indent=2))
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.9"
4
+ # dependencies = []
5
+ # ///
6
+ """Merge module help entries into shared _bmad/module-help.csv.
7
+
8
+ Reads a source CSV with module help entries and merges them into a target CSV.
9
+ Uses an anti-zombie pattern: all existing rows matching the source module code
10
+ are removed before appending fresh rows.
11
+
12
+ Legacy cleanup: when --legacy-dir and --module-code are provided, deletes old
13
+ per-module module-help.csv files from {legacy-dir}/{module-code}/ and
14
+ {legacy-dir}/core/. Only the current module and core are touched.
15
+
16
+ Exit codes: 0=success, 1=validation error, 2=runtime error
17
+ """
18
+
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import sys
23
+ from io import StringIO
24
+ from pathlib import Path
25
+
26
+ # CSV header for module-help.csv
27
+ HEADER = [
28
+ "module",
29
+ "skill",
30
+ "display-name",
31
+ "menu-code",
32
+ "description",
33
+ "action",
34
+ "args",
35
+ "phase",
36
+ "after",
37
+ "before",
38
+ "required",
39
+ "output-location",
40
+ "outputs",
41
+ ]
42
+
43
+
44
+ def parse_args():
45
+ parser = argparse.ArgumentParser(
46
+ description="Merge module help entries into shared _bmad/module-help.csv with anti-zombie pattern."
47
+ )
48
+ parser.add_argument(
49
+ "--target",
50
+ required=True,
51
+ help="Path to the target _bmad/module-help.csv file",
52
+ )
53
+ parser.add_argument(
54
+ "--source",
55
+ required=True,
56
+ help="Path to the source module-help.csv with entries to merge",
57
+ )
58
+ parser.add_argument(
59
+ "--legacy-dir",
60
+ help="Path to _bmad/ directory to check for legacy per-module CSV files.",
61
+ )
62
+ parser.add_argument(
63
+ "--module-code",
64
+ help="Module code (required with --legacy-dir for scoping cleanup).",
65
+ )
66
+ parser.add_argument(
67
+ "--verbose",
68
+ action="store_true",
69
+ help="Print detailed progress to stderr",
70
+ )
71
+ return parser.parse_args()
72
+
73
+
74
+ def read_csv_rows(path: str) -> tuple[list[str], list[list[str]]]:
75
+ """Read CSV file returning (header, data_rows).
76
+
77
+ Returns empty header and rows if file doesn't exist.
78
+ """
79
+ file_path = Path(path)
80
+ if not file_path.exists():
81
+ return [], []
82
+
83
+ with open(file_path, "r", encoding="utf-8", newline="") as f:
84
+ content = f.read()
85
+
86
+ reader = csv.reader(StringIO(content))
87
+ rows = list(reader)
88
+
89
+ if not rows:
90
+ return [], []
91
+
92
+ return rows[0], rows[1:]
93
+
94
+
95
+ def extract_module_codes(rows: list[list[str]]) -> set[str]:
96
+ """Extract unique module codes from data rows."""
97
+ codes = set()
98
+ for row in rows:
99
+ if row and row[0].strip():
100
+ codes.add(row[0].strip())
101
+ return codes
102
+
103
+
104
+ def filter_rows(rows: list[list[str]], module_code: str) -> list[list[str]]:
105
+ """Remove all rows matching the given module code."""
106
+ return [row for row in rows if not row or row[0].strip() != module_code]
107
+
108
+
109
+ def write_csv(path: str, header: list[str], rows: list[list[str]], verbose: bool = False) -> None:
110
+ """Write header + rows to CSV file, creating parent dirs as needed."""
111
+ file_path = Path(path)
112
+ file_path.parent.mkdir(parents=True, exist_ok=True)
113
+
114
+ if verbose:
115
+ print(f"Writing {len(rows)} data rows to {path}", file=sys.stderr)
116
+
117
+ with open(file_path, "w", encoding="utf-8", newline="") as f:
118
+ writer = csv.writer(f)
119
+ writer.writerow(header)
120
+ for row in rows:
121
+ writer.writerow(row)
122
+
123
+
124
+ def cleanup_legacy_csvs(
125
+ legacy_dir: str, module_code: str, verbose: bool = False
126
+ ) -> list:
127
+ """Delete legacy per-module module-help.csv files for this module and core only.
128
+
129
+ Returns list of deleted file paths.
130
+ """
131
+ deleted = []
132
+ for subdir in (module_code, "core"):
133
+ legacy_path = Path(legacy_dir) / subdir / "module-help.csv"
134
+ if legacy_path.exists():
135
+ if verbose:
136
+ print(f"Deleting legacy CSV: {legacy_path}", file=sys.stderr)
137
+ legacy_path.unlink()
138
+ deleted.append(str(legacy_path))
139
+ return deleted
140
+
141
+
142
+ def main():
143
+ args = parse_args()
144
+
145
+ # Read source entries
146
+ source_header, source_rows = read_csv_rows(args.source)
147
+ if not source_rows:
148
+ print(f"Error: No data rows found in source {args.source}", file=sys.stderr)
149
+ sys.exit(1)
150
+
151
+ # Determine module codes being merged
152
+ source_codes = extract_module_codes(source_rows)
153
+ if not source_codes:
154
+ print("Error: Could not determine module code from source rows", file=sys.stderr)
155
+ sys.exit(1)
156
+
157
+ if args.verbose:
158
+ print(f"Source module codes: {source_codes}", file=sys.stderr)
159
+ print(f"Source rows: {len(source_rows)}", file=sys.stderr)
160
+
161
+ # Read existing target (may not exist)
162
+ target_header, target_rows = read_csv_rows(args.target)
163
+ target_existed = Path(args.target).exists()
164
+
165
+ if args.verbose:
166
+ print(f"Target exists: {target_existed}", file=sys.stderr)
167
+ if target_existed:
168
+ print(f"Existing target rows: {len(target_rows)}", file=sys.stderr)
169
+
170
+ # Use source header if target doesn't exist or has no header
171
+ header = target_header if target_header else (source_header if source_header else HEADER)
172
+
173
+ # Anti-zombie: remove all rows for each source module code
174
+ filtered_rows = target_rows
175
+ removed_count = 0
176
+ for code in source_codes:
177
+ before_count = len(filtered_rows)
178
+ filtered_rows = filter_rows(filtered_rows, code)
179
+ removed_count += before_count - len(filtered_rows)
180
+
181
+ if args.verbose and removed_count > 0:
182
+ print(f"Removed {removed_count} existing rows (anti-zombie)", file=sys.stderr)
183
+
184
+ # Append source rows
185
+ merged_rows = filtered_rows + source_rows
186
+
187
+ # Write result
188
+ write_csv(args.target, header, merged_rows, args.verbose)
189
+
190
+ # Legacy cleanup: delete old per-module CSV files
191
+ legacy_deleted = []
192
+ if args.legacy_dir:
193
+ if not args.module_code:
194
+ print(
195
+ "Error: --module-code is required when --legacy-dir is provided",
196
+ file=sys.stderr,
197
+ )
198
+ sys.exit(1)
199
+ legacy_deleted = cleanup_legacy_csvs(
200
+ args.legacy_dir, args.module_code, args.verbose
201
+ )
202
+
203
+ # Output result summary as JSON
204
+ result = {
205
+ "status": "success",
206
+ "target_path": str(Path(args.target).resolve()),
207
+ "target_existed": target_existed,
208
+ "module_codes": sorted(source_codes),
209
+ "rows_removed": removed_count,
210
+ "rows_added": len(source_rows),
211
+ "total_rows": len(merged_rows),
212
+ "legacy_csvs_deleted": legacy_deleted,
213
+ }
214
+ print(json.dumps(result, indent=2))
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -1,12 +1,12 @@
1
1
  {
2
- "generated": "2026-04-08T21:19:53.359Z",
2
+ "generated": "2026-04-09T10:08:13.598Z",
3
3
  "bmadMethodVersion": "6.2.2",
4
4
  "modules": {
5
5
  "bmb": {
6
6
  "url": "https://github.com/bmad-code-org/bmad-builder",
7
7
  "branch": "main",
8
- "commitSha": "a873b7f0b30be65e1525e4e08ff046ae07c724af",
9
- "clonedAt": "2026-04-08T21:19:18.546Z"
8
+ "commitSha": "605e07656f9f633b5e429297388e1db9687d1996",
9
+ "clonedAt": "2026-04-09T10:07:44.090Z"
10
10
  },
11
11
  "cis": {
12
12
  "url": "https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite",
@@ -2,7 +2,6 @@ module,phase,name,code,sequence,workflow-file,command,required,agent,options,des
2
2
  ma-skills,anytime,SRE Agent,bmad-ma-agent-sre,,skill:bmad-ma-agent-sre,bmad-ma-agent-sre,false,bmm-sre,,"Site Reliability Engineer agent for system availability, reliability, and Kubernetes operations.",output_folder,"agent customization",
3
3
  ma-skills,anytime,DevOps Agent,bmad-ma-agent-devops,,skill:bmad-ma-agent-devops,bmad-ma-agent-devops,false,bmm-devops,,"DevOps Engineer agent for CI/CD pipeline automation, Infrastructure as Code, and cloud-native technologies.",output_folder,"agent customization",
4
4
  ma-skills,anytime,Cyber Agent,bmad-ma-agent-cyber,,skill:bmad-ma-agent-cyber,bmad-ma-agent-cyber,false,bmm-cyber,,"Cyber Security Analyst agent for vulnerability assessment, threat modeling, and system hardening.",output_folder,"agent customization",
5
- ma-skills,anytime,MIL-498 Agent,bmad-ma-agent-mil498,,skill:bmad-ma-agent-mil498,bmad-ma-agent-mil498,false,bmm-mil498,,"MIL-STD-498 Documentation Expert agent for defense industry standards and Data Item Descriptions.",output_folder,"agent customization",
6
5
  ma-skills,anytime,ML Scientist Agent,bmad-ma-agent-ml,,skill:bmad-ma-agent-ml,bmad-ma-agent-ml,false,bmm-demerzel,,"Machine Learning Scientist agent for hypothesis-driven ML lifecycle with scientific rigor.",output_folder,"agent customization",
7
6
  ma-skills,anytime,SQA Agent,bmad-ma-agent-sqa,,skill:bmad-ma-agent-sqa,bmad-ma-agent-sqa,false,bmm-qa,,"Software Quality Assurance agent (Gad) for project auditing, compliance verification, and quality reporting.",output_folder,"agent customization",
8
7
  ma-skills,4-implementation,Generate SSS,mil498-sss,,skill:mil498-sss,bmad-mil-generate-sss,false,bmm-mil498,,"Generate a MIL-STD-498 System/Subsystem Specification (SSS).",output_folder,"MIL-498 document",