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.
@@ -0,0 +1,403 @@
1
+ """GSD (Get Shit Done) Artifact Hijack adapter (.planning/phases/*/*-PLAN.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from collections.abc import Sequence
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from sdd_parallel.models import EffortTier, TaskNode, TaskStatus, Wave, WaveTrack
12
+
13
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
14
+ _LIST_INLINE_RE = re.compile(r"^([A-Za-z0-9_]+):\s*\[(.*?)\]\s*$")
15
+ _SCALAR_LINE_RE = re.compile(r"^([A-Za-z0-9_]+):\s*(.+?)\s*$")
16
+ _LIST_ITEM_RE = re.compile(r"^\s*-\s*[\"']?([^\"']+)[\"']?\s*$")
17
+ _TITLE_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
18
+ _ROADMAP_PHASE_RE = re.compile(
19
+ r"^##+\s+Phase\s+(\d+)\s*[-:]\s*(.+?)(?:\s*\((?:depends\s+on|after)\s+Phase\s+(\d+)\))?$",
20
+ re.IGNORECASE | re.MULTILINE,
21
+ )
22
+ _CHECKLIST_DONE_RE = re.compile(r"^\s*-\s*\[[xX]\]\s*(.+)$")
23
+
24
+
25
+ class GSDHijackAdapter:
26
+ """Artifact Hijack adapter for gsd-build/get-shit-done (.planning/).
27
+
28
+ Reads `{phase}-{NN}-PLAN.md` files across all phases, lifting GSD's intra-phase
29
+ lock-in while locking `.planning/STATE.md` and `.planning/ROADMAP.md` inside
30
+ worker worktrees so parallel workers never conflict on global state files.
31
+ """
32
+
33
+ kit_id: str = "gsd"
34
+
35
+ FORBIDDEN_STATE_FILES: dict[str, str] = {
36
+ ".planning/STATE.md": "Locked by GSD Artifact Hijack (updated centrally on merge)",
37
+ ".planning/ROADMAP.md": "Locked by GSD Artifact Hijack (updated centrally on merge)",
38
+ }
39
+
40
+ def __init__(self, repo_root: Path) -> None:
41
+ self.repo_root = repo_root.resolve()
42
+ self.planning_dir = self.repo_root / ".planning"
43
+ self.phases_dir = self.planning_dir / "phases"
44
+ self.state_file = self.planning_dir / "STATE.md"
45
+ self.roadmap_file = self.planning_dir / "ROADMAP.md"
46
+
47
+ def detect(self) -> bool:
48
+ """Return True if GSD .planning/ directory exists."""
49
+ return self.roadmap_file.is_file() or self.state_file.is_file() or self.phases_dir.is_dir()
50
+
51
+ @staticmethod
52
+ def _parse_frontmatter(text: str) -> dict[str, str | list[str]]:
53
+ m = _FRONTMATTER_RE.match(text)
54
+ if not m:
55
+ return {}
56
+ block = m.group(1)
57
+ data: dict[str, str | list[str]] = {}
58
+ current_list_key: str | None = None
59
+
60
+ for raw_line in block.splitlines():
61
+ line = raw_line.rstrip()
62
+ if not line.strip() or line.lstrip().startswith("#"):
63
+ continue
64
+
65
+ inline_m = _LIST_INLINE_RE.match(line.strip())
66
+ if inline_m:
67
+ key, inner = inline_m.groups()
68
+ items = [
69
+ item.strip().strip("\"'")
70
+ for item in inner.split(",")
71
+ if item.strip().strip("\"'")
72
+ ]
73
+ data[key.lower()] = items
74
+ current_list_key = None
75
+ continue
76
+
77
+ if line.strip().endswith(":") and not line.startswith(" "):
78
+ current_list_key = line.strip()[:-1].strip().lower()
79
+ data[current_list_key] = []
80
+ continue
81
+
82
+ item_m = _LIST_ITEM_RE.match(line)
83
+ if item_m and current_list_key is not None:
84
+ cur_val = data.get(current_list_key)
85
+ if isinstance(cur_val, list):
86
+ cur_val.append(item_m.group(1).strip().strip("\"'"))
87
+ continue
88
+
89
+ scalar_m = _SCALAR_LINE_RE.match(line.strip())
90
+ if scalar_m:
91
+ key, val = scalar_m.groups()
92
+ data[key.lower()] = val.strip().strip("\"'")
93
+ current_list_key = None
94
+
95
+ return data
96
+
97
+ def _get_done_tasks_from_state(self) -> set[str]:
98
+ """Extract plan IDs marked as completed [x] in .planning/STATE.md."""
99
+ done_ids: set[str] = set()
100
+ if not self.state_file.is_file():
101
+ return done_ids
102
+ for line in self.state_file.read_text(encoding="utf-8-sig").splitlines():
103
+ m = _CHECKLIST_DONE_RE.match(line)
104
+ if m:
105
+ content = m.group(1)
106
+ for token in re.split(r"[\s,:;()]+", content):
107
+ clean = token.strip().strip("`'\"")
108
+ if re.match(r"^\d{1,3}-\d{1,3}$", clean):
109
+ done_ids.add(clean)
110
+ return done_ids
111
+
112
+ def _parse_roadmap_phase_dependencies(self) -> dict[str, list[str]]:
113
+ """Parse inter-phase dependencies from .planning/ROADMAP.md if present."""
114
+ phase_deps: dict[str, list[str]] = {}
115
+ if not self.roadmap_file.is_file():
116
+ return phase_deps
117
+ text = self.roadmap_file.read_text(encoding="utf-8-sig")
118
+ for m in _ROADMAP_PHASE_RE.finditer(text):
119
+ ph_num, _ph_title, dep_ph = m.groups()
120
+ key = f"phase-{ph_num}"
121
+ if dep_ph:
122
+ phase_deps[key] = [f"phase-{dep_ph}"]
123
+ return phase_deps
124
+
125
+ def load_tasks(
126
+ self,
127
+ include_done: bool = False,
128
+ plan_source: str = "auto",
129
+ ) -> list[TaskNode]:
130
+ """Load GSD plan files across all phases for cross-phase wave scheduling."""
131
+ if not self.phases_dir.is_dir():
132
+ return []
133
+
134
+ done_in_state = self._get_done_tasks_from_state()
135
+ tasks: list[TaskNode] = []
136
+ plan_files = sorted(self.phases_dir.rglob("*-PLAN.md"))
137
+
138
+ for plan_file in plan_files:
139
+ stem = plan_file.name[: -len("-PLAN.md")]
140
+ summary_file = plan_file.with_name(f"{stem}-SUMMARY.md")
141
+ status = (
142
+ TaskStatus.DONE
143
+ if (summary_file.is_file() or stem in done_in_state)
144
+ else TaskStatus.READY_FOR_DEV
145
+ )
146
+ if status == TaskStatus.DONE and not include_done:
147
+ continue
148
+
149
+ text = plan_file.read_text(encoding="utf-8-sig")
150
+ fm = self._parse_frontmatter(text)
151
+
152
+ raw_files = (
153
+ fm.get("files_modified") or fm.get("files") or fm.get("modified_files") or []
154
+ )
155
+ write_files: list[str] = (
156
+ [f.replace("\\", "/").strip() for f in raw_files if isinstance(f, str)]
157
+ if isinstance(raw_files, list)
158
+ else []
159
+ )
160
+
161
+ # XML-tag parsing: <task type="auto">...<files>src/a.py, src/b.py</files>...</task>
162
+ xml_files_blocks = re.findall(
163
+ r"<files>(.*?)</files>", text, flags=re.DOTALL | re.IGNORECASE
164
+ )
165
+ for block in xml_files_blocks:
166
+ for raw_token in re.split(r"[,\n;]+", block):
167
+ cleaned = raw_token.strip().strip("`'\"")
168
+ if cleaned:
169
+ norm_f = cleaned.replace("\\", "/")
170
+ if norm_f not in write_files:
171
+ write_files.append(norm_f)
172
+
173
+ raw_deps = fm.get("depends_on") or fm.get("dependencies") or fm.get("deps") or []
174
+ depends_on: list[str] = []
175
+ if isinstance(raw_deps, list):
176
+ for dep in raw_deps:
177
+ if isinstance(dep, str):
178
+ clean_dep = dep.strip()
179
+ if clean_dep.endswith("-PLAN.md"):
180
+ clean_dep = clean_dep[: -len("-PLAN.md")]
181
+ elif clean_dep.endswith(".md"):
182
+ clean_dep = clean_dep[: -len(".md")]
183
+ if clean_dep and clean_dep != stem and clean_dep not in depends_on:
184
+ depends_on.append(clean_dep)
185
+
186
+ # XML-tag parsing: <depends_on>...</depends_on>
187
+ xml_dep_blocks = re.findall(
188
+ r"<depends_on>(.*?)</depends_on>", text, flags=re.DOTALL | re.IGNORECASE
189
+ )
190
+ for block in xml_dep_blocks:
191
+ for raw_token in re.split(r"[,\n;]+", block):
192
+ cleaned = raw_token.strip().strip("`'\"")
193
+ if cleaned.endswith("-PLAN.md"):
194
+ cleaned = cleaned[: -len("-PLAN.md")]
195
+ elif cleaned.endswith(".md"):
196
+ cleaned = cleaned[: -len(".md")]
197
+ if cleaned and cleaned != stem and cleaned not in depends_on:
198
+ depends_on.append(cleaned)
199
+
200
+ title_m = _TITLE_RE.search(text)
201
+ title = title_m.group(1).strip() if title_m else f"GSD Plan {stem}"
202
+ phase_name = plan_file.parent.name
203
+
204
+ # Check explicit effort in frontmatter
205
+ fm_effort = fm.get("effort")
206
+ effort = EffortTier.MEDIUM
207
+ if isinstance(fm_effort, str):
208
+ eff_lower = fm_effort.lower()
209
+ if eff_lower == "low":
210
+ effort = EffortTier.LOW
211
+ elif eff_lower == "high":
212
+ effort = EffortTier.HIGH
213
+ elif len(write_files) >= 5 or any("schema.py" in f for f in write_files):
214
+ effort = EffortTier.HIGH
215
+ elif len(write_files) == 1 and not any(
216
+ s in write_files[0] for s in ("schema.py", "models.py")
217
+ ):
218
+ effort = EffortTier.LOW
219
+
220
+ tasks.append(
221
+ TaskNode(
222
+ id=stem,
223
+ title=title,
224
+ epic_id=phase_name,
225
+ status=status,
226
+ effort=effort,
227
+ write_files=write_files,
228
+ depends_on=depends_on,
229
+ )
230
+ )
231
+
232
+ return tasks
233
+
234
+ def update_state_on_merge(
235
+ self,
236
+ wave_number: int,
237
+ merged_task_ids: list[str],
238
+ ) -> Path | None:
239
+ """Centrally update .planning/STATE.md upon Wave K barrier merge checkpoint.
240
+
241
+ Marks merged plans as completed [x] and records wave checkpoint history
242
+ without causing worktree git merge conflicts.
243
+ """
244
+ if not self.planning_dir.is_dir():
245
+ self.planning_dir.mkdir(parents=True, exist_ok=True)
246
+
247
+ now_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
248
+ lines: list[str] = []
249
+ if self.state_file.is_file():
250
+ existing = self.state_file.read_text(encoding="utf-8-sig").splitlines()
251
+ for line in existing:
252
+ updated_line = line
253
+ # Check off task items if present in checklist form
254
+ for tid in merged_task_ids:
255
+ if f"{tid}" in line and "- [ ]" in line:
256
+ updated_line = line.replace("- [ ]", "- [x]")
257
+ break
258
+ lines.append(updated_line)
259
+ else:
260
+ lines = [
261
+ "# Project State (GSD Artifact Hijack Mode)",
262
+ "",
263
+ "## Status",
264
+ f"- **Last Updated:** {now_str}",
265
+ "- **Active Mode:** Parallel Wave Execution via sdd-parallel",
266
+ "",
267
+ "## Completed Plans",
268
+ ]
269
+
270
+ # Append wave merge checkpoint record
271
+ tasks_str = ", ".join(merged_task_ids)
272
+ lines.extend(
273
+ [
274
+ "",
275
+ f"### Wave {wave_number} Merge Checkpoint (`{now_str}`)",
276
+ f"- **Merged Tasks:** `{tasks_str}`",
277
+ "- **Status:** Barrier Merge Verified & Quality Gates Passed",
278
+ ]
279
+ )
280
+
281
+ for tid in merged_task_ids:
282
+ task_entry = f"- [x] {tid} (Completed in Wave {wave_number})"
283
+ if task_entry not in lines:
284
+ lines.append(task_entry)
285
+
286
+ content = "\n".join(lines).strip() + "\n"
287
+ self.state_file.write_text(content, encoding="utf-8")
288
+ return self.state_file
289
+
290
+ def reset_tasks_status(
291
+ self,
292
+ task_ids: Sequence[str],
293
+ target_status: str = "ready-for-dev",
294
+ ) -> None:
295
+ """Reset completed tasks in .planning/STATE.md and remove *-SUMMARY.md."""
296
+ target_set = set(task_ids)
297
+ if self.state_file.is_file():
298
+ lines = self.state_file.read_text(encoding="utf-8-sig").splitlines()
299
+ new_lines: list[str] = []
300
+ for line in lines:
301
+ m = _CHECKLIST_DONE_RE.match(line)
302
+ if m:
303
+ content = m.group(1)
304
+ if any(tid in content for tid in target_set):
305
+ line = re.sub(r"\[[xX]\]", "[ ]", line, count=1)
306
+ new_lines.append(line)
307
+ self.state_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
308
+
309
+ if self.phases_dir.is_dir():
310
+ for tid in target_set:
311
+ for sf in list(self.phases_dir.rglob(f"*{tid}*-SUMMARY.md")):
312
+ sf.unlink(missing_ok=True)
313
+
314
+ def cleanup_wave_artifacts(self, wave_id: int, task_ids: Sequence[str]) -> None:
315
+ """Reset task states and clean wave summaries."""
316
+ self.reset_tasks_status(task_ids)
317
+
318
+ def check_wave_zero_blockers(self, wave: Wave | None = None) -> list[str]:
319
+ """Detect uncommitted changes in Git working tree."""
320
+ blockers: list[str] = []
321
+ try:
322
+ proc = subprocess.run(
323
+ ["git", "status", "--porcelain"],
324
+ cwd=self.repo_root,
325
+ capture_output=True,
326
+ text=True,
327
+ check=False,
328
+ )
329
+ dirty_lines: list[str] = []
330
+ for raw_line in proc.stdout.splitlines():
331
+ if len(raw_line) < 4:
332
+ continue
333
+ path_part = raw_line[3:].strip().strip('"').replace("\\", "/").rstrip("/")
334
+ if any(
335
+ path_part == pref or path_part.startswith(f"{pref}/")
336
+ for pref in (
337
+ ".tmp",
338
+ "_bmad-output/parallel-artifacts",
339
+ ".parallel",
340
+ ".worktrees",
341
+ ".parallel-lock.json",
342
+ )
343
+ ):
344
+ continue
345
+ dirty_lines.append(raw_line.strip())
346
+
347
+ if dirty_lines:
348
+ blockers.append(
349
+ f"Dirty Git working tree ({len(dirty_lines)} modified/untracked files). "
350
+ "Commit or stash before spawning parallel git worktrees."
351
+ )
352
+ except OSError:
353
+ pass
354
+ return blockers
355
+
356
+ def get_allowed_write_globs(self, track: WaveTrack) -> list[str]:
357
+ """Return allowed write globs for GSD phases."""
358
+ return [".planning/phases/**"]
359
+
360
+ def get_forbidden_files(self, wave: Wave, track: WaveTrack) -> dict[str, str]:
361
+ """Return immutable GSD state and roadmap files."""
362
+ reason = "Locked by GSD Artifact Hijack (updated centrally on merge)"
363
+ return {
364
+ ".planning/STATE.md": reason,
365
+ ".planning/ROADMAP.md": reason,
366
+ ".planning/PROJECT.md": reason,
367
+ ".planning/REQUIREMENTS.md": reason,
368
+ }
369
+
370
+ def get_track_env_vars(self, track: WaveTrack) -> dict[str, str]:
371
+ return {}
372
+
373
+ def check_preflight_context_gate(self, wave: Wave) -> list[str]:
374
+ return []
375
+
376
+ def prepare_worktree_overlay(self, worktree_path: Path, track: WaveTrack) -> list[str]:
377
+ return []
378
+
379
+ def verify_track_spec_scope(
380
+ self,
381
+ worktree_path: Path,
382
+ track: WaveTrack,
383
+ forbidden_files: dict[str, str],
384
+ ) -> list[str]:
385
+ violations: list[str] = []
386
+ for sid in track.task_ids:
387
+ for fp in track.exclusive_write_files:
388
+ norm_fp = fp.replace("\\", "/")
389
+ if norm_fp in forbidden_files:
390
+ reason = forbidden_files[norm_fp]
391
+ violations.append(
392
+ f"Wave track {track.track_id} (task {sid}) "
393
+ f"touches forbidden file '{norm_fp}': {reason}"
394
+ )
395
+ return violations
396
+
397
+ def render_worker_command(self, track: WaveTrack) -> str:
398
+ """Render operator worker command for GSD track."""
399
+ return (
400
+ f"`/gsd-execute-phase {track.task_ids[0]}`"
401
+ if track.task_ids
402
+ else "`/gsd-execute-phase`"
403
+ )