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
sdd_parallel/__init__.py
ADDED
|
@@ -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
|
+
)
|