easy-coding-harness 0.10.0-beta.4 → 0.10.0-beta.6

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
@@ -15,11 +16,23 @@ import sys
15
16
 
16
17
  from easy_dev_spec import (
17
18
  EasyDevSpecError,
19
+ inspect_manifest,
18
20
  inspect_spec,
19
21
  inspection_summary,
20
22
  select_consumption_scopes,
21
23
  select_tasks,
22
24
  )
25
+ from easy_dev_spec_execution import (
26
+ ExecutionConflictError,
27
+ ExecutionStateError,
28
+ initialize_execution,
29
+ record_dependency_status,
30
+ record_step_status,
31
+ record_task_status,
32
+ show_execution,
33
+ sync_design,
34
+ )
35
+ from easy_dev_spec_protocol import split_execution_region
23
36
 
24
37
 
25
38
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
@@ -106,6 +119,12 @@ LEGACY_STAGE_MAP = {
106
119
 
107
120
  DEFAULT_SHORT_TERM_MAX = 10
108
121
  DEFAULT_SHORT_TERM_KEEP = 5
122
+ # 架构认知正文的项目相对路径,用于冻结与复核 ABSTRACT 内容指纹。
123
+ ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
124
+ # 架构认知变更日志的项目相对路径,用于验证 backfill/update 留下审计记录。
125
+ ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
126
+ # MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
127
+ ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
109
128
  SESSION_STALE_THRESHOLD_HOURS = 30 * 24
110
129
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
111
130
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
@@ -812,6 +831,76 @@ def validate_recorded_short_memory(
812
831
  validate_short_memory_file(root, task_id, memory_file, expected_sha256)
813
832
 
814
833
 
834
+ def architecture_asset_baseline(root: Path, relative_path: Path) -> dict:
835
+ path = root / relative_path
836
+ if not path.exists():
837
+ return {
838
+ "path": str(relative_path),
839
+ "exists": False,
840
+ "non_empty": False,
841
+ "sha256": None,
842
+ }
843
+ if not path.is_file():
844
+ raise StateError(f"Architecture asset is not a file: {relative_path}")
845
+ try:
846
+ content = path.read_text(encoding="utf-8")
847
+ except (OSError, UnicodeError) as error:
848
+ raise StateError(f"Cannot read architecture asset as UTF-8: {relative_path}") from error
849
+ return {
850
+ "path": str(relative_path),
851
+ "exists": True,
852
+ "non_empty": bool(content.strip()),
853
+ "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
854
+ }
855
+
856
+
857
+ def read_project_mode(root: Path) -> str | None:
858
+ project_profile = root / ".easy-coding" / "project.yaml"
859
+ if not project_profile.is_file():
860
+ return None
861
+ try:
862
+ content = project_profile.read_text(encoding="utf-8")
863
+ except (OSError, UnicodeError) as error:
864
+ raise StateError("Cannot read .easy-coding/project.yaml as UTF-8.") from error
865
+ for raw_line in content.splitlines():
866
+ match = re.fullmatch(
867
+ r"\s*mode\s*:\s*(['\"]?)(startup|iterative)\1\s*(?:#.*)?", raw_line
868
+ )
869
+ if match:
870
+ return match.group(2)
871
+ return None
872
+
873
+
874
+ def build_architecture_assessment_instruction(root: Path, memory_action: str) -> dict:
875
+ abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
876
+ changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
877
+ if not abstract["non_empty"] and read_project_mode(root) == "startup":
878
+ required = True
879
+ trigger = "missing-abstract"
880
+ allowed_actions = ["backfill"]
881
+ elif not abstract["non_empty"]:
882
+ raise StateError(
883
+ "ABSTRACT.md is missing or empty outside the startup backfill exception; "
884
+ "run ec-init supplementary initialization before completing MEMORY."
885
+ )
886
+ elif memory_action == "distill":
887
+ required = True
888
+ trigger = "distillation"
889
+ allowed_actions = ["no-op", "update"]
890
+ else:
891
+ required = False
892
+ trigger = "none"
893
+ allowed_actions = []
894
+ instruction = {
895
+ "required": required,
896
+ "trigger": trigger,
897
+ "allowed_actions": allowed_actions,
898
+ "abstract": abstract,
899
+ "changelog": changelog,
900
+ }
901
+ return instruction
902
+
903
+
815
904
  def build_memory_instruction(
816
905
  root: Path,
817
906
  checkpoint_file: str | None = None,
@@ -832,7 +921,7 @@ def build_memory_instruction(
832
921
  checkpoint_disposition = "kept"
833
922
  else:
834
923
  raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
835
- return {
924
+ instruction = {
836
925
  "short_count": short_count,
837
926
  "short_term_max": config["short_term_max"],
838
927
  "short_term_keep": config["short_term_keep"],
@@ -842,6 +931,216 @@ def build_memory_instruction(
842
931
  "kept_files": kept_files,
843
932
  "checkpoint_disposition": checkpoint_disposition,
844
933
  }
934
+ if not legacy_checkpoint:
935
+ instruction["architecture_assessment"] = build_architecture_assessment_instruction(
936
+ root, action
937
+ )
938
+ return instruction
939
+
940
+
941
+ def require_architecture_instruction(instruction: dict) -> dict | None:
942
+ assessment_instruction = instruction.get("architecture_assessment")
943
+ if assessment_instruction is None:
944
+ # 0.10.0-beta.5 之前已冻结的指令继续按旧契约完成,避免升级中断在途任务。
945
+ return None
946
+ if not isinstance(assessment_instruction, dict):
947
+ raise StateError("Memory instruction has an invalid architecture assessment contract.")
948
+ return assessment_instruction
949
+
950
+
951
+ def validate_architecture_asset_changed(
952
+ baseline: dict,
953
+ current: dict,
954
+ label: str,
955
+ ) -> None:
956
+ if not current.get("exists") or not current.get("non_empty") or not current.get("sha256"):
957
+ raise StateError(f"Architecture {label} must exist and be non-empty after this action.")
958
+ if baseline.get("sha256") == current.get("sha256"):
959
+ raise StateError(f"Architecture {label} did not change after this action.")
960
+
961
+
962
+ def validate_architecture_assets_unchanged(root: Path, instruction: dict) -> None:
963
+ for key, relative_path in (
964
+ ("abstract", ARCHITECTURE_ABSTRACT_PATH),
965
+ ("changelog", ARCHITECTURE_CHANGELOG_PATH),
966
+ ):
967
+ baseline = instruction.get(key)
968
+ if not isinstance(baseline, dict):
969
+ raise StateError(f"Architecture assessment is missing the {key} baseline.")
970
+ if architecture_asset_baseline(root, relative_path) != baseline:
971
+ raise StateError(f"Architecture asset changed during a no-op assessment: {relative_path}")
972
+
973
+
974
+ def validate_architecture_action_result(
975
+ root: Path,
976
+ instruction: dict,
977
+ action: str,
978
+ ) -> tuple[dict, dict]:
979
+ abstract_before = instruction.get("abstract")
980
+ changelog_before = instruction.get("changelog")
981
+ if not isinstance(abstract_before, dict) or not isinstance(changelog_before, dict):
982
+ raise StateError("Architecture assessment is missing frozen asset baselines.")
983
+ abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
984
+ changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
985
+ if action == "no-op":
986
+ validate_architecture_assets_unchanged(root, instruction)
987
+ elif action == "backfill":
988
+ if abstract_before.get("non_empty") is True:
989
+ raise StateError(
990
+ "Architecture backfill is allowed only when ABSTRACT.md was missing or empty."
991
+ )
992
+ validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
993
+ validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
994
+ elif action == "update":
995
+ if abstract_before.get("non_empty") is not True:
996
+ raise StateError("Architecture update requires an existing ABSTRACT.md baseline.")
997
+ validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
998
+ validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
999
+ else:
1000
+ raise StateError(f"Unknown architecture assessment action: {action}")
1001
+ return abstract, changelog
1002
+
1003
+
1004
+ def allowed_architecture_evidence(progress: dict, instruction: dict) -> set[str]:
1005
+ allowed_evidence = set(instruction.get("candidate_files") or [])
1006
+ if not allowed_evidence:
1007
+ checkpoint_file = progress.get("short_memory_file")
1008
+ if isinstance(checkpoint_file, str):
1009
+ allowed_evidence.add(checkpoint_file)
1010
+ return allowed_evidence
1011
+
1012
+
1013
+ def record_architecture_assessment(
1014
+ root: Path,
1015
+ action: str,
1016
+ reason: str,
1017
+ evidence: list[str],
1018
+ affected_sections: list[str],
1019
+ agent: str,
1020
+ task_id: str | None = None,
1021
+ session_file: str | Path | None = None,
1022
+ ) -> dict:
1023
+ if action not in ARCHITECTURE_ACTIONS:
1024
+ raise StateError(f"Unknown architecture assessment action: {action}")
1025
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1026
+ if task.get("status") != "MEMORY":
1027
+ raise StateError("Architecture assessment is only available during MEMORY.")
1028
+ progress = task.get("memory_progress")
1029
+ if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
1030
+ raise StateError("Short memory must be recorded before architecture assessment.")
1031
+ instruction = progress.get("instruction")
1032
+ if not isinstance(instruction, dict):
1033
+ raise StateError("Request the authoritative memory instruction before architecture assessment.")
1034
+ validate_recorded_short_memory(root, resolved_task_id, progress)
1035
+ for candidate_file in instruction.get("candidate_files") or []:
1036
+ if not resolve_short_memory_path(root, candidate_file).is_file():
1037
+ raise StateError(
1038
+ "Keep every frozen distillation candidate until architecture assessment succeeds: "
1039
+ f"{candidate_file}"
1040
+ )
1041
+ assessment_instruction = require_architecture_instruction(instruction)
1042
+ if assessment_instruction is None:
1043
+ raise StateError("Legacy memory instructions do not require an architecture assessment.")
1044
+ if assessment_instruction.get("required") is not True:
1045
+ raise StateError("Architecture assessment is not required for this memory instruction.")
1046
+ allowed_actions = assessment_instruction.get("allowed_actions")
1047
+ if not isinstance(allowed_actions, list) or action not in allowed_actions:
1048
+ raise StateError(
1049
+ f"Architecture action {action} is not allowed for trigger "
1050
+ f"{assessment_instruction.get('trigger')}."
1051
+ )
1052
+ normalized_reason = reason.strip()
1053
+ normalized_evidence = list(dict.fromkeys(item.strip() for item in evidence if item.strip()))
1054
+ normalized_sections = list(
1055
+ dict.fromkeys(item.strip() for item in affected_sections if item.strip())
1056
+ )
1057
+ if not normalized_reason:
1058
+ raise StateError("Architecture assessment requires a non-empty reason.")
1059
+ if not normalized_evidence:
1060
+ raise StateError("Architecture assessment requires at least one frozen memory evidence file.")
1061
+ allowed_evidence = allowed_architecture_evidence(progress, instruction)
1062
+ invalid_evidence = [item for item in normalized_evidence if item not in allowed_evidence]
1063
+ if invalid_evidence:
1064
+ raise StateError(
1065
+ "Architecture assessment evidence must come from the frozen memory set: "
1066
+ + ", ".join(invalid_evidence)
1067
+ )
1068
+ if action in {"backfill", "update"} and not normalized_sections:
1069
+ raise StateError("Architecture backfill/update requires at least one affected section.")
1070
+ if action == "no-op" and normalized_sections:
1071
+ raise StateError("Architecture no-op must not declare affected sections.")
1072
+
1073
+ abstract, changelog = validate_architecture_action_result(
1074
+ root, assessment_instruction, action
1075
+ )
1076
+
1077
+ assessment = {
1078
+ "action": action,
1079
+ "trigger": assessment_instruction.get("trigger"),
1080
+ "reason": normalized_reason,
1081
+ "evidence": normalized_evidence,
1082
+ "affected_sections": normalized_sections,
1083
+ "abstract_sha256": abstract.get("sha256"),
1084
+ "changelog_sha256": changelog.get("sha256"),
1085
+ "recorded_at": now_iso(),
1086
+ "recorded_by": agent,
1087
+ }
1088
+ progress["architecture_assessment"] = assessment
1089
+ progress["updated_at"] = now_iso()
1090
+ task["memory_progress"] = progress
1091
+ task["last_agent"] = agent
1092
+ write_task(root, resolved_task_id, task)
1093
+ snapshot = snapshot_state(root, session_file, session)
1094
+ snapshot["memory"] = instruction
1095
+ snapshot["architecture_assessment"] = assessment
1096
+ snapshot["action"] = "memory-architecture-assessment"
1097
+ return snapshot
1098
+
1099
+
1100
+ def validate_recorded_architecture_assessment(root: Path, progress: dict, instruction: dict) -> None:
1101
+ assessment_instruction = require_architecture_instruction(instruction)
1102
+ if assessment_instruction is None:
1103
+ return
1104
+ if assessment_instruction.get("required") is not True:
1105
+ validate_architecture_assets_unchanged(root, assessment_instruction)
1106
+ if progress.get("architecture_assessment") is not None:
1107
+ raise StateError("Unexpected architecture assessment for a no-op memory instruction.")
1108
+ return
1109
+ assessment = progress.get("architecture_assessment")
1110
+ if not isinstance(assessment, dict):
1111
+ raise StateError("Complete the required architecture assessment before MEMORY completion.")
1112
+ action = assessment.get("action")
1113
+ allowed_actions = assessment_instruction.get("allowed_actions")
1114
+ if not isinstance(allowed_actions, list) or action not in allowed_actions:
1115
+ raise StateError("Recorded architecture assessment has an invalid action.")
1116
+ if assessment.get("trigger") != assessment_instruction.get("trigger"):
1117
+ raise StateError("Recorded architecture assessment trigger does not match its instruction.")
1118
+ reason = assessment.get("reason")
1119
+ if not isinstance(reason, str) or not reason.strip():
1120
+ raise StateError("Recorded architecture assessment is missing its reason.")
1121
+ evidence = assessment.get("evidence")
1122
+ if not isinstance(evidence, list) or not evidence or not all(
1123
+ isinstance(item, str) for item in evidence
1124
+ ):
1125
+ raise StateError("Recorded architecture assessment has invalid evidence.")
1126
+ if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
1127
+ raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
1128
+ affected_sections = assessment.get("affected_sections")
1129
+ if not isinstance(affected_sections, list) or not all(
1130
+ isinstance(item, str) and item.strip() for item in affected_sections
1131
+ ):
1132
+ raise StateError("Recorded architecture assessment has invalid affected sections.")
1133
+ if action == "no-op" and affected_sections:
1134
+ raise StateError("Recorded architecture no-op must not declare affected sections.")
1135
+ if action in {"backfill", "update"} and not affected_sections:
1136
+ raise StateError("Recorded architecture backfill/update requires affected sections.")
1137
+ abstract, changelog = validate_architecture_action_result(
1138
+ root, assessment_instruction, action
1139
+ )
1140
+ if assessment.get("abstract_sha256") != abstract.get("sha256"):
1141
+ raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
1142
+ if assessment.get("changelog_sha256") != changelog.get("sha256"):
1143
+ raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
845
1144
 
846
1145
 
847
1146
  def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
@@ -923,7 +1222,28 @@ def normalize_legacy_task(task: dict) -> bool:
923
1222
 
924
1223
  def write_json(path: Path, data: dict) -> None:
925
1224
  path.parent.mkdir(parents=True, exist_ok=True)
926
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1225
+ descriptor, temporary_name = tempfile.mkstemp(
1226
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
1227
+ )
1228
+ temporary_path = Path(temporary_name)
1229
+ try:
1230
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
1231
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
1232
+ handle.flush()
1233
+ os.fsync(handle.fileno())
1234
+ os.replace(temporary_path, path)
1235
+ try:
1236
+ directory_descriptor = os.open(path.parent, os.O_RDONLY)
1237
+ try:
1238
+ os.fsync(directory_descriptor)
1239
+ finally:
1240
+ os.close(directory_descriptor)
1241
+ except OSError:
1242
+ # Some platforms do not allow opening directories; file replacement is still atomic.
1243
+ pass
1244
+ finally:
1245
+ if temporary_path.exists():
1246
+ temporary_path.unlink()
927
1247
 
928
1248
 
929
1249
  def acquire_legacy_state_lock(root: Path) -> Path | None:
@@ -1195,6 +1515,8 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1195
1515
  path.parent.mkdir(parents=True, exist_ok=True)
1196
1516
  with path.open("a", encoding="utf-8") as handle:
1197
1517
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
1518
+ handle.flush()
1519
+ os.fsync(handle.fileno())
1198
1520
 
1199
1521
 
1200
1522
  def is_non_empty_string(value: object) -> bool:
@@ -1341,25 +1663,58 @@ def stored_spec_path(root: Path, task: dict) -> Path:
1341
1663
  source = task.get("spec_source")
1342
1664
  if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
1343
1665
  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
1666
+ path_mode = source.get("path_mode")
1667
+ raw_path = Path(str(source["path"])).expanduser()
1668
+ if path_mode is None:
1669
+ path_mode = "absolute" if raw_path.is_absolute() else "project-relative"
1670
+ if path_mode not in {"project-relative", "absolute"}:
1671
+ raise StateError("Spec-backed task has an invalid spec_source.path_mode.")
1672
+ if path_mode == "absolute" and not raw_path.is_absolute():
1673
+ raise StateError("Absolute Spec binding must store an absolute path.")
1674
+ if path_mode == "project-relative" and raw_path.is_absolute():
1675
+ raise StateError("Project-relative Spec binding must not store an absolute path.")
1676
+ resolved = (raw_path if path_mode == "absolute" else root / raw_path).resolve()
1677
+ if path_mode == "project-relative":
1678
+ try:
1679
+ resolved.relative_to(root.resolve())
1680
+ except ValueError as exc:
1681
+ raise StateError("Project-relative Spec source escapes the project root.") from exc
1682
+ if not resolved.is_file():
1683
+ raise StateError(
1684
+ "Canonical Spec source is unavailable; run rebind-spec-source with an explicit path."
1685
+ )
1351
1686
  return resolved
1352
1687
 
1353
1688
 
1689
+ def legacy_source_digest_matches(
1690
+ spec_path: Path, legacy_sha256: object, current_source_sha256: object
1691
+ ) -> bool:
1692
+ if not is_non_empty_string(legacy_sha256):
1693
+ return False
1694
+ if legacy_sha256 == current_source_sha256:
1695
+ return True
1696
+ try:
1697
+ design_text, execution = split_execution_region(
1698
+ spec_path.read_text(encoding="utf-8")
1699
+ )
1700
+ except (OSError, UnicodeError, ValueError):
1701
+ return False
1702
+ if execution is None:
1703
+ return False
1704
+ design_document_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
1705
+ return legacy_sha256 == design_document_sha256
1706
+
1707
+
1354
1708
  def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1355
1709
  source = task.get("spec_source")
1356
1710
  selected = task.get("selected_spec_tasks")
1357
1711
  repo_paths = task.get("repo_paths")
1358
1712
  if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
1359
1713
  raise StateError("Spec-backed task source and selected task metadata are incomplete.")
1714
+ spec_path = stored_spec_path(root, task)
1360
1715
  try:
1361
1716
  inspection = inspect_spec(
1362
- stored_spec_path(root, task),
1717
+ spec_path,
1363
1718
  root,
1364
1719
  repo_paths if isinstance(repo_paths, dict) else {},
1365
1720
  selected,
@@ -1375,6 +1730,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1375
1730
  selection = select_tasks(inspection, selected, satisfied)
1376
1731
  except EasyDevSpecError as exc:
1377
1732
  raise StateError(f"Canonical Spec validation failed: {exc}") from exc
1733
+ if not isinstance(inspection.get("execution"), dict):
1734
+ raise StateError(
1735
+ "Canonical Spec shared execution is not initialized; run initialize-spec-execution."
1736
+ )
1378
1737
  stored_dependencies = task.get("spec_dependency_evidence")
1379
1738
  if not isinstance(stored_dependencies, list):
1380
1739
  raise StateError("Spec-backed task dependency metadata is incomplete.")
@@ -1387,30 +1746,52 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1387
1746
  for record in stored_dependencies
1388
1747
  if isinstance(record, dict)
1389
1748
  }
1390
- if (
1391
- len(stored_by_edge) != len(stored_dependencies)
1392
- or set(stored_by_edge) != set(expected_by_edge)
1393
- ):
1749
+ if len(stored_by_edge) != len(stored_dependencies) or set(stored_by_edge) != set(expected_by_edge):
1394
1750
  raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1751
+ refreshed_dependencies: list[dict] = []
1395
1752
  for edge, expected in expected_by_edge.items():
1396
1753
  stored = stored_by_edge[edge]
1397
- for field in ("dependency_type", "required_evidence", "status"):
1754
+ for field in ("dependency_type", "required_evidence"):
1398
1755
  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
- )
1756
+ raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1757
+ refreshed = dict(stored)
1758
+ for field in (
1759
+ "status",
1760
+ "shared_status",
1761
+ "dependency_task_status",
1762
+ "basis",
1763
+ ):
1764
+ if expected.get(field) is None:
1765
+ refreshed.pop(field, None)
1766
+ else:
1767
+ refreshed[field] = expected.get(field)
1768
+ if expected.get("evidence"):
1769
+ refreshed["evidence"] = expected.get("evidence")
1770
+ refreshed_dependencies.append(refreshed)
1406
1771
  if source.get("schema") != inspection.get("schema"):
1407
1772
  raise StateError("Canonical Spec schema no longer matches task.json.")
1408
1773
  if source.get("spec_id") != inspection.get("spec_id"):
1409
1774
  raise StateError("Canonical Spec ID no longer matches task.json.")
1410
1775
  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.")
1776
+ raise StateError("Canonical Spec design revision changed; return the task to ANALYSIS.")
1777
+ stored_design_sha256 = source.get("design_sha256")
1778
+ if stored_design_sha256 is None:
1779
+ if not legacy_source_digest_matches(
1780
+ spec_path, source.get("sha256"), inspection.get("source_sha256")
1781
+ ):
1782
+ raise StateError(
1783
+ "Legacy Canonical Spec digest changed before migration; rebind or recreate the task."
1784
+ )
1785
+ stored_design_sha256 = inspection.get("design_sha256")
1786
+ if stored_design_sha256 != inspection.get("design_sha256"):
1787
+ raise StateError("Canonical Spec static design changed; return the task to ANALYSIS.")
1788
+ stored_execution_revision = source.get("execution_revision")
1789
+ current_execution_revision = inspection.get("execution_revision")
1790
+ if isinstance(stored_execution_revision, int) and isinstance(current_execution_revision, int):
1791
+ if current_execution_revision < stored_execution_revision:
1792
+ raise StateError(
1793
+ "Canonical Spec execution revision moved backwards; restore the latest shared Spec."
1794
+ )
1414
1795
  selected_repo_ids = set(selection["selected_repo_ids"])
1415
1796
  stored_bindings = task.get("spec_repositories")
1416
1797
  if not isinstance(stored_bindings, list):
@@ -1438,6 +1819,18 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1438
1819
  for field in ("repo_id", "name", "path", "baseline_commit"):
1439
1820
  if stored.get(field) != current.get(field):
1440
1821
  raise StateError("Canonical Spec repository bindings no longer match task.json.")
1822
+ source.update(
1823
+ {
1824
+ "path_mode": source.get("path_mode")
1825
+ or ("absolute" if Path(str(source.get("path"))).is_absolute() else "project-relative"),
1826
+ "design_sha256": inspection.get("design_sha256"),
1827
+ "document_sha256": inspection.get("document_sha256"),
1828
+ "execution_revision": inspection.get("execution_revision"),
1829
+ }
1830
+ )
1831
+ source.pop("sha256", None)
1832
+ task["spec_source"] = source
1833
+ task["spec_dependency_evidence"] = refreshed_dependencies
1441
1834
  return inspection, selection
1442
1835
 
1443
1836
 
@@ -1696,6 +2089,8 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
1696
2089
  return False
1697
2090
  if isinstance(record, dict) and record.get("type") == "plan":
1698
2091
  latest_plan = record
2092
+ elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
2093
+ latest_plan = None
1699
2094
  except OSError:
1700
2095
  return False
1701
2096
  task = load_task(root, task_id)
@@ -1735,6 +2130,8 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1735
2130
  for record in execution_records(root, task_id):
1736
2131
  if record.get("type") == "plan":
1737
2132
  latest = record
2133
+ elif record.get("type") == "spec-design-sync":
2134
+ latest = None
1738
2135
  if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
1739
2136
  return None
1740
2137
  return latest
@@ -2190,10 +2587,16 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
2190
2587
  digest.update(b"\0")
2191
2588
  if task and isinstance(task.get("spec_source"), dict):
2192
2589
  digest.update(b"canonical-spec\0")
2590
+ source = task.get("spec_source") or {}
2193
2591
  digest.update(
2194
2592
  json.dumps(
2195
2593
  {
2196
- "source": task.get("spec_source"),
2594
+ "source": {
2595
+ "schema": source.get("schema"),
2596
+ "spec_id": source.get("spec_id"),
2597
+ "revision": source.get("revision"),
2598
+ "design_sha256": source.get("design_sha256"),
2599
+ },
2197
2600
  "selected_tasks": task.get("selected_spec_tasks"),
2198
2601
  },
2199
2602
  ensure_ascii=False,
@@ -3509,6 +3912,7 @@ def spec_task_summary(task: dict | None) -> dict | None:
3509
3912
  "selected_spec_tasks": task.get("selected_spec_tasks", []),
3510
3913
  "repositories": task.get("spec_repositories", []),
3511
3914
  "pending_dependencies": pending_dependencies,
3915
+ "writeback": task.get("spec_writeback_progress"),
3512
3916
  }
3513
3917
 
3514
3918
 
@@ -4222,15 +4626,6 @@ def create_task(
4222
4626
  return {"task_id": task_id, "task": task}
4223
4627
 
4224
4628
 
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
4629
  def create_task_from_spec(
4235
4630
  root: Path,
4236
4631
  spec_path: str,
@@ -4244,12 +4639,14 @@ def create_task_from_spec(
4244
4639
  set_current: bool = True,
4245
4640
  session_file: str | Path | None = None,
4246
4641
  ) -> 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",
4642
+ raw_spec_path = Path(spec_path).expanduser()
4643
+ resolved_spec_path = (
4644
+ raw_spec_path.resolve()
4645
+ if raw_spec_path.is_absolute()
4646
+ else (root / raw_spec_path).resolve()
4252
4647
  )
4648
+ if not resolved_spec_path.is_file():
4649
+ raise StateError("Canonical Spec path must be an explicitly selected UTF-8 file.")
4253
4650
  try:
4254
4651
  inspection = inspect_spec(
4255
4652
  resolved_spec_path,
@@ -4273,7 +4670,16 @@ def create_task_from_spec(
4273
4670
  str(binding["repo_id"]): str(binding["path"])
4274
4671
  for binding in bindings
4275
4672
  }
4276
- source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
4673
+ if not isinstance(inspection.get("execution"), dict):
4674
+ raise StateError(
4675
+ "Canonical Spec shared execution is not initialized; run initialize-spec-execution first."
4676
+ )
4677
+ try:
4678
+ source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
4679
+ path_mode = "project-relative"
4680
+ except ValueError:
4681
+ source_path = str(resolved_spec_path)
4682
+ path_mode = "absolute"
4277
4683
  fields = {
4278
4684
  "repos": list(selection["selected_repo_ids"]),
4279
4685
  "repo_paths": stored_repo_paths,
@@ -4282,11 +4688,19 @@ def create_task_from_spec(
4282
4688
  "spec_id": inspection["spec_id"],
4283
4689
  "revision": inspection["revision"],
4284
4690
  "path": source_path,
4285
- "sha256": inspection["source_sha256"],
4691
+ "path_mode": path_mode,
4692
+ "design_sha256": inspection["design_sha256"],
4693
+ "document_sha256": inspection["document_sha256"],
4694
+ "execution_revision": inspection["execution_revision"],
4286
4695
  },
4287
4696
  "selected_spec_tasks": selection["selected_task_ids"],
4288
4697
  "spec_repositories": bindings,
4289
4698
  "spec_dependency_evidence": selection["dependency_records"],
4699
+ "spec_writeback_progress": {
4700
+ "last_execution_revision": inspection["execution_revision"],
4701
+ "status": "ok",
4702
+ "updated_at": now_iso(),
4703
+ },
4290
4704
  }
4291
4705
  return create_task(
4292
4706
  root,
@@ -4300,82 +4714,1325 @@ def create_task_from_spec(
4300
4714
  )
4301
4715
 
4302
4716
 
4303
- def satisfy_spec_dependency(
4304
- root: Path,
4305
- dependency_task_id: str,
4306
- evidence: str,
4307
- agent: str,
4308
- source_task_id: str | None = None,
4309
- task_id: str | None = None,
4310
- session_file: str | Path | None = None,
4311
- ) -> dict:
4312
- 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.")
4717
+ SPEC_WRITEBACK_APP = "easy-coding"
4718
+
4719
+
4720
+ def spec_writeback_agent(agent: str) -> str:
4721
+ raw_agent = str(agent).strip()
4722
+ if raw_agent.endswith(" with Easy Coding"):
4723
+ return raw_agent
4724
+ normalized = normalize_agent_identity(raw_agent)
4725
+ display_name = {
4726
+ "claude-code": "Claude Code",
4727
+ "codex": "Codex",
4728
+ "qoder": "Qoder",
4729
+ "unknown": "Unknown Agent",
4730
+ }.get(normalized, normalized)
4731
+ return f"{display_name} with Easy Coding"
4732
+
4733
+
4734
+ def initialize_spec_execution_state(root: Path, spec_path: str) -> dict:
4735
+ raw_path = Path(spec_path).expanduser()
4736
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
4737
+ if not resolved.is_file():
4738
+ raise StateError("Canonical Spec path must identify an explicit UTF-8 file.")
4739
+ try:
4740
+ execution = initialize_execution(resolved)
4741
+ details = show_execution(resolved)
4742
+ except (ExecutionStateError, ExecutionConflictError) as exc:
4743
+ raise StateError(f"Cannot initialize Canonical Spec execution: {exc}") from exc
4744
+ return {
4745
+ "action": "initialize-spec-execution",
4746
+ "spec": str(resolved),
4747
+ "design_sha256": details["design_sha256"],
4748
+ "document_sha256": details["document_sha256"],
4749
+ "execution_revision": execution["execution_revision"],
4750
+ }
4751
+
4752
+
4753
+ def _spec_event(execution: dict, idempotency_key: str) -> dict:
4321
4754
  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)
4755
+ event
4756
+ for event in execution.get("events", [])
4757
+ if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
4327
4758
  ]
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."
4759
+ if len(matches) != 1:
4760
+ raise StateError("Shared Spec writeback did not expose one matching idempotent event.")
4761
+ return matches[0]
4762
+
4763
+
4764
+ def _writeback_progress(task: dict) -> dict:
4765
+ progress = task.get("spec_writeback_progress")
4766
+ if not isinstance(progress, dict):
4767
+ progress = {}
4768
+ task["spec_writeback_progress"] = progress
4769
+ return progress
4770
+
4771
+
4772
+ def _is_idempotency_key_conflict(exc: ExecutionConflictError) -> bool:
4773
+ return str(exc).startswith("幂等键已被不同事件使用")
4774
+
4775
+
4776
+ def _execute_spec_writeback(
4777
+ root: Path,
4778
+ harness_task_id: str,
4779
+ task: dict,
4780
+ action: dict,
4781
+ idempotency_key: str,
4782
+ invoke,
4783
+ ) -> dict:
4784
+ inspection, _ = inspect_task_spec(root, task)
4785
+ source = task["spec_source"]
4786
+ progress = _writeback_progress(task)
4787
+ serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
4788
+ existing_pending = progress.get("pending_action")
4789
+ if isinstance(existing_pending, str) and existing_pending.strip():
4790
+ try:
4791
+ existing_action = json.loads(existing_pending)
4792
+ except json.JSONDecodeError as exc:
4793
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
4794
+ if existing_action != action:
4795
+ raise StateError(
4796
+ "A different Canonical Spec writeback is pending; run "
4797
+ "reconcile-spec-execution before starting another action."
4798
+ )
4799
+ progress.update(
4800
+ {
4801
+ "last_execution_revision": source["execution_revision"],
4802
+ "pending_action": serialized_action,
4803
+ "status": "pending",
4804
+ "updated_at": now_iso(),
4805
+ }
4806
+ )
4807
+ write_task(root, harness_task_id, task)
4808
+
4809
+ def call_writer(current_inspection: dict) -> dict:
4810
+ return invoke(
4811
+ str(current_inspection["design_sha256"]),
4812
+ int(current_inspection["execution_revision"]),
4333
4813
  )
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)
4343
- snapshot = snapshot_state(root, session_file, session)
4344
- snapshot["action"] = "satisfy-spec-dependency"
4345
- return snapshot
4346
4814
 
4815
+ try:
4816
+ execution = call_writer(inspection)
4817
+ except ExecutionConflictError:
4818
+ try:
4819
+ refreshed = inspect_spec(
4820
+ stored_spec_path(root, task),
4821
+ root,
4822
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
4823
+ task.get("selected_spec_tasks") or [],
4824
+ )
4825
+ except EasyDevSpecError as exc:
4826
+ progress["status"] = "error"
4827
+ progress["updated_at"] = now_iso()
4828
+ progress.pop("pending_action", None)
4829
+ write_task(root, harness_task_id, task)
4830
+ raise StateError(f"Cannot refresh Canonical Spec after CAS conflict: {exc}") from exc
4831
+ if refreshed.get("design_sha256") != source.get("design_sha256"):
4832
+ progress["status"] = "error"
4833
+ progress["updated_at"] = now_iso()
4834
+ progress.pop("pending_action", None)
4835
+ write_task(root, harness_task_id, task)
4836
+ raise StateError("Canonical Spec design changed during writeback; return to ANALYSIS.")
4837
+ if int(refreshed.get("execution_revision", -1)) < int(source["execution_revision"]):
4838
+ progress["status"] = "conflict"
4839
+ progress["updated_at"] = now_iso()
4840
+ write_task(root, harness_task_id, task)
4841
+ raise StateError("Canonical Spec execution revision moved backwards during writeback.")
4842
+ try:
4843
+ execution = call_writer(refreshed)
4844
+ except (ExecutionStateError, ExecutionConflictError) as exc:
4845
+ terminal_conflict = isinstance(
4846
+ exc, ExecutionConflictError
4847
+ ) and _is_idempotency_key_conflict(exc)
4848
+ progress["status"] = "error" if terminal_conflict else "conflict"
4849
+ progress["updated_at"] = now_iso()
4850
+ if terminal_conflict:
4851
+ progress.pop("pending_action", None)
4852
+ write_task(root, harness_task_id, task)
4853
+ raise StateError(f"Canonical Spec CAS retry failed: {exc}") from exc
4854
+ except ExecutionStateError as exc:
4855
+ progress["status"] = "error"
4856
+ progress["updated_at"] = now_iso()
4857
+ progress.pop("pending_action", None)
4858
+ write_task(root, harness_task_id, task)
4859
+ raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
4347
4860
 
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()})
4861
+ event = _spec_event(execution, idempotency_key)
4862
+ try:
4863
+ details = show_execution(stored_spec_path(root, task))
4864
+ except ExecutionStateError as exc:
4865
+ raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
4866
+ source.update(
4867
+ {
4868
+ "revision": details["design_revision"],
4869
+ "design_sha256": details["design_sha256"],
4870
+ "document_sha256": details["document_sha256"],
4871
+ "execution_revision": execution["execution_revision"],
4872
+ }
4873
+ )
4874
+ inspect_task_spec(root, task)
4875
+ progress.update(
4876
+ {
4877
+ "last_execution_revision": execution["execution_revision"],
4878
+ "last_event_id": event["event_id"],
4879
+ "last_idempotency_key": idempotency_key,
4880
+ "status": "ok",
4881
+ "updated_at": now_iso(),
4882
+ }
4883
+ )
4884
+ progress.pop("pending_action", None)
4885
+ acknowledgment = {
4886
+ "type": "spec-writeback",
4887
+ "action": action,
4888
+ "event_id": event["event_id"],
4889
+ "execution_revision": execution["execution_revision"],
4890
+ "idempotency_key": idempotency_key,
4891
+ "timestamp": now_iso(),
4892
+ }
4893
+ already_acknowledged = any(
4894
+ record.get("type") == "spec-writeback"
4895
+ and record.get("idempotency_key") == idempotency_key
4896
+ for record in execution_records(root, harness_task_id)
4897
+ )
4898
+ if not already_acknowledged:
4899
+ append_execution_record(root, harness_task_id, acknowledgment)
4900
+ write_task(root, harness_task_id, task)
4901
+ return acknowledgment
4351
4902
 
4352
4903
 
4353
- def resolve_current_task(
4904
+ def writeback_spec_task(
4354
4905
  root: Path,
4906
+ source_task_id: str,
4907
+ status_value: str,
4908
+ summary: str,
4909
+ evidence: list[dict],
4910
+ idempotency_key: str,
4911
+ agent: str,
4355
4912
  task_id: str | None = None,
4356
4913
  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
4914
+ ) -> dict:
4915
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4916
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
4917
+ raise StateError("Canonical source task is outside the Harness task selection.")
4918
+ action = {
4919
+ "kind": "task",
4920
+ "source_task_id": source_task_id,
4921
+ "status": status_value,
4922
+ "summary": summary,
4923
+ "evidence": evidence,
4924
+ "idempotency_key": idempotency_key,
4925
+ "agent": agent,
4926
+ }
4927
+ acknowledgment = _execute_spec_writeback(
4928
+ root,
4929
+ resolved_task_id,
4930
+ task,
4931
+ action,
4932
+ idempotency_key,
4933
+ lambda design_digest, execution_revision: record_task_status(
4934
+ stored_spec_path(root, task),
4935
+ source_task_id,
4936
+ status_value,
4937
+ summary,
4938
+ SPEC_WRITEBACK_APP,
4939
+ spec_writeback_agent(agent),
4940
+ design_digest,
4941
+ execution_revision,
4942
+ evidence=evidence,
4943
+ run_id=resolved_task_id,
4944
+ idempotency_key=idempotency_key,
4945
+ ),
4946
+ )
4947
+ snapshot = snapshot_state(root, session_file, session)
4948
+ snapshot["spec_writeback"] = acknowledgment
4949
+ snapshot["action"] = "writeback-spec-task"
4950
+ return snapshot
4366
4951
 
4367
4952
 
4368
- def validate_workflow_mode_proposal(
4953
+ def writeback_spec_step(
4369
4954
  root: Path,
4370
- session: dict,
4371
- proposal: object,
4955
+ source_task_id: str,
4956
+ step_id: str,
4957
+ status_value: str,
4958
+ summary: str,
4959
+ evidence: list[dict],
4960
+ idempotency_key: str,
4961
+ agent: str,
4372
4962
  task_id: str | None = None,
4963
+ session_file: str | Path | None = None,
4373
4964
  ) -> dict:
4374
- if not isinstance(proposal, dict):
4375
- raise StateError("workflow_mode_proposal is missing.")
4376
- configured = str(proposal.get("configured_mode") or "")
4377
- selected = str(proposal.get("selected_mode") or "")
4378
- minimum = str(proposal.get("minimum_mode") or "")
4965
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4966
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
4967
+ raise StateError("Canonical source task is outside the Harness task selection.")
4968
+ action = {
4969
+ "kind": "step",
4970
+ "source_task_id": source_task_id,
4971
+ "step_id": step_id,
4972
+ "status": status_value,
4973
+ "summary": summary,
4974
+ "evidence": evidence,
4975
+ "idempotency_key": idempotency_key,
4976
+ "agent": agent,
4977
+ }
4978
+ acknowledgment = _execute_spec_writeback(
4979
+ root,
4980
+ resolved_task_id,
4981
+ task,
4982
+ action,
4983
+ idempotency_key,
4984
+ lambda design_digest, execution_revision: record_step_status(
4985
+ stored_spec_path(root, task),
4986
+ source_task_id,
4987
+ step_id,
4988
+ status_value,
4989
+ summary,
4990
+ SPEC_WRITEBACK_APP,
4991
+ spec_writeback_agent(agent),
4992
+ design_digest,
4993
+ execution_revision,
4994
+ evidence=evidence,
4995
+ run_id=resolved_task_id,
4996
+ idempotency_key=idempotency_key,
4997
+ ),
4998
+ )
4999
+ snapshot = snapshot_state(root, session_file, session)
5000
+ snapshot["spec_writeback"] = acknowledgment
5001
+ snapshot["action"] = "writeback-spec-step"
5002
+ return snapshot
5003
+
5004
+
5005
+ def writeback_spec_dependency(
5006
+ root: Path,
5007
+ source_task_id: str,
5008
+ dependency_task_id: str,
5009
+ status_value: str,
5010
+ summary: str,
5011
+ evidence: list[dict],
5012
+ idempotency_key: str,
5013
+ agent: str,
5014
+ task_id: str | None = None,
5015
+ session_file: str | Path | None = None,
5016
+ ) -> dict:
5017
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5018
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
5019
+ raise StateError("Canonical source task is outside the Harness task selection.")
5020
+ action = {
5021
+ "kind": "dependency",
5022
+ "source_task_id": source_task_id,
5023
+ "dependency_task_id": dependency_task_id,
5024
+ "status": status_value,
5025
+ "summary": summary,
5026
+ "evidence": evidence,
5027
+ "idempotency_key": idempotency_key,
5028
+ "agent": agent,
5029
+ }
5030
+ acknowledgment = _execute_spec_writeback(
5031
+ root,
5032
+ resolved_task_id,
5033
+ task,
5034
+ action,
5035
+ idempotency_key,
5036
+ lambda design_digest, execution_revision: record_dependency_status(
5037
+ stored_spec_path(root, task),
5038
+ source_task_id,
5039
+ dependency_task_id,
5040
+ status_value,
5041
+ summary,
5042
+ SPEC_WRITEBACK_APP,
5043
+ spec_writeback_agent(agent),
5044
+ design_digest,
5045
+ execution_revision,
5046
+ evidence=evidence,
5047
+ run_id=resolved_task_id,
5048
+ idempotency_key=idempotency_key,
5049
+ ),
5050
+ )
5051
+ snapshot = snapshot_state(root, session_file, session)
5052
+ snapshot["spec_writeback"] = acknowledgment
5053
+ snapshot["action"] = "writeback-spec-dependency"
5054
+ return snapshot
5055
+
5056
+
5057
+ def rebind_spec_source(
5058
+ root: Path,
5059
+ spec_path: str,
5060
+ agent: str,
5061
+ task_id: str | None = None,
5062
+ session_file: str | Path | None = None,
5063
+ ) -> dict:
5064
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5065
+ source = task.get("spec_source")
5066
+ if not isinstance(source, dict):
5067
+ raise StateError("Current task is not backed by a Canonical Spec.")
5068
+ raw_path = Path(spec_path).expanduser()
5069
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
5070
+ try:
5071
+ inspection = inspect_spec(
5072
+ resolved,
5073
+ root,
5074
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
5075
+ task.get("selected_spec_tasks") or [],
5076
+ )
5077
+ except EasyDevSpecError as exc:
5078
+ raise StateError(f"Cannot rebind Canonical Spec: {exc}") from exc
5079
+ for field in ("schema", "spec_id", "revision", "design_sha256"):
5080
+ expected = source.get(field)
5081
+ if field == "design_sha256" and expected is None and source.get("sha256") == inspection.get("source_sha256"):
5082
+ expected = inspection.get("design_sha256")
5083
+ if expected != inspection.get(field):
5084
+ raise StateError(f"Rebind rejected because Canonical Spec {field} does not match.")
5085
+ previous_execution_revision = source.get("execution_revision", 0)
5086
+ if int(inspection.get("execution_revision", -1)) < int(previous_execution_revision):
5087
+ raise StateError("Rebind rejected because Canonical execution revision moved backwards.")
5088
+ try:
5089
+ source_path = resolved.relative_to(root.resolve()).as_posix()
5090
+ path_mode = "project-relative"
5091
+ except ValueError:
5092
+ source_path = str(resolved)
5093
+ path_mode = "absolute"
5094
+ source.update({"path": source_path, "path_mode": path_mode})
5095
+ inspect_task_spec(root, task)
5096
+ task["last_agent"] = agent
5097
+ write_task(root, resolved_task_id, task)
5098
+ snapshot = snapshot_state(root, session_file, session)
5099
+ snapshot["action"] = "rebind-spec-source"
5100
+ return snapshot
5101
+
5102
+
5103
+ def reconcile_local_result_evidence(
5104
+ root: Path,
5105
+ resolved_task_id: str,
5106
+ task: dict,
5107
+ agent: str,
5108
+ session_file: str | Path | None,
5109
+ ) -> tuple[int, list[str]]:
5110
+ plan = latest_execution_plan(root, resolved_task_id)
5111
+ if not isinstance(plan, dict):
5112
+ return 0, []
5113
+ inspection, selection = inspect_task_spec(root, task)
5114
+ snapshots = _selected_execution_snapshots(inspection, task)
5115
+ units = {
5116
+ str(unit.get("id")): unit
5117
+ for unit in plan.get("units", [])
5118
+ if isinstance(unit, dict) and is_non_empty_string(unit.get("id"))
5119
+ }
5120
+ records = execution_records(root, resolved_task_id)
5121
+ last_plan_index = max(
5122
+ (index for index, record in enumerate(records) if record.get("type") == "plan"),
5123
+ default=-1,
5124
+ )
5125
+ lifecycle_by_unit: dict[str, list[tuple[int, dict]]] = {
5126
+ unit_id: [] for unit_id in units
5127
+ }
5128
+ for record_index, record in enumerate(records[last_plan_index + 1 :], last_plan_index + 1):
5129
+ unit_id = str(record.get("unit_id") or "")
5130
+ if record.get("type") in {"dispatch", "result"} and unit_id in lifecycle_by_unit:
5131
+ lifecycle_by_unit[unit_id].append((record_index, record))
5132
+ latest_results = {
5133
+ unit_id: lifecycle[-1]
5134
+ for unit_id, lifecycle in lifecycle_by_unit.items()
5135
+ if lifecycle and lifecycle[-1][1].get("type") == "result"
5136
+ }
5137
+ step_by_id = {
5138
+ str(step.get("step_id")): step
5139
+ for step in selection.get("selected_steps", [])
5140
+ if isinstance(step, dict)
5141
+ }
5142
+ test_by_id = {
5143
+ str(test.get("test_id")): test
5144
+ for test in selection.get("selected_tests", [])
5145
+ if isinstance(test, dict)
5146
+ }
5147
+ reconciled = 0
5148
+ unresolved: list[str] = []
5149
+ for unit_id, (result_index, result) in latest_results.items():
5150
+ unit = units.get(unit_id)
5151
+ if not unit:
5152
+ continue
5153
+ lifecycle = lifecycle_by_unit.get(unit_id, [])
5154
+ if len(lifecycle) < 2 or lifecycle[-2][1].get("type") != "dispatch":
5155
+ unresolved.append(f"{unit_id}:missing-matching-dispatch")
5156
+ continue
5157
+ dispatch_index, dispatch = lifecycle[-2]
5158
+ source_task_id = str(unit.get("source_task_id") or "")
5159
+ source_steps = [str(value) for value in unit.get("source_step_ids", [])]
5160
+ if source_task_id not in snapshots or not source_steps:
5161
+ continue
5162
+ if (
5163
+ dispatch.get("source_task_id") != source_task_id
5164
+ or dispatch.get("repo_id") != unit.get("repo_id")
5165
+ or result.get("source_task_id") != source_task_id
5166
+ or result.get("repo_id") != unit.get("repo_id")
5167
+ or not isinstance(result.get("changed_files"), list)
5168
+ or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
5169
+ or not is_non_empty_string(result.get("summary"))
5170
+ ):
5171
+ unresolved.append(f"{unit_id}:source-ownership-mismatch")
5172
+ continue
5173
+ current_status = snapshots[source_task_id].get("status")
5174
+ if current_status != "in_progress":
5175
+ unresolved.append(
5176
+ f"{unit_id}:shared-task-status={current_status or 'missing'}"
5177
+ )
5178
+ continue
5179
+ attempt_id, attempt_completed_steps = _shared_attempt_projection(
5180
+ inspection, source_task_id
5181
+ )
5182
+ if not attempt_id:
5183
+ unresolved.append(f"{unit_id}:missing-in-progress-attempt")
5184
+ continue
5185
+ attempt_ack_index = max(
5186
+ (
5187
+ index
5188
+ for index, record in enumerate(records)
5189
+ if record.get("type") == "spec-writeback"
5190
+ and record.get("event_id") == attempt_id
5191
+ and isinstance(record.get("action"), dict)
5192
+ and record["action"].get("kind") == "task"
5193
+ and record["action"].get("source_task_id") == source_task_id
5194
+ and record["action"].get("status") == "in_progress"
5195
+ ),
5196
+ default=-1,
5197
+ )
5198
+ if attempt_ack_index < 0:
5199
+ unresolved.append(f"{unit_id}:missing-in-progress-acknowledgment")
5200
+ continue
5201
+ if dispatch_index <= attempt_ack_index or result_index <= attempt_ack_index:
5202
+ unresolved.append(f"{unit_id}:no-result-for-current-attempt")
5203
+ continue
5204
+ result_status = result.get("status")
5205
+ successful = (
5206
+ result_status == "completed"
5207
+ and result.get("issues") == []
5208
+ and result.get("needs_attention") == []
5209
+ )
5210
+ failed = result_status == "failed"
5211
+ if not successful and not failed:
5212
+ unresolved.append(f"{unit_id}:invalid-result-status-or-issues")
5213
+ continue
5214
+ if failed:
5215
+ if len(source_steps) != 1:
5216
+ unresolved.append(f"{unit_id}:ambiguous-failed-source-step")
5217
+ continue
5218
+ step_id = source_steps[0]
5219
+ key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-failed"
5220
+ writeback_spec_step(
5221
+ root,
5222
+ source_task_id,
5223
+ step_id,
5224
+ "failed",
5225
+ str(result.get("summary") or f"Unit {unit_id} failed"),
5226
+ [
5227
+ {
5228
+ "kind": "result",
5229
+ "status": "failed",
5230
+ "ref": f"execution.jsonl#unit={unit_id}",
5231
+ }
5232
+ ],
5233
+ key,
5234
+ agent,
5235
+ resolved_task_id,
5236
+ session_file,
5237
+ )
5238
+ reconciled += 1
5239
+ unresolved.extend(
5240
+ f"{unit_id}:{remaining_step}:blocked-after-unit-failure"
5241
+ for remaining_step in source_steps[1:]
5242
+ )
5243
+ task = load_task(root, resolved_task_id) or task
5244
+ inspection, selection = inspect_task_spec(root, task)
5245
+ snapshots = _selected_execution_snapshots(inspection, task)
5246
+ continue
5247
+ passed_commands = {
5248
+ str(check.get("command"))
5249
+ for check in result.get("checks", [])
5250
+ if isinstance(check, dict)
5251
+ and check.get("passed") is True
5252
+ and is_non_empty_string(check.get("command"))
5253
+ }
5254
+ missing_unit_commands = sorted(set(unit.get("test_commands", [])) - passed_commands)
5255
+ if missing_unit_commands:
5256
+ unresolved.append(
5257
+ f"{unit_id}:missing-passed-command=" + ",".join(missing_unit_commands)
5258
+ )
5259
+ continue
5260
+ pending_steps = list(dict.fromkeys(source_steps))
5261
+ while pending_steps:
5262
+ ready_step_id = next(
5263
+ (
5264
+ step_id
5265
+ for step_id in pending_steps
5266
+ if step_id in attempt_completed_steps
5267
+ or set((step_by_id.get(step_id) or {}).get("depends_on_step_ids", []))
5268
+ .issubset(attempt_completed_steps)
5269
+ ),
5270
+ None,
5271
+ )
5272
+ if ready_step_id is None:
5273
+ unresolved.extend(
5274
+ f"{unit_id}:{step_id}:dependency-pending" for step_id in pending_steps
5275
+ )
5276
+ break
5277
+ step_id = ready_step_id
5278
+ pending_steps.remove(step_id)
5279
+ if step_id in attempt_completed_steps:
5280
+ continue
5281
+ step = step_by_id.get(step_id)
5282
+ if not step:
5283
+ unresolved.append(f"{unit_id}:{step_id}:missing-step")
5284
+ continue
5285
+ tests = [test_by_id.get(str(test_id)) for test_id in step.get("test_ids", [])]
5286
+ if any(not isinstance(test, dict) for test in tests):
5287
+ unresolved.append(f"{unit_id}:{step_id}:missing-test")
5288
+ continue
5289
+ missing_commands = [
5290
+ str(test.get("command"))
5291
+ for test in tests
5292
+ if str(test.get("command")) not in passed_commands
5293
+ ]
5294
+ if missing_commands:
5295
+ unresolved.append(
5296
+ f"{unit_id}:{step_id}:missing-passed-command=" + ",".join(missing_commands)
5297
+ )
5298
+ continue
5299
+ evidence = [
5300
+ {
5301
+ "kind": "test",
5302
+ "status": "passed",
5303
+ "ref": f"execution.jsonl#unit={unit_id};command={test.get('command')}",
5304
+ "test_id": str(test.get("test_id")),
5305
+ }
5306
+ for test in tests
5307
+ ]
5308
+ key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-completed"
5309
+ writeback_spec_step(
5310
+ root,
5311
+ source_task_id,
5312
+ step_id,
5313
+ "completed",
5314
+ str(result.get("summary") or f"Unit {unit_id} completed"),
5315
+ evidence,
5316
+ key,
5317
+ agent,
5318
+ resolved_task_id,
5319
+ session_file,
5320
+ )
5321
+ reconciled += 1
5322
+ task = load_task(root, resolved_task_id) or task
5323
+ inspection, selection = inspect_task_spec(root, task)
5324
+ snapshots = _selected_execution_snapshots(inspection, task)
5325
+ _, attempt_completed_steps = _shared_attempt_projection(
5326
+ inspection, source_task_id
5327
+ )
5328
+ task = load_task(root, resolved_task_id) or task
5329
+ inspection, _ = inspect_task_spec(root, task)
5330
+ snapshots = _selected_execution_snapshots(inspection, task)
5331
+ selected_tasks = {
5332
+ str(item.get("task_id")): item
5333
+ for item in selection.get("selected_tasks", [])
5334
+ if isinstance(item, dict)
5335
+ }
5336
+ for source_task_id, snapshot in snapshots.items():
5337
+ if snapshot.get("status") != "in_progress":
5338
+ continue
5339
+ expected_steps = set(selected_tasks.get(source_task_id, {}).get("step_ids", []))
5340
+ attempt_id, attempt_completed_steps = _shared_attempt_projection(
5341
+ inspection, source_task_id
5342
+ )
5343
+ if attempt_id and expected_steps and attempt_completed_steps == expected_steps:
5344
+ key = (
5345
+ f"{resolved_task_id}:{source_task_id}:{attempt_id}:"
5346
+ "implemented-from-results"
5347
+ )
5348
+ writeback_spec_task(
5349
+ root,
5350
+ source_task_id,
5351
+ "implemented",
5352
+ "All Canonical Steps have passed local implementation evidence",
5353
+ [],
5354
+ key,
5355
+ agent,
5356
+ resolved_task_id,
5357
+ session_file,
5358
+ )
5359
+ reconciled += 1
5360
+ return reconciled, unresolved
5361
+
5362
+
5363
+ def reconcile_spec_execution(
5364
+ root: Path,
5365
+ agent: str,
5366
+ task_id: str | None = None,
5367
+ session_file: str | Path | None = None,
5368
+ ) -> dict:
5369
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5370
+ progress = _writeback_progress(task)
5371
+ pending = progress.get("pending_action")
5372
+ if not isinstance(pending, str) or not pending.strip():
5373
+ reconciled, unresolved = reconcile_local_result_evidence(
5374
+ root,
5375
+ resolved_task_id,
5376
+ task,
5377
+ agent,
5378
+ session_file,
5379
+ )
5380
+ task = load_task(root, resolved_task_id) or task
5381
+ inspect_task_spec(root, task)
5382
+ progress.update(
5383
+ {
5384
+ "last_execution_revision": task["spec_source"]["execution_revision"],
5385
+ "status": "ok",
5386
+ "updated_at": now_iso(),
5387
+ }
5388
+ )
5389
+ write_task(root, resolved_task_id, task)
5390
+ snapshot = snapshot_state(root, session_file, session)
5391
+ snapshot["action"] = "reconcile-spec-execution"
5392
+ snapshot["reconciled"] = reconciled > 0
5393
+ snapshot["reconciled_actions"] = reconciled
5394
+ snapshot["unresolved_local_evidence"] = unresolved
5395
+ return snapshot
5396
+ try:
5397
+ action = json.loads(pending)
5398
+ except json.JSONDecodeError as exc:
5399
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
5400
+ kind = action.get("kind")
5401
+ if kind == "sync-design":
5402
+ affected_task_ids = action.get("affected_task_ids")
5403
+ if not is_string_list(affected_task_ids):
5404
+ raise StateError("Pending Canonical Spec design sync has invalid affected tasks.")
5405
+ result = sync_spec_design_state(
5406
+ root,
5407
+ affected_task_ids,
5408
+ str(action.get("summary") or "Reconciled Canonical Spec design sync"),
5409
+ str(action.get("idempotency_key") or ""),
5410
+ str(action.get("agent") or agent),
5411
+ resolved_task_id,
5412
+ session_file,
5413
+ )
5414
+ result["action"] = "reconcile-spec-execution"
5415
+ result["reconciled"] = True
5416
+ return result
5417
+ try:
5418
+ design_text, _ = split_execution_region(
5419
+ stored_spec_path(root, task).read_text(encoding="utf-8")
5420
+ )
5421
+ except (OSError, UnicodeError, ValueError) as exc:
5422
+ raise StateError(f"Cannot inspect pending Canonical Spec writeback: {exc}") from exc
5423
+ current_design_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
5424
+ source = task.get("spec_source")
5425
+ if not isinstance(source, dict):
5426
+ raise StateError("Current task is not backed by a Canonical Spec.")
5427
+ if current_design_sha256 != source.get("design_sha256"):
5428
+ # 旧设计上的进度事件不能重放到新设计;清除单槽 pending,允许后续 sync-design。
5429
+ progress["status"] = "error"
5430
+ progress["updated_at"] = now_iso()
5431
+ progress.pop("pending_action", None)
5432
+ write_task(root, resolved_task_id, task)
5433
+ raise StateError(
5434
+ "Pending Canonical Spec writeback belongs to an obsolete design and was "
5435
+ "discarded; return to ANALYSIS and run sync-spec-design."
5436
+ )
5437
+ common = {
5438
+ "root": root,
5439
+ "summary": str(action.get("summary") or "Reconciled shared Spec writeback"),
5440
+ "evidence": action.get("evidence") if isinstance(action.get("evidence"), list) else [],
5441
+ "idempotency_key": str(action.get("idempotency_key") or ""),
5442
+ "agent": str(action.get("agent") or agent),
5443
+ "task_id": resolved_task_id,
5444
+ "session_file": session_file,
5445
+ }
5446
+ if not common["idempotency_key"]:
5447
+ raise StateError("Pending Canonical Spec writeback has no idempotency key.")
5448
+ if kind == "task":
5449
+ result = writeback_spec_task(
5450
+ source_task_id=str(action.get("source_task_id") or ""),
5451
+ status_value=str(action.get("status") or ""),
5452
+ **common,
5453
+ )
5454
+ elif kind == "step":
5455
+ result = writeback_spec_step(
5456
+ source_task_id=str(action.get("source_task_id") or ""),
5457
+ step_id=str(action.get("step_id") or ""),
5458
+ status_value=str(action.get("status") or ""),
5459
+ **common,
5460
+ )
5461
+ elif kind == "dependency":
5462
+ result = writeback_spec_dependency(
5463
+ source_task_id=str(action.get("source_task_id") or ""),
5464
+ dependency_task_id=str(action.get("dependency_task_id") or ""),
5465
+ status_value=str(action.get("status") or ""),
5466
+ **common,
5467
+ )
5468
+ else:
5469
+ raise StateError("Pending Canonical Spec writeback kind is unsupported.")
5470
+ result["action"] = "reconcile-spec-execution"
5471
+ result["reconciled"] = True
5472
+ return result
5473
+
5474
+
5475
+ def sync_spec_design_state(
5476
+ root: Path,
5477
+ affected_task_ids: list[str],
5478
+ summary: str,
5479
+ idempotency_key: str,
5480
+ agent: str,
5481
+ task_id: str | None = None,
5482
+ session_file: str | Path | None = None,
5483
+ ) -> dict:
5484
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5485
+ source = task.get("spec_source")
5486
+ if not isinstance(source, dict):
5487
+ raise StateError("Current task is not backed by a Canonical Spec.")
5488
+ spec_path = stored_spec_path(root, task)
5489
+ requested_task_ids = sorted(set(affected_task_ids))
5490
+
5491
+ def current_execution_envelope() -> dict:
5492
+ try:
5493
+ from easy_dev_spec_protocol import split_execution_region
5494
+
5495
+ _, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
5496
+ except (OSError, UnicodeError, ValueError) as exc:
5497
+ raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
5498
+ if not isinstance(execution, dict):
5499
+ raise StateError("Canonical Spec shared execution is missing before sync-design.")
5500
+ if execution.get("design_sha256") != source.get("design_sha256"):
5501
+ matching_events = [
5502
+ event
5503
+ for event in execution.get("events", [])
5504
+ if isinstance(event, dict)
5505
+ and event.get("type") == "spec_revised"
5506
+ and event.get("idempotency_key") == idempotency_key
5507
+ and event.get("requested_task_ids") == requested_task_ids
5508
+ and event.get("run_id") == resolved_task_id
5509
+ ]
5510
+ if len(matching_events) != 1:
5511
+ raise StateError(
5512
+ "Canonical Spec execution baseline no longer matches the bound design."
5513
+ )
5514
+ return execution
5515
+
5516
+ current_revision = int(current_execution_envelope().get("execution_revision", -1))
5517
+ progress = _writeback_progress(task)
5518
+ pending_action = {
5519
+ "kind": "sync-design",
5520
+ "affected_task_ids": requested_task_ids,
5521
+ "summary": summary,
5522
+ "idempotency_key": idempotency_key,
5523
+ "agent": agent,
5524
+ }
5525
+ serialized_pending_action = json.dumps(
5526
+ pending_action, ensure_ascii=False, sort_keys=True
5527
+ )
5528
+ existing_pending = progress.get("pending_action")
5529
+ if isinstance(existing_pending, str) and existing_pending.strip():
5530
+ try:
5531
+ existing_action = json.loads(existing_pending)
5532
+ except json.JSONDecodeError as exc:
5533
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
5534
+ if existing_action != pending_action:
5535
+ raise StateError(
5536
+ "A different Canonical Spec writeback is pending; run "
5537
+ "reconcile-spec-execution before sync-design."
5538
+ )
5539
+ progress.update(
5540
+ {
5541
+ "last_execution_revision": current_revision,
5542
+ "pending_action": serialized_pending_action,
5543
+ "status": "pending",
5544
+ "updated_at": now_iso(),
5545
+ }
5546
+ )
5547
+ write_task(root, resolved_task_id, task)
5548
+
5549
+ def invoke_sync(execution_revision: int) -> dict:
5550
+ return sync_design(
5551
+ spec_path,
5552
+ requested_task_ids,
5553
+ summary,
5554
+ SPEC_WRITEBACK_APP,
5555
+ spec_writeback_agent(agent),
5556
+ str(source.get("design_sha256")),
5557
+ execution_revision,
5558
+ run_id=resolved_task_id,
5559
+ idempotency_key=idempotency_key,
5560
+ )
5561
+
5562
+ try:
5563
+ execution = invoke_sync(current_revision)
5564
+ except ExecutionConflictError:
5565
+ try:
5566
+ execution = invoke_sync(
5567
+ int(current_execution_envelope().get("execution_revision", -1))
5568
+ )
5569
+ except ExecutionStateError as exc:
5570
+ progress["status"] = "error"
5571
+ progress["updated_at"] = now_iso()
5572
+ progress.pop("pending_action", None)
5573
+ write_task(root, resolved_task_id, task)
5574
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
5575
+ except ExecutionConflictError as exc:
5576
+ terminal_conflict = _is_idempotency_key_conflict(exc)
5577
+ progress["status"] = "error" if terminal_conflict else "conflict"
5578
+ progress["updated_at"] = now_iso()
5579
+ if terminal_conflict:
5580
+ progress.pop("pending_action", None)
5581
+ write_task(root, resolved_task_id, task)
5582
+ raise StateError(f"Cannot synchronize Canonical Spec design after CAS retry: {exc}") from exc
5583
+ except ExecutionStateError as exc:
5584
+ progress["status"] = "error"
5585
+ progress["updated_at"] = now_iso()
5586
+ progress.pop("pending_action", None)
5587
+ write_task(root, resolved_task_id, task)
5588
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
5589
+ try:
5590
+ details = show_execution(spec_path)
5591
+ inspection = inspect_spec(
5592
+ spec_path,
5593
+ root,
5594
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
5595
+ task.get("selected_spec_tasks") or [],
5596
+ )
5597
+ except (ExecutionStateError, EasyDevSpecError) as exc:
5598
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
5599
+ if inspection.get("spec_id") != source.get("spec_id"):
5600
+ raise StateError("Synchronized Canonical Spec identity changed unexpectedly.")
5601
+ binding_was_synchronized = (
5602
+ source.get("revision") == inspection.get("revision")
5603
+ and source.get("design_sha256") == inspection.get("design_sha256")
5604
+ )
5605
+ source.update(
5606
+ {
5607
+ "revision": inspection["revision"],
5608
+ "design_sha256": inspection["design_sha256"],
5609
+ "document_sha256": inspection["document_sha256"],
5610
+ "execution_revision": execution["execution_revision"],
5611
+ }
5612
+ )
5613
+ event = _spec_event(execution, idempotency_key)
5614
+ if not binding_was_synchronized:
5615
+ reset_task_ids = set(event.get("task_ids", []))
5616
+ refreshed_dependencies: list[dict] = []
5617
+ for dependency in task.get("spec_dependency_evidence", []):
5618
+ if not isinstance(dependency, dict):
5619
+ continue
5620
+ refreshed = dict(dependency)
5621
+ if refreshed.get("source_task_id") in reset_task_ids:
5622
+ refreshed["status"] = "pending"
5623
+ refreshed["shared_status"] = "pending"
5624
+ for field in ("evidence", "satisfied_at", "satisfied_by"):
5625
+ refreshed.pop(field, None)
5626
+ refreshed_dependencies.append(refreshed)
5627
+ task["spec_dependency_evidence"] = refreshed_dependencies
5628
+ inspect_task_spec(root, task)
5629
+ progress.update(
5630
+ {
5631
+ "last_execution_revision": execution["execution_revision"],
5632
+ "last_event_id": event["event_id"],
5633
+ "last_idempotency_key": idempotency_key,
5634
+ "status": "ok",
5635
+ "updated_at": now_iso(),
5636
+ }
5637
+ )
5638
+ progress.pop("pending_action", None)
5639
+ if task.get("status") not in {"INIT", "ANALYSIS"}:
5640
+ task["status"] = "ANALYSIS"
5641
+ append_stage_history(task, "ANALYSIS", agent)
5642
+ task.pop("pending_transition", None)
5643
+ task["last_agent"] = agent
5644
+ already_acknowledged = any(
5645
+ record.get("type") == "spec-design-sync"
5646
+ and record.get("idempotency_key") == idempotency_key
5647
+ for record in execution_records(root, resolved_task_id)
5648
+ )
5649
+ if not already_acknowledged:
5650
+ append_execution_record(
5651
+ root,
5652
+ resolved_task_id,
5653
+ {
5654
+ "type": "spec-design-sync",
5655
+ "affected_task_ids": requested_task_ids,
5656
+ "event_id": event["event_id"],
5657
+ "design_sha256": details["design_sha256"],
5658
+ "execution_revision": execution["execution_revision"],
5659
+ "idempotency_key": idempotency_key,
5660
+ "timestamp": now_iso(),
5661
+ },
5662
+ )
5663
+ write_task(root, resolved_task_id, task)
5664
+ snapshot = snapshot_state(root, session_file, session)
5665
+ snapshot["action"] = "sync-spec-design"
5666
+ return snapshot
5667
+
5668
+
5669
+ def _selected_execution_snapshots(inspection: dict, task: dict) -> dict[str, dict]:
5670
+ selected = set(task.get("selected_spec_tasks") or [])
5671
+ execution = inspection.get("execution")
5672
+ if not isinstance(execution, dict):
5673
+ raise StateError("Canonical Spec shared execution is unavailable.")
5674
+ return {
5675
+ str(snapshot.get("task_id")): snapshot
5676
+ for snapshot in execution.get("tasks", [])
5677
+ if isinstance(snapshot, dict) and snapshot.get("task_id") in selected
5678
+ }
5679
+
5680
+
5681
+ def _shared_attempt_projection(
5682
+ inspection: dict, source_task_id: str
5683
+ ) -> tuple[str | None, set[str]]:
5684
+ execution = inspection.get("execution")
5685
+ if not isinstance(execution, dict):
5686
+ return None, set()
5687
+ events = [event for event in execution.get("events", []) if isinstance(event, dict)]
5688
+ start_index = next(
5689
+ (
5690
+ index
5691
+ for index in range(len(events) - 1, -1, -1)
5692
+ if events[index].get("type") == "task_status_changed"
5693
+ and events[index].get("task_id") == source_task_id
5694
+ and events[index].get("to_status") == "in_progress"
5695
+ ),
5696
+ None,
5697
+ )
5698
+ if start_index is None:
5699
+ return None, set()
5700
+ start_event = events[start_index]
5701
+ completed_steps: set[str] = set()
5702
+ for event in events[start_index + 1 :]:
5703
+ if event.get("type") != "step_status_changed" or event.get("task_id") != source_task_id:
5704
+ continue
5705
+ step_id = str(event.get("step_id") or "")
5706
+ if not step_id:
5707
+ continue
5708
+ if event.get("step_status") == "completed":
5709
+ completed_steps.add(step_id)
5710
+ elif event.get("step_status") == "failed":
5711
+ completed_steps.discard(step_id)
5712
+ return str(start_event.get("event_id") or "") or None, completed_steps
5713
+
5714
+
5715
+ def _snapshot_dependencies_ready(snapshot: dict, all_snapshots: dict[str, dict]) -> bool:
5716
+ for dependency in snapshot.get("dependencies", []):
5717
+ if not isinstance(dependency, dict) or dependency.get("type") not in {"hard", "contract"}:
5718
+ continue
5719
+ if dependency.get("status") == "satisfied":
5720
+ continue
5721
+ if dependency.get("type") == "hard" and all_snapshots.get(
5722
+ str(dependency.get("task_id")), {}
5723
+ ).get("status") == "completed":
5724
+ continue
5725
+ return False
5726
+ return True
5727
+
5728
+
5729
+ def writeback_ready_tasks_for_implement(
5730
+ root: Path,
5731
+ harness_task_id: str,
5732
+ task: dict,
5733
+ agent: str,
5734
+ restart_statuses: set[str] | None = None,
5735
+ ) -> None:
5736
+ inspection, _ = inspect_task_spec(root, task)
5737
+ implement_attempt = 1 + sum(
5738
+ 1
5739
+ for entry in task.get("stage_history", [])
5740
+ if isinstance(entry, dict) and entry.get("stage") == "IMPLEMENT"
5741
+ )
5742
+ all_snapshots = {
5743
+ str(snapshot.get("task_id")): snapshot
5744
+ for snapshot in inspection["execution"].get("tasks", [])
5745
+ if isinstance(snapshot, dict)
5746
+ }
5747
+ selected_snapshots = _selected_execution_snapshots(inspection, task)
5748
+ for source_task_id in task.get("selected_spec_tasks") or []:
5749
+ snapshot = selected_snapshots.get(str(source_task_id))
5750
+ if not snapshot or snapshot.get("status") == "in_progress":
5751
+ continue
5752
+ if restart_statuses is not None and snapshot.get("status") not in restart_statuses:
5753
+ continue
5754
+ if not _snapshot_dependencies_ready(snapshot, all_snapshots):
5755
+ continue
5756
+ key = (
5757
+ f"{harness_task_id}:{source_task_id}:enter-implement:"
5758
+ f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
5759
+ )
5760
+ action = {
5761
+ "kind": "task",
5762
+ "source_task_id": source_task_id,
5763
+ "status": "in_progress",
5764
+ "summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
5765
+ "evidence": [],
5766
+ "idempotency_key": key,
5767
+ "agent": agent,
5768
+ }
5769
+ _execute_spec_writeback(
5770
+ root,
5771
+ harness_task_id,
5772
+ task,
5773
+ action,
5774
+ key,
5775
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5776
+ stored_spec_path(root, task),
5777
+ str(source_task_id),
5778
+ "in_progress",
5779
+ "Harness entered IMPLEMENT for a dependency-ready Canonical task",
5780
+ SPEC_WRITEBACK_APP,
5781
+ spec_writeback_agent(agent),
5782
+ design_digest,
5783
+ execution_revision,
5784
+ run_id=harness_task_id,
5785
+ idempotency_key=key,
5786
+ ),
5787
+ )
5788
+
5789
+
5790
+ def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> None:
5791
+ inspection, _ = inspect_task_spec(root, task)
5792
+ snapshots = _selected_execution_snapshots(inspection, task)
5793
+ invalid = [
5794
+ f"{task_id}:{snapshots.get(str(task_id), {}).get('status', 'missing')}"
5795
+ for task_id in task.get("selected_spec_tasks") or []
5796
+ if snapshots.get(str(task_id), {}).get("status") not in allowed
5797
+ ]
5798
+ if invalid:
5799
+ raise StateError(
5800
+ "Canonical Spec writeback is incomplete for selected tasks: " + ", ".join(invalid)
5801
+ )
5802
+
5803
+
5804
+ def writeback_completed_tasks(
5805
+ root: Path,
5806
+ harness_task_id: str,
5807
+ task: dict,
5808
+ agent: str,
5809
+ ) -> None:
5810
+ inspection, _ = inspect_task_spec(root, task)
5811
+ snapshots = _selected_execution_snapshots(inspection, task)
5812
+ for source_task_id in task.get("selected_spec_tasks") or []:
5813
+ status_value = snapshots.get(str(source_task_id), {}).get("status")
5814
+ if status_value == "completed":
5815
+ continue
5816
+ if status_value != "verified":
5817
+ raise StateError(
5818
+ f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
5819
+ )
5820
+ key = f"{harness_task_id}:{source_task_id}:complete:{task['spec_source']['revision']}"
5821
+ action = {
5822
+ "kind": "task",
5823
+ "source_task_id": source_task_id,
5824
+ "status": "completed",
5825
+ "summary": "Harness MEMORY completed and the Canonical task is complete",
5826
+ "evidence": [],
5827
+ "idempotency_key": key,
5828
+ "agent": agent,
5829
+ }
5830
+ _execute_spec_writeback(
5831
+ root,
5832
+ harness_task_id,
5833
+ task,
5834
+ action,
5835
+ key,
5836
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5837
+ stored_spec_path(root, task),
5838
+ str(source_task_id),
5839
+ "completed",
5840
+ "Harness MEMORY completed and the Canonical task is complete",
5841
+ SPEC_WRITEBACK_APP,
5842
+ spec_writeback_agent(agent),
5843
+ design_digest,
5844
+ execution_revision,
5845
+ run_id=harness_task_id,
5846
+ idempotency_key=key,
5847
+ ),
5848
+ )
5849
+
5850
+
5851
+ def cancel_shared_tasks(
5852
+ root: Path,
5853
+ harness_task_id: str,
5854
+ task: dict,
5855
+ reason: str,
5856
+ agent: str,
5857
+ ) -> None:
5858
+ inspection, _ = inspect_task_spec(root, task)
5859
+ snapshots = _selected_execution_snapshots(inspection, task)
5860
+ for source_task_id in task.get("selected_spec_tasks") or []:
5861
+ current = snapshots.get(str(source_task_id), {}).get("status")
5862
+ if current in {"completed", "cancelled"}:
5863
+ continue
5864
+ if current in {"implemented", "verified"}:
5865
+ blocked_key = f"{harness_task_id}:{source_task_id}:close-blocked"
5866
+ blocked_action = {
5867
+ "kind": "task",
5868
+ "source_task_id": source_task_id,
5869
+ "status": "blocked",
5870
+ "summary": reason,
5871
+ "evidence": [],
5872
+ "idempotency_key": blocked_key,
5873
+ "agent": agent,
5874
+ }
5875
+ _execute_spec_writeback(
5876
+ root,
5877
+ harness_task_id,
5878
+ task,
5879
+ blocked_action,
5880
+ blocked_key,
5881
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5882
+ stored_spec_path(root, task),
5883
+ str(source_task_id),
5884
+ "blocked",
5885
+ reason,
5886
+ SPEC_WRITEBACK_APP,
5887
+ spec_writeback_agent(agent),
5888
+ design_digest,
5889
+ execution_revision,
5890
+ run_id=harness_task_id,
5891
+ idempotency_key=blocked_key,
5892
+ ),
5893
+ )
5894
+ cancel_key = f"{harness_task_id}:{source_task_id}:cancel"
5895
+ cancel_action = {
5896
+ "kind": "task",
5897
+ "source_task_id": source_task_id,
5898
+ "status": "cancelled",
5899
+ "summary": reason,
5900
+ "evidence": [],
5901
+ "idempotency_key": cancel_key,
5902
+ "agent": agent,
5903
+ }
5904
+ _execute_spec_writeback(
5905
+ root,
5906
+ harness_task_id,
5907
+ task,
5908
+ cancel_action,
5909
+ cancel_key,
5910
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
5911
+ stored_spec_path(root, task),
5912
+ str(source_task_id),
5913
+ "cancelled",
5914
+ reason,
5915
+ SPEC_WRITEBACK_APP,
5916
+ spec_writeback_agent(agent),
5917
+ design_digest,
5918
+ execution_revision,
5919
+ run_id=harness_task_id,
5920
+ idempotency_key=cancel_key,
5921
+ ),
5922
+ )
5923
+
5924
+
5925
+ def satisfy_spec_dependency(
5926
+ root: Path,
5927
+ dependency_task_id: str,
5928
+ evidence: str,
5929
+ agent: str,
5930
+ source_task_id: str | None = None,
5931
+ task_id: str | None = None,
5932
+ session_file: str | Path | None = None,
5933
+ ) -> dict:
5934
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5935
+ if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
5936
+ raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
5937
+ if not is_non_empty_string(evidence):
5938
+ raise StateError("Spec dependency evidence must be non-empty.")
5939
+ inspect_task_spec(root, task)
5940
+ records = task.get("spec_dependency_evidence")
5941
+ if not isinstance(records, list):
5942
+ raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
5943
+ matches = [
5944
+ record
5945
+ for record in records
5946
+ if isinstance(record, dict)
5947
+ and record.get("task_id") == dependency_task_id
5948
+ and (source_task_id is None or record.get("source_task_id") == source_task_id)
5949
+ ]
5950
+ if not matches:
5951
+ raise StateError("Canonical Spec dependency edge was not found.")
5952
+ if source_task_id is None and len(matches) > 1:
5953
+ raise StateError(
5954
+ "Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
5955
+ )
5956
+ record = matches[0]
5957
+ if record.get("dependency_type") == "contract":
5958
+ raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
5959
+ record["status"] = "satisfied"
5960
+ record["evidence"] = evidence.strip()
5961
+ record["satisfied_at"] = now_iso()
5962
+ record["satisfied_by"] = agent
5963
+ task["last_agent"] = agent
5964
+ evidence_digest = hashlib.sha256(evidence.strip().encode("utf-8")).hexdigest()[:16]
5965
+ idempotency_key = (
5966
+ f"{resolved_task_id}:{record.get('source_task_id')}:{dependency_task_id}:"
5967
+ f"dependency-satisfied:revision-{task['spec_source']['revision']}:{evidence_digest}"
5968
+ )
5969
+ action = {
5970
+ "kind": "dependency",
5971
+ "source_task_id": str(record.get("source_task_id")),
5972
+ "dependency_task_id": dependency_task_id,
5973
+ "status": "satisfied",
5974
+ "summary": evidence.strip(),
5975
+ "evidence": [{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
5976
+ "idempotency_key": idempotency_key,
5977
+ "agent": agent,
5978
+ }
5979
+ _execute_spec_writeback(
5980
+ root,
5981
+ resolved_task_id,
5982
+ task,
5983
+ action,
5984
+ idempotency_key,
5985
+ lambda design_digest, execution_revision: record_dependency_status(
5986
+ stored_spec_path(root, task),
5987
+ str(record.get("source_task_id")),
5988
+ dependency_task_id,
5989
+ "satisfied",
5990
+ evidence.strip(),
5991
+ SPEC_WRITEBACK_APP,
5992
+ spec_writeback_agent(agent),
5993
+ design_digest,
5994
+ execution_revision,
5995
+ evidence=[{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
5996
+ run_id=resolved_task_id,
5997
+ idempotency_key=idempotency_key,
5998
+ ),
5999
+ )
6000
+ snapshot = snapshot_state(root, session_file, session)
6001
+ snapshot["action"] = "satisfy-spec-dependency"
6002
+ return snapshot
6003
+
6004
+
6005
+ def append_stage_history(task: dict, stage: str, agent: str) -> None:
6006
+ history = task.setdefault("stage_history", [])
6007
+ history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
6008
+
6009
+
6010
+ def resolve_current_task(
6011
+ root: Path,
6012
+ task_id: str | None = None,
6013
+ session_file: str | Path | None = None,
6014
+ ) -> tuple[dict, str, dict]:
6015
+ session = ensure_session(root, session_file)
6016
+ resolved_task_id = task_id or session.get("current_task")
6017
+ if not resolved_task_id:
6018
+ raise StateError("No current task is set.")
6019
+ task = load_task(root, str(resolved_task_id))
6020
+ if task is None:
6021
+ raise StateError(f"Task not found: {resolved_task_id}")
6022
+ return session, str(resolved_task_id), task
6023
+
6024
+
6025
+ def validate_workflow_mode_proposal(
6026
+ root: Path,
6027
+ session: dict,
6028
+ proposal: object,
6029
+ task_id: str | None = None,
6030
+ ) -> dict:
6031
+ if not isinstance(proposal, dict):
6032
+ raise StateError("workflow_mode_proposal is missing.")
6033
+ configured = str(proposal.get("configured_mode") or "")
6034
+ selected = str(proposal.get("selected_mode") or "")
6035
+ minimum = str(proposal.get("minimum_mode") or "")
4379
6036
  source = str(proposal.get("source") or "")
4380
6037
  reasons = proposal.get("reasons")
4381
6038
  effective_configured = resolve_behavior(root, session)[5]
@@ -4680,14 +6337,27 @@ def apply_transition(
4680
6337
  if task.get("workflow_mode_legacy") is not True:
4681
6338
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4682
6339
  freeze_tdd_mode(root, session, resolved_task_id, task, agent)
6340
+ if stage == "IMPLEMENT" and previous != "IMPLEMENT":
6341
+ if isinstance(task.get("spec_source"), dict):
6342
+ writeback_ready_tasks_for_implement(
6343
+ root,
6344
+ resolved_task_id,
6345
+ task,
6346
+ agent,
6347
+ {"blocked"} if previous in {"REVIEW", "VERIFICATION"} else None,
6348
+ )
4683
6349
  if previous == "REVIEW" and stage == "VERIFICATION":
4684
6350
  validate_review_readiness(root, resolved_task_id, task)
4685
6351
  if previous == "VERIFICATION" and stage == "MEMORY":
4686
6352
  validate_verification_readiness(root, resolved_task_id, task)
6353
+ if isinstance(task.get("spec_source"), dict):
6354
+ require_shared_task_statuses(root, task, {"verified", "completed"})
4687
6355
  if previous == "MEMORY" and stage == "COMPLETE":
4688
6356
  progress = task.get("memory_progress")
4689
6357
  if not isinstance(progress, dict) or progress.get("completed") is not True:
4690
6358
  raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
6359
+ if isinstance(task.get("spec_source"), dict):
6360
+ writeback_completed_tasks(root, resolved_task_id, task, agent)
4691
6361
  if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
4692
6362
  validate_read_only_completion(root, resolved_task_id)
4693
6363
  if previous != stage:
@@ -4914,6 +6584,7 @@ def memory_complete(
4914
6584
  action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
4915
6585
  ),
4916
6586
  )
6587
+ validate_recorded_architecture_assessment(root, progress, instruction)
4917
6588
  if action == "distill":
4918
6589
  validate_distillation_file_sets(root, instruction)
4919
6590
  progress["long_memory_action"] = action
@@ -4941,6 +6612,8 @@ def close_current_task(
4941
6612
  task = load_task(root, str(task_id))
4942
6613
  if task is None:
4943
6614
  raise StateError(f"Task not found: {task_id}")
6615
+ if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
6616
+ cancel_shared_tasks(root, str(task_id), task, reason, agent)
4944
6617
  if task.get("status") != "CLOSED":
4945
6618
  task["status"] = "CLOSED"
4946
6619
  append_stage_history(task, "CLOSED", agent)
@@ -5042,6 +6715,19 @@ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
5042
6715
  return mappings
5043
6716
 
5044
6717
 
6718
+ def parse_evidence_args(values: list[str]) -> list[dict]:
6719
+ evidence: list[dict] = []
6720
+ for value in values:
6721
+ try:
6722
+ parsed = json.loads(value)
6723
+ except json.JSONDecodeError as exc:
6724
+ raise StateError(f"--evidence must be a JSON object: {exc}") from exc
6725
+ if not isinstance(parsed, dict):
6726
+ raise StateError("--evidence must be a JSON object.")
6727
+ evidence.append(parsed)
6728
+ return evidence
6729
+
6730
+
5045
6731
  def main() -> int:
5046
6732
  configure_stdio()
5047
6733
  common = argparse.ArgumentParser(add_help=False)
@@ -5058,6 +6744,11 @@ def main() -> int:
5058
6744
  inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
5059
6745
  inspect_spec_parser.add_argument("--spec", required=True)
5060
6746
  inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
6747
+ inspect_spec_parser.add_argument("--spec-task", action="append", default=[])
6748
+ inspect_spec_parser.add_argument("--manifest-only", action="store_true")
6749
+
6750
+ initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
6751
+ initialize_spec.add_argument("--spec", required=True)
5061
6752
 
5062
6753
  select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
5063
6754
  select_spec_scope.add_argument("--spec", required=True)
@@ -5076,11 +6767,71 @@ def main() -> int:
5076
6767
  create_from_spec.add_argument("--task-id", required=True)
5077
6768
  create_from_spec.add_argument("--type", required=True)
5078
6769
  create_from_spec.add_argument("--title", required=True)
5079
- create_from_spec.add_argument("--repo-path", required=True, action="append")
6770
+ create_from_spec.add_argument("--repo-path", action="append", default=[])
5080
6771
  create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
5081
6772
  create_from_spec.add_argument("--agent", required=True)
5082
6773
  create_from_spec.add_argument("--no-set-current", action="store_true")
5083
6774
 
6775
+ rebind_spec = subcommands.add_parser("rebind-spec-source", parents=[common])
6776
+ rebind_spec.add_argument("--spec", required=True)
6777
+ rebind_spec.add_argument("--agent", required=True)
6778
+ rebind_spec.add_argument("--task-id")
6779
+
6780
+ writeback_task = subcommands.add_parser("writeback-spec-task", parents=[common])
6781
+ writeback_task.add_argument("--spec-task", required=True)
6782
+ writeback_task.add_argument(
6783
+ "--status",
6784
+ required=True,
6785
+ choices=[
6786
+ "in_progress",
6787
+ "blocked",
6788
+ "implemented",
6789
+ "verified",
6790
+ "completed",
6791
+ "cancelled",
6792
+ ],
6793
+ )
6794
+ writeback_task.add_argument("--summary", required=True)
6795
+ writeback_task.add_argument("--evidence", action="append", default=[])
6796
+ writeback_task.add_argument("--idempotency-key", required=True)
6797
+ writeback_task.add_argument("--agent", required=True)
6798
+ writeback_task.add_argument("--task-id")
6799
+
6800
+ writeback_step = subcommands.add_parser("writeback-spec-step", parents=[common])
6801
+ writeback_step.add_argument("--spec-task", required=True)
6802
+ writeback_step.add_argument("--step", required=True)
6803
+ writeback_step.add_argument("--status", required=True, choices=["completed", "failed"])
6804
+ writeback_step.add_argument("--summary", required=True)
6805
+ writeback_step.add_argument("--evidence", action="append", default=[])
6806
+ writeback_step.add_argument("--idempotency-key", required=True)
6807
+ writeback_step.add_argument("--agent", required=True)
6808
+ writeback_step.add_argument("--task-id")
6809
+
6810
+ writeback_dependency = subcommands.add_parser(
6811
+ "writeback-spec-dependency", parents=[common]
6812
+ )
6813
+ writeback_dependency.add_argument("--source-task", required=True)
6814
+ writeback_dependency.add_argument("--dependency-task", required=True)
6815
+ writeback_dependency.add_argument(
6816
+ "--status", required=True, choices=["pending", "satisfied"]
6817
+ )
6818
+ writeback_dependency.add_argument("--summary", required=True)
6819
+ writeback_dependency.add_argument("--evidence", action="append", default=[])
6820
+ writeback_dependency.add_argument("--idempotency-key", required=True)
6821
+ writeback_dependency.add_argument("--agent", required=True)
6822
+ writeback_dependency.add_argument("--task-id")
6823
+
6824
+ sync_spec = subcommands.add_parser("sync-spec-design", parents=[common])
6825
+ sync_spec.add_argument("--affected-task", action="append", default=[])
6826
+ sync_spec.add_argument("--summary", required=True)
6827
+ sync_spec.add_argument("--idempotency-key", required=True)
6828
+ sync_spec.add_argument("--agent", required=True)
6829
+ sync_spec.add_argument("--task-id")
6830
+
6831
+ reconcile_spec = subcommands.add_parser("reconcile-spec-execution", parents=[common])
6832
+ reconcile_spec.add_argument("--agent", required=True)
6833
+ reconcile_spec.add_argument("--task-id")
6834
+
5084
6835
  set_current = subcommands.add_parser("set-current", parents=[common])
5085
6836
  set_current.add_argument("--task-id", required=True)
5086
6837
  set_current.add_argument("--agent", required=True)
@@ -5210,6 +6961,18 @@ def main() -> int:
5210
6961
  memory_instruction_parser.add_argument("--agent")
5211
6962
  memory_instruction_parser.add_argument("--task-id")
5212
6963
 
6964
+ memory_architecture_parser = subcommands.add_parser(
6965
+ "memory-architecture-assessment", parents=[common]
6966
+ )
6967
+ memory_architecture_parser.add_argument(
6968
+ "--action", required=True, choices=sorted(ARCHITECTURE_ACTIONS)
6969
+ )
6970
+ memory_architecture_parser.add_argument("--reason", required=True)
6971
+ memory_architecture_parser.add_argument("--evidence", action="append", default=[])
6972
+ memory_architecture_parser.add_argument("--affected-section", action="append", default=[])
6973
+ memory_architecture_parser.add_argument("--agent", required=True)
6974
+ memory_architecture_parser.add_argument("--task-id")
6975
+
5213
6976
  memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
5214
6977
  memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
5215
6978
  memory_complete_parser.add_argument("--agent", required=True)
@@ -5251,6 +7014,7 @@ def main() -> int:
5251
7014
  )
5252
7015
  if session_file is None and command not in {
5253
7016
  "inspect-dev-spec",
7017
+ "initialize-spec-execution",
5254
7018
  "select-dev-spec-scope",
5255
7019
  "list-tasks",
5256
7020
  "memory-new-id",
@@ -5263,18 +7027,26 @@ def main() -> int:
5263
7027
  if command == "snapshot":
5264
7028
  emit(snapshot_state(root, session_file))
5265
7029
  elif command == "inspect-dev-spec":
5266
- spec_path = Path(args.spec)
5267
- emit(
5268
- inspection_summary(
5269
- inspect_spec(
5270
- spec_path if spec_path.is_absolute() else root / spec_path,
5271
- root,
5272
- parse_mapping_args(args.repo_path, "--repo-path"),
5273
- )
7030
+ spec_path = Path(args.spec).expanduser()
7031
+ if args.manifest_only and args.spec_task:
7032
+ raise StateError("--manifest-only cannot be combined with --spec-task")
7033
+ resolved_spec = spec_path if spec_path.is_absolute() else root / spec_path
7034
+ repo_paths = parse_mapping_args(args.repo_path, "--repo-path")
7035
+ inspection = (
7036
+ inspect_manifest(resolved_spec, root, repo_paths)
7037
+ if args.manifest_only
7038
+ else inspect_spec(
7039
+ resolved_spec,
7040
+ root,
7041
+ repo_paths,
7042
+ args.spec_task or None,
5274
7043
  )
5275
7044
  )
7045
+ emit(inspection_summary(inspection))
7046
+ elif command == "initialize-spec-execution":
7047
+ emit(initialize_spec_execution_state(root, args.spec))
5276
7048
  elif command == "select-dev-spec-scope":
5277
- spec_path = Path(args.spec)
7049
+ spec_path = Path(args.spec).expanduser()
5278
7050
  emit(
5279
7051
  select_consumption_scopes(
5280
7052
  spec_path if spec_path.is_absolute() else root / spec_path,
@@ -5325,6 +7097,100 @@ def main() -> int:
5325
7097
  session_file,
5326
7098
  )
5327
7099
  )
7100
+ elif command == "rebind-spec-source":
7101
+ emit(
7102
+ attach_status_context(
7103
+ root,
7104
+ rebind_spec_source(root, args.spec, agent, args.task_id, session_file),
7105
+ agent,
7106
+ session_file,
7107
+ )
7108
+ )
7109
+ elif command == "writeback-spec-task":
7110
+ emit(
7111
+ attach_status_context(
7112
+ root,
7113
+ writeback_spec_task(
7114
+ root,
7115
+ args.spec_task,
7116
+ args.status,
7117
+ args.summary,
7118
+ parse_evidence_args(args.evidence),
7119
+ args.idempotency_key,
7120
+ agent,
7121
+ args.task_id,
7122
+ session_file,
7123
+ ),
7124
+ agent,
7125
+ session_file,
7126
+ )
7127
+ )
7128
+ elif command == "writeback-spec-step":
7129
+ emit(
7130
+ attach_status_context(
7131
+ root,
7132
+ writeback_spec_step(
7133
+ root,
7134
+ args.spec_task,
7135
+ args.step,
7136
+ args.status,
7137
+ args.summary,
7138
+ parse_evidence_args(args.evidence),
7139
+ args.idempotency_key,
7140
+ agent,
7141
+ args.task_id,
7142
+ session_file,
7143
+ ),
7144
+ agent,
7145
+ session_file,
7146
+ )
7147
+ )
7148
+ elif command == "writeback-spec-dependency":
7149
+ emit(
7150
+ attach_status_context(
7151
+ root,
7152
+ writeback_spec_dependency(
7153
+ root,
7154
+ args.source_task,
7155
+ args.dependency_task,
7156
+ args.status,
7157
+ args.summary,
7158
+ parse_evidence_args(args.evidence),
7159
+ args.idempotency_key,
7160
+ agent,
7161
+ args.task_id,
7162
+ session_file,
7163
+ ),
7164
+ agent,
7165
+ session_file,
7166
+ )
7167
+ )
7168
+ elif command == "sync-spec-design":
7169
+ emit(
7170
+ attach_status_context(
7171
+ root,
7172
+ sync_spec_design_state(
7173
+ root,
7174
+ args.affected_task,
7175
+ args.summary,
7176
+ args.idempotency_key,
7177
+ agent,
7178
+ args.task_id,
7179
+ session_file,
7180
+ ),
7181
+ agent,
7182
+ session_file,
7183
+ )
7184
+ )
7185
+ elif command == "reconcile-spec-execution":
7186
+ emit(
7187
+ attach_status_context(
7188
+ root,
7189
+ reconcile_spec_execution(root, agent, args.task_id, session_file),
7190
+ agent,
7191
+ session_file,
7192
+ )
7193
+ )
5328
7194
  elif command == "set-current":
5329
7195
  emit(
5330
7196
  attach_status_context(
@@ -5591,6 +7457,24 @@ def main() -> int:
5591
7457
  session_file,
5592
7458
  )
5593
7459
  )
7460
+ elif command == "memory-architecture-assessment":
7461
+ emit(
7462
+ attach_status_context(
7463
+ root,
7464
+ record_architecture_assessment(
7465
+ root,
7466
+ args.action,
7467
+ args.reason,
7468
+ args.evidence,
7469
+ args.affected_section,
7470
+ agent,
7471
+ args.task_id,
7472
+ session_file,
7473
+ ),
7474
+ agent,
7475
+ session_file,
7476
+ )
7477
+ )
5594
7478
  elif command == "memory-complete":
5595
7479
  emit(
5596
7480
  attach_status_context(