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.
- package/CHANGELOG.md +72 -1
- package/README.md +32 -25
- package/dist/cli.js +364 -39
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +7 -8
- package/templates/claude/agents/ec-reviewer.md +14 -2
- package/templates/claude/agents/ec-verifier.md +11 -2
- package/templates/codex/agents/ec-implementer.toml +7 -8
- package/templates/codex/agents/ec-reviewer.toml +14 -2
- package/templates/codex/agents/ec-verifier.toml +11 -2
- package/templates/common/bundled-skills/ec-init/SKILL.md +1 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +15 -11
- package/templates/common/skills/ec-analysis/SKILL.md +9 -8
- package/templates/common/skills/ec-config/SKILL.md +2 -2
- package/templates/common/skills/ec-implementing/SKILL.md +38 -31
- package/templates/common/skills/ec-lite/SKILL.md +74 -0
- package/templates/common/skills/ec-no-harness/SKILL.md +3 -0
- package/templates/common/skills/ec-quality/SKILL.md +153 -0
- package/templates/common/skills/ec-task-management/SKILL.md +9 -5
- package/templates/common/skills/ec-tdd-init/SKILL.md +5 -4
- package/templates/common/skills/ec-workflow/SKILL.md +25 -29
- package/templates/main-constraint/AGENTS.md.tpl +27 -15
- package/templates/main-constraint/CLAUDE.md.tpl +24 -15
- package/templates/qoder/agents/ec-implementer.md +7 -8
- package/templates/qoder/agents/ec-reviewer.md +14 -2
- package/templates/qoder/agents/ec-verifier.md +11 -2
- package/templates/runtime/templates/dev-spec-skeleton.md +2 -2
- package/templates/shared-hooks/easy_coding_state.py +2961 -372
- package/templates/claude/agents/ec-fixer.md +0 -37
- package/templates/codex/agents/ec-fixer.toml +0 -26
- package/templates/common/skills/ec-reviewing/SKILL.md +0 -109
- package/templates/common/skills/ec-verification/SKILL.md +0 -177
- package/templates/qoder/agents/ec-fixer.md +0 -37
|
@@ -67,10 +67,8 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
|
67
67
|
"idle": {"INIT"},
|
|
68
68
|
"INIT": {"ANALYSIS", "CLOSED"},
|
|
69
69
|
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
73
|
-
"VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
|
|
70
|
+
"IMPLEMENT": {"QUALITY", "ANALYSIS", "CLOSED"},
|
|
71
|
+
"QUALITY": {"MEMORY", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
74
72
|
"MEMORY": {"COMPLETE", "CLOSED"},
|
|
75
73
|
"COMPLETE": set(),
|
|
76
74
|
"CLOSED": set(),
|
|
@@ -80,8 +78,6 @@ ALWAYS_AUTO_TRANSITIONS = {
|
|
|
80
78
|
("INIT", "ANALYSIS"),
|
|
81
79
|
("MEMORY", "COMPLETE"),
|
|
82
80
|
}
|
|
83
|
-
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
84
|
-
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
85
81
|
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
86
82
|
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
87
83
|
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
@@ -89,6 +85,20 @@ WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
|
89
85
|
WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
|
|
90
86
|
STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
|
|
91
87
|
REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
|
|
88
|
+
QUALITY_GATE_STATUSES = {"passed", "failed", "cancelled"}
|
|
89
|
+
QUALITY_FAILURE_CLASSES = {
|
|
90
|
+
"code-defect",
|
|
91
|
+
"test-defect",
|
|
92
|
+
"contract-ambiguity",
|
|
93
|
+
"environment",
|
|
94
|
+
"suggestion",
|
|
95
|
+
}
|
|
96
|
+
QUALITY_CANCELLATION_REASONS = {
|
|
97
|
+
"implementation-drift",
|
|
98
|
+
"config-drift",
|
|
99
|
+
"manual-return",
|
|
100
|
+
"task-closed",
|
|
101
|
+
}
|
|
92
102
|
HIGH_WORKFLOW_RISK_PATTERN = re.compile(
|
|
93
103
|
r"(\bhigh[-_ ]?risk\b|\bcritical\b|\bsevere\b|\birreversible\b|"
|
|
94
104
|
r"\bdata[-_ ]?loss\b|\bfinancial[-_ ]?loss\b|"
|
|
@@ -123,12 +133,14 @@ JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
|
123
133
|
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
124
134
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
125
135
|
("ANALYSIS", "IMPLEMENT"),
|
|
126
|
-
("
|
|
136
|
+
("QUALITY", "MEMORY"),
|
|
127
137
|
}
|
|
128
138
|
ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
|
|
129
139
|
|
|
130
140
|
LEGACY_STAGE_MAP = {
|
|
131
141
|
"WAITING_CONFIRM": "ANALYSIS",
|
|
142
|
+
"REVIEW": "QUALITY",
|
|
143
|
+
"VERIFICATION": "QUALITY",
|
|
132
144
|
"MEMORY_SHORT": "MEMORY",
|
|
133
145
|
"MEMORY_LONG": "MEMORY",
|
|
134
146
|
}
|
|
@@ -143,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
|
-
|
|
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
|
|
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
|
|
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
|
|
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":
|
|
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"] =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2759
|
-
|
|
2760
|
-
|
|
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
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
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
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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
|
-
|
|
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
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
)
|
|
2812
|
-
|
|
2813
|
-
|
|
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("
|
|
4265
|
+
checkpoint = task.get("quality_checkpoint")
|
|
2982
4266
|
if not isinstance(checkpoint, dict):
|
|
2983
|
-
raise StateError("
|
|
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("
|
|
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("
|
|
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("
|
|
4277
|
+
raise StateError("Quality checkpoint snapshot is missing or invalid.")
|
|
2994
4278
|
if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
|
|
2995
|
-
raise StateError("
|
|
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("
|
|
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("
|
|
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
|
|
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("
|
|
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("
|
|
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") != "
|
|
3190
|
-
raise StateError("
|
|
3191
|
-
if isinstance(task.get("
|
|
4474
|
+
if task.get("status") != "QUALITY":
|
|
4475
|
+
raise StateError("Quality checkpoint can only be recorded during QUALITY.")
|
|
4476
|
+
if isinstance(task.get("quality_checkpoint"), dict):
|
|
4477
|
+
require_checkpoint_quality_record(root, resolved_task_id, task)
|
|
3192
4478
|
load_acceptance_snapshot(root, task)
|
|
3193
4479
|
result = snapshot_state(root, session_file, session)
|
|
3194
|
-
result["action"] = "
|
|
3195
|
-
result["
|
|
4480
|
+
result["action"] = "quality-checkpoint"
|
|
4481
|
+
result["quality_checkpoint"] = task["quality_checkpoint"]
|
|
3196
4482
|
result["checkpoint_unchanged"] = True
|
|
3197
4483
|
return result
|
|
3198
|
-
|
|
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["
|
|
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"] = "
|
|
3216
|
-
result["
|
|
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("
|
|
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
|
|
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") != "
|
|
3268
|
-
raise StateError("Transition drift can only be inspected during
|
|
4570
|
+
if task.get("status") != "QUALITY":
|
|
4571
|
+
raise StateError("Transition drift can only be inspected during QUALITY.")
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
if
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
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
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
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
|
-
|
|
4025
|
-
|
|
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
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
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
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
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
|
-
|
|
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
|
|
4045
|
-
|
|
5999
|
+
"Canonical QUALITY blocked writeback must belong to the current "
|
|
6000
|
+
f"Harness task and QUALITY attempt: {source_task_id}."
|
|
4046
6001
|
)
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
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
|
-
"
|
|
4061
|
-
f"
|
|
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
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
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
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
if
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4650
|
-
if test_strategy.exists():
|
|
4651
|
-
reasons.append("
|
|
4652
|
-
|
|
4653
|
-
|
|
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"
|
|
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
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
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
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
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
|
-
|
|
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
|
|
5415
|
-
|
|
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
|
-
|
|
5535
|
-
if
|
|
5536
|
-
|
|
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) >=
|
|
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)
|
|
9426
|
+
if len(units) >= 4:
|
|
7093
9427
|
standard_reasons.append("multiple-units")
|
|
7094
|
-
if len(files) >
|
|
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 == "
|
|
9526
|
+
if stage == "QUALITY":
|
|
7195
9527
|
raise StateError(
|
|
7196
|
-
"Return to IMPLEMENT before raising workflow mode from
|
|
7197
|
-
"task can re-enter
|
|
9528
|
+
"Return to IMPLEMENT before raising workflow mode from QUALITY so the "
|
|
9529
|
+
"task can re-enter QUALITY with fresh evidence."
|
|
7198
9530
|
)
|
|
7199
|
-
if stage
|
|
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 == "
|
|
7258
|
-
|
|
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 == "
|
|
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
|
|
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
|
-
"
|
|
9612
|
+
"Quality metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7272
9613
|
)
|
|
7273
9614
|
if acceptance_drift["status"] == "clean":
|
|
7274
|
-
|
|
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
|
|
9701
|
+
{"blocked"} if previous == "QUALITY" else None,
|
|
9702
|
+
repair_source_task_ids,
|
|
7330
9703
|
)
|
|
7331
|
-
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7360
|
-
|
|
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 == "
|
|
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
|
|
9805
|
+
"Behavior config changed after quality checks; rerun QUALITY before MEMORY."
|
|
7412
9806
|
)
|
|
7413
9807
|
if drift["metadata_changed"]:
|
|
7414
9808
|
raise StateError(
|
|
7415
|
-
"
|
|
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": "
|
|
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 == "
|
|
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-
|
|
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 =
|
|
8115
|
-
|
|
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,
|
|
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
|
-
**
|
|
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 == "
|
|
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__":
|