Skip to content

fix(setup): register into the BMAD v6.10 TOML config layout (#258)#285

Open
pbean wants to merge 8 commits into
mainfrom
fix/setup-toml-config-layout-258
Open

fix(setup): register into the BMAD v6.10 TOML config layout (#258)#285
pbean wants to merge 8 commits into
mainfrom
fix/setup-toml-config-layout-258

Conversation

@pbean

@pbean pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

BMAD-METHOD v6.10 consolidated onto TOML. Its resolver (_bmad/scripts/resolve_config.py)
reads exactly four files — config.toml, config.user.toml, custom/config.toml,
custom/config.user.toml — and never config.yaml. /bmad-help reads exactly one
catalog, _bmad/_config/bmad-help.csv, the installer's assembled concatenation of the
per-module _bmad/<mod>/module-help.csv files.

/bmad-loop-setup's two merge scripts still wrote the pre-6.10 root _bmad/config.yaml
and _bmad/module-help.csv. Neither is read by anything on v6.10, and neither is even tracked
in _bmad/_config/files-manifest.csv. Net effect: on a v6.10 project the module was
registered nowhere and its skills never appeared in /bmad-help.

Two claims in the issue were checked and corrected (details in the
validation comment):

  • "The installer already registered the module, so setup can no-op." Not on a
    uv tool install + bmad-loop init project — manifest.yaml lists only core + bmm,
    there are 0 bmad-loop rows anywhere, and init never touches _bmad/. A no-op would
    leave the module permanently invisible, so the fix has to register into the new layout.
  • "validate reports BMAD config OK from the TOML layout." No — cli.pybmadconfig.py
    reads _bmad/bmm/config.yaml, the per-module YAML, which v6.10.1 still generates and
    manifest-tracks. The runtime is unaffected by this issue (that migration is bmadconfig: read the four-layer central TOML as primary, legacy bmm/config.yaml as fallback #154).

Design — detect the layout, register into what the project actually reads

Layout is TOML iff <bmad-dir>/config.toml exists (pure Path.exists(), no parsing, no
imports). Both scripts gained a required --bmad-dir and carry their own copy of
detect_layout / detect_orphans — they ship standalone into user projects, so a shared
import module would break them.

TOML branch (v6.10+) writes three things and nothing else:

File How
_bmad/custom/config.toml [modules.bmad-loop] — surgical text edit of that table's span only
_bmad/bmad-loop/module-help.csv full overwrite (the whole file is ours)
_bmad/_config/bmad-help.csv anti-zombie merge, catalog's own header preserved

Legacy YAML branch (pre-6.10) is byte-for-byte today's behavior, still driven by
--config-path / --user-config-path / --target. Each branch fails loudly with the
layout-specific argument it is missing.

Why no core values on the custom layer

custom/config.toml is the layer BMAD documents as "never touched by the installer" — which
is exactly why it is the right home for our module table, and exactly why user_name,
output_folder and friends must not go there
. The custom layer wins over the installer
layer, so a value pinned there would silently override every future installer answer. Core
config stays installer-owned; core_updated is always false and user_keys always empty on
this branch, by design. Since assets/module.yaml declares zero prompt variables, there is
nothing to collect on this layout at all.

Writing TOML without a writer

There is no stdlib TOML writer, and custom/config.toml is a human-authored, comment-bearing
file — round-tripping it through a parser + emitter would destroy it. So the table is
hand-emitted and spliced in via upsert_module_table(); everything outside the replaced span
comes back byte-identical, and the result is validated by re-parsing with tomllib (lazy
import; toml_validated: false + a stderr note when unavailable). No new dependency was
added
tomli-w was deliberately avoided because it would deepen #259.

Orphans: report, never delete

Inert pre-6.10 leftovers directly under _bmad/ (config.yaml, config.user.yaml,
module-help.csv) are reported as orphans_detected[{"path", "has_module_entries"}]
and never deleted, per the #64 never-delete-live-config doctrine. legacy_configs_deleted
/ legacy_csvs_deleted stay []. The skill now tells the user these are safe to remove by
hand and that bmad-loop will not do it for them.

Caveat: the installer regenerates the catalog

_config/bmad-help.csv is generated, not hash-tracked in files-manifest.csv — merging into
it desyncs no manifest, but a BMAD installer re-run regenerates it and drops our rows.
Expected, and now documented in the skill's Confirm step: re-run /bmad-loop-setup to restore
them.

Commits

  • e03d579merge-config.py: --bmad-dir, detect_layout, emit_module_table,
    upsert_module_table, detect_orphans, TOML branch (+8 tests)
  • 606f76amerge-help-csv.py: own layout/orphan copies, per-module CSV + catalog merge,
    stale HEADER constant fix (after/beforepreceded-by/followed-by) (+8 tests)
  • fff4c6b — docs: SKILL.md two-layout rewrite, docs/setup-guide.md (what setup does +
    uninstall), README.md, data/skills/README.md, CHANGELOG

SKILL.md's two script command lines were stale after the first two commits (neither passed
the now-required --bmad-dir); the docs commit closes that seam. The commands now carry the
legacy-only arguments too, so one command line works on both layouts — they are simply
ignored on the TOML branch.

One drive-by content fix: assets/module-help.csv's outputs cell for bmad-loop-setup read
config.yaml entries, which is wrong on v6.10. It is now the layout-neutral
module config + help entries. No test asserted the old string.

End-to-end proof

A throwaway v6.10 fixture (config.toml + config.user.toml + a comment-bearing
custom/config.toml carrying an unrelated [agents.bmad-agent-pm] table + a realistic
_config/bmad-help.csv), both scripts run against it twice, then BMAD's own unmodified
resolve_config.py used as the oracle.

(a) BMAD's resolver reads our registration back:

$ uv run --no-project --python 3.11 <fixture>/_bmad/scripts/resolve_config.py \
    --project-root <fixture> --key modules.bmad-loop
{
  "modules.bmad-loop": {
    "name": "BMAD Loop Skills",
    "description": "Automation-mode skills driven by the bmad-loop orchestrator: interactive escalation resolution (bmad-loop-resolve) and deferred-work sweep triage (bmad-loop-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill",
    "version": "0.9.0",
    "default_selected": false
  }
}

The pre-existing comments and the unrelated [agents.bmad-agent-pm] table survived verbatim:

# Team / enterprise overrides for _bmad/config.toml.
# Committed to the repo — applies to every developer on the project.

# my own overrides — keep me
[agents.bmad-agent-pm]
description = "Prefers short, bulleted PRDs."

[modules.bmad-loop]
name = "BMAD Loop Skills"
description = "..."
version = "0.9.0"
default_selected = false

(b) Exactly one block of our rows in the catalog after two runs (2 pre-existing rows kept):

module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,...
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
Core,bmad-help,Help,H,Show the BMad help menu.,menu,,anytime,,,false,,
BMAD Loop Skills,bmad-loop-setup,Setup Loop Module,SA,Install or update BMAD Loop module c...
BMAD Loop Skills,bmad-loop-sweep,Sweep Triage,ST,Read-only triage of the deferred-work led...

--- 'BMAD Loop Skills' row count: 2 (source has 2 rows)

Run 1 reported table_replaced: false / rows_removed: 0, rows_added: 2, total_rows: 4;
run 2 reported table_replaced: true / rows_removed: 2, rows_added: 2, total_rows: 4.
toml_validated: true on both.

(c) No legacy files created:

  ok: _bmad/config.yaml absent
  ok: _bmad/config.user.yaml absent
  ok: _bmad/module-help.csv absent

(d) Orphans reported, never deleted — a second fixture pre-seeded with pre-6.10 leftovers:

{
  "layout": "toml",
  "orphans_detected": [
    { "path": ".../_bmad/config.yaml",      "has_module_entries": true  },
    { "path": ".../_bmad/config.user.yaml", "has_module_entries": false },
    { "path": ".../_bmad/module-help.csv",  "has_module_entries": true  }
  ],
  "legacy_configs_deleted": [],
  "core_updated": false
}

All three files survived byte-identical (config.yaml still bauto:| version: 0.4.0|bmad-loop:| version: 0.8.0|).

Verification

uv run pytest -q     2944 passed, 7 skipped
trunk check          No issues

16 new tests in tests/test_setup_cleanup_scripts.py behind a _v610_bmad fixture cover both
scripts' TOML branches: custom-table write, comment/unrelated-table preservation (append and
replace-with-a-neighbour-below), no YAML written, no core values leaked, upsert idempotence
including a version bump, per-module CSV, catalog merge + idempotence, catalog created when
absent, root CSV left alone, orphans reported-not-deleted, and both legacy YAML branches
unchanged plus their missing-argument errors.

Note: test_merge_help_toml_strips_pre_rename_rows asserts the opposite of what its name
suggests, on purpose — the anti-zombie filter keys on source module codes
(BMAD Loop Skills), so leftover pre-rename BMAD Automator Skills rows survive the merge.
That is now documented as a manual-delete step in the skill's bauto-migration section, extended
to cover _bmad/_config/bmad-help.csv.

Out of scope (both remain open)

Closes #258

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1c16d22-b2db-4907-bb3a-b01de02bb25c

📥 Commits

Reviewing files that changed from the base of the PR and between d4d1764 and f83facb.

⛔ Files ignored due to path filters (1)
  • src/bmad_loop/data/skills/bmad-loop-setup/assets/module-help.csv is excluded by !**/*.csv
📒 Files selected for processing (8)
  • CHANGELOG.md
  • README.md
  • docs/setup-guide.md
  • src/bmad_loop/data/skills/README.md
  • src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md
  • src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-config.py
  • src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-help-csv.py
  • tests/test_setup_cleanup_scripts.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/setup-toml-config-layout-258

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive review findings

Recommendation: request changes; do not merge as-is. The v6.10 registration design is sound, but the TOML editor can corrupt a valid human-authored config and still report success.

Triage: 2 high, 3 medium. The focused and full test suites are green, so these are untested behavioral gaps rather than ordinary test failures.

Findings

  1. High — valid TOML can be corrupted by the regex upsert (merge-config.py:450-500).

    Reproduced with a valid inline-commented header ([modules.bmad-loop] # comment), a quoted equivalent (["modules"."bmad-loop"]), and a multiline string containing a line beginning with [. The first two are missed as existing tables, causing a duplicate semantic table to be appended. The multiline case is mistaken for a new table boundary and truncated. Both produce invalid TOML.

  2. High — invalid TOML is written and reported as success (merge-config.py:634-661, SKILL.md:233).

    The script overwrites custom/config.toml before validation. A parse failure only sets toml_validated: false; it still returns status: success and exits 0. The setup skill also explains false as only meaning tomllib was unavailable, even though parse errors take the same path. The documented invocation additionally parses unused legacy YAML after writing TOML, so malformed legacy YAML can leave another partial setup.

  3. Medium — the byte/comment preservation guarantee is false (merge-config.py:464-500, SKILL.md:20).

    Comments inside the existing module table and footer comments after the final table are deleted during replacement. CRLF input processed on POSIX is also normalized to LF by text I/O, rewriting unrelated bytes. This contradicts the stated preservation contract for the human-authored custom layer.

  4. Medium — catalog rows assume source and destination headers have identical ordering (merge-help-csv.py:331-348).

    The catalog header is preserved, but source rows are appended positionally without checking or remapping columns. Current v6.10 headers match; a reordered or extended BMAD schema would silently shift help fields into the wrong columns.

  5. Medium — the two help outputs are not committed safely (merge-help-csv.py:324-348).

    The per-module CSV is overwritten before the shared catalog is fully read and staged, and both files are truncated directly. A catalog failure or interruption can leave split registration or damage the shared help catalog.

Remediation plan

  1. Replace the regex-only TOML boundary detection with a TOML-aware scanner, or fail closed whenever semantic parsing finds the module but the editor cannot safely locate its source span.
  2. Build and validate the complete candidate before touching disk. Invalid candidates must exit nonzero and leave the original unchanged.
  3. Preserve comments, newline convention, permissions, and unrelated bytes; write through a sibling temporary file followed by atomic replacement.
  4. Validate CSV headers and row widths, require one source module, and map source rows by column name into the catalog schema. Stage both outputs before replacing either.
  5. Add regression tests for quoted/commented headers, multiline values, comments at EOF, CRLF, invalid-candidate rollback, malformed legacy YAML, reordered CSV headers, and partial-write failures. Include an end-to-end assertion using BMADs actual resolver rather than only tomllib.loads().

Verification

  • Focused setup suite: 26 passed
  • Full local suite: 2,944 passed, 7 skipped
  • GitHub CI: all Python 3.11–3.14, Windows, lint, typecheck, and version checks pass
  • git diff --check: clean

The documented catalog-regeneration caveat, manual pre-rename cleanup, and issues #154/#259 were treated as accepted or out of scope rather than review findings.

pbean added 2 commits July 24, 2026 07:51
The hand-rolled editor for _bmad/custom/config.toml found a table's span with a
regex, which is not safe on TOML: a header line may carry a trailing comment or
quoted key components, and a [-leading line may sit inside a multi-line string or
array. Each miss produced INVALID TOML from valid input -- a duplicate table when
the header was missed, a truncated value when a string body was mistaken for the
next table -- and the result was written before it was validated, so the script
still reported status: success and exited 0.

That failure was silent all the way down. BMAD's resolve_config.py loads the
custom layer as optional, so an unparseable file is swallowed as a warning and
merged as {}: our table is not registered AND every override the user wrote in
that file stops applying, with a zero exit code everywhere.

merge-config.py:
- scan_table_headers() replaces the regex: a real scanner tracking single- and
  multi-line strings, comments, and array/inline-table depth, normalising bare and
  quoted key paths.
- verify_candidate() is the gate that makes the edit trustworthy. It checks the
  RESULT, not the scanner: the candidate must parse and must differ from the
  original in nothing but modules.<code>. Duplicates, truncation and clobbered
  neighbours all fail it, so a scanner bug refuses the run instead of corrupting a
  config. Nothing is written unless it passes; a refusal exits non-zero and leaves
  the file byte-identical. Needs tomllib, so the TOML branch now requires Python
  3.11+ -- as BMAD's own resolver already does.
- Newline convention, and file mode, are preserved: text-mode I/O translated CRLF
  to LF, rewriting every line of a CRLF config for a one-table edit.
- Trailing blank/comment lines are backed off the span at EOF too, so a footer
  comment on the last table survives.
- The TOML branch no longer PARSES legacy YAML under --legacy-dir. It discards
  those values anyway, and it read them after the write: a malformed leftover
  raised, reporting failure for a registration that had succeeded.

merge-help-csv.py:
- align_rows() re-keys our rows into the catalog's own column order by name.
  Appending positionally was only correct while the two schemas agreed; BMAD has
  already renamed help columns once, and a reorder silently filed each description
  under skill. Columns the target cannot hold are reported in columns_dropped.
- Both outputs are rendered in full before either is committed, and the source is
  required to carry exactly one module code on the TOML branch.

Both scripts commit every write through a temp file and one atomic rename
(mirroring platform_util.atomic_replace, duplicated because these ship standalone),
so _config/bmad-help.csv -- shared with every module and not hash-tracked -- can
never be left truncated. An OSError now surfaces through the JSON error contract
instead of a traceback.

21 new tests, each verified to fail against the previous code.
…review)

The docs promised more than the code delivered, and explained a failure mode
wrongly:

- SKILL.md said "every comment ... comes back untouched" of custom/config.toml.
  Comments inside the [modules.bmad-loop] table do not survive -- that table is
  rewritten wholesale, which IS the anti-zombie guarantee. State the contract that
  actually holds: every unrelated table, every comment outside our table, and the
  file's line endings and mode.
- SKILL.md explained toml_validated: false as meaning only that tomllib was
  unavailable. A parse failure took the same path, so a corrupt config read as a
  Python-version footnote. The edit is now verified before the write, so the field
  is always true on success and a failure exits non-zero having written nothing.
- Record why that matters: BMAD's resolver treats an unparseable custom layer as an
  empty one, so a corrupt file silently drops the user's own overrides too.
- Note the Python 3.11+ requirement on the TOML branch, the two errors only the
  user can clear (an unparseable existing file, an older interpreter), that a
  non-zero exit always means nothing was committed, and the new columns_dropped
  field.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Review findings addressed — 9eb5a7f (code + tests), 6578023 (docs)

All five findings reproduced against the branch and are fixed. One correction to the triage: finding 1+2 is more severe than "reports success" — it can leave the project worse than not running setup at all, silently.

resolve_config.py loads the custom layer as optional, so a TOMLDecodeError there is swallowed as a warning and merged as {}:

warning: failed to parse .../custom/config.toml: Unterminated string (at end of document)
{ "core": { "user_name": "BMad" } }     # our table gone — AND the user's own overrides gone
resolver exit = 0

So a corrupt write doesn't just fail to register the module; it silently disables every hand-written override in that file, with a zero exit code from setup and from BMAD. That is what drove the fix toward "never write something we haven't verified" rather than "validate harder after writing".

Findings → resolution

1. Regex upsert corrupts valid TOML — replaced with scan_table_headers(), a real scanner tracking single/multi-line strings, comments and array/inline-table depth, normalising bare and quoted key paths. All reported cases confirmed and fixed, plus three more found while building fixtures: ''' literal strings, # inside a string, and a multi-line array whose elements start a line with [.

2. Invalid TOML written and reported as success — inverted to verify-before-write. verify_candidate() checks the result, not the scanner: the candidate must parse and must differ from the original in nothing but modules.<code>. Duplicates raise in tomllib, truncation fails the parse, a clobbered neighbour fails the diff — so a scanner bug I haven't thought of refuses the run instead of corrupting a config. A refusal writes nothing, leaves the file byte-identical and exits non-zero. An existing file that is already unparseable is refused outright: bmad-loop will not edit a config it cannot reason about.

That needs tomllib, so the TOML branch now requires Python 3.11+ — no real loss, since BMAD's own resolve_config.py exits without it, so a v6.10 project cannot function on 3.10 anyway. The legacy YAML branch still runs on 3.9/3.10 and the PEP 723 header is unchanged.

Also fixed: the TOML branch no longer parses legacy YAML under --legacy-dir. It discarded those values anyway and read them after the write, so a malformed _bmad/core/config.yaml raised a traceback and reported failure for a registration that had already succeeded.

3. Preservation guarantee false — newline convention and file mode are now preserved (CRLF stays CRLF; verified on raw bytes), and the trailing blank/comment back-off applies at EOF too, so a footer comment on the last table survives. In-table comments genuinely do not survive — that table is rewritten wholesale, which is the anti-zombie guarantee — so the docs now state the contract that holds instead of over-promising.

4. Positional CSV appendalign_rows() re-keys rows into the target's own column order by name, in both branches, with row widths normalised. Columns the target cannot hold are reported in a new columns_dropped field and warned on stderr. Identical headers stay the fast path, so nothing changes on a current BMAD tree. The source must now carry exactly one module code on the TOML branch, since _bmad/<code>/module-help.csv is by definition one module's file.

5. Unsafe commit of the two help outputs — both are rendered in full before either is written, and every write in both scripts goes through a temp file + one atomic rename (mirroring platform_util.atomic_replace; duplicated rather than imported, since these ship standalone). _config/bmad-help.csv can no longer be left truncated. An OSError now surfaces through the JSON error contract instead of a traceback.

On "an end-to-end assertion using BMAD's actual resolver"

_bmad/ is gitignored, so a checkout never carries resolve_config.py and CI cannot depend on it. Two tests instead: _resolve_layers() replicates the documented four-layer merge as the always-on CI assertion, and test_registration_resolves_through_bmads_own_resolver runs the real resolver when one is available (BMAD_RESOLVE_CONFIG, or the project's own _bmad/), skipping otherwise. Verified passing against a real v6.10.1 resolve_config.py.

Verification

  • 21 new tests, each confirmed to fail against the previous code (18 red on the old scripts) — the corruption cases fail there with real TOMLDecodeErrors.
  • Full suite: 2965 passed, 8 skipped (was 2944/7; the extra skip is the opt-in real-resolver test).
  • trunk check: no issues.
  • End-to-end on a fresh v6.10 fixture — CRLF custom layer, comments, an unrelated [agents.bmad-agent-pm] carrying a multi-line string containing a fake [modules.bmad-loop] line, footer comment at EOF — both scripts run twice, then BMAD's real resolver:
{
  "modules.bmad-loop": { "name": "BMAD Loop Skills", "version": "0.9.0", "default_selected": false },
  "agents.bmad-agent-pm": { "description": "[modules.bmad-loop]\nnot a real table\n" }
}

File still CRLF, footer comment intact, every original line byte-identical, 2 catalog rows after two runs, no legacy files created.

pbean added 3 commits July 24, 2026 07:53
Two self-review fixes on the previous commit:

- Stacked pytest skipif markers are ALL evaluated, so the separate os.geteuid()
  marker on the catalog-truncation test would raise AttributeError at collection
  time on Windows -- breaking the whole module there, not just that test. One
  marker with a short-circuiting `or` instead.
- _skip_string consumed unbounded quotes after a multi-line string's closing
  delimiter (and carried an always-true guard). TOML allows at most two, so
  a value like `"""ends with a quote""""` now closes where TOML says it does.
…-layout-258

# Conflicts:
#	CHANGELOG.md
#	README.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bmad-loop-setup writes legacy config.yaml/module-help.csv; ignored on BMAD v6.10.0 TOML config layout

1 participant