Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter
from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy
from ..core.agent_config import normalize_model as _model_or_none
from ..core.sprint import sprint_status_done_in_text
from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json


Expand Down Expand Up @@ -192,11 +193,8 @@ def cmd_sprint_compare(args: list[str]) -> int:
if isinstance(current_story, str) and current_story in story_range:
before = story_range[: story_range.index(current_story)]
sprint_text = read_text(sprint)
incomplete = []
for story_id in before:
match = re.search(rf"(?m)^\s*{re.escape(story_id)}:\s*(\S+)", sprint_text)
if not match or match.group(1) != "done":
incomplete.append(story_id)
project_root = get_project_root()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

cmd_sprint_compare accepts explicit --state and --sprint paths, but this now feeds the ambient project root into the text-backed resolver. When that root is absent or points at a different project, a child row like release-3-phase-2-1-title: done can satisfy predecessor release.3, so the predecessor gate reports no incomplete story.

Repro:

tmp=$(mktemp -d)
cat > "$tmp/orchestration.md" <<'EOF'
---
storyRange: ["release.3", "release-3-phase-2.1"]
currentStory: release-3-phase-2.1
---
EOF
cat > "$tmp/sprint-status.yaml" <<'EOF'
development_status:
  release-3-phase-2-1-title: done
EOF
env -u PROJECT_ROOT PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=skills/bmad-story-automator/src python3 -m story_automator sprint-compare --state "$tmp/orchestration.md" --sprint "$tmp/sprint-status.yaml"

Output:

{"ok":true,"incomplete":[],"checked":["release.3"]}

Suggested fix: make the text-backed lookup disambiguate from the supplied sprint content, or derive/reject a project root tied to the explicit sprint path, so unmatched predecessors fail closed.

incomplete = [sid for sid in before if not sprint_status_done_in_text(sprint_text, sid, project_root)]
write_json({"ok": True, "incomplete": incomplete, "checked": before})
return 0

Expand Down
19 changes: 14 additions & 5 deletions skills/bmad-story-automator/src/story_automator/core/sprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ class SprintStatus:
reason: str = ""


def sprint_status_get(project_root: str, story_key: str) -> SprintStatus:
status_file = sprint_status_file(project_root)
if not file_exists(status_file):
return SprintStatus(False, story_key, "unknown", False, "sprint-status.yaml not found")
content = read_text(status_file)
def _status_from_content(project_root: str, content: str, story_key: str) -> SprintStatus:
norm = normalize_story_key(project_root, story_key)
if norm is not None:
result = _best_status_match(project_root, content, story_key, norm)
Expand All @@ -33,6 +29,19 @@ def sprint_status_get(project_root: str, story_key: str) -> SprintStatus:
return SprintStatus(False, story_key, "not_found", False)


def sprint_status_get(project_root: str, story_key: str) -> SprintStatus:
status_file = sprint_status_file(project_root)
if not file_exists(status_file):
return SprintStatus(False, story_key, "unknown", False, "sprint-status.yaml not found")
return _status_from_content(project_root, read_text(status_file), story_key)


def sprint_status_done_in_text(content: str, story_id: str, project_root: str = "") -> bool:
# Text-based variant of sprint_status_get for callers that already hold the
# sprint-status content and an explicit path (e.g. sprint-compare).
return _status_from_content(project_root, content, story_id).done


def sprint_status_epic(project_root: str, epic: str) -> tuple[list[str], int]:
status_file = sprint_status_file(project_root)
if not file_exists(status_file):
Expand Down
124 changes: 124 additions & 0 deletions tests/test_sprint_compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

import io
import json
import tempfile
import textwrap
import unittest
from contextlib import redirect_stdout
from pathlib import Path

from story_automator.commands.state import cmd_sprint_compare
from story_automator.core.sprint import sprint_status_done_in_text


SPRINT_STATUS = textwrap.dedent(
"""\
development_status:
epic-1: in-progress
1-1-host-feasibility-probe: done
1-2-docker-dev-test-environment: done
1-3-database-wrapper-migrations: done
1-4-redis-wrapper-clock: done
2-1-users-schema-permanent-admin: in-progress
"""
)


class SprintStatusDoneInTextTests(unittest.TestCase):
"""`sprint_status_done_in_text` must resolve dotted IDs against descriptive
slug keys -- the BMAD default `sprint-status.yaml` format."""

def test_dotted_id_resolves_descriptive_slug(self) -> None:
self.assertTrue(sprint_status_done_in_text(SPRINT_STATUS, "1.1"))

def test_dashed_prefix_resolves_descriptive_slug(self) -> None:
self.assertTrue(sprint_status_done_in_text(SPRINT_STATUS, "1-1"))

def test_full_slug_key_exact_match(self) -> None:
self.assertTrue(sprint_status_done_in_text(SPRINT_STATUS, "1-1-host-feasibility-probe"))

def test_not_done_is_false(self) -> None:
self.assertFalse(sprint_status_done_in_text(SPRINT_STATUS, "2.1"))

def test_missing_story_is_false(self) -> None:
self.assertFalse(sprint_status_done_in_text(SPRINT_STATUS, "9.9"))

def test_descriptive_slug_wins_over_bare_prefix_regardless_of_order(self) -> None:
# When both a bare prefix key and a descriptive slug key exist for the
# same story, the descriptive slug must win deterministically (matching
# `_best_status_match` / `sprint_status_get`), not whichever comes first.
prefix_first = textwrap.dedent(
"""\
development_status:
1-1: in-progress
1-1-host-feasibility-probe: done
"""
)
slug_first = textwrap.dedent(
"""\
development_status:
1-1-host-feasibility-probe: done
1-1: in-progress
"""
)
self.assertTrue(sprint_status_done_in_text(prefix_first, "1.1"))
self.assertTrue(sprint_status_done_in_text(slug_first, "1.1"))


class SprintCompareCommandTests(unittest.TestCase):
"""End-to-end regression for the false-positive `incomplete` bug: dotted
`storyRange` vs descriptive-slug `sprint-status.yaml` keys."""

def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
self.sprint = root / "sprint-status.yaml"
self.sprint.write_text(SPRINT_STATUS)
self.state = root / "orchestration.md"
self.state.write_text(
textwrap.dedent(
"""\
---
storyRange: ["1.1", "1.2", "1.3", "1.4", "2.1"]
currentStory: 2.1
---
# Orchestration
"""
)
)

def tearDown(self) -> None:
self.tmp.cleanup()

def _run(self) -> dict:
buf = io.StringIO()
with redirect_stdout(buf):
rc = cmd_sprint_compare(["--state", str(self.state), "--sprint", str(self.sprint)])
self.assertEqual(rc, 0)
return json.loads(buf.getvalue())

def test_all_earlier_done_reports_nothing_incomplete(self) -> None:
result = self._run()
self.assertEqual(result["checked"], ["1.1", "1.2", "1.3", "1.4"])
self.assertEqual(result["incomplete"], [])

def test_flags_genuinely_incomplete_story(self) -> None:
# 2.1 is in-progress; if it were an earlier story it must be flagged.
self.state.write_text(
textwrap.dedent(
"""\
---
storyRange: ["1.1", "2.1", "1.2"]
currentStory: 1.2
---
"""
)
)
result = self._run()
self.assertEqual(result["checked"], ["1.1", "2.1"])
self.assertEqual(result["incomplete"], ["2.1"])


if __name__ == "__main__":
unittest.main()