Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
67a3e2d
fix(loop): journal spent review budgets, dedupe harvest sightings, cl…
Aug 31, 2026
84fa8ca
sweep dw-resolve-session-root-context: DW-14, DW-35 via bmad-loop
Sep 1, 2026
26b5f09
sweep dw-remove-dead-artifact-relpaths: DW-15 via bmad-loop
Sep 1, 2026
fc65024
sweep dw-peel-task-generation-suffix: DW-16 via bmad-loop
Sep 1, 2026
fa0b934
sweep dw-document-spec-path-resolvers: DW-17, DW-18, DW-36 via bmad-loop
Sep 1, 2026
c6f57ac
sweep dw-atomic-tui-replan: DW-33 via bmad-loop
Sep 1, 2026
60ab6fe
sweep dw2-isolation-flip-mount-state: DW-41, DW-42, DW-45 via bmad-loop
Sep 1, 2026
fb521a1
sweep dw2-path-assertion-test-hardening: DW-43, DW-44 via bmad-loop
Sep 1, 2026
0182d06
Fix session-authored park assertions
Sep 1, 2026
21c1d9b
Harden park marker provenance
Sep 1, 2026
35fd2ff
sweep dw2-session-authored-park-assertion: DW-46, DW-47 via bmad-loop
Sep 1, 2026
9af10d9
sweep dw2-proof-probe-consistency: DW-48, DW-49, DW-50 via bmad-loop
Sep 1, 2026
b9207ca
sweep dw2-document-optional-baseline-claim: DW-51 via bmad-loop
Sep 1, 2026
7ba1bda
test(generic): bump marker mtimes by a tick the filesystem can record
Sep 2, 2026
89bf2e3
test(resolve,cli): carry build_context's third member through this sl…
Sep 2, 2026
520118b
fix(resolve,engine): aim the re-arm's spec writes and the defer's car…
Sep 2, 2026
b9e1e5a
fix(deferredwork,engine): file the finding when a seen-again match go…
Sep 3, 2026
024ceee
docs(features): match the review-budget bullet to the shipped behavior
Sep 3, 2026
0c7a75c
fix(generic): catch the symlink-loop RuntimeError in the marker key b…
Sep 3, 2026
4fe4efd
fix(engine): pick the defer notice's arm on the same mounted-task pai…
Sep 3, 2026
a42f1c9
fix(verify): gate `worktree list -z` on git 2.36; keep the newline pa…
Sep 3, 2026
871b117
fix(tui,runs): anchor the escalation modal's spec on the tree the re-…
Sep 3, 2026
f53c21e
fix(tui,runs): locate the modal's stories folder on the tree the re-a…
Sep 3, 2026
285821b
test(tui): write the moved-project spec with LF so the bytes read mat…
Sep 3, 2026
5351da9
fix(workspace): park an orphaned mount's uncommitted work before the …
Sep 3, 2026
b1b27d0
fix(workspace): catch a remounted run branch up to the pinned base
Sep 3, 2026
63f2bbf
fix(workspace): refuse a remount whose branch is checked out at a for…
Sep 3, 2026
fe2783c
fix(runs): leave a recorded path that traverses out of the project un…
Sep 3, 2026
95d30e9
fix(runs,resolve): probe the rebased mount before the stories root fa…
Sep 3, 2026
f1baf7f
fix(engine): pick the rescue and salvage arms on the same mounted-tas…
Sep 3, 2026
64bca0d
fix(runs): confine the re-arm rollback to the live spec root
Sep 3, 2026
0bdc4d6
fix(verify): keep a registered checkout path's trailing whitespace
Sep 3, 2026
b746d5a
fix(worktree): require the mounted accepted-spec probe to stay in the…
Sep 3, 2026
44bfcfd
fix(deferredwork,engine): decline the ledger anchor over a rival that…
Sep 4, 2026
8ecc8ad
fix(workspace): park the orphan's orchestrator-owned artifacts before…
Sep 4, 2026
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
186 changes: 175 additions & 11 deletions CHANGELOG.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions docs/FEATURES.md

