easy-coding-harness 0.10.0-beta.7 → 0.10.0-beta.8

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,6 +141,8 @@ 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._-]+$")
144
148
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
@@ -2751,6 +2755,665 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2751
2755
  }
2752
2756
 
2753
2757
 
2758
+ def acceptance_snapshot_path(root: Path, task_id: str) -> Path:
2759
+ assert_safe_task_id(task_id)
2760
+ return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
2761
+
2762
+
2763
+ def canonical_json_sha256(value: object) -> str:
2764
+ payload = json.dumps(
2765
+ value,
2766
+ ensure_ascii=False,
2767
+ sort_keys=True,
2768
+ separators=(",", ":"),
2769
+ ).encode("utf-8")
2770
+ return hashlib.sha256(payload).hexdigest()
2771
+
2772
+
2773
+ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
2774
+ plan = latest_execution_plan(root, task_id)
2775
+ if plan is None:
2776
+ raise StateError("Cannot fingerprint verification contract without a valid plan.")
2777
+ source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
2778
+ contract = {
2779
+ "workflow_mode": task.get("workflow_mode"),
2780
+ "tdd_enabled": task.get("tdd_enabled"),
2781
+ "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
2782
+ "tdd_baselines": task.get("tdd_baselines"),
2783
+ "plan": plan,
2784
+ "canonical": {
2785
+ "schema": source.get("schema"),
2786
+ "spec_id": source.get("spec_id"),
2787
+ "revision": source.get("revision"),
2788
+ "design_sha256": source.get("design_sha256"),
2789
+ "selected_tasks": task.get("selected_spec_tasks"),
2790
+ "repository_bindings": task.get("spec_repositories"),
2791
+ "repo_paths": task.get("repo_paths"),
2792
+ }
2793
+ if source
2794
+ else None,
2795
+ }
2796
+ return canonical_json_sha256(contract)
2797
+
2798
+
2799
+ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
2800
+ pathspecs = repository_scope_pathspecs(repository, scopes)
2801
+ index_entries = git_index_entries(repository, pathspecs)
2802
+ listed = run_git(
2803
+ repository,
2804
+ "ls-files",
2805
+ "--cached",
2806
+ "--others",
2807
+ "--exclude-standard",
2808
+ "-z",
2809
+ "--",
2810
+ *pathspecs,
2811
+ )
2812
+ modified = run_git(
2813
+ repository,
2814
+ "diff-files",
2815
+ "--name-only",
2816
+ "-z",
2817
+ "--ignore-submodules=none",
2818
+ "--",
2819
+ *pathspecs,
2820
+ )
2821
+ if listed is None or listed.returncode != 0 or modified is None or modified.returncode != 0:
2822
+ raise StateError(f"Cannot capture verification snapshot for {repository}.")
2823
+ modified_paths = set(filter(None, modified.stdout.split(b"\0")))
2824
+ raw_paths = set(filter(None, listed.stdout.split(b"\0"))) | set(index_entries)
2825
+ entries: list[dict] = []
2826
+ for raw_path in sorted(raw_paths):
2827
+ relative_name = os.fsdecode(raw_path)
2828
+ if is_easy_coding_state_path(repository, relative_name, scopes):
2829
+ continue
2830
+ candidate = repository / relative_name
2831
+ index_entry = index_entries.get(raw_path)
2832
+ if index_entry is not None and index_entry[0] == b"160000":
2833
+ entries.append(
2834
+ {
2835
+ "path": relative_name,
2836
+ "exists": True,
2837
+ "mode": "160000",
2838
+ "git_oid": index_entry[1].decode("ascii", errors="replace"),
2839
+ "sha256": hashlib.sha256(index_entry[1]).hexdigest(),
2840
+ }
2841
+ )
2842
+ continue
2843
+ exists = candidate.exists() or candidate.is_symlink()
2844
+ if not exists:
2845
+ entries.append(
2846
+ {
2847
+ "path": relative_name,
2848
+ "exists": False,
2849
+ "mode": None,
2850
+ "sha256": None,
2851
+ }
2852
+ )
2853
+ continue
2854
+ try:
2855
+ content = (
2856
+ os.fsencode(os.readlink(candidate))
2857
+ if candidate.is_symlink()
2858
+ else candidate.read_bytes()
2859
+ )
2860
+ except OSError as exc:
2861
+ raise StateError(f"Cannot read verification snapshot file: {relative_name}") from exc
2862
+ mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
2863
+ entry = {
2864
+ "path": relative_name,
2865
+ "exists": True,
2866
+ "mode": mode,
2867
+ "sha256": hashlib.sha256(content).hexdigest(),
2868
+ }
2869
+ if index_entry is not None and raw_path not in modified_paths:
2870
+ entry["git_oid"] = index_entry[1].decode("ascii", errors="replace")
2871
+ else:
2872
+ # 仅无法从 Git object 还原的工作区内容进入被忽略的临时快照。
2873
+ entry["content_b64"] = base64.b64encode(content).decode("ascii")
2874
+ entries.append(entry)
2875
+ return entries
2876
+
2877
+
2878
+ def acceptance_filesystem_repositories(
2879
+ root: Path,
2880
+ plan: dict,
2881
+ git_scopes: list[tuple[Path, list[Path]]],
2882
+ ) -> list[dict]:
2883
+ files_by_root: dict[Path, set[Path]] = {}
2884
+ for unit in plan.get("units", []):
2885
+ if not isinstance(unit, dict):
2886
+ continue
2887
+ for file_name in unit.get("files", []):
2888
+ if not is_non_empty_string(file_name):
2889
+ continue
2890
+ raw_path = Path(str(file_name))
2891
+ base = root.resolve()
2892
+ candidate = raw_path if raw_path.is_absolute() else base / raw_path
2893
+ resolved = candidate.resolve()
2894
+ if not raw_path.is_absolute() and not is_path_within(resolved, base):
2895
+ raise StateError(f"Execution plan file escapes project: {file_name}")
2896
+ if any(
2897
+ is_path_within(resolved, scope)
2898
+ for _repository, scopes in git_scopes
2899
+ for scope in scopes
2900
+ ):
2901
+ continue
2902
+ snapshot_root = resolved.parent if raw_path.is_absolute() else base
2903
+ files_by_root.setdefault(snapshot_root, set()).add(resolved)
2904
+
2905
+ repositories: list[dict] = []
2906
+ for snapshot_root, files in sorted(
2907
+ files_by_root.items(), key=lambda item: item[0].as_posix()
2908
+ ):
2909
+ entries = []
2910
+ for candidate in sorted(files, key=lambda item: item.as_posix()):
2911
+ relative_name = candidate.relative_to(snapshot_root).as_posix()
2912
+ exists = candidate.exists() or candidate.is_symlink()
2913
+ if not exists:
2914
+ entries.append(
2915
+ {
2916
+ "path": relative_name,
2917
+ "exists": False,
2918
+ "mode": None,
2919
+ "sha256": None,
2920
+ }
2921
+ )
2922
+ continue
2923
+ try:
2924
+ content = (
2925
+ os.fsencode(os.readlink(candidate))
2926
+ if candidate.is_symlink()
2927
+ else candidate.read_bytes()
2928
+ )
2929
+ except OSError as exc:
2930
+ raise StateError(
2931
+ f"Cannot read verification snapshot file: {candidate}"
2932
+ ) from exc
2933
+ entries.append(
2934
+ {
2935
+ "path": relative_name,
2936
+ "exists": True,
2937
+ "mode": worktree_git_mode(candidate).decode("ascii", errors="replace"),
2938
+ "sha256": hashlib.sha256(content).hexdigest(),
2939
+ "content_b64": base64.b64encode(content).decode("ascii"),
2940
+ }
2941
+ )
2942
+ repositories.append(
2943
+ {
2944
+ "root": str(snapshot_root),
2945
+ "display": display_path(root, snapshot_root),
2946
+ "scopes": [],
2947
+ "entries": entries,
2948
+ }
2949
+ )
2950
+ return repositories
2951
+
2952
+
2953
+ def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
2954
+ plan = latest_execution_plan(root, task_id)
2955
+ if plan is None:
2956
+ raise StateError("Cannot capture verification snapshot without a valid plan.")
2957
+ fingerprints = evidence_fingerprints(root, task_id)
2958
+ repository_scopes = task_repository_scopes(root, task, plan)
2959
+ repositories = []
2960
+ for repository, scopes in repository_scopes:
2961
+ repositories.append(
2962
+ {
2963
+ "root": str(repository.resolve()),
2964
+ "display": display_path(root, repository.resolve()),
2965
+ "scopes": [
2966
+ scope.relative_to(repository.resolve()).as_posix() for scope in scopes
2967
+ ],
2968
+ "entries": acceptance_repository_entries(repository.resolve(), scopes),
2969
+ }
2970
+ )
2971
+ repositories.extend(acceptance_filesystem_repositories(root, plan, repository_scopes))
2972
+ return {
2973
+ "schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
2974
+ **fingerprints,
2975
+ "contract_fingerprint": verification_contract_fingerprint(root, task_id, task),
2976
+ "repositories": repositories,
2977
+ }
2978
+
2979
+
2980
+ def load_acceptance_snapshot(root: Path, task: dict) -> dict:
2981
+ checkpoint = task.get("verification_checkpoint")
2982
+ if not isinstance(checkpoint, dict):
2983
+ raise StateError("VERIFICATION has no frozen acceptance checkpoint.")
2984
+ raw_path = checkpoint.get("snapshot_file")
2985
+ if not is_non_empty_string(raw_path):
2986
+ raise StateError("Verification checkpoint has no snapshot file.")
2987
+ candidate = (root / str(raw_path)).resolve()
2988
+ sessions_root = (root / ".easy-coding" / "sessions").resolve()
2989
+ if not is_path_within(candidate, sessions_root):
2990
+ raise StateError("Verification checkpoint snapshot escapes .easy-coding/sessions.")
2991
+ snapshot = load_json(candidate)
2992
+ if not isinstance(snapshot, dict) or snapshot.get("schema") != ACCEPTANCE_SNAPSHOT_SCHEMA:
2993
+ raise StateError("Verification checkpoint snapshot is missing or invalid.")
2994
+ if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
2995
+ raise StateError("Verification checkpoint snapshot fingerprint changed.")
2996
+ if (
2997
+ snapshot.get("implementation_fingerprint")
2998
+ != checkpoint.get("implementation_fingerprint")
2999
+ or snapshot.get("config_fingerprint") != checkpoint.get("config_fingerprint")
3000
+ or snapshot.get("contract_fingerprint") != checkpoint.get("contract_fingerprint")
3001
+ ):
3002
+ raise StateError("Verification checkpoint metadata does not match its snapshot.")
3003
+ return snapshot
3004
+
3005
+
3006
+ def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None:
3007
+ if not isinstance(entry, dict) or entry.get("exists") is not True:
3008
+ return None
3009
+ encoded = entry.get("content_b64")
3010
+ if isinstance(encoded, str):
3011
+ try:
3012
+ return base64.b64decode(encoded, validate=True)
3013
+ except ValueError as exc:
3014
+ raise StateError("Verification checkpoint contains invalid file content.") from exc
3015
+ object_id = entry.get("git_oid")
3016
+ if not is_non_empty_string(object_id):
3017
+ return None
3018
+ if entry.get("mode") == "160000":
3019
+ return str(object_id).encode("ascii", errors="replace")
3020
+ result = run_git(repository, "cat-file", "blob", str(object_id))
3021
+ if result is None or result.returncode != 0:
3022
+ raise StateError(f"Cannot restore verification checkpoint Git object: {object_id}")
3023
+ return result.stdout
3024
+
3025
+
3026
+ def acceptance_snapshot_entries(snapshot: dict) -> dict[tuple[str, str], tuple[Path, dict]]:
3027
+ entries: dict[tuple[str, str], tuple[Path, dict]] = {}
3028
+ for repository in snapshot.get("repositories", []):
3029
+ if not isinstance(repository, dict) or not is_non_empty_string(repository.get("root")):
3030
+ continue
3031
+ repository_root = Path(str(repository["root"]))
3032
+ for entry in repository.get("entries", []):
3033
+ if isinstance(entry, dict) and is_non_empty_string(entry.get("path")):
3034
+ entries[(str(repository_root), str(entry["path"]))] = (repository_root, entry)
3035
+ return entries
3036
+
3037
+
3038
+ def acceptance_change_patch(
3039
+ path_name: str,
3040
+ previous: bytes | None,
3041
+ current: bytes | None,
3042
+ ) -> tuple[bool, str]:
3043
+ if (previous is not None and b"\0" in previous) or (current is not None and b"\0" in current):
3044
+ return True, ""
3045
+ try:
3046
+ previous_text = previous.decode("utf-8") if previous is not None else ""
3047
+ current_text = current.decode("utf-8") if current is not None else ""
3048
+ except UnicodeDecodeError:
3049
+ return True, ""
3050
+ patch = "".join(
3051
+ difflib.unified_diff(
3052
+ previous_text.splitlines(keepends=True),
3053
+ current_text.splitlines(keepends=True),
3054
+ fromfile=f"a/{path_name}" if previous is not None else "/dev/null",
3055
+ tofile=f"b/{path_name}" if current is not None else "/dev/null",
3056
+ )
3057
+ )
3058
+ return False, patch
3059
+
3060
+
3061
+ def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
3062
+ checkpoint = task.get("verification_checkpoint")
3063
+ baseline = load_acceptance_snapshot(root, task)
3064
+ current = build_acceptance_snapshot(root, task_id, task)
3065
+ baseline_entries = acceptance_snapshot_entries(baseline)
3066
+ current_entries = acceptance_snapshot_entries(current)
3067
+ changes: list[dict] = []
3068
+ digest_changes: list[dict] = []
3069
+ nested_repository_changed = False
3070
+ for key in sorted(set(baseline_entries) | set(current_entries)):
3071
+ previous_repository, previous_entry = baseline_entries.get(key, (Path(key[0]), None))
3072
+ current_repository, current_entry = current_entries.get(key, (Path(key[0]), None))
3073
+ if (
3074
+ isinstance(previous_entry, dict)
3075
+ and isinstance(current_entry, dict)
3076
+ and previous_entry.get("exists") == current_entry.get("exists")
3077
+ and previous_entry.get("mode") == current_entry.get("mode")
3078
+ and previous_entry.get("sha256") == current_entry.get("sha256")
3079
+ ):
3080
+ continue
3081
+ repository = current_repository if isinstance(current_entry, dict) else previous_repository
3082
+ previous_content = snapshot_entry_content(previous_repository, previous_entry)
3083
+ current_content = snapshot_entry_content(current_repository, current_entry)
3084
+ binary, patch = acceptance_change_patch(key[1], previous_content, current_content)
3085
+ change_type = (
3086
+ "added"
3087
+ if previous_content is None and current_content is not None
3088
+ else "deleted"
3089
+ if previous_content is not None and current_content is None
3090
+ else "modified"
3091
+ )
3092
+ label = f"{display_path(root, repository)}:{key[1]}"
3093
+ detail = {
3094
+ "file": label,
3095
+ "repository": display_path(root, repository),
3096
+ "path": key[1],
3097
+ "change_type": change_type,
3098
+ "old_mode": previous_entry.get("mode") if isinstance(previous_entry, dict) else None,
3099
+ "new_mode": current_entry.get("mode") if isinstance(current_entry, dict) else None,
3100
+ "old_sha256": previous_entry.get("sha256")
3101
+ if isinstance(previous_entry, dict)
3102
+ else None,
3103
+ "new_sha256": current_entry.get("sha256")
3104
+ if isinstance(current_entry, dict)
3105
+ else None,
3106
+ "binary": binary,
3107
+ "patch": patch,
3108
+ }
3109
+ if detail["old_mode"] == "160000" or detail["new_mode"] == "160000":
3110
+ nested_repository_changed = True
3111
+ changes.append(detail)
3112
+ digest_changes.append(
3113
+ {key_name: value for key_name, value in detail.items() if key_name != "patch"}
3114
+ )
3115
+ current_implementation = str(current["implementation_fingerprint"])
3116
+ baseline_implementation = str(checkpoint["implementation_fingerprint"])
3117
+ config_changed = current.get("config_fingerprint") != checkpoint.get("config_fingerprint")
3118
+ contract_changed = current.get("contract_fingerprint") != checkpoint.get(
3119
+ "contract_fingerprint"
3120
+ )
3121
+ metadata_changed = bool(
3122
+ contract_changed
3123
+ or nested_repository_changed
3124
+ or (current_implementation != baseline_implementation and not changes)
3125
+ )
3126
+ metadata_reasons = [
3127
+ reason
3128
+ for condition, reason in (
3129
+ (contract_changed, "verification-contract-changed"),
3130
+ (nested_repository_changed, "nested-repository-changed"),
3131
+ (
3132
+ current_implementation != baseline_implementation
3133
+ and not changes
3134
+ and not contract_changed,
3135
+ "unclassified-implementation-drift",
3136
+ ),
3137
+ )
3138
+ if condition
3139
+ ]
3140
+ digest_payload = {
3141
+ "from": baseline_implementation,
3142
+ "to": current_implementation,
3143
+ "config_changed": config_changed,
3144
+ "metadata_changed": metadata_changed,
3145
+ "changes": digest_changes,
3146
+ }
3147
+ return {
3148
+ "status": "drift" if changes or config_changed or metadata_changed else "clean",
3149
+ "from_implementation_fingerprint": baseline_implementation,
3150
+ "implementation_fingerprint": current_implementation,
3151
+ "config_fingerprint": str(current["config_fingerprint"]),
3152
+ "config_changed": config_changed,
3153
+ "metadata_changed": metadata_changed,
3154
+ "metadata_reasons": metadata_reasons,
3155
+ "diff_sha256": canonical_json_sha256(digest_payload),
3156
+ "changed_files": [str(change["file"]) for change in changes],
3157
+ "changes": changes,
3158
+ }
3159
+
3160
+
3161
+ def cleanup_verification_checkpoint(root: Path, task_id: str, task: dict) -> None:
3162
+ checkpoint = task.pop("verification_checkpoint", None)
3163
+ if not isinstance(checkpoint, dict):
3164
+ return
3165
+ raw_path = checkpoint.get("snapshot_file")
3166
+ if not is_non_empty_string(raw_path):
3167
+ return
3168
+ candidate = (root / str(raw_path)).resolve()
3169
+ sessions_root = (root / ".easy-coding" / "sessions").resolve()
3170
+ if not is_path_within(candidate, sessions_root):
3171
+ return
3172
+ try:
3173
+ candidate.unlink()
3174
+ except FileNotFoundError:
3175
+ pass
3176
+ try:
3177
+ candidate.parent.rmdir()
3178
+ except OSError:
3179
+ pass
3180
+
3181
+
3182
+ def record_verification_checkpoint(
3183
+ root: Path,
3184
+ agent: str,
3185
+ task_id: str | None = None,
3186
+ session_file: str | Path | None = None,
3187
+ ) -> dict:
3188
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3189
+ if task.get("status") != "VERIFICATION":
3190
+ raise StateError("Verification checkpoint can only be recorded during VERIFICATION.")
3191
+ if isinstance(task.get("verification_checkpoint"), dict):
3192
+ load_acceptance_snapshot(root, task)
3193
+ result = snapshot_state(root, session_file, session)
3194
+ result["action"] = "verification-checkpoint"
3195
+ result["verification_checkpoint"] = task["verification_checkpoint"]
3196
+ result["checkpoint_unchanged"] = True
3197
+ return result
3198
+ validate_verification_readiness(root, resolved_task_id, task)
3199
+ snapshot = build_acceptance_snapshot(root, resolved_task_id, task)
3200
+ path = acceptance_snapshot_path(root, resolved_task_id)
3201
+ write_json(path, snapshot)
3202
+ task["verification_checkpoint"] = {
3203
+ "schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
3204
+ "implementation_fingerprint": snapshot["implementation_fingerprint"],
3205
+ "config_fingerprint": snapshot["config_fingerprint"],
3206
+ "contract_fingerprint": snapshot["contract_fingerprint"],
3207
+ "snapshot_file": display_path(root, path),
3208
+ "snapshot_sha256": canonical_json_sha256(snapshot),
3209
+ "recorded_at": now_iso(),
3210
+ "recorded_by": agent,
3211
+ }
3212
+ task["last_agent"] = agent
3213
+ write_task(root, resolved_task_id, task)
3214
+ result = snapshot_state(root, session_file, session)
3215
+ result["action"] = "verification-checkpoint"
3216
+ result["verification_checkpoint"] = task["verification_checkpoint"]
3217
+ return result
3218
+
3219
+
3220
+ def latest_acceptance_record(root: Path, task_id: str, task: dict) -> dict | None:
3221
+ latest_implement = max(
3222
+ (
3223
+ str(entry.get("entered_at"))
3224
+ for entry in task.get("stage_history", [])
3225
+ if isinstance(entry, dict)
3226
+ and entry.get("stage") == "IMPLEMENT"
3227
+ and is_non_empty_string(entry.get("entered_at"))
3228
+ ),
3229
+ default="",
3230
+ )
3231
+ latest: dict | None = None
3232
+ for record in execution_records(root, task_id):
3233
+ if record.get("type") != "acceptance" or not is_non_empty_string(
3234
+ record.get("timestamp")
3235
+ ):
3236
+ continue
3237
+ if latest_implement and str(record["timestamp"]) < latest_implement:
3238
+ continue
3239
+ latest = record
3240
+ return latest
3241
+
3242
+
3243
+ def ensure_verification_checkpoint(
3244
+ root: Path,
3245
+ task_id: str,
3246
+ task: dict,
3247
+ agent: str,
3248
+ session_file: str | Path | None,
3249
+ ) -> dict:
3250
+ if isinstance(task.get("verification_checkpoint"), dict):
3251
+ load_acceptance_snapshot(root, task)
3252
+ return task
3253
+ record_verification_checkpoint(root, agent, task_id, session_file)
3254
+ refreshed = load_task(root, task_id)
3255
+ if not isinstance(refreshed, dict):
3256
+ raise StateError(f"Task not found after verification checkpoint: {task_id}")
3257
+ return refreshed
3258
+
3259
+
3260
+ def inspect_transition_drift(
3261
+ root: Path,
3262
+ agent: str,
3263
+ task_id: str | None = None,
3264
+ session_file: str | Path | None = None,
3265
+ ) -> dict:
3266
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3267
+ if task.get("status") != "VERIFICATION":
3268
+ raise StateError("Transition drift can only be inspected during VERIFICATION.")
3269
+ task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
3270
+ result = snapshot_state(root, session_file, session)
3271
+ result["acceptance_drift"] = inspect_acceptance_drift(root, resolved_task_id, task)
3272
+ result["action"] = "inspect-transition-drift"
3273
+ return result
3274
+
3275
+
3276
+ def append_transition_acceptance(
3277
+ root: Path,
3278
+ task_id: str,
3279
+ task: dict,
3280
+ agent: str,
3281
+ approval_mode: str,
3282
+ authorization: str,
3283
+ expected_diff_sha256: str | None = None,
3284
+ verification_policy: str | None = None,
3285
+ summary: str | None = None,
3286
+ ) -> dict:
3287
+ drift = inspect_acceptance_drift(root, task_id, task)
3288
+ if drift["config_changed"]:
3289
+ raise StateError(
3290
+ "Behavior config changed after verification; rerun verification before MEMORY."
3291
+ )
3292
+ if drift["metadata_changed"]:
3293
+ raise StateError(
3294
+ "Execution plan, workflow, Canonical design, or nested repository state changed "
3295
+ "after verification; return to ANALYSIS or IMPLEMENT instead of accepting it as a code diff."
3296
+ )
3297
+ changed_files = list(drift["changed_files"])
3298
+ if changed_files:
3299
+ if expected_diff_sha256 != drift["diff_sha256"]:
3300
+ raise StateError(
3301
+ "Verified code changed after the acceptance checkpoint. Inspect the exact drift "
3302
+ "and confirm its current diff_sha256 before entering MEMORY."
3303
+ )
3304
+ if verification_policy not in ACCEPTANCE_VERIFICATION_POLICIES:
3305
+ raise StateError(
3306
+ "Accepted code drift requires verification policy carry-forward, targeted, or waived."
3307
+ )
3308
+ review_policy = "user-accepted-without-rereview"
3309
+ else:
3310
+ verification_policy = "current"
3311
+ review_policy = "current"
3312
+ required_targeted_source_tasks = (
3313
+ targeted_source_tasks_for_changes(root, task_id, task, drift["changes"])
3314
+ if verification_policy == "targeted"
3315
+ else []
3316
+ )
3317
+ normalized_summary = (
3318
+ summary.strip()
3319
+ if isinstance(summary, str) and summary.strip()
3320
+ else "User accepted the verified implementation"
3321
+ if authorization == "explicit-user"
3322
+ else f"Approval mode {approval_mode} authorized the verified implementation"
3323
+ )
3324
+ record = {
3325
+ "type": "acceptance",
3326
+ "from_implementation_fingerprint": drift["from_implementation_fingerprint"],
3327
+ "implementation_fingerprint": drift["implementation_fingerprint"],
3328
+ "config_fingerprint": drift["config_fingerprint"],
3329
+ "diff_sha256": drift["diff_sha256"],
3330
+ "changed_files": changed_files,
3331
+ "authorization": authorization,
3332
+ "approval_mode": approval_mode,
3333
+ "review_policy": review_policy,
3334
+ "verification_policy": verification_policy,
3335
+ "required_targeted_source_tasks": required_targeted_source_tasks,
3336
+ "summary": normalized_summary,
3337
+ "recorded_by": agent,
3338
+ "timestamp": now_iso(),
3339
+ }
3340
+ existing = latest_acceptance_record(root, task_id, task)
3341
+ identity_fields = (
3342
+ "from_implementation_fingerprint",
3343
+ "implementation_fingerprint",
3344
+ "config_fingerprint",
3345
+ "diff_sha256",
3346
+ "authorization",
3347
+ "approval_mode",
3348
+ "review_policy",
3349
+ "verification_policy",
3350
+ "required_targeted_source_tasks",
3351
+ "summary",
3352
+ )
3353
+ if not (
3354
+ isinstance(existing, dict)
3355
+ and existing.get("changed_files") == changed_files
3356
+ and all(existing.get(field) == record.get(field) for field in identity_fields)
3357
+ ):
3358
+ append_execution_record(root, task_id, record)
3359
+ return record
3360
+
3361
+
3362
+ def targeted_source_tasks_for_changes(
3363
+ root: Path,
3364
+ task_id: str,
3365
+ task: dict,
3366
+ changes: list[dict],
3367
+ ) -> list[str]:
3368
+ if not isinstance(task.get("spec_source"), dict):
3369
+ return []
3370
+ plan = latest_execution_plan(root, task_id)
3371
+ repo_paths = task.get("repo_paths")
3372
+ if plan is None or not isinstance(repo_paths, dict):
3373
+ raise StateError("Canonical targeted verification requires a valid repository plan.")
3374
+
3375
+ units_by_repository: dict[str, list[dict]] = {}
3376
+ for unit in plan.get("units", []):
3377
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
3378
+ continue
3379
+ raw_repository = repo_paths.get(str(unit["repo_id"]))
3380
+ if not is_non_empty_string(raw_repository):
3381
+ continue
3382
+ candidate = Path(str(raw_repository))
3383
+ repository = (candidate if candidate.is_absolute() else root / candidate).resolve()
3384
+ units_by_repository.setdefault(display_path(root, repository), []).append(unit)
3385
+
3386
+ impacted: set[str] = set()
3387
+ for change in changes:
3388
+ if not isinstance(change, dict):
3389
+ continue
3390
+ repository_units = units_by_repository.get(str(change.get("repository") or ""), [])
3391
+ if not repository_units:
3392
+ continue
3393
+ changed_path = str(change.get("path") or "")
3394
+ matched_units = [
3395
+ unit
3396
+ for unit in repository_units
3397
+ if any(
3398
+ changed_path == str(file_name)
3399
+ or changed_path.startswith(str(file_name).rstrip("/") + "/")
3400
+ for file_name in unit.get("files", [])
3401
+ if is_non_empty_string(file_name)
3402
+ )
3403
+ ]
3404
+ scoped_units = matched_units or repository_units
3405
+ impacted.update(
3406
+ str(unit["source_task_id"])
3407
+ for unit in scoped_units
3408
+ if is_non_empty_string(unit.get("source_task_id"))
3409
+ )
3410
+ if not impacted:
3411
+ raise StateError(
3412
+ "Canonical executable drift could not be mapped to a selected source task."
3413
+ )
3414
+ return sorted(impacted)
3415
+
3416
+
2754
3417
  def command_option_value(command: str, option: str) -> str | None:
