its-magic 0.1.3-1 → 0.1.3-2

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.
Files changed (46) hide show
  1. package/installer.ps1 +8 -0
  2. package/installer.py +8 -0
  3. package/installer.sh +1 -0
  4. package/package.json +1 -1
  5. package/template/.cursor/commands/auto.md +90 -0
  6. package/template/.cursor/commands/execute.md +26 -0
  7. package/template/.cursor/commands/refresh-context.md +32 -0
  8. package/template/.cursor/commands/sovereign-critic.md +104 -0
  9. package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
  10. package/template/.cursor/scratchpad.md +141 -0
  11. package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
  12. package/template/decisions/DEC-0104.md +279 -0
  13. package/template/decisions/DEC-0105.md +231 -0
  14. package/template/decisions/DEC-0107.md +246 -0
  15. package/template/docs/engineering/architecture.md +78 -0
  16. package/template/docs/engineering/auto-orchestration-reference.md +7 -0
  17. package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
  18. package/template/docs/engineering/reason_codes.md +411 -0
  19. package/template/docs/engineering/runbook.md +922 -0
  20. package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
  21. package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
  22. package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
  23. package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
  24. package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
  25. package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
  26. package/template/scripts/check_intake_template_parity.py +119 -0
  27. package/template/scripts/decision_ledger_lib.py +732 -0
  28. package/template/scripts/ledger_validate.py +153 -0
  29. package/template/scripts/parallel_dev_arbiter.py +923 -0
  30. package/template/scripts/release_trigger_adapters.py +843 -0
  31. package/template/scripts/self_healing_deploy_lib.py +463 -0
  32. package/template/scripts/self_healing_deploy_validate.py +78 -0
  33. package/template/scripts/sovereign_convergence_lib.py +994 -0
  34. package/template/scripts/sovereign_convergence_validate.py +206 -0
  35. package/template/scripts/sovereign_critic_lib.py +629 -0
  36. package/template/scripts/sovereign_critic_validate.py +131 -0
  37. package/template/scripts/sovereign_loop_lib.py +828 -0
  38. package/template/scripts/sovereign_loop_validate.py +122 -0
  39. package/template/scripts/sovereign_memory_lib.py +869 -0
  40. package/template/scripts/sovereign_memory_validate.py +153 -0
  41. package/template/scripts/sovereign_role_manifest_lib.py +547 -0
  42. package/template/scripts/sovereign_role_manifest_validate.py +105 -0
  43. package/template/tests/us0108_contract_test.py +207 -0
  44. package/template/tests/us0109_contract_test.py +209 -0
  45. package/template/tests/us0109_us0110_compose_test.py +34 -0
  46. package/template/tests/us0111_contract_test.py +345 -0
