Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.

Commit 9c85590

Browse files
committed
fix: address PR review feedback
1 parent c8a02e4 commit 9c85590

12 files changed

Lines changed: 287 additions & 41 deletions

scripts/run-smoke-automator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ def __init__(self, *, root: Path, workspace: Path, project: Path, story_id: str)
8686
self.env = deterministic_smoke_env(project)
8787
self.helper = project / HELPER
8888

89+
def close(self) -> None:
90+
return None
91+
92+
8993
def run(self, *, reset_artifacts: bool) -> dict[str, Any]:
9094
self._assert_prepared()
9195
if reset_artifacts:

scripts/run-smoke-dev-loop.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ def __init__(self, *, root: Path, workspace: Path, project: Path, story_ids: lis
8686
self.env = deterministic_smoke_env(project)
8787
self.helper = project / HELPER
8888

89+
def close(self) -> None:
90+
return None
91+
92+
8993
def run(self, *, reset_artifacts: bool) -> dict[str, Any]:
9094
self._assert_prepared()
9195
if reset_artifacts:

scripts/run-smoke-finish-loop.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,19 @@ def _persist_diagnostics(self, state: Path, commit_repo: Path) -> dict[str, obje
214214
state_dest.write_text(state.read_text(encoding="utf-8"), encoding="utf-8")
215215
log = self._git("log", "--oneline", "-5", cwd=commit_repo).stdout
216216
(dest / "git-log.txt").write_text(log, encoding="utf-8")
217+
learnings = self.output / "learnings.md"
218+
persisted_learnings = None
219+
if learnings.exists():
220+
persisted_learnings = dest / learnings.name
221+
persisted_learnings.write_text(learnings.read_text(encoding="utf-8"), encoding="utf-8")
222+
if "wrapup" in self.results:
223+
self.results["wrapup"]["learnings"] = str(persisted_learnings)
217224
return {
218225
"folder": str(dest),
219226
"stateFile": str(state_dest),
220227
"gitLog": str(dest / "git-log.txt"),
221228
"gitLogRepo": self._repo_descriptor(commit_repo),
229+
**({"learnings": str(persisted_learnings)} if persisted_learnings else {}),
222230
}
223231

224232
def _ephemeral_project_descriptor(self) -> dict[str, object]:
@@ -322,7 +330,13 @@ def _call(self, fn, args: list[str]) -> tuple[int, str]:
322330

323331
def _json(self, code: int, raw: str) -> dict[str, object]:
324332
self._expect(code in {0, 1}, f"unexpected exit {code}: {raw}")
325-
return json.loads(raw)
333+
try:
334+
payload = json.loads(raw)
335+
except json.JSONDecodeError as exc:
336+
raise FinishSmokeError(f"helper returned invalid JSON: {raw}") from exc
337+
if not isinstance(payload, dict):
338+
raise FinishSmokeError(f"helper returned non-object JSON: {raw}")
339+
return payload
326340

327341
def _expect(self, condition: bool, message: str) -> None:
328342
if not condition:

skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def check_blocking_action(args: list[str]) -> int:
9898
if norm is None:
9999
print_json({"ok": False, "error": "could_not_normalize_key", "input": args[0]})
100100
return 1
101-
epic = norm.id.split(".", 1)[0]
101+
epic = norm.id.rsplit(".", 1)[0]
102102
epic_file = find_epic_file(epic)
103103
if not epic_file:
104104
print_json({"ok": True, "blocking": True, "story": norm.id, "epic": epic, "dependents": [], "reason": "epic_file_not_found", "source": "unknown"})
@@ -310,7 +310,7 @@ def _story_key_rank(story: str, norm: StoryKey | None) -> int:
310310

311311
def _line_references_story(project_root: str, epic: str, target: StoryKey, requested_story: str, line: str) -> bool:
312312
requested_full_key = _is_explicit_full_key(requested_story, target)
313-
for match in re.finditer(r"\b(?:\d+\.\d+|\d+-\d+(?:-[\w]+)*|[A-Za-z][\w-]*(?:\.\d+|-\d+(?:-[\w]+)*))\b", line):
313+
for match in re.finditer(r"\b(?:\d+(?:\.\d+)+|\d+-\d+(?:-[\w]+)*|[A-Za-z][\w-]*(?:\.\d+|-\d+(?:-[\w]+)*))\b", line):
314314
token = match.group(0)
315315
norm = normalize_story_key_for_epic(project_root, epic, token)
316316
if norm is not None and norm.id == target.id:

skills/bmad-story-automator/src/story_automator/core/epic_parser.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from .story_keys import normalize_story_key, normalize_story_key_for_epic
1010

1111

12-
STORY_HEADER_RE = re.compile(r"^###\s+(?:(?:Story\s+)?(\d+\.\d+)|Story\s+([^:]+)):\s*(.*)$", re.IGNORECASE)
13-
EPIC_HEADER_RE = re.compile(r"^##\s+Epic\s+([A-Za-z][\w-]*|\d+):\s*(.*)$", re.IGNORECASE)
12+
STORY_HEADER_RE = re.compile(r"^###\s+(?:(?:Story\s+)?(\d+(?:\.\d+)+)|Story\s+([^:]+)):\s*(.*)$", re.IGNORECASE)
13+
EPIC_HEADER_RE = re.compile(r"^##\s+Epic\s+([A-Za-z][\w-]*|\d+(?:\.\d+)*):\s*(.*)$", re.IGNORECASE)
1414

1515

1616
def parse_epic_file(epic_file: str | Path) -> dict[str, Any]:
@@ -74,7 +74,11 @@ def parse_story(epic_file: str | Path, story_id: str, rules_file: str | Path) ->
7474
story_key = _normalize_header_story(project_root, current_epic, raw_story.strip())
7575
if story_key is None:
7676
continue
77-
if target_id not in {raw_story.strip(), story_key.id, story_key.prefix, story_key.key}:
77+
target_aliases = {raw_story.strip(), story_key.id, story_key.prefix, story_key.key}
78+
title_slug = _slugify_title(raw_title)
79+
if title_slug and not _is_explicit_header_story(raw_story.strip(), story_key.id):
80+
target_aliases.add(f"{story_key.prefix}-{title_slug}")
81+
if target_id not in target_aliases:
7882
continue
7983
start_index = index
8084
target_id = story_key.id
@@ -170,7 +174,7 @@ def _normalize_header_story(project_root: str, current_epic: str, raw_story: str
170174
story_key = normalize_story_key_for_epic(project_root, current_epic, raw_story)
171175
if story_key is None:
172176
return None
173-
if story_key.id.rsplit(".", 1)[0] != current_epic:
177+
if story_key.id != current_epic and not story_key.id.startswith(f"{current_epic}."):
174178
return None
175179
return story_key
176180

@@ -202,6 +206,8 @@ def parse_story_range(user_input: str, total: int, ids_csv: str = "") -> dict[st
202206
end = id_index[end_raw]
203207
low, high = sorted((start, end))
204208
selected.update(range(low, high + 1))
209+
else:
210+
raise ValueError(f"invalid_story_range:{part}")
205211
elif part.isdigit():
206212
selected.add(int(part))
207213
indices = sorted(index for index in selected if 1 <= index <= total)
@@ -238,8 +244,7 @@ def _story_aliases(story: dict[str, str]) -> set[str]:
238244
if story_key:
239245
aliases.add(story_key)
240246
if not _is_explicit_header_story(header_story, story_id):
241-
epic, _, story_num = story_id.rpartition(".")
242-
prefix = f"{epic}-{story_num}"
247+
prefix = story_id.replace(".", "-")
243248
aliases.add(prefix)
244249
title_slug = _slugify_title(story.get("title", ""))
245250
if title_slug:
@@ -250,16 +255,15 @@ def _story_aliases(story: dict[str, str]) -> set[str]:
250255
def _is_explicit_header_story(header_story: str, story_id: str) -> bool:
251256
if not header_story:
252257
return False
253-
epic, _, story_num = story_id.rpartition(".")
254-
return header_story not in {story_id, f"{epic}-{story_num}"}
258+
return header_story not in {story_id, story_id.replace(".", "-")}
255259

256260

257261
def _slugify_title(title: str) -> str:
258262
return "-".join(part for part in re.split(r"[^A-Za-z0-9]+", title.lower()) if part)
259263

260264

261-
def _story_sort_key(value: str) -> tuple[int, int, str, int, str]:
265+
def _story_sort_key(value: str) -> tuple[int, tuple[int, ...], str, int, str]:
262266
epic, _, story_num = value.rpartition(".")
263-
if epic.isdigit():
264-
return (0, int(epic), "", int(story_num) if story_num.isdigit() else 0, value)
265-
return (1, 0, epic, int(story_num) if story_num.isdigit() else 0, value)
267+
if all(part.isdigit() for part in value.split(".")):
268+
return (0, tuple(int(part) for part in value.split(".")), "", 0, value)
269+
return (1, (), epic, int(story_num) if story_num.isdigit() else 0, value)

skills/bmad-story-automator/src/story_automator/core/story_keys.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@ def sprint_status_file(project_root: str) -> str:
2020

2121

2222
def normalize_story_key(project_root: str, value: str) -> StoryKey | None:
23-
if re.fullmatch(r"\d+\.\d+", value):
23+
if re.fullmatch(r"\d+(?:\.\d+)+", value):
2424
story_id = value
2525
prefix = value.replace(".", "-")
2626
key = ""
27-
elif re.fullmatch(r"\d+-\d+", value):
27+
elif re.fullmatch(r"\d+(?:-\d+)+", value):
2828
prefix = value
2929
story_id = value.replace("-", ".")
3030
key = ""
31-
elif re.fullmatch(r"\d+-\d+-.+", value):
31+
elif re.fullmatch(r"\d+(?:-\d+)+-.+", value):
3232
key = value
33-
prefix = "-".join(value.split("-", 2)[:2])
33+
prefix = _numeric_full_key_prefix(value)
3434
story_id = prefix.replace("-", ".")
3535
elif re.fullmatch(r"[A-Za-z][\w-]*\.\d+", value):
3636
story_id = value
@@ -63,6 +63,11 @@ def normalize_story_key_for_epic(project_root: str, epic: str, value: str) -> St
6363
return None
6464
return norm
6565

66+
nested_numeric = _nested_numeric_story_for_epic(epic, value)
67+
if nested_numeric is not None:
68+
story_id, prefix, key = nested_numeric
69+
return _complete_story_key(project_root, story_id, prefix, key)
70+
6671
dotted = re.fullmatch(rf"{re.escape(epic)}\.(\d+)", value)
6772
if dotted:
6873
story_num = dotted.group(1)
@@ -139,6 +144,36 @@ def _status_keys(content: str) -> list[str]:
139144
return keys
140145

141146

147+
def _numeric_full_key_prefix(value: str) -> str:
148+
parts = value.split("-")
149+
if len(parts) >= 4 and all(part.isdigit() for part in parts[:3]):
150+
nested_prefix = "-".join(parts[:3])
151+
if not _numeric_title_segment(parts):
152+
return nested_prefix
153+
return "-".join(parts[:2])
154+
155+
156+
def _nested_numeric_story_for_epic(epic: str, value: str) -> tuple[str, str, str] | None:
157+
if not all(part.isdigit() for part in epic.split(".")):
158+
return None
159+
epic_prefix = epic.replace(".", "-")
160+
match = re.fullmatch(rf"{re.escape(epic_prefix)}-(\d+)(?:-.+)?", value)
161+
if not match:
162+
return None
163+
parts = value.split("-")
164+
if len(parts) >= 4 and _numeric_title_segment(parts):
165+
return None
166+
prefix = f"{epic_prefix}-{match.group(1)}"
167+
key = value if value != prefix else ""
168+
return f"{epic}.{match.group(1)}", prefix, key
169+
170+
171+
def _numeric_title_segment(parts: list[str]) -> bool:
172+
if len(parts[2]) == 4:
173+
return True
174+
return len(parts[2]) == 2 and len(parts) >= 4 and parts[3] in {"release"}
175+
176+
142177
def _full_key_matches_story(project_root: str, key: str, story_id: str, *, allow_ambiguous_same_id: bool) -> bool:
143178
norm = normalize_story_key(project_root, key)
144179
if norm is not None:

skills/bmad-story-automator/src/story_automator/core/success_verifiers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ def _epic_identifier(project_root: str, story_key: str) -> str:
289289
return story_key
290290
norm = normalize_story_key(project_root, story_key)
291291
if norm is not None:
292-
return norm.id.split(".", 1)[0]
292+
return norm.id.rsplit(".", 1)[0]
293293
if re.fullmatch(r"[A-Za-z][\w-]*", story_key) and sprint_status_epic(project_root, story_key)[0]:
294294
return story_key
295295
return ""

tests/test_epic_parser.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import unittest
55
from pathlib import Path
66

7-
from story_automator.core.epic_parser import epic_complete, parse_epic_file, parse_story
7+
from story_automator.core.epic_parser import epic_complete, parse_epic_file, parse_story, parse_story_range
88

99

1010
class EpicParserTests(unittest.TestCase):
@@ -77,6 +77,19 @@ def test_parse_story_accepts_canonical_id_for_full_key_header(self) -> None:
7777
self.assertEqual(payload["storyId"], "multi-leg.3")
7878
self.assertEqual(payload["title"], "Old")
7979

80+
def test_parse_story_rejects_missing_explicit_full_key_sibling(self) -> None:
81+
self.epic_file.write_text(
82+
"""# Epic Numeric
83+
## Epic 1.2: Nested Epic
84+
### Story 1-2-3-old: New
85+
Acceptance Criteria
86+
- Works
87+
""",
88+
encoding="utf-8",
89+
)
90+
with self.assertRaisesRegex(ValueError, "story_not_found"):
91+
parse_story(self.epic_file, "1-2-3-new", self.rules_file)
92+
8093
def test_epic_complete_accepts_non_numeric_story_ids(self) -> None:
8194
self.epic_file.write_text(
8295
"""# Epic Multi Leg
@@ -144,6 +157,87 @@ def test_epic_complete_sorts_numeric_epics_numerically(self) -> None:
144157
self.assertFalse(payload["epicComplete"])
145158
self.assertEqual(payload["maxEpicStory"], "10.1")
146159

160+
def test_parse_epic_file_accepts_three_level_numeric_story_ids(self) -> None:
161+
self.epic_file.write_text(
162+
"""# Epic Numeric
163+
### 1.1.1: Nested
164+
""",
165+
encoding="utf-8",
166+
)
167+
payload = parse_epic_file(self.epic_file)
168+
self.assertTrue(payload["ok"])
169+
self.assertEqual(payload["stories"][0]["storyId"], "1.1.1")
170+
self.assertEqual(payload["stories"][0]["epicNum"], "1.1")
171+
self.assertEqual(payload["stories"][0]["storyNum"], "1")
172+
173+
def test_parse_epic_file_accepts_nested_numeric_story_under_numeric_epic(self) -> None:
174+
self.epic_file.write_text(
175+
"""# Epic Numeric
176+
## Epic 1.1: Nested Epic
177+
### 1.1.1: Nested
178+
""",
179+
encoding="utf-8",
180+
)
181+
payload = parse_epic_file(self.epic_file)
182+
self.assertTrue(payload["ok"])
183+
self.assertEqual(payload["stories"][0]["storyId"], "1.1.1")
184+
self.assertEqual(payload["stories"][0]["epicTitle"], "Nested Epic")
185+
186+
def test_epic_complete_sorts_nested_numeric_story_ids_numerically(self) -> None:
187+
self.epic_file.write_text(
188+
"""# Epic Numeric
189+
### 1.9.1: Old
190+
### 1.10.1: New
191+
""",
192+
encoding="utf-8",
193+
)
194+
payload = epic_complete(self.epic_file, "1.9.1")
195+
self.assertTrue(payload["ok"])
196+
self.assertFalse(payload["epicComplete"])
197+
self.assertEqual(payload["maxEpicStory"], "1.10.1")
198+
199+
def test_nested_numeric_story_accepts_canonical_dashed_full_key_alias(self) -> None:
200+
self.epic_file.write_text(
201+
"""# Epic Numeric
202+
## Epic 1.2: Nested Epic
203+
### 1.2.3: Nested
204+
Acceptance Criteria
205+
- Works
206+
""",
207+
encoding="utf-8",
208+
)
209+
story = parse_story(self.epic_file, "1-2-3-nested", self.rules_file)
210+
self.assertEqual(story["storyId"], "1.2.3")
211+
complete = epic_complete(self.epic_file, "1-2-3-nested")
212+
self.assertTrue(complete["ok"])
213+
self.assertTrue(complete["epicComplete"])
214+
215+
def test_nested_numeric_story_accepts_multi_digit_dashed_full_key_under_epic(self) -> None:
216+
self.epic_file.write_text(
217+
"""# Epic Numeric
218+
## Epic 1.2: Nested Epic
219+
### Story 1-2-21-nested: Nested
220+
""",
221+
encoding="utf-8",
222+
)
223+
payload = parse_epic_file(self.epic_file)
224+
self.assertEqual(payload["stories"][0]["storyId"], "1.2.21")
225+
226+
def test_nested_numeric_epic_preserves_title_like_numeric_segments(self) -> None:
227+
self.epic_file.write_text(
228+
"""# Epic Numeric
229+
## Epic 1.2: Nested Epic
230+
### Story 1-2-2026-release: Release
231+
""",
232+
encoding="utf-8",
233+
)
234+
payload = parse_epic_file(self.epic_file)
235+
self.assertEqual(payload["stories"][0]["storyId"], "1.2")
236+
237+
def test_parse_story_range_rejects_unknown_hyphenated_story_id(self) -> None:
238+
with self.assertRaisesRegex(ValueError, "invalid_story_range:multi-leg-3x"):
239+
parse_story_range("multi-leg-3x", 2, "multi-leg-3,multi-leg-4")
240+
147241

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

tests/test_normalize_story_key.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,52 @@ def test_numeric_full_key(self) -> None:
4444
self.assertEqual(result.prefix, "1-2")
4545
self.assertEqual(result.key, "1-2-user-authentication")
4646

47+
def test_numeric_full_key_with_numeric_title_segment(self) -> None:
48+
result = normalize_story_key(str(self.project_root), "1-2-2026-release")
49+
assert result is not None
50+
self.assertEqual(result.id, "1.2")
51+
self.assertEqual(result.prefix, "1-2")
52+
self.assertEqual(result.key, "1-2-2026-release")
53+
54+
def test_numeric_full_key_with_two_digit_numeric_title_segment(self) -> None:
55+
result = normalize_story_key(str(self.project_root), "1-2-42-release")
56+
assert result is not None
57+
self.assertEqual(result.id, "1.2")
58+
self.assertEqual(result.prefix, "1-2")
59+
self.assertEqual(result.key, "1-2-42-release")
60+
61+
def test_nested_numeric_full_key(self) -> None:
62+
result = normalize_story_key(str(self.project_root), "1-2-3-nested")
63+
assert result is not None
64+
self.assertEqual(result.id, "1.2.3")
65+
self.assertEqual(result.prefix, "1-2-3")
66+
self.assertEqual(result.key, "1-2-3-nested")
67+
68+
def test_nested_numeric_full_key_with_multi_digit_story_number(self) -> None:
69+
result = normalize_story_key(str(self.project_root), "1-2-10-nested")
70+
assert result is not None
71+
self.assertEqual(result.id, "1.2.10")
72+
self.assertEqual(result.prefix, "1-2-10")
73+
self.assertEqual(result.key, "1-2-10-nested")
74+
75+
def test_nested_numeric_full_key_above_twenty(self) -> None:
76+
result = normalize_story_key(str(self.project_root), "1-2-21-nested")
77+
assert result is not None
78+
self.assertEqual(result.id, "1.2.21")
79+
self.assertEqual(result.prefix, "1-2-21")
80+
self.assertEqual(result.key, "1-2-21-nested")
81+
82+
def test_epic_hint_preserves_numeric_title_segments(self) -> None:
83+
year = normalize_story_key_for_epic(str(self.project_root), "1.2", "1-2-2026-release")
84+
assert year is not None
85+
self.assertEqual(year.id, "1.2")
86+
self.assertEqual(year.prefix, "1-2")
87+
88+
release = normalize_story_key_for_epic(str(self.project_root), "1.2", "1-2-42-release")
89+
assert release is not None
90+
self.assertEqual(release.id, "1.2")
91+
self.assertEqual(release.prefix, "1-2")
92+
4793
# --- Non-numeric epic keys (the regression this patch restores) ---
4894

4995
def test_non_numeric_dotted_id(self) -> None:

0 commit comments

Comments
 (0)