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,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`"