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.
- package/installer.ps1 +8 -0
- package/installer.py +8 -0
- package/installer.sh +1 -0
- package/package.json +1 -1
- package/template/.cursor/commands/auto.md +90 -0
- package/template/.cursor/commands/execute.md +26 -0
- package/template/.cursor/commands/refresh-context.md +32 -0
- package/template/.cursor/commands/sovereign-critic.md +104 -0
- package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
- package/template/.cursor/scratchpad.md +141 -0
- package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
- package/template/decisions/DEC-0104.md +279 -0
- package/template/decisions/DEC-0105.md +231 -0
- package/template/decisions/DEC-0107.md +246 -0
- package/template/docs/engineering/architecture.md +78 -0
- package/template/docs/engineering/auto-orchestration-reference.md +7 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
- package/template/docs/engineering/reason_codes.md +411 -0
- package/template/docs/engineering/runbook.md +922 -0
- package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
- package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
- package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
- package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
- package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
- package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
- package/template/scripts/check_intake_template_parity.py +119 -0
- package/template/scripts/decision_ledger_lib.py +732 -0
- package/template/scripts/ledger_validate.py +153 -0
- package/template/scripts/parallel_dev_arbiter.py +923 -0
- package/template/scripts/release_trigger_adapters.py +843 -0
- package/template/scripts/self_healing_deploy_lib.py +463 -0
- package/template/scripts/self_healing_deploy_validate.py +78 -0
- package/template/scripts/sovereign_convergence_lib.py +994 -0
- package/template/scripts/sovereign_convergence_validate.py +206 -0
- package/template/scripts/sovereign_critic_lib.py +629 -0
- package/template/scripts/sovereign_critic_validate.py +131 -0
- package/template/scripts/sovereign_loop_lib.py +828 -0
- package/template/scripts/sovereign_loop_validate.py +122 -0
- package/template/scripts/sovereign_memory_lib.py +869 -0
- package/template/scripts/sovereign_memory_validate.py +153 -0
- package/template/scripts/sovereign_role_manifest_lib.py +547 -0
- package/template/scripts/sovereign_role_manifest_validate.py +105 -0
- package/template/tests/us0108_contract_test.py +207 -0
- package/template/tests/us0109_contract_test.py +209 -0
- package/template/tests/us0109_us0110_compose_test.py +34 -0
- 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())
|