Large diffs are not rendered by default.

107 changes: 104 additions & 3 deletions src/bmad_loop/adapters/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol, cast

from .. import devcontract, gates, runs
from ..bmadconfig import ProjectPaths
Expand Down Expand Up @@ -1269,6 +1269,12 @@ def read_usage(self, result: SessionResult) -> TokenUsage | None:
time.sleep(RESULT_POLL_S)


class _SessionStarter(Protocol):
"""Next concrete adapter in the dev mixin's cooperative MRO."""

def start_session(self, spec: SessionSpec) -> SessionHandle: ...


class _DevSynthesisMixin(_ResultFileMixin):
"""Result synthesis for the generic ``bmad-build-auto`` skill, shared by
every transport that drives it (tmux today; see GenericDevAdapter for the
Expand Down Expand Up @@ -1327,6 +1333,93 @@ def _configure_dev_knobs(self) -> None:
# apply — this budget is not a counter and touches no stall counters).
self._contract_nudge_sent: set[str] = set()
self._contract_nudge_enabled = self.policy.limits.dev_contract_nudge
# Marker identities present immediately before each real session launch.
# The adapter, not whole-file mtime, owns this attempt-relative evidence:
# touching another part of a parked spec must not make its retained marker
# look session-authored. A task-level None means directory enumeration was
# incomplete; a path-level None means that one launch file was unreadable.
# Both fail closed at the affected scope without letting an unrelated bad
# Markdown file suppress a newly created, readable story spec.
self._launch_auto_run_results: dict[str, dict[str, tuple[int, str] | None] | None] = {}

@staticmethod
def _marker_path_key(path: Path) -> str:
# `(OSError, RuntimeError)`, like every other `resolve()` guard in this
# package: on the 3.11 support floor a symlink LOOP raises RuntimeError,
# not an OSError (3.13 resolves it silently), and a bare `except OSError`
# let one looped `*.md` under an artifact dir abort the launch capture —
# and with it every unpinned dev session — before the transport started.
try:
return str(path.resolve())
except (OSError, RuntimeError):
return str(path.absolute())
Comment on lines +1352 to +1355

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle symlink loops during marker capture

On supported Python 3.11, Path.resolve() raises RuntimeError for a symlink loop, but this fallback catches only OSError. Consequently, a single looped *.md symlink in an artifact directory makes _capture_launch_auto_run_results abort before the transport starts, blocking every unpinned generic dev session instead of treating that unrelated marker as unreadable; catch RuntimeError here as the surrounding path-observation code does.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.


def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None:
"""Snapshot real result markers before the child can write its spec."""
paths: list[Path] = []
complete = True
if spec.expected_spec:
expected = Path(spec.expected_spec)
paths = [expected if expected.is_absolute() else Path(spec.cwd) / expected]
else:
for artifacts in self._artifact_dirs(spec.cwd):
try:
paths.extend(artifacts.glob("*.md"))
Comment on lines +1364 to +1367

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release marker snapshots after session readback

For every session without expected_spec—including stories-mode sessions and first sprint attempts—this scans every Markdown artifact and retains all marker fingerprints under a unique task ID for the adapter's entire lifetime. With N existing specs and M such sessions, the run performs and retains O(N×M) work, which can make large backlogs increasingly slow or memory-heavy. Scope the capture to the deterministic stories candidate where available, or evict each task's snapshot after its result is finalized.

Useful? React with 👍 / 👎.

except OSError:
complete = False

captured: dict[str, tuple[int, str] | None] = {}
for path in paths:
key = self._marker_path_key(path)
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
continue
except (OSError, UnicodeDecodeError):
captured[key] = None
continue
fingerprint = devcontract.auto_run_result_fingerprint(text)
if fingerprint[0]:
captured[key] = fingerprint
self._launch_auto_run_results[spec.task_id] = captured if complete else None

def start_session(self, spec: SessionSpec) -> SessionHandle:
self._capture_launch_auto_run_results(spec)
# The mixin is shared by two unrelated concrete transports. Keep the
# cooperative MRO dispatch rather than naming either host explicitly;
# the protocol gives Pyright the host contract without adding a runtime
# base that could alter method resolution.
return cast(_SessionStarter, super()).start_session(spec)

def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> bool:
"""Whether the live marker differs from this session's launch marker."""
if spec.task_id not in self._launch_auto_run_results:
# Production always enters through start_session. A direct diagnostic
# read-back has no attempt-relative evidence and therefore fails closed.
return False
captured = self._launch_auto_run_results[spec.task_id]
if captured is None:
return False
try:
current = devcontract.auto_run_result_fingerprint(spec_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError):
return False
key = self._marker_path_key(spec_path)
if key in captured:
launch = captured[key]
if launch is None:
return False
# Appending another marker is authorship even when its text repeats;
# an in-place rewrite is authorship when the final section changes.
# Deleting older sections while retaining the same final marker is not.
return current[0] > launch[0] or (current[0] == launch[0] and current[1] != launch[1])

