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,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
|
+
)
|