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.
Files changed (34) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +34 -26
  3. package/dist/cli.js +272 -37
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/claude/agents/ec-implementer.md +7 -8
  7. package/templates/claude/agents/ec-reviewer.md +14 -2
  8. package/templates/claude/agents/ec-verifier.md +11 -2
  9. package/templates/codex/agents/ec-implementer.toml +7 -8
  10. package/templates/codex/agents/ec-reviewer.toml +14 -2
  11. package/templates/codex/agents/ec-verifier.toml +11 -2
  12. package/templates/common/bundled-skills/ec-init/SKILL.md +1 -1
  13. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +15 -11
  14. package/templates/common/skills/ec-analysis/SKILL.md +24 -13
  15. package/templates/common/skills/ec-config/SKILL.md +2 -2
  16. package/templates/common/skills/ec-implementing/SKILL.md +35 -30
  17. package/templates/common/skills/ec-lite/SKILL.md +74 -0
  18. package/templates/common/skills/ec-no-harness/SKILL.md +3 -0
  19. package/templates/common/skills/ec-quality/SKILL.md +153 -0
  20. package/templates/common/skills/ec-task-management/SKILL.md +9 -5
  21. package/templates/common/skills/ec-tdd-init/SKILL.md +5 -4
  22. package/templates/common/skills/ec-workflow/SKILL.md +32 -30
  23. package/templates/main-constraint/AGENTS.md.tpl +31 -19
  24. package/templates/main-constraint/CLAUDE.md.tpl +31 -19
  25. package/templates/qoder/agents/ec-implementer.md +7 -8
  26. package/templates/qoder/agents/ec-reviewer.md +14 -2
  27. package/templates/qoder/agents/ec-verifier.md +11 -2
  28. package/templates/runtime/templates/dev-spec-skeleton.md +2 -2
  29. package/templates/shared-hooks/easy_coding_state.py +2793 -318
  30. package/templates/claude/agents/ec-fixer.md +0 -37
  31. package/templates/codex/agents/ec-fixer.toml +0 -26
  32. package/templates/common/skills/ec-reviewing/SKILL.md +0 -109
  33. package/templates/common/skills/ec-verification/SKILL.md +0 -177
  34. 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
