Skip to content

forward-port the 0.9.1 hotfix (#405) to main #433

Description

@pbean

Forward-port the 0.9.1 hotfix to main as a sequenced program of sub-phases.

PR #406 merged into release/0.9.x on 2026-08-02 and shipped 57 commits (+~2,000 net
source lines across 18 files, +12,076 test lines) after ~20 adversarial bot-review rounds.
main is 274 commits ahead of the v0.9.0 tag the hotfix was cut from, so this is a
re-implementation, not a cherry-pick: worktree_flow.py owns provisioning,
resolve_review_layers/_CUSTOMIZE_OVERRIDES exist and must follow the resolved name,
worktree excludes are worktree-scoped, and pyright is a configured CI gate that has never run
on any of this code.

bmad-build-auto has zero occurrences on main today — nothing pre-landed.

Sub-phases

Fifteen sessions, fourteen PRs. Every sub-phase leaves main green and coherent.
CONTRIBUTING's one-concern-per-PR rule was waived for the hotfix only; it applies here.

# Sub-phase PR Closes
6A resolution seam (+ tracking issue) 1
6B prompt threading + dry-run banner 1 #393
6C small independent contracts + pyright canary 2
6D verify pathspec hardening 3 #423
6E #414 refusal 4 supersedes #414
6F renderer preflight 5 resolves #410
6G provisioning totality 6 #421, #422
6H _bmad seeding + escalations 7
6I deferral parser + ledger primitives + model fields 8
6J harvest + proof-of-work exclusion 9
6K snapshot / restore / disarm + ledger classification 10 #420
6L accept-gated bundle close + reopen 11
6M isolation carry + crash replay 12
6N #425 + #426 + residuals 13 #425, #426
6O CHANGELOG + program close-out 14

6C, 6D, 6E are order-independent among themselves. 6F→6G→6H is a chain; 6I→6J→6K→6L→6M is a
chain; the two chains are independent after 6E. Ship-worthy release points: after PR 1
(the fix users are waiting for), after PR 7, after PR 12.

Main-adaptation decisions (settled — do not re-litigate per session)

  1. append_entry: port ZERO writer code. Main already emits location. Adopt main's
    spelling and rewrite the 0.9.x tests that pin 0.9.x's field order. Re-landing
    location: str | None = None silently reorders the field for every existing producer. The
    harvest caller passes location=<value> or "n/a" — main's _one_line("") emits a blank
    line, whereas the hotfix normalized empty → n/a inside the writer. The
    deferred-work-format.md paragraph asserts source_spec: sits "directly after
    origin:"
    ; on main it sits after location:port the paragraph with the ordering
    claim corrected.
  2. _CUSTOMIZE_OVERRIDES becomes _customize_overrides(resolved_name), reading the
    RESOLVED name only.
    Keep skills.customize-legacy as the warning when a legacy file
    exists with no new counterpart. ⚠️ A design pass recommended dual-reading (legacy pair
    then new pair, later-wins) on the grounds that on main these files actually drive
    _merged_review_layers, so warning alone drops the operator's layers.
    Rejected:
    upstream's own resolver keys on the skill dir, so at run time the legacy file is
    ignored — dual-reading would make the preflight resolve layers the session never applies,
    which is exactly the failure resolve_review_layers's docstring exists to prevent
    ("Matching that keeps the preflight agreeing with the run"). The operator's layers really
    are inert; the honest response is a warning that says so and names the rename. Word the
    message accordingly, and keep it a warning, not a problem — see the false-green rule
    below.
  3. Do NOT port d1e60af's shared-exclude gating, and re-derive the /_bmad/render/
    shield — see 6H's D-1.
  4. Merge kwargs, never replace: verify_dev/verify_dev_bundle/verify_dev_stories/
    verify_review take main's operator_park and the new engine_written. Make
    engine_written keyword-only on all three, fixing 0.9.x's asymmetry (it is positional
    on verify_dev_stories). ⚠️ On the stories plan-halt leg main passes
    extra_exclude=None, which means skip the gate — leave it None; None + tuple is a
    TypeError, and "fixing" it would arm a gate on a leg with no diff to satisfy it.
  5. policy.dev.skill stays the literal "bmad-dev-auto" and DEV_SKILLS stays a
    one-element set — it is the adapter discriminator read by engine.py:1996 and
    runsetup.py:83. Improve the PolicyError text to say the invoked name is disk-resolved.
    runsetup.py:83 and engine.py:1996 stay byte-identical.
  6. Extend _copy_traversable; do not add a second copier (correction 3).
  7. DEV_BASE_SKILLS stays keyed on LEGACY; NEW goes into BASE_SKILLS only (the
    copy-if-present superset), leaving main's _REVIEW_LAYER_SKILLS/MERGED_REVIEW_SKILL
    subsumption logic untouched.

Hard ordering constraints (violating any ships a silent bug)

  1. install.DEV_PRIMITIVE_ROLES, cli._skill_trees and WorktreeFlow.worktree_profiles move
    together. Main is already misaligned 3-vs-2 (correction 2); a one-sided move deepens it
    in one direction — gate a tree no worktree carries (refuses runs over a skill nothing reads)
    or provision a tree nothing gates (ships a session into the Unknown command stall the
    preflight exists to catch). (6A)
  2. RENDERER_SEED_SENTINELS crosses the installengine boundary by exact string
    membership
    . The tuple may ship inert in 6F — that is fine. What is not fine is
    shipping 6H's skipped producer without the renderer_stub_resolved conjunct: the
    escalation then fires on every pre-#2601 inline-SKILL.md project. (6F → 6H)
  3. HARVEST_ORIGIN = "spec-deferred" is the on-disk dedup key. Changing it re-files every
    already-harvested finding. (6J)
  4. verify.path_tracked lands before git.render-tracked (6F) and before
    _ledger_is_gits_to_restore (6K) — hence 6D sits early. It ships in 6D with zero
    callers
    ; that is inert, not silent (neither ruff's E4,E7,E9,F set nor pyright basic
    flags an unused module function). Justify it in the PR body and name its consumers.
  5. Vacuous on mainappend_entry(location=) already exists (decision 1).
  6. model.py's 8 fields land before any reader, and each needs a line in both
    to_dict:314 and from_dict:361 — they are hand-enumerated, so a field in only one is
    silently lost across every save/load, and with no getattr fallbacks the reader sees the
    default, not an error. (6I)
  7. _post_dev_accepted_sync and the reduction of sweep._post_dev_state_sync to a no-op are
    one change, or the bundle close runs twice at two different gate positions. (6L)
  8. SweepEngine._carry_isolated_ledger_writes calls super() first (harvest before close
    — reversed, the close lands in front of append_entry's open-only dedup and the harvest
    files a duplicate id); isolated_ledger_carried is set at the call sites, never inside
    the base hook (otherwise the resume replay cannot distinguish "carried" from "hook ran and
    found nothing"). (6M)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions