You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #224/#271 (PR #273, merged). The missing-marker fallback synthesizes a result when the bmad-dev-auto skill finalizes a spec's frontmatter to terminal status: without appending the ## Auto Run Result marker — but its attribution is heuristic (2-stable-Stops fingerprint, or single sighting post-kill), the spec is left non-compliant on disk, and PR #273 documented three accepted risks. This issue makes catch + fix deterministic with four harness-side mechanisms, in ONE PR, delivered in 4 single-session phases on branch feat/deterministic-marker-catch-<this issue>.
Mechanisms
M1 — launch-state snapshot + content-hash gate._reset_spec_for_review (engine.py) captures SpecSnapshot(path, mtime_ns, sha256, fm_status) immediately after the [BUG] The #109 stall-grace fix cannot reach the review leg: a review pass is killed on its first Stop by the dev pass's stale ## Auto Run Result #160 marker strip and threads it through _run_session into a new SessionSpec.spec_snapshot field. _frontmatter_fallback deterministically refuses to synthesize when the candidate's bytes hash equals the snapshot (spec provably untouched by this session) — in ALL modes, including dead_window. Kills the documented dead-window false positive (review killed after an mtime-only bump, before flipping to in-review, scored done without running). New resultless verdict unmodified-since-launch; dead-window lifecycle crumb frontmatter-unmodified-refused. Snapshot is process-transient (crash-resume degrades to today's conservative path).
M2 — mid-session status-transition observation. New _observe_tick(handle, spec) no-op hook on CodingCLIAdapter (must be on the ABC — OpencodeDevAdapter shares _DevSynthesisMixin with its own wait loop), called from both heartbeat-throttled blocks (generic + opencode). The mixin impl reads the snapshot path's frontmatter each ~30s tick and records the FIRST observed normalized status that is non-empty, non-terminal, and ≠ the launch status (in practice in-review) into _fm_transition_obs, with lifecycle crumb spec-status-transition-observed. A recorded transition makes a later terminal frontmatter deterministic proof this session wrote it → sound single-sighting synthesis, live or dead window. Missed transitions (flip between ticks) fall back to the 2-obs fingerprint. Hash gate outranks transition evidence (bytes reverted to launch state must refuse).
M3 — artifact repair append_auto_run_result. New devcontract writer, inverse of strip_auto_run_result: appends ## Auto Run Result / Status: <fm status> / provenance note (ORCHESTRATOR_SYNTH_NOTE). False on absent file or existing real (non-fenced) heading; present-but-unreadable raises (caller imposes best-effort). Engine-side _repair_spec_marker(task, rj) called at the session-synthesized-from-frontmatter journal site (covers live-Stop, crash-path, and post-kill synthesis): guards = _generic_dev(), within-roots (skip reason out-of-tree), fm re-read must be terminal AND match rj["status"] (skip reason fm-mismatch — never author an inconsistent marker); journal kinds spec-marker-repaired / spec-marker-repair-failed / spec-marker-repair-skipped. Non-interference: _reconcile_generic_terminal_status only acts when fm LAGS prose; _salvage_review_timeout reads fm fresh; the append is engine-side ONLY (an adapter-side write would perturb its own mtime/hash observations).
M4 — targeted contract nudge, gated by new policy knob limits.dev_contract_nudge (bool, default true). At the first terminal-frontmatter-pending observation (wait=True, exactly one candidate, not dead-window, transition not proven), send one tmux nudge (CONTRACT_NUDGE_TEXT, parameterized spec path + status) telling the skill to append its required marker section, then end its turn. Exactly once per session via a never-cleared _contract_nudge_sent set, marked BEFORE the send (a raising transport still satisfies exactly-once); MultiplexerError swallowed; touches NO stall counters (Claude adapter: Stop hook + idle detection re-entrant loop prevents session from ending #149: an mtime bump resets the observation counter but must never re-nudge). A compliant skill's append is then harvested by the NORMAL marker scan; synthesis stays the backstop.
Decision ladder (_frontmatter_fallback, final shape)
scan → 0 candidates: pop obs, no-artifact → >1: ambiguous-frontmatter → read candidate (mtime, fm status, sha256 when snapshot path matches; OSError = torn-read degrade) → hash gate (M1, all modes) → evidence: transition_proven (M2) + fingerprint/stable/observations → synthesize when dead_window or transition_proven or (stable and obs >= 2) (crumb gains transition=) → record-obs branch: fingerprint + terminal-frontmatter-pending + contract nudge (M4).
HARD CONSTRAINT
The spec's status: done at review launch is load-bearing routing input to the upstream skill (step-01 → fresh step-04 review pass; _reset_spec_for_review docstring). Frontmatter status is NEVER mutated at review launch — all four mechanisms are observation or prose-append only.
Phases (one fresh session each, shared branch, PR opened in phase 4)
S4 — M4: nudge + dev_contract_nudge knob (policy.py + core.toml + template + tests) + docs (FEATURES.md, tui-guide.md) + CHANGELOG finalize + open PR (ready-for-review, closes this issue) + file & cross-link the upstream BMAD-METHOD issue (bmad-dev-auto omits the marker append at review HALT)
Every phase ends: targeted pytest → full uv run pytest -q → trunk check --no-fix → commit → push (branch stays shippable).
Traps (carry into every phase)
Do NOT add kwargs to _synth_result — the monkeypatched stub in tests/test_generic_tmux.py (def raising(handle, spec, *, wait, dead_window=False)) must stay valid; snapshot rides SessionSpec, state rides adapter dicts.
_DevSynthesisMixin must declare send_text as a BARE class annotation, never a def (MRO: the mixin precedes the concrete adapter — a def would shadow the real send_text on GenericDevAdapter AND OpencodeDevAdapter).
CHANGELOG Unreleased heading order is Added/Changed/Fixed; inserting a heading mid-section silently reparents entries. One consolidated entry, extended per phase.
core.toml settings entries are default_ref/options_ref into policy.py, never literals; sync tests in tests/test_settings_schema.py enforce bidirectional coverage.
Engine mock tests run the generic path (_generic_dev() always True): review runs via dev_effect(followup_review=True), sprint advances at DEV time.
trunk check --no-fix (ruff F401 autofix deletes re-exports). uv run pytest (PEP668). 2 pre-existing test_module_skills_sync.py failures are known drift.
First heartbeat tick always fires (last_heartbeat is None) — M2 observation runs on the first tick too.
Follow-up to #224/#271 (PR #273, merged). The missing-marker fallback synthesizes a result when the
bmad-dev-autoskill finalizes a spec's frontmatter to terminalstatus:without appending the## Auto Run Resultmarker — but its attribution is heuristic (2-stable-Stops fingerprint, or single sighting post-kill), the spec is left non-compliant on disk, and PR #273 documented three accepted risks. This issue makes catch + fix deterministic with four harness-side mechanisms, in ONE PR, delivered in 4 single-session phases on branchfeat/deterministic-marker-catch-<this issue>.Mechanisms
_reset_spec_for_review(engine.py) capturesSpecSnapshot(path, mtime_ns, sha256, fm_status)immediately after the [BUG] The #109 stall-grace fix cannot reach the review leg: a review pass is killed on its first Stop by the dev pass's stale## Auto Run Result#160 marker strip and threads it through_run_sessioninto a newSessionSpec.spec_snapshotfield._frontmatter_fallbackdeterministically refuses to synthesize when the candidate's bytes hash equals the snapshot (spec provably untouched by this session) — in ALL modes, includingdead_window. Kills the documented dead-window false positive (review killed after an mtime-only bump, before flipping toin-review, scoreddonewithout running). New resultless verdictunmodified-since-launch; dead-window lifecycle crumbfrontmatter-unmodified-refused. Snapshot is process-transient (crash-resume degrades to today's conservative path)._observe_tick(handle, spec)no-op hook onCodingCLIAdapter(must be on the ABC —OpencodeDevAdaptershares_DevSynthesisMixinwith its own wait loop), called from both heartbeat-throttled blocks (generic + opencode). The mixin impl reads the snapshot path's frontmatter each ~30s tick and records the FIRST observed normalized status that is non-empty, non-terminal, and ≠ the launch status (in practicein-review) into_fm_transition_obs, with lifecycle crumbspec-status-transition-observed. A recorded transition makes a later terminal frontmatter deterministic proof this session wrote it → sound single-sighting synthesis, live or dead window. Missed transitions (flip between ticks) fall back to the 2-obs fingerprint. Hash gate outranks transition evidence (bytes reverted to launch state must refuse).append_auto_run_result. New devcontract writer, inverse ofstrip_auto_run_result: appends## Auto Run Result/Status: <fm status>/ provenance note (ORCHESTRATOR_SYNTH_NOTE). False on absent file or existing real (non-fenced) heading; present-but-unreadable raises (caller imposes best-effort). Engine-side_repair_spec_marker(task, rj)called at thesession-synthesized-from-frontmatterjournal site (covers live-Stop, crash-path, and post-kill synthesis): guards =_generic_dev(), within-roots (skip reasonout-of-tree), fm re-read must be terminal AND matchrj["status"](skip reasonfm-mismatch— never author an inconsistent marker); journal kindsspec-marker-repaired/spec-marker-repair-failed/spec-marker-repair-skipped. Non-interference:_reconcile_generic_terminal_statusonly acts when fm LAGS prose;_salvage_review_timeoutreads fm fresh; the append is engine-side ONLY (an adapter-side write would perturb its own mtime/hash observations).limits.dev_contract_nudge(bool, default true). At the firstterminal-frontmatter-pendingobservation (wait=True, exactly one candidate, not dead-window, transition not proven), send one tmux nudge (CONTRACT_NUDGE_TEXT, parameterized spec path + status) telling the skill to append its required marker section, then end its turn. Exactly once per session via a never-cleared_contract_nudge_sentset, marked BEFORE the send (a raising transport still satisfies exactly-once);MultiplexerErrorswallowed; touches NO stall counters (Claude adapter: Stop hook + idle detection re-entrant loop prevents session from ending #149: an mtime bump resets the observation counter but must never re-nudge). A compliant skill's append is then harvested by the NORMAL marker scan; synthesis stays the backstop.Decision ladder (
_frontmatter_fallback, final shape)scan → 0 candidates: pop obs,
no-artifact→ >1:ambiguous-frontmatter→ read candidate (mtime, fm status, sha256 when snapshot path matches; OSError = torn-read degrade) → hash gate (M1, all modes) → evidence:transition_proven(M2) + fingerprint/stable/observations → synthesize whendead_window or transition_proven or (stable and obs >= 2)(crumb gainstransition=) → record-obs branch: fingerprint +terminal-frontmatter-pending+ contract nudge (M4).HARD CONSTRAINT
The spec's
status: doneat review launch is load-bearing routing input to the upstream skill (step-01 → fresh step-04 review pass;_reset_spec_for_reviewdocstring). Frontmatter status is NEVER mutated at review launch — all four mechanisms are observation or prose-append only.Phases (one fresh session each, shared branch, PR opened in phase 4)
SpecSnapshotin adapters/base.py + engine capture/threading + hash gate in_frontmatter_fallback+ adapter/engine tests + consolidated CHANGELOG entry_observe_tickhook + both heartbeat call sites (generic.py, opencode_http.py) + mixin observation + widened synthesis predicate + tests incl. opencode mirrorappend_auto_run_result+ORCHESTRATOR_SYNTH_NOTEin devcontract.py + engine_repair_spec_marker+ devcontract/engine testsdev_contract_nudgeknob (policy.py + core.toml + template + tests) + docs (FEATURES.md, tui-guide.md) + CHANGELOG finalize + open PR (ready-for-review, closes this issue) + file & cross-link the upstream BMAD-METHOD issue (bmad-dev-auto omits the marker append at review HALT)Every phase ends: targeted pytest → full
uv run pytest -q→trunk check --no-fix→ commit → push (branch stays shippable).Traps (carry into every phase)
_synth_result— the monkeypatched stub in tests/test_generic_tmux.py (def raising(handle, spec, *, wait, dead_window=False)) must stay valid; snapshot ridesSessionSpec, state rides adapter dicts._DevSynthesisMixinmust declaresend_textas a BARE class annotation, never adef(MRO: the mixin precedes the concrete adapter — a def would shadow the realsend_texton GenericDevAdapter AND OpencodeDevAdapter).default_ref/options_refinto policy.py, never literals; sync tests in tests/test_settings_schema.py enforce bidirectional coverage._generic_dev()always True): review runs viadev_effect(followup_review=True), sprint advances at DEV time.trunk check --no-fix(ruff F401 autofix deletes re-exports).uv run pytest(PEP668). 2 pre-existing test_module_skills_sync.py failures are known drift.last_heartbeat is None) — M2 observation runs on the first tick too.