easy-coding-harness 0.10.0-beta.6 → 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
@@ -87,10 +89,24 @@ WORKFLOW_MODES = {"fast", "standard", "strict"}
87
89
  WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
88
90
  STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
89
91
  REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
90
- STRICT_WORKFLOW_RISK_PATTERN = re.compile(
91
- r"(migration|migrate|schema|state[-_ ]?machine|security|payment|data[-_ ]?loss|"
92
- r"concurren|cross[-_ ]?repo|public[-_ ]?(api|contract)|迁移|状态机|安全|支付|"
93
- r"数据丢失|并发|跨仓|公共接口|公共契约)",
92
+ HIGH_WORKFLOW_RISK_PATTERN = re.compile(
93
+ r"(\bhigh[-_ ]?risk\b|\bcritical\b|\bsevere\b|\birreversible\b|"
94
+ r"\bdata[-_ ]?loss\b|\bfinancial[-_ ]?loss\b|"
95
+ r"\bsecurity[-_ ]?(boundary|breach)\b|\bprivilege[-_ ]?escalation\b|"
96
+ r"\bproduction[-_ ]?outage\b|"
97
+ r"高风险|严重|不可逆|数据丢失|资损|安全边界|安全事件|权限提升|生产故障)",
98
+ re.IGNORECASE,
99
+ )
100
+ NEGATED_HIGH_WORKFLOW_RISK_PATTERN = re.compile(
101
+ r"(\b(?:non[-_ ]?|not[-_ ]+|no[-_ ]+)(?:high[-_ ]?risk|critical|severe|irreversible)\b|"
102
+ r"\b(?:no|without)[-_ ]+(?:risk[-_ ]+of[-_ ]+)?(?:data[-_ ]?loss|"
103
+ r"financial[-_ ]?loss|security[-_ ]?breach|production[-_ ]?outage)\b|"
104
+ r"低风险|非高风险|不严重|(?<!不)可逆|无(?:数据丢失|资损|安全事件|生产故障)|"
105
+ r"不会导致(?:数据丢失|资损|安全事件|生产故障))",
106
+ re.IGNORECASE,
107
+ )
108
+ WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
109
+ r"(cross[-_ ]?repo|public[-_ ]?(api|contract)|跨仓|公共接口|公共契约)",
94
110
  re.IGNORECASE,
95
111
  )
96
112
  DEFAULT_APPROVAL_MODE = "guard"
@@ -125,6 +141,8 @@ ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
125
141
  ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
126
142
  # MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
127
143
  ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
144
+ ACCEPTANCE_SNAPSHOT_SCHEMA = 1
145
+ ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
128
146
  SESSION_STALE_THRESHOLD_HOURS = 30 * 24
129
147
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
130
148
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
@@ -1589,7 +1607,7 @@ def is_valid_execution_plan(
1589
1607
  has_empty_file_scope = True
1590
1608
  if not is_string_list(unit.get("depends_on")):
1591
1609
  return False
1592
- for optional_list in ("rules_sections", "abstract_modules"):
1610
+ for optional_list in ("rules_sections", "abstract_modules", "local_baseline"):
1593
1611
  if optional_list in unit and not is_string_list(unit.get(optional_list)):
1594
1612
  return False
1595
1613
  if require_unit_contracts:
@@ -2279,6 +2297,42 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
2279
2297
  ]
2280
2298
 
2281
2299
 
