fix(quick-dev): HALT cleanly when base config.toml is unparseable

Load the four config layers through a load_toml helper that marks the
base _bmad/config.toml as required. A missing, unparseable, or unreadable
base now prints a HALT directive to stdout and exits, instead of being
silently skipped and then crashing downstream with a KeyError when a
derived value (e.g. implementation_artifacts) is absent. Optional layers
still warn on stderr and fall back to empty. Merge semantics are
unchanged (dict-aware deep merge, override wins for lists and scalars).
This commit is contained in:
Alex Verkhovsky 2026-05-24 20:28:51 -07:00
parent 839be11932
commit b290a15298
1 changed files with 44 additions and 28 deletions

View File

@ -7,7 +7,8 @@ rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/.
Config: four-layer merge of _bmad/config.toml + config.user.toml + Config: four-layer merge of _bmad/config.toml + config.user.toml +
custom/config.toml + custom/config.user.toml (post-#2285 installs). custom/config.toml + custom/config.user.toml (post-#2285 installs).
Keys surface from [core] and [modules.bmm]. Missing config.toml HALT. Keys surface from [core] and [modules.bmm]. Missing or unparseable
config.toml HALT.
Runtime {variable} placeholders (single curly) pass through untouched for Runtime {variable} placeholders (single curly) pass through untouched for
the LLM to resolve during workflow execution. the LLM to resolve during workflow execution.
@ -40,6 +41,39 @@ def find_project_root():
current = parent current = parent
def load_toml(path, required=False):
"""Load a TOML file. For required files, HALT (stdout) on missing/parse
error so the LLM-driven workflow stops stdout is how this script signals
workflow halts to its LLM caller. For optional files, write a stderr
warning and return {}."""
if not os.path.isfile(path):
if required:
print(
f"HALT and report to the user: required config file not found: {path}"
"ensure this is a post-#2285 BMAD install"
)
sys.exit(1)
return {}
try:
with open(path, "rb") as fh:
parsed = tomllib.load(fh)
except tomllib.TOMLDecodeError as error:
if required:
print(f"HALT and report to the user: failed to parse {path}: {error}")
sys.exit(1)
print(f"render.py: warning: failed to parse {path}: {error}", file=sys.stderr)
return {}
except OSError as error:
if required:
print(f"HALT and report to the user: failed to read {path}: {error}")
sys.exit(1)
print(f"render.py: warning: failed to read {path}: {error}", file=sys.stderr)
return {}
if not isinstance(parsed, dict):
return {}
return parsed
def _deep_merge(base, override): def _deep_merge(base, override):
"""Dict-aware deep merge. Lists and scalars: override wins (we don't need """Dict-aware deep merge. Lists and scalars: override wins (we don't need
the full keyed-merge semantics of resolve_config.py quick-dev only reads the full keyed-merge semantics of resolve_config.py quick-dev only reads
@ -53,35 +87,17 @@ def _deep_merge(base, override):
def load_central_config(root): def load_central_config(root):
"""Four-layer merge of _bmad/config.toml and its peers. HALTs if the base """Four-layer merge of _bmad/config.toml and its peers (highest priority
_bmad/config.toml is absent.""" last). HALTs if the base _bmad/config.toml is missing or unparseable."""
bmad_dir = posixpath.join(root, "_bmad") bmad_dir = posixpath.join(root, "_bmad")
base = posixpath.join(bmad_dir, "config.toml") base_team = load_toml(posixpath.join(bmad_dir, "config.toml"), required=True)
if not os.path.isfile(base): base_user = load_toml(posixpath.join(bmad_dir, "config.user.toml"))
print( custom_team = load_toml(posixpath.join(bmad_dir, "custom", "config.toml"))
f"HALT and report to the user: central config not found at {base}" custom_user = load_toml(posixpath.join(bmad_dir, "custom", "config.user.toml"))
"ensure this is a post-#2285 BMAD install"
)
sys.exit(1)
layers = [ merged = _deep_merge(base_team, base_user)
base, merged = _deep_merge(merged, custom_team)
posixpath.join(bmad_dir, "config.user.toml"), merged = _deep_merge(merged, custom_user)
posixpath.join(bmad_dir, "custom", "config.toml"),
posixpath.join(bmad_dir, "custom", "config.user.toml"),
]
merged = {}
for path in layers:
if not os.path.isfile(path):
continue
try:
with open(path, "rb") as fh:
data = tomllib.load(fh)
except (tomllib.TOMLDecodeError, OSError) as error:
print(f"render.py: skipping {path}: {error}", file=sys.stderr)
continue
if isinstance(data, dict):
merged = _deep_merge(merged, data)
return merged return merged