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.
- package/CHANGELOG.md +34 -0
- package/README.md +14 -4
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +10 -0
- package/templates/claude/agents/ec-reviewer.md +6 -1
- package/templates/codex/agents/ec-implementer.toml +10 -0
- package/templates/codex/agents/ec-reviewer.toml +6 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +8 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +10 -4
- package/templates/common/skills/ec-analysis/SKILL.md +47 -13
- package/templates/common/skills/ec-config/SKILL.md +6 -0
- package/templates/common/skills/ec-implementing/SKILL.md +27 -2
- package/templates/common/skills/ec-memory/SKILL.md +7 -0
- package/templates/common/skills/ec-reviewing/SKILL.md +11 -1
- package/templates/common/skills/ec-verification/SKILL.md +45 -9
- package/templates/common/skills/ec-workflow/SKILL.md +22 -2
- package/templates/main-constraint/AGENTS.md.tpl +13 -4
- package/templates/main-constraint/CLAUDE.md.tpl +13 -4
- package/templates/qoder/agents/ec-implementer.md +10 -0
- package/templates/qoder/agents/ec-reviewer.md +6 -1
- package/templates/shared-hooks/easy_coding_state.py +1187 -48
|
@@ -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
|
-
|
|
91
|
-
r"(
|
|
92
|
-
r"
|
|
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")
|
|
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
|
-
|
|
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 =
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
)
|