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

  1. 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.
  2. 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.
  3. 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='"').
  4. 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

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


AI-assisted analysis of bmad-code-org/BMAD-METHOD@b70486b9bd (2026-08-13). Data as JSON: /api/errors/e46e3e6ae7ee1e7c. Report an issue: GitHub.