easy-coding-harness 0.10.0-beta.3 → 0.10.0-beta.5

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.
@@ -7,6 +7,7 @@ import re
7
7
  import secrets
8
8
  import shlex
9
9
  import subprocess
10
+ import tempfile
10
11
  import time
11
12
  import uuid
12
13
  from datetime import datetime, timezone
@@ -20,6 +21,17 @@ from easy_dev_spec import (
20
21
  select_consumption_scopes,
21
22
  select_tasks,
22
23
  )
24
+ from easy_dev_spec_execution import (
25
+ ExecutionConflictError,
26
+ ExecutionStateError,
27
+ initialize_execution,
28
+ record_dependency_status,
29
+ record_step_status,
30
+ record_task_status,
31
+ show_execution,
32
+ sync_design,
33
+ )
34
+ from easy_dev_spec_protocol import split_execution_region
23
35
 
24
36
 
25
37
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
@@ -38,6 +50,7 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
38
50
  "### 需求解析",
39
51
  "### 现状",
40
52
  "### 冲突摘要",
53
+ "### 决策闭环",
41
54
  "### 影响面分析",
42
55
  "### 改动范围",
43
56
  "### 修改方案",
@@ -105,6 +118,12 @@ LEGACY_STAGE_MAP = {
105
118
 
106
119
  DEFAULT_SHORT_TERM_MAX = 10
107
120
  DEFAULT_SHORT_TERM_KEEP = 5
121
+ # 架构认知正文的项目相对路径,用于冻结与复核 ABSTRACT 内容指纹。
122
+ ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
123
+ # 架构认知变更日志的项目相对路径,用于验证 backfill/update 留下审计记录。
124
+ ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
125
+ # MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
126
+ ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
108
127
  SESSION_STALE_THRESHOLD_HOURS = 30 * 24
109
128
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
110
129
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
@@ -117,6 +136,19 @@ SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
117
136
  )
118
137
  LEGACY_SHORT_MEMORY_ID_PATTERN = re.compile(r"^SM-\d{8}-\d+$")
119
138
  DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
139
+ DECISION_STATUS_PATTERN = re.compile(
140
+ r"\s*decision_status\s*:\s*([a-z][a-z0-9_-]*)\s*", re.IGNORECASE
141
+ )
142
+ DECISION_CONCLUSIONS_PATTERN = re.compile(
143
+ r"\s*(?:[-+*]\s+)?(?:\*\*)?已解决问题与结论(?:\*\*)?\s*[::]\s*(.+?)\s*"
144
+ )
145
+ DECISION_EVIDENCE_PATTERN = re.compile(
146
+ r"\s*(?:[-+*]\s+)?(?:\*\*)?确认依据(?:\*\*)?\s*[::]\s*(.+?)\s*"
147
+ )
148
+ UNRESOLVED_DECISION_VALUE_PATTERN = re.compile(
149
+ r"(?:待确认|待决策|未确认|未决|todo|tbd|unknown|open|pending|unresolved)[。.!!]?",
150
+ re.IGNORECASE,
151
+ )
120
152
  MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