2755
3418
  try:
2756
3419
  tokens = shlex.split(command)
@@ -2782,6 +3445,68 @@ def coverage_command_matches_frozen_contract(
2782
3445
  )
2783
3446
 
2784
3447
 
3448
+ def current_acceptance_record(
3449
+ root: Path,
3450
+ task_id: str,
3451
+ task: dict,
3452
+ implementation_fingerprint_value: str,
3453
+ config_fingerprint_value: str,
3454
+ ) -> dict | None:
3455
+ record = latest_acceptance_record(root, task_id, task)
3456
+ if not isinstance(record, dict):
3457
+ return None
3458
+ if (
3459
+ record.get("implementation_fingerprint") != implementation_fingerprint_value
3460
+ or record.get("config_fingerprint") != config_fingerprint_value
3461
+ or not is_non_empty_string(record.get("from_implementation_fingerprint"))
3462
+ or record.get("review_policy")
3463
+ not in {"current", "user-accepted-without-rereview"}
3464
+ or record.get("verification_policy")
3465
+ not in {"current", *ACCEPTANCE_VERIFICATION_POLICIES}
3466
+ or not is_string_list(record.get("required_targeted_source_tasks"))
3467
+ ):
3468
+ return None
3469
+ return record
3470
+
3471
+
3472
+ def accepted_review_fingerprints(
3473
+ root: Path, task_id: str, task: dict, current_fingerprint: str
3474
+ ) -> set[str]:
3475
+ accepted = {current_fingerprint}
3476
+ record = current_acceptance_record(
3477
+ root,
3478
+ task_id,
3479
+ task,
3480
+ current_fingerprint,
3481
+ behavior_config_fingerprint(root, task),
3482
+ )
3483
+ if record and record.get("review_policy") == "user-accepted-without-rereview":
3484
+ accepted.add(str(record["from_implementation_fingerprint"]))
3485
+ return accepted
3486
+
3487
+
3488
+ def accepted_verification_fingerprints(
3489
+ root: Path,
3490
+ task_id: str,
3491
+ task: dict,
3492
+ current_implementation: str,
3493
+ current_config: str,
3494
+ ) -> tuple[set[str], dict | None]:
3495
+ record = current_acceptance_record(
3496
+ root,
3497
+ task_id,
3498
+ task,
3499
+ current_implementation,
3500
+ current_config,
3501
+ )
3502
+ if not record or record.get("verification_policy") == "current":
3503
+ return {current_implementation}, record
3504
+ previous = str(record["from_implementation_fingerprint"])
3505
+ if record.get("verification_policy") == "targeted":
3506
+ return {previous, current_implementation}, record
3507
+ return {previous}, record
3508
+
3509
+
2785
3510
  def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