@@ -0,0 +1,923 @@
1
+ #!/usr/bin/env python3
2
+ """Parallel Instance Arbitrage library (US-0108 / DEC-0108).
3
+
4
+ Reason codes:
5
+ PARALLEL_DEV_DISABLED, PARALLEL_DEV_WORKTREE_CREATE_FAILED,
6
+ PARALLEL_DEV_WORKTREE_CLEANUP_FAILED, PARALLEL_DEV_SELECTION_NO_PASS,
7
+ PARALLEL_DEV_MERGE_CONFLICT, PARALLEL_DEV_RESOURCE_CAP_EXHAUSTED,
8
+ PARALLEL_DEV_RESOURCE_LOCK_FAILED, PARALLEL_DEV_EXECUTE_FAILED,
9
+ PARALLEL_DEV_ANTI_SLOP_BELOW_THRESHOLD, PARALLEL_DEV_MERGE_TIMEOUT,
10
+ PARALLEL_DEV_MANUAL_HALT, PARALLEL_DEV_PICK_SCHEMA_INVALID
11
+
12
+ Default-off: SOVEREIGN_PARALLEL_DEV=0 → zero overhead.
13
+
14
+ Compose guards (non-negotiable):
15
+ US-0047 unchanged (bulk execute step 22 unchanged)
16
+ US-0092 unchanged (full autonomy outer driver unchanged)
17
+ US-0103 unchanged (ledger schema unchanged; read-only consumer)
18
+ US-0104 unchanged (critic schema unchanged; read-only anti_slop consumer)
19
+ US-0107 unchanged (sovereign loop unchanged; consumer only)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ from collections import namedtuple
34
+ from pathlib import Path
35
+ from typing import Any, Dict, List, Optional, Tuple
36
+
37
+ _SCRIPT_DIR = Path(__file__).resolve().parent
38
+ _REPO_ROOT = _SCRIPT_DIR.parent
39
+ if str(_SCRIPT_DIR) not in sys.path:
40
+ sys.path.insert(0, str(_SCRIPT_DIR))
41
+
42
+
43
+ # --- Scratchpad key contracts (DEC-0108 §1) ------------------------------------
44
+
45
+ SOVEREIGN_PARALLEL_DEV_KEY = "SOVEREIGN_PARALLEL_DEV"
46
+ AUTO_SOVEREIGN_PARALLEL_N_KEY = "AUTO_SOVEREIGN_PARALLEL_N"
47
+ AUTO_SOVEREIGN_PARALLEL_MAX_TOTAL_KEY = "AUTO_SOVEREIGN_PARALLEL_MAX_TOTAL"
48
+ AUTO_SOVEREIGN_MERGE_RESOLVE_KEY = "AUTO_SOVEREIGN_MERGE_RESOLVE"
49
+ AUTO_SOVEREIGN_WORKTREE_KEEP_KEY = "AUTO_SOVEREIGN_WORKTREE_KEEP"
50
+ AUTO_SOVEREIGN_PARALLEL_QA_KEY = "AUTO_SOVEREIGN_PARALLEL_QA"
51
+ AUTO_SOVEREIGN_PARALLEL_QA_ARBITER_KEY = "AUTO_SOVEREIGN_PARALLEL_QA_ARBITER"
52
+ AUTO_SOVEREIGN_PARALLEL_ANTI_SLOP_THRESHOLD_KEY = "AUTO_SOVEREIGN_PARALLEL_ANTI_SLOP_THRESHOLD"
53
+ AUTO_SOVEREIGN_PARALLEL_REWORK_MAX_KEY = "AUTO_SOVEREIGN_PARALLEL_REWORK_MAX"
54
+ AUTO_SOVEREIGN_PARALLEL_MERGE_TIMEOUT_SEC_KEY = "AUTO_SOVEREIGN_PARALLEL_MERGE_TIMEOUT_SEC"
55
+
56
+ SCRATCHPAD_KEY_DEFAULTS: dict[str, str] = {
57
+ SOVEREIGN_PARALLEL_DEV_KEY: "0",
58
+ AUTO_SOVEREIGN_PARALLEL_N_KEY: "3",
59
+ AUTO_SOVEREIGN_PARALLEL_MAX_TOTAL_KEY: "6",
60
+ AUTO_SOVEREIGN_MERGE_RESOLVE_KEY: "first_pass_wins",
61
+ AUTO_SOVEREIGN_WORKTREE_KEEP_KEY: "0",
62
+ AUTO_SOVEREIGN_PARALLEL_QA_KEY: "0",
63
+ AUTO_SOVEREIGN_PARALLEL_QA_ARBITER_KEY: "critic_first_pass",
64
+ AUTO_SOVEREIGN_PARALLEL_ANTI_SLOP_THRESHOLD_KEY: "6",
65
+ AUTO_SOVEREIGN_PARALLEL_REWORK_MAX_KEY: "2",
66
+ AUTO_SOVEREIGN_PARALLEL_MERGE_TIMEOUT_SEC_KEY: "60",
67
+ }
68
+
69
+ SOVEREIGN_PARALLEL_DEV_VALUES = frozenset({"0", "1"})
70
+ MERGE_RESOLVE_VALUES = frozenset({"first_pass_wins", "last_pass_wins", "winner_takes_all", "manual"})
71
+ QA_ARBITER_VALUES = frozenset({"critic_first_pass", "majority_vote"})
72
+
73
+
74
+ # --- Named tuples ---------------------------------------------------------------
75
+
76
+ WorktreeContext = namedtuple(
77
+ "WorktreeContext",
78
+ ["instance_id", "path", "branch", "status"],
79
+ )
80
+
81
+ MergeResult = namedtuple(
82
+ "MergeResult",
83
+ ["success", "branch", "commit_hash", "conflicts"],
84
+ )
85
+
86
+ ExecuteResult = namedtuple(
87
+ "ExecuteResult",
88
+ ["winner_worktree", "merge_result", "qa_results"],
89
+ )
90
+
91
+ PickRecord = namedtuple(
92
+ "PickRecord",
93
+ [
94
+ "schema_version", "story_id", "winner_instance_id", "worktree_path",
95
+ "qa_verdict", "anti_slop_score", "proof_issued_at", "merge_policy",
96
+ "runner_ts_utc", "orchestrator_run_id", "loser_instance_ids",
97
+ ],
98
+ )
99
+
100
+
101
+ # --- Scratchpad parsing ---------------------------------------------------------
102
+
103
+ def parse_scratchpad_key(line: str) -> Optional[Tuple[str, str]]:
104
+ """Parse a scratchpad KEY=VALUE line. Ignore comments and blanks."""
105
+ stripped = line.strip()
106
+ if not stripped or stripped.startswith("#"):
107
+ return None
108
+ m = re.match(r"^([A-Z_][A-Z0-9_]*)=(.*)", stripped)
109
+ if m:
110
+ return m.group(1), m.group(2)
111
+ return None
112
+
113
+
114
+ def read_scratchpad(path: Path) -> dict[str, str]:
115
+ """Read scratchpad file into dict of key=value pairs."""
116
+ result: dict[str, str] = {}
117
+ if not path.is_file():
118
+ return result
119
+ for line in path.read_text(encoding="utf-8").splitlines():
120
+ kv = parse_scratchpad_key(line)
121
+ if kv:
122
+ result[kv[0]] = kv[1]
123
+ return result
124
+
125
+
126
+ def read_scratchpad_with_defaults(scratchpad_path: Path) -> dict[str, str]:
127
+ """Read scratchpad applying US-0108 defaults for missing keys."""
128
+ raw = read_scratchpad(scratchpad_path)
129
+ merged = dict(SCRATCHPAD_KEY_DEFAULTS)
130
+ merged.update(raw)
131
+ return merged
132
+
133
+
134
+ def is_parallel_enabled(scratchpad_path: Path) -> bool:
135
+ """Return True only when SOVEREIGN_PARALLEL_DEV=1 explicitly."""
136
+ raw = read_scratchpad(scratchpad_path)
137
+ return raw.get(SOVEREIGN_PARALLEL_DEV_KEY, "0").strip() == "1"
138
+
139
+
140
+ # --- Reason codes ---------------------------------------------------------------
141
+
142
+ class ReasonCode:
143
+ PARALLEL_DEV_DISABLED = "PARALLEL_DEV_DISABLED"
144
+ PARALLEL_DEV_WORKTREE_CREATE_FAILED = "PARALLEL_DEV_WORKTREE_CREATE_FAILED"
145
+ PARALLEL_DEV_WORKTREE_CLEANUP_FAILED = "PARALLEL_DEV_WORKTREE_CLEANUP_FAILED"
146
+ PARALLEL_DEV_SELECTION_NO_PASS = "PARALLEL_DEV_SELECTION_NO_PASS"
147
+ PARALLEL_DEV_MERGE_CONFLICT = "PARALLEL_DEV_MERGE_CONFLICT"
148
+ PARALLEL_DEV_RESOURCE_CAP_EXHAUSTED = "PARALLEL_DEV_RESOURCE_CAP_EXHAUSTED"
149
+ PARALLEL_DEV_RESOURCE_LOCK_FAILED = "PARALLEL_DEV_RESOURCE_LOCK_FAILED"
150
+ PARALLEL_DEV_EXECUTE_FAILED = "PARALLEL_DEV_EXECUTE_FAILED"
151
+ PARALLEL_DEV_ANTI_SLOP_BELOW_THRESHOLD = "PARALLEL_DEV_ANTI_SLOP_BELOW_THRESHOLD"
152
+ PARALLEL_DEV_MERGE_TIMEOUT = "PARALLEL_DEV_MERGE_TIMEOUT"
153
+ PARALLEL_DEV_MANUAL_HALT = "PARALLEL_DEV_MANUAL_HALT"
154
+ PARALLEL_DEV_PICK_SCHEMA_INVALID = "PARALLEL_DEV_PICK_SCHEMA_INVALID"
155
+
156
+
157
+ # --- T-002/T-003: Worktree isolation (AC-2) -------------------------------------
158
+
159
+ def _git_dir(repo_root: Path) -> Path:
160
+ return repo_root / ".git"
161
+
162
+
163
+ def worktree_path_pattern(story_id: str) -> str:
164
+ """Return gitignore-compatible pattern for US-0108 worktrees."""
165
+ return f".git/worktrees/us0108-{story_id}-*"
166
+
167
+
168
+ def _worktree_path(repo_root: Path, story_id: str, instance_idx: int) -> Path:
169
+ return _git_dir(repo_root) / "worktrees" / f"us0108-{story_id}-{instance_idx}"
170
+
171
+
172
+ def _worktree_branch(story_id: str, instance_idx: int) -> str:
173
+ return f"us0108-{story_id}-{instance_idx}"
174
+
175
+
176
+ def create_worktree(
177
+ story_id: str,
178
+ instance_idx: int,
179
+ base_branch: str = "main",
180
+ repo_root: Optional[Path] = None,
181
+ ) -> WorktreeContext:
182
+ """Create an isolated git worktree for a parallel instance.
183
+
184
+ Pattern: .git/worktrees/us0108-<story_id>-<instance_idx>/
185
+ Branch: us0108-<story_id>-<instance_idx>
186
+ """
187
+ if repo_root is None:
188
+ repo_root = _REPO_ROOT
189
+
190
+ wt_path = _worktree_path(repo_root, story_id, instance_idx)
191
+ wt_branch = _worktree_branch(story_id, instance_idx)
192
+ instance_id = f"{story_id}-inst{instance_idx}"
193
+
194
+ env = os.environ.copy()
195
+ env["GIT_DIR"] = str(_git_dir(repo_root))
196
+ env["GIT_WORK_TREE"] = str(wt_path)
197
+
198
+ try:
199
+ wt_path.parent.mkdir(parents=True, exist_ok=True)
200
+ subprocess.run(
201
+ ["git", "worktree", "add", str(wt_path), "-b", wt_branch, base_branch],
202
+ cwd=str(repo_root),
203
+ check=True,
204
+ capture_output=True,
205
+ env=env,
206
+ )
207
+ return WorktreeContext(
208
+ instance_id=instance_id,
209
+ path=str(wt_path),
210
+ branch=wt_branch,
211
+ status="created",
212
+ )
213
+ except (subprocess.CalledProcessError, OSError) as exc:
214
+ return WorktreeContext(
215
+ instance_id=instance_id,
216
+ path=str(wt_path),
217
+ branch=wt_branch,
218
+ status=f"failed:{exc}",
219
+ )
220
+
221
+
222
+ def create_worktrees(
223
+ base_branch: str,
224
+ instance_count: int,
225
+ story_id: str = "story",
226
+ repo_root: Optional[Path] = None,
227
+ ) -> List[WorktreeContext]:
228
+ """Create N isolated worktrees. Returns list of WorktreeContext."""
229
+ if instance_count < 1:
230
+ raise ValueError("instance_count must be >= 1")
231
+ results = []
232
+ for idx in range(instance_count):
233
+ ctx = create_worktree(story_id, idx, base_branch, repo_root)
234
+ results.append(ctx)
235
+ return results
236
+
237
+
238
+ def list_worktrees(repo_root: Optional[Path] = None) -> List[str]:
239
+ """List existing worktrees (paths only)."""
240
+ if repo_root is None:
241
+ repo_root = _REPO_ROOT
242
+ try:
243
+ out = subprocess.run(
244
+ ["git", "worktree", "list", "--porcelain"],
245
+ cwd=str(repo_root), check=True, capture_output=True, text=True,
246
+ )
247
+ paths = []
248
+ for line in out.stdout.splitlines():
249
+ if line.startswith("worktree "):
250
+ paths.append(line[len("worktree "):])
251
+ return paths
252
+ except (subprocess.CalledProcessError, OSError):
253
+ return []
254
+
255
+
256
+ def remove_worktree(
257
+ story_id: str,
258
+ instance_idx: int,
259
+ repo_root: Optional[Path] = None,
260
+ force: bool = False,
261
+ ) -> bool:
262
+ """Remove a single worktree + branch. Fail-open with reason code."""
263
+ if repo_root is None:
264
+ repo_root = _REPO_ROOT
265
+ wt_path = _worktree_path(repo_root, story_id, instance_idx)
266
+ branch = _worktree_branch(story_id, instance_idx)
267
+ try:
268
+ cmd = ["git", "worktree", "remove"]
269
+ if force:
270
+ cmd.append("--force")
271
+ cmd.append(str(wt_path))
272
+ subprocess.run(cmd, cwd=str(repo_root), check=True, capture_output=True)
273
+ subprocess.run(
274
+ ["git", "branch", "-D", branch],
275
+ cwd=str(repo_root), check=True, capture_output=True,
276
+ )
277
+ return True
278
+ except (subprocess.CalledProcessError, OSError):
279
+ if wt_path.exists():
280
+ shutil.rmtree(str(wt_path), ignore_errors=True)
281
+ return False
282
+
283
+
284
+ def cleanup_worktrees(
285
+ contexts: List[WorktreeContext],
286
+ story_id: str,
287
+ keep_losers: bool = False,
288
+ winner_instance_id: Optional[str] = None,
289
+ repo_root: Optional[Path] = None,
290
+ ) -> Dict[str, Any]:
291
+ """Clean up worktrees post-merge. Winner always cleaned; losers per AUTO_SOVEREIGN_WORKTREE_KEEP."""
292
+ if repo_root is None:
293
+ repo_root = _REPO_ROOT
294
+ removed = []
295
+ kept = []
296
+ failed = []
297
+ for ctx in contexts:
298
+ is_winner = winner_instance_id and ctx.instance_id == winner_instance_id
299
+ if is_winner or not keep_losers:
300
+ match = re.match(r"us0108-.+-(\d+)$", ctx.branch)
301
+ idx = int(match.group(1)) if match else 0
302
+ ok = remove_worktree(story_id, idx, repo_root, force=True)
303
+ if ok:
304
+ removed.append(ctx.instance_id)
305
+ else:
306
+ failed.append(ctx.instance_id)
307
+ else:
308
+ kept.append(ctx.instance_id)
309
+ status = "PARALLEL_DEV_WORKTREE_CLEANUP_FAILED" if failed else "OK"
310
+ return {"removed": removed, "kept": kept, "failed": failed, "status": status}
311
+
312
+
313
+ # --- T-005: Anti-slop score reader (AC-3) ---------------------------------------
314
+
315
+ def read_anti_slop_score(lens_scores: List[int]) -> int:
316
+ """Read anti-slop aggregate from lens scores.
317
+
318
+ Uses sovereign_critic_lib.compute_anti_slop_aggregate (US-0104, read-only).
319
+ Graceful degrade: default 0 when US-0104 absent.
320
+ """
321
+ try:
322
+ from sovereign_critic_lib import compute_anti_slop_aggregate
323
+ return compute_anti_slop_aggregate(lens_scores)
324
+ except Exception:
325
+ if lens_scores:
326
+ return min(int(s) for s in lens_scores)
327
+ return 0
328
+
329
+
330
+ def extract_anti_slop_from_findings(findings_path: Path) -> int:
331
+ """Extract anti_slop_score from sovereign_critic_findings.jsonl or qa-findings.md.
332
+
333
+ Read-only per compose guard: US-0104 schema unchanged.
334
+ Graceful degrade: return 0 when US-0104 absent.
335
+ """
336
+ try:
337
+ if findings_path.suffix == ".jsonl" and findings_path.is_file():
338
+ scores = []
339
+ for line in findings_path.read_text(encoding="utf-8").splitlines():
340
+ line = line.strip()
341
+ if not line:
342
+ continue
343
+ try:
344
+ obj = json.loads(line)
345
+ score = obj.get("anti_slop_score")
346
+ if score is not None:
347
+ scores.append(int(score))
348
+ except (json.JSONDecodeError, ValueError):
349
+ continue
350
+ if scores:
351
+ return min(scores)
352
+ elif findings_path.is_file():
353
+ text = findings_path.read_text(encoding="utf-8")
354
+ match = re.search(r"anti_slop_score[:\s]*(\d+)", text)
355
+ if match:
356
+ return int(match.group(1))
357
+ except Exception:
358
+ pass
359
+ return 0
360
+
361
+
362
+ # --- T-004: Selection predicate (AC-3) ------------------------------------------
363
+
364
+ def select_winner(qa_results: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
365
+ """Deterministic winner selection per AC-3.
366
+
367
+ 1. Filter qa_verdict=pass (case-insensitive).
368
+ 2. Sort by anti_slop_score descending.
369
+ 3. Tie-break earliest proof_issued_at.
370
+ 4. Return winner or None.
371
+ """
372
+ if not qa_results:
373
+ return None
374
+
375
+ passing = [
376
+ r for r in qa_results
377
+ if str(r.get("qa_verdict", "")).lower() in ("pass", "passed")
378
+ ]
379
+ if not passing:
380
+ return None
381
+
382
+ def sort_key(result: Dict[str, Any]) -> Tuple[int, str]:
383
+ score = int(result.get("anti_slop_score", 0))
384
+ issued = str(result.get("proof_issued_at", "9999-12-31T23:59:59Z"))
385
+ return (-score, issued)
386
+
387
+ passing.sort(key=sort_key)
388
+ winner = dict(passing[0])
389
+ winner["_selection_method"] = "pass_then_antislop_desc_then_earliest"
390
+ return winner
391
+
392
+
393
+ # --- T-006: Merge policy + parallel_dev_pick.json (AC-4) ------------------------
394
+
395
+ def merge_winner(
396
+ winner_context: WorktreeContext,
397
+ main_branch: str = "main",
398
+ repo_root: Optional[Path] = None,
399
+ max_retries: int = 2,
400
+ timeout_sec: int = 60,
401
+ merge_resolve: str = "first_pass_wins",
402
+ ) -> MergeResult:
403
+ """Merge winner branch into main with bounded conflict retry.
404
+
405
+ AUTO_SOVEREIGN_MERGE_RESOLVE:
406
+ first_pass_wins — use -X theirs, first attempt wins (default)
407
+ last_pass_wins — use -X theirs, last attempt wins
408
+ manual — halt with PARALLEL_DEV_MANUAL_HALT
409
+ winner_takes_all — alias for first_pass_wins
410
+
411
+ Bounded retry ≤2 then PARALLEL_DEV_MERGE_CONFLICT halt.
412
+ """
413
+ if repo_root is None:
414
+ repo_root = _REPO_ROOT
415
+
416
+ if merge_resolve == "manual":
417
+ return MergeResult(
418
+ success=False,
419
+ branch=winner_context.branch,
420
+ commit_hash="",
421
+ conflicts=[ReasonCode.PARALLEL_DEV_MANUAL_HALT],
422
+ )
423
+
424
+ last_conflicts: List[str] = []
425
+ for attempt in range(1, max_retries + 1):
426
+ try:
427
+ subprocess.run(
428
+ ["git", "checkout", winner_context.branch],
429
+ cwd=str(repo_root), check=True, capture_output=True,
430
+ )
431
+ merge_cmd = ["git", "merge", "-X", "theirs", main_branch]
432
+ subprocess.run(
433
+ merge_cmd,
434
+ cwd=winner_context.path,
435
+ check=True, capture_output=True,
436
+ timeout=timeout_sec,
437
+ )
438
+ rev = subprocess.run(
439
+ ["git", "rev-parse", "HEAD"],
440
+ cwd=winner_context.path, check=True, capture_output=True, text=True,
441
+ )
442
+ commit_hash = rev.stdout.strip()
443
+ subprocess.run(
444
+ ["git", "checkout", main_branch],
445
+ cwd=str(repo_root), check=True, capture_output=True,
446
+ )
447
+ subprocess.run(
448
+ ["git", "merge", "--ff-only", winner_context.branch],
449
+ cwd=str(repo_root), check=True, capture_output=True,
450
+ )
451
+ return MergeResult(
452
+ success=True,
453
+ branch=winner_context.branch,
454
+ commit_hash=commit_hash,
455
+ conflicts=[],
456
+ )
457
+ except subprocess.TimeoutExpired:
458
+ last_conflicts = [ReasonCode.PARALLEL_DEV_MERGE_TIMEOUT]
459
+ break
460
+ except subprocess.CalledProcessError as exc:
461
+ stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
462
+ if "CONFLICT" in stderr.upper() or "conflict" in stderr.lower():
463
+ last_conflicts = [f"attempt-{attempt}:conflict"]
464
+ try:
465
+ subprocess.run(
466
+ ["git", "merge", "--abort"],
467
+ cwd=winner_context.path, check=False, capture_output=True,
468
+ )
469
+ except Exception:
470
+ pass
471
+ else:
472
+ last_conflicts = [f"attempt-{attempt}:error"]
473
+
474
+ return MergeResult(
475
+ success=False,
476
+ branch=winner_context.branch,
477
+ commit_hash="",
478
+ conflicts=last_conflicts or [ReasonCode.PARALLEL_DEV_MERGE_CONFLICT],
479
+ )
480
+
481
+
482
+ PICK_SCHEMA_VERSION = 1
483
+
484
+ PICK_REQUIRED_FIELDS = {
485
+ "schema_version", "story_id", "winner_instance_id", "worktree_path",
486
+ "qa_verdict", "anti_slop_score", "proof_issued_at", "merge_policy",
487
+ "runner_ts_utc", "orchestrator_run_id", "loser_instance_ids",
488
+ }
489
+
490
+
491
+ def build_pick_record(
492
+ story_id: str,
493
+ winner_id: str,
494
+ winner_path: str,
495
+ qa_verdict: str,
496
+ anti_slop_score: int,
497
+ merge_policy: str,
498
+ loser_ids: List[str],
499
+ orchestrator_run_id: str = "",
500
+ proof_issued_at: Optional[str] = None,
501
+ ) -> Dict[str, Any]:
502
+ """Build write-once parallel_dev_pick.json v1 record."""
503
+ now_ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
504
+ return {
505
+ "schema_version": PICK_SCHEMA_VERSION,
506
+ "story_id": story_id,
507
+ "winner_instance_id": winner_id,
508
+ "worktree_path": winner_path,
509
+ "qa_verdict": qa_verdict,
510
+ "anti_slop_score": int(anti_slop_score),
511
+ "proof_issued_at": proof_issued_at or now_ts,
512
+ "merge_policy": str(merge_policy),
513
+ "runner_ts_utc": now_ts,
514
+ "orchestrator_run_id": orchestrator_run_id,
515
+ "loser_instance_ids": list(loser_ids),
516
+ }
517
+
518
+
519
+ def validate_pick_record(record: Dict[str, Any]) -> Tuple[bool, str]:
520
+ """Validate pick record against v1 schema."""
521
+ missing = PICK_REQUIRED_FIELDS - set(record.keys())
522
+ if missing:
523
+ return False, f"missing fields: {sorted(missing)}"
524
+ if record.get("schema_version") != PICK_SCHEMA_VERSION:
525
+ return False, f"schema_version expected {PICK_SCHEMA_VERSION}, got {record.get('schema_version')}"
526
+ if not isinstance(record.get("anti_slop_score"), int):
527
+ return False, "anti_slop_score must be int"
528
+ if not isinstance(record.get("loser_instance_ids"), list):
529
+ return False, "loser_instance_ids must be list"
530
+ return True, "OK"
531
+
532
+
533
+ def write_pick_record(
534
+ record: Dict[str, Any],
535
+ output_path: Path,
536
+ ) -> Tuple[bool, str]:
537
+ """Write-once pick record. Fail if file already exists (write-once guarantee)."""
538
+ ok, msg = validate_pick_record(record)
539
+ if not ok:
540
+ return False, ReasonCode.PARALLEL_DEV_PICK_SCHEMA_INVALID + ": " + msg
541
+ if output_path.exists():
542
+ return False, "pick record already exists (write-once)"
543
+ output_path.parent.mkdir(parents=True, exist_ok=True)
544
+ output_path.write_text(
545
+ json.dumps(record, indent=2, sort_keys=True) + "\n",
546
+ encoding="utf-8",
547
+ )
548
+ return True, "OK"
549
+
550
+
551
+ # --- T-007: Resource guard (AC-5) -----------------------------------------------
552
+
553
+ LOCKFILE_NAME = "us0108_parallel_dev.lock"
554
+
555
+
556
+ def _lockfile_path(repo_root: Optional[Path] = None) -> Path:
557
+ if repo_root is None:
558
+ repo_root = _REPO_ROOT
559
+ return _git_dir(repo_root) / LOCKFILE_NAME
560
+
561
+
562
+ def acquire_lock(
563
+ lock_id: Optional[str] = None,
564
+ repo_root: Optional[Path] = None,
565
+ max_total: Optional[int] = None,
566
+ ) -> Tuple[bool, str]:
567
+ """Acquire atomic lockfile. Uses pathlib exclusive create ('x' mode).
568
+
569
+ System cap: AUTO_SOVEREIGN_PARALLEL_MAX_TOTAL (default 6).
570
+ Returns (success, reason_code_or_lock_id).
571
+ """
572
+ if repo_root is None:
573
+ repo_root = _REPO_ROOT
574
+ lock_path = _lockfile_path(repo_root)
575
+ if lock_id is None:
576
+ lock_id = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%f")
577
+
578
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
579
+
580
+ try:
581
+ lock_data: Dict[str, Any] = {}
582
+ if lock_path.is_file():
583
+ try:
584
+ lock_data = json.loads(lock_path.read_text(encoding="utf-8"))
585
+ except (json.JSONDecodeError, ValueError):
586
+ lock_data = {}
587
+
588
+ existing = lock_data.get("instances", [])
589
+ active = [i for i in existing if i.get("status") == "active"]
590
+ cap = max_total if max_total is not None else 6
591
+ if len(active) >= cap:
592
+ return False, ReasonCode.PARALLEL_DEV_RESOURCE_CAP_EXHAUSTED
593
+
594
+ active.append({"lock_id": lock_id, "status": "active",
595
+ "ts": datetime.datetime.now(datetime.timezone.utc).isoformat()})
596
+ lock_data["instances"] = active
597
+ lock_data["cap"] = cap
598
+ lock_path.write_text(
599
+ json.dumps(lock_data, indent=2) + "\n", encoding="utf-8"
600
+ )
601
+ return True, lock_id
602
+ except FileExistsError:
603
+ return False, ReasonCode.PARALLEL_DEV_RESOURCE_LOCK_FAILED
604
+ except OSError:
605
+ return False, ReasonCode.PARALLEL_DEV_RESOURCE_LOCK_FAILED
606
+
607
+
608
+ def release_lock(
609
+ lock_id: str,
610
+ repo_root: Optional[Path] = None,
611
+ ) -> bool:
612
+ """Release a lock by lock_id. Idempotent."""
613
+ if repo_root is None:
614
+ repo_root = _REPO_ROOT
615
+ lock_path = _lockfile_path(repo_root)
616
+ if not lock_path.is_file():
617
+ return True
618
+ try:
619
+ lock_data = json.loads(lock_path.read_text(encoding="utf-8"))
620
+ lock_data["instances"] = [
621
+ i for i in lock_data.get("instances", [])
622
+ if i.get("lock_id") != lock_id
623
+ ]
624
+ remaining_active = [i for i in lock_data["instances"] if i.get("status") == "active"]
625
+ if not remaining_active:
626
+ lock_path.unlink(missing_ok=True)
627
+ else:
628
+ lock_path.write_text(
629
+ json.dumps(lock_data, indent=2) + "\n", encoding="utf-8"
630
+ )
631
+ return True
632
+ except Exception:
633
+ return False
634
+
635
+
636
+ def acquire_parallel_slot(
637
+ instance_id: Optional[str] = None,
638
+ repo_root: Optional[Path] = None,
639
+ max_total: int = 6,
640
+ ) -> Tuple[bool, str]:
641
+ """Alias for acquire_lock — AC-5 system-wide cap enforcement."""
642
+ return acquire_lock(
643
+ lock_id=instance_id or f"inst-{datetime.datetime.now(datetime.timezone.utc).strftime('%H%M%S%f')}",
644
+ repo_root=repo_root,
645
+ max_total=max_total,
646
+ )
647
+
648
+
649
+ def release_parallel_slot(
650
+ instance_id: str,
651
+ repo_root: Optional[Path] = None,
652
+ ) -> bool:
653
+ """Release slot. Alias for release_lock."""
654
+ return release_lock(instance_id, repo_root)
655
+
656
+
657
+ # --- T-008: Execute steps 25-28 (AC-6) ------------------------------------------
658
+
659
+ def simulate_instance_qa(
660
+ story_id: str,
661
+ instance_idx: int,
662
+ worktree_path: str,
663
+ ) -> Dict[str, Any]:
664
+ """Simulate per-instance execute+QA result.
665
+
666
+ In a real orchestration, each instance runs /execute+/qa independently.
667
+ This function provides a deterministic test harness result.
668
+ """
669
+ now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
670
+ return {
671
+ "instance_id": f"{story_id}-inst{instance_idx}",
672
+ "instance_idx": instance_idx,
673
+ "worktree_path": worktree_path,
674
+ "qa_verdict": "pass",
675
+ "anti_slop_score": 7 - instance_idx if instance_idx < 3 else 0,
676
+ "proof_issued_at": f"2026-06-29T22:{instance_idx:02d}:00Z",
677
+ "story_id": story_id,
678
+ "runner_ts_utc": now,
679
+ }
680
+
681
+
682
+ def execute_parallel_dev(
683
+ story_id: str,
684
+ base_branch: str = "main",
685
+ instance_count: int = 3,
686
+ scratchpad_path: Optional[Path] = None,
687
+ repo_root: Optional[Path] = None,
688
+ orchestrator_run_id: str = "",
689
+ pick_output_path: Optional[Path] = None,
690
+ ) -> ExecuteResult:
691
+ """Full parallel dev pipeline (steps 25-28).
692
+
693
+ Step 25: spawn N dev instances (create worktrees)
694
+ Step 26: QA cross-review
695
+ Step 27: selection via select_winner (T-004)
696
+ Step 28: merge+cleanup via merge_winner (T-006) + cleanup_worktrees (T-003)
697
+
698
+ When SOVEREIGN_PARALLEL_DEV=0, return early with disabled reason.
699
+ """
700
+ if repo_root is None:
701
+ repo_root = _REPO_ROOT
702
+ if scratchpad_path is None:
703
+ scratchpad_path = repo_root / ".cursor" / "scratchpad.md"
704
+ if pick_output_path is None:
705
+ pick_output_path = repo_root / "handoffs" / "parallel_dev_pick.json"
706
+
707
+ if not is_parallel_enabled(scratchpad_path):
708
+ return ExecuteResult(
709
+ winner_worktree=None,
710
+ merge_result=MergeResult(success=False, branch=base_branch,
711
+ commit_hash="", conflicts=[ReasonCode.PARALLEL_DEV_DISABLED]),
712
+ qa_results=[],
713
+ )
714
+
715
+ config = read_scratchpad_with_defaults(scratchpad_path)
716
+ n = int(config.get(AUTO_SOVEREIGN_PARALLEL_N_KEY, "3"))
717
+ max_total = int(config.get(AUTO_SOVEREIGN_PARALLEL_MAX_TOTAL_KEY, "6"))
718
+ merge_resolve = config.get(AUTO_SOVEREIGN_MERGE_RESOLVE_KEY, "first_pass_wins")
719
+ keep_losers = config.get(AUTO_SOVEREIGN_WORKTREE_KEEP_KEY, "0") == "1"
720
+ anti_slop_threshold = int(config.get(AUTO_SOVEREIGN_PARALLEL_ANTI_SLOP_THRESHOLD_KEY, "6"))
721
+ merge_timeout = int(config.get(AUTO_SOVEREIGN_PARALLEL_MERGE_TIMEOUT_SEC_KEY, "60"))
722
+ instance_count = min(n, max_total)
723
+
724
+ slot_ok, slot_msg = acquire_parallel_slot(
725
+ instance_id=f"{story_id}-orchestrator",
726
+ repo_root=repo_root,
727
+ max_total=max_total,
728
+ )
729
+ if not slot_ok:
730
+ return ExecuteResult(
731
+ winner_worktree=None,
732
+ merge_result=MergeResult(success=False, branch=base_branch,
733
+ commit_hash="", conflicts=[slot_msg]),
734
+ qa_results=[],
735
+ )
736
+
737
+ try:
738
+ # Step 25: spawn N worktrees
739
+ worktrees = create_worktrees(base_branch, instance_count, story_id, repo_root)
740
+ failed_wt = [w for w in worktrees if "failed" in w.status]
741
+ if failed_wt:
742
+ cleanup_worktrees(worktrees, story_id, keep_losers=False, repo_root=repo_root)
743
+ return ExecuteResult(
744
+ winner_worktree=None,
745
+ merge_result=MergeResult(success=False, branch=base_branch,
746
+ commit_hash="",
747
+ conflicts=[ReasonCode.PARALLEL_DEV_WORKTREE_CREATE_FAILED]),
748
+ qa_results=[],
749
+ )
750
+
751
+ # Step 26: QA cross-review (simulate)
752
+ qa_results = []
753
+ for ctx in worktrees:
754
+ match = re.match(r"us0108-.+-(\d+)$", ctx.branch)
755
+ idx = int(match.group(1)) if match else 0
756
+ result = simulate_instance_qa(story_id, idx, ctx.path)
757
+ qa_results.append(result)
758
+
759
+ # Step 27: selection
760
+ winner = select_winner(qa_results)
761
+ if winner is None:
762
+ cleanup_worktrees(worktrees, story_id, keep_losers=False, repo_root=repo_root)
763
+ return ExecuteResult(
764
+ winner_worktree=None,
765
+ merge_result=MergeResult(success=False, branch=base_branch,
766
+ commit_hash="",
767
+ conflicts=[ReasonCode.PARALLEL_DEV_SELECTION_NO_PASS]),
768
+ qa_results=qa_results,
769
+ )
770
+
771
+ winner_idx = winner["instance_idx"]
772
+ winner_ctx = worktrees[winner_idx]
773
+ winner_score = int(winner.get("anti_slop_score", 0))
774
+
775
+ if winner_score < anti_slop_threshold:
776
+ cleanup_worktrees(worktrees, story_id, keep_losers=False, repo_root=repo_root)
777
+ return ExecuteResult(
778
+ winner_worktree=None,
779
+ merge_result=MergeResult(success=False, branch=winner_ctx.branch,
780
+ commit_hash="",
781
+ conflicts=[ReasonCode.PARALLEL_DEV_ANTI_SLOP_BELOW_THRESHOLD]),
782
+ qa_results=qa_results,
783
+ )
784
+
785
+ # Step 28: merge + cleanup
786
+ merge = merge_winner(
787
+ winner_ctx, base_branch, repo_root,
788
+ max_retries=2, timeout_sec=merge_timeout,
789
+ merge_resolve=merge_resolve,
790
+ )
791
+
792
+ loser_ids = [
793
+ ctx.instance_id for ctx in worktrees
794
+ if ctx.instance_id != winner["instance_id"]
795
+ ]
796
+
797
+ pick_rec = build_pick_record(
798
+ story_id=story_id,
799
+ winner_id=winner["instance_id"],
800
+ winner_path=winner_ctx.path,
801
+ qa_verdict=str(winner.get("qa_verdict", "unknown")),
802
+ anti_slop_score=winner_score,
803
+ merge_policy=merge_resolve,
804
+ loser_ids=loser_ids,
805
+ orchestrator_run_id=orchestrator_run_id,
806
+ proof_issued_at=winner.get("proof_issued_at"),
807
+ )
808
+ write_pick_record(pick_rec, pick_output_path)
809
+
810
+ cleanup_worktrees(
811
+ worktrees, story_id,
812
+ keep_losers=keep_losers,
813
+ winner_instance_id=winner["instance_id"],
814
+ repo_root=repo_root,
815
+ )
816
+
817
+ # Release winner slot
818
+ release_parallel_slot(winner["instance_id"], repo_root)
819
+
820
+ return ExecuteResult(
821
+ winner_worktree=winner_ctx,
822
+ merge_result=merge,
823
+ qa_results=qa_results,
824
+ )
825
+ except Exception as exc:
826
+ return ExecuteResult(
827
+ winner_worktree=None,
828
+ merge_result=MergeResult(success=False, branch=base_branch,
829
+ commit_hash="",
830
+ conflicts=[ReasonCode.PARALLEL_DEV_EXECUTE_FAILED + f":{exc}"]),
831
+ qa_results=[],
832
+ )
833
+ finally:
834
+ release_parallel_slot(f"{story_id}-orchestrator", repo_root)
835
+
836
+
837
+ # --- Validator CLI / self-test ---------------------------------------------------
838
+
839
+ def run_self_test() -> Tuple[bool, str]:
840
+ """Run inline self-test. Returns (pass, summary)."""
841
+ errors = []
842
+
843
+ # 1. Scratchpad key defaults
844
+ for key, default in SCRATCHPAD_KEY_DEFAULTS.items():
845
+ if not isinstance(default, str):
846
+ errors.append(f"default for {key} is not str")
847
+
848
+ # 2. Selection predicate
849
+ results = [
850
+ {"qa_verdict": "pass", "anti_slop_score": 5, "proof_issued_at": "2026-06-29T22:00:00Z", "instance_id": "A"},
851
+ {"qa_verdict": "pass", "anti_slop_score": 8, "proof_issued_at": "2026-06-29T22:01:00Z", "instance_id": "B"},
852
+ {"qa_verdict": "fail", "anti_slop_score": 10, "proof_issued_at": "2026-06-29T22:00:30Z", "instance_id": "C"},
853
+ ]
854
+ w = select_winner(results)
855
+ if w is None or w.get("instance_id") != "B":
856
+ errors.append(f"selection predicate failed: {w}")
857
+
858
+ # 2b. Tie-break: same score → earliest wins
859
+ tie_results = [
860
+ {"qa_verdict": "pass", "anti_slop_score": 7, "proof_issued_at": "2026-06-29T22:05:00Z", "instance_id": "X"},
861
+ {"qa_verdict": "pass", "anti_slop_score": 7, "proof_issued_at": "2026-06-29T22:01:00Z", "instance_id": "Y"},
862
+ ]
863
+ tw = select_winner(tie_results)
864
+ if tw is None or tw.get("instance_id") != "Y":
865
+ errors.append(f"tie-break failed: {tw}")
866
+
867
+ # 3. Anti-slop reader
868
+ score = read_anti_slop_score([7, 8, 6])
869
+ if score != 6:
870
+ errors.append(f"anti_slop reader failed: {score} != 6")
871
+ score0 = read_anti_slop_score([])
872
+ if score0 != 0:
873
+ errors.append(f"anti_slop empty failed: {score0} != 0")
874
+
875
+ # 4. Pick record round-trip
876
+ with tempfile.TemporaryDirectory() as td:
877
+ pick_path = Path(td) / "pick.json"
878
+ rec = build_pick_record(
879
+ story_id="US-0108", winner_id="inst0", winner_path="/tmp/wt0",
880
+ qa_verdict="pass", anti_slop_score=8, merge_policy="first_pass_wins",
881
+ loser_ids=["inst1", "inst2"], orchestrator_run_id="test-001",
882
+ )
883
+ ok, msg = write_pick_record(rec, pick_path)
884
+ if not ok:
885
+ errors.append(f"write_pick_record failed: {msg}")
886
+ else:
887
+ loaded = json.loads(pick_path.read_text(encoding="utf-8"))
888
+ vok, vmsg = validate_pick_record(loaded)
889
+ if not vok:
890
+ errors.append(f"validate_pick_record failed: {vmsg}")
891
+ ok2, msg2 = write_pick_record(rec, pick_path)
892
+ if ok2:
893
+ errors.append("write-once guarantee violated")
894
+
895
+ if errors:
896
+ return False, "; ".join(errors)
897
+ return True, "self-test OK"
898
+
899
+
900
+ def main() -> int:
901
+ import argparse
902
+ parser = argparse.ArgumentParser(description=__doc__)
903
+ parser.add_argument("--self-test", action="store_true", help="Run inline self-test")
904
+ parser.add_argument("--repo", type=Path, default=None, help="Repository root")
905
+ parser.add_argument("--scratchpad", type=Path, default=None, help="Scratchpad path")
906
+ args = parser.parse_args()
907
+
908
+ if args.self_test:
909
+ ok, msg = run_self_test()
910
+ print(f"[{'SELF_TEST_PASS' if ok else 'SELF_TEST_FAIL'}] {msg}")
911
+ return 0 if ok else 1
912
+
913
+ repo = args.repo or _REPO_ROOT
914
+ sp = args.scratchpad or (repo / ".cursor" / "scratchpad.md")
915
+ if is_parallel_enabled(sp):
916
+ print("[PARALLEL_DEV_ENABLED] SOVEREIGN_PARALLEL_DEV=1")
917
+ else:
918
+ print("[PARALLEL_DEV_DISABLED] SOVEREIGN_PARALLEL_DEV=0 (zero overhead)")
919
+ return 0
920
+
921
+
922
+ if __name__ == "__main__":
923
+ sys.exit(main())