2300
+ def workflow_plan_repository_roots(root: Path, task: dict, plan: dict) -> list[Path]:
2301
+ """Resolve only repositories that own files in the current execution plan."""
2302
+ repositories: set[Path] = set()
2303
+ repo_paths = task.get("repo_paths")
2304
+ canonical = isinstance(task.get("spec_source"), dict)
2305
+
2306
+ for unit in plan.get("units", []):
2307
+ if not isinstance(unit, dict):
2308
+ continue
2309
+ if canonical:
2310
+ repo_id = unit.get("repo_id")
2311
+ if not is_non_empty_string(repo_id) or not isinstance(repo_paths, dict):
2312
+ raise StateError("Canonical workflow Unit is missing its repository binding.")
2313
+ raw_repo_path = repo_paths.get(str(repo_id))
2314
+ if not is_non_empty_string(raw_repo_path):
2315
+ raise StateError(f"Canonical workflow repository path is missing: {repo_id}")
2316
+ candidate = Path(str(raw_repo_path))
2317
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
2318
+ repository = git_repository_root(resolved)
2319
+ if repository is None or repository.resolve() != resolved:
2320
+ raise StateError(f"Canonical workflow repository binding is not a Git root: {repo_id}")
2321
+ repositories.add(repository.resolve())
2322
+ continue
2323
+
2324
+ for file_name in unit.get("files", []):
2325
+ if not is_non_empty_string(file_name):
2326
+ continue
2327
+ candidate = Path(str(file_name))
2328
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
2329
+ repository = git_repository_root(resolved)
2330
+ if repository is not None:
2331
+ repositories.add(repository.resolve())
2332
+
2333
+ return sorted(repositories, key=lambda item: item.as_posix())
2334
+
2335
+
2282
2336
  def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
2283
2337
  if isinstance(task.get("spec_source"), dict):
2284
2338
  repo_paths = task.get("repo_paths")
@@ -2701,6 +2755,665 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2701
2755
  }
2702
2756
 
2703
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
+
2704
3417
  def command_option_value(command: str, option: str) -> str | None:
2705
3418
  try:
2706
3419
  tokens = shlex.split(command)
@@ -2732,6 +3445,68 @@ def coverage_command_matches_frozen_contract(
2732
3445
  )
2733
3446
 
2734
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
+
2735
3510
  def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
2736
3511
  if not isinstance(task.get("spec_source"), dict):
2737
3512
  return
@@ -2808,11 +3583,12 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2808
3583
  if task.get("workflow_mode_legacy") is True and not is_spec_task:
2809
3584
  return
2810
3585
  expected = implementation_fingerprint(root, task_id)
3586
+ accepted_fingerprints = accepted_review_fingerprints(root, task_id, task, expected)
2811
3587
  latest_by_dimension: dict[str, dict] = {}
2812
3588
  for record in execution_records(root, task_id):
2813
3589
  if (
2814
3590
  record.get("type") == "review"
2815
- and record.get("implementation_fingerprint") == expected
3591
+ and record.get("implementation_fingerprint") in accepted_fingerprints
2816
3592
  and is_non_empty_string(record.get("dimension"))
2817
3593
  ):
2818
3594
  dimension = str(record["dimension"])
@@ -2927,6 +3703,13 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2927
3703
 
2928
3704
  def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
2929
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
+ )
2930
3713
  is_spec_task = isinstance(task.get("spec_source"), dict)
