easy-coding-harness 0.10.0-beta.9 → 1.0.0-beta.1
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/CHANGELOG.md +67 -1
- package/README.md +34 -26
- package/dist/cli.js +272 -37
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +7 -8
- package/templates/claude/agents/ec-reviewer.md +14 -2
- package/templates/claude/agents/ec-verifier.md +11 -2
- package/templates/codex/agents/ec-implementer.toml +7 -8
- package/templates/codex/agents/ec-reviewer.toml +14 -2
- package/templates/codex/agents/ec-verifier.toml +11 -2
- package/templates/common/bundled-skills/ec-init/SKILL.md +1 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +15 -11
- package/templates/common/skills/ec-analysis/SKILL.md +24 -13
- package/templates/common/skills/ec-config/SKILL.md +2 -2
- package/templates/common/skills/ec-implementing/SKILL.md +35 -30
- package/templates/common/skills/ec-lite/SKILL.md +74 -0
- package/templates/common/skills/ec-no-harness/SKILL.md +3 -0
- package/templates/common/skills/ec-quality/SKILL.md +153 -0
- package/templates/common/skills/ec-task-management/SKILL.md +9 -5
- package/templates/common/skills/ec-tdd-init/SKILL.md +5 -4
- package/templates/common/skills/ec-workflow/SKILL.md +32 -30
- package/templates/main-constraint/AGENTS.md.tpl +31 -19
- package/templates/main-constraint/CLAUDE.md.tpl +31 -19
- package/templates/qoder/agents/ec-implementer.md +7 -8
- package/templates/qoder/agents/ec-reviewer.md +14 -2
- package/templates/qoder/agents/ec-verifier.md +11 -2
- package/templates/runtime/templates/dev-spec-skeleton.md +2 -2
- package/templates/shared-hooks/easy_coding_state.py +2793 -318
- package/templates/claude/agents/ec-fixer.md +0 -37
- package/templates/codex/agents/ec-fixer.toml +0 -26
- package/templates/common/skills/ec-reviewing/SKILL.md +0 -109
- package/templates/common/skills/ec-verification/SKILL.md +0 -177
- package/templates/qoder/agents/ec-fixer.md +0 -37
|
@@ -67,10 +67,8 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
|
67
67
|
"idle": {"INIT"},
|
|
68
68
|
"INIT": {"ANALYSIS", "CLOSED"},
|
|
69
69
|
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
73
|
-
"VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
|
|
70
|
+
"IMPLEMENT": {"QUALITY", "ANALYSIS", "CLOSED"},
|
|
71
|
+
"QUALITY": {"MEMORY", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
74
72
|
"MEMORY": {"COMPLETE", "CLOSED"},
|
|
75
73
|
"COMPLETE": set(),
|
|
76
74
|
"CLOSED": set(),
|
|
@@ -80,8 +78,6 @@ ALWAYS_AUTO_TRANSITIONS = {
|
|
|
80
78
|
("INIT", "ANALYSIS"),
|
|
81
79
|
("MEMORY", "COMPLETE"),
|
|
82
80
|
}
|
|
83
|
-
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
84
|
-
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
85
81
|
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
86
82
|
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
87
83
|
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
@@ -89,6 +85,20 @@ WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
|
89
85
|
WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
|
|
90
86
|
STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
|
|
91
87
|
REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
|
|
88
|
+
QUALITY_GATE_STATUSES = {"passed", "failed", "cancelled"}
|
|
89
|
+
QUALITY_FAILURE_CLASSES = {
|
|
90
|
+
"code-defect",
|
|
91
|
+
"test-defect",
|
|
92
|
+
"contract-ambiguity",
|
|
93
|
+
"environment",
|
|
94
|
+
"suggestion",
|
|
95
|
+
}
|
|
96
|
+
QUALITY_CANCELLATION_REASONS = {
|
|
97
|
+
"implementation-drift",
|
|
98
|
+
"config-drift",
|
|
99
|
+
"manual-return",
|
|
100
|
+
"task-closed",
|
|
101
|
+
}
|
|
92
102
|
HIGH_WORKFLOW_RISK_PATTERN = re.compile(
|
|
93
103
|
r"(\bhigh[-_ ]?risk\b|\bcritical\b|\bsevere\b|\birreversible\b|"
|
|
94
104
|
r"\bdata[-_ ]?loss\b|\bfinancial[-_ ]?loss\b|"
|
|
@@ -123,12 +133,14 @@ JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
|
123
133
|
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
124
134
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
125
135
|
("ANALYSIS", "IMPLEMENT"),
|
|
126
|
-
("
|
|
136
|
+
("QUALITY", "MEMORY"),
|
|
127
137
|
}
|
|
128
138
|
ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
|
|
129
139
|
|
|
130
140
|
LEGACY_STAGE_MAP = {
|
|
131
141
|
"WAITING_CONFIRM": "ANALYSIS",
|
|
142
|
+
"REVIEW": "QUALITY",
|
|
143
|
+
"VERIFICATION": "QUALITY",
|
|
132
144
|
"MEMORY_SHORT": "MEMORY",
|
|
133
145
|
"MEMORY_LONG": "MEMORY",
|
|
134
146
|
}
|
|
@@ -143,7 +155,9 @@ ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
|
|
|
143
155
|
ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
|
|
144
156
|
ACCEPTANCE_SNAPSHOT_SCHEMA = 1
|
|
145
157
|
ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
|
|
146
|
-
|
|
158
|
+
SESSION_IDLE_RETENTION_HOURS = 7 * 24
|
|
159
|
+
SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
|
|
160
|
+
MAX_SESSION_FILES = 100
|
|
147
161
|
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
148
162
|
WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
|
|
149
163
|
# 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
|
|
@@ -160,6 +174,9 @@ LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
|
160
174
|
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
161
175
|
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
162
176
|
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
177
|
+
SESSION_COMMAND_LOCK_TIMEOUT_SECONDS = 5.0
|
|
178
|
+
SESSION_COMMAND_LOCK_STALE_SECONDS = 60.0
|
|
179
|
+
SESSION_COMMAND_LOCK_POLL_SECONDS = 0.02
|
|
163
180
|
SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
|
|
164
181
|
r"^SM-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
165
182
|
)
|
|
@@ -1262,6 +1279,26 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
1262
1279
|
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
1263
1280
|
changed = True
|
|
1264
1281
|
|
|
1282
|
+
pending = task.get("pending_transition")
|
|
1283
|
+
if isinstance(pending, dict):
|
|
1284
|
+
source = normalize_legacy_stage(pending.get("from"))
|
|
1285
|
+
target = normalize_legacy_stage(pending.get("to"))
|
|
1286
|
+
if source == target:
|
|
1287
|
+
task.pop("pending_transition", None)
|
|
1288
|
+
changed = True
|
|
1289
|
+
elif source != pending.get("from") or target != pending.get("to"):
|
|
1290
|
+
task["pending_transition"] = {**pending, "from": source, "to": target}
|
|
1291
|
+
changed = True
|
|
1292
|
+
|
|
1293
|
+
if not isinstance(task.get("quality_checkpoint"), dict) and isinstance(
|
|
1294
|
+
task.get("verification_checkpoint"), dict
|
|
1295
|
+
):
|
|
1296
|
+
task["quality_checkpoint"] = task["verification_checkpoint"]
|
|
1297
|
+
changed = True
|
|
1298
|
+
if "verification_checkpoint" in task:
|
|
1299
|
+
task.pop("verification_checkpoint")
|
|
1300
|
+
changed = True
|
|
1301
|
+
|
|
1265
1302
|
history = task.get("stage_history")
|
|
1266
1303
|
if isinstance(history, list):
|
|
1267
1304
|
normalized_history: list[dict] = []
|
|
@@ -1287,11 +1324,14 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
1287
1324
|
task["stage_history"] = normalized_history
|
|
1288
1325
|
|
|
1289
1326
|
if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
|
|
1327
|
+
requested_by = canonical_agent_identity(
|
|
1328
|
+
task.get("last_agent"), allow_legacy_display=True
|
|
1329
|
+
) or "legacy-migration"
|
|
1290
1330
|
task["pending_transition"] = {
|
|
1291
1331
|
"from": "ANALYSIS",
|
|
1292
1332
|
"to": "IMPLEMENT",
|
|
1293
1333
|
"requested_at": now_iso(),
|
|
1294
|
-
"requested_by":
|
|
1334
|
+
"requested_by": requested_by,
|
|
1295
1335
|
"reason": "migrated-from-WAITING_CONFIRM",
|
|
1296
1336
|
}
|
|
1297
1337
|
changed = True
|
|
@@ -1341,6 +1381,44 @@ def write_json(path: Path, data: dict) -> None:
|
|
|
1341
1381
|
temporary_path.unlink()
|
|
1342
1382
|
|
|
1343
1383
|
|
|
1384
|
+
def session_command_lock_path(root: Path, session_path: Path) -> Path:
|
|
1385
|
+
key = hashlib.sha256(str(session_path.resolve()).encode("utf-8")).hexdigest()[:24]
|
|
1386
|
+
return root / ".easy-coding" / "sessions" / f".session-{key}.lock"
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def acquire_session_command_lock(root: Path, session_path: Path) -> Path:
|
|
1390
|
+
lock_path = session_command_lock_path(root, session_path)
|
|
1391
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1392
|
+
deadline = time.monotonic() + SESSION_COMMAND_LOCK_TIMEOUT_SECONDS
|
|
1393
|
+
while True:
|
|
1394
|
+
try:
|
|
1395
|
+
lock_path.mkdir()
|
|
1396
|
+
return lock_path
|
|
1397
|
+
except FileExistsError:
|
|
1398
|
+
try:
|
|
1399
|
+
if time.time() - lock_path.stat().st_mtime > SESSION_COMMAND_LOCK_STALE_SECONDS:
|
|
1400
|
+
lock_path.rmdir()
|
|
1401
|
+
continue
|
|
1402
|
+
except FileNotFoundError:
|
|
1403
|
+
continue
|
|
1404
|
+
except OSError:
|
|
1405
|
+
pass
|
|
1406
|
+
if time.monotonic() >= deadline:
|
|
1407
|
+
raise StateError("Timed out waiting for the logical session command lock.")
|
|
1408
|
+
time.sleep(SESSION_COMMAND_LOCK_POLL_SECONDS)
|
|
1409
|
+
except OSError as exc:
|
|
1410
|
+
raise StateError("Cannot acquire the logical session command lock.") from exc
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def release_session_command_lock(lock_path: Path | None) -> None:
|
|
1414
|
+
if lock_path is None:
|
|
1415
|
+
return
|
|
1416
|
+
try:
|
|
1417
|
+
lock_path.rmdir()
|
|
1418
|
+
except OSError:
|
|
1419
|
+
pass
|
|
1420
|
+
|
|
1421
|
+
|
|
1344
1422
|
def acquire_legacy_state_lock(root: Path) -> Path | None:
|
|
1345
1423
|
state_path = root / ".easy-coding" / "state.json"
|
|
1346
1424
|
lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
|
|
@@ -1474,7 +1552,8 @@ def clear_session_pointer(session: dict, agent: str | None = None) -> None:
|
|
|
1474
1552
|
|
|
1475
1553
|
|
|
1476
1554
|
def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
|
|
1477
|
-
|
|
1555
|
+
session = load_json(resolve_session_path(root, session_file))
|
|
1556
|
+
return session if isinstance(session, dict) else None
|
|
1478
1557
|
|
|
1479
1558
|
|
|
1480
1559
|
def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
|
|
@@ -1523,6 +1602,20 @@ def ensure_hook_session(
|
|
|
1523
1602
|
payload: dict,
|
|
1524
1603
|
agent: str | None,
|
|
1525
1604
|
ppid: int | None = None,
|
|
1605
|
+
) -> tuple[dict, Path]:
|
|
1606
|
+
session_path = resolve_hook_session_path(root, payload, agent, ppid)
|
|
1607
|
+
lock_path = acquire_session_command_lock(root, session_path)
|
|
1608
|
+
try:
|
|
1609
|
+
return ensure_hook_session_unlocked(root, payload, agent, ppid)
|
|
1610
|
+
finally:
|
|
1611
|
+
release_session_command_lock(lock_path)
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def ensure_hook_session_unlocked(
|
|
1615
|
+
root: Path,
|
|
1616
|
+
payload: dict,
|
|
1617
|
+
agent: str | None,
|
|
1618
|
+
ppid: int | None = None,
|
|
1526
1619
|
) -> tuple[dict, Path]:
|
|
1527
1620
|
identity = hook_session_identity(payload, agent, ppid)
|
|
1528
1621
|
session_path = resolve_hook_session_path(root, payload, agent, ppid)
|
|
@@ -1537,7 +1630,7 @@ def ensure_hook_session(
|
|
|
1537
1630
|
)
|
|
1538
1631
|
|
|
1539
1632
|
if session is None:
|
|
1540
|
-
|
|
1633
|
+
clean_session_runtime(root, reserve_slots=1)
|
|
1541
1634
|
session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
|
|
1542
1635
|
if session is None:
|
|
1543
1636
|
session = load_session(root, session_path)
|
|
@@ -1560,36 +1653,128 @@ def ensure_hook_session(
|
|
|
1560
1653
|
|
|
1561
1654
|
def clean_stale_sessions(
|
|
1562
1655
|
root: Path,
|
|
1563
|
-
threshold_hours: int =
|
|
1656
|
+
threshold_hours: int | None = None,
|
|
1657
|
+
idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
|
|
1658
|
+
attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
|
|
1659
|
+
max_sessions: int = MAX_SESSION_FILES,
|
|
1660
|
+
reserve_slots: int = 0,
|
|
1564
1661
|
) -> int:
|
|
1565
1662
|
sessions_dir = root / ".easy-coding" / "sessions"
|
|
1566
1663
|
if not sessions_dir.is_dir():
|
|
1567
1664
|
return 0
|
|
1568
1665
|
|
|
1569
1666
|
now = datetime.now(timezone.utc)
|
|
1570
|
-
|
|
1571
|
-
|
|
1667
|
+
if threshold_hours is not None:
|
|
1668
|
+
idle_threshold_hours = threshold_hours
|
|
1669
|
+
attached_threshold_hours = threshold_hours
|
|
1670
|
+
candidates: list[tuple[Path, str, dict, datetime]] = []
|
|
1572
1671
|
for entry in sessions_dir.iterdir():
|
|
1573
|
-
if entry.suffix != ".json":
|
|
1672
|
+
if not entry.is_file() or entry.suffix != ".json":
|
|
1574
1673
|
continue
|
|
1575
1674
|
try:
|
|
1576
|
-
|
|
1577
|
-
|
|
1675
|
+
content = entry.read_text(encoding="utf-8")
|
|
1676
|
+
try:
|
|
1677
|
+
session = json.loads(content)
|
|
1678
|
+
except json.JSONDecodeError:
|
|
1679
|
+
session = {}
|
|
1680
|
+
if not isinstance(session, dict):
|
|
1681
|
+
session = {}
|
|
1682
|
+
activity_value = session.get("last_active_at") or session.get("created_at")
|
|
1683
|
+
try:
|
|
1684
|
+
if not isinstance(activity_value, str):
|
|
1685
|
+
raise ValueError
|
|
1686
|
+
last_active = datetime.fromisoformat(activity_value)
|
|
1687
|
+
if last_active.tzinfo is None:
|
|
1688
|
+
last_active = last_active.replace(tzinfo=timezone.utc)
|
|
1689
|
+
except (ValueError, TypeError):
|
|
1690
|
+
last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
|
1691
|
+
candidates.append((entry, content, session, last_active))
|
|
1692
|
+
except OSError:
|
|
1693
|
+
continue
|
|
1694
|
+
|
|
1695
|
+
removed: set[Path] = set()
|
|
1696
|
+
for entry, content, session, last_active in candidates:
|
|
1697
|
+
retention_hours = (
|
|
1698
|
+
attached_threshold_hours if session.get("current_task") else idle_threshold_hours
|
|
1699
|
+
)
|
|
1700
|
+
age_hours = (now - last_active).total_seconds() / 3600
|
|
1701
|
+
if age_hours <= retention_hours:
|
|
1702
|
+
continue
|
|
1703
|
+
if unlink_session_if_unchanged(entry, content):
|
|
1704
|
+
removed.add(entry)
|
|
1705
|
+
|
|
1706
|
+
allowed_existing = max(0, max_sessions - reserve_slots)
|
|
1707
|
+
remaining = sorted(
|
|
1708
|
+
(candidate for candidate in candidates if candidate[0] not in removed),
|
|
1709
|
+
key=lambda candidate: candidate[3],
|
|
1710
|
+
)
|
|
1711
|
+
overflow = max(0, len(remaining) - allowed_existing)
|
|
1712
|
+
for entry, content, _session, _last_active in remaining[:overflow]:
|
|
1713
|
+
if unlink_session_if_unchanged(entry, content):
|
|
1714
|
+
removed.add(entry)
|
|
1715
|
+
return len(removed)
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
|
|
1719
|
+
try:
|
|
1720
|
+
if entry.read_text(encoding="utf-8") != expected_content:
|
|
1721
|
+
return False
|
|
1722
|
+
entry.unlink()
|
|
1723
|
+
return True
|
|
1724
|
+
except OSError:
|
|
1725
|
+
# GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
|
|
1726
|
+
return False
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def clean_orphan_acceptance_snapshots(root: Path) -> int:
|
|
1730
|
+
acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
|
|
1731
|
+
if not acceptance_dir.is_dir():
|
|
1732
|
+
return 0
|
|
1733
|
+
|
|
1734
|
+
cleaned = 0
|
|
1735
|
+
for entry in acceptance_dir.iterdir():
|
|
1736
|
+
if not entry.is_file() or entry.suffix != ".json":
|
|
1737
|
+
continue
|
|
1738
|
+
task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
|
|
1739
|
+
if task_path.is_file():
|
|
1740
|
+
try:
|
|
1741
|
+
task = json.loads(task_path.read_text(encoding="utf-8"))
|
|
1742
|
+
except (OSError, json.JSONDecodeError):
|
|
1578
1743
|
continue
|
|
1579
|
-
|
|
1580
|
-
last_active = datetime.fromisoformat(str(activity_value))
|
|
1581
|
-
if last_active.tzinfo is None:
|
|
1582
|
-
last_active = last_active.replace(tzinfo=timezone.utc)
|
|
1583
|
-
age_hours = (now - last_active).total_seconds() / 3600
|
|
1584
|
-
if age_hours <= threshold_hours:
|
|
1744
|
+
if not isinstance(task, dict):
|
|
1585
1745
|
continue
|
|
1746
|
+
else:
|
|
1747
|
+
task = None
|
|
1748
|
+
|
|
1749
|
+
checkpoint = None
|
|
1750
|
+
if task is not None:
|
|
1751
|
+
checkpoint = task.get("quality_checkpoint")
|
|
1752
|
+
if not isinstance(checkpoint, dict):
|
|
1753
|
+
checkpoint = task.get("verification_checkpoint")
|
|
1754
|
+
snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
|
|
1755
|
+
referenced = bool(
|
|
1756
|
+
isinstance(snapshot_file, str)
|
|
1757
|
+
and (root / snapshot_file).resolve() == entry.resolve()
|
|
1758
|
+
)
|
|
1759
|
+
terminal = task is not None and task.get("status") in TERMINAL_STATUSES
|
|
1760
|
+
if task is not None and referenced and not terminal:
|
|
1761
|
+
continue
|
|
1762
|
+
try:
|
|
1586
1763
|
entry.unlink()
|
|
1587
1764
|
cleaned += 1
|
|
1588
|
-
except
|
|
1765
|
+
except OSError:
|
|
1766
|
+
# 验收快照清理失败不能阻断新逻辑会话启动。
|
|
1589
1767
|
continue
|
|
1590
1768
|
return cleaned
|
|
1591
1769
|
|
|
1592
1770
|
|
|
1771
|
+
def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
|
|
1772
|
+
return {
|
|
1773
|
+
"sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
|
|
1774
|
+
"acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
|
|
1593
1778
|
def task_json_path(root: Path, task_id: str) -> Path:
|
|
1594
1779
|
assert_safe_task_id(task_id)
|
|
1595
1780
|
return root / ".easy-coding" / "tasks" / task_id / "task.json"
|
|
@@ -1645,6 +1830,76 @@ def is_valid_review_finding(value: object) -> bool:
|
|
|
1645
1830
|
)
|
|
1646
1831
|
|
|
1647
1832
|
|
|
1833
|
+
def validate_quality_gate_record_schemas(
|
|
1834
|
+
review_records: list[dict], verification_records: list[dict]
|
|
1835
|
+
) -> None:
|
|
1836
|
+
latest_reviews: dict[tuple[str, str], dict] = {}
|
|
1837
|
+
for index, record in enumerate(review_records):
|
|
1838
|
+
dimension = str(record.get("dimension") or f"<missing-{index}>")
|
|
1839
|
+
latest_reviews[(str(record.get("source_task_id") or ""), dimension)] = record
|
|
1840
|
+
for record in latest_reviews.values():
|
|
1841
|
+
findings = record.get("findings")
|
|
1842
|
+
if (
|
|
1843
|
+
not is_non_empty_string(record.get("dimension"))
|
|
1844
|
+
or type(record.get("passed")) is not bool
|
|
1845
|
+
or not is_non_empty_string(record.get("reviewer"))
|
|
1846
|
+
or not isinstance(findings, list)
|
|
1847
|
+
or not all(is_valid_review_finding(finding) for finding in findings)
|
|
1848
|
+
):
|
|
1849
|
+
raise StateError(
|
|
1850
|
+
"Review Gate evidence must include dimension, boolean passed, reviewer, "
|
|
1851
|
+
"timestamp, and valid structured findings."
|
|
1852
|
+
)
|
|
1853
|
+
parse_quality_timestamp(record.get("timestamp"), "review timestamp")
|
|
1854
|
+
failure_classes = record.get("failure_classes")
|
|
1855
|
+
if failure_classes is not None and (
|
|
1856
|
+
not isinstance(failure_classes, list)
|
|
1857
|
+
or any(
|
|
1858
|
+
value not in QUALITY_FAILURE_CLASSES - {"suggestion"}
|
|
1859
|
+
for value in failure_classes
|
|
1860
|
+
)
|
|
1861
|
+
):
|
|
1862
|
+
raise StateError("Review Gate failure_classes are invalid.")
|
|
1863
|
+
|
|
1864
|
+
latest_verifications: dict[tuple[str, str, str], dict] = {}
|
|
1865
|
+
for index, record in enumerate(verification_records):
|
|
1866
|
+
check = str(record.get("check") or f"<missing-{index}>")
|
|
1867
|
+
latest_verifications[
|
|
1868
|
+
(
|
|
1869
|
+
str(record.get("source_task_id") or ""),
|
|
1870
|
+
check,
|
|
1871
|
+
str(record.get("coverage_scope") or ""),
|
|
1872
|
+
)
|
|
1873
|
+
] = record
|
|
1874
|
+
for record in latest_verifications.values():
|
|
1875
|
+
applicable = record.get("applicable") is not False
|
|
1876
|
+
if (
|
|
1877
|
+
not is_non_empty_string(record.get("check"))
|
|
1878
|
+
or record.get("check_type")
|
|
1879
|
+
not in STRICT_VERIFICATION_CHECK_TYPES | {"coverage"}
|
|
1880
|
+
or type(record.get("passed")) is not bool
|
|
1881
|
+
or (applicable and not is_non_empty_string(record.get("command")))
|
|
1882
|
+
or (
|
|
1883
|
+
not applicable
|
|
1884
|
+
and not is_non_empty_string(record.get("not_applicable_reason"))
|
|
1885
|
+
)
|
|
1886
|
+
):
|
|
1887
|
+
raise StateError(
|
|
1888
|
+
"Verification Gate evidence must include check, check_type, boolean passed, "
|
|
1889
|
+
"timestamp, and command or an explicit not-applicable reason."
|
|
1890
|
+
)
|
|
1891
|
+
parse_quality_timestamp(record.get("timestamp"), "verification timestamp")
|
|
1892
|
+
failure_classes = record.get("failure_classes")
|
|
1893
|
+
if failure_classes is not None and (
|
|
1894
|
+
not isinstance(failure_classes, list)
|
|
1895
|
+
or any(
|
|
1896
|
+
value not in QUALITY_FAILURE_CLASSES - {"suggestion"}
|
|
1897
|
+
for value in failure_classes
|
|
1898
|
+
)
|
|
1899
|
+
):
|
|
1900
|
+
raise StateError("Verification Gate failure_classes are invalid.")
|
|
1901
|
+
|
|
1902
|
+
|
|
1648
1903
|
def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
|
|
1649
1904
|
remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
|
|
1650
1905
|
resolved: set[str] = set()
|
|
@@ -1749,16 +2004,6 @@ def is_valid_execution_plan(
|
|
|
1749
2004
|
return True
|
|
1750
2005
|
|
|
1751
2006
|
|
|
1752
|
-
def is_read_only_execution_plan(plan: object) -> bool:
|
|
1753
|
-
return (
|
|
1754
|
-
is_valid_execution_plan(plan, allow_empty_files=True)
|
|
1755
|
-
and isinstance(plan, dict)
|
|
1756
|
-
and plan.get("strategy") == "single"
|
|
1757
|
-
and len(plan["units"]) == 1
|
|
1758
|
-
and plan["units"][0].get("files") == []
|
|
1759
|
-
)
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
2007
|
def stored_spec_path(root: Path, task: dict) -> Path:
|
|
1763
2008
|
source = task.get("spec_source")
|
|
1764
2009
|
if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
|
|
@@ -2194,9 +2439,6 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
|
2194
2439
|
except OSError:
|
|
2195
2440
|
return False
|
|
2196
2441
|
task = load_task(root, task_id)
|
|
2197
|
-
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
2198
|
-
if task_type in NO_CODE_TASK_TYPES:
|
|
2199
|
-
return is_read_only_execution_plan(latest_plan)
|
|
2200
2442
|
valid = is_valid_execution_plan(
|
|
2201
2443
|
latest_plan,
|
|
2202
2444
|
require_unit_contracts=read_project_schema_version(root) >= 3,
|
|
@@ -2781,6 +3023,77 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
2781
3023
|
return digest.hexdigest()
|
|
2782
3024
|
|
|
2783
3025
|
|
|
3026
|
+
def canonical_repository_fingerprints(
|
|
3027
|
+
root: Path, task_id: str, task: dict
|
|
3028
|
+
) -> dict[str, str]:
|
|
3029
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
3030
|
+
return {}
|
|
3031
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
3032
|
+
repo_paths = task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {}
|
|
3033
|
+
fingerprints: dict[str, str] = {}
|
|
3034
|
+
for repo_id in sorted(
|
|
3035
|
+
{
|
|
3036
|
+
str(unit.get("repo_id"))
|
|
3037
|
+
for unit in plan.get("units", [])
|
|
3038
|
+
if isinstance(unit, dict) and is_non_empty_string(unit.get("repo_id"))
|
|
3039
|
+
}
|
|
3040
|
+
):
|
|
3041
|
+
raw_base = repo_paths.get(repo_id)
|
|
3042
|
+
if not is_non_empty_string(raw_base):
|
|
3043
|
+
continue
|
|
3044
|
+
base = Path(str(raw_base))
|
|
3045
|
+
if not base.is_absolute():
|
|
3046
|
+
base = root / base
|
|
3047
|
+
base = base.resolve()
|
|
3048
|
+
digest = hashlib.sha256()
|
|
3049
|
+
units = [
|
|
3050
|
+
unit
|
|
3051
|
+
for unit in plan.get("units", [])
|
|
3052
|
+
if isinstance(unit, dict) and unit.get("repo_id") == repo_id
|
|
3053
|
+
]
|
|
3054
|
+
digest.update(
|
|
3055
|
+
json.dumps(
|
|
3056
|
+
units,
|
|
3057
|
+
ensure_ascii=False,
|
|
3058
|
+
sort_keys=True,
|
|
3059
|
+
separators=(",", ":"),
|
|
3060
|
+
).encode("utf-8")
|
|
3061
|
+
)
|
|
3062
|
+
digest.update(b"\0")
|
|
3063
|
+
repository = git_repository_root(base)
|
|
3064
|
+
if repository is not None and repository.resolve() == base:
|
|
3065
|
+
update_git_repository_content_fingerprint(
|
|
3066
|
+
digest,
|
|
3067
|
+
root,
|
|
3068
|
+
repository,
|
|
3069
|
+
[base],
|
|
3070
|
+
set(),
|
|
3071
|
+
)
|
|
3072
|
+
else:
|
|
3073
|
+
for unit in units:
|
|
3074
|
+
for file_name in sorted(
|
|
3075
|
+
str(value)
|
|
3076
|
+
for value in unit.get("files", [])
|
|
3077
|
+
if is_non_empty_string(value)
|
|
3078
|
+
):
|
|
3079
|
+
candidate = (base / file_name).resolve()
|
|
3080
|
+
try:
|
|
3081
|
+
candidate.relative_to(base)
|
|
3082
|
+
except ValueError as error:
|
|
3083
|
+
raise StateError(
|
|
3084
|
+
f"Execution plan file escapes repository: {file_name}"
|
|
3085
|
+
) from error
|
|
3086
|
+
digest.update(file_name.encode("utf-8"))
|
|
3087
|
+
digest.update(b"\0")
|
|
3088
|
+
try:
|
|
3089
|
+
digest.update(candidate.read_bytes())
|
|
3090
|
+
except OSError:
|
|
3091
|
+
digest.update(b"<missing>")
|
|
3092
|
+
digest.update(b"\0")
|
|
3093
|
+
fingerprints[repo_id] = digest.hexdigest()
|
|
3094
|
+
return fingerprints
|
|
3095
|
+
|
|
3096
|
+
|
|
2784
3097
|
def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
|
|
2785
3098
|
"""任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
|
|
2786
3099
|
try:
|
|
@@ -2837,98 +3150,987 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
|
|
|
2837
3150
|
}
|
|
2838
3151
|
|
|
2839
3152
|
|
|
2840
|
-
def
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
3153
|
+
def parse_quality_timestamp(value: object, field: str) -> datetime:
|
|
3154
|
+
if not is_non_empty_string(value):
|
|
3155
|
+
raise StateError(f"QUALITY record {field} must be a non-empty ISO timestamp.")
|
|
3156
|
+
try:
|
|
3157
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
3158
|
+
except ValueError as exc:
|
|
3159
|
+
raise StateError(f"QUALITY record {field} must be an ISO timestamp.") from exc
|
|
3160
|
+
if parsed.tzinfo is None:
|
|
3161
|
+
raise StateError(f"QUALITY record {field} must include a timezone.")
|
|
3162
|
+
return parsed.astimezone(timezone.utc)
|
|
3163
|
+
|
|
3164
|
+
|
|
3165
|
+
def validated_quality_records(root: Path, task_id: str) -> list[tuple[int, dict]]:
|
|
3166
|
+
validated: list[tuple[int, dict]] = []
|
|
3167
|
+
expected_attempt = 1
|
|
3168
|
+
repair_count = 0
|
|
3169
|
+
for index, record in enumerate(execution_records(root, task_id)):
|
|
3170
|
+
if record.get("type") != "quality":
|
|
3171
|
+
continue
|
|
3172
|
+
outcome = record.get("outcome")
|
|
3173
|
+
if outcome not in {"passed", "repair", "replan", "cancelled"}:
|
|
3174
|
+
raise StateError(
|
|
3175
|
+
"QUALITY record outcome must be passed, repair, replan, or cancelled."
|
|
3176
|
+
)
|
|
3177
|
+
if outcome == "repair":
|
|
3178
|
+
repair_count += 1
|
|
3179
|
+
started_at = parse_quality_timestamp(record.get("started_at"), "started_at")
|
|
3180
|
+
completed_at = parse_quality_timestamp(record.get("completed_at"), "completed_at")
|
|
3181
|
+
duration_ms = record.get("duration_ms")
|
|
3182
|
+
evidence_start = record.get("evidence_start_index")
|
|
3183
|
+
evidence_end = record.get("evidence_end_index")
|
|
3184
|
+
failure_classes = record.get("failure_classes", [])
|
|
3185
|
+
repository_fingerprints = record.get("repository_fingerprints", {})
|
|
3186
|
+
cancellation_reason = record.get("cancellation_reason")
|
|
3187
|
+
if (
|
|
3188
|
+
record.get("attempt") != expected_attempt
|
|
3189
|
+
or not is_non_empty_string(record.get("implementation_fingerprint"))
|
|
3190
|
+
or not is_non_empty_string(record.get("config_fingerprint"))
|
|
3191
|
+
or type(duration_ms) is not int
|
|
3192
|
+
or duration_ms < 0
|
|
3193
|
+
or record.get("repair_count") != repair_count
|
|
3194
|
+
or type(evidence_start) is not int
|
|
3195
|
+
or type(evidence_end) is not int
|
|
3196
|
+
or evidence_start < 0
|
|
3197
|
+
or evidence_end < evidence_start
|
|
3198
|
+
or evidence_end != index
|
|
3199
|
+
or completed_at < started_at
|
|
3200
|
+
or not isinstance(failure_classes, list)
|
|
3201
|
+
or any(value not in QUALITY_FAILURE_CLASSES for value in failure_classes)
|
|
3202
|
+
or not isinstance(repository_fingerprints, dict)
|
|
3203
|
+
or any(
|
|
3204
|
+
not is_non_empty_string(key) or not is_non_empty_string(value)
|
|
3205
|
+
for key, value in repository_fingerprints.items()
|
|
3206
|
+
)
|
|
3207
|
+
or record.get("review_gate") not in QUALITY_GATE_STATUSES
|
|
3208
|
+
or record.get("verification_gate") not in QUALITY_GATE_STATUSES
|
|
3209
|
+
or not is_non_empty_string(record.get("summary"))
|
|
3210
|
+
or (
|
|
3211
|
+
outcome == "cancelled"
|
|
3212
|
+
and cancellation_reason not in QUALITY_CANCELLATION_REASONS
|
|
3213
|
+
)
|
|
3214
|
+
or (outcome != "cancelled" and cancellation_reason is not None)
|
|
3215
|
+
):
|
|
3216
|
+
raise StateError(
|
|
3217
|
+
"QUALITY records must be sequential, finalized, fingerprint-bound, and append-only."
|
|
3218
|
+
)
|
|
3219
|
+
validated.append((index, record))
|
|
3220
|
+
expected_attempt += 1
|
|
3221
|
+
return validated
|
|
2853
3222
|
|
|
2854
3223
|
|
|
2855
|
-
def
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
3224
|
+
def build_quality_attempt_context(
|
|
3225
|
+
root: Path,
|
|
3226
|
+
task_id: str,
|
|
3227
|
+
task: dict,
|
|
3228
|
+
infer_existing_evidence: bool = False,
|
|
3229
|
+
) -> dict:
|
|
3230
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3231
|
+
records = execution_records(root, task_id)
|
|
3232
|
+
quality_records = validated_quality_records(root, task_id)
|
|
3233
|
+
execution_start_index = len(records)
|
|
3234
|
+
started_at = now_iso()
|
|
3235
|
+
if infer_existing_evidence:
|
|
3236
|
+
previous_quality_index = quality_records[-1][0] if quality_records else -1
|
|
3237
|
+
candidates = [
|
|
3238
|
+
(index, record)
|
|
3239
|
+
for index, record in enumerate(records[previous_quality_index + 1 :], previous_quality_index + 1)
|
|
3240
|
+
if (
|
|
3241
|
+
record.get("type") == "review"
|
|
3242
|
+
and record.get("implementation_fingerprint")
|
|
3243
|
+
== fingerprints["implementation_fingerprint"]
|
|
3244
|
+
)
|
|
3245
|
+
or (
|
|
3246
|
+
record.get("type") == "verify"
|
|
3247
|
+
and record.get("implementation_fingerprint")
|
|
3248
|
+
== fingerprints["implementation_fingerprint"]
|
|
3249
|
+
and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
|
|
3250
|
+
)
|
|
3251
|
+
]
|
|
3252
|
+
if candidates:
|
|
3253
|
+
execution_start_index = candidates[0][0]
|
|
3254
|
+
timestamps = [
|
|
3255
|
+
str(record.get("timestamp"))
|
|
3256
|
+
for _index, record in candidates
|
|
3257
|
+
if is_non_empty_string(record.get("timestamp"))
|
|
3258
|
+
]
|
|
3259
|
+
if timestamps:
|
|
3260
|
+
started_at = min(timestamps)
|
|
3261
|
+
return {
|
|
3262
|
+
"schema": 1,
|
|
3263
|
+
"attempt": len(quality_records) + 1,
|
|
3264
|
+
"implementation_fingerprint": fingerprints["implementation_fingerprint"],
|
|
3265
|
+
"config_fingerprint": fingerprints["config_fingerprint"],
|
|
3266
|
+
"started_at": started_at,
|
|
3267
|
+
"execution_start_index": execution_start_index,
|
|
3268
|
+
"repair_count": sum(
|
|
3269
|
+
1 for _index, record in quality_records if record.get("outcome") == "repair"
|
|
3270
|
+
),
|
|
2877
3271
|
}
|
|
2878
|
-
return canonical_json_sha256(contract)
|
|
2879
3272
|
|
|
2880
3273
|
|
|
2881
|
-
def
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
"
|
|
2887
|
-
"
|
|
2888
|
-
"
|
|
2889
|
-
"--exclude-standard",
|
|
2890
|
-
"-z",
|
|
2891
|
-
"--",
|
|
2892
|
-
*pathspecs,
|
|
2893
|
-
)
|
|
2894
|
-
modified = run_git(
|
|
2895
|
-
repository,
|
|
2896
|
-
"diff-files",
|
|
2897
|
-
"--name-only",
|
|
2898
|
-
"-z",
|
|
2899
|
-
"--ignore-submodules=none",
|
|
2900
|
-
"--",
|
|
2901
|
-
*pathspecs,
|
|
3274
|
+
def quality_record_matches_active_attempt(record: dict, active: dict) -> bool:
|
|
3275
|
+
return (
|
|
3276
|
+
record.get("attempt") == active.get("attempt")
|
|
3277
|
+
and record.get("implementation_fingerprint")
|
|
3278
|
+
== active.get("implementation_fingerprint")
|
|
3279
|
+
and record.get("config_fingerprint") == active.get("config_fingerprint")
|
|
3280
|
+
and record.get("evidence_start_index")
|
|
3281
|
+
== active.get("execution_start_index")
|
|
2902
3282
|
)
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
3283
|
+
|
|
3284
|
+
|
|
3285
|
+
def cancel_active_quality_attempt(
|
|
3286
|
+
root: Path,
|
|
3287
|
+
task_id: str,
|
|
3288
|
+
task: dict,
|
|
3289
|
+
agent: str,
|
|
3290
|
+
summary: str,
|
|
3291
|
+
cancellation_reason: str,
|
|
3292
|
+
) -> dict | None:
|
|
3293
|
+
current = task.get("quality_attempt")
|
|
3294
|
+
if not isinstance(current, dict):
|
|
3295
|
+
return None
|
|
3296
|
+
quality_records = validated_quality_records(root, task_id)
|
|
3297
|
+
if quality_records:
|
|
3298
|
+
finalized = quality_records[-1][1]
|
|
3299
|
+
if finalized.get("outcome") == "cancelled" and quality_record_matches_active_attempt(
|
|
3300
|
+
finalized, current
|
|
3301
|
+
):
|
|
3302
|
+
return reconcile_finalized_quality_state(
|
|
3303
|
+
root, task_id, task, finalized, agent
|
|
2923
3304
|
)
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
3305
|
+
if (
|
|
3306
|
+
current.get("schema") != 1
|
|
3307
|
+
or current.get("attempt") != len(quality_records) + 1
|
|
3308
|
+
or not is_non_empty_string(current.get("implementation_fingerprint"))
|
|
3309
|
+
or not is_non_empty_string(current.get("config_fingerprint"))
|
|
3310
|
+
or type(current.get("execution_start_index")) is not int
|
|
3311
|
+
or current["execution_start_index"] < 0
|
|
3312
|
+
or not is_non_empty_string(current.get("started_at"))
|
|
3313
|
+
or not is_non_empty_string(summary)
|
|
3314
|
+
or cancellation_reason not in QUALITY_CANCELLATION_REASONS
|
|
3315
|
+
):
|
|
3316
|
+
raise StateError("The active QUALITY attempt metadata is invalid.")
|
|
3317
|
+
started_at = parse_quality_timestamp(current.get("started_at"), "started_at")
|
|
3318
|
+
completed_at = datetime.now(timezone.utc)
|
|
3319
|
+
evidence_end_index = len(execution_records(root, task_id))
|
|
3320
|
+
record = {
|
|
3321
|
+
"type": "quality",
|
|
3322
|
+
"attempt": current["attempt"],
|
|
3323
|
+
"implementation_fingerprint": current["implementation_fingerprint"],
|
|
3324
|
+
"config_fingerprint": current["config_fingerprint"],
|
|
3325
|
+
"started_at": started_at.isoformat(),
|
|
3326
|
+
"completed_at": completed_at.isoformat(),
|
|
3327
|
+
"duration_ms": max(0, int((completed_at - started_at).total_seconds() * 1000)),
|
|
3328
|
+
"repair_count": int(current.get("repair_count") or 0),
|
|
3329
|
+
"outcome": "cancelled",
|
|
3330
|
+
"cancellation_reason": cancellation_reason,
|
|
3331
|
+
"review_gate": "cancelled",
|
|
3332
|
+
"verification_gate": "cancelled",
|
|
3333
|
+
"summary": summary.strip(),
|
|
3334
|
+
"failure_classes": [],
|
|
3335
|
+
"repository_fingerprints": {},
|
|
3336
|
+
"evidence_start_index": current["execution_start_index"],
|
|
3337
|
+
"evidence_end_index": evidence_end_index,
|
|
3338
|
+
}
|
|
3339
|
+
append_execution_record(root, task_id, record)
|
|
3340
|
+
return reconcile_finalized_quality_state(root, task_id, task, record, agent)
|
|
3341
|
+
|
|
3342
|
+
|
|
3343
|
+
def ensure_quality_attempt_context(
|
|
3344
|
+
root: Path,
|
|
3345
|
+
task_id: str,
|
|
3346
|
+
task: dict,
|
|
3347
|
+
agent: str,
|
|
3348
|
+
persist: bool = False,
|
|
3349
|
+
infer_existing_evidence: bool = False,
|
|
3350
|
+
) -> dict:
|
|
3351
|
+
if isinstance(task.get("canonical_repair_transition"), dict):
|
|
3352
|
+
raise StateError(
|
|
3353
|
+
"Canonical repair transition is incomplete; resume it before collecting new QUALITY evidence."
|
|
3354
|
+
)
|
|
3355
|
+
if isinstance(task.get("quality_return_required"), dict):
|
|
3356
|
+
raise StateError(
|
|
3357
|
+
"QUALITY candidate drift requires a return to IMPLEMENT before collecting new evidence."
|
|
3358
|
+
)
|
|
3359
|
+
current = task.get("quality_attempt")
|
|
3360
|
+
expected = evidence_fingerprints(root, task_id)
|
|
3361
|
+
quality_records = validated_quality_records(root, task_id)
|
|
3362
|
+
if isinstance(current, dict) and quality_records:
|
|
3363
|
+
finalized = quality_records[-1][1]
|
|
3364
|
+
if finalized.get("outcome") == "cancelled" and quality_record_matches_active_attempt(
|
|
3365
|
+
finalized, current
|
|
3366
|
+
):
|
|
3367
|
+
reconcile_finalized_quality_state(root, task_id, task, finalized, agent)
|
|
3368
|
+
task = load_task(root, task_id) or task
|
|
3369
|
+
current = None
|
|
3370
|
+
if isinstance(task.get("quality_return_required"), dict):
|
|
3371
|
+
raise StateError(
|
|
3372
|
+
"QUALITY candidate drift requires a return to IMPLEMENT before collecting new evidence."
|
|
3373
|
+
)
|
|
3374
|
+
if isinstance(current, dict):
|
|
3375
|
+
structurally_invalid = (
|
|
3376
|
+
current.get("schema") != 1
|
|
3377
|
+
or current.get("attempt") != len(quality_records) + 1
|
|
3378
|
+
or type(current.get("execution_start_index")) is not int
|
|
3379
|
+
or current["execution_start_index"] < 0
|
|
3380
|
+
or not is_non_empty_string(current.get("started_at"))
|
|
3381
|
+
)
|
|
3382
|
+
implementation_changed = (
|
|
3383
|
+
current.get("implementation_fingerprint")
|
|
3384
|
+
!= expected["implementation_fingerprint"]
|
|
3385
|
+
)
|
|
3386
|
+
config_changed = current.get("config_fingerprint") != expected["config_fingerprint"]
|
|
3387
|
+
if structurally_invalid:
|
|
3388
|
+
raise StateError(
|
|
3389
|
+
"The active QUALITY attempt no longer matches the current candidate."
|
|
3390
|
+
)
|
|
3391
|
+
if implementation_changed:
|
|
3392
|
+
if persist:
|
|
3393
|
+
cancel_active_quality_attempt(
|
|
3394
|
+
root,
|
|
3395
|
+
task_id,
|
|
3396
|
+
task,
|
|
3397
|
+
agent,
|
|
3398
|
+
"Implementation changed during QUALITY; return to IMPLEMENT.",
|
|
3399
|
+
"implementation-drift",
|
|
3400
|
+
)
|
|
3401
|
+
raise StateError(
|
|
3402
|
+
"The QUALITY attempt was cancelled because the implementation changed; "
|
|
3403
|
+
"return to IMPLEMENT before collecting new evidence."
|
|
3404
|
+
)
|
|
3405
|
+
raise StateError(
|
|
3406
|
+
"The active QUALITY attempt no longer matches the current candidate."
|
|
3407
|
+
)
|
|
3408
|
+
if config_changed:
|
|
3409
|
+
if not persist:
|
|
3410
|
+
raise StateError(
|
|
3411
|
+
"The active QUALITY attempt no longer matches the current config."
|
|
3412
|
+
)
|
|
3413
|
+
cancel_active_quality_attempt(
|
|
3414
|
+
root,
|
|
3415
|
+
task_id,
|
|
3416
|
+
task,
|
|
3417
|
+
agent,
|
|
3418
|
+
"Behavior config changed during QUALITY; restart the quality attempt.",
|
|
3419
|
+
"config-drift",
|
|
3420
|
+
)
|
|
3421
|
+
current = None
|
|
3422
|
+
if isinstance(current, dict):
|
|
3423
|
+
return current
|
|
3424
|
+
if quality_records:
|
|
3425
|
+
finalized = quality_records[-1][1]
|
|
3426
|
+
if (
|
|
3427
|
+
finalized.get("outcome") in {"passed", "repair", "replan"}
|
|
3428
|
+
and finalized.get("attempt") != task.get("quality_consumed_attempt")
|
|
3429
|
+
and finalized.get("implementation_fingerprint")
|
|
3430
|
+
!= expected["implementation_fingerprint"]
|
|
3431
|
+
):
|
|
3432
|
+
if persist:
|
|
3433
|
+
task["quality_return_required"] = {
|
|
3434
|
+
"schema": 1,
|
|
3435
|
+
"reason": "finalized-candidate-drift",
|
|
3436
|
+
"previous_implementation_fingerprint": finalized.get(
|
|
3437
|
+
"implementation_fingerprint"
|
|
3438
|
+
),
|
|
3439
|
+
"implementation_fingerprint": expected[
|
|
3440
|
+
"implementation_fingerprint"
|
|
3441
|
+
],
|
|
3442
|
+
"detected_at": now_iso(),
|
|
3443
|
+
}
|
|
3444
|
+
task["last_agent"] = agent
|
|
3445
|
+
write_task(root, task_id, task)
|
|
3446
|
+
raise StateError(
|
|
3447
|
+
"The finalized QUALITY candidate changed; return to IMPLEMENT before "
|
|
3448
|
+
"collecting new evidence."
|
|
3449
|
+
)
|
|
3450
|
+
if (
|
|
3451
|
+
finalized.get("outcome") in {"passed", "repair", "replan"}
|
|
3452
|
+
and finalized.get("implementation_fingerprint")
|
|
3453
|
+
== expected["implementation_fingerprint"]
|
|
3454
|
+
and finalized.get("config_fingerprint") == expected["config_fingerprint"]
|
|
3455
|
+
):
|
|
3456
|
+
raise StateError(
|
|
3457
|
+
"The current QUALITY candidate is already finalized; apply its transition "
|
|
3458
|
+
"before starting another attempt."
|
|
3459
|
+
)
|
|
3460
|
+
context = build_quality_attempt_context(
|
|
3461
|
+
root, task_id, task, infer_existing_evidence=infer_existing_evidence
|
|
3462
|
+
)
|
|
3463
|
+
if persist:
|
|
3464
|
+
append_canonical_quality_carry_forward(root, task_id, task, context, agent)
|
|
3465
|
+
task["quality_attempt"] = context
|
|
3466
|
+
task["last_agent"] = agent
|
|
3467
|
+
write_task(root, task_id, task)
|
|
3468
|
+
return context
|
|
3469
|
+
|
|
3470
|
+
|
|
3471
|
+
def require_finalized_quality_record(
|
|
3472
|
+
root: Path, task_id: str, task: dict, outcome: str
|
|
3473
|
+
) -> dict:
|
|
3474
|
+
records = validated_quality_records(root, task_id)
|
|
3475
|
+
if not records:
|
|
3476
|
+
raise StateError("QUALITY has no finalized attempt record.")
|
|
3477
|
+
record = records[-1][1]
|
|
3478
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3479
|
+
if (
|
|
3480
|
+
record.get("outcome") != outcome
|
|
3481
|
+
or record.get("implementation_fingerprint")
|
|
3482
|
+
!= fingerprints["implementation_fingerprint"]
|
|
3483
|
+
or record.get("config_fingerprint") != fingerprints["config_fingerprint"]
|
|
3484
|
+
):
|
|
3485
|
+
raise StateError(
|
|
3486
|
+
f"The latest QUALITY attempt must finalize the current candidate as {outcome}."
|
|
3487
|
+
)
|
|
3488
|
+
return record
|
|
3489
|
+
|
|
3490
|
+
|
|
3491
|
+
def require_checkpoint_quality_record(root: Path, task_id: str, task: dict) -> dict:
|
|
3492
|
+
checkpoint = task.get("quality_checkpoint")
|
|
3493
|
+
records = validated_quality_records(root, task_id)
|
|
3494
|
+
if not isinstance(checkpoint, dict) or not records:
|
|
3495
|
+
raise StateError("QUALITY checkpoint has no finalized passed attempt record.")
|
|
3496
|
+
record = records[-1][1]
|
|
3497
|
+
if (
|
|
3498
|
+
record.get("outcome") != "passed"
|
|
3499
|
+
or record.get("implementation_fingerprint")
|
|
3500
|
+
!= checkpoint.get("implementation_fingerprint")
|
|
3501
|
+
or record.get("config_fingerprint") != checkpoint.get("config_fingerprint")
|
|
3502
|
+
):
|
|
3503
|
+
raise StateError("QUALITY checkpoint is not bound to its finalized passed attempt.")
|
|
3504
|
+
return record
|
|
3505
|
+
|
|
3506
|
+
|
|
3507
|
+
def reconcile_finalized_quality_state(
|
|
3508
|
+
root: Path,
|
|
3509
|
+
task_id: str,
|
|
3510
|
+
task: dict,
|
|
3511
|
+
record: dict,
|
|
3512
|
+
agent: str,
|
|
3513
|
+
failures: dict[str, list[str]] | None = None,
|
|
3514
|
+
) -> dict:
|
|
3515
|
+
refreshed = load_task(root, task_id) or task
|
|
3516
|
+
active = refreshed.get("quality_attempt")
|
|
3517
|
+
if isinstance(active, dict):
|
|
3518
|
+
if (
|
|
3519
|
+
active.get("attempt") != record.get("attempt")
|
|
3520
|
+
or active.get("implementation_fingerprint")
|
|
3521
|
+
!= record.get("implementation_fingerprint")
|
|
3522
|
+
or active.get("config_fingerprint") != record.get("config_fingerprint")
|
|
3523
|
+
or active.get("execution_start_index")
|
|
3524
|
+
!= record.get("evidence_start_index")
|
|
3525
|
+
):
|
|
3526
|
+
raise StateError(
|
|
3527
|
+
"The finalized QUALITY record does not match the active attempt."
|
|
3528
|
+
)
|
|
3529
|
+
refreshed.pop("quality_attempt", None)
|
|
3530
|
+
|
|
3531
|
+
if (
|
|
3532
|
+
record.get("outcome") == "cancelled"
|
|
3533
|
+
and record.get("cancellation_reason") == "implementation-drift"
|
|
3534
|
+
):
|
|
3535
|
+
current_fingerprint = evidence_fingerprints(root, task_id)[
|
|
3536
|
+
"implementation_fingerprint"
|
|
3537
|
+
]
|
|
3538
|
+
expected_return = {
|
|
3539
|
+
"schema": 1,
|
|
3540
|
+
"reason": "implementation-drift",
|
|
3541
|
+
"previous_implementation_fingerprint": record[
|
|
3542
|
+
"implementation_fingerprint"
|
|
3543
|
+
],
|
|
3544
|
+
"implementation_fingerprint": current_fingerprint,
|
|
3545
|
+
}
|
|
3546
|
+
current_return = refreshed.get("quality_return_required")
|
|
3547
|
+
if isinstance(current_return, dict):
|
|
3548
|
+
if any(
|
|
3549
|
+
current_return.get(key) != value
|
|
3550
|
+
for key, value in expected_return.items()
|
|
3551
|
+
):
|
|
3552
|
+
raise StateError(
|
|
3553
|
+
"QUALITY implementation-drift return intent no longer matches the cancelled attempt."
|
|
3554
|
+
)
|
|
3555
|
+
else:
|
|
3556
|
+
refreshed["quality_return_required"] = {
|
|
3557
|
+
**expected_return,
|
|
3558
|
+
"detected_at": now_iso(),
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
if record.get("outcome") == "repair" and isinstance(
|
|
3562
|
+
refreshed.get("spec_source"), dict
|
|
3563
|
+
):
|
|
3564
|
+
repair_failures = failures or quality_repair_failures_for_window(
|
|
3565
|
+
root,
|
|
3566
|
+
task_id,
|
|
3567
|
+
refreshed,
|
|
3568
|
+
int(record["evidence_start_index"]),
|
|
3569
|
+
int(record["evidence_end_index"]),
|
|
3570
|
+
int(record["attempt"]),
|
|
3571
|
+
str(record["implementation_fingerprint"]),
|
|
3572
|
+
str(record["config_fingerprint"]),
|
|
3573
|
+
)
|
|
3574
|
+
if not repair_failures:
|
|
3575
|
+
raise StateError("Canonical QUALITY repair has no affected source tasks.")
|
|
3576
|
+
expected_intent = {
|
|
3577
|
+
"schema": 1,
|
|
3578
|
+
"implementation_fingerprint": record["implementation_fingerprint"],
|
|
3579
|
+
"config_fingerprint": record["config_fingerprint"],
|
|
3580
|
+
"quality_attempt": record["attempt"],
|
|
3581
|
+
"source_task_ids": sorted(repair_failures),
|
|
3582
|
+
}
|
|
3583
|
+
current_intent = refreshed.get("canonical_repair_transition")
|
|
3584
|
+
if isinstance(current_intent, dict):
|
|
3585
|
+
if any(
|
|
3586
|
+
current_intent.get(key) != value
|
|
3587
|
+
for key, value in expected_intent.items()
|
|
3588
|
+
):
|
|
3589
|
+
raise StateError(
|
|
3590
|
+
"Canonical repair transition intent no longer matches QUALITY evidence."
|
|
3591
|
+
)
|
|
3592
|
+
else:
|
|
3593
|
+
refreshed["canonical_repair_transition"] = {
|
|
3594
|
+
**expected_intent,
|
|
3595
|
+
"started_at": now_iso(),
|
|
3596
|
+
"started_by": agent,
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
refreshed["last_agent"] = agent
|
|
3600
|
+
write_task(root, task_id, refreshed)
|
|
3601
|
+
validated_quality_records(root, task_id)
|
|
3602
|
+
return record
|
|
3603
|
+
|
|
3604
|
+
|
|
3605
|
+
def finalize_quality_attempt(
|
|
3606
|
+
root: Path,
|
|
3607
|
+
task_id: str,
|
|
3608
|
+
task: dict,
|
|
3609
|
+
outcome: str,
|
|
3610
|
+
agent: str,
|
|
3611
|
+
review_gate: str = "passed",
|
|
3612
|
+
verification_gate: str = "passed",
|
|
3613
|
+
failure_classes: list[str] | None = None,
|
|
3614
|
+
summary: str = "QUALITY gates passed for the current candidate.",
|
|
3615
|
+
) -> dict:
|
|
3616
|
+
if outcome not in {"passed", "repair", "replan"}:
|
|
3617
|
+
raise StateError("Unknown QUALITY outcome.")
|
|
3618
|
+
if review_gate not in QUALITY_GATE_STATUSES or verification_gate not in QUALITY_GATE_STATUSES:
|
|
3619
|
+
raise StateError("Both QUALITY gates must be passed, failed, or cancelled.")
|
|
3620
|
+
normalized_classes = sorted(set(failure_classes or []))
|
|
3621
|
+
if any(value not in QUALITY_FAILURE_CLASSES for value in normalized_classes):
|
|
3622
|
+
raise StateError("Unknown QUALITY failure class.")
|
|
3623
|
+
if not is_non_empty_string(summary):
|
|
3624
|
+
raise StateError("QUALITY decision summary must be non-empty.")
|
|
3625
|
+
existing = validated_quality_records(root, task_id)
|
|
3626
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3627
|
+
if existing:
|
|
3628
|
+
finalized = existing[-1][1]
|
|
3629
|
+
if (
|
|
3630
|
+
finalized.get("outcome") != "cancelled"
|
|
3631
|
+
and finalized.get("implementation_fingerprint")
|
|
3632
|
+
== fingerprints["implementation_fingerprint"]
|
|
3633
|
+
and finalized.get("config_fingerprint") == fingerprints["config_fingerprint"]
|
|
3634
|
+
):
|
|
3635
|
+
same_decision = (
|
|
3636
|
+
finalized.get("outcome") == outcome
|
|
3637
|
+
and finalized.get("review_gate") == review_gate
|
|
3638
|
+
and finalized.get("verification_gate") == verification_gate
|
|
3639
|
+
and finalized.get("failure_classes") == normalized_classes
|
|
3640
|
+
and finalized.get("summary") == summary.strip()
|
|
3641
|
+
)
|
|
3642
|
+
active = task.get("quality_attempt")
|
|
3643
|
+
if isinstance(active, dict):
|
|
3644
|
+
if active.get("attempt") == finalized.get("attempt"):
|
|
3645
|
+
if not same_decision:
|
|
3646
|
+
raise StateError(
|
|
3647
|
+
"The current QUALITY candidate already finalized with another decision."
|
|
3648
|
+
)
|
|
3649
|
+
return reconcile_finalized_quality_state(
|
|
3650
|
+
root, task_id, task, finalized, agent
|
|
3651
|
+
)
|
|
3652
|
+
if active.get("attempt") != int(finalized.get("attempt") or 0) + 1:
|
|
3653
|
+
raise StateError(
|
|
3654
|
+
"The active QUALITY attempt does not follow the latest finalized attempt."
|
|
3655
|
+
)
|
|
3656
|
+
else:
|
|
3657
|
+
if same_decision:
|
|
3658
|
+
return finalized
|
|
3659
|
+
raise StateError(
|
|
3660
|
+
"The current QUALITY candidate already finalized with another decision."
|
|
3661
|
+
)
|
|
3662
|
+
context = ensure_quality_attempt_context(
|
|
3663
|
+
root,
|
|
3664
|
+
task_id,
|
|
3665
|
+
task,
|
|
3666
|
+
agent,
|
|
3667
|
+
infer_existing_evidence=True,
|
|
3668
|
+
)
|
|
3669
|
+
evidence_end_index = len(execution_records(root, task_id))
|
|
3670
|
+
window_records = execution_records(root, task_id)[
|
|
3671
|
+
int(context["execution_start_index"]) : evidence_end_index
|
|
3672
|
+
]
|
|
3673
|
+
matching_reviews = [
|
|
3674
|
+
record
|
|
3675
|
+
for record in window_records
|
|
3676
|
+
if record.get("type") == "review"
|
|
3677
|
+
and record.get("implementation_fingerprint")
|
|
3678
|
+
== context["implementation_fingerprint"]
|
|
3679
|
+
]
|
|
3680
|
+
matching_verifications = [
|
|
3681
|
+
record
|
|
3682
|
+
for record in window_records
|
|
3683
|
+
if record.get("type") == "verify"
|
|
3684
|
+
and record.get("implementation_fingerprint")
|
|
3685
|
+
== context["implementation_fingerprint"]
|
|
3686
|
+
and record.get("config_fingerprint") == context["config_fingerprint"]
|
|
3687
|
+
]
|
|
3688
|
+
attempt_binding_required = task.get("workflow_mode_legacy") is not True or isinstance(
|
|
3689
|
+
task.get("spec_source"), dict
|
|
3690
|
+
)
|
|
3691
|
+
if attempt_binding_required:
|
|
3692
|
+
unexpected_attempts = [
|
|
3693
|
+
record
|
|
3694
|
+
for record in [*matching_reviews, *matching_verifications]
|
|
3695
|
+
if type(record.get("quality_attempt")) is not int
|
|
3696
|
+
or record.get("quality_attempt") > context["attempt"]
|
|
3697
|
+
]
|
|
3698
|
+
if unexpected_attempts:
|
|
3699
|
+
raise StateError(
|
|
3700
|
+
"QUALITY review and verification evidence must bind to the active attempt."
|
|
3701
|
+
)
|
|
3702
|
+
current_reviews = [
|
|
3703
|
+
record
|
|
3704
|
+
for record in matching_reviews
|
|
3705
|
+
if record.get("quality_attempt") == context["attempt"]
|
|
3706
|
+
]
|
|
3707
|
+
current_verifications = [
|
|
3708
|
+
record
|
|
3709
|
+
for record in matching_verifications
|
|
3710
|
+
if record.get("quality_attempt") == context["attempt"]
|
|
3711
|
+
]
|
|
3712
|
+
else:
|
|
3713
|
+
current_reviews = matching_reviews
|
|
3714
|
+
current_verifications = matching_verifications
|
|
3715
|
+
if task.get("workflow_mode_legacy") is not True or isinstance(
|
|
3716
|
+
task.get("spec_source"), dict
|
|
3717
|
+
):
|
|
3718
|
+
validate_quality_gate_record_schemas(current_reviews, current_verifications)
|
|
3719
|
+
carried_reviews, carried_verifications = resolve_canonical_quality_carry_forward(
|
|
3720
|
+
root, task_id, task, context, window_records
|
|
3721
|
+
)
|
|
3722
|
+
readiness_reviews = [*carried_reviews, *current_reviews]
|
|
3723
|
+
readiness_verifications = [*carried_verifications, *current_verifications]
|
|
3724
|
+
failures = quality_repair_failures_for_window(
|
|
3725
|
+
root,
|
|
3726
|
+
task_id,
|
|
3727
|
+
task,
|
|
3728
|
+
int(context["execution_start_index"]),
|
|
3729
|
+
evidence_end_index,
|
|
3730
|
+
int(context["attempt"]),
|
|
3731
|
+
)
|
|
3732
|
+
failure_kinds = {
|
|
3733
|
+
value.split(":", 1)[0]
|
|
3734
|
+
for values in failures.values()
|
|
3735
|
+
for value in values
|
|
3736
|
+
}
|
|
3737
|
+
canonical = isinstance(task.get("spec_source"), dict)
|
|
3738
|
+
latest_failure_records: dict[tuple[str, str], dict] = {}
|
|
3739
|
+
for record in [*current_reviews, *current_verifications]:
|
|
3740
|
+
owner = str(record.get("source_task_id")) if canonical else task_id
|
|
3741
|
+
if record.get("type") == "review" and is_non_empty_string(
|
|
3742
|
+
record.get("dimension")
|
|
3743
|
+
):
|
|
3744
|
+
label = f"review:{record['dimension']}"
|
|
3745
|
+
elif record.get("type") == "verify" and is_non_empty_string(
|
|
3746
|
+
record.get("check")
|
|
3747
|
+
):
|
|
3748
|
+
coverage_scope = str(record.get("coverage_scope") or "")
|
|
3749
|
+
label = f"verify:{record['check']}"
|
|
3750
|
+
if coverage_scope:
|
|
3751
|
+
label = f"{label}:{coverage_scope}"
|
|
3752
|
+
else:
|
|
3753
|
+
continue
|
|
3754
|
+
latest_failure_records[(owner, label)] = record
|
|
3755
|
+
evidence_failure_classes: set[str] = set()
|
|
3756
|
+
for owner, labels in failures.items():
|
|
3757
|
+
for label in labels:
|
|
3758
|
+
record = latest_failure_records.get((owner, label))
|
|
3759
|
+
record_classes = record.get("failure_classes") if isinstance(record, dict) else None
|
|
3760
|
+
if (
|
|
3761
|
+
not isinstance(record_classes, list)
|
|
3762
|
+
or not record_classes
|
|
3763
|
+
or any(
|
|
3764
|
+
value not in QUALITY_FAILURE_CLASSES - {"suggestion"}
|
|
3765
|
+
for value in record_classes
|
|
3766
|
+
)
|
|
3767
|
+
):
|
|
3768
|
+
raise StateError(
|
|
3769
|
+
"Each blocking QUALITY record must include structured failure_classes."
|
|
3770
|
+
)
|
|
3771
|
+
evidence_failure_classes.update(str(value) for value in record_classes)
|
|
3772
|
+
if outcome != "passed" and evidence_failure_classes != {
|
|
3773
|
+
value for value in normalized_classes if value != "suggestion"
|
|
3774
|
+
}:
|
|
3775
|
+
raise StateError(
|
|
3776
|
+
"QUALITY decision failure classes must exactly match the blocking gate evidence."
|
|
3777
|
+
)
|
|
3778
|
+
if outcome == "passed":
|
|
3779
|
+
if review_gate == "passed":
|
|
3780
|
+
validate_review_readiness(root, task_id, task, readiness_reviews)
|
|
3781
|
+
if verification_gate == "passed":
|
|
3782
|
+
validate_verification_readiness(
|
|
3783
|
+
root,
|
|
3784
|
+
task_id,
|
|
3785
|
+
task,
|
|
3786
|
+
validate_review=False,
|
|
3787
|
+
evidence_records=readiness_verifications,
|
|
3788
|
+
)
|
|
3789
|
+
for gate_name, gate_status, gate_records, failure_kind in (
|
|
3790
|
+
(
|
|
3791
|
+
"Review",
|
|
3792
|
+
review_gate,
|
|
3793
|
+
readiness_reviews if review_gate == "passed" else current_reviews,
|
|
3794
|
+
"review",
|
|
3795
|
+
),
|
|
3796
|
+
(
|
|
3797
|
+
"Verification",
|
|
3798
|
+
verification_gate,
|
|
3799
|
+
readiness_verifications
|
|
3800
|
+
if verification_gate == "passed"
|
|
3801
|
+
else current_verifications,
|
|
3802
|
+
"verify",
|
|
3803
|
+
),
|
|
3804
|
+
):
|
|
3805
|
+
if gate_status != "cancelled" and not gate_records:
|
|
3806
|
+
raise StateError(f"The {gate_name} Gate has no evidence for this QUALITY attempt.")
|
|
3807
|
+
if gate_status == "failed" and failure_kind not in failure_kinds:
|
|
3808
|
+
raise StateError(f"The {gate_name} Gate is marked failed without blocking evidence.")
|
|
3809
|
+
if gate_status != "failed" and failure_kind in failure_kinds:
|
|
3810
|
+
raise StateError(f"The {gate_name} Gate has blocking evidence and must be marked failed.")
|
|
3811
|
+
|
|
3812
|
+
if outcome != "passed":
|
|
3813
|
+
if review_gate == "passed":
|
|
3814
|
+
validate_review_readiness(root, task_id, task, readiness_reviews)
|
|
3815
|
+
if verification_gate == "passed":
|
|
3816
|
+
validate_verification_readiness(
|
|
3817
|
+
root,
|
|
3818
|
+
task_id,
|
|
3819
|
+
task,
|
|
3820
|
+
validate_review=False,
|
|
3821
|
+
evidence_records=readiness_verifications,
|
|
3822
|
+
)
|
|
3823
|
+
|
|
3824
|
+
if outcome == "passed":
|
|
3825
|
+
if review_gate != "passed" or verification_gate != "passed":
|
|
3826
|
+
raise StateError("A passed QUALITY attempt requires both gates to pass.")
|
|
3827
|
+
if any(value != "suggestion" for value in normalized_classes):
|
|
3828
|
+
raise StateError("A passed QUALITY attempt can contain only suggestion findings.")
|
|
3829
|
+
else:
|
|
3830
|
+
if not failures:
|
|
3831
|
+
raise StateError(f"QUALITY cannot finalize {outcome} without blocking evidence.")
|
|
3832
|
+
if outcome == "repair" and (
|
|
3833
|
+
not normalized_classes
|
|
3834
|
+
or any(value not in {"code-defect", "test-defect", "suggestion"} for value in normalized_classes)
|
|
3835
|
+
or not ({"code-defect", "test-defect"} & set(normalized_classes))
|
|
3836
|
+
):
|
|
3837
|
+
raise StateError(
|
|
3838
|
+
"QUALITY repair requires a code-defect or test-defect classification only."
|
|
3839
|
+
)
|
|
3840
|
+
if outcome == "replan" and (
|
|
3841
|
+
"contract-ambiguity" not in normalized_classes
|
|
3842
|
+
or any(
|
|
3843
|
+
value
|
|
3844
|
+
not in {
|
|
3845
|
+
"contract-ambiguity",
|
|
3846
|
+
"code-defect",
|
|
3847
|
+
"test-defect",
|
|
3848
|
+
"suggestion",
|
|
3849
|
+
}
|
|
3850
|
+
for value in normalized_classes
|
|
3851
|
+
)
|
|
3852
|
+
):
|
|
3853
|
+
raise StateError(
|
|
3854
|
+
"QUALITY replan requires contract ambiguity and may preserve code/test defects."
|
|
3855
|
+
)
|
|
3856
|
+
|
|
3857
|
+
started_at = parse_quality_timestamp(context.get("started_at"), "started_at")
|
|
3858
|
+
completed_at = datetime.now(timezone.utc)
|
|
3859
|
+
record = {
|
|
3860
|
+
"type": "quality",
|
|
3861
|
+
"attempt": context["attempt"],
|
|
3862
|
+
"implementation_fingerprint": context["implementation_fingerprint"],
|
|
3863
|
+
"config_fingerprint": context["config_fingerprint"],
|
|
3864
|
+
"started_at": started_at.isoformat(),
|
|
3865
|
+
"completed_at": completed_at.isoformat(),
|
|
3866
|
+
"duration_ms": max(0, int((completed_at - started_at).total_seconds() * 1000)),
|
|
3867
|
+
"repair_count": int(context.get("repair_count") or 0)
|
|
3868
|
+
+ (1 if outcome == "repair" else 0),
|
|
3869
|
+
"outcome": outcome,
|
|
3870
|
+
"review_gate": review_gate,
|
|
3871
|
+
"verification_gate": verification_gate,
|
|
3872
|
+
"summary": summary.strip(),
|
|
3873
|
+
"failure_classes": normalized_classes,
|
|
3874
|
+
"repository_fingerprints": canonical_repository_fingerprints(
|
|
3875
|
+
root, task_id, task
|
|
3876
|
+
),
|
|
3877
|
+
"evidence_start_index": context["execution_start_index"],
|
|
3878
|
+
"evidence_end_index": evidence_end_index,
|
|
3879
|
+
}
|
|
3880
|
+
append_execution_record(root, task_id, record)
|
|
3881
|
+
return reconcile_finalized_quality_state(
|
|
3882
|
+
root, task_id, task, record, agent, failures
|
|
3883
|
+
)
|
|
3884
|
+
|
|
3885
|
+
|
|
3886
|
+
def ensure_finalized_quality_outcome(
|
|
3887
|
+
root: Path,
|
|
3888
|
+
task_id: str,
|
|
3889
|
+
task: dict,
|
|
3890
|
+
outcome: str,
|
|
3891
|
+
agent: str,
|
|
3892
|
+
) -> dict:
|
|
3893
|
+
if not isinstance(task.get("quality_attempt"), dict):
|
|
3894
|
+
return require_finalized_quality_record(root, task_id, task, outcome)
|
|
3895
|
+
if outcome != "passed":
|
|
3896
|
+
raise StateError(
|
|
3897
|
+
f"Finalize the active QUALITY attempt as {outcome} before requesting the transition."
|
|
3898
|
+
)
|
|
3899
|
+
return finalize_quality_attempt(root, task_id, task, outcome, agent)
|
|
3900
|
+
|
|
3901
|
+
|
|
3902
|
+
def finalize_quality_decision(
|
|
3903
|
+
root: Path,
|
|
3904
|
+
outcome: str,
|
|
3905
|
+
review_gate: str,
|
|
3906
|
+
verification_gate: str,
|
|
3907
|
+
failure_classes: list[str],
|
|
3908
|
+
summary: str,
|
|
3909
|
+
agent: str,
|
|
3910
|
+
task_id: str | None = None,
|
|
3911
|
+
session_file: str | Path | None = None,
|
|
3912
|
+
) -> dict:
|
|
3913
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3914
|
+
if task.get("status") != "QUALITY":
|
|
3915
|
+
raise StateError("A QUALITY decision can only be finalized during QUALITY.")
|
|
3916
|
+
record = finalize_quality_attempt(
|
|
3917
|
+
root,
|
|
3918
|
+
resolved_task_id,
|
|
3919
|
+
task,
|
|
3920
|
+
outcome,
|
|
3921
|
+
agent,
|
|
3922
|
+
review_gate,
|
|
3923
|
+
verification_gate,
|
|
3924
|
+
failure_classes,
|
|
3925
|
+
summary,
|
|
3926
|
+
)
|
|
3927
|
+
result = snapshot_state(root, session_file, session)
|
|
3928
|
+
result["action"] = "finalize-quality"
|
|
3929
|
+
result["quality"] = record
|
|
3930
|
+
return result
|
|
3931
|
+
|
|
3932
|
+
|
|
3933
|
+
def current_finalized_quality_outcome(
|
|
3934
|
+
root: Path, task_id: str, task: dict
|
|
3935
|
+
) -> str | None:
|
|
3936
|
+
if isinstance(task.get("quality_attempt"), dict):
|
|
3937
|
+
return None
|
|
3938
|
+
records = validated_quality_records(root, task_id)
|
|
3939
|
+
if not records:
|
|
3940
|
+
return None
|
|
3941
|
+
record = records[-1][1]
|
|
3942
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3943
|
+
if (
|
|
3944
|
+
record.get("implementation_fingerprint")
|
|
3945
|
+
!= fingerprints["implementation_fingerprint"]
|
|
3946
|
+
or record.get("config_fingerprint") != fingerprints["config_fingerprint"]
|
|
3947
|
+
):
|
|
3948
|
+
return None
|
|
3949
|
+
return str(record.get("outcome"))
|
|
3950
|
+
|
|
3951
|
+
|
|
3952
|
+
def active_quality_failures(
|
|
3953
|
+
root: Path, task_id: str, task: dict
|
|
3954
|
+
) -> dict[str, list[str]]:
|
|
3955
|
+
attempt = task.get("quality_attempt")
|
|
3956
|
+
if not isinstance(attempt, dict):
|
|
3957
|
+
return {}
|
|
3958
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3959
|
+
if (
|
|
3960
|
+
attempt.get("implementation_fingerprint")
|
|
3961
|
+
!= fingerprints["implementation_fingerprint"]
|
|
3962
|
+
or attempt.get("config_fingerprint") != fingerprints["config_fingerprint"]
|
|
3963
|
+
):
|
|
3964
|
+
return {}
|
|
3965
|
+
return quality_repair_failures_for_window(
|
|
3966
|
+
root,
|
|
3967
|
+
task_id,
|
|
3968
|
+
task,
|
|
3969
|
+
int(attempt.get("execution_start_index") or 0),
|
|
3970
|
+
len(execution_records(root, task_id)),
|
|
3971
|
+
int(attempt.get("attempt") or 0),
|
|
3972
|
+
)
|
|
3973
|
+
|
|
3974
|
+
|
|
3975
|
+
def validate_quality_exit_request(
|
|
3976
|
+
root: Path, task_id: str, task: dict, stage: str
|
|
3977
|
+
) -> None:
|
|
3978
|
+
required_outcome = "repair" if stage == "IMPLEMENT" else "replan"
|
|
3979
|
+
current_outcome = current_finalized_quality_outcome(root, task_id, task)
|
|
3980
|
+
if isinstance(task.get("canonical_repair_transition"), dict):
|
|
3981
|
+
if stage != "IMPLEMENT":
|
|
3982
|
+
raise StateError(
|
|
3983
|
+
"Canonical repair transition is incomplete and must resume the original "
|
|
3984
|
+
"QUALITY repair before any other exit."
|
|
3985
|
+
)
|
|
3986
|
+
return
|
|
3987
|
+
if isinstance(task.get("quality_return_required"), dict):
|
|
3988
|
+
if stage != "IMPLEMENT":
|
|
3989
|
+
raise StateError(
|
|
3990
|
+
"QUALITY candidate drift must return to IMPLEMENT before another transition."
|
|
3991
|
+
)
|
|
3992
|
+
return
|
|
3993
|
+
if current_outcome == required_outcome:
|
|
3994
|
+
return
|
|
3995
|
+
if current_outcome in {"repair", "replan"}:
|
|
3996
|
+
raise StateError(
|
|
3997
|
+
f"The current QUALITY decision is {current_outcome}; transition to its matching stage."
|
|
3998
|
+
)
|
|
3999
|
+
if active_quality_failures(root, task_id, task):
|
|
4000
|
+
raise StateError(
|
|
4001
|
+
f"Finalize the active QUALITY attempt as {required_outcome} before requesting the transition."
|
|
4002
|
+
)
|
|
4003
|
+
|
|
4004
|
+
|
|
4005
|
+
def prepare_quality_exit(
|
|
4006
|
+
root: Path,
|
|
4007
|
+
task_id: str,
|
|
4008
|
+
task: dict,
|
|
4009
|
+
stage: str,
|
|
4010
|
+
agent: str,
|
|
4011
|
+
) -> tuple[dict, str]:
|
|
4012
|
+
validate_quality_exit_request(root, task_id, task, stage)
|
|
4013
|
+
required_outcome = "repair" if stage == "IMPLEMENT" else "replan"
|
|
4014
|
+
if isinstance(task.get("canonical_repair_transition"), dict):
|
|
4015
|
+
return task, "repair"
|
|
4016
|
+
if current_finalized_quality_outcome(root, task_id, task) == required_outcome:
|
|
4017
|
+
return task, required_outcome
|
|
4018
|
+
return_required = task.get("quality_return_required")
|
|
4019
|
+
if (
|
|
4020
|
+
isinstance(return_required, dict)
|
|
4021
|
+
and return_required.get("reason") == "implementation-drift"
|
|
4022
|
+
and not isinstance(task.get("quality_attempt"), dict)
|
|
4023
|
+
):
|
|
4024
|
+
return task, "cancelled"
|
|
4025
|
+
|
|
4026
|
+
if not isinstance(task.get("quality_attempt"), dict):
|
|
4027
|
+
task["quality_attempt"] = build_quality_attempt_context(root, task_id, task)
|
|
4028
|
+
task["last_agent"] = agent
|
|
4029
|
+
write_task(root, task_id, task)
|
|
4030
|
+
task = load_task(root, task_id) or task
|
|
4031
|
+
cancel_active_quality_attempt(
|
|
4032
|
+
root,
|
|
4033
|
+
task_id,
|
|
4034
|
+
task,
|
|
4035
|
+
agent,
|
|
4036
|
+
f"QUALITY returned to {stage} without a gate defect decision.",
|
|
4037
|
+
"manual-return",
|
|
4038
|
+
)
|
|
4039
|
+
return load_task(root, task_id) or task, "cancelled"
|
|
4040
|
+
|
|
4041
|
+
|
|
4042
|
+
def acceptance_snapshot_path(root: Path, task_id: str) -> Path:
|
|
4043
|
+
assert_safe_task_id(task_id)
|
|
4044
|
+
return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
|
|
4045
|
+
|
|
4046
|
+
|
|
4047
|
+
def canonical_json_sha256(value: object) -> str:
|
|
4048
|
+
payload = json.dumps(
|
|
4049
|
+
value,
|
|
4050
|
+
ensure_ascii=False,
|
|
4051
|
+
sort_keys=True,
|
|
4052
|
+
separators=(",", ":"),
|
|
4053
|
+
).encode("utf-8")
|
|
4054
|
+
return hashlib.sha256(payload).hexdigest()
|
|
4055
|
+
|
|
4056
|
+
|
|
4057
|
+
def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
|
|
4058
|
+
plan = latest_execution_plan(root, task_id)
|
|
4059
|
+
if plan is None:
|
|
4060
|
+
raise StateError("Cannot fingerprint verification contract without a valid plan.")
|
|
4061
|
+
source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
|
|
4062
|
+
contract = {
|
|
4063
|
+
"workflow_mode": task.get("workflow_mode"),
|
|
4064
|
+
"tdd_enabled": task.get("tdd_enabled"),
|
|
4065
|
+
"tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
|
|
4066
|
+
"tdd_baselines": task.get("tdd_baselines"),
|
|
4067
|
+
"plan": plan,
|
|
4068
|
+
"canonical": {
|
|
4069
|
+
"schema": source.get("schema"),
|
|
4070
|
+
"spec_id": source.get("spec_id"),
|
|
4071
|
+
"revision": source.get("revision"),
|
|
4072
|
+
"design_sha256": source.get("design_sha256"),
|
|
4073
|
+
"selected_tasks": task.get("selected_spec_tasks"),
|
|
4074
|
+
"repository_bindings": task.get("spec_repositories"),
|
|
4075
|
+
"repo_paths": task.get("repo_paths"),
|
|
4076
|
+
}
|
|
4077
|
+
if source
|
|
4078
|
+
else None,
|
|
4079
|
+
}
|
|
4080
|
+
return canonical_json_sha256(contract)
|
|
4081
|
+
|
|
4082
|
+
|
|
4083
|
+
def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
|
|
4084
|
+
pathspecs = repository_scope_pathspecs(repository, scopes)
|
|
4085
|
+
index_entries = git_index_entries(repository, pathspecs)
|
|
4086
|
+
listed = run_git(
|
|
4087
|
+
repository,
|
|
4088
|
+
"ls-files",
|
|
4089
|
+
"--cached",
|
|
4090
|
+
"--others",
|
|
4091
|
+
"--exclude-standard",
|
|
4092
|
+
"-z",
|
|
4093
|
+
"--",
|
|
4094
|
+
*pathspecs,
|
|
4095
|
+
)
|
|
4096
|
+
modified = run_git(
|
|
4097
|
+
repository,
|
|
4098
|
+
"diff-files",
|
|
4099
|
+
"--name-only",
|
|
4100
|
+
"-z",
|
|
4101
|
+
"--ignore-submodules=none",
|
|
4102
|
+
"--",
|
|
4103
|
+
*pathspecs,
|
|
4104
|
+
)
|
|
4105
|
+
if listed is None or listed.returncode != 0 or modified is None or modified.returncode != 0:
|
|
4106
|
+
raise StateError(f"Cannot capture verification snapshot for {repository}.")
|
|
4107
|
+
modified_paths = set(filter(None, modified.stdout.split(b"\0")))
|
|
4108
|
+
raw_paths = set(filter(None, listed.stdout.split(b"\0"))) | set(index_entries)
|
|
4109
|
+
entries: list[dict] = []
|
|
4110
|
+
for raw_path in sorted(raw_paths):
|
|
4111
|
+
relative_name = os.fsdecode(raw_path)
|
|
4112
|
+
if is_easy_coding_state_path(repository, relative_name, scopes):
|
|
4113
|
+
continue
|
|
4114
|
+
candidate = repository / relative_name
|
|
4115
|
+
index_entry = index_entries.get(raw_path)
|
|
4116
|
+
if index_entry is not None and index_entry[0] == b"160000":
|
|
4117
|
+
entries.append(
|
|
4118
|
+
{
|
|
4119
|
+
"path": relative_name,
|
|
4120
|
+
"exists": True,
|
|
4121
|
+
"mode": "160000",
|
|
4122
|
+
"git_oid": index_entry[1].decode("ascii", errors="replace"),
|
|
4123
|
+
"sha256": hashlib.sha256(index_entry[1]).hexdigest(),
|
|
4124
|
+
}
|
|
4125
|
+
)
|
|
4126
|
+
continue
|
|
4127
|
+
exists = candidate.exists() or candidate.is_symlink()
|
|
4128
|
+
if not exists:
|
|
4129
|
+
entries.append(
|
|
4130
|
+
{
|
|
4131
|
+
"path": relative_name,
|
|
4132
|
+
"exists": False,
|
|
4133
|
+
"mode": None,
|
|
2932
4134
|
"sha256": None,
|
|
2933
4135
|
}
|
|
2934
4136
|
)
|
|
@@ -3060,28 +4262,28 @@ def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
|
|
|
3060
4262
|
|
|
3061
4263
|
|
|
3062
4264
|
def load_acceptance_snapshot(root: Path, task: dict) -> dict:
|
|
3063
|
-
checkpoint = task.get("
|
|
4265
|
+
checkpoint = task.get("quality_checkpoint")
|
|
3064
4266
|
if not isinstance(checkpoint, dict):
|
|
3065
|
-
raise StateError("
|
|
4267
|
+
raise StateError("QUALITY has no frozen acceptance checkpoint.")
|
|
3066
4268
|
raw_path = checkpoint.get("snapshot_file")
|
|
3067
4269
|
if not is_non_empty_string(raw_path):
|
|
3068
|
-
raise StateError("
|
|
4270
|
+
raise StateError("Quality checkpoint has no snapshot file.")
|
|
3069
4271
|
candidate = (root / str(raw_path)).resolve()
|
|
3070
4272
|
sessions_root = (root / ".easy-coding" / "sessions").resolve()
|
|
3071
4273
|
if not is_path_within(candidate, sessions_root):
|
|
3072
|
-
raise StateError("
|
|
4274
|
+
raise StateError("Quality checkpoint snapshot escapes .easy-coding/sessions.")
|
|
3073
4275
|
snapshot = load_json(candidate)
|
|
3074
4276
|
if not isinstance(snapshot, dict) or snapshot.get("schema") != ACCEPTANCE_SNAPSHOT_SCHEMA:
|
|
3075
|
-
raise StateError("
|
|
4277
|
+
raise StateError("Quality checkpoint snapshot is missing or invalid.")
|
|
3076
4278
|
if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
|
|
3077
|
-
raise StateError("
|
|
4279
|
+
raise StateError("Quality checkpoint snapshot fingerprint changed.")
|
|
3078
4280
|
if (
|
|
3079
4281
|
snapshot.get("implementation_fingerprint")
|
|
3080
4282
|
!= checkpoint.get("implementation_fingerprint")
|
|
3081
4283
|
or snapshot.get("config_fingerprint") != checkpoint.get("config_fingerprint")
|
|
3082
4284
|
or snapshot.get("contract_fingerprint") != checkpoint.get("contract_fingerprint")
|
|
3083
4285
|
):
|
|
3084
|
-
raise StateError("
|
|
4286
|
+
raise StateError("Quality checkpoint metadata does not match its snapshot.")
|
|
3085
4287
|
return snapshot
|
|
3086
4288
|
|
|
3087
4289
|
|
|
@@ -3093,7 +4295,7 @@ def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None
|
|
|
3093
4295
|
try:
|
|
3094
4296
|
return base64.b64decode(encoded, validate=True)
|
|
3095
4297
|
except ValueError as exc:
|
|
3096
|
-
raise StateError("
|
|
4298
|
+
raise StateError("Quality checkpoint contains invalid file content.") from exc
|
|
3097
4299
|
object_id = entry.get("git_oid")
|
|
3098
4300
|
if not is_non_empty_string(object_id):
|
|
3099
4301
|
return None
|
|
@@ -3101,7 +4303,7 @@ def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None
|
|
|
3101
4303
|
return str(object_id).encode("ascii", errors="replace")
|
|
3102
4304
|
result = run_git(repository, "cat-file", "blob", str(object_id))
|
|
3103
4305
|
if result is None or result.returncode != 0:
|
|
3104
|
-
raise StateError(f"Cannot restore
|
|
4306
|
+
raise StateError(f"Cannot restore quality checkpoint Git object: {object_id}")
|
|
3105
4307
|
return result.stdout
|
|
3106
4308
|
|
|
3107
4309
|
|
|
@@ -3141,7 +4343,7 @@ def acceptance_change_patch(
|
|
|
3141
4343
|
|
|
3142
4344
|
|
|
3143
4345
|
def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
|
|
3144
|
-
checkpoint = task.get("
|
|
4346
|
+
checkpoint = task.get("quality_checkpoint")
|
|
3145
4347
|
baseline = load_acceptance_snapshot(root, task)
|
|
3146
4348
|
current = build_acceptance_snapshot(root, task_id, task)
|
|
3147
4349
|
baseline_entries = acceptance_snapshot_entries(baseline)
|
|
@@ -3241,7 +4443,8 @@ def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
|
|
|
3241
4443
|
|
|
3242
4444
|
|
|
3243
4445
|
def cleanup_verification_checkpoint(root: Path, task_id: str, task: dict) -> None:
|
|
3244
|
-
checkpoint = task.pop("
|
|
4446
|
+
checkpoint = task.pop("quality_checkpoint", None)
|
|
4447
|
+
task.pop("verification_checkpoint", None)
|
|
3245
4448
|
if not isinstance(checkpoint, dict):
|
|
3246
4449
|
return
|
|
3247
4450
|
raw_path = checkpoint.get("snapshot_file")
|
|
@@ -3268,20 +4471,38 @@ def record_verification_checkpoint(
|
|
|
3268
4471
|
session_file: str | Path | None = None,
|
|
3269
4472
|
) -> dict:
|
|
3270
4473
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3271
|
-
if task.get("status") != "
|
|
3272
|
-
raise StateError("
|
|
3273
|
-
if isinstance(task.get("
|
|
4474
|
+
if task.get("status") != "QUALITY":
|
|
4475
|
+
raise StateError("Quality checkpoint can only be recorded during QUALITY.")
|
|
4476
|
+
if isinstance(task.get("quality_checkpoint"), dict):
|
|
4477
|
+
require_checkpoint_quality_record(root, resolved_task_id, task)
|
|
3274
4478
|
load_acceptance_snapshot(root, task)
|
|
3275
4479
|
result = snapshot_state(root, session_file, session)
|
|
3276
|
-
result["action"] = "
|
|
3277
|
-
result["
|
|
4480
|
+
result["action"] = "quality-checkpoint"
|
|
4481
|
+
result["quality_checkpoint"] = task["quality_checkpoint"]
|
|
3278
4482
|
result["checkpoint_unchanged"] = True
|
|
3279
4483
|
return result
|
|
3280
|
-
|
|
4484
|
+
if (
|
|
4485
|
+
not isinstance(task.get("quality_attempt"), dict)
|
|
4486
|
+
and current_finalized_quality_outcome(root, resolved_task_id, task) is None
|
|
4487
|
+
):
|
|
4488
|
+
ensure_quality_attempt_context(
|
|
4489
|
+
root,
|
|
4490
|
+
resolved_task_id,
|
|
4491
|
+
task,
|
|
4492
|
+
agent,
|
|
4493
|
+
persist=True,
|
|
4494
|
+
infer_existing_evidence=True,
|
|
4495
|
+
)
|
|
4496
|
+
task = load_task(root, resolved_task_id) or task
|
|
4497
|
+
ensure_finalized_quality_outcome(
|
|
4498
|
+
root, resolved_task_id, task, "passed", agent
|
|
4499
|
+
)
|
|
4500
|
+
task = load_task(root, resolved_task_id) or task
|
|
4501
|
+
require_finalized_quality_record(root, resolved_task_id, task, "passed")
|
|
3281
4502
|
snapshot = build_acceptance_snapshot(root, resolved_task_id, task)
|
|
3282
4503
|
path = acceptance_snapshot_path(root, resolved_task_id)
|
|
3283
4504
|
write_json(path, snapshot)
|
|
3284
|
-
task["
|
|
4505
|
+
task["quality_checkpoint"] = {
|
|
3285
4506
|
"schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
|
|
3286
4507
|
"implementation_fingerprint": snapshot["implementation_fingerprint"],
|
|
3287
4508
|
"config_fingerprint": snapshot["config_fingerprint"],
|
|
@@ -3294,8 +4515,8 @@ def record_verification_checkpoint(
|
|
|
3294
4515
|
task["last_agent"] = agent
|
|
3295
4516
|
write_task(root, resolved_task_id, task)
|
|
3296
4517
|
result = snapshot_state(root, session_file, session)
|
|
3297
|
-
result["action"] = "
|
|
3298
|
-
result["
|
|
4518
|
+
result["action"] = "quality-checkpoint"
|
|
4519
|
+
result["quality_checkpoint"] = task["quality_checkpoint"]
|
|
3299
4520
|
return result
|
|
3300
4521
|
|
|
3301
4522
|
|
|
@@ -3329,13 +4550,13 @@ def ensure_verification_checkpoint(
|
|
|
3329
4550
|
agent: str,
|
|
3330
4551
|
session_file: str | Path | None,
|
|
3331
4552
|
) -> dict:
|
|
3332
|
-
if isinstance(task.get("
|
|
4553
|
+
if isinstance(task.get("quality_checkpoint"), dict):
|
|
3333
4554
|
load_acceptance_snapshot(root, task)
|
|
3334
4555
|
return task
|
|
3335
4556
|
record_verification_checkpoint(root, agent, task_id, session_file)
|
|
3336
4557
|
refreshed = load_task(root, task_id)
|
|
3337
4558
|
if not isinstance(refreshed, dict):
|
|
3338
|
-
raise StateError(f"Task not found after
|
|
4559
|
+
raise StateError(f"Task not found after quality checkpoint: {task_id}")
|
|
3339
4560
|
return refreshed
|
|
3340
4561
|
|
|
3341
4562
|
|
|
@@ -3346,8 +4567,8 @@ def inspect_transition_drift(
|
|
|
3346
4567
|
session_file: str | Path | None = None,
|
|
3347
4568
|
) -> dict:
|
|
3348
4569
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3349
|
-
if task.get("status") != "
|
|
3350
|
-
raise StateError("Transition drift can only be inspected during
|
|
4570
|
+
if task.get("status") != "QUALITY":
|
|
4571
|
+
raise StateError("Transition drift can only be inspected during QUALITY.")
|
|
3351
4572
|
task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
|
|
3352
4573
|
result = snapshot_state(root, session_file, session)
|
|
3353
4574
|
result["acceptance_drift"] = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
@@ -3369,18 +4590,18 @@ def append_transition_acceptance(
|
|
|
3369
4590
|
drift = inspect_acceptance_drift(root, task_id, task)
|
|
3370
4591
|
if drift["config_changed"]:
|
|
3371
4592
|
raise StateError(
|
|
3372
|
-
"Behavior config changed after
|
|
4593
|
+
"Behavior config changed after quality checks; rerun QUALITY before MEMORY."
|
|
3373
4594
|
)
|
|
3374
4595
|
if drift["metadata_changed"]:
|
|
3375
4596
|
raise StateError(
|
|
3376
4597
|
"Execution plan, workflow, Canonical design, or nested repository state changed "
|
|
3377
|
-
"after
|
|
4598
|
+
"after quality checks; return to ANALYSIS or IMPLEMENT instead of accepting it as a code diff."
|
|
3378
4599
|
)
|
|
3379
4600
|
changed_files = list(drift["changed_files"])
|
|
3380
4601
|
if changed_files:
|
|
3381
4602
|
if expected_diff_sha256 != drift["diff_sha256"]:
|
|
3382
4603
|
raise StateError(
|
|
3383
|
-
"
|
|
4604
|
+
"Quality-approved code changed after the acceptance checkpoint. Inspect the exact drift "
|
|
3384
4605
|
"and confirm its current diff_sha256 before entering MEMORY."
|
|
3385
4606
|
)
|
|
3386
4607
|
if verification_policy not in ACCEPTANCE_VERIFICATION_POLICIES:
|
|
@@ -3659,15 +4880,28 @@ def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -
|
|
|
3659
4880
|
)
|
|
3660
4881
|
|
|
3661
4882
|
|
|
3662
|
-
def validate_review_readiness(
|
|
4883
|
+
def validate_review_readiness(
|
|
4884
|
+
root: Path,
|
|
4885
|
+
task_id: str,
|
|
4886
|
+
task: dict,
|
|
4887
|
+
evidence_records: list[dict] | None = None,
|
|
4888
|
+
) -> None:
|
|
3663
4889
|
validate_spec_implementation_results(root, task_id, task)
|
|
3664
4890
|
is_spec_task = isinstance(task.get("spec_source"), dict)
|
|
3665
4891
|
if task.get("workflow_mode_legacy") is True and not is_spec_task:
|
|
3666
4892
|
return
|
|
3667
4893
|
expected = implementation_fingerprint(root, task_id)
|
|
3668
|
-
accepted_fingerprints =
|
|
4894
|
+
accepted_fingerprints = (
|
|
4895
|
+
{expected}
|
|
4896
|
+
if evidence_records is not None
|
|
4897
|
+
else accepted_review_fingerprints(root, task_id, task, expected)
|
|
4898
|
+
)
|
|
3669
4899
|
latest_by_dimension: dict[str, dict] = {}
|
|
3670
|
-
for record in
|
|
4900
|
+
for record in (
|
|
4901
|
+
evidence_records
|
|
4902
|
+
if evidence_records is not None
|
|
4903
|
+
else execution_records(root, task_id)
|
|
4904
|
+
):
|
|
3671
4905
|
if (
|
|
3672
4906
|
record.get("type") == "review"
|
|
3673
4907
|
and record.get("implementation_fingerprint") in accepted_fingerprints
|
|
@@ -3679,7 +4913,7 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
3679
4913
|
latest_by_dimension[record_key] = record
|
|
3680
4914
|
if not latest_by_dimension:
|
|
3681
4915
|
raise StateError(
|
|
3682
|
-
"
|
|
4916
|
+
"QUALITY cannot advance to MEMORY without review evidence for the current implementation fingerprint."
|
|
3683
4917
|
)
|
|
3684
4918
|
for record in latest_by_dimension.values():
|
|
3685
4919
|
if (
|
|
@@ -3744,7 +4978,7 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
3744
4978
|
break
|
|
3745
4979
|
if has_failed_dimension:
|
|
3746
4980
|
raise StateError(
|
|
3747
|
-
"
|
|
4981
|
+
"QUALITY cannot advance while a review dimension is not passed or has error findings."
|
|
3748
4982
|
)
|
|
3749
4983
|
if task.get("tdd_enabled") is True:
|
|
3750
4984
|
if is_spec_task:
|
|
@@ -3783,24 +5017,34 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
3783
5017
|
)
|
|
3784
5018
|
|
|
3785
5019
|
|
|
3786
|
-
def validate_verification_readiness(
|
|
5020
|
+
def validate_verification_readiness(
|
|
5021
|
+
root: Path,
|
|
5022
|
+
task_id: str,
|
|
5023
|
+
task: dict,
|
|
5024
|
+
validate_review: bool = True,
|
|
5025
|
+
evidence_records: list[dict] | None = None,
|
|
5026
|
+
) -> None:
|
|
3787
5027
|
fingerprints = evidence_fingerprints(root, task_id)
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
5028
|
+
if evidence_records is not None:
|
|
5029
|
+
accepted_fingerprints = {fingerprints["implementation_fingerprint"]}
|
|
5030
|
+
acceptance = None
|
|
5031
|
+
else:
|
|
5032
|
+
accepted_fingerprints, acceptance = accepted_verification_fingerprints(
|
|
5033
|
+
root,
|
|
5034
|
+
task_id,
|
|
5035
|
+
task,
|
|
5036
|
+
fingerprints["implementation_fingerprint"],
|
|
5037
|
+
fingerprints["config_fingerprint"],
|
|
5038
|
+
)
|
|
3795
5039
|
is_spec_task = isinstance(task.get("spec_source"), dict)
|
|
3796
|
-
if
|
|
3797
|
-
(task.get("workflow_mode_legacy") is not True or is_spec_task)
|
|
3798
|
-
and task.get("workflow_mode_legacy_review_bypass_fingerprint")
|
|
3799
|
-
!= fingerprints["implementation_fingerprint"]
|
|
3800
|
-
):
|
|
5040
|
+
if validate_review:
|
|
3801
5041
|
validate_review_readiness(root, task_id, task)
|
|
3802
5042
|
latest_by_check: dict[str, dict] = {}
|
|
3803
|
-
for record in
|
|
5043
|
+
for record in (
|
|
5044
|
+
evidence_records
|
|
5045
|
+
if evidence_records is not None
|
|
5046
|
+
else execution_records(root, task_id)
|
|
5047
|
+
):
|
|
3804
5048
|
if (
|
|
3805
5049
|
record.get("type") == "verify"
|
|
3806
5050
|
and record.get("implementation_fingerprint") in accepted_fingerprints
|
|
@@ -3829,7 +5073,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
3829
5073
|
latest_by_check[check] = record
|
|
3830
5074
|
if not latest_by_check:
|
|
3831
5075
|
raise StateError(
|
|
3832
|
-
"
|
|
5076
|
+
"QUALITY cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
|
|
3833
5077
|
)
|
|
3834
5078
|
if task.get("workflow_mode_legacy") is not True or is_spec_task:
|
|
3835
5079
|
for record in latest_by_check.values():
|
|
@@ -3875,11 +5119,11 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
3875
5119
|
]
|
|
3876
5120
|
if not applicable_records:
|
|
3877
5121
|
raise StateError(
|
|
3878
|
-
"
|
|
5122
|
+
"QUALITY cannot advance to MEMORY without at least one applicable executed check."
|
|
3879
5123
|
)
|
|
3880
5124
|
if any(record.get("passed") is not True for record in applicable_records):
|
|
3881
5125
|
raise StateError(
|
|
3882
|
-
"
|
|
5126
|
+
"QUALITY cannot advance to MEMORY while verification evidence contains failures."
|
|
3883
5127
|
)
|
|
3884
5128
|
if acceptance and acceptance.get("verification_policy") == "targeted":
|
|
3885
5129
|
current_records = [
|
|
@@ -4139,81 +5383,716 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
4139
5383
|
for record in pending_integration
|
|
4140
5384
|
)
|
|
4141
5385
|
raise StateError(
|
|
4142
|
-
"
|
|
5386
|
+
"QUALITY cannot advance to MEMORY while Canonical Spec integration "
|
|
4143
5387
|
f"dependencies are pending: {edges}."
|
|
4144
5388
|
)
|
|
4145
5389
|
|
|
4146
5390
|
|
|
4147
|
-
def
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
records: list[dict] = []
|
|
4156
|
-
if not path.exists():
|
|
4157
|
-
reasons.append("execution.jsonl is missing")
|
|
5391
|
+
def validate_quality_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
5392
|
+
if isinstance(task.get("quality_checkpoint"), dict):
|
|
5393
|
+
require_checkpoint_quality_record(root, task_id, task)
|
|
5394
|
+
acceptance = latest_acceptance_record(root, task_id, task)
|
|
5395
|
+
if isinstance(acceptance, dict) and acceptance.get(
|
|
5396
|
+
"verification_policy"
|
|
5397
|
+
) in ACCEPTANCE_VERIFICATION_POLICIES:
|
|
5398
|
+
validate_verification_readiness(root, task_id, task)
|
|
4158
5399
|
else:
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
5400
|
+
require_finalized_quality_record(root, task_id, task, "passed")
|
|
5401
|
+
|
|
5402
|
+
|
|
5403
|
+
def quality_repair_failures_for_window(
|
|
5404
|
+
root: Path,
|
|
5405
|
+
task_id: str,
|
|
5406
|
+
task: dict,
|
|
5407
|
+
evidence_start_index: int,
|
|
5408
|
+
evidence_end_index: int,
|
|
5409
|
+
quality_attempt: int | None = None,
|
|
5410
|
+
implementation_fingerprint_value: str | None = None,
|
|
5411
|
+
config_fingerprint_value: str | None = None,
|
|
5412
|
+
) -> dict[str, list[str]]:
|
|
5413
|
+
canonical = isinstance(task.get("spec_source"), dict)
|
|
5414
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
5415
|
+
task_repositories = {
|
|
5416
|
+
str(unit.get("source_task_id")): str(unit.get("repo_id"))
|
|
5417
|
+
for unit in plan.get("units", [])
|
|
5418
|
+
if isinstance(unit, dict)
|
|
5419
|
+
and is_non_empty_string(unit.get("source_task_id"))
|
|
5420
|
+
and is_non_empty_string(unit.get("repo_id"))
|
|
5421
|
+
}
|
|
5422
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
5423
|
+
implementation = (
|
|
5424
|
+
implementation_fingerprint_value or fingerprints["implementation_fingerprint"]
|
|
5425
|
+
)
|
|
5426
|
+
config = config_fingerprint_value or fingerprints["config_fingerprint"]
|
|
5427
|
+
latest_reviews: dict[tuple[str, str], dict] = {}
|
|
5428
|
+
latest_verifications: dict[tuple[str, str, str], dict] = {}
|
|
5429
|
+
records = execution_records(root, task_id)
|
|
5430
|
+
for record in records[evidence_start_index:evidence_end_index]:
|
|
5431
|
+
record_type = record.get("type")
|
|
5432
|
+
if (
|
|
5433
|
+
quality_attempt is not None
|
|
5434
|
+
and (
|
|
5435
|
+
task.get("workflow_mode_legacy") is not True
|
|
5436
|
+
or isinstance(task.get("spec_source"), dict)
|
|
5437
|
+
)
|
|
5438
|
+
and record_type in {"review", "verify"}
|
|
5439
|
+
):
|
|
5440
|
+
matches_candidate = (
|
|
5441
|
+
record_type == "review"
|
|
5442
|
+
and record.get("implementation_fingerprint") == implementation
|
|
5443
|
+
) or (
|
|
5444
|
+
record_type == "verify"
|
|
5445
|
+
and record.get("implementation_fingerprint") == implementation
|
|
5446
|
+
and record.get("config_fingerprint") == config
|
|
5447
|
+
)
|
|
5448
|
+
if matches_candidate:
|
|
5449
|
+
record_attempt = record.get("quality_attempt")
|
|
5450
|
+
if type(record_attempt) is not int or record_attempt > quality_attempt:
|
|
5451
|
+
raise StateError(
|
|
5452
|
+
"QUALITY review and verification evidence must bind to the active attempt."
|
|
5453
|
+
)
|
|
5454
|
+
if record_attempt < quality_attempt:
|
|
4162
5455
|
continue
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
5456
|
+
source_task_id = str(record.get("source_task_id") or "")
|
|
5457
|
+
repo_id = str(record.get("repo_id") or "")
|
|
5458
|
+
if record_type == "review" and record.get(
|
|
5459
|
+
"implementation_fingerprint"
|
|
5460
|
+
) == implementation:
|
|
5461
|
+
findings = record.get("findings")
|
|
5462
|
+
failed = record.get("passed") is not True or (
|
|
5463
|
+
isinstance(findings, list)
|
|
5464
|
+
and any(
|
|
5465
|
+
isinstance(finding, dict)
|
|
5466
|
+
and str(finding.get("severity") or "").lower() == "error"
|
|
5467
|
+
for finding in findings
|
|
5468
|
+
)
|
|
5469
|
+
)
|
|
5470
|
+
if failed and canonical and (
|
|
5471
|
+
source_task_id not in task_repositories
|
|
5472
|
+
or repo_id != task_repositories[source_task_id]
|
|
5473
|
+
or not is_non_empty_string(record.get("dimension"))
|
|
5474
|
+
):
|
|
5475
|
+
raise StateError(
|
|
5476
|
+
"Canonical QUALITY failure evidence must preserve a valid "
|
|
5477
|
+
"repository/source-task/dimension ownership."
|
|
5478
|
+
)
|
|
5479
|
+
if is_non_empty_string(record.get("dimension")) and (
|
|
5480
|
+
not canonical
|
|
5481
|
+
or (
|
|
5482
|
+
source_task_id in task_repositories
|
|
5483
|
+
and repo_id == task_repositories[source_task_id]
|
|
5484
|
+
)
|
|
5485
|
+
):
|
|
5486
|
+
owner = source_task_id if canonical else task_id
|
|
5487
|
+
latest_reviews[(owner, str(record["dimension"]))] = record
|
|
5488
|
+
elif record_type == "verify" and record.get(
|
|
5489
|
+
"implementation_fingerprint"
|
|
5490
|
+
) == implementation and record.get("config_fingerprint") == config:
|
|
5491
|
+
if (
|
|
5492
|
+
task.get("tdd_enabled") is True
|
|
5493
|
+
and record.get("check_type") == "coverage"
|
|
5494
|
+
and record.get("coverage_scope") == "gitlab"
|
|
5495
|
+
):
|
|
5496
|
+
continue
|
|
5497
|
+
failed = record.get("applicable") is not False and record.get("passed") is not True
|
|
5498
|
+
if failed and canonical and (
|
|
5499
|
+
source_task_id not in task_repositories
|
|
5500
|
+
or repo_id != task_repositories[source_task_id]
|
|
5501
|
+
or not is_non_empty_string(record.get("check"))
|
|
5502
|
+
):
|
|
5503
|
+
raise StateError(
|
|
5504
|
+
"Canonical QUALITY failure evidence must preserve a valid "
|
|
5505
|
+
"repository/source-task/check ownership."
|
|
5506
|
+
)
|
|
5507
|
+
if not is_non_empty_string(record.get("check")) or (
|
|
5508
|
+
canonical
|
|
5509
|
+
and (
|
|
5510
|
+
source_task_id not in task_repositories
|
|
5511
|
+
or repo_id != task_repositories[source_task_id]
|
|
5512
|
+
)
|
|
5513
|
+
):
|
|
5514
|
+
continue
|
|
5515
|
+
owner = source_task_id if canonical else task_id
|
|
5516
|
+
latest_verifications[
|
|
5517
|
+
(
|
|
5518
|
+
owner,
|
|
5519
|
+
str(record["check"]),
|
|
5520
|
+
str(record.get("coverage_scope") or ""),
|
|
5521
|
+
)
|
|
5522
|
+
] = record
|
|
4170
5523
|
|
|
4171
|
-
|
|
4172
|
-
for
|
|
4173
|
-
|
|
4174
|
-
|
|
5524
|
+
failures: dict[str, list[str]] = {}
|
|
5525
|
+
for (source_task_id, dimension), record in latest_reviews.items():
|
|
5526
|
+
findings = record.get("findings")
|
|
5527
|
+
has_error = isinstance(findings, list) and any(
|
|
5528
|
+
isinstance(finding, dict)
|
|
5529
|
+
and str(finding.get("severity") or "").lower() == "error"
|
|
5530
|
+
for finding in findings
|
|
5531
|
+
)
|
|
5532
|
+
if record.get("passed") is not True or has_error:
|
|
5533
|
+
failures.setdefault(source_task_id, []).append(f"review:{dimension}")
|
|
5534
|
+
for (source_task_id, check, scope), record in latest_verifications.items():
|
|
5535
|
+
if record.get("applicable") is not False and record.get("passed") is not True:
|
|
5536
|
+
label = f"verify:{check}"
|
|
5537
|
+
if scope:
|
|
5538
|
+
label = f"{label}:{scope}"
|
|
5539
|
+
failures.setdefault(source_task_id, []).append(label)
|
|
5540
|
+
return failures
|
|
5541
|
+
|
|
5542
|
+
|
|
5543
|
+
def canonical_carry_forward_sources(
|
|
5544
|
+
root: Path,
|
|
5545
|
+
task_id: str,
|
|
5546
|
+
task: dict,
|
|
5547
|
+
plan: dict,
|
|
5548
|
+
stable_repositories: set[str],
|
|
5549
|
+
failures: dict[str, list[str]],
|
|
5550
|
+
) -> set[str]:
|
|
5551
|
+
units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
|
|
5552
|
+
unit_sources = {
|
|
5553
|
+
str(unit.get("id")): str(unit.get("source_task_id"))
|
|
5554
|
+
for unit in units
|
|
5555
|
+
if is_non_empty_string(unit.get("id"))
|
|
5556
|
+
and is_non_empty_string(unit.get("source_task_id"))
|
|
5557
|
+
}
|
|
5558
|
+
source_repositories = {
|
|
5559
|
+
str(unit.get("source_task_id")): str(unit.get("repo_id"))
|
|
5560
|
+
for unit in units
|
|
5561
|
+
if is_non_empty_string(unit.get("source_task_id"))
|
|
5562
|
+
and is_non_empty_string(unit.get("repo_id"))
|
|
5563
|
+
}
|
|
5564
|
+
invalid_sources = set(failures) | {
|
|
5565
|
+
source_task_id
|
|
5566
|
+
for source_task_id, repo_id in source_repositories.items()
|
|
5567
|
+
if repo_id not in stable_repositories
|
|
5568
|
+
}
|
|
5569
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5570
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5571
|
+
changed = True
|
|
5572
|
+
while changed:
|
|
5573
|
+
changed = False
|
|
5574
|
+
for unit in units:
|
|
5575
|
+
source_task_id = str(unit.get("source_task_id") or "")
|
|
5576
|
+
if not source_task_id or source_task_id in invalid_sources:
|
|
5577
|
+
continue
|
|
5578
|
+
dependency_sources = {
|
|
5579
|
+
unit_sources.get(str(dependency_id), "")
|
|
5580
|
+
for dependency_id in unit.get("depends_on", [])
|
|
5581
|
+
}
|
|
5582
|
+
snapshot = snapshots.get(source_task_id, {})
|
|
5583
|
+
dependency_sources.update(
|
|
5584
|
+
str(dependency.get("task_id"))
|
|
5585
|
+
for dependency in snapshot.get("dependencies", [])
|
|
5586
|
+
if isinstance(dependency, dict)
|
|
5587
|
+
and dependency.get("type") in {"hard", "contract"}
|
|
5588
|
+
)
|
|
5589
|
+
if invalid_sources.intersection(dependency_sources):
|
|
5590
|
+
invalid_sources.add(source_task_id)
|
|
5591
|
+
changed = True
|
|
4175
5592
|
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
5593
|
+
return {
|
|
5594
|
+
source_task_id
|
|
5595
|
+
for source_task_id, repo_id in source_repositories.items()
|
|
5596
|
+
if repo_id in stable_repositories and source_task_id not in invalid_sources
|
|
5597
|
+
}
|
|
5598
|
+
|
|
5599
|
+
|
|
5600
|
+
def append_canonical_quality_carry_forward(
|
|
5601
|
+
root: Path,
|
|
5602
|
+
task_id: str,
|
|
5603
|
+
task: dict,
|
|
5604
|
+
context: dict,
|
|
5605
|
+
agent: str,
|
|
5606
|
+
) -> None:
|
|
5607
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
5608
|
+
return
|
|
5609
|
+
consumed_attempt = task.get("quality_consumed_attempt")
|
|
5610
|
+
previous = next(
|
|
5611
|
+
(
|
|
5612
|
+
record
|
|
5613
|
+
for _index, record in reversed(validated_quality_records(root, task_id))
|
|
5614
|
+
if record.get("outcome") == "repair"
|
|
5615
|
+
and record.get("attempt") == consumed_attempt
|
|
5616
|
+
),
|
|
5617
|
+
None,
|
|
5618
|
+
)
|
|
5619
|
+
if (
|
|
5620
|
+
not isinstance(previous, dict)
|
|
5621
|
+
or previous.get("config_fingerprint") != context.get("config_fingerprint")
|
|
5622
|
+
):
|
|
5623
|
+
return
|
|
5624
|
+
previous_repositories = previous.get("repository_fingerprints")
|
|
5625
|
+
current_repositories = canonical_repository_fingerprints(root, task_id, task)
|
|
5626
|
+
if not isinstance(previous_repositories, dict):
|
|
5627
|
+
return
|
|
5628
|
+
stable_repositories = {
|
|
5629
|
+
repo_id
|
|
5630
|
+
for repo_id, fingerprint in current_repositories.items()
|
|
5631
|
+
if previous_repositories.get(repo_id) == fingerprint
|
|
5632
|
+
}
|
|
5633
|
+
failures = quality_repair_failures_for_window(
|
|
5634
|
+
root,
|
|
5635
|
+
task_id,
|
|
5636
|
+
task,
|
|
5637
|
+
int(previous["evidence_start_index"]),
|
|
5638
|
+
int(previous["evidence_end_index"]),
|
|
5639
|
+
int(previous["attempt"]),
|
|
5640
|
+
str(previous["implementation_fingerprint"]),
|
|
5641
|
+
str(previous["config_fingerprint"]),
|
|
5642
|
+
)
|
|
5643
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
5644
|
+
eligible_sources = canonical_carry_forward_sources(
|
|
5645
|
+
root, task_id, task, plan, stable_repositories, failures
|
|
5646
|
+
)
|
|
5647
|
+
if not eligible_sources:
|
|
5648
|
+
return
|
|
5649
|
+
records = execution_records(root, task_id)
|
|
5650
|
+
latest: dict[tuple[str, str, str], tuple[int, dict]] = {}
|
|
5651
|
+
for index in range(
|
|
5652
|
+
int(previous["evidence_start_index"]), int(previous["evidence_end_index"])
|
|
5653
|
+
):
|
|
5654
|
+
record = records[index]
|
|
5655
|
+
source_task_id = str(record.get("source_task_id") or "")
|
|
5656
|
+
if (
|
|
5657
|
+
source_task_id not in eligible_sources
|
|
5658
|
+
or record.get("quality_attempt") != previous["attempt"]
|
|
5659
|
+
):
|
|
5660
|
+
continue
|
|
5661
|
+
if record.get("type") == "review" and is_non_empty_string(
|
|
5662
|
+
record.get("dimension")
|
|
5663
|
+
):
|
|
5664
|
+
key = (source_task_id, "review", str(record["dimension"]))
|
|
5665
|
+
elif record.get("type") == "verify" and is_non_empty_string(
|
|
5666
|
+
record.get("check")
|
|
5667
|
+
):
|
|
5668
|
+
key = (
|
|
5669
|
+
source_task_id,
|
|
5670
|
+
"verify",
|
|
5671
|
+
f"{record['check']}\0{record.get('coverage_scope') or ''}",
|
|
5672
|
+
)
|
|
5673
|
+
else:
|
|
5674
|
+
continue
|
|
5675
|
+
latest[key] = (index, record)
|
|
5676
|
+
evidence_indices: list[int] = []
|
|
5677
|
+
review_records: list[dict] = []
|
|
5678
|
+
verification_records: list[dict] = []
|
|
5679
|
+
for index, record in latest.values():
|
|
5680
|
+
if record.get("type") == "review":
|
|
5681
|
+
findings = record.get("findings")
|
|
5682
|
+
if record.get("passed") is not True or (
|
|
5683
|
+
isinstance(findings, list)
|
|
5684
|
+
and any(
|
|
5685
|
+
isinstance(finding, dict)
|
|
5686
|
+
and finding.get("severity") == "error"
|
|
5687
|
+
for finding in findings
|
|
5688
|
+
)
|
|
5689
|
+
):
|
|
5690
|
+
continue
|
|
5691
|
+
review_records.append(record)
|
|
5692
|
+
else:
|
|
5693
|
+
if record.get("applicable") is not False and record.get("passed") is not True:
|
|
5694
|
+
continue
|
|
5695
|
+
verification_records.append(record)
|
|
5696
|
+
evidence_indices.append(index)
|
|
5697
|
+
if not evidence_indices:
|
|
5698
|
+
return
|
|
5699
|
+
validate_quality_gate_record_schemas(review_records, verification_records)
|
|
5700
|
+
append_execution_record(
|
|
5701
|
+
root,
|
|
5702
|
+
task_id,
|
|
5703
|
+
{
|
|
5704
|
+
"type": "quality-carry-forward",
|
|
5705
|
+
"quality_attempt": context["attempt"],
|
|
5706
|
+
"from_attempt": previous["attempt"],
|
|
5707
|
+
"from_implementation_fingerprint": previous[
|
|
5708
|
+
"implementation_fingerprint"
|
|
5709
|
+
],
|
|
5710
|
+
"implementation_fingerprint": context["implementation_fingerprint"],
|
|
5711
|
+
"config_fingerprint": context["config_fingerprint"],
|
|
5712
|
+
"source_task_ids": sorted(eligible_sources),
|
|
5713
|
+
"evidence_indices": sorted(evidence_indices),
|
|
5714
|
+
"repository_fingerprints": {
|
|
5715
|
+
repo_id: current_repositories[repo_id]
|
|
5716
|
+
for repo_id in sorted(stable_repositories)
|
|
5717
|
+
},
|
|
5718
|
+
"reason": "Unchanged Canonical repositories retain passed Gate evidence.",
|
|
5719
|
+
"timestamp": now_iso(),
|
|
5720
|
+
"carried_by": agent,
|
|
5721
|
+
},
|
|
5722
|
+
)
|
|
5723
|
+
|
|
5724
|
+
|
|
5725
|
+
def resolve_canonical_quality_carry_forward(
|
|
5726
|
+
root: Path,
|
|
5727
|
+
task_id: str,
|
|
5728
|
+
task: dict,
|
|
5729
|
+
context: dict,
|
|
5730
|
+
window_records: list[dict],
|
|
5731
|
+
) -> tuple[list[dict], list[dict]]:
|
|
5732
|
+
carry_records = [
|
|
5733
|
+
record
|
|
5734
|
+
for record in window_records
|
|
5735
|
+
if record.get("type") == "quality-carry-forward"
|
|
5736
|
+
and record.get("quality_attempt") == context.get("attempt")
|
|
5737
|
+
]
|
|
5738
|
+
if not carry_records:
|
|
5739
|
+
return [], []
|
|
5740
|
+
if len(carry_records) != 1 or not isinstance(task.get("spec_source"), dict):
|
|
5741
|
+
raise StateError("QUALITY carry-forward metadata is invalid.")
|
|
5742
|
+
carry = carry_records[0]
|
|
5743
|
+
previous = next(
|
|
5744
|
+
(
|
|
5745
|
+
record
|
|
5746
|
+
for _index, record in reversed(validated_quality_records(root, task_id))
|
|
5747
|
+
if record.get("outcome") == "repair"
|
|
5748
|
+
and record.get("attempt") == carry.get("from_attempt")
|
|
5749
|
+
),
|
|
5750
|
+
None,
|
|
5751
|
+
)
|
|
5752
|
+
source_task_ids = carry.get("source_task_ids")
|
|
5753
|
+
evidence_indices = carry.get("evidence_indices")
|
|
5754
|
+
repository_fingerprints = carry.get("repository_fingerprints")
|
|
5755
|
+
current_repositories = canonical_repository_fingerprints(root, task_id, task)
|
|
5756
|
+
previous_repositories = (
|
|
5757
|
+
previous.get("repository_fingerprints") if isinstance(previous, dict) else {}
|
|
5758
|
+
)
|
|
5759
|
+
stable_repositories = {
|
|
5760
|
+
repo_id: fingerprint
|
|
5761
|
+
for repo_id, fingerprint in current_repositories.items()
|
|
5762
|
+
if isinstance(previous_repositories, dict)
|
|
5763
|
+
and previous_repositories.get(repo_id) == fingerprint
|
|
5764
|
+
}
|
|
5765
|
+
failures = (
|
|
5766
|
+
quality_repair_failures_for_window(
|
|
5767
|
+
root,
|
|
5768
|
+
task_id,
|
|
5769
|
+
task,
|
|
5770
|
+
int(previous["evidence_start_index"]),
|
|
5771
|
+
int(previous["evidence_end_index"]),
|
|
5772
|
+
int(previous["attempt"]),
|
|
5773
|
+
str(previous["implementation_fingerprint"]),
|
|
5774
|
+
str(previous["config_fingerprint"]),
|
|
5775
|
+
)
|
|
5776
|
+
if isinstance(previous, dict)
|
|
5777
|
+
else {}
|
|
5778
|
+
)
|
|
5779
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
5780
|
+
expected_sources = canonical_carry_forward_sources(
|
|
5781
|
+
root, task_id, task, plan, set(stable_repositories), failures
|
|
5782
|
+
)
|
|
5783
|
+
if (
|
|
5784
|
+
not isinstance(previous, dict)
|
|
5785
|
+
or previous.get("attempt") != task.get("quality_consumed_attempt")
|
|
5786
|
+
or previous.get("config_fingerprint") != context.get("config_fingerprint")
|
|
5787
|
+
or carry.get("from_implementation_fingerprint")
|
|
5788
|
+
!= previous.get("implementation_fingerprint")
|
|
5789
|
+
or carry.get("implementation_fingerprint")
|
|
5790
|
+
!= context.get("implementation_fingerprint")
|
|
5791
|
+
or carry.get("config_fingerprint") != context.get("config_fingerprint")
|
|
5792
|
+
or not is_string_list(source_task_ids, allow_empty=False)
|
|
5793
|
+
or not isinstance(evidence_indices, list)
|
|
5794
|
+
or not evidence_indices
|
|
5795
|
+
or any(type(index) is not int for index in evidence_indices)
|
|
5796
|
+
or len(set(evidence_indices)) != len(evidence_indices)
|
|
5797
|
+
or not isinstance(repository_fingerprints, dict)
|
|
5798
|
+
or set(source_task_ids) != expected_sources
|
|
5799
|
+
or repository_fingerprints != stable_repositories
|
|
5800
|
+
or not is_non_empty_string(carry.get("reason"))
|
|
5801
|
+
or not is_non_empty_string(carry.get("carried_by"))
|
|
5802
|
+
):
|
|
5803
|
+
raise StateError("QUALITY carry-forward metadata is invalid.")
|
|
5804
|
+
parse_quality_timestamp(carry.get("timestamp"), "carry-forward timestamp")
|
|
5805
|
+
records = execution_records(root, task_id)
|
|
5806
|
+
latest_indices: dict[tuple[str, str, str], int] = {}
|
|
5807
|
+
for index in range(
|
|
5808
|
+
int(previous["evidence_start_index"]), int(previous["evidence_end_index"])
|
|
5809
|
+
):
|
|
5810
|
+
record = records[index]
|
|
5811
|
+
source_task_id = str(record.get("source_task_id") or "")
|
|
5812
|
+
if source_task_id not in expected_sources:
|
|
5813
|
+
continue
|
|
5814
|
+
if record.get("type") == "review" and is_non_empty_string(
|
|
5815
|
+
record.get("dimension")
|
|
5816
|
+
):
|
|
5817
|
+
key = (source_task_id, "review", str(record["dimension"]))
|
|
5818
|
+
elif record.get("type") == "verify" and is_non_empty_string(
|
|
5819
|
+
record.get("check")
|
|
5820
|
+
):
|
|
5821
|
+
key = (
|
|
5822
|
+
source_task_id,
|
|
5823
|
+
"verify",
|
|
5824
|
+
f"{record['check']}\0{record.get('coverage_scope') or ''}",
|
|
5825
|
+
)
|
|
5826
|
+
else:
|
|
5827
|
+
continue
|
|
5828
|
+
latest_indices[key] = index
|
|
5829
|
+
reviews: list[dict] = []
|
|
5830
|
+
verifications: list[dict] = []
|
|
5831
|
+
for index in evidence_indices:
|
|
5832
|
+
if (
|
|
5833
|
+
index < int(previous["evidence_start_index"])
|
|
5834
|
+
or index >= int(previous["evidence_end_index"])
|
|
5835
|
+
or index >= len(records)
|
|
5836
|
+
):
|
|
5837
|
+
raise StateError("QUALITY carry-forward evidence index is outside its source attempt.")
|
|
5838
|
+
record = records[index]
|
|
5839
|
+
if record.get("type") == "review":
|
|
5840
|
+
evidence_key = (
|
|
5841
|
+
str(record.get("source_task_id") or ""),
|
|
5842
|
+
"review",
|
|
5843
|
+
str(record.get("dimension") or ""),
|
|
5844
|
+
)
|
|
5845
|
+
else:
|
|
5846
|
+
evidence_key = (
|
|
5847
|
+
str(record.get("source_task_id") or ""),
|
|
5848
|
+
"verify",
|
|
5849
|
+
f"{record.get('check') or ''}\0{record.get('coverage_scope') or ''}",
|
|
5850
|
+
)
|
|
5851
|
+
if (
|
|
5852
|
+
record.get("quality_attempt") != previous["attempt"]
|
|
5853
|
+
or record.get("source_task_id") not in source_task_ids
|
|
5854
|
+
or latest_indices.get(evidence_key) != index
|
|
5855
|
+
):
|
|
5856
|
+
raise StateError("QUALITY carry-forward evidence ownership is invalid.")
|
|
5857
|
+
carried = {
|
|
5858
|
+
**record,
|
|
5859
|
+
"implementation_fingerprint": context["implementation_fingerprint"],
|
|
5860
|
+
"config_fingerprint": context["config_fingerprint"],
|
|
5861
|
+
"quality_attempt": context["attempt"],
|
|
5862
|
+
"carried_from_attempt": previous["attempt"],
|
|
5863
|
+
"carried_from_evidence_index": index,
|
|
5864
|
+
}
|
|
5865
|
+
if record.get("type") == "review":
|
|
5866
|
+
findings = record.get("findings")
|
|
5867
|
+
if record.get("passed") is not True or (
|
|
5868
|
+
isinstance(findings, list)
|
|
5869
|
+
and any(
|
|
5870
|
+
isinstance(finding, dict)
|
|
5871
|
+
and finding.get("severity") == "error"
|
|
5872
|
+
for finding in findings
|
|
5873
|
+
)
|
|
5874
|
+
):
|
|
5875
|
+
raise StateError("QUALITY carry-forward review evidence must be passed.")
|
|
5876
|
+
reviews.append(carried)
|
|
5877
|
+
elif record.get("type") == "verify":
|
|
5878
|
+
if record.get("applicable") is not False and record.get("passed") is not True:
|
|
5879
|
+
raise StateError("QUALITY carry-forward verification evidence must be passed.")
|
|
5880
|
+
verifications.append(carried)
|
|
4183
5881
|
else:
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
5882
|
+
raise StateError("QUALITY carry-forward can reference only Gate evidence.")
|
|
5883
|
+
validate_quality_gate_record_schemas(reviews, verifications)
|
|
5884
|
+
return reviews, verifications
|
|
5885
|
+
|
|
5886
|
+
|
|
5887
|
+
def canonical_quality_repair_failures(
|
|
5888
|
+
root: Path, task_id: str, task: dict
|
|
5889
|
+
) -> dict[str, list[str]]:
|
|
5890
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
5891
|
+
return {}
|
|
5892
|
+
intent = task.get("canonical_repair_transition")
|
|
5893
|
+
if isinstance(intent, dict):
|
|
5894
|
+
record = next(
|
|
5895
|
+
(
|
|
5896
|
+
candidate
|
|
5897
|
+
for _index, candidate in reversed(
|
|
5898
|
+
validated_quality_records(root, task_id)
|
|
5899
|
+
)
|
|
5900
|
+
if candidate.get("outcome") == "repair"
|
|
5901
|
+
and candidate.get("attempt") == intent.get("quality_attempt")
|
|
5902
|
+
and candidate.get("implementation_fingerprint")
|
|
5903
|
+
== intent.get("implementation_fingerprint")
|
|
5904
|
+
and candidate.get("config_fingerprint")
|
|
5905
|
+
== intent.get("config_fingerprint")
|
|
5906
|
+
),
|
|
5907
|
+
None,
|
|
5908
|
+
)
|
|
5909
|
+
if not isinstance(record, dict):
|
|
5910
|
+
raise StateError(
|
|
5911
|
+
"Canonical repair transition intent has no matching QUALITY record."
|
|
5912
|
+
)
|
|
5913
|
+
else:
|
|
5914
|
+
record = require_finalized_quality_record(root, task_id, task, "repair")
|
|
5915
|
+
return quality_repair_failures_for_window(
|
|
5916
|
+
root,
|
|
5917
|
+
task_id,
|
|
5918
|
+
task,
|
|
5919
|
+
int(record["evidence_start_index"]),
|
|
5920
|
+
int(record["evidence_end_index"]),
|
|
5921
|
+
int(record["attempt"]),
|
|
5922
|
+
str(record["implementation_fingerprint"]),
|
|
5923
|
+
str(record["config_fingerprint"]),
|
|
4196
5924
|
)
|
|
4197
|
-
|
|
4198
|
-
|
|
5925
|
+
|
|
5926
|
+
|
|
5927
|
+
def validate_canonical_quality_repair_writeback(
|
|
5928
|
+
root: Path, task_id: str, task: dict
|
|
5929
|
+
) -> set[str]:
|
|
5930
|
+
failures = canonical_quality_repair_failures(root, task_id, task)
|
|
5931
|
+
if not failures:
|
|
5932
|
+
raise StateError("Canonical QUALITY repair has no affected source tasks.")
|
|
5933
|
+
intent = task.get("canonical_repair_transition")
|
|
5934
|
+
if isinstance(intent, dict):
|
|
5935
|
+
quality_record = next(
|
|
5936
|
+
candidate
|
|
5937
|
+
for _index, candidate in reversed(validated_quality_records(root, task_id))
|
|
5938
|
+
if candidate.get("outcome") == "repair"
|
|
5939
|
+
and candidate.get("attempt") == intent.get("quality_attempt")
|
|
5940
|
+
)
|
|
4199
5941
|
else:
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
if
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
5942
|
+
quality_record = require_finalized_quality_record(root, task_id, task, "repair")
|
|
5943
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5944
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5945
|
+
allowed_statuses = {"blocked"}
|
|
5946
|
+
if isinstance(intent, dict):
|
|
5947
|
+
if (
|
|
5948
|
+
intent.get("schema") != 1
|
|
5949
|
+
or intent.get("implementation_fingerprint")
|
|
5950
|
+
!= quality_record.get("implementation_fingerprint")
|
|
5951
|
+
or intent.get("config_fingerprint")
|
|
5952
|
+
!= quality_record.get("config_fingerprint")
|
|
5953
|
+
or intent.get("quality_attempt") != quality_record.get("attempt")
|
|
5954
|
+
or set(intent.get("source_task_ids") or []) != set(failures)
|
|
5955
|
+
):
|
|
5956
|
+
raise StateError("Canonical repair transition intent no longer matches QUALITY evidence.")
|
|
5957
|
+
allowed_statuses.add("in_progress")
|
|
5958
|
+
invalid_status = sorted(
|
|
5959
|
+
source_task_id
|
|
5960
|
+
for source_task_id in failures
|
|
5961
|
+
if snapshots.get(source_task_id, {}).get("status") not in allowed_statuses
|
|
5962
|
+
)
|
|
5963
|
+
if invalid_status:
|
|
5964
|
+
details = "; ".join(
|
|
5965
|
+
f"{source_task_id} ({', '.join(failures[source_task_id])})"
|
|
5966
|
+
for source_task_id in invalid_status
|
|
5967
|
+
)
|
|
5968
|
+
raise StateError(
|
|
5969
|
+
"Canonical QUALITY repair must write affected source tasks blocked before "
|
|
5970
|
+
f"returning to IMPLEMENT: {details}."
|
|
5971
|
+
)
|
|
5972
|
+
execution = inspection.get("execution")
|
|
5973
|
+
events = execution.get("events", []) if isinstance(execution, dict) else []
|
|
5974
|
+
for source_task_id, source_failures in failures.items():
|
|
5975
|
+
if snapshots.get(source_task_id, {}).get("status") == "in_progress":
|
|
5976
|
+
continue
|
|
5977
|
+
latest_status_event = next(
|
|
5978
|
+
(
|
|
5979
|
+
event
|
|
5980
|
+
for event in reversed(events)
|
|
5981
|
+
if isinstance(event, dict)
|
|
5982
|
+
and event.get("type") == "task_status_changed"
|
|
5983
|
+
and event.get("task_id") == source_task_id
|
|
5984
|
+
),
|
|
5985
|
+
None,
|
|
5986
|
+
)
|
|
5987
|
+
expected_key = (
|
|
5988
|
+
f"{task_id}:{source_task_id}:"
|
|
5989
|
+
f"{quality_record['implementation_fingerprint']}:"
|
|
5990
|
+
f"quality-{quality_record['attempt']}:blocked"
|
|
5991
|
+
)
|
|
5992
|
+
if (
|
|
5993
|
+
not isinstance(latest_status_event, dict)
|
|
5994
|
+
or latest_status_event.get("to_status") != "blocked"
|
|
5995
|
+
or latest_status_event.get("run_id") != task_id
|
|
5996
|
+
or latest_status_event.get("idempotency_key") != expected_key
|
|
5997
|
+
):
|
|
5998
|
+
raise StateError(
|
|
5999
|
+
"Canonical QUALITY blocked writeback must belong to the current "
|
|
6000
|
+
f"Harness task and QUALITY attempt: {source_task_id}."
|
|
6001
|
+
)
|
|
6002
|
+
evidence = latest_status_event.get("evidence")
|
|
6003
|
+
required_kinds = {value.split(":", 1)[0] for value in source_failures}
|
|
6004
|
+
evidence_kinds = {
|
|
6005
|
+
str(value.get("kind"))
|
|
6006
|
+
for value in evidence
|
|
6007
|
+
if isinstance(value, dict)
|
|
6008
|
+
and value.get("kind") in {"review", "verify"}
|
|
6009
|
+
and value.get("status") == "failed"
|
|
6010
|
+
and value.get("ref")
|
|
6011
|
+
== (
|
|
6012
|
+
"execution.jsonl#"
|
|
6013
|
+
f"quality-attempt={quality_record['attempt']};"
|
|
6014
|
+
f"implementation={quality_record['implementation_fingerprint']};"
|
|
6015
|
+
f"source-task={source_task_id};kind={value.get('kind')}"
|
|
6016
|
+
)
|
|
6017
|
+
} if isinstance(evidence, list) else set()
|
|
6018
|
+
if not required_kinds.issubset(evidence_kinds):
|
|
6019
|
+
raise StateError(
|
|
6020
|
+
"Canonical QUALITY blocked writeback must reference the current "
|
|
6021
|
+
f"failed gate evidence: {source_task_id}."
|
|
6022
|
+
)
|
|
6023
|
+
return set(failures)
|
|
6024
|
+
|
|
6025
|
+
|
|
6026
|
+
def prepare_canonical_repair_transition(
|
|
6027
|
+
root: Path, task_id: str, task: dict, agent: str
|
|
6028
|
+
) -> tuple[dict, set[str]]:
|
|
6029
|
+
source_task_ids = validate_canonical_quality_repair_writeback(root, task_id, task)
|
|
6030
|
+
if isinstance(task.get("canonical_repair_transition"), dict):
|
|
6031
|
+
return task, source_task_ids
|
|
6032
|
+
quality_record = require_finalized_quality_record(root, task_id, task, "repair")
|
|
6033
|
+
task["canonical_repair_transition"] = {
|
|
6034
|
+
"schema": 1,
|
|
6035
|
+
"implementation_fingerprint": quality_record["implementation_fingerprint"],
|
|
6036
|
+
"config_fingerprint": quality_record["config_fingerprint"],
|
|
6037
|
+
"quality_attempt": quality_record["attempt"],
|
|
6038
|
+
"source_task_ids": sorted(source_task_ids),
|
|
6039
|
+
"started_at": now_iso(),
|
|
6040
|
+
"started_by": agent,
|
|
6041
|
+
}
|
|
6042
|
+
task["last_agent"] = agent
|
|
6043
|
+
write_task(root, task_id, task)
|
|
6044
|
+
return task, source_task_ids
|
|
4213
6045
|
|
|
4214
|
-
|
|
6046
|
+
|
|
6047
|
+
def validate_canonical_repair_reopened(
|
|
6048
|
+
root: Path, task_id: str, task: dict, source_task_ids: set[str]
|
|
6049
|
+
) -> None:
|
|
6050
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
6051
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6052
|
+
pending = sorted(
|
|
6053
|
+
source_task_id
|
|
6054
|
+
for source_task_id in source_task_ids
|
|
6055
|
+
if snapshots.get(source_task_id, {}).get("status") != "in_progress"
|
|
6056
|
+
)
|
|
6057
|
+
if pending:
|
|
6058
|
+
raise StateError(
|
|
6059
|
+
"Canonical repair transition remains pending for source tasks: "
|
|
6060
|
+
+ ", ".join(pending)
|
|
6061
|
+
)
|
|
6062
|
+
execution = inspection.get("execution")
|
|
6063
|
+
events = execution.get("events", []) if isinstance(execution, dict) else []
|
|
6064
|
+
implement_attempt = 1 + sum(
|
|
6065
|
+
1
|
|
6066
|
+
for entry in task.get("stage_history", [])
|
|
6067
|
+
if isinstance(entry, dict) and entry.get("stage") == "IMPLEMENT"
|
|
6068
|
+
)
|
|
6069
|
+
invalid_ownership: list[str] = []
|
|
6070
|
+
for source_task_id in source_task_ids:
|
|
6071
|
+
latest_status_event = next(
|
|
6072
|
+
(
|
|
6073
|
+
event
|
|
6074
|
+
for event in reversed(events)
|
|
6075
|
+
if isinstance(event, dict)
|
|
6076
|
+
and event.get("type") == "task_status_changed"
|
|
6077
|
+
and event.get("task_id") == source_task_id
|
|
6078
|
+
),
|
|
6079
|
+
None,
|
|
6080
|
+
)
|
|
6081
|
+
expected_key = (
|
|
6082
|
+
f"{task_id}:{source_task_id}:enter-implement:"
|
|
6083
|
+
f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
|
|
6084
|
+
)
|
|
6085
|
+
if (
|
|
6086
|
+
not isinstance(latest_status_event, dict)
|
|
6087
|
+
or latest_status_event.get("to_status") != "in_progress"
|
|
6088
|
+
or latest_status_event.get("run_id") != task_id
|
|
6089
|
+
or latest_status_event.get("idempotency_key") != expected_key
|
|
6090
|
+
):
|
|
6091
|
+
invalid_ownership.append(source_task_id)
|
|
6092
|
+
if invalid_ownership:
|
|
4215
6093
|
raise StateError(
|
|
4216
|
-
"
|
|
6094
|
+
"Canonical repair reopen must belong to the current Harness transition: "
|
|
6095
|
+
+ ", ".join(sorted(invalid_ownership))
|
|
4217
6096
|
)
|
|
4218
6097
|
|
|
4219
6098
|
|
|
@@ -4379,7 +6258,6 @@ def validate_analysis_readiness(
|
|
|
4379
6258
|
task_dir = task_json_path(root, task_id).parent
|
|
4380
6259
|
task = load_task(root, task_id)
|
|
4381
6260
|
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
4382
|
-
is_read_only_task = task_type in NO_CODE_TASK_TYPES
|
|
4383
6261
|
dev_spec = task_dir / "dev-spec.md"
|
|
4384
6262
|
skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
|
|
4385
6263
|
test_strategy = task_dir / "test-strategy.md"
|
|
@@ -4401,10 +6279,6 @@ def validate_analysis_readiness(
|
|
|
4401
6279
|
|
|
4402
6280
|
if dev_spec_content:
|
|
4403
6281
|
missing_headers, empty_sections = validate_mandatory_dev_spec_sections(dev_spec_content)
|
|
4404
|
-
if is_read_only_task:
|
|
4405
|
-
empty_sections = [
|
|
4406
|
-
header for header in empty_sections if header != "### 改动范围"
|
|
4407
|
-
]
|
|
4408
6282
|
if missing_headers:
|
|
4409
6283
|
reasons.append(
|
|
4410
6284
|
"dev-spec.md is missing mandatory headers: "
|
|
@@ -4498,7 +6372,7 @@ def validate_analysis_readiness(
|
|
|
4498
6372
|
plan_is_valid = has_valid_execution_plan(root, task_id)
|
|
4499
6373
|
if not plan_is_valid:
|
|
4500
6374
|
reasons.append("execution.jsonl has no valid plan record")
|
|
4501
|
-
if tdd_enabled
|
|
6375
|
+
if tdd_enabled:
|
|
4502
6376
|
readiness = tdd_readiness(root)
|
|
4503
6377
|
if readiness["status"] != "ready":
|
|
4504
6378
|
reasons.append(
|
|
@@ -4586,7 +6460,7 @@ def validate_analysis_readiness(
|
|
|
4586
6460
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
4587
6461
|
):
|
|
4588
6462
|
reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
|
|
4589
|
-
|
|
6463
|
+
else:
|
|
4590
6464
|
if re.search(
|
|
4591
6465
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
4592
6466
|
):
|
|
@@ -4728,15 +6602,11 @@ def validate_analysis_readiness(
|
|
|
4728
6602
|
reasons.append(str(exc))
|
|
4729
6603
|
except OSError:
|
|
4730
6604
|
reasons.append("test-strategy.md cannot be read")
|
|
4731
|
-
|
|
4732
|
-
if test_strategy.exists():
|
|
4733
|
-
reasons.append("
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
if not test_strategy.exists() or not test_strategy.read_text(encoding="utf-8").strip():
|
|
4737
|
-
reasons.append("test-strategy.md is missing or empty")
|
|
4738
|
-
except OSError:
|
|
4739
|
-
reasons.append("test-strategy.md cannot be read")
|
|
6605
|
+
try:
|
|
6606
|
+
if not test_strategy.exists() or not test_strategy.read_text(encoding="utf-8").strip():
|
|
6607
|
+
reasons.append("test-strategy.md is missing or empty")
|
|
6608
|
+
except OSError:
|
|
6609
|
+
reasons.append("test-strategy.md cannot be read")
|
|
4740
6610
|
|
|
4741
6611
|
if reasons:
|
|
4742
6612
|
raise StateError(
|
|
@@ -4866,17 +6736,9 @@ def validate_transition(
|
|
|
4866
6736
|
) -> str | None:
|
|
4867
6737
|
if previous == current:
|
|
4868
6738
|
return None
|
|
4869
|
-
normalized_task_type = task_type.strip().lower()
|
|
4870
6739
|
allowed = set(VALID_TRANSITIONS.get(previous, set()))
|
|
4871
|
-
if previous == "IMPLEMENT"
|
|
4872
|
-
allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
|
|
4873
|
-
elif previous == "IMPLEMENT":
|
|
6740
|
+
if previous == "IMPLEMENT":
|
|
4874
6741
|
allowed.discard("COMPLETE")
|
|
4875
|
-
if not (
|
|
4876
|
-
isinstance(task, dict)
|
|
4877
|
-
and task.get("workflow_mode_legacy_direct_edge") is True
|
|
4878
|
-
):
|
|
4879
|
-
allowed.discard("VERIFICATION")
|
|
4880
6742
|
if current in allowed:
|
|
4881
6743
|
return None
|
|
4882
6744
|
return (
|
|
@@ -4997,6 +6859,8 @@ def snapshot_state(
|
|
|
4997
6859
|
"session_confirm_mode": session_approval_mode,
|
|
4998
6860
|
"effective_confirm_mode": effective_approval_mode,
|
|
4999
6861
|
"harness_disabled": resolved_session.get("harness_disabled") is True,
|
|
6862
|
+
"lite_mode": resolved_session.get("lite_mode") is True,
|
|
6863
|
+
"lite_proposal": resolved_session.get("lite_proposal"),
|
|
5000
6864
|
}
|
|
5001
6865
|
|
|
5002
6866
|
|
|
@@ -5007,6 +6871,17 @@ def build_status_line(
|
|
|
5007
6871
|
session_file: str | Path | None = None,
|
|
5008
6872
|
) -> str:
|
|
5009
6873
|
state = snapshot_state(root, session_file, session)
|
|
6874
|
+
if state["lite_mode"]:
|
|
6875
|
+
lite_state = (
|
|
6876
|
+
"Awaiting Confirmation"
|
|
6877
|
+
if isinstance(state.get("lite_proposal"), dict)
|
|
6878
|
+
and not state["lite_proposal"].get("confirmed_at")
|
|
6879
|
+
else "Ready"
|
|
6880
|
+
)
|
|
6881
|
+
return (
|
|
6882
|
+
f"> **Easy Coding** · **Lite Direct** · {lite_state} · "
|
|
6883
|
+
"No Task / Quality / Memory · Use `ec-lite` to exit"
|
|
6884
|
+
)
|
|
5010
6885
|
approval = str(state["effective_approval_mode"]).capitalize()
|
|
5011
6886
|
workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
|
|
5012
6887
|
status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
|
|
@@ -5077,17 +6952,7 @@ def build_machine_breadcrumbs(
|
|
|
5077
6952
|
if target:
|
|
5078
6953
|
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
5079
6954
|
task_type = str(task.get("type") or "") if task else ""
|
|
5080
|
-
|
|
5081
|
-
source == "IMPLEMENT"
|
|
5082
|
-
and target == "REVIEW"
|
|
5083
|
-
and isinstance(task, dict)
|
|
5084
|
-
and task.get("workflow_mode_legacy_direct_edge") is True
|
|
5085
|
-
)
|
|
5086
|
-
if legacy_review_bypass:
|
|
5087
|
-
lines.append(
|
|
5088
|
-
"[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
|
|
5089
|
-
)
|
|
5090
|
-
elif pending.get("confirmation_override") == "evidence-drift":
|
|
6955
|
+
if pending.get("confirmation_override") == "evidence-drift":
|
|
5091
6956
|
lines.append(
|
|
5092
6957
|
"[easy-coding:acceptance-drift-confirmation-required]"
|
|
5093
6958
|
)
|
|
@@ -5152,6 +7017,17 @@ def build_status_context(
|
|
|
5152
7017
|
f"[easy-coding:session-file:{display_path(root, session_path)}]",
|
|
5153
7018
|
]
|
|
5154
7019
|
)
|
|
7020
|
+
if session.get("lite_mode") is True:
|
|
7021
|
+
session_path = resolve_session_path(root, session_file)
|
|
7022
|
+
proposal = session.get("lite_proposal")
|
|
7023
|
+
lines = [
|
|
7024
|
+
build_status_line(root, session, agent, session_file),
|
|
7025
|
+
"[easy-coding:lite-direct]",
|
|
7026
|
+
f"[easy-coding:session-file:{display_path(root, session_path)}]",
|
|
7027
|
+
]
|
|
7028
|
+
if isinstance(proposal, dict):
|
|
7029
|
+
lines.append(f"[easy-coding:lite-proposal:{proposal.get('digest', 'missing')}]")
|
|
7030
|
+
return "\n".join(lines)
|
|
5155
7031
|
return "\n".join(
|
|
5156
7032
|
[
|
|
5157
7033
|
build_status_line(root, session, agent, session_file),
|
|
@@ -5233,6 +7109,8 @@ def set_current_task(root: Path, task_id: str, agent: str, session_file: str | P
|
|
|
5233
7109
|
if task is None:
|
|
5234
7110
|
raise StateError(f"Task not found: {task_id}")
|
|
5235
7111
|
session = ensure_session(root, session_file)
|
|
7112
|
+
if session.get("lite_mode") is True:
|
|
7113
|
+
raise StateError("Exit ec-lite before attaching a Harness task.")
|
|
5236
7114
|
session["current_task"] = task_id
|
|
5237
7115
|
session["last_seen_task"] = task_id
|
|
5238
7116
|
session["last_seen_stage"] = str(task.get("status") or "PENDING")
|
|
@@ -5413,6 +7291,339 @@ def clear_session_tdd(
|
|
|
5413
7291
|
return snapshot
|
|
5414
7292
|
|
|
5415
7293
|
|
|
7294
|
+
def normalize_lite_target_files(root: Path, target_files: list[str]) -> list[str]:
|
|
7295
|
+
normalized: list[str] = []
|
|
7296
|
+
for raw_file in target_files:
|
|
7297
|
+
raw_path = raw_file.strip()
|
|
7298
|
+
candidate = Path(raw_path)
|
|
7299
|
+
if (
|
|
7300
|
+
not raw_path
|
|
7301
|
+
or candidate.is_absolute()
|
|
7302
|
+
or ".." in candidate.parts
|
|
7303
|
+
or candidate == Path(".")
|
|
7304
|
+
or candidate.parts[:2] == (".easy-coding", "sessions")
|
|
7305
|
+
):
|
|
7306
|
+
raise StateError("Lite target files must be safe project-relative file paths.")
|
|
7307
|
+
resolved = (root / candidate).resolve()
|
|
7308
|
+
if not is_path_within(resolved, root.resolve()) or resolved.is_dir():
|
|
7309
|
+
raise StateError("Lite target files must stay within the project and cannot be directories.")
|
|
7310
|
+
normalized.append(candidate.as_posix())
|
|
7311
|
+
normalized = list(dict.fromkeys(normalized))
|
|
7312
|
+
if not normalized or len(normalized) > 50:
|
|
7313
|
+
raise StateError("Lite proposal requires 1 to 50 target files.")
|
|
7314
|
+
return normalized
|
|
7315
|
+
|
|
7316
|
+
|
|
7317
|
+
def lite_git_head(repository: Path) -> str | None:
|
|
7318
|
+
result = run_git(repository, "rev-parse", "--verify", "HEAD")
|
|
7319
|
+
if result is None:
|
|
7320
|
+
raise StateError("Cannot inspect the Git baseline for Lite Direct.")
|
|
7321
|
+
if result.returncode != 0:
|
|
7322
|
+
return None
|
|
7323
|
+
head = result.stdout.decode("ascii", errors="ignore").strip()
|
|
7324
|
+
if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head) is None:
|
|
7325
|
+
raise StateError("Lite Direct received an invalid Git baseline.")
|
|
7326
|
+
return head
|
|
7327
|
+
|
|
7328
|
+
|
|
7329
|
+
def lite_git_dirty_paths(root: Path, repository: Path) -> set[str]:
|
|
7330
|
+
try:
|
|
7331
|
+
project_prefix = root.resolve().relative_to(repository.resolve()).as_posix() or "."
|
|
7332
|
+
except ValueError as exc:
|
|
7333
|
+
raise StateError("Lite Direct project root is outside its Git repository.") from exc
|
|
7334
|
+
|
|
7335
|
+
commands = (
|
|
7336
|
+
("diff", "--name-only", "--no-renames", "-z", "--", project_prefix),
|
|
7337
|
+
("diff", "--cached", "--name-only", "--no-renames", "-z", "--", project_prefix),
|
|
7338
|
+
("ls-files", "--others", "--exclude-standard", "-z", "--", project_prefix),
|
|
7339
|
+
)
|
|
7340
|
+
paths: set[str] = set()
|
|
7341
|
+
for command in commands:
|
|
7342
|
+
result = run_git(repository, *command)
|
|
7343
|
+
if result is None or result.returncode != 0:
|
|
7344
|
+
raise StateError("Cannot inspect Lite Direct Git changes.")
|
|
7345
|
+
for raw_path in filter(None, result.stdout.split(b"\0")):
|
|
7346
|
+
resolved = (repository / os.fsdecode(raw_path)).resolve()
|
|
7347
|
+
if is_path_within(resolved, root.resolve()):
|
|
7348
|
+
relative = resolved.relative_to(root.resolve())
|
|
7349
|
+
if relative.parts[:2] != (".easy-coding", "sessions"):
|
|
7350
|
+
paths.add(relative.as_posix())
|
|
7351
|
+
return paths
|
|
7352
|
+
|
|
7353
|
+
|
|
7354
|
+
def lite_file_state(path: Path) -> dict:
|
|
7355
|
+
if not path.exists() and not path.is_symlink():
|
|
7356
|
+
return {"exists": False, "mode": None, "sha256": None}
|
|
7357
|
+
if path.is_dir():
|
|
7358
|
+
return {"exists": True, "mode": "directory", "sha256": None}
|
|
7359
|
+
try:
|
|
7360
|
+
content = os.fsencode(os.readlink(path)) if path.is_symlink() else path.read_bytes()
|
|
7361
|
+
except OSError as exc:
|
|
7362
|
+
raise StateError(f"Cannot inspect Lite Direct file: {path}") from exc
|
|
7363
|
+
return {
|
|
7364
|
+
"exists": True,
|
|
7365
|
+
"mode": worktree_git_mode(path).decode("ascii", errors="replace"),
|
|
7366
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
7367
|
+
}
|
|
7368
|
+
|
|
7369
|
+
|
|
7370
|
+
def capture_lite_baseline(root: Path, target_files: list[str]) -> dict:
|
|
7371
|
+
repository = git_repository_root(root)
|
|
7372
|
+
if repository is None:
|
|
7373
|
+
raise StateError("Lite Direct scope verification requires a Git worktree.")
|
|
7374
|
+
repository = repository.resolve()
|
|
7375
|
+
for target_file in target_files:
|
|
7376
|
+
target_repository = git_repository_root(root / target_file)
|
|
7377
|
+
if target_repository is None or target_repository.resolve() != repository:
|
|
7378
|
+
raise StateError(
|
|
7379
|
+
"Lite Direct target files must belong to the current project Git repository."
|
|
7380
|
+
)
|
|
7381
|
+
dirty_paths = lite_git_dirty_paths(root, repository)
|
|
7382
|
+
tracked_paths = dirty_paths | set(target_files)
|
|
7383
|
+
return {
|
|
7384
|
+
"schema": 1,
|
|
7385
|
+
"repository_root": str(repository),
|
|
7386
|
+
"head": lite_git_head(repository),
|
|
7387
|
+
"dirty_paths": sorted(dirty_paths),
|
|
7388
|
+
"states": {
|
|
7389
|
+
path_name: lite_file_state(root / path_name)
|
|
7390
|
+
for path_name in sorted(tracked_paths)
|
|
7391
|
+
},
|
|
7392
|
+
}
|
|
7393
|
+
|
|
7394
|
+
|
|
7395
|
+
def validate_lite_completion(root: Path, proposal: dict) -> list[str]:
|
|
7396
|
+
target_files = proposal.get("target_files")
|
|
7397
|
+
baseline = proposal.get("baseline")
|
|
7398
|
+
if not is_string_list(target_files, allow_empty=False) or not isinstance(baseline, dict):
|
|
7399
|
+
raise StateError("Lite proposal has no confirmed Git scope baseline.")
|
|
7400
|
+
repository = git_repository_root(root)
|
|
7401
|
+
if (
|
|
7402
|
+
repository is None
|
|
7403
|
+
or baseline.get("schema") != 1
|
|
7404
|
+
or str(repository.resolve()) != baseline.get("repository_root")
|
|
7405
|
+
or lite_git_head(repository.resolve()) != baseline.get("head")
|
|
7406
|
+
):
|
|
7407
|
+
raise StateError("Lite Direct Git baseline changed; present and confirm the proposal again.")
|
|
7408
|
+
baseline_dirty = baseline.get("dirty_paths")
|
|
7409
|
+
baseline_states = baseline.get("states")
|
|
7410
|
+
if not is_string_list(baseline_dirty) or not isinstance(baseline_states, dict):
|
|
7411
|
+
raise StateError("Lite proposal contains an invalid Git scope baseline.")
|
|
7412
|
+
|
|
7413
|
+
current_dirty = lite_git_dirty_paths(root, repository.resolve())
|
|
7414
|
+
target_set = set(target_files)
|
|
7415
|
+
baseline_dirty_set = set(baseline_dirty)
|
|
7416
|
+
candidate_paths = baseline_dirty_set | current_dirty | target_set
|
|
7417
|
+
changed_paths: list[str] = []
|
|
7418
|
+
for path_name in sorted(candidate_paths):
|
|
7419
|
+
before = baseline_states.get(path_name)
|
|
7420
|
+
after = lite_file_state(root / path_name)
|
|
7421
|
+
if path_name in baseline_dirty_set or path_name in target_set:
|
|
7422
|
+
if before != after:
|
|
7423
|
+
changed_paths.append(path_name)
|
|
7424
|
+
elif path_name in current_dirty:
|
|
7425
|
+
changed_paths.append(path_name)
|
|
7426
|
+
|
|
7427
|
+
outside_scope = [path_name for path_name in changed_paths if path_name not in target_set]
|
|
7428
|
+
if outside_scope:
|
|
7429
|
+
raise StateError(
|
|
7430
|
+
"Lite Direct changed files outside the confirmed scope: " + ", ".join(outside_scope)
|
|
7431
|
+
)
|
|
7432
|
+
changed_targets = [path_name for path_name in changed_paths if path_name in target_set]
|
|
7433
|
+
if not changed_targets:
|
|
7434
|
+
raise StateError("Lite Direct did not change any confirmed target file.")
|
|
7435
|
+
return changed_targets
|
|
7436
|
+
|
|
7437
|
+
|
|
7438
|
+
def enable_lite_mode(
|
|
7439
|
+
root: Path,
|
|
7440
|
+
agent: str,
|
|
7441
|
+
active_task_policy: str | None = None,
|
|
7442
|
+
expected_task_id: str | None = None,
|
|
7443
|
+
session_file: str | Path | None = None,
|
|
7444
|
+
) -> dict:
|
|
7445
|
+
session = ensure_session(root, session_file)
|
|
7446
|
+
if session.get("harness_disabled") is True:
|
|
7447
|
+
raise StateError("Enable Harness before entering ec-lite.")
|
|
7448
|
+
if session.get("lite_mode") is True:
|
|
7449
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7450
|
+
snapshot["action"] = "lite-already-enabled"
|
|
7451
|
+
return snapshot
|
|
7452
|
+
if active_task_policy == "cancel":
|
|
7453
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7454
|
+
snapshot["action"] = "lite-enable-cancelled"
|
|
7455
|
+
return snapshot
|
|
7456
|
+
|
|
7457
|
+
task_id = session.get("current_task")
|
|
7458
|
+
task = load_task(root, str(task_id)) if task_id else None
|
|
7459
|
+
if task_id and (task is None or task.get("status") in TERMINAL_STATUSES):
|
|
7460
|
+
clear_session_pointer(session, agent)
|
|
7461
|
+
task_id = None
|
|
7462
|
+
task = None
|
|
7463
|
+
if active_task_policy in {"close", "ignore"} and expected_task_id != str(task_id or ""):
|
|
7464
|
+
raise StateError(
|
|
7465
|
+
"Active task changed after the Lite decision was shown; inspect it again."
|
|
7466
|
+
)
|
|
7467
|
+
|
|
7468
|
+
if task_id and task and task.get("status") not in TERMINAL_STATUSES:
|
|
7469
|
+
if active_task_policy is None:
|
|
7470
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7471
|
+
snapshot["action"] = "lite-active-task-decision-required"
|
|
7472
|
+
snapshot["active_task"] = {
|
|
7473
|
+
"id": str(task_id),
|
|
7474
|
+
"title": task.get("title"),
|
|
7475
|
+
"status": task.get("status"),
|
|
7476
|
+
}
|
|
7477
|
+
snapshot["choices"] = ["cancel", "close", "ignore"]
|
|
7478
|
+
return snapshot
|
|
7479
|
+
if active_task_policy == "close":
|
|
7480
|
+
close_current_task(
|
|
7481
|
+
root,
|
|
7482
|
+
"user-switched-to-lite",
|
|
7483
|
+
agent,
|
|
7484
|
+
session_file,
|
|
7485
|
+
expected_task_id=str(task_id),
|
|
7486
|
+
)
|
|
7487
|
+
session = ensure_session(root, session_file)
|
|
7488
|
+
elif active_task_policy == "ignore":
|
|
7489
|
+
session = ensure_session(root, session_file)
|
|
7490
|
+
if session.get("current_task") != expected_task_id:
|
|
7491
|
+
raise StateError(
|
|
7492
|
+
"Active task changed after the Lite decision was shown; inspect it again."
|
|
7493
|
+
)
|
|
7494
|
+
clear_session_pointer(session, agent)
|
|
7495
|
+
else:
|
|
7496
|
+
raise StateError("Active task policy must be cancel, close, or ignore.")
|
|
7497
|
+
|
|
7498
|
+
session["lite_mode"] = True
|
|
7499
|
+
session.pop("lite_proposal", None)
|
|
7500
|
+
session["last_agent"] = agent
|
|
7501
|
+
write_session(root, session, session_file)
|
|
7502
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7503
|
+
snapshot["action"] = "enable-lite"
|
|
7504
|
+
return snapshot
|
|
7505
|
+
|
|
7506
|
+
|
|
7507
|
+
def disable_lite_mode(
|
|
7508
|
+
root: Path,
|
|
7509
|
+
agent: str,
|
|
7510
|
+
session_file: str | Path | None = None,
|
|
7511
|
+
) -> dict:
|
|
7512
|
+
session = ensure_session(root, session_file)
|
|
7513
|
+
session.pop("lite_mode", None)
|
|
7514
|
+
session.pop("lite_proposal", None)
|
|
7515
|
+
session["last_agent"] = agent
|
|
7516
|
+
write_session(root, session, session_file)
|
|
7517
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7518
|
+
snapshot["action"] = "disable-lite"
|
|
7519
|
+
return snapshot
|
|
7520
|
+
|
|
7521
|
+
|
|
7522
|
+
def set_lite_proposal(
|
|
7523
|
+
root: Path,
|
|
7524
|
+
summary: str,
|
|
7525
|
+
target_files: list[str],
|
|
7526
|
+
agent: str,
|
|
7527
|
+
session_file: str | Path | None = None,
|
|
7528
|
+
) -> dict:
|
|
7529
|
+
session = ensure_session(root, session_file)
|
|
7530
|
+
if session.get("lite_mode") is not True:
|
|
7531
|
+
raise StateError("ec-lite is not enabled.")
|
|
7532
|
+
if session.get("current_task"):
|
|
7533
|
+
raise StateError("Lite proposal cannot coexist with a Harness task pointer.")
|
|
7534
|
+
normalized_summary = summary.strip()
|
|
7535
|
+
normalized_files = normalize_lite_target_files(root, target_files)
|
|
7536
|
+
if not normalized_summary or len(normalized_summary) > 2000:
|
|
7537
|
+
raise StateError("Lite proposal summary must contain 1 to 2000 characters.")
|
|
7538
|
+
proposal_payload = {
|
|
7539
|
+
"proposal_id": secrets.token_hex(16),
|
|
7540
|
+
"summary": normalized_summary,
|
|
7541
|
+
"target_files": normalized_files,
|
|
7542
|
+
"baseline": capture_lite_baseline(root, normalized_files),
|
|
7543
|
+
}
|
|
7544
|
+
session["lite_proposal"] = {
|
|
7545
|
+
**proposal_payload,
|
|
7546
|
+
"digest": canonical_json_sha256(proposal_payload),
|
|
7547
|
+
"created_at": now_iso(),
|
|
7548
|
+
}
|
|
7549
|
+
session["last_agent"] = agent
|
|
7550
|
+
write_session(root, session, session_file)
|
|
7551
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7552
|
+
snapshot["action"] = "set-lite-proposal"
|
|
7553
|
+
return snapshot
|
|
7554
|
+
|
|
7555
|
+
|
|
7556
|
+
def confirm_lite_proposal(
|
|
7557
|
+
root: Path,
|
|
7558
|
+
digest: str,
|
|
7559
|
+
agent: str,
|
|
7560
|
+
session_file: str | Path | None = None,
|
|
7561
|
+
) -> dict:
|
|
7562
|
+
session = ensure_session(root, session_file)
|
|
7563
|
+
proposal = session.get("lite_proposal")
|
|
7564
|
+
if session.get("lite_mode") is not True or not isinstance(proposal, dict):
|
|
7565
|
+
raise StateError("No Lite proposal is awaiting confirmation.")
|
|
7566
|
+
if proposal.get("confirmed_at"):
|
|
7567
|
+
raise StateError("This Lite proposal was already confirmed and cannot be replayed.")
|
|
7568
|
+
current_digest = canonical_json_sha256(
|
|
7569
|
+
{
|
|
7570
|
+
"proposal_id": proposal.get("proposal_id"),
|
|
7571
|
+
"summary": proposal.get("summary"),
|
|
7572
|
+
"target_files": proposal.get("target_files"),
|
|
7573
|
+
"baseline": proposal.get("baseline"),
|
|
7574
|
+
}
|
|
7575
|
+
)
|
|
7576
|
+
if proposal.get("digest") != current_digest or digest != current_digest:
|
|
7577
|
+
raise StateError("Lite proposal digest changed; present the current proposal again.")
|
|
7578
|
+
if capture_lite_baseline(root, list(proposal["target_files"])) != proposal.get(
|
|
7579
|
+
"baseline"
|
|
7580
|
+
):
|
|
7581
|
+
raise StateError(
|
|
7582
|
+
"Lite Direct Git baseline changed before confirmation; present the proposal again."
|
|
7583
|
+
)
|
|
7584
|
+
proposal["confirmed_at"] = now_iso()
|
|
7585
|
+
proposal["confirmed_by"] = agent
|
|
7586
|
+
session["last_agent"] = agent
|
|
7587
|
+
write_session(root, session, session_file)
|
|
7588
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7589
|
+
snapshot["action"] = "confirm-lite-proposal"
|
|
7590
|
+
return snapshot
|
|
7591
|
+
|
|
7592
|
+
|
|
7593
|
+
def complete_lite_proposal(
|
|
7594
|
+
root: Path,
|
|
7595
|
+
digest: str,
|
|
7596
|
+
agent: str,
|
|
7597
|
+
session_file: str | Path | None = None,
|
|
7598
|
+
) -> dict:
|
|
7599
|
+
session = ensure_session(root, session_file)
|
|
7600
|
+
proposal = session.get("lite_proposal")
|
|
7601
|
+
if session.get("lite_mode") is not True or not isinstance(proposal, dict):
|
|
7602
|
+
raise StateError("No confirmed Lite proposal is active.")
|
|
7603
|
+
current_digest = canonical_json_sha256(
|
|
7604
|
+
{
|
|
7605
|
+
"proposal_id": proposal.get("proposal_id"),
|
|
7606
|
+
"summary": proposal.get("summary"),
|
|
7607
|
+
"target_files": proposal.get("target_files"),
|
|
7608
|
+
"baseline": proposal.get("baseline"),
|
|
7609
|
+
}
|
|
7610
|
+
)
|
|
7611
|
+
if (
|
|
7612
|
+
proposal.get("digest") != current_digest
|
|
7613
|
+
or digest != current_digest
|
|
7614
|
+
or not proposal.get("confirmed_at")
|
|
7615
|
+
):
|
|
7616
|
+
raise StateError("Complete the exact user-confirmed Lite proposal.")
|
|
7617
|
+
changed_files = validate_lite_completion(root, proposal)
|
|
7618
|
+
session.pop("lite_proposal", None)
|
|
7619
|
+
session["last_agent"] = agent
|
|
7620
|
+
write_session(root, session, session_file)
|
|
7621
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7622
|
+
snapshot["action"] = "complete-lite-proposal"
|
|
7623
|
+
snapshot["changed_files"] = changed_files
|
|
7624
|
+
return snapshot
|
|
7625
|
+
|
|
7626
|
+
|
|
5416
7627
|
def set_harness_disabled(
|
|
5417
7628
|
root: Path,
|
|
5418
7629
|
disabled: bool,
|
|
@@ -5481,6 +7692,10 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
|
|
|
5481
7692
|
if status in TERMINAL_STATUSES:
|
|
5482
7693
|
raise StateError(f"Cannot claim terminal task: {task_id}")
|
|
5483
7694
|
|
|
7695
|
+
session = ensure_session(root, session_file)
|
|
7696
|
+
if session.get("lite_mode") is True:
|
|
7697
|
+
raise StateError("Exit ec-lite before claiming a Harness task.")
|
|
7698
|
+
|
|
5484
7699
|
previous_agent = task.get("last_agent")
|
|
5485
7700
|
action = (
|
|
5486
7701
|
"continue"
|
|
@@ -5491,7 +7706,6 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
|
|
|
5491
7706
|
task["last_agent"] = agent
|
|
5492
7707
|
write_task(root, task_id, task)
|
|
5493
7708
|
|
|
5494
|
-
session = ensure_session(root, session_file)
|
|
5495
7709
|
session["current_task"] = task_id
|
|
5496
7710
|
session["last_seen_task"] = task_id
|
|
5497
7711
|
session["last_seen_stage"] = status
|
|
@@ -5527,8 +7741,13 @@ def create_task(
|
|
|
5527
7741
|
task_fields: dict | None = None,
|
|
5528
7742
|
) -> dict:
|
|
5529
7743
|
assert_safe_task_id(task_id)
|
|
5530
|
-
if
|
|
5531
|
-
|
|
7744
|
+
if task_type.strip().lower() in {"analysis", "doc", "report"}:
|
|
7745
|
+
raise StateError(
|
|
7746
|
+
"Read-only conversation does not create a Harness task; stay Ready and answer directly."
|
|
7747
|
+
)
|
|
7748
|
+
session = ensure_session(root, session_file)
|
|
7749
|
+
if session.get("lite_mode") is True:
|
|
7750
|
+
raise StateError("Exit ec-lite before creating a Harness task.")
|
|
5532
7751
|
path = task_json_path(root, task_id)
|
|
5533
7752
|
if path.exists():
|
|
5534
7753
|
raise StateError(f"Task already exists: {task_id}")
|
|
@@ -6661,6 +8880,7 @@ def writeback_ready_tasks_for_implement(
|
|
|
6661
8880
|
task: dict,
|
|
6662
8881
|
agent: str,
|
|
6663
8882
|
restart_statuses: set[str] | None = None,
|
|
8883
|
+
source_task_ids: set[str] | None = None,
|
|
6664
8884
|
) -> None:
|
|
6665
8885
|
inspection, _ = inspect_task_spec(root, task)
|
|
6666
8886
|
implement_attempt = 1 + sum(
|
|
@@ -6675,6 +8895,8 @@ def writeback_ready_tasks_for_implement(
|
|
|
6675
8895
|
}
|
|
6676
8896
|
selected_snapshots = _selected_execution_snapshots(inspection, task)
|
|
6677
8897
|
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
8898
|
+
if source_task_ids is not None and str(source_task_id) not in source_task_ids:
|
|
8899
|
+
continue
|
|
6678
8900
|
snapshot = selected_snapshots.get(str(source_task_id))
|
|
6679
8901
|
if not snapshot or snapshot.get("status") == "in_progress":
|
|
6680
8902
|
continue
|
|
@@ -7146,9 +9368,6 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
|
|
|
7146
9368
|
if task is None:
|
|
7147
9369
|
raise StateError(f"Task not found: {task_id}")
|
|
7148
9370
|
task_type = str(task.get("type") or "").strip().lower()
|
|
7149
|
-
if task_type in NO_CODE_TASK_TYPES:
|
|
7150
|
-
return "fast", ["read-only-task"]
|
|
7151
|
-
|
|
7152
9371
|
plan = latest_execution_plan(root, task_id)
|
|
7153
9372
|
if not plan:
|
|
7154
9373
|
raise StateError("Cannot calculate workflow floor without a valid execution plan.")
|
|
@@ -7189,7 +9408,7 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
|
|
|
7189
9408
|
complexity_reasons: list[str] = []
|
|
7190
9409
|
if len(repositories) > 1:
|
|
7191
9410
|
complexity_reasons.append("cross-repository-change")
|
|
7192
|
-
if len(units) >=
|
|
9411
|
+
if len(units) >= 5 or len(files) >= 15:
|
|
7193
9412
|
complexity_reasons.append("broad-change-scope")
|
|
7194
9413
|
if WIDE_WORKFLOW_CONTRACT_PATTERN.search(" ".join(contract_values)):
|
|
7195
9414
|
complexity_reasons.append("wide-contract-impact")
|
|
@@ -7204,11 +9423,11 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
|
|
|
7204
9423
|
if high_risk:
|
|
7205
9424
|
standard_reasons.append("bounded-high-risk-change")
|
|
7206
9425
|
standard_reasons.extend(complexity_reasons)
|
|
7207
|
-
if len(units)
|
|
9426
|
+
if len(units) >= 4:
|
|
7208
9427
|
standard_reasons.append("multiple-units")
|
|
7209
|
-
if len(files) >
|
|
9428
|
+
if len(files) > 8:
|
|
7210
9429
|
standard_reasons.append("multi-file-impact")
|
|
7211
|
-
if plan.get("strategy") == "parallel":
|
|
9430
|
+
if plan.get("strategy") == "parallel" and len(units) >= 3:
|
|
7212
9431
|
standard_reasons.append("parallel-execution")
|
|
7213
9432
|
if standard_reasons:
|
|
7214
9433
|
return "standard", list(dict.fromkeys(standard_reasons))
|
|
@@ -7264,9 +9483,7 @@ def freeze_tdd_mode(
|
|
|
7264
9483
|
behavior = resolve_behavior(root, session)
|
|
7265
9484
|
task_type = str(task.get("type") or "").strip().lower()
|
|
7266
9485
|
task["tdd_enabled"] = (
|
|
7267
|
-
behavior[8]
|
|
7268
|
-
if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
|
|
7269
|
-
else False
|
|
9486
|
+
behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
|
|
7270
9487
|
)
|
|
7271
9488
|
task["tdd_coverage_threshold"] = behavior[11]
|
|
7272
9489
|
if task["tdd_enabled"] is True:
|
|
@@ -7306,12 +9523,12 @@ def raise_workflow_mode(
|
|
|
7306
9523
|
) -> dict:
|
|
7307
9524
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
7308
9525
|
stage = str(task.get("status") or "")
|
|
7309
|
-
if stage == "
|
|
9526
|
+
if stage == "QUALITY":
|
|
7310
9527
|
raise StateError(
|
|
7311
|
-
"Return to IMPLEMENT before raising workflow mode from
|
|
7312
|
-
"task can re-enter
|
|
9528
|
+
"Return to IMPLEMENT before raising workflow mode from QUALITY so the "
|
|
9529
|
+
"task can re-enter QUALITY with fresh evidence."
|
|
7313
9530
|
)
|
|
7314
|
-
if stage
|
|
9531
|
+
if stage != "IMPLEMENT":
|
|
7315
9532
|
raise StateError("A frozen workflow mode can only be raised during active execution.")
|
|
7316
9533
|
current = str(task.get("workflow_mode") or "")
|
|
7317
9534
|
if current not in WORKFLOW_MODES or mode not in WORKFLOW_MODES:
|
|
@@ -7369,24 +9586,33 @@ def request_transition(
|
|
|
7369
9586
|
task.get("workflow_mode_proposal"),
|
|
7370
9587
|
resolved_task_id,
|
|
7371
9588
|
)
|
|
7372
|
-
if previous == "
|
|
7373
|
-
|
|
9589
|
+
if previous == "QUALITY" and stage in {"IMPLEMENT", "ANALYSIS"}:
|
|
9590
|
+
validate_quality_exit_request(root, resolved_task_id, task, stage)
|
|
9591
|
+
if (
|
|
9592
|
+
stage == "IMPLEMENT"
|
|
9593
|
+
and current_finalized_quality_outcome(root, resolved_task_id, task)
|
|
9594
|
+
== "repair"
|
|
9595
|
+
and isinstance(task.get("spec_source"), dict)
|
|
9596
|
+
):
|
|
9597
|
+
validate_canonical_quality_repair_writeback(
|
|
9598
|
+
root, resolved_task_id, task
|
|
9599
|
+
)
|
|
7374
9600
|
acceptance_drift: dict | None = None
|
|
7375
|
-
if previous == "
|
|
9601
|
+
if previous == "QUALITY" and stage == "MEMORY":
|
|
7376
9602
|
task = ensure_verification_checkpoint(
|
|
7377
9603
|
root, resolved_task_id, task, agent, session_file
|
|
7378
9604
|
)
|
|
7379
9605
|
acceptance_drift = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
7380
9606
|
if acceptance_drift["config_changed"]:
|
|
7381
9607
|
raise StateError(
|
|
7382
|
-
"Behavior config changed after
|
|
9608
|
+
"Behavior config changed after quality checks; rerun QUALITY before MEMORY."
|
|
7383
9609
|
)
|
|
7384
9610
|
if acceptance_drift["metadata_changed"]:
|
|
7385
9611
|
raise StateError(
|
|
7386
|
-
"
|
|
9612
|
+
"Quality metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7387
9613
|
)
|
|
7388
9614
|
if acceptance_drift["status"] == "clean":
|
|
7389
|
-
|
|
9615
|
+
validate_quality_readiness(root, resolved_task_id, task)
|
|
7390
9616
|
existing = task.get("pending_transition")
|
|
7391
9617
|
if isinstance(existing, dict):
|
|
7392
9618
|
if existing.get("from") != previous or existing.get("to") != stage:
|
|
@@ -7394,11 +9620,27 @@ def request_transition(
|
|
|
7394
9620
|
"A different transition is already pending. Cancel it before requesting another."
|
|
7395
9621
|
)
|
|
7396
9622
|
else:
|
|
9623
|
+
transition_binding: dict[str, object] = {}
|
|
9624
|
+
repair_intent = task.get("canonical_repair_transition")
|
|
9625
|
+
if (
|
|
9626
|
+
previous == "QUALITY"
|
|
9627
|
+
and stage == "IMPLEMENT"
|
|
9628
|
+
and isinstance(repair_intent, dict)
|
|
9629
|
+
):
|
|
9630
|
+
transition_binding = {
|
|
9631
|
+
"quality_attempt": repair_intent.get("quality_attempt"),
|
|
9632
|
+
"implementation_fingerprint": repair_intent.get(
|
|
9633
|
+
"implementation_fingerprint"
|
|
9634
|
+
),
|
|
9635
|
+
"config_fingerprint": repair_intent.get("config_fingerprint"),
|
|
9636
|
+
"source_task_ids": repair_intent.get("source_task_ids"),
|
|
9637
|
+
}
|
|
7397
9638
|
task["pending_transition"] = {
|
|
7398
9639
|
"from": previous,
|
|
7399
9640
|
"to": stage,
|
|
7400
9641
|
"requested_at": now_iso(),
|
|
7401
9642
|
"requested_by": agent,
|
|
9643
|
+
**transition_binding,
|
|
7402
9644
|
**({"reason": reason.strip()} if reason and reason.strip() else {}),
|
|
7403
9645
|
}
|
|
7404
9646
|
task["last_agent"] = agent
|
|
@@ -7425,7 +9667,6 @@ def apply_transition(
|
|
|
7425
9667
|
previous = str(task.get("status") or "idle")
|
|
7426
9668
|
task_type = str(task.get("type") or "")
|
|
7427
9669
|
approval_mode = resolve_approval_mode(root, session)[2]
|
|
7428
|
-
legacy_edge = task.get("workflow_mode_legacy") is True
|
|
7429
9670
|
violation = validate_transition(previous, stage, task_type, task)
|
|
7430
9671
|
if violation:
|
|
7431
9672
|
raise StateError(violation)
|
|
@@ -7434,19 +9675,39 @@ def apply_transition(
|
|
|
7434
9675
|
if task.get("workflow_mode_legacy") is not True:
|
|
7435
9676
|
freeze_workflow_mode(root, session, resolved_task_id, task, agent)
|
|
7436
9677
|
freeze_tdd_mode(root, session, resolved_task_id, task, agent)
|
|
9678
|
+
repair_source_task_ids: set[str] | None = None
|
|
9679
|
+
quality_exit_outcome: str | None = None
|
|
9680
|
+
if previous == "QUALITY" and stage in {"IMPLEMENT", "ANALYSIS"}:
|
|
9681
|
+
task, quality_exit_outcome = prepare_quality_exit(
|
|
9682
|
+
root, resolved_task_id, task, stage, agent
|
|
9683
|
+
)
|
|
9684
|
+
if (
|
|
9685
|
+
stage == "IMPLEMENT"
|
|
9686
|
+
and quality_exit_outcome == "repair"
|
|
9687
|
+
and isinstance(task.get("spec_source"), dict)
|
|
9688
|
+
):
|
|
9689
|
+
task, repair_source_task_ids = prepare_canonical_repair_transition(
|
|
9690
|
+
root, resolved_task_id, task, agent
|
|
9691
|
+
)
|
|
7437
9692
|
if stage == "IMPLEMENT" and previous != "IMPLEMENT":
|
|
7438
|
-
if isinstance(task.get("spec_source"), dict)
|
|
9693
|
+
if isinstance(task.get("spec_source"), dict) and (
|
|
9694
|
+
previous != "QUALITY" or quality_exit_outcome == "repair"
|
|
9695
|
+
):
|
|
7439
9696
|
writeback_ready_tasks_for_implement(
|
|
7440
9697
|
root,
|
|
7441
9698
|
resolved_task_id,
|
|
7442
9699
|
task,
|
|
7443
9700
|
agent,
|
|
7444
|
-
{"blocked"} if previous
|
|
9701
|
+
{"blocked"} if previous == "QUALITY" else None,
|
|
9702
|
+
repair_source_task_ids,
|
|
7445
9703
|
)
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
9704
|
+
task = load_task(root, resolved_task_id) or task
|
|
9705
|
+
if previous == "QUALITY" and repair_source_task_ids is not None:
|
|
9706
|
+
validate_canonical_repair_reopened(
|
|
9707
|
+
root, resolved_task_id, task, repair_source_task_ids
|
|
9708
|
+
)
|
|
9709
|
+
if previous == "QUALITY" and stage == "MEMORY":
|
|
9710
|
+
validate_quality_readiness(root, resolved_task_id, task)
|
|
7450
9711
|
if isinstance(task.get("spec_source"), dict):
|
|
7451
9712
|
writeback_verified_tasks(
|
|
7452
9713
|
root, resolved_task_id, task, agent, session_file
|
|
@@ -7459,20 +9720,38 @@ def apply_transition(
|
|
|
7459
9720
|
raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
|
|
7460
9721
|
if isinstance(task.get("spec_source"), dict):
|
|
7461
9722
|
writeback_completed_tasks(root, resolved_task_id, task, agent)
|
|
7462
|
-
if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
|
|
7463
|
-
validate_read_only_completion(root, resolved_task_id)
|
|
7464
9723
|
if previous != stage:
|
|
7465
9724
|
task["status"] = stage
|
|
7466
9725
|
append_stage_history(task, stage, agent)
|
|
7467
|
-
|
|
7468
|
-
task.pop("workflow_mode_legacy", None)
|
|
7469
|
-
if previous in {"IMPLEMENT", "REVIEW"} and stage == "VERIFICATION":
|
|
7470
|
-
task["workflow_mode_legacy_review_bypass_fingerprint"] = (
|
|
7471
|
-
implementation_fingerprint(root, resolved_task_id)
|
|
7472
|
-
)
|
|
9726
|
+
task.pop("workflow_mode_legacy", None)
|
|
7473
9727
|
task.pop("workflow_mode_legacy_direct_edge", None)
|
|
7474
|
-
|
|
7475
|
-
|
|
9728
|
+
task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
|
|
9729
|
+
if (
|
|
9730
|
+
previous == "QUALITY"
|
|
9731
|
+
and stage in {"IMPLEMENT", "ANALYSIS"}
|
|
9732
|
+
and quality_exit_outcome in {"repair", "replan"}
|
|
9733
|
+
):
|
|
9734
|
+
quality_records = validated_quality_records(root, resolved_task_id)
|
|
9735
|
+
task["quality_consumed_attempt"] = quality_records[-1][1]["attempt"]
|
|
9736
|
+
if (
|
|
9737
|
+
previous == "QUALITY"
|
|
9738
|
+
and stage == "IMPLEMENT"
|
|
9739
|
+
and quality_exit_outcome == "repair"
|
|
9740
|
+
and repair_source_task_ids is not None
|
|
9741
|
+
):
|
|
9742
|
+
task.pop("canonical_repair_transition", None)
|
|
9743
|
+
if previous == "QUALITY" and stage in {"IMPLEMENT", "ANALYSIS"}:
|
|
9744
|
+
task.pop("quality_return_required", None)
|
|
9745
|
+
if previous == "QUALITY" and stage == "CLOSED":
|
|
9746
|
+
cancel_active_quality_attempt(
|
|
9747
|
+
root,
|
|
9748
|
+
resolved_task_id,
|
|
9749
|
+
task,
|
|
9750
|
+
agent,
|
|
9751
|
+
"Task closed during QUALITY.",
|
|
9752
|
+
"task-closed",
|
|
9753
|
+
)
|
|
9754
|
+
task = load_task(root, resolved_task_id) or task
|
|
7476
9755
|
if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
|
|
7477
9756
|
cleanup_verification_checkpoint(root, resolved_task_id, task)
|
|
7478
9757
|
task.pop("pending_transition", None)
|
|
@@ -7516,18 +9795,18 @@ def auto_transition(
|
|
|
7516
9795
|
"A different transition is already pending. Cancel it before automatic transition."
|
|
7517
9796
|
)
|
|
7518
9797
|
|
|
7519
|
-
if previous == "
|
|
9798
|
+
if previous == "QUALITY" and stage == "MEMORY":
|
|
7520
9799
|
task = ensure_verification_checkpoint(
|
|
7521
9800
|
root, resolved_task_id, task, agent, session_file
|
|
7522
9801
|
)
|
|
7523
9802
|
drift = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
7524
9803
|
if drift["config_changed"]:
|
|
7525
9804
|
raise StateError(
|
|
7526
|
-
"Behavior config changed after
|
|
9805
|
+
"Behavior config changed after quality checks; rerun QUALITY before MEMORY."
|
|
7527
9806
|
)
|
|
7528
9807
|
if drift["metadata_changed"]:
|
|
7529
9808
|
raise StateError(
|
|
7530
|
-
"
|
|
9809
|
+
"Quality metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7531
9810
|
)
|
|
7532
9811
|
if drift["changed_files"]:
|
|
7533
9812
|
task["pending_transition"] = {
|
|
@@ -7535,7 +9814,7 @@ def auto_transition(
|
|
|
7535
9814
|
"to": stage,
|
|
7536
9815
|
"requested_at": now_iso(),
|
|
7537
9816
|
"requested_by": agent,
|
|
7538
|
-
"reason": "
|
|
9817
|
+
"reason": "quality checkpoint drift requires exact user acceptance",
|
|
7539
9818
|
"confirmation_override": "evidence-drift",
|
|
7540
9819
|
}
|
|
7541
9820
|
task["last_agent"] = agent
|
|
@@ -7590,8 +9869,22 @@ def confirm_transition(
|
|
|
7590
9869
|
f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
|
|
7591
9870
|
"use auto-transition instead."
|
|
7592
9871
|
)
|
|
9872
|
+
if source == "QUALITY" and target == "IMPLEMENT" and "quality_attempt" in pending:
|
|
9873
|
+
repair_intent = task.get("canonical_repair_transition")
|
|
9874
|
+
if (
|
|
9875
|
+
not isinstance(repair_intent, dict)
|
|
9876
|
+
or pending.get("quality_attempt") != repair_intent.get("quality_attempt")
|
|
9877
|
+
or pending.get("implementation_fingerprint")
|
|
9878
|
+
!= repair_intent.get("implementation_fingerprint")
|
|
9879
|
+
or pending.get("config_fingerprint")
|
|
9880
|
+
!= repair_intent.get("config_fingerprint")
|
|
9881
|
+
or pending.get("source_task_ids") != repair_intent.get("source_task_ids")
|
|
9882
|
+
):
|
|
9883
|
+
raise StateError(
|
|
9884
|
+
"Pending Canonical repair transition no longer matches its QUALITY intent."
|
|
9885
|
+
)
|
|
7593
9886
|
|
|
7594
|
-
if source == "
|
|
9887
|
+
if source == "QUALITY" and target == "MEMORY":
|
|
7595
9888
|
task = ensure_verification_checkpoint(
|
|
7596
9889
|
root, resolved_task_id, task, agent, session_file
|
|
7597
9890
|
)
|
|
@@ -7684,7 +9977,7 @@ def memory_short_complete(
|
|
|
7684
9977
|
*(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
|
|
7685
9978
|
]
|
|
7686
9979
|
raise StateError(
|
|
7687
|
-
"Short memory must record the complete accepted post-
|
|
9980
|
+
"Short memory must record the complete accepted post-quality decision; "
|
|
7688
9981
|
"missing: " + ", ".join(missing_labels)
|
|
7689
9982
|
)
|
|
7690
9983
|
progress = task.get("memory_progress")
|
|
@@ -7804,14 +10097,29 @@ def close_current_task(
|
|
|
7804
10097
|
reason: str,
|
|
7805
10098
|
agent: str,
|
|
7806
10099
|
session_file: str | Path | None = None,
|
|
10100
|
+
expected_task_id: str | None = None,
|
|
7807
10101
|
) -> dict:
|
|
7808
10102
|
session = ensure_session(root, session_file)
|
|
7809
10103
|
task_id = session.get("current_task")
|
|
7810
10104
|
if not task_id:
|
|
7811
10105
|
raise StateError("No current task is set.")
|
|
10106
|
+
if expected_task_id is not None and str(task_id) != expected_task_id:
|
|
10107
|
+
raise StateError(
|
|
10108
|
+
"Active task changed after the Lite decision was shown; inspect it again."
|
|
10109
|
+
)
|
|
7812
10110
|
task = load_task(root, str(task_id))
|
|
7813
10111
|
if task is None:
|
|
7814
10112
|
raise StateError(f"Task not found: {task_id}")
|
|
10113
|
+
if task.get("status") == "QUALITY":
|
|
10114
|
+
cancel_active_quality_attempt(
|
|
10115
|
+
root,
|
|
10116
|
+
str(task_id),
|
|
10117
|
+
task,
|
|
10118
|
+
agent,
|
|
10119
|
+
"Task closed during QUALITY.",
|
|
10120
|
+
"task-closed",
|
|
10121
|
+
)
|
|
10122
|
+
task = load_task(root, str(task_id)) or task
|
|
7815
10123
|
if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
|
|
7816
10124
|
cancel_shared_tasks(root, str(task_id), task, reason, agent)
|
|
7817
10125
|
if task.get("status") != "CLOSED":
|
|
@@ -8109,12 +10417,40 @@ def main() -> int:
|
|
|
8109
10417
|
fingerprints_parser.add_argument("--agent", required=True)
|
|
8110
10418
|
fingerprints_parser.add_argument("--task-id")
|
|
8111
10419
|
|
|
10420
|
+
finalize_quality_parser = subcommands.add_parser(
|
|
10421
|
+
"finalize-quality", parents=[common]
|
|
10422
|
+
)
|
|
10423
|
+
finalize_quality_parser.add_argument(
|
|
10424
|
+
"--outcome", required=True, choices=["repair", "replan"]
|
|
10425
|
+
)
|
|
10426
|
+
finalize_quality_parser.add_argument(
|
|
10427
|
+
"--review-gate", required=True, choices=sorted(QUALITY_GATE_STATUSES)
|
|
10428
|
+
)
|
|
10429
|
+
finalize_quality_parser.add_argument(
|
|
10430
|
+
"--verification-gate", required=True, choices=sorted(QUALITY_GATE_STATUSES)
|
|
10431
|
+
)
|
|
10432
|
+
finalize_quality_parser.add_argument(
|
|
10433
|
+
"--failure-class",
|
|
10434
|
+
required=True,
|
|
10435
|
+
action="append",
|
|
10436
|
+
choices=sorted(QUALITY_FAILURE_CLASSES),
|
|
10437
|
+
)
|
|
10438
|
+
finalize_quality_parser.add_argument("--summary", required=True)
|
|
10439
|
+
finalize_quality_parser.add_argument("--agent", required=True)
|
|
10440
|
+
finalize_quality_parser.add_argument("--task-id")
|
|
10441
|
+
|
|
8112
10442
|
verification_checkpoint_parser = subcommands.add_parser(
|
|
8113
10443
|
"verification-checkpoint", parents=[common]
|
|
8114
10444
|
)
|
|
8115
10445
|
verification_checkpoint_parser.add_argument("--agent", required=True)
|
|
8116
10446
|
verification_checkpoint_parser.add_argument("--task-id")
|
|
8117
10447
|
|
|
10448
|
+
quality_checkpoint_parser = subcommands.add_parser(
|
|
10449
|
+
"quality-checkpoint", parents=[common]
|
|
10450
|
+
)
|
|
10451
|
+
quality_checkpoint_parser.add_argument("--agent", required=True)
|
|
10452
|
+
quality_checkpoint_parser.add_argument("--task-id")
|
|
10453
|
+
|
|
8118
10454
|
inspect_transition_drift_parser = subcommands.add_parser(
|
|
8119
10455
|
"inspect-transition-drift", parents=[common]
|
|
8120
10456
|
)
|
|
@@ -8127,6 +10463,33 @@ def main() -> int:
|
|
|
8127
10463
|
enable_harness_parser = subcommands.add_parser("enable-harness", parents=[common])
|
|
8128
10464
|
enable_harness_parser.add_argument("--agent", required=True)
|
|
8129
10465
|
|
|
10466
|
+
enable_lite_parser = subcommands.add_parser("enable-lite", parents=[common])
|
|
10467
|
+
enable_lite_parser.add_argument(
|
|
10468
|
+
"--active-task-policy", choices=["cancel", "close", "ignore"]
|
|
10469
|
+
)
|
|
10470
|
+
enable_lite_parser.add_argument("--expected-task-id")
|
|
10471
|
+
enable_lite_parser.add_argument("--agent", required=True)
|
|
10472
|
+
|
|
10473
|
+
disable_lite_parser = subcommands.add_parser("disable-lite", parents=[common])
|
|
10474
|
+
disable_lite_parser.add_argument("--agent", required=True)
|
|
10475
|
+
|
|
10476
|
+
lite_proposal_parser = subcommands.add_parser("set-lite-proposal", parents=[common])
|
|
10477
|
+
lite_proposal_parser.add_argument("--summary", required=True)
|
|
10478
|
+
lite_proposal_parser.add_argument("--target-file", action="append", default=[])
|
|
10479
|
+
lite_proposal_parser.add_argument("--agent", required=True)
|
|
10480
|
+
|
|
10481
|
+
confirm_lite_parser = subcommands.add_parser(
|
|
10482
|
+
"confirm-lite-proposal", parents=[common]
|
|
10483
|
+
)
|
|
10484
|
+
confirm_lite_parser.add_argument("--digest", required=True)
|
|
10485
|
+
confirm_lite_parser.add_argument("--agent", required=True)
|
|
10486
|
+
|
|
10487
|
+
complete_lite_parser = subcommands.add_parser(
|
|
10488
|
+
"complete-lite-proposal", parents=[common]
|
|
10489
|
+
)
|
|
10490
|
+
complete_lite_parser.add_argument("--digest", required=True)
|
|
10491
|
+
complete_lite_parser.add_argument("--agent", required=True)
|
|
10492
|
+
|
|
8130
10493
|
handoff = subcommands.add_parser("handoff-task", parents=[common])
|
|
8131
10494
|
handoff.add_argument("--agent", required=True)
|
|
8132
10495
|
handoff.add_argument("--summary", required=True)
|
|
@@ -8222,6 +10585,7 @@ def main() -> int:
|
|
|
8222
10585
|
satisfy_dependency.add_argument("--task-id")
|
|
8223
10586
|
|
|
8224
10587
|
args = parser.parse_args()
|
|
10588
|
+
command_lock: Path | None = None
|
|
8225
10589
|
try:
|
|
8226
10590
|
root = resolve_root(getattr(args, "cwd", None))
|
|
8227
10591
|
session_file = getattr(args, "session_file", None)
|
|
@@ -8246,6 +10610,10 @@ def main() -> int:
|
|
|
8246
10610
|
"Cannot resolve the logical session. Pass --session-file or --agent."
|
|
8247
10611
|
)
|
|
8248
10612
|
_, session_file = ensure_hook_session(root, {}, session_agent)
|
|
10613
|
+
if session_file is not None:
|
|
10614
|
+
command_lock = acquire_session_command_lock(
|
|
10615
|
+
root, resolve_session_path(root, session_file)
|
|
10616
|
+
)
|
|
8249
10617
|
if command == "snapshot":
|
|
8250
10618
|
emit(snapshot_state(root, session_file))
|
|
8251
10619
|
elif command == "inspect-dev-spec":
|
|
@@ -8560,21 +10928,73 @@ def main() -> int:
|
|
|
8560
10928
|
)
|
|
8561
10929
|
)
|
|
8562
10930
|
elif command == "evidence-fingerprints":
|
|
8563
|
-
session, resolved_task_id,
|
|
10931
|
+
session, resolved_task_id, task = resolve_current_task(
|
|
8564
10932
|
root, args.task_id, session_file
|
|
8565
10933
|
)
|
|
10934
|
+
fingerprints = evidence_fingerprints(root, resolved_task_id)
|
|
10935
|
+
quality_attempt = None
|
|
10936
|
+
checkpoint = task.get("quality_checkpoint")
|
|
10937
|
+
checkpoint_config_changed = (
|
|
10938
|
+
isinstance(checkpoint, dict)
|
|
10939
|
+
and checkpoint.get("config_fingerprint")
|
|
10940
|
+
!= fingerprints["config_fingerprint"]
|
|
10941
|
+
)
|
|
10942
|
+
if task.get("status") == "QUALITY" and checkpoint_config_changed:
|
|
10943
|
+
cleanup_verification_checkpoint(root, resolved_task_id, task)
|
|
10944
|
+
task["last_agent"] = agent
|
|
10945
|
+
write_task(root, resolved_task_id, task)
|
|
10946
|
+
task = load_task(root, resolved_task_id) or task
|
|
10947
|
+
checkpoint = None
|
|
10948
|
+
accepted_candidate_drift = (
|
|
10949
|
+
isinstance(checkpoint, dict)
|
|
10950
|
+
and checkpoint.get("implementation_fingerprint")
|
|
10951
|
+
!= fingerprints["implementation_fingerprint"]
|
|
10952
|
+
)
|
|
10953
|
+
if task.get("status") == "QUALITY" and not accepted_candidate_drift:
|
|
10954
|
+
quality_attempt = ensure_quality_attempt_context(
|
|
10955
|
+
root,
|
|
10956
|
+
resolved_task_id,
|
|
10957
|
+
task,
|
|
10958
|
+
agent,
|
|
10959
|
+
persist=True,
|
|
10960
|
+
infer_existing_evidence=True,
|
|
10961
|
+
)
|
|
8566
10962
|
emit(
|
|
8567
10963
|
attach_status_context(
|
|
8568
10964
|
root,
|
|
8569
10965
|
{
|
|
8570
10966
|
"task_id": resolved_task_id,
|
|
8571
|
-
**
|
|
10967
|
+
**fingerprints,
|
|
10968
|
+
**(
|
|
10969
|
+
{"quality_attempt": quality_attempt}
|
|
10970
|
+
if quality_attempt is not None
|
|
10971
|
+
else {}
|
|
10972
|
+
),
|
|
8572
10973
|
},
|
|
8573
10974
|
visible_agent,
|
|
8574
10975
|
session_file,
|
|
8575
10976
|
)
|
|
8576
10977
|
)
|
|
8577
|
-
elif command == "
|
|
10978
|
+
elif command == "finalize-quality":
|
|
10979
|
+
emit(
|
|
10980
|
+
attach_status_context(
|
|
10981
|
+
root,
|
|
10982
|
+
finalize_quality_decision(
|
|
10983
|
+
root,
|
|
10984
|
+
args.outcome,
|
|
10985
|
+
args.review_gate,
|
|
10986
|
+
args.verification_gate,
|
|
10987
|
+
args.failure_class,
|
|
10988
|
+
args.summary,
|
|
10989
|
+
agent,
|
|
10990
|
+
args.task_id,
|
|
10991
|
+
session_file,
|
|
10992
|
+
),
|
|
10993
|
+
agent,
|
|
10994
|
+
session_file,
|
|
10995
|
+
)
|
|
10996
|
+
)
|
|
10997
|
+
elif command in {"quality-checkpoint", "verification-checkpoint"}:
|
|
8578
10998
|
emit(
|
|
8579
10999
|
attach_status_context(
|
|
8580
11000
|
root,
|
|
@@ -8614,6 +11034,59 @@ def main() -> int:
|
|
|
8614
11034
|
session_file,
|
|
8615
11035
|
)
|
|
8616
11036
|
)
|
|
11037
|
+
elif command == "enable-lite":
|
|
11038
|
+
emit(
|
|
11039
|
+
attach_status_context(
|
|
11040
|
+
root,
|
|
11041
|
+
enable_lite_mode(
|
|
11042
|
+
root,
|
|
11043
|
+
agent,
|
|
11044
|
+
args.active_task_policy,
|
|
11045
|
+
args.expected_task_id,
|
|
11046
|
+
session_file,
|
|
11047
|
+
),
|
|
11048
|
+
agent,
|
|
11049
|
+
session_file,
|
|
11050
|
+
)
|
|
11051
|
+
)
|
|
11052
|
+
elif command == "disable-lite":
|
|
11053
|
+
emit(
|
|
11054
|
+
attach_status_context(
|
|
11055
|
+
root,
|
|
11056
|
+
disable_lite_mode(root, agent, session_file),
|
|
11057
|
+
agent,
|
|
11058
|
+
session_file,
|
|
11059
|
+
)
|
|
11060
|
+
)
|
|
11061
|
+
elif command == "set-lite-proposal":
|
|
11062
|
+
emit(
|
|
11063
|
+
attach_status_context(
|
|
11064
|
+
root,
|
|
11065
|
+
set_lite_proposal(
|
|
11066
|
+
root, args.summary, args.target_file, agent, session_file
|
|
11067
|
+
),
|
|
11068
|
+
agent,
|
|
11069
|
+
session_file,
|
|
11070
|
+
)
|
|
11071
|
+
)
|
|
11072
|
+
elif command == "confirm-lite-proposal":
|
|
11073
|
+
emit(
|
|
11074
|
+
attach_status_context(
|
|
11075
|
+
root,
|
|
11076
|
+
confirm_lite_proposal(root, args.digest, agent, session_file),
|
|
11077
|
+
agent,
|
|
11078
|
+
session_file,
|
|
11079
|
+
)
|
|
11080
|
+
)
|
|
11081
|
+
elif command == "complete-lite-proposal":
|
|
11082
|
+
emit(
|
|
11083
|
+
attach_status_context(
|
|
11084
|
+
root,
|
|
11085
|
+
complete_lite_proposal(root, args.digest, agent, session_file),
|
|
11086
|
+
agent,
|
|
11087
|
+
session_file,
|
|
11088
|
+
)
|
|
11089
|
+
)
|
|
8617
11090
|
elif command == "handoff-task":
|
|
8618
11091
|
emit(
|
|
8619
11092
|
attach_status_context(
|
|
@@ -8791,6 +11264,8 @@ def main() -> int:
|
|
|
8791
11264
|
except (StateError, EasyDevSpecError) as error:
|
|
8792
11265
|
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
8793
11266
|
return 1
|
|
11267
|
+
finally:
|
|
11268
|
+
release_session_command_lock(command_lock)
|
|
8794
11269
|
|
|
8795
11270
|
|
|
8796
11271
|
if __name__ == "__main__":
|