2786
3511
  if not isinstance(task.get("spec_source"), dict):
2787
3512
  return
@@ -2858,11 +3583,12 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2858
3583
  if task.get("workflow_mode_legacy") is True and not is_spec_task:
2859
3584
  return
2860
3585
  expected = implementation_fingerprint(root, task_id)
3586
+ accepted_fingerprints = accepted_review_fingerprints(root, task_id, task, expected)
2861
3587
  latest_by_dimension: dict[str, dict] = {}
2862
3588
  for record in execution_records(root, task_id):
2863
3589
  if (
2864
3590
  record.get("type") == "review"
2865
- and record.get("implementation_fingerprint") == expected
3591
+ and record.get("implementation_fingerprint") in accepted_fingerprints
2866
3592
  and is_non_empty_string(record.get("dimension"))
2867
3593
  ):
2868
3594
  dimension = str(record["dimension"])
@@ -2977,6 +3703,13 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2977
3703
 
2978
3704
  def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
2979
3705
  fingerprints = evidence_fingerprints(root, task_id)
3706
+ accepted_fingerprints, acceptance = accepted_verification_fingerprints(
3707
+ root,
3708
+ task_id,
3709
+ task,
3710
+ fingerprints["implementation_fingerprint"],
3711
+ fingerprints["config_fingerprint"],
3712
+ )
2980
3713
  is_spec_task = isinstance(task.get("spec_source"), dict)
