sdd-parallel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdd_parallel/__init__.py +28 -0
- sdd_parallel/adapters/__init__.py +168 -0
- sdd_parallel/adapters/beads.py +324 -0
- sdd_parallel/adapters/bmad.py +637 -0
- sdd_parallel/adapters/gsd_hijack.py +403 -0
- sdd_parallel/adapters/openspec.py +419 -0
- sdd_parallel/adapters/spec_kit.py +483 -0
- sdd_parallel/ast_scanner.py +209 -0
- sdd_parallel/autopilot.py +167 -0
- sdd_parallel/cli.py +740 -0
- sdd_parallel/crg_sidecar.py +137 -0
- sdd_parallel/demo.py +897 -0
- sdd_parallel/hooks/__init__.py +12 -0
- sdd_parallel/hooks/blast_radius_guard.py +339 -0
- sdd_parallel/models.py +230 -0
- sdd_parallel/plugin_installer.py +162 -0
- sdd_parallel/py.typed +0 -0
- sdd_parallel/resources/SKILL.md +130 -0
- sdd_parallel/resources/__init__.py +1 -0
- sdd_parallel/scheduler.py +378 -0
- sdd_parallel/status_merger.py +284 -0
- sdd_parallel/worktree.py +1346 -0
- sdd_parallel-0.1.0.dist-info/METADATA +320 -0
- sdd_parallel-0.1.0.dist-info/RECORD +27 -0
- sdd_parallel-0.1.0.dist-info/WHEEL +4 -0
- sdd_parallel-0.1.0.dist-info/entry_points.txt +4 -0
- sdd_parallel-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""BMAD Method adapter for parsing sprint-status.yaml, epics.md, and story specs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from sdd_parallel.models import EffortTier, TaskNode, TaskStatus, Wave, WaveTrack
|
|
12
|
+
from sdd_parallel.status_merger import (
|
|
13
|
+
_DEV_STATUS_HEADER_RE,
|
|
14
|
+
_KV_LINE_RE,
|
|
15
|
+
_extract_status_map,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_STORY_KEY_RE = re.compile(r"^(\d+)-(\d+)-(.+)$")
|
|
19
|
+
_FILE_PATH_RE = re.compile(r"`((?:src|tests)/[A-Za-z0-9_./-]+\.py)`")
|
|
20
|
+
_SHORT_MOD_RE = re.compile(
|
|
21
|
+
r"(?<![/.\w])`?([A-Za-z0-9_]+(?:/[A-Za-z0-9_]+)*\.py)(?:\([a-z0-9_-]+\))?`?(?![/\w])"
|
|
22
|
+
)
|
|
23
|
+
_CODE_MAP_SECTION_RE = re.compile(r"^##+\s+Code Map\b", re.IGNORECASE)
|
|
24
|
+
_NEGATIVE_LINE_RE = re.compile(
|
|
25
|
+
r"^\s*[-*]\s*(?:\*\*)?(?:Без изменений|Не менять|Не трогать"
|
|
26
|
+
r"|Unchanged|Do not modify|Never|Read-only)\b",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
_EPIC_STORY_HEADER_RE = re.compile(
|
|
30
|
+
r"^(?:###|[-*]\s+\*\*|[-*]\s+)\s*Story\s+(\d+)[.-](\d+)\s*:\s*(.+?)(?:\*\*)?$",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
_PROPOSAL_FILES_LINE_RE = re.compile(
|
|
34
|
+
r"^\s*[-*]?\s*\*\*(?:Файлы|Files)\s*:?\*\*:?\s*(.+)$",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
_DEP_REF_RE = re.compile(
|
|
38
|
+
r"(?:depends\s+on|after|requires|зависимость\s+от|после|требует|из|from)\s+(?:story\s+)?`?(\d+)[.-](\d+)`?",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
_CHAIN_LINE_RE = re.compile(r"(\d+-\d+(?:\s*(?:→|->)\s*\d+-\d+)+)")
|
|
42
|
+
_EFFORT_ROW_RE = re.compile(
|
|
43
|
+
r"^\|\s*(\d+-\d+)\b[^|]*\|\s*\*\*(low|medium|high)\*\*\s*\|",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
_IGNORED_DIRTY_PREFIXES = (
|
|
47
|
+
".tmp",
|
|
48
|
+
"_bmad-output/parallel-artifacts",
|
|
49
|
+
".parallel-lock.json",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class _StoryHints:
|
|
55
|
+
title: str | None = None
|
|
56
|
+
files: list[str] = field(default_factory=list)
|
|
57
|
+
depends_on: list[str] = field(default_factory=list)
|
|
58
|
+
effort: EffortTier | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BMADAdapter:
|
|
62
|
+
"""Read-Only Anti-Corruption Layer for BMAD Method v6/v7 artifacts."""
|
|
63
|
+
|
|
64
|
+
kit_id: str = "bmad"
|
|
65
|
+
|
|
66
|
+
def __init__(self, repo_root: Path) -> None:
|
|
67
|
+
self.repo_root = repo_root.resolve()
|
|
68
|
+
self.impl_dir = self.repo_root / "_bmad-output" / "implementation-artifacts"
|
|
69
|
+
self.plan_dir = self.repo_root / "_bmad-output" / "planning-artifacts"
|
|
70
|
+
self.sprint_status_path = self.impl_dir / "sprint-status.yaml"
|
|
71
|
+
self.epics_path = self.plan_dir / "epics.md"
|
|
72
|
+
self._pkg_prefix = self._detect_src_package_prefix()
|
|
73
|
+
|
|
74
|
+
def _detect_src_package_prefix(self) -> str:
|
|
75
|
+
src_dir = self.repo_root / "src"
|
|
76
|
+
if not src_dir.is_dir():
|
|
77
|
+
return "src"
|
|
78
|
+
pkgs = [
|
|
79
|
+
d.name
|
|
80
|
+
for d in sorted(src_dir.iterdir())
|
|
81
|
+
if d.is_dir() and not d.name.startswith((".", "_")) and not d.name.endswith(".egg-info")
|
|
82
|
+
]
|
|
83
|
+
if len(pkgs) == 1:
|
|
84
|
+
return f"src/{pkgs[0]}"
|
|
85
|
+
return "src"
|
|
86
|
+
|
|
87
|
+
def _resolve_short_module(self, short_mod: str) -> str:
|
|
88
|
+
clean = short_mod.replace("\\", "/").strip().lstrip("./")
|
|
89
|
+
if not clean or ".." in clean or clean.startswith(("/", ".")):
|
|
90
|
+
return ""
|
|
91
|
+
if clean.startswith(("src/", "tests/")):
|
|
92
|
+
return clean
|
|
93
|
+
candidate = f"{self._pkg_prefix}/{clean}"
|
|
94
|
+
if (self.repo_root / candidate).is_file():
|
|
95
|
+
return candidate
|
|
96
|
+
pkg_dir = self.repo_root / self._pkg_prefix
|
|
97
|
+
if pkg_dir.is_dir():
|
|
98
|
+
matches = [
|
|
99
|
+
p for p in pkg_dir.rglob(clean) if p.is_file() and not p.name.startswith((".", "_"))
|
|
100
|
+
]
|
|
101
|
+
if len(matches) == 1:
|
|
102
|
+
return matches[0].relative_to(self.repo_root).as_posix()
|
|
103
|
+
return candidate
|
|
104
|
+
|
|
105
|
+
def detect(self) -> bool:
|
|
106
|
+
"""Return True if the repository contains BMAD Method artifacts."""
|
|
107
|
+
return (
|
|
108
|
+
self.sprint_status_path.is_file()
|
|
109
|
+
or self.epics_path.is_file()
|
|
110
|
+
or (self.repo_root / "_bmad").is_dir()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def get_epics_for_wave(self, wave: Wave) -> set[str]:
|
|
114
|
+
"""Extract unique epic IDs touched by the tasks in the given wave."""
|
|
115
|
+
epics: set[str] = set()
|
|
116
|
+
for tid in wave.all_task_ids:
|
|
117
|
+
clean = tid.removeprefix("epic-")
|
|
118
|
+
if "-" in clean:
|
|
119
|
+
epic_id = clean.split("-")[0]
|
|
120
|
+
if epic_id:
|
|
121
|
+
epics.add(epic_id)
|
|
122
|
+
elif clean:
|
|
123
|
+
epics.add(clean)
|
|
124
|
+
return epics
|
|
125
|
+
|
|
126
|
+
def find_epic_context(self, epic_id: str) -> Path | None:
|
|
127
|
+
"""Find pre-compiled epic context in implementation or planning artifacts."""
|
|
128
|
+
impl_ctx = self.impl_dir / f"epic-{epic_id}-context.md"
|
|
129
|
+
if impl_ctx.is_file():
|
|
130
|
+
return impl_ctx
|
|
131
|
+
plan_ctx = self.plan_dir / f"epic-{epic_id}-context.md"
|
|
132
|
+
if plan_ctx.is_file():
|
|
133
|
+
return plan_ctx
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def check_epic_contexts(self, wave: Wave) -> list[str]:
|
|
137
|
+
"""Check existence of pre-compiled epic-<N>-context.md for epics touched by wave.
|
|
138
|
+
|
|
139
|
+
Checks both implementation-artifacts and planning-artifacts directories.
|
|
140
|
+
Returns a list of missing relative paths if not found in either location.
|
|
141
|
+
"""
|
|
142
|
+
missing: list[str] = []
|
|
143
|
+
epics = self.get_epics_for_wave(wave)
|
|
144
|
+
for epic_id in sorted(epics):
|
|
145
|
+
if self.find_epic_context(epic_id) is None:
|
|
146
|
+
missing.append(f"_bmad-output/planning-artifacts/epic-{epic_id}-context.md")
|
|
147
|
+
return missing
|
|
148
|
+
|
|
149
|
+
def check_wave_zero_blockers(self, wave: Wave | None = None) -> list[str]:
|
|
150
|
+
"""Detect uncommitted changes, dirty in-progress stories, or missing epic contexts."""
|
|
151
|
+
blockers: list[str] = []
|
|
152
|
+
try:
|
|
153
|
+
proc = subprocess.run(
|
|
154
|
+
["git", "status", "--porcelain"],
|
|
155
|
+
cwd=self.repo_root,
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
dirty_lines: list[str] = []
|
|
161
|
+
for raw_line in proc.stdout.splitlines():
|
|
162
|
+
if len(raw_line) < 4:
|
|
163
|
+
continue
|
|
164
|
+
path_part = raw_line[3:].strip().strip('"').replace("\\", "/").rstrip("/")
|
|
165
|
+
if any(
|
|
166
|
+
path_part == pref or path_part.startswith(f"{pref}/")
|
|
167
|
+
for pref in _IGNORED_DIRTY_PREFIXES
|
|
168
|
+
):
|
|
169
|
+
continue
|
|
170
|
+
dirty_lines.append(raw_line.strip())
|
|
171
|
+
|
|
172
|
+
if dirty_lines:
|
|
173
|
+
blockers.append(
|
|
174
|
+
f"Dirty Git working tree ({len(dirty_lines)} modified/untracked files). "
|
|
175
|
+
"Commit or stash before spawning parallel git worktrees."
|
|
176
|
+
)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
if self.sprint_status_path.is_file():
|
|
181
|
+
status_map = _extract_status_map(
|
|
182
|
+
self.sprint_status_path.read_text(encoding="utf-8-sig")
|
|
183
|
+
)
|
|
184
|
+
for key, raw_status in status_map.items():
|
|
185
|
+
if _STORY_KEY_RE.match(key) and raw_status == TaskStatus.IN_PROGRESS.value:
|
|
186
|
+
blockers.append(
|
|
187
|
+
f"Story '{key}' is currently 'in-progress'. "
|
|
188
|
+
"Complete and commit it in Wave 0."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if wave is not None:
|
|
192
|
+
missing_contexts = self.check_epic_contexts(wave)
|
|
193
|
+
for ctx_path in missing_contexts:
|
|
194
|
+
blockers.append(
|
|
195
|
+
f"Missing pre-compiled epic context: '{ctx_path}'. "
|
|
196
|
+
"Compile it on master before spawning parallel tracks "
|
|
197
|
+
"to prevent fatal add/add git merge conflicts."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return blockers
|
|
201
|
+
|
|
202
|
+
def _extract_spec_files_and_deps(
|
|
203
|
+
self, story_key: str, short_id: str
|
|
204
|
+
) -> tuple[list[str], list[str], dict[str, str]]:
|
|
205
|
+
"""Extract Python file paths, explicit deps, and function scope notes from spec."""
|
|
206
|
+
if not self.impl_dir.is_dir():
|
|
207
|
+
return [], [], {}
|
|
208
|
+
candidates = list(self.impl_dir.glob(f"spec-{story_key}*.md"))
|
|
209
|
+
if not candidates:
|
|
210
|
+
candidates = list(self.impl_dir.glob(f"spec-{short_id}-*.md"))
|
|
211
|
+
if not candidates:
|
|
212
|
+
return [], [], {}
|
|
213
|
+
|
|
214
|
+
spec_text = candidates[0].read_text(encoding="utf-8-sig")
|
|
215
|
+
in_code_map = False
|
|
216
|
+
code_map_src: list[str] = []
|
|
217
|
+
code_map_tests: list[str] = []
|
|
218
|
+
all_src: list[str] = []
|
|
219
|
+
all_tests: list[str] = []
|
|
220
|
+
deps: list[str] = []
|
|
221
|
+
notes: dict[str, str] = {}
|
|
222
|
+
|
|
223
|
+
lines = spec_text.splitlines()
|
|
224
|
+
for i, line in enumerate(lines):
|
|
225
|
+
if _CODE_MAP_SECTION_RE.match(line):
|
|
226
|
+
in_code_map = True
|
|
227
|
+
continue
|
|
228
|
+
if in_code_map and line.startswith("## "):
|
|
229
|
+
in_code_map = False
|
|
230
|
+
|
|
231
|
+
for dep_match in _DEP_REF_RE.finditer(line):
|
|
232
|
+
dep_id = f"{dep_match.group(1)}-{dep_match.group(2)}"
|
|
233
|
+
if dep_id != short_id and dep_id not in deps:
|
|
234
|
+
deps.append(dep_id)
|
|
235
|
+
|
|
236
|
+
if _NEGATIVE_LINE_RE.match(line):
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
for match in _FILE_PATH_RE.finditer(line):
|
|
240
|
+
rel = match.group(1)
|
|
241
|
+
is_test = rel.startswith("tests/")
|
|
242
|
+
if is_test:
|
|
243
|
+
if rel not in all_tests:
|
|
244
|
+
all_tests.append(rel)
|
|
245
|
+
if in_code_map and rel not in code_map_tests:
|
|
246
|
+
code_map_tests.append(rel)
|
|
247
|
+
else:
|
|
248
|
+
if rel not in all_src:
|
|
249
|
+
all_src.append(rel)
|
|
250
|
+
if in_code_map and rel not in code_map_src:
|
|
251
|
+
code_map_src.append(rel)
|
|
252
|
+
if in_code_map:
|
|
253
|
+
rest = line[match.end() :].strip()
|
|
254
|
+
cleaned_note = re.sub(r"^[\s—–:-]+", "", rest).strip()
|
|
255
|
+
if not cleaned_note:
|
|
256
|
+
sub_notes: list[str] = []
|
|
257
|
+
for next_line in lines[i + 1 :]:
|
|
258
|
+
if next_line.startswith((" ", "\t")):
|
|
259
|
+
sub = re.sub(r"^\s*[-*]\s*", "", next_line).strip()
|
|
260
|
+
if sub:
|
|
261
|
+
sub_notes.append(sub)
|
|
262
|
+
else:
|
|
263
|
+
break
|
|
264
|
+
cleaned_note = "; ".join(sub_notes)
|
|
265
|
+
if cleaned_note and rel not in notes:
|
|
266
|
+
notes[rel] = cleaned_note
|
|
267
|
+
|
|
268
|
+
if code_map_src:
|
|
269
|
+
return code_map_src, deps, notes
|
|
270
|
+
if all_src:
|
|
271
|
+
return all_src, deps, notes
|
|
272
|
+
return (code_map_tests if code_map_tests else all_tests), deps, notes
|
|
273
|
+
|
|
274
|
+
def _parse_epics_and_planning_hints(self) -> dict[str, _StoryHints]:
|
|
275
|
+
"""Parse epics.md and planning-artifacts for titles, footprints, deps, and effort."""
|
|
276
|
+
hints: dict[str, _StoryHints] = {}
|
|
277
|
+
|
|
278
|
+
# 1. Parse epics.md
|
|
279
|
+
if self.epics_path.is_file():
|
|
280
|
+
epics_text = self.epics_path.read_text(encoding="utf-8-sig")
|
|
281
|
+
current_id: str | None = None
|
|
282
|
+
for line in epics_text.splitlines():
|
|
283
|
+
stripped = line.strip()
|
|
284
|
+
if stripped.startswith(("## ", "---")):
|
|
285
|
+
hdr = _EPIC_STORY_HEADER_RE.match(stripped)
|
|
286
|
+
if not hdr:
|
|
287
|
+
current_id = None
|
|
288
|
+
if stripped.startswith("## "):
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
hdr = _EPIC_STORY_HEADER_RE.match(stripped)
|
|
292
|
+
if hdr:
|
|
293
|
+
e_num, s_num, raw_title = hdr.groups()
|
|
294
|
+
current_id = f"{e_num}-{s_num}"
|
|
295
|
+
clean_title = (
|
|
296
|
+
re.sub(r"\s*\(`[^`]+`\)\s*$", "", raw_title).strip().rstrip("*").strip()
|
|
297
|
+
)
|
|
298
|
+
info = hints.setdefault(current_id, _StoryHints())
|
|
299
|
+
info.title = clean_title
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
if current_id:
|
|
303
|
+
info = hints[current_id]
|
|
304
|
+
for m_full in _FILE_PATH_RE.finditer(line):
|
|
305
|
+
fp = m_full.group(1)
|
|
306
|
+
if fp.startswith("src/") and fp not in info.files:
|
|
307
|
+
info.files.append(fp)
|
|
308
|
+
for m_short in _SHORT_MOD_RE.finditer(line):
|
|
309
|
+
resolved = self._resolve_short_module(m_short.group(1))
|
|
310
|
+
if resolved.startswith("src/") and resolved not in info.files:
|
|
311
|
+
info.files.append(resolved)
|
|
312
|
+
for dep_m in _DEP_REF_RE.finditer(line):
|
|
313
|
+
dep_id = f"{dep_m.group(1)}-{dep_m.group(2)}"
|
|
314
|
+
if dep_id != current_id and dep_id not in info.depends_on:
|
|
315
|
+
info.depends_on.append(dep_id)
|
|
316
|
+
|
|
317
|
+
# 2. Parse execution-plan-*.md if present in planning-artifacts
|
|
318
|
+
if self.plan_dir.is_dir():
|
|
319
|
+
for plan_file in sorted(self.plan_dir.glob("execution-plan-*.md")):
|
|
320
|
+
text = plan_file.read_text(encoding="utf-8-sig")
|
|
321
|
+
for line in text.splitlines():
|
|
322
|
+
# Check explicit chain arrows: 11-1 -> 11-4 -> 12-1 -> 12-2
|
|
323
|
+
for chain_m in _CHAIN_LINE_RE.finditer(line):
|
|
324
|
+
parts = [
|
|
325
|
+
p.strip() for p in re.split(r"→|->", chain_m.group(1)) if p.strip()
|
|
326
|
+
]
|
|
327
|
+
for prev_id, nxt_id in zip(parts, parts[1:], strict=False):
|
|
328
|
+
info = hints.setdefault(nxt_id, _StoryHints())
|
|
329
|
+
if prev_id != nxt_id and prev_id not in info.depends_on:
|
|
330
|
+
info.depends_on.append(prev_id)
|
|
331
|
+
|
|
332
|
+
# Check Effort table rows: | 11-2 | **high** |
|
|
333
|
+
eff_m = _EFFORT_ROW_RE.match(line.strip())
|
|
334
|
+
if eff_m:
|
|
335
|
+
sid, raw_eff = eff_m.group(1), eff_m.group(2).lower()
|
|
336
|
+
info = hints.setdefault(sid, _StoryHints())
|
|
337
|
+
info.effort = EffortTier(raw_eff)
|
|
338
|
+
|
|
339
|
+
# Check inventory table rows: | 11-2 Title | ... | modules |
|
|
340
|
+
if line.strip().startswith("|"):
|
|
341
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
342
|
+
if len(cells) >= 3:
|
|
343
|
+
first_m = re.match(r"^(\d+-\d+)\b", cells[0])
|
|
344
|
+
if first_m:
|
|
345
|
+
sid = first_m.group(1)
|
|
346
|
+
info = hints.setdefault(sid, _StoryHints())
|
|
347
|
+
for dep_m in _DEP_REF_RE.finditer(line):
|
|
348
|
+
dep_id = f"{dep_m.group(1)}-{dep_m.group(2)}"
|
|
349
|
+
if dep_id != sid and dep_id not in info.depends_on:
|
|
350
|
+
info.depends_on.append(dep_id)
|
|
351
|
+
for m_short in _SHORT_MOD_RE.finditer(cells[2]):
|
|
352
|
+
resolved = self._resolve_short_module(m_short.group(1))
|
|
353
|
+
if resolved.startswith("src/") and resolved not in info.files:
|
|
354
|
+
info.files.append(resolved)
|
|
355
|
+
|
|
356
|
+
# 3. Parse sprint-change-proposal-*.md if present in planning-artifacts
|
|
357
|
+
if self.plan_dir.is_dir():
|
|
358
|
+
for prop_file in sorted(self.plan_dir.glob("sprint-change-proposal-*.md")):
|
|
359
|
+
text = prop_file.read_text(encoding="utf-8-sig")
|
|
360
|
+
current_id = None
|
|
361
|
+
for line in text.splitlines():
|
|
362
|
+
stripped = line.strip()
|
|
363
|
+
if stripped.startswith(("## ", "### Epic ", "---")):
|
|
364
|
+
hdr = _EPIC_STORY_HEADER_RE.match(stripped)
|
|
365
|
+
if not hdr:
|
|
366
|
+
current_id = None
|
|
367
|
+
if stripped.startswith("## ") or stripped.startswith("### Epic "):
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
hdr = _EPIC_STORY_HEADER_RE.match(stripped)
|
|
371
|
+
if hdr:
|
|
372
|
+
e_num, s_num, raw_title = hdr.groups()
|
|
373
|
+
current_id = f"{e_num}-{s_num}"
|
|
374
|
+
clean_title = (
|
|
375
|
+
re.sub(r"\s*\(`[^`]+`\)\s*$", "", raw_title).strip().rstrip("*").strip()
|
|
376
|
+
)
|
|
377
|
+
info = hints.setdefault(current_id, _StoryHints())
|
|
378
|
+
if not info.title:
|
|
379
|
+
info.title = clean_title
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
if current_id:
|
|
383
|
+
info = hints[current_id]
|
|
384
|
+
if _NEGATIVE_LINE_RE.match(line):
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
files_m = _PROPOSAL_FILES_LINE_RE.match(stripped)
|
|
388
|
+
if files_m:
|
|
389
|
+
content = files_m.group(1)
|
|
390
|
+
for bt_m in re.finditer(r"`([^`]+)`", content):
|
|
391
|
+
raw_path = bt_m.group(1).strip()
|
|
392
|
+
if raw_path.startswith("src/"):
|
|
393
|
+
if raw_path not in info.files:
|
|
394
|
+
info.files.append(raw_path)
|
|
395
|
+
elif raw_path.endswith(".py"):
|
|
396
|
+
res = self._resolve_short_module(raw_path)
|
|
397
|
+
if res.startswith("src/") and res not in info.files:
|
|
398
|
+
info.files.append(res)
|
|
399
|
+
for un_m in re.finditer(
|
|
400
|
+
r"(?:^|[\s,;])((?:src)/[A-Za-z0-9_./-]+(?:\.[A-Za-z0-9_-]+)?)",
|
|
401
|
+
content,
|
|
402
|
+
):
|
|
403
|
+
un_path = un_m.group(1).rstrip(".,;")
|
|
404
|
+
if un_path.startswith("src/") and un_path not in info.files:
|
|
405
|
+
info.files.append(un_path)
|
|
406
|
+
|
|
407
|
+
for m_full in _FILE_PATH_RE.finditer(line):
|
|
408
|
+
fp = m_full.group(1)
|
|
409
|
+
if fp.startswith("src/") and fp not in info.files:
|
|
410
|
+
info.files.append(fp)
|
|
411
|
+
for m_short in _SHORT_MOD_RE.finditer(line):
|
|
412
|
+
resolved = self._resolve_short_module(m_short.group(1))
|
|
413
|
+
if resolved.startswith("src/") and resolved not in info.files:
|
|
414
|
+
info.files.append(resolved)
|
|
415
|
+
for dep_m in _DEP_REF_RE.finditer(line):
|
|
416
|
+
dep_id = f"{dep_m.group(1)}-{dep_m.group(2)}"
|
|
417
|
+
if dep_id != current_id and dep_id not in info.depends_on:
|
|
418
|
+
info.depends_on.append(dep_id)
|
|
419
|
+
|
|
420
|
+
return hints
|
|
421
|
+
|
|
422
|
+
def load_tasks(
|
|
423
|
+
self,
|
|
424
|
+
include_done: bool = False,
|
|
425
|
+
plan_source: str = "auto",
|
|
426
|
+
) -> list[TaskNode]:
|
|
427
|
+
"""Load BMAD stories from sprint-status.yaml and enrich from specs/epics."""
|
|
428
|
+
if not self.sprint_status_path.is_file():
|
|
429
|
+
return []
|
|
430
|
+
|
|
431
|
+
status_map = _extract_status_map(self.sprint_status_path.read_text(encoding="utf-8-sig"))
|
|
432
|
+
hints_map = self._parse_epics_and_planning_hints()
|
|
433
|
+
tasks: list[TaskNode] = []
|
|
434
|
+
|
|
435
|
+
for key, raw_status in status_map.items():
|
|
436
|
+
match = _STORY_KEY_RE.match(key)
|
|
437
|
+
if not match:
|
|
438
|
+
continue
|
|
439
|
+
epic_num, story_num, slug = match.groups()
|
|
440
|
+
status = TaskStatus.from_raw(raw_status)
|
|
441
|
+
if status == TaskStatus.DONE and not include_done:
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
short_id = f"{epic_num}-{story_num}"
|
|
445
|
+
hint = hints_map.get(short_id, _StoryHints())
|
|
446
|
+
title = hint.title or slug.replace("-", " ").capitalize()
|
|
447
|
+
|
|
448
|
+
if plan_source == "plan" and hint.files:
|
|
449
|
+
write_files = list(hint.files)
|
|
450
|
+
depends_on = list(dict.fromkeys(hint.depends_on))
|
|
451
|
+
shared_function_notes: dict[str, str] = {}
|
|
452
|
+
else:
|
|
453
|
+
spec_files, spec_deps, spec_notes = self._extract_spec_files_and_deps(key, short_id)
|
|
454
|
+
if plan_source == "specs":
|
|
455
|
+
write_files = spec_files
|
|
456
|
+
depends_on = list(dict.fromkeys(spec_deps))
|
|
457
|
+
shared_function_notes = spec_notes
|
|
458
|
+
else: # "auto"
|
|
459
|
+
write_files = spec_files if spec_files else list(hint.files)
|
|
460
|
+
depends_on = list(dict.fromkeys([*hint.depends_on, *spec_deps]))
|
|
461
|
+
shared_function_notes = spec_notes
|
|
462
|
+
|
|
463
|
+
if hint.effort is not None:
|
|
464
|
+
effort = hint.effort
|
|
465
|
+
else:
|
|
466
|
+
effort = EffortTier.MEDIUM
|
|
467
|
+
if len(write_files) >= 5 or (
|
|
468
|
+
len(write_files) >= 3 and any("schema.py" in f for f in write_files)
|
|
469
|
+
):
|
|
470
|
+
effort = EffortTier.HIGH
|
|
471
|
+
elif len(write_files) == 1 and not any(
|
|
472
|
+
s in write_files[0] for s in ("schema.py", "models.py")
|
|
473
|
+
):
|
|
474
|
+
effort = EffortTier.LOW
|
|
475
|
+
|
|
476
|
+
tasks.append(
|
|
477
|
+
TaskNode(
|
|
478
|
+
id=short_id,
|
|
479
|
+
title=title,
|
|
480
|
+
epic_id=f"epic-{epic_num}",
|
|
481
|
+
status=status,
|
|
482
|
+
effort=effort,
|
|
483
|
+
write_files=write_files,
|
|
484
|
+
depends_on=depends_on,
|
|
485
|
+
shared_function_notes=shared_function_notes,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return tasks
|
|
490
|
+
|
|
491
|
+
def reset_tasks_status(
|
|
492
|
+
self,
|
|
493
|
+
task_ids: Sequence[str],
|
|
494
|
+
target_status: str = "ready-for-dev",
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Reset task statuses in sprint-status.yaml to target_status and delete generated specs."""
|
|
497
|
+
target_set = set(task_ids)
|
|
498
|
+
if self.sprint_status_path.is_file():
|
|
499
|
+
lines = self.sprint_status_path.read_text(encoding="utf-8-sig").splitlines()
|
|
500
|
+
new_lines: list[str] = []
|
|
501
|
+
in_block = False
|
|
502
|
+
for line in lines:
|
|
503
|
+
if _DEV_STATUS_HEADER_RE.match(line):
|
|
504
|
+
in_block = True
|
|
505
|
+
new_lines.append(line)
|
|
506
|
+
continue
|
|
507
|
+
if in_block:
|
|
508
|
+
if line.strip() and not line.startswith(" ") and not line.startswith("#"):
|
|
509
|
+
in_block = False
|
|
510
|
+
new_lines.append(line)
|
|
511
|
+
continue
|
|
512
|
+
match = _KV_LINE_RE.match(line)
|
|
513
|
+
if match:
|
|
514
|
+
indent, key, _val, comment = match.groups()
|
|
515
|
+
clean_id = key
|
|
516
|
+
if "-" in key:
|
|
517
|
+
parts = key.split("-")
|
|
518
|
+
if len(parts) >= 2:
|
|
519
|
+
clean_id = f"{parts[0]}-{parts[1]}"
|
|
520
|
+
if key in target_set or clean_id in target_set:
|
|
521
|
+
new_lines.append(f"{indent}{key}: {target_status}{comment}")
|
|
522
|
+
continue
|
|
523
|
+
new_lines.append(line)
|
|
524
|
+
self.sprint_status_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
525
|
+
|
|
526
|
+
if self.impl_dir.is_dir():
|
|
527
|
+
for tid in target_set:
|
|
528
|
+
for p in list(self.impl_dir.glob(f"spec-{tid}*.md")):
|
|
529
|
+
p.unlink(missing_ok=True)
|
|
530
|
+
|
|
531
|
+
def cleanup_wave_artifacts(self, wave_id: int, task_ids: Sequence[str]) -> None:
|
|
532
|
+
"""Clean up wave parallel artifacts and generated specs."""
|
|
533
|
+
self.reset_tasks_status(task_ids)
|
|
534
|
+
parallel_dir = self.repo_root / "_bmad-output" / "parallel-artifacts"
|
|
535
|
+
if parallel_dir.is_dir():
|
|
536
|
+
for f in list(parallel_dir.glob(f"*wave-{wave_id}*")):
|
|
537
|
+
f.unlink(missing_ok=True)
|
|
538
|
+
|
|
539
|
+
def get_allowed_write_globs(self, track: WaveTrack) -> list[str]:
|
|
540
|
+
"""Return allowed write globs for BMAD sprint-status and story specs."""
|
|
541
|
+
return [
|
|
542
|
+
"_bmad-output/implementation-artifacts/sprint-status.yaml",
|
|
543
|
+
*[f"_bmad-output/implementation-artifacts/spec-{sid}*.md" for sid in track.task_ids],
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
def get_forbidden_files(self, wave: Wave, track: WaveTrack) -> dict[str, str]:
|
|
547
|
+
"""Return immutable epic context files during wave execution."""
|
|
548
|
+
forbidden: dict[str, str] = {}
|
|
549
|
+
epic_reason = (
|
|
550
|
+
"Pre-compiled Epic Context is immutable during wave execution "
|
|
551
|
+
"(Zero Cache Invalidation invariant)"
|
|
552
|
+
)
|
|
553
|
+
for search_dir in (self.plan_dir, self.impl_dir):
|
|
554
|
+
if search_dir.is_dir():
|
|
555
|
+
for ctx_path in sorted(search_dir.glob("epic-*-context.md")):
|
|
556
|
+
rel_ctx = ctx_path.relative_to(self.repo_root).as_posix()
|
|
557
|
+
forbidden.setdefault(rel_ctx, epic_reason)
|
|
558
|
+
for epic_id in sorted(self.get_epics_for_wave(wave)):
|
|
559
|
+
found_ctx = self.find_epic_context(epic_id)
|
|
560
|
+
if found_ctx is not None:
|
|
561
|
+
rel_ctx = found_ctx.relative_to(self.repo_root).as_posix()
|
|
562
|
+
forbidden.setdefault(rel_ctx, epic_reason)
|
|
563
|
+
else:
|
|
564
|
+
rel_ctx = f"_bmad-output/planning-artifacts/epic-{epic_id}-context.md"
|
|
565
|
+
forbidden.setdefault(rel_ctx, epic_reason)
|
|
566
|
+
return forbidden
|
|
567
|
+
|
|
568
|
+
def get_track_env_vars(self, track: WaveTrack) -> dict[str, str]:
|
|
569
|
+
"""BMAD does not require external environment variables."""
|
|
570
|
+
return {}
|
|
571
|
+
|
|
572
|
+
def check_preflight_context_gate(self, wave: Wave) -> list[str]:
|
|
573
|
+
"""Check for missing epic context files before wave execution."""
|
|
574
|
+
return self.check_epic_contexts(wave)
|
|
575
|
+
|
|
576
|
+
def prepare_worktree_overlay(self, worktree_path: Path, track: WaveTrack) -> list[str]:
|
|
577
|
+
"""Write BMAD configuration files and advance stories to ready-for-dev."""
|
|
578
|
+
injected: list[str] = []
|
|
579
|
+
bmad_custom = worktree_path / "_bmad" / "custom"
|
|
580
|
+
bmad_custom.mkdir(parents=True, exist_ok=True)
|
|
581
|
+
toml_content = (
|
|
582
|
+
'[workflow]\npersistent_facts = ["file:{project-root}/.tmp/track-context.md"]\n'
|
|
583
|
+
)
|
|
584
|
+
for name in (
|
|
585
|
+
"bmad-build.user.toml",
|
|
586
|
+
"bmad-build-auto.user.toml",
|
|
587
|
+
"bmad-build.toml",
|
|
588
|
+
"bmad-build-auto.toml",
|
|
589
|
+
):
|
|
590
|
+
dest = bmad_custom / name
|
|
591
|
+
dest.write_text(toml_content, encoding="utf-8")
|
|
592
|
+
injected.append(dest.relative_to(worktree_path).as_posix())
|
|
593
|
+
|
|
594
|
+
sprint_yaml_path = (
|
|
595
|
+
worktree_path / "_bmad-output" / "implementation-artifacts" / "sprint-status.yaml"
|
|
596
|
+
)
|
|
597
|
+
if sprint_yaml_path.is_file():
|
|
598
|
+
try:
|
|
599
|
+
content = sprint_yaml_path.read_text(encoding="utf-8")
|
|
600
|
+
modified = False
|
|
601
|
+
for t_id in track.task_ids:
|
|
602
|
+
pattern = re.compile(
|
|
603
|
+
rf"^(\s*{re.escape(t_id)}[\w-]*:\s*)backlog\s*$",
|
|
604
|
+
re.MULTILINE,
|
|
605
|
+
)
|
|
606
|
+
if pattern.search(content):
|
|
607
|
+
content = pattern.sub(r"\g<1>ready-for-dev", content)
|
|
608
|
+
modified = True
|
|
609
|
+
if modified:
|
|
610
|
+
sprint_yaml_path.write_text(content, encoding="utf-8")
|
|
611
|
+
except OSError:
|
|
612
|
+
pass
|
|
613
|
+
return injected
|
|
614
|
+
|
|
615
|
+
def verify_track_spec_scope(
|
|
616
|
+
self,
|
|
617
|
+
worktree_path: Path,
|
|
618
|
+
track: WaveTrack,
|
|
619
|
+
forbidden_files: dict[str, str],
|
|
620
|
+
) -> list[str]:
|
|
621
|
+
"""Verify that BMAD story specs do not touch forbidden files."""
|
|
622
|
+
violations: list[str] = []
|
|
623
|
+
for sid in track.task_ids:
|
|
624
|
+
spec_files, _, _ = self._extract_spec_files_and_deps(sid, sid)
|
|
625
|
+
for fp in spec_files:
|
|
626
|
+
norm_fp = fp.replace("\\", "/")
|
|
627
|
+
if norm_fp in forbidden_files:
|
|
628
|
+
reason = forbidden_files[norm_fp]
|
|
629
|
+
violations.append(
|
|
630
|
+
f"Wave track {track.track_id} (story {sid}) "
|
|
631
|
+
f"spec Code Map touches forbidden file '{norm_fp}': {reason}"
|
|
632
|
+
)
|
|
633
|
+
return violations
|
|
634
|
+
|
|
635
|
+
def render_worker_command(self, track: WaveTrack) -> str:
|
|
636
|
+
"""Render operator worker command for BMAD track."""
|
|
637
|
+
return f"`/bmad-build {track.task_ids[0]}`" if track.task_ids else "`/bmad-build`"
|