diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 0929736..ed5611c 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -459,6 +459,12 @@ def _artifact_dirs(self, cwd: Path) -> list[Path]: return dirs def _result_json(self, handle: SessionHandle, spec: SessionSpec, *, wait: bool) -> dict | None: + # Stories mode (folder+id dispatch): the story spec lives at a + # deterministic id-keyed path, so resolve it directly instead of the + # mtime-floor scan. The engine exports BMAD_LOOP_SPEC_FOLDER only for + # stories runs, so sprint/sweep runs keep the scan path below unchanged. + if spec.env.get("BMAD_LOOP_SPEC_FOLDER"): + return self._stories_result_json(handle, spec, wait=wait) # Mirror the base _await_result poll: the skill's terminal spec may not be # flushed to disk the instant the Stop event fires, so briefly await it when # wait=True instead of reading once and mis-reporting a stall. @@ -481,6 +487,43 @@ def _result_json(self, handle: SessionHandle, spec: SessionSpec, *, wait: bool) return None time.sleep(RESULT_POLL_S) + def _stories_result_json( + self, handle: SessionHandle, spec: SessionSpec, *, wait: bool + ) -> dict | None: + """Deterministic stories-mode read-back: resolve ``/stories/ + -*.md`` by id (never the mtime scan) and synthesize from it. + + ``BMAD_LOOP_SPEC_FOLDER`` carries the project-relative (or absolute) spec + folder; rebase a relative one against ``spec.cwd`` exactly like + ``_artifact_dirs`` so worktree isolation resolves inside the live checkout. + A PRESENT or SENTINEL spec synthesizes (a blocked sentinel becomes a + CRITICAL escalation → PAUSE, same as any block); a still-PENDING or + AMBIGUOUS state is not-yet-terminal → keep waiting, then None (a + result-less Stop the dev-stall grace handles). + + On a plan-halt leg (``BMAD_LOOP_PLAN_HALT`` set by the engine for a + spec_checkpoint story's first dispatch) the skill HALTs at + ``ready-for-dev``; pass ``plan_halt=True`` so synthesize treats that as a + successful terminal (marked ``plan_halt``) rather than died-mid-flight.""" + from .. import stories + + story_key = spec.env.get("BMAD_LOOP_STORY_KEY") or "" + folder = Path(spec.env["BMAD_LOOP_SPEC_FOLDER"]) + base = folder if folder.is_absolute() else Path(spec.cwd) / folder + plan_halt = bool(spec.env.get("BMAD_LOOP_PLAN_HALT")) + deadline = time.monotonic() + RESULT_GRACE_S + while True: + state = stories.resolve_story_spec(base, story_key) + if state.kind in (stories.KIND_PRESENT, stories.KIND_SENTINEL) and state.path: + result = devcontract.synthesize_result( + state.path, story_key=story_key or None, plan_halt=plan_halt + ) + if result.result_json is not None: + return result.result_json + if not wait or time.monotonic() >= deadline: + return None + time.sleep(RESULT_POLL_S) + # Back-compat alias: the adapter was ``GenericTmuxAdapter`` before tmux moved # behind the multiplexer seam. Keeps existing imports stable. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 035fbf4..6500473 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -23,6 +23,9 @@ resolve, runs, sprintstatus, +) +from . import stories as stories_mod +from . import ( verify, ) from .adapters.base import CodingCLIAdapter @@ -31,6 +34,7 @@ from .model import RunState from .process_host import ProcessHostError from .runs import RUNS_DIR +from .stories_engine import StoriesEngine from .sweep import SweepEngine POLICY_FILE = policy_mod.POLICY_FILE @@ -158,21 +162,14 @@ def cmd_validate(args: argparse.Namespace) -> int: problems.append(str(e)) paths = None - if paths: - try: - ss = sprintstatus.load(paths.sprint_status) - actionable = [s for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] - notes.append( - f"sprint-status OK: {len(ss.stories)} stories, {len(actionable)} actionable" - ) - if ss.unknown_keys: - notes.append(f" warning: unknown keys ignored: {', '.join(ss.unknown_keys)}") - except sprintstatus.SprintStatusError as e: - problems.append(str(e)) - + # Policy first — its [stories].source (or a --spec override) selects which + # story-queue gate runs below: the sprint-status file (sprint mode) or the + # stories.yaml manifest (stories mode). Loaded before the queue gate so a + # stories-only project is not failed on a missing sprint-status.yaml. from .adapters.profile import ProfileError, get_profile profiles = [] + pol = None try: pol = policy_mod.load(_policy_path(project)) role_names = {role: pol.adapter.resolved(role).name for role in ROLES} @@ -188,7 +185,24 @@ def cmd_validate(args: argparse.Namespace) -> int: problems.append(str(e)) except policy_mod.PolicyError as e: problems.append(str(e)) - pol = None + + stories_on, spec_folder = _stories_mode(args, pol) + if paths: + if stories_on: + _validate_stories_queue( + project, paths, spec_folder, [p.skill_tree for p in profiles], notes, problems + ) + else: + try: + ss = sprintstatus.load(paths.sprint_status) + actionable = [s for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] + notes.append( + f"sprint-status OK: {len(ss.stories)} stories, {len(actionable)} actionable" + ) + if ss.unknown_keys: + notes.append(f" warning: unknown keys ignored: {', '.join(ss.unknown_keys)}") + except sprintstatus.SprintStatusError as e: + problems.append(str(e)) try: if not verify.worktree_clean(project): @@ -240,14 +254,18 @@ def cmd_validate(args: argparse.Namespace) -> int: return 1 if problems else 0 -def _require_base_skills(project: Path, pol) -> bool: +def _require_base_skills(project: Path, pol, *, require_stories: bool = False) -> bool: """Preflight the upstream skills the orchestrator drives (bmad-dev-auto + the two adversarial review hunters it invokes inline). Returns True when everything is in place; otherwise prints the problems and returns False so the caller can abort before spawning any session (a missing skill would otherwise stall as an `Unknown command` until the run times out). - """ + + ``require_stories`` additionally content-probes bmad-dev-auto for folder+id + dispatch — stories mode needs a newer skill than sprint mode, so an older + install must fail loudly here rather than HALT `no stories.yaml`-style at + dispatch time.""" from .adapters.profile import ProfileError, get_profile skill_trees = [] @@ -257,6 +275,8 @@ def _require_base_skills(project: Path, pol) -> bool: except ProfileError: continue problems = install.missing_base_skills(project, skill_trees) + if require_stories: + problems += install.missing_stories_support(project, skill_trees) if problems: for problem in problems: print(f"FAIL: {problem}", file=sys.stderr) @@ -265,27 +285,108 @@ def _require_base_skills(project: Path, pol) -> bool: return True +def _stories_mode(args: argparse.Namespace, pol) -> tuple[bool, str]: + """Resolve whether this run is stories mode and its spec folder. + + ``run --spec `` forces stories mode (overrides policy); otherwise the + run follows ``[stories].source``. Returns ``(is_stories, spec_folder)`` — the + folder is "" in sprint mode. ``pol`` may be None (e.g. a policy that failed to + load in ``validate``): then only an explicit ``--spec`` can force stories mode.""" + spec = getattr(args, "spec", None) + if spec: + return True, spec + if pol is not None and pol.stories.source == "stories": + return True, pol.stories.spec_folder + return False, "" + + +def _validate_stories_folder( + paths: bmadconfig.ProjectPaths, spec_folder: str, *, selector: str | None = None +) -> str | None: + """Preflight the stories-mode inputs: stories.yaml parses + rules pass, SPEC.md + (the epic spec every first dispatch loads) exists, and — when a ``--story`` + ``selector`` is given — the id is actually in the manifest. Returns a problem + string to print, or None when OK. Catching an unknown ``--story`` here fails the + run before it starts, instead of crashing it mid-flight in the scheduler.""" + folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) + try: + story_set = stories_mod.load_stories(folder) + except stories_mod.StoriesError as e: + return f"stories mode: {e} (spec folder: {folder})" + if not story_set.entries: + return f"stories mode: stories.yaml has no entries: {folder}" + if not (folder / "SPEC.md").is_file(): + return ( + f"stories mode: {folder}/SPEC.md not found — a first dispatch loads the " + f"epic spec (the skill would HALT `no epic spec found`)" + ) + if selector is not None and story_set.get(selector) is None: + return ( + f"stories mode: --story id {selector!r} is not in stories.yaml — " + f"pick one of: {', '.join(e.id for e in story_set.entries)}" + ) + return None + + +def _validate_stories_queue( + project: Path, + paths: bmadconfig.ProjectPaths, + spec_folder: str, + skill_trees: list[str], + notes: list[str], + problems: list[str], +) -> None: + """Stories-mode counterpart of ``cmd_validate``'s sprint-status gate: validate + the ``stories.yaml`` manifest + ``SPEC.md`` and confirm the installed + ``bmad-dev-auto`` carries the folder+id dispatch flow stories mode needs (an + older skill would HALT at dispatch). Appends notes/problems in place; the + probe carries its own remediation text ("update the bmm module").""" + folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) + problem = _validate_stories_folder(paths, spec_folder) + if problem: + problems.append(problem) + else: + try: + count = len(stories_mod.load_stories(folder).entries) + notes.append( + f"stories mode OK: {count} stories in {folder}/stories.yaml, SPEC.md present" + ) + except stories_mod.StoriesError as e: # already validated above — defensive + problems.append(f"stories mode: {e} (spec folder: {folder})") + stories_probs = install.missing_stories_support(project, skill_trees) + if skill_trees and not stories_probs: + notes.append("bmad-dev-auto supports folder+id dispatch (stories mode)") + problems.extend(stories_probs) + + def cmd_run(args: argparse.Namespace) -> int: project = _project(args) paths = bmadconfig.load_paths(project) pol = policy_mod.load(_policy_path(project)) + stories_on, spec_folder = _stories_mode(args, pol) if args.dry_run: - return _dry_run(paths, pol, args) + return _dry_run(paths, pol, args, stories_on, spec_folder) - try: - sprintstatus.select_actionable( - sprintstatus.load(paths.sprint_status), args.epic, args.story - ) - except sprintstatus.SprintStatusError as e: - print(e, file=sys.stderr) - return 1 + if stories_on: + problem = _validate_stories_folder(paths, spec_folder, selector=args.story) + if problem: + print(problem, file=sys.stderr) + return 1 + else: + try: + sprintstatus.select_actionable( + sprintstatus.load(paths.sprint_status), args.epic, args.story + ) + except sprintstatus.SprintStatusError as e: + print(e, file=sys.stderr) + return 1 if not verify.worktree_clean(paths.repo_root): print("git worktree is not clean — commit or stash first", file=sys.stderr) return 1 - if not _require_base_skills(project, pol): + if not _require_base_skills(project, pol, require_stories=stories_on): return 1 _reconcile_stale(project, paths, pol) @@ -301,6 +402,8 @@ def cmd_run(args: argparse.Namespace) -> int: epic_filter=args.epic, story_filter=args.story, max_stories=args.max_stories, + source="stories" if stories_on else "sprint-status", + spec_folder=spec_folder if stories_on else "", ) save_state(run_dir, state) runs.write_pid(run_dir) @@ -308,12 +411,13 @@ def cmd_run(args: argparse.Namespace) -> int: journal.append( "run-start", run_id=run_id, + source=state.source, adapter_dev=pol.adapter.resolved("dev").name, adapter_review=pol.adapter.resolved("review").name, ) print(f"run {run_id} starting (attach: bmad-loop attach)") - engine = Engine( + common = dict( paths=paths, policy=pol, adapter=adapters["dev"], @@ -326,6 +430,9 @@ def cmd_run(args: argparse.Namespace) -> int: story_filter=args.story, sweep_factory=_sweep_factory(project, paths), ) + engine: Engine = ( + StoriesEngine(**common, spec_folder=spec_folder) if stories_on else Engine(**common) + ) summary = engine.run() print(summary.render()) return 0 @@ -348,7 +455,16 @@ def _render_invocation(pol, project: Path, role: str, prompt: str) -> str: return " ".join(argv) -def _dry_run(paths: bmadconfig.ProjectPaths, pol, args: argparse.Namespace) -> int: +def _dry_run( + paths: bmadconfig.ProjectPaths, + pol, + args: argparse.Namespace, + stories_on: bool = False, + spec_folder: str = "", +) -> int: + if stories_on: + return _dry_run_stories(paths, pol, args, spec_folder) + def render(role: str, prompt: str) -> str: return _render_invocation(pol, paths.project, role, prompt) @@ -372,6 +488,77 @@ def render(role: str, prompt: str) -> str: return 0 +def _checkpoint_badge(row: stories_mod.StoryRow) -> str: + """`` [spec-checkpoint, done-checkpoint]`` for a story's HITL flags, or ``""`` + when it sets neither. Shared spelling for the dry-run schedule + status.""" + marks = [] + if row.spec_checkpoint: + marks.append("spec-checkpoint") + if row.done_checkpoint: + marks.append("done-checkpoint") + return f" [{', '.join(marks)}]" if marks else "" + + +def _dry_run_stories( + paths: bmadconfig.ProjectPaths, pol, args: argparse.Namespace, spec_folder: str +) -> int: + """Print the linear stories-mode schedule (list order, checkpoints, live + on-disk state) — no topo waves, one story per line, spawns nothing.""" + folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) + # The real dispatch always uses the project-relative folder (the engine + # relativizes it); render the identical string here so dry-run and run agree. + rel = stories_mod.relativize_spec_folder(paths.project, spec_folder) + try: + rows = stories_mod.story_rows(folder, selector=args.story, max_stories=args.max_stories) + except stories_mod.StoriesError as e: + print(f"stories mode: {e} (spec folder: {folder})", file=sys.stderr) + return 1 + if args.story and not rows: + print(f"stories mode: story id {args.story!r} not found in stories.yaml", file=sys.stderr) + return 1 + spec_ok = "" if (folder / "SPEC.md").is_file() else " [!] SPEC.md missing" + print( + f"stories mode: {len(rows)} stories from {folder}/stories.yaml " + f"(gates={pol.gates.mode}){spec_ok}" + ) + print("linear schedule (list order — no depends_on, strictly serial):") + for row in rows: + print(f"\n {row.position}. {row.id} ({row.label}){_checkpoint_badge(row)} {row.title}") + # A spec_checkpoint story whose plan is not yet on disk dispatches leg 1 + # (Halt after planning + BMAD_LOOP_PLAN_HALT); mirror the real dispatch's + # markers so dry-run does not under-report what run would emit. + plan_halt = stories_mod.is_plan_halt_leg(row.spec_checkpoint, row.state) + dispatch = f"/bmad-dev-auto Spec folder: {rel}. Story id: {row.id}." + if plan_halt: + dispatch += " Halt after planning." + print(f" dev: {_render_invocation(pol, paths.project, 'dev', dispatch)}") + env = f"BMAD_LOOP_MODE=1 BMAD_LOOP_STORY_KEY={row.id} BMAD_LOOP_SPEC_FOLDER={rel}" + if plan_halt: + env += " BMAD_LOOP_PLAN_HALT=1" + print(f" env: {env}") + return 0 + + +def _print_stories_status(state: RunState, project: Path) -> None: + """The stories-mode board for `status`: id, live on-disk state, checkpoint + markers and title, read from the run's pinned spec folder. Mode-aware + counterpart of the sprint-backlog line — the run stamped ``source`` and + ``spec_folder`` at start, so no flag is needed to re-derive the mode.""" + folder = stories_mod.resolve_spec_folder(project, state.spec_folder) + try: + rows = stories_mod.story_rows(folder) + except stories_mod.StoriesError as e: + print(f"stories: {e} (spec folder: {folder})") + return + done = sum(1 for r in rows if r.label == stories_mod.DONE) + print(f"stories: {done}/{len(rows)} done ({folder}/stories.yaml)") + for row in rows: + print( + f" {row.position:2d}. {row.id:12s} {row.label:16s}" + f"{_checkpoint_badge(row)} {row.title}" + ) + + def _start_sweep( project: Path, paths: bmadconfig.ProjectPaths, @@ -515,7 +702,7 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int: print(f"run {run_dir.name} already finished", file=sys.stderr) return 1 pol = policy_mod.load(_policy_path(project)) - if not _require_base_skills(project, pol): + if not _require_base_skills(project, pol, require_stories=state.source == "stories"): return 1 journal = Journal(run_dir) journal.append("run-resume", was_paused=state.paused_reason) @@ -544,7 +731,7 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int: max_cycles=opts.get("max_cycles"), ) else: - engine = Engine( + story_common = dict( paths=paths, policy=pol, adapter=adapters["dev"], @@ -559,6 +746,13 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int: max_stories=state.max_stories, sweep_factory=_sweep_factory(project, paths), ) + # stories mode is pinned in run state at launch, so resume rebuilds the + # same picker (StoriesEngine) without any flag. + engine = ( + StoriesEngine(**story_common, spec_folder=state.spec_folder) + if state.source == "stories" + else Engine(**story_common) + ) summary = engine.run() print(summary.render()) return 0 @@ -769,13 +963,16 @@ def cmd_status(args: argparse.Namespace) -> int: f" {key:40s} {task.phase:16s} dev×{task.attempt} review×{task.review_cycle} " f"{tokens} {extra}" ) - try: - paths = bmadconfig.load_paths(project) - ss = sprintstatus.load(paths.sprint_status) - remaining = [s.key for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] - print(f"sprint backlog remaining: {len(remaining)}") - except (bmadconfig.BmadConfigError, sprintstatus.SprintStatusError): - pass + if state.source == "stories": + _print_stories_status(state, project) + else: + try: + paths = bmadconfig.load_paths(project) + ss = sprintstatus.load(paths.sprint_status) + remaining = [s.key for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] + print(f"sprint backlog remaining: {len(remaining)}") + except (bmadconfig.BmadConfigError, sprintstatus.SprintStatusError): + pass try: missed = decisions.pending_missed_decisions(project) if missed: @@ -1220,7 +1417,13 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: action="store_true", help="overwrite bmad-loop-* skill dirs that already exist (default: skip them)", ) - add("validate", cmd_validate, "preflight checks; exit non-zero on failure") + validate_p = add("validate", cmd_validate, "preflight checks; exit non-zero on failure") + validate_p.add_argument( + "--spec", + metavar="FOLDER", + help="validate stories mode against this epic spec folder's stories.yaml " + "(overrides [stories].source; skips the sprint-status gate)", + ) probe_p = add( "probe-adapter", @@ -1253,8 +1456,18 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: probe_p.add_argument("--keep-temp", action="store_true", help=argparse.SUPPRESS) run_p = add("run", cmd_run, "run the orchestration loop") - run_p.add_argument("--epic", type=int, help="only stories from this epic") - run_p.add_argument("--story", help="story: E-S / E.S, a slug fragment, or full key") + run_p.add_argument( + "--spec", + metavar="FOLDER", + help="force stories mode: dispatch the epic spec folder's stories.yaml by " + "folder+id (overrides [stories].source)", + ) + run_p.add_argument("--epic", type=int, help="only stories from this epic (sprint mode)") + run_p.add_argument( + "--story", + help="story: E-S / E.S, a slug fragment, or full key (sprint mode); " + "a story id (stories mode)", + ) run_p.add_argument("--max-stories", type=int, help="stop after N stories") run_p.add_argument("--dry-run", action="store_true", help="print the plan, spawn nothing") run_p.add_argument("--run-id", help=argparse.SUPPRESS) # pre-assigned id (used by the TUI) diff --git a/src/bmad_loop/data/settings/core.toml b/src/bmad_loop/data/settings/core.toml index 66ea4d1..f776259 100644 --- a/src/bmad_loop/data/settings/core.toml +++ b/src/bmad_loop/data/settings/core.toml @@ -46,6 +46,23 @@ default_ref = "ReviewPolicy.trigger" label = "review trigger" description = "recommended: run the 2nd-opinion review only when bmad-dev-auto flags it · always: run it every story (both bounded by limits.max_review_cycles)" +[[section]] +name = "stories" +description = "story-queue source: classic sprint board vs. folder+id dispatch" +[[section.field]] +key = "source" +kind = "select" +options_ref = "STORIES_SOURCES" +default_ref = "StoriesPolicy.source" +label = "story source" +description = "sprint-status: walk sprint-status.yaml · stories: folder+id dispatch off a typed stories.yaml (needs spec_folder)" +[[section.field]] +key = "spec_folder" +kind = "str" +default_ref = "StoriesPolicy.spec_folder" +label = "spec folder" +description = "stories mode only: project-relative path to the epic spec folder (holds stories.yaml + SPEC.md)" + [[section]] name = "limits" description = "cycle/attempt caps, timeout & token budget" diff --git a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md index 1b4795f..234019a 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -45,6 +45,34 @@ These environment variables are set: } ``` +In **stories mode** (folder+id dispatch) the context also carries a `stories` +block — the manifest intent for this story, so you can see WHAT it is meant to do +without hunting for it: + +```json +{ + "stories": { + "spec_folder": "_bmad-output/epic-1", + "story": { + "id": "6-4-cli-list-command", + "title": "CLI list command", + "description": "…", + "spec_checkpoint": false, + "done_checkpoint": false, + "invoke_dev_with": "…free-text planner→dev note, or ''…" + }, + "sentinel": { + "kind": "unresolved", + "path": ".../stories/6-4-cli-list-command-unresolved.md", + "blocking_condition": "…the reason planning halted…" + } + } +} +``` + +The `sentinel` sub-block is present ONLY when the escalated story is a **sentinel** +(see the stories-mode section below); an ordinary escalation omits it. + Your **output marker** is the file at `resolution_path`. Writing it is the LAST action of a successful resolution. Schema: @@ -91,6 +119,32 @@ action of a successful resolution. Schema: - **Do NOT** widen scope. Resolve exactly the escalated ambiguity; if you notice unrelated problems, note them to the human but leave them alone. +## Stories mode: sentinels and the preserved copy + +In stories mode a story that could not even be **planned** — the dev session hit +a contradiction or gap before it could write a real spec — leaves a fixed-slug +**sentinel** file instead of a frozen spec: `-unresolved.md` (the intent was +too ambiguous to plan) or `-ambiguous.md` (more than one story spec matched +the id). The context's `stories.sentinel` block names it and carries the +`blocking_condition` the session recorded. + +A sentinel is **not a spec you edit** — there is no plan or `` +block inside it. So for a sentinel: + +- **Do not try to amend a frozen spec** (step 4's "edit the frozen spec" does not + apply — there isn't one). Instead resolve the _upstream_ ambiguity so a fresh + planning pass can succeed: usually that means clarifying `SPEC.md` (the epic + spec) or this story's entry in `stories.yaml` — the `title` / `description` / + `invoke_dev_with` the planner reads — with the human. +- On **re-arm** the orchestrator does NOT flip the sentinel to `ready-for-dev` + (there is no plan to route to). It **preserves a copy** of the sentinel under + `{run}/sentinels/-.md` as a breadcrumb, **deletes** the sentinel, and + the next dispatch re-plans the story from scratch (leg 1 again for a + `spec_checkpoint` story). You do not touch the sentinel file yourself. +- Write the resolution marker as usual once the human has decided how to + disambiguate; set `spec_file` to whatever you edited (e.g. `SPEC.md`), or omit + it if the fix was entirely in `stories.yaml`. + ## If you cannot resolve it If the human defers, the information needed is genuinely unavailable, or the diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 4114b4c..fb6c2a7 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -740,6 +740,7 @@ def _loop(self) -> None: self._save() started += 1 self._run_story(task) + self._after_story(task) def _pick_next(self): ss = load_sprint_status(self.paths.sprint_status) @@ -1047,19 +1048,20 @@ def _finish_inflight(self) -> None: continue isolated = self._isolated and task.worktree_path if task.phase == Phase.DEV_VERIFY and task.spec_file: - # paused at the spec-approval gate: dev verified, review pending - self.journal.append("resume-review", story_key=task.story_key) + # paused at the spec-approval gate (or, in stories mode, a + # plan-checkpoint awaiting implementation — _resume_after_dev_verify + # dispatches the right leg): dev verified on disk. if isolated: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: - self._review_and_commit(task) + self._resume_after_dev_verify(task) finally: self.workspace = prev self._integrate_unit(task, unit) else: - self._review_and_commit(task) + self._resume_after_dev_verify(task) elif (resumable := self._resumable_session(task)) is not None: # the host died inside the post-session window: the session # itself completed and its recorded result is on disk, so @@ -1108,6 +1110,10 @@ def _finish_inflight(self) -> None: task.phase = Phase.PENDING # deliberate reset, not a normal transition self._save() self._run_story(task) + # a resumed story that just reached DONE gets the same post-story hook + # the _loop path fires (e.g. the stories-mode done_checkpoint pause), + # after any worktree integration above — no-op in the base engine. + self._after_story(task) def _resumable_session(self, task: StoryTask) -> tuple[str, SessionResult] | None: """The in-flight session's durably-recorded result, when complete enough @@ -1413,7 +1419,7 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None (result.result_json or {}).get("followup_review_recommended", False) ) outcome = self._verify_dev_artifacts(task, result.result_json) - if outcome.ok: + if outcome.ok and self._run_verify_commands_after_dev(task, result.result_json): # deterministic gates run here too: a broken build must not # reach the (far more expensive) review loop outcome = verify.verify_commands_outcome(self.policy, self.workspace.root) @@ -1835,6 +1841,39 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non target = "review" if review_enabled else "done" sprint_advance(self.workspace.paths.sprint_status, task.story_key, target) + def _extra_session_env( + self, task: StoryTask, role: str, label: str | None = None + ) -> dict[str, str]: + """Engine-variant additions to a session's environment. Base: none. + StoriesEngine overrides this to export BMAD_LOOP_SPEC_FOLDER for the + adapter's deterministic id-keyed read-back. ``label`` is None for the + primary dev/review session and set for an injected plugin-workflow session, + so a variant can scope its env to primary sessions only.""" + return {} + + def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | None) -> bool: + """Whether the deterministic verify commands run after a completed dev + pass. Base: always. StoriesEngine skips them on a plan-halt leg — a plan + (spec at ready-for-dev) has no implementation to build/test, so a project + build/test gate would spuriously fail before the plan review.""" + return True + + def _resume_after_dev_verify(self, task: StoryTask) -> None: + """Resume a task the run paused at DEV_VERIFY (dev verified, spec on disk). + Base: the spec-approval-gate resume — run the review loop + commit. + StoriesEngine overrides this to re-drive the implement leg of a + plan-checkpoint-paused story (leg-2) instead.""" + self.journal.append("resume-review", story_key=task.story_key) + self._review_and_commit(task) + + def _after_story(self, task: StoryTask) -> None: + """Hook fired once a story is fully processed and (under isolation) + integrated — from _loop after _run_story and from _finish_inflight after a + resumed task completes. Base: no-op. StoriesEngine uses it for the + done_checkpoint pause, which must land after integration so a committed + unit is merged before the run stops.""" + return + def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): return verify.verify_dev( task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled() @@ -1903,6 +1942,12 @@ def _run_session( "BMAD_LOOP_TASK_ID": task_id, "BMAD_LOOP_STORY_KEY": task.story_key, } + # engine-variant env seam: StoriesEngine adds BMAD_LOOP_SPEC_FOLDER so the + # dev adapter resolves the story spec deterministically by id instead of + # mtime-scanning. Base returns {} — sprint/sweep runs stay byte-identical. + # ``label`` (set only for injected plugin-workflow sessions) is passed so the + # variant can withhold that env from non-primary sessions. + env.update(self._extra_session_env(task, role, label=label)) if task.dw_ids: # Deferred-work bundle: the orchestrator owns the bundle→dw-id binding # (the generic bmad-dev-auto primitive knows nothing of dw ids). Export diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 83d504b..57726a0 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -79,6 +79,44 @@ # Every non-bundled skill that might need copying into an isolated worktree. BASE_SKILLS = dict(DEV_BASE_SKILLS) +# Stories mode (folder+id dispatch, BMAD-METHOD #2549) needs a *newer* bmad-dev-auto +# than sprint mode: one whose step-01 routes a spec-folder + story-id invocation. +# File existence (missing_base_skills) can't tell the two skill versions apart, so +# a content probe confirms the merged dispatch protocol is present. This literal is +# stable prose in the merged step-01 ("this is a **folder+id dispatch**"). +STORIES_PROBE_SKILL = "bmad-dev-auto" +STORIES_PROBE_FILE = "step-01-clarify-and-route.md" +STORIES_PROBE_TEXT = "folder+id dispatch" + + +def missing_stories_support(project: Path, trees: Sequence[str]) -> list[str]: + """Problems for stories mode's stricter bmad-dev-auto requirement. + + Sprint mode drives any bmad-dev-auto; stories mode needs the folder+id + dispatch flow, which older skill versions lack. For each active CLI skill + tree, confirm ``bmad-dev-auto/step-01-clarify-and-route.md`` exists and + carries the dispatch-protocol marker. Returns one human-readable problem per + tree lacking it (empty = OK). Callers gate this on stories mode only — + sprint-mode runs must not require the newer skill.""" + problems: list[str] = [] + for tree in dict.fromkeys(trees): + probe = project / tree / STORIES_PROBE_SKILL / STORIES_PROBE_FILE + try: + text = probe.read_text(encoding="utf-8") + except OSError: + problems.append( + f"{tree}/{STORIES_PROBE_SKILL}/{STORIES_PROBE_FILE} not found — stories " + f"mode needs folder+id dispatch; update the BMad Method (bmm) module" + ) + continue + if STORIES_PROBE_TEXT not in text: + problems.append( + f"{tree}/{STORIES_PROBE_SKILL} lacks folder+id dispatch (no " + f"{STORIES_PROBE_TEXT!r} in {STORIES_PROBE_FILE}) — stories mode needs a " + f"newer bmad-dev-auto; update the bmm module" + ) + return problems + def missing_base_skills(project: Path, trees: Sequence[str]) -> list[str]: """Problems for the upstream skills the orchestrator drives but doesn't bundle. diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 1c9d55c..825b187 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -30,6 +30,12 @@ class Phase(StrEnum): PAUSE_EPIC_BOUNDARY = "epic-boundary" PAUSE_ESCALATION = "escalation" PAUSE_STORY_GATE = "story-gate" +# stories-mode HITL checkpoints (independent per story). PLAN fires after a +# spec_checkpoint story's plan-halt leg (ready-for-dev, awaiting human plan +# review before implementation); STORY fires after a done_checkpoint story's +# commit (skip-if-last). Both re-arm through the same resume path. +PAUSE_PLAN_CHECKPOINT = "plan-checkpoint" +PAUSE_STORY_CHECKPOINT = "story-checkpoint" @dataclass @@ -152,6 +158,21 @@ class StoryTask: # tracked content, so a mid-re-drive retry/defer reset can't silently revert # the human correction. Survives the resume serialization round-trip. resolved_redrive: bool = False + # stories mode only: set when a spec_checkpoint story's plan-halt leg verified + # (spec at ready-for-dev) and the run paused for human plan review. On resume + # StoriesEngine._resume_after_dev_verify reads it to re-drive the implement leg + # (rather than the base review+commit) and clears it. Survives the round-trip. + plan_checkpoint_pending: bool = False + # stories mode only: the durable "a human plan review is still owed" obligation + # for a spec_checkpoint story. Latched at the story's first (leg-1) dispatch — + # BEFORE the session runs and keyed off the entry's spec_checkpoint flag, not + # the leg's on-disk status or result — so it survives a crash, a non-fixable + # retry, or a skill that overran `Halt after planning.`, none of which the + # on-disk-status-keyed _plan_halt_leg / result-keyed plan_checkpoint_pending + # carry across. Cleared ONLY when a plan-review pause actually raises (the + # obligation is discharged). While set after a dev leg that did not itself pause, + # StoriesEngine pauses before commit so the story can never commit un-reviewed. + plan_review_owed: bool = False # sweep bundles only: the deferred-work ids this task closes and the # rendered intent file handed to dev sessions dw_ids: list[str] = field(default_factory=list) @@ -203,6 +224,8 @@ def to_dict(self) -> dict[str, Any]: "defer_reason": self.defer_reason, "rearmed": self.rearmed, "resolved_redrive": self.resolved_redrive, + "plan_checkpoint_pending": self.plan_checkpoint_pending, + "plan_review_owed": self.plan_review_owed, "dw_ids": self.dw_ids, "bundle_file": self.bundle_file, "worktree_path": self.worktree_path, @@ -245,6 +268,8 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": defer_reason=d.get("defer_reason"), rearmed=bool(d.get("rearmed", False)), resolved_redrive=bool(d.get("resolved_redrive", False)), + plan_checkpoint_pending=bool(d.get("plan_checkpoint_pending", False)), + plan_review_owed=bool(d.get("plan_review_owed", False)), dw_ids=[str(i) for i in d.get("dw_ids", [])], bundle_file=d.get("bundle_file"), worktree_path=str(d.get("worktree_path", "")), @@ -282,6 +307,14 @@ class RunState: crashed: bool = False crash_error: str | None = None run_type: str = "story" # "story" | "sweep" — resume/status dispatch on it + # story-queue source (policy.StoriesPolicy.source), pinned at run start so + # resume/resolve rebuild the right engine (StoriesEngine vs the sprint Engine) + # without re-reading policy — a policy edit mid-run must not switch a live run's + # mode. `run_type` stays "story" for both; `source` selects the picker. + source: str = "sprint-status" + # stories mode only: the project-relative (or absolute) spec folder holding + # stories.yaml + SPEC.md. Empty under sprint-status. + spec_folder: str = "" # sweep runs only: the triage->bundles cycle in progress; 1 maps to the # legacy (unsuffixed) artifact names so old paused runs resume unchanged sweep_cycle: int = 1 @@ -345,6 +378,8 @@ def to_dict(self) -> dict[str, Any]: "crashed": self.crashed, "crash_error": self.crash_error, "run_type": self.run_type, + "source": self.source, + "spec_folder": self.spec_folder, "sweep_cycle": self.sweep_cycle, "sweeps_triggered": self.sweeps_triggered, "target_branch": self.target_branch, @@ -371,6 +406,8 @@ def from_dict(cls, d: dict[str, Any]) -> "RunState": crashed=bool(d.get("crashed", False)), crash_error=d.get("crash_error"), run_type=str(d.get("run_type", "story")), + source=str(d.get("source", "sprint-status")), + spec_folder=str(d.get("spec_folder", "")), sweep_cycle=int(d.get("sweep_cycle", 1)), sweeps_triggered=[str(s) for s in d.get("sweeps_triggered", [])], target_branch=str(d.get("target_branch", "")), diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index aa9c650..e4a397e 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -14,6 +14,11 @@ RETRO_MODES = {"never", "notify", "auto"} SWEEP_AUTO_MODES = {"never", "per-epic", "run-end"} REVIEW_TRIGGER_MODES = {"always", "recommended"} +# Where the run gets its story queue. "sprint-status" (default) is the classic +# flow — bmad-sprint-planning writes sprint-status.yaml from prose epics. +# "stories" is the opt-in folder+id dispatch flow (BMAD-METHOD #2549): a typed, +# human-reviewed stories.yaml sibling of SPEC.md drives the loop. +STORIES_SOURCES = {"sprint-status", "stories"} ISOLATION_MODES = {"none", "worktree"} BRANCH_PER_MODES = {"story", "run"} MERGE_STRATEGIES = {"ff", "merge", "squash"} @@ -107,6 +112,24 @@ class ReviewPolicy: trigger: str = "recommended" +@dataclass(frozen=True) +class StoriesPolicy: + """Story-queue source selection. Default reproduces sprint mode exactly. + + ``source = "stories"`` opts a run into folder+id dispatch: the loop reads a + typed ``stories.yaml`` (Story Breakdown output, sibling of ``SPEC.md``) under + ``spec_folder`` and dispatches each entry by folder+id instead of walking + ``sprint-status.yaml``. ``spec_folder`` is the project-relative (or absolute) + path to the epic's spec folder; required and must parse when + ``source = "stories"``. There is deliberately **no** ``continue_independent`` + knob — the manifest is strictly serial (no ``depends_on``), so a blocked + story always pauses the run for resolve rather than leapfrogging to later + work.""" + + source: str = "sprint-status" + spec_folder: str = "" + + @dataclass(frozen=True) class DevPolicy: # Which inner dev skill the orchestrator drives. The sole supported value is @@ -321,6 +344,7 @@ class Policy: verify: VerifyPolicy = field(default_factory=VerifyPolicy) notify: NotifyPolicy = field(default_factory=NotifyPolicy) review: ReviewPolicy = field(default_factory=ReviewPolicy) + stories: StoriesPolicy = field(default_factory=StoriesPolicy) dev: DevPolicy = field(default_factory=DevPolicy) adapter: AdapterPolicy = field(default_factory=AdapterPolicy) sweep: SweepPolicy = field(default_factory=SweepPolicy) @@ -436,6 +460,7 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy: verify_d = _section(doc, "verify") notify_d = _section(doc, "notify") review_d = _section(doc, "review") + stories_d = _section(doc, "stories") dev_d = _section(doc, "dev") adapter_d = _section(doc, "adapter") sweep_d = _section(doc, "sweep") @@ -504,6 +529,19 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy: raise PolicyError( f"review.trigger must be one of {sorted(REVIEW_TRIGGER_MODES)}: got {review.trigger!r}" ) + stories = StoriesPolicy( + source=str(stories_d.get("source", StoriesPolicy.source)).strip(), + spec_folder=str(stories_d.get("spec_folder", StoriesPolicy.spec_folder)).strip(), + ) + if stories.source not in STORIES_SOURCES: + raise PolicyError( + f"stories.source must be one of {sorted(STORIES_SOURCES)}: got {stories.source!r}" + ) + # source="stories" needs a spec_folder to read stories.yaml from; the reverse + # (a spec_folder set under sprint-status mode) is a harmless leftover, ignored + # at run time — no error, so switching source back and forth keeps the path. + if stories.source == "stories" and not stories.spec_folder: + raise PolicyError('stories.source = "stories" requires stories.spec_folder to be set') dev = DevPolicy(skill=str(dev_d.get("skill", DevPolicy.skill))) if dev.skill not in DEV_SKILLS: raise PolicyError(f"dev.skill must be one of {sorted(DEV_SKILLS)}: got {dev.skill!r}") @@ -643,6 +681,7 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy: verify=verify, notify=notify, review=review, + stories=stories, dev=dev, adapter=adapter, sweep=sweep, @@ -722,6 +761,17 @@ def _fold_deprecated_engine( # The loop is bounded by limits.max_review_cycles either way. trigger = "recommended" +[stories] +# Story-queue source. "sprint-status" (default) walks sprint-status.yaml written +# by bmad-sprint-planning. "stories" opts into folder+id dispatch: the loop reads +# a typed stories.yaml (Story Breakdown output, sibling of SPEC.md) and dispatches +# each entry by spec-folder + story id. `bmad-loop run --spec ` forces +# stories mode for a single run regardless of this setting. +source = "sprint-status" # sprint-status | stories +# Required (and must parse) when source = "stories": the project-relative path to +# the epic's spec folder holding stories.yaml + SPEC.md. Ignored under sprint-status. +spec_folder = "" + [adapter] name = "claude" # claude | codex | gemini | model = "" # empty = CLI default model diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 24acd71..f8b58cd 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -77,12 +77,61 @@ def build_context(state: RunState, run_dir: Path, story_key: str) -> Path: # path is consumed by the agent, and Python/tools accept '/' on Windows). "resolution_path": resolution_path(run_dir, story_key).as_posix(), } + # Stories mode: hand the resolver the manifest intent (the story entry) and a + # sentinel indicator, so it sees WHAT the story is meant to do and WHETHER the + # frozen spec even exists yet (a sentinel has no plan to edit — resolve the + # underlying ambiguity instead). Sprint mode leaves the context unchanged. + if state.source == "stories": + stories_ctx = _stories_context(state, story_key) + if stories_ctx: + context["stories"] = stories_ctx path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(context, indent=2), encoding="utf-8") return path +def _stories_context(state: RunState, story_key: str) -> dict[str, Any]: + """The stories-mode extension of the resolve context: the spec folder, the + manifest entry for the story (title/description/checkpoint flags/invoke_dev_with), + and — when the escalated spec is a fixed-slug pre-planning-halt sentinel — a + sentinel indicator with its kind and recorded blocking condition. Best-effort: + an unreadable manifest just yields the folder (resolve still runs).""" + from . import stories + + project = Path(state.project) + folder = stories.resolve_spec_folder(project, state.spec_folder) + ctx: dict[str, Any] = {"spec_folder": state.spec_folder} + try: + entry = stories.load_stories(folder).get(story_key) + except stories.StoriesError: + entry = None + if entry is not None: + ctx["story"] = { + "id": entry.id, + "title": entry.title, + "description": entry.description, + "spec_checkpoint": entry.spec_checkpoint, + "done_checkpoint": entry.done_checkpoint, + "invoke_dev_with": entry.invoke_dev_with, + } + try: + st = stories.resolve_story_spec(folder, story_key) + except OSError: + st = None + if st is not None and st.kind == stories.KIND_SENTINEL and st.path is not None: + try: + condition = stories.recorded_blocking_condition(st.path.read_text(encoding="utf-8")) + except OSError: + condition = "" + ctx["sentinel"] = { + "kind": st.sentinel_kind, + "path": st.path.as_posix(), + "blocking_condition": condition, + } + return ctx + + def run_session(adapter, project: Path, run_dir: Path, story_key: str, *, model: str = "") -> bool: """Launch the interactive resolve agent attached to the caller's terminal. diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index d1c794d..f343ac6 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -520,6 +520,13 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: as terminal from its first save. Does NOT clear the pause; the caller resumes the run separately. + Stories mode: when the escalated spec is a fixed-slug sentinel + (`-unresolved.md` / `-ambiguous.md`, written by a pre-planning HALT), + it cannot be re-opened by a status flip — its very presence wedges the id. + Instead preserve a copy under `{run_dir}/sentinels/`, journal `sentinel-cleared` + with the blocking condition, and delete it, so the re-dispatch resolves to a + clean PENDING and re-plans from scratch (leg 1 again for a spec_checkpoint id). + Returns the re-armed story key. Raises RearmError when the run is not paused at the escalation stage or the target story is not escalated. """ @@ -538,6 +545,7 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: if task.phase != Phase.ESCALATED: raise RearmError(f"story {key} is not escalated (phase: {task.phase})") + journal = Journal(run_dir) # deliberate reset, not a normal state-machine transition (mirrors # engine._finish_inflight): a clean re-attempt against the corrected spec. task.phase = Phase.PENDING @@ -547,6 +555,29 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: task.rearmed = True # resume-time recovery notice describes a clean rebuild, # not a failed attempt (engine._finish_inflight clears it once the rebuild runs) + if task.spec_file: + spec_path = Path(task.spec_file) + # Stories mode only: a fixed-slug pre-planning-halt sentinel + # (`-unresolved.md` / `-ambiguous.md`) is cleared by deletion, not a + # status flip. Gate on the run source so a *sprint* spec that merely happens + # to be named `-unresolved.md` is status-flipped like any other spec and + # never deleted — the sentinel filename convention exists only in stories mode. + sentinel_kind = _sentinel_condition(spec_path, key) if state.source == "stories" else None + if sentinel_kind is not None: + # a sentinel is cleared by deletion, not a status flip; drop the stale + # spec_file so the re-dispatch starts from PENDING (clean re-plan). + _clear_sentinel(run_dir, journal, spec_path, key, sentinel_kind) + task.spec_file = None + else: + # route /bmad-dev-auto to re-implement (decision table: ready-for-dev + # -> step-03); independent of the resolve agent having set it. + verify.set_frontmatter_status(spec_path, "ready-for-dev") + # drop the stale `## Auto Run Result` section along with the status flip + # (mirrors engine._reset_spec_for_repair): find_result_artifact keys on + # that heading, so leaving it would let the re-driven session's first + # save of the spec parse as the prior attempt's terminal outcome. + devcontract.strip_auto_run_result(spec_path) + # Advance the attempt baseline to the project's current HEAD and refresh the # untracked snapshot: whatever the human-driven resolve session left on the # branch (a committed fixture, a corrected ledger, ...) is authorized input @@ -557,9 +588,11 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: # very gap the human just resolved. Best-effort: on a git failure the old # baseline stands (the redrive rollback path tolerates a stale baseline; it # just loses this protection). - # The two locals are computed before either task field is assigned, so a - # failure on either git call can't advance baseline_commit while - # baseline_untracked stays stale, or vice versa. + # Runs AFTER the spec block so a just-cleared stories sentinel (an untracked + # file removed above) is not captured into baseline_untracked as a phantom + # pre-existing untracked file. The two locals are computed before either task + # field is assigned, so a failure on either git call can't advance + # baseline_commit while baseline_untracked stays stale, or vice versa. try: repo = Path(state.project) head = verify.rev_parse_head(repo) @@ -569,18 +602,43 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: except Exception: # noqa: BLE001 # nosec B110 - best-effort git read, must not fail re-arm pass - if task.spec_file: - # route /bmad-dev-auto to re-implement (decision table: ready-for-dev - # -> step-03); independent of the resolve agent having set it. - verify.set_frontmatter_status(Path(task.spec_file), "ready-for-dev") - # drop the stale `## Auto Run Result` section along with the status flip - # (mirrors engine._reset_spec_for_repair): find_result_artifact keys on - # that heading, so leaving it would let the re-driven session's first - # save of the spec parse as the prior attempt's terminal outcome. - devcontract.strip_auto_run_result(Path(task.spec_file)) - save_state(run_dir, state) - Journal(run_dir).append( - "story-escalation-resolved", story_key=key, baseline=task.baseline_commit or "" - ) + journal.append("story-escalation-resolved", story_key=key, baseline=task.baseline_commit or "") return key + + +def _sentinel_condition(spec_path: Path, story_key: str) -> str | None: + """The blocking condition (``unresolved`` / ``ambiguous``) iff ``spec_path`` is + a fixed-slug pre-planning-halt sentinel for ``story_key``, else None.""" + from .stories import SENTINEL_SLUGS + + for slug in SENTINEL_SLUGS: + if spec_path.name == f"{story_key}-{slug}.md": + return slug + return None + + +def _clear_sentinel( + run_dir: Path, journal: Journal, spec_path: Path, story_key: str, sentinel_kind: str +) -> None: + """Preserve a copy of the sentinel under ``{run_dir}/sentinels/`` (a write-only + breadcrumb of what blocked planning), journal ``sentinel-cleared`` — carrying + both the fixed slug (``sentinel_kind``) and the *recorded blocking condition* + parsed from the sentinel's ``## Auto Run Result`` (the reason planning halted) — + then delete the sentinel so the next dispatch is clean.""" + from .stories import recorded_blocking_condition + + dest_dir = run_dir / "sentinels" + dest_dir.mkdir(parents=True, exist_ok=True) + condition = "" + if spec_path.is_file(): + condition = recorded_blocking_condition(spec_path.read_text(encoding="utf-8")) + shutil.copy2(spec_path, dest_dir / spec_path.name) + spec_path.unlink() + journal.append( + "sentinel-cleared", + story_key=story_key, + sentinel_kind=sentinel_kind, + condition=condition, + sentinel=spec_path.name, + ) diff --git a/src/bmad_loop/stories.py b/src/bmad_loop/stories.py index 571d3df..68e5c49 100644 --- a/src/bmad_loop/stories.py +++ b/src/bmad_loop/stories.py @@ -49,6 +49,14 @@ DONE = "done" BLOCKED = "blocked" +# Statuses that prove a spec_checkpoint story's plan already exists on disk: once +# the plan reached (or passed) the Ready-for-Development gate the halt leg is +# spent, so a re-dispatch is the plain implement leg. PENDING / draft (plan not +# yet produced) and sentinel/ambiguous (a failed pre-planning halt) fall through +# to a fresh halt leg. Shared by the engine (real dispatch) and the CLI dry-run +# so the two agree on which leg a story is on. +PLAN_PRODUCED_STATUSES = frozenset({"ready-for-dev", "in-progress", "in-review", "done", "blocked"}) + # resolve_story_spec state kinds. KIND_PENDING = "pending" # no story spec on disk yet KIND_PRESENT = "present" # exactly one real story spec; carries .status @@ -315,6 +323,7 @@ def schedule( stories: Stories, states: dict[str, StoryState], selector: str | None = None, + skip: set[str] | None = None, ) -> Schedule: """Linear scheduler: the first list entry ready to (re)dispatch. @@ -330,10 +339,20 @@ def schedule( ``selector`` restricts the scan to a single story id (raises when the id is unknown), for ``--story`` runs. A state missing from ``states`` is treated as PENDING (no spec on disk). + + ``skip`` is the orchestrator's within-run memory: ids already driven to a + terminal phase *this run* (done, or plateau-deferred). They are passed over + like a ``done`` entry — the scan continues past them rather than stopping or + re-dispatching. This mirrors the sprint engine's ``base_skip`` and is what + keeps a deferred story (whose on-disk spec may still read as a resumable + non-terminal) from being re-picked forever within one run. """ + skip = skip or set() entries: tuple[StoryEntry, ...] entries = (find_entry(stories, selector),) if selector is not None else stories.entries for entry in entries: + if entry.id in skip: + continue state = states.get(entry.id) if state is None: state = StoryState(kind=KIND_PENDING) @@ -359,3 +378,137 @@ def _classify(state: StoryState) -> str: return "wedged" # AMBIGUOUS or SENTINEL — not actionable without dispatcher/human recovery. return "wedged" + + +# ------------------------------------------------------------ table projection +# +# A read-only, disk-derived view of a stories manifest shared by the CLI (`status` +# / `run --dry-run`) and the TUI stories table, so every surface agrees on the +# same human-facing state string. Pure: no engine or RunState coupling. + + +def resolve_spec_folder(project: Path, spec_folder: str) -> Path: + """The absolute spec folder for ``spec_folder`` (project-relative or already + absolute) under ``project`` — the one place the folder anchoring lives so + the CLI, dry-run, preflight and TUI resolve it identically.""" + folder = Path(spec_folder) + return folder if folder.is_absolute() else project / folder + + +def relativize_spec_folder(project: Path, spec_folder: str) -> str: + """The project-relative posix form of ``spec_folder`` — what the orchestrator + actually dispatches (``BMAD_LOOP_SPEC_FOLDER`` / the ``Spec folder:`` prompt). + + An absolute path inside the project tree is rebased to the project root; + anything else is kept verbatim (the contract allows an absolute spec folder, + though we never author one). The one place this lives so the engine's real + dispatch and the CLI dry-run render the identical folder string.""" + raw = Path(spec_folder) + if raw.is_absolute(): + try: + return raw.resolve().relative_to(project.resolve()).as_posix() + except ValueError: + return raw.as_posix() # outside the project tree — leave absolute + return raw.as_posix() + + +def is_plan_halt_leg(spec_checkpoint: bool, state: StoryState) -> bool: + """Whether a ``spec_checkpoint`` story's next dispatch HALTs after planning + (leg 1) given its resolved on-disk ``state``. + + True only for a spec_checkpoint story whose plan has not yet reached + ``ready-for-dev`` on disk; once the plan exists (leg 2 after the plan + checkpoint, or a repair that reset the spec to in-progress) it is the plain + implement leg. Pure predicate shared by the engine's ``_plan_halt_leg`` and + the CLI dry-run so both key off the same on-disk state.""" + if not spec_checkpoint: + return False + if state.kind == KIND_PRESENT and state.status in PLAN_PRODUCED_STATUSES: + return False + return True + + +_AUTO_RUN_RESULT_HEADING = "## Auto Run Result" + + +def recorded_blocking_condition(sentinel_text: str) -> str: + """The blocking condition a pre-planning-halt sentinel records under its + ``## Auto Run Result`` heading — the reason planning could not proceed. + + Returns the block body (heading dropped, collapsed to a single line), or "" + when the sentinel carries no such block. A write-only breadcrumb for the + ``sentinel-cleared`` / ``sentinel-detected`` journal events and the resolve + context; never parsed back into a decision.""" + idx = sentinel_text.find(_AUTO_RUN_RESULT_HEADING) + if idx == -1: + return "" + body = sentinel_text[idx + len(_AUTO_RUN_RESULT_HEADING) :] + next_heading = body.find("\n## ") + if next_heading != -1: + body = body[:next_heading] + return " ".join(body.split()) + + +def state_label(state: StoryState) -> str: + """The single human-facing state string for a resolved story, matching the + dispatch-protocol read model: PRESENT shows its frontmatter status + (``draft`` / ``ready-for-dev`` / ``in-progress`` / ``in-review`` / ``done`` / + ``blocked``); PENDING / AMBIGUOUS show the kind; a SENTINEL shows + ``sentinel:`` so the recoverable-by-deletion anomaly + reads distinctly from a real ``ambiguous`` (two rival specs).""" + if state.kind == KIND_PRESENT: + return state.status or KIND_PRESENT + if state.kind == KIND_SENTINEL: + return f"sentinel:{state.sentinel_kind}" if state.sentinel_kind else KIND_SENTINEL + return state.kind # pending / ambiguous + + +@dataclass(frozen=True) +class StoryRow: + """One row of a stories-mode status table: the manifest fields (id / title / + checkpoint flags) joined with the live on-disk state of the id-keyed story + spec. ``position`` is 1-based list order.""" + + position: int + id: str + title: str + spec_checkpoint: bool + done_checkpoint: bool + state: StoryState + label: str + + +def story_rows( + spec_folder: Path | str, + *, + selector: str | None = None, + max_stories: int | None = None, +) -> list[StoryRow]: + """Load ``stories.yaml`` and project every entry to a :class:`StoryRow`, + resolving each story's on-disk state. ``selector`` restricts to one id + (empty result when unknown — the caller decides how to report that); + ``max_stories`` truncates like the run limit. Raises :class:`StoriesError` + when the manifest is missing or invalid, so a caller rendering a table can + surface the same message the run would HALT on.""" + folder = Path(spec_folder) + story_set = load_stories(folder) + entries = story_set.entries + if selector is not None: + entries = tuple(e for e in entries if e.id == selector) + if max_stories is not None: + entries = entries[:max_stories] + rows: list[StoryRow] = [] + for position, entry in enumerate(entries, 1): + state = resolve_story_spec(folder, entry.id) + rows.append( + StoryRow( + position=position, + id=entry.id, + title=entry.title, + spec_checkpoint=entry.spec_checkpoint, + done_checkpoint=entry.done_checkpoint, + state=state, + label=state_label(state), + ) + ) + return rows diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py new file mode 100644 index 0000000..71fdc5f --- /dev/null +++ b/src/bmad_loop/stories_engine.py @@ -0,0 +1,592 @@ +"""Engine variant for "stories mode" — folder+id dispatch (BMAD-METHOD #2549). + +Where the default :class:`~bmad_loop.engine.Engine` walks ``sprint-status.yaml``, +``StoriesEngine`` drives a typed ``stories.yaml`` (the Story Breakdown output, +a fixed-name sibling of ``SPEC.md``). Each entry is dispatched by *spec folder + +story id* rather than a spec path: the inner ``bmad-dev-auto`` skill reads its +own entry, creates-or-resumes the story spec at ``/stories/-.md``, +and the orchestrator reads that id-keyed path back deterministically — no +mtime-scan, no shared mutable board. + +Like ``SweepEngine``, this is a thin override layer over the mature story +pipeline: only the story source (``_pick_next``), the dispatch prompt +(``_dev_prompt``), the (absent) bookkeeping sync (``_post_dev_state_sync``), the +artifact verification (``_verify_dev_artifacts``), the session env +(``_extra_session_env``), and the HITL checkpoints differ. Everything else — +dev/verify/review/commit, crash resume, worktree isolation, gates — is inherited +unchanged. + +HITL checkpoints (Phase 3) are per-story and independent (a story may set both +and pause twice): + +* ``spec_checkpoint`` — a two-leg dispatch. Leg 1 sends ``Halt after planning.`` + (:meth:`_plan_halt_leg`); the skill HALTs at ``ready-for-dev``, the plan is + verified, and the run pauses at :data:`PAUSE_PLAN_CHECKPOINT` for human plan + review. Resume re-drives leg 2 (a plain folder+id dispatch that the skill routes + straight to implementation) via :meth:`_resume_after_dev_verify`. +* ``done_checkpoint`` — after the story commits, the run pauses at + :data:`PAUSE_STORY_CHECKPOINT` (:meth:`_after_story`), skipped when the story was + the last one to dispatch. + +A blocked / sentinel / ambiguous story stops the scan and escalates for resolve +(:meth:`_pause_wedged`); sentinel auto-clear on re-arm lives in +``runs.rearm_escalation``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from . import gates, stories, verify +from .adapters.base import SessionResult +from .engine import Engine, RunPaused +from .model import ( + PAUSE_ESCALATION, + PAUSE_PLAN_CHECKPOINT, + PAUSE_SPEC_APPROVAL, + PAUSE_STORY_CHECKPOINT, + Phase, + StoryTask, +) + + +@dataclass(frozen=True) +class _StoryRef: + """The minimal shape :meth:`Engine._loop` reads from a picked story: a key + and an epic. Stories mode has no epic numbering (a flat list), so ``epic`` is + always 0 — inert for the epic-boundary logic, which only fires on a change.""" + + key: str + epic: int = 0 + + +class StoriesModeError(Exception): + """Raised when stories mode cannot proceed for a structural reason the run + should surface loudly (unreadable/invalid ``stories.yaml`` mid-run). + + Distinct from the contract-side :class:`stories.StoriesError` (the parser's + error) so the two seams — parse vs engine drive — never get conflated when a + parser error leaks through an engine catch.""" + + +class _UnknownSelector(StoriesModeError): + """The ``--story`` selector id is not (or no longer) in ``stories.yaml``. + + A subclass so :meth:`StoriesEngine._pick_next` can turn it into a pause for + resolve (the id is recoverable — fix the typo or the manifest, then resume) + rather than let it crash the run like a genuinely-unreadable manifest.""" + + def __init__(self, selector: str): + super().__init__(f"--story id {selector!r} is not in stories.yaml") + self.selector = selector + + +class StoriesEngine(Engine): + def __init__(self, *args: Any, spec_folder: str, **kwargs: Any): + # `--story` is a plain story id here, not a sprint E-S ref — keep it out of + # the base's sprint-style parse_selector (which would reject a bare "2") + # and out of the epic filter (a flat list has no epics). We scan by id in + # _pick_next instead. The CLI still persists story_filter on RunState, so + # resume restores the id filter unchanged. + self._story_id_filter = kwargs.get("story_filter") or None + kwargs["story_filter"] = None + kwargs["epic_filter"] = None + super().__init__(*args, **kwargs) + # Project-relative posix path to the epic spec folder. Anchored on the + # *current* workspace root at use time (project root in place, the unit + # worktree under isolation), which keeps _pick_next, _dev_prompt, verify, + # and the adapter read-back all resolving against the same live checkout. + self._spec_folder_rel = self._relativize(spec_folder) + # journal `stories-validated` once per process (re-validated every pick). + self._validated = False + # story keys already warned about an unreadable manifest — journal once + # each (``_entry_for`` is called several times per dispatch). + self._warned_unreadable: set[str] = set() + # pin the resolved mode into run state so resume/resolve rebuild a + # StoriesEngine (not the sprint Engine) without re-reading policy. + self.state.source = "stories" + self.state.spec_folder = self._spec_folder_rel + + # ------------------------------------------------------------ spec folder + + def _relativize(self, spec_folder: str) -> str: + """Store the spec folder project-relative when possible (we always + dispatch project-relative). Shared with the CLI dry-run via + :func:`stories.relativize_spec_folder` so both render the identical + folder string.""" + return stories.relativize_spec_folder(self.paths.project, spec_folder) + + def _stories_folder(self) -> Path: + """The spec folder resolved against the current workspace root (project + root at pick time, the unit worktree during a driven story).""" + rel = Path(self._spec_folder_rel) + return rel if rel.is_absolute() else self.workspace.root / rel + + def _load_stories(self) -> stories.Stories: + return stories.load_stories(self._stories_folder()) + + # --------------------------------------------------------------- picking + + def _compute_schedule(self) -> stories.Schedule: + """Run the linear scheduler against fresh on-disk state, honoring the + ``--story`` selector and the within-run skip set (stories already driven + to a terminal *retirement* this run — mirrors the sprint engine's base_skip). + Re-loads + re-validates stories.yaml every call (id-stability rule F: a + between-runs re-derive is safe because pinned ids are stable and un-started + ids reschedule by list order). Shared by :meth:`_pick_next` and the + done_checkpoint skip-if-last check.""" + try: + story_set = self._load_stories() + except stories.StoriesError as e: + raise StoriesModeError(f"stories.yaml no longer usable: {e}") from e + self._journal_validated(story_set) + # A --story id that vanished from the manifest mid-run (or slipped past + # preflight) must not crash the run via find_entry: raise a recoverable + # _UnknownSelector that _pick_next turns into a pause for resolve. + if self._story_id_filter is not None and story_set.get(self._story_id_filter) is None: + raise _UnknownSelector(self._story_id_filter) + folder = self._stories_folder() + states = {e.id: stories.resolve_story_spec(folder, e.id) for e in story_set.entries} + # Skip only stories retired this run — DONE (completed) or DEFERRED + # (plateaued). Crucially NOT ESCALATED: schedule() consults the skip set + # *before* classifying disk state, so skipping a wedge would let a bare + # `bmad-loop resume` (no resolve) leapfrog it and dispatch a later story + # onto a tree missing the blocked story's work — breaking the linear + # "a blocked story cannot be leapfrogged" invariant. Left out of the skip + # set, an unresolved wedge re-classifies from disk (blocked/sentinel) and + # re-pauses on itself; once `resolve` re-arms it to PENDING it re-dispatches. + skip = { + key + for key, task in self.state.tasks.items() + if task.phase in (Phase.DONE, Phase.DEFERRED) + } + return stories.schedule(story_set, states, selector=self._story_id_filter, skip=skip) + + def _journal_validated(self, story_set: stories.Stories) -> None: + if self._validated: + return + self.journal.append( + "stories-validated", + count=len(story_set.entries), + spec_folder=self._spec_folder_rel, + ) + self._validated = True + + def _pick_next(self) -> _StoryRef | None: + try: + sched = self._compute_schedule() + except _UnknownSelector as e: + # A bad/removed --story id: pause for resolve (fix the manifest, then + # resume) rather than crash the run. Keyed on the selector id so the + # escalation viewer + rearm act on the same key. + self._pause_unknown_selector(e.selector) # always raises RunPaused + if sched.outcome == stories.SCHEDULE_NEXT and sched.entry is not None: + return _StoryRef(key=sched.entry.id) + if sched.outcome == stories.SCHEDULE_WEDGED and sched.entry is not None: + self._pause_wedged(sched) + return None # SCHEDULE_COMPLETE — every story is done + + def _pause_unknown_selector(self, selector: str) -> None: + """The ``--story`` selector resolves to no manifest entry: pause for + resolve keyed on the selector, the same ESCALATED shape a wedge leaves, so + the CLI/TUI surface + rearm handle it uniformly. Always raises.""" + task = StoryTask(story_key=selector, epic=0) + task.phase = Phase.ESCALATED + self.state.tasks[selector] = task + reason = ( + f"--story id {selector!r} is not in stories.yaml — fix the id or the " + f"manifest, then `bmad-loop resume {self.state.run_id}`" + ) + self.journal.append("stories-selector-unknown", story_key=selector) + gates.notify(self.policy, self.run_dir, f"unknown --story id: {selector}", reason) + self._save() + raise RunPaused(reason, PAUSE_ESCALATION, selector) + + def _pause_wedged(self, sched: stories.Schedule) -> None: + """A blocked / sentinel / ambiguous story stopped the scan before any + session ran this pick — it was already in that state on disk (a prior + run, or a resume of one that halted). A blocked story cannot be + leapfrogged, so pause for resolve. + + Records an ESCALATED task keyed by the story id — the same shape an in-run + escalation leaves — so ``bmad-loop resolve`` (``runs.rearm_escalation`` + + ``resolve.build_context``) and the sentinel auto-clear act on it uniformly, + and the resolved re-drive flows through ``_finish_inflight`` exactly like a + rearmed escalation. A PRESENT/SENTINEL wedge carries its spec path so + resolve can open it; an AMBIGUOUS wedge leaves it unset (no single file to + pick — the human removes the duplicate).""" + entry = sched.entry + state = sched.state + assert entry is not None and state is not None + task = StoryTask(story_key=entry.id, epic=0) + task.phase = Phase.ESCALATED # deliberate: a wedge has no legal advance from PENDING + if state.kind in (stories.KIND_PRESENT, stories.KIND_SENTINEL) and state.path is not None: + task.spec_file = str(state.path) + self.state.tasks[entry.id] = task + detail = self._wedged_detail(state) + reason = f"story {entry.id!r} needs resolution before the run can continue: {detail}" + if state.kind == stories.KIND_SENTINEL: + self._journal_sentinel_detected(entry.id, state) + self.journal.append( + "stories-wedged", + story_key=entry.id, + state_kind=state.kind, + sentinel_kind=state.sentinel_kind or None, + ) + gates.notify( + self.policy, + self.run_dir, + f"story blocked: {entry.id}", + f"{detail} — resolve, then `bmad-loop resume {self.state.run_id}`", + ) + self._save() + raise RunPaused(reason, PAUSE_ESCALATION, entry.id) + + @staticmethod + def _wedged_detail(state: stories.StoryState) -> str: + if state.kind == stories.KIND_SENTINEL: + return f"pre-planning halt sentinel ({state.sentinel_kind}) at {state.path}" + if state.kind == stories.KIND_AMBIGUOUS: + names = ", ".join(p.name for p in state.paths) + return f"ambiguous story file match: {names}" + # KIND_PRESENT with a blocked / unrecognized status. + return f"story spec status {state.status!r} at {state.path}" + + def _journal_sentinel_detected(self, story_key: str, state: stories.StoryState) -> None: + """Record that a read-back resolved a story to a fixed-slug pre-planning-halt + sentinel, carrying its recorded blocking condition (the reason planning + halted, parsed from ``## Auto Run Result``). Fires at pick-time (a sentinel + left by a prior run/resume) and post-dev (this run's session HALTed), so the + journal always has a distinct trace before the escalation/wedge event.""" + condition = "" + if state.path is not None: + try: + condition = stories.recorded_blocking_condition( + state.path.read_text(encoding="utf-8") + ) + except OSError: + condition = "" + self.journal.append( + "sentinel-detected", + story_key=story_key, + sentinel_kind=state.sentinel_kind or None, + condition=condition, + ) + + # -------------------------------------------------------------- dispatch + + def _extra_session_env( + self, task: StoryTask, role: str, label: str | None = None + ) -> dict[str, str]: + # Only PRIMARY dev/review sessions get the story-spec env. An injected + # plugin-workflow session (label set — e.g. a TEA pre_commit_gate) runs the + # generic adapter too; exporting BMAD_LOOP_SPEC_FOLDER would make it + # short-circuit to story-spec synthesis, so at pre_commit_gate (spec already + # `done`) a gate session that did nothing would read `completed:done` and + # bypass the completion-marker + monotonic stall-nudge contract (the + # TEA-livelock fix). Withhold the env so workflow sessions keep the marker + # contract; sprint-mode workflow sessions are already env-free here. + if label is not None: + return {} + # Let the dev/review adapter resolve the story spec deterministically by + # id (skip the mtime scan). Project-relative — the adapter rebases it + # against spec.cwd, so it is correct in place and under worktree isolation. + env = {"BMAD_LOOP_SPEC_FOLDER": self._spec_folder_rel} + # On a plan-halt leg tell the adapter to synthesize the ready-for-dev spec + # as a *successful* terminal (plan done), not died-mid-flight. Keyed off the + # same on-disk state as the prompt's `Halt after planning.` leg, so the two + # never disagree (both read before the session writes the spec). + if role == "dev" and self._plan_halt_leg(task, self._entry_for(task)): + env["BMAD_LOOP_PLAN_HALT"] = "1" + return env + + def _dev_prompt(self, task: StoryTask, feedback: Path | None) -> str: + return self._stories_dev_prompt(task, feedback) + + def _stories_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str: + """Folder+id dispatch for a fresh (or resumed-to-implement) story; the + repair leg falls back to the inherited explicit-spec-file resume. + + Fresh dispatch: + ``/bmad-dev-auto Spec folder: . Story id: .`` + + (plan-halt leg) `` Halt after planning.`` + + (when ``invoke_dev_with`` non-empty) a newline then its verbatim text. + + The folder is always project-relative (kills the absolute-path concern; + the contract allows an absolute one but we never emit it). ``invoke_dev_with`` + is appended verbatim — the single planner→dev channel, never interpreted.""" + if feedback is not None: + # Deterministic-verify repair: re-open the id-keyed story spec and + # resume on it in place. Identical to the base generic repair leg (an + # explicit-spec-file invocation, a conforming mode) — the story spec + # is just a normal spec file. + self._reset_spec_for_repair(task) + spec_ref = task.spec_file or task.story_key + return ( + f"/bmad-dev-auto Resume the autonomous dev session on the in-progress " + f"spec at `{spec_ref}`. The previous session's work failed deterministic " + f"verification; repair the working tree so verification passes without " + f"changing the spec's frozen intent contract. Verification evidence is " + f"in `{feedback}`." + ) + entry = self._entry_for(task) + prompt = f"/bmad-dev-auto Spec folder: {self._spec_folder_rel}. Story id: {task.story_key}." + if self._plan_halt_leg(task, entry): + prompt += " Halt after planning." + if entry is not None and entry.invoke_dev_with: + prompt += "\n" + entry.invoke_dev_with + return prompt + + def _entry_for(self, task: StoryTask) -> stories.StoryEntry | None: + """The manifest entry for the task, re-read fresh so a resume (which does + not go through ``_pick_next``) still resolves ``invoke_dev_with`` / the + checkpoint flags. None when the id is absent — a pinned id never + disappears, so this only happens on a hand-broken manifest, where the + bare folder+id dispatch (title/description read by the skill) still runs. + + A parse failure journals a one-time ``stories-manifest-unreadable`` warning + per story (this is called several times per dispatch) so the silent + fallback leaves a trace, then returns None.""" + try: + return self._load_stories().get(task.story_key) + except stories.StoriesError as e: + if task.story_key not in self._warned_unreadable: + self._warned_unreadable.add(task.story_key) + self.journal.append( + "stories-manifest-unreadable", story_key=task.story_key, error=str(e) + ) + return None + + def _plan_halt_leg(self, task: StoryTask, entry: stories.StoryEntry | None) -> bool: + """Whether this dispatch HALTs after planning for a human plan review. + + True only for a ``spec_checkpoint`` story whose plan has not yet reached + ``ready-for-dev`` on disk — i.e. leg 1. Once the plan exists (leg 2 after + the plan-checkpoint resume, or a mid-flight repair that reset the spec to + in-progress) this is False and the story dispatches straight to + implementation. Reading on-disk state (not a task flag) is what keeps the + prompt's ``Halt after planning.`` leg and the env's ``BMAD_LOOP_PLAN_HALT`` + in lock-step: both call this before the session writes the spec.""" + if entry is None: + return False + state = stories.resolve_story_spec(self._stories_folder(), task.story_key) + return stories.is_plan_halt_leg(entry.spec_checkpoint, state) + + # ---------------------------------------------------------- sync + verify + + def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None: + """No-op: stories mode has no sprint board (and no deferred-work ledger to + flip). Honors the contract's "the orchestrator writes nothing" on the + happy path — the dev skill is the sole writer of each story spec's status.""" + return + + def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): + # The adapter marks a plan-halt leg's synthesized result `plan_halt`; latch + # it onto the task so _drive_story pauses for plan review (and clears it on + # the leg-2 re-drive), and switch verify to the ready-for-dev plan gate. + plan_halt = bool((result_json or {}).get("plan_halt")) + task.plan_checkpoint_pending = plan_halt + # Read-back detection: the just-run dev session HALTed pre-planning and left + # a fixed-slug sentinel. Journal it (with its recorded blocking condition) + # before verify turns it into a retryable failure → escalation, so the + # sentinel has a distinct trace at read-back, not only the later escalation. + folder = self._stories_folder() + state = stories.resolve_story_spec(folder, task.story_key) + if state.kind == stories.KIND_SENTINEL: + self._journal_sentinel_detected(task.story_key, state) + return verify.verify_dev_stories( + task, + self.workspace.paths, + result_json, + spec_folder=folder, + review_enabled=self._dev_review_enabled(), + plan_halt=plan_halt, + ) + + def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | None) -> bool: + # A plan-halt leg produced only the plan (spec at ready-for-dev); there is + # no implementation yet, so skip the project build/test gate — it would + # fail on a half-built tree before the human ever sees the plan. + return not bool((result_json or {}).get("plan_halt")) + + def _verify_review(self, task: StoryTask): + # Drop the sprint-status gate (stories mode has no board); the id-keyed + # story spec's own `done` frontmatter is authoritative. + return verify.verify_review_stories(task, self.workspace.paths, self.policy) + + # -------------------------------------------------------- HITL checkpoints + + def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) -> None: + # Journal the plan-halt dispatch of a spec_checkpoint story's leg 1 (the + # `Halt after planning.` prompt + BMAD_LOOP_PLAN_HALT env are emitted by + # _dev_prompt / _extra_session_env, both keyed off the same on-disk state). + # dev_resume None means a fresh drive, not a mid-session crash replay. + if dev_resume is None and self._plan_halt_leg(task, self._entry_for(task)): + # Latch the plan-review obligation BEFORE the session runs, so it + # survives a crash in the post-session window, a non-fixable retry that + # advanced the on-disk plan status, or a skill that overran the Halt + # directive — cases where _plan_halt_leg (on-disk-status keyed) and + # plan_checkpoint_pending (result keyed) both go stale. Saved eagerly so + # a host death before the durable session record still finds it set. + task.plan_review_owed = True + self._save() + self.journal.append( + "plan-halt", story_key=task.story_key, spec_folder=self._spec_folder_rel + ) + if not self._dev_phase(task, resume_result=dev_resume): + return + if task.plan_checkpoint_pending: + self._pause_plan_checkpoint(task) # always raises RunPaused + if task.plan_review_owed: + # A plan review is still owed but this dev leg did NOT itself pause for + # it: the skill overran `Halt after planning.` and drove to done, or a + # crash/non-fixable-retry re-drove straight to implementation on an + # already-planned spec. Pause before commit so a spec_checkpoint story + # can never commit un-reviewed (distinct "owed but implemented" message). + self._pause_plan_review_owed(task) # always raises RunPaused + # preserve the global spec-approval gate (rarely configured in stories + # mode, but the plan-checkpoint is not a substitute for it). + if gates.pause_after_spec(self.policy): + gates.notify( + self.policy, + self.run_dir, + f"spec ready for approval: {task.story_key}", + f"review {task.spec_file}, then `bmad-loop resume {self.state.run_id}`", + ) + raise RunPaused( + f"awaiting spec approval for {task.story_key}", + PAUSE_SPEC_APPROVAL, + task.story_key, + ) + self._review_and_commit(task) + + def _pause_plan_checkpoint(self, task: StoryTask) -> None: + """Leg 1 of a spec_checkpoint story verified (plan at ready-for-dev): pause + for human plan review. The task stays at DEV_VERIFY with + ``plan_checkpoint_pending`` set, so on resume _finish_inflight routes it to + :meth:`_resume_after_dev_verify` for the implement leg. Always raises.""" + task.plan_review_owed = False # discharged: we are pausing for the review now + self.journal.append( + "checkpoint-pause", story_key=task.story_key, checkpoint="plan", spec=task.spec_file + ) + gates.notify( + self.policy, + self.run_dir, + f"plan ready for review: {task.story_key}", + f"review the planned spec {task.spec_file}, then " + f"`bmad-loop resume {self.state.run_id}`", + ) + self._save() + raise RunPaused( + f"plan checkpoint for {task.story_key}: review the spec, then resume", + PAUSE_PLAN_CHECKPOINT, + task.story_key, + ) + + def _pause_plan_review_owed(self, task: StoryTask) -> None: + """A spec_checkpoint story reached implementation without ever pausing for + its plan review (the skill overran ``Halt after planning.``, or a crash / + non-fixable retry re-drove straight to implementation on an already-planned + spec). The plan review is still owed, so pause before commit — distinct from + :meth:`_pause_plan_checkpoint` in that the code is already written. + + ``plan_checkpoint_pending`` is deliberately NOT set: on resume the story is + already implemented, so :meth:`_resume_after_dev_verify` proceeds to + review+commit (the human approved) rather than re-driving an implement leg. + Reuses PAUSE_PLAN_CHECKPOINT so the CLI/TUI resume handling is unchanged. + Always raises.""" + task.plan_review_owed = False # discharged: we are pausing for the review now + self.journal.append( + "checkpoint-pause", + story_key=task.story_key, + checkpoint="plan", + spec=task.spec_file, + owed_after_implement=True, + ) + gates.notify( + self.policy, + self.run_dir, + f"plan review owed (already implemented): {task.story_key}", + f"the story was implemented before its plan checkpoint fired — review " + f"{task.spec_file}, then `bmad-loop resume {self.state.run_id}`", + ) + self._save() + raise RunPaused( + f"plan review owed for {task.story_key}: the skill implemented before the " + f"plan checkpoint — review the spec, then resume", + PAUSE_PLAN_CHECKPOINT, + task.story_key, + ) + + def _resume_after_dev_verify(self, task: StoryTask) -> None: + # A plan-checkpoint pause re-drives the implement leg here rather than the + # base review+commit. The on-disk status is now ready-for-dev, so _drive_story + # dispatches a plain folder+id leg (implement) and, once it commits, the + # done_checkpoint fires from _after_story like any other story. + if task.plan_checkpoint_pending: + task.plan_checkpoint_pending = False + task.phase = Phase.PENDING # deliberate reset for the re-drive + self.journal.append("checkpoint-resume", story_key=task.story_key, checkpoint="plan") + self._save() + self._drive_story(task) + return + super()._resume_after_dev_verify(task) + + def _after_story(self, task: StoryTask) -> None: + # done_checkpoint: after a committed story, pause for review — but skip when + # this was the last story to dispatch (the run ends here anyway). Fires from + # both _loop and _finish_inflight, always after any worktree integration. + if task.phase != Phase.DONE: + return + entry = self._entry_for(task) + if entry is None or not entry.done_checkpoint: + return + # skip-if-last: no further story will dispatch. Either the schedule is + # complete OR this committed story is the run's --max-stories-th (the bound + # stops the loop next iteration). Honoring the cap here — durably, from + # committed-story count, not the resume-reset _loop counter — is what keeps + # a done_checkpoint pause from letting a resume leapfrog past the bound (the + # pause+resume would otherwise reset the local counter and dispatch more). + if self._schedule_complete() or self._max_stories_reached(): + self.journal.append( + "checkpoint-skip-last", story_key=task.story_key, checkpoint="story" + ) + return + self.journal.append( + "checkpoint-pause", story_key=task.story_key, checkpoint="story", commit=task.commit_sha + ) + gates.notify( + self.policy, + self.run_dir, + f"story committed — review checkpoint: {task.story_key}", + f"review {task.story_key}, then `bmad-loop resume {self.state.run_id}`", + ) + self._save() + raise RunPaused( + f"story checkpoint for {task.story_key}: review, then resume", + PAUSE_STORY_CHECKPOINT, + task.story_key, + ) + + def _schedule_complete(self) -> bool: + """Whether the schedule has no more actionable stories. Guarded: a manifest + that went unreadable (or a --story id that vanished) between the commit and + this skip-if-last check must not crash the after-commit path — treat an + error as "not complete" so the done_checkpoint still fires (the safe default + is to pause for review, never to silently swallow it).""" + try: + return self._compute_schedule().is_complete + except (StoriesModeError, stories.StoriesError): + return False + + def _max_stories_reached(self) -> bool: + """Whether the run has committed its ``--max-stories`` allotment. Counts DONE + tasks durably from run state (survives a done_checkpoint pause/resume, unlike + the _loop-local ``started`` counter), so the done_checkpoint's skip-if-last + honors the same bound the loop enforces.""" + if self.max_stories is None: + return False + done = sum(1 for t in self.state.tasks.values() if t.phase == Phase.DONE) + return done >= self.max_stories diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 02aaedb..38a2611 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -1132,6 +1132,13 @@ def verify_dev_bundle( return VerifyOutcome.passed() +# A spec_checkpoint story's plan-halt leg leaves the spec at this status (the +# skill HALTs after the Ready-for-Development gate); mirrors +# devcontract.PLAN_HALT_STATUS, kept literal here to avoid a verify<-devcontract +# import cycle (devcontract imports verify). +PLAN_HALT_STATUS = "ready-for-dev" + + def verify_dev_stories( task: StoryTask, paths: ProjectPaths, @@ -1139,6 +1146,7 @@ def verify_dev_stories( *, spec_folder: Path, review_enabled: bool = True, + plan_halt: bool = False, ) -> VerifyOutcome: """verify_dev for stories mode: the story spec lives at the id-keyed path ``/stories/-.md`` and there is no sprint-status entry. @@ -1150,6 +1158,13 @@ def verify_dev_stories( and the sprint-status gate is dropped (stories mode has no sprint board). A resolution that is pending / ambiguous / a sentinel is a retryable failure, and the resolved filename's id prefix is asserted to equal the task id. + + ``plan_halt`` verifies a spec_checkpoint story's plan-halt leg instead of an + implementation: the expected status is ``ready-for-dev`` (the plan is done, + not the code) and the proof-of-work gate is skipped — a plan writes only its + own spec, which proof-of-work already excludes, so requiring code changes + would spuriously fail every plan leg. The spec-resolution, id-prefix, workflow, + and baseline gates still run, and ``task.spec_file`` is still recorded. """ from . import stories @@ -1182,8 +1197,12 @@ def verify_dev_stories( ) # Generic path always self-finalizes to done (no in-review handoff); the - # review_enabled arm mirrors verify_dev for symmetry. - expected = "in-review" if review_enabled else "done" + # review_enabled arm mirrors verify_dev for symmetry. A plan-halt leg instead + # expects the ready-for-dev plan gate (the plan is done, not the code). + if plan_halt: + expected = PLAN_HALT_STATUS + else: + expected = "in-review" if review_enabled else "done" fm = read_frontmatter(spec_path) status = status_of(fm) if status != expected: @@ -1197,11 +1216,25 @@ def verify_dev_stories( f"orchestrator-recorded baseline {task.baseline_commit[:12]}" ) - if task.baseline_commit: + # A plan-halt leg produced only its own spec (the plan); proof-of-work would + # spuriously fail (the spec is excluded), so skip it and record the plan spec. + if task.baseline_commit and not plan_halt: + # Proof-of-work must not count the story's own record (the id-keyed spec) + # or the human-authored stories.yaml as implementation work — a story that + # only wrote its spec did no real work. Use the file-granular exclude + # (`verify_dev_exclude_relpaths`, matching verify_dev post-#79: only the + # session's own spec + the sprint-status ledger), NOT the whole-folder + # `artifact_relpaths` — otherwise a story whose entire authorized scope is + # ledger/spec reconciliation (e.g. deferred-work.md under implementation_ + # artifacts) would keep registering as a permanent false "no changes". Then + # add the spec folder's stories/ subdir + stories.yaml, which hold only + # specs and the human-authored manifest (never implementation work), for a + # spec folder that sits outside the artifact dirs (both no-ops under output_folder). + exclude = verify_dev_exclude_relpaths(paths, spec_path) + _stories_relpaths( + paths.project, spec_folder + ) try: - if not has_changes_since( - paths.project, task.baseline_commit, exclude=artifact_relpaths(paths) - ): + if not has_changes_since(paths.project, task.baseline_commit, exclude=exclude): return VerifyOutcome.retry("no changes in worktree since baseline commit") except GitError as e: return VerifyOutcome.escalate(str(e)) @@ -1210,6 +1243,20 @@ def verify_dev_stories( return VerifyOutcome.passed() +def _stories_relpaths(project: Path, spec_folder: Path) -> tuple[str, ...]: + """Proof-of-work exclude prefixes for the story record + manifest: the spec + folder's ``stories/`` subdir and its ``stories.yaml``, project-relative. Empty + when the spec folder is outside the project tree (nothing to exclude there).""" + from .stories import STORIES_FILENAME, STORIES_SUBDIR + + try: + rel = spec_folder.resolve().relative_to(project.resolve()).as_posix() + except ValueError: + return () + base = "" if rel == "." else f"{rel}/" + return (f"{base}{STORIES_SUBDIR}", f"{base}{STORIES_FILENAME}") + + @dataclass(frozen=True) class CommandResult: command: str @@ -1268,6 +1315,19 @@ def verify_review(task: StoryTask, paths: ProjectPaths, policy: Policy) -> Verif return verify_commands_outcome(policy, paths.project) +def verify_review_stories(task: StoryTask, paths: ProjectPaths, policy: Policy) -> VerifyOutcome: + """verify_review for stories mode: same spec-done + verify-commands gates, + minus the sprint-status gate (stories mode has no sprint board — the story + spec's own frontmatter status is authoritative). ``task.spec_file`` is the + id-keyed story spec ``verify_dev_stories`` recorded on the dev pass.""" + if not task.spec_file: + return VerifyOutcome.retry("no spec file recorded for task") + status = status_of(read_frontmatter(Path(task.spec_file))) + if status != "done": + return VerifyOutcome.retry(f"spec status is {status!r}, expected 'done'") + return verify_commands_outcome(policy, paths.project) + + def verify_review_bundle(task: StoryTask, paths: ProjectPaths, policy: Policy) -> VerifyOutcome: """verify_review for a deferred-work bundle: no sprint-status check, but every dw id the bundle owns must be marked done in the ledger on disk. The diff --git a/tests/test_cli.py b/tests/test_cli.py index 88fbc6e..b58fcb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,11 +4,30 @@ import json import pytest +import yaml from conftest import install_bmad_config, write_sprint from bmad_loop import cli from bmad_loop import policy as policy_mod +STORIES_SPEC_FOLDER = "_bmad-output/epic-1" + + +def _stories_entry(story_id, **over): + d = {"id": story_id, "title": f"Story {story_id}", "description": "does a thing"} + d.update(over) + return d + + +def _setup_stories_fixture(paths, entries, *, with_spec_md=True): + folder = paths.project / STORIES_SPEC_FOLDER + (folder / "stories").mkdir(parents=True, exist_ok=True) + if with_spec_md: + (folder / "SPEC.md").write_text("# Epic 1\n", encoding="utf-8") + (folder / "stories.yaml").write_text(yaml.safe_dump(entries, sort_keys=False), encoding="utf-8") + return folder + + DUAL_CLIENT_POLICY = """\ [adapter] name = "claude" @@ -99,6 +118,92 @@ def test_dry_run_reports_targeted_not_actionable(project, capsys): assert "3-2 matched 3-2-foo" in err and "not actionable" in err +# ------------------------------------------------------------ stories mode + + +def test_stories_mode_forced_by_spec_flag(): + args = argparse.Namespace(spec="_bmad-output/epic-1") + on, folder = cli._stories_mode(args, policy_mod.loads("")) + assert on is True and folder == "_bmad-output/epic-1" + + +def test_stories_mode_from_policy_source(): + pol = policy_mod.loads('[stories]\nsource = "stories"\nspec_folder = "epic-2"\n') + assert cli._stories_mode(argparse.Namespace(spec=None), pol) == (True, "epic-2") + + +def test_stories_mode_default_off(): + assert cli._stories_mode(argparse.Namespace(spec=None), policy_mod.loads("")) == (False, "") + + +def test_stories_mode_spec_flag_overrides_policy_sprint_source(): + # --spec forces stories mode even when policy says sprint-status + args = argparse.Namespace(spec="_bmad-output/epic-9") + on, folder = cli._stories_mode(args, policy_mod.loads("")) + assert on and folder == "_bmad-output/epic-9" + + +def test_validate_stories_folder_ok(project): + _setup_stories_fixture(project, [_stories_entry("1")]) + assert cli._validate_stories_folder(project, STORIES_SPEC_FOLDER) is None + + +def test_validate_stories_folder_missing_manifest(project): + problem = cli._validate_stories_folder(project, STORIES_SPEC_FOLDER) + assert problem is not None and "no stories.yaml found" in problem + + +def test_validate_stories_folder_missing_spec_md(project): + _setup_stories_fixture(project, [_stories_entry("1")], with_spec_md=False) + problem = cli._validate_stories_folder(project, STORIES_SPEC_FOLDER) + assert problem is not None and "SPEC.md not found" in problem + + +def test_validate_stories_folder_invalid_manifest(project): + _setup_stories_fixture(project, [_stories_entry("3"), _stories_entry("3", title="dup")]) + problem = cli._validate_stories_folder(project, STORIES_SPEC_FOLDER) + assert problem is not None and "duplicate id" in problem + + +def test_dry_run_stories_prints_linear_schedule(project, capsys): + _setup_stories_fixture( + project, + [ + _stories_entry("1", spec_checkpoint=True, done_checkpoint=True), + _stories_entry("2"), + ], + ) + _write_policy(project.project) + pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml") + args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story=None, max_stories=None) + + assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 0 + out = capsys.readouterr().out + assert "linear schedule" in out + assert "Spec folder: _bmad-output/epic-1. Story id: 1." in out + assert "Spec folder: _bmad-output/epic-1. Story id: 2." in out + assert "spec-checkpoint" in out and "done-checkpoint" in out + assert "BMAD_LOOP_SPEC_FOLDER=_bmad-output/epic-1" in out + # pending on-disk state shown for an unstarted story + assert "(pending)" in out + + +def test_dry_run_stories_filters_by_story_id(project, capsys): + _setup_stories_fixture(project, [_stories_entry("1"), _stories_entry("2")]) + pol = policy_mod.loads("") + args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story="2", max_stories=None) + assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 0 + out = capsys.readouterr().out + assert "Story id: 2." in out and "Story id: 1." not in out + + +def test_dry_run_stories_bad_folder_errors(project, capsys): + pol = policy_mod.loads("") + args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story=None, max_stories=None) + assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 1 + assert "no stories.yaml found" in capsys.readouterr().err + + def _make_run_with_decision(project, run_id="20260101-000000-aaaa", dw_id="DW-1"): run_dir = project.project / ".bmad-loop" / "runs" / run_id run_dir.mkdir(parents=True, exist_ok=True) @@ -218,6 +323,50 @@ def test_status_ambiguous_ref_errors(project, capsys): assert "ambiguous run ref 'aa' matches 2 runs" in capsys.readouterr().err +def _make_stories_run(project, run_id="20260101-000000-st01"): + """A stories-mode run dir + state.json pinned to source=stories.""" + run_dir = project.project / ".bmad-loop" / "runs" / run_id + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "state.json").write_text( + json.dumps( + { + "run_id": run_id, + "project": str(project.project), + "started_at": "now", + "source": "stories", + "spec_folder": STORIES_SPEC_FOLDER, + } + ), + encoding="utf-8", + ) + return run_dir + + +def test_status_stories_mode_prints_board(project, capsys): + from bmad_loop.stories import STORIES_SUBDIR + + _setup_stories_fixture( + project, [_stories_entry("1", spec_checkpoint=True), _stories_entry("2")] + ) + (project.project / STORIES_SPEC_FOLDER / STORIES_SUBDIR / "1-slug.md").write_text( + "---\nstatus: done\n---\n", encoding="utf-8" + ) + _make_stories_run(project) + assert cli.main(["status", "--project", str(project.project)]) == 0 + out = capsys.readouterr().out + assert "stories: 1/2 done" in out + assert "spec-checkpoint" in out + # the sprint-mode backlog line must not appear for a stories run + assert "sprint backlog remaining" not in out + + +def test_status_stories_mode_bad_manifest_is_soft(project, capsys): + # a stories run whose manifest is gone still prints the run header, not a crash + _make_stories_run(project) + assert cli.main(["status", "--project", str(project.project)]) == 0 + assert "no stories.yaml found" in capsys.readouterr().out + + def test_list_shows_short_refs(project, capsys): _make_run_with_decision(project, run_id="20260101-000000-aaaa") _make_run_with_decision(project, run_id="20260102-000000-bbbb") @@ -1085,3 +1234,104 @@ def _boom(): notes, problems = cli._platform_preflight() # must not raise assert any("bogus" in p for p in problems) assert any("_FakeBackend" in n for n in notes) # the healthy seam still reported + + +# --------------- item 8/10: stories-aware validate + selector preflight ------- + +STORIES_POLICY = '[stories]\nsource = "stories"\nspec_folder = "_bmad-output/epic-1"\n' + + +def _validate_output(capsys): + out = capsys.readouterr() + return (out.out + out.err).lower() + + +def test_validate_stories_mode_skips_sprint_gate(project, capsys): + """Item 8: a stories-mode project (no sprint-status.yaml) validates its + stories.yaml manifest instead of failing on the missing sprint gate.""" + install_bmad_config(project) + _setup_stories_fixture(project, [_stories_entry("1")]) + _write_policy(project.project, STORIES_POLICY) + args = argparse.Namespace(project=str(project.project), spec=None) + + cli.cmd_validate(args) + text = _validate_output(capsys) + assert "sprint status" not in text # the sprint gate is skipped + assert "stories mode ok" in text # the manifest validated instead + + +def test_validate_stories_mode_reports_missing_manifest(project, capsys): + """Item 8: stories mode with no stories.yaml fails validate with the pinned + remediation-bearing message (not the sprint-status error).""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + args = argparse.Namespace(project=str(project.project), spec=None) + + assert cli.cmd_validate(args) == 1 + text = _validate_output(capsys) + assert "no stories.yaml found" in text + assert "sprint status" not in text + + +def test_validate_sprint_mode_still_gates_on_sprint_status(project, capsys): + """Item 8 regression: the default (sprint) mode still requires sprint-status.""" + install_bmad_config(project) + _write_policy(project.project) # DUAL_CLIENT_POLICY -> sprint mode (no [stories]) + args = argparse.Namespace(project=str(project.project), spec=None) + + assert cli.cmd_validate(args) == 1 + assert "sprint" in _validate_output(capsys) + + +def test_validate_spec_flag_forces_stories_mode(project, capsys): + """Item 8: `validate --spec ` forces stories mode even under a sprint + policy — the sprint gate is skipped and the manifest is validated.""" + install_bmad_config(project) + _setup_stories_fixture(project, [_stories_entry("1")]) + _write_policy(project.project) # sprint policy + args = argparse.Namespace(project=str(project.project), spec=STORIES_SPEC_FOLDER) + + cli.cmd_validate(args) + text = _validate_output(capsys) + assert "stories mode ok" in text + assert "sprint status" not in text + + +def test_validate_stories_folder_unknown_selector(project): + """Item 10: an unknown --story id is caught at preflight (fails before the run + starts) rather than crashing mid-flight in the scheduler.""" + _setup_stories_fixture(project, [_stories_entry("1"), _stories_entry("2")]) + problem = cli._validate_stories_folder(project, STORIES_SPEC_FOLDER, selector="99") + assert problem is not None and "'99'" in problem and "not in stories.yaml" in problem + + +def test_validate_stories_folder_known_selector_ok(project): + _setup_stories_fixture(project, [_stories_entry("1"), _stories_entry("2")]) + assert cli._validate_stories_folder(project, STORIES_SPEC_FOLDER, selector="2") is None + + +def test_dry_run_stories_shows_plan_halt_markers(project, capsys): + """Item 10: dry-run mirrors the real dispatch's leg-1 markers for a pending + spec_checkpoint story (`Halt after planning.` + BMAD_LOOP_PLAN_HALT).""" + _setup_stories_fixture(project, [_stories_entry("1", spec_checkpoint=True)]) + pol = policy_mod.loads("") + args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story=None, max_stories=None) + + assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 0 + out = capsys.readouterr().out + assert "Halt after planning." in out + assert "BMAD_LOOP_PLAN_HALT=1" in out + + +def test_dry_run_stories_relativizes_absolute_folder(project, capsys): + """Item 10: an absolute --spec inside the project renders the project-relative + folder in the dispatch/env, matching what the engine actually dispatches.""" + _setup_stories_fixture(project, [_stories_entry("1")]) + abs_folder = str(project.project / STORIES_SPEC_FOLDER) + pol = policy_mod.loads("") + args = argparse.Namespace(spec=abs_folder, epic=None, story=None, max_stories=None) + + assert cli._dry_run(project, pol, args, True, abs_folder) == 0 + out = capsys.readouterr().out + assert "Spec folder: _bmad-output/epic-1. Story id: 1." in out + assert f"Spec folder: {abs_folder}" not in out # not the raw absolute path diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index a2f3d43..e629846 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -387,6 +387,104 @@ def delayed_find(artifacts, *, since_ns): assert calls["n"] >= 3 # it polled rather than giving up on the first miss +# ------------------------------- GenericDevAdapter stories-mode read-back +# +# Under folder+id dispatch (BMAD_LOOP_SPEC_FOLDER set), the adapter resolves the +# story spec deterministically at /stories/-*.md instead of the +# mtime-floor scan. + + +def _stories_spec(tmp_path, story_key="1", spec_folder="epic") -> SessionSpec: + return SessionSpec( + task_id="1-dev-1", + role="dev", + prompt="/bmad-dev-auto Spec folder: epic. Story id: 1.", + cwd=tmp_path, + env={"BMAD_LOOP_STORY_KEY": story_key, "BMAD_LOOP_SPEC_FOLDER": spec_folder}, + ) + + +def _write_story_spec(tmp_path, story_key, slug, body, spec_folder="epic") -> Path: + d = tmp_path / spec_folder / "stories" + d.mkdir(parents=True, exist_ok=True) + p = d / f"{story_key}-{slug}.md" + p.write_text(body, encoding="utf-8") + return p + + +def test_stories_readback_resolves_by_id_not_mtime_scan(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + # a stray, NEWER artifact in the impl dir would win the mtime scan — the + # stories path must ignore it entirely (never call find_result_artifact). + (impl / "spec-stray.md").write_text( + "---\nstatus: done\nbaseline_revision: straybase\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + _write_story_spec( + tmp_path, + "1", + "foo", + "---\nstatus: done\nbaseline_revision: story1base\n---\n\n" + "## Auto Run Result\n\nStatus: done\nImplemented.\n", + ) + + def boom(*a, **k): + raise AssertionError("stories mode must not call the mtime scan") + + monkeypatch.setattr(generic.devcontract, "find_result_artifact", boom) + rj = adapter._result_json(_dev_handle(), _stories_spec(tmp_path), wait=True) + assert rj["status"] == "done" + assert rj["story_key"] == "1" + assert rj["baseline_commit"] == "story1base" # the story spec, not the stray + + +def test_stories_readback_sentinel_is_blocked_escalation(tmp_path): + adapter, _ = make_dev_adapter(tmp_path) + _write_story_spec( + tmp_path, + "1", + "unresolved", + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\n" + "Status: blocked\nBlocking condition: story already blocked\n", + ) + rj = adapter._result_json(_dev_handle(), _stories_spec(tmp_path), wait=True) + assert rj is not None and rj["status"] == "blocked" + crits = [e for e in rj["escalations"] if str(e.get("severity", "")).upper() == "CRITICAL"] + assert crits, "a blocked sentinel must synthesize a CRITICAL escalation" + + +def test_stories_readback_pending_returns_none(tmp_path): + adapter, _ = make_dev_adapter(tmp_path) + # no story spec on disk yet -> not terminal + assert adapter._result_json(_dev_handle(), _stories_spec(tmp_path), wait=False) is None + + +def test_stories_readback_non_terminal_returns_none(tmp_path): + adapter, _ = make_dev_adapter(tmp_path) + # a died-mid-flight ready-for-dev (no plan-halt) is not a terminal result + _write_story_spec(tmp_path, "1", "foo", "---\nstatus: ready-for-dev\n---\n\nplanned only\n") + assert adapter._result_json(_dev_handle(), _stories_spec(tmp_path), wait=False) is None + + +def test_stories_readback_plan_halt_is_successful_terminal(tmp_path): + # BMAD_LOOP_PLAN_HALT flips the SAME ready-for-dev spec into a successful, + # plan-marked terminal (the leg-1 plan is done, awaiting implementation). + adapter, _ = make_dev_adapter(tmp_path) + _write_story_spec( + tmp_path, + "1", + "foo", + "---\nstatus: ready-for-dev\nbaseline_revision: planbase\n---\n\nplan\n", + ) + spec = _stories_spec(tmp_path) + spec.env["BMAD_LOOP_PLAN_HALT"] = "1" + rj = adapter._result_json(_dev_handle(), spec, wait=True) + assert rj is not None + assert rj["status"] == "ready-for-dev" + assert rj["plan_halt"] is True + assert rj["escalations"] == [] + assert rj["baseline_commit"] == "planbase" + + def test_generic_dev_result_json_no_wait_reads_once(tmp_path, monkeypatch): """wait=False keeps the read-once behavior: no polling, immediate None.""" adapter, _ = make_dev_adapter(tmp_path) diff --git a/tests/test_install.py b/tests/test_install.py index a5d22a1..4dc443e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -553,6 +553,57 @@ def test_missing_base_skills_reports_absent_and_incomplete(tmp_path): assert "step-04-review.md" in problems[0] +def test_missing_stories_support_probes_step01_content(tmp_path): + from bmad_loop.install import ( + STORIES_PROBE_FILE, + STORIES_PROBE_SKILL, + missing_stories_support, + ) + + claude = get_profile("claude") + tree = claude.skill_tree + step01 = tmp_path / tree / STORIES_PROBE_SKILL / STORIES_PROBE_FILE + + # step-01 absent → reported (older/half install) + problems = missing_stories_support(tmp_path, [tree]) + assert len(problems) == 1 and "not found" in problems[0] + + # present but WITHOUT the folder+id dispatch marker (a pre-#2549 skill) + step01.parent.mkdir(parents=True, exist_ok=True) + step01.write_text("# Step 1\nold clarify-and-route, no dispatch protocol\n", encoding="utf-8") + problems = missing_stories_support(tmp_path, [tree]) + assert len(problems) == 1 and "folder+id dispatch" in problems[0] + + # present WITH the marker → OK + step01.write_text("route a **folder+id dispatch** invocation\n", encoding="utf-8") + assert missing_stories_support(tmp_path, [tree]) == [] + + +def test_new_dev_auto_skill_is_additive_for_sprint_mode(tmp_path): + """Scenario 6 additivity: installing the *new* bmad-dev-auto (folder+id + dispatch present) satisfies both preflights — sprint mode's file-existence + check (`missing_base_skills`, which never inspects the dispatch content) and + stories mode's content probe (`missing_stories_support`). The new skill + breaks neither pipeline.""" + from bmad_loop.install import ( + STORIES_PROBE_FILE, + STORIES_PROBE_SKILL, + missing_stories_support, + ) + + claude = get_profile("claude") + tree = claude.skill_tree + _install_base_skills(tmp_path, tree) + # upgrade bmad-dev-auto in place to the folder+id dispatch version + step01 = tmp_path / tree / STORIES_PROBE_SKILL / STORIES_PROBE_FILE + step01.write_text("route a **folder+id dispatch** invocation\n", encoding="utf-8") + + # sprint mode (file existence) is unaffected by the new dispatch content … + assert missing_base_skills(tmp_path, [tree]) == [] + # … and stories mode now also passes its stricter content probe + assert missing_stories_support(tmp_path, [tree]) == [] + + def test_provision_worktree_seeds_gitignored_config(tmp_path): """A gitignored config present in the main repo is copied into the worktree (a `git worktree add` checkout would omit it).""" diff --git a/tests/test_model.py b/tests/test_model.py index 7ce5d46..309fec6 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -17,6 +17,25 @@ def _task_with_session(usage: TokenUsage | None = None) -> StoryTask: return task +def test_run_state_stories_fields_default_and_round_trip(): + default = _state() + assert default.source == "sprint-status" + assert default.spec_folder == "" + stories = _state(source="stories", spec_folder="_bmad-output/epic-1") + back = RunState.from_dict(stories.to_dict()) + assert back.source == "stories" + assert back.spec_folder == "_bmad-output/epic-1" + + +def test_run_state_stories_fields_default_when_absent_from_dict(): + # a pre-stories state.json (no source/spec_folder keys) reads as sprint mode + d = _state().to_dict() + del d["source"] + del d["spec_folder"] + back = RunState.from_dict(d) + assert back.source == "sprint-status" and back.spec_folder == "" + + def test_attach_session_usage_folds_usage_into_record_and_totals(): task = _task_with_session() task.attach_session_usage("1-1-a-dev-1", TokenUsage(input_tokens=10, output_tokens=5)) @@ -84,6 +103,17 @@ def test_resolved_redrive_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).resolved_redrive is False +def test_plan_checkpoint_pending_round_trips(): + task = StoryTask(story_key="1", epic=0, plan_checkpoint_pending=True) + assert StoryTask.from_dict(task.to_dict()).plan_checkpoint_pending is True + + +def test_plan_checkpoint_pending_defaults_false_for_legacy_state(): + doc = StoryTask(story_key="1", epic=0).to_dict() + del doc["plan_checkpoint_pending"] # state.json from before the field existed + assert StoryTask.from_dict(doc).plan_checkpoint_pending is False + + def test_stopped_round_trips(): state = _state(stopped=True) assert RunState.from_dict(state.to_dict()).stopped is True diff --git a/tests/test_policy.py b/tests/test_policy.py index b3e0b8a..88916fd 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -37,6 +37,36 @@ def test_review_trigger_invalid(): policy.loads('[review]\ntrigger = "sometimes"\n') +def test_stories_defaults(): + pol = policy.loads("") + assert pol.stories.source == "sprint-status" + assert pol.stories.spec_folder == "" + + +def test_stories_parse_and_folder(): + pol = policy.loads('[stories]\nsource = "stories"\nspec_folder = "_bmad-output/epic-1"\n') + assert pol.stories.source == "stories" + assert pol.stories.spec_folder == "_bmad-output/epic-1" + + +def test_stories_source_invalid(): + with pytest.raises(policy.PolicyError, match="stories.source"): + policy.loads('[stories]\nsource = "manifest"\n') + + +def test_stories_mode_requires_spec_folder(): + with pytest.raises(policy.PolicyError, match="requires stories.spec_folder"): + policy.loads('[stories]\nsource = "stories"\n') + + +def test_stories_spec_folder_under_sprint_mode_is_tolerated(): + # a leftover spec_folder while source stays sprint-status is not an error — + # it's ignored at run time, so flipping source back and forth keeps the path. + pol = policy.loads('[stories]\nspec_folder = "_bmad-output/epic-1"\n') + assert pol.stories.source == "sprint-status" + assert pol.stories.spec_folder == "_bmad-output/epic-1" + + def test_cleanup_session_on_finish_default_and_override(tmp_path): assert policy.load(None).adapter.cleanup_session_on_finish is True p = tmp_path / "policy.toml" diff --git a/tests/test_resolve.py b/tests/test_resolve.py index e4fb8e6..d3c34e0 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -3,6 +3,7 @@ import json import pytest +import yaml from conftest import git from bmad_loop import devcontract, resolve, runs, verify @@ -30,7 +31,14 @@ """ -def _escalated_run(project, run_id="20260613-111429-6a14", *, spec_file=None, with_session=True): +def _escalated_run( + project, + run_id="20260613-111429-6a14", + *, + spec_file=None, + with_session=True, + source="sprint-status", +): task = StoryTask( story_key="6-4-cli-list-command", epic=6, @@ -54,6 +62,7 @@ def _escalated_run(project, run_id="20260613-111429-6a14", *, spec_file=None, wi paused_stage=PAUSE_ESCALATION, paused_story_key="6-4-cli-list-command", tasks={task.story_key: task}, + source=source, ) run_dir = project / ".bmad-loop" / "runs" / run_id save_state(run_dir, state) @@ -223,6 +232,81 @@ def test_rearm_keeps_stale_baseline_outside_a_repo(tmp_path): assert task.baseline_commit == "abc123" +def test_rearm_clears_sentinel_preserving_a_copy(tmp_path): + """Stories mode: a fixed-slug sentinel (`-unresolved.md`) is cleared by + deletion, not a status flip — re-arm preserves a copy, journals the blocking + condition, drops the sentinel, and unsets spec_file so the re-dispatch starts + clean (PENDING → re-plan from scratch).""" + key = "6-4-cli-list-command" + stories_dir = tmp_path / "stories" + stories_dir.mkdir(parents=True) + sentinel = stories_dir / f"{key}-unresolved.md" + sentinel.write_text( + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", + encoding="utf-8", + ) + run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") + + returned = runs.rearm_escalation(run_dir) + assert returned == key + + # sentinel deleted from disk, a copy preserved under the run dir + assert not sentinel.exists() + preserved = run_dir / "sentinels" / f"{key}-unresolved.md" + assert preserved.is_file() and "intent too vague" in preserved.read_text(encoding="utf-8") + + state = load_state(run_dir) + task = state.tasks[key] + assert task.phase == Phase.PENDING + assert task.spec_file is None # cleared → next dispatch resolves to PENDING + + journal = (run_dir / "journal.jsonl").read_text(encoding="utf-8") + assert "sentinel-cleared" in journal + cleared = [ + json.loads(line) + for line in journal.splitlines() + if json.loads(line).get("kind") == "sentinel-cleared" + ] + # the journal carries the fixed slug (sentinel_kind) AND the recorded blocking + # condition parsed from the sentinel's ## Auto Run Result (not just the slug). + assert cleared[0]["sentinel_kind"] == "unresolved" and cleared[0]["story_key"] == key + assert "intent too vague" in cleared[0]["condition"] + + +def test_rearm_non_sentinel_spec_still_flips_status(tmp_path): + """A blocked (non-sentinel) story spec is re-opened by the status flip, not + deleted — the sentinel branch must not swallow the normal re-arm path.""" + key = "6-4-cli-list-command" + stories_dir = tmp_path / "stories" + stories_dir.mkdir(parents=True) + spec = stories_dir / f"{key}-slug.md" # a real spec, not a fixed-slug sentinel + spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") + run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) + + runs.rearm_escalation(run_dir) + assert spec.is_file() # not deleted + assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" + assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept + assert not (run_dir / "sentinels").exists() + + +def test_rearm_sprint_spec_named_like_a_sentinel_is_not_deleted(tmp_path): + """MINOR-G: the sentinel-clear path is stories-mode-only. A *sprint* spec that + merely happens to be named `-unresolved.md` must be status-flipped and + kept like any other spec — never deleted — since the fixed-slug sentinel + convention exists only in stories mode. (source defaults to sprint-status.)""" + key = "6-4-cli-list-command" + spec = tmp_path / f"{key}-unresolved.md" # sentinel-shaped name, but a sprint spec + spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nreal work\n", encoding="utf-8") + run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) # sprint-status source + + runs.rearm_escalation(run_dir) + assert spec.is_file() # NOT deleted despite the sentinel-shaped name + assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" # flipped like any spec + assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept + assert not (run_dir / "sentinels").exists() # no sentinel preservation in sprint mode + + def test_rearm_rejects_non_escalation_stage(tmp_path): run_dir = tmp_path / ".bmad-loop" / "runs" / "r1" save_state( @@ -292,3 +376,69 @@ def test_run_session_clears_stale_marker(tmp_path, monkeypatch): resolve.run_session(_FakeAdapter(None), tmp_path, run_dir, "6-4-cli-list-command") is False ) assert not stale.exists() # stale marker was removed, not reused + + +# ---------------- item 9: build_context stories-mode enrichment -------------- + + +def _stories_manifest(folder, entries): + (folder / "stories").mkdir(parents=True, exist_ok=True) + (folder / "stories.yaml").write_text(yaml.safe_dump(entries, sort_keys=False), encoding="utf-8") + + +def test_build_context_stories_carries_manifest_entry(tmp_path): + """Stories mode: context.json carries the manifest intent (spec folder + the + story entry's title/description/checkpoints/invoke_dev_with) for the resolver.""" + key = "6-4-cli-list-command" + _stories_manifest( + tmp_path / "epic-1", + [ + { + "id": key, + "title": "List command", + "description": "list notes", + "spec_checkpoint": True, + "invoke_dev_with": "use redis", + } + ], + ) + run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md", source="stories") + state.spec_folder = "epic-1" + + ctx = json.loads(resolve.build_context(state, run_dir, key).read_text(encoding="utf-8")) + st = ctx["stories"] + assert st["spec_folder"] == "epic-1" + assert st["story"]["title"] == "List command" + assert st["story"]["spec_checkpoint"] is True + assert st["story"]["invoke_dev_with"] == "use redis" + assert "sentinel" not in st # an ordinary escalation has no sentinel block + + +def test_build_context_stories_sentinel_indicator(tmp_path): + """Stories mode: a sentinel-escalated story carries a sentinel indicator with + its kind and recorded blocking condition (so the resolver knows there is no + frozen spec to edit).""" + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + sentinel = folder / "stories" / f"{key}-unresolved.md" + sentinel.write_text( + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", + encoding="utf-8", + ) + run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") + state.spec_folder = "epic-1" + + ctx = json.loads(resolve.build_context(state, run_dir, key).read_text(encoding="utf-8")) + sent = ctx["stories"]["sentinel"] + assert sent["kind"] == "unresolved" + assert "intent too vague" in sent["blocking_condition"] + + +def test_build_context_sprint_mode_has_no_stories_block(tmp_path): + """Sprint mode leaves the context contract unchanged — no stories block.""" + run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") # sprint source + ctx = json.loads( + resolve.build_context(state, run_dir, "6-4-cli-list-command").read_text(encoding="utf-8") + ) + assert "stories" not in ctx diff --git a/tests/test_settings_schema.py b/tests/test_settings_schema.py index d843cb7..cc52b1f 100644 --- a/tests/test_settings_schema.py +++ b/tests/test_settings_schema.py @@ -22,6 +22,7 @@ POLICY_TEMPLATE, RETRO_MODES, REVIEW_TRIGGER_MODES, + STORIES_SOURCES, SWEEP_AUTO_MODES, AdapterPolicy, CleanupPolicy, @@ -32,6 +33,7 @@ ReviewPolicy, ScmPolicy, StageAdapterPolicy, + StoriesPolicy, SweepPolicy, TuiPolicy, VerifyPolicy, @@ -44,6 +46,7 @@ SECTION_DC = { "gates": GatesPolicy, "review": ReviewPolicy, + "stories": StoriesPolicy, "limits": LimitsPolicy, "verify": VerifyPolicy, "notify": NotifyPolicy, @@ -62,6 +65,7 @@ ("gates", "mode"): GATE_MODES, ("gates", "retrospective"): RETRO_MODES, ("review", "trigger"): REVIEW_TRIGGER_MODES, + ("stories", "source"): STORIES_SOURCES, ("sweep", "auto"): SWEEP_AUTO_MODES, ("scm", "isolation"): ISOLATION_MODES, ("scm", "branch_per"): BRANCH_PER_MODES, diff --git a/tests/test_stories.py b/tests/test_stories.py index 3bb5224..ebf1fe2 100644 --- a/tests/test_stories.py +++ b/tests/test_stories.py @@ -340,6 +340,33 @@ def test_schedule_selector_unknown_raises(): stories.schedule(s, {}, selector="99") +def test_schedule_skip_passes_over_a_touched_story(): + # story 1 was driven this run but its on-disk spec still reads resumable + # (e.g. it plateau-deferred). skip must pass over it, not re-pick it. + s = _stories("1", "2") + states = {"1": _present("in-progress")} + assert stories.schedule(s, states).entry.id == "1" # without skip: re-picked + sched = stories.schedule(s, states, skip={"1"}) + assert sched.outcome == stories.SCHEDULE_NEXT and sched.entry.id == "2" + + +def test_schedule_skip_all_touched_is_complete(): + # every story either done or already handled this run -> run finishes. + s = _stories("1", "2") + states = {"1": _present("done"), "2": _present("ready-for-dev")} + sched = stories.schedule(s, states, skip={"2"}) + assert sched.is_complete + + +def test_schedule_skip_does_not_leapfrog_a_blocked_story(): + # a blocked story that is NOT in skip still stops the scan even when an + # earlier story was skipped. + s = _stories("1", "2", "3") + states = {"1": _present("done"), "2": _present("blocked")} + sched = stories.schedule(s, states, skip={"1"}) + assert sched.is_wedged and sched.entry.id == "2" + + # --------------------------------------------------------------- resolve_story_spec @@ -377,3 +404,66 @@ def test_resolve_prefix_isolation(tmp_path): # id "3" must not resolve a file for id "31" — `3-*.md` doesn't match `31-*.md`. write_story_spec(tmp_path, "31-other.md", status="done") assert stories.resolve_story_spec(tmp_path, "3").kind == stories.KIND_PENDING + + +# --------------------------------------------------- state_label / table projection + + +def test_state_label_present_shows_status(tmp_path): + write_story_spec(tmp_path, "1-slug.md", status="ready-for-dev") + assert stories.state_label(stories.resolve_story_spec(tmp_path, "1")) == "ready-for-dev" + + +def test_state_label_pending_and_ambiguous(tmp_path): + assert stories.state_label(stories.resolve_story_spec(tmp_path, "1")) == "pending" + write_story_spec(tmp_path, "1-a.md", status="done") + write_story_spec(tmp_path, "1-b.md", status="done") + assert stories.state_label(stories.resolve_story_spec(tmp_path, "1")) == "ambiguous" + + +def test_state_label_sentinel_carries_kind(tmp_path): + write_story_spec(tmp_path, "1-unresolved.md", status="blocked") + assert stories.state_label(stories.resolve_story_spec(tmp_path, "1")) == "sentinel:unresolved" + + +def test_resolve_spec_folder_relative_and_absolute(tmp_path): + assert stories.resolve_spec_folder(tmp_path, "epic-1") == tmp_path / "epic-1" + abs_folder = tmp_path / "somewhere" + assert stories.resolve_spec_folder(tmp_path, str(abs_folder)) == abs_folder + + +def test_story_rows_projects_manifest_and_disk_state(tmp_path): + write_stories( + tmp_path, + '- id: "1"\n title: First\n description: d\n spec_checkpoint: true\n' + '- id: "2"\n title: Second\n description: d\n done_checkpoint: true\n' + '- id: "3"\n title: Third\n description: d\n', + ) + write_story_spec(tmp_path, "1-slug.md", status="done") + write_story_spec(tmp_path, "2-slug.md", status="in-progress") + rows = stories.story_rows(tmp_path) + assert [(r.position, r.id, r.label) for r in rows] == [ + (1, "1", "done"), + (2, "2", "in-progress"), + (3, "3", "pending"), + ] + assert rows[0].spec_checkpoint and not rows[0].done_checkpoint + assert rows[1].done_checkpoint and not rows[1].spec_checkpoint + assert rows[0].title == "First" + + +def test_story_rows_selector_and_limit(tmp_path): + write_stories( + tmp_path, + '- id: "1"\n title: t\n description: d\n' + '- id: "2"\n title: t\n description: d\n' + '- id: "3"\n title: t\n description: d\n', + ) + assert [r.id for r in stories.story_rows(tmp_path, selector="2")] == ["2"] + assert [r.id for r in stories.story_rows(tmp_path, selector="nope")] == [] + assert [r.id for r in stories.story_rows(tmp_path, max_stories=2)] == ["1", "2"] + + +def test_story_rows_missing_manifest_raises(tmp_path): + with pytest.raises(stories.StoriesError, match="no stories.yaml found"): + stories.story_rows(tmp_path) diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py new file mode 100644 index 0000000..077b25d --- /dev/null +++ b/tests/test_stories_engine.py @@ -0,0 +1,944 @@ +"""StoriesEngine (folder+id dispatch) scenario + seam tests against the mock +adapter — no tmux, no LLM. Mirrors test_engine.py / test_sweep.py conventions.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from conftest import git, write_spec + +from bmad_loop.adapters.base import SessionResult +from bmad_loop.adapters.mock import MockAdapter +from bmad_loop.journal import Journal, load_state +from bmad_loop.model import ( + PAUSE_ESCALATION, + PAUSE_PLAN_CHECKPOINT, + PAUSE_SPEC_APPROVAL, + PAUSE_STORY_CHECKPOINT, + Phase, + RunState, + StoryTask, + TokenUsage, +) +from bmad_loop.plugins import PluginRegistry +from bmad_loop.plugins.model import LoadedPlugin, PluginManifest, WorkflowSpec +from bmad_loop.policy import ( + GatesPolicy, + NotifyPolicy, + Policy, + ReviewPolicy, + ScmPolicy, +) +from bmad_loop.stories_engine import StoriesEngine +from bmad_loop.verify import read_frontmatter, rev_parse_head, status_of + +QUIET = NotifyPolicy(desktop=False, file=True) +SPEC_FOLDER = "_bmad-output/epic-1" # under output_folder -> excluded from proof-of-work + + +def _stories_policy(**over) -> Policy: + # review disabled keeps the happy path one session per story; gates none. + base = dict( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=False), + scm=ScmPolicy(rollback_on_failure=True), + ) + base.update(over) + return Policy(**base) + + +def setup_stories(paths, entries: list[dict], *, spec_folder: str = SPEC_FOLDER) -> Path: + """Lay down /{SPEC.md, stories.yaml, stories/} and commit it.""" + folder = paths.project / spec_folder + (folder / "stories").mkdir(parents=True, exist_ok=True) + (folder / "SPEC.md").write_text("---\ntitle: Epic 1\n---\n# Epic 1\n", encoding="utf-8") + (folder / "stories.yaml").write_text(yaml.safe_dump(entries, sort_keys=False), encoding="utf-8") + git(paths.project, "add", "-A") + git(paths.project, "commit", "-q", "-m", "stories fixture") + return folder + + +def stories_dev_effect( + *, final_status: str = "done", followup_review: bool = False, prose_status: str | None = None +): + """Simulate a bmad-dev-auto folder+id dispatch: read the story id + spec + folder from the session env (as the real adapter does), write the id-keyed + story spec, and touch real code so proof-of-work passes.""" + + def effect(spec) -> SessionResult: + story_id = spec.env["BMAD_LOOP_STORY_KEY"] + rel = spec.env["BMAD_LOOP_SPEC_FOLDER"] + baseline = rev_parse_head(Path(spec.cwd)) + stories_dir = Path(spec.cwd) / rel / "stories" + stories_dir.mkdir(parents=True, exist_ok=True) + sp = stories_dir / f"{story_id}-slug.md" + src = Path(spec.cwd) / "src.txt" + src.write_text(src.read_text() + f"work for {story_id}\n") + write_spec(sp, final_status, baseline, prose_status=prose_status) + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": story_id, + "spec_file": str(sp), + "baseline_commit": baseline, + "tasks_total": 1, + "tasks_done": 1, + "verification": [], + "escalations": [], + "followup_review_recommended": followup_review, + }, + ) + + return effect + + +def stories_checkpoint_effect(): + """Simulate bmad-dev-auto honoring `Halt after planning.`: on a plan-halt leg + (BMAD_LOOP_PLAN_HALT set by the engine) write the id-keyed spec at + ready-for-dev with NO code change — the plan is just the spec — and mark the + synthesized result `plan_halt`; otherwise implement to done + touch real code + (the plain implement leg). One effect drives both legs of a spec_checkpoint + story across a plan-checkpoint pause/resume.""" + + def effect(spec) -> SessionResult: + story_id = spec.env["BMAD_LOOP_STORY_KEY"] + rel = spec.env["BMAD_LOOP_SPEC_FOLDER"] + baseline = rev_parse_head(Path(spec.cwd)) + stories_dir = Path(spec.cwd) / rel / "stories" + stories_dir.mkdir(parents=True, exist_ok=True) + sp = stories_dir / f"{story_id}-slug.md" + common = { + "workflow": "auto-dev", + "story_key": story_id, + "spec_file": str(sp), + "baseline_commit": baseline, + "escalations": [], + } + if spec.env.get("BMAD_LOOP_PLAN_HALT"): + write_spec(sp, "ready-for-dev", baseline) + return SessionResult( + status="completed", + result_json={**common, "status": "ready-for-dev", "plan_halt": True}, + ) + src = Path(spec.cwd) / "src.txt" + src.write_text(src.read_text() + f"work for {story_id}\n") + write_spec(sp, "done", baseline) + return SessionResult( + status="completed", + result_json={**common, "status": "done", "followup_review_recommended": False}, + ) + + return effect + + +def make_engine(project, script, *, policy=None, spec_folder=SPEC_FOLDER, **kwargs): + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + adapter = MockAdapter(script, usage_per_session=TokenUsage(input_tokens=10, output_tokens=5)) + state = RunState(run_id="test-run", project=str(project.project), started_at="now") + engine = StoriesEngine( + paths=project, + policy=policy or _stories_policy(), + adapter=adapter, + run_dir=run_dir, + journal=Journal(run_dir), + state=state, + spec_folder=spec_folder, + **kwargs, + ) + return engine, adapter + + +def resume_engine(project, engine, script): + """Rebuild a StoriesEngine from persisted state, as cli._resume_paused_run + does — the spec folder is restored from RunState, no flag.""" + state = load_state(engine.run_dir) + state.clear_pause() + adapter = MockAdapter(script) + new_engine = StoriesEngine( + paths=project, + policy=engine.policy, + adapter=adapter, + run_dir=engine.run_dir, + journal=engine.journal, + state=state, + story_filter=state.story_filter, + max_stories=state.max_stories, + spec_folder=state.spec_folder, + ) + return new_engine, adapter + + +def story_spec(paths, story_id: str, *, spec_folder: str = SPEC_FOLDER) -> Path: + return paths.project / spec_folder / "stories" / f"{story_id}-slug.md" + + +def entry(story_id: str, **over) -> dict: + d = {"id": story_id, "title": f"Story {story_id}", "description": "does a thing"} + d.update(over) + return d + + +# ------------------------------------------------------------- happy path + + +def test_two_story_happy_path(project): + setup_stories(project, [entry("1"), entry("2")]) + engine, adapter = make_engine(project, [stories_dev_effect(), stories_dev_effect()]) + summary = engine.run() + + assert summary.done == 2 + assert not summary.paused + # dispatched in strict list order + dev_prompts = [s.prompt for s in adapter.sessions if s.role == "dev"] + assert dev_prompts == [ + "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 1.", + "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 2.", + ] + # both story specs are done on disk, both committed + for sid in ("1", "2"): + assert status_of(read_frontmatter(story_spec(project, sid))) == "done" + assert engine.state.tasks["1"].phase == Phase.DONE + assert engine.state.tasks["2"].phase == Phase.DONE + + +def test_run_state_pins_stories_mode(project): + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, [stories_dev_effect()]) + engine.run() + persisted = load_state(engine.run_dir) + assert persisted.source == "stories" + assert persisted.spec_folder == SPEC_FOLDER + + +def test_session_env_carries_spec_folder(project): + setup_stories(project, [entry("1")]) + engine, adapter = make_engine(project, [stories_dev_effect()]) + engine.run() + dev = next(s for s in adapter.sessions if s.role == "dev") + assert dev.env["BMAD_LOOP_SPEC_FOLDER"] == SPEC_FOLDER + assert dev.env["BMAD_LOOP_STORY_KEY"] == "1" + + +def test_stories_validated_journaled_once(project): + setup_stories(project, [entry("1"), entry("2")]) + engine, _ = make_engine(project, [stories_dev_effect(), stories_dev_effect()]) + engine.run() + validated = [e for e in engine.journal.entries() if e.get("kind") == "stories-validated"] + assert len(validated) == 1 + assert validated[0]["count"] == 2 + + +# ------------------------------------------------------------- scheduling + + +def test_skips_done_on_disk_and_resumes_later(project): + # story 1 already done on disk from a prior run (its spec present + committed); + # a fresh run must skip it and dispatch story 2. + folder = setup_stories(project, [entry("1"), entry("2")]) + sp1 = folder / "stories" / "1-slug.md" + write_spec(sp1, "done", rev_parse_head(project.project)) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "story 1 pre-done") + + engine, adapter = make_engine(project, [stories_dev_effect()]) + summary = engine.run() + assert summary.done == 1 # only story 2 driven this run + dev_prompts = [s.prompt for s in adapter.sessions if s.role == "dev"] + assert dev_prompts == ["/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 2."] + + +def test_blocked_on_disk_pauses_for_resolve(project): + folder = setup_stories(project, [entry("1"), entry("2")]) + write_spec(folder / "stories" / "1-slug.md", "blocked", rev_parse_head(project.project)) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "story 1 blocked") + + engine, adapter = make_engine(project, []) # no session should run + summary = engine.run() + assert summary.paused + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_ESCALATION + assert persisted.paused_story_key == "1" + assert not any(s.role == "dev" for s in adapter.sessions) # never leapfrogged to story 2 + # an ESCALATED task is recorded (spec path attached) so resolve/rearm act on it + wedged = persisted.tasks["1"] + assert wedged.phase == Phase.ESCALATED + assert wedged.spec_file == str(folder / "stories" / "1-slug.md") + + +def test_bare_resume_does_not_leapfrog_a_wedged_story(project): + """MAJOR-A: a wedge (story 1 blocked on disk → ESCALATED task persisted) must + not be leapfrogged by a plain `bmad-loop resume` that never resolved it. The + within-run skip set excludes ESCALATED tasks, so resume re-classifies story 1 + from disk (still blocked) and re-pauses on it — story 2 never dispatches onto a + tree missing story 1's work, honoring the linear no-leapfrog invariant.""" + folder = setup_stories(project, [entry("1"), entry("2")]) + write_spec(folder / "stories" / "1-slug.md", "blocked", rev_parse_head(project.project)) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "story 1 blocked") + + engine, _ = make_engine(project, []) + assert engine.run().paused + assert load_state(engine.run_dir).tasks["1"].phase == Phase.ESCALATED + + # bare resume WITHOUT resolving — sessions are available but none must run + resumed, radapter = resume_engine(project, engine, [stories_dev_effect(), stories_dev_effect()]) + rsummary = resumed.run() + assert rsummary.paused and rsummary.done == 0 + persisted = load_state(resumed.run_dir) + assert persisted.paused_stage == PAUSE_ESCALATION + assert persisted.paused_story_key == "1" # re-paused on the SAME story, not story 2 + assert not any(s.role == "dev" for s in radapter.sessions) # story 2 never dispatched + + +def test_sentinel_on_disk_pauses(project): + folder = setup_stories(project, [entry("1")]) + # a pre-planning halt left a fixed-slug sentinel with status blocked + write_spec(folder / "stories" / "1-unresolved.md", "blocked", rev_parse_head(project.project)) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "sentinel") + engine, _ = make_engine(project, []) + summary = engine.run() + assert summary.paused + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_ESCALATION + # the sentinel path is attached so rearm can preserve + delete it + assert persisted.tasks["1"].spec_file == str(folder / "stories" / "1-unresolved.md") + + +def test_story_selector_filters_to_one_id(project): + setup_stories(project, [entry("1"), entry("2"), entry("3")]) + engine, adapter = make_engine(project, [stories_dev_effect()], story_filter="2") + summary = engine.run() + assert summary.done == 1 + dev_prompts = [s.prompt for s in adapter.sessions if s.role == "dev"] + assert dev_prompts == ["/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 2."] + + +# ----------------------------------------------------------- prompt seams + + +def test_dev_prompt_fresh_dispatch_shape(project): + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert ( + engine._dev_prompt(task, None) + == "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 1." + ) + + +def test_dev_prompt_appends_invoke_dev_with_verbatim(project): + setup_stories(project, [entry("1", invoke_dev_with="Use Redis, not in-process memory.")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + prompt = engine._dev_prompt(task, None) + assert prompt == ( + "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 1.\n" + "Use Redis, not in-process memory." + ) + + +def test_dev_prompt_plan_halt_leg(project, monkeypatch): + # the plan-halt branch is dormant in Phase 2 (returns False); force it on to + # prove the seam emits the pinned `Halt after planning.` phrasing. + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _ = make_engine(project, []) + monkeypatch.setattr(engine, "_plan_halt_leg", lambda task, e: True) + task = StoryTask(story_key="1", epic=0) + assert engine._dev_prompt(task, None) == ( + "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 1. Halt after planning." + ) + + +def test_plan_halt_leg_true_for_fresh_spec_checkpoint(project): + # leg 1: a spec_checkpoint story with no plan yet on disk halts after planning + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert engine._plan_halt_leg(task, engine._entry_for(task)) is True + assert "Halt after planning." in engine._dev_prompt(task, None) + + +def test_plan_halt_leg_false_once_plan_produced(project): + # leg 2: once the plan exists at ready-for-dev, the dispatch is plain implement + folder = setup_stories(project, [entry("1", spec_checkpoint=True)]) + write_spec(folder / "stories" / "1-slug.md", "ready-for-dev", rev_parse_head(project.project)) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert engine._plan_halt_leg(task, engine._entry_for(task)) is False + assert "Halt after planning" not in engine._dev_prompt(task, None) + + +def test_plan_halt_leg_false_without_spec_checkpoint(project): + # a plain story never halts, even with no plan on disk + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert engine._plan_halt_leg(task, engine._entry_for(task)) is False + + +def test_plan_halt_env_only_on_leg_one(project): + # BMAD_LOOP_PLAN_HALT tracks _plan_halt_leg so the prompt + env never disagree + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert engine._extra_session_env(task, "dev")["BMAD_LOOP_PLAN_HALT"] == "1" + # review sessions never carry it + assert "BMAD_LOOP_PLAN_HALT" not in engine._extra_session_env(task, "review") + + +def test_dev_prompt_repair_leg_is_explicit_spec_resume(project, tmp_path): + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + task.spec_file = str(story_spec(project, "1")) + # need the spec file present for _reset_spec_for_repair + story_spec(project, "1").parent.mkdir(parents=True, exist_ok=True) + write_spec(story_spec(project, "1"), "done", "abc") + feedback = tmp_path / "fb.md" + feedback.write_text("boom") + prompt = engine._dev_prompt(task, feedback) + assert prompt.startswith("/bmad-dev-auto Resume the autonomous dev session on the in-progress") + assert "Story id:" not in prompt # repair is an explicit-spec-file invocation + + +# ------------------------------------------------------------- other seams + + +def test_extra_session_env(project): + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert engine._extra_session_env(task, "dev") == {"BMAD_LOOP_SPEC_FOLDER": SPEC_FOLDER} + + +def test_extra_session_env_withheld_from_injected_workflow(project): + # MAJOR-C: a labeled (injected plugin-workflow) session must NOT get the + # story-spec env — only the primary dev/review session does. + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + assert engine._extra_session_env(task, "review", label="tea.gate") == {} + assert engine._extra_session_env(task, "dev", label=None) == { + "BMAD_LOOP_SPEC_FOLDER": SPEC_FOLDER + } + + +def _workflow_capture(captured: list): + def effect(spec) -> SessionResult: + captured.append(spec) + return SessionResult(status="completed", result_json={}) + + return effect + + +def test_injected_workflow_session_does_not_leak_story_spec_env(project): + """MAJOR-C: an injected pre_commit_gate workflow session in stories mode must + not carry BMAD_LOOP_SPEC_FOLDER. Otherwise the generic adapter short-circuits + to story-spec synthesis — at pre_commit_gate the spec is already `done`, so a + gate session that did nothing would read `completed:done` and bypass the + completion-marker + monotonic stall-nudge contract (the TEA-livelock fix). The + primary dev session still gets the env for its id-keyed read-back.""" + setup_stories(project, [entry("1")]) + reg = PluginRegistry( + [ + LoadedPlugin( + manifest=PluginManifest( + name="tea", + api_version=1, + workflows=( + WorkflowSpec( + name="gate", + stage="pre_commit_gate", + role="review", + prompt="/gate {story_key}", + blocking=False, + ), + ), + ) + ) + ] + ) + captured: list = [] + engine, adapter = make_engine( + project, [stories_dev_effect(), _workflow_capture(captured)], registry=reg + ) + summary = engine.run() + assert summary.done == 1 + + # the gate session ran and did NOT get the story-spec short-circuit env + assert len(captured) == 1 + gate = captured[0] + assert "BMAD_LOOP_SPEC_FOLDER" not in gate.env + assert "BMAD_LOOP_PLAN_HALT" not in gate.env + # the primary dev session still carries it for id-keyed read-back + dev = next(s for s in adapter.sessions if s.role == "dev" and "gate" not in s.task_id) + assert dev.env["BMAD_LOOP_SPEC_FOLDER"] == SPEC_FOLDER + + +def test_post_dev_state_sync_is_noop(project): + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + # no sprint board written, no exception + engine._post_dev_state_sync(task, {"spec_file": "whatever"}) + assert not project.sprint_status.exists() + + +@pytest.mark.parametrize( + "given,expected", + [ + ("_bmad-output/epic-1", "_bmad-output/epic-1"), + ("./_bmad-output/epic-1", "_bmad-output/epic-1"), + ], +) +def test_relativize_keeps_relative(project, given, expected): + engine, _ = make_engine(project, [], spec_folder=given) + assert engine._spec_folder_rel == expected + + +def test_relativize_absolute_in_project_becomes_relative(project): + abs_folder = str(project.project / "_bmad-output" / "epic-1") + engine, _ = make_engine(project, [], spec_folder=abs_folder) + assert engine._spec_folder_rel == "_bmad-output/epic-1" + + +# --------------------------------------------------------------- resume + + +def test_resume_rebuilds_stories_engine_from_persisted_state(project): + """A crash mid-story persists source/spec_folder; resume rebuilds a + StoriesEngine from run state (no flag) and drives the recorded dev result to + DONE without re-running the dev session.""" + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, [stories_dev_effect()]) + original_emit = engine._emit + + def crashing_emit(stage, *a, **k): + if stage == "post_session": + raise RuntimeError("host died in the post-session window") + return original_emit(stage, *a, **k) + + engine._emit = crashing_emit + assert engine.run().crashed + + saved = load_state(engine.run_dir) + assert saved.source == "stories" and saved.spec_folder == SPEC_FOLDER + assert saved.tasks["1"].phase == Phase.DEV_RUNNING + + resumed, adapter = resume_engine(project, engine, []) # no new session needed + summary = resumed.run() + assert summary.done == 1 and not summary.crashed + assert load_state(resumed.run_dir).tasks["1"].phase == Phase.DONE + assert not any(s.role == "dev" for s in adapter.sessions) # dev NOT re-run + + +# ----------------------------------------------------- HITL checkpoints (Phase 3) + + +def _kinds(journal, kind): + return [e for e in journal.entries() if e.get("kind") == kind] + + +def test_plan_checkpoint_pause_then_resume_implements(project): + """spec_checkpoint: leg 1 halts after planning (spec at ready-for-dev) and the + run pauses at PAUSE_PLAN_CHECKPOINT; resume dispatches a plain implement leg + that carries no plan-halt directive, drives the story to done, and commits.""" + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, adapter = make_engine(project, [stories_checkpoint_effect()]) + summary = engine.run() + + assert summary.paused and summary.done == 0 + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_PLAN_CHECKPOINT + assert persisted.paused_story_key == "1" + task = persisted.tasks["1"] + assert task.phase == Phase.DEV_VERIFY and task.plan_checkpoint_pending + # leg 1 planned only — spec at ready-for-dev, no code, no commit + assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev" + leg1 = next(s for s in adapter.sessions if s.role == "dev") + assert leg1.prompt.endswith("Halt after planning.") + assert leg1.env["BMAD_LOOP_PLAN_HALT"] == "1" + assert _kinds(engine.journal, "plan-halt") + assert _kinds(engine.journal, "checkpoint-pause")[-1]["checkpoint"] == "plan" + + resumed, radapter = resume_engine(project, engine, [stories_checkpoint_effect()]) + rsummary = resumed.run() + assert rsummary.done == 1 and not rsummary.paused + assert load_state(resumed.run_dir).tasks["1"].phase == Phase.DONE + assert status_of(read_frontmatter(story_spec(project, "1"))) == "done" + # leg 2 is a plain implement dispatch — no halt directive, no plan-halt env + leg2 = next(s for s in radapter.sessions if s.role == "dev") + assert "Halt after planning" not in leg2.prompt + assert "BMAD_LOOP_PLAN_HALT" not in leg2.env + + +# -------- MAJOR-B: a spec_checkpoint story can never commit without a plan review + + +def _write_story_spec_effect(status: str, *, touch_code: bool, result_over: dict | None = None): + """A dev effect that writes the id-keyed story spec at ``status`` (optionally + touching real code) and returns a completed result, with ``result_over`` merged + into result.json. Used to script the three ways a plan review gets bypassed.""" + + def effect(spec) -> SessionResult: + story_id = spec.env["BMAD_LOOP_STORY_KEY"] + rel = spec.env["BMAD_LOOP_SPEC_FOLDER"] + baseline = rev_parse_head(Path(spec.cwd)) + stories_dir = Path(spec.cwd) / rel / "stories" + stories_dir.mkdir(parents=True, exist_ok=True) + sp = stories_dir / f"{story_id}-slug.md" + if touch_code: + src = Path(spec.cwd) / "src.txt" + src.write_text(src.read_text() + f"work for {story_id}\n") + write_spec(sp, status, baseline) + result = { + "workflow": "auto-dev", + "story_key": story_id, + "spec_file": str(sp), + "baseline_commit": baseline, + "escalations": [], + } + result.update(result_over or {}) + return SessionResult(status="completed", result_json=result) + + return effect + + +def test_plan_review_owed_survives_crash_before_durable_record(project): + """MAJOR-B(a): the plan-halt leg wrote the plan (ready-for-dev) but the host + died before the durable session record. plan_review_owed is latched + saved + BEFORE the session runs, so it survives the crash; on resume the on-disk plan + makes the re-drive an implement leg, but the run pauses for the owed plan review + before committing instead of silently implementing past the checkpoint.""" + setup_stories(project, [entry("1", spec_checkpoint=True)]) + + def plan_then_die(spec): + story_id = spec.env["BMAD_LOOP_STORY_KEY"] + rel = spec.env["BMAD_LOOP_SPEC_FOLDER"] + baseline = rev_parse_head(Path(spec.cwd)) + stories_dir = Path(spec.cwd) / rel / "stories" + stories_dir.mkdir(parents=True, exist_ok=True) + write_spec(stories_dir / f"{story_id}-slug.md", "ready-for-dev", baseline) + raise RuntimeError("host died after the plan was written, before the durable record") + + engine, _ = make_engine(project, [plan_then_die]) + assert engine.run().crashed + crashed = load_state(engine.run_dir).tasks["1"] + assert crashed.phase == Phase.DEV_RUNNING and crashed.plan_review_owed + # the plan survives the crash (spec folder is under output_folder, rollback-kept) + assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev" + + # resume: the implement leg runs (session available) but must pause, not commit + resumed, _ = resume_engine(project, engine, [stories_dev_effect()]) + rsummary = resumed.run() + assert rsummary.paused and rsummary.done == 0 + persisted = load_state(resumed.run_dir) + assert persisted.paused_stage == PAUSE_PLAN_CHECKPOINT + task = persisted.tasks["1"] + assert not task.plan_review_owed # discharged at the pause + assert task.commit_sha is None # never committed un-reviewed + + +def test_plan_review_owed_after_non_fixable_retry_becomes_implement_leg(project): + """MAJOR-B(b): leg 1 plans (ready-for-dev) but fails verify non-fixably (wrong + workflow tag), so the tree resets and attempt 2 re-dispatches. The plan survived + (rollback-kept), so attempt 2 is an implement leg — which must still pause for + the owed plan review rather than drive straight to a commit.""" + setup_stories(project, [entry("1", spec_checkpoint=True)]) + # attempt 1: a plan-halt leg that writes the plan but claims the wrong workflow → + # verify_dev_stories retries (non-fixable); attempt 2: a clean implement leg. + attempt1 = _write_story_spec_effect( + "ready-for-dev", touch_code=False, result_over={"workflow": "quick-dev", "plan_halt": True} + ) + attempt2 = _write_story_spec_effect("done", touch_code=True, result_over={"status": "done"}) + engine, adapter = make_engine(project, [attempt1, attempt2]) + summary = engine.run() + + assert summary.paused and summary.done == 0 + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_PLAN_CHECKPOINT + assert not persisted.tasks["1"].plan_review_owed # discharged at the pause + assert persisted.tasks["1"].commit_sha is None # not committed un-reviewed + # attempt 2 really was an implement leg (no halt directive), yet it still paused + assert len([s for s in adapter.sessions if s.role == "dev"]) == 2 + assert "Halt after planning" not in adapter.sessions[-1].prompt + + +def test_plan_review_owed_when_skill_overruns_halt_to_done(project): + """MAJOR-B(c): the skill ignores `Halt after planning.` and drives leg 1 all the + way to done (result carries no plan_halt marker). The obligation latched at + dispatch forces a pause before commit, so the story cannot commit without the + human ever reviewing the plan.""" + setup_stories(project, [entry("1", spec_checkpoint=True)]) + overrun = _write_story_spec_effect("done", touch_code=True, result_over={"status": "done"}) + engine, _ = make_engine(project, [overrun]) + summary = engine.run() + + assert summary.paused and summary.done == 0 + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_PLAN_CHECKPOINT + task = persisted.tasks["1"] + assert not task.plan_review_owed # discharged at the pause + assert task.commit_sha is None # not committed un-reviewed + # the pause is the distinct "owed after implement" variant, not the clean leg-1 halt + owed = [e for e in engine.journal.entries() if e.get("owed_after_implement")] + assert owed and owed[-1]["story_key"] == "1" + + +def test_story_checkpoint_pause_after_commit(project): + """done_checkpoint: the story commits, then the run pauses at + PAUSE_STORY_CHECKPOINT because another story still remains to dispatch.""" + setup_stories(project, [entry("1", done_checkpoint=True), entry("2")]) + engine, _ = make_engine(project, [stories_dev_effect()]) + summary = engine.run() + + assert summary.paused and summary.done == 1 + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_STORY_CHECKPOINT + assert persisted.paused_story_key == "1" + assert persisted.tasks["1"].phase == Phase.DONE # committed before the pause + assert "2" not in persisted.tasks # story 2 not started yet + pause = _kinds(engine.journal, "checkpoint-pause") + assert pause and pause[-1]["checkpoint"] == "story" + + # resume drives story 2 to done (summary.done is cumulative over run state) + resumed, _ = resume_engine(project, engine, [stories_dev_effect()]) + assert not resumed.run().paused + assert load_state(resumed.run_dir).tasks["2"].phase == Phase.DONE + + +def test_story_checkpoint_skipped_when_last(project): + """done_checkpoint on the final story does NOT pause — the run ends anyway, so + there is nothing to come back to review before.""" + setup_stories(project, [entry("1"), entry("2", done_checkpoint=True)]) + engine, _ = make_engine(project, [stories_dev_effect(), stories_dev_effect()]) + summary = engine.run() + + assert summary.done == 2 and not summary.paused + assert not load_state(engine.run_dir).paused + assert _kinds(engine.journal, "checkpoint-skip-last") + assert not _kinds(engine.journal, "checkpoint-pause") + + +def test_both_checkpoints_pause_twice(project): + """A story with BOTH flags pauses at the plan checkpoint, then (after the + resumed implement leg commits) again at the story checkpoint — two pauses for + one story, because there is a later story still to dispatch.""" + setup_stories(project, [entry("1", spec_checkpoint=True, done_checkpoint=True), entry("2")]) + engine, _ = make_engine(project, [stories_checkpoint_effect()]) + # pause 1: plan checkpoint + assert engine.run().paused + assert load_state(engine.run_dir).paused_stage == PAUSE_PLAN_CHECKPOINT + + # pause 2: story checkpoint, after leg 2 implements + commits on resume + resumed, _ = resume_engine(project, engine, [stories_checkpoint_effect()]) + s2 = resumed.run() + assert s2.paused and s2.done == 1 + persisted = load_state(resumed.run_dir) + assert persisted.paused_stage == PAUSE_STORY_CHECKPOINT + assert persisted.tasks["1"].phase == Phase.DONE + assert status_of(read_frontmatter(story_spec(project, "1"))) == "done" + + # final resume drives story 2, no more pauses + resumed2, _ = resume_engine(project, resumed, [stories_dev_effect()]) + assert not resumed2.run().paused + assert load_state(resumed2.run_dir).tasks["2"].phase == Phase.DONE + + +# ------------------------------------ blocked → resolve → re-dispatch (E2E) + + +def test_blocked_resolve_rearm_then_redispatch_to_done(project): + """Scenario 4: a blocked story stops the run; re-arm (as `resolve + --no-interactive` does) flips it back to ready-for-dev + strips the stale + Auto Run Result, and the resumed run re-dispatches it through to done — the + end-to-end path the pause-only tests above leave un-stitched.""" + from bmad_loop import runs + + folder = setup_stories(project, [entry("1"), entry("2")]) + write_spec(folder / "stories" / "1-slug.md", "blocked", rev_parse_head(project.project)) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "story 1 blocked") + + engine, adapter = make_engine(project, []) + assert engine.run().paused + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_ESCALATION and persisted.paused_story_key == "1" + assert not any(s.role == "dev" for s in adapter.sessions) # story 2 not leapfrogged + + # human fixed the frozen spec → re-arm (must run while still escalation-paused) + runs.rearm_escalation(engine.run_dir, "1") + assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev" + + # resume re-drives the re-armed story, then continues the schedule to story 2 + resumed, radapter = resume_engine(project, engine, [stories_dev_effect(), stories_dev_effect()]) + rsummary = resumed.run() + assert rsummary.done == 2 and not rsummary.paused + assert status_of(read_frontmatter(story_spec(project, "1"))) == "done" + assert status_of(read_frontmatter(story_spec(project, "2"))) == "done" + dev_prompts = [s.prompt for s in radapter.sessions if s.role == "dev"] + assert dev_prompts == [ + "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 1.", + "/bmad-dev-auto Spec folder: _bmad-output/epic-1. Story id: 2.", + ] + + +# ----------------------------------------------- worktree isolation (E2E) + + +def test_worktree_isolation_two_stories(project): + """Scenario 5: StoriesEngine inherits worktree isolation unchanged — each + story runs in its own worktree (spec.cwd) and merges back to the target + branch, leaving the main checkout clean with both story specs done.""" + from bmad_loop.verify import worktree_clean + + setup_stories(project, [entry("1"), entry("2")]) + engine, adapter = make_engine( + project, + [stories_dev_effect(), stories_dev_effect()], + policy=_stories_policy(scm=ScmPolicy(isolation="worktree")), + ) + summary = engine.run() + + assert summary.done == 2 and not summary.paused + assert worktree_clean(project.project) # unit worktrees merged back + torn down + # sessions ran inside a worktree checkout, not the project root + dev = [s for s in adapter.sessions if s.role == "dev"] + assert dev and all(Path(s.cwd) != project.project for s in dev) + assert status_of(read_frontmatter(story_spec(project, "1"))) == "done" + assert status_of(read_frontmatter(story_spec(project, "2"))) == "done" + + +# ---------------------- item 10: MINOR/NOTE batch (Session 3) ---------------- + + +def test_gate_and_spec_checkpoint_pause_additively(project): + """MINOR-4: a spec_checkpoint story under gates.mode=per-story-spec-approval + pauses TWICE — first at the plan checkpoint (before code), then, after the + resumed implement leg, at the run-global spec-approval gate. The per-story + checkpoint does not substitute for the run-global gate; they stack.""" + setup_stories(project, [entry("1", spec_checkpoint=True), entry("2")]) + pol = _stories_policy(gates=GatesPolicy(mode="per-story-spec-approval")) + engine, _ = make_engine(project, [stories_checkpoint_effect()], policy=pol) + + # pause 1: plan checkpoint (leg 1 halted after planning, no code yet) + assert engine.run().paused + assert load_state(engine.run_dir).paused_stage == PAUSE_PLAN_CHECKPOINT + + # pause 2: the run-global spec-approval gate, after the implement leg + resumed, _ = resume_engine(project, engine, [stories_checkpoint_effect()]) + assert resumed.run().paused + assert load_state(resumed.run_dir).paused_stage == PAUSE_SPEC_APPROVAL + assert load_state(resumed.run_dir).tasks["1"].phase != Phase.DONE # not committed yet + + # approve the spec gate → story 1 commits (story 2 then pauses at its own gate) + resumed2, _ = resume_engine(project, resumed, [stories_dev_effect()]) + resumed2.run() + assert load_state(resumed2.run_dir).tasks["1"].phase == Phase.DONE + assert status_of(read_frontmatter(story_spec(project, "1"))) == "done" + + +def test_unknown_story_selector_pauses_not_crashes(project): + """MINOR-E: a --story id absent from the manifest pauses for resolve (fix the + id/manifest, resume) instead of crashing the run in the scheduler.""" + setup_stories(project, [entry("1"), entry("2")]) + engine, adapter = make_engine(project, [], story_filter="99") + summary = engine.run() + + assert summary.paused + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_ESCALATION + assert persisted.paused_story_key == "99" + assert persisted.tasks["99"].phase == Phase.ESCALATED + assert not any(s.role == "dev" for s in adapter.sessions) # nothing dispatched + assert _kinds(engine.journal, "stories-selector-unknown") + + +def test_done_checkpoint_skipped_at_max_stories(project): + """MINOR-F: with --max-stories=1 a done_checkpoint on the only dispatched story + is SKIPPED (the bound ends the run here) — otherwise the pause+resume would + reset the loop counter and leapfrog story 2 past the cap.""" + setup_stories(project, [entry("1", done_checkpoint=True), entry("2")]) + engine, _ = make_engine(project, [stories_dev_effect()], max_stories=1) + summary = engine.run() + + assert summary.done == 1 and not summary.paused + assert _kinds(engine.journal, "checkpoint-skip-last") + assert not _kinds(engine.journal, "checkpoint-pause") + assert "2" not in load_state(engine.run_dir).tasks # capped, story 2 never dispatched + + +def test_sentinel_detected_journaled_at_pick(project): + """MINOR-6: a fixed-slug sentinel found by the pick-time read-back journals a + distinct sentinel-detected event carrying its recorded blocking condition, not + only the later stories-wedged / escalation trace.""" + folder = setup_stories(project, [entry("1")]) + (folder / "stories" / "1-unresolved.md").write_text( + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", + encoding="utf-8", + ) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "sentinel") + engine, _ = make_engine(project, []) + assert engine.run().paused + + detected = _kinds(engine.journal, "sentinel-detected") + assert detected and detected[-1]["story_key"] == "1" + assert detected[-1]["sentinel_kind"] == "unresolved" + assert "intent too vague" in detected[-1]["condition"] + + +def test_sentinel_detected_journaled_at_readback(project): + """MINOR-6: the just-run dev session HALTs pre-planning and writes a sentinel; + the post-dev read-back journals sentinel-detected before the escalation.""" + setup_stories(project, [entry("1")]) + + def sentinel_effect(spec) -> SessionResult: + story_id = spec.env["BMAD_LOOP_STORY_KEY"] + rel = spec.env["BMAD_LOOP_SPEC_FOLDER"] + stories_dir = Path(spec.cwd) / rel / "stories" + stories_dir.mkdir(parents=True, exist_ok=True) + (stories_dir / f"{story_id}-unresolved.md").write_text( + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\ntoo vague\n", + encoding="utf-8", + ) + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": story_id, + "status": "blocked", + "escalations": [ + {"type": "spec-gap", "severity": "CRITICAL", "detail": "too vague"} + ], + }, + ) + + engine, _ = make_engine(project, [sentinel_effect]) + engine.run() + + detected = _kinds(engine.journal, "sentinel-detected") + assert detected and detected[-1]["sentinel_kind"] == "unresolved" + assert "too vague" in detected[-1]["condition"] + + +def test_entry_for_unreadable_manifest_journals_warning_once(project): + """NOTE-10: _entry_for swallows a hand-broken manifest (bare dispatch still + runs) but now leaves a one-time stories-manifest-unreadable trace per story.""" + setup_stories(project, [entry("1")]) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1", epic=0) + (project.project / SPEC_FOLDER / "stories.yaml").write_text("[unclosed", encoding="utf-8") + + assert engine._entry_for(task) is None + warned = _kinds(engine.journal, "stories-manifest-unreadable") + assert warned and warned[-1]["story_key"] == "1" + # a second call for the same story does not re-journal (dedup per story key) + assert engine._entry_for(task) is None + assert len(_kinds(engine.journal, "stories-manifest-unreadable")) == 1 diff --git a/tests/test_verify.py b/tests/test_verify.py index fa89198..0b51f73 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -472,6 +472,100 @@ def test_verify_dev_stories_no_changes(project): assert not out.ok and "no changes" in out.reason +def test_verify_dev_stories_ledger_only_counts_as_real_work(project): + """T3 regression: a stories-mode story whose entire authorized diff is + ledger/spec reconciliation under implementation_artifacts (e.g. deferred-work.md) + must pass proof-of-work, not false-negative "no changes". Guards the file-granular + exclude port off #79 — the old whole-folder `artifact_relpaths` exclusion + swallowed the ledger, re-introducing KNOWN-BUG-ledger-only-story-false-no- + changes.md in stories mode (verify_dev_exclude_relpaths excludes only the + session's own spec + sprint-status, so sibling ledger content counts).""" + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + sp = write_story(spec_folder, "1", "x", "done", task.baseline_commit) + # The ONLY real change since baseline is the ledger under implementation_artifacts; + # the story's own spec (under the spec folder's stories/) is excluded either way. + project.deferred_work.parent.mkdir(parents=True, exist_ok=True) + project.deferred_work.write_text("### DW-1: reconciled\n\nstatus: done\n") + out = verify.verify_dev_stories( + task, project, dev_result(sp), spec_folder=spec_folder, review_enabled=False + ) + assert out.ok + + +def test_verify_dev_stories_spec_only_change_outside_artifacts_is_not_work(project): + # spec folder OUTSIDE the artifact dirs: the story record + stories.yaml must + # still not count as implementation work (the _stories_relpaths exclusion), + # so a story that only wrote its spec fails proof-of-work. + spec_folder = project.project / "docs" / "epic-a" + task = make_stories_task(project, "1") + sp = write_story(spec_folder, "1", "x", "done", task.baseline_commit) + (spec_folder / "stories.yaml").write_text("- id: '1'\n title: t\n description: d\n") + out = verify.verify_dev_stories( + task, project, dev_result(sp), spec_folder=spec_folder, review_enabled=False + ) + assert not out.ok and "no changes" in out.reason + # real code alongside the spec -> proof-of-work passes + (project.project / "src.txt").write_text("real work\n") + out2 = verify.verify_dev_stories( + task, project, dev_result(sp), spec_folder=spec_folder, review_enabled=False + ) + assert out2.ok + + +def test_verify_dev_stories_plan_halt_expects_ready_for_dev(project): + # plan-halt leg: the spec is at ready-for-dev (the plan), not done, and there + # is NO code change — proof-of-work is skipped and the plan spec is recorded. + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + sp = write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + out = verify.verify_dev_stories( + task, + project, + {"workflow": "auto-dev", "plan_halt": True}, + spec_folder=spec_folder, + review_enabled=False, + plan_halt=True, + ) + assert out.ok # no code change required for a plan + assert task.spec_file == str(sp) + + +def test_verify_dev_stories_plan_halt_rejects_non_plan_status(project): + # a plan-halt leg that did not reach ready-for-dev (still draft) is a retry + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + write_story(spec_folder, "1", "x", "draft", task.baseline_commit) + out = verify.verify_dev_stories( + task, + project, + {"workflow": "auto-dev", "plan_halt": True}, + spec_folder=spec_folder, + review_enabled=False, + plan_halt=True, + ) + assert not out.ok and "expected 'ready-for-dev'" in out.reason + + +def test_verify_review_stories_no_sprint_gate(project): + # verify_review_stories checks spec == done + verify commands, no sprint gate. + assert not project.sprint_status.is_file() + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + sp = write_story(spec_folder, "1", "x", "done", task.baseline_commit) + task.spec_file = str(sp) + assert verify.verify_review_stories(task, project, Policy()).ok + + +def test_verify_review_stories_non_done_retries(project): + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + sp = write_story(spec_folder, "1", "x", "in-review", task.baseline_commit) + task.spec_file = str(sp) + out = verify.verify_review_stories(task, project, Policy()) + assert not out.ok and "expected 'done'" in out.reason + + def test_verify_review_bundle_ledger_gate(project): task = make_bundle_task(project) sp = project.implementation_artifacts / "spec-dw-test-bundle.md"