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,28 @@
1
+ """SDD-Parallel: Multi-track parallel roadmap orchestrator and blast-radius solver."""
2
+
3
+ from sdd_parallel.models import (
4
+ ConflictEdge,
5
+ ConflictType,
6
+ EffortTier,
7
+ FileRole,
8
+ LockManifest,
9
+ TaskNode,
10
+ TaskStatus,
11
+ Wave,
12
+ WavePlan,
13
+ WaveTrack,
14
+ )
15
+
16
+ __all__ = [
17
+ "ConflictEdge",
18
+ "ConflictType",
19
+ "EffortTier",
20
+ "FileRole",
21
+ "LockManifest",
22
+ "TaskNode",
23
+ "TaskStatus",
24
+ "Wave",
25
+ "WavePlan",
26
+ "WaveTrack",
27
+ ]
28
+ __version__ = "0.1.0"
@@ -0,0 +1,168 @@
1
+ """Unified SDD kit auto-detection and adapter registry (BMAD, Spec Kit, GSD, OpenSpec, Beads)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from sdd_parallel.adapters.beads import BeadsAdapter
12
+ from sdd_parallel.adapters.bmad import BMADAdapter
13
+ from sdd_parallel.adapters.gsd_hijack import GSDHijackAdapter
14
+ from sdd_parallel.adapters.openspec import OpenSpecAdapter
15
+ from sdd_parallel.adapters.spec_kit import SpecKitAdapter
16
+ from sdd_parallel.models import TaskNode, Wave, WaveTrack
17
+
18
+
19
+ class SDDAdapterProtocol(Protocol):
20
+ """Common adapter contract across all SDD kits."""
21
+
22
+ kit_id: str
23
+
24
+ def __init__(self, repo_root: Path) -> None: ...
25
+ def detect(self) -> bool: ...
26
+ def load_tasks(
27
+ self,
28
+ include_done: bool = False,
29
+ plan_source: str = "auto",
30
+ ) -> list[TaskNode]: ...
31
+ def reset_tasks_status(
32
+ self,
33
+ task_ids: Sequence[str],
34
+ target_status: str = "ready-for-dev",
35
+ ) -> None: ...
36
+ def cleanup_wave_artifacts(
37
+ self,
38
+ wave_id: int,
39
+ task_ids: Sequence[str],
40
+ ) -> None: ...
41
+ def check_wave_zero_blockers(self, wave: Wave | None = None) -> list[str]: ...
42
+ def check_preflight_context_gate(self, wave: Wave) -> list[str]: ...
43
+ def get_allowed_write_globs(self, track: WaveTrack) -> list[str]: ...
44
+ def get_forbidden_files(self, wave: Wave, track: WaveTrack) -> dict[str, str]: ...
45
+ def get_track_env_vars(self, track: WaveTrack) -> dict[str, str]: ...
46
+ def prepare_worktree_overlay(self, worktree_path: Path, track: WaveTrack) -> list[str]: ...
47
+ def verify_track_spec_scope(
48
+ self,
49
+ worktree_path: Path,
50
+ track: WaveTrack,
51
+ forbidden_files: dict[str, str],
52
+ ) -> list[str]: ...
53
+ def render_worker_command(self, track: WaveTrack) -> str: ...
54
+
55
+
56
+ def detect_sdd_kits(repo_root: Path) -> list[str]:
57
+ """Return list of all SDD kits detected in `repo_root` in priority order."""
58
+ root = repo_root.resolve()
59
+ detected: list[str] = []
60
+ for kit_name, adapter_cls in ADAPTER_REGISTRY.items():
61
+ if adapter_cls(root).detect():
62
+ detected.append(kit_name)
63
+ return detected
64
+
65
+
66
+ def _get_configured_default_kit(repo_root: Path) -> str | None:
67
+ """Read user-configured default_kit from .parallel-config.json or pyproject.toml."""
68
+ cfg_file = repo_root / ".parallel-config.json"
69
+ if cfg_file.is_file():
70
+ try:
71
+ data = json.loads(cfg_file.read_text(encoding="utf-8-sig"))
72
+ if isinstance(data, dict):
73
+ val = data.get("default_kit") or data.get("kit")
74
+ if isinstance(val, str) and val.strip():
75
+ return val.strip().lower()
76
+ except Exception:
77
+ pass
78
+
79
+ pyproject_file = repo_root / "pyproject.toml"
80
+ if pyproject_file.is_file():
81
+ try:
82
+ with open(pyproject_file, "rb") as f:
83
+ data = tomllib.load(f)
84
+ tool_cfg = data.get("tool", {}).get("sdd-parallel", {})
85
+ if isinstance(tool_cfg, dict):
86
+ val = tool_cfg.get("default_kit") or tool_cfg.get("kit")
87
+ if isinstance(val, str) and val.strip():
88
+ return val.strip().lower()
89
+ except Exception:
90
+ pass
91
+ return None
92
+
93
+
94
+ ADAPTER_REGISTRY: dict[str, type[SDDAdapterProtocol]] = {
95
+ "bmad": BMADAdapter,
96
+ "spec-kit": SpecKitAdapter,
97
+ "gsd": GSDHijackAdapter,
98
+ "openspec": OpenSpecAdapter,
99
+ "beads": BeadsAdapter,
100
+ }
101
+
102
+
103
+ def _instantiate_adapter(kit_name: str, root: Path) -> tuple[str, SDDAdapterProtocol]:
104
+ norm = kit_name.strip().lower()
105
+ if norm == "speckit":
106
+ norm = "spec-kit"
107
+ if norm in ADAPTER_REGISTRY:
108
+ adapter_cls = ADAPTER_REGISTRY[norm]
109
+ return norm, adapter_cls(root)
110
+ raise ValueError(f"Unsupported SDD kit: '{kit_name}'")
111
+
112
+
113
+ def diagnose_multi_kit_coexistence(repo_root: Path, selected_kit: str) -> list[str]:
114
+ """Return diagnostic warning if multiple SDD kits coexist in repo_root."""
115
+ detected = detect_sdd_kits(repo_root)
116
+ if len(detected) > 1:
117
+ return [
118
+ f"Multiple SDD frameworks detected in repository ({', '.join(detected)}). "
119
+ f"Active adapter: '{selected_kit}'. Use '--kit <name>' or set 'default_kit' "
120
+ "in .parallel-config.json to switch."
121
+ ]
122
+ return []
123
+
124
+
125
+ def get_adapter(repo_root: Path, kit: str = "auto") -> tuple[str, SDDAdapterProtocol]:
126
+ """Instantiate the requested SDD kit adapter (or auto-detect the primary kit)."""
127
+ root = repo_root.resolve()
128
+ normalized = kit.strip().lower()
129
+
130
+ if normalized == "auto":
131
+ cfg_kit = _get_configured_default_kit(root)
132
+ if cfg_kit is not None:
133
+ normalized = cfg_kit
134
+ else:
135
+ detected = detect_sdd_kits(root)
136
+ if len(detected) > 1:
137
+ # Select the first detected kit that has active (non-done) tasks
138
+ selected: str | None = None
139
+ for cand in detected:
140
+ _, cand_adapter = _instantiate_adapter(cand, root)
141
+ try:
142
+ active_tasks = cand_adapter.load_tasks(include_done=False)
143
+ if len(active_tasks) > 0:
144
+ selected = cand
145
+ break
146
+ except Exception:
147
+ continue
148
+ normalized = selected if selected is not None else detected[0]
149
+ elif len(detected) == 1:
150
+ normalized = detected[0]
151
+ else:
152
+ normalized = "bmad"
153
+
154
+ return _instantiate_adapter(normalized, root)
155
+
156
+
157
+ __all__ = [
158
+ "ADAPTER_REGISTRY",
159
+ "BMADAdapter",
160
+ "BeadsAdapter",
161
+ "GSDHijackAdapter",
162
+ "OpenSpecAdapter",
163
+ "SDDAdapterProtocol",
164
+ "SpecKitAdapter",
165
+ "detect_sdd_kits",
166
+ "diagnose_multi_kit_coexistence",
167
+ "get_adapter",
168
+ ]
@@ -0,0 +1,324 @@
1
+ """Beads DAG tracker adapter (.beads/issues.jsonl)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from sdd_parallel.models import EffortTier, TaskNode, TaskStatus, Wave, WaveTrack
13
+
14
+ _BACKTICK_PATH_RE = re.compile(
15
+ r"`([A-Za-z0-9_./-]+\.(?:py|ts|tsx|js|jsx|go|rs|sql|json|yaml|yml|toml|html|css|md))`"
16
+ )
17
+ _PREFIX_PATH_RE = re.compile(
18
+ r"(?:(?<=\s)|^)((?:src|tests|app|lib|pkg|internal|packages)/[A-Za-z0-9_./-]+\.(?:py|ts|tsx|js|jsx|go|rs|sql|json|yaml|yml|toml|html|css|md))\b"
19
+ )
20
+
21
+
22
+ class BeadsAdapter:
23
+ """Read-Only ACL for Steve Yegge's Beads DAG issue tracker (.beads/issues.jsonl)."""
24
+
25
+ kit_id: str = "beads"
26
+
27
+ def __init__(self, repo_root: Path) -> None:
28
+ self.repo_root = repo_root.resolve()
29
+ self.beads_dir = self.repo_root / ".beads"
30
+ if (self.beads_dir / "beads.jsonl").is_file():
31
+ self.issues_path = self.beads_dir / "beads.jsonl"
32
+ elif (self.beads_dir / "issues.jsonl").is_file():
33
+ self.issues_path = self.beads_dir / "issues.jsonl"
34
+ else:
35
+ self.issues_path = self.beads_dir / "beads.jsonl"
36
+
37
+ def get_existing_jsonl_paths(self) -> list[Path]:
38
+ """Return list of existing beads jsonl files (.beads/beads.jsonl, .beads/issues.jsonl)."""
39
+ paths: list[Path] = []
40
+ for name in ("beads.jsonl", "issues.jsonl"):
41
+ p = self.beads_dir / name
42
+ if p.is_file():
43
+ paths.append(p)
44
+ return paths
45
+
46
+ def detect(self) -> bool:
47
+ """Return True if .beads/beads.jsonl, .beads/issues.jsonl or .beads/ config exists."""
48
+ return (
49
+ (self.beads_dir / "beads.jsonl").is_file()
50
+ or (self.beads_dir / "issues.jsonl").is_file()
51
+ or (self.beads_dir / "config.yaml").is_file()
52
+ )
53
+
54
+ def _extract_files(self, record: dict[str, Any]) -> list[str]:
55
+ write_files: list[str] = []
56
+ raw_files = (
57
+ record.get("files") or record.get("write_files") or record.get("target_files") or []
58
+ )
59
+ if isinstance(raw_files, list):
60
+ for rf in raw_files:
61
+ if isinstance(rf, str):
62
+ clean_rf = rf.replace("\\", "/").strip()
63
+ if clean_rf and clean_rf not in write_files:
64
+ write_files.append(clean_rf)
65
+
66
+ text_block = "\n".join(
67
+ [
68
+ str(record.get("title", "")),
69
+ str(record.get("description", "")),
70
+ str(record.get("notes", "")),
71
+ ]
72
+ )
73
+ for fm in _BACKTICK_PATH_RE.finditer(text_block):
74
+ fp = fm.group(1).replace("\\", "/")
75
+ if fp not in write_files:
76
+ write_files.append(fp)
77
+ for fm in _PREFIX_PATH_RE.finditer(text_block):
78
+ fp = fm.group(1).replace("\\", "/")
79
+ if fp not in write_files:
80
+ write_files.append(fp)
81
+
82
+ return write_files
83
+
84
+ def _extract_dependencies(self, record: dict[str, Any], current_id: str) -> list[str]:
85
+ depends_on: list[str] = []
86
+ raw_deps = (
87
+ record.get("depends_on") or record.get("dependencies") or record.get("blocked_by") or []
88
+ )
89
+ if isinstance(raw_deps, str):
90
+ for part in re.split(r"[,; ]+", raw_deps):
91
+ clean_dep = part.strip().strip("`'\"")
92
+ if clean_dep and clean_dep != current_id and clean_dep not in depends_on:
93
+ depends_on.append(clean_dep)
94
+ elif isinstance(raw_deps, list):
95
+ for dep in raw_deps:
96
+ if isinstance(dep, str):
97
+ clean_dep = dep.strip().strip("`'\"")
98
+ if clean_dep and clean_dep != current_id and clean_dep not in depends_on:
99
+ depends_on.append(clean_dep)
100
+ elif isinstance(dep, dict):
101
+ dep_type = str(dep.get("type", "blocks")).lower().strip()
102
+ if dep_type not in {"blocks", "parent-child", "depends_on", ""}:
103
+ continue
104
+ dep_id = (
105
+ dep.get("target")
106
+ or dep.get("depends_on_id")
107
+ or dep.get("id")
108
+ or dep.get("issue_id")
109
+ )
110
+ if isinstance(dep_id, str):
111
+ clean_dep = dep_id.strip().strip("`'\"")
112
+ if clean_dep and clean_dep != current_id and clean_dep not in depends_on:
113
+ depends_on.append(clean_dep)
114
+ return depends_on
115
+
116
+ def _determine_effort(self, record: dict[str, Any], write_files: list[str]) -> EffortTier:
117
+ priority = record.get("priority")
118
+ if isinstance(priority, (int, float)):
119
+ if priority == 0:
120
+ return EffortTier.HIGH
121
+ if priority in {1, 2}:
122
+ return EffortTier.MEDIUM
123
+ if priority >= 3:
124
+ return EffortTier.LOW
125
+
126
+ raw_effort = record.get("effort") or record.get("story_points") or record.get("points")
127
+ if isinstance(raw_effort, str):
128
+ eff_lower = raw_effort.lower().strip()
129
+ if eff_lower == "low":
130
+ return EffortTier.LOW
131
+ if eff_lower == "high":
132
+ return EffortTier.HIGH
133
+ if eff_lower == "medium":
134
+ return EffortTier.MEDIUM
135
+ elif isinstance(raw_effort, (int, float)):
136
+ if raw_effort <= 2:
137
+ return EffortTier.LOW
138
+ if raw_effort >= 8:
139
+ return EffortTier.HIGH
140
+ return EffortTier.MEDIUM
141
+
142
+ if len(write_files) >= 5 or any("schema.py" in f for f in write_files):
143
+ return EffortTier.HIGH
144
+ if len(write_files) == 1 and not any(
145
+ s in write_files[0] for s in ("schema.py", "models.py")
146
+ ):
147
+ return EffortTier.LOW
148
+ return EffortTier.MEDIUM
149
+
150
+ def load_tasks(
151
+ self,
152
+ include_done: bool = False,
153
+ plan_source: str = "auto",
154
+ ) -> list[TaskNode]:
155
+ """Load issues from .beads/issues.jsonl and enrich with file footprints."""
156
+ if not self.issues_path.is_file():
157
+ return []
158
+
159
+ tasks: list[TaskNode] = []
160
+ for raw_line in self.issues_path.read_text(encoding="utf-8-sig").splitlines():
161
+ line = raw_line.strip()
162
+ if not line:
163
+ continue
164
+ try:
165
+ record: dict[str, Any] = json.loads(line)
166
+ except json.JSONDecodeError:
167
+ continue
168
+
169
+ tid = str(record.get("id") or "").strip()
170
+ if not tid:
171
+ continue
172
+
173
+ raw_status = str(record.get("status") or "backlog").lower()
174
+ if raw_status in {"closed", "done", "resolved", "completed"}:
175
+ status = TaskStatus.DONE
176
+ elif raw_status in {"in_progress", "in-progress", "started", "active", "wip"}:
177
+ status = TaskStatus.IN_PROGRESS
178
+ elif raw_status in {"blocked"}:
179
+ status = TaskStatus.BLOCKED
180
+ else:
181
+ status = TaskStatus.READY_FOR_DEV
182
+
183
+ if status == TaskStatus.DONE and not include_done:
184
+ continue
185
+
186
+ title = str(record.get("title") or tid)
187
+ write_files = self._extract_files(record)
188
+ depends_on = self._extract_dependencies(record, tid)
189
+ effort = self._determine_effort(record, write_files)
190
+
191
+ epic_val = (
192
+ record.get("epic")
193
+ or record.get("epic_id")
194
+ or record.get("parent")
195
+ or record.get("parent_id")
196
+ or "beads"
197
+ )
198
+
199
+ tasks.append(
200
+ TaskNode(
201
+ id=tid,
202
+ title=title,
203
+ epic_id=str(epic_val),
204
+ status=status,
205
+ effort=effort,
206
+ write_files=write_files,
207
+ depends_on=depends_on,
208
+ )
209
+ )
210
+
211
+ return tasks
212
+
213
+ def reset_tasks_status(
214
+ self,
215
+ task_ids: Sequence[str],
216
+ target_status: str = "ready-for-dev",
217
+ ) -> None:
218
+ """Reset status of issues in .beads/issues.jsonl to open."""
219
+ target_set = set(task_ids)
220
+ if not self.issues_path.is_file():
221
+ return
222
+ lines = self.issues_path.read_text(encoding="utf-8-sig").splitlines()
223
+ new_lines: list[str] = []
224
+ for line in lines:
225
+ if not line.strip():
226
+ continue
227
+ try:
228
+ rec: dict[str, Any] = json.loads(line)
229
+ except json.JSONDecodeError:
230
+ new_lines.append(line)
231
+ continue
232
+ issue_id = str(rec.get("id", ""))
233
+ if issue_id in target_set:
234
+ rec["status"] = "open"
235
+ rec["closed_at"] = None
236
+ new_lines.append(json.dumps(rec, ensure_ascii=False))
237
+ self.issues_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
238
+
239
+ def cleanup_wave_artifacts(self, wave_id: int, task_ids: Sequence[str]) -> None:
240
+ """Reset tasks and clean wave artifacts."""
241
+ self.reset_tasks_status(task_ids)
242
+
243
+ def check_wave_zero_blockers(self, wave: Wave | None = None) -> list[str]:
244
+ """Detect uncommitted changes in Git working tree."""
245
+ blockers: list[str] = []
246
+ try:
247
+ proc = subprocess.run(
248
+ ["git", "status", "--porcelain"],
249
+ cwd=self.repo_root,
250
+ capture_output=True,
251
+ text=True,
252
+ check=False,
253
+ )
254
+ dirty_lines: list[str] = []
255
+ for raw_line in proc.stdout.splitlines():
256
+ if len(raw_line) < 4:
257
+ continue
258
+ path_part = raw_line[3:].strip().strip('"').replace("\\", "/").rstrip("/")
259
+ if any(
260
+ path_part == pref or path_part.startswith(f"{pref}/")
261
+ for pref in (
262
+ ".tmp",
263
+ "_bmad-output/parallel-artifacts",
264
+ ".parallel",
265
+ ".worktrees",
266
+ ".parallel-lock.json",
267
+ )
268
+ ):
269
+ continue
270
+ dirty_lines.append(raw_line.strip())
271
+
272
+ if dirty_lines:
273
+ blockers.append(
274
+ f"Dirty Git working tree ({len(dirty_lines)} modified/untracked files). "
275
+ "Commit or stash before spawning parallel git worktrees."
276
+ )
277
+ except OSError:
278
+ pass
279
+ return blockers
280
+
281
+ def get_allowed_write_globs(self, track: WaveTrack) -> list[str]:
282
+ """Return allowed write globs for Beads issue database."""
283
+ return [".beads/issues.jsonl", ".beads/beads.jsonl"]
284
+
285
+ def get_forbidden_files(self, wave: Wave, track: WaveTrack) -> dict[str, str]:
286
+ """Return immutable Beads configuration file."""
287
+ return {
288
+ ".beads/config.yaml": "Beads tracker configuration is locked during wave execution",
289
+ }
290
+
291
+ def get_track_env_vars(self, track: WaveTrack) -> dict[str, str]:
292
+ return {}
293
+
294
+ def check_preflight_context_gate(self, wave: Wave) -> list[str]:
295
+ return []
296
+
297
+ def prepare_worktree_overlay(self, worktree_path: Path, track: WaveTrack) -> list[str]:
298
+ return []
299
+
300
+ def verify_track_spec_scope(
301
+ self,
302
+ worktree_path: Path,
303
+ track: WaveTrack,
304
+ forbidden_files: dict[str, str],
305
+ ) -> list[str]:
306
+ violations: list[str] = []
307
+ for sid in track.task_ids:
308
+ for fp in track.exclusive_write_files:
309
+ norm_fp = fp.replace("\\", "/")
310
+ if norm_fp in forbidden_files:
311
+ reason = forbidden_files[norm_fp]
312
+ violations.append(
313
+ f"Wave track {track.track_id} (task {sid}) "
314
+ f"touches forbidden file '{norm_fp}': {reason}"
315
+ )
316
+ return violations
317
+
318
+ def render_worker_command(self, track: WaveTrack) -> str:
319
+ """Render operator worker command for Beads track."""
320
+ return (
321
+ f"`bd update {track.task_ids[0]} --status in-progress`"
322
+ if track.task_ids
323
+ else "`bd update`"
324
+ )