2981
3714
  if (
2982
3715
  (task.get("workflow_mode_legacy") is not True or is_spec_task)
@@ -2988,8 +3721,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2988
3721
  for record in execution_records(root, task_id):
2989
3722
  if (
2990
3723
  record.get("type") == "verify"
2991
- and record.get("implementation_fingerprint")
2992
- == fingerprints["implementation_fingerprint"]
3724
+ and record.get("implementation_fingerprint") in accepted_fingerprints
2993
3725
  and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
2994
3726
  and is_non_empty_string(record.get("check"))
2995
3727
  ):
@@ -3067,6 +3799,32 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
3067
3799
  raise StateError(
3068
3800
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
3069
3801
  )
3802
+ if acceptance and acceptance.get("verification_policy") == "targeted":
3803
+ current_records = [
3804
+ record
3805
+ for record in applicable_records
3806
+ if record.get("implementation_fingerprint")
3807
+ == fingerprints["implementation_fingerprint"]
3808
+ ]
3809
+ if not current_records or any(record.get("passed") is not True for record in current_records):
3810
+ raise StateError(
3811
+ "Accepted executable drift requires at least one passed targeted verification "
3812
+ "record for the current implementation fingerprint."
3813
+ )
3814
+ if is_spec_task:
3815
+ required_source_tasks = set(acceptance["required_targeted_source_tasks"])
3816
+ current_source_tasks = {
3817
+ str(record.get("source_task_id"))
3818
+ for record in current_records
3819
+ if is_non_empty_string(record.get("source_task_id"))
3820
+ }
3821
+ missing_source_tasks = sorted(required_source_tasks - current_source_tasks)
3822
+ if missing_source_tasks:
3823
+ raise StateError(
3824
+ "Accepted Canonical executable drift requires a passed current-fingerprint "
3825
+ "targeted verification record for affected source tasks: "
3826
+ + ", ".join(missing_source_tasks)
3827
+ )
3070
3828
  if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
3071
3829
  readiness = tdd_readiness(root)
3072
3830
  if readiness["status"] != "ready":
@@ -4223,6 +4981,11 @@ def build_machine_breadcrumbs(
4223
4981
  lines.append(
4224
4982
  "[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
4225
4983
  )
4984
+ elif pending.get("confirmation_override") == "evidence-drift":
4985
+ lines.append(
4986
+ "[easy-coding:acceptance-drift-confirmation-required]"
4987
+ )
4988
+ lines.append("[easy-coding:transition-confirmation-required]")
4226
4989
  elif is_automatic_transition(
4227
4990
  source,
4228
4991
  target,
@@ -5687,6 +6450,7 @@ def sync_spec_design_state(
5687
6450
  )
5688
6451
  progress.pop("pending_action", None)
5689
6452
  if task.get("status") not in {"INIT", "ANALYSIS"}:
6453
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
5690
6454
  task["status"] = "ANALYSIS"
5691
6455
  append_stage_history(task, "ANALYSIS", agent)
5692
6456
  task.pop("pending_transition", None)
@@ -5851,6 +6615,134 @@ def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> N
5851
6615
  )
5852
6616
 
5853
6617
 
6618
+ def effective_verification_records(root: Path, task_id: str, task: dict) -> list[dict]:
6619
+ fingerprints = evidence_fingerprints(root, task_id)
6620
+ accepted_fingerprints, _ = accepted_verification_fingerprints(
6621
+ root,
6622
+ task_id,
6623
+ task,
6624
+ fingerprints["implementation_fingerprint"],
6625
+ fingerprints["config_fingerprint"],
6626
+ )
6627
+ latest: dict[tuple[str, str, str], dict] = {}
6628
+ for record in execution_records(root, task_id):
6629
+ if (
6630
+ record.get("type") != "verify"
6631
+ or record.get("implementation_fingerprint") not in accepted_fingerprints
6632
+ or record.get("config_fingerprint") != fingerprints["config_fingerprint"]
6633
+ or record.get("applicable") is False
6634
+ ):
6635
+ continue
6636
+ key = (
6637
+ str(record.get("source_task_id") or ""),
6638
+ str(record.get("repo_id") or ""),
6639
+ str(record.get("command") or record.get("check") or ""),
6640
+ )
6641
+ latest[key] = record
6642
+ return list(latest.values())
6643
+
6644
+
6645
+ def acceptance_spec_evidence(acceptance: dict) -> dict:
6646
+ digest = str(acceptance.get("diff_sha256") or "")
6647
+ reference = (
6648
+ "execution.jsonl#acceptance="
6649
+ + digest
6650
+ + ";authorization="
6651
+ + str(acceptance.get("authorization") or "")
6652
+ + ";approval_mode="
6653
+ + str(acceptance.get("approval_mode") or "")
6654
+ + ";review_policy="
6655
+ + str(acceptance.get("review_policy") or "")
6656
+ + ";verification_policy="
6657
+ + str(acceptance.get("verification_policy") or "")
6658
+ + ";targeted_source_tasks="
6659
+ + ",".join(str(value) for value in acceptance.get("required_targeted_source_tasks", []))
6660
+ )
6661
+ return {
6662
+ "kind": "acceptance",
6663
+ "status": "recorded",
6664
+ "ref": reference,
6665
+ "sha256": digest,
6666
+ }
6667
+
6668
+
6669
+ def writeback_verified_tasks(
6670
+ root: Path,
6671
+ harness_task_id: str,
6672
+ task: dict,
6673
+ agent: str,
6674
+ session_file: str | Path | None = None,
6675
+ ) -> None:
6676
+ inspection, selection = inspect_task_spec(root, task)
6677
+ snapshots = _selected_execution_snapshots(inspection, task)
6678
+ acceptance = latest_acceptance_record(root, harness_task_id, task)
6679
+ if not isinstance(acceptance, dict):
6680
+ raise StateError("Canonical verification writeback requires an acceptance record.")
6681
+ verification_records = effective_verification_records(root, harness_task_id, task)
6682
+ selected_tasks = {
6683
+ str(item.get("task_id")): item
6684
+ for item in selection.get("selected_tasks", [])
6685
+ if isinstance(item, dict)
6686
+ }
6687
+ tests_by_task: dict[str, list[dict]] = {}
6688
+ for test in selection.get("selected_tests", []):
6689
+ if isinstance(test, dict):
6690
+ tests_by_task.setdefault(str(test.get("task_id")), []).append(test)
6691
+ for source_task_id in task.get("selected_spec_tasks") or []:
6692
+ source_task_id = str(source_task_id)
6693
+ status_value = snapshots.get(source_task_id, {}).get("status")
6694
+ if status_value in {"verified", "completed"}:
6695
+ continue
6696
+ if status_value != "implemented":
6697
+ raise StateError(
6698
+ f"Canonical task {source_task_id} must remain implemented until MEMORY entry is applied."
6699
+ )
6700
+ repo_id = str(selected_tasks.get(source_task_id, {}).get("repo_id") or "")
6701
+ evidence = []
6702
+ for test in tests_by_task.get(source_task_id, []):
6703
+ command = str(test.get("command") or "")
6704
+ matching = next(
6705
+ (
6706
+ record
6707
+ for record in verification_records
6708
+ if record.get("passed") is True
6709
+ and str(record.get("source_task_id") or "") == source_task_id
6710
+ and str(record.get("repo_id") or "") == repo_id
6711
+ and str(record.get("command") or "") == command
6712
+ ),
6713
+ None,
6714
+ )
6715
+ if matching is None:
6716
+ raise StateError(
6717
+ f"Canonical Test {test.get('test_id')} has no accepted verification command: {command}"
6718
+ )
6719
+ evidence.append(
6720
+ {
6721
+ "kind": "test",
6722
+ "status": "passed",
6723
+ "ref": f"execution.jsonl#verify;command={command}",
6724
+ "test_id": str(test.get("test_id")),
6725
+ }
6726
+ )
6727
+ evidence.append(acceptance_spec_evidence(acceptance))
6728
+ acceptance_key = str(acceptance.get("diff_sha256") or "")[:16]
6729
+ key = (
6730
+ f"{harness_task_id}:{source_task_id}:verified-after-acceptance:"
6731
+ f"{task['spec_source']['revision']}:{acceptance_key}"
6732
+ )
6733
+ writeback_spec_task(
6734
+ root,
6735
+ source_task_id,
6736
+ "verified",
6737
+ "Harness verification was accepted when the MEMORY boundary was applied",
6738
+ evidence,
6739
+ key,
6740
+ agent,
6741
+ harness_task_id,
6742
+ session_file,
6743
+ )
6744
+
6745
+
5854
6746
  def writeback_completed_tasks(
5855
6747
  root: Path,
5856
6748
  harness_task_id: str,
@@ -5859,6 +6751,10 @@ def writeback_completed_tasks(
5859
6751
  ) -> None:
5860
6752
  inspection, _ = inspect_task_spec(root, task)
5861
6753
  snapshots = _selected_execution_snapshots(inspection, task)
6754
+ acceptance = latest_acceptance_record(root, harness_task_id, task)
6755
+ acceptance_evidence = (
6756
+ [acceptance_spec_evidence(acceptance)] if isinstance(acceptance, dict) else []
6757
+ )
5862
6758
  for source_task_id in task.get("selected_spec_tasks") or []:
5863
6759
  status_value = snapshots.get(str(source_task_id), {}).get("status")
5864
6760
  if status_value == "completed":
@@ -5867,13 +6763,21 @@ def writeback_completed_tasks(
5867
6763
  raise StateError(
5868
6764
  f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
5869
6765
  )
5870
- key = f"{harness_task_id}:{source_task_id}:complete:{task['spec_source']['revision']}"
6766
+ acceptance_key = (
6767
+ str(acceptance.get("diff_sha256") or "")[:16]
6768
+ if isinstance(acceptance, dict)
6769
+ else "legacy"
6770
+ )
6771
+ key = (
6772
+ f"{harness_task_id}:{source_task_id}:complete:"
6773
+ f"{task['spec_source']['revision']}:{acceptance_key}"
6774
+ )
5871
6775
  action = {
5872
6776
  "kind": "task",
5873
6777
  "source_task_id": source_task_id,
5874
6778
  "status": "completed",
5875
6779
  "summary": "Harness MEMORY completed and the Canonical task is complete",
5876
- "evidence": [],
6780
+ "evidence": acceptance_evidence,
5877
6781
  "idempotency_key": key,
5878
6782
  "agent": agent,
5879
6783
  }
@@ -5892,6 +6796,7 @@ def writeback_completed_tasks(
5892
6796
  spec_writeback_agent(agent),
5893
6797
  design_digest,
5894
6798
  execution_revision,
6799
+ evidence=acceptance_evidence,
5895
6800
  run_id=harness_task_id,
5896
6801
  idempotency_key=key,
5897
6802
  ),
@@ -6351,8 +7256,22 @@ def request_transition(
6351
7256
  )
6352
7257
  if previous == "REVIEW" and stage == "VERIFICATION":
6353
7258
  validate_review_readiness(root, resolved_task_id, task)
7259
+ acceptance_drift: dict | None = None
6354
7260
  if previous == "VERIFICATION" and stage == "MEMORY":
6355
- validate_verification_readiness(root, resolved_task_id, task)
7261
+ task = ensure_verification_checkpoint(
7262
+ root, resolved_task_id, task, agent, session_file
7263
+ )
7264
+ acceptance_drift = inspect_acceptance_drift(root, resolved_task_id, task)
7265
+ if acceptance_drift["config_changed"]:
7266
+ raise StateError(
7267
+ "Behavior config changed after verification; rerun verification before MEMORY."
7268
+ )
7269
+ if acceptance_drift["metadata_changed"]:
7270
+ raise StateError(
7271
+ "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
7272
+ )
7273
+ if acceptance_drift["status"] == "clean":
7274
+ validate_verification_readiness(root, resolved_task_id, task)
6356
7275
  existing = task.get("pending_transition")
6357
7276
  if isinstance(existing, dict):
6358
7277
  if existing.get("from") != previous or existing.get("to") != stage:
@@ -6372,6 +7291,8 @@ def request_transition(
6372
7291
 
6373
7292
  snapshot = snapshot_state(root, session_file, session)
6374
7293
  snapshot["action"] = "request-transition"
7294
+ if acceptance_drift is not None:
7295
+ snapshot["acceptance_drift"] = acceptance_drift
6375
7296
  return snapshot
6376
7297
 
6377
7298
 
@@ -6412,6 +7333,10 @@ def apply_transition(
6412
7333
  if previous == "VERIFICATION" and stage == "MEMORY":
6413
7334
  validate_verification_readiness(root, resolved_task_id, task)
6414
7335
  if isinstance(task.get("spec_source"), dict):
7336
+ writeback_verified_tasks(
7337
+ root, resolved_task_id, task, agent, session_file
7338
+ )
7339
+ task = load_task(root, resolved_task_id) or task
6415
7340
  require_shared_task_statuses(root, task, {"verified", "completed"})
6416
7341
  if previous == "MEMORY" and stage == "COMPLETE":
6417
7342
  progress = task.get("memory_progress")
@@ -6433,6 +7358,8 @@ def apply_transition(
6433
7358
  task.pop("workflow_mode_legacy_direct_edge", None)
6434
7359
  if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
6435
7360
  task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
7361
+ if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
7362
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
6436
7363
  task.pop("pending_transition", None)
6437
7364
  if stage == "MEMORY" and previous != stage:
6438
7365
  task["memory_progress"] = {}
@@ -6457,7 +7384,7 @@ def auto_transition(
6457
7384
  task_id: str | None = None,
6458
7385
  session_file: str | Path | None = None,
6459
7386
  ) -> dict:
6460
- session, _, task = resolve_current_task(root, task_id, session_file)
7387
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6461
7388
  previous = str(task.get("status") or "idle")
6462
7389
  task_type = str(task.get("type") or "")
6463
7390
  approval_mode = resolve_approval_mode(root, session)[2]
@@ -6474,6 +7401,43 @@ def auto_transition(
6474
7401
  "A different transition is already pending. Cancel it before automatic transition."
6475
7402
  )
6476
7403
 
7404
+ if previous == "VERIFICATION" and stage == "MEMORY":
7405
+ task = ensure_verification_checkpoint(
7406
+ root, resolved_task_id, task, agent, session_file
7407
+ )
7408
+ drift = inspect_acceptance_drift(root, resolved_task_id, task)
7409
+ if drift["config_changed"]:
7410
+ raise StateError(
7411
+ "Behavior config changed after verification; rerun verification before MEMORY."
7412
+ )
7413
+ if drift["metadata_changed"]:
7414
+ raise StateError(
7415
+ "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
7416
+ )
7417
+ if drift["changed_files"]:
7418
+ task["pending_transition"] = {
7419
+ "from": previous,
7420
+ "to": stage,
7421
+ "requested_at": now_iso(),
7422
+ "requested_by": agent,
7423
+ "reason": "verification checkpoint drift requires exact user acceptance",
7424
+ "confirmation_override": "evidence-drift",
7425
+ }
7426
+ task["last_agent"] = agent
7427
+ write_task(root, resolved_task_id, task)
7428
+ snapshot = snapshot_state(root, session_file, session)
7429
+ snapshot["action"] = "acceptance-drift"
7430
+ snapshot["acceptance_drift"] = drift
7431
+ return snapshot
7432
+ append_transition_acceptance(
7433
+ root,
7434
+ resolved_task_id,
7435
+ task,
7436
+ agent,
7437
+ approval_mode,
7438
+ "approval-policy",
7439
+ )
7440
+
6477
7441
  snapshot = apply_transition(root, stage, agent, task_id, session_file)
6478
7442
  snapshot["action"] = "auto-transition"
6479
7443
  snapshot["automatic_transition"] = {"from": previous, "to": stage}
@@ -6486,8 +7450,11 @@ def confirm_transition(
6486
7450
  stage: str | None = None,
6487
7451
  task_id: str | None = None,
6488
7452
  session_file: str | Path | None = None,
7453
+ expected_diff_sha256: str | None = None,
7454
+ verification_policy: str | None = None,
7455
+ decision_summary: str | None = None,
6489
7456
  ) -> dict:
6490
- session, _, task = resolve_current_task(root, task_id, session_file)
7457
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6491
7458
  pending = task.get("pending_transition")
6492
7459
  if not isinstance(pending, dict):
6493
7460
  raise StateError("No transition is pending user confirmation.")
@@ -6502,12 +7469,29 @@ def confirm_transition(
6502
7469
  )
6503
7470
  if stage and stage != target:
6504
7471
  raise StateError(f"Pending transition targets {target}, not {stage}.")
6505
- if is_automatic_transition(source, target, task_type, approval_mode):
7472
+ drift_override = pending.get("confirmation_override") == "evidence-drift"
7473
+ if is_automatic_transition(source, target, task_type, approval_mode) and not drift_override:
6506
7474
  raise StateError(
6507
7475
  f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
6508
7476
  "use auto-transition instead."
6509
7477
  )
6510
7478
 
7479
+ if source == "VERIFICATION" and target == "MEMORY":
7480
+ task = ensure_verification_checkpoint(
7481
+ root, resolved_task_id, task, agent, session_file
7482
+ )
7483
+ append_transition_acceptance(
7484
+ root,
7485
+ resolved_task_id,
7486
+ task,
7487
+ agent,
7488
+ approval_mode,
7489
+ "explicit-user",
7490
+ expected_diff_sha256,
7491
+ verification_policy,
7492
+ decision_summary,
7493
+ )
7494
+
6511
7495
  snapshot = apply_transition(root, target, agent, task_id, session_file)
6512
7496
  snapshot["action"] = "confirm-transition"
6513
7497
  snapshot["confirmed_transition"] = {"from": source, "to": target}
@@ -6548,6 +7532,46 @@ def memory_short_complete(
6548
7532
  memory_file.strip(),
6549
7533
  require_current_id=True,
6550
7534
  )
7535
+ acceptance = latest_acceptance_record(root, resolved_task_id, task)
7536
+ if isinstance(acceptance, dict) and acceptance.get("changed_files"):
7537
+ try:
7538
+ memory_text = resolved_memory_path.read_text(encoding="utf-8")
7539
+ except (OSError, UnicodeError) as exc:
7540
+ raise StateError(f"Cannot read short-memory file: {resolved_memory_path}") from exc
7541
+ required_decision_fields = {
7542
+ "diff_sha256": str(acceptance.get("diff_sha256") or ""),
7543
+ "authorization": str(acceptance.get("authorization") or ""),
7544
+ "approval_mode": str(acceptance.get("approval_mode") or ""),
7545
+ "review_policy": str(acceptance.get("review_policy") or ""),
7546
+ "verification_policy": str(acceptance.get("verification_policy") or ""),
7547
+ "summary": str(acceptance.get("summary") or ""),
7548
+ }
7549
+ missing_decision_fields = [
7550
+ field_name
7551
+ for field_name, value in required_decision_fields.items()
7552
+ if not value or value not in memory_text
7553
+ ]
7554
+ missing_changed_files = [
7555
+ str(file_name)
7556
+ for file_name in acceptance.get("changed_files", [])
7557
+ if not is_non_empty_string(file_name) or str(file_name) not in memory_text
7558
+ ]
7559
+ missing_targeted_tasks = [
7560
+ str(source_task_id)
7561
+ for source_task_id in acceptance.get("required_targeted_source_tasks", [])
7562
+ if not is_non_empty_string(source_task_id)
7563
+ or str(source_task_id) not in memory_text
7564
+ ]
7565
+ if missing_decision_fields or missing_changed_files or missing_targeted_tasks:
7566
+ missing_labels = [
7567
+ *missing_decision_fields,
7568
+ *(f"changed_file:{file_name}" for file_name in missing_changed_files),
7569
+ *(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
7570
+ ]
7571
+ raise StateError(
7572
+ "Short memory must record the complete accepted post-verification decision; "
7573
+ "missing: " + ", ".join(missing_labels)
7574
+ )
6551
7575
  progress = task.get("memory_progress")
6552
7576
  if not isinstance(progress, dict):
6553
7577
  progress = {}
@@ -6676,6 +7700,7 @@ def close_current_task(
6676
7700
  if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
6677
7701
  cancel_shared_tasks(root, str(task_id), task, reason, agent)
6678
7702
  if task.get("status") != "CLOSED":
7703
+ cleanup_verification_checkpoint(root, str(task_id), task)
6679
7704
  task["status"] = "CLOSED"
6680
7705
  append_stage_history(task, "CLOSED", agent)
6681
7706
  task.pop("pending_transition", None)
@@ -6969,6 +7994,18 @@ def main() -> int:
6969
7994
  fingerprints_parser.add_argument("--agent", required=True)
6970
7995
  fingerprints_parser.add_argument("--task-id")
6971
7996
 
7997
+ verification_checkpoint_parser = subcommands.add_parser(
7998
+ "verification-checkpoint", parents=[common]
7999
+ )
8000
+ verification_checkpoint_parser.add_argument("--agent", required=True)
8001
+ verification_checkpoint_parser.add_argument("--task-id")
8002
+
8003
+ inspect_transition_drift_parser = subcommands.add_parser(
8004
+ "inspect-transition-drift", parents=[common]
8005
+ )
8006
+ inspect_transition_drift_parser.add_argument("--agent", required=True)
8007
+ inspect_transition_drift_parser.add_argument("--task-id")
8008
+
6972
8009
  disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
6973
8010
  disable_harness_parser.add_argument("--agent", required=True)
6974
8011
 
@@ -6994,6 +8031,11 @@ def main() -> int:
6994
8031
  confirm_transition_parser.add_argument("--stage")
6995
8032
  confirm_transition_parser.add_argument("--agent", required=True)
6996
8033
  confirm_transition_parser.add_argument("--task-id")
8034
+ confirm_transition_parser.add_argument("--diff-sha256")
8035
+ confirm_transition_parser.add_argument(
8036
+ "--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
8037
+ )
8038
+ confirm_transition_parser.add_argument("--decision-summary")
6997
8039
 
6998
8040
  auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
6999
8041
  auto_transition_parser.add_argument("--stage", required=True)
@@ -7005,6 +8047,11 @@ def main() -> int:
7005
8047
  transition.add_argument("--stage")
7006
8048
  transition.add_argument("--agent", required=True)
7007
8049
  transition.add_argument("--task-id")
8050
+ transition.add_argument("--diff-sha256")
8051
+ transition.add_argument(
8052
+ "--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
8053
+ )
8054
+ transition.add_argument("--decision-summary")
7008
8055
 
7009
8056
  cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
7010
8057
  cancel_transition_parser.add_argument("--agent", required=True)
@@ -7413,6 +8460,28 @@ def main() -> int:
7413
8460
  session_file,
7414
8461
  )
7415
8462
  )
8463
+ elif command == "verification-checkpoint":
8464
+ emit(
8465
+ attach_status_context(
8466
+ root,
8467
+ record_verification_checkpoint(
8468
+ root, agent, args.task_id, session_file
8469
+ ),
8470
+ agent,
8471
+ session_file,
8472
+ )
8473
+ )
8474
+ elif command == "inspect-transition-drift":
8475
+ emit(
8476
+ attach_status_context(
8477
+ root,
8478
+ inspect_transition_drift(
8479
+ root, agent, args.task_id, session_file
8480
+ ),
8481
+ agent,
8482
+ session_file,
8483
+ )
8484
+ )
7416
8485
  elif command == "disable-harness":
7417
8486
  emit(
7418
8487
  attach_status_context(
@@ -7469,7 +8538,16 @@ def main() -> int:
7469
8538
  emit(
7470
8539
  attach_status_context(
7471
8540
  root,
7472
- confirm_transition(root, agent, args.stage, args.task_id, session_file),
8541
+ confirm_transition(
8542
+ root,
8543
+ agent,
8544
+ args.stage,
8545
+ args.task_id,
8546
+ session_file,
8547
+ args.diff_sha256,
8548
+ args.verification_policy,
8549
+ args.decision_summary,
8550
+ ),
7473
8551
  agent,
7474
8552
  session_file,
7475
8553
  )