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,419 @@
|
|
|
1
|
+
"""OpenSpec adapter (openspec/changes/<change-id>/tasks.md and delta specs)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sdd_parallel.models import EffortTier, TaskNode, TaskStatus, Wave, WaveTrack
|
|
12
|
+
|
|
13
|
+
_CHECKLIST_RE = re.compile(r"^\s*[-*]\s*\[([ xX])\]\s*(.+)$")
|
|
14
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
|
|
15
|
+
_TITLE_RE = re.compile(r"^#+\s+(?:Proposal|Change|RFC)?[:\s]*(.+)$", re.IGNORECASE | re.MULTILINE)
|
|
16
|
+
_BACKTICK_PATH_RE = re.compile(
|
|
17
|
+
r"`([A-Za-z0-9_./-]+\.(?:py|ts|tsx|js|jsx|go|rs|sql|json|yaml|yml|toml|html|css|md))`"
|
|
18
|
+
)
|
|
19
|
+
_PREFIX_PATH_RE = re.compile(
|
|
20
|
+
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"
|
|
21
|
+
)
|
|
22
|
+
_DEP_CHANGE_RE = re.compile(
|
|
23
|
+
r"(?:depends\s+on|requires|prerequisite|blocked\s+by)[:\s]+`?([A-Za-z0-9_,-/ ]+)`?",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
_INLINE_LIST_RE = re.compile(r"^([A-Za-z0-9_]+):\s*\[(.*?)\]\s*$")
|
|
27
|
+
_SCALAR_LINE_RE = re.compile(r"^([A-Za-z0-9_]+):\s*(.+?)\s*$")
|
|
28
|
+
_LIST_ITEM_RE = re.compile(r"^\s*-\s*[\"']?([^\"']+)[\"']?\s*$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenSpecAdapter:
|
|
32
|
+
"""Read-Only ACL for Fission-AI OpenSpec (openspec/changes/<change-id>/)."""
|
|
33
|
+
|
|
34
|
+
kit_id: str = "openspec"
|
|
35
|
+
|
|
36
|
+
def __init__(self, repo_root: Path) -> None:
|
|
37
|
+
self.repo_root = repo_root.resolve()
|
|
38
|
+
self.openspec_dir = self.repo_root / "openspec"
|
|
39
|
+
self.changes_dir = self.openspec_dir / "changes"
|
|
40
|
+
|
|
41
|
+
def detect(self) -> bool:
|
|
42
|
+
"""Return True if OpenSpec directory structure exists."""
|
|
43
|
+
return (
|
|
44
|
+
self.changes_dir.is_dir()
|
|
45
|
+
or (self.openspec_dir / "specs").is_dir()
|
|
46
|
+
or (self.openspec_dir / "project.md").is_file()
|
|
47
|
+
or (self.openspec_dir / "config.yaml").is_file()
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _parse_frontmatter(text: str) -> dict[str, Any]:
|
|
52
|
+
m = _FRONTMATTER_RE.match(text)
|
|
53
|
+
if not m:
|
|
54
|
+
return {}
|
|
55
|
+
block = m.group(1)
|
|
56
|
+
data: dict[str, Any] = {}
|
|
57
|
+
current_list_key: str | None = None
|
|
58
|
+
|
|
59
|
+
for raw_line in block.splitlines():
|
|
60
|
+
line = raw_line.rstrip()
|
|
61
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
inline_m = _INLINE_LIST_RE.match(line.strip())
|
|
65
|
+
if inline_m:
|
|
66
|
+
key, inner = inline_m.groups()
|
|
67
|
+
items = [
|
|
68
|
+
item.strip().strip("\"'")
|
|
69
|
+
for item in inner.split(",")
|
|
70
|
+
if item.strip().strip("\"'")
|
|
71
|
+
]
|
|
72
|
+
data[key.lower()] = items
|
|
73
|
+
current_list_key = None
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if line.strip().endswith(":") and not line.startswith(" "):
|
|
77
|
+
current_list_key = line.strip()[:-1].strip().lower()
|
|
78
|
+
data[current_list_key] = []
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
item_m = _LIST_ITEM_RE.match(line)
|
|
82
|
+
if item_m and current_list_key is not None:
|
|
83
|
+
cur_val = data.get(current_list_key)
|
|
84
|
+
if isinstance(cur_val, list):
|
|
85
|
+
cur_val.append(item_m.group(1).strip().strip("\"'"))
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
scalar_m = _SCALAR_LINE_RE.match(line.strip())
|
|
89
|
+
if scalar_m:
|
|
90
|
+
key, val = scalar_m.groups()
|
|
91
|
+
data[key.lower()] = val.strip().strip("\"'")
|
|
92
|
+
current_list_key = None
|
|
93
|
+
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
def _extract_files_from_text(self, text: str) -> list[str]:
|
|
97
|
+
write_files: list[str] = []
|
|
98
|
+
for fm in _BACKTICK_PATH_RE.finditer(text):
|
|
99
|
+
fp = fm.group(1).replace("\\", "/")
|
|
100
|
+
if not fp.startswith("tests/") and fp not in write_files:
|
|
101
|
+
write_files.append(fp)
|
|
102
|
+
for fm in _PREFIX_PATH_RE.finditer(text):
|
|
103
|
+
fp = fm.group(1).replace("\\", "/")
|
|
104
|
+
if not fp.startswith("tests/") and fp not in write_files:
|
|
105
|
+
write_files.append(fp)
|
|
106
|
+
return write_files
|
|
107
|
+
|
|
108
|
+
def load_tasks(
|
|
109
|
+
self,
|
|
110
|
+
include_done: bool = False,
|
|
111
|
+
plan_source: str = "auto",
|
|
112
|
+
) -> list[TaskNode]:
|
|
113
|
+
"""Load active OpenSpec change folders as schedulable TaskNodes."""
|
|
114
|
+
if not self.changes_dir.is_dir():
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
tasks: list[TaskNode] = []
|
|
118
|
+
for change_dir in sorted(self.changes_dir.iterdir()):
|
|
119
|
+
if not change_dir.is_dir() or change_dir.name in {"archive", ".archive"}:
|
|
120
|
+
continue
|
|
121
|
+
if "archive" in change_dir.parts and not include_done:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
change_id = change_dir.name
|
|
125
|
+
tasks_file = change_dir / "tasks.md"
|
|
126
|
+
proposal_file = change_dir / "proposal.md"
|
|
127
|
+
design_file = change_dir / "design.md"
|
|
128
|
+
|
|
129
|
+
combined_texts: list[str] = []
|
|
130
|
+
prop_text = ""
|
|
131
|
+
if proposal_file.is_file():
|
|
132
|
+
prop_text = proposal_file.read_text(encoding="utf-8-sig")
|
|
133
|
+
combined_texts.append(prop_text)
|
|
134
|
+
if tasks_file.is_file():
|
|
135
|
+
combined_texts.append(tasks_file.read_text(encoding="utf-8-sig"))
|
|
136
|
+
if design_file.is_file():
|
|
137
|
+
combined_texts.append(design_file.read_text(encoding="utf-8-sig"))
|
|
138
|
+
for delta_spec in sorted(change_dir.rglob("specs/*/spec.md")):
|
|
139
|
+
combined_texts.append(delta_spec.read_text(encoding="utf-8-sig"))
|
|
140
|
+
|
|
141
|
+
if not combined_texts:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
all_text = "\n".join(combined_texts)
|
|
145
|
+
fm = self._parse_frontmatter(prop_text) if prop_text else {}
|
|
146
|
+
|
|
147
|
+
total_items = 0
|
|
148
|
+
done_items = 0
|
|
149
|
+
if tasks_file.is_file():
|
|
150
|
+
for line in tasks_file.read_text(encoding="utf-8-sig").splitlines():
|
|
151
|
+
m = _CHECKLIST_RE.match(line)
|
|
152
|
+
if m:
|
|
153
|
+
total_items += 1
|
|
154
|
+
if m.group(1).lower() == "x":
|
|
155
|
+
done_items += 1
|
|
156
|
+
|
|
157
|
+
fm_status = str(fm.get("status", "")).lower()
|
|
158
|
+
if fm_status in {"done", "merged", "completed"}:
|
|
159
|
+
status = TaskStatus.DONE
|
|
160
|
+
elif total_items > 0 and done_items == total_items:
|
|
161
|
+
status = TaskStatus.DONE
|
|
162
|
+
elif done_items > 0 or fm_status in {"in_progress", "in-progress", "active"}:
|
|
163
|
+
status = TaskStatus.IN_PROGRESS
|
|
164
|
+
else:
|
|
165
|
+
status = TaskStatus.READY_FOR_DEV
|
|
166
|
+
|
|
167
|
+
if status == TaskStatus.DONE and not include_done:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
# Extract write files
|
|
171
|
+
write_files = self._extract_files_from_text(all_text)
|
|
172
|
+
fm_files = fm.get("files") or fm.get("files_modified")
|
|
173
|
+
if isinstance(fm_files, list):
|
|
174
|
+
for f in fm_files:
|
|
175
|
+
clean_f = str(f).replace("\\", "/").strip()
|
|
176
|
+
if clean_f and clean_f not in write_files:
|
|
177
|
+
write_files.append(clean_f)
|
|
178
|
+
|
|
179
|
+
# Extract dependencies
|
|
180
|
+
depends_on: list[str] = []
|
|
181
|
+
fm_deps = fm.get("depends_on") or fm.get("dependencies") or fm.get("requires")
|
|
182
|
+
if isinstance(fm_deps, list):
|
|
183
|
+
for dep in fm_deps:
|
|
184
|
+
d_str = str(dep).strip().strip("`'\"")
|
|
185
|
+
if d_str and d_str != change_id and d_str not in depends_on:
|
|
186
|
+
depends_on.append(d_str)
|
|
187
|
+
|
|
188
|
+
for dm in _DEP_CHANGE_RE.finditer(all_text):
|
|
189
|
+
raw_deps = dm.group(1)
|
|
190
|
+
for part in re.split(r"[,; ]+", raw_deps):
|
|
191
|
+
dep_id = part.strip().strip("`'\"")
|
|
192
|
+
if dep_id and dep_id != change_id and dep_id not in depends_on:
|
|
193
|
+
depends_on.append(dep_id)
|
|
194
|
+
|
|
195
|
+
# Match depends on / requires in proposal without frontmatter
|
|
196
|
+
for match in re.finditer(
|
|
197
|
+
r"(?:depends\s+on|requires|after)\s*[:\-]?\s*([A-Za-z0-9_.\-,\s]+)",
|
|
198
|
+
all_text,
|
|
199
|
+
re.IGNORECASE,
|
|
200
|
+
):
|
|
201
|
+
for part in re.split(r"[,; ]+", match.group(1)):
|
|
202
|
+
dep_id = part.strip().strip("`'\"")
|
|
203
|
+
if dep_id and dep_id != change_id and dep_id not in depends_on:
|
|
204
|
+
depends_on.append(dep_id)
|
|
205
|
+
|
|
206
|
+
# Determine title
|
|
207
|
+
title = fm.get("title")
|
|
208
|
+
if not title and prop_text:
|
|
209
|
+
tm = _TITLE_RE.search(prop_text)
|
|
210
|
+
if tm:
|
|
211
|
+
title = tm.group(1).strip()
|
|
212
|
+
if not title:
|
|
213
|
+
for line in prop_text.splitlines():
|
|
214
|
+
clean_l = line.strip()
|
|
215
|
+
if clean_l.startswith("# Proposal:") or clean_l.startswith("# Proposal -"):
|
|
216
|
+
title = clean_l.split(":", 1)[-1].strip()
|
|
217
|
+
break
|
|
218
|
+
if clean_l.startswith("# ") and not clean_l.lower().startswith("# why"):
|
|
219
|
+
title = clean_l[2:].strip()
|
|
220
|
+
break
|
|
221
|
+
if not title:
|
|
222
|
+
title = f"OpenSpec change: {change_id}"
|
|
223
|
+
|
|
224
|
+
# Determine effort
|
|
225
|
+
effort = EffortTier.MEDIUM
|
|
226
|
+
fm_eff = fm.get("effort")
|
|
227
|
+
if isinstance(fm_eff, str):
|
|
228
|
+
eff_lower = fm_eff.lower()
|
|
229
|
+
if eff_lower == "low":
|
|
230
|
+
effort = EffortTier.LOW
|
|
231
|
+
elif eff_lower == "high":
|
|
232
|
+
effort = EffortTier.HIGH
|
|
233
|
+
elif len(write_files) >= 5 or any("schema.py" in f for f in write_files):
|
|
234
|
+
effort = EffortTier.HIGH
|
|
235
|
+
elif len(write_files) == 1:
|
|
236
|
+
effort = EffortTier.LOW
|
|
237
|
+
|
|
238
|
+
tasks.append(
|
|
239
|
+
TaskNode(
|
|
240
|
+
id=change_id,
|
|
241
|
+
title=str(title),
|
|
242
|
+
epic_id="openspec",
|
|
243
|
+
status=status,
|
|
244
|
+
effort=effort,
|
|
245
|
+
write_files=write_files,
|
|
246
|
+
depends_on=depends_on,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return tasks
|
|
251
|
+
|
|
252
|
+
def load_granular_tasks(self, change_id: str, include_done: bool = False) -> list[TaskNode]:
|
|
253
|
+
"""Decompose a single OpenSpec change folder into its individual task checklist items."""
|
|
254
|
+
change_dir = self.changes_dir / change_id
|
|
255
|
+
tasks_file = change_dir / "tasks.md"
|
|
256
|
+
if not tasks_file.is_file():
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
tasks: list[TaskNode] = []
|
|
260
|
+
last_task_id: str | None = None
|
|
261
|
+
task_idx = 1
|
|
262
|
+
for line in tasks_file.read_text(encoding="utf-8-sig").splitlines():
|
|
263
|
+
m = _CHECKLIST_RE.match(line)
|
|
264
|
+
if not m:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
chk, desc = m.groups()
|
|
268
|
+
sub_id = f"{change_id}-T{task_idx:02d}"
|
|
269
|
+
task_idx += 1
|
|
270
|
+
status = TaskStatus.DONE if chk.lower() == "x" else TaskStatus.READY_FOR_DEV
|
|
271
|
+
if status == TaskStatus.DONE and not include_done:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
item_files = self._extract_files_from_text(desc)
|
|
275
|
+
depends_on: list[str] = []
|
|
276
|
+
if last_task_id is not None:
|
|
277
|
+
depends_on.append(last_task_id)
|
|
278
|
+
last_task_id = sub_id
|
|
279
|
+
|
|
280
|
+
tasks.append(
|
|
281
|
+
TaskNode(
|
|
282
|
+
id=sub_id,
|
|
283
|
+
title=desc.strip(),
|
|
284
|
+
epic_id=change_id,
|
|
285
|
+
status=status,
|
|
286
|
+
effort=EffortTier.MEDIUM if len(item_files) >= 3 else EffortTier.LOW,
|
|
287
|
+
write_files=item_files,
|
|
288
|
+
depends_on=depends_on,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return tasks
|
|
293
|
+
|
|
294
|
+
def reset_tasks_status(
|
|
295
|
+
self,
|
|
296
|
+
task_ids: Sequence[str],
|
|
297
|
+
target_status: str = "ready-for-dev",
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Reset checklist items for task_ids in openspec changes tasks.md."""
|
|
300
|
+
target_set = set(task_ids)
|
|
301
|
+
if not self.changes_dir.is_dir():
|
|
302
|
+
return
|
|
303
|
+
for tf in self.changes_dir.rglob("tasks.md"):
|
|
304
|
+
lines = tf.read_text(encoding="utf-8-sig").splitlines()
|
|
305
|
+
new_lines: list[str] = []
|
|
306
|
+
for line in lines:
|
|
307
|
+
if any(tid in line for tid in target_set):
|
|
308
|
+
line = re.sub(r"\[[xX]\]", "[ ]", line, count=1)
|
|
309
|
+
new_lines.append(line)
|
|
310
|
+
tf.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
311
|
+
|
|
312
|
+
def cleanup_wave_artifacts(self, wave_id: int, task_ids: Sequence[str]) -> None:
|
|
313
|
+
"""Reset tasks and clean wave artifacts."""
|
|
314
|
+
self.reset_tasks_status(task_ids)
|
|
315
|
+
|
|
316
|
+
def check_wave_zero_blockers(self, wave: Wave | None = None) -> list[str]:
|
|
317
|
+
"""Detect uncommitted changes in Git working tree."""
|
|
318
|
+
blockers: list[str] = []
|
|
319
|
+
try:
|
|
320
|
+
proc = subprocess.run(
|
|
321
|
+
["git", "status", "--porcelain"],
|
|
322
|
+
cwd=self.repo_root,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
check=False,
|
|
326
|
+
)
|
|
327
|
+
dirty_lines: list[str] = []
|
|
328
|
+
for raw_line in proc.stdout.splitlines():
|
|
329
|
+
if len(raw_line) < 4:
|
|
330
|
+
continue
|
|
331
|
+
path_part = raw_line[3:].strip().strip('"').replace("\\", "/").rstrip("/")
|
|
332
|
+
if any(
|
|
333
|
+
path_part == pref or path_part.startswith(f"{pref}/")
|
|
334
|
+
for pref in (
|
|
335
|
+
".tmp",
|
|
336
|
+
"_bmad-output/parallel-artifacts",
|
|
337
|
+
".parallel",
|
|
338
|
+
".worktrees",
|
|
339
|
+
".parallel-lock.json",
|
|
340
|
+
)
|
|
341
|
+
):
|
|
342
|
+
continue
|
|
343
|
+
dirty_lines.append(raw_line.strip())
|
|
344
|
+
|
|
345
|
+
if dirty_lines:
|
|
346
|
+
blockers.append(
|
|
347
|
+
f"Dirty Git working tree ({len(dirty_lines)} modified/untracked files). "
|
|
348
|
+
"Commit or stash before spawning parallel git worktrees."
|
|
349
|
+
)
|
|
350
|
+
except OSError:
|
|
351
|
+
pass
|
|
352
|
+
return blockers
|
|
353
|
+
|
|
354
|
+
def get_allowed_write_globs(self, track: WaveTrack) -> list[str]:
|
|
355
|
+
"""Return allowed write globs for OpenSpec change directories of this track."""
|
|
356
|
+
return [f"openspec/changes/{sid}/**" for sid in track.task_ids]
|
|
357
|
+
|
|
358
|
+
def get_forbidden_files(self, wave: Wave, track: WaveTrack) -> dict[str, str]:
|
|
359
|
+
"""Return immutable OpenSpec specs and sibling change folders."""
|
|
360
|
+
forbidden: dict[str, str] = {}
|
|
361
|
+
canonical_reason = "Canonical OpenSpec specs are updated only via openspec archive"
|
|
362
|
+
forbidden["openspec/project.md"] = canonical_reason
|
|
363
|
+
forbidden["openspec/config.yaml"] = canonical_reason
|
|
364
|
+
|
|
365
|
+
specs_dir = self.openspec_dir / "specs"
|
|
366
|
+
if specs_dir.is_dir():
|
|
367
|
+
for p in sorted(specs_dir.rglob("*")):
|
|
368
|
+
if p.is_file():
|
|
369
|
+
forbidden[p.relative_to(self.repo_root).as_posix()] = canonical_reason
|
|
370
|
+
else:
|
|
371
|
+
forbidden["openspec/specs/**"] = canonical_reason
|
|
372
|
+
|
|
373
|
+
for sibling in wave.tracks:
|
|
374
|
+
if sibling.track_id == track.track_id:
|
|
375
|
+
continue
|
|
376
|
+
for sib_task in sibling.task_ids:
|
|
377
|
+
sib_dir = self.changes_dir / sib_task
|
|
378
|
+
if sib_dir.is_dir():
|
|
379
|
+
for p in sorted(sib_dir.rglob("*")):
|
|
380
|
+
if p.is_file():
|
|
381
|
+
forbidden[p.relative_to(self.repo_root).as_posix()] = (
|
|
382
|
+
f"Locked by sibling track '{sibling.track_id}'"
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
forbidden[f"openspec/changes/{sib_task}/**"] = (
|
|
386
|
+
f"Locked by sibling track '{sibling.track_id}'"
|
|
387
|
+
)
|
|
388
|
+
return forbidden
|
|
389
|
+
|
|
390
|
+
def get_track_env_vars(self, track: WaveTrack) -> dict[str, str]:
|
|
391
|
+
return {}
|
|
392
|
+
|
|
393
|
+
def check_preflight_context_gate(self, wave: Wave) -> list[str]:
|
|
394
|
+
return []
|
|
395
|
+
|
|
396
|
+
def prepare_worktree_overlay(self, worktree_path: Path, track: WaveTrack) -> list[str]:
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
def verify_track_spec_scope(
|
|
400
|
+
self,
|
|
401
|
+
worktree_path: Path,
|
|
402
|
+
track: WaveTrack,
|
|
403
|
+
forbidden_files: dict[str, str],
|
|
404
|
+
) -> list[str]:
|
|
405
|
+
violations: list[str] = []
|
|
406
|
+
for sid in track.task_ids:
|
|
407
|
+
for fp in track.exclusive_write_files:
|
|
408
|
+
norm_fp = fp.replace("\\", "/")
|
|
409
|
+
if norm_fp in forbidden_files:
|
|
410
|
+
reason = forbidden_files[norm_fp]
|
|
411
|
+
violations.append(
|
|
412
|
+
f"Wave track {track.track_id} (task {sid}) "
|
|
413
|
+
f"touches forbidden file '{norm_fp}': {reason}"
|
|
414
|
+
)
|
|
415
|
+
return violations
|
|
416
|
+
|
|
417
|
+
def render_worker_command(self, track: WaveTrack) -> str:
|
|
418
|
+
"""Render operator worker command for OpenSpec track."""
|
|
419
|
+
return f"`/openspec-apply {track.task_ids[0]}`" if track.task_ids else "`/openspec-apply`"
|