# A marker moved or copied from another launch path is inherited evidence,
# not a marker authored by this attempt. A genuinely new marker whose text
# happens to collide also fails closed; byte identity cannot prove authorship.
if current in (fingerprint for fingerprint in captured.values() if fingerprint):
return False
return current[0] > 0

def _probe_alive(self, handle: SessionHandle) -> bool | None:
"""Liveness of the session's native surface (tmux window, server
Expand Down Expand Up @@ -1418,10 +1511,13 @@ def _known_spec_synth_result(
observation and the M1 launch-snapshot gate all still apply — scoped to the
one legitimate path instead of a shared directory.

No launch-snapshot gate is needed on the marker branch itself: the
No whole-file launch-snapshot gate is needed on the marker branch itself: the
pre-review-launch strip (`Engine._reset_spec_for_review`) REMOVES the
marker, so a spec carrying one again has necessarily changed bytes since the
snapshot and the gate would be a no-op (`_snapshot_verdict` → NEUTRAL).
Marker-level launch capture still runs for every real session: it prevents
an unrelated post-launch touch from lending a retained park marker to the
new attempt.

Note this deliberately does NOT fall back to the scan when the expected spec
yields nothing: a session that did not write the spec it owed produced no
Expand All @@ -1447,7 +1543,12 @@ def _synthesize_from(self, spec_path: Path, spec: SessionSpec) -> devcontract.Sy
story_key = spec.env.get("BMAD_LOOP_STORY_KEY") or None
raw_dw_ids = (spec.env.get("BMAD_LOOP_DW_IDS") or "").split(",")
dw_ids = [tok for tok in (i.strip() for i in raw_dw_ids) if tok]
return devcontract.synthesize_result(spec_path, story_key=story_key, dw_ids=dw_ids or None)
return devcontract.synthesize_result(
spec_path,
story_key=story_key,
dw_ids=dw_ids or None,
park_marker_session_authored=self._park_marker_session_authored(spec_path, spec),
)

def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None:
"""Mid-session status-transition observation (#276 M2), called each
Expand Down
2 changes: 2 additions & 0 deletions src/bmad_loop/bmadconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ def load_paths(project: Path) -> ProjectPaths:
doc = yaml.safe_load(raw) or {}
except yaml.YAMLError as e:
raise BmadConfigError(f"invalid YAML in {config_path}: {e}") from e
if not isinstance(doc, dict):
raise BmadConfigError(f"{config_path} must contain a top-level mapping")

impl = doc.get("implementation_artifacts")
plan = doc.get("planning_artifacts")
Expand Down
38 changes: 37 additions & 1 deletion src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3116,9 +3116,31 @@ def cmd_resolve(args: argparse.Namespace) -> int:
if args.interactive:
adapters = _make_adapters(project, run_dir, pol)
model = pol.adapter.resolved("dev").model
# The interactive session uses the CURRENT CLI project as cwd. Its code root
# must come from the CURRENT config too: both can have moved since state.json
# was written. This is best-effort observation only; the mandatory config
# re-read after the human conversation remains the authority for re-arm.
try:
pre_session_paths = bmadconfig.load_paths(project)
except (bmadconfig.BmadConfigError, OSError):
pre_session_code_root = state.code_root
else:
pre_session_code_root = pre_session_paths.repo_root
_ctx_path, withheld, unreadable = resolve.build_context(
state, run_dir, story_key, isolation=pol.scm.isolation
state,
run_dir,
story_key,
isolation=pol.scm.isolation,
project_root=project,
code_root=pre_session_code_root,
)
if pre_session_code_root != project:
print(
f"warning: resolve session stays project-rooted at {project.as_posix()!r}; "
"code fixes and commits belong in the run's code root at "
f"{pre_session_code_root.as_posix()!r}",
file=sys.stderr,
)
print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…")
try:
produced = resolve.run_session(
Expand Down Expand Up @@ -3248,6 +3270,15 @@ def cmd_resolve(args: argparse.Namespace) -> int:
file=sys.stderr,
)
else:
if args.interactive and paths.repo_root != pre_session_code_root:
print(
"error: the code root changed during the resolve session from "
f"{pre_session_code_root.as_posix()!r} to {paths.repo_root.as_posix()!r}; "
"the agent's guidance no longer names the tree the re-drive would use. "
"No re-arm was performed; reconcile the code change, then run resolve again.",
file=sys.stderr,
)
return 1
# The SAME refusal `_resume_paused_run` makes, hoisted ahead of both writes
# below — because aiming the mirror at the tree config.yaml names is only
# correct for a configuration the orchestrator will actually run, and this is
Expand Down Expand Up @@ -3285,6 +3316,11 @@ def cmd_resolve(args: argparse.Namespace) -> int:
restore_patch=restore_patch,
isolated_redrive=pol.scm.isolation == "worktree",
resolution_recorded=resolution_recorded,
# The tree this invocation is acting in, which is also the tree
# `build_context` published a `spec_file` from. `state.project` is where the
# run was LAUNCHED and nothing re-stamps it, so a moved project would have
# the agent edit one file and the re-arm flip another.
project_root=project,
)
except runs.RearmError as e:
print(f"error: {e}", file=sys.stderr)
Expand Down
16 changes: 14 additions & 2 deletions src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ These environment variables are set:
{
"story_key": "6-4-cli-list-command",
"run_id": "20260613-111429-6a14",
"project_root": "/abs/path/to/bmad-project",
"code_root": "/abs/path/to/code-repository",
"spec_file": "/abs/path/to/_bmad-output/implementation-artifacts/spec-<story>.md",
"spec_reaches_the_redrive": true,
"redrive_base_ref": "<branch the re-drive reads, or HEAD>",
Expand All @@ -47,13 +49,23 @@ These environment variables are set:
}
```

The interactive session's working directory is always `project_root`. That tree holds
the BMAD artifacts and specs you inspect or clarify. `code_root` is the tree where the
run's code and git work belong; it may be different. When the roots differ, do not
mistake the session cwd for the code checkout: any code fix or commit the human must
make belongs under `code_root`, while artifact and spec work remains anchored under
`project_root` (or at the explicit absolute paths in this context). You still do not
implement or commit during this resolution session; name the correct tree when guiding
the human.

**`spec_reaches_the_redrive` says whether your edit has a future.** The re-drive
reads one tree; `spec_file` may name another. Under worktree isolation the run's mount
is discarded before the re-drive reads anything, so a spec inside that mount is
destroyed with it. When this field is `false`, every write to `spec_file` still
SUCCEEDS and is then thrown away — worse than not editing at all, because the session
looks resolved. `null` means the task has no spec on record: there is nothing to edit
and step 4 does not apply.
looks resolved. `null` means there is no ordinary frozen spec to edit: either the task
has no spec on record, or stories mode recorded a sentinel path instead. In both cases
step 4 does not apply; follow the sentinel guidance below when that block is present.

**`redrive_base_ref` tells you which of the two remedies applies.** Read it before you
tell the human anything: a branch name and `HEAD` mean opposite things.
Expand Down
Loading