easy-coding-harness 0.10.0-beta.8 → 1.0.0-beta.0

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 +72 -1
  2. package/README.md +32 -25
  3. package/dist/cli.js +364 -39
  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 +9 -8
  15. package/templates/common/skills/ec-config/SKILL.md +2 -2
  16. package/templates/common/skills/ec-implementing/SKILL.md +38 -31
  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 +25 -29
  23. package/templates/main-constraint/AGENTS.md.tpl +27 -15
  24. package/templates/main-constraint/CLAUDE.md.tpl +24 -15
  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 +2961 -372
  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,13 +155,28 @@ 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._-]+$")
162
+ WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
163
+ # 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
164
+ INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
148
165
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
149
166
  CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
167
+ LEGACY_DISPLAY_AGENT_IDENTITIES = {
168
+ "claude with easy coding": "claude-code",
169
+ "claude-code with easy coding": "claude-code",
170
+ "claude code with easy coding": "claude-code",
171
+ "codex with easy coding": "codex",
172
+ "qoder with easy coding": "qoder",
173
+ }
150
174
  LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
151
175
  LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
152
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
153
180
  SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
154
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}$"
155
182
  )
@@ -226,14 +253,25 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
226
253
  return (2, memory_id)
227
254
 
228
255
 
229
- def normalize_agent_identity(agent: str | None) -> str:
256
+ def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
230
257
  raw_agent = str(agent or "unknown").strip()
231
258
  normalized = raw_agent.lower()
232
259
  # Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
233
260
  if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
234
261
  return "codex"
235
- if normalized in SESSION_AGENT_NAMESPACES:
262
+ if normalized in WORKFLOW_AGENT_IDENTITIES:
236
263
  return normalized
264
+ if allow_legacy_display:
265
+ return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
266
+ return None
267
+
268
+
269
+ def normalize_agent_identity(agent: str | None) -> str:
270
+ raw_agent = str(agent or "unknown").strip()
271
+ # 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
272
+ canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
273
+ if canonical is not None:
274
+ return canonical
237
275
  return raw_agent
238
276
 
239
277
 
@@ -247,6 +285,9 @@ def agents_equivalent(first: str | None, second: str | None) -> bool:
247
285
 
248
286
 
249
287
  def detect_runtime_agent() -> str:
288
+ if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
289
+ return INSTALLED_WORKFLOW_AGENT
290
+ # 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
250
291
  script_path = Path(sys.argv[0]).as_posix()
251
292
  if ".qoder/" in script_path or ".qodercn/" in script_path:
252
293
  return "qoder"
@@ -262,6 +303,45 @@ def detect_runtime_agent() -> str:
262
303
  return "unknown"
263
304
 
264
305
 
306
+ def resolve_state_agent(explicit_agent: str | None) -> str:
307
+ runtime_agent = detect_runtime_agent()
308
+ explicit_identity = None
309
+ if explicit_agent is not None:
310
+ explicit_identity = canonical_agent_identity(explicit_agent)
311
+ if explicit_identity is None:
312
+ raise StateError(
313
+ "Workflow --agent must be one of claude-code, codex, or qoder; "
314
+ "display attribution such as 'Codex with Easy Coding' is not an agent identity."
315
+ )
316
+ if runtime_agent in WORKFLOW_AGENT_IDENTITIES:
317
+ if explicit_identity is not None and explicit_identity != runtime_agent:
318
+ raise StateError(
319
+ f"Workflow agent mismatch: script belongs to {runtime_agent}, "
320
+ f"but --agent resolved to {explicit_identity}. Use the active platform's state script."
321
+ )
322
+ return runtime_agent
323
+ return explicit_identity or "unknown"
324
+
325
+
326
+ def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
327
+ if session_file is None or agent not in WORKFLOW_AGENT_IDENTITIES:
328
+ return
329
+ session_name = Path(str(session_file)).name
330
+ session_agent = next(
331
+ (
332
+ candidate
333
+ for candidate in WORKFLOW_AGENT_IDENTITIES
334
+ if session_name.startswith(f"{candidate}-")
335
+ ),
336
+ None,
337
+ )
338
+ if session_agent is not None and session_agent != agent:
339
+ raise StateError(
340
+ f"Workflow session mismatch: session belongs to {session_agent}, "
341
+ f"but the state operation resolved to {agent}. Use the active session's state script."
342
+ )
343
+
344
+
265
345
  def normalize_session_component(value: str) -> str:
266
346
  if (
267
347
  value not in {".", ".."}
@@ -1183,14 +1263,42 @@ def normalize_legacy_stage(stage: object) -> object:
1183
1263
 
1184
1264
 
1185
1265
  def normalize_legacy_task(task: dict) -> bool:
1186
- """Normalize pre-0.6 stage names without touching task artifacts outside task.json."""
1266
+ """Normalize legacy task state without touching artifacts outside task.json."""
1187
1267
  legacy_status = str(task.get("status") or "")
1188
1268
  changed = False
1189
1269
 
1270
+ for field in ("created_by", "last_agent"):
1271
+ normalized_agent = canonical_agent_identity(
1272
+ task.get(field), allow_legacy_display=True
1273
+ )
1274
+ if normalized_agent is not None and normalized_agent != task.get(field):
1275
+ task[field] = normalized_agent
1276
+ changed = True
1277
+
1190
1278
  if legacy_status in LEGACY_STAGE_MAP:
1191
1279
  task["status"] = LEGACY_STAGE_MAP[legacy_status]
1192
1280
  changed = True
1193
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
+
1194
1302
  history = task.get("stage_history")
1195
1303
  if isinstance(history, list):
1196
1304
  normalized_history: list[dict] = []
@@ -1202,6 +1310,12 @@ def normalize_legacy_task(task: dict) -> bool:
1202
1310
  if mapped_stage != entry.get("stage"):
1203
1311
  entry["stage"] = mapped_stage
1204
1312
  changed = True
1313
+ normalized_agent = canonical_agent_identity(
1314
+ entry.get("agent"), allow_legacy_display=True
1315
+ )
1316
+ if normalized_agent is not None and normalized_agent != entry.get("agent"):
1317
+ entry["agent"] = normalized_agent
1318
+ changed = True
1205
1319
  if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
1206
1320
  changed = True
1207
1321
  continue
@@ -1210,11 +1324,14 @@ def normalize_legacy_task(task: dict) -> bool:
1210
1324
  task["stage_history"] = normalized_history
1211
1325
 
1212
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"
1213
1330
  task["pending_transition"] = {
1214
1331
  "from": "ANALYSIS",
1215
1332
  "to": "IMPLEMENT",
1216
1333
  "requested_at": now_iso(),
1217
- "requested_by": str(task.get("last_agent") or "legacy-migration"),
1334
+ "requested_by": requested_by,
1218
1335
  "reason": "migrated-from-WAITING_CONFIRM",
1219
1336
  }
1220
1337
  changed = True
@@ -1264,6 +1381,44 @@ def write_json(path: Path, data: dict) -> None:
1264
1381
  temporary_path.unlink()
1265
1382
 
1266
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
+
1267
1422
  def acquire_legacy_state_lock(root: Path) -> Path | None:
1268
1423
  state_path = root / ".easy-coding" / "state.json"
1269
1424
  lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
@@ -1316,7 +1471,12 @@ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
1316
1471
  if "stage_history" not in task or not task["stage_history"]:
1317
1472
  task["stage_history"] = old_state.get("stage_history", [])
1318
1473
  if "last_agent" not in task or not task["last_agent"]:
1319
- task["last_agent"] = old_state.get("last_agent", agent)
1474
+ task["last_agent"] = (
1475
+ canonical_agent_identity(
1476
+ old_state.get("last_agent"), allow_legacy_display=True
1477
+ )
1478
+ or agent
1479
+ )
1320
1480
  if old_state.get("confirmed_by_user"):
1321
1481
  task["confirmed_by_user"] = True
1322
1482
  if old_state.get("test_strategy_confirmed"):
@@ -1392,7 +1552,8 @@ def clear_session_pointer(session: dict, agent: str | None = None) -> None:
1392
1552
 
1393
1553
 
1394
1554
  def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
1395
- 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
1396
1557
 
1397
1558
 
1398
1559
  def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
@@ -1441,6 +1602,20 @@ def ensure_hook_session(
1441
1602
  payload: dict,
1442
1603
  agent: str | None,
1443
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,
1444
1619
  ) -> tuple[dict, Path]:
1445
1620
  identity = hook_session_identity(payload, agent, ppid)
1446
1621
  session_path = resolve_hook_session_path(root, payload, agent, ppid)
@@ -1455,7 +1630,7 @@ def ensure_hook_session(
1455
1630
  )
1456
1631
 
1457
1632
  if session is None:
1458
- clean_stale_sessions(root)
1633
+ clean_session_runtime(root, reserve_slots=1)
1459
1634
  session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
1460
1635
  if session is None:
1461
1636
  session = load_session(root, session_path)
@@ -1478,36 +1653,128 @@ def ensure_hook_session(
1478
1653
 
1479
1654
  def clean_stale_sessions(
1480
1655
  root: Path,
1481
- 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,
1482
1661
  ) -> int:
1483
1662
  sessions_dir = root / ".easy-coding" / "sessions"
1484
1663
  if not sessions_dir.is_dir():
1485
1664
  return 0
1486
1665
 
1487
1666
  now = datetime.now(timezone.utc)
1488
- cleaned = 0
1489
- # 逻辑会话不对应独立进程,仅清理长期空闲且没有当前任务的 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]] = []
1490
1671
  for entry in sessions_dir.iterdir():
1491
- if entry.suffix != ".json":
1672
+ if not entry.is_file() or entry.suffix != ".json":
1492
1673
  continue
1493
1674
  try:
1494
- session = json.loads(entry.read_text(encoding="utf-8"))
1495
- 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):
1496
1743
  continue
1497
- activity_value = session.get("last_active_at") or session.get("created_at") or ""
1498
- last_active = datetime.fromisoformat(str(activity_value))
1499
- if last_active.tzinfo is None:
1500
- last_active = last_active.replace(tzinfo=timezone.utc)
1501
- age_hours = (now - last_active).total_seconds() / 3600
1502
- if age_hours <= threshold_hours:
1744
+ if not isinstance(task, dict):
1503
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:
1504
1763
  entry.unlink()
1505
1764
  cleaned += 1
1506
- except (OSError, json.JSONDecodeError, ValueError, TypeError):
1765
+ except OSError:
1766
+ # 验收快照清理失败不能阻断新逻辑会话启动。
1507
1767
  continue
1508
1768
  return cleaned
1509
1769
 
1510
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
+
1511
1778
  def task_json_path(root: Path, task_id: str) -> Path:
1512
1779
  assert_safe_task_id(task_id)
1513
1780
  return root / ".easy-coding" / "tasks" / task_id / "task.json"
@@ -1563,6 +1830,76 @@ def is_valid_review_finding(value: object) -> bool:
1563
1830
  )
