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.
@@ -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 normalize_agent_identity(agent: str | None) -> str:
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 SESSION_AGENT_NAMESPACES:
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 pre-0.6 stage names without touching task artifacts outside task.json."""
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"] = old_state.get("last_agent", 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") == expected
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
- last_agent = state.get("last_agent")
4158
- if agent and last_agent and not agents_equivalent(last_agent, agent):
4159
- line += f" · Handoff -> `{last_agent}`"
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
- last_agent = state.get("last_agent")
4207
- if agent and last_agent and not agents_equivalent(last_agent, agent):
4208
- lines.append(f"[easy-coding:handoff-from:{last_agent}]")
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
- raw_agent = str(agent).strip()
4772
- if raw_agent.endswith(" with Easy Coding"):
4773
- return raw_agent
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
- key = f"{harness_task_id}:{source_task_id}:complete:{task['spec_source']['revision']}"
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
- validate_verification_readiness(root, resolved_task_id, task)
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, _, task = resolve_current_task(root, task_id, session_file)
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, _, task = resolve_current_task(root, task_id, session_file)
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
- if is_automatic_transition(source, target, task_type, approval_mode):
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 = normalize_agent_identity(
7068
- getattr(args, "agent", None) or detect_runtime_agent()
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(root, agent, args.stage, args.task_id, session_file),
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
  )