bmad-method 6.10.1-next.14 → 6.10.1-next.15

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method",
4
- "version": "6.10.1-next.14",
4
+ "version": "6.10.1-next.15",
5
5
  "description": "Breakthrough Method of Agile AI-driven Development",
6
6
  "keywords": [
7
7
  "agile",
@@ -12,7 +12,10 @@ files to {project-root}/_bmad/render/bmad-dev-auto/.
12
12
  Config: four-layer merge of _bmad/config.toml + config.user.toml +
13
13
  custom/config.toml + custom/config.user.toml (post-#2285 installs).
14
14
  Keys surface from [core] and [modules.bmm]. Missing or unparseable
15
- config.toml → HALT.
15
+ config.toml → HALT. A {{.var}} referenced by this skill's .md sources but
16
+ absent from the merged config → HALT (never a silent empty substitution).
17
+ Optional layers may be missing, but one that exists and cannot be parsed
18
+ or read → HALT.
16
19
 
17
20
  Customization: three-layer merge of {skill}/customize.toml +
18
21
  _bmad/custom/bmad-dev-auto.toml + .user.toml (same structural rules as
@@ -50,10 +53,12 @@ def find_project_root():
50
53
 
51
54
 
52
55
  def load_toml(path, required=False):
53
- """Load a TOML file. For required files, HALT (stdout) on missing/parse
54
- error so the LLM-driven workflow stops — stdout is how this script signals
55
- workflow halts to its LLM caller. For optional files, write a stderr
56
- warning and return {}."""
56
+ """Load a TOML file. Only absence is negotiable: a missing optional file
57
+ returns {} (customization layers are optional), a missing required file
58
+ HALTs. A file that exists but cannot be parsed or read always HALTs —
59
+ stdout is how this script signals workflow halts to its LLM caller — the
60
+ user wrote it to be honored, and silently continuing with {} would discard
61
+ their customizations with no failure signal."""
57
62
  if not os.path.isfile(path):
58
63
  if required:
59
64
  print(
@@ -66,17 +71,11 @@ def load_toml(path, required=False):
66
71
  with open(path, "rb") as fh:
67
72
  parsed = tomllib.load(fh)
68
73
  except tomllib.TOMLDecodeError as error:
69
- if required:
70
- print(f"HALT and report to the user: failed to parse {path}: {error}")
71
- sys.exit(1)
72
- print(f"render.py: warning: failed to parse {path}: {error}", file=sys.stderr)
73
- return {}
74
+ print(f"HALT and report to the user: failed to parse {path}: {error}")
75
+ sys.exit(1)
74
76
  except OSError as error:
75
- if required:
76
- print(f"HALT and report to the user: failed to read {path}: {error}")
77
- sys.exit(1)
78
- print(f"render.py: warning: failed to read {path}: {error}", file=sys.stderr)
79
- return {}
77
+ print(f"HALT and report to the user: failed to read {path}: {error}")
78
+ sys.exit(1)
80
79
  if not isinstance(parsed, dict):
81
80
  return {}
82
81
  return parsed
@@ -160,7 +159,7 @@ def resolve_workflow(root, skill_dir, skill_name):
160
159
  """Resolve the [workflow] customization block via the three-layer merge
161
160
  (skill defaults -> team -> user), highest priority last. Same structural
162
161
  rules as resolve_customization.py. All three layers are optional: a missing
163
- or unparseable file warns (via load_toml) and is skipped."""
162
+ file is skipped, but an unparseable one HALTs (via load_toml)."""
164
163
  defaults = load_toml(posixpath.join(skill_dir, "customize.toml"))
165
164
  custom_dir = posixpath.join(root, "_bmad", "custom")
166
165
  team = load_toml(posixpath.join(custom_dir, f"{skill_name}.toml"))
@@ -205,11 +204,27 @@ def flatten_central_config(merged):
205
204
 
206
205
 
207
206
  def render_template(content, vars_):
208
- """Resolve {{.var}} substitutions. Unresolved references emit an empty string
209
- (Go's missingkey=zero semantics)."""
207
+ """Resolve {{.var}} substitutions. Unresolved references emit an empty string,
208
+ but main() HALTs on any missing reference before rendering starts, so this
209
+ fallback never fires in practice."""
210
210
  return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content)
211
211
 
212
212
 
213
+ def collect_missing_vars(sources, vars_):
214
+ """Map each {{.var}} name referenced by the source .md files but absent from
215
+ the merged config to the files that reference it. A missing key must HALT:
216
+ missingkey=zero rendering would bake a corrupted workflow (empty paths,
217
+ blank language lines) with no failure signal."""
218
+ missing = {}
219
+ for fname, content in sources:
220
+ for name in re.findall(r"\{\{\.(\w+)\}\}", content):
221
+ if name not in vars_:
222
+ files = missing.setdefault(name, [])
223
+ if fname not in files:
224
+ files.append(fname)
225
+ return missing
226
+
227
+
213
228
  def _scalar_str(value):
214
229
  """Stringify a scalar for inline rendering: booleans lowercase (matching
215
230
  BMad config conventions), None as empty, everything else via str()."""
@@ -305,6 +320,9 @@ def main():
305
320
 
306
321
  vars_["project_root"] = root
307
322
 
323
+ # Guarded ahead of the general missing-vars scan: sprint_status and
324
+ # deferred_work_file derive from it below, and unlike the scan (absent
325
+ # keys only) this also HALTs on a present-but-empty value.
308
326
  implementation_artifacts = vars_.get("implementation_artifacts", "").strip()
309
327
  if not implementation_artifacts:
310
328
  print(
@@ -320,6 +338,27 @@ def main():
320
338
  implementation_artifacts, "deferred-work.md"
321
339
  )
322
340
 
341
+ sources = []
342
+ for fname in sorted(os.listdir(script_dir)):
343
+ if not fname.endswith(".md") or fname == "SKILL.md":
344
+ continue
345
+ with open(
346
+ posixpath.join(script_dir, fname), "r", encoding="utf-8", newline=""
347
+ ) as fh:
348
+ sources.append((fname, fh.read()))
349
+
350
+ missing = collect_missing_vars(sources, vars_)
351
+ if missing:
352
+ details = "; ".join(
353
+ f"`{name}` (referenced by {', '.join(files)})"
354
+ for name, files in sorted(missing.items())
355
+ )
356
+ print(
357
+ f"HALT and report to the user: config is missing {details} "
358
+ "(expected under [core] or [modules.bmm] in _bmad/config.toml)"
359
+ )
360
+ sys.exit(1)
361
+
323
362
  workflow = resolve_workflow(root, script_dir.replace(os.sep, "/"), skill_name)
324
363
 
325
364
  out_dir = posixpath.join(root, "_bmad", "render", skill_name)
@@ -329,13 +368,8 @@ def main():
329
368
  if fname.endswith(".md"):
330
369
  os.remove(posixpath.join(out_dir, fname))
331
370
 
332
- for fname in sorted(os.listdir(script_dir)):
333
- if not fname.endswith(".md") or fname == "SKILL.md":
334
- continue
335
- src = posixpath.join(script_dir, fname)
371
+ for fname, content in sources:
336
372
  dst = posixpath.join(out_dir, fname)
337
- with open(src, "r", encoding="utf-8", newline="") as fh:
338
- content = fh.read()
339
373
  with open(dst, "w", encoding="utf-8", newline="") as fh:
340
374
  fh.write(render_workflow(render_template(content, vars_), workflow))
341
375
 
@@ -12,7 +12,10 @@ files to {project-root}/_bmad/render/bmad-quick-dev/.
12
12
  Config: four-layer merge of _bmad/config.toml + config.user.toml +
13
13
  custom/config.toml + custom/config.user.toml (post-#2285 installs).
14
14
  Keys surface from [core] and [modules.bmm]. Missing or unparseable
15
- config.toml → HALT.
15
+ config.toml → HALT. A {{.var}} referenced by this skill's .md sources but
16
+ absent from the merged config → HALT (never a silent empty substitution).
17
+ Optional layers may be missing, but one that exists and cannot be parsed
18
+ or read → HALT.
16
19
 
17
20
  Customization: three-layer merge of {skill}/customize.toml +
18
21
  _bmad/custom/bmad-quick-dev.toml + .user.toml (same structural rules as
@@ -50,10 +53,12 @@ def find_project_root():
50
53
 
51
54
 
52
55
  def load_toml(path, required=False):
53
- """Load a TOML file. For required files, HALT (stdout) on missing/parse
54
- error so the LLM-driven workflow stops — stdout is how this script signals
55
- workflow halts to its LLM caller. For optional files, write a stderr
56
- warning and return {}."""
56
+ """Load a TOML file. Only absence is negotiable: a missing optional file
57
+ returns {} (customization layers are optional), a missing required file
58
+ HALTs. A file that exists but cannot be parsed or read always HALTs —
59
+ stdout is how this script signals workflow halts to its LLM caller — the
60
+ user wrote it to be honored, and silently continuing with {} would discard
61
+ their customizations with no failure signal."""
57
62
  if not os.path.isfile(path):
58
63
  if required:
59
64
  print(
@@ -66,17 +71,11 @@ def load_toml(path, required=False):
66
71
  with open(path, "rb") as fh:
67
72
  parsed = tomllib.load(fh)
68
73
  except tomllib.TOMLDecodeError as error:
69
- if required:
70
- print(f"HALT and report to the user: failed to parse {path}: {error}")
71
- sys.exit(1)
72
- print(f"render.py: warning: failed to parse {path}: {error}", file=sys.stderr)
73
- return {}
74
+ print(f"HALT and report to the user: failed to parse {path}: {error}")
75
+ sys.exit(1)
74
76
  except OSError as error:
75
- if required:
76
- print(f"HALT and report to the user: failed to read {path}: {error}")
77
- sys.exit(1)
78
- print(f"render.py: warning: failed to read {path}: {error}", file=sys.stderr)
79
- return {}
77
+ print(f"HALT and report to the user: failed to read {path}: {error}")
78
+ sys.exit(1)
80
79
  if not isinstance(parsed, dict):
81
80
  return {}
82
81
  return parsed
@@ -160,7 +159,7 @@ def resolve_workflow(root, skill_dir, skill_name):
160
159
  """Resolve the [workflow] customization block via the three-layer merge
161
160
  (skill defaults -> team -> user), highest priority last. Same structural
162
161
  rules as resolve_customization.py. All three layers are optional: a missing
163
- or unparseable file warns (via load_toml) and is skipped."""
162
+ file is skipped, but an unparseable one HALTs (via load_toml)."""
164
163
  defaults = load_toml(posixpath.join(skill_dir, "customize.toml"))
165
164
  custom_dir = posixpath.join(root, "_bmad", "custom")
166
165
  team = load_toml(posixpath.join(custom_dir, f"{skill_name}.toml"))
@@ -205,11 +204,27 @@ def flatten_central_config(merged):
205
204
 
206
205
 
207
206
  def render_template(content, vars_):
208
- """Resolve {{.var}} substitutions. Unresolved references emit an empty string
209
- (Go's missingkey=zero semantics)."""
207
+ """Resolve {{.var}} substitutions. Unresolved references emit an empty string,
208
+ but main() HALTs on any missing reference before rendering starts, so this
209
+ fallback never fires in practice."""
210
210
  return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content)
211
211
 
212
212
 
213
+ def collect_missing_vars(sources, vars_):
214
+ """Map each {{.var}} name referenced by the source .md files but absent from
215
+ the merged config to the files that reference it. A missing key must HALT:
216
+ missingkey=zero rendering would bake a corrupted workflow (empty paths,
217
+ blank language lines) with no failure signal."""
218
+ missing = {}
219
+ for fname, content in sources:
220
+ for name in re.findall(r"\{\{\.(\w+)\}\}", content):
221
+ if name not in vars_:
222
+ files = missing.setdefault(name, [])
223
+ if fname not in files:
224
+ files.append(fname)
225
+ return missing
226
+
227
+
213
228
  def _scalar_str(value):
214
229
  """Stringify a scalar for inline rendering: booleans lowercase (matching
215
230
  BMad config conventions), None as empty, everything else via str()."""
@@ -305,6 +320,9 @@ def main():
305
320
 
306
321
  vars_["project_root"] = root
307
322
 
323
+ # Guarded ahead of the general missing-vars scan: sprint_status and
324
+ # deferred_work_file derive from it below, and unlike the scan (absent
325
+ # keys only) this also HALTs on a present-but-empty value.
308
326
  implementation_artifacts = vars_.get("implementation_artifacts", "").strip()
309
327
  if not implementation_artifacts:
310
328
  print(
@@ -320,6 +338,27 @@ def main():
320
338
  implementation_artifacts, "deferred-work.md"
321
339
  )
322
340
 
341
+ sources = []
342
+ for fname in sorted(os.listdir(script_dir)):
343
+ if not fname.endswith(".md") or fname == "SKILL.md":
344
+ continue
345
+ with open(
346
+ posixpath.join(script_dir, fname), "r", encoding="utf-8", newline=""
347
+ ) as fh:
348
+ sources.append((fname, fh.read()))
349
+
350
+ missing = collect_missing_vars(sources, vars_)
351
+ if missing:
352
+ details = "; ".join(
353
+ f"`{name}` (referenced by {', '.join(files)})"
354
+ for name, files in sorted(missing.items())
355
+ )
356
+ print(
357
+ f"HALT and report to the user: config is missing {details} "
358
+ "(expected under [core] or [modules.bmm] in _bmad/config.toml)"
359
+ )
360
+ sys.exit(1)
361
+
323
362
  workflow = resolve_workflow(root, script_dir.replace(os.sep, "/"), skill_name)
324
363
 
325
364
  out_dir = posixpath.join(root, "_bmad", "render", skill_name)
@@ -329,13 +368,8 @@ def main():
329
368
  if fname.endswith(".md"):
330
369
  os.remove(posixpath.join(out_dir, fname))
331
370
 
332
- for fname in sorted(os.listdir(script_dir)):
333
- if not fname.endswith(".md") or fname == "SKILL.md":
334
- continue
335
- src = posixpath.join(script_dir, fname)
371
+ for fname, content in sources:
336
372
  dst = posixpath.join(out_dir, fname)
337
- with open(src, "r", encoding="utf-8", newline="") as fh:
338
- content = fh.read()
339
373
  with open(dst, "w", encoding="utf-8", newline="") as fh:
340
374
  fh.write(render_workflow(render_template(content, vars_), workflow))
341
375