1564
1831
 
1565
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
+
1566
1903
  def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
1567
1904
  remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
1568
1905
  resolved: set[str] = set()
@@ -1667,16 +2004,6 @@ def is_valid_execution_plan(
1667
2004
  return True
1668
2005
 
1669
2006
 
1670
- def is_read_only_execution_plan(plan: object) -> bool:
1671
- return (
1672
- is_valid_execution_plan(plan, allow_empty_files=True)
1673
- and isinstance(plan, dict)
1674
- and plan.get("strategy") == "single"
1675
- and len(plan["units"]) == 1
1676
- and plan["units"][0].get("files") == []
1677
- )
1678
-
1679
-
1680
2007
  def stored_spec_path(root: Path, task: dict) -> Path:
1681
2008
  source = task.get("spec_source")
1682
2009
  if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
@@ -2112,9 +2439,6 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
2112
2439
  except OSError:
2113
2440
  return False
2114
2441
  task = load_task(root, task_id)
2115
- task_type = str(task.get("type") or "").strip().lower() if task else ""
2116
- if task_type in NO_CODE_TASK_TYPES:
2117
- return is_read_only_execution_plan(latest_plan)
2118
2442
  valid = is_valid_execution_plan(
2119
2443
  latest_plan,
2120
2444
  require_unit_contracts=read_project_schema_version(root) >= 3,
@@ -2699,6 +3023,77 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
2699
3023
  return digest.hexdigest()
2700
3024
 
2701
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
+
2702
3097
  def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
2703
3098
  """任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
2704
3099
  try:
@@ -2755,62 +3150,951 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2755
3150
  }
2756
3151
 
2757
3152
 
2758
- def acceptance_snapshot_path(root: Path, task_id: str) -> Path:
2759
- assert_safe_task_id(task_id)
2760
- return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
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
2761
3222
 
2762
3223
 
2763
- def canonical_json_sha256(value: object) -> str:
2764
- payload = json.dumps(
2765
- value,
2766
- ensure_ascii=False,
2767
- sort_keys=True,
2768
- separators=(",", ":"),
2769
- ).encode("utf-8")
2770
- return hashlib.sha256(payload).hexdigest()
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
+ ),
3271
+ }
2771
3272
 
2772
3273
 
2773
- def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
2774
- plan = latest_execution_plan(root, task_id)
2775
- if plan is None:
2776
- raise StateError("Cannot fingerprint verification contract without a valid plan.")
2777
- source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
2778
- contract = {
2779
- "workflow_mode": task.get("workflow_mode"),
2780
- "tdd_enabled": task.get("tdd_enabled"),
2781
- "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
2782
- "tdd_baselines": task.get("tdd_baselines"),
2783
- "plan": plan,
2784
- "canonical": {
2785
- "schema": source.get("schema"),
2786
- "spec_id": source.get("spec_id"),
2787
- "revision": source.get("revision"),
2788
- "design_sha256": source.get("design_sha256"),
2789
- "selected_tasks": task.get("selected_spec_tasks"),
2790
- "repository_bindings": task.get("spec_repositories"),
2791
- "repo_paths": task.get("repo_paths"),
2792
- }
2793
- if source
2794
- else None,
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")
3282
+ )
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
3304
+ )
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,
2795
3338
  }
2796
- return canonical_json_sha256(contract)
3339
+ append_execution_record(root, task_id, record)
3340
+ return reconcile_finalized_quality_state(root, task_id, task, record, agent)
2797
3341
 
2798
3342
 
2799
- def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
2800
- pathspecs = repository_scope_pathspecs(repository, scopes)
2801
- index_entries = git_index_entries(repository, pathspecs)
2802
- listed = run_git(
2803
- repository,
2804
- "ls-files",
2805
- "--cached",
2806
- "--others",
2807
- "--exclude-standard",
2808
- "-z",
2809
- "--",
2810
- *pathspecs,
2811
- )
2812
- modified = run_git(
2813
- repository,
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,
2814
4098
  "diff-files",
2815
4099
  "--name-only",
2816
4100
  "-z",
@@ -2978,28 +4262,28 @@ def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
2978
4262
 
2979
4263
 
2980
4264
  def load_acceptance_snapshot(root: Path, task: dict) -> dict:
2981
- checkpoint = task.get("verification_checkpoint")
4265
+ checkpoint = task.get("quality_checkpoint")
2982
4266
  if not isinstance(checkpoint, dict):
2983
- raise StateError("VERIFICATION has no frozen acceptance checkpoint.")
4267
+ raise StateError("QUALITY has no frozen acceptance checkpoint.")
2984
4268
  raw_path = checkpoint.get("snapshot_file")
2985
4269
  if not is_non_empty_string(raw_path):
2986
- raise StateError("Verification checkpoint has no snapshot file.")
4270
+ raise StateError("Quality checkpoint has no snapshot file.")
2987
4271
  candidate = (root / str(raw_path)).resolve()
2988
4272
  sessions_root = (root / ".easy-coding" / "sessions").resolve()
2989
4273
  if not is_path_within(candidate, sessions_root):
2990
- raise StateError("Verification checkpoint snapshot escapes .easy-coding/sessions.")
4274
+ raise StateError("Quality checkpoint snapshot escapes .easy-coding/sessions.")
2991
4275
  snapshot = load_json(candidate)
2992
4276
  if not isinstance(snapshot, dict) or snapshot.get("schema") != ACCEPTANCE_SNAPSHOT_SCHEMA:
2993
- raise StateError("Verification checkpoint snapshot is missing or invalid.")
4277
+ raise StateError("Quality checkpoint snapshot is missing or invalid.")
2994
4278
  if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
2995
- raise StateError("Verification checkpoint snapshot fingerprint changed.")
4279
+ raise StateError("Quality checkpoint snapshot fingerprint changed.")
2996
4280
  if (
2997
4281
  snapshot.get("implementation_fingerprint")
2998
4282
  != checkpoint.get("implementation_fingerprint")
2999
4283
  or snapshot.get("config_fingerprint") != checkpoint.get("config_fingerprint")
3000
4284
  or snapshot.get("contract_fingerprint") != checkpoint.get("contract_fingerprint")
3001
4285
  ):
3002
- raise StateError("Verification checkpoint metadata does not match its snapshot.")
4286
+ raise StateError("Quality checkpoint metadata does not match its snapshot.")
3003
4287
  return snapshot
3004
4288
 
3005
4289
 
@@ -3011,7 +4295,7 @@ def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None
3011
4295
  try:
3012
4296
  return base64.b64decode(encoded, validate=True)
3013
4297
  except ValueError as exc:
3014
- raise StateError("Verification checkpoint contains invalid file content.") from exc
4298
+ raise StateError("Quality checkpoint contains invalid file content.") from exc
3015
4299
  object_id = entry.get("git_oid")
3016
4300
  if not is_non_empty_string(object_id):
3017
4301
  return None
@@ -3019,7 +4303,7 @@ def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None
3019
4303
  return str(object_id).encode("ascii", errors="replace")
3020
4304
  result = run_git(repository, "cat-file", "blob", str(object_id))
3021
4305
  if result is None or result.returncode != 0:
3022
- raise StateError(f"Cannot restore verification checkpoint Git object: {object_id}")
4306
+ raise StateError(f"Cannot restore quality checkpoint Git object: {object_id}")
3023
4307
  return result.stdout
3024
4308
 
3025
4309
 
@@ -3059,7 +4343,7 @@ def acceptance_change_patch(
3059
4343
 
3060
4344
 
3061
4345
  def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
3062
- checkpoint = task.get("verification_checkpoint")
4346
+ checkpoint = task.get("quality_checkpoint")
3063
4347
  baseline = load_acceptance_snapshot(root, task)
3064
4348
  current = build_acceptance_snapshot(root, task_id, task)
3065
4349
  baseline_entries = acceptance_snapshot_entries(baseline)
@@ -3159,7 +4443,8 @@ def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
3159
4443
 
3160
4444
 
3161
4445
  def cleanup_verification_checkpoint(root: Path, task_id: str, task: dict) -> None:
3162
- checkpoint = task.pop("verification_checkpoint", None)
4446
+ checkpoint = task.pop("quality_checkpoint", None)
4447
+ task.pop("verification_checkpoint", None)
3163
4448
  if not isinstance(checkpoint, dict):
3164
4449
  return
3165
4450
  raw_path = checkpoint.get("snapshot_file")
@@ -3186,20 +4471,38 @@ def record_verification_checkpoint(
3186
4471
  session_file: str | Path | None = None,
3187
4472
  ) -> dict:
3188
4473
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3189
- if task.get("status") != "VERIFICATION":
3190
- raise StateError("Verification checkpoint can only be recorded during VERIFICATION.")
3191
- 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)
3192
4478
  load_acceptance_snapshot(root, task)
3193
4479
  result = snapshot_state(root, session_file, session)
3194
- result["action"] = "verification-checkpoint"
3195
- result["verification_checkpoint"] = task["verification_checkpoint"]
4480
+ result["action"] = "quality-checkpoint"
4481
+ result["quality_checkpoint"] = task["quality_checkpoint"]
3196
4482
  result["checkpoint_unchanged"] = True
3197
4483
  return result
3198
- 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")
3199
4502
  snapshot = build_acceptance_snapshot(root, resolved_task_id, task)
3200
4503
  path = acceptance_snapshot_path(root, resolved_task_id)
3201
4504
  write_json(path, snapshot)
3202
- task["verification_checkpoint"] = {
4505
+ task["quality_checkpoint"] = {
3203
4506
  "schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
3204
4507
  "implementation_fingerprint": snapshot["implementation_fingerprint"],
3205
4508
  "config_fingerprint": snapshot["config_fingerprint"],
@@ -3212,8 +4515,8 @@ def record_verification_checkpoint(
3212
4515
  task["last_agent"] = agent
3213
4516
  write_task(root, resolved_task_id, task)
3214
4517
  result = snapshot_state(root, session_file, session)
3215
- result["action"] = "verification-checkpoint"
3216
- result["verification_checkpoint"] = task["verification_checkpoint"]
4518
+ result["action"] = "quality-checkpoint"
4519
+ result["quality_checkpoint"] = task["quality_checkpoint"]
3217
4520
  return result
3218
4521
 
3219
4522
 
@@ -3247,13 +4550,13 @@ def ensure_verification_checkpoint(
3247
4550
  agent: str,
3248
4551
  session_file: str | Path | None,
3249
4552
  ) -> dict:
3250
- if isinstance(task.get("verification_checkpoint"), dict):
4553
+ if isinstance(task.get("quality_checkpoint"), dict):
3251
4554
  load_acceptance_snapshot(root, task)
3252
4555
  return task
3253
4556
  record_verification_checkpoint(root, agent, task_id, session_file)
3254
4557
  refreshed = load_task(root, task_id)
3255
4558
  if not isinstance(refreshed, dict):
3256
- raise StateError(f"Task not found after verification checkpoint: {task_id}")
4559
+ raise StateError(f"Task not found after quality checkpoint: {task_id}")
3257
4560
  return refreshed
3258
4561
 
3259
4562
 
@@ -3264,8 +4567,8 @@ def inspect_transition_drift(
3264
4567
  session_file: str | Path | None = None,
3265
4568
  ) -> dict:
3266
4569
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3267
- if task.get("status") != "VERIFICATION":
3268
- 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.")
3269
4572
  task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
3270
4573
  result = snapshot_state(root, session_file, session)
3271
4574
  result["acceptance_drift"] = inspect_acceptance_drift(root, resolved_task_id, task)
@@ -3287,18 +4590,18 @@ def append_transition_acceptance(
3287
4590
  drift = inspect_acceptance_drift(root, task_id, task)
3288
4591
  if drift["config_changed"]:
3289
4592
  raise StateError(
3290
- "Behavior config changed after verification; rerun verification before MEMORY."
4593
+ "Behavior config changed after quality checks; rerun QUALITY before MEMORY."
3291
4594
  )
3292
4595
  if drift["metadata_changed"]:
3293
4596
  raise StateError(
3294
4597
  "Execution plan, workflow, Canonical design, or nested repository state changed "
3295
- "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."
3296
4599
  )
3297
4600
  changed_files = list(drift["changed_files"])
3298
4601
  if changed_files:
3299
4602
  if expected_diff_sha256 != drift["diff_sha256"]:
3300
4603
  raise StateError(
3301
- "Verified code changed after the acceptance checkpoint. Inspect the exact drift "
4604
+ "Quality-approved code changed after the acceptance checkpoint. Inspect the exact drift "
3302
4605
  "and confirm its current diff_sha256 before entering MEMORY."
3303
4606
  )
3304
4607
  if verification_policy not in ACCEPTANCE_VERIFICATION_POLICIES:
@@ -3577,15 +4880,28 @@ def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -
3577
4880
  )
3578
4881
 
3579
4882
 
3580
- 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:
3581
4889
  validate_spec_implementation_results(root, task_id, task)
3582
4890
  is_spec_task = isinstance(task.get("spec_source"), dict)
3583
4891
  if task.get("workflow_mode_legacy") is True and not is_spec_task:
3584
4892
  return
3585
4893
  expected = implementation_fingerprint(root, task_id)
3586
- 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
+ )
3587
4899
  latest_by_dimension: dict[str, dict] = {}
3588
- 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
+ ):
3589
4905
  if (
3590
4906
  record.get("type") == "review"
3591
4907
  and record.get("implementation_fingerprint") in accepted_fingerprints
@@ -3597,7 +4913,7 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
3597
4913
  latest_by_dimension[record_key] = record
3598
4914
  if not latest_by_dimension:
3599
4915
  raise StateError(
3600
- "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."
3601
4917
  )
3602
4918
  for record in latest_by_dimension.values():
3603
4919
  if (
@@ -3662,7 +4978,7 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
3662
4978
  break
3663
4979
  if has_failed_dimension:
3664
4980
  raise StateError(
3665
- "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."
3666
4982
  )
3667
4983
  if task.get("tdd_enabled") is True:
3668
4984
  if is_spec_task:
@@ -3701,24 +5017,34 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
3701
5017
  )
3702
5018
 
3703
5019
 
3704
- 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:
3705
5027
  fingerprints = evidence_fingerprints(root, task_id)
3706
- accepted_fingerprints, acceptance = accepted_verification_fingerprints(
3707
- root,
3708
- task_id,
3709
- task,
3710
- fingerprints["implementation_fingerprint"],
3711
- fingerprints["config_fingerprint"],
3712
- )
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
+ )
3713
5039
  is_spec_task = isinstance(task.get("spec_source"), dict)
3714
- if (
3715
- (task.get("workflow_mode_legacy") is not True or is_spec_task)
3716
- and task.get("workflow_mode_legacy_review_bypass_fingerprint")
3717
- != fingerprints["implementation_fingerprint"]
3718
- ):
5040
+ if validate_review:
3719
5041
  validate_review_readiness(root, task_id, task)
3720
5042
  latest_by_check: dict[str, dict] = {}
3721
- 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
+ ):
3722
5048
  if (
3723
5049
  record.get("type") == "verify"
3724
5050
  and record.get("implementation_fingerprint") in accepted_fingerprints
@@ -3747,7 +5073,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
3747
5073
  latest_by_check[check] = record
3748
5074
  if not latest_by_check:
3749
5075
  raise StateError(
3750
- "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."
3751
5077
  )
3752
5078
  if task.get("workflow_mode_legacy") is not True or is_spec_task:
3753
5079
  for record in latest_by_check.values():
@@ -3793,11 +5119,11 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
3793
5119
  ]
3794
5120
  if not applicable_records:
3795
5121
  raise StateError(
3796
- "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."
3797
5123
  )
3798
5124
  if any(record.get("passed") is not True for record in applicable_records):
3799
5125
  raise StateError(
3800
- "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
5126
+ "QUALITY cannot advance to MEMORY while verification evidence contains failures."
3801
5127
  )
3802
5128
  if acceptance and acceptance.get("verification_policy") == "targeted":
3803
5129
  current_records = [
@@ -3981,157 +5307,792 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
3981
5307
  )
3982
5308
  )
3983
5309
  else:
3984
- latest_by_type: dict[str, dict] = {}
3985
- for record in latest_by_check.values():
3986
- check_type = str(record.get("check_type") or "")
3987
- if check_type in STRICT_VERIFICATION_CHECK_TYPES:
3988
- latest_by_type[check_type] = record
3989
- missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
3990
- if missing_types:
3991
- raise StateError(
3992
- "Strict workflow requires current verification evidence for every check type: "
3993
- + ", ".join(missing_types)
3994
- + "."
5310
+ latest_by_type: dict[str, dict] = {}
5311
+ for record in latest_by_check.values():
5312
+ check_type = str(record.get("check_type") or "")
5313
+ if check_type in STRICT_VERIFICATION_CHECK_TYPES:
5314
+ latest_by_type[check_type] = record
5315
+ missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
5316
+ if missing_types:
5317
+ raise StateError(
5318
+ "Strict workflow requires current verification evidence for every check type: "
5319
+ + ", ".join(missing_types)
5320
+ + "."
5321
+ )
5322
+ for check_type, record in latest_by_type.items():
5323
+ if record.get("applicable") is False and not is_non_empty_string(
5324
+ record.get("not_applicable_reason")
5325
+ ):
5326
+ raise StateError(
5327
+ "Strict workflow requires a non-empty not_applicable_reason when "
5328
+ f"{check_type} is marked not applicable."
5329
+ )
5330
+ if is_spec_task:
5331
+ inspect_task_spec(root, task)
5332
+ plan = latest_execution_plan(root, task_id)
5333
+ required_test_commands = {
5334
+ (
5335
+ str(unit.get("source_task_id")),
5336
+ str(unit.get("repo_id")),
5337
+ str(command),
5338
+ )
5339
+ for unit in (plan or {}).get("units", [])
5340
+ if isinstance(unit, dict)
5341
+ for command in unit.get("test_commands", [])
5342
+ if is_non_empty_string(command)
5343
+ }
5344
+ executed_commands = {
5345
+ (
5346
+ str(record.get("source_task_id")),
5347
+ str(record.get("repo_id")),
5348
+ str(record.get("command")),
5349
+ )
5350
+ for record in applicable_records
5351
+ if is_non_empty_string(record.get("command"))
5352
+ }
5353
+ missing_commands = sorted(required_test_commands - executed_commands)
5354
+ if missing_commands:
5355
+ raise StateError(
5356
+ "Canonical Spec verification is missing source test commands: "
5357
+ + ", ".join(
5358
+ f"{source_task_id}@{repo_id}: {command}"
5359
+ for source_task_id, repo_id, command in missing_commands
5360
+ )
5361
+ )
5362
+ covered_verification_tasks = {
5363
+ str(record.get("source_task_id")) for record in applicable_records
5364
+ }
5365
+ missing_verification_tasks = sorted(
5366
+ set(task_repositories) - covered_verification_tasks
5367
+ )
5368
+ if missing_verification_tasks:
5369
+ raise StateError(
5370
+ "Canonical Spec verification evidence does not cover selected source tasks: "
5371
+ + ", ".join(missing_verification_tasks)
5372
+ )
5373
+ pending_integration = [
5374
+ record
5375
+ for record in task.get("spec_dependency_evidence", [])
5376
+ if isinstance(record, dict)
5377
+ and record.get("dependency_type") == "integration"
5378
+ and record.get("status") != "satisfied"
5379
+ ]
5380
+ if pending_integration:
5381
+ edges = ", ".join(
5382
+ f"{record.get('source_task_id')}->{record.get('task_id')}"
5383
+ for record in pending_integration
5384
+ )
5385
+ raise StateError(
5386
+ "QUALITY cannot advance to MEMORY while Canonical Spec integration "
5387
+ f"dependencies are pending: {edges}."
5388
+ )
5389
+
5390
+
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)
5399
+ else:
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:
5455
+ continue
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
5523
+
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
5592
+
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
3995
5688
  )
3996
- for check_type, record in latest_by_type.items():
3997
- if record.get("applicable") is False and not is_non_empty_string(
3998
- record.get("not_applicable_reason")
3999
- ):
4000
- raise StateError(
4001
- "Strict workflow requires a non-empty not_applicable_reason when "
4002
- f"{check_type} is marked not applicable."
4003
- )
4004
- if is_spec_task:
4005
- inspect_task_spec(root, task)
4006
- plan = latest_execution_plan(root, task_id)
4007
- required_test_commands = {
4008
- (
4009
- str(unit.get("source_task_id")),
4010
- str(unit.get("repo_id")),
4011
- str(command),
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 ''}",
4012
5825
  )
4013
- for unit in (plan or {}).get("units", [])
4014
- if isinstance(unit, dict)
4015
- for command in unit.get("test_commands", [])
4016
- if is_non_empty_string(command)
4017
- }
4018
- executed_commands = {
4019
- (
4020
- str(record.get("source_task_id")),
4021
- str(record.get("repo_id")),
4022
- str(record.get("command")),
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 ""),
4023
5844
  )
4024
- for record in applicable_records
4025
- if is_non_empty_string(record.get("command"))
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,
4026
5864
  }
4027
- missing_commands = sorted(required_test_commands - executed_commands)
4028
- if missing_commands:
4029
- raise StateError(
4030
- "Canonical Spec verification is missing source test commands: "
4031
- + ", ".join(
4032
- f"{source_task_id}@{repo_id}: {command}"
4033
- for source_task_id, repo_id, command in missing_commands
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)
5881
+ else:
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)
4034
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."
4035
5912
  )
4036
- covered_verification_tasks = {
4037
- str(record.get("source_task_id")) for record in applicable_records
4038
- }
4039
- missing_verification_tasks = sorted(
4040
- set(task_repositories) - covered_verification_tasks
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"]),
5924
+ )
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")
4041
5940
  )
4042
- if missing_verification_tasks:
5941
+ else:
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
+ ):
4043
5998
  raise StateError(
4044
- "Canonical Spec verification evidence does not cover selected source tasks: "
4045
- + ", ".join(missing_verification_tasks)
5999
+ "Canonical QUALITY blocked writeback must belong to the current "
6000
+ f"Harness task and QUALITY attempt: {source_task_id}."
4046
6001
  )
4047
- pending_integration = [
4048
- record
4049
- for record in task.get("spec_dependency_evidence", [])
4050
- if isinstance(record, dict)
4051
- and record.get("dependency_type") == "integration"
4052
- and record.get("status") != "satisfied"
4053
- ]
4054
- if pending_integration:
4055
- edges = ", ".join(
4056
- f"{record.get('source_task_id')}->{record.get('task_id')}"
4057
- for record in pending_integration
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')}"
4058
6016
  )
6017
+ } if isinstance(evidence, list) else set()
6018
+ if not required_kinds.issubset(evidence_kinds):
4059
6019
  raise StateError(
4060
- "VERIFICATION cannot advance to MEMORY while Canonical Spec integration "
4061
- f"dependencies are pending: {edges}."
6020
+ "Canonical QUALITY blocked writeback must reference the current "
6021
+ f"failed gate evidence: {source_task_id}."
4062
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
4063
6045
 
4064
6046
 
4065
- def validate_read_only_completion(root: Path, task_id: str) -> None:
4066
- task = load_task(root, task_id)
4067
- task_type = str(task.get("type") or "").strip().lower() if task else ""
4068
- reasons: list[str] = []
4069
- if task_type not in NO_CODE_TASK_TYPES:
4070
- reasons.append("task type is not doc, analysis, or report")
4071
-
4072
- path = execution_log_path(root, task_id)
4073
- records: list[dict] = []
4074
- if not path.exists():
4075
- reasons.append("execution.jsonl is missing")
4076
- else:
4077
- try:
4078
- for line in path.read_text(encoding="utf-8").splitlines():
4079
- if not line.strip():
4080
- continue
4081
- record = json.loads(line)
4082
- if not isinstance(record, dict):
4083
- reasons.append("execution.jsonl contains a non-object record")
4084
- break
4085
- records.append(record)
4086
- except (OSError, json.JSONDecodeError):
4087
- reasons.append("execution.jsonl cannot be read as valid JSONL")
4088
-
4089
- latest_plan_index: int | None = None
4090
- for index, record in enumerate(records):
4091
- if record.get("type") == "plan":
4092
- latest_plan_index = index
4093
-
4094
- unit_id = ""
4095
- if latest_plan_index is None:
4096
- reasons.append("execution.jsonl has no plan record")
4097
- else:
4098
- plan = records[latest_plan_index]
4099
- if not is_read_only_execution_plan(plan):
4100
- reasons.append("latest plan record is invalid")
4101
- else:
4102
- units = plan["units"]
4103
- unit_id = str(units[0]["id"])
4104
-
4105
- unit_records: list[dict] = []
4106
- if latest_plan_index is not None and unit_id:
4107
- for record in records[latest_plan_index + 1 :]:
4108
- if record.get("unit_id") == unit_id and record.get("type") in {"dispatch", "result"}:
4109
- unit_records.append(record)
4110
- latest_result = (
4111
- unit_records[-1]
4112
- if unit_records and unit_records[-1].get("type") == "result"
4113
- else None
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"
4114
6056
  )
4115
- if latest_result is None:
4116
- reasons.append("latest read-only unit has no result record")
4117
- else:
4118
- matching_dispatch = unit_records[-2] if len(unit_records) >= 2 else None
4119
- if matching_dispatch is None or matching_dispatch.get("type") != "dispatch":
4120
- reasons.append("latest read-only result has no matching dispatch record")
4121
- elif not is_non_empty_string(matching_dispatch.get("timestamp")):
4122
- reasons.append("latest read-only dispatch record has no timestamp")
4123
- if latest_result.get("changed_files") != []:
4124
- reasons.append("read-only result must contain changed_files:[]")
4125
- if not is_non_empty_string(latest_result.get("deliverable")):
4126
- reasons.append("read-only result must contain a non-empty deliverable")
4127
- if latest_result.get("issues") != []:
4128
- reasons.append("read-only result must contain issues:[]")
4129
- if latest_result.get("needs_attention") != []:
4130
- reasons.append("read-only result must contain needs_attention:[]")
4131
-
4132
- if reasons:
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:
4133
6093
  raise StateError(
4134
- "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))
4135
6096
  )
4136
6097
 
4137
6098
 
@@ -4297,7 +6258,6 @@ def validate_analysis_readiness(
4297
6258
  task_dir = task_json_path(root, task_id).parent
4298
6259
  task = load_task(root, task_id)
4299
6260
  task_type = str(task.get("type") or "").strip().lower() if task else ""
4300
- is_read_only_task = task_type in NO_CODE_TASK_TYPES
4301
6261
  dev_spec = task_dir / "dev-spec.md"
4302
6262
  skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
4303
6263
  test_strategy = task_dir / "test-strategy.md"
@@ -4319,10 +6279,6 @@ def validate_analysis_readiness(
4319
6279
 
4320
6280
  if dev_spec_content:
4321
6281
  missing_headers, empty_sections = validate_mandatory_dev_spec_sections(dev_spec_content)
4322
- if is_read_only_task:
4323
- empty_sections = [
4324
- header for header in empty_sections if header != "### 改动范围"
4325
- ]
4326
6282
  if missing_headers:
4327
6283
  reasons.append(
4328
6284
  "dev-spec.md is missing mandatory headers: "
@@ -4416,7 +6372,7 @@ def validate_analysis_readiness(
4416
6372
  plan_is_valid = has_valid_execution_plan(root, task_id)
4417
6373
  if not plan_is_valid:
4418
6374
  reasons.append("execution.jsonl has no valid plan record")
4419
- if tdd_enabled and not is_read_only_task:
6375
+ if tdd_enabled:
4420
6376
  readiness = tdd_readiness(root)
4421
6377
  if readiness["status"] != "ready":
4422
6378
  reasons.append(
@@ -4504,7 +6460,7 @@ def validate_analysis_readiness(
4504
6460
  r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
4505
6461
  ):
4506
6462
  reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
4507
- elif not is_read_only_task:
6463
+ else:
4508
6464
  if re.search(
4509
6465
  r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
4510
6466
  ):
@@ -4646,15 +6602,11 @@ def validate_analysis_readiness(
4646
6602
  reasons.append(str(exc))
4647
6603
  except OSError:
4648
6604
  reasons.append("test-strategy.md cannot be read")
4649
- if is_read_only_task:
4650
- if test_strategy.exists():
4651
- reasons.append("read-only task must not create test-strategy.md")
4652
- else:
4653
- try:
4654
- if not test_strategy.exists() or not test_strategy.read_text(encoding="utf-8").strip():
4655
- reasons.append("test-strategy.md is missing or empty")
4656
- except OSError:
4657
- 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")
4658
6610
 
4659
6611
  if reasons:
4660
6612
  raise StateError(
@@ -4683,6 +6635,28 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
4683
6635
  return latest
4684
6636
 
4685
6637
 
6638
+ def pending_handoff_record(root: Path, task_id: str) -> dict | None:
6639
+ path = execution_log_path(root, task_id)
6640
+ if not path.exists():
6641
+ return None
6642
+ latest_coordination: dict | None = None
6643
+ try:
6644
+ for line in path.read_text(encoding="utf-8").splitlines():
6645
+ if not line.strip():
6646
+ continue
6647
+ try:
6648
+ record = json.loads(line)
6649
+ except json.JSONDecodeError:
6650
+ continue
6651
+ if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
6652
+ latest_coordination = record
6653
+ except OSError:
6654
+ return None
6655
+ if latest_coordination and latest_coordination.get("type") == "handoff":
6656
+ return latest_coordination
6657
+ return None
6658
+
6659
+
4686
6660
  def assert_safe_task_id(task_id: str) -> None:
4687
6661
  path = Path(task_id)
4688
6662
  if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
@@ -4762,17 +6736,9 @@ def validate_transition(
4762
6736
  ) -> str | None:
4763
6737
  if previous == current:
4764
6738
  return None
4765
- normalized_task_type = task_type.strip().lower()
4766
6739
  allowed = set(VALID_TRANSITIONS.get(previous, set()))
4767
- if previous == "IMPLEMENT" and normalized_task_type in NO_CODE_TASK_TYPES:
4768
- allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
4769
- elif previous == "IMPLEMENT":
6740
+ if previous == "IMPLEMENT":
4770
6741
  allowed.discard("COMPLETE")
4771
- if not (
4772
- isinstance(task, dict)
4773
- and task.get("workflow_mode_legacy_direct_edge") is True
4774
- ):
4775
- allowed.discard("VERIFICATION")
4776
6742
  if current in allowed:
4777
6743
  return None
4778
6744
  return (
@@ -4893,6 +6859,8 @@ def snapshot_state(
4893
6859
  "session_confirm_mode": session_approval_mode,
4894
6860
  "effective_confirm_mode": effective_approval_mode,
4895
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"),
4896
6864
  }
4897
6865
 
4898
6866
 
@@ -4903,6 +6871,17 @@ def build_status_line(
4903
6871
  session_file: str | Path | None = None,
4904
6872
  ) -> str:
4905
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
+ )
4906
6885
  approval = str(state["effective_approval_mode"]).capitalize()
4907
6886
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
4908
6887
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
@@ -4912,9 +6891,10 @@ def build_status_line(
4912
6891
  if task_id:
4913
6892
  status = str(state["status"])
4914
6893
  line = f"{status_brand} · `{task_id}` · `{status}`"
4915
- last_agent = state.get("last_agent")
4916
- if agent and last_agent and not agents_equivalent(last_agent, agent):
4917
- line += f" · Handoff -> `{last_agent}`"
6894
+ handoff = pending_handoff_record(root, str(task_id))
6895
+ handoff_from = handoff.get("from") if handoff else None
6896
+ if agent and handoff_from and not agents_equivalent(handoff_from, agent):
6897
+ line += f" · Handoff -> `{handoff_from}`"
4918
6898
  if state["is_terminal"] or state["task_missing"]:
4919
6899
  line += f" · {HELP_SUFFIX}"
4920
6900
  return line
@@ -4961,9 +6941,10 @@ def build_machine_breadcrumbs(
4961
6941
  lines.append(f"[current-task:{task_id}]")
4962
6942
  if state["task_missing"]:
4963
6943
  lines.append(f"[easy-coding:current-task-missing:{task_id}]")
4964
- last_agent = state.get("last_agent")
4965
- if agent and last_agent and not agents_equivalent(last_agent, agent):
4966
- lines.append(f"[easy-coding:handoff-from:{last_agent}]")
6944
+ handoff = pending_handoff_record(root, str(task_id))
6945
+ handoff_from = handoff.get("from") if handoff else None
6946
+ if agent and handoff_from and not agents_equivalent(handoff_from, agent):
6947
+ lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
4967
6948
  pending = state.get("pending_transition")
4968
6949
  if isinstance(pending, dict):
4969
6950
  source = str(pending.get("from") or stage)
@@ -4971,17 +6952,7 @@ def build_machine_breadcrumbs(
4971
6952
  if target:
4972
6953
  lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
4973
6954
  task_type = str(task.get("type") or "") if task else ""
4974
- legacy_review_bypass = (
4975
- source == "IMPLEMENT"
4976
- and target == "REVIEW"
4977
- and isinstance(task, dict)
4978
- and task.get("workflow_mode_legacy_direct_edge") is True
4979
- )
4980
- if legacy_review_bypass:
4981
- lines.append(
4982
- "[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
4983
- )
4984
- elif pending.get("confirmation_override") == "evidence-drift":
6955
+ if pending.get("confirmation_override") == "evidence-drift":
4985
6956
  lines.append(
4986
6957
  "[easy-coding:acceptance-drift-confirmation-required]"
4987
6958
  )
@@ -5046,6 +7017,17 @@ def build_status_context(
5046
7017
  f"[easy-coding:session-file:{display_path(root, session_path)}]",
5047
7018
  ]
5048
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)
5049
7031
  return "\n".join(
5050
7032
  [
5051
7033
  build_status_line(root, session, agent, session_file),
@@ -5127,6 +7109,8 @@ def set_current_task(root: Path, task_id: str, agent: str, session_file: str | P
5127
7109
  if task is None:
5128
7110
  raise StateError(f"Task not found: {task_id}")
5129
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.")
5130
7114
  session["current_task"] = task_id
5131
7115
  session["last_seen_task"] = task_id
5132
7116
  session["last_seen_stage"] = str(task.get("status") or "PENDING")
@@ -5307,6 +7291,339 @@ def clear_session_tdd(
5307
7291
  return snapshot
5308
7292
 
5309
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
+
5310
7627
  def set_harness_disabled(
5311
7628
  root: Path,
5312
7629
  disabled: bool,
@@ -5375,6 +7692,10 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
5375
7692
  if status in TERMINAL_STATUSES:
5376
7693
  raise StateError(f"Cannot claim terminal task: {task_id}")
5377
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
+
5378
7699
  previous_agent = task.get("last_agent")
5379
7700
  action = (
5380
7701
  "continue"
@@ -5385,18 +7706,27 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
5385
7706
  task["last_agent"] = agent
5386
7707
  write_task(root, task_id, task)
5387
7708
 
5388
- session = ensure_session(root, session_file)
5389
7709
  session["current_task"] = task_id
5390
7710
  session["last_seen_task"] = task_id
5391
7711
  session["last_seen_stage"] = status
5392
7712
  session["last_agent"] = agent
5393
7713
  write_session(root, session, session_file)
5394
7714
 
7715
+ claim = {
7716
+ "type": "claim",
7717
+ "agent": agent,
7718
+ "previous_agent": previous_agent,
7719
+ "action": action,
7720
+ "timestamp": now_iso(),
7721
+ }
7722
+ append_execution_record(root, task_id, claim)
7723
+
5395
7724
  snapshot = snapshot_state(root, session_file, session)
5396
7725
  snapshot["task_id"] = task_id
5397
7726
  snapshot["action"] = action
5398
7727
  snapshot["previous_agent"] = previous_agent
5399
7728
  snapshot["latest_handoff"] = latest_handoff
7729
+ snapshot["claim"] = claim
5400
7730
  return snapshot
5401
7731
 
5402
7732
 
@@ -5411,8 +7741,13 @@ def create_task(
5411
7741
  task_fields: dict | None = None,
5412
7742
  ) -> dict:
5413
7743
  assert_safe_task_id(task_id)
5414
- if set_current:
5415
- 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.")
5416
7751
  path = task_json_path(root, task_id)
5417
7752
  if path.exists():
5418
7753
  raise StateError(f"Task already exists: {task_id}")
@@ -5531,10 +7866,9 @@ SPEC_WRITEBACK_APP = "easy-coding"
5531
7866
 
5532
7867
 
5533
7868
  def spec_writeback_agent(agent: str) -> str:
5534
- raw_agent = str(agent).strip()
5535
- if raw_agent.endswith(" with Easy Coding"):
5536
- return raw_agent
5537
- normalized = normalize_agent_identity(raw_agent)
7869
+ normalized = canonical_agent_identity(agent)
7870
+ if normalized is None:
7871
+ raise StateError("Canonical Spec attribution requires a canonical workflow agent identity.")
5538
7872
  display_name = {
5539
7873
  "claude-code": "Claude Code",
5540
7874
  "codex": "Codex",
@@ -6546,6 +8880,7 @@ def writeback_ready_tasks_for_implement(
6546
8880
  task: dict,
6547
8881
  agent: str,
6548
8882
  restart_statuses: set[str] | None = None,
8883
+ source_task_ids: set[str] | None = None,
6549
8884
  ) -> None:
6550
8885
  inspection, _ = inspect_task_spec(root, task)
6551
8886
  implement_attempt = 1 + sum(
@@ -6560,6 +8895,8 @@ def writeback_ready_tasks_for_implement(
6560
8895
  }
6561
8896
  selected_snapshots = _selected_execution_snapshots(inspection, task)
6562
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
6563
8900
  snapshot = selected_snapshots.get(str(source_task_id))
6564
8901
  if not snapshot or snapshot.get("status") == "in_progress":
6565
8902
  continue
@@ -7031,9 +9368,6 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
7031
9368
  if task is None:
7032
9369
  raise StateError(f"Task not found: {task_id}")
7033
9370
  task_type = str(task.get("type") or "").strip().lower()
7034
- if task_type in NO_CODE_TASK_TYPES:
7035
- return "fast", ["read-only-task"]
7036
-
7037
9371
  plan = latest_execution_plan(root, task_id)
7038
9372
  if not plan:
7039
9373
  raise StateError("Cannot calculate workflow floor without a valid execution plan.")
@@ -7074,7 +9408,7 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
7074
9408
  complexity_reasons: list[str] = []
7075
9409
  if len(repositories) > 1:
7076
9410
  complexity_reasons.append("cross-repository-change")
7077
- if len(units) >= 4 or len(files) >= 10:
9411
+ if len(units) >= 5 or len(files) >= 15:
7078
9412
  complexity_reasons.append("broad-change-scope")
7079
9413
  if WIDE_WORKFLOW_CONTRACT_PATTERN.search(" ".join(contract_values)):
7080
9414
  complexity_reasons.append("wide-contract-impact")
@@ -7089,11 +9423,11 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
7089
9423
  if high_risk:
7090
9424
  standard_reasons.append("bounded-high-risk-change")
7091
9425
  standard_reasons.extend(complexity_reasons)
7092
- if len(units) > 1:
9426
+ if len(units) >= 4:
7093
9427
  standard_reasons.append("multiple-units")
7094
- if len(files) > 5:
9428
+ if len(files) > 8:
7095
9429
  standard_reasons.append("multi-file-impact")
7096
- if plan.get("strategy") == "parallel":
9430
+ if plan.get("strategy") == "parallel" and len(units) >= 3:
7097
9431
  standard_reasons.append("parallel-execution")
7098
9432
  if standard_reasons:
7099
9433
  return "standard", list(dict.fromkeys(standard_reasons))
@@ -7149,9 +9483,7 @@ def freeze_tdd_mode(
7149
9483
  behavior = resolve_behavior(root, session)
7150
9484
  task_type = str(task.get("type") or "").strip().lower()
7151
9485
  task["tdd_enabled"] = (
7152
- behavior[8]
7153
- if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
7154
- else False
9486
+ behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
7155
9487
  )
7156
9488
  task["tdd_coverage_threshold"] = behavior[11]
7157
9489
  if task["tdd_enabled"] is True:
@@ -7191,12 +9523,12 @@ def raise_workflow_mode(
7191
9523
  ) -> dict:
7192
9524
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
7193
9525
  stage = str(task.get("status") or "")
7194
- if stage == "VERIFICATION":
9526
+ if stage == "QUALITY":
7195
9527
  raise StateError(
7196
- "Return to IMPLEMENT before raising workflow mode from VERIFICATION so the "
7197
- "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."
7198
9530
  )
7199
- if stage not in {"IMPLEMENT", "REVIEW"}:
9531
+ if stage != "IMPLEMENT":
7200
9532
  raise StateError("A frozen workflow mode can only be raised during active execution.")
7201
9533
  current = str(task.get("workflow_mode") or "")
7202
9534
  if current not in WORKFLOW_MODES or mode not in WORKFLOW_MODES:
@@ -7254,24 +9586,33 @@ def request_transition(
7254
9586
  task.get("workflow_mode_proposal"),
7255
9587
  resolved_task_id,
7256
9588
  )
7257
- if previous == "REVIEW" and stage == "VERIFICATION":
7258
- 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
+ )
7259
9600
  acceptance_drift: dict | None = None
7260
- if previous == "VERIFICATION" and stage == "MEMORY":
9601
+ if previous == "QUALITY" and stage == "MEMORY":
7261
9602
  task = ensure_verification_checkpoint(
7262
9603
  root, resolved_task_id, task, agent, session_file
7263
9604
  )
7264
9605
  acceptance_drift = inspect_acceptance_drift(root, resolved_task_id, task)
7265
9606
  if acceptance_drift["config_changed"]:
7266
9607
  raise StateError(
7267
- "Behavior config changed after verification; rerun verification before MEMORY."
9608
+ "Behavior config changed after quality checks; rerun QUALITY before MEMORY."
7268
9609
  )
7269
9610
  if acceptance_drift["metadata_changed"]:
7270
9611
  raise StateError(
7271
- "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
9612
+ "Quality metadata changed; return to ANALYSIS or IMPLEMENT."
7272
9613
  )
7273
9614
  if acceptance_drift["status"] == "clean":
7274
- validate_verification_readiness(root, resolved_task_id, task)
9615
+ validate_quality_readiness(root, resolved_task_id, task)
7275
9616
  existing = task.get("pending_transition")
7276
9617
  if isinstance(existing, dict):
7277
9618
  if existing.get("from") != previous or existing.get("to") != stage:
@@ -7279,11 +9620,27 @@ def request_transition(
7279
9620
  "A different transition is already pending. Cancel it before requesting another."
7280
9621
  )
7281
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
+ }
7282
9638
  task["pending_transition"] = {
7283
9639
  "from": previous,
7284
9640
  "to": stage,
7285
9641
  "requested_at": now_iso(),
7286
9642
  "requested_by": agent,
9643
+ **transition_binding,
7287
9644
  **({"reason": reason.strip()} if reason and reason.strip() else {}),
7288
9645
  }
7289
9646
  task["last_agent"] = agent
@@ -7310,7 +9667,6 @@ def apply_transition(
7310
9667
  previous = str(task.get("status") or "idle")
7311
9668
  task_type = str(task.get("type") or "")
7312
9669
  approval_mode = resolve_approval_mode(root, session)[2]
7313
- legacy_edge = task.get("workflow_mode_legacy") is True
7314
9670
  violation = validate_transition(previous, stage, task_type, task)
7315
9671
  if violation:
7316
9672
  raise StateError(violation)
@@ -7319,19 +9675,39 @@ def apply_transition(
7319
9675
  if task.get("workflow_mode_legacy") is not True:
7320
9676
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
7321
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
+ )
7322
9692
  if stage == "IMPLEMENT" and previous != "IMPLEMENT":
7323
- 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
+ ):
7324
9696
  writeback_ready_tasks_for_implement(
7325
9697
  root,
7326
9698
  resolved_task_id,
7327
9699
  task,
7328
9700
  agent,
7329
- {"blocked"} if previous in {"REVIEW", "VERIFICATION"} else None,
9701
+ {"blocked"} if previous == "QUALITY" else None,
9702
+ repair_source_task_ids,
7330
9703
  )
7331
- if previous == "REVIEW" and stage == "VERIFICATION":
7332
- validate_review_readiness(root, resolved_task_id, task)
7333
- if previous == "VERIFICATION" and stage == "MEMORY":
7334
- 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)
7335
9711
  if isinstance(task.get("spec_source"), dict):
7336
9712
  writeback_verified_tasks(
7337
9713
  root, resolved_task_id, task, agent, session_file
@@ -7344,20 +9720,38 @@ def apply_transition(
7344
9720
  raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
7345
9721
  if isinstance(task.get("spec_source"), dict):
7346
9722
  writeback_completed_tasks(root, resolved_task_id, task, agent)
7347
- if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
7348
- validate_read_only_completion(root, resolved_task_id)
7349
9723
  if previous != stage:
7350
9724
  task["status"] = stage
7351
9725
  append_stage_history(task, stage, agent)
7352
- if legacy_edge:
7353
- task.pop("workflow_mode_legacy", None)
7354
- if previous in {"IMPLEMENT", "REVIEW"} and stage == "VERIFICATION":
7355
- task["workflow_mode_legacy_review_bypass_fingerprint"] = (
7356
- implementation_fingerprint(root, resolved_task_id)
7357
- )
9726
+ task.pop("workflow_mode_legacy", None)
7358
9727
  task.pop("workflow_mode_legacy_direct_edge", None)
7359
- if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
7360
- 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
7361
9755
  if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
7362
9756
  cleanup_verification_checkpoint(root, resolved_task_id, task)
7363
9757
  task.pop("pending_transition", None)
@@ -7401,18 +9795,18 @@ def auto_transition(
7401
9795
  "A different transition is already pending. Cancel it before automatic transition."
7402
9796
  )
7403
9797
 
7404
- if previous == "VERIFICATION" and stage == "MEMORY":
9798
+ if previous == "QUALITY" and stage == "MEMORY":
7405
9799
  task = ensure_verification_checkpoint(
7406
9800
  root, resolved_task_id, task, agent, session_file
7407
9801
  )
7408
9802
  drift = inspect_acceptance_drift(root, resolved_task_id, task)
7409
9803
  if drift["config_changed"]:
7410
9804
  raise StateError(
7411
- "Behavior config changed after verification; rerun verification before MEMORY."
9805
+ "Behavior config changed after quality checks; rerun QUALITY before MEMORY."
7412
9806
  )
7413
9807
  if drift["metadata_changed"]:
7414
9808
  raise StateError(
7415
- "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
9809
+ "Quality metadata changed; return to ANALYSIS or IMPLEMENT."
7416
9810
  )
7417
9811
  if drift["changed_files"]:
7418
9812
  task["pending_transition"] = {
@@ -7420,7 +9814,7 @@ def auto_transition(
7420
9814
  "to": stage,
7421
9815
  "requested_at": now_iso(),
7422
9816
  "requested_by": agent,
7423
- "reason": "verification checkpoint drift requires exact user acceptance",
9817
+ "reason": "quality checkpoint drift requires exact user acceptance",
7424
9818
  "confirmation_override": "evidence-drift",
7425
9819
  }
7426
9820
  task["last_agent"] = agent
@@ -7475,8 +9869,22 @@ def confirm_transition(
7475
9869
  f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
7476
9870
  "use auto-transition instead."
7477
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
+ )
7478
9886
 
7479
- if source == "VERIFICATION" and target == "MEMORY":
9887
+ if source == "QUALITY" and target == "MEMORY":
7480
9888
  task = ensure_verification_checkpoint(
7481
9889
  root, resolved_task_id, task, agent, session_file
7482
9890
  )
@@ -7569,7 +9977,7 @@ def memory_short_complete(
7569
9977
  *(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
7570
9978
  ]
7571
9979
  raise StateError(
7572
- "Short memory must record the complete accepted post-verification decision; "
9980
+ "Short memory must record the complete accepted post-quality decision; "
7573
9981
  "missing: " + ", ".join(missing_labels)
7574
9982
  )
7575
9983
  progress = task.get("memory_progress")
@@ -7689,14 +10097,29 @@ def close_current_task(
7689
10097
  reason: str,
7690
10098
  agent: str,
7691
10099
  session_file: str | Path | None = None,
10100
+ expected_task_id: str | None = None,
7692
10101
  ) -> dict:
7693
10102
  session = ensure_session(root, session_file)
7694
10103
  task_id = session.get("current_task")
7695
10104
  if not task_id:
7696
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
+ )
7697
10110
  task = load_task(root, str(task_id))
7698
10111
  if task is None:
7699
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
7700
10123
  if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
7701
10124
  cancel_shared_tasks(root, str(task_id), task, reason, agent)
7702
10125
  if task.get("status") != "CLOSED":
@@ -7994,12 +10417,40 @@ def main() -> int:
7994
10417
  fingerprints_parser.add_argument("--agent", required=True)
7995
10418
  fingerprints_parser.add_argument("--task-id")
7996
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
+
7997
10442
  verification_checkpoint_parser = subcommands.add_parser(
7998
10443
  "verification-checkpoint", parents=[common]
7999
10444
  )
8000
10445
  verification_checkpoint_parser.add_argument("--agent", required=True)
8001
10446
  verification_checkpoint_parser.add_argument("--task-id")
8002
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
+
8003
10454
  inspect_transition_drift_parser = subcommands.add_parser(
8004
10455
  "inspect-transition-drift", parents=[common]
8005
10456
  )
@@ -8012,6 +10463,33 @@ def main() -> int:
8012
10463
  enable_harness_parser = subcommands.add_parser("enable-harness", parents=[common])
8013
10464
  enable_harness_parser.add_argument("--agent", required=True)
8014
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
+
8015
10493
  handoff = subcommands.add_parser("handoff-task", parents=[common])
8016
10494
  handoff.add_argument("--agent", required=True)
8017
10495
  handoff.add_argument("--summary", required=True)
@@ -8107,13 +10585,13 @@ def main() -> int:
8107
10585
  satisfy_dependency.add_argument("--task-id")
8108
10586
 
8109
10587
  args = parser.parse_args()
10588
+ command_lock: Path | None = None
8110
10589
  try:
8111
10590
  root = resolve_root(getattr(args, "cwd", None))
8112
10591
  session_file = getattr(args, "session_file", None)
8113
10592
  command = args.command or "snapshot"
8114
- agent = normalize_agent_identity(
8115
- getattr(args, "agent", None) or detect_runtime_agent()
8116
- )
10593
+ agent = resolve_state_agent(getattr(args, "agent", None))
10594
+ validate_session_agent(agent, session_file)
8117
10595
  session_agent = normalize_session_agent(agent)
8118
10596
  visible_agent = None if agent == "unknown" else agent
8119
10597
  if session_file is None and command == "project-init-complete":
@@ -8132,6 +10610,10 @@ def main() -> int:
8132
10610
  "Cannot resolve the logical session. Pass --session-file or --agent."
8133
10611
  )
8134
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
+ )
8135
10617
  if command == "snapshot":
8136
10618
  emit(snapshot_state(root, session_file))
8137
10619
  elif command == "inspect-dev-spec":
@@ -8446,21 +10928,73 @@ def main() -> int:
8446
10928
  )
8447
10929
  )
8448
10930
  elif command == "evidence-fingerprints":
8449
- session, resolved_task_id, _ = resolve_current_task(
10931
+ session, resolved_task_id, task = resolve_current_task(
8450
10932
  root, args.task_id, session_file
8451
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
+ )
8452
10962
  emit(
8453
10963
  attach_status_context(
8454
10964
  root,
8455
10965
  {
8456
10966
  "task_id": resolved_task_id,
8457
- **evidence_fingerprints(root, resolved_task_id),
10967
+ **fingerprints,
10968
+ **(
10969
+ {"quality_attempt": quality_attempt}
10970
+ if quality_attempt is not None
10971
+ else {}
10972
+ ),
8458
10973
  },
8459
10974
  visible_agent,
8460
10975
  session_file,
8461
10976
  )
8462
10977
  )
8463
- 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"}:
8464
10998
  emit(
8465
10999
  attach_status_context(
8466
11000
  root,
@@ -8500,6 +11034,59 @@ def main() -> int:
8500
11034
  session_file,
8501
11035
  )
8502
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
+ )
8503
11090
  elif command == "handoff-task":
8504
11091
  emit(
8505
11092
  attach_status_context(
@@ -8677,6 +11264,8 @@ def main() -> int:
8677
11264
  except (StateError, EasyDevSpecError) as error:
8678
11265
  print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
8679
11266
  return 1
11267
+ finally:
11268
+ release_session_command_lock(command_lock)
8680
11269
 
8681
11270
 
8682
11271
  if __name__ == "__main__":