bmad-code-org/BMAD-METHOD · error · ValueError
development_status mismatch after write
Error message
development_status mismatch after write
What it means
Raised by sprint_plan.py after it atomically writes the status YAML and immediately re-reads it to verify integrity. The `development_status` mapping that comes back must equal the `dev` dict that was written. It fires only when YAML serialization is not idempotent for the keys/values in `development_status` — i.e. a key or value that the emitter prints unquoted but the parser coerces back into a different Python type (bool, null, int, float, date). On the raise, the surrounding handler restores the original file bytes (or deletes a newly-created file) and calls `_fail`, so the on-disk file is left intact.
Source
Thrown at src/bmm-skills/plan/bmad-sprint-planning/scripts/sprint_plan.py:454
"counts": _counts(dev),
"generated": generated,
"last_updated": args.date,
"warnings": warnings,
**report,
}
if args.dry_run:
print(json.dumps(result, default=str))
return
try:
payload = _dump_bytes(yaml, doc)
_atomic_write(args.status_file, payload, original_mode)
verify_yaml = _make_yaml()
with io.open(args.status_file, "r", encoding="utf-8") as fh:
reread = verify_yaml.load(fh)
if dict(reread.get("development_status") or {}) != {k: v for k, v in dev.items()}:
raise ValueError("development_status mismatch after write")
for field in ("generated", "last_updated", "project"):
if str(reread.get(field)) != str(doc[field]):
raise ValueError(f"{field} mismatch after write")
except Exception as exc:
if original_bytes is not None:
try:
_atomic_write(args.status_file, original_bytes, original_mode)
restored = True
except Exception:
restored = False
else:
Path(args.status_file).unlink(missing_ok=True)
restored = True
_fail(f"write or validation failed, original {'restored' if restored else 'NOT restored'}: {exc}",
restored=restored)
print(json.dumps(result, default=str))
View on GitHub (pinned to b70486b9bd)
Solutions
- Inspect the failed status_file and look for any `development_status` key or value that is a YAML-reserved word or bare number/date; rename it (e.g. `yes` -> `yes-story`, `null` -> `unstarted`) or quote it at the source.
- Run with `--dry-run` to print the generated document as JSON and confirm every key/value is a plain string before the write is attempted.
- If the collision is unavoidable, patch the generator to coerce all `development_status` keys and values to str and configure the YAML emitter to force-quote keys (default_flow_style=False plus a custom representer that emits strings with style='"').
- Check that no two stories resolve to the same key after YAML normalization (a bool and a string collapsing onto one key also produces inequality).
Example fix
# before
[development_status]
true = "done" # key 'true' round-trips to Python bool True
2026-08-12 = "todo" # key parses as a date
# after: keep identifiers as unambiguous strings
[development_status]
"E-true" = "done"
"S-2026-08-12" = "todo"
# or, in the generator, force string keys/values before writing:
doc["development_status"] = {str(k): str(v) for k, v in dev.items()} Defensive patterns
Strategy: validation
Validate before calling
# Before writing, assert every development_status key/value is a plain
# string that round-trips through YAML without type coercion.
import yaml
RESERVED = {"true","false","yes","no","on","off","null","none","~",""}
def dev_keys_roundtrip(dev: dict) -> bool:
for k, v in dev.items():
if not isinstance(k, str) or not isinstance(v, str):
return False
if k.lower() in RESERVED or k.isdigit():
return False
# confirm symmetric load/dump
if yaml.safe_load(yaml.safe_dump({k: v})) != {k: v}:
return False
return True
if not dev_keys_roundtrip(dev):
raise SystemExit("refusing to write: development_status keys would not round-trip") Type guard
def is_plain_str_map(d: object) -> bool:
return isinstance(d, dict) and all(
isinstance(k, str) and isinstance(v, str) and k.strip()
and k.lower() not in {"true","false","yes","no","on","off","null","~"}
for k, v in d.items()
) Try / catch
# The script already restores the original on failure; as a caller, treat a
# non-zero exit as fatal and inspect the status_file for YAML-reserved keys.
import subprocess
res = subprocess.run(["python","sprint_plan.py",...])
if res.returncode != 0:
log.error(res.stderr.decode()) # message says whether original was restored
raise Prevention
- Restrict story/epic identifiers to unambiguous strings: letters, digits, hyphens, but never bare YAML-reserved words.
- Always coerce dev keys/values to str before building the doc.
- Run with --dry-run first; it skips the write and prints the JSON, exposing risky keys.
- Pin the YAML library version so the set of coerced words does not change under you.
When it happens
Trigger: A story/epic identifier or a status string in `development_status` collides with a YAML scalar word: `true`, `false`, `yes`, `no`, `on`, `off`, `null`, `~`, a bare integer like `1`, or an ISO date `2026-08-12`. The dumper writes the key bare, the loader returns a bool/None/int/date, and `dict(reread...)` differs from the all-string `dev` dict. Also triggered by NaN/Inf floats, or by keys containing characters that round-trip through a different type.
Common situations: A team names a story `E-true` or `S-yes`; a status value is set to the literal string `null` for 'not started'; a tracking system returns numeric IDs that get stored as the dev key. Upgrading ruamel.yaml/PyYAML can also widen the set of words it coerces, suddenly failing builds on a file that wrote fine before.
Related errors
- {field} mismatch after write
- All selected tool(s) are suspended: ${suspendedIdes.join(',
- Platform codes configuration not found at: ${PLATFORM_CODES_
- Invalid GITHUB_REPOSITORY format: "${process.env.GITHUB_REPO
- --extra must be a JSON array of objects
AI-assisted analysis of bmad-code-org/BMAD-METHOD@b70486b9bd (2026-08-13).
Data as JSON: /api/errors/e46e3e6ae7ee1e7c.
Report an issue: GitHub.