2931
3714
  if (
2932
3715
  (task.get("workflow_mode_legacy") is not True or is_spec_task)
@@ -2938,8 +3721,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2938
3721
  for record in execution_records(root, task_id):
2939
3722
  if (
2940
3723
  record.get("type") == "verify"
2941
- and record.get("implementation_fingerprint")
2942
- == fingerprints["implementation_fingerprint"]
3724
+ and record.get("implementation_fingerprint") in accepted_fingerprints
2943
3725
  and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
2944
3726
  and is_non_empty_string(record.get("check"))
2945
3727
  ):
@@ -3017,6 +3799,32 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
3017
3799
  raise StateError(
3018
3800
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
3019
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
+ )
3020
3828
  if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
3021
3829
  readiness = tdd_readiness(root)
3022
3830
  if readiness["status"] != "ready":
@@ -4173,6 +4981,11 @@ def build_machine_breadcrumbs(
4173
4981
  lines.append(
4174
4982
  "[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
4175
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]")
4176
4989
  elif is_automatic_transition(
4177
4990
  source,
4178
4991
  target,
@@ -5637,6 +6450,7 @@ def sync_spec_design_state(
5637
6450
  )
5638
6451
  progress.pop("pending_action", None)
5639
6452
  if task.get("status") not in {"INIT", "ANALYSIS"}:
6453
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
5640
6454
  task["status"] = "ANALYSIS"
5641
6455
  append_stage_history(task, "ANALYSIS", agent)
5642
6456
  task.pop("pending_transition", None)
@@ -5801,6 +6615,134 @@ def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> N
5801
6615
  )
5802
6616
 
5803
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
+
5804
6746
  def writeback_completed_tasks(
5805
6747
  root: Path,
5806
6748
  harness_task_id: str,
@@ -5809,6 +6751,10 @@ def writeback_completed_tasks(
5809
6751
  ) -> None:
5810
6752
  inspection, _ = inspect_task_spec(root, task)
5811
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
+ )
5812
6758
  for source_task_id in task.get("selected_spec_tasks") or []:
5813
6759
  status_value = snapshots.get(str(source_task_id), {}).get("status")
5814
6760
  if status_value == "completed":
@@ -5817,13 +6763,21 @@ def writeback_completed_tasks(
5817
6763
  raise StateError(
5818
6764
  f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
5819
6765
  )
5820
- 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
+ )
5821
6775
  action = {
5822
6776
  "kind": "task",
5823
6777
  "source_task_id": source_task_id,
5824
6778
  "status": "completed",
5825
6779
  "summary": "Harness MEMORY completed and the Canonical task is complete",
5826
- "evidence": [],
6780
+ "evidence": acceptance_evidence,
5827
6781
  "idempotency_key": key,
5828
6782
  "agent": agent,
5829
6783
  }
@@ -5842,6 +6796,7 @@ def writeback_completed_tasks(
5842
6796
  spec_writeback_agent(agent),
5843
6797
  design_digest,
5844
6798
  execution_revision,
6799
+ evidence=acceptance_evidence,
5845
6800
  run_id=harness_task_id,
5846
6801
  idempotency_key=key,
5847
6802
  ),
@@ -6083,54 +7038,65 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
6083
7038
  if not plan:
6084
7039
  raise StateError("Cannot calculate workflow floor without a valid execution plan.")
6085
7040
  units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
7041
+ missing_local_baseline = [
7042
+ str(unit.get("id") or "<unknown>")
7043
+ for unit in units
7044
+ if not is_string_list(unit.get("local_baseline"), allow_empty=False)
7045
+ ]
7046
+ if missing_local_baseline:
7047
+ raise StateError(
7048
+ "Workflow plan Units must record a non-empty local_baseline: "
7049
+ + ", ".join(missing_local_baseline)
7050
+ )
6086
7051
  files = {
6087
7052
  str(file_name)
6088
7053
  for unit in units
6089
7054
  for file_name in unit.get("files", [])
6090
7055
  if is_non_empty_string(file_name)
6091
7056
  }
6092
- repositories = task_repository_roots(root, task, plan)
6093
- repos = task.get("repos")
6094
- repo_paths = task.get("repo_paths")
6095
- metadata_repo_count = max(
6096
- len(repos) if isinstance(repos, list) else 0,
6097
- len(repo_paths) if isinstance(repo_paths, dict) else 0,
6098
- )
6099
- repo_count = max(len(repositories), metadata_repo_count)
6100
- risk_text = " ".join(
6101
- [
6102
- str(task.get("title") or ""),
6103
- task_type,
6104
- *files,
6105
- *[
6106
- str(item)
6107
- for unit in units
6108
- for field in ("risks", "contracts")
6109
- for item in unit.get(field, [])
6110
- if is_non_empty_string(item)
6111
- and str(item).strip().lower() not in {"none", "no", "n/a", "无", "无风险"}
6112
- ],
7057
+ repositories = workflow_plan_repository_roots(root, task, plan)
7058
+ ignored_values = {"none", "no", "n/a", "无", "无风险"}
7059
+ risk_values = [
7060
+ str(item)
7061
+ for unit in units
7062
+ for item in unit.get("risks", [])
7063
+ if is_non_empty_string(item) and str(item).strip().lower() not in ignored_values
7064
+ ]
7065
+ contract_values = [
7066
+ str(item)
7067
+ for unit in units
7068
+ for item in unit.get("contracts", [])
7069
+ if is_non_empty_string(item) and str(item).strip().lower() not in ignored_values
7070
+ ]
7071
+ risk_text = NEGATED_HIGH_WORKFLOW_RISK_PATTERN.sub("", " ".join(risk_values))
7072
+ high_risk = bool(HIGH_WORKFLOW_RISK_PATTERN.search(risk_text))
7073
+
7074
+ complexity_reasons: list[str] = []
7075
+ if len(repositories) > 1:
7076
+ complexity_reasons.append("cross-repository-change")
7077
+ if len(units) >= 4 or len(files) >= 10:
7078
+ complexity_reasons.append("broad-change-scope")
7079
+ if WIDE_WORKFLOW_CONTRACT_PATTERN.search(" ".join(contract_values)):
7080
+ complexity_reasons.append("wide-contract-impact")
7081
+ if high_risk and complexity_reasons:
7082
+ return "strict", [
7083
+ "compound-high-risk-and-complexity",
7084
+ "explicit-high-risk-signal",
7085
+ *complexity_reasons,
6113
7086
  ]
6114
- )
6115
- strict_reasons: list[str] = []
6116
- if repo_count > 1:
6117
- strict_reasons.append("cross-repository-scope")
6118
- if len(units) >= 4 or len(files) >= 8:
6119
- strict_reasons.append("broad-change-scope")
6120
- if STRICT_WORKFLOW_RISK_PATTERN.search(risk_text):
6121
- strict_reasons.append("high-risk-contract-or-domain")
6122
- if strict_reasons:
6123
- return "strict", strict_reasons
6124
7087
 
6125
7088
  standard_reasons: list[str] = []
7089
+ if high_risk:
7090
+ standard_reasons.append("bounded-high-risk-change")
7091
+ standard_reasons.extend(complexity_reasons)
6126
7092
  if len(units) > 1:
6127
7093
  standard_reasons.append("multiple-units")
6128
- if len(files) >= 3:
7094
+ if len(files) > 5:
6129
7095
  standard_reasons.append("multi-file-impact")
6130
7096
  if plan.get("strategy") == "parallel":
6131
7097
  standard_reasons.append("parallel-execution")
6132
7098
  if standard_reasons:
6133
- return "standard", standard_reasons
7099
+ return "standard", list(dict.fromkeys(standard_reasons))
6134
7100
  return "fast", ["single-bounded-unit"]
6135
7101
 
6136
7102
 
@@ -6290,8 +7256,22 @@ def request_transition(
6290
7256
  )
6291
7257
  if previous == "REVIEW" and stage == "VERIFICATION":
6292
7258
  validate_review_readiness(root, resolved_task_id, task)
7259
+ acceptance_drift: dict | None = None
6293
7260
  if previous == "VERIFICATION" and stage == "MEMORY":
6294
- 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)
6295
7275
  existing = task.get("pending_transition")
6296
7276
  if isinstance(existing, dict):
6297
7277
  if existing.get("from") != previous or existing.get("to") != stage:
@@ -6311,6 +7291,8 @@ def request_transition(
6311
7291
 
6312
7292
  snapshot = snapshot_state(root, session_file, session)
6313
7293
  snapshot["action"] = "request-transition"
7294
+ if acceptance_drift is not None:
7295
+ snapshot["acceptance_drift"] = acceptance_drift
6314
7296
  return snapshot
6315
7297
 
6316
7298
 
@@ -6351,6 +7333,10 @@ def apply_transition(
6351
7333
  if previous == "VERIFICATION" and stage == "MEMORY":
6352
7334
  validate_verification_readiness(root, resolved_task_id, task)
6353
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
6354
7340
  require_shared_task_statuses(root, task, {"verified", "completed"})
6355
7341
  if previous == "MEMORY" and stage == "COMPLETE":
6356
7342
  progress = task.get("memory_progress")
@@ -6372,6 +7358,8 @@ def apply_transition(
6372
7358
  task.pop("workflow_mode_legacy_direct_edge", None)
6373
7359
  if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
6374
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)
6375
7363
  task.pop("pending_transition", None)
6376
7364
  if stage == "MEMORY" and previous != stage:
6377
7365
  task["memory_progress"] = {}
@@ -6396,7 +7384,7 @@ def auto_transition(
6396
7384
  task_id: str | None = None,
6397
7385
  session_file: str | Path | None = None,
6398
7386
  ) -> dict:
6399
- session, _, task = resolve_current_task(root, task_id, session_file)
7387
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6400
7388
  previous = str(task.get("status") or "idle")
6401
7389
  task_type = str(task.get("type") or "")
6402
7390
  approval_mode = resolve_approval_mode(root, session)[2]
@@ -6413,6 +7401,43 @@ def auto_transition(
6413
7401
  "A different transition is already pending. Cancel it before automatic transition."
6414
7402
  )
6415
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
+
6416
7441
  snapshot = apply_transition(root, stage, agent, task_id, session_file)
6417
7442
  snapshot["action"] = "auto-transition"
6418
7443
  snapshot["automatic_transition"] = {"from": previous, "to": stage}
@@ -6425,8 +7450,11 @@ def confirm_transition(
6425
7450
  stage: str | None = None,
6426
7451
  task_id: str | None = None,
6427
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,
6428
7456
  ) -> dict:
6429
- session, _, task = resolve_current_task(root, task_id, session_file)
7457
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6430
7458
  pending = task.get("pending_transition")
6431
7459
  if not isinstance(pending, dict):
6432
7460
  raise StateError("No transition is pending user confirmation.")
@@ -6441,12 +7469,29 @@ def confirm_transition(
6441
7469
  )
6442
7470
  if stage and stage != target:
6443
7471
  raise StateError(f"Pending transition targets {target}, not {stage}.")
6444
- 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:
6445
7474
  raise StateError(
6446
7475
  f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
6447
7476
  "use auto-transition instead."
6448
7477
  )
6449
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
+
6450
7495
  snapshot = apply_transition(root, target, agent, task_id, session_file)
6451
7496
  snapshot["action"] = "confirm-transition"
6452
7497
  snapshot["confirmed_transition"] = {"from": source, "to": target}
@@ -6487,6 +7532,46 @@ def memory_short_complete(
6487
7532
  memory_file.strip(),
6488
7533
  require_current_id=True,
6489
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
+ )
6490
7575
  progress = task.get("memory_progress")
6491
7576
  if not isinstance(progress, dict):
6492
7577
  progress = {}
@@ -6615,6 +7700,7 @@ def close_current_task(
6615
7700
  if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
6616
7701
  cancel_shared_tasks(root, str(task_id), task, reason, agent)
6617
7702
  if task.get("status") != "CLOSED":
7703
+ cleanup_verification_checkpoint(root, str(task_id), task)
6618
7704
  task["status"] = "CLOSED"
6619
7705
  append_stage_history(task, "CLOSED", agent)
6620
7706
  task.pop("pending_transition", None)
@@ -6908,6 +7994,18 @@ def main() -> int:
6908
7994
  fingerprints_parser.add_argument("--agent", required=True)
6909
7995
  fingerprints_parser.add_argument("--task-id")
6910
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
+
6911
8009
  disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
6912
8010
  disable_harness_parser.add_argument("--agent", required=True)
6913
8011
 
@@ -6933,6 +8031,11 @@ def main() -> int:
6933
8031
  confirm_transition_parser.add_argument("--stage")
6934
8032
  confirm_transition_parser.add_argument("--agent", required=True)
6935
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")
6936
8039
 
6937
8040
  auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
6938
8041
  auto_transition_parser.add_argument("--stage", required=True)
@@ -6944,6 +8047,11 @@ def main() -> int:
6944
8047
  transition.add_argument("--stage")
6945
8048
  transition.add_argument("--agent", required=True)
6946
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")
6947
8055
 
6948
8056
  cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
6949
8057
  cancel_transition_parser.add_argument("--agent", required=True)
@@ -7352,6 +8460,28 @@ def main() -> int:
7352
8460
  session_file,
7353
8461
  )
7354
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
+ )
7355
8485
  elif command == "disable-harness":
7356
8486
  emit(
7357
8487
  attach_status_context(
@@ -7408,7 +8538,16 @@ def main() -> int:
7408
8538
  emit(
7409
8539
  attach_status_context(
7410
8540
  root,
7411
- 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
+ ),
7412
8551
  agent,
7413
8552
  session_file,
7414
8553
  )