easy-coding-harness 0.10.0-beta.4 → 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"}
@@ -106,6 +118,12 @@ LEGACY_STAGE_MAP = {
106
118
 
107
119
  DEFAULT_SHORT_TERM_MAX = 10
108
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"}
109
127
  SESSION_STALE_THRESHOLD_HOURS = 30 * 24
110
128
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
111
129
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
@@ -812,6 +830,76 @@ def validate_recorded_short_memory(
812
830
  validate_short_memory_file(root, task_id, memory_file, expected_sha256)
813
831
 
814
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
+
815
903
  def build_memory_instruction(
816
904
  root: Path,
817
905
  checkpoint_file: str | None = None,
@@ -832,7 +920,7 @@ def build_memory_instruction(
832
920
  checkpoint_disposition = "kept"
833
921
  else:
834
922
  raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
835
- return {
923
+ instruction = {
836
924
  "short_count": short_count,
837
925
  "short_term_max": config["short_term_max"],
838
926
  "short_term_keep": config["short_term_keep"],
@@ -842,6 +930,216 @@ def build_memory_instruction(
842
930
  "kept_files": kept_files,
843
931
  "checkpoint_disposition": checkpoint_disposition,
844
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.")
845
1143
 
846
1144
 
847
1145
  def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
@@ -923,7 +1221,28 @@ def normalize_legacy_task(task: dict) -> bool:
923
1221
 
924
1222
  def write_json(path: Path, data: dict) -> None:
925
1223
  path.parent.mkdir(parents=True, exist_ok=True)
926
- 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()
927
1246
 
928
1247
 
929
1248
  def acquire_legacy_state_lock(root: Path) -> Path | None:
@@ -1195,6 +1514,8 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1195
1514
  path.parent.mkdir(parents=True, exist_ok=True)
1196
1515
  with path.open("a", encoding="utf-8") as handle:
1197
1516
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
1517
+ handle.flush()
1518
+ os.fsync(handle.fileno())
1198
1519
 
1199
1520
 
1200
1521
  def is_non_empty_string(value: object) -> bool:
@@ -1341,25 +1662,58 @@ def stored_spec_path(root: Path, task: dict) -> Path:
1341
1662
  source = task.get("spec_source")
1342
1663
  if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
1343
1664
  raise StateError("Spec-backed task is missing spec_source.path.")
1344
- raw_path = Path(str(source["path"]))
1345
- path = raw_path if raw_path.is_absolute() else root / raw_path
1346
- resolved = path.resolve()
1347
- try:
1348
- resolved.relative_to(root.resolve())
1349
- except ValueError as exc:
1350
- 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
+ )
1351
1685
  return resolved
1352
1686
 
1353
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
+
1354
1707
  def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1355
1708
  source = task.get("spec_source")
1356
1709
  selected = task.get("selected_spec_tasks")
1357
1710
  repo_paths = task.get("repo_paths")
1358
1711
  if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
1359
1712
  raise StateError("Spec-backed task source and selected task metadata are incomplete.")
1713
+ spec_path = stored_spec_path(root, task)
1360
1714
  try:
1361
1715
  inspection = inspect_spec(
1362
- stored_spec_path(root, task),
1716
+ spec_path,
1363
1717
  root,
1364
1718
  repo_paths if isinstance(repo_paths, dict) else {},
1365
1719
  selected,
@@ -1375,6 +1729,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1375
1729
  selection = select_tasks(inspection, selected, satisfied)
1376
1730
  except EasyDevSpecError as exc:
1377
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
+ )
1378
1736
  stored_dependencies = task.get("spec_dependency_evidence")
1379
1737
  if not isinstance(stored_dependencies, list):
1380
1738
  raise StateError("Spec-backed task dependency metadata is incomplete.")
@@ -1387,30 +1745,44 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1387
1745
  for record in stored_dependencies
1388
1746
  if isinstance(record, dict)
1389
1747
  }
1390
- if (
1391
- len(stored_by_edge) != len(stored_dependencies)
1392
- or set(stored_by_edge) != set(expected_by_edge)
1393
- ):
1748
+ if len(stored_by_edge) != len(stored_dependencies) or set(stored_by_edge) != set(expected_by_edge):
1394
1749
  raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1750
+ refreshed_dependencies: list[dict] = []
1395
1751
  for edge, expected in expected_by_edge.items():
1396
1752
  stored = stored_by_edge[edge]
1397
- for field in ("dependency_type", "required_evidence", "status"):
1753
+ for field in ("dependency_type", "required_evidence"):
1398
1754
  if stored.get(field) != expected.get(field):
1399
- raise StateError(
1400
- "Canonical Spec dependency metadata no longer matches source selection."
1401
- )
1402
- if stored.get("evidence") != expected.get("evidence"):
1403
- raise StateError(
1404
- "Canonical Spec dependency evidence no longer matches its recorded status."
1405
- )
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)
1406
1762
  if source.get("schema") != inspection.get("schema"):
1407
1763
  raise StateError("Canonical Spec schema no longer matches task.json.")
1408
1764
  if source.get("spec_id") != inspection.get("spec_id"):
1409
1765
  raise StateError("Canonical Spec ID no longer matches task.json.")
1410
1766
  if source.get("revision") != inspection.get("revision"):
1411
- raise StateError("Canonical Spec revision no longer matches task.json.")
1412
- if source.get("sha256") != inspection.get("source_sha256"):
1413
- 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
+ )
1414
1786
  selected_repo_ids = set(selection["selected_repo_ids"])
1415
1787
  stored_bindings = task.get("spec_repositories")
1416
1788
  if not isinstance(stored_bindings, list):
@@ -1438,6 +1810,18 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1438
1810
  for field in ("repo_id", "name", "path", "baseline_commit"):
1439
1811
  if stored.get(field) != current.get(field):
1440
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
1441
1825
  return inspection, selection
1442
1826
 
1443
1827
 
@@ -1696,6 +2080,8 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
1696
2080
  return False
1697
2081
  if isinstance(record, dict) and record.get("type") == "plan":
1698
2082
  latest_plan = record
2083
+ elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
2084
+ latest_plan = None
1699
2085
  except OSError:
1700
2086
  return False
1701
2087
  task = load_task(root, task_id)
@@ -1735,6 +2121,8 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1735
2121
  for record in execution_records(root, task_id):
1736
2122
  if record.get("type") == "plan":
1737
2123
  latest = record
2124
+ elif record.get("type") == "spec-design-sync":
2125
+ latest = None
1738
2126
  if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
1739
2127
  return None
1740
2128
  return latest
@@ -2190,10 +2578,16 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
2190
2578
  digest.update(b"\0")
2191
2579
  if task and isinstance(task.get("spec_source"), dict):
2192
2580
  digest.update(b"canonical-spec\0")
2581
+ source = task.get("spec_source") or {}
2193
2582
  digest.update(
2194
2583
  json.dumps(
2195
2584
  {
2196
- "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
+ },
2197
2591
  "selected_tasks": task.get("selected_spec_tasks"),
2198
2592
  },
2199
2593
  ensure_ascii=False,
@@ -3509,6 +3903,7 @@ def spec_task_summary(task: dict | None) -> dict | None:
3509
3903
  "selected_spec_tasks": task.get("selected_spec_tasks", []),
3510
3904
  "repositories": task.get("spec_repositories", []),
3511
3905
  "pending_dependencies": pending_dependencies,
3906
+ "writeback": task.get("spec_writeback_progress"),
3512
3907
  }
3513
3908
 
3514
3909
 
@@ -4222,15 +4617,6 @@ def create_task(
4222
4617
  return {"task_id": task_id, "task": task}
4223
4618
 
4224
4619
 
4225
- def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
4226
- resolved = path.resolve()
4227
- try:
4228
- resolved.relative_to(root.resolve())
4229
- except ValueError as exc:
4230
- raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
4231
- return resolved
4232
-
4233
-
4234
4620
  def create_task_from_spec(
4235
4621
  root: Path,
4236
4622
  spec_path: str,
@@ -4244,12 +4630,14 @@ def create_task_from_spec(
4244
4630
  set_current: bool = True,
4245
4631
  session_file: str | Path | None = None,
4246
4632
  ) -> dict:
4247
- raw_spec_path = Path(spec_path)
4248
- resolved_spec_path = ensure_path_inside_root(
4249
- root,
4250
- raw_spec_path if raw_spec_path.is_absolute() else root / raw_spec_path,
4251
- "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()
4252
4638
  )
4639
+ if not resolved_spec_path.is_file():
4640
+ raise StateError("Canonical Spec path must be an explicitly selected UTF-8 file.")
4253
4641
  try:
4254
4642
  inspection = inspect_spec(
4255
4643
  resolved_spec_path,
@@ -4273,7 +4661,16 @@ def create_task_from_spec(
4273
4661
  str(binding["repo_id"]): str(binding["path"])
4274
4662
  for binding in bindings
4275
4663
  }
4276
- 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"
4277
4674
  fields = {
4278
4675
  "repos": list(selection["selected_repo_ids"]),
4279
4676
  "repo_paths": stored_repo_paths,
@@ -4282,11 +4679,19 @@ def create_task_from_spec(
4282
4679
  "spec_id": inspection["spec_id"],
4283
4680
  "revision": inspection["revision"],
4284
4681
  "path": source_path,
4285
- "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"],
4286
4686
  },
4287
4687
  "selected_spec_tasks": selection["selected_task_ids"],
4288
4688
  "spec_repositories": bindings,
4289
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
+ },
4290
4695
  }
4291
4696
  return create_task(
4292
4697
  root,
@@ -4300,74 +4705,1317 @@ def create_task_from_spec(
4300
4705
  )
4301
4706
 
4302
4707
 
4303
- def satisfy_spec_dependency(
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:
4745
+ matches = [
4746
+ event
4747
+ for event in execution.get("events", [])
4748
+ if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
4749
+ ]
4750
+ if len(matches) != 1:
4751
+ raise StateError("Shared Spec writeback did not expose one matching idempotent event.")
4752
+ return matches[0]
4753
+
4754
+
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
4761
+
4762
+
4763
+ def _is_idempotency_key_conflict(exc: ExecutionConflictError) -> bool:
4764
+ return str(exc).startswith("幂等键已被不同事件使用")
4765
+
4766
+
4767
+ def _execute_spec_writeback(
4304
4768
  root: Path,
4305
- dependency_task_id: str,
4306
- evidence: str,
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,
4307
4902
  agent: str,
4308
- source_task_id: str | None = None,
4309
4903
  task_id: str | None = None,
4310
4904
  session_file: str | Path | None = None,
4311
4905
  ) -> dict:
4312
4906
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4313
- if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
4314
- raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
4315
- if not is_non_empty_string(evidence):
4316
- raise StateError("Spec dependency evidence must be non-empty.")
4317
- inspect_task_spec(root, task)
4318
- records = task.get("spec_dependency_evidence")
4319
- if not isinstance(records, list):
4320
- raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
4321
- matches = [
4322
- record
4323
- for record in records
4324
- if isinstance(record, dict)
4325
- and record.get("task_id") == dependency_task_id
4326
- and (source_task_id is None or record.get("source_task_id") == source_task_id)
4327
- ]
4328
- if not matches:
4329
- raise StateError("Canonical Spec dependency edge was not found.")
4330
- if source_task_id is None and len(matches) > 1:
4331
- raise StateError(
4332
- "Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
4333
- )
4334
- record = matches[0]
4335
- if record.get("dependency_type") == "contract":
4336
- raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
4337
- record["status"] = "satisfied"
4338
- record["evidence"] = evidence.strip()
4339
- record["satisfied_at"] = now_iso()
4340
- record["satisfied_by"] = agent
4341
- task["last_agent"] = agent
4342
- write_task(root, resolved_task_id, task)
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
+ )
4343
4938
  snapshot = snapshot_state(root, session_file, session)
4344
- snapshot["action"] = "satisfy-spec-dependency"
4939
+ snapshot["spec_writeback"] = acknowledgment
4940
+ snapshot["action"] = "writeback-spec-task"
4345
4941
  return snapshot
4346
4942
 
4347
4943
 
4348
- def append_stage_history(task: dict, stage: str, agent: str) -> None:
4349
- history = task.setdefault("stage_history", [])
4350
- history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
4351
-
4352
-
4353
- def resolve_current_task(
4944
+ def writeback_spec_step(
4354
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,
4355
4953
  task_id: str | None = None,
4356
4954
  session_file: str | Path | None = None,
4357
- ) -> tuple[dict, str, dict]:
4358
- session = ensure_session(root, session_file)
4359
- resolved_task_id = task_id or session.get("current_task")
4360
- if not resolved_task_id:
4361
- raise StateError("No current task is set.")
4362
- task = load_task(root, str(resolved_task_id))
4363
- if task is None:
4364
- raise StateError(f"Task not found: {resolved_task_id}")
4365
- return session, str(resolved_task_id), task
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
4366
4994
 
4367
4995
 
4368
- def validate_workflow_mode_proposal(
4996
+ def writeback_spec_dependency(
4369
4997
  root: Path,
4370
- session: dict,
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
6014
+
6015
+
6016
+ def validate_workflow_mode_proposal(
6017
+ root: Path,
6018
+ session: dict,
4371
6019
  proposal: object,
4372
6020
  task_id: str | None = None,
4373
6021
  ) -> dict:
@@ -4680,14 +6328,27 @@ def apply_transition(
4680
6328
  if task.get("workflow_mode_legacy") is not True:
4681
6329
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4682
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
+ )
4683
6340
  if previous == "REVIEW" and stage == "VERIFICATION":
4684
6341
  validate_review_readiness(root, resolved_task_id, task)
4685
6342
  if previous == "VERIFICATION" and stage == "MEMORY":
4686
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"})
4687
6346
  if previous == "MEMORY" and stage == "COMPLETE":
4688
6347
  progress = task.get("memory_progress")
4689
6348
  if not isinstance(progress, dict) or progress.get("completed") is not True:
4690
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)
4691
6352
  if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
4692
6353
  validate_read_only_completion(root, resolved_task_id)
4693
6354
  if previous != stage:
@@ -4914,6 +6575,7 @@ def memory_complete(
4914
6575
  action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
4915
6576
  ),
4916
6577
  )
6578
+ validate_recorded_architecture_assessment(root, progress, instruction)
4917
6579
  if action == "distill":
4918
6580
  validate_distillation_file_sets(root, instruction)
4919
6581
  progress["long_memory_action"] = action
@@ -4941,6 +6603,8 @@ def close_current_task(
4941
6603
  task = load_task(root, str(task_id))
4942
6604
  if task is None:
4943
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)
4944
6608
  if task.get("status") != "CLOSED":
4945
6609
  task["status"] = "CLOSED"
4946
6610
  append_stage_history(task, "CLOSED", agent)
@@ -5042,6 +6706,19 @@ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
5042
6706
  return mappings
5043
6707
 
5044
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
+
5045
6722
  def main() -> int:
5046
6723
  configure_stdio()
5047
6724
  common = argparse.ArgumentParser(add_help=False)
@@ -5059,6 +6736,9 @@ def main() -> int:
5059
6736
  inspect_spec_parser.add_argument("--spec", required=True)
5060
6737
  inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
5061
6738
 
6739
+ initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
6740
+ initialize_spec.add_argument("--spec", required=True)
6741
+
5062
6742
  select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
5063
6743
  select_spec_scope.add_argument("--spec", required=True)
5064
6744
  select_spec_scope.add_argument("--spec-task", required=True, action="append")
@@ -5081,6 +6761,66 @@ def main() -> int:
5081
6761
  create_from_spec.add_argument("--agent", required=True)
5082
6762
  create_from_spec.add_argument("--no-set-current", action="store_true")
5083
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
+
5084
6824
  set_current = subcommands.add_parser("set-current", parents=[common])
5085
6825
  set_current.add_argument("--task-id", required=True)
5086
6826
  set_current.add_argument("--agent", required=True)
@@ -5210,6 +6950,18 @@ def main() -> int:
5210
6950
  memory_instruction_parser.add_argument("--agent")
5211
6951
  memory_instruction_parser.add_argument("--task-id")
5212
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
+
5213
6965
  memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
5214
6966
  memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
5215
6967
  memory_complete_parser.add_argument("--agent", required=True)
@@ -5251,6 +7003,7 @@ def main() -> int:
5251
7003
  )
5252
7004
  if session_file is None and command not in {
5253
7005
  "inspect-dev-spec",
7006
+ "initialize-spec-execution",
5254
7007
  "select-dev-spec-scope",
5255
7008
  "list-tasks",
5256
7009
  "memory-new-id",
@@ -5263,7 +7016,7 @@ def main() -> int:
5263
7016
  if command == "snapshot":
5264
7017
  emit(snapshot_state(root, session_file))
5265
7018
  elif command == "inspect-dev-spec":
5266
- spec_path = Path(args.spec)
7019
+ spec_path = Path(args.spec).expanduser()
5267
7020
  emit(
5268
7021
  inspection_summary(
5269
7022
  inspect_spec(
@@ -5273,8 +7026,10 @@ def main() -> int:
5273
7026
  )
5274
7027
  )
5275
7028
  )
7029
+ elif command == "initialize-spec-execution":
7030
+ emit(initialize_spec_execution_state(root, args.spec))
5276
7031
  elif command == "select-dev-spec-scope":
5277
- spec_path = Path(args.spec)
7032
+ spec_path = Path(args.spec).expanduser()
5278
7033
  emit(
5279
7034
  select_consumption_scopes(
5280
7035
  spec_path if spec_path.is_absolute() else root / spec_path,
@@ -5325,6 +7080,100 @@ def main() -> int:
5325
7080
  session_file,
5326
7081
  )
5327
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
+ )
5328
7177
  elif command == "set-current":
5329
7178
  emit(
5330
7179
  attach_status_context(
@@ -5591,6 +7440,24 @@ def main() -> int:
5591
7440
  session_file,
5592
7441
  )
5593
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
+ )
5594
7461
  elif command == "memory-complete":
5595
7462
  emit(
5596
7463
  attach_status_context(