121
153
  TABLE_HEADER_CELLS = {
122
154
  "改动文件",
@@ -798,6 +830,76 @@ def validate_recorded_short_memory(
798
830
  validate_short_memory_file(root, task_id, memory_file, expected_sha256)
799
831
 
800
832
 
833
+ def architecture_asset_baseline(root: Path, relative_path: Path) -> dict:
834
+ path = root / relative_path
835
+ if not path.exists():
836
+ return {
837
+ "path": str(relative_path),
838
+ "exists": False,
839
+ "non_empty": False,
840
+ "sha256": None,
841
+ }
842
+ if not path.is_file():
843
+ raise StateError(f"Architecture asset is not a file: {relative_path}")
844
+ try:
845
+ content = path.read_text(encoding="utf-8")
846
+ except (OSError, UnicodeError) as error:
847
+ raise StateError(f"Cannot read architecture asset as UTF-8: {relative_path}") from error
848
+ return {
849
+ "path": str(relative_path),
850
+ "exists": True,
851
+ "non_empty": bool(content.strip()),
852
+ "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
853
+ }
854
+
855
+
856
+ def read_project_mode(root: Path) -> str | None:
857
+ project_profile = root / ".easy-coding" / "project.yaml"
858
+ if not project_profile.is_file():
859
+ return None
860
+ try:
861
+ content = project_profile.read_text(encoding="utf-8")
862
+ except (OSError, UnicodeError) as error:
863
+ raise StateError("Cannot read .easy-coding/project.yaml as UTF-8.") from error
864
+ for raw_line in content.splitlines():
865
+ match = re.fullmatch(
866
+ r"\s*mode\s*:\s*(['\"]?)(startup|iterative)\1\s*(?:#.*)?", raw_line
867
+ )
868
+ if match:
869
+ return match.group(2)
870
+ return None
871
+
872
+
873
+ def build_architecture_assessment_instruction(root: Path, memory_action: str) -> dict:
874
+ abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
875
+ changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
876
+ if not abstract["non_empty"] and read_project_mode(root) == "startup":
877
+ required = True
878
+ trigger = "missing-abstract"
879
+ allowed_actions = ["backfill"]
880
+ elif not abstract["non_empty"]:
881
+ raise StateError(
882
+ "ABSTRACT.md is missing or empty outside the startup backfill exception; "
883
+ "run ec-init supplementary initialization before completing MEMORY."
884
+ )
885
+ elif memory_action == "distill":
886
+ required = True
887
+ trigger = "distillation"
888
+ allowed_actions = ["no-op", "update"]
889
+ else:
890
+ required = False
891
+ trigger = "none"
892
+ allowed_actions = []
893
+ instruction = {
894
+ "required": required,
895
+ "trigger": trigger,
896
+ "allowed_actions": allowed_actions,
897
+ "abstract": abstract,
898
+ "changelog": changelog,
899
+ }
900
+ return instruction
901
+
902
+
801
903
  def build_memory_instruction(
802
904
  root: Path,
803
905
  checkpoint_file: str | None = None,
@@ -818,7 +920,7 @@ def build_memory_instruction(
818
920
  checkpoint_disposition = "kept"
819
921
  else:
820
922
  raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
821
- return {
923
+ instruction = {
822
924
  "short_count": short_count,
823
925
  "short_term_max": config["short_term_max"],
824
926
  "short_term_keep": config["short_term_keep"],
@@ -828,6 +930,216 @@ def build_memory_instruction(
828
930
  "kept_files": kept_files,
829
931
  "checkpoint_disposition": checkpoint_disposition,
830
932
  }
933
+ if not legacy_checkpoint:
934
+ instruction["architecture_assessment"] = build_architecture_assessment_instruction(
935
+ root, action
936
+ )
937
+ return instruction
938
+
939
+
940
+ def require_architecture_instruction(instruction: dict) -> dict | None:
941
+ assessment_instruction = instruction.get("architecture_assessment")
942
+ if assessment_instruction is None:
943
+ # 0.10.0-beta.5 之前已冻结的指令继续按旧契约完成,避免升级中断在途任务。
944
+ return None
945
+ if not isinstance(assessment_instruction, dict):
946
+ raise StateError("Memory instruction has an invalid architecture assessment contract.")
947
+ return assessment_instruction
948
+
949
+
950
+ def validate_architecture_asset_changed(
951
+ baseline: dict,
952
+ current: dict,
953
+ label: str,
954
+ ) -> None:
955
+ if not current.get("exists") or not current.get("non_empty") or not current.get("sha256"):
956
+ raise StateError(f"Architecture {label} must exist and be non-empty after this action.")
957
+ if baseline.get("sha256") == current.get("sha256"):
958
+ raise StateError(f"Architecture {label} did not change after this action.")
959
+
960
+
961
+ def validate_architecture_assets_unchanged(root: Path, instruction: dict) -> None:
962
+ for key, relative_path in (
963
+ ("abstract", ARCHITECTURE_ABSTRACT_PATH),
964
+ ("changelog", ARCHITECTURE_CHANGELOG_PATH),
965
+ ):
966
+ baseline = instruction.get(key)
967
+ if not isinstance(baseline, dict):
968
+ raise StateError(f"Architecture assessment is missing the {key} baseline.")
969
+ if architecture_asset_baseline(root, relative_path) != baseline:
970
+ raise StateError(f"Architecture asset changed during a no-op assessment: {relative_path}")
971
+
972
+
973
+ def validate_architecture_action_result(
974
+ root: Path,
975
+ instruction: dict,
976
+ action: str,
977
+ ) -> tuple[dict, dict]:
978
+ abstract_before = instruction.get("abstract")
979
+ changelog_before = instruction.get("changelog")
980
+ if not isinstance(abstract_before, dict) or not isinstance(changelog_before, dict):
981
+ raise StateError("Architecture assessment is missing frozen asset baselines.")
982
+ abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
983
+ changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
984
+ if action == "no-op":
985
+ validate_architecture_assets_unchanged(root, instruction)
986
+ elif action == "backfill":
987
+ if abstract_before.get("non_empty") is True:
988
+ raise StateError(
989
+ "Architecture backfill is allowed only when ABSTRACT.md was missing or empty."
990
+ )
991
+ validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
992
+ validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
993
+ elif action == "update":
994
+ if abstract_before.get("non_empty") is not True:
995
+ raise StateError("Architecture update requires an existing ABSTRACT.md baseline.")
996
+ validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
997
+ validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
998
+ else:
999
+ raise StateError(f"Unknown architecture assessment action: {action}")
1000
+ return abstract, changelog
1001
+
1002
+
1003
+ def allowed_architecture_evidence(progress: dict, instruction: dict) -> set[str]:
1004
+ allowed_evidence = set(instruction.get("candidate_files") or [])
1005
+ if not allowed_evidence:
1006
+ checkpoint_file = progress.get("short_memory_file")
1007
+ if isinstance(checkpoint_file, str):
1008
+ allowed_evidence.add(checkpoint_file)
1009
+ return allowed_evidence
1010
+
1011
+
1012
+ def record_architecture_assessment(
1013
+ root: Path,
1014
+ action: str,
1015
+ reason: str,
1016
+ evidence: list[str],
1017
+ affected_sections: list[str],
1018
+ agent: str,
1019
+ task_id: str | None = None,
1020
+ session_file: str | Path | None = None,
1021
+ ) -> dict:
1022
+ if action not in ARCHITECTURE_ACTIONS:
1023
+ raise StateError(f"Unknown architecture assessment action: {action}")
1024
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1025
+ if task.get("status") != "MEMORY":
1026
+ raise StateError("Architecture assessment is only available during MEMORY.")
1027
+ progress = task.get("memory_progress")
1028
+ if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
1029
+ raise StateError("Short memory must be recorded before architecture assessment.")
1030
+ instruction = progress.get("instruction")
1031
+ if not isinstance(instruction, dict):
1032
+ raise StateError("Request the authoritative memory instruction before architecture assessment.")
1033
+ validate_recorded_short_memory(root, resolved_task_id, progress)
1034
+ for candidate_file in instruction.get("candidate_files") or []:
1035
+ if not resolve_short_memory_path(root, candidate_file).is_file():
1036
+ raise StateError(
1037
+ "Keep every frozen distillation candidate until architecture assessment succeeds: "
1038
+ f"{candidate_file}"
1039
+ )
1040
+ assessment_instruction = require_architecture_instruction(instruction)
1041
+ if assessment_instruction is None:
1042
+ raise StateError("Legacy memory instructions do not require an architecture assessment.")
1043
+ if assessment_instruction.get("required") is not True:
1044
+ raise StateError("Architecture assessment is not required for this memory instruction.")
1045
+ allowed_actions = assessment_instruction.get("allowed_actions")
1046
+ if not isinstance(allowed_actions, list) or action not in allowed_actions:
1047
+ raise StateError(
1048
+ f"Architecture action {action} is not allowed for trigger "
1049
+ f"{assessment_instruction.get('trigger')}."
1050
+ )
1051
+ normalized_reason = reason.strip()
1052
+ normalized_evidence = list(dict.fromkeys(item.strip() for item in evidence if item.strip()))
1053
+ normalized_sections = list(
1054
+ dict.fromkeys(item.strip() for item in affected_sections if item.strip())
1055
+ )
1056
+ if not normalized_reason:
1057
+ raise StateError("Architecture assessment requires a non-empty reason.")
1058
+ if not normalized_evidence:
1059
+ raise StateError("Architecture assessment requires at least one frozen memory evidence file.")
1060
+ allowed_evidence = allowed_architecture_evidence(progress, instruction)
1061
+ invalid_evidence = [item for item in normalized_evidence if item not in allowed_evidence]
1062
+ if invalid_evidence:
1063
+ raise StateError(
1064
+ "Architecture assessment evidence must come from the frozen memory set: "
1065
+ + ", ".join(invalid_evidence)
1066
+ )
1067
+ if action in {"backfill", "update"} and not normalized_sections:
1068
+ raise StateError("Architecture backfill/update requires at least one affected section.")
1069
+ if action == "no-op" and normalized_sections:
1070
+ raise StateError("Architecture no-op must not declare affected sections.")
1071
+
1072
+ abstract, changelog = validate_architecture_action_result(
1073
+ root, assessment_instruction, action
1074
+ )
1075
+
1076
+ assessment = {
1077
+ "action": action,
1078
+ "trigger": assessment_instruction.get("trigger"),
1079
+ "reason": normalized_reason,
1080
+ "evidence": normalized_evidence,
1081
+ "affected_sections": normalized_sections,
1082
+ "abstract_sha256": abstract.get("sha256"),
1083
+ "changelog_sha256": changelog.get("sha256"),
1084
+ "recorded_at": now_iso(),
1085
+ "recorded_by": agent,
1086
+ }
1087
+ progress["architecture_assessment"] = assessment
1088
+ progress["updated_at"] = now_iso()
1089
+ task["memory_progress"] = progress
1090
+ task["last_agent"] = agent
1091
+ write_task(root, resolved_task_id, task)
1092
+ snapshot = snapshot_state(root, session_file, session)
1093
+ snapshot["memory"] = instruction
1094
+ snapshot["architecture_assessment"] = assessment
1095
+ snapshot["action"] = "memory-architecture-assessment"
1096
+ return snapshot
1097
+
1098
+
1099
+ def validate_recorded_architecture_assessment(root: Path, progress: dict, instruction: dict) -> None:
1100
+ assessment_instruction = require_architecture_instruction(instruction)
1101
+ if assessment_instruction is None:
1102
+ return
1103
+ if assessment_instruction.get("required") is not True:
1104
+ validate_architecture_assets_unchanged(root, assessment_instruction)
1105
+ if progress.get("architecture_assessment") is not None:
1106
+ raise StateError("Unexpected architecture assessment for a no-op memory instruction.")
1107
+ return
1108
+ assessment = progress.get("architecture_assessment")
1109
+ if not isinstance(assessment, dict):
1110
+ raise StateError("Complete the required architecture assessment before MEMORY completion.")
1111
+ action = assessment.get("action")
1112
+ allowed_actions = assessment_instruction.get("allowed_actions")
1113
+ if not isinstance(allowed_actions, list) or action not in allowed_actions:
1114
+ raise StateError("Recorded architecture assessment has an invalid action.")
1115
+ if assessment.get("trigger") != assessment_instruction.get("trigger"):
1116
+ raise StateError("Recorded architecture assessment trigger does not match its instruction.")
1117
+ reason = assessment.get("reason")
1118
+ if not isinstance(reason, str) or not reason.strip():
1119
+ raise StateError("Recorded architecture assessment is missing its reason.")
1120
+ evidence = assessment.get("evidence")
1121
+ if not isinstance(evidence, list) or not evidence or not all(
1122
+ isinstance(item, str) for item in evidence
1123
+ ):
1124
+ raise StateError("Recorded architecture assessment has invalid evidence.")
1125
+ if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
1126
+ raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
1127
+ affected_sections = assessment.get("affected_sections")
1128
+ if not isinstance(affected_sections, list) or not all(
1129
+ isinstance(item, str) and item.strip() for item in affected_sections
1130
+ ):
1131
+ raise StateError("Recorded architecture assessment has invalid affected sections.")
1132
+ if action == "no-op" and affected_sections:
1133
+ raise StateError("Recorded architecture no-op must not declare affected sections.")
1134
+ if action in {"backfill", "update"} and not affected_sections:
1135
+ raise StateError("Recorded architecture backfill/update requires affected sections.")
1136
+ abstract, changelog = validate_architecture_action_result(
1137
+ root, assessment_instruction, action
1138
+ )
1139
+ if assessment.get("abstract_sha256") != abstract.get("sha256"):
1140
+ raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
1141
+ if assessment.get("changelog_sha256") != changelog.get("sha256"):
1142
+ raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
831
1143
 
832
1144
 
833
1145
  def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
@@ -909,7 +1221,28 @@ def normalize_legacy_task(task: dict) -> bool:
909
1221
 
910
1222
  def write_json(path: Path, data: dict) -> None:
911
1223
  path.parent.mkdir(parents=True, exist_ok=True)
912
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1224
+ descriptor, temporary_name = tempfile.mkstemp(
1225
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
1226
+ )
1227
+ temporary_path = Path(temporary_name)
1228
+ try:
1229
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
1230
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
1231
+ handle.flush()
1232
+ os.fsync(handle.fileno())
1233
+ os.replace(temporary_path, path)
1234
+ try:
1235
+ directory_descriptor = os.open(path.parent, os.O_RDONLY)
1236
+ try:
1237
+ os.fsync(directory_descriptor)
1238
+ finally:
1239
+ os.close(directory_descriptor)
1240
+ except OSError:
1241
+ # Some platforms do not allow opening directories; file replacement is still atomic.
1242
+ pass
1243
+ finally:
1244
+ if temporary_path.exists():
1245
+ temporary_path.unlink()
913
1246
 
914
1247
 
915
1248
  def acquire_legacy_state_lock(root: Path) -> Path | None:
@@ -1181,6 +1514,8 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1181
1514
  path.parent.mkdir(parents=True, exist_ok=True)
1182
1515
  with path.open("a", encoding="utf-8") as handle:
1183
1516
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
1517
+ handle.flush()
1518
+ os.fsync(handle.fileno())
1184
1519
 
1185
1520
 
1186
1521
  def is_non_empty_string(value: object) -> bool:
@@ -1327,25 +1662,58 @@ def stored_spec_path(root: Path, task: dict) -> Path:
1327
1662
  source = task.get("spec_source")
1328
1663
  if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
1329
1664
  raise StateError("Spec-backed task is missing spec_source.path.")
1330
- raw_path = Path(str(source["path"]))
1331
- path = raw_path if raw_path.is_absolute() else root / raw_path
1332
- resolved = path.resolve()
1333
- try:
1334
- resolved.relative_to(root.resolve())
1335
- except ValueError as exc:
1336
- raise StateError("Spec-backed task source path must remain inside the project root.") from exc
1665
+ path_mode = source.get("path_mode")
1666
+ raw_path = Path(str(source["path"])).expanduser()
1667
+ if path_mode is None:
1668
+ path_mode = "absolute" if raw_path.is_absolute() else "project-relative"
1669
+ if path_mode not in {"project-relative", "absolute"}:
1670
+ raise StateError("Spec-backed task has an invalid spec_source.path_mode.")
1671
+ if path_mode == "absolute" and not raw_path.is_absolute():
1672
+ raise StateError("Absolute Spec binding must store an absolute path.")
1673
+ if path_mode == "project-relative" and raw_path.is_absolute():
1674
+ raise StateError("Project-relative Spec binding must not store an absolute path.")
1675
+ resolved = (raw_path if path_mode == "absolute" else root / raw_path).resolve()
1676
+ if path_mode == "project-relative":
1677
+ try:
1678
+ resolved.relative_to(root.resolve())
1679
+ except ValueError as exc:
1680
+ raise StateError("Project-relative Spec source escapes the project root.") from exc
1681
+ if not resolved.is_file():
1682
+ raise StateError(
1683
+ "Canonical Spec source is unavailable; run rebind-spec-source with an explicit path."
1684
+ )
1337
1685
  return resolved
1338
1686
 
1339
1687
 
1688
+ def legacy_source_digest_matches(
1689
+ spec_path: Path, legacy_sha256: object, current_source_sha256: object
1690
+ ) -> bool:
1691
+ if not is_non_empty_string(legacy_sha256):
1692
+ return False
1693
+ if legacy_sha256 == current_source_sha256:
1694
+ return True
1695
+ try:
1696
+ design_text, execution = split_execution_region(
1697
+ spec_path.read_text(encoding="utf-8")
1698
+ )
1699
+ except (OSError, UnicodeError, ValueError):
1700
+ return False
1701
+ if execution is None:
1702
+ return False
1703
+ design_document_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
1704
+ return legacy_sha256 == design_document_sha256
1705
+
1706
+
1340
1707
  def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1341
1708
  source = task.get("spec_source")
1342
1709
  selected = task.get("selected_spec_tasks")
1343
1710
  repo_paths = task.get("repo_paths")
1344
1711
  if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
1345
1712
  raise StateError("Spec-backed task source and selected task metadata are incomplete.")
1713
+ spec_path = stored_spec_path(root, task)
1346
1714
  try:
1347
1715
  inspection = inspect_spec(
1348
- stored_spec_path(root, task),
1716
+ spec_path,
1349
1717
  root,
1350
1718
  repo_paths if isinstance(repo_paths, dict) else {},
1351
1719
  selected,
@@ -1361,6 +1729,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1361
1729
  selection = select_tasks(inspection, selected, satisfied)
1362
1730
  except EasyDevSpecError as exc:
1363
1731
  raise StateError(f"Canonical Spec validation failed: {exc}") from exc
1732
+ if not isinstance(inspection.get("execution"), dict):
1733
+ raise StateError(
1734
+ "Canonical Spec shared execution is not initialized; run initialize-spec-execution."
1735
+ )
1364
1736
  stored_dependencies = task.get("spec_dependency_evidence")
1365
1737
  if not isinstance(stored_dependencies, list):
1366
1738
  raise StateError("Spec-backed task dependency metadata is incomplete.")
@@ -1373,30 +1745,44 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1373
1745
  for record in stored_dependencies
1374
1746
  if isinstance(record, dict)
1375
1747
  }
1376
- if (
1377
- len(stored_by_edge) != len(stored_dependencies)
1378
- or set(stored_by_edge) != set(expected_by_edge)
1379
- ):
1748
+ if len(stored_by_edge) != len(stored_dependencies) or set(stored_by_edge) != set(expected_by_edge):
1380
1749
  raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1750
+ refreshed_dependencies: list[dict] = []
1381
1751
  for edge, expected in expected_by_edge.items():
1382
1752
  stored = stored_by_edge[edge]
1383
- for field in ("dependency_type", "required_evidence", "status"):
1753
+ for field in ("dependency_type", "required_evidence"):
1384
1754
  if stored.get(field) != expected.get(field):
1385
- raise StateError(
1386
- "Canonical Spec dependency metadata no longer matches source selection."
1387
- )
1388
- if stored.get("evidence") != expected.get("evidence"):
1389
- raise StateError(
1390
- "Canonical Spec dependency evidence no longer matches its recorded status."
1391
- )
1755
+ raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1756
+ refreshed = dict(stored)
1757
+ refreshed["status"] = expected.get("status")
1758
+ refreshed["shared_status"] = expected.get("shared_status")
1759
+ if expected.get("evidence"):
1760
+ refreshed["evidence"] = expected.get("evidence")
1761
+ refreshed_dependencies.append(refreshed)
1392
1762
  if source.get("schema") != inspection.get("schema"):
1393
1763
  raise StateError("Canonical Spec schema no longer matches task.json.")
1394
1764
  if source.get("spec_id") != inspection.get("spec_id"):
1395
1765
  raise StateError("Canonical Spec ID no longer matches task.json.")
1396
1766
  if source.get("revision") != inspection.get("revision"):
1397
- raise StateError("Canonical Spec revision no longer matches task.json.")
1398
- if source.get("sha256") != inspection.get("source_sha256"):
1399
- raise StateError("Canonical Spec SHA-256 changed after task creation.")
1767
+ raise StateError("Canonical Spec design revision changed; return the task to ANALYSIS.")
1768
+ stored_design_sha256 = source.get("design_sha256")
1769
+ if stored_design_sha256 is None:
1770
+ if not legacy_source_digest_matches(
1771
+ spec_path, source.get("sha256"), inspection.get("source_sha256")
1772
+ ):
1773
+ raise StateError(
1774
+ "Legacy Canonical Spec digest changed before migration; rebind or recreate the task."
1775
+ )
1776
+ stored_design_sha256 = inspection.get("design_sha256")
1777
+ if stored_design_sha256 != inspection.get("design_sha256"):
1778
+ raise StateError("Canonical Spec static design changed; return the task to ANALYSIS.")
1779
+ stored_execution_revision = source.get("execution_revision")
1780
+ current_execution_revision = inspection.get("execution_revision")
1781
+ if isinstance(stored_execution_revision, int) and isinstance(current_execution_revision, int):
1782
+ if current_execution_revision < stored_execution_revision:
1783
+ raise StateError(
1784
+ "Canonical Spec execution revision moved backwards; restore the latest shared Spec."
1785
+ )
1400
1786
  selected_repo_ids = set(selection["selected_repo_ids"])
1401
1787
  stored_bindings = task.get("spec_repositories")
1402
1788
  if not isinstance(stored_bindings, list):
@@ -1424,6 +1810,18 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1424
1810
  for field in ("repo_id", "name", "path", "baseline_commit"):
1425
1811
  if stored.get(field) != current.get(field):
1426
1812
  raise StateError("Canonical Spec repository bindings no longer match task.json.")
1813
+ source.update(
1814
+ {
1815
+ "path_mode": source.get("path_mode")
1816
+ or ("absolute" if Path(str(source.get("path"))).is_absolute() else "project-relative"),
1817
+ "design_sha256": inspection.get("design_sha256"),
1818
+ "document_sha256": inspection.get("document_sha256"),
1819
+ "execution_revision": inspection.get("execution_revision"),
1820
+ }
1821
+ )
1822
+ source.pop("sha256", None)
1823
+ task["spec_source"] = source
1824
+ task["spec_dependency_evidence"] = refreshed_dependencies
1427
1825
  return inspection, selection
1428
1826
 
1429
1827
 
@@ -1682,6 +2080,8 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
1682
2080
  return False
1683
2081
  if isinstance(record, dict) and record.get("type") == "plan":
1684
2082
  latest_plan = record
2083
+ elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
2084
+ latest_plan = None
1685
2085
  except OSError:
1686
2086
  return False
1687
2087
  task = load_task(root, task_id)
@@ -1721,6 +2121,8 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1721
2121
  for record in execution_records(root, task_id):
1722
2122
  if record.get("type") == "plan":
1723
2123
  latest = record
2124
+ elif record.get("type") == "spec-design-sync":
2125
+ latest = None
1724
2126
  if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
1725
2127
  return None
1726
2128
  return latest
@@ -2176,10 +2578,16 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
2176
2578
  digest.update(b"\0")
2177
2579
  if task and isinstance(task.get("spec_source"), dict):
2178
2580
  digest.update(b"canonical-spec\0")
2581
+ source = task.get("spec_source") or {}
2179
2582
  digest.update(
2180
2583
  json.dumps(
2181
2584
  {
2182
- "source": task.get("spec_source"),
2585
+ "source": {
2586
+ "schema": source.get("schema"),
2587
+ "spec_id": source.get("spec_id"),
2588
+ "revision": source.get("revision"),
2589
+ "design_sha256": source.get("design_sha256"),
2590
+ },
2183
2591
  "selected_tasks": task.get("selected_spec_tasks"),
2184
2592
  },
2185
2593
  ensure_ascii=False,
@@ -2910,19 +3318,35 @@ def validate_read_only_completion(root: Path, task_id: str) -> None:
2910
3318
  )
2911
3319
 
2912
3320
 
3321
+ def markdown_fence_token(line: str) -> tuple[str, int, str] | None:
3322
+ stripped = line.lstrip()
3323
+ if not stripped or stripped[0] not in {"`", "~"}:
3324
+ return None
3325
+ marker = stripped[0]
3326
+ run_length = len(stripped) - len(stripped.lstrip(marker))
3327
+ if run_length < 3:
3328
+ return None
3329
+ return marker, run_length, stripped[run_length:]
3330
+
3331
+
2913
3332
  def markdown_headings(content: str) -> list[tuple[int, int, str]]:
2914
3333
  headings: list[tuple[int, int, str]] = []
2915
- fence_marker: str | None = None
3334
+ fence_marker: tuple[str, int] | None = None
2916
3335
  for index, line in enumerate(content.splitlines()):
2917
- stripped = line.lstrip()
2918
- if stripped.startswith(("```", "~~~")):
2919
- marker = stripped[:3]
2920
- if fence_marker is None:
2921
- fence_marker = marker
2922
- elif fence_marker == marker:
3336
+ fence = markdown_fence_token(line)
3337
+ if fence_marker is not None:
3338
+ marker, run_length, remainder = fence or ("", 0, "")
3339
+ if (
3340
+ marker == fence_marker[0]
3341
+ and run_length >= fence_marker[1]
3342
+ and not remainder.strip()
3343
+ ):
2923
3344
  fence_marker = None
2924
3345
  continue
2925
- if fence_marker is not None:
3346
+ if fence is not None:
3347
+ marker, run_length, remainder = fence
3348
+ if marker != "`" or "`" not in remainder:
3349
+ fence_marker = (marker, run_length)
2926
3350
  continue
2927
3351
  match = MARKDOWN_HEADING_PATTERN.match(line.strip())
2928
3352
  if match:
@@ -2930,6 +3354,55 @@ def markdown_headings(content: str) -> list[tuple[int, int, str]]:
2930
3354
  return headings
2931
3355
 
2932
3356
 
3357
+ def markdown_section_body(content: str, title: str, level: int = 3) -> str | None:
3358
+ lines = content.splitlines()
3359
+ headings = markdown_headings(content)
3360
+ heading_index = next(
3361
+ (
3362
+ index
3363
+ for index, (_, heading_level, heading_title) in enumerate(headings)
3364
+ if heading_level == level and heading_title == title
3365
+ ),
3366
+ None,
3367
+ )
3368
+ if heading_index is None:
3369
+ return None
3370
+ line_index, heading_level, _ = headings[heading_index]
3371
+ next_line_index = len(lines)
3372
+ for candidate_line, candidate_level, _ in headings[heading_index + 1 :]:
3373
+ if candidate_level <= heading_level:
3374
+ next_line_index = candidate_line
3375
+ break
3376
+ return "\n".join(lines[line_index + 1 : next_line_index])
3377
+
3378
+
3379
+ def markdown_standalone_field_values(content: str, pattern: re.Pattern[str]) -> list[str]:
3380
+ values: list[str] = []
3381
+ fence_marker: tuple[str, int] | None = None
3382
+ for line in content.splitlines():
3383
+ fence = markdown_fence_token(line)
3384
+ if fence_marker is not None:
3385
+ marker, run_length, remainder = fence or ("", 0, "")
3386
+ if (
3387
+ marker == fence_marker[0]
3388
+ and run_length >= fence_marker[1]
3389
+ and not remainder.strip()
3390
+ ):
3391
+ fence_marker = None
3392
+ continue
3393
+ if fence is not None:
3394
+ marker, run_length, remainder = fence
3395
+ if marker != "`" or "`" not in remainder:
3396
+ fence_marker = (marker, run_length)
3397
+ continue
3398
+ if line.startswith(("\t", " ")):
3399
+ continue
3400
+ match = pattern.fullmatch(line)
3401
+ if match:
3402
+ values.append(match.group(1).strip())
3403
+ return values
3404
+
3405
+
2933
3406
  def has_meaningful_markdown_body(content: str) -> bool:
2934
3407
  for line in content.splitlines():
2935
3408
  stripped = line.strip()
@@ -3046,6 +3519,71 @@ def validate_analysis_readiness(
3046
3519
  if "[阶段:ANALYSIS]" in dev_spec_content or "### 待用户决策" in dev_spec_content:
3047
3520
  reasons.append("dev-spec.md contains forbidden analysis-only sections")
3048
3521
 
3522
+ decision_headings = [
3523
+ heading
3524
+ for heading in markdown_headings(dev_spec_content)
3525
+ if heading[1] == 3 and heading[2] == "决策闭环"
3526
+ ]
3527
+ if len(decision_headings) != 1:
3528
+ reasons.append(
3529
+ "dev-spec.md must contain exactly one `### 决策闭环` section; "
3530
+ f"found {len(decision_headings)}"
3531
+ )
3532
+ decision_section = markdown_section_body(dev_spec_content, "决策闭环") or ""
3533
+ all_decision_statuses = [
3534
+ value.lower()
3535
+ for value in markdown_standalone_field_values(
3536
+ dev_spec_content, DECISION_STATUS_PATTERN
3537
+ )
3538
+ ]
3539
+ section_decision_statuses = [
3540
+ value.lower()
3541
+ for value in markdown_standalone_field_values(
3542
+ decision_section, DECISION_STATUS_PATTERN
3543
+ )
3544
+ ]
3545
+ if not all_decision_statuses:
3546
+ reasons.append(
3547
+ "dev-spec.md is missing the decision closure marker `decision_status: closed`; "
3548
+ "resume ec-analysis, resolve material questions, and record the conclusions first"
3549
+ )
3550
+ elif len(all_decision_statuses) != 1:
3551
+ reasons.append(
3552
+ "dev-spec.md must contain exactly one decision_status marker; "
3553
+ f"found {len(all_decision_statuses)}"
3554
+ )
3555
+ elif len(section_decision_statuses) != 1:
3556
+ reasons.append(
3557
+ "dev-spec.md decision_status marker must be inside the `### 决策闭环` section"
3558
+ )
3559
+ elif section_decision_statuses[0] != "closed":
3560
+ reasons.append(
3561
+ "dev-spec.md has unresolved material decisions: "
3562
+ f"decision_status is {section_decision_statuses[0]!r}, expected 'closed'"
3563
+ )
3564
+ decision_conclusions = markdown_standalone_field_values(
3565
+ decision_section, DECISION_CONCLUSIONS_PATTERN
3566
+ )
3567
+ decision_evidence = markdown_standalone_field_values(
3568
+ decision_section, DECISION_EVIDENCE_PATTERN
3569
+ )
3570
+ for field_name, values in (
3571
+ ("已解决问题与结论", decision_conclusions),
3572
+ ("确认依据", decision_evidence),
3573
+ ):
3574
+ if len(values) != 1:
3575
+ reasons.append(
3576
+ "dev-spec.md decision closure must contain exactly one non-empty "
3577
+ f"`{field_name}` field; found {len(values)}"
3578
+ )
3579
+ elif UNRESOLVED_DECISION_VALUE_PATTERN.fullmatch(
3580
+ re.sub(r"[`*_]", "", values[0]).strip()
3581
+ ):
3582
+ reasons.append(
3583
+ "dev-spec.md has unresolved decision closure evidence: "
3584
+ f"`{field_name}` is {values[0]!r}"
3585
+ )
3586
+
3049
3587
  if not skeleton.exists():
3050
3588
  reasons.append("dev-spec skeleton template is missing")
3051
3589
  else:
@@ -3365,6 +3903,7 @@ def spec_task_summary(task: dict | None) -> dict | None:
3365
3903
  "selected_spec_tasks": task.get("selected_spec_tasks", []),
3366
3904
  "repositories": task.get("spec_repositories", []),
3367
3905
  "pending_dependencies": pending_dependencies,
3906
+ "writeback": task.get("spec_writeback_progress"),
3368
3907
  }
3369
3908
 
3370
3909
 
@@ -4078,15 +4617,6 @@ def create_task(
4078
4617
  return {"task_id": task_id, "task": task}
4079
4618
 
4080
4619
 
4081
- def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
4082
- resolved = path.resolve()
4083
- try:
4084
- resolved.relative_to(root.resolve())
4085
- except ValueError as exc:
4086
- raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
4087
- return resolved
4088
-
4089
-
4090
4620
  def create_task_from_spec(
4091
4621
  root: Path,
4092
4622
  spec_path: str,
@@ -4100,12 +4630,14 @@ def create_task_from_spec(
4100
4630
  set_current: bool = True,
4101
4631
  session_file: str | Path | None = None,
4102
4632
  ) -> dict:
4103
- raw_spec_path = Path(spec_path)
4104
- resolved_spec_path = ensure_path_inside_root(
4105
- root,
4106
- raw_spec_path if raw_spec_path.is_absolute() else root / raw_spec_path,
4107
- "Canonical Spec path",
4633
+ raw_spec_path = Path(spec_path).expanduser()
4634
+ resolved_spec_path = (
4635
+ raw_spec_path.resolve()
4636
+ if raw_spec_path.is_absolute()
4637
+ else (root / raw_spec_path).resolve()
4108
4638
  )
4639
+ if not resolved_spec_path.is_file():
4640
+ raise StateError("Canonical Spec path must be an explicitly selected UTF-8 file.")
4109
4641
  try:
4110
4642
  inspection = inspect_spec(
4111
4643
  resolved_spec_path,
@@ -4129,7 +4661,16 @@ def create_task_from_spec(
4129
4661
  str(binding["repo_id"]): str(binding["path"])
4130
4662
  for binding in bindings
4131
4663
  }
4132
- source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
4664
+ if not isinstance(inspection.get("execution"), dict):
4665
+ raise StateError(
4666
+ "Canonical Spec shared execution is not initialized; run initialize-spec-execution first."
4667
+ )
4668
+ try:
4669
+ source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
4670
+ path_mode = "project-relative"
4671
+ except ValueError:
4672
+ source_path = str(resolved_spec_path)
4673
+ path_mode = "absolute"
4133
4674
  fields = {
4134
4675
  "repos": list(selection["selected_repo_ids"]),
4135
4676
  "repo_paths": stored_repo_paths,
@@ -4138,11 +4679,19 @@ def create_task_from_spec(
4138
4679
  "spec_id": inspection["spec_id"],
4139
4680
  "revision": inspection["revision"],
4140
4681
  "path": source_path,
4141
- "sha256": inspection["source_sha256"],
4682
+ "path_mode": path_mode,
4683
+ "design_sha256": inspection["design_sha256"],
4684
+ "document_sha256": inspection["document_sha256"],
4685
+ "execution_revision": inspection["execution_revision"],
4142
4686
  },
4143
4687
  "selected_spec_tasks": selection["selected_task_ids"],
4144
4688
  "spec_repositories": bindings,
4145
4689
  "spec_dependency_evidence": selection["dependency_records"],
4690
+ "spec_writeback_progress": {
4691
+ "last_execution_revision": inspection["execution_revision"],
4692
+ "status": "ok",
4693
+ "updated_at": now_iso(),
4694
+ },
4146
4695
  }
4147
4696
  return create_task(
4148
4697
  root,
@@ -4156,69 +4705,1312 @@ def create_task_from_spec(
4156
4705
  )
4157
4706
 
4158
4707
 
4159
- def satisfy_spec_dependency(
4160
- root: Path,
4161
- dependency_task_id: str,
4162
- evidence: str,
4163
- agent: str,
4164
- source_task_id: str | None = None,
4165
- task_id: str | None = None,
4166
- session_file: str | Path | None = None,
4167
- ) -> dict:
4168
- session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4169
- if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
4170
- raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
4171
- if not is_non_empty_string(evidence):
4172
- raise StateError("Spec dependency evidence must be non-empty.")
4173
- inspect_task_spec(root, task)
4174
- records = task.get("spec_dependency_evidence")
4175
- if not isinstance(records, list):
4176
- raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
4708
+ SPEC_WRITEBACK_APP = "easy-coding"
4709
+
4710
+
4711
+ def spec_writeback_agent(agent: str) -> str:
4712
+ raw_agent = str(agent).strip()
4713
+ if raw_agent.endswith(" with Easy Coding"):
4714
+ return raw_agent
4715
+ normalized = normalize_agent_identity(raw_agent)
4716
+ display_name = {
4717
+ "claude-code": "Claude Code",
4718
+ "codex": "Codex",
4719
+ "qoder": "Qoder",
4720
+ "unknown": "Unknown Agent",
4721
+ }.get(normalized, normalized)
4722
+ return f"{display_name} with Easy Coding"
4723
+
4724
+
4725
+ def initialize_spec_execution_state(root: Path, spec_path: str) -> dict:
4726
+ raw_path = Path(spec_path).expanduser()
4727
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
4728
+ if not resolved.is_file():
4729
+ raise StateError("Canonical Spec path must identify an explicit UTF-8 file.")
4730
+ try:
4731
+ execution = initialize_execution(resolved)
4732
+ details = show_execution(resolved)
4733
+ except (ExecutionStateError, ExecutionConflictError) as exc:
4734
+ raise StateError(f"Cannot initialize Canonical Spec execution: {exc}") from exc
4735
+ return {
4736
+ "action": "initialize-spec-execution",
4737
+ "spec": str(resolved),
4738
+ "design_sha256": details["design_sha256"],
4739
+ "document_sha256": details["document_sha256"],
4740
+ "execution_revision": execution["execution_revision"],
4741
+ }
4742
+
4743
+
4744
+ def _spec_event(execution: dict, idempotency_key: str) -> dict:
4177
4745
  matches = [
4178
- record
4179
- for record in records
4180
- if isinstance(record, dict)
4181
- and record.get("task_id") == dependency_task_id
4182
- and (source_task_id is None or record.get("source_task_id") == source_task_id)
4746
+ event
4747
+ for event in execution.get("events", [])
4748
+ if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
4183
4749
  ]
4184
- if not matches:
4185
- raise StateError("Canonical Spec dependency edge was not found.")
4186
- if source_task_id is None and len(matches) > 1:
4187
- raise StateError(
4188
- "Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
4189
- )
4190
- record = matches[0]
4191
- if record.get("dependency_type") == "contract":
4192
- raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
4193
- record["status"] = "satisfied"
4194
- record["evidence"] = evidence.strip()
4195
- record["satisfied_at"] = now_iso()
4196
- record["satisfied_by"] = agent
4197
- task["last_agent"] = agent
4198
- write_task(root, resolved_task_id, task)
4199
- snapshot = snapshot_state(root, session_file, session)
4200
- snapshot["action"] = "satisfy-spec-dependency"
4201
- return snapshot
4750
+ if len(matches) != 1:
4751
+ raise StateError("Shared Spec writeback did not expose one matching idempotent event.")
4752
+ return matches[0]
4202
4753
 
4203
4754
 
4204
- def append_stage_history(task: dict, stage: str, agent: str) -> None:
4205
- history = task.setdefault("stage_history", [])
4206
- history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
4755
+ def _writeback_progress(task: dict) -> dict:
4756
+ progress = task.get("spec_writeback_progress")
4757
+ if not isinstance(progress, dict):
4758
+ progress = {}
4759
+ task["spec_writeback_progress"] = progress
4760
+ return progress
4207
4761
 
4208
4762
 
4209
- def resolve_current_task(
4210
- root: Path,
4211
- task_id: str | None = None,
4212
- session_file: str | Path | None = None,
4213
- ) -> tuple[dict, str, dict]:
4214
- session = ensure_session(root, session_file)
4215
- resolved_task_id = task_id or session.get("current_task")
4216
- if not resolved_task_id:
4217
- raise StateError("No current task is set.")
4218
- task = load_task(root, str(resolved_task_id))
4219
- if task is None:
4220
- raise StateError(f"Task not found: {resolved_task_id}")
4221
- return session, str(resolved_task_id), task
4763
+ def _is_idempotency_key_conflict(exc: ExecutionConflictError) -> bool:
4764
+ return str(exc).startswith("幂等键已被不同事件使用")
4765
+
4766
+
4767
+ def _execute_spec_writeback(
4768
+ root: Path,
4769
+ harness_task_id: str,
4770
+ task: dict,
4771
+ action: dict,
4772
+ idempotency_key: str,
4773
+ invoke,
4774
+ ) -> dict:
4775
+ inspection, _ = inspect_task_spec(root, task)
4776
+ source = task["spec_source"]
4777
+ progress = _writeback_progress(task)
4778
+ serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
4779
+ existing_pending = progress.get("pending_action")
4780
+ if isinstance(existing_pending, str) and existing_pending.strip():
4781
+ try:
4782
+ existing_action = json.loads(existing_pending)
4783
+ except json.JSONDecodeError as exc:
4784
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
4785
+ if existing_action != action:
4786
+ raise StateError(
4787
+ "A different Canonical Spec writeback is pending; run "
4788
+ "reconcile-spec-execution before starting another action."
4789
+ )
4790
+ progress.update(
4791
+ {
4792
+ "last_execution_revision": source["execution_revision"],
4793
+ "pending_action": serialized_action,
4794
+ "status": "pending",
4795
+ "updated_at": now_iso(),
4796
+ }
4797
+ )
4798
+ write_task(root, harness_task_id, task)
4799
+
4800
+ def call_writer(current_inspection: dict) -> dict:
4801
+ return invoke(
4802
+ str(current_inspection["design_sha256"]),
4803
+ int(current_inspection["execution_revision"]),
4804
+ )
4805
+
4806
+ try:
4807
+ execution = call_writer(inspection)
4808
+ except ExecutionConflictError:
4809
+ try:
4810
+ refreshed = inspect_spec(
4811
+ stored_spec_path(root, task),
4812
+ root,
4813
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
4814
+ task.get("selected_spec_tasks") or [],
4815
+ )
4816
+ except EasyDevSpecError as exc:
4817
+ progress["status"] = "error"
4818
+ progress["updated_at"] = now_iso()
4819
+ progress.pop("pending_action", None)
4820
+ write_task(root, harness_task_id, task)
4821
+ raise StateError(f"Cannot refresh Canonical Spec after CAS conflict: {exc}") from exc
4822
+ if refreshed.get("design_sha256") != source.get("design_sha256"):
4823
+ progress["status"] = "error"
4824
+ progress["updated_at"] = now_iso()
4825
+ progress.pop("pending_action", None)
4826
+ write_task(root, harness_task_id, task)
4827
+ raise StateError("Canonical Spec design changed during writeback; return to ANALYSIS.")
4828
+ if int(refreshed.get("execution_revision", -1)) < int(source["execution_revision"]):
4829
+ progress["status"] = "conflict"
4830
+ progress["updated_at"] = now_iso()
4831
+ write_task(root, harness_task_id, task)
4832
+ raise StateError("Canonical Spec execution revision moved backwards during writeback.")
4833
+ try:
4834
+ execution = call_writer(refreshed)
4835
+ except (ExecutionStateError, ExecutionConflictError) as exc:
4836
+ terminal_conflict = isinstance(
4837
+ exc, ExecutionConflictError
4838
+ ) and _is_idempotency_key_conflict(exc)
4839
+ progress["status"] = "error" if terminal_conflict else "conflict"
4840
+ progress["updated_at"] = now_iso()
4841
+ if terminal_conflict:
4842
+ progress.pop("pending_action", None)
4843
+ write_task(root, harness_task_id, task)
4844
+ raise StateError(f"Canonical Spec CAS retry failed: {exc}") from exc
4845
+ except ExecutionStateError as exc:
4846
+ progress["status"] = "error"
4847
+ progress["updated_at"] = now_iso()
4848
+ progress.pop("pending_action", None)
4849
+ write_task(root, harness_task_id, task)
4850
+ raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
4851
+
4852
+ event = _spec_event(execution, idempotency_key)
4853
+ try:
4854
+ details = show_execution(stored_spec_path(root, task))
4855
+ except ExecutionStateError as exc:
4856
+ raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
4857
+ source.update(
4858
+ {
4859
+ "revision": details["design_revision"],
4860
+ "design_sha256": details["design_sha256"],
4861
+ "document_sha256": details["document_sha256"],
4862
+ "execution_revision": execution["execution_revision"],
4863
+ }
4864
+ )
4865
+ inspect_task_spec(root, task)
4866
+ progress.update(
4867
+ {
4868
+ "last_execution_revision": execution["execution_revision"],
4869
+ "last_event_id": event["event_id"],
4870
+ "last_idempotency_key": idempotency_key,
4871
+ "status": "ok",
4872
+ "updated_at": now_iso(),
4873
+ }
4874
+ )
4875
+ progress.pop("pending_action", None)
4876
+ acknowledgment = {
4877
+ "type": "spec-writeback",
4878
+ "action": action,
4879
+ "event_id": event["event_id"],
4880
+ "execution_revision": execution["execution_revision"],
4881
+ "idempotency_key": idempotency_key,
4882
+ "timestamp": now_iso(),
4883
+ }
4884
+ already_acknowledged = any(
4885
+ record.get("type") == "spec-writeback"
4886
+ and record.get("idempotency_key") == idempotency_key
4887
+ for record in execution_records(root, harness_task_id)
4888
+ )
4889
+ if not already_acknowledged:
4890
+ append_execution_record(root, harness_task_id, acknowledgment)
4891
+ write_task(root, harness_task_id, task)
4892
+ return acknowledgment
4893
+
4894
+
4895
+ def writeback_spec_task(
4896
+ root: Path,
4897
+ source_task_id: str,
4898
+ status_value: str,
4899
+ summary: str,
4900
+ evidence: list[dict],
4901
+ idempotency_key: str,
4902
+ agent: str,
4903
+ task_id: str | None = None,
4904
+ session_file: str | Path | None = None,
4905
+ ) -> dict:
4906
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4907
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
4908
+ raise StateError("Canonical source task is outside the Harness task selection.")
4909
+ action = {
4910
+ "kind": "task",
4911
+ "source_task_id": source_task_id,
4912
+ "status": status_value,
4913
+ "summary": summary,
4914
+ "evidence": evidence,
4915
+ "idempotency_key": idempotency_key,
4916
+ "agent": agent,
4917
+ }
4918
+ acknowledgment = _execute_spec_writeback(
4919
+ root,
4920
+ resolved_task_id,
4921
+ task,
4922
+ action,
4923
+ idempotency_key,
4924
+ lambda design_digest, execution_revision: record_task_status(
4925
+ stored_spec_path(root, task),
4926
+ source_task_id,
4927
+ status_value,
4928
+ summary,
4929
+ SPEC_WRITEBACK_APP,
4930
+ spec_writeback_agent(agent),
4931
+ design_digest,
4932
+ execution_revision,
4933
+ evidence=evidence,
4934
+ run_id=resolved_task_id,
4935
+ idempotency_key=idempotency_key,
4936
+ ),
4937
+ )
4938
+ snapshot = snapshot_state(root, session_file, session)
4939
+ snapshot["spec_writeback"] = acknowledgment
4940
+ snapshot["action"] = "writeback-spec-task"
4941
+ return snapshot
4942
+
4943
+
4944
+ def writeback_spec_step(
4945
+ root: Path,
4946
+ source_task_id: str,
4947
+ step_id: str,
4948
+ status_value: str,
4949
+ summary: str,
4950
+ evidence: list[dict],
4951
+ idempotency_key: str,
4952
+ agent: str,
4953
+ task_id: str | None = None,
4954
+ session_file: str | Path | None = None,
4955
+ ) -> dict:
4956
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4957
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
4958
+ raise StateError("Canonical source task is outside the Harness task selection.")
4959
+ action = {
4960
+ "kind": "step",
4961
+ "source_task_id": source_task_id,
4962
+ "step_id": step_id,
4963
+ "status": status_value,
4964
+ "summary": summary,
4965
+ "evidence": evidence,
4966
+ "idempotency_key": idempotency_key,
4967
+ "agent": agent,
4968
+ }
4969
+ acknowledgment = _execute_spec_writeback(
4970
+ root,
4971
+ resolved_task_id,
4972
+ task,
4973
+ action,
4974
+ idempotency_key,
4975
+ lambda design_digest, execution_revision: record_step_status(
4976
+ stored_spec_path(root, task),
4977
+ source_task_id,
4978
+ step_id,
4979
+ status_value,
4980
+ summary,
4981
+ SPEC_WRITEBACK_APP,
4982
+ spec_writeback_agent(agent),
4983
+ design_digest,
4984
+ execution_revision,
4985
+ evidence=evidence,
4986
+ run_id=resolved_task_id,
4987
+ idempotency_key=idempotency_key,
4988
+ ),
4989
+ )
4990
+ snapshot = snapshot_state(root, session_file, session)
4991
+ snapshot["spec_writeback"] = acknowledgment
4992
+ snapshot["action"] = "writeback-spec-step"
4993
+ return snapshot
4994
+
4995
+
4996
+ def writeback_spec_dependency(
4997
+ root: Path,
4998
+ source_task_id: str,
4999
+ dependency_task_id: str,
5000
+ status_value: str,
5001
+ summary: str,
5002
+ evidence: list[dict],
5003
+ idempotency_key: str,
5004
+ agent: str,
5005
+ task_id: str | None = None,
5006
+ session_file: str | Path | None = None,
5007
+ ) -> dict:
5008
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5009
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
5010
+ raise StateError("Canonical source task is outside the Harness task selection.")
5011
+ action = {
5012
+ "kind": "dependency",
5013
+ "source_task_id": source_task_id,
5014
+ "dependency_task_id": dependency_task_id,
5015
+ "status": status_value,
5016
+ "summary": summary,
5017
+ "evidence": evidence,
5018
+ "idempotency_key": idempotency_key,
5019
+ "agent": agent,
5020
+ }
5021
+ acknowledgment = _execute_spec_writeback(
5022
+ root,
5023
+ resolved_task_id,
5024
+ task,
5025
+ action,
5026
+ idempotency_key,
5027
+ lambda design_digest, execution_revision: record_dependency_status(
5028
+ stored_spec_path(root, task),
5029
+ source_task_id,
5030
+ dependency_task_id,
5031
+ status_value,
5032
+ summary,
5033
+ SPEC_WRITEBACK_APP,
5034
+ spec_writeback_agent(agent),
5035
+ design_digest,
5036
+ execution_revision,
5037
+ evidence=evidence,
5038
+ run_id=resolved_task_id,
5039
+ idempotency_key=idempotency_key,
5040
+ ),
5041
+ )
5042
+ snapshot = snapshot_state(root, session_file, session)
5043
+ snapshot["spec_writeback"] = acknowledgment
5044
+ snapshot["action"] = "writeback-spec-dependency"
5045
+ return snapshot
5046
+
5047
+
5048
+ def rebind_spec_source(
5049
+ root: Path,
5050
+ spec_path: str,
5051
+ agent: str,
5052
+ task_id: str | None = None,
5053
+ session_file: str | Path | None = None,
5054
+ ) -> dict:
5055
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5056
+ source = task.get("spec_source")
5057
+ if not isinstance(source, dict):
5058
+ raise StateError("Current task is not backed by a Canonical Spec.")
5059
+ raw_path = Path(spec_path).expanduser()
5060
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
5061
+ try:
5062
+ inspection = inspect_spec(
5063
+ resolved,
5064
+ root,
5065
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
5066
+ task.get("selected_spec_tasks") or [],
5067
+ )
5068
+ except EasyDevSpecError as exc:
5069
+ raise StateError(f"Cannot rebind Canonical Spec: {exc}") from exc
5070
+ for field in ("schema", "spec_id", "revision", "design_sha256"):
5071
+ expected = source.get(field)
5072
+ if field == "design_sha256" and expected is None and source.get("sha256") == inspection.get("source_sha256"):
5073
+ expected = inspection.get("design_sha256")
5074
+ if expected != inspection.get(field):
5075
+ raise StateError(f"Rebind rejected because Canonical Spec {field} does not match.")
5076
+ previous_execution_revision = source.get("execution_revision", 0)
5077
+ if int(inspection.get("execution_revision", -1)) < int(previous_execution_revision):
5078
+ raise StateError("Rebind rejected because Canonical execution revision moved backwards.")
5079
+ try:
5080
+ source_path = resolved.relative_to(root.resolve()).as_posix()
5081
+ path_mode = "project-relative"
5082
+ except ValueError:
5083
+ source_path = str(resolved)
5084
+ path_mode = "absolute"
5085
+ source.update({"path": source_path, "path_mode": path_mode})
5086
+ inspect_task_spec(root, task)
5087
+ task["last_agent"] = agent
5088
+ write_task(root, resolved_task_id, task)
5089
+ snapshot = snapshot_state(root, session_file, session)
5090
+ snapshot["action"] = "rebind-spec-source"
5091
+ return snapshot
5092
+
5093
+
5094
+ def reconcile_local_result_evidence(
5095
+ root: Path,
5096
+ resolved_task_id: str,
5097
+ task: dict,
5098
+ agent: str,
5099
+ session_file: str | Path | None,
5100
+ ) -> tuple[int, list[str]]:
5101
+ plan = latest_execution_plan(root, resolved_task_id)
5102
+ if not isinstance(plan, dict):
5103
+ return 0, []
5104
+ inspection, selection = inspect_task_spec(root, task)
5105
+ snapshots = _selected_execution_snapshots(inspection, task)
5106
+ units = {
5107
+ str(unit.get("id")): unit
5108
+ for unit in plan.get("units", [])
5109
+ if isinstance(unit, dict) and is_non_empty_string(unit.get("id"))
5110
+ }
5111
+ records = execution_records(root, resolved_task_id)
5112
+ last_plan_index = max(
5113
+ (index for index, record in enumerate(records) if record.get("type") == "plan"),
5114
+ default=-1,
5115
+ )
5116
+ lifecycle_by_unit: dict[str, list[tuple[int, dict]]] = {
5117
+ unit_id: [] for unit_id in units
5118
+ }
5119
+ for record_index, record in enumerate(records[last_plan_index + 1 :], last_plan_index + 1):
5120
+ unit_id = str(record.get("unit_id") or "")
5121
+ if record.get("type") in {"dispatch", "result"} and unit_id in lifecycle_by_unit:
5122
+ lifecycle_by_unit[unit_id].append((record_index, record))
5123
+ latest_results = {
5124
+ unit_id: lifecycle[-1]
5125
+ for unit_id, lifecycle in lifecycle_by_unit.items()
5126
+ if lifecycle and lifecycle[-1][1].get("type") == "result"
5127
+ }
5128
+ step_by_id = {
5129
+ str(step.get("step_id")): step
5130
+ for step in selection.get("selected_steps", [])
5131
+ if isinstance(step, dict)
5132
+ }
5133
+ test_by_id = {
5134
+ str(test.get("test_id")): test
5135
+ for test in selection.get("selected_tests", [])
5136
+ if isinstance(test, dict)
5137
+ }
5138
+ reconciled = 0
5139
+ unresolved: list[str] = []
5140
+ for unit_id, (result_index, result) in latest_results.items():
5141
+ unit = units.get(unit_id)
5142
+ if not unit:
5143
+ continue
5144
+ lifecycle = lifecycle_by_unit.get(unit_id, [])
5145
+ if len(lifecycle) < 2 or lifecycle[-2][1].get("type") != "dispatch":
5146
+ unresolved.append(f"{unit_id}:missing-matching-dispatch")
5147
+ continue
5148
+ dispatch_index, dispatch = lifecycle[-2]
5149
+ source_task_id = str(unit.get("source_task_id") or "")
5150
+ source_steps = [str(value) for value in unit.get("source_step_ids", [])]
5151
+ if source_task_id not in snapshots or not source_steps:
5152
+ continue
5153
+ if (
5154
+ dispatch.get("source_task_id") != source_task_id
5155
+ or dispatch.get("repo_id") != unit.get("repo_id")
5156
+ or result.get("source_task_id") != source_task_id
5157
+ or result.get("repo_id") != unit.get("repo_id")
5158
+ or not isinstance(result.get("changed_files"), list)
5159
+ or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
5160
+ or not is_non_empty_string(result.get("summary"))
5161
+ ):
5162
+ unresolved.append(f"{unit_id}:source-ownership-mismatch")
5163
+ continue
5164
+ current_status = snapshots[source_task_id].get("status")
5165
+ if current_status != "in_progress":
5166
+ unresolved.append(
5167
+ f"{unit_id}:shared-task-status={current_status or 'missing'}"
5168
+ )
5169
+ continue
5170
+ attempt_id, attempt_completed_steps = _shared_attempt_projection(
5171
+ inspection, source_task_id
5172
+ )
5173
+ if not attempt_id:
5174
+ unresolved.append(f"{unit_id}:missing-in-progress-attempt")
5175
+ continue
5176
+ attempt_ack_index = max(
5177
+ (
5178
+ index
5179
+ for index, record in enumerate(records)
5180
+ if record.get("type") == "spec-writeback"
5181
+ and record.get("event_id") == attempt_id
5182
+ and isinstance(record.get("action"), dict)
5183
+ and record["action"].get("kind") == "task"
5184
+ and record["action"].get("source_task_id") == source_task_id
5185
+ and record["action"].get("status") == "in_progress"
5186
+ ),
5187
+ default=-1,
5188
+ )
5189
+ if attempt_ack_index < 0:
5190
+ unresolved.append(f"{unit_id}:missing-in-progress-acknowledgment")
5191
+ continue
5192
+ if dispatch_index <= attempt_ack_index or result_index <= attempt_ack_index:
5193
+ unresolved.append(f"{unit_id}:no-result-for-current-attempt")
5194
+ continue
5195
+ result_status = result.get("status")
5196
+ successful = (
5197
+ result_status == "completed"
5198
+ and result.get("issues") == []
5199
+ and result.get("needs_attention") == []
5200
+ )
5201
+ failed = result_status == "failed"
5202
+ if not successful and not failed:
5203
+ unresolved.append(f"{unit_id}:invalid-result-status-or-issues")
5204
+ continue
5205
+ if failed:
5206
+ if len(source_steps) != 1:
5207
+ unresolved.append(f"{unit_id}:ambiguous-failed-source-step")
5208
+ continue
5209
+ step_id = source_steps[0]
5210
+ key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-failed"
5211
+ writeback_spec_step(
5212
+ root,
5213
+ source_task_id,
5214
+ step_id,
5215
+ "failed",
5216
+ str(result.get("summary") or f"Unit {unit_id} failed"),
5217
+ [
5218
+ {
5219
+ "kind": "result",
5220
+ "status": "failed",
5221
+ "ref": f"execution.jsonl#unit={unit_id}",
5222
+ }
5223
+ ],
5224
+ key,
5225
+ agent,
5226
+ resolved_task_id,
5227
+ session_file,
5228
+ )
5229
+ reconciled += 1
5230
+ unresolved.extend(
5231
+ f"{unit_id}:{remaining_step}:blocked-after-unit-failure"
5232
+ for remaining_step in source_steps[1:]
5233
+ )
5234
+ task = load_task(root, resolved_task_id) or task
5235
+ inspection, selection = inspect_task_spec(root, task)
5236
+ snapshots = _selected_execution_snapshots(inspection, task)
5237
+ continue
5238
+ passed_commands = {
5239
+ str(check.get("command"))
5240
+ for check in result.get("checks", [])
5241
+ if isinstance(check, dict)
5242
+ and check.get("passed") is True
5243
+ and is_non_empty_string(check.get("command"))
5244
+ }
5245
+ missing_unit_commands = sorted(set(unit.get("test_commands", [])) - passed_commands)
5246
+ if missing_unit_commands:
5247
+ unresolved.append(
5248
+ f"{unit_id}:missing-passed-command=" + ",".join(missing_unit_commands)
5249
+ )
5250
+ continue
5251
+ pending_steps = list(dict.fromkeys(source_steps))
5252
+ while pending_steps:
5253
+ ready_step_id = next(
5254
+ (
5255
+ step_id
5256
+ for step_id in pending_steps
5257
+ if step_id in attempt_completed_steps
5258
+ or set((step_by_id.get(step_id) or {}).get("depends_on_step_ids", []))
5259
+ .issubset(attempt_completed_steps)
5260
+ ),
5261
+ None,
5262
+ )
5263
+ if ready_step_id is None:
5264
+ unresolved.extend(
5265
+ f"{unit_id}:{step_id}:dependency-pending" for step_id in pending_steps
5266
+ )
5267
+ break
5268
+ step_id = ready_step_id
5269
+ pending_steps.remove(step_id)
5270
+ if step_id in attempt_completed_steps:
5271
+ continue
5272
+ step = step_by_id.get(step_id)
5273
+ if not step:
5274
+ unresolved.append(f"{unit_id}:{step_id}:missing-step")
5275
+ continue
5276
+ tests = [test_by_id.get(str(test_id)) for test_id in step.get("test_ids", [])]
5277
+ if any(not isinstance(test, dict) for test in tests):
5278
+ unresolved.append(f"{unit_id}:{step_id}:missing-test")
5279
+ continue
5280
+ missing_commands = [
5281
+ str(test.get("command"))
5282
+ for test in tests
5283
+ if str(test.get("command")) not in passed_commands
5284
+ ]
5285
+ if missing_commands:
5286
+ unresolved.append(
5287
+ f"{unit_id}:{step_id}:missing-passed-command=" + ",".join(missing_commands)
5288
+ )
5289
+ continue
5290
+ evidence = [
5291
+ {
5292
+ "kind": "test",
5293
+ "status": "passed",
5294
+ "ref": f"execution.jsonl#unit={unit_id};command={test.get('command')}",
5295
+ "test_id": str(test.get("test_id")),
5296
+ }
5297
+ for test in tests
5298
+ ]
5299
+ key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-completed"
5300
+ writeback_spec_step(
5301
+ root,
5302
+ source_task_id,
5303
+ step_id,
5304
+ "completed",
5305
+ str(result.get("summary") or f"Unit {unit_id} completed"),
5306
+ evidence,
5307
+ key,
5308
+ agent,
5309
+ resolved_task_id,
5310
+ session_file,
5311
+ )
5312
+ reconciled += 1
5313
+ task = load_task(root, resolved_task_id) or task
5314
+ inspection, selection = inspect_task_spec(root, task)
5315
+ snapshots = _selected_execution_snapshots(inspection, task)
5316
+ _, attempt_completed_steps = _shared_attempt_projection(
5317
+ inspection, source_task_id
5318
+ )
5319
+ task = load_task(root, resolved_task_id) or task
5320
+ inspection, _ = inspect_task_spec(root, task)
5321
+ snapshots = _selected_execution_snapshots(inspection, task)
5322
+ selected_tasks = {
5323
+ str(item.get("task_id")): item
5324
+ for item in selection.get("selected_tasks", [])
5325
+ if isinstance(item, dict)
5326
+ }
5327
+ for source_task_id, snapshot in snapshots.items():
5328
+ if snapshot.get("status") != "in_progress":
5329
+ continue
5330
+ expected_steps = set(selected_tasks.get(source_task_id, {}).get("step_ids", []))
5331
+ attempt_id, attempt_completed_steps = _shared_attempt_projection(
5332
+ inspection, source_task_id
5333
+ )
5334
+ if attempt_id and expected_steps and attempt_completed_steps == expected_steps:
5335
+ key = (
5336
+ f"{resolved_task_id}:{source_task_id}:{attempt_id}:"
5337
+ "implemented-from-results"
5338
+ )
5339
+ writeback_spec_task(
5340
+ root,
5341
+ source_task_id,
5342
+ "implemented",
5343
+ "All Canonical Steps have passed local implementation evidence",
5344
+ [],
5345
+ key,
5346
+ agent,
5347
+ resolved_task_id,
5348
+ session_file,
5349
+ )
5350
+ reconciled += 1
5351
+ return reconciled, unresolved
5352
+
5353
+
5354
+ def reconcile_spec_execution(
5355
+ root: Path,
5356
+ agent: str,
5357
+ task_id: str | None = None,
5358
+ session_file: str | Path | None = None,
5359
+ ) -> dict:
5360
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5361
+ progress = _writeback_progress(task)
5362
+ pending = progress.get("pending_action")
5363
+ if not isinstance(pending, str) or not pending.strip():
5364
+ reconciled, unresolved = reconcile_local_result_evidence(
5365
+ root,
5366
+ resolved_task_id,
5367
+ task,
5368
+ agent,
5369
+ session_file,
5370
+ )
5371
+ task = load_task(root, resolved_task_id) or task
5372
+ inspect_task_spec(root, task)
5373
+ progress.update(
5374
+ {
5375
+ "last_execution_revision": task["spec_source"]["execution_revision"],
5376
+ "status": "ok",
5377
+ "updated_at": now_iso(),
5378
+ }
5379
+ )
5380
+ write_task(root, resolved_task_id, task)
5381
+ snapshot = snapshot_state(root, session_file, session)
5382
+ snapshot["action"] = "reconcile-spec-execution"
5383
+ snapshot["reconciled"] = reconciled > 0
5384
+ snapshot["reconciled_actions"] = reconciled
5385
+ snapshot["unresolved_local_evidence"] = unresolved
5386
+ return snapshot
5387
+ try:
5388
+ action = json.loads(pending)
5389
+ except json.JSONDecodeError as exc:
5390
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
5391
+ kind = action.get("kind")
5392
+ if kind == "sync-design":
5393
+ affected_task_ids = action.get("affected_task_ids")
5394
+ if not is_string_list(affected_task_ids):
5395
+ raise StateError("Pending Canonical Spec design sync has invalid affected tasks.")
5396
+ result = sync_spec_design_state(
5397
+ root,
5398
+ affected_task_ids,
5399
+ str(action.get("summary") or "Reconciled Canonical Spec design sync"),
5400
+ str(action.get("idempotency_key") or ""),
5401
+ str(action.get("agent") or agent),
5402
+ resolved_task_id,
5403
+ session_file,
5404
+ )
5405
+ result["action"] = "reconcile-spec-execution"
5406
+ result["reconciled"] = True
5407
+ return result
5408
+ try:
5409
+ design_text, _ = split_execution_region(
5410
+ stored_spec_path(root, task).read_text(encoding="utf-8")
5411
+ )
5412
+ except (OSError, UnicodeError, ValueError) as exc:
5413
+ raise StateError(f"Cannot inspect pending Canonical Spec writeback: {exc}") from exc
5414
+ current_design_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
5415
+ source = task.get("spec_source")
5416
+ if not isinstance(source, dict):
5417
+ raise StateError("Current task is not backed by a Canonical Spec.")
5418
+ if current_design_sha256 != source.get("design_sha256"):
5419
+ # 旧设计上的进度事件不能重放到新设计;清除单槽 pending,允许后续 sync-design。
5420
+ progress["status"] = "error"
5421
+ progress["updated_at"] = now_iso()
5422
+ progress.pop("pending_action", None)
5423
+ write_task(root, resolved_task_id, task)
5424
+ raise StateError(
5425
+ "Pending Canonical Spec writeback belongs to an obsolete design and was "
5426
+ "discarded; return to ANALYSIS and run sync-spec-design."
5427
+ )
5428
+ common = {
5429
+ "root": root,
5430
+ "summary": str(action.get("summary") or "Reconciled shared Spec writeback"),
5431
+ "evidence": action.get("evidence") if isinstance(action.get("evidence"), list) else [],
5432
+ "idempotency_key": str(action.get("idempotency_key") or ""),
5433
+ "agent": str(action.get("agent") or agent),
5434
+ "task_id": resolved_task_id,
5435
+ "session_file": session_file,
5436
+ }
5437
+ if not common["idempotency_key"]:
5438
+ raise StateError("Pending Canonical Spec writeback has no idempotency key.")
5439
+ if kind == "task":
5440
+ result = writeback_spec_task(
5441
+ source_task_id=str(action.get("source_task_id") or ""),
5442
+ status_value=str(action.get("status") or ""),
5443
+ **common,
5444
+ )
5445
+ elif kind == "step":
5446
+ result = writeback_spec_step(
5447
+ source_task_id=str(action.get("source_task_id") or ""),
5448
+ step_id=str(action.get("step_id") or ""),
5449
+ status_value=str(action.get("status") or ""),
5450
+ **common,
5451
+ )
5452
+ elif kind == "dependency":
5453
+ result = writeback_spec_dependency(
5454
+ source_task_id=str(action.get("source_task_id") or ""),
5455
+ dependency_task_id=str(action.get("dependency_task_id") or ""),
5456
+ status_value=str(action.get("status") or ""),
5457
+ **common,
5458
+ )
5459
+ else:
5460
+ raise StateError("Pending Canonical Spec writeback kind is unsupported.")
5461
+ result["action"] = "reconcile-spec-execution"
5462
+ result["reconciled"] = True
5463
+ return result
5464
+
5465
+
5466
+ def sync_spec_design_state(
5467
+ root: Path,
5468
+ affected_task_ids: list[str],
5469
+ summary: str,
5470
+ idempotency_key: str,
5471
+ agent: str,
5472
+ task_id: str | None = None,
5473
+ session_file: str | Path | None = None,
5474
+ ) -> dict:
5475
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5476
+ source = task.get("spec_source")
5477
+ if not isinstance(source, dict):
5478
+ raise StateError("Current task is not backed by a Canonical Spec.")
5479
+ spec_path = stored_spec_path(root, task)
5480
+ requested_task_ids = sorted(set(affected_task_ids))
5481
+
5482
+ def current_execution_envelope() -> dict:
5483
+ try:
5484
+ from easy_dev_spec_protocol import split_execution_region
5485
+
5486
+ _, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
5487
+ except (OSError, UnicodeError, ValueError) as exc:
5488
+ raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
5489
+ if not isinstance(execution, dict):
5490
+ raise StateError("Canonical Spec shared execution is missing before sync-design.")
5491
+ if execution.get("design_sha256") != source.get("design_sha256"):
5492
+ matching_events = [
5493
+ event
5494
+ for event in execution.get("events", [])
5495
+ if isinstance(event, dict)
5496
+ and event.get("type") == "spec_revised"
5497
+ and event.get("idempotency_key") == idempotency_key
5498
+ and event.get("requested_task_ids") == requested_task_ids
5499
+ and event.get("run_id") == resolved_task_id
5500
+ ]
5501
+ if len(matching_events) != 1:
5502
+ raise StateError(
5503
+ "Canonical Spec execution baseline no longer matches the bound design."
5504
+ )
5505
+ return execution
5506
+
5507
+ current_revision = int(current_execution_envelope().get("execution_revision", -1))
5508
+ progress = _writeback_progress(task)
5509
+ pending_action = {
5510
+ "kind": "sync-design",
5511
+ "affected_task_ids": requested_task_ids,
5512
+ "summary": summary,
5513
+ "idempotency_key": idempotency_key,
5514
+ "agent": agent,
5515
+ }
5516
+ serialized_pending_action = json.dumps(
5517
+ pending_action, ensure_ascii=False, sort_keys=True
5518
+ )
5519
+ existing_pending = progress.get("pending_action")
5520
+ if isinstance(existing_pending, str) and existing_pending.strip():
5521
+ try:
5522
+ existing_action = json.loads(existing_pending)
5523
+ except json.JSONDecodeError as exc:
5524
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
5525
+ if existing_action != pending_action:
5526
+ raise StateError(
5527
+ "A different Canonical Spec writeback is pending; run "
5528
+ "reconcile-spec-execution before sync-design."
5529
+ )
5530
+ progress.update(
5531
+ {
5532
+ "last_execution_revision": current_revision,
5533
+ "pending_action": serialized_pending_action,
5534
+ "status": "pending",
5535
+ "updated_at": now_iso(),
5536
+ }
5537
+ )
5538
+ write_task(root, resolved_task_id, task)
5539
+
5540
+ def invoke_sync(execution_revision: int) -> dict:
5541
+ return sync_design(
5542
+ spec_path,
5543
+ requested_task_ids,
5544
+ summary,
5545
+ SPEC_WRITEBACK_APP,
5546
+ spec_writeback_agent(agent),
5547
+ str(source.get("design_sha256")),
5548
+ execution_revision,
5549
+ run_id=resolved_task_id,
5550
+ idempotency_key=idempotency_key,
5551
+ )
5552
+
5553
+ try:
5554
+ execution = invoke_sync(current_revision)
5555
+ except ExecutionConflictError:
5556
+ try:
5557
+ execution = invoke_sync(
5558
+ int(current_execution_envelope().get("execution_revision", -1))
5559
+ )
5560
+ except ExecutionStateError as exc:
5561
+ progress["status"] = "error"
5562
+ progress["updated_at"] = now_iso()
5563
+ progress.pop("pending_action", None)
5564
+ write_task(root, resolved_task_id, task)
5565
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
5566
+ except ExecutionConflictError as exc:
5567
+ terminal_conflict = _is_idempotency_key_conflict(exc)
5568
+ progress["status"] = "error" if terminal_conflict else "conflict"
5569
+ progress["updated_at"] = now_iso()
5570
+ if terminal_conflict:
5571
+ progress.pop("pending_action", None)
5572
+ write_task(root, resolved_task_id, task)
5573
+ raise StateError(f"Cannot synchronize Canonical Spec design after CAS retry: {exc}") from exc
5574
+ except ExecutionStateError as exc:
5575
+ progress["status"] = "error"
5576
+ progress["updated_at"] = now_iso()
5577
+ progress.pop("pending_action", None)
5578
+ write_task(root, resolved_task_id, task)
5579
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
5580
+ try:
5581
+ details = show_execution(spec_path)
5582
+ inspection = inspect_spec(
5583
+ spec_path,
5584
+ root,
5585
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
5586
+ task.get("selected_spec_tasks") or [],
5587
+ )
5588
+ except (ExecutionStateError, EasyDevSpecError) as exc:
5589
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
5590
+ if inspection.get("spec_id") != source.get("spec_id"):
5591
+ raise StateError("Synchronized Canonical Spec identity changed unexpectedly.")
5592
+ binding_was_synchronized = (
5593
+ source.get("revision") == inspection.get("revision")
5594
+ and source.get("design_sha256") == inspection.get("design_sha256")
5595
+ )
5596
+ source.update(
5597
+ {
5598
+ "revision": inspection["revision"],
5599
+ "design_sha256": inspection["design_sha256"],
5600
+ "document_sha256": inspection["document_sha256"],
5601
+ "execution_revision": execution["execution_revision"],
5602
+ }
5603
+ )
5604
+ event = _spec_event(execution, idempotency_key)
5605
+ if not binding_was_synchronized:
5606
+ reset_task_ids = set(event.get("task_ids", []))
5607
+ refreshed_dependencies: list[dict] = []
5608
+ for dependency in task.get("spec_dependency_evidence", []):
5609
+ if not isinstance(dependency, dict):
5610
+ continue
5611
+ refreshed = dict(dependency)
5612
+ if refreshed.get("source_task_id") in reset_task_ids:
5613
+ refreshed["status"] = "pending"
5614
+ refreshed["shared_status"] = "pending"
5615
+ for field in ("evidence", "satisfied_at", "satisfied_by"):
5616
+ refreshed.pop(field, None)
5617
+ refreshed_dependencies.append(refreshed)
5618
+ task["spec_dependency_evidence"] = refreshed_dependencies
5619
+ inspect_task_spec(root, task)
5620
+ progress.update(
5621
+ {
5622
+ "last_execution_revision": execution["execution_revision"],
5623
+ "last_event_id": event["event_id"],
5624
+ "last_idempotency_key": idempotency_key,
5625
+ "status": "ok",
5626
+ "updated_at": now_iso(),
5627
+ }
5628
+ )
5629
+ progress.pop("pending_action", None)
5630
+ if task.get("status") not in {"INIT", "ANALYSIS"}:
5631
+ task["status"] = "ANALYSIS"
5632
+ append_stage_history(task, "ANALYSIS", agent)
5633
+ task.pop("pending_transition", None)
5634
+ task["last_agent"] = agent
5635
+ already_acknowledged = any(
5636
+ record.get("type") == "spec-design-sync"
5637
+ and record.get("idempotency_key") == idempotency_key
5638
+ for record in execution_records(root, resolved_task_id)
5639
+ )
5640
+ if not already_acknowledged:
5641
+ append_execution_record(
5642
+ root,
5643
+ resolved_task_id,
5644
+ {
5645
+ "type": "spec-design-sync",
5646
+ "affected_task_ids": requested_task_ids,
5647
+ "event_id": event["event_id"],
5648
+ "design_sha256": details["design_sha256"],
5649
+ "execution_revision": execution["execution_revision"],
5650
+ "idempotency_key": idempotency_key,
5651
+ "timestamp": now_iso(),
5652
+ },
5653
+ )
5654
+ write_task(root, resolved_task_id, task)
5655
+ snapshot = snapshot_state(root, session_file, session)
5656
+ snapshot["action"] = "sync-spec-design"
5657
+ return snapshot
5658
+
5659
+
5660
+ def _selected_execution_snapshots(inspection: dict, task: dict) -> dict[str, dict]:
5661
+ selected = set(task.get("selected_spec_tasks") or [])
5662
+ execution = inspection.get("execution")
5663
+ if not isinstance(execution, dict):
5664
+ raise StateError("Canonical Spec shared execution is unavailable.")
5665
+ return {
5666
+ str(snapshot.get("task_id")): snapshot
5667
+ for snapshot in execution.get("tasks", [])
5668
+ if isinstance(snapshot, dict) and snapshot.get("task_id") in selected
5669
+ }
5670
+
5671
+
5672
+ def _shared_attempt_projection(
5673
+ inspection: dict, source_task_id: str
5674
+ ) -> tuple[str | None, set[str]]:
5675
+ execution = inspection.get("execution")
5676
+ if not isinstance(execution, dict):
5677
+ return None, set()
5678
+ events = [event for event in execution.get("events", []) if isinstance(event, dict)]
5679
+ start_index = next(
5680
+ (
5681
+ index
5682
+ for index in range(len(events) - 1, -1, -1)
5683
+ if events[index].get("type") == "task_status_changed"
5684
+ and events[index].get("task_id") == source_task_id
5685
+ and events[index].get("to_status") == "in_progress"
5686
+ ),
5687
+ None,
5688
+ )
5689
+ if start_index is None:
5690
+ return None, set()
5691
+ start_event = events[start_index]
5692
+ completed_steps: set[str] = set()
5693
+ for event in events[start_index + 1 :]:
5694
+ if event.get("type") != "step_status_changed" or event.get("task_id") != source_task_id:
5695
+ continue
5696
+ step_id = str(event.get("step_id") or "")
5697
+ if not step_id:
5698
+ continue
5699
+ if event.get("step_status") == "completed":
5700
+ completed_steps.add(step_id)
5701
+ elif event.get("step_status") == "failed":
5702
+ completed_steps.discard(step_id)
5703
+ return str(start_event.get("event_id") or "") or None, completed_steps
5704
+
5705
+
5706
+ def _snapshot_dependencies_ready(snapshot: dict, all_snapshots: dict[str, dict]) -> bool:
5707
+ for dependency in snapshot.get("dependencies", []):
5708
+ if not isinstance(dependency, dict) or dependency.get("type") not in {"hard", "contract"}:
5709
+ continue
5710
+ if dependency.get("status") == "satisfied":
5711
+ continue
5712
+ if dependency.get("type") == "hard" and all_snapshots.get(
5713
+ str(dependency.get("task_id")), {}
5714
+ ).get("status") == "completed":
5715
+ continue
5716
+ return False
5717
+ return True
5718
+
5719
+
5720
+ def writeback_ready_tasks_for_implement(
5721
+ root: Path,
5722
+ harness_task_id: str,
5723
+ task: dict,
5724
+ agent: str,
5725
+ restart_statuses: set[str] | None = None,
5726
+ ) -> None:
5727
+ inspection, _ = inspect_task_spec(root, task)
5728
+ implement_attempt = 1 + sum(
5729
+ 1
5730
+ for entry in task.get("stage_history", [])
5731
+ if isinstance(entry, dict) and entry.get("stage") == "IMPLEMENT"
5732
+ )
5733
+ all_snapshots = {
5734
+ str(snapshot.get("task_id")): snapshot
5735
+ for snapshot in inspection["execution"].get("tasks", [])
5736
+ if isinstance(snapshot, dict)
5737
+ }
5738
+ selected_snapshots = _selected_execution_snapshots(inspection, task)
5739
+ for source_task_id in task.get("selected_spec_tasks") or []:
5740
+ snapshot = selected_snapshots.get(str(source_task_id))
5741
+ if not snapshot or snapshot.get("status") == "in_progress":
5742
+ continue
5743
+ if restart_statuses is not None and snapshot.get("status") not in restart_statuses:
5744
+ continue
5745
+ if not _snapshot_dependencies_ready(snapshot, all_snapshots):
5746
+ continue
5747
+ key = (
5748
+ f"{harness_task_id}:{source_task_id}:enter-implement:"
5749
+ f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
5750
+ )
5751
+ action = {
5752
+ "kind": "task",
5753
+ "source_task_id": source_task_id,
5754
+ "status": "in_progress",
5755
+ "summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
5756
+ "evidence": [],
5757
+ "idempotency_key": key,
5758
+ "agent": agent,
5759
+ }
5760
+ _execute_spec_writeback(
5761
+ root,
5762
+ harness_task_id,
5763
+ task,
5764
+ action,
5765
+ key,
5766
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5767
+ stored_spec_path(root, task),
5768
+ str(source_task_id),
5769
+ "in_progress",
5770
+ "Harness entered IMPLEMENT for a dependency-ready Canonical task",
5771
+ SPEC_WRITEBACK_APP,
5772
+ spec_writeback_agent(agent),
5773
+ design_digest,
5774
+ execution_revision,
5775
+ run_id=harness_task_id,
5776
+ idempotency_key=key,
5777
+ ),
5778
+ )
5779
+
5780
+
5781
+ def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> None:
5782
+ inspection, _ = inspect_task_spec(root, task)
5783
+ snapshots = _selected_execution_snapshots(inspection, task)
5784
+ invalid = [
5785
+ f"{task_id}:{snapshots.get(str(task_id), {}).get('status', 'missing')}"
5786
+ for task_id in task.get("selected_spec_tasks") or []
5787
+ if snapshots.get(str(task_id), {}).get("status") not in allowed
5788
+ ]
5789
+ if invalid:
5790
+ raise StateError(
5791
+ "Canonical Spec writeback is incomplete for selected tasks: " + ", ".join(invalid)
5792
+ )
5793
+
5794
+
5795
+ def writeback_completed_tasks(
5796
+ root: Path,
5797
+ harness_task_id: str,
5798
+ task: dict,
5799
+ agent: str,
5800
+ ) -> None:
5801
+ inspection, _ = inspect_task_spec(root, task)
5802
+ snapshots = _selected_execution_snapshots(inspection, task)
5803
+ for source_task_id in task.get("selected_spec_tasks") or []:
5804
+ status_value = snapshots.get(str(source_task_id), {}).get("status")
5805
+ if status_value == "completed":
5806
+ continue
5807
+ if status_value != "verified":
5808
+ raise StateError(
5809
+ f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
5810
+ )
5811
+ key = f"{harness_task_id}:{source_task_id}:complete:{task['spec_source']['revision']}"
5812
+ action = {
5813
+ "kind": "task",
5814
+ "source_task_id": source_task_id,
5815
+ "status": "completed",
5816
+ "summary": "Harness MEMORY completed and the Canonical task is complete",
5817
+ "evidence": [],
5818
+ "idempotency_key": key,
5819
+ "agent": agent,
5820
+ }
5821
+ _execute_spec_writeback(
5822
+ root,
5823
+ harness_task_id,
5824
+ task,
5825
+ action,
5826
+ key,
5827
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5828
+ stored_spec_path(root, task),
5829
+ str(source_task_id),
5830
+ "completed",
5831
+ "Harness MEMORY completed and the Canonical task is complete",
5832
+ SPEC_WRITEBACK_APP,
5833
+ spec_writeback_agent(agent),
5834
+ design_digest,
5835
+ execution_revision,
5836
+ run_id=harness_task_id,
5837
+ idempotency_key=key,
5838
+ ),
5839
+ )
5840
+
5841
+
5842
+ def cancel_shared_tasks(
5843
+ root: Path,
5844
+ harness_task_id: str,
5845
+ task: dict,
5846
+ reason: str,
5847
+ agent: str,
5848
+ ) -> None:
5849
+ inspection, _ = inspect_task_spec(root, task)
5850
+ snapshots = _selected_execution_snapshots(inspection, task)
5851
+ for source_task_id in task.get("selected_spec_tasks") or []:
5852
+ current = snapshots.get(str(source_task_id), {}).get("status")
5853
+ if current in {"completed", "cancelled"}:
5854
+ continue
5855
+ if current in {"implemented", "verified"}:
5856
+ blocked_key = f"{harness_task_id}:{source_task_id}:close-blocked"
5857
+ blocked_action = {
5858
+ "kind": "task",
5859
+ "source_task_id": source_task_id,
5860
+ "status": "blocked",
5861
+ "summary": reason,
5862
+ "evidence": [],
5863
+ "idempotency_key": blocked_key,
5864
+ "agent": agent,
5865
+ }
5866
+ _execute_spec_writeback(
5867
+ root,
5868
+ harness_task_id,
5869
+ task,
5870
+ blocked_action,
5871
+ blocked_key,
5872
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5873
+ stored_spec_path(root, task),
5874
+ str(source_task_id),
5875
+ "blocked",
5876
+ reason,
5877
+ SPEC_WRITEBACK_APP,
5878
+ spec_writeback_agent(agent),
5879
+ design_digest,
5880
+ execution_revision,
5881
+ run_id=harness_task_id,
5882
+ idempotency_key=blocked_key,
5883
+ ),
5884
+ )
5885
+ cancel_key = f"{harness_task_id}:{source_task_id}:cancel"
5886
+ cancel_action = {
5887
+ "kind": "task",
5888
+ "source_task_id": source_task_id,
5889
+ "status": "cancelled",
5890
+ "summary": reason,
5891
+ "evidence": [],
5892
+ "idempotency_key": cancel_key,
5893
+ "agent": agent,
5894
+ }
5895
+ _execute_spec_writeback(
5896
+ root,
5897
+ harness_task_id,
5898
+ task,
5899
+ cancel_action,
5900
+ cancel_key,
5901
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5902
+ stored_spec_path(root, task),
5903
+ str(source_task_id),
5904
+ "cancelled",
5905
+ reason,
5906
+ SPEC_WRITEBACK_APP,
5907
+ spec_writeback_agent(agent),
5908
+ design_digest,
5909
+ execution_revision,
5910
+ run_id=harness_task_id,
5911
+ idempotency_key=cancel_key,
5912
+ ),
5913
+ )
5914
+
5915
+
5916
+ def satisfy_spec_dependency(
5917
+ root: Path,
5918
+ dependency_task_id: str,
5919
+ evidence: str,
5920
+ agent: str,
5921
+ source_task_id: str | None = None,
5922
+ task_id: str | None = None,
5923
+ session_file: str | Path | None = None,
5924
+ ) -> dict:
5925
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5926
+ if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
5927
+ raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
5928
+ if not is_non_empty_string(evidence):
5929
+ raise StateError("Spec dependency evidence must be non-empty.")
5930
+ inspect_task_spec(root, task)
5931
+ records = task.get("spec_dependency_evidence")
5932
+ if not isinstance(records, list):
5933
+ raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
5934
+ matches = [
5935
+ record
5936
+ for record in records
5937
+ if isinstance(record, dict)
5938
+ and record.get("task_id") == dependency_task_id
5939
+ and (source_task_id is None or record.get("source_task_id") == source_task_id)
5940
+ ]
5941
+ if not matches:
5942
+ raise StateError("Canonical Spec dependency edge was not found.")
5943
+ if source_task_id is None and len(matches) > 1:
5944
+ raise StateError(
5945
+ "Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
5946
+ )
5947
+ record = matches[0]
5948
+ if record.get("dependency_type") == "contract":
5949
+ raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
5950
+ record["status"] = "satisfied"
5951
+ record["evidence"] = evidence.strip()
5952
+ record["satisfied_at"] = now_iso()
5953
+ record["satisfied_by"] = agent
5954
+ task["last_agent"] = agent
5955
+ evidence_digest = hashlib.sha256(evidence.strip().encode("utf-8")).hexdigest()[:16]
5956
+ idempotency_key = (
5957
+ f"{resolved_task_id}:{record.get('source_task_id')}:{dependency_task_id}:"
5958
+ f"dependency-satisfied:revision-{task['spec_source']['revision']}:{evidence_digest}"
5959
+ )
5960
+ action = {
5961
+ "kind": "dependency",
5962
+ "source_task_id": str(record.get("source_task_id")),
5963
+ "dependency_task_id": dependency_task_id,
5964
+ "status": "satisfied",
5965
+ "summary": evidence.strip(),
5966
+ "evidence": [{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
5967
+ "idempotency_key": idempotency_key,
5968
+ "agent": agent,
5969
+ }
5970
+ _execute_spec_writeback(
5971
+ root,
5972
+ resolved_task_id,
5973
+ task,
5974
+ action,
5975
+ idempotency_key,
5976
+ lambda design_digest, execution_revision: record_dependency_status(
5977
+ stored_spec_path(root, task),
5978
+ str(record.get("source_task_id")),
5979
+ dependency_task_id,
5980
+ "satisfied",
5981
+ evidence.strip(),
5982
+ SPEC_WRITEBACK_APP,
5983
+ spec_writeback_agent(agent),
5984
+ design_digest,
5985
+ execution_revision,
5986
+ evidence=[{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
5987
+ run_id=resolved_task_id,
5988
+ idempotency_key=idempotency_key,
5989
+ ),
5990
+ )
5991
+ snapshot = snapshot_state(root, session_file, session)
5992
+ snapshot["action"] = "satisfy-spec-dependency"
5993
+ return snapshot
5994
+
5995
+
5996
+ def append_stage_history(task: dict, stage: str, agent: str) -> None:
5997
+ history = task.setdefault("stage_history", [])
5998
+ history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
5999
+
6000
+
6001
+ def resolve_current_task(
6002
+ root: Path,
6003
+ task_id: str | None = None,
6004
+ session_file: str | Path | None = None,
6005
+ ) -> tuple[dict, str, dict]:
6006
+ session = ensure_session(root, session_file)
6007
+ resolved_task_id = task_id or session.get("current_task")
6008
+ if not resolved_task_id:
6009
+ raise StateError("No current task is set.")
6010
+ task = load_task(root, str(resolved_task_id))
6011
+ if task is None:
6012
+ raise StateError(f"Task not found: {resolved_task_id}")
6013
+ return session, str(resolved_task_id), task
4222
6014
 
4223
6015
 
4224
6016
  def validate_workflow_mode_proposal(
@@ -4536,14 +6328,27 @@ def apply_transition(
4536
6328
  if task.get("workflow_mode_legacy") is not True:
4537
6329
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4538
6330
  freeze_tdd_mode(root, session, resolved_task_id, task, agent)
6331
+ if stage == "IMPLEMENT" and previous != "IMPLEMENT":
6332
+ if isinstance(task.get("spec_source"), dict):
6333
+ writeback_ready_tasks_for_implement(
6334
+ root,
6335
+ resolved_task_id,
6336
+ task,
6337
+ agent,
6338
+ {"blocked"} if previous in {"REVIEW", "VERIFICATION"} else None,
6339
+ )
4539
6340
  if previous == "REVIEW" and stage == "VERIFICATION":
4540
6341
  validate_review_readiness(root, resolved_task_id, task)
4541
6342
  if previous == "VERIFICATION" and stage == "MEMORY":
4542
6343
  validate_verification_readiness(root, resolved_task_id, task)
6344
+ if isinstance(task.get("spec_source"), dict):
6345
+ require_shared_task_statuses(root, task, {"verified", "completed"})
4543
6346
  if previous == "MEMORY" and stage == "COMPLETE":
4544
6347
  progress = task.get("memory_progress")
4545
6348
  if not isinstance(progress, dict) or progress.get("completed") is not True:
4546
6349
  raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
6350
+ if isinstance(task.get("spec_source"), dict):
6351
+ writeback_completed_tasks(root, resolved_task_id, task, agent)
4547
6352
  if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
4548
6353
  validate_read_only_completion(root, resolved_task_id)
4549
6354
  if previous != stage:
@@ -4770,6 +6575,7 @@ def memory_complete(
4770
6575
  action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
4771
6576
  ),
4772
6577
  )
6578
+ validate_recorded_architecture_assessment(root, progress, instruction)
4773
6579
  if action == "distill":
4774
6580
  validate_distillation_file_sets(root, instruction)
4775
6581
  progress["long_memory_action"] = action
@@ -4797,6 +6603,8 @@ def close_current_task(
4797
6603
  task = load_task(root, str(task_id))
4798
6604
  if task is None:
4799
6605
  raise StateError(f"Task not found: {task_id}")
6606
+ if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
6607
+ cancel_shared_tasks(root, str(task_id), task, reason, agent)
4800
6608
  if task.get("status") != "CLOSED":
4801
6609
  task["status"] = "CLOSED"
4802
6610
  append_stage_history(task, "CLOSED", agent)
@@ -4898,6 +6706,19 @@ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
4898
6706
  return mappings
4899
6707
 
4900
6708
 
6709
+ def parse_evidence_args(values: list[str]) -> list[dict]:
6710
+ evidence: list[dict] = []
6711
+ for value in values:
6712
+ try:
6713
+ parsed = json.loads(value)
6714
+ except json.JSONDecodeError as exc:
6715
+ raise StateError(f"--evidence must be a JSON object: {exc}") from exc
6716
+ if not isinstance(parsed, dict):
6717
+ raise StateError("--evidence must be a JSON object.")
6718
+ evidence.append(parsed)
6719
+ return evidence
6720
+
6721
+
4901
6722
  def main() -> int:
4902
6723
  configure_stdio()
4903
6724
  common = argparse.ArgumentParser(add_help=False)
@@ -4915,6 +6736,9 @@ def main() -> int:
4915
6736
  inspect_spec_parser.add_argument("--spec", required=True)
4916
6737
  inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
4917
6738
 
6739
+ initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
6740
+ initialize_spec.add_argument("--spec", required=True)
6741
+
4918
6742
  select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
4919
6743
  select_spec_scope.add_argument("--spec", required=True)
4920
6744
  select_spec_scope.add_argument("--spec-task", required=True, action="append")
@@ -4937,6 +6761,66 @@ def main() -> int:
4937
6761
  create_from_spec.add_argument("--agent", required=True)
4938
6762
  create_from_spec.add_argument("--no-set-current", action="store_true")
4939
6763
 
6764
+ rebind_spec = subcommands.add_parser("rebind-spec-source", parents=[common])
6765
+ rebind_spec.add_argument("--spec", required=True)
6766
+ rebind_spec.add_argument("--agent", required=True)
6767
+ rebind_spec.add_argument("--task-id")
6768
+
6769
+ writeback_task = subcommands.add_parser("writeback-spec-task", parents=[common])
6770
+ writeback_task.add_argument("--spec-task", required=True)
6771
+ writeback_task.add_argument(
6772
+ "--status",
6773
+ required=True,
6774
+ choices=[
6775
+ "in_progress",
6776
+ "blocked",
6777
+ "implemented",
6778
+ "verified",
6779
+ "completed",
6780
+ "cancelled",
6781
+ ],
6782
+ )
6783
+ writeback_task.add_argument("--summary", required=True)
6784
+ writeback_task.add_argument("--evidence", action="append", default=[])
6785
+ writeback_task.add_argument("--idempotency-key", required=True)
6786
+ writeback_task.add_argument("--agent", required=True)
6787
+ writeback_task.add_argument("--task-id")
6788
+
6789
+ writeback_step = subcommands.add_parser("writeback-spec-step", parents=[common])
6790
+ writeback_step.add_argument("--spec-task", required=True)
6791
+ writeback_step.add_argument("--step", required=True)
6792
+ writeback_step.add_argument("--status", required=True, choices=["completed", "failed"])
6793
+ writeback_step.add_argument("--summary", required=True)
6794
+ writeback_step.add_argument("--evidence", action="append", default=[])
6795
+ writeback_step.add_argument("--idempotency-key", required=True)
6796
+ writeback_step.add_argument("--agent", required=True)
6797
+ writeback_step.add_argument("--task-id")
6798
+
6799
+ writeback_dependency = subcommands.add_parser(
6800
+ "writeback-spec-dependency", parents=[common]
6801
+ )
6802
+ writeback_dependency.add_argument("--source-task", required=True)
6803
+ writeback_dependency.add_argument("--dependency-task", required=True)
6804
+ writeback_dependency.add_argument(
6805
+ "--status", required=True, choices=["pending", "satisfied"]
6806
+ )
6807
+ writeback_dependency.add_argument("--summary", required=True)
6808
+ writeback_dependency.add_argument("--evidence", action="append", default=[])
6809
+ writeback_dependency.add_argument("--idempotency-key", required=True)
6810
+ writeback_dependency.add_argument("--agent", required=True)
6811
+ writeback_dependency.add_argument("--task-id")
6812
+
6813
+ sync_spec = subcommands.add_parser("sync-spec-design", parents=[common])
6814
+ sync_spec.add_argument("--affected-task", action="append", default=[])
6815
+ sync_spec.add_argument("--summary", required=True)
6816
+ sync_spec.add_argument("--idempotency-key", required=True)
6817
+ sync_spec.add_argument("--agent", required=True)
6818
+ sync_spec.add_argument("--task-id")
6819
+
6820
+ reconcile_spec = subcommands.add_parser("reconcile-spec-execution", parents=[common])
6821
+ reconcile_spec.add_argument("--agent", required=True)
6822
+ reconcile_spec.add_argument("--task-id")
6823
+
4940
6824
  set_current = subcommands.add_parser("set-current", parents=[common])
4941
6825
  set_current.add_argument("--task-id", required=True)
4942
6826
  set_current.add_argument("--agent", required=True)
@@ -5066,6 +6950,18 @@ def main() -> int:
5066
6950
  memory_instruction_parser.add_argument("--agent")
5067
6951
  memory_instruction_parser.add_argument("--task-id")
5068
6952
 
6953
+ memory_architecture_parser = subcommands.add_parser(
6954
+ "memory-architecture-assessment", parents=[common]
6955
+ )
6956
+ memory_architecture_parser.add_argument(
6957
+ "--action", required=True, choices=sorted(ARCHITECTURE_ACTIONS)
6958
+ )
6959
+ memory_architecture_parser.add_argument("--reason", required=True)
6960
+ memory_architecture_parser.add_argument("--evidence", action="append", default=[])
6961
+ memory_architecture_parser.add_argument("--affected-section", action="append", default=[])
6962
+ memory_architecture_parser.add_argument("--agent", required=True)
6963
+ memory_architecture_parser.add_argument("--task-id")
6964
+
5069
6965
  memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
5070
6966
  memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
5071
6967
  memory_complete_parser.add_argument("--agent", required=True)
@@ -5107,6 +7003,7 @@ def main() -> int:
5107
7003
  )
5108
7004
  if session_file is None and command not in {
5109
7005
  "inspect-dev-spec",
7006
+ "initialize-spec-execution",
5110
7007
  "select-dev-spec-scope",
5111
7008
  "list-tasks",
5112
7009
  "memory-new-id",
@@ -5119,7 +7016,7 @@ def main() -> int:
5119
7016
  if command == "snapshot":
5120
7017
  emit(snapshot_state(root, session_file))
5121
7018
  elif command == "inspect-dev-spec":
5122
- spec_path = Path(args.spec)
7019
+ spec_path = Path(args.spec).expanduser()
5123
7020
  emit(
5124
7021
  inspection_summary(
5125
7022
  inspect_spec(
@@ -5129,8 +7026,10 @@ def main() -> int:
5129
7026
  )
5130
7027
  )
5131
7028
  )
7029
+ elif command == "initialize-spec-execution":
7030
+ emit(initialize_spec_execution_state(root, args.spec))
5132
7031
  elif command == "select-dev-spec-scope":
5133
- spec_path = Path(args.spec)
7032
+ spec_path = Path(args.spec).expanduser()
5134
7033
  emit(
5135
7034
  select_consumption_scopes(
5136
7035
  spec_path if spec_path.is_absolute() else root / spec_path,
@@ -5181,6 +7080,100 @@ def main() -> int:
5181
7080
  session_file,
5182
7081
  )
5183
7082
  )
7083
+ elif command == "rebind-spec-source":
7084
+ emit(
7085
+ attach_status_context(
7086
+ root,
7087
+ rebind_spec_source(root, args.spec, agent, args.task_id, session_file),
7088
+ agent,
7089
+ session_file,
7090
+ )
7091
+ )
7092
+ elif command == "writeback-spec-task":
7093
+ emit(
7094
+ attach_status_context(
7095
+ root,
7096
+ writeback_spec_task(
7097
+ root,
7098
+ args.spec_task,
7099
+ args.status,
7100
+ args.summary,
7101
+ parse_evidence_args(args.evidence),
7102
+ args.idempotency_key,
7103
+ agent,
7104
+ args.task_id,
7105
+ session_file,
7106
+ ),
7107
+ agent,
7108
+ session_file,
7109
+ )
7110
+ )
7111
+ elif command == "writeback-spec-step":
7112
+ emit(
7113
+ attach_status_context(
7114
+ root,
7115
+ writeback_spec_step(
7116
+ root,
7117
+ args.spec_task,
7118
+ args.step,
7119
+ args.status,
7120
+ args.summary,
7121
+ parse_evidence_args(args.evidence),
7122
+ args.idempotency_key,
7123
+ agent,
7124
+ args.task_id,
7125
+ session_file,
7126
+ ),
7127
+ agent,
7128
+ session_file,
7129
+ )
7130
+ )
7131
+ elif command == "writeback-spec-dependency":
7132
+ emit(
7133
+ attach_status_context(
7134
+ root,
7135
+ writeback_spec_dependency(
7136
+ root,
7137
+ args.source_task,
7138
+ args.dependency_task,
7139
+ args.status,
7140
+ args.summary,
7141
+ parse_evidence_args(args.evidence),
7142
+ args.idempotency_key,
7143
+ agent,
7144
+ args.task_id,
7145
+ session_file,
7146
+ ),
7147
+ agent,
7148
+ session_file,
7149
+ )
7150
+ )
7151
+ elif command == "sync-spec-design":
7152
+ emit(
7153
+ attach_status_context(
7154
+ root,
7155
+ sync_spec_design_state(
7156
+ root,
7157
+ args.affected_task,
7158
+ args.summary,
7159
+ args.idempotency_key,
7160
+ agent,
7161
+ args.task_id,
7162
+ session_file,
7163
+ ),
7164
+ agent,
7165
+ session_file,
7166
+ )
7167
+ )
7168
+ elif command == "reconcile-spec-execution":
7169
+ emit(
7170
+ attach_status_context(
7171
+ root,
7172
+ reconcile_spec_execution(root, agent, args.task_id, session_file),
7173
+ agent,
7174
+ session_file,
7175
+ )
7176
+ )
5184
7177
  elif command == "set-current":
5185
7178
  emit(
5186
7179
  attach_status_context(
@@ -5447,6 +7440,24 @@ def main() -> int:
5447
7440
  session_file,
5448
7441
  )
5449
7442
  )
7443
+ elif command == "memory-architecture-assessment":
7444
+ emit(
7445
+ attach_status_context(
7446
+ root,
7447
+ record_architecture_assessment(
7448
+ root,
7449
+ args.action,
7450
+ args.reason,
7451
+ args.evidence,
7452
+ args.affected_section,
7453
+ agent,
7454
+ args.task_id,
7455
+ session_file,
7456
+ ),
7457
+ agent,
7458
+ session_file,
7459
+ )
7460
+ )
5450
7461
  elif command == "memory-complete":
5451
7462
  emit(
5452
7463
  attach_status_context(