easy-coding-harness 0.10.0-beta.7 → 0.10.0-beta.9
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 +33 -0
- package/README.md +10 -3
- package/dist/cli.js +95 -5
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +10 -4
- package/templates/common/skills/ec-config/SKILL.md +6 -0
- package/templates/common/skills/ec-implementing/SKILL.md +3 -1
- package/templates/common/skills/ec-memory/SKILL.md +7 -0
- package/templates/common/skills/ec-verification/SKILL.md +45 -9
- package/templates/common/skills/ec-workflow/SKILL.md +25 -2
- package/templates/main-constraint/AGENTS.md.tpl +21 -6
- package/templates/main-constraint/CLAUDE.md.tpl +18 -6
- package/templates/shared-hooks/easy_coding_state.py +1219 -27
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
|
+
import base64
|
|
4
|
+
import difflib
|
|
3
5
|
import hashlib
|
|
4
6
|
import json
|
|
5
7
|
import os
|
|
@@ -139,10 +141,22 @@ ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
|
|
|
139
141
|
ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
|
|
140
142
|
# MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
|
|
141
143
|
ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
|
|
144
|
+
ACCEPTANCE_SNAPSHOT_SCHEMA = 1
|
|
145
|
+
ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
|
|
142
146
|
SESSION_STALE_THRESHOLD_HOURS = 30 * 24
|
|
143
147
|
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
148
|
+
WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
|
|
149
|
+
# 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
|
|
150
|
+
INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
|
|
144
151
|
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
145
152
|
CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
|
|
153
|
+
LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
154
|
+
"claude with easy coding": "claude-code",
|
|
155
|
+
"claude-code with easy coding": "claude-code",
|
|
156
|
+
"claude code with easy coding": "claude-code",
|
|
157
|
+
"codex with easy coding": "codex",
|
|
158
|
+
"qoder with easy coding": "qoder",
|
|
159
|
+
}
|
|
146
160
|
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
147
161
|
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
148
162
|
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
@@ -222,14 +236,25 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
|
|
|
222
236
|
return (2, memory_id)
|
|
223
237
|
|
|
224
238
|
|
|
225
|
-
def
|
|
239
|
+
def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
|
|
226
240
|
raw_agent = str(agent or "unknown").strip()
|
|
227
241
|
normalized = raw_agent.lower()
|
|
228
242
|
# Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
|
|
229
243
|
if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
|
|
230
244
|
return "codex"
|
|
231
|
-
if normalized in
|
|
245
|
+
if normalized in WORKFLOW_AGENT_IDENTITIES:
|
|
232
246
|
return normalized
|
|
247
|
+
if allow_legacy_display:
|
|
248
|
+
return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def normalize_agent_identity(agent: str | None) -> str:
|
|
253
|
+
raw_agent = str(agent or "unknown").strip()
|
|
254
|
+
# 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
|
|
255
|
+
canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
|
|
256
|
+
if canonical is not None:
|
|
257
|
+
return canonical
|
|
233
258
|
return raw_agent
|
|
234
259
|
|
|
235
260
|
|
|
@@ -243,6 +268,9 @@ def agents_equivalent(first: str | None, second: str | None) -> bool:
|
|
|
243
268
|
|
|
244
269
|
|
|
245
270
|
def detect_runtime_agent() -> str:
|
|
271
|
+
if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
|
|
272
|
+
return INSTALLED_WORKFLOW_AGENT
|
|
273
|
+
# 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
|
|
246
274
|
script_path = Path(sys.argv[0]).as_posix()
|
|
247
275
|
if ".qoder/" in script_path or ".qodercn/" in script_path:
|
|
248
276
|
return "qoder"
|
|
@@ -258,6 +286,45 @@ def detect_runtime_agent() -> str:
|
|
|
258
286
|
return "unknown"
|
|
259
287
|
|
|
260
288
|
|
|
289
|
+
def resolve_state_agent(explicit_agent: str | None) -> str:
|
|
290
|
+
runtime_agent = detect_runtime_agent()
|
|
291
|
+
explicit_identity = None
|
|
292
|
+
if explicit_agent is not None:
|
|
293
|
+
explicit_identity = canonical_agent_identity(explicit_agent)
|
|
294
|
+
if explicit_identity is None:
|
|
295
|
+
raise StateError(
|
|
296
|
+
"Workflow --agent must be one of claude-code, codex, or qoder; "
|
|
297
|
+
"display attribution such as 'Codex with Easy Coding' is not an agent identity."
|
|
298
|
+
)
|
|
299
|
+
if runtime_agent in WORKFLOW_AGENT_IDENTITIES:
|
|
300
|
+
if explicit_identity is not None and explicit_identity != runtime_agent:
|
|
301
|
+
raise StateError(
|
|
302
|
+
f"Workflow agent mismatch: script belongs to {runtime_agent}, "
|
|
303
|
+
f"but --agent resolved to {explicit_identity}. Use the active platform's state script."
|
|
304
|
+
)
|
|
305
|
+
return runtime_agent
|
|
306
|
+
return explicit_identity or "unknown"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
|
|
310
|
+
if session_file is None or agent not in WORKFLOW_AGENT_IDENTITIES:
|
|
311
|
+
return
|
|
312
|
+
session_name = Path(str(session_file)).name
|
|
313
|
+
session_agent = next(
|
|
314
|
+
(
|
|
315
|
+
candidate
|
|
316
|
+
for candidate in WORKFLOW_AGENT_IDENTITIES
|
|
317
|
+
if session_name.startswith(f"{candidate}-")
|
|
318
|
+
),
|
|
319
|
+
None,
|
|
320
|
+
)
|
|
321
|
+
if session_agent is not None and session_agent != agent:
|
|
322
|
+
raise StateError(
|
|
323
|
+
f"Workflow session mismatch: session belongs to {session_agent}, "
|
|
324
|
+
f"but the state operation resolved to {agent}. Use the active session's state script."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
261
328
|
def normalize_session_component(value: str) -> str:
|
|
262
329
|
if (
|
|
263
330
|
value not in {".", ".."}
|
|
@@ -1179,10 +1246,18 @@ def normalize_legacy_stage(stage: object) -> object:
|
|
|
1179
1246
|
|
|
1180
1247
|
|
|
1181
1248
|
def normalize_legacy_task(task: dict) -> bool:
|
|
1182
|
-
"""Normalize
|
|
1249
|
+
"""Normalize legacy task state without touching artifacts outside task.json."""
|
|
1183
1250
|
legacy_status = str(task.get("status") or "")
|
|
1184
1251
|
changed = False
|
|
1185
1252
|
|
|
1253
|
+
for field in ("created_by", "last_agent"):
|
|
1254
|
+
normalized_agent = canonical_agent_identity(
|
|
1255
|
+
task.get(field), allow_legacy_display=True
|
|
1256
|
+
)
|
|
1257
|
+
if normalized_agent is not None and normalized_agent != task.get(field):
|
|
1258
|
+
task[field] = normalized_agent
|
|
1259
|
+
changed = True
|
|
1260
|
+
|
|
1186
1261
|
if legacy_status in LEGACY_STAGE_MAP:
|
|
1187
1262
|
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
1188
1263
|
changed = True
|
|
@@ -1198,6 +1273,12 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
1198
1273
|
if mapped_stage != entry.get("stage"):
|
|
1199
1274
|
entry["stage"] = mapped_stage
|
|
1200
1275
|
changed = True
|
|
1276
|
+
normalized_agent = canonical_agent_identity(
|
|
1277
|
+
entry.get("agent"), allow_legacy_display=True
|
|
1278
|
+
)
|
|
1279
|
+
if normalized_agent is not None and normalized_agent != entry.get("agent"):
|
|
1280
|
+
entry["agent"] = normalized_agent
|
|
1281
|
+
changed = True
|
|
1201
1282
|
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
1202
1283
|
changed = True
|
|
1203
1284
|
continue
|
|
@@ -1312,7 +1393,12 @@ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
|
|
|
1312
1393
|
if "stage_history" not in task or not task["stage_history"]:
|
|
1313
1394
|
task["stage_history"] = old_state.get("stage_history", [])
|
|
1314
1395
|
if "last_agent" not in task or not task["last_agent"]:
|
|
1315
|
-
task["last_agent"] =
|
|
1396
|
+
task["last_agent"] = (
|
|
1397
|
+
canonical_agent_identity(
|
|
1398
|
+
old_state.get("last_agent"), allow_legacy_display=True
|
|
1399
|
+
)
|
|
1400
|
+
or agent
|
|
1401
|
+
)
|
|
1316
1402
|
if old_state.get("confirmed_by_user"):
|
|
1317
1403
|
task["confirmed_by_user"] = True
|
|
1318
1404
|
if old_state.get("test_strategy_confirmed"):
|
|
@@ -2751,6 +2837,665 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
|
|
|
2751
2837
|
}
|
|
2752
2838
|
|
|
2753
2839
|
|
|
2840
|
+
def acceptance_snapshot_path(root: Path, task_id: str) -> Path:
|
|
2841
|
+
assert_safe_task_id(task_id)
|
|
2842
|
+
return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
def canonical_json_sha256(value: object) -> str:
|
|
2846
|
+
payload = json.dumps(
|
|
2847
|
+
value,
|
|
2848
|
+
ensure_ascii=False,
|
|
2849
|
+
sort_keys=True,
|
|
2850
|
+
separators=(",", ":"),
|
|
2851
|
+
).encode("utf-8")
|
|
2852
|
+
return hashlib.sha256(payload).hexdigest()
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
|
|
2856
|
+
plan = latest_execution_plan(root, task_id)
|
|
2857
|
+
if plan is None:
|
|
2858
|
+
raise StateError("Cannot fingerprint verification contract without a valid plan.")
|
|
2859
|
+
source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
|
|
2860
|
+
contract = {
|
|
2861
|
+
"workflow_mode": task.get("workflow_mode"),
|
|
2862
|
+
"tdd_enabled": task.get("tdd_enabled"),
|
|
2863
|
+
"tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
|
|
2864
|
+
"tdd_baselines": task.get("tdd_baselines"),
|
|
2865
|
+
"plan": plan,
|
|
2866
|
+
"canonical": {
|
|
2867
|
+
"schema": source.get("schema"),
|
|
2868
|
+
"spec_id": source.get("spec_id"),
|
|
2869
|
+
"revision": source.get("revision"),
|
|
2870
|
+
"design_sha256": source.get("design_sha256"),
|
|
2871
|
+
"selected_tasks": task.get("selected_spec_tasks"),
|
|
2872
|
+
"repository_bindings": task.get("spec_repositories"),
|
|
2873
|
+
"repo_paths": task.get("repo_paths"),
|
|
2874
|
+
}
|
|
2875
|
+
if source
|
|
2876
|
+
else None,
|
|
2877
|
+
}
|
|
2878
|
+
return canonical_json_sha256(contract)
|
|
2879
|
+
|
|
2880
|
+
|
|
2881
|
+
def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
|
|
2882
|
+
pathspecs = repository_scope_pathspecs(repository, scopes)
|
|
2883
|
+
index_entries = git_index_entries(repository, pathspecs)
|
|
2884
|
+
listed = run_git(
|
|
2885
|
+
repository,
|
|
2886
|
+
"ls-files",
|
|
2887
|
+
"--cached",
|
|
2888
|
+
"--others",
|
|
2889
|
+
"--exclude-standard",
|
|
2890
|
+
"-z",
|
|
2891
|
+
"--",
|
|
2892
|
+
*pathspecs,
|
|
2893
|
+
)
|
|
2894
|
+
modified = run_git(
|
|
2895
|
+
repository,
|
|
2896
|
+
"diff-files",
|
|
2897
|
+
"--name-only",
|
|
2898
|
+
"-z",
|
|
2899
|
+
"--ignore-submodules=none",
|
|
2900
|
+
"--",
|
|
2901
|
+
*pathspecs,
|
|
2902
|
+
)
|
|
2903
|
+
if listed is None or listed.returncode != 0 or modified is None or modified.returncode != 0:
|
|
2904
|
+
raise StateError(f"Cannot capture verification snapshot for {repository}.")
|
|
2905
|
+
modified_paths = set(filter(None, modified.stdout.split(b"\0")))
|
|
2906
|
+
raw_paths = set(filter(None, listed.stdout.split(b"\0"))) | set(index_entries)
|
|
2907
|
+
entries: list[dict] = []
|
|
2908
|
+
for raw_path in sorted(raw_paths):
|
|
2909
|
+
relative_name = os.fsdecode(raw_path)
|
|
2910
|
+
if is_easy_coding_state_path(repository, relative_name, scopes):
|
|
2911
|
+
continue
|
|
2912
|
+
candidate = repository / relative_name
|
|
2913
|
+
index_entry = index_entries.get(raw_path)
|
|
2914
|
+
if index_entry is not None and index_entry[0] == b"160000":
|
|
2915
|
+
entries.append(
|
|
2916
|
+
{
|
|
2917
|
+
"path": relative_name,
|
|
2918
|
+
"exists": True,
|
|
2919
|
+
"mode": "160000",
|
|
2920
|
+
"git_oid": index_entry[1].decode("ascii", errors="replace"),
|
|
2921
|
+
"sha256": hashlib.sha256(index_entry[1]).hexdigest(),
|
|
2922
|
+
}
|
|
2923
|
+
)
|
|
2924
|
+
continue
|
|
2925
|
+
exists = candidate.exists() or candidate.is_symlink()
|
|
2926
|
+
if not exists:
|
|
2927
|
+
entries.append(
|
|
2928
|
+
{
|
|
2929
|
+
"path": relative_name,
|
|
2930
|
+
"exists": False,
|
|
2931
|
+
"mode": None,
|
|
2932
|
+
"sha256": None,
|
|
2933
|
+
}
|
|
2934
|
+
)
|
|
2935
|
+
continue
|
|
2936
|
+
try:
|
|
2937
|
+
content = (
|
|
2938
|
+
os.fsencode(os.readlink(candidate))
|
|
2939
|
+
if candidate.is_symlink()
|
|
2940
|
+
else candidate.read_bytes()
|
|
2941
|
+
)
|
|
2942
|
+
except OSError as exc:
|
|
2943
|
+
raise StateError(f"Cannot read verification snapshot file: {relative_name}") from exc
|
|
2944
|
+
mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
|
|
2945
|
+
entry = {
|
|
2946
|
+
"path": relative_name,
|
|
2947
|
+
"exists": True,
|
|
2948
|
+
"mode": mode,
|
|
2949
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
2950
|
+
}
|
|
2951
|
+
if index_entry is not None and raw_path not in modified_paths:
|
|
2952
|
+
entry["git_oid"] = index_entry[1].decode("ascii", errors="replace")
|
|
2953
|
+
else:
|
|
2954
|
+
# 仅无法从 Git object 还原的工作区内容进入被忽略的临时快照。
|
|
2955
|
+
entry["content_b64"] = base64.b64encode(content).decode("ascii")
|
|
2956
|
+
entries.append(entry)
|
|
2957
|
+
return entries
|
|
2958
|
+
|
|
2959
|
+
|
|
2960
|
+
def acceptance_filesystem_repositories(
|
|
2961
|
+
root: Path,
|
|
2962
|
+
plan: dict,
|
|
2963
|
+
git_scopes: list[tuple[Path, list[Path]]],
|
|
2964
|
+
) -> list[dict]:
|
|
2965
|
+
files_by_root: dict[Path, set[Path]] = {}
|
|
2966
|
+
for unit in plan.get("units", []):
|
|
2967
|
+
if not isinstance(unit, dict):
|
|
2968
|
+
continue
|
|
2969
|
+
for file_name in unit.get("files", []):
|
|
2970
|
+
if not is_non_empty_string(file_name):
|
|
2971
|
+
continue
|
|
2972
|
+
raw_path = Path(str(file_name))
|
|
2973
|
+
base = root.resolve()
|
|
2974
|
+
candidate = raw_path if raw_path.is_absolute() else base / raw_path
|
|
2975
|
+
resolved = candidate.resolve()
|
|
2976
|
+
if not raw_path.is_absolute() and not is_path_within(resolved, base):
|
|
2977
|
+
raise StateError(f"Execution plan file escapes project: {file_name}")
|
|
2978
|
+
if any(
|
|
2979
|
+
is_path_within(resolved, scope)
|
|
2980
|
+
for _repository, scopes in git_scopes
|
|
2981
|
+
for scope in scopes
|
|
2982
|
+
):
|
|
2983
|
+
continue
|
|
2984
|
+
snapshot_root = resolved.parent if raw_path.is_absolute() else base
|
|
2985
|
+
files_by_root.setdefault(snapshot_root, set()).add(resolved)
|
|
2986
|
+
|
|
2987
|
+
repositories: list[dict] = []
|
|
2988
|
+
for snapshot_root, files in sorted(
|
|
2989
|
+
files_by_root.items(), key=lambda item: item[0].as_posix()
|
|
2990
|
+
):
|
|
2991
|
+
entries = []
|
|
2992
|
+
for candidate in sorted(files, key=lambda item: item.as_posix()):
|
|
2993
|
+
relative_name = candidate.relative_to(snapshot_root).as_posix()
|
|
2994
|
+
exists = candidate.exists() or candidate.is_symlink()
|
|
2995
|
+
if not exists:
|
|
2996
|
+
entries.append(
|
|
2997
|
+
{
|
|
2998
|
+
"path": relative_name,
|
|
2999
|
+
"exists": False,
|
|
3000
|
+
"mode": None,
|
|
3001
|
+
"sha256": None,
|
|
3002
|
+
}
|
|
3003
|
+
)
|
|
3004
|
+
continue
|
|
3005
|
+
try:
|
|
3006
|
+
content = (
|
|
3007
|
+
os.fsencode(os.readlink(candidate))
|
|
3008
|
+
if candidate.is_symlink()
|
|
3009
|
+
else candidate.read_bytes()
|
|
3010
|
+
)
|
|
3011
|
+
except OSError as exc:
|
|
3012
|
+
raise StateError(
|
|
3013
|
+
f"Cannot read verification snapshot file: {candidate}"
|
|
3014
|
+
) from exc
|
|
3015
|
+
entries.append(
|
|
3016
|
+
{
|
|
3017
|
+
"path": relative_name,
|
|
3018
|
+
"exists": True,
|
|
3019
|
+
"mode": worktree_git_mode(candidate).decode("ascii", errors="replace"),
|
|
3020
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
3021
|
+
"content_b64": base64.b64encode(content).decode("ascii"),
|
|
3022
|
+
}
|
|
3023
|
+
)
|
|
3024
|
+
repositories.append(
|
|
3025
|
+
{
|
|
3026
|
+
"root": str(snapshot_root),
|
|
3027
|
+
"display": display_path(root, snapshot_root),
|
|
3028
|
+
"scopes": [],
|
|
3029
|
+
"entries": entries,
|
|
3030
|
+
}
|
|
3031
|
+
)
|
|
3032
|
+
return repositories
|
|
3033
|
+
|
|
3034
|
+
|
|
3035
|
+
def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
|
|
3036
|
+
plan = latest_execution_plan(root, task_id)
|
|
3037
|
+
if plan is None:
|
|
3038
|
+
raise StateError("Cannot capture verification snapshot without a valid plan.")
|
|
3039
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3040
|
+
repository_scopes = task_repository_scopes(root, task, plan)
|
|
3041
|
+
repositories = []
|
|
3042
|
+
for repository, scopes in repository_scopes:
|
|
3043
|
+
repositories.append(
|
|
3044
|
+
{
|
|
3045
|
+
"root": str(repository.resolve()),
|
|
3046
|
+
"display": display_path(root, repository.resolve()),
|
|
3047
|
+
"scopes": [
|
|
3048
|
+
scope.relative_to(repository.resolve()).as_posix() for scope in scopes
|
|
3049
|
+
],
|
|
3050
|
+
"entries": acceptance_repository_entries(repository.resolve(), scopes),
|
|
3051
|
+
}
|
|
3052
|
+
)
|
|
3053
|
+
repositories.extend(acceptance_filesystem_repositories(root, plan, repository_scopes))
|
|
3054
|
+
return {
|
|
3055
|
+
"schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
|
|
3056
|
+
**fingerprints,
|
|
3057
|
+
"contract_fingerprint": verification_contract_fingerprint(root, task_id, task),
|
|
3058
|
+
"repositories": repositories,
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
|
|
3062
|
+
def load_acceptance_snapshot(root: Path, task: dict) -> dict:
|
|
3063
|
+
checkpoint = task.get("verification_checkpoint")
|
|
3064
|
+
if not isinstance(checkpoint, dict):
|
|
3065
|
+
raise StateError("VERIFICATION has no frozen acceptance checkpoint.")
|
|
3066
|
+
raw_path = checkpoint.get("snapshot_file")
|
|
3067
|
+
if not is_non_empty_string(raw_path):
|
|
3068
|
+
raise StateError("Verification checkpoint has no snapshot file.")
|
|
3069
|
+
candidate = (root / str(raw_path)).resolve()
|
|
3070
|
+
sessions_root = (root / ".easy-coding" / "sessions").resolve()
|
|
3071
|
+
if not is_path_within(candidate, sessions_root):
|
|
3072
|
+
raise StateError("Verification checkpoint snapshot escapes .easy-coding/sessions.")
|
|
3073
|
+
snapshot = load_json(candidate)
|
|
3074
|
+
if not isinstance(snapshot, dict) or snapshot.get("schema") != ACCEPTANCE_SNAPSHOT_SCHEMA:
|
|
3075
|
+
raise StateError("Verification checkpoint snapshot is missing or invalid.")
|
|
3076
|
+
if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
|
|
3077
|
+
raise StateError("Verification checkpoint snapshot fingerprint changed.")
|
|
3078
|
+
if (
|
|
3079
|
+
snapshot.get("implementation_fingerprint")
|
|
3080
|
+
!= checkpoint.get("implementation_fingerprint")
|
|
3081
|
+
or snapshot.get("config_fingerprint") != checkpoint.get("config_fingerprint")
|
|
3082
|
+
or snapshot.get("contract_fingerprint") != checkpoint.get("contract_fingerprint")
|
|
3083
|
+
):
|
|
3084
|
+
raise StateError("Verification checkpoint metadata does not match its snapshot.")
|
|
3085
|
+
return snapshot
|
|
3086
|
+
|
|
3087
|
+
|
|
3088
|
+
def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None:
|
|
3089
|
+
if not isinstance(entry, dict) or entry.get("exists") is not True:
|
|
3090
|
+
return None
|
|
3091
|
+
encoded = entry.get("content_b64")
|
|
3092
|
+
if isinstance(encoded, str):
|
|
3093
|
+
try:
|
|
3094
|
+
return base64.b64decode(encoded, validate=True)
|
|
3095
|
+
except ValueError as exc:
|
|
3096
|
+
raise StateError("Verification checkpoint contains invalid file content.") from exc
|
|
3097
|
+
object_id = entry.get("git_oid")
|
|
3098
|
+
if not is_non_empty_string(object_id):
|
|
3099
|
+
return None
|
|
3100
|
+
if entry.get("mode") == "160000":
|
|
3101
|
+
return str(object_id).encode("ascii", errors="replace")
|
|
3102
|
+
result = run_git(repository, "cat-file", "blob", str(object_id))
|
|
3103
|
+
if result is None or result.returncode != 0:
|
|
3104
|
+
raise StateError(f"Cannot restore verification checkpoint Git object: {object_id}")
|
|
3105
|
+
return result.stdout
|
|
3106
|
+
|
|
3107
|
+
|
|
3108
|
+
def acceptance_snapshot_entries(snapshot: dict) -> dict[tuple[str, str], tuple[Path, dict]]:
|
|
3109
|
+
entries: dict[tuple[str, str], tuple[Path, dict]] = {}
|
|
3110
|
+
for repository in snapshot.get("repositories", []):
|
|
3111
|
+
if not isinstance(repository, dict) or not is_non_empty_string(repository.get("root")):
|
|
3112
|
+
continue
|
|
3113
|
+
repository_root = Path(str(repository["root"]))
|
|
3114
|
+
for entry in repository.get("entries", []):
|
|
3115
|
+
if isinstance(entry, dict) and is_non_empty_string(entry.get("path")):
|
|
3116
|
+
entries[(str(repository_root), str(entry["path"]))] = (repository_root, entry)
|
|
3117
|
+
return entries
|
|
3118
|
+
|
|
3119
|
+
|
|
3120
|
+
def acceptance_change_patch(
|
|
3121
|
+
path_name: str,
|
|
3122
|
+
previous: bytes | None,
|
|
3123
|
+
current: bytes | None,
|
|
3124
|
+
) -> tuple[bool, str]:
|
|
3125
|
+
if (previous is not None and b"\0" in previous) or (current is not None and b"\0" in current):
|
|
3126
|
+
return True, ""
|
|
3127
|
+
try:
|
|
3128
|
+
previous_text = previous.decode("utf-8") if previous is not None else ""
|
|
3129
|
+
current_text = current.decode("utf-8") if current is not None else ""
|
|
3130
|
+
except UnicodeDecodeError:
|
|
3131
|
+
return True, ""
|
|
3132
|
+
patch = "".join(
|
|
3133
|
+
difflib.unified_diff(
|
|
3134
|
+
previous_text.splitlines(keepends=True),
|
|
3135
|
+
current_text.splitlines(keepends=True),
|
|
3136
|
+
fromfile=f"a/{path_name}" if previous is not None else "/dev/null",
|
|
3137
|
+
tofile=f"b/{path_name}" if current is not None else "/dev/null",
|
|
3138
|
+
)
|
|
3139
|
+
)
|
|
3140
|
+
return False, patch
|
|
3141
|
+
|
|
3142
|
+
|
|
3143
|
+
def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
|
|
3144
|
+
checkpoint = task.get("verification_checkpoint")
|
|
3145
|
+
baseline = load_acceptance_snapshot(root, task)
|
|
3146
|
+
current = build_acceptance_snapshot(root, task_id, task)
|
|
3147
|
+
baseline_entries = acceptance_snapshot_entries(baseline)
|
|
3148
|
+
current_entries = acceptance_snapshot_entries(current)
|
|
3149
|
+
changes: list[dict] = []
|
|
3150
|
+
digest_changes: list[dict] = []
|
|
3151
|
+
nested_repository_changed = False
|
|
3152
|
+
for key in sorted(set(baseline_entries) | set(current_entries)):
|
|
3153
|
+
previous_repository, previous_entry = baseline_entries.get(key, (Path(key[0]), None))
|
|
3154
|
+
current_repository, current_entry = current_entries.get(key, (Path(key[0]), None))
|
|
3155
|
+
if (
|
|
3156
|
+
isinstance(previous_entry, dict)
|
|
3157
|
+
and isinstance(current_entry, dict)
|
|
3158
|
+
and previous_entry.get("exists") == current_entry.get("exists")
|
|
3159
|
+
and previous_entry.get("mode") == current_entry.get("mode")
|
|
3160
|
+
and previous_entry.get("sha256") == current_entry.get("sha256")
|
|
3161
|
+
):
|
|
3162
|
+
continue
|
|
3163
|
+
repository = current_repository if isinstance(current_entry, dict) else previous_repository
|
|
3164
|
+
previous_content = snapshot_entry_content(previous_repository, previous_entry)
|
|
3165
|
+
current_content = snapshot_entry_content(current_repository, current_entry)
|
|
3166
|
+
binary, patch = acceptance_change_patch(key[1], previous_content, current_content)
|
|
3167
|
+
change_type = (
|
|
3168
|
+
"added"
|
|
3169
|
+
if previous_content is None and current_content is not None
|
|
3170
|
+
else "deleted"
|
|
3171
|
+
if previous_content is not None and current_content is None
|
|
3172
|
+
else "modified"
|
|
3173
|
+
)
|
|
3174
|
+
label = f"{display_path(root, repository)}:{key[1]}"
|
|
3175
|
+
detail = {
|
|
3176
|
+
"file": label,
|
|
3177
|
+
"repository": display_path(root, repository),
|
|
3178
|
+
"path": key[1],
|
|
3179
|
+
"change_type": change_type,
|
|
3180
|
+
"old_mode": previous_entry.get("mode") if isinstance(previous_entry, dict) else None,
|
|
3181
|
+
"new_mode": current_entry.get("mode") if isinstance(current_entry, dict) else None,
|
|
3182
|
+
"old_sha256": previous_entry.get("sha256")
|
|
3183
|
+
if isinstance(previous_entry, dict)
|
|
3184
|
+
else None,
|
|
3185
|
+
"new_sha256": current_entry.get("sha256")
|
|
3186
|
+
if isinstance(current_entry, dict)
|
|
3187
|
+
else None,
|
|
3188
|
+
"binary": binary,
|
|
3189
|
+
"patch": patch,
|
|
3190
|
+
}
|
|
3191
|
+
if detail["old_mode"] == "160000" or detail["new_mode"] == "160000":
|
|
3192
|
+
nested_repository_changed = True
|
|
3193
|
+
changes.append(detail)
|
|
3194
|
+
digest_changes.append(
|
|
3195
|
+
{key_name: value for key_name, value in detail.items() if key_name != "patch"}
|
|
3196
|
+
)
|
|
3197
|
+
current_implementation = str(current["implementation_fingerprint"])
|
|
3198
|
+
baseline_implementation = str(checkpoint["implementation_fingerprint"])
|
|
3199
|
+
config_changed = current.get("config_fingerprint") != checkpoint.get("config_fingerprint")
|
|
3200
|
+
contract_changed = current.get("contract_fingerprint") != checkpoint.get(
|
|
3201
|
+
"contract_fingerprint"
|
|
3202
|
+
)
|
|
3203
|
+
metadata_changed = bool(
|
|
3204
|
+
contract_changed
|
|
3205
|
+
or nested_repository_changed
|
|
3206
|
+
or (current_implementation != baseline_implementation and not changes)
|
|
3207
|
+
)
|
|
3208
|
+
metadata_reasons = [
|
|
3209
|
+
reason
|
|
3210
|
+
for condition, reason in (
|
|
3211
|
+
(contract_changed, "verification-contract-changed"),
|
|
3212
|
+
(nested_repository_changed, "nested-repository-changed"),
|
|
3213
|
+
(
|
|
3214
|
+
current_implementation != baseline_implementation
|
|
3215
|
+
and not changes
|
|
3216
|
+
and not contract_changed,
|
|
3217
|
+
"unclassified-implementation-drift",
|
|
3218
|
+
),
|
|
3219
|
+
)
|
|
3220
|
+
if condition
|
|
3221
|
+
]
|
|
3222
|
+
digest_payload = {
|
|
3223
|
+
"from": baseline_implementation,
|
|
3224
|
+
"to": current_implementation,
|
|
3225
|
+
"config_changed": config_changed,
|
|
3226
|
+
"metadata_changed": metadata_changed,
|
|
3227
|
+
"changes": digest_changes,
|
|
3228
|
+
}
|
|
3229
|
+
return {
|
|
3230
|
+
"status": "drift" if changes or config_changed or metadata_changed else "clean",
|
|
3231
|
+
"from_implementation_fingerprint": baseline_implementation,
|
|
3232
|
+
"implementation_fingerprint": current_implementation,
|
|
3233
|
+
"config_fingerprint": str(current["config_fingerprint"]),
|
|
3234
|
+
"config_changed": config_changed,
|
|
3235
|
+
"metadata_changed": metadata_changed,
|
|
3236
|
+
"metadata_reasons": metadata_reasons,
|
|
3237
|
+
"diff_sha256": canonical_json_sha256(digest_payload),
|
|
3238
|
+
"changed_files": [str(change["file"]) for change in changes],
|
|
3239
|
+
"changes": changes,
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
|
|
3243
|
+
def cleanup_verification_checkpoint(root: Path, task_id: str, task: dict) -> None:
|
|
3244
|
+
checkpoint = task.pop("verification_checkpoint", None)
|
|
3245
|
+
if not isinstance(checkpoint, dict):
|
|
3246
|
+
return
|
|
3247
|
+
raw_path = checkpoint.get("snapshot_file")
|
|
3248
|
+
if not is_non_empty_string(raw_path):
|
|
3249
|
+
return
|
|
3250
|
+
candidate = (root / str(raw_path)).resolve()
|
|
3251
|
+
sessions_root = (root / ".easy-coding" / "sessions").resolve()
|
|
3252
|
+
if not is_path_within(candidate, sessions_root):
|
|
3253
|
+
return
|
|
3254
|
+
try:
|
|
3255
|
+
candidate.unlink()
|
|
3256
|
+
except FileNotFoundError:
|
|
3257
|
+
pass
|
|
3258
|
+
try:
|
|
3259
|
+
candidate.parent.rmdir()
|
|
3260
|
+
except OSError:
|
|
3261
|
+
pass
|
|
3262
|
+
|
|
3263
|
+
|
|
3264
|
+
def record_verification_checkpoint(
|
|
3265
|
+
root: Path,
|
|
3266
|
+
agent: str,
|
|
3267
|
+
task_id: str | None = None,
|
|
3268
|
+
session_file: str | Path | None = None,
|
|
3269
|
+
) -> dict:
|
|
3270
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3271
|
+
if task.get("status") != "VERIFICATION":
|
|
3272
|
+
raise StateError("Verification checkpoint can only be recorded during VERIFICATION.")
|
|
3273
|
+
if isinstance(task.get("verification_checkpoint"), dict):
|
|
3274
|
+
load_acceptance_snapshot(root, task)
|
|
3275
|
+
result = snapshot_state(root, session_file, session)
|
|
3276
|
+
result["action"] = "verification-checkpoint"
|
|
3277
|
+
result["verification_checkpoint"] = task["verification_checkpoint"]
|
|
3278
|
+
result["checkpoint_unchanged"] = True
|
|
3279
|
+
return result
|
|
3280
|
+
validate_verification_readiness(root, resolved_task_id, task)
|
|
3281
|
+
snapshot = build_acceptance_snapshot(root, resolved_task_id, task)
|
|
3282
|
+
path = acceptance_snapshot_path(root, resolved_task_id)
|
|
3283
|
+
write_json(path, snapshot)
|
|
3284
|
+
task["verification_checkpoint"] = {
|
|
3285
|
+
"schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
|
|
3286
|
+
"implementation_fingerprint": snapshot["implementation_fingerprint"],
|
|
3287
|
+
"config_fingerprint": snapshot["config_fingerprint"],
|
|
3288
|
+
"contract_fingerprint": snapshot["contract_fingerprint"],
|
|
3289
|
+
"snapshot_file": display_path(root, path),
|
|
3290
|
+
"snapshot_sha256": canonical_json_sha256(snapshot),
|
|
3291
|
+
"recorded_at": now_iso(),
|
|
3292
|
+
"recorded_by": agent,
|
|
3293
|
+
}
|
|
3294
|
+
task["last_agent"] = agent
|
|
3295
|
+
write_task(root, resolved_task_id, task)
|
|
3296
|
+
result = snapshot_state(root, session_file, session)
|
|
3297
|
+
result["action"] = "verification-checkpoint"
|
|
3298
|
+
result["verification_checkpoint"] = task["verification_checkpoint"]
|
|
3299
|
+
return result
|
|
3300
|
+
|
|
3301
|
+
|
|
3302
|
+
def latest_acceptance_record(root: Path, task_id: str, task: dict) -> dict | None:
|
|
3303
|
+
latest_implement = max(
|
|
3304
|
+
(
|
|
3305
|
+
str(entry.get("entered_at"))
|
|
3306
|
+
for entry in task.get("stage_history", [])
|
|
3307
|
+
if isinstance(entry, dict)
|
|
3308
|
+
and entry.get("stage") == "IMPLEMENT"
|
|
3309
|
+
and is_non_empty_string(entry.get("entered_at"))
|
|
3310
|
+
),
|
|
3311
|
+
default="",
|
|
3312
|
+
)
|
|
3313
|
+
latest: dict | None = None
|
|
3314
|
+
for record in execution_records(root, task_id):
|
|
3315
|
+
if record.get("type") != "acceptance" or not is_non_empty_string(
|
|
3316
|
+
record.get("timestamp")
|
|
3317
|
+
):
|
|
3318
|
+
continue
|
|
3319
|
+
if latest_implement and str(record["timestamp"]) < latest_implement:
|
|
3320
|
+
continue
|
|
3321
|
+
latest = record
|
|
3322
|
+
return latest
|
|
3323
|
+
|
|
3324
|
+
|
|
3325
|
+
def ensure_verification_checkpoint(
|
|
3326
|
+
root: Path,
|
|
3327
|
+
task_id: str,
|
|
3328
|
+
task: dict,
|
|
3329
|
+
agent: str,
|
|
3330
|
+
session_file: str | Path | None,
|
|
3331
|
+
) -> dict:
|
|
3332
|
+
if isinstance(task.get("verification_checkpoint"), dict):
|
|
3333
|
+
load_acceptance_snapshot(root, task)
|
|
3334
|
+
return task
|
|
3335
|
+
record_verification_checkpoint(root, agent, task_id, session_file)
|
|
3336
|
+
refreshed = load_task(root, task_id)
|
|
3337
|
+
if not isinstance(refreshed, dict):
|
|
3338
|
+
raise StateError(f"Task not found after verification checkpoint: {task_id}")
|
|
3339
|
+
return refreshed
|
|
3340
|
+
|
|
3341
|
+
|
|
3342
|
+
def inspect_transition_drift(
|
|
3343
|
+
root: Path,
|
|
3344
|
+
agent: str,
|
|
3345
|
+
task_id: str | None = None,
|
|
3346
|
+
session_file: str | Path | None = None,
|
|
3347
|
+
) -> dict:
|
|
3348
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3349
|
+
if task.get("status") != "VERIFICATION":
|
|
3350
|
+
raise StateError("Transition drift can only be inspected during VERIFICATION.")
|
|
3351
|
+
task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
|
|
3352
|
+
result = snapshot_state(root, session_file, session)
|
|
3353
|
+
result["acceptance_drift"] = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
3354
|
+
result["action"] = "inspect-transition-drift"
|
|
3355
|
+
return result
|
|
3356
|
+
|
|
3357
|
+
|
|
3358
|
+
def append_transition_acceptance(
|
|
3359
|
+
root: Path,
|
|
3360
|
+
task_id: str,
|
|
3361
|
+
task: dict,
|
|
3362
|
+
agent: str,
|
|
3363
|
+
approval_mode: str,
|
|
3364
|
+
authorization: str,
|
|
3365
|
+
expected_diff_sha256: str | None = None,
|
|
3366
|
+
verification_policy: str | None = None,
|
|
3367
|
+
summary: str | None = None,
|
|
3368
|
+
) -> dict:
|
|
3369
|
+
drift = inspect_acceptance_drift(root, task_id, task)
|
|
3370
|
+
if drift["config_changed"]:
|
|
3371
|
+
raise StateError(
|
|
3372
|
+
"Behavior config changed after verification; rerun verification before MEMORY."
|
|
3373
|
+
)
|
|
3374
|
+
if drift["metadata_changed"]:
|
|
3375
|
+
raise StateError(
|
|
3376
|
+
"Execution plan, workflow, Canonical design, or nested repository state changed "
|
|
3377
|
+
"after verification; return to ANALYSIS or IMPLEMENT instead of accepting it as a code diff."
|
|
3378
|
+
)
|
|
3379
|
+
changed_files = list(drift["changed_files"])
|
|
3380
|
+
if changed_files:
|
|
3381
|
+
if expected_diff_sha256 != drift["diff_sha256"]:
|
|
3382
|
+
raise StateError(
|
|
3383
|
+
"Verified code changed after the acceptance checkpoint. Inspect the exact drift "
|
|
3384
|
+
"and confirm its current diff_sha256 before entering MEMORY."
|
|
3385
|
+
)
|
|
3386
|
+
if verification_policy not in ACCEPTANCE_VERIFICATION_POLICIES:
|
|
3387
|
+
raise StateError(
|
|
3388
|
+
"Accepted code drift requires verification policy carry-forward, targeted, or waived."
|
|
3389
|
+
)
|
|
3390
|
+
review_policy = "user-accepted-without-rereview"
|
|
3391
|
+
else:
|
|
3392
|
+
verification_policy = "current"
|
|
3393
|
+
review_policy = "current"
|
|
3394
|
+
required_targeted_source_tasks = (
|
|
3395
|
+
targeted_source_tasks_for_changes(root, task_id, task, drift["changes"])
|
|
3396
|
+
if verification_policy == "targeted"
|
|
3397
|
+
else []
|
|
3398
|
+
)
|
|
3399
|
+
normalized_summary = (
|
|
3400
|
+
summary.strip()
|
|
3401
|
+
if isinstance(summary, str) and summary.strip()
|
|
3402
|
+
else "User accepted the verified implementation"
|
|
3403
|
+
if authorization == "explicit-user"
|
|
3404
|
+
else f"Approval mode {approval_mode} authorized the verified implementation"
|
|
3405
|
+
)
|
|
3406
|
+
record = {
|
|
3407
|
+
"type": "acceptance",
|
|
3408
|
+
"from_implementation_fingerprint": drift["from_implementation_fingerprint"],
|
|
3409
|
+
"implementation_fingerprint": drift["implementation_fingerprint"],
|
|
3410
|
+
"config_fingerprint": drift["config_fingerprint"],
|
|
3411
|
+
"diff_sha256": drift["diff_sha256"],
|
|
3412
|
+
"changed_files": changed_files,
|
|
3413
|
+
"authorization": authorization,
|
|
3414
|
+
"approval_mode": approval_mode,
|
|
3415
|
+
"review_policy": review_policy,
|
|
3416
|
+
"verification_policy": verification_policy,
|
|
3417
|
+
"required_targeted_source_tasks": required_targeted_source_tasks,
|
|
3418
|
+
"summary": normalized_summary,
|
|
3419
|
+
"recorded_by": agent,
|
|
3420
|
+
"timestamp": now_iso(),
|
|
3421
|
+
}
|
|
3422
|
+
existing = latest_acceptance_record(root, task_id, task)
|
|
3423
|
+
identity_fields = (
|
|
3424
|
+
"from_implementation_fingerprint",
|
|
3425
|
+
"implementation_fingerprint",
|
|
3426
|
+
"config_fingerprint",
|
|
3427
|
+
"diff_sha256",
|
|
3428
|
+
"authorization",
|
|
3429
|
+
"approval_mode",
|
|
3430
|
+
"review_policy",
|
|
3431
|
+
"verification_policy",
|
|
3432
|
+
"required_targeted_source_tasks",
|
|
3433
|
+
"summary",
|
|
3434
|
+
)
|
|
3435
|
+
if not (
|
|
3436
|
+
isinstance(existing, dict)
|
|
3437
|
+
and existing.get("changed_files") == changed_files
|
|
3438
|
+
and all(existing.get(field) == record.get(field) for field in identity_fields)
|
|
3439
|
+
):
|
|
3440
|
+
append_execution_record(root, task_id, record)
|
|
3441
|
+
return record
|
|
3442
|
+
|
|
3443
|
+
|
|
3444
|
+
def targeted_source_tasks_for_changes(
|
|
3445
|
+
root: Path,
|
|
3446
|
+
task_id: str,
|
|
3447
|
+
task: dict,
|
|
3448
|
+
changes: list[dict],
|
|
3449
|
+
) -> list[str]:
|
|
3450
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
3451
|
+
return []
|
|
3452
|
+
plan = latest_execution_plan(root, task_id)
|
|
3453
|
+
repo_paths = task.get("repo_paths")
|
|
3454
|
+
if plan is None or not isinstance(repo_paths, dict):
|
|
3455
|
+
raise StateError("Canonical targeted verification requires a valid repository plan.")
|
|
3456
|
+
|
|
3457
|
+
units_by_repository: dict[str, list[dict]] = {}
|
|
3458
|
+
for unit in plan.get("units", []):
|
|
3459
|
+
if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
|
|
3460
|
+
continue
|
|
3461
|
+
raw_repository = repo_paths.get(str(unit["repo_id"]))
|
|
3462
|
+
if not is_non_empty_string(raw_repository):
|
|
3463
|
+
continue
|
|
3464
|
+
candidate = Path(str(raw_repository))
|
|
3465
|
+
repository = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
3466
|
+
units_by_repository.setdefault(display_path(root, repository), []).append(unit)
|
|
3467
|
+
|
|
3468
|
+
impacted: set[str] = set()
|
|
3469
|
+
for change in changes:
|
|
3470
|
+
if not isinstance(change, dict):
|
|
3471
|
+
continue
|
|
3472
|
+
repository_units = units_by_repository.get(str(change.get("repository") or ""), [])
|
|
3473
|
+
if not repository_units:
|
|
3474
|
+
continue
|
|
3475
|
+
changed_path = str(change.get("path") or "")
|
|
3476
|
+
matched_units = [
|
|
3477
|
+
unit
|
|
3478
|
+
for unit in repository_units
|
|
3479
|
+
if any(
|
|
3480
|
+
changed_path == str(file_name)
|
|
3481
|
+
or changed_path.startswith(str(file_name).rstrip("/") + "/")
|
|
3482
|
+
for file_name in unit.get("files", [])
|
|
3483
|
+
if is_non_empty_string(file_name)
|
|
3484
|
+
)
|
|
3485
|
+
]
|
|
3486
|
+
scoped_units = matched_units or repository_units
|
|
3487
|
+
impacted.update(
|
|
3488
|
+
str(unit["source_task_id"])
|
|
3489
|
+
for unit in scoped_units
|
|
3490
|
+
if is_non_empty_string(unit.get("source_task_id"))
|
|
3491
|
+
)
|
|
3492
|
+
if not impacted:
|
|
3493
|
+
raise StateError(
|
|
3494
|
+
"Canonical executable drift could not be mapped to a selected source task."
|
|
3495
|
+
)
|
|
3496
|
+
return sorted(impacted)
|
|
3497
|
+
|
|
3498
|
+
|
|
2754
3499
|
def command_option_value(command: str, option: str) -> str | None:
|
|
2755
3500
|
try:
|
|
2756
3501
|
tokens = shlex.split(command)
|
|
@@ -2782,6 +3527,68 @@ def coverage_command_matches_frozen_contract(
|
|
|
2782
3527
|
)
|
|
2783
3528
|
|
|
2784
3529
|
|
|
3530
|
+
def current_acceptance_record(
|
|
3531
|
+
root: Path,
|
|
3532
|
+
task_id: str,
|
|
3533
|
+
task: dict,
|
|
3534
|
+
implementation_fingerprint_value: str,
|
|
3535
|
+
config_fingerprint_value: str,
|
|
3536
|
+
) -> dict | None:
|
|
3537
|
+
record = latest_acceptance_record(root, task_id, task)
|
|
3538
|
+
if not isinstance(record, dict):
|
|
3539
|
+
return None
|
|
3540
|
+
if (
|
|
3541
|
+
record.get("implementation_fingerprint") != implementation_fingerprint_value
|
|
3542
|
+
or record.get("config_fingerprint") != config_fingerprint_value
|
|
3543
|
+
or not is_non_empty_string(record.get("from_implementation_fingerprint"))
|
|
3544
|
+
or record.get("review_policy")
|
|
3545
|
+
not in {"current", "user-accepted-without-rereview"}
|
|
3546
|
+
or record.get("verification_policy")
|
|
3547
|
+
not in {"current", *ACCEPTANCE_VERIFICATION_POLICIES}
|
|
3548
|
+
or not is_string_list(record.get("required_targeted_source_tasks"))
|
|
3549
|
+
):
|
|
3550
|
+
return None
|
|
3551
|
+
return record
|
|
3552
|
+
|
|
3553
|
+
|
|
3554
|
+
def accepted_review_fingerprints(
|
|
3555
|
+
root: Path, task_id: str, task: dict, current_fingerprint: str
|
|
3556
|
+
) -> set[str]:
|
|
3557
|
+
accepted = {current_fingerprint}
|
|
3558
|
+
record = current_acceptance_record(
|
|
3559
|
+
root,
|
|
3560
|
+
task_id,
|
|
3561
|
+
task,
|
|
3562
|
+
current_fingerprint,
|
|
3563
|
+
behavior_config_fingerprint(root, task),
|
|
3564
|
+
)
|
|
3565
|
+
if record and record.get("review_policy") == "user-accepted-without-rereview":
|
|
3566
|
+
accepted.add(str(record["from_implementation_fingerprint"]))
|
|
3567
|
+
return accepted
|
|
3568
|
+
|
|
3569
|
+
|
|
3570
|
+
def accepted_verification_fingerprints(
|
|
3571
|
+
root: Path,
|
|
3572
|
+
task_id: str,
|
|
3573
|
+
task: dict,
|
|
3574
|
+
current_implementation: str,
|
|
3575
|
+
current_config: str,
|
|
3576
|
+
) -> tuple[set[str], dict | None]:
|
|
3577
|
+
record = current_acceptance_record(
|
|
3578
|
+
root,
|
|
3579
|
+
task_id,
|
|
3580
|
+
task,
|
|
3581
|
+
current_implementation,
|
|
3582
|
+
current_config,
|
|
3583
|
+
)
|
|
3584
|
+
if not record or record.get("verification_policy") == "current":
|
|
3585
|
+
return {current_implementation}, record
|
|
3586
|
+
previous = str(record["from_implementation_fingerprint"])
|
|
3587
|
+
if record.get("verification_policy") == "targeted":
|
|
3588
|
+
return {previous, current_implementation}, record
|
|
3589
|
+
return {previous}, record
|
|
3590
|
+
|
|
3591
|
+
|
|
2785
3592
|
def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
|
|
2786
3593
|
if not isinstance(task.get("spec_source"), dict):
|
|
2787
3594
|
return
|
|
@@ -2858,11 +3665,12 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
2858
3665
|
if task.get("workflow_mode_legacy") is True and not is_spec_task:
|
|
2859
3666
|
return
|
|
2860
3667
|
expected = implementation_fingerprint(root, task_id)
|
|
3668
|
+
accepted_fingerprints = accepted_review_fingerprints(root, task_id, task, expected)
|
|
2861
3669
|
latest_by_dimension: dict[str, dict] = {}
|
|
2862
3670
|
for record in execution_records(root, task_id):
|
|
2863
3671
|
if (
|
|
2864
3672
|
record.get("type") == "review"
|
|
2865
|
-
and record.get("implementation_fingerprint")
|
|
3673
|
+
and record.get("implementation_fingerprint") in accepted_fingerprints
|
|
2866
3674
|
and is_non_empty_string(record.get("dimension"))
|
|
2867
3675
|
):
|
|
2868
3676
|
dimension = str(record["dimension"])
|
|
@@ -2977,6 +3785,13 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
2977
3785
|
|
|
2978
3786
|
def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
2979
3787
|
fingerprints = evidence_fingerprints(root, task_id)
|
|
3788
|
+
accepted_fingerprints, acceptance = accepted_verification_fingerprints(
|
|
3789
|
+
root,
|
|
3790
|
+
task_id,
|
|
3791
|
+
task,
|
|
3792
|
+
fingerprints["implementation_fingerprint"],
|
|
3793
|
+
fingerprints["config_fingerprint"],
|
|
3794
|
+
)
|
|
2980
3795
|
is_spec_task = isinstance(task.get("spec_source"), dict)
|
|
2981
3796
|
if (
|
|
2982
3797
|
(task.get("workflow_mode_legacy") is not True or is_spec_task)
|
|
@@ -2988,8 +3803,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2988
3803
|
for record in execution_records(root, task_id):
|
|
2989
3804
|
if (
|
|
2990
3805
|
record.get("type") == "verify"
|
|
2991
|
-
and record.get("implementation_fingerprint")
|
|
2992
|
-
== fingerprints["implementation_fingerprint"]
|
|
3806
|
+
and record.get("implementation_fingerprint") in accepted_fingerprints
|
|
2993
3807
|
and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
|
|
2994
3808
|
and is_non_empty_string(record.get("check"))
|
|
2995
3809
|
):
|
|
@@ -3067,6 +3881,32 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
3067
3881
|
raise StateError(
|
|
3068
3882
|
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
3069
3883
|
)
|
|
3884
|
+
if acceptance and acceptance.get("verification_policy") == "targeted":
|
|
3885
|
+
current_records = [
|
|
3886
|
+
record
|
|
3887
|
+
for record in applicable_records
|
|
3888
|
+
if record.get("implementation_fingerprint")
|
|
3889
|
+
== fingerprints["implementation_fingerprint"]
|
|
3890
|
+
]
|
|
3891
|
+
if not current_records or any(record.get("passed") is not True for record in current_records):
|
|
3892
|
+
raise StateError(
|
|
3893
|
+
"Accepted executable drift requires at least one passed targeted verification "
|
|
3894
|
+
"record for the current implementation fingerprint."
|
|
3895
|
+
)
|
|
3896
|
+
if is_spec_task:
|
|
3897
|
+
required_source_tasks = set(acceptance["required_targeted_source_tasks"])
|
|
3898
|
+
current_source_tasks = {
|
|
3899
|
+
str(record.get("source_task_id"))
|
|
3900
|
+
for record in current_records
|
|
3901
|
+
if is_non_empty_string(record.get("source_task_id"))
|
|
3902
|
+
}
|
|
3903
|
+
missing_source_tasks = sorted(required_source_tasks - current_source_tasks)
|
|
3904
|
+
if missing_source_tasks:
|
|
3905
|
+
raise StateError(
|
|
3906
|
+
"Accepted Canonical executable drift requires a passed current-fingerprint "
|
|
3907
|
+
"targeted verification record for affected source tasks: "
|
|
3908
|
+
+ ", ".join(missing_source_tasks)
|
|
3909
|
+
)
|
|
3070
3910
|
if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
|
|
3071
3911
|
readiness = tdd_readiness(root)
|
|
3072
3912
|
if readiness["status"] != "ready":
|
|
@@ -3925,6 +4765,28 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
|
3925
4765
|
return latest
|
|
3926
4766
|
|
|
3927
4767
|
|
|
4768
|
+
def pending_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
4769
|
+
path = execution_log_path(root, task_id)
|
|
4770
|
+
if not path.exists():
|
|
4771
|
+
return None
|
|
4772
|
+
latest_coordination: dict | None = None
|
|
4773
|
+
try:
|
|
4774
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
4775
|
+
if not line.strip():
|
|
4776
|
+
continue
|
|
4777
|
+
try:
|
|
4778
|
+
record = json.loads(line)
|
|
4779
|
+
except json.JSONDecodeError:
|
|
4780
|
+
continue
|
|
4781
|
+
if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
|
|
4782
|
+
latest_coordination = record
|
|
4783
|
+
except OSError:
|
|
4784
|
+
return None
|
|
4785
|
+
if latest_coordination and latest_coordination.get("type") == "handoff":
|
|
4786
|
+
return latest_coordination
|
|
4787
|
+
return None
|
|
4788
|
+
|
|
4789
|
+
|
|
3928
4790
|
def assert_safe_task_id(task_id: str) -> None:
|
|
3929
4791
|
path = Path(task_id)
|
|
3930
4792
|
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
@@ -4154,9 +5016,10 @@ def build_status_line(
|
|
|
4154
5016
|
if task_id:
|
|
4155
5017
|
status = str(state["status"])
|
|
4156
5018
|
line = f"{status_brand} · `{task_id}` · `{status}`"
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
5019
|
+
handoff = pending_handoff_record(root, str(task_id))
|
|
5020
|
+
handoff_from = handoff.get("from") if handoff else None
|
|
5021
|
+
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
5022
|
+
line += f" · Handoff -> `{handoff_from}`"
|
|
4160
5023
|
if state["is_terminal"] or state["task_missing"]:
|
|
4161
5024
|
line += f" · {HELP_SUFFIX}"
|
|
4162
5025
|
return line
|
|
@@ -4203,9 +5066,10 @@ def build_machine_breadcrumbs(
|
|
|
4203
5066
|
lines.append(f"[current-task:{task_id}]")
|
|
4204
5067
|
if state["task_missing"]:
|
|
4205
5068
|
lines.append(f"[easy-coding:current-task-missing:{task_id}]")
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
5069
|
+
handoff = pending_handoff_record(root, str(task_id))
|
|
5070
|
+
handoff_from = handoff.get("from") if handoff else None
|
|
5071
|
+
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
5072
|
+
lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
|
|
4209
5073
|
pending = state.get("pending_transition")
|
|
4210
5074
|
if isinstance(pending, dict):
|
|
4211
5075
|
source = str(pending.get("from") or stage)
|
|
@@ -4223,6 +5087,11 @@ def build_machine_breadcrumbs(
|
|
|
4223
5087
|
lines.append(
|
|
4224
5088
|
"[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
|
|
4225
5089
|
)
|
|
5090
|
+
elif pending.get("confirmation_override") == "evidence-drift":
|
|
5091
|
+
lines.append(
|
|
5092
|
+
"[easy-coding:acceptance-drift-confirmation-required]"
|
|
5093
|
+
)
|
|
5094
|
+
lines.append("[easy-coding:transition-confirmation-required]")
|
|
4226
5095
|
elif is_automatic_transition(
|
|
4227
5096
|
source,
|
|
4228
5097
|
target,
|
|
@@ -4629,11 +5498,21 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
|
|
|
4629
5498
|
session["last_agent"] = agent
|
|
4630
5499
|
write_session(root, session, session_file)
|
|
4631
5500
|
|
|
5501
|
+
claim = {
|
|
5502
|
+
"type": "claim",
|
|
5503
|
+
"agent": agent,
|
|
5504
|
+
"previous_agent": previous_agent,
|
|
5505
|
+
"action": action,
|
|
5506
|
+
"timestamp": now_iso(),
|
|
5507
|
+
}
|
|
5508
|
+
append_execution_record(root, task_id, claim)
|
|
5509
|
+
|
|
4632
5510
|
snapshot = snapshot_state(root, session_file, session)
|
|
4633
5511
|
snapshot["task_id"] = task_id
|
|
4634
5512
|
snapshot["action"] = action
|
|
4635
5513
|
snapshot["previous_agent"] = previous_agent
|
|
4636
5514
|
snapshot["latest_handoff"] = latest_handoff
|
|
5515
|
+
snapshot["claim"] = claim
|
|
4637
5516
|
return snapshot
|
|
4638
5517
|
|
|
4639
5518
|
|
|
@@ -4768,10 +5647,9 @@ SPEC_WRITEBACK_APP = "easy-coding"
|
|
|
4768
5647
|
|
|
4769
5648
|
|
|
4770
5649
|
def spec_writeback_agent(agent: str) -> str:
|
|
4771
|
-
|
|
4772
|
-
if
|
|
4773
|
-
|
|
4774
|
-
normalized = normalize_agent_identity(raw_agent)
|
|
5650
|
+
normalized = canonical_agent_identity(agent)
|
|
5651
|
+
if normalized is None:
|
|
5652
|
+
raise StateError("Canonical Spec attribution requires a canonical workflow agent identity.")
|
|
4775
5653
|
display_name = {
|
|
4776
5654
|
"claude-code": "Claude Code",
|
|
4777
5655
|
"codex": "Codex",
|
|
@@ -5687,6 +6565,7 @@ def sync_spec_design_state(
|
|
|
5687
6565
|
)
|
|
5688
6566
|
progress.pop("pending_action", None)
|
|
5689
6567
|
if task.get("status") not in {"INIT", "ANALYSIS"}:
|
|
6568
|
+
cleanup_verification_checkpoint(root, resolved_task_id, task)
|
|
5690
6569
|
task["status"] = "ANALYSIS"
|
|
5691
6570
|
append_stage_history(task, "ANALYSIS", agent)
|
|
5692
6571
|
task.pop("pending_transition", None)
|
|
@@ -5851,6 +6730,134 @@ def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> N
|
|
|
5851
6730
|
)
|
|
5852
6731
|
|
|
5853
6732
|
|
|
6733
|
+
def effective_verification_records(root: Path, task_id: str, task: dict) -> list[dict]:
|
|
6734
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
6735
|
+
accepted_fingerprints, _ = accepted_verification_fingerprints(
|
|
6736
|
+
root,
|
|
6737
|
+
task_id,
|
|
6738
|
+
task,
|
|
6739
|
+
fingerprints["implementation_fingerprint"],
|
|
6740
|
+
fingerprints["config_fingerprint"],
|
|
6741
|
+
)
|
|
6742
|
+
latest: dict[tuple[str, str, str], dict] = {}
|
|
6743
|
+
for record in execution_records(root, task_id):
|
|
6744
|
+
if (
|
|
6745
|
+
record.get("type") != "verify"
|
|
6746
|
+
or record.get("implementation_fingerprint") not in accepted_fingerprints
|
|
6747
|
+
or record.get("config_fingerprint") != fingerprints["config_fingerprint"]
|
|
6748
|
+
or record.get("applicable") is False
|
|
6749
|
+
):
|
|
6750
|
+
continue
|
|
6751
|
+
key = (
|
|
6752
|
+
str(record.get("source_task_id") or ""),
|
|
6753
|
+
str(record.get("repo_id") or ""),
|
|
6754
|
+
str(record.get("command") or record.get("check") or ""),
|
|
6755
|
+
)
|
|
6756
|
+
latest[key] = record
|
|
6757
|
+
return list(latest.values())
|
|
6758
|
+
|
|
6759
|
+
|
|
6760
|
+
def acceptance_spec_evidence(acceptance: dict) -> dict:
|
|
6761
|
+
digest = str(acceptance.get("diff_sha256") or "")
|
|
6762
|
+
reference = (
|
|
6763
|
+
"execution.jsonl#acceptance="
|
|
6764
|
+
+ digest
|
|
6765
|
+
+ ";authorization="
|
|
6766
|
+
+ str(acceptance.get("authorization") or "")
|
|
6767
|
+
+ ";approval_mode="
|
|
6768
|
+
+ str(acceptance.get("approval_mode") or "")
|
|
6769
|
+
+ ";review_policy="
|
|
6770
|
+
+ str(acceptance.get("review_policy") or "")
|
|
6771
|
+
+ ";verification_policy="
|
|
6772
|
+
+ str(acceptance.get("verification_policy") or "")
|
|
6773
|
+
+ ";targeted_source_tasks="
|
|
6774
|
+
+ ",".join(str(value) for value in acceptance.get("required_targeted_source_tasks", []))
|
|
6775
|
+
)
|
|
6776
|
+
return {
|
|
6777
|
+
"kind": "acceptance",
|
|
6778
|
+
"status": "recorded",
|
|
6779
|
+
"ref": reference,
|
|
6780
|
+
"sha256": digest,
|
|
6781
|
+
}
|
|
6782
|
+
|
|
6783
|
+
|
|
6784
|
+
def writeback_verified_tasks(
|
|
6785
|
+
root: Path,
|
|
6786
|
+
harness_task_id: str,
|
|
6787
|
+
task: dict,
|
|
6788
|
+
agent: str,
|
|
6789
|
+
session_file: str | Path | None = None,
|
|
6790
|
+
) -> None:
|
|
6791
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
6792
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6793
|
+
acceptance = latest_acceptance_record(root, harness_task_id, task)
|
|
6794
|
+
if not isinstance(acceptance, dict):
|
|
6795
|
+
raise StateError("Canonical verification writeback requires an acceptance record.")
|
|
6796
|
+
verification_records = effective_verification_records(root, harness_task_id, task)
|
|
6797
|
+
selected_tasks = {
|
|
6798
|
+
str(item.get("task_id")): item
|
|
6799
|
+
for item in selection.get("selected_tasks", [])
|
|
6800
|
+
if isinstance(item, dict)
|
|
6801
|
+
}
|
|
6802
|
+
tests_by_task: dict[str, list[dict]] = {}
|
|
6803
|
+
for test in selection.get("selected_tests", []):
|
|
6804
|
+
if isinstance(test, dict):
|
|
6805
|
+
tests_by_task.setdefault(str(test.get("task_id")), []).append(test)
|
|
6806
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
6807
|
+
source_task_id = str(source_task_id)
|
|
6808
|
+
status_value = snapshots.get(source_task_id, {}).get("status")
|
|
6809
|
+
if status_value in {"verified", "completed"}:
|
|
6810
|
+
continue
|
|
6811
|
+
if status_value != "implemented":
|
|
6812
|
+
raise StateError(
|
|
6813
|
+
f"Canonical task {source_task_id} must remain implemented until MEMORY entry is applied."
|
|
6814
|
+
)
|
|
6815
|
+
repo_id = str(selected_tasks.get(source_task_id, {}).get("repo_id") or "")
|
|
6816
|
+
evidence = []
|
|
6817
|
+
for test in tests_by_task.get(source_task_id, []):
|
|
6818
|
+
command = str(test.get("command") or "")
|
|
6819
|
+
matching = next(
|
|
6820
|
+
(
|
|
6821
|
+
record
|
|
6822
|
+
for record in verification_records
|
|
6823
|
+
if record.get("passed") is True
|
|
6824
|
+
and str(record.get("source_task_id") or "") == source_task_id
|
|
6825
|
+
and str(record.get("repo_id") or "") == repo_id
|
|
6826
|
+
and str(record.get("command") or "") == command
|
|
6827
|
+
),
|
|
6828
|
+
None,
|
|
6829
|
+
)
|
|
6830
|
+
if matching is None:
|
|
6831
|
+
raise StateError(
|
|
6832
|
+
f"Canonical Test {test.get('test_id')} has no accepted verification command: {command}"
|
|
6833
|
+
)
|
|
6834
|
+
evidence.append(
|
|
6835
|
+
{
|
|
6836
|
+
"kind": "test",
|
|
6837
|
+
"status": "passed",
|
|
6838
|
+
"ref": f"execution.jsonl#verify;command={command}",
|
|
6839
|
+
"test_id": str(test.get("test_id")),
|
|
6840
|
+
}
|
|
6841
|
+
)
|
|
6842
|
+
evidence.append(acceptance_spec_evidence(acceptance))
|
|
6843
|
+
acceptance_key = str(acceptance.get("diff_sha256") or "")[:16]
|
|
6844
|
+
key = (
|
|
6845
|
+
f"{harness_task_id}:{source_task_id}:verified-after-acceptance:"
|
|
6846
|
+
f"{task['spec_source']['revision']}:{acceptance_key}"
|
|
6847
|
+
)
|
|
6848
|
+
writeback_spec_task(
|
|
6849
|
+
root,
|
|
6850
|
+
source_task_id,
|
|
6851
|
+
"verified",
|
|
6852
|
+
"Harness verification was accepted when the MEMORY boundary was applied",
|
|
6853
|
+
evidence,
|
|
6854
|
+
key,
|
|
6855
|
+
agent,
|
|
6856
|
+
harness_task_id,
|
|
6857
|
+
session_file,
|
|
6858
|
+
)
|
|
6859
|
+
|
|
6860
|
+
|
|
5854
6861
|
def writeback_completed_tasks(
|
|
5855
6862
|
root: Path,
|
|
5856
6863
|
harness_task_id: str,
|
|
@@ -5859,6 +6866,10 @@ def writeback_completed_tasks(
|
|
|
5859
6866
|
) -> None:
|
|
5860
6867
|
inspection, _ = inspect_task_spec(root, task)
|
|
5861
6868
|
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6869
|
+
acceptance = latest_acceptance_record(root, harness_task_id, task)
|
|
6870
|
+
acceptance_evidence = (
|
|
6871
|
+
[acceptance_spec_evidence(acceptance)] if isinstance(acceptance, dict) else []
|
|
6872
|
+
)
|
|
5862
6873
|
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
5863
6874
|
status_value = snapshots.get(str(source_task_id), {}).get("status")
|
|
5864
6875
|
if status_value == "completed":
|
|
@@ -5867,13 +6878,21 @@ def writeback_completed_tasks(
|
|
|
5867
6878
|
raise StateError(
|
|
5868
6879
|
f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
|
|
5869
6880
|
)
|
|
5870
|
-
|
|
6881
|
+
acceptance_key = (
|
|
6882
|
+
str(acceptance.get("diff_sha256") or "")[:16]
|
|
6883
|
+
if isinstance(acceptance, dict)
|
|
6884
|
+
else "legacy"
|
|
6885
|
+
)
|
|
6886
|
+
key = (
|
|
6887
|
+
f"{harness_task_id}:{source_task_id}:complete:"
|
|
6888
|
+
f"{task['spec_source']['revision']}:{acceptance_key}"
|
|
6889
|
+
)
|
|
5871
6890
|
action = {
|
|
5872
6891
|
"kind": "task",
|
|
5873
6892
|
"source_task_id": source_task_id,
|
|
5874
6893
|
"status": "completed",
|
|
5875
6894
|
"summary": "Harness MEMORY completed and the Canonical task is complete",
|
|
5876
|
-
"evidence":
|
|
6895
|
+
"evidence": acceptance_evidence,
|
|
5877
6896
|
"idempotency_key": key,
|
|
5878
6897
|
"agent": agent,
|
|
5879
6898
|
}
|
|
@@ -5892,6 +6911,7 @@ def writeback_completed_tasks(
|
|
|
5892
6911
|
spec_writeback_agent(agent),
|
|
5893
6912
|
design_digest,
|
|
5894
6913
|
execution_revision,
|
|
6914
|
+
evidence=acceptance_evidence,
|
|
5895
6915
|
run_id=harness_task_id,
|
|
5896
6916
|
idempotency_key=key,
|
|
5897
6917
|
),
|
|
@@ -6351,8 +7371,22 @@ def request_transition(
|
|
|
6351
7371
|
)
|
|
6352
7372
|
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
6353
7373
|
validate_review_readiness(root, resolved_task_id, task)
|
|
7374
|
+
acceptance_drift: dict | None = None
|
|
6354
7375
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
6355
|
-
|
|
7376
|
+
task = ensure_verification_checkpoint(
|
|
7377
|
+
root, resolved_task_id, task, agent, session_file
|
|
7378
|
+
)
|
|
7379
|
+
acceptance_drift = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
7380
|
+
if acceptance_drift["config_changed"]:
|
|
7381
|
+
raise StateError(
|
|
7382
|
+
"Behavior config changed after verification; rerun verification before MEMORY."
|
|
7383
|
+
)
|
|
7384
|
+
if acceptance_drift["metadata_changed"]:
|
|
7385
|
+
raise StateError(
|
|
7386
|
+
"Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7387
|
+
)
|
|
7388
|
+
if acceptance_drift["status"] == "clean":
|
|
7389
|
+
validate_verification_readiness(root, resolved_task_id, task)
|
|
6356
7390
|
existing = task.get("pending_transition")
|
|
6357
7391
|
if isinstance(existing, dict):
|
|
6358
7392
|
if existing.get("from") != previous or existing.get("to") != stage:
|
|
@@ -6372,6 +7406,8 @@ def request_transition(
|
|
|
6372
7406
|
|
|
6373
7407
|
snapshot = snapshot_state(root, session_file, session)
|
|
6374
7408
|
snapshot["action"] = "request-transition"
|
|
7409
|
+
if acceptance_drift is not None:
|
|
7410
|
+
snapshot["acceptance_drift"] = acceptance_drift
|
|
6375
7411
|
return snapshot
|
|
6376
7412
|
|
|
6377
7413
|
|
|
@@ -6412,6 +7448,10 @@ def apply_transition(
|
|
|
6412
7448
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
6413
7449
|
validate_verification_readiness(root, resolved_task_id, task)
|
|
6414
7450
|
if isinstance(task.get("spec_source"), dict):
|
|
7451
|
+
writeback_verified_tasks(
|
|
7452
|
+
root, resolved_task_id, task, agent, session_file
|
|
7453
|
+
)
|
|
7454
|
+
task = load_task(root, resolved_task_id) or task
|
|
6415
7455
|
require_shared_task_statuses(root, task, {"verified", "completed"})
|
|
6416
7456
|
if previous == "MEMORY" and stage == "COMPLETE":
|
|
6417
7457
|
progress = task.get("memory_progress")
|
|
@@ -6433,6 +7473,8 @@ def apply_transition(
|
|
|
6433
7473
|
task.pop("workflow_mode_legacy_direct_edge", None)
|
|
6434
7474
|
if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
|
|
6435
7475
|
task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
|
|
7476
|
+
if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
|
|
7477
|
+
cleanup_verification_checkpoint(root, resolved_task_id, task)
|
|
6436
7478
|
task.pop("pending_transition", None)
|
|
6437
7479
|
if stage == "MEMORY" and previous != stage:
|
|
6438
7480
|
task["memory_progress"] = {}
|
|
@@ -6457,7 +7499,7 @@ def auto_transition(
|
|
|
6457
7499
|
task_id: str | None = None,
|
|
6458
7500
|
session_file: str | Path | None = None,
|
|
6459
7501
|
) -> dict:
|
|
6460
|
-
session,
|
|
7502
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
6461
7503
|
previous = str(task.get("status") or "idle")
|
|
6462
7504
|
task_type = str(task.get("type") or "")
|
|
6463
7505
|
approval_mode = resolve_approval_mode(root, session)[2]
|
|
@@ -6474,6 +7516,43 @@ def auto_transition(
|
|
|
6474
7516
|
"A different transition is already pending. Cancel it before automatic transition."
|
|
6475
7517
|
)
|
|
6476
7518
|
|
|
7519
|
+
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
7520
|
+
task = ensure_verification_checkpoint(
|
|
7521
|
+
root, resolved_task_id, task, agent, session_file
|
|
7522
|
+
)
|
|
7523
|
+
drift = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
7524
|
+
if drift["config_changed"]:
|
|
7525
|
+
raise StateError(
|
|
7526
|
+
"Behavior config changed after verification; rerun verification before MEMORY."
|
|
7527
|
+
)
|
|
7528
|
+
if drift["metadata_changed"]:
|
|
7529
|
+
raise StateError(
|
|
7530
|
+
"Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7531
|
+
)
|
|
7532
|
+
if drift["changed_files"]:
|
|
7533
|
+
task["pending_transition"] = {
|
|
7534
|
+
"from": previous,
|
|
7535
|
+
"to": stage,
|
|
7536
|
+
"requested_at": now_iso(),
|
|
7537
|
+
"requested_by": agent,
|
|
7538
|
+
"reason": "verification checkpoint drift requires exact user acceptance",
|
|
7539
|
+
"confirmation_override": "evidence-drift",
|
|
7540
|
+
}
|
|
7541
|
+
task["last_agent"] = agent
|
|
7542
|
+
write_task(root, resolved_task_id, task)
|
|
7543
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7544
|
+
snapshot["action"] = "acceptance-drift"
|
|
7545
|
+
snapshot["acceptance_drift"] = drift
|
|
7546
|
+
return snapshot
|
|
7547
|
+
append_transition_acceptance(
|
|
7548
|
+
root,
|
|
7549
|
+
resolved_task_id,
|
|
7550
|
+
task,
|
|
7551
|
+
agent,
|
|
7552
|
+
approval_mode,
|
|
7553
|
+
"approval-policy",
|
|
7554
|
+
)
|
|
7555
|
+
|
|
6477
7556
|
snapshot = apply_transition(root, stage, agent, task_id, session_file)
|
|
6478
7557
|
snapshot["action"] = "auto-transition"
|
|
6479
7558
|
snapshot["automatic_transition"] = {"from": previous, "to": stage}
|
|
@@ -6486,8 +7565,11 @@ def confirm_transition(
|
|
|
6486
7565
|
stage: str | None = None,
|
|
6487
7566
|
task_id: str | None = None,
|
|
6488
7567
|
session_file: str | Path | None = None,
|
|
7568
|
+
expected_diff_sha256: str | None = None,
|
|
7569
|
+
verification_policy: str | None = None,
|
|
7570
|
+
decision_summary: str | None = None,
|
|
6489
7571
|
) -> dict:
|
|
6490
|
-
session,
|
|
7572
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
6491
7573
|
pending = task.get("pending_transition")
|
|
6492
7574
|
if not isinstance(pending, dict):
|
|
6493
7575
|
raise StateError("No transition is pending user confirmation.")
|
|
@@ -6502,12 +7584,29 @@ def confirm_transition(
|
|
|
6502
7584
|
)
|
|
6503
7585
|
if stage and stage != target:
|
|
6504
7586
|
raise StateError(f"Pending transition targets {target}, not {stage}.")
|
|
6505
|
-
|
|
7587
|
+
drift_override = pending.get("confirmation_override") == "evidence-drift"
|
|
7588
|
+
if is_automatic_transition(source, target, task_type, approval_mode) and not drift_override:
|
|
6506
7589
|
raise StateError(
|
|
6507
7590
|
f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
|
|
6508
7591
|
"use auto-transition instead."
|
|
6509
7592
|
)
|
|
6510
7593
|
|
|
7594
|
+
if source == "VERIFICATION" and target == "MEMORY":
|
|
7595
|
+
task = ensure_verification_checkpoint(
|
|
7596
|
+
root, resolved_task_id, task, agent, session_file
|
|
7597
|
+
)
|
|
7598
|
+
append_transition_acceptance(
|
|
7599
|
+
root,
|
|
7600
|
+
resolved_task_id,
|
|
7601
|
+
task,
|
|
7602
|
+
agent,
|
|
7603
|
+
approval_mode,
|
|
7604
|
+
"explicit-user",
|
|
7605
|
+
expected_diff_sha256,
|
|
7606
|
+
verification_policy,
|
|
7607
|
+
decision_summary,
|
|
7608
|
+
)
|
|
7609
|
+
|
|
6511
7610
|
snapshot = apply_transition(root, target, agent, task_id, session_file)
|
|
6512
7611
|
snapshot["action"] = "confirm-transition"
|
|
6513
7612
|
snapshot["confirmed_transition"] = {"from": source, "to": target}
|
|
@@ -6548,6 +7647,46 @@ def memory_short_complete(
|
|
|
6548
7647
|
memory_file.strip(),
|
|
6549
7648
|
require_current_id=True,
|
|
6550
7649
|
)
|
|
7650
|
+
acceptance = latest_acceptance_record(root, resolved_task_id, task)
|
|
7651
|
+
if isinstance(acceptance, dict) and acceptance.get("changed_files"):
|
|
7652
|
+
try:
|
|
7653
|
+
memory_text = resolved_memory_path.read_text(encoding="utf-8")
|
|
7654
|
+
except (OSError, UnicodeError) as exc:
|
|
7655
|
+
raise StateError(f"Cannot read short-memory file: {resolved_memory_path}") from exc
|
|
7656
|
+
required_decision_fields = {
|
|
7657
|
+
"diff_sha256": str(acceptance.get("diff_sha256") or ""),
|
|
7658
|
+
"authorization": str(acceptance.get("authorization") or ""),
|
|
7659
|
+
"approval_mode": str(acceptance.get("approval_mode") or ""),
|
|
7660
|
+
"review_policy": str(acceptance.get("review_policy") or ""),
|
|
7661
|
+
"verification_policy": str(acceptance.get("verification_policy") or ""),
|
|
7662
|
+
"summary": str(acceptance.get("summary") or ""),
|
|
7663
|
+
}
|
|
7664
|
+
missing_decision_fields = [
|
|
7665
|
+
field_name
|
|
7666
|
+
for field_name, value in required_decision_fields.items()
|
|
7667
|
+
if not value or value not in memory_text
|
|
7668
|
+
]
|
|
7669
|
+
missing_changed_files = [
|
|
7670
|
+
str(file_name)
|
|
7671
|
+
for file_name in acceptance.get("changed_files", [])
|
|
7672
|
+
if not is_non_empty_string(file_name) or str(file_name) not in memory_text
|
|
7673
|
+
]
|
|
7674
|
+
missing_targeted_tasks = [
|
|
7675
|
+
str(source_task_id)
|
|
7676
|
+
for source_task_id in acceptance.get("required_targeted_source_tasks", [])
|
|
7677
|
+
if not is_non_empty_string(source_task_id)
|
|
7678
|
+
or str(source_task_id) not in memory_text
|
|
7679
|
+
]
|
|
7680
|
+
if missing_decision_fields or missing_changed_files or missing_targeted_tasks:
|
|
7681
|
+
missing_labels = [
|
|
7682
|
+
*missing_decision_fields,
|
|
7683
|
+
*(f"changed_file:{file_name}" for file_name in missing_changed_files),
|
|
7684
|
+
*(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
|
|
7685
|
+
]
|
|
7686
|
+
raise StateError(
|
|
7687
|
+
"Short memory must record the complete accepted post-verification decision; "
|
|
7688
|
+
"missing: " + ", ".join(missing_labels)
|
|
7689
|
+
)
|
|
6551
7690
|
progress = task.get("memory_progress")
|
|
6552
7691
|
if not isinstance(progress, dict):
|
|
6553
7692
|
progress = {}
|
|
@@ -6676,6 +7815,7 @@ def close_current_task(
|
|
|
6676
7815
|
if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
|
|
6677
7816
|
cancel_shared_tasks(root, str(task_id), task, reason, agent)
|
|
6678
7817
|
if task.get("status") != "CLOSED":
|
|
7818
|
+
cleanup_verification_checkpoint(root, str(task_id), task)
|
|
6679
7819
|
task["status"] = "CLOSED"
|
|
6680
7820
|
append_stage_history(task, "CLOSED", agent)
|
|
6681
7821
|
task.pop("pending_transition", None)
|
|
@@ -6969,6 +8109,18 @@ def main() -> int:
|
|
|
6969
8109
|
fingerprints_parser.add_argument("--agent", required=True)
|
|
6970
8110
|
fingerprints_parser.add_argument("--task-id")
|
|
6971
8111
|
|
|
8112
|
+
verification_checkpoint_parser = subcommands.add_parser(
|
|
8113
|
+
"verification-checkpoint", parents=[common]
|
|
8114
|
+
)
|
|
8115
|
+
verification_checkpoint_parser.add_argument("--agent", required=True)
|
|
8116
|
+
verification_checkpoint_parser.add_argument("--task-id")
|
|
8117
|
+
|
|
8118
|
+
inspect_transition_drift_parser = subcommands.add_parser(
|
|
8119
|
+
"inspect-transition-drift", parents=[common]
|
|
8120
|
+
)
|
|
8121
|
+
inspect_transition_drift_parser.add_argument("--agent", required=True)
|
|
8122
|
+
inspect_transition_drift_parser.add_argument("--task-id")
|
|
8123
|
+
|
|
6972
8124
|
disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
|
|
6973
8125
|
disable_harness_parser.add_argument("--agent", required=True)
|
|
6974
8126
|
|
|
@@ -6994,6 +8146,11 @@ def main() -> int:
|
|
|
6994
8146
|
confirm_transition_parser.add_argument("--stage")
|
|
6995
8147
|
confirm_transition_parser.add_argument("--agent", required=True)
|
|
6996
8148
|
confirm_transition_parser.add_argument("--task-id")
|
|
8149
|
+
confirm_transition_parser.add_argument("--diff-sha256")
|
|
8150
|
+
confirm_transition_parser.add_argument(
|
|
8151
|
+
"--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
|
|
8152
|
+
)
|
|
8153
|
+
confirm_transition_parser.add_argument("--decision-summary")
|
|
6997
8154
|
|
|
6998
8155
|
auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
|
|
6999
8156
|
auto_transition_parser.add_argument("--stage", required=True)
|
|
@@ -7005,6 +8162,11 @@ def main() -> int:
|
|
|
7005
8162
|
transition.add_argument("--stage")
|
|
7006
8163
|
transition.add_argument("--agent", required=True)
|
|
7007
8164
|
transition.add_argument("--task-id")
|
|
8165
|
+
transition.add_argument("--diff-sha256")
|
|
8166
|
+
transition.add_argument(
|
|
8167
|
+
"--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
|
|
8168
|
+
)
|
|
8169
|
+
transition.add_argument("--decision-summary")
|
|
7008
8170
|
|
|
7009
8171
|
cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
|
|
7010
8172
|
cancel_transition_parser.add_argument("--agent", required=True)
|
|
@@ -7064,9 +8226,8 @@ def main() -> int:
|
|
|
7064
8226
|
root = resolve_root(getattr(args, "cwd", None))
|
|
7065
8227
|
session_file = getattr(args, "session_file", None)
|
|
7066
8228
|
command = args.command or "snapshot"
|
|
7067
|
-
agent =
|
|
7068
|
-
|
|
7069
|
-
)
|
|
8229
|
+
agent = resolve_state_agent(getattr(args, "agent", None))
|
|
8230
|
+
validate_session_agent(agent, session_file)
|
|
7070
8231
|
session_agent = normalize_session_agent(agent)
|
|
7071
8232
|
visible_agent = None if agent == "unknown" else agent
|
|
7072
8233
|
if session_file is None and command == "project-init-complete":
|
|
@@ -7413,6 +8574,28 @@ def main() -> int:
|
|
|
7413
8574
|
session_file,
|
|
7414
8575
|
)
|
|
7415
8576
|
)
|
|
8577
|
+
elif command == "verification-checkpoint":
|
|
8578
|
+
emit(
|
|
8579
|
+
attach_status_context(
|
|
8580
|
+
root,
|
|
8581
|
+
record_verification_checkpoint(
|
|
8582
|
+
root, agent, args.task_id, session_file
|
|
8583
|
+
),
|
|
8584
|
+
agent,
|
|
8585
|
+
session_file,
|
|
8586
|
+
)
|
|
8587
|
+
)
|
|
8588
|
+
elif command == "inspect-transition-drift":
|
|
8589
|
+
emit(
|
|
8590
|
+
attach_status_context(
|
|
8591
|
+
root,
|
|
8592
|
+
inspect_transition_drift(
|
|
8593
|
+
root, agent, args.task_id, session_file
|
|
8594
|
+
),
|
|
8595
|
+
agent,
|
|
8596
|
+
session_file,
|
|
8597
|
+
)
|
|
8598
|
+
)
|
|
7416
8599
|
elif command == "disable-harness":
|
|
7417
8600
|
emit(
|
|
7418
8601
|
attach_status_context(
|
|
@@ -7469,7 +8652,16 @@ def main() -> int:
|
|
|
7469
8652
|
emit(
|
|
7470
8653
|
attach_status_context(
|
|
7471
8654
|
root,
|
|
7472
|
-
confirm_transition(
|
|
8655
|
+
confirm_transition(
|
|
8656
|
+
root,
|
|
8657
|
+
agent,
|
|
8658
|
+
args.stage,
|
|
8659
|
+
args.task_id,
|
|
8660
|
+
session_file,
|
|
8661
|
+
args.diff_sha256,
|
|
8662
|
+
args.verification_policy,
|
|
8663
|
+
args.decision_summary,
|
|
8664
|
+
),
|
|
7473
8665
|
agent,
|
|
7474
8666
|
session_file,
|
|
7475
8667
|
)
|