- # IMPLEMENT -> VERIFICATION remains parseable only for pre-0.9 in-flight tasks.
71
- "IMPLEMENT": {"REVIEW", "VERIFICATION", "ANALYSIS", "COMPLETE", "CLOSED"},
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
- ("VERIFICATION", "MEMORY"),
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
- SESSION_STALE_THRESHOLD_HOURS = 30 * 24
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": str(task.get("last_agent") or "legacy-migration"),
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
- return load_json(resolve_session_path(root, session_file))
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
- clean_stale_sessions(root)
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 = SESSION_STALE_THRESHOLD_HOURS,
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
- cleaned = 0
1571
- # 逻辑会话不对应独立进程,仅清理长期空闲且没有当前任务的 session。
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
- session = json.loads(entry.read_text(encoding="utf-8"))
1577
- if session.get("current_task"):
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
- activity_value = session.get("last_active_at") or session.get("created_at") or ""
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 (OSError, json.JSONDecodeError, ValueError, TypeError):
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 acceptance_snapshot_path(root: Path, task_id: str) -> Path:
2841
- assert_safe_task_id(task_id)
2842
- return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
2843
-
2844
-
2845
- def canonical_json_sha256(value: object) -> str:
2846
- payload = json.dumps(
2847
- value,
2848
- ensure_ascii=False,
2849
- sort_keys=True,
2850
- separators=(",", ":"),
2851
- ).encode("utf-8")
2852
- return hashlib.sha256(payload).hexdigest()
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 verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
2856
- plan = latest_execution_plan(root, task_id)
2857
- if plan is None:
2858
- raise StateError("Cannot fingerprint verification contract without a valid plan.")
2859
- source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
2860
- contract = {
2861
- "workflow_mode": task.get("workflow_mode"),
2862
- "tdd_enabled": task.get("tdd_enabled"),
2863
- "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
2864
- "tdd_baselines": task.get("tdd_baselines"),
2865
- "plan": plan,
2866
- "canonical": {
2867
- "schema": source.get("schema"),
2868
- "spec_id": source.get("spec_id"),
2869
- "revision": source.get("revision"),
2870
- "design_sha256": source.get("design_sha256"),
2871
- "selected_tasks": task.get("selected_spec_tasks"),
2872
- "repository_bindings": task.get("spec_repositories"),
2873
- "repo_paths": task.get("repo_paths"),
2874
- }
2875
- if source
2876
- else None,
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 acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
2882
- pathspecs = repository_scope_pathspecs(repository, scopes)
2883
- index_entries = git_index_entries(repository, pathspecs)
2884
- listed = run_git(
2885
- repository,
2886
- "ls-files",
2887
- "--cached",
2888
- "--others",
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
- if listed is None or listed.returncode != 0 or modified is None or modified.returncode != 0:
2904
- raise StateError(f"Cannot capture verification snapshot for {repository}.")
2905
- modified_paths = set(filter(None, modified.stdout.split(b"\0")))
2906
- raw_paths = set(filter(None, listed.stdout.split(b"\0"))) | set(index_entries)
2907
- entries: list[dict] = []
2908
- for raw_path in sorted(raw_paths):
2909
- relative_name = os.fsdecode(raw_path)
2910
- if is_easy_coding_state_path(repository, relative_name, scopes):
2911
- continue
2912
- candidate = repository / relative_name
2913
- index_entry = index_entries.get(raw_path)
2914
- if index_entry is not None and index_entry[0] == b"160000":
2915
- entries.append(
2916
- {
2917
- "path": relative_name,
2918
- "exists": True,
2919
- "mode": "160000",
2920
- "git_oid": index_entry[1].decode("ascii", errors="replace"),
2921
- "sha256": hashlib.sha256(index_entry[1]).hexdigest(),
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
- continue
2925
- exists = candidate.exists() or candidate.is_symlink()
2926
- if not exists:
2927
- entries.append(
2928
- {
2929
- "path": relative_name,
2930
- "exists": False,
2931
- "mode": None,
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("verification_checkpoint")
4265
+ checkpoint = task.get("quality_checkpoint")
3064
4266
  if not isinstance(checkpoint, dict):
3065
- raise StateError("VERIFICATION has no frozen acceptance checkpoint.")
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("Verification checkpoint has no snapshot file.")
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("Verification checkpoint snapshot escapes .easy-coding/sessions.")
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("Verification checkpoint snapshot is missing or invalid.")
4277
+ raise StateError("Quality checkpoint snapshot is missing or invalid.")
3076
4278
  if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
3077
- raise StateError("Verification checkpoint snapshot fingerprint changed.")
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("Verification checkpoint metadata does not match its snapshot.")
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("Verification checkpoint contains invalid file content.") from exc
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 verification checkpoint Git object: {object_id}")
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("verification_checkpoint")
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("verification_checkpoint", None)
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") != "VERIFICATION":
3272
- raise StateError("Verification checkpoint can only be recorded during VERIFICATION.")
3273
- if isinstance(task.get("verification_checkpoint"), dict):
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"] = "verification-checkpoint"
3277
- result["verification_checkpoint"] = task["verification_checkpoint"]
4480
+ result["action"] = "quality-checkpoint"
4481
+ result["quality_checkpoint"] = task["quality_checkpoint"]
3278
4482
  result["checkpoint_unchanged"] = True
3279
4483
  return result
3280
- validate_verification_readiness(root, resolved_task_id, task)
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["verification_checkpoint"] = {
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"] = "verification-checkpoint"
3298
- result["verification_checkpoint"] = task["verification_checkpoint"]
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("verification_checkpoint"), dict):
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 verification checkpoint: {task_id}")
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") != "VERIFICATION":
3350
- raise StateError("Transition drift can only be inspected during VERIFICATION.")
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 verification; rerun verification before MEMORY."
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 verification; return to ANALYSIS or IMPLEMENT instead of accepting it as a code diff."
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
- "Verified code changed after the acceptance checkpoint. Inspect the exact drift "
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(root: Path, task_id: str, task: dict) -> None:
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 = accepted_review_fingerprints(root, task_id, task, expected)
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 execution_records(root, task_id):
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
- "REVIEW cannot advance to VERIFICATION without a review record for the current implementation fingerprint."
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
- "REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
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(root: Path, task_id: str, task: dict) -> None:
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
- accepted_fingerprints, acceptance = accepted_verification_fingerprints(
3789
- root,
3790
- task_id,
3791
- task,
3792
- fingerprints["implementation_fingerprint"],
3793
- fingerprints["config_fingerprint"],
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 execution_records(root, task_id):
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
- "VERIFICATION cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
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
- "VERIFICATION cannot advance to MEMORY without at least one applicable executed check."
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
- "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
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
- "VERIFICATION cannot advance to MEMORY while Canonical Spec integration "
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 validate_read_only_completion(root: Path, task_id: str) -> None:
4148
- task = load_task(root, task_id)
4149
- task_type = str(task.get("type") or "").strip().lower() if task else ""
4150
- reasons: list[str] = []
4151
- if task_type not in NO_CODE_TASK_TYPES:
4152
- reasons.append("task type is not doc, analysis, or report")
4153
-
4154
- path = execution_log_path(root, task_id)
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
- try:
4160
- for line in path.read_text(encoding="utf-8").splitlines():
4161
- if not line.strip():
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
- record = json.loads(line)
4164
- if not isinstance(record, dict):
4165
- reasons.append("execution.jsonl contains a non-object record")
4166
- break
4167
- records.append(record)
4168
- except (OSError, json.JSONDecodeError):
4169
- reasons.append("execution.jsonl cannot be read as valid JSONL")
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
- latest_plan_index: int | None = None
4172
- for index, record in enumerate(records):
4173
- if record.get("type") == "plan":
4174
- latest_plan_index = index
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
- unit_id = ""
4177
- if latest_plan_index is None:
4178
- reasons.append("execution.jsonl has no plan record")
4179
- else:
4180
- plan = records[latest_plan_index]
4181
- if not is_read_only_execution_plan(plan):
4182
- reasons.append("latest plan record is invalid")
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
- units = plan["units"]
4185
- unit_id = str(units[0]["id"])
4186
-
4187
- unit_records: list[dict] = []
4188
- if latest_plan_index is not None and unit_id:
4189
- for record in records[latest_plan_index + 1 :]:
4190
- if record.get("unit_id") == unit_id and record.get("type") in {"dispatch", "result"}:
4191
- unit_records.append(record)
4192
- latest_result = (
4193
- unit_records[-1]
4194
- if unit_records and unit_records[-1].get("type") == "result"
4195
- else None
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
- if latest_result is None:
4198
- reasons.append("latest read-only unit has no result record")
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
- matching_dispatch = unit_records[-2] if len(unit_records) >= 2 else None
4201
- if matching_dispatch is None or matching_dispatch.get("type") != "dispatch":
4202
- reasons.append("latest read-only result has no matching dispatch record")
4203
- elif not is_non_empty_string(matching_dispatch.get("timestamp")):
4204
- reasons.append("latest read-only dispatch record has no timestamp")
4205
- if latest_result.get("changed_files") != []:
4206
- reasons.append("read-only result must contain changed_files:[]")
4207
- if not is_non_empty_string(latest_result.get("deliverable")):
4208
- reasons.append("read-only result must contain a non-empty deliverable")
4209
- if latest_result.get("issues") != []:
4210
- reasons.append("read-only result must contain issues:[]")
4211
- if latest_result.get("needs_attention") != []:
4212
- reasons.append("read-only result must contain needs_attention:[]")
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
- if reasons:
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
- "Read-only IMPLEMENT cannot complete before its report is ready: " + "; ".join(reasons)
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 and not is_read_only_task:
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
- elif not is_read_only_task:
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
- if is_read_only_task:
4732
- if test_strategy.exists():
4733
- reasons.append("read-only task must not create test-strategy.md")
4734
- else:
4735
- try:
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" and normalized_task_type in NO_CODE_TASK_TYPES:
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
- legacy_review_bypass = (
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 set_current:
5531
- resolve_session_path(root, session_file)
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) >= 4 or len(files) >= 10:
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) > 1:
9426
+ if len(units) >= 4:
7208
9427
  standard_reasons.append("multiple-units")
7209
- if len(files) > 5:
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 == "VERIFICATION":
9526
+ if stage == "QUALITY":
7310
9527
  raise StateError(
7311
- "Return to IMPLEMENT before raising workflow mode from VERIFICATION so the "
7312
- "task can re-enter REVIEW with fresh evidence."
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 not in {"IMPLEMENT", "REVIEW"}:
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 == "REVIEW" and stage == "VERIFICATION":
7373
- validate_review_readiness(root, resolved_task_id, task)
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 == "VERIFICATION" and stage == "MEMORY":
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 verification; rerun verification before MEMORY."
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
- "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
9612
+ "Quality metadata changed; return to ANALYSIS or IMPLEMENT."
7387
9613
  )
7388
9614
  if acceptance_drift["status"] == "clean":
7389
- validate_verification_readiness(root, resolved_task_id, task)
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 in {"REVIEW", "VERIFICATION"} else None,
9701
+ {"blocked"} if previous == "QUALITY" else None,
9702
+ repair_source_task_ids,
7445
9703
  )
7446
- if previous == "REVIEW" and stage == "VERIFICATION":
7447
- validate_review_readiness(root, resolved_task_id, task)
7448
- if previous == "VERIFICATION" and stage == "MEMORY":
7449
- validate_verification_readiness(root, resolved_task_id, task)
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
- if legacy_edge:
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
- if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
7475
- task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
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 == "VERIFICATION" and stage == "MEMORY":
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 verification; rerun verification before MEMORY."
9805
+ "Behavior config changed after quality checks; rerun QUALITY before MEMORY."
7527
9806
  )
7528
9807
  if drift["metadata_changed"]:
7529
9808
  raise StateError(
7530
- "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
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": "verification checkpoint drift requires exact user acceptance",
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 == "VERIFICATION" and target == "MEMORY":
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-verification decision; "
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, _ = resolve_current_task(
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
- **evidence_fingerprints(root, resolved_task_id),
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 == "verification-checkpoint":
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__":