Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,64 @@ breaking changes may land in a minor release.
new resultless-stop breadcrumb verdicts `terminal-frontmatter-pending` and
`ambiguous-frontmatter` record the fingerprint's progress.

- **Deterministic missing-marker catch + repair (#276).** Hardens the #224 missing-marker
fallback, whose attribution was heuristic (a 2-stable-Stops fingerprint, or a single sighting
post-kill) and left the spec non-compliant on disk:
- _Launch-state snapshot + content-hash gate._ Before every review launch the engine now
captures a `SpecSnapshot` (content hash, mtime, frontmatter status) of the spec immediately
after the #160 marker strip and threads it onto the review `SessionSpec`. The fallback
deterministically refuses to synthesize from a candidate whose bytes still hash equal to that
snapshot — the spec is provably untouched by this session (a `done` spec re-opened for review,
never re-written) — in **every** mode, including the dead-window post-kill reconcile. This kills
the documented dead-window false positive (a review killed after an mtime-only bump but before
the `in-review` flip, previously scored `done` without having run). New resultless-stop verdict
`unmodified-since-launch` and dead-window lifecycle crumb `frontmatter-unmodified-refused`
record each refusal. The snapshot is process-transient (a crash-resume degrades to the
conservative 2-observation path), and the review-launch frontmatter `status` is never
mutated — it remains load-bearing skill routing. The same launch-snapshot decision (M1 hash +
M2 transition, a single shared `_snapshot_verdict`) also guards the stories-mode folder+id
read-back, closing the identical false completion on that path; snapshot/candidate identity is
by resolved filesystem path, not raw string spelling.
- _Mid-session status-transition observation._ On each heartbeat tick the generic (and
OpenCode) dev adapter now samples the snapshotted spec's frontmatter and records the first
status it observes this session drive off its launch state to a live, non-terminal value
(in practice `in-review`), with a `spec-status-transition-observed` lifecycle crumb. A
recorded transition is deterministic proof the terminal frontmatter the spec later carries
is this session's own write, so the fallback synthesizes on a single terminal sighting —
live or dead window — instead of the 2-observation fingerprint (the synthesized crumb gains
a `transition` flag). A recorded transition now **outranks** the content-hash gate: a clean
review can round-trip `done → in-review → done` back to the launch bytes while still omitting
its marker, and the observed `in-review` proves it ran, so the hash gate refuses only when no
transition was seen. A transition that flips entirely between two ticks is missed and falls
back to the conservative fingerprint path.
- _Artifact repair._ When the fallback synthesizes a result the engine now appends the
`## Auto Run Result` marker the skill owed onto the on-disk spec (new
`devcontract.append_auto_run_result`, the inverse of the #160 strip), so the once-invisible
spec re-enters the normal marker scan and the next review launch strips it exactly like a
skill-written marker. Best-effort at the `session-synthesized-from-frontmatter` site (covers
live-Stop, crash-path, and post-kill synthesis): guarded to the generic path, refused for a
spec resolved outside the orchestrator-owned roots or whose fresh frontmatter no longer agrees
with the synthesized status (journal `spec-marker-repaired` / `spec-marker-repair-failed` /
`spec-marker-repair-skipped`), so it can never author a marker that disagrees with the
frontmatter. The append and the #160 strip/reset now rewrite the spec atomically (temp +
`atomic_replace`), so an interrupted or disk-full repair leaves the original spec intact rather
than truncated, and a spec ending in a bare `\r` no longer receives an invisible (unparsed)
heading.
- _Targeted contract nudge_ (`limits.dev_contract_nudge`, default `true`). On the first
`terminal-frontmatter-pending` Stop — a marker-less terminal spec that is not the hash-gate
refusal and whose transition is not yet proven — the dev adapter sends one tmux nudge asking
the skill to append the `## Auto Run Result` section it owed and end its turn, repairing the
omission at its source; a compliant append is then harvested by the ordinary marker scan (no
synthesis, no `synthesized_from_frontmatter` flag). It fires exactly once per session — marked
before the send (a raising transport still counts), never refilled, and touching no stall
counters, so an mtime bump that resets the observation counter can never re-nudge (the #149
refill hazard structurally cannot apply). `contract-nudge-sent` lifecycle crumb; set
`dev_contract_nudge = false` to rely on harness-side synthesis alone.

Together these hold one HARD CONSTRAINT: the spec's `status:` at review launch is load-bearing
routing input to the upstream skill, so the frontmatter is **never** mutated at review launch —
every mechanism is observation or a prose-append, never a status write.

## [0.9.0] — 2026-07-21

### Added
Expand Down
3 changes: 2 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
### Spec + implementation (dev stage)

- Drives the upstream `bmad-dev-auto` skill (unmodified) in a fresh tmux session: it plans a 1.5–4k-token spec, auto-approves it, implements, and self-finalizes the spec; the orchestrator syncs `sprint-status` and synthesizes `result.json` from the spec the skill leaves on disk.
- Deterministic missing-marker catch + repair (#276): the review HALT intermittently finalizes a spec's frontmatter to a terminal `status:` without appending the `## Auto Run Result` section the harvest scan keys on, which once livelocked a finished story to a DEFER-drop (#224). Four harness-side mechanisms make catch + fix deterministic without ever mutating the launch frontmatter (load-bearing skill routing): a **launch-state content-hash snapshot** the fallback refuses to synthesize from when the candidate is byte-identical (a `done` spec merely re-opened for review — in every mode, killing the dead-window false positive); **mid-session status-transition observation** (a heartbeat-tick sighting of the spec moving off its launch status proves a later terminal frontmatter is this session's write, so synthesis needs one sighting, not two); **artifact repair** that appends the owed marker onto the spec on synthesis so it re-enters the normal scan; and — gated by `limits.dev_contract_nudge` (default on) — one **targeted contract nudge** per session asking the skill to append the section itself. Frontmatter synthesis stays the backstop for a session that never complies.
- Spec-only contract between stages — review consumes the frozen spec, not the dev session's context.

### Verification (trust-nothing gate)
Expand Down Expand Up @@ -93,7 +94,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se

- Every run is a resumable on-disk state machine: `bmad-loop resume <run-id>` continues from a gate, escalation, or interruption.
- A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item.
- All run state in `.bmad-loop/runs/<run-id>/` (gitignored): `state.json`, `journal.jsonl` (every decision), `events/` (hook signals), `tasks/<id>/` (per-session prompt + `result.json` + diagnostic breadcrumbs — `session-lifecycle.jsonl` records timeout-fire, budget-guard trips/terminations (`budget-tripped` / `over-budget-fired`), and transport-failure classification (`env-fault-classified`, #194), `heartbeat.json` is the wait loop's proof-of-life, `resultless-stops.jsonl` records give-up Stops), `logs/`, `deferred/`, `resolve/`, `ATTENTION`.
- All run state in `.bmad-loop/runs/<run-id>/` (gitignored): `state.json`, `journal.jsonl` (every decision — including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` marker repair, #276), `events/` (hook signals), `tasks/<id>/` (per-session prompt + `result.json` + diagnostic breadcrumbs — `session-lifecycle.jsonl` records timeout-fire, budget-guard trips/terminations (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), and the #276 missing-marker forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`), `heartbeat.json` is the wait loop's proof-of-life, `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch` (bytes unchanged since launch, #276), or `terminal-frontmatter-pending`), `logs/`, `deferred/`, `resolve/`, `ATTENTION`.
- `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time.

### Hook-based transport (no pane-scraping)
Expand Down
11 changes: 8 additions & 3 deletions docs/tui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,15 @@ One row per story (or sweep bundle/triage task) in the selected run:
(the guard's mid-session sample at trip time). The
matching `tasks/<id>/` dir holds the forensic breadcrumbs the adapter wrote
while the session ran: `session-lifecycle.jsonl` (timeout-fire,
budget-guard `budget-tripped` / `over-budget-fired`, and kill-escalation
lines), `heartbeat.json` (the wait loop's proof-of-life —
budget-guard `budget-tripped` / `over-budget-fired`, kill-escalation, and the
#276 missing-marker forensics `spec-status-transition-observed` /
`frontmatter-unmodified-refused` / `contract-nudge-sent`),
`heartbeat.json` (the wait loop's proof-of-life —
stale under a live session means the orchestrator itself was frozen), and
`resultless-stops.jsonl`.
`resultless-stops.jsonl` (each give-up Stop with its verdict: `no-artifact`,
`ambiguous-frontmatter`, `unmodified-since-launch` — the spec's bytes were
unchanged since review launch, so it is a prior `done` re-opened, not this
session's output (#276) — or `terminal-frontmatter-pending`).
- **Log** — the active agent session's pane output (`logs/<task-id>.log`),
ANSI colors preserved, starting with a dim `— <task-id>.log —` header. The
active task is the last `session-start` without a matching `session-end`
Expand Down
42 changes: 42 additions & 0 deletions src/bmad_loop/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@
from ..model import TokenUsage


@dataclass(frozen=True)
class SpecSnapshot:
"""Launch-state fingerprint of a review session's spec, captured by the
engine immediately after the pre-review-launch marker strip
(``_reset_spec_for_review``) and threaded onto its ``SessionSpec``.

Lets the generic adapter's missing-marker fallback deterministically refuse
to synthesize from a candidate whose bytes are byte-identical to the spec's
launch state: such a spec is provably untouched by this session, so the
terminal ``status:`` it carries is the PRIOR pass's ``done`` (re-opened for
review), not proof this session finished (#276 M1).

Process-transient: it rides the live ``SessionSpec`` only and is deliberately
NOT persisted (no ``StoryTask.to_dict`` entry). A crash-resume that
reconstructs the ``SessionSpec`` therefore carries no snapshot, and the
fallback degrades to its conservative 2-observation fingerprint path."""

path: str
mtime_ns: int
sha256: str
fm_status: str


@dataclass(frozen=True)
class SessionSpec:
task_id: str
Expand Down Expand Up @@ -53,6 +76,13 @@ class SessionSpec:
token_budget_mode: str = "off" # "off" | "warn" | "enforce"
token_budget_grace_s: float = 240.0
cache_read_weight: float = 0.1
# Launch-state snapshot of a review session's spec (#276 M1): captured by the
# engine right after the pre-review-launch marker strip and threaded here so
# the generic adapter's missing-marker fallback can deterministically refuse
# to synthesize from a spec still byte-identical to its launch state. None for
# every non-review session and on a crash-resume (process-transient — see
# SpecSnapshot). Kept LAST so positional SessionSpec constructions stay valid.
spec_snapshot: SpecSnapshot | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -142,6 +172,18 @@ def _post_kill_reconcile(
verdict had to leave open."""
return result

def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None:
"""Heartbeat-cadence hook for mid-session on-disk observation, called from
the wait loop's heartbeat-throttled block (~every HEARTBEAT_INTERVAL_S; the
first tick always fires). Base behavior: nothing. Adapters that drive a
skill whose terminal on-disk state is heuristic to attribute may sample it
here (see GenericDevAdapter's `_DevSynthesisMixin`, which records the spec's
first non-terminal status transition to make a later terminal frontmatter
deterministic proof this session wrote it, #276 M2). An observation seam
only — it MUST NOT mutate session state or the spec, and any read failure is
a sample it silently skips, never a verdict."""
return None

def _classify_env_fault(
self, handle: SessionHandle, spec: SessionSpec, result: SessionResult
) -> SessionResult:
Expand Down
Loading