easy-coding-harness 0.10.0-beta.1 → 0.10.0-beta.10

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +50 -20
  3. package/dist/cli.js +478 -47
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/claude/agents/ec-implementer.md +11 -0
  7. package/templates/claude/agents/ec-reviewer.md +6 -1
  8. package/templates/codex/agents/ec-implementer.toml +11 -0
  9. package/templates/codex/agents/ec-reviewer.toml +6 -1
  10. package/templates/common/bundled-skills/ec-init/SKILL.md +18 -6
  11. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +27 -12
  12. package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
  13. package/templates/common/skills/ec-analysis/SKILL.md +141 -35
  14. package/templates/common/skills/ec-config/SKILL.md +24 -2
  15. package/templates/common/skills/ec-git/SKILL.md +7 -1
  16. package/templates/common/skills/ec-implementing/SKILL.md +61 -1
  17. package/templates/common/skills/ec-memory/SKILL.md +76 -6
  18. package/templates/common/skills/ec-reviewing/SKILL.md +21 -3
  19. package/templates/common/skills/ec-task-close/SKILL.md +4 -0
  20. package/templates/common/skills/ec-task-management/SKILL.md +7 -1
  21. package/templates/common/skills/ec-tdd-init/SKILL.md +101 -0
  22. package/templates/common/skills/ec-verification/SKILL.md +68 -26
  23. package/templates/common/skills/ec-workflow/SKILL.md +81 -22
  24. package/templates/main-constraint/AGENTS.md.tpl +49 -14
  25. package/templates/main-constraint/CLAUDE.md.tpl +46 -14
  26. package/templates/qoder/agents/ec-implementer.md +11 -0
  27. package/templates/qoder/agents/ec-reviewer.md +6 -1
  28. package/templates/runtime/templates/dev-spec-skeleton.md +8 -1
  29. package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
  30. package/templates/shared-hooks/easy_coding_state.py +3878 -259
  31. package/templates/shared-hooks/easy_dev_spec.py +444 -30
  32. package/templates/shared-hooks/easy_dev_spec_execution.py +1014 -0
  33. package/templates/shared-hooks/easy_dev_spec_protocol.py +1426 -18
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  import argparse
3
+ import base64
4
+ import difflib
3
5
  import hashlib
4
6
  import json
5
7
  import os
@@ -7,6 +9,7 @@ import re
7
9
  import secrets
8
10
  import shlex
9
11
  import subprocess
12
+ import tempfile
10
13
  import time
11
14
  import uuid
12
15
  from datetime import datetime, timezone
@@ -15,11 +18,23 @@ import sys
15
18
 
16
19
  from easy_dev_spec import (
17
20
  EasyDevSpecError,
21
+ inspect_manifest,
18
22
  inspect_spec,
19
23
  inspection_summary,
20
24
  select_consumption_scopes,
21
25
  select_tasks,
22
26
  )
27
+ from easy_dev_spec_execution import (
28
+ ExecutionConflictError,
29
+ ExecutionStateError,
30
+ initialize_execution,
31
+ record_dependency_status,
32
+ record_step_status,
33
+ record_task_status,
34
+ show_execution,
35
+ sync_design,
36
+ )
37
+ from easy_dev_spec_protocol import split_execution_region
23
38
 
24
39
 
25
40
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
@@ -38,6 +53,7 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
38
53
  "### 需求解析",
39
54
  "### 现状",
40
55
  "### 冲突摘要",
56
+ "### 决策闭环",
41
57
  "### 影响面分析",
42
58
  "### 改动范围",
43
59
  "### 修改方案",
@@ -66,22 +82,45 @@ ALWAYS_AUTO_TRANSITIONS = {
66
82
  }
67
83
  READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
68
84
  NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
85
+ TDD_INIT_TASK_TYPE = "tdd-init"
69
86
  APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
70
87
  CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
71
88
  WORKFLOW_MODES = {"fast", "standard", "strict"}
72
89
  WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
73
90
  STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
74
91
  REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
75
- STRICT_WORKFLOW_RISK_PATTERN = re.compile(
76
- r"(migration|migrate|schema|state[-_ ]?machine|security|payment|data[-_ ]?loss|"
77
- r"concurren|cross[-_ ]?repo|public[-_ ]?(api|contract)|迁移|状态机|安全|支付|"
78
- r"数据丢失|并发|跨仓|公共接口|公共契约)",
92
+ HIGH_WORKFLOW_RISK_PATTERN = re.compile(
93
+ r"(\bhigh[-_ ]?risk\b|\bcritical\b|\bsevere\b|\birreversible\b|"
94
+ r"\bdata[-_ ]?loss\b|\bfinancial[-_ ]?loss\b|"
95
+ r"\bsecurity[-_ ]?(boundary|breach)\b|\bprivilege[-_ ]?escalation\b|"
96
+ r"\bproduction[-_ ]?outage\b|"
97
+ r"高风险|严重|不可逆|数据丢失|资损|安全边界|安全事件|权限提升|生产故障)",
98
+ re.IGNORECASE,
99
+ )
100
+ NEGATED_HIGH_WORKFLOW_RISK_PATTERN = re.compile(
101
+ r"(\b(?:non[-_ ]?|not[-_ ]+|no[-_ ]+)(?:high[-_ ]?risk|critical|severe|irreversible)\b|"
102
+ r"\b(?:no|without)[-_ ]+(?:risk[-_ ]+of[-_ ]+)?(?:data[-_ ]?loss|"
103
+ r"financial[-_ ]?loss|security[-_ ]?breach|production[-_ ]?outage)\b|"
104
+ r"低风险|非高风险|不严重|(?<!不)可逆|无(?:数据丢失|资损|安全事件|生产故障)|"
105
+ r"不会导致(?:数据丢失|资损|安全事件|生产故障))",
106
+ re.IGNORECASE,
107
+ )
108
+ WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
109
+ r"(cross[-_ ]?repo|public[-_ ]?(api|contract)|跨仓|公共接口|公共契约)",
79
110
  re.IGNORECASE,
80
111
  )
81
112
  DEFAULT_APPROVAL_MODE = "guard"
82
113
  DEFAULT_WORKFLOW_MODE = "adaptive"
83
114
  DEFAULT_TDD_ENABLED = False
84
115
  DEFAULT_TDD_COVERAGE_THRESHOLD = 90
116
+ TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
117
+ TDD_READINESS_SCOPE = "changed-production-lines"
118
+ TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
119
+ TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
120
+ TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
121
+ COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
122
+ JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
123
+ GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
85
124
  CRITICAL_CONFIRM_TRANSITIONS = {
86
125
  ("ANALYSIS", "IMPLEMENT"),
87
126
  ("VERIFICATION", "MEMORY"),
@@ -96,10 +135,30 @@ LEGACY_STAGE_MAP = {
96
135
 
97
136
  DEFAULT_SHORT_TERM_MAX = 10
98
137
  DEFAULT_SHORT_TERM_KEEP = 5
99
- SESSION_STALE_THRESHOLD_HOURS = 30 * 24
138
+ # 架构认知正文的项目相对路径,用于冻结与复核 ABSTRACT 内容指纹。
139
+ ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
140
+ # 架构认知变更日志的项目相对路径,用于验证 backfill/update 留下审计记录。
141
+ ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
142
+ # MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
143
+ ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
144
+ ACCEPTANCE_SNAPSHOT_SCHEMA = 1
145
+ ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
146
+ SESSION_IDLE_RETENTION_HOURS = 7 * 24
147
+ SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
148
+ MAX_SESSION_FILES = 100
100
149
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
150
+ WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
151
+ # 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
152
+ INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
101
153
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
102
154
  CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
155
+ LEGACY_DISPLAY_AGENT_IDENTITIES = {
156
+ "claude with easy coding": "claude-code",
157
+ "claude-code with easy coding": "claude-code",
158
+ "claude code with easy coding": "claude-code",
159
+ "codex with easy coding": "codex",
160
+ "qoder with easy coding": "qoder",
161
+ }
103
162
  LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
104
163
  LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
105
164
  LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
@@ -108,6 +167,19 @@ SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
108
167
  )
109
168
  LEGACY_SHORT_MEMORY_ID_PATTERN = re.compile(r"^SM-\d{8}-\d+$")
110
169
  DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
170
+ DECISION_STATUS_PATTERN = re.compile(
171
+ r"\s*decision_status\s*:\s*([a-z][a-z0-9_-]*)\s*", re.IGNORECASE
172
+ )
173
+ DECISION_CONCLUSIONS_PATTERN = re.compile(
174
+ r"\s*(?:[-+*]\s+)?(?:\*\*)?已解决问题与结论(?:\*\*)?\s*[::]\s*(.+?)\s*"
175
+ )
176
+ DECISION_EVIDENCE_PATTERN = re.compile(
177
+ r"\s*(?:[-+*]\s+)?(?:\*\*)?确认依据(?:\*\*)?\s*[::]\s*(.+?)\s*"
178
+ )
179
+ UNRESOLVED_DECISION_VALUE_PATTERN = re.compile(
180
+ r"(?:待确认|待决策|未确认|未决|todo|tbd|unknown|open|pending|unresolved)[。.!!]?",
181
+ re.IGNORECASE,
182
+ )
111
183
  MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
112
184
  TABLE_HEADER_CELLS = {
113
185
  "改动文件",
@@ -166,14 +238,25 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
166
238
  return (2, memory_id)
167
239
 
168
240
 
169
- def normalize_agent_identity(agent: str | None) -> str:
241
+ def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
170
242
  raw_agent = str(agent or "unknown").strip()
171
243
  normalized = raw_agent.lower()
172
244
  # Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
173
245
  if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
174
246
  return "codex"
175
- if normalized in SESSION_AGENT_NAMESPACES:
247
+ if normalized in WORKFLOW_AGENT_IDENTITIES:
176
248
  return normalized
249
+ if allow_legacy_display:
250
+ return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
251
+ return None
252
+
253
+
254
+ def normalize_agent_identity(agent: str | None) -> str:
255
+ raw_agent = str(agent or "unknown").strip()
256
+ # 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
257
+ canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
258
+ if canonical is not None:
259
+ return canonical
177
260
  return raw_agent
178
261
 
179
262
 
@@ -187,6 +270,9 @@ def agents_equivalent(first: str | None, second: str | None) -> bool:
187
270
 
188
271
 
189
272
  def detect_runtime_agent() -> str:
273
+ if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
274
+ return INSTALLED_WORKFLOW_AGENT
275
+ # 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
190
276
  script_path = Path(sys.argv[0]).as_posix()
191
277
  if ".qoder/" in script_path or ".qodercn/" in script_path:
192
278
  return "qoder"
@@ -202,6 +288,45 @@ def detect_runtime_agent() -> str:
202
288
  return "unknown"
203
289
 
204
290
 
291
+ def resolve_state_agent(explicit_agent: str | None) -> str:
292
+ runtime_agent = detect_runtime_agent()
293
+ explicit_identity = None
294
+ if explicit_agent is not None:
295
+ explicit_identity = canonical_agent_identity(explicit_agent)
296
+ if explicit_identity is None:
297
+ raise StateError(
298
+ "Workflow --agent must be one of claude-code, codex, or qoder; "
299
+ "display attribution such as 'Codex with Easy Coding' is not an agent identity."
300
+ )
301
+ if runtime_agent in WORKFLOW_AGENT_IDENTITIES:
302
+ if explicit_identity is not None and explicit_identity != runtime_agent:
303
+ raise StateError(
304
+ f"Workflow agent mismatch: script belongs to {runtime_agent}, "
305
+ f"but --agent resolved to {explicit_identity}. Use the active platform's state script."
306
+ )
307
+ return runtime_agent
308
+ return explicit_identity or "unknown"
309
+
310
+
311
+ def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
312
+ if session_file is None or agent not in WORKFLOW_AGENT_IDENTITIES:
313
+ return
314
+ session_name = Path(str(session_file)).name
315
+ session_agent = next(
316
+ (
317
+ candidate
318
+ for candidate in WORKFLOW_AGENT_IDENTITIES
319
+ if session_name.startswith(f"{candidate}-")
320
+ ),
321
+ None,
322
+ )
323
+ if session_agent is not None and session_agent != agent:
324
+ raise StateError(
325
+ f"Workflow session mismatch: session belongs to {session_agent}, "
326
+ f"but the state operation resolved to {agent}. Use the active session's state script."
327
+ )
328
+
329
+
205
330
  def normalize_session_component(value: str) -> str:
206
331
  if (
207
332
  value not in {".", ".."}
@@ -399,7 +524,11 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
399
524
  "expected adaptive, fast, standard, or strict."
400
525
  )
401
526
  if schema_version >= 4:
402
- tdd_enabled = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
527
+ tdd_enabled = (
528
+ parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
529
+ if schema_version >= 5
530
+ else DEFAULT_TDD_ENABLED
531
+ )
403
532
  tdd_threshold = parse_tdd_threshold(
404
533
  behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
405
534
  "behavior.tdd_coverage_threshold",
@@ -410,6 +539,175 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
410
539
  return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
411
540
 
412
541
 
542
+ def safe_tdd_report_pattern(value: object) -> bool:
543
+ if not is_non_empty_string(value):
544
+ return False
545
+ candidate = Path(str(value))
546
+ return not candidate.is_absolute() and ".." not in candidate.parts
547
+
548
+
549
+ def tdd_gate_uses_task_variables(command: object) -> bool:
550
+ if not is_non_empty_string(command):
551
+ return False
552
+ try:
553
+ tokens = shlex.split(str(command))
554
+ except ValueError:
555
+ return False
556
+ options: dict[str, str] = {}
557
+ for index, token in enumerate(tokens[:-1]):
558
+ if token in {"--base", "--threshold"}:
559
+ options[token] = tokens[index + 1]
560
+ return options.get("--base") in {
561
+ f"${TDD_BASE_VARIABLE}",
562
+ "$" + "{" + TDD_BASE_VARIABLE + "}",
563
+ } and options.get("--threshold") in {
564
+ f"${TDD_THRESHOLD_VARIABLE}",
565
+ "$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
566
+ }
567
+
568
+
569
+ def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
570
+ combined = "\n".join(
571
+ re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
572
+ for line in "\n".join(contents).splitlines()
573
+ )
574
+ lowered = combined.lower()
575
+ reasons: list[str] = []
576
+ for marker in (
577
+ "jacoco",
578
+ "artifacts",
579
+ COVERAGE_TOOL_PATH,
580
+ TDD_BASE_VARIABLE,
581
+ TDD_THRESHOLD_VARIABLE,
582
+ ):
583
+ if marker.lower() not in lowered:
584
+ reasons.append(f"CI files do not contain required marker: {marker}")
585
+ if not tdd_gate_uses_task_variables(combined):
586
+ reasons.append(
587
+ "CI changed-line gate must use the task baseline and threshold variables"
588
+ )
589
+ if re.search(
590
+ r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
591
+ combined,
592
+ re.IGNORECASE,
593
+ ) is None:
594
+ reasons.append("CI files do not declare a TEST-stage job")
595
+ return reasons
596
+
597
+
598
+ def tdd_readiness(root: Path) -> dict[str, object]:
599
+ receipt = root / TDD_READINESS_PATH
600
+ if not receipt.is_file():
601
+ return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
602
+ try:
603
+ manifest = json.loads(receipt.read_text(encoding="utf-8"))
604
+ except (OSError, UnicodeError, json.JSONDecodeError):
605
+ return {"status": "needs_init", "reasons": ["TDD readiness receipt is invalid"]}
606
+ if not isinstance(manifest, dict):
607
+ return {
608
+ "status": "needs_init",
609
+ "reasons": ["TDD readiness receipt must be a JSON object"],
610
+ }
611
+
612
+ reasons: list[str] = []
613
+ if manifest.get("schema") != TDD_READINESS_SCHEMA:
614
+ reasons.append("unsupported readiness schema")
615
+ if manifest.get("provider") != "gitlab":
616
+ reasons.append("readiness provider must be gitlab")
617
+ if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
618
+ reasons.append("coverage scope must be changed-production-lines")
619
+ if manifest.get("historical_coverage_required") is not False:
620
+ reasons.append("historical coverage must remain disabled")
621
+ reports = manifest.get("coverage_report_patterns")
622
+ if not isinstance(reports, list) or not reports or not all(
623
+ safe_tdd_report_pattern(item) for item in reports
624
+ ):
625
+ reasons.append(
626
+ "coverage_report_patterns must contain safe project-relative report patterns"
627
+ )
628
+ gate = manifest.get("changed_line_gate_command")
629
+ if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
630
+ reasons.append("changed-line coverage gate command is missing")
631
+ elif not tdd_gate_uses_task_variables(gate):
632
+ reasons.append(
633
+ "changed-line coverage gate must use the task baseline and threshold variables"
634
+ )
635
+
636
+ contents: dict[str, list[str]] = {
637
+ "build_files": [],
638
+ "ci_files": [],
639
+ "tool_files": [],
640
+ }
641
+ for field in contents:
642
+ records = manifest.get(field)
643
+ if not isinstance(records, list) or not records:
644
+ reasons.append(f"{field} must contain at least one file")
645
+ continue
646
+ for record in records:
647
+ if not isinstance(record, dict):
648
+ reasons.append(f"{field} contains an invalid record")
649
+ continue
650
+ file_name = record.get("path")
651
+ expected = record.get("sha256")
652
+ if not is_non_empty_string(file_name) or not re.fullmatch(
653
+ r"[a-f0-9]{64}", str(expected or "")
654
+ ):
655
+ reasons.append(f"{field} contains an invalid path or SHA-256")
656
+ continue
657
+ candidate = Path(str(file_name))
658
+ if candidate.is_absolute():
659
+ reasons.append(f"readiness file must be project-relative: {file_name}")
660
+ continue
661
+ resolved = (root / candidate).resolve()
662
+ try:
663
+ resolved.relative_to(root.resolve())
664
+ payload = resolved.read_bytes()
665
+ contents[field].append(payload.decode("utf-8"))
666
+ if hashlib.sha256(payload).hexdigest() != expected:
667
+ reasons.append(f"readiness file changed: {file_name}")
668
+ except (OSError, UnicodeError, ValueError):
669
+ reasons.append(f"readiness file is missing or unreadable: {file_name}")
670
+
671
+ manifest_build_files = manifest.get("build_files")
672
+ manifest_ci_files = manifest.get("ci_files")
673
+ manifest_tool_files = manifest.get("tool_files")
674
+ build_paths = {
675
+ Path(str(item.get("path", ""))).name
676
+ for item in manifest_build_files
677
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
678
+ } if isinstance(manifest_build_files, list) else set()
679
+ ci_paths = {
680
+ str(item.get("path", "")).replace("\\", "/")
681
+ for item in manifest_ci_files
682
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
683
+ } if isinstance(manifest_ci_files, list) else set()
684
+ if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
685
+ reasons.append("build_files must include a Maven or Gradle Java build file")
686
+ if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
687
+ reasons.append("ci_files must include the project-root GitLab CI entry file")
688
+ tool_paths = {
689
+ str(item.get("path", "")).replace("\\", "/")
690
+ for item in manifest_tool_files
691
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
692
+ } if isinstance(manifest_tool_files, list) else set()
693
+ if COVERAGE_TOOL_PATH not in tool_paths:
694
+ reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
695
+ if not any("jacoco" in content.lower() for content in contents["build_files"]):
696
+ reasons.append("build files do not configure JaCoCo")
697
+ reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
698
+ return {
699
+ "status": "ready" if not reasons else "needs_init",
700
+ "reasons": list(dict.fromkeys(reasons)),
701
+ }
702
+
703
+
704
+ def require_tdd_readiness(root: Path) -> None:
705
+ readiness = tdd_readiness(root)
706
+ if readiness["status"] != "ready":
707
+ reasons = "; ".join(str(reason) for reason in readiness["reasons"])
708
+ raise StateError(f"TDD cannot be enabled before ec-tdd-init succeeds: {reasons}")
709
+
710
+
413
711
  def resolve_behavior(
414
712
  root: Path, session: dict
415
713
  ) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
@@ -616,6 +914,76 @@ def validate_recorded_short_memory(
616
914
  validate_short_memory_file(root, task_id, memory_file, expected_sha256)
617
915
 
618
916
 
917
+ def architecture_asset_baseline(root: Path, relative_path: Path) -> dict:
918
+ path = root / relative_path
919
+ if not path.exists():
920
+ return {
921
+ "path": str(relative_path),
922
+ "exists": False,
923
+ "non_empty": False,
924
+ "sha256": None,
925
+ }
926
+ if not path.is_file():
927
+ raise StateError(f"Architecture asset is not a file: {relative_path}")
928
+ try:
929
+ content = path.read_text(encoding="utf-8")
930
+ except (OSError, UnicodeError) as error:
931
+ raise StateError(f"Cannot read architecture asset as UTF-8: {relative_path}") from error
932
+ return {
933
+ "path": str(relative_path),
934
+ "exists": True,
935
+ "non_empty": bool(content.strip()),
936
+ "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
937
+ }
938
+
939
+
940
+ def read_project_mode(root: Path) -> str | None:
941
+ project_profile = root / ".easy-coding" / "project.yaml"
942
+ if not project_profile.is_file():
943
+ return None
944
+ try:
945
+ content = project_profile.read_text(encoding="utf-8")
946
+ except (OSError, UnicodeError) as error:
947
+ raise StateError("Cannot read .easy-coding/project.yaml as UTF-8.") from error
948
+ for raw_line in content.splitlines():
949
+ match = re.fullmatch(
950
+ r"\s*mode\s*:\s*(['\"]?)(startup|iterative)\1\s*(?:#.*)?", raw_line
951
+ )
952
+ if match:
953
+ return match.group(2)
954
+ return None
955
+
956
+
957
+ def build_architecture_assessment_instruction(root: Path, memory_action: str) -> dict:
958
+ abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
959
+ changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
960
+ if not abstract["non_empty"] and read_project_mode(root) == "startup":
961
+ required = True
962
+ trigger = "missing-abstract"
963
+ allowed_actions = ["backfill"]
964
+ elif not abstract["non_empty"]:
965
+ raise StateError(
966
+ "ABSTRACT.md is missing or empty outside the startup backfill exception; "
967
+ "run ec-init supplementary initialization before completing MEMORY."
968
+ )
969
+ elif memory_action == "distill":
970
+ required = True
971
+ trigger = "distillation"
972
+ allowed_actions = ["no-op", "update"]
973
+ else:
974
+ required = False
975
+ trigger = "none"
976
+ allowed_actions = []
977
+ instruction = {
978
+ "required": required,
979
+ "trigger": trigger,
980
+ "allowed_actions": allowed_actions,
981
+ "abstract": abstract,
982
+ "changelog": changelog,
983
+ }
984
+ return instruction
985
+
986
+
619
987
  def build_memory_instruction(
620
988
  root: Path,
621
989
  checkpoint_file: str | None = None,
@@ -636,7 +1004,7 @@ def build_memory_instruction(
636
1004
  checkpoint_disposition = "kept"
637
1005
  else:
638
1006
  raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
639
- return {
1007
+ instruction = {
640
1008
  "short_count": short_count,
641
1009
  "short_term_max": config["short_term_max"],
642
1010
  "short_term_keep": config["short_term_keep"],
@@ -646,6 +1014,216 @@ def build_memory_instruction(
646
1014
  "kept_files": kept_files,
647
1015
  "checkpoint_disposition": checkpoint_disposition,
648
1016
  }
1017
+ if not legacy_checkpoint:
1018
+ instruction["architecture_assessment"] = build_architecture_assessment_instruction(
1019
+ root, action
1020
+ )
1021
+ return instruction
1022
+
1023
+
1024
+ def require_architecture_instruction(instruction: dict) -> dict | None:
1025
+ assessment_instruction = instruction.get("architecture_assessment")
1026
+ if assessment_instruction is None:
1027
+ # 0.10.0-beta.5 之前已冻结的指令继续按旧契约完成,避免升级中断在途任务。
1028
+ return None
1029
+ if not isinstance(assessment_instruction, dict):
1030
+ raise StateError("Memory instruction has an invalid architecture assessment contract.")
1031
+ return assessment_instruction
1032
+
1033
+
1034
+ def validate_architecture_asset_changed(
1035
+ baseline: dict,
1036
+ current: dict,
1037
+ label: str,
1038
+ ) -> None:
1039
+ if not current.get("exists") or not current.get("non_empty") or not current.get("sha256"):
1040
+ raise StateError(f"Architecture {label} must exist and be non-empty after this action.")
1041
+ if baseline.get("sha256") == current.get("sha256"):
1042
+ raise StateError(f"Architecture {label} did not change after this action.")
1043
+
1044
+
1045
+ def validate_architecture_assets_unchanged(root: Path, instruction: dict) -> None:
1046
+ for key, relative_path in (
1047
+ ("abstract", ARCHITECTURE_ABSTRACT_PATH),
1048
+ ("changelog", ARCHITECTURE_CHANGELOG_PATH),
1049
+ ):
1050
+ baseline = instruction.get(key)
1051
+ if not isinstance(baseline, dict):
1052
+ raise StateError(f"Architecture assessment is missing the {key} baseline.")
1053
+ if architecture_asset_baseline(root, relative_path) != baseline:
1054
+ raise StateError(f"Architecture asset changed during a no-op assessment: {relative_path}")
1055
+
1056
+
1057
+ def validate_architecture_action_result(
1058
+ root: Path,
1059
+ instruction: dict,
1060
+ action: str,
1061
+ ) -> tuple[dict, dict]:
1062
+ abstract_before = instruction.get("abstract")
1063
+ changelog_before = instruction.get("changelog")
1064
+ if not isinstance(abstract_before, dict) or not isinstance(changelog_before, dict):
1065
+ raise StateError("Architecture assessment is missing frozen asset baselines.")
1066
+ abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
1067
+ changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
1068
+ if action == "no-op":
1069
+ validate_architecture_assets_unchanged(root, instruction)
1070
+ elif action == "backfill":
1071
+ if abstract_before.get("non_empty") is True:
1072
+ raise StateError(
1073
+ "Architecture backfill is allowed only when ABSTRACT.md was missing or empty."
1074
+ )
1075
+ validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
1076
+ validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
1077
+ elif action == "update":
1078
+ if abstract_before.get("non_empty") is not True:
1079
+ raise StateError("Architecture update requires an existing ABSTRACT.md baseline.")
1080
+ validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
1081
+ validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
1082
+ else:
1083
+ raise StateError(f"Unknown architecture assessment action: {action}")
1084
+ return abstract, changelog
1085
+
1086
+
1087
+ def allowed_architecture_evidence(progress: dict, instruction: dict) -> set[str]:
1088
+ allowed_evidence = set(instruction.get("candidate_files") or [])
1089
+ if not allowed_evidence:
1090
+ checkpoint_file = progress.get("short_memory_file")
1091
+ if isinstance(checkpoint_file, str):
1092
+ allowed_evidence.add(checkpoint_file)
1093
+ return allowed_evidence
1094
+
1095
+
1096
+ def record_architecture_assessment(
1097
+ root: Path,
1098
+ action: str,
1099
+ reason: str,
1100
+ evidence: list[str],
1101
+ affected_sections: list[str],
1102
+ agent: str,
1103
+ task_id: str | None = None,
1104
+ session_file: str | Path | None = None,
1105
+ ) -> dict:
1106
+ if action not in ARCHITECTURE_ACTIONS:
1107
+ raise StateError(f"Unknown architecture assessment action: {action}")
1108
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1109
+ if task.get("status") != "MEMORY":
1110
+ raise StateError("Architecture assessment is only available during MEMORY.")
1111
+ progress = task.get("memory_progress")
1112
+ if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
1113
+ raise StateError("Short memory must be recorded before architecture assessment.")
1114
+ instruction = progress.get("instruction")
1115
+ if not isinstance(instruction, dict):
1116
+ raise StateError("Request the authoritative memory instruction before architecture assessment.")
1117
+ validate_recorded_short_memory(root, resolved_task_id, progress)
1118
+ for candidate_file in instruction.get("candidate_files") or []:
1119
+ if not resolve_short_memory_path(root, candidate_file).is_file():
1120
+ raise StateError(
1121
+ "Keep every frozen distillation candidate until architecture assessment succeeds: "
1122
+ f"{candidate_file}"
1123
+ )
1124
+ assessment_instruction = require_architecture_instruction(instruction)
1125
+ if assessment_instruction is None:
1126
+ raise StateError("Legacy memory instructions do not require an architecture assessment.")
1127
+ if assessment_instruction.get("required") is not True:
1128
+ raise StateError("Architecture assessment is not required for this memory instruction.")
1129
+ allowed_actions = assessment_instruction.get("allowed_actions")
1130
+ if not isinstance(allowed_actions, list) or action not in allowed_actions:
1131
+ raise StateError(
1132
+ f"Architecture action {action} is not allowed for trigger "
1133
+ f"{assessment_instruction.get('trigger')}."
1134
+ )
1135
+ normalized_reason = reason.strip()
1136
+ normalized_evidence = list(dict.fromkeys(item.strip() for item in evidence if item.strip()))
1137
+ normalized_sections = list(
1138
+ dict.fromkeys(item.strip() for item in affected_sections if item.strip())
1139
+ )
1140
+ if not normalized_reason:
1141
+ raise StateError("Architecture assessment requires a non-empty reason.")
1142
+ if not normalized_evidence:
1143
+ raise StateError("Architecture assessment requires at least one frozen memory evidence file.")
1144
+ allowed_evidence = allowed_architecture_evidence(progress, instruction)
1145
+ invalid_evidence = [item for item in normalized_evidence if item not in allowed_evidence]
1146
+ if invalid_evidence:
1147
+ raise StateError(
1148
+ "Architecture assessment evidence must come from the frozen memory set: "
1149
+ + ", ".join(invalid_evidence)
1150
+ )
1151
+ if action in {"backfill", "update"} and not normalized_sections:
1152
+ raise StateError("Architecture backfill/update requires at least one affected section.")
1153
+ if action == "no-op" and normalized_sections:
1154
+ raise StateError("Architecture no-op must not declare affected sections.")
1155
+
1156
+ abstract, changelog = validate_architecture_action_result(
1157
+ root, assessment_instruction, action
1158
+ )
1159
+
1160
+ assessment = {
1161
+ "action": action,
1162
+ "trigger": assessment_instruction.get("trigger"),
1163
+ "reason": normalized_reason,
1164
+ "evidence": normalized_evidence,
1165
+ "affected_sections": normalized_sections,
1166
+ "abstract_sha256": abstract.get("sha256"),
1167
+ "changelog_sha256": changelog.get("sha256"),
1168
+ "recorded_at": now_iso(),
1169
+ "recorded_by": agent,
1170
+ }
1171
+ progress["architecture_assessment"] = assessment
1172
+ progress["updated_at"] = now_iso()
1173
+ task["memory_progress"] = progress
1174
+ task["last_agent"] = agent
1175
+ write_task(root, resolved_task_id, task)
1176
+ snapshot = snapshot_state(root, session_file, session)
1177
+ snapshot["memory"] = instruction
1178
+ snapshot["architecture_assessment"] = assessment
1179
+ snapshot["action"] = "memory-architecture-assessment"
1180
+ return snapshot
1181
+
1182
+
1183
+ def validate_recorded_architecture_assessment(root: Path, progress: dict, instruction: dict) -> None:
1184
+ assessment_instruction = require_architecture_instruction(instruction)
1185
+ if assessment_instruction is None:
1186
+ return
1187
+ if assessment_instruction.get("required") is not True:
1188
+ validate_architecture_assets_unchanged(root, assessment_instruction)
1189
+ if progress.get("architecture_assessment") is not None:
1190
+ raise StateError("Unexpected architecture assessment for a no-op memory instruction.")
1191
+ return
1192
+ assessment = progress.get("architecture_assessment")
1193
+ if not isinstance(assessment, dict):
1194
+ raise StateError("Complete the required architecture assessment before MEMORY completion.")
1195
+ action = assessment.get("action")
1196
+ allowed_actions = assessment_instruction.get("allowed_actions")
1197
+ if not isinstance(allowed_actions, list) or action not in allowed_actions:
1198
+ raise StateError("Recorded architecture assessment has an invalid action.")
1199
+ if assessment.get("trigger") != assessment_instruction.get("trigger"):
1200
+ raise StateError("Recorded architecture assessment trigger does not match its instruction.")
1201
+ reason = assessment.get("reason")
1202
+ if not isinstance(reason, str) or not reason.strip():
1203
+ raise StateError("Recorded architecture assessment is missing its reason.")
1204
+ evidence = assessment.get("evidence")
1205
+ if not isinstance(evidence, list) or not evidence or not all(
1206
+ isinstance(item, str) for item in evidence
1207
+ ):
1208
+ raise StateError("Recorded architecture assessment has invalid evidence.")
1209
+ if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
1210
+ raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
1211
+ affected_sections = assessment.get("affected_sections")
1212
+ if not isinstance(affected_sections, list) or not all(
1213
+ isinstance(item, str) and item.strip() for item in affected_sections
1214
+ ):
1215
+ raise StateError("Recorded architecture assessment has invalid affected sections.")
1216
+ if action == "no-op" and affected_sections:
1217
+ raise StateError("Recorded architecture no-op must not declare affected sections.")
1218
+ if action in {"backfill", "update"} and not affected_sections:
1219
+ raise StateError("Recorded architecture backfill/update requires affected sections.")
1220
+ abstract, changelog = validate_architecture_action_result(
1221
+ root, assessment_instruction, action
1222
+ )
1223
+ if assessment.get("abstract_sha256") != abstract.get("sha256"):
1224
+ raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
1225
+ if assessment.get("changelog_sha256") != changelog.get("sha256"):
1226
+ raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
649
1227
 
650
1228
 
651
1229
  def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
@@ -670,10 +1248,18 @@ def normalize_legacy_stage(stage: object) -> object:
670
1248
 
671
1249
 
672
1250
  def normalize_legacy_task(task: dict) -> bool:
673
- """Normalize pre-0.6 stage names without touching task artifacts outside task.json."""
1251
+ """Normalize legacy task state without touching artifacts outside task.json."""
674
1252
  legacy_status = str(task.get("status") or "")
675
1253
  changed = False
676
1254
 
1255
+ for field in ("created_by", "last_agent"):
1256
+ normalized_agent = canonical_agent_identity(
1257
+ task.get(field), allow_legacy_display=True
1258
+ )
1259
+ if normalized_agent is not None and normalized_agent != task.get(field):
1260
+ task[field] = normalized_agent
1261
+ changed = True
1262
+
677
1263
  if legacy_status in LEGACY_STAGE_MAP:
678
1264
  task["status"] = LEGACY_STAGE_MAP[legacy_status]
679
1265
  changed = True
@@ -689,6 +1275,12 @@ def normalize_legacy_task(task: dict) -> bool:
689
1275
  if mapped_stage != entry.get("stage"):
690
1276
  entry["stage"] = mapped_stage
691
1277
  changed = True
1278
+ normalized_agent = canonical_agent_identity(
1279
+ entry.get("agent"), allow_legacy_display=True
1280
+ )
1281
+ if normalized_agent is not None and normalized_agent != entry.get("agent"):
1282
+ entry["agent"] = normalized_agent
1283
+ changed = True
692
1284
  if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
693
1285
  changed = True
694
1286
  continue
@@ -727,7 +1319,28 @@ def normalize_legacy_task(task: dict) -> bool:
727
1319
 
728
1320
  def write_json(path: Path, data: dict) -> None:
729
1321
  path.parent.mkdir(parents=True, exist_ok=True)
730
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1322
+ descriptor, temporary_name = tempfile.mkstemp(
1323
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
1324
+ )
1325
+ temporary_path = Path(temporary_name)
1326
+ try:
1327
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
1328
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
1329
+ handle.flush()
1330
+ os.fsync(handle.fileno())
1331
+ os.replace(temporary_path, path)
1332
+ try:
1333
+ directory_descriptor = os.open(path.parent, os.O_RDONLY)
1334
+ try:
1335
+ os.fsync(directory_descriptor)
1336
+ finally:
1337
+ os.close(directory_descriptor)
1338
+ except OSError:
1339
+ # Some platforms do not allow opening directories; file replacement is still atomic.
1340
+ pass
1341
+ finally:
1342
+ if temporary_path.exists():
1343
+ temporary_path.unlink()
731
1344
 
732
1345
 
733
1346
  def acquire_legacy_state_lock(root: Path) -> Path | None:
@@ -782,7 +1395,12 @@ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
782
1395
  if "stage_history" not in task or not task["stage_history"]:
783
1396
  task["stage_history"] = old_state.get("stage_history", [])
784
1397
  if "last_agent" not in task or not task["last_agent"]:
785
- task["last_agent"] = old_state.get("last_agent", agent)
1398
+ task["last_agent"] = (
1399
+ canonical_agent_identity(
1400
+ old_state.get("last_agent"), allow_legacy_display=True
1401
+ )
1402
+ or agent
1403
+ )
786
1404
  if old_state.get("confirmed_by_user"):
787
1405
  task["confirmed_by_user"] = True
788
1406
  if old_state.get("test_strategy_confirmed"):
@@ -858,7 +1476,8 @@ def clear_session_pointer(session: dict, agent: str | None = None) -> None:
858
1476
 
859
1477
 
860
1478
  def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
861
- return load_json(resolve_session_path(root, session_file))
1479
+ session = load_json(resolve_session_path(root, session_file))
1480
+ return session if isinstance(session, dict) else None
862
1481
 
863
1482
 
864
1483
  def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
@@ -921,7 +1540,7 @@ def ensure_hook_session(
921
1540
  )
922
1541
 
923
1542
  if session is None:
924
- clean_stale_sessions(root)
1543
+ clean_session_runtime(root, reserve_slots=1)
925
1544
  session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
926
1545
  if session is None:
927
1546
  session = load_session(root, session_path)
@@ -944,36 +1563,124 @@ def ensure_hook_session(
944
1563
 
945
1564
  def clean_stale_sessions(
946
1565
  root: Path,
947
- threshold_hours: int = SESSION_STALE_THRESHOLD_HOURS,
1566
+ threshold_hours: int | None = None,
1567
+ idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
1568
+ attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
1569
+ max_sessions: int = MAX_SESSION_FILES,
1570
+ reserve_slots: int = 0,
948
1571
  ) -> int:
949
1572
  sessions_dir = root / ".easy-coding" / "sessions"
950
1573
  if not sessions_dir.is_dir():
951
1574
  return 0
952
1575
 
953
1576
  now = datetime.now(timezone.utc)
954
- cleaned = 0
955
- # 逻辑会话不对应独立进程,仅清理长期空闲且没有当前任务的 session。
1577
+ if threshold_hours is not None:
1578
+ idle_threshold_hours = threshold_hours
1579
+ attached_threshold_hours = threshold_hours
1580
+ candidates: list[tuple[Path, str, dict, datetime]] = []
956
1581
  for entry in sessions_dir.iterdir():
957
- if entry.suffix != ".json":
1582
+ if not entry.is_file() or entry.suffix != ".json":
958
1583
  continue
959
1584
  try:
960
- session = json.loads(entry.read_text(encoding="utf-8"))
961
- if session.get("current_task"):
1585
+ content = entry.read_text(encoding="utf-8")
1586
+ try:
1587
+ session = json.loads(content)
1588
+ except json.JSONDecodeError:
1589
+ session = {}
1590
+ if not isinstance(session, dict):
1591
+ session = {}
1592
+ activity_value = session.get("last_active_at") or session.get("created_at")
1593
+ try:
1594
+ if not isinstance(activity_value, str):
1595
+ raise ValueError
1596
+ last_active = datetime.fromisoformat(activity_value)
1597
+ if last_active.tzinfo is None:
1598
+ last_active = last_active.replace(tzinfo=timezone.utc)
1599
+ except (ValueError, TypeError):
1600
+ last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
1601
+ candidates.append((entry, content, session, last_active))
1602
+ except OSError:
1603
+ continue
1604
+
1605
+ removed: set[Path] = set()
1606
+ for entry, content, session, last_active in candidates:
1607
+ retention_hours = (
1608
+ attached_threshold_hours if session.get("current_task") else idle_threshold_hours
1609
+ )
1610
+ age_hours = (now - last_active).total_seconds() / 3600
1611
+ if age_hours <= retention_hours:
1612
+ continue
1613
+ if unlink_session_if_unchanged(entry, content):
1614
+ removed.add(entry)
1615
+
1616
+ allowed_existing = max(0, max_sessions - reserve_slots)
1617
+ remaining = sorted(
1618
+ (candidate for candidate in candidates if candidate[0] not in removed),
1619
+ key=lambda candidate: candidate[3],
1620
+ )
1621
+ overflow = max(0, len(remaining) - allowed_existing)
1622
+ for entry, content, _session, _last_active in remaining[:overflow]:
1623
+ if unlink_session_if_unchanged(entry, content):
1624
+ removed.add(entry)
1625
+ return len(removed)
1626
+
1627
+
1628
+ def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
1629
+ try:
1630
+ if entry.read_text(encoding="utf-8") != expected_content:
1631
+ return False
1632
+ entry.unlink()
1633
+ return True
1634
+ except OSError:
1635
+ # GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
1636
+ return False
1637
+
1638
+
1639
+ def clean_orphan_acceptance_snapshots(root: Path) -> int:
1640
+ acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
1641
+ if not acceptance_dir.is_dir():
1642
+ return 0
1643
+
1644
+ cleaned = 0
1645
+ for entry in acceptance_dir.iterdir():
1646
+ if not entry.is_file() or entry.suffix != ".json":
1647
+ continue
1648
+ task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
1649
+ if task_path.is_file():
1650
+ try:
1651
+ task = json.loads(task_path.read_text(encoding="utf-8"))
1652
+ except (OSError, json.JSONDecodeError):
962
1653
  continue
963
- activity_value = session.get("last_active_at") or session.get("created_at") or ""
964
- last_active = datetime.fromisoformat(str(activity_value))
965
- if last_active.tzinfo is None:
966
- last_active = last_active.replace(tzinfo=timezone.utc)
967
- age_hours = (now - last_active).total_seconds() / 3600
968
- if age_hours <= threshold_hours:
1654
+ if not isinstance(task, dict):
969
1655
  continue
1656
+ else:
1657
+ task = None
1658
+
1659
+ checkpoint = task.get("verification_checkpoint") if task is not None else None
1660
+ snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
1661
+ referenced = bool(
1662
+ isinstance(snapshot_file, str)
1663
+ and (root / snapshot_file).resolve() == entry.resolve()
1664
+ )
1665
+ terminal = task is not None and task.get("status") in TERMINAL_STATUSES
1666
+ if task is not None and referenced and not terminal:
1667
+ continue
1668
+ try:
970
1669
  entry.unlink()
971
1670
  cleaned += 1
972
- except (OSError, json.JSONDecodeError, ValueError, TypeError):
1671
+ except OSError:
1672
+ # 验收快照清理失败不能阻断新逻辑会话启动。
973
1673
  continue
974
1674
  return cleaned
975
1675
 
976
1676
 
1677
+ def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
1678
+ return {
1679
+ "sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
1680
+ "acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
1681
+ }
1682
+
1683
+
977
1684
  def task_json_path(root: Path, task_id: str) -> Path:
978
1685
  assert_safe_task_id(task_id)
979
1686
  return root / ".easy-coding" / "tasks" / task_id / "task.json"
@@ -999,6 +1706,8 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
999
1706
  path.parent.mkdir(parents=True, exist_ok=True)
1000
1707
  with path.open("a", encoding="utf-8") as handle:
1001
1708
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
1709
+ handle.flush()
1710
+ os.fsync(handle.fileno())
1002
1711
 
1003
1712
 
1004
1713
  def is_non_empty_string(value: object) -> bool:
@@ -1071,7 +1780,7 @@ def is_valid_execution_plan(
1071
1780
  has_empty_file_scope = True
1072
1781
  if not is_string_list(unit.get("depends_on")):
1073
1782
  return False
1074
- for optional_list in ("rules_sections", "abstract_modules"):
1783
+ for optional_list in ("rules_sections", "abstract_modules", "local_baseline"):
1075
1784
  if optional_list in unit and not is_string_list(unit.get(optional_list)):
1076
1785
  return False
1077
1786
  if require_unit_contracts:
@@ -1145,25 +1854,58 @@ def stored_spec_path(root: Path, task: dict) -> Path:
1145
1854
  source = task.get("spec_source")
1146
1855
  if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
1147
1856
  raise StateError("Spec-backed task is missing spec_source.path.")
1148
- raw_path = Path(str(source["path"]))
1149
- path = raw_path if raw_path.is_absolute() else root / raw_path
1150
- resolved = path.resolve()
1151
- try:
1152
- resolved.relative_to(root.resolve())
1153
- except ValueError as exc:
1154
- raise StateError("Spec-backed task source path must remain inside the project root.") from exc
1857
+ path_mode = source.get("path_mode")
1858
+ raw_path = Path(str(source["path"])).expanduser()
1859
+ if path_mode is None:
1860
+ path_mode = "absolute" if raw_path.is_absolute() else "project-relative"
1861
+ if path_mode not in {"project-relative", "absolute"}:
1862
+ raise StateError("Spec-backed task has an invalid spec_source.path_mode.")
1863
+ if path_mode == "absolute" and not raw_path.is_absolute():
1864
+ raise StateError("Absolute Spec binding must store an absolute path.")
1865
+ if path_mode == "project-relative" and raw_path.is_absolute():
1866
+ raise StateError("Project-relative Spec binding must not store an absolute path.")
1867
+ resolved = (raw_path if path_mode == "absolute" else root / raw_path).resolve()
1868
+ if path_mode == "project-relative":
1869
+ try:
1870
+ resolved.relative_to(root.resolve())
1871
+ except ValueError as exc:
1872
+ raise StateError("Project-relative Spec source escapes the project root.") from exc
1873
+ if not resolved.is_file():
1874
+ raise StateError(
1875
+ "Canonical Spec source is unavailable; run rebind-spec-source with an explicit path."
1876
+ )
1155
1877
  return resolved
1156
1878
 
1157
1879
 
1880
+ def legacy_source_digest_matches(
1881
+ spec_path: Path, legacy_sha256: object, current_source_sha256: object
1882
+ ) -> bool:
1883
+ if not is_non_empty_string(legacy_sha256):
1884
+ return False
1885
+ if legacy_sha256 == current_source_sha256:
1886
+ return True
1887
+ try:
1888
+ design_text, execution = split_execution_region(
1889
+ spec_path.read_text(encoding="utf-8")
1890
+ )
1891
+ except (OSError, UnicodeError, ValueError):
1892
+ return False
1893
+ if execution is None:
1894
+ return False
1895
+ design_document_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
1896
+ return legacy_sha256 == design_document_sha256
1897
+
1898
+
1158
1899
  def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1159
1900
  source = task.get("spec_source")
1160
1901
  selected = task.get("selected_spec_tasks")
1161
1902
  repo_paths = task.get("repo_paths")
1162
1903
  if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
1163
1904
  raise StateError("Spec-backed task source and selected task metadata are incomplete.")
1905
+ spec_path = stored_spec_path(root, task)
1164
1906
  try:
1165
1907
  inspection = inspect_spec(
1166
- stored_spec_path(root, task),
1908
+ spec_path,
1167
1909
  root,
1168
1910
  repo_paths if isinstance(repo_paths, dict) else {},
1169
1911
  selected,
@@ -1179,6 +1921,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1179
1921
  selection = select_tasks(inspection, selected, satisfied)
1180
1922
  except EasyDevSpecError as exc:
1181
1923
  raise StateError(f"Canonical Spec validation failed: {exc}") from exc
1924
+ if not isinstance(inspection.get("execution"), dict):
1925
+ raise StateError(
1926
+ "Canonical Spec shared execution is not initialized; run initialize-spec-execution."
1927
+ )
1182
1928
  stored_dependencies = task.get("spec_dependency_evidence")
1183
1929
  if not isinstance(stored_dependencies, list):
1184
1930
  raise StateError("Spec-backed task dependency metadata is incomplete.")
@@ -1191,30 +1937,52 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1191
1937
  for record in stored_dependencies
1192
1938
  if isinstance(record, dict)
1193
1939
  }
1194
- if (
1195
- len(stored_by_edge) != len(stored_dependencies)
1196
- or set(stored_by_edge) != set(expected_by_edge)
1197
- ):
1940
+ if len(stored_by_edge) != len(stored_dependencies) or set(stored_by_edge) != set(expected_by_edge):
1198
1941
  raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1942
+ refreshed_dependencies: list[dict] = []
1199
1943
  for edge, expected in expected_by_edge.items():
1200
1944
  stored = stored_by_edge[edge]
1201
- for field in ("dependency_type", "required_evidence", "status"):
1945
+ for field in ("dependency_type", "required_evidence"):
1202
1946
  if stored.get(field) != expected.get(field):
1203
- raise StateError(
1204
- "Canonical Spec dependency metadata no longer matches source selection."
1205
- )
1206
- if stored.get("evidence") != expected.get("evidence"):
1207
- raise StateError(
1208
- "Canonical Spec dependency evidence no longer matches its recorded status."
1209
- )
1947
+ raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1948
+ refreshed = dict(stored)
1949
+ for field in (
1950
+ "status",
1951
+ "shared_status",
1952
+ "dependency_task_status",
1953
+ "basis",
1954
+ ):
1955
+ if expected.get(field) is None:
1956
+ refreshed.pop(field, None)
1957
+ else:
1958
+ refreshed[field] = expected.get(field)
1959
+ if expected.get("evidence"):
1960
+ refreshed["evidence"] = expected.get("evidence")
1961
+ refreshed_dependencies.append(refreshed)
1210
1962
  if source.get("schema") != inspection.get("schema"):
1211
1963
  raise StateError("Canonical Spec schema no longer matches task.json.")
1212
1964
  if source.get("spec_id") != inspection.get("spec_id"):
1213
1965
  raise StateError("Canonical Spec ID no longer matches task.json.")
1214
1966
  if source.get("revision") != inspection.get("revision"):
1215
- raise StateError("Canonical Spec revision no longer matches task.json.")
1216
- if source.get("sha256") != inspection.get("source_sha256"):
1217
- raise StateError("Canonical Spec SHA-256 changed after task creation.")
1967
+ raise StateError("Canonical Spec design revision changed; return the task to ANALYSIS.")
1968
+ stored_design_sha256 = source.get("design_sha256")
1969
+ if stored_design_sha256 is None:
1970
+ if not legacy_source_digest_matches(
1971
+ spec_path, source.get("sha256"), inspection.get("source_sha256")
1972
+ ):
1973
+ raise StateError(
1974
+ "Legacy Canonical Spec digest changed before migration; rebind or recreate the task."
1975
+ )
1976
+ stored_design_sha256 = inspection.get("design_sha256")
1977
+ if stored_design_sha256 != inspection.get("design_sha256"):
1978
+ raise StateError("Canonical Spec static design changed; return the task to ANALYSIS.")
1979
+ stored_execution_revision = source.get("execution_revision")
1980
+ current_execution_revision = inspection.get("execution_revision")
1981
+ if isinstance(stored_execution_revision, int) and isinstance(current_execution_revision, int):
1982
+ if current_execution_revision < stored_execution_revision:
1983
+ raise StateError(
1984
+ "Canonical Spec execution revision moved backwards; restore the latest shared Spec."
1985
+ )
1218
1986
  selected_repo_ids = set(selection["selected_repo_ids"])
1219
1987
  stored_bindings = task.get("spec_repositories")
1220
1988
  if not isinstance(stored_bindings, list):
@@ -1242,6 +2010,18 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1242
2010
  for field in ("repo_id", "name", "path", "baseline_commit"):
1243
2011
  if stored.get(field) != current.get(field):
1244
2012
  raise StateError("Canonical Spec repository bindings no longer match task.json.")
2013
+ source.update(
2014
+ {
2015
+ "path_mode": source.get("path_mode")
2016
+ or ("absolute" if Path(str(source.get("path"))).is_absolute() else "project-relative"),
2017
+ "design_sha256": inspection.get("design_sha256"),
2018
+ "document_sha256": inspection.get("document_sha256"),
2019
+ "execution_revision": inspection.get("execution_revision"),
2020
+ }
2021
+ )
2022
+ source.pop("sha256", None)
2023
+ task["spec_source"] = source
2024
+ task["spec_dependency_evidence"] = refreshed_dependencies
1245
2025
  return inspection, selection
1246
2026
 
1247
2027
 
@@ -1500,6 +2280,8 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
1500
2280
  return False
1501
2281
  if isinstance(record, dict) and record.get("type") == "plan":
1502
2282
  latest_plan = record
2283
+ elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
2284
+ latest_plan = None
1503
2285
  except OSError:
1504
2286
  return False
1505
2287
  task = load_task(root, task_id)
@@ -1539,6 +2321,8 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1539
2321
  for record in execution_records(root, task_id):
1540
2322
  if record.get("type") == "plan":
1541
2323
  latest = record
2324
+ elif record.get("type") == "spec-design-sync":
2325
+ latest = None
1542
2326
  if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
1543
2327
  return None
1544
2328
  return latest
@@ -1686,25 +2470,61 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
1686
2470
  ]
1687
2471
 
1688
2472
 
1689
- def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
1690
- if isinstance(task.get("spec_source"), dict):
1691
- repo_paths = task.get("repo_paths")
1692
- if not isinstance(repo_paths, dict):
1693
- raise StateError("TDD Canonical task is missing repository bindings.")
1694
- repositories: dict[str, Path] = {}
1695
- for unit in plan.get("units", []):
1696
- if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
1697
- raise StateError("TDD Canonical unit is missing repo_id.")
1698
- repo_id = str(unit["repo_id"])
1699
- raw_path = repo_paths.get(repo_id)
1700
- if not is_non_empty_string(raw_path):
1701
- raise StateError(f"TDD repository path is missing: {repo_id}")
1702
- candidate = Path(str(raw_path))
1703
- resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
1704
- repository = git_repository_root(resolved)
1705
- if repository is None or repository.resolve() != resolved:
1706
- raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
1707
- repositories[repo_id] = repository
2473
+ def workflow_plan_repository_roots(root: Path, task: dict, plan: dict) -> list[Path]:
2474
+ """Resolve only repositories that own files in the current execution plan."""
2475
+ repositories: set[Path] = set()
2476
+ repo_paths = task.get("repo_paths")
2477
+ canonical = isinstance(task.get("spec_source"), dict)
2478
+
2479
+ for unit in plan.get("units", []):
2480
+ if not isinstance(unit, dict):
2481
+ continue
2482
+ if canonical:
2483
+ repo_id = unit.get("repo_id")
2484
+ if not is_non_empty_string(repo_id) or not isinstance(repo_paths, dict):
2485
+ raise StateError("Canonical workflow Unit is missing its repository binding.")
2486
+ raw_repo_path = repo_paths.get(str(repo_id))
2487
+ if not is_non_empty_string(raw_repo_path):
2488
+ raise StateError(f"Canonical workflow repository path is missing: {repo_id}")
2489
+ candidate = Path(str(raw_repo_path))
2490
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
2491
+ repository = git_repository_root(resolved)
2492
+ if repository is None or repository.resolve() != resolved:
2493
+ raise StateError(f"Canonical workflow repository binding is not a Git root: {repo_id}")
2494
+ repositories.add(repository.resolve())
2495
+ continue
2496
+
2497
+ for file_name in unit.get("files", []):
2498
+ if not is_non_empty_string(file_name):
2499
+ continue
2500
+ candidate = Path(str(file_name))
2501
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
2502
+ repository = git_repository_root(resolved)
2503
+ if repository is not None:
2504
+ repositories.add(repository.resolve())
2505
+
2506
+ return sorted(repositories, key=lambda item: item.as_posix())
2507
+
2508
+
2509
+ def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
2510
+ if isinstance(task.get("spec_source"), dict):
2511
+ repo_paths = task.get("repo_paths")
2512
+ if not isinstance(repo_paths, dict):
2513
+ raise StateError("TDD Canonical task is missing repository bindings.")
2514
+ repositories: dict[str, Path] = {}
2515
+ for unit in plan.get("units", []):
2516
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
2517
+ raise StateError("TDD Canonical unit is missing repo_id.")
2518
+ repo_id = str(unit["repo_id"])
2519
+ raw_path = repo_paths.get(repo_id)
2520
+ if not is_non_empty_string(raw_path):
2521
+ raise StateError(f"TDD repository path is missing: {repo_id}")
2522
+ candidate = Path(str(raw_path))
2523
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
2524
+ repository = git_repository_root(resolved)
2525
+ if repository is None or repository.resolve() != resolved:
2526
+ raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
2527
+ repositories[repo_id] = repository
1708
2528
  return repositories
1709
2529
 
1710
2530
  repositories = task_repository_roots(root, task, plan)
@@ -1994,10 +2814,16 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1994
2814
  digest.update(b"\0")
1995
2815
  if task and isinstance(task.get("spec_source"), dict):
1996
2816
  digest.update(b"canonical-spec\0")
2817
+ source = task.get("spec_source") or {}
1997
2818
  digest.update(
1998
2819
  json.dumps(
1999
2820
  {
2000
- "source": task.get("spec_source"),
2821
+ "source": {
2822
+ "schema": source.get("schema"),
2823
+ "spec_id": source.get("spec_id"),
2824
+ "revision": source.get("revision"),
2825
+ "design_sha256": source.get("design_sha256"),
2826
+ },
2001
2827
  "selected_tasks": task.get("selected_spec_tasks"),
2002
2828
  },
2003
2829
  ensure_ascii=False,
@@ -2102,6 +2928,665 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2102
2928
  }
2103
2929
 
2104
2930
 
2931
+ def acceptance_snapshot_path(root: Path, task_id: str) -> Path:
2932
+ assert_safe_task_id(task_id)
2933
+ return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
2934
+
2935
+
2936
+ def canonical_json_sha256(value: object) -> str:
2937
+ payload = json.dumps(
2938
+ value,
2939
+ ensure_ascii=False,
2940
+ sort_keys=True,
2941
+ separators=(",", ":"),
2942
+ ).encode("utf-8")
2943
+ return hashlib.sha256(payload).hexdigest()
2944
+
2945
+
2946
+ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
2947
+ plan = latest_execution_plan(root, task_id)
2948
+ if plan is None:
2949
+ raise StateError("Cannot fingerprint verification contract without a valid plan.")
2950
+ source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
2951
+ contract = {
2952
+ "workflow_mode": task.get("workflow_mode"),
2953
+ "tdd_enabled": task.get("tdd_enabled"),
2954
+ "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
2955
+ "tdd_baselines": task.get("tdd_baselines"),
2956
+ "plan": plan,
2957
+ "canonical": {
2958
+ "schema": source.get("schema"),
2959
+ "spec_id": source.get("spec_id"),
2960
+ "revision": source.get("revision"),
2961
+ "design_sha256": source.get("design_sha256"),
2962
+ "selected_tasks": task.get("selected_spec_tasks"),
2963
+ "repository_bindings": task.get("spec_repositories"),
2964
+ "repo_paths": task.get("repo_paths"),
2965
+ }
2966
+ if source
2967
+ else None,
2968
+ }
2969
+ return canonical_json_sha256(contract)
2970
+
2971
+
2972
+ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
2973
+ pathspecs = repository_scope_pathspecs(repository, scopes)
2974
+ index_entries = git_index_entries(repository, pathspecs)
2975
+ listed = run_git(
2976
+ repository,
2977
+ "ls-files",
2978
+ "--cached",
2979
+ "--others",
2980
+ "--exclude-standard",
2981
+ "-z",
2982
+ "--",
2983
+ *pathspecs,
2984
+ )
2985
+ modified = run_git(
2986
+ repository,
2987
+ "diff-files",
2988
+ "--name-only",
2989
+ "-z",
2990
+ "--ignore-submodules=none",
2991
+ "--",
2992
+ *pathspecs,
2993
+ )
2994
+ if listed is None or listed.returncode != 0 or modified is None or modified.returncode != 0:
2995
+ raise StateError(f"Cannot capture verification snapshot for {repository}.")
2996
+ modified_paths = set(filter(None, modified.stdout.split(b"\0")))
2997
+ raw_paths = set(filter(None, listed.stdout.split(b"\0"))) | set(index_entries)
2998
+ entries: list[dict] = []
2999
+ for raw_path in sorted(raw_paths):
3000
+ relative_name = os.fsdecode(raw_path)
3001
+ if is_easy_coding_state_path(repository, relative_name, scopes):
3002
+ continue
3003
+ candidate = repository / relative_name
3004
+ index_entry = index_entries.get(raw_path)
3005
+ if index_entry is not None and index_entry[0] == b"160000":
3006
+ entries.append(
3007
+ {
3008
+ "path": relative_name,
3009
+ "exists": True,
3010
+ "mode": "160000",
3011
+ "git_oid": index_entry[1].decode("ascii", errors="replace"),
3012
+ "sha256": hashlib.sha256(index_entry[1]).hexdigest(),
3013
+ }
3014
+ )
3015
+ continue
3016
+ exists = candidate.exists() or candidate.is_symlink()
3017
+ if not exists:
3018
+ entries.append(
3019
+ {
3020
+ "path": relative_name,
3021
+ "exists": False,
3022
+ "mode": None,
3023
+ "sha256": None,
3024
+ }
3025
+ )
3026
+ continue
3027
+ try:
3028
+ content = (
3029
+ os.fsencode(os.readlink(candidate))
3030
+ if candidate.is_symlink()
3031
+ else candidate.read_bytes()
3032
+ )
3033
+ except OSError as exc:
3034
+ raise StateError(f"Cannot read verification snapshot file: {relative_name}") from exc
3035
+ mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
3036
+ entry = {
3037
+ "path": relative_name,
3038
+ "exists": True,
3039
+ "mode": mode,
3040
+ "sha256": hashlib.sha256(content).hexdigest(),
3041
+ }
3042
+ if index_entry is not None and raw_path not in modified_paths:
3043
+ entry["git_oid"] = index_entry[1].decode("ascii", errors="replace")
3044
+ else:
3045
+ # 仅无法从 Git object 还原的工作区内容进入被忽略的临时快照。
3046
+ entry["content_b64"] = base64.b64encode(content).decode("ascii")
3047
+ entries.append(entry)
3048
+ return entries
3049
+
3050
+
3051
+ def acceptance_filesystem_repositories(
3052
+ root: Path,
3053
+ plan: dict,
3054
+ git_scopes: list[tuple[Path, list[Path]]],
3055
+ ) -> list[dict]:
3056
+ files_by_root: dict[Path, set[Path]] = {}
3057
+ for unit in plan.get("units", []):
3058
+ if not isinstance(unit, dict):
3059
+ continue
3060
+ for file_name in unit.get("files", []):
3061
+ if not is_non_empty_string(file_name):
3062
+ continue
3063
+ raw_path = Path(str(file_name))
3064
+ base = root.resolve()
3065
+ candidate = raw_path if raw_path.is_absolute() else base / raw_path
3066
+ resolved = candidate.resolve()
3067
+ if not raw_path.is_absolute() and not is_path_within(resolved, base):
3068
+ raise StateError(f"Execution plan file escapes project: {file_name}")
3069
+ if any(
3070
+ is_path_within(resolved, scope)
3071
+ for _repository, scopes in git_scopes
3072
+ for scope in scopes
3073
+ ):
3074
+ continue
3075
+ snapshot_root = resolved.parent if raw_path.is_absolute() else base
3076
+ files_by_root.setdefault(snapshot_root, set()).add(resolved)
3077
+
3078
+ repositories: list[dict] = []
3079
+ for snapshot_root, files in sorted(
3080
+ files_by_root.items(), key=lambda item: item[0].as_posix()
3081
+ ):
3082
+ entries = []
3083
+ for candidate in sorted(files, key=lambda item: item.as_posix()):
3084
+ relative_name = candidate.relative_to(snapshot_root).as_posix()
3085
+ exists = candidate.exists() or candidate.is_symlink()
3086
+ if not exists:
3087
+ entries.append(
3088
+ {
3089
+ "path": relative_name,
3090
+ "exists": False,
3091
+ "mode": None,
3092
+ "sha256": None,
3093
+ }
3094
+ )
3095
+ continue
3096
+ try:
3097
+ content = (
3098
+ os.fsencode(os.readlink(candidate))
3099
+ if candidate.is_symlink()
3100
+ else candidate.read_bytes()
3101
+ )
3102
+ except OSError as exc:
3103
+ raise StateError(
3104
+ f"Cannot read verification snapshot file: {candidate}"
3105
+ ) from exc
3106
+ entries.append(
3107
+ {
3108
+ "path": relative_name,
3109
+ "exists": True,
3110
+ "mode": worktree_git_mode(candidate).decode("ascii", errors="replace"),
3111
+ "sha256": hashlib.sha256(content).hexdigest(),
3112
+ "content_b64": base64.b64encode(content).decode("ascii"),
3113
+ }
3114
+ )
3115
+ repositories.append(
3116
+ {
3117
+ "root": str(snapshot_root),
3118
+ "display": display_path(root, snapshot_root),
3119
+ "scopes": [],
3120
+ "entries": entries,
3121
+ }
3122
+ )
3123
+ return repositories
3124
+
3125
+
3126
+ def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
3127
+ plan = latest_execution_plan(root, task_id)
3128
+ if plan is None:
3129
+ raise StateError("Cannot capture verification snapshot without a valid plan.")
3130
+ fingerprints = evidence_fingerprints(root, task_id)
3131
+ repository_scopes = task_repository_scopes(root, task, plan)
3132
+ repositories = []
3133
+ for repository, scopes in repository_scopes:
3134
+ repositories.append(
3135
+ {
3136
+ "root": str(repository.resolve()),
3137
+ "display": display_path(root, repository.resolve()),
3138
+ "scopes": [
3139
+ scope.relative_to(repository.resolve()).as_posix() for scope in scopes
3140
+ ],
3141
+ "entries": acceptance_repository_entries(repository.resolve(), scopes),
3142
+ }
3143
+ )
3144
+ repositories.extend(acceptance_filesystem_repositories(root, plan, repository_scopes))
3145
+ return {
3146
+ "schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
3147
+ **fingerprints,
3148
+ "contract_fingerprint": verification_contract_fingerprint(root, task_id, task),
3149
+ "repositories": repositories,
3150
+ }
3151
+
3152
+
3153
+ def load_acceptance_snapshot(root: Path, task: dict) -> dict:
3154
+ checkpoint = task.get("verification_checkpoint")
3155
+ if not isinstance(checkpoint, dict):
3156
+ raise StateError("VERIFICATION has no frozen acceptance checkpoint.")
3157
+ raw_path = checkpoint.get("snapshot_file")
3158
+ if not is_non_empty_string(raw_path):
3159
+ raise StateError("Verification checkpoint has no snapshot file.")
3160
+ candidate = (root / str(raw_path)).resolve()
3161
+ sessions_root = (root / ".easy-coding" / "sessions").resolve()
3162
+ if not is_path_within(candidate, sessions_root):
3163
+ raise StateError("Verification checkpoint snapshot escapes .easy-coding/sessions.")
3164
+ snapshot = load_json(candidate)
3165
+ if not isinstance(snapshot, dict) or snapshot.get("schema") != ACCEPTANCE_SNAPSHOT_SCHEMA:
3166
+ raise StateError("Verification checkpoint snapshot is missing or invalid.")
3167
+ if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
3168
+ raise StateError("Verification checkpoint snapshot fingerprint changed.")
3169
+ if (
3170
+ snapshot.get("implementation_fingerprint")
3171
+ != checkpoint.get("implementation_fingerprint")
3172
+ or snapshot.get("config_fingerprint") != checkpoint.get("config_fingerprint")
3173
+ or snapshot.get("contract_fingerprint") != checkpoint.get("contract_fingerprint")
3174
+ ):
3175
+ raise StateError("Verification checkpoint metadata does not match its snapshot.")
3176
+ return snapshot
3177
+
3178
+
3179
+ def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None:
3180
+ if not isinstance(entry, dict) or entry.get("exists") is not True:
3181
+ return None
3182
+ encoded = entry.get("content_b64")
3183
+ if isinstance(encoded, str):
3184
+ try:
3185
+ return base64.b64decode(encoded, validate=True)
3186
+ except ValueError as exc:
3187
+ raise StateError("Verification checkpoint contains invalid file content.") from exc
3188
+ object_id = entry.get("git_oid")
3189
+ if not is_non_empty_string(object_id):
3190
+ return None
3191
+ if entry.get("mode") == "160000":
3192
+ return str(object_id).encode("ascii", errors="replace")
3193
+ result = run_git(repository, "cat-file", "blob", str(object_id))
3194
+ if result is None or result.returncode != 0:
3195
+ raise StateError(f"Cannot restore verification checkpoint Git object: {object_id}")
3196
+ return result.stdout
3197
+
3198
+
3199
+ def acceptance_snapshot_entries(snapshot: dict) -> dict[tuple[str, str], tuple[Path, dict]]:
3200
+ entries: dict[tuple[str, str], tuple[Path, dict]] = {}
3201
+ for repository in snapshot.get("repositories", []):
3202
+ if not isinstance(repository, dict) or not is_non_empty_string(repository.get("root")):
3203
+ continue
3204
+ repository_root = Path(str(repository["root"]))
3205
+ for entry in repository.get("entries", []):
3206
+ if isinstance(entry, dict) and is_non_empty_string(entry.get("path")):
3207
+ entries[(str(repository_root), str(entry["path"]))] = (repository_root, entry)
3208
+ return entries
3209
+
3210
+
3211
+ def acceptance_change_patch(
3212
+ path_name: str,
3213
+ previous: bytes | None,
3214
+ current: bytes | None,
3215
+ ) -> tuple[bool, str]:
3216
+ if (previous is not None and b"\0" in previous) or (current is not None and b"\0" in current):
3217
+ return True, ""
3218
+ try:
3219
+ previous_text = previous.decode("utf-8") if previous is not None else ""
3220
+ current_text = current.decode("utf-8") if current is not None else ""
3221
+ except UnicodeDecodeError:
3222
+ return True, ""
3223
+ patch = "".join(
3224
+ difflib.unified_diff(
3225
+ previous_text.splitlines(keepends=True),
3226
+ current_text.splitlines(keepends=True),
3227
+ fromfile=f"a/{path_name}" if previous is not None else "/dev/null",
3228
+ tofile=f"b/{path_name}" if current is not None else "/dev/null",
3229
+ )
3230
+ )
3231
+ return False, patch
3232
+
3233
+
3234
+ def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
3235
+ checkpoint = task.get("verification_checkpoint")
3236
+ baseline = load_acceptance_snapshot(root, task)
3237
+ current = build_acceptance_snapshot(root, task_id, task)
3238
+ baseline_entries = acceptance_snapshot_entries(baseline)
3239
+ current_entries = acceptance_snapshot_entries(current)
3240
+ changes: list[dict] = []
3241
+ digest_changes: list[dict] = []
3242
+ nested_repository_changed = False
3243
+ for key in sorted(set(baseline_entries) | set(current_entries)):
3244
+ previous_repository, previous_entry = baseline_entries.get(key, (Path(key[0]), None))
3245
+ current_repository, current_entry = current_entries.get(key, (Path(key[0]), None))
3246
+ if (
3247
+ isinstance(previous_entry, dict)
3248
+ and isinstance(current_entry, dict)
3249
+ and previous_entry.get("exists") == current_entry.get("exists")
3250
+ and previous_entry.get("mode") == current_entry.get("mode")
3251
+ and previous_entry.get("sha256") == current_entry.get("sha256")
3252
+ ):
3253
+ continue
3254
+ repository = current_repository if isinstance(current_entry, dict) else previous_repository
3255
+ previous_content = snapshot_entry_content(previous_repository, previous_entry)
3256
+ current_content = snapshot_entry_content(current_repository, current_entry)
3257
+ binary, patch = acceptance_change_patch(key[1], previous_content, current_content)
3258
+ change_type = (
3259
+ "added"
3260
+ if previous_content is None and current_content is not None
3261
+ else "deleted"
3262
+ if previous_content is not None and current_content is None
3263
+ else "modified"
3264
+ )
3265
+ label = f"{display_path(root, repository)}:{key[1]}"
3266
+ detail = {
3267
+ "file": label,
3268
+ "repository": display_path(root, repository),
3269
+ "path": key[1],
3270
+ "change_type": change_type,
3271
+ "old_mode": previous_entry.get("mode") if isinstance(previous_entry, dict) else None,
3272
+ "new_mode": current_entry.get("mode") if isinstance(current_entry, dict) else None,
3273
+ "old_sha256": previous_entry.get("sha256")
3274
+ if isinstance(previous_entry, dict)
3275
+ else None,
3276
+ "new_sha256": current_entry.get("sha256")
3277
+ if isinstance(current_entry, dict)
3278
+ else None,
3279
+ "binary": binary,
3280
+ "patch": patch,
3281
+ }
3282
+ if detail["old_mode"] == "160000" or detail["new_mode"] == "160000":
3283
+ nested_repository_changed = True
3284
+ changes.append(detail)
3285
+ digest_changes.append(
3286
+ {key_name: value for key_name, value in detail.items() if key_name != "patch"}
3287
+ )
3288
+ current_implementation = str(current["implementation_fingerprint"])
3289
+ baseline_implementation = str(checkpoint["implementation_fingerprint"])
3290
+ config_changed = current.get("config_fingerprint") != checkpoint.get("config_fingerprint")
3291
+ contract_changed = current.get("contract_fingerprint") != checkpoint.get(
3292
+ "contract_fingerprint"
3293
+ )
3294
+ metadata_changed = bool(
3295
+ contract_changed
3296
+ or nested_repository_changed
3297
+ or (current_implementation != baseline_implementation and not changes)
3298
+ )
3299
+ metadata_reasons = [
3300
+ reason
3301
+ for condition, reason in (
3302
+ (contract_changed, "verification-contract-changed"),
3303
+ (nested_repository_changed, "nested-repository-changed"),
3304
+ (
3305
+ current_implementation != baseline_implementation
3306
+ and not changes
3307
+ and not contract_changed,
3308
+ "unclassified-implementation-drift",
3309
+ ),
3310
+ )
3311
+ if condition
3312
+ ]
3313
+ digest_payload = {
3314
+ "from": baseline_implementation,
3315
+ "to": current_implementation,
3316
+ "config_changed": config_changed,
3317
+ "metadata_changed": metadata_changed,
3318
+ "changes": digest_changes,
3319
+ }
3320
+ return {
3321
+ "status": "drift" if changes or config_changed or metadata_changed else "clean",
3322
+ "from_implementation_fingerprint": baseline_implementation,
3323
+ "implementation_fingerprint": current_implementation,
3324
+ "config_fingerprint": str(current["config_fingerprint"]),
3325
+ "config_changed": config_changed,
3326
+ "metadata_changed": metadata_changed,
3327
+ "metadata_reasons": metadata_reasons,
3328
+ "diff_sha256": canonical_json_sha256(digest_payload),
3329
+ "changed_files": [str(change["file"]) for change in changes],
3330
+ "changes": changes,
3331
+ }
3332
+
3333
+
3334
+ def cleanup_verification_checkpoint(root: Path, task_id: str, task: dict) -> None:
3335
+ checkpoint = task.pop("verification_checkpoint", None)
3336
+ if not isinstance(checkpoint, dict):
3337
+ return
3338
+ raw_path = checkpoint.get("snapshot_file")
3339
+ if not is_non_empty_string(raw_path):
3340
+ return
3341
+ candidate = (root / str(raw_path)).resolve()
3342
+ sessions_root = (root / ".easy-coding" / "sessions").resolve()
3343
+ if not is_path_within(candidate, sessions_root):
3344
+ return
3345
+ try:
3346
+ candidate.unlink()
3347
+ except FileNotFoundError:
3348
+ pass
3349
+ try:
3350
+ candidate.parent.rmdir()
3351
+ except OSError:
3352
+ pass
3353
+
3354
+
3355
+ def record_verification_checkpoint(
3356
+ root: Path,
3357
+ agent: str,
3358
+ task_id: str | None = None,
3359
+ session_file: str | Path | None = None,
3360
+ ) -> dict:
3361
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3362
+ if task.get("status") != "VERIFICATION":
3363
+ raise StateError("Verification checkpoint can only be recorded during VERIFICATION.")
3364
+ if isinstance(task.get("verification_checkpoint"), dict):
3365
+ load_acceptance_snapshot(root, task)
3366
+ result = snapshot_state(root, session_file, session)
3367
+ result["action"] = "verification-checkpoint"
3368
+ result["verification_checkpoint"] = task["verification_checkpoint"]
3369
+ result["checkpoint_unchanged"] = True
3370
+ return result
3371
+ validate_verification_readiness(root, resolved_task_id, task)
3372
+ snapshot = build_acceptance_snapshot(root, resolved_task_id, task)
3373
+ path = acceptance_snapshot_path(root, resolved_task_id)
3374
+ write_json(path, snapshot)
3375
+ task["verification_checkpoint"] = {
3376
+ "schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
3377
+ "implementation_fingerprint": snapshot["implementation_fingerprint"],
3378
+ "config_fingerprint": snapshot["config_fingerprint"],
3379
+ "contract_fingerprint": snapshot["contract_fingerprint"],
3380
+ "snapshot_file": display_path(root, path),
3381
+ "snapshot_sha256": canonical_json_sha256(snapshot),
3382
+ "recorded_at": now_iso(),
3383
+ "recorded_by": agent,
3384
+ }
3385
+ task["last_agent"] = agent
3386
+ write_task(root, resolved_task_id, task)
3387
+ result = snapshot_state(root, session_file, session)
3388
+ result["action"] = "verification-checkpoint"
3389
+ result["verification_checkpoint"] = task["verification_checkpoint"]
3390
+ return result
3391
+
3392
+
3393
+ def latest_acceptance_record(root: Path, task_id: str, task: dict) -> dict | None:
3394
+ latest_implement = max(
3395
+ (
3396
+ str(entry.get("entered_at"))
3397
+ for entry in task.get("stage_history", [])
3398
+ if isinstance(entry, dict)
3399
+ and entry.get("stage") == "IMPLEMENT"
3400
+ and is_non_empty_string(entry.get("entered_at"))
3401
+ ),
3402
+ default="",
3403
+ )
3404
+ latest: dict | None = None
3405
+ for record in execution_records(root, task_id):
3406
+ if record.get("type") != "acceptance" or not is_non_empty_string(
3407
+ record.get("timestamp")
3408
+ ):
3409
+ continue
3410
+ if latest_implement and str(record["timestamp"]) < latest_implement:
3411
+ continue
3412
+ latest = record
3413
+ return latest
3414
+
3415
+
3416
+ def ensure_verification_checkpoint(
3417
+ root: Path,
3418
+ task_id: str,
3419
+ task: dict,
3420
+ agent: str,
3421
+ session_file: str | Path | None,
3422
+ ) -> dict:
3423
+ if isinstance(task.get("verification_checkpoint"), dict):
3424
+ load_acceptance_snapshot(root, task)
3425
+ return task
3426
+ record_verification_checkpoint(root, agent, task_id, session_file)
3427
+ refreshed = load_task(root, task_id)
3428
+ if not isinstance(refreshed, dict):
3429
+ raise StateError(f"Task not found after verification checkpoint: {task_id}")
3430
+ return refreshed
3431
+
3432
+
3433
+ def inspect_transition_drift(
3434
+ root: Path,
3435
+ agent: str,
3436
+ task_id: str | None = None,
3437
+ session_file: str | Path | None = None,
3438
+ ) -> dict:
3439
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3440
+ if task.get("status") != "VERIFICATION":
3441
+ raise StateError("Transition drift can only be inspected during VERIFICATION.")
3442
+ task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
3443
+ result = snapshot_state(root, session_file, session)
3444
+ result["acceptance_drift"] = inspect_acceptance_drift(root, resolved_task_id, task)
3445
+ result["action"] = "inspect-transition-drift"
3446
+ return result
3447
+
3448
+
3449
+ def append_transition_acceptance(
3450
+ root: Path,
3451
+ task_id: str,
3452
+ task: dict,
3453
+ agent: str,
3454
+ approval_mode: str,
3455
+ authorization: str,
3456
+ expected_diff_sha256: str | None = None,
3457
+ verification_policy: str | None = None,
3458
+ summary: str | None = None,
3459
+ ) -> dict:
3460
+ drift = inspect_acceptance_drift(root, task_id, task)
3461
+ if drift["config_changed"]:
3462
+ raise StateError(
3463
+ "Behavior config changed after verification; rerun verification before MEMORY."
3464
+ )
3465
+ if drift["metadata_changed"]:
3466
+ raise StateError(
3467
+ "Execution plan, workflow, Canonical design, or nested repository state changed "
3468
+ "after verification; return to ANALYSIS or IMPLEMENT instead of accepting it as a code diff."
3469
+ )
3470
+ changed_files = list(drift["changed_files"])
3471
+ if changed_files:
3472
+ if expected_diff_sha256 != drift["diff_sha256"]:
3473
+ raise StateError(
3474
+ "Verified code changed after the acceptance checkpoint. Inspect the exact drift "
3475
+ "and confirm its current diff_sha256 before entering MEMORY."
3476
+ )
3477
+ if verification_policy not in ACCEPTANCE_VERIFICATION_POLICIES:
3478
+ raise StateError(
3479
+ "Accepted code drift requires verification policy carry-forward, targeted, or waived."
3480
+ )
3481
+ review_policy = "user-accepted-without-rereview"
3482
+ else:
3483
+ verification_policy = "current"
3484
+ review_policy = "current"
3485
+ required_targeted_source_tasks = (
3486
+ targeted_source_tasks_for_changes(root, task_id, task, drift["changes"])
3487
+ if verification_policy == "targeted"
3488
+ else []
3489
+ )
3490
+ normalized_summary = (
3491
+ summary.strip()
3492
+ if isinstance(summary, str) and summary.strip()
3493
+ else "User accepted the verified implementation"
3494
+ if authorization == "explicit-user"
3495
+ else f"Approval mode {approval_mode} authorized the verified implementation"
3496
+ )
3497
+ record = {
3498
+ "type": "acceptance",
3499
+ "from_implementation_fingerprint": drift["from_implementation_fingerprint"],
3500
+ "implementation_fingerprint": drift["implementation_fingerprint"],
3501
+ "config_fingerprint": drift["config_fingerprint"],
3502
+ "diff_sha256": drift["diff_sha256"],
3503
+ "changed_files": changed_files,
3504
+ "authorization": authorization,
3505
+ "approval_mode": approval_mode,
3506
+ "review_policy": review_policy,
3507
+ "verification_policy": verification_policy,
3508
+ "required_targeted_source_tasks": required_targeted_source_tasks,
3509
+ "summary": normalized_summary,
3510
+ "recorded_by": agent,
3511
+ "timestamp": now_iso(),
3512
+ }
3513
+ existing = latest_acceptance_record(root, task_id, task)
3514
+ identity_fields = (
3515
+ "from_implementation_fingerprint",
3516
+ "implementation_fingerprint",
3517
+ "config_fingerprint",
3518
+ "diff_sha256",
3519
+ "authorization",
3520
+ "approval_mode",
3521
+ "review_policy",
3522
+ "verification_policy",
3523
+ "required_targeted_source_tasks",
3524
+ "summary",
3525
+ )
3526
+ if not (
3527
+ isinstance(existing, dict)
3528
+ and existing.get("changed_files") == changed_files
3529
+ and all(existing.get(field) == record.get(field) for field in identity_fields)
3530
+ ):
3531
+ append_execution_record(root, task_id, record)
3532
+ return record
3533
+
3534
+
3535
+ def targeted_source_tasks_for_changes(
3536
+ root: Path,
3537
+ task_id: str,
3538
+ task: dict,
3539
+ changes: list[dict],
3540
+ ) -> list[str]:
3541
+ if not isinstance(task.get("spec_source"), dict):
3542
+ return []
3543
+ plan = latest_execution_plan(root, task_id)
3544
+ repo_paths = task.get("repo_paths")
3545
+ if plan is None or not isinstance(repo_paths, dict):
3546
+ raise StateError("Canonical targeted verification requires a valid repository plan.")
3547
+
3548
+ units_by_repository: dict[str, list[dict]] = {}
3549
+ for unit in plan.get("units", []):
3550
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
3551
+ continue
3552
+ raw_repository = repo_paths.get(str(unit["repo_id"]))
3553
+ if not is_non_empty_string(raw_repository):
3554
+ continue
3555
+ candidate = Path(str(raw_repository))
3556
+ repository = (candidate if candidate.is_absolute() else root / candidate).resolve()
3557
+ units_by_repository.setdefault(display_path(root, repository), []).append(unit)
3558
+
3559
+ impacted: set[str] = set()
3560
+ for change in changes:
3561
+ if not isinstance(change, dict):
3562
+ continue
3563
+ repository_units = units_by_repository.get(str(change.get("repository") or ""), [])
3564
+ if not repository_units:
3565
+ continue
3566
+ changed_path = str(change.get("path") or "")
3567
+ matched_units = [
3568
+ unit
3569
+ for unit in repository_units
3570
+ if any(
3571
+ changed_path == str(file_name)
3572
+ or changed_path.startswith(str(file_name).rstrip("/") + "/")
3573
+ for file_name in unit.get("files", [])
3574
+ if is_non_empty_string(file_name)
3575
+ )
3576
+ ]
3577
+ scoped_units = matched_units or repository_units
3578
+ impacted.update(
3579
+ str(unit["source_task_id"])
3580
+ for unit in scoped_units
3581
+ if is_non_empty_string(unit.get("source_task_id"))
3582
+ )
3583
+ if not impacted:
3584
+ raise StateError(
3585
+ "Canonical executable drift could not be mapped to a selected source task."
3586
+ )
3587
+ return sorted(impacted)
3588
+
3589
+
2105
3590
  def command_option_value(command: str, option: str) -> str | None:
2106
3591
  try:
2107
3592
  tokens = shlex.split(command)
@@ -2133,19 +3618,81 @@ def coverage_command_matches_frozen_contract(
2133
3618
  )
2134
3619
 
2135
3620
 
2136
- def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
2137
- if not isinstance(task.get("spec_source"), dict):
2138
- return
2139
- plan = latest_execution_plan(root, task_id)
2140
- if plan is None or not is_valid_spec_execution_plan(root, task, plan):
2141
- raise StateError("Canonical Spec implementation has no valid source-traceable plan.")
2142
- unit_by_id = {
2143
- str(unit["id"]): unit for unit in plan.get("units", []) if isinstance(unit, dict)
2144
- }
2145
- records = execution_records(root, task_id)
2146
- latest_plan_index = max(
2147
- (index for index, record in enumerate(records) if record.get("type") == "plan"),
2148
- default=-1,
3621
+ def current_acceptance_record(
3622
+ root: Path,
3623
+ task_id: str,
3624
+ task: dict,
3625
+ implementation_fingerprint_value: str,
3626
+ config_fingerprint_value: str,
3627
+ ) -> dict | None:
3628
+ record = latest_acceptance_record(root, task_id, task)
3629
+ if not isinstance(record, dict):
3630
+ return None
3631
+ if (
3632
+ record.get("implementation_fingerprint") != implementation_fingerprint_value
3633
+ or record.get("config_fingerprint") != config_fingerprint_value
3634
+ or not is_non_empty_string(record.get("from_implementation_fingerprint"))
3635
+ or record.get("review_policy")
3636
+ not in {"current", "user-accepted-without-rereview"}
3637
+ or record.get("verification_policy")
3638
+ not in {"current", *ACCEPTANCE_VERIFICATION_POLICIES}
3639
+ or not is_string_list(record.get("required_targeted_source_tasks"))
3640
+ ):
3641
+ return None
3642
+ return record
3643
+
3644
+
3645
+ def accepted_review_fingerprints(
3646
+ root: Path, task_id: str, task: dict, current_fingerprint: str
3647
+ ) -> set[str]:
3648
+ accepted = {current_fingerprint}
3649
+ record = current_acceptance_record(
3650
+ root,
3651
+ task_id,
3652
+ task,
3653
+ current_fingerprint,
3654
+ behavior_config_fingerprint(root, task),
3655
+ )
3656
+ if record and record.get("review_policy") == "user-accepted-without-rereview":
3657
+ accepted.add(str(record["from_implementation_fingerprint"]))
3658
+ return accepted
3659
+
3660
+
3661
+ def accepted_verification_fingerprints(
3662
+ root: Path,
3663
+ task_id: str,
3664
+ task: dict,
3665
+ current_implementation: str,
3666
+ current_config: str,
3667
+ ) -> tuple[set[str], dict | None]:
3668
+ record = current_acceptance_record(
3669
+ root,
3670
+ task_id,
3671
+ task,
3672
+ current_implementation,
3673
+ current_config,
3674
+ )
3675
+ if not record or record.get("verification_policy") == "current":
3676
+ return {current_implementation}, record
3677
+ previous = str(record["from_implementation_fingerprint"])
3678
+ if record.get("verification_policy") == "targeted":
3679
+ return {previous, current_implementation}, record
3680
+ return {previous}, record
3681
+
3682
+
3683
+ def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
3684
+ if not isinstance(task.get("spec_source"), dict):
3685
+ return
3686
+ plan = latest_execution_plan(root, task_id)
3687
+ if plan is None or not is_valid_spec_execution_plan(root, task, plan):
3688
+ raise StateError("Canonical Spec implementation has no valid source-traceable plan.")
3689
+ unit_by_id = {
3690
+ str(unit["id"]): unit for unit in plan.get("units", []) if isinstance(unit, dict)
3691
+ }
3692
+ records = execution_records(root, task_id)
3693
+ latest_plan_index = max(
3694
+ (index for index, record in enumerate(records) if record.get("type") == "plan"),
3695
+ default=-1,
2149
3696
  )
2150
3697
  lifecycle_by_unit: dict[str, list[dict]] = {unit_id: [] for unit_id in unit_by_id}
2151
3698
  for record in records[latest_plan_index + 1 :]:
@@ -2209,11 +3756,12 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2209
3756
  if task.get("workflow_mode_legacy") is True and not is_spec_task:
2210
3757
  return
2211
3758
  expected = implementation_fingerprint(root, task_id)
3759
+ accepted_fingerprints = accepted_review_fingerprints(root, task_id, task, expected)
2212
3760
  latest_by_dimension: dict[str, dict] = {}
2213
3761
  for record in execution_records(root, task_id):
2214
3762
  if (
2215
3763
  record.get("type") == "review"
2216
- and record.get("implementation_fingerprint") == expected
3764
+ and record.get("implementation_fingerprint") in accepted_fingerprints
2217
3765
  and is_non_empty_string(record.get("dimension"))
2218
3766
  ):
2219
3767
  dimension = str(record["dimension"])
@@ -2328,6 +3876,13 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2328
3876
 
2329
3877
  def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
2330
3878
  fingerprints = evidence_fingerprints(root, task_id)
3879
+ accepted_fingerprints, acceptance = accepted_verification_fingerprints(
3880
+ root,
3881
+ task_id,
3882
+ task,
3883
+ fingerprints["implementation_fingerprint"],
3884
+ fingerprints["config_fingerprint"],
3885
+ )
2331
3886
  is_spec_task = isinstance(task.get("spec_source"), dict)
2332
3887
  if (
2333
3888
  (task.get("workflow_mode_legacy") is not True or is_spec_task)
@@ -2339,11 +3894,17 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2339
3894
  for record in execution_records(root, task_id):
2340
3895
  if (
2341
3896
  record.get("type") == "verify"
2342
- and record.get("implementation_fingerprint")
2343
- == fingerprints["implementation_fingerprint"]
3897
+ and record.get("implementation_fingerprint") in accepted_fingerprints
2344
3898
  and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
2345
3899
  and is_non_empty_string(record.get("check"))
2346
3900
  ):
3901
+ if (
3902
+ task.get("tdd_enabled") is True
3903
+ and record.get("check_type") == "coverage"
3904
+ and record.get("coverage_scope") == "gitlab"
3905
+ ):
3906
+ # 远程 CI 只作为生成的自动化能力,历史 pending/failed 记录不再参与本地验收。
3907
+ continue
2347
3908
  check = str(record["check"])
2348
3909
  if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
2349
3910
  check = f"{check}\0{record.get('coverage_scope') or ''}"
@@ -2411,6 +3972,39 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2411
3972
  raise StateError(
2412
3973
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
2413
3974
  )
3975
+ if acceptance and acceptance.get("verification_policy") == "targeted":
3976
+ current_records = [
3977
+ record
3978
+ for record in applicable_records
3979
+ if record.get("implementation_fingerprint")
3980
+ == fingerprints["implementation_fingerprint"]
3981
+ ]
3982
+ if not current_records or any(record.get("passed") is not True for record in current_records):
3983
+ raise StateError(
3984
+ "Accepted executable drift requires at least one passed targeted verification "
3985
+ "record for the current implementation fingerprint."
3986
+ )
3987
+ if is_spec_task:
3988
+ required_source_tasks = set(acceptance["required_targeted_source_tasks"])
3989
+ current_source_tasks = {
3990
+ str(record.get("source_task_id"))
3991
+ for record in current_records
3992
+ if is_non_empty_string(record.get("source_task_id"))
3993
+ }
3994
+ missing_source_tasks = sorted(required_source_tasks - current_source_tasks)
3995
+ if missing_source_tasks:
3996
+ raise StateError(
3997
+ "Accepted Canonical executable drift requires a passed current-fingerprint "
3998
+ "targeted verification record for affected source tasks: "
3999
+ + ", ".join(missing_source_tasks)
4000
+ )
4001
+ if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
4002
+ readiness = tdd_readiness(root)
4003
+ if readiness["status"] != "ready":
4004
+ raise StateError(
4005
+ "TDD initialization cannot advance to MEMORY until readiness passes: "
4006
+ + "; ".join(str(reason) for reason in readiness["reasons"])
4007
+ )
2414
4008
  if task.get("tdd_enabled") is not True and any(
2415
4009
  record.get("check_type") == "coverage" for record in latest_by_check.values()
2416
4010
  ):
@@ -2418,6 +4012,13 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2418
4012
  "Coverage verification evidence is not allowed when the frozen TDD mode is off."
2419
4013
  )
2420
4014
  if task.get("tdd_enabled") is True:
4015
+ require_tdd_readiness(root)
4016
+ test_records = [
4017
+ record
4018
+ for record in latest_by_check.values()
4019
+ if record.get("check_type") == "test"
4020
+ and record.get("applicable") is not False
4021
+ ]
2421
4022
  coverage_records = [
2422
4023
  record
2423
4024
  for record in latest_by_check.values()
@@ -2428,6 +4029,17 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2428
4029
  "TDD verification requires changed-production-line JaCoCo coverage evidence."
2429
4030
  )
2430
4031
  if is_spec_task:
4032
+ tested_source_tasks = {
4033
+ str(record.get("source_task_id") or "") for record in test_records
4034
+ }
4035
+ missing_test_tasks = sorted(
4036
+ set(task_repositories) - tested_source_tasks
4037
+ )
4038
+ if missing_test_tasks:
4039
+ raise StateError(
4040
+ "TDD Canonical verification requires local unit-test evidence for every selected source task: "
4041
+ + ", ".join(missing_test_tasks)
4042
+ )
2431
4043
  covered_source_tasks = {
2432
4044
  str(record.get("source_task_id") or "") for record in coverage_records
2433
4045
  }
@@ -2439,19 +4051,16 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2439
4051
  "TDD Canonical verification requires separate coverage evidence for every selected source task: "
2440
4052
  + ", ".join(missing_coverage_tasks)
2441
4053
  )
2442
- coverage_scopes_by_owner: dict[str, set[str]] = {}
4054
+ elif not test_records:
4055
+ raise StateError(
4056
+ "TDD verification requires passed local unit-test evidence."
4057
+ )
2443
4058
  for record in coverage_records:
2444
4059
  scope = str(record.get("coverage_scope") or "")
2445
- if scope not in {"local", "gitlab"}:
4060
+ if scope != "local":
2446
4061
  raise StateError(
2447
- "TDD coverage evidence must identify coverage_scope as local or gitlab."
4062
+ "TDD coverage evidence must identify coverage_scope as local."
2448
4063
  )
2449
- owner = (
2450
- str(record.get("source_task_id") or "")
2451
- if is_spec_task
2452
- else "project"
2453
- )
2454
- coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
2455
4064
  expected_threshold = task.get("tdd_coverage_threshold")
2456
4065
  expected_baselines = task.get("tdd_baselines")
2457
4066
  if (
@@ -2466,18 +4075,6 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2466
4075
  coverage = record.get("coverage")
2467
4076
  if not isinstance(coverage, dict):
2468
4077
  raise StateError("TDD coverage evidence must include the coverage result object.")
2469
- if record.get("coverage_scope") == "gitlab":
2470
- ci = record.get("ci")
2471
- if (
2472
- not isinstance(ci, dict)
2473
- or ci.get("provider") != "gitlab"
2474
- or ci.get("status") != "success"
2475
- or not is_non_empty_string(ci.get("pipeline_url"))
2476
- or not is_non_empty_string(ci.get("job_name"))
2477
- ):
2478
- raise StateError(
2479
- "GitLab coverage evidence requires a successful pipeline URL and job name."
2480
- )
2481
4078
  total = coverage.get("total_lines")
2482
4079
  covered = coverage.get("covered_lines")
2483
4080
  percentage = coverage.get("percentage")
@@ -2532,18 +4129,6 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2532
4129
  raise StateError(
2533
4130
  f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
2534
4131
  )
2535
- expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
2536
- missing_scopes = [
2537
- f"{owner}:{scope}"
2538
- for owner in sorted(expected_coverage_owners)
2539
- for scope in ("local", "gitlab")
2540
- if scope not in coverage_scopes_by_owner.get(owner, set())
2541
- ]
2542
- if missing_scopes:
2543
- raise StateError(
2544
- "TDD verification requires both local and successful GitLab coverage gates: "
2545
- + ", ".join(missing_scopes)
2546
- )
2547
4132
  if task.get("workflow_mode") == "strict":
2548
4133
  if is_spec_task:
2549
4134
  check_types_by_repository: dict[str, set[str]] = {
@@ -2723,19 +4308,35 @@ def validate_read_only_completion(root: Path, task_id: str) -> None:
2723
4308
  )
2724
4309
 
2725
4310
 
4311
+ def markdown_fence_token(line: str) -> tuple[str, int, str] | None:
4312
+ stripped = line.lstrip()
4313
+ if not stripped or stripped[0] not in {"`", "~"}:
4314
+ return None
4315
+ marker = stripped[0]
4316
+ run_length = len(stripped) - len(stripped.lstrip(marker))
4317
+ if run_length < 3:
4318
+ return None
4319
+ return marker, run_length, stripped[run_length:]
4320
+
4321
+
2726
4322
  def markdown_headings(content: str) -> list[tuple[int, int, str]]:
2727
4323
  headings: list[tuple[int, int, str]] = []
2728
- fence_marker: str | None = None
4324
+ fence_marker: tuple[str, int] | None = None
2729
4325
  for index, line in enumerate(content.splitlines()):
2730
- stripped = line.lstrip()
2731
- if stripped.startswith(("```", "~~~")):
2732
- marker = stripped[:3]
2733
- if fence_marker is None:
2734
- fence_marker = marker
2735
- elif fence_marker == marker:
4326
+ fence = markdown_fence_token(line)
4327
+ if fence_marker is not None:
4328
+ marker, run_length, remainder = fence or ("", 0, "")
4329
+ if (
4330
+ marker == fence_marker[0]
4331
+ and run_length >= fence_marker[1]
4332
+ and not remainder.strip()
4333
+ ):
2736
4334
  fence_marker = None
2737
4335
  continue
2738
- if fence_marker is not None:
4336
+ if fence is not None:
4337
+ marker, run_length, remainder = fence
4338
+ if marker != "`" or "`" not in remainder:
4339
+ fence_marker = (marker, run_length)
2739
4340
  continue
2740
4341
  match = MARKDOWN_HEADING_PATTERN.match(line.strip())
2741
4342
  if match:
@@ -2743,6 +4344,55 @@ def markdown_headings(content: str) -> list[tuple[int, int, str]]:
2743
4344
  return headings
2744
4345
 
2745
4346
 
4347
+ def markdown_section_body(content: str, title: str, level: int = 3) -> str | None:
4348
+ lines = content.splitlines()
4349
+ headings = markdown_headings(content)
4350
+ heading_index = next(
4351
+ (
4352
+ index
4353
+ for index, (_, heading_level, heading_title) in enumerate(headings)
4354
+ if heading_level == level and heading_title == title
4355
+ ),
4356
+ None,
4357
+ )
4358
+ if heading_index is None:
4359
+ return None
4360
+ line_index, heading_level, _ = headings[heading_index]
4361
+ next_line_index = len(lines)
4362
+ for candidate_line, candidate_level, _ in headings[heading_index + 1 :]:
4363
+ if candidate_level <= heading_level:
4364
+ next_line_index = candidate_line
4365
+ break
4366
+ return "\n".join(lines[line_index + 1 : next_line_index])
4367
+
4368
+
4369
+ def markdown_standalone_field_values(content: str, pattern: re.Pattern[str]) -> list[str]:
4370
+ values: list[str] = []
4371
+ fence_marker: tuple[str, int] | None = None
4372
+ for line in content.splitlines():
4373
+ fence = markdown_fence_token(line)
4374
+ if fence_marker is not None:
4375
+ marker, run_length, remainder = fence or ("", 0, "")
4376
+ if (
4377
+ marker == fence_marker[0]
4378
+ and run_length >= fence_marker[1]
4379
+ and not remainder.strip()
4380
+ ):
4381
+ fence_marker = None
4382
+ continue
4383
+ if fence is not None:
4384
+ marker, run_length, remainder = fence
4385
+ if marker != "`" or "`" not in remainder:
4386
+ fence_marker = (marker, run_length)
4387
+ continue
4388
+ if line.startswith(("\t", " ")):
4389
+ continue
4390
+ match = pattern.fullmatch(line)
4391
+ if match:
4392
+ values.append(match.group(1).strip())
4393
+ return values
4394
+
4395
+
2746
4396
  def has_meaningful_markdown_body(content: str) -> bool:
2747
4397
  for line in content.splitlines():
2748
4398
  stripped = line.strip()
@@ -2826,7 +4476,7 @@ def validate_analysis_readiness(
2826
4476
  test_strategy = task_dir / "test-strategy.md"
2827
4477
  reasons: list[str] = []
2828
4478
  behavior = resolve_behavior(root, session or default_session())
2829
- tdd_enabled = behavior[8]
4479
+ tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
2830
4480
  tdd_threshold = behavior[11]
2831
4481
 
2832
4482
  dev_spec_content = ""
@@ -2859,6 +4509,71 @@ def validate_analysis_readiness(
2859
4509
  if "[阶段:ANALYSIS]" in dev_spec_content or "### 待用户决策" in dev_spec_content:
2860
4510
  reasons.append("dev-spec.md contains forbidden analysis-only sections")
2861
4511
 
4512
+ decision_headings = [
4513
+ heading
4514
+ for heading in markdown_headings(dev_spec_content)
4515
+ if heading[1] == 3 and heading[2] == "决策闭环"
4516
+ ]
4517
+ if len(decision_headings) != 1:
4518
+ reasons.append(
4519
+ "dev-spec.md must contain exactly one `### 决策闭环` section; "
4520
+ f"found {len(decision_headings)}"
4521
+ )
4522
+ decision_section = markdown_section_body(dev_spec_content, "决策闭环") or ""
4523
+ all_decision_statuses = [
4524
+ value.lower()
4525
+ for value in markdown_standalone_field_values(
4526
+ dev_spec_content, DECISION_STATUS_PATTERN
4527
+ )
4528
+ ]
4529
+ section_decision_statuses = [
4530
+ value.lower()
4531
+ for value in markdown_standalone_field_values(
4532
+ decision_section, DECISION_STATUS_PATTERN
4533
+ )
4534
+ ]
4535
+ if not all_decision_statuses:
4536
+ reasons.append(
4537
+ "dev-spec.md is missing the decision closure marker `decision_status: closed`; "
4538
+ "resume ec-analysis, resolve material questions, and record the conclusions first"
4539
+ )
4540
+ elif len(all_decision_statuses) != 1:
4541
+ reasons.append(
4542
+ "dev-spec.md must contain exactly one decision_status marker; "
4543
+ f"found {len(all_decision_statuses)}"
4544
+ )
4545
+ elif len(section_decision_statuses) != 1:
4546
+ reasons.append(
4547
+ "dev-spec.md decision_status marker must be inside the `### 决策闭环` section"
4548
+ )
4549
+ elif section_decision_statuses[0] != "closed":
4550
+ reasons.append(
4551
+ "dev-spec.md has unresolved material decisions: "
4552
+ f"decision_status is {section_decision_statuses[0]!r}, expected 'closed'"
4553
+ )
4554
+ decision_conclusions = markdown_standalone_field_values(
4555
+ decision_section, DECISION_CONCLUSIONS_PATTERN
4556
+ )
4557
+ decision_evidence = markdown_standalone_field_values(
4558
+ decision_section, DECISION_EVIDENCE_PATTERN
4559
+ )
4560
+ for field_name, values in (
4561
+ ("已解决问题与结论", decision_conclusions),
4562
+ ("确认依据", decision_evidence),
4563
+ ):
4564
+ if len(values) != 1:
4565
+ reasons.append(
4566
+ "dev-spec.md decision closure must contain exactly one non-empty "
4567
+ f"`{field_name}` field; found {len(values)}"
4568
+ )
4569
+ elif UNRESOLVED_DECISION_VALUE_PATTERN.fullmatch(
4570
+ re.sub(r"[`*_]", "", values[0]).strip()
4571
+ ):
4572
+ reasons.append(
4573
+ "dev-spec.md has unresolved decision closure evidence: "
4574
+ f"`{field_name}` is {values[0]!r}"
4575
+ )
4576
+
2862
4577
  if not skeleton.exists():
2863
4578
  reasons.append("dev-spec skeleton template is missing")
2864
4579
  else:
@@ -2875,6 +4590,12 @@ def validate_analysis_readiness(
2875
4590
  if not plan_is_valid:
2876
4591
  reasons.append("execution.jsonl has no valid plan record")
2877
4592
  if tdd_enabled and not is_read_only_task:
4593
+ readiness = tdd_readiness(root)
4594
+ if readiness["status"] != "ready":
4595
+ reasons.append(
4596
+ "TDD infrastructure is not ready; run ec-tdd-init first: "
4597
+ + "; ".join(str(reason) for reason in readiness["reasons"])
4598
+ )
2878
4599
  plan = latest_execution_plan(root, task_id) or {}
2879
4600
  if re.search(
2880
4601
  r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
@@ -2902,7 +4623,13 @@ def validate_analysis_readiness(
2902
4623
  strategy_content = test_strategy.read_text(encoding="utf-8")
2903
4624
  except OSError:
2904
4625
  strategy_content = ""
2905
- required_tdd_markers = ["TDD", "JaCoCo", "baseline", "GitLab"]
4626
+ required_tdd_markers = [
4627
+ "TDD",
4628
+ "JaCoCo",
4629
+ "baseline",
4630
+ "local_test_gate: required",
4631
+ "remote_ci_acceptance: non-blocking",
4632
+ ]
2906
4633
  missing_tdd_markers = [
2907
4634
  marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
2908
4635
  ]
@@ -2924,6 +4651,32 @@ def validate_analysis_readiness(
2924
4651
  dev_spec_content, strategy_content, baselines
2925
4652
  )
2926
4653
  )
4654
+ elif task_type == TDD_INIT_TASK_TYPE:
4655
+ try:
4656
+ strategy_content = test_strategy.read_text(encoding="utf-8")
4657
+ except OSError:
4658
+ strategy_content = ""
4659
+ required_init_markers = [
4660
+ "JaCoCo",
4661
+ "GitLab",
4662
+ "changed production lines",
4663
+ "historical coverage required: no",
4664
+ "easy_coding_tdd_readiness.py",
4665
+ ]
4666
+ missing_init_markers = [
4667
+ marker
4668
+ for marker in required_init_markers
4669
+ if marker.lower() not in strategy_content.lower()
4670
+ ]
4671
+ if missing_init_markers:
4672
+ reasons.append(
4673
+ "TDD initialization strategy is missing: "
4674
+ + ", ".join(missing_init_markers)
4675
+ )
4676
+ if re.search(
4677
+ r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
4678
+ ):
4679
+ reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
2927
4680
  elif not is_read_only_task:
2928
4681
  if re.search(
2929
4682
  r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
@@ -3103,6 +4856,28 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
3103
4856
  return latest
3104
4857
 
3105
4858
 
4859
+ def pending_handoff_record(root: Path, task_id: str) -> dict | None:
4860
+ path = execution_log_path(root, task_id)
4861
+ if not path.exists():
4862
+ return None
4863
+ latest_coordination: dict | None = None
4864
+ try:
4865
+ for line in path.read_text(encoding="utf-8").splitlines():
4866
+ if not line.strip():
4867
+ continue
4868
+ try:
4869
+ record = json.loads(line)
4870
+ except json.JSONDecodeError:
4871
+ continue
4872
+ if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
4873
+ latest_coordination = record
4874
+ except OSError:
4875
+ return None
4876
+ if latest_coordination and latest_coordination.get("type") == "handoff":
4877
+ return latest_coordination
4878
+ return None
4879
+
4880
+
3106
4881
  def assert_safe_task_id(task_id: str) -> None:
3107
4882
  path = Path(task_id)
3108
4883
  if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
@@ -3140,6 +4915,7 @@ def spec_task_summary(task: dict | None) -> dict | None:
3140
4915
  "selected_spec_tasks": task.get("selected_spec_tasks", []),
3141
4916
  "repositories": task.get("spec_repositories", []),
3142
4917
  "pending_dependencies": pending_dependencies,
4918
+ "writeback": task.get("spec_writeback_progress"),
3143
4919
  }
3144
4920
 
3145
4921
 
@@ -3254,12 +5030,25 @@ def snapshot_state(
3254
5030
  and status not in {"ANALYSIS", "INIT"}
3255
5031
  and isinstance(task_tdd_enabled, bool)
3256
5032
  )
3257
- displayed_tdd_enabled = task_tdd_enabled if frozen_tdd else effective_tdd_enabled
5033
+ is_tdd_init = bool(
5034
+ task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
5035
+ )
5036
+ displayed_tdd_enabled = (
5037
+ False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
5038
+ )
3258
5039
  displayed_tdd_threshold = (
3259
5040
  task_tdd_coverage_threshold
3260
5041
  if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
3261
5042
  else effective_tdd_coverage_threshold
3262
5043
  )
5044
+ should_check_readiness = bool(
5045
+ effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
5046
+ )
5047
+ readiness = (
5048
+ tdd_readiness(root)
5049
+ if should_check_readiness
5050
+ else {"status": "not_checked", "reasons": []}
5051
+ )
3263
5052
 
3264
5053
  return {
3265
5054
  "session_file": display_path(root, session_path),
@@ -3291,6 +5080,8 @@ def snapshot_state(
3291
5080
  "task_tdd_baselines": task.get("tdd_baselines") if task else None,
3292
5081
  "displayed_tdd_enabled": displayed_tdd_enabled,
3293
5082
  "displayed_tdd_coverage_threshold": displayed_tdd_threshold,
5083
+ "tdd_readiness_status": readiness["status"],
5084
+ "tdd_readiness_reasons": readiness["reasons"],
3294
5085
  "spec_summary": spec_task_summary(task),
3295
5086
  # Compatibility output aliases for pre-0.9 clients.
3296
5087
  "project_confirm_mode": project_approval_mode,
@@ -3316,9 +5107,10 @@ def build_status_line(
3316
5107
  if task_id:
3317
5108
  status = str(state["status"])
3318
5109
  line = f"{status_brand} · `{task_id}` · `{status}`"
3319
- last_agent = state.get("last_agent")
3320
- if agent and last_agent and not agents_equivalent(last_agent, agent):
3321
- line += f" · Handoff -> `{last_agent}`"
5110
+ handoff = pending_handoff_record(root, str(task_id))
5111
+ handoff_from = handoff.get("from") if handoff else None
5112
+ if agent and handoff_from and not agents_equivalent(handoff_from, agent):
5113
+ line += f" · Handoff -> `{handoff_from}`"
3322
5114
  if state["is_terminal"] or state["task_missing"]:
3323
5115
  line += f" · {HELP_SUFFIX}"
3324
5116
  return line
@@ -3365,9 +5157,10 @@ def build_machine_breadcrumbs(
3365
5157
  lines.append(f"[current-task:{task_id}]")
3366
5158
  if state["task_missing"]:
3367
5159
  lines.append(f"[easy-coding:current-task-missing:{task_id}]")
3368
- last_agent = state.get("last_agent")
3369
- if agent and last_agent and not agents_equivalent(last_agent, agent):
3370
- lines.append(f"[easy-coding:handoff-from:{last_agent}]")
5160
+ handoff = pending_handoff_record(root, str(task_id))
5161
+ handoff_from = handoff.get("from") if handoff else None
5162
+ if agent and handoff_from and not agents_equivalent(handoff_from, agent):
5163
+ lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
3371
5164
  pending = state.get("pending_transition")
3372
5165
  if isinstance(pending, dict):
3373
5166
  source = str(pending.get("from") or stage)
@@ -3385,6 +5178,11 @@ def build_machine_breadcrumbs(
3385
5178
  lines.append(
3386
5179
  "[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
3387
5180
  )
5181
+ elif pending.get("confirmation_override") == "evidence-drift":
5182
+ lines.append(
5183
+ "[easy-coding:acceptance-drift-confirmation-required]"
5184
+ )
5185
+ lines.append("[easy-coding:transition-confirmation-required]")
3388
5186
  elif is_automatic_transition(
3389
5187
  source,
3390
5188
  target,
@@ -3674,6 +5472,8 @@ def set_session_tdd(
3674
5472
  threshold: int | None = None,
3675
5473
  session_file: str | Path | None = None,
3676
5474
  ) -> dict:
5475
+ if enabled:
5476
+ require_tdd_readiness(root)
3677
5477
  session = ensure_session(root, session_file)
3678
5478
  materialize_legacy_session_behavior(session)
3679
5479
  session["tdd_enabled"] = enabled
@@ -3789,11 +5589,21 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
3789
5589
  session["last_agent"] = agent
3790
5590
  write_session(root, session, session_file)
3791
5591
 
5592
+ claim = {
5593
+ "type": "claim",
5594
+ "agent": agent,
5595
+ "previous_agent": previous_agent,
5596
+ "action": action,
5597
+ "timestamp": now_iso(),
5598
+ }
5599
+ append_execution_record(root, task_id, claim)
5600
+
3792
5601
  snapshot = snapshot_state(root, session_file, session)
3793
5602
  snapshot["task_id"] = task_id
3794
5603
  snapshot["action"] = action
3795
5604
  snapshot["previous_agent"] = previous_agent
3796
5605
  snapshot["latest_handoff"] = latest_handoff
5606
+ snapshot["claim"] = claim
3797
5607
  return snapshot
3798
5608
 
3799
5609
 
@@ -3836,82 +5646,1441 @@ def create_task(
3836
5646
  return {"task_id": task_id, "task": task}
3837
5647
 
3838
5648
 
3839
- def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
3840
- resolved = path.resolve()
5649
+ def create_task_from_spec(
5650
+ root: Path,
5651
+ spec_path: str,
5652
+ spec_task_ids: list[str],
5653
+ task_id: str,
5654
+ task_type: str,
5655
+ title: str,
5656
+ repo_paths: dict[str, str],
5657
+ dependency_evidence: dict[str, str],
5658
+ agent: str,
5659
+ set_current: bool = True,
5660
+ session_file: str | Path | None = None,
5661
+ ) -> dict:
5662
+ raw_spec_path = Path(spec_path).expanduser()
5663
+ resolved_spec_path = (
5664
+ raw_spec_path.resolve()
5665
+ if raw_spec_path.is_absolute()
5666
+ else (root / raw_spec_path).resolve()
5667
+ )
5668
+ if not resolved_spec_path.is_file():
5669
+ raise StateError("Canonical Spec path must be an explicitly selected UTF-8 file.")
3841
5670
  try:
3842
- resolved.relative_to(root.resolve())
3843
- except ValueError as exc:
3844
- raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
3845
- return resolved
5671
+ inspection = inspect_spec(
5672
+ resolved_spec_path,
5673
+ root,
5674
+ repo_paths,
5675
+ spec_task_ids,
5676
+ )
5677
+ selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
5678
+ except EasyDevSpecError as exc:
5679
+ raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
5680
+
5681
+ selected_repo_ids = set(selection["selected_repo_ids"])
5682
+ bindings = [
5683
+ binding
5684
+ for binding in inspection["repository_bindings"]
5685
+ if binding.get("repo_id") in selected_repo_ids
5686
+ ]
5687
+ if len(bindings) != len(selected_repo_ids):
5688
+ raise StateError("Canonical Spec repository bindings do not cover every selected task.")
5689
+ stored_repo_paths = {
5690
+ str(binding["repo_id"]): str(binding["path"])
5691
+ for binding in bindings
5692
+ }
5693
+ if not isinstance(inspection.get("execution"), dict):
5694
+ raise StateError(
5695
+ "Canonical Spec shared execution is not initialized; run initialize-spec-execution first."
5696
+ )
5697
+ try:
5698
+ source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
5699
+ path_mode = "project-relative"
5700
+ except ValueError:
5701
+ source_path = str(resolved_spec_path)
5702
+ path_mode = "absolute"
5703
+ fields = {
5704
+ "repos": list(selection["selected_repo_ids"]),
5705
+ "repo_paths": stored_repo_paths,
5706
+ "spec_source": {
5707
+ "schema": inspection["schema"],
5708
+ "spec_id": inspection["spec_id"],
5709
+ "revision": inspection["revision"],
5710
+ "path": source_path,
5711
+ "path_mode": path_mode,
5712
+ "design_sha256": inspection["design_sha256"],
5713
+ "document_sha256": inspection["document_sha256"],
5714
+ "execution_revision": inspection["execution_revision"],
5715
+ },
5716
+ "selected_spec_tasks": selection["selected_task_ids"],
5717
+ "spec_repositories": bindings,
5718
+ "spec_dependency_evidence": selection["dependency_records"],
5719
+ "spec_writeback_progress": {
5720
+ "last_execution_revision": inspection["execution_revision"],
5721
+ "status": "ok",
5722
+ "updated_at": now_iso(),
5723
+ },
5724
+ }
5725
+ return create_task(
5726
+ root,
5727
+ task_id,
5728
+ task_type,
5729
+ title,
5730
+ agent,
5731
+ set_current,
5732
+ session_file,
5733
+ fields,
5734
+ )
5735
+
5736
+
5737
+ SPEC_WRITEBACK_APP = "easy-coding"
5738
+
5739
+
5740
+ def spec_writeback_agent(agent: str) -> str:
5741
+ normalized = canonical_agent_identity(agent)
5742
+ if normalized is None:
5743
+ raise StateError("Canonical Spec attribution requires a canonical workflow agent identity.")
5744
+ display_name = {
5745
+ "claude-code": "Claude Code",
5746
+ "codex": "Codex",
5747
+ "qoder": "Qoder",
5748
+ "unknown": "Unknown Agent",
5749
+ }.get(normalized, normalized)
5750
+ return f"{display_name} with Easy Coding"
5751
+
5752
+
5753
+ def initialize_spec_execution_state(root: Path, spec_path: str) -> dict:
5754
+ raw_path = Path(spec_path).expanduser()
5755
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
5756
+ if not resolved.is_file():
5757
+ raise StateError("Canonical Spec path must identify an explicit UTF-8 file.")
5758
+ try:
5759
+ execution = initialize_execution(resolved)
5760
+ details = show_execution(resolved)
5761
+ except (ExecutionStateError, ExecutionConflictError) as exc:
5762
+ raise StateError(f"Cannot initialize Canonical Spec execution: {exc}") from exc
5763
+ return {
5764
+ "action": "initialize-spec-execution",
5765
+ "spec": str(resolved),
5766
+ "design_sha256": details["design_sha256"],
5767
+ "document_sha256": details["document_sha256"],
5768
+ "execution_revision": execution["execution_revision"],
5769
+ }
5770
+
5771
+
5772
+ def _spec_event(execution: dict, idempotency_key: str) -> dict:
5773
+ matches = [
5774
+ event
5775
+ for event in execution.get("events", [])
5776
+ if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
5777
+ ]
5778
+ if len(matches) != 1:
5779
+ raise StateError("Shared Spec writeback did not expose one matching idempotent event.")
5780
+ return matches[0]
5781
+
5782
+
5783
+ def _writeback_progress(task: dict) -> dict:
5784
+ progress = task.get("spec_writeback_progress")
5785
+ if not isinstance(progress, dict):
5786
+ progress = {}
5787
+ task["spec_writeback_progress"] = progress
5788
+ return progress
5789
+
5790
+
5791
+ def _is_idempotency_key_conflict(exc: ExecutionConflictError) -> bool:
5792
+ return str(exc).startswith("幂等键已被不同事件使用")
5793
+
5794
+
5795
+ def _execute_spec_writeback(
5796
+ root: Path,
5797
+ harness_task_id: str,
5798
+ task: dict,
5799
+ action: dict,
5800
+ idempotency_key: str,
5801
+ invoke,
5802
+ ) -> dict:
5803
+ inspection, _ = inspect_task_spec(root, task)
5804
+ source = task["spec_source"]
5805
+ progress = _writeback_progress(task)
5806
+ serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
5807
+ existing_pending = progress.get("pending_action")
5808
+ if isinstance(existing_pending, str) and existing_pending.strip():
5809
+ try:
5810
+ existing_action = json.loads(existing_pending)
5811
+ except json.JSONDecodeError as exc:
5812
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
5813
+ if existing_action != action:
5814
+ raise StateError(
5815
+ "A different Canonical Spec writeback is pending; run "
5816
+ "reconcile-spec-execution before starting another action."
5817
+ )
5818
+ progress.update(
5819
+ {
5820
+ "last_execution_revision": source["execution_revision"],
5821
+ "pending_action": serialized_action,
5822
+ "status": "pending",
5823
+ "updated_at": now_iso(),
5824
+ }
5825
+ )
5826
+ write_task(root, harness_task_id, task)
5827
+
5828
+ def call_writer(current_inspection: dict) -> dict:
5829
+ return invoke(
5830
+ str(current_inspection["design_sha256"]),
5831
+ int(current_inspection["execution_revision"]),
5832
+ )
5833
+
5834
+ try:
5835
+ execution = call_writer(inspection)
5836
+ except ExecutionConflictError:
5837
+ try:
5838
+ refreshed = inspect_spec(
5839
+ stored_spec_path(root, task),
5840
+ root,
5841
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
5842
+ task.get("selected_spec_tasks") or [],
5843
+ )
5844
+ except EasyDevSpecError as exc:
5845
+ progress["status"] = "error"
5846
+ progress["updated_at"] = now_iso()
5847
+ progress.pop("pending_action", None)
5848
+ write_task(root, harness_task_id, task)
5849
+ raise StateError(f"Cannot refresh Canonical Spec after CAS conflict: {exc}") from exc
5850
+ if refreshed.get("design_sha256") != source.get("design_sha256"):
5851
+ progress["status"] = "error"
5852
+ progress["updated_at"] = now_iso()
5853
+ progress.pop("pending_action", None)
5854
+ write_task(root, harness_task_id, task)
5855
+ raise StateError("Canonical Spec design changed during writeback; return to ANALYSIS.")
5856
+ if int(refreshed.get("execution_revision", -1)) < int(source["execution_revision"]):
5857
+ progress["status"] = "conflict"
5858
+ progress["updated_at"] = now_iso()
5859
+ write_task(root, harness_task_id, task)
5860
+ raise StateError("Canonical Spec execution revision moved backwards during writeback.")
5861
+ try:
5862
+ execution = call_writer(refreshed)
5863
+ except (ExecutionStateError, ExecutionConflictError) as exc:
5864
+ terminal_conflict = isinstance(
5865
+ exc, ExecutionConflictError
5866
+ ) and _is_idempotency_key_conflict(exc)
5867
+ progress["status"] = "error" if terminal_conflict else "conflict"
5868
+ progress["updated_at"] = now_iso()
5869
+ if terminal_conflict:
5870
+ progress.pop("pending_action", None)
5871
+ write_task(root, harness_task_id, task)
5872
+ raise StateError(f"Canonical Spec CAS retry failed: {exc}") from exc
5873
+ except ExecutionStateError as exc:
5874
+ progress["status"] = "error"
5875
+ progress["updated_at"] = now_iso()
5876
+ progress.pop("pending_action", None)
5877
+ write_task(root, harness_task_id, task)
5878
+ raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
5879
+
5880
+ event = _spec_event(execution, idempotency_key)
5881
+ try:
5882
+ details = show_execution(stored_spec_path(root, task))
5883
+ except ExecutionStateError as exc:
5884
+ raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
5885
+ source.update(
5886
+ {
5887
+ "revision": details["design_revision"],
5888
+ "design_sha256": details["design_sha256"],
5889
+ "document_sha256": details["document_sha256"],
5890
+ "execution_revision": execution["execution_revision"],
5891
+ }
5892
+ )
5893
+ inspect_task_spec(root, task)
5894
+ progress.update(
5895
+ {
5896
+ "last_execution_revision": execution["execution_revision"],
5897
+ "last_event_id": event["event_id"],
5898
+ "last_idempotency_key": idempotency_key,
5899
+ "status": "ok",
5900
+ "updated_at": now_iso(),
5901
+ }
5902
+ )
5903
+ progress.pop("pending_action", None)
5904
+ acknowledgment = {
5905
+ "type": "spec-writeback",
5906
+ "action": action,
5907
+ "event_id": event["event_id"],
5908
+ "execution_revision": execution["execution_revision"],
5909
+ "idempotency_key": idempotency_key,
5910
+ "timestamp": now_iso(),
5911
+ }
5912
+ already_acknowledged = any(
5913
+ record.get("type") == "spec-writeback"
5914
+ and record.get("idempotency_key") == idempotency_key
5915
+ for record in execution_records(root, harness_task_id)
5916
+ )
5917
+ if not already_acknowledged:
5918
+ append_execution_record(root, harness_task_id, acknowledgment)
5919
+ write_task(root, harness_task_id, task)
5920
+ return acknowledgment
5921
+
5922
+
5923
+ def writeback_spec_task(
5924
+ root: Path,
5925
+ source_task_id: str,
5926
+ status_value: str,
5927
+ summary: str,
5928
+ evidence: list[dict],
5929
+ idempotency_key: str,
5930
+ agent: str,
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 source_task_id not in set(task.get("selected_spec_tasks") or []):
5936
+ raise StateError("Canonical source task is outside the Harness task selection.")
5937
+ action = {
5938
+ "kind": "task",
5939
+ "source_task_id": source_task_id,
5940
+ "status": status_value,
5941
+ "summary": summary,
5942
+ "evidence": evidence,
5943
+ "idempotency_key": idempotency_key,
5944
+ "agent": agent,
5945
+ }
5946
+ acknowledgment = _execute_spec_writeback(
5947
+ root,
5948
+ resolved_task_id,
5949
+ task,
5950
+ action,
5951
+ idempotency_key,
5952
+ lambda design_digest, execution_revision: record_task_status(
5953
+ stored_spec_path(root, task),
5954
+ source_task_id,
5955
+ status_value,
5956
+ summary,
5957
+ SPEC_WRITEBACK_APP,
5958
+ spec_writeback_agent(agent),
5959
+ design_digest,
5960
+ execution_revision,
5961
+ evidence=evidence,
5962
+ run_id=resolved_task_id,
5963
+ idempotency_key=idempotency_key,
5964
+ ),
5965
+ )
5966
+ snapshot = snapshot_state(root, session_file, session)
5967
+ snapshot["spec_writeback"] = acknowledgment
5968
+ snapshot["action"] = "writeback-spec-task"
5969
+ return snapshot
5970
+
5971
+
5972
+ def writeback_spec_step(
5973
+ root: Path,
5974
+ source_task_id: str,
5975
+ step_id: str,
5976
+ status_value: str,
5977
+ summary: str,
5978
+ evidence: list[dict],
5979
+ idempotency_key: str,
5980
+ agent: str,
5981
+ task_id: str | None = None,
5982
+ session_file: str | Path | None = None,
5983
+ ) -> dict:
5984
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
5985
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
5986
+ raise StateError("Canonical source task is outside the Harness task selection.")
5987
+ action = {
5988
+ "kind": "step",
5989
+ "source_task_id": source_task_id,
5990
+ "step_id": step_id,
5991
+ "status": status_value,
5992
+ "summary": summary,
5993
+ "evidence": evidence,
5994
+ "idempotency_key": idempotency_key,
5995
+ "agent": agent,
5996
+ }
5997
+ acknowledgment = _execute_spec_writeback(
5998
+ root,
5999
+ resolved_task_id,
6000
+ task,
6001
+ action,
6002
+ idempotency_key,
6003
+ lambda design_digest, execution_revision: record_step_status(
6004
+ stored_spec_path(root, task),
6005
+ source_task_id,
6006
+ step_id,
6007
+ status_value,
6008
+ summary,
6009
+ SPEC_WRITEBACK_APP,
6010
+ spec_writeback_agent(agent),
6011
+ design_digest,
6012
+ execution_revision,
6013
+ evidence=evidence,
6014
+ run_id=resolved_task_id,
6015
+ idempotency_key=idempotency_key,
6016
+ ),
6017
+ )
6018
+ snapshot = snapshot_state(root, session_file, session)
6019
+ snapshot["spec_writeback"] = acknowledgment
6020
+ snapshot["action"] = "writeback-spec-step"
6021
+ return snapshot
6022
+
6023
+
6024
+ def writeback_spec_dependency(
6025
+ root: Path,
6026
+ source_task_id: str,
6027
+ dependency_task_id: str,
6028
+ status_value: str,
6029
+ summary: str,
6030
+ evidence: list[dict],
6031
+ idempotency_key: str,
6032
+ agent: str,
6033
+ task_id: str | None = None,
6034
+ session_file: str | Path | None = None,
6035
+ ) -> dict:
6036
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6037
+ if source_task_id not in set(task.get("selected_spec_tasks") or []):
6038
+ raise StateError("Canonical source task is outside the Harness task selection.")
6039
+ action = {
6040
+ "kind": "dependency",
6041
+ "source_task_id": source_task_id,
6042
+ "dependency_task_id": dependency_task_id,
6043
+ "status": status_value,
6044
+ "summary": summary,
6045
+ "evidence": evidence,
6046
+ "idempotency_key": idempotency_key,
6047
+ "agent": agent,
6048
+ }
6049
+ acknowledgment = _execute_spec_writeback(
6050
+ root,
6051
+ resolved_task_id,
6052
+ task,
6053
+ action,
6054
+ idempotency_key,
6055
+ lambda design_digest, execution_revision: record_dependency_status(
6056
+ stored_spec_path(root, task),
6057
+ source_task_id,
6058
+ dependency_task_id,
6059
+ status_value,
6060
+ summary,
6061
+ SPEC_WRITEBACK_APP,
6062
+ spec_writeback_agent(agent),
6063
+ design_digest,
6064
+ execution_revision,
6065
+ evidence=evidence,
6066
+ run_id=resolved_task_id,
6067
+ idempotency_key=idempotency_key,
6068
+ ),
6069
+ )
6070
+ snapshot = snapshot_state(root, session_file, session)
6071
+ snapshot["spec_writeback"] = acknowledgment
6072
+ snapshot["action"] = "writeback-spec-dependency"
6073
+ return snapshot
6074
+
6075
+
6076
+ def rebind_spec_source(
6077
+ root: Path,
6078
+ spec_path: str,
6079
+ agent: str,
6080
+ task_id: str | None = None,
6081
+ session_file: str | Path | None = None,
6082
+ ) -> dict:
6083
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6084
+ source = task.get("spec_source")
6085
+ if not isinstance(source, dict):
6086
+ raise StateError("Current task is not backed by a Canonical Spec.")
6087
+ raw_path = Path(spec_path).expanduser()
6088
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
6089
+ try:
6090
+ inspection = inspect_spec(
6091
+ resolved,
6092
+ root,
6093
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
6094
+ task.get("selected_spec_tasks") or [],
6095
+ )
6096
+ except EasyDevSpecError as exc:
6097
+ raise StateError(f"Cannot rebind Canonical Spec: {exc}") from exc
6098
+ for field in ("schema", "spec_id", "revision", "design_sha256"):
6099
+ expected = source.get(field)
6100
+ if field == "design_sha256" and expected is None and source.get("sha256") == inspection.get("source_sha256"):
6101
+ expected = inspection.get("design_sha256")
6102
+ if expected != inspection.get(field):
6103
+ raise StateError(f"Rebind rejected because Canonical Spec {field} does not match.")
6104
+ previous_execution_revision = source.get("execution_revision", 0)
6105
+ if int(inspection.get("execution_revision", -1)) < int(previous_execution_revision):
6106
+ raise StateError("Rebind rejected because Canonical execution revision moved backwards.")
6107
+ try:
6108
+ source_path = resolved.relative_to(root.resolve()).as_posix()
6109
+ path_mode = "project-relative"
6110
+ except ValueError:
6111
+ source_path = str(resolved)
6112
+ path_mode = "absolute"
6113
+ source.update({"path": source_path, "path_mode": path_mode})
6114
+ inspect_task_spec(root, task)
6115
+ task["last_agent"] = agent
6116
+ write_task(root, resolved_task_id, task)
6117
+ snapshot = snapshot_state(root, session_file, session)
6118
+ snapshot["action"] = "rebind-spec-source"
6119
+ return snapshot
6120
+
6121
+
6122
+ def reconcile_local_result_evidence(
6123
+ root: Path,
6124
+ resolved_task_id: str,
6125
+ task: dict,
6126
+ agent: str,
6127
+ session_file: str | Path | None,
6128
+ ) -> tuple[int, list[str]]:
6129
+ plan = latest_execution_plan(root, resolved_task_id)
6130
+ if not isinstance(plan, dict):
6131
+ return 0, []
6132
+ inspection, selection = inspect_task_spec(root, task)
6133
+ snapshots = _selected_execution_snapshots(inspection, task)
6134
+ units = {
6135
+ str(unit.get("id")): unit
6136
+ for unit in plan.get("units", [])
6137
+ if isinstance(unit, dict) and is_non_empty_string(unit.get("id"))
6138
+ }
6139
+ records = execution_records(root, resolved_task_id)
6140
+ last_plan_index = max(
6141
+ (index for index, record in enumerate(records) if record.get("type") == "plan"),
6142
+ default=-1,
6143
+ )
6144
+ lifecycle_by_unit: dict[str, list[tuple[int, dict]]] = {
6145
+ unit_id: [] for unit_id in units
6146
+ }
6147
+ for record_index, record in enumerate(records[last_plan_index + 1 :], last_plan_index + 1):
6148
+ unit_id = str(record.get("unit_id") or "")
6149
+ if record.get("type") in {"dispatch", "result"} and unit_id in lifecycle_by_unit:
6150
+ lifecycle_by_unit[unit_id].append((record_index, record))
6151
+ latest_results = {
6152
+ unit_id: lifecycle[-1]
6153
+ for unit_id, lifecycle in lifecycle_by_unit.items()
6154
+ if lifecycle and lifecycle[-1][1].get("type") == "result"
6155
+ }
6156
+ step_by_id = {
6157
+ str(step.get("step_id")): step
6158
+ for step in selection.get("selected_steps", [])
6159
+ if isinstance(step, dict)
6160
+ }
6161
+ test_by_id = {
6162
+ str(test.get("test_id")): test
6163
+ for test in selection.get("selected_tests", [])
6164
+ if isinstance(test, dict)
6165
+ }
6166
+ reconciled = 0
6167
+ unresolved: list[str] = []
6168
+ for unit_id, (result_index, result) in latest_results.items():
6169
+ unit = units.get(unit_id)
6170
+ if not unit:
6171
+ continue
6172
+ lifecycle = lifecycle_by_unit.get(unit_id, [])
6173
+ if len(lifecycle) < 2 or lifecycle[-2][1].get("type") != "dispatch":
6174
+ unresolved.append(f"{unit_id}:missing-matching-dispatch")
6175
+ continue
6176
+ dispatch_index, dispatch = lifecycle[-2]
6177
+ source_task_id = str(unit.get("source_task_id") or "")
6178
+ source_steps = [str(value) for value in unit.get("source_step_ids", [])]
6179
+ if source_task_id not in snapshots or not source_steps:
6180
+ continue
6181
+ if (
6182
+ dispatch.get("source_task_id") != source_task_id
6183
+ or dispatch.get("repo_id") != unit.get("repo_id")
6184
+ or result.get("source_task_id") != source_task_id
6185
+ or result.get("repo_id") != unit.get("repo_id")
6186
+ or not isinstance(result.get("changed_files"), list)
6187
+ or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
6188
+ or not is_non_empty_string(result.get("summary"))
6189
+ ):
6190
+ unresolved.append(f"{unit_id}:source-ownership-mismatch")
6191
+ continue
6192
+ current_status = snapshots[source_task_id].get("status")
6193
+ if current_status != "in_progress":
6194
+ unresolved.append(
6195
+ f"{unit_id}:shared-task-status={current_status or 'missing'}"
6196
+ )
6197
+ continue
6198
+ attempt_id, attempt_completed_steps = _shared_attempt_projection(
6199
+ inspection, source_task_id
6200
+ )
6201
+ if not attempt_id:
6202
+ unresolved.append(f"{unit_id}:missing-in-progress-attempt")
6203
+ continue
6204
+ attempt_ack_index = max(
6205
+ (
6206
+ index
6207
+ for index, record in enumerate(records)
6208
+ if record.get("type") == "spec-writeback"
6209
+ and record.get("event_id") == attempt_id
6210
+ and isinstance(record.get("action"), dict)
6211
+ and record["action"].get("kind") == "task"
6212
+ and record["action"].get("source_task_id") == source_task_id
6213
+ and record["action"].get("status") == "in_progress"
6214
+ ),
6215
+ default=-1,
6216
+ )
6217
+ if attempt_ack_index < 0:
6218
+ unresolved.append(f"{unit_id}:missing-in-progress-acknowledgment")
6219
+ continue
6220
+ if dispatch_index <= attempt_ack_index or result_index <= attempt_ack_index:
6221
+ unresolved.append(f"{unit_id}:no-result-for-current-attempt")
6222
+ continue
6223
+ result_status = result.get("status")
6224
+ successful = (
6225
+ result_status == "completed"
6226
+ and result.get("issues") == []
6227
+ and result.get("needs_attention") == []
6228
+ )
6229
+ failed = result_status == "failed"
6230
+ if not successful and not failed:
6231
+ unresolved.append(f"{unit_id}:invalid-result-status-or-issues")
6232
+ continue
6233
+ if failed:
6234
+ if len(source_steps) != 1:
6235
+ unresolved.append(f"{unit_id}:ambiguous-failed-source-step")
6236
+ continue
6237
+ step_id = source_steps[0]
6238
+ key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-failed"
6239
+ writeback_spec_step(
6240
+ root,
6241
+ source_task_id,
6242
+ step_id,
6243
+ "failed",
6244
+ str(result.get("summary") or f"Unit {unit_id} failed"),
6245
+ [
6246
+ {
6247
+ "kind": "result",
6248
+ "status": "failed",
6249
+ "ref": f"execution.jsonl#unit={unit_id}",
6250
+ }
6251
+ ],
6252
+ key,
6253
+ agent,
6254
+ resolved_task_id,
6255
+ session_file,
6256
+ )
6257
+ reconciled += 1
6258
+ unresolved.extend(
6259
+ f"{unit_id}:{remaining_step}:blocked-after-unit-failure"
6260
+ for remaining_step in source_steps[1:]
6261
+ )
6262
+ task = load_task(root, resolved_task_id) or task
6263
+ inspection, selection = inspect_task_spec(root, task)
6264
+ snapshots = _selected_execution_snapshots(inspection, task)
6265
+ continue
6266
+ passed_commands = {
6267
+ str(check.get("command"))
6268
+ for check in result.get("checks", [])
6269
+ if isinstance(check, dict)
6270
+ and check.get("passed") is True
6271
+ and is_non_empty_string(check.get("command"))
6272
+ }
6273
+ missing_unit_commands = sorted(set(unit.get("test_commands", [])) - passed_commands)
6274
+ if missing_unit_commands:
6275
+ unresolved.append(
6276
+ f"{unit_id}:missing-passed-command=" + ",".join(missing_unit_commands)
6277
+ )
6278
+ continue
6279
+ pending_steps = list(dict.fromkeys(source_steps))
6280
+ while pending_steps:
6281
+ ready_step_id = next(
6282
+ (
6283
+ step_id
6284
+ for step_id in pending_steps
6285
+ if step_id in attempt_completed_steps
6286
+ or set((step_by_id.get(step_id) or {}).get("depends_on_step_ids", []))
6287
+ .issubset(attempt_completed_steps)
6288
+ ),
6289
+ None,
6290
+ )
6291
+ if ready_step_id is None:
6292
+ unresolved.extend(
6293
+ f"{unit_id}:{step_id}:dependency-pending" for step_id in pending_steps
6294
+ )
6295
+ break
6296
+ step_id = ready_step_id
6297
+ pending_steps.remove(step_id)
6298
+ if step_id in attempt_completed_steps:
6299
+ continue
6300
+ step = step_by_id.get(step_id)
6301
+ if not step:
6302
+ unresolved.append(f"{unit_id}:{step_id}:missing-step")
6303
+ continue
6304
+ tests = [test_by_id.get(str(test_id)) for test_id in step.get("test_ids", [])]
6305
+ if any(not isinstance(test, dict) for test in tests):
6306
+ unresolved.append(f"{unit_id}:{step_id}:missing-test")
6307
+ continue
6308
+ missing_commands = [
6309
+ str(test.get("command"))
6310
+ for test in tests
6311
+ if str(test.get("command")) not in passed_commands
6312
+ ]
6313
+ if missing_commands:
6314
+ unresolved.append(
6315
+ f"{unit_id}:{step_id}:missing-passed-command=" + ",".join(missing_commands)
6316
+ )
6317
+ continue
6318
+ evidence = [
6319
+ {
6320
+ "kind": "test",
6321
+ "status": "passed",
6322
+ "ref": f"execution.jsonl#unit={unit_id};command={test.get('command')}",
6323
+ "test_id": str(test.get("test_id")),
6324
+ }
6325
+ for test in tests
6326
+ ]
6327
+ key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-completed"
6328
+ writeback_spec_step(
6329
+ root,
6330
+ source_task_id,
6331
+ step_id,
6332
+ "completed",
6333
+ str(result.get("summary") or f"Unit {unit_id} completed"),
6334
+ evidence,
6335
+ key,
6336
+ agent,
6337
+ resolved_task_id,
6338
+ session_file,
6339
+ )
6340
+ reconciled += 1
6341
+ task = load_task(root, resolved_task_id) or task
6342
+ inspection, selection = inspect_task_spec(root, task)
6343
+ snapshots = _selected_execution_snapshots(inspection, task)
6344
+ _, attempt_completed_steps = _shared_attempt_projection(
6345
+ inspection, source_task_id
6346
+ )
6347
+ task = load_task(root, resolved_task_id) or task
6348
+ inspection, _ = inspect_task_spec(root, task)
6349
+ snapshots = _selected_execution_snapshots(inspection, task)
6350
+ selected_tasks = {
6351
+ str(item.get("task_id")): item
6352
+ for item in selection.get("selected_tasks", [])
6353
+ if isinstance(item, dict)
6354
+ }
6355
+ for source_task_id, snapshot in snapshots.items():
6356
+ if snapshot.get("status") != "in_progress":
6357
+ continue
6358
+ expected_steps = set(selected_tasks.get(source_task_id, {}).get("step_ids", []))
6359
+ attempt_id, attempt_completed_steps = _shared_attempt_projection(
6360
+ inspection, source_task_id
6361
+ )
6362
+ if attempt_id and expected_steps and attempt_completed_steps == expected_steps:
6363
+ key = (
6364
+ f"{resolved_task_id}:{source_task_id}:{attempt_id}:"
6365
+ "implemented-from-results"
6366
+ )
6367
+ writeback_spec_task(
6368
+ root,
6369
+ source_task_id,
6370
+ "implemented",
6371
+ "All Canonical Steps have passed local implementation evidence",
6372
+ [],
6373
+ key,
6374
+ agent,
6375
+ resolved_task_id,
6376
+ session_file,
6377
+ )
6378
+ reconciled += 1
6379
+ return reconciled, unresolved
6380
+
6381
+
6382
+ def reconcile_spec_execution(
6383
+ root: Path,
6384
+ agent: str,
6385
+ task_id: str | None = None,
6386
+ session_file: str | Path | None = None,
6387
+ ) -> dict:
6388
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6389
+ progress = _writeback_progress(task)
6390
+ pending = progress.get("pending_action")
6391
+ if not isinstance(pending, str) or not pending.strip():
6392
+ reconciled, unresolved = reconcile_local_result_evidence(
6393
+ root,
6394
+ resolved_task_id,
6395
+ task,
6396
+ agent,
6397
+ session_file,
6398
+ )
6399
+ task = load_task(root, resolved_task_id) or task
6400
+ inspect_task_spec(root, task)
6401
+ progress.update(
6402
+ {
6403
+ "last_execution_revision": task["spec_source"]["execution_revision"],
6404
+ "status": "ok",
6405
+ "updated_at": now_iso(),
6406
+ }
6407
+ )
6408
+ write_task(root, resolved_task_id, task)
6409
+ snapshot = snapshot_state(root, session_file, session)
6410
+ snapshot["action"] = "reconcile-spec-execution"
6411
+ snapshot["reconciled"] = reconciled > 0
6412
+ snapshot["reconciled_actions"] = reconciled
6413
+ snapshot["unresolved_local_evidence"] = unresolved
6414
+ return snapshot
6415
+ try:
6416
+ action = json.loads(pending)
6417
+ except json.JSONDecodeError as exc:
6418
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
6419
+ kind = action.get("kind")
6420
+ if kind == "sync-design":
6421
+ affected_task_ids = action.get("affected_task_ids")
6422
+ if not is_string_list(affected_task_ids):
6423
+ raise StateError("Pending Canonical Spec design sync has invalid affected tasks.")
6424
+ result = sync_spec_design_state(
6425
+ root,
6426
+ affected_task_ids,
6427
+ str(action.get("summary") or "Reconciled Canonical Spec design sync"),
6428
+ str(action.get("idempotency_key") or ""),
6429
+ str(action.get("agent") or agent),
6430
+ resolved_task_id,
6431
+ session_file,
6432
+ )
6433
+ result["action"] = "reconcile-spec-execution"
6434
+ result["reconciled"] = True
6435
+ return result
6436
+ try:
6437
+ design_text, _ = split_execution_region(
6438
+ stored_spec_path(root, task).read_text(encoding="utf-8")
6439
+ )
6440
+ except (OSError, UnicodeError, ValueError) as exc:
6441
+ raise StateError(f"Cannot inspect pending Canonical Spec writeback: {exc}") from exc
6442
+ current_design_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
6443
+ source = task.get("spec_source")
6444
+ if not isinstance(source, dict):
6445
+ raise StateError("Current task is not backed by a Canonical Spec.")
6446
+ if current_design_sha256 != source.get("design_sha256"):
6447
+ # 旧设计上的进度事件不能重放到新设计;清除单槽 pending,允许后续 sync-design。
6448
+ progress["status"] = "error"
6449
+ progress["updated_at"] = now_iso()
6450
+ progress.pop("pending_action", None)
6451
+ write_task(root, resolved_task_id, task)
6452
+ raise StateError(
6453
+ "Pending Canonical Spec writeback belongs to an obsolete design and was "
6454
+ "discarded; return to ANALYSIS and run sync-spec-design."
6455
+ )
6456
+ common = {
6457
+ "root": root,
6458
+ "summary": str(action.get("summary") or "Reconciled shared Spec writeback"),
6459
+ "evidence": action.get("evidence") if isinstance(action.get("evidence"), list) else [],
6460
+ "idempotency_key": str(action.get("idempotency_key") or ""),
6461
+ "agent": str(action.get("agent") or agent),
6462
+ "task_id": resolved_task_id,
6463
+ "session_file": session_file,
6464
+ }
6465
+ if not common["idempotency_key"]:
6466
+ raise StateError("Pending Canonical Spec writeback has no idempotency key.")
6467
+ if kind == "task":
6468
+ result = writeback_spec_task(
6469
+ source_task_id=str(action.get("source_task_id") or ""),
6470
+ status_value=str(action.get("status") or ""),
6471
+ **common,
6472
+ )
6473
+ elif kind == "step":
6474
+ result = writeback_spec_step(
6475
+ source_task_id=str(action.get("source_task_id") or ""),
6476
+ step_id=str(action.get("step_id") or ""),
6477
+ status_value=str(action.get("status") or ""),
6478
+ **common,
6479
+ )
6480
+ elif kind == "dependency":
6481
+ result = writeback_spec_dependency(
6482
+ source_task_id=str(action.get("source_task_id") or ""),
6483
+ dependency_task_id=str(action.get("dependency_task_id") or ""),
6484
+ status_value=str(action.get("status") or ""),
6485
+ **common,
6486
+ )
6487
+ else:
6488
+ raise StateError("Pending Canonical Spec writeback kind is unsupported.")
6489
+ result["action"] = "reconcile-spec-execution"
6490
+ result["reconciled"] = True
6491
+ return result
6492
+
6493
+
6494
+ def sync_spec_design_state(
6495
+ root: Path,
6496
+ affected_task_ids: list[str],
6497
+ summary: str,
6498
+ idempotency_key: str,
6499
+ agent: str,
6500
+ task_id: str | None = None,
6501
+ session_file: str | Path | None = None,
6502
+ ) -> dict:
6503
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
6504
+ source = task.get("spec_source")
6505
+ if not isinstance(source, dict):
6506
+ raise StateError("Current task is not backed by a Canonical Spec.")
6507
+ spec_path = stored_spec_path(root, task)
6508
+ requested_task_ids = sorted(set(affected_task_ids))
6509
+
6510
+ def current_execution_envelope() -> dict:
6511
+ try:
6512
+ from easy_dev_spec_protocol import split_execution_region
6513
+
6514
+ _, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
6515
+ except (OSError, UnicodeError, ValueError) as exc:
6516
+ raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
6517
+ if not isinstance(execution, dict):
6518
+ raise StateError("Canonical Spec shared execution is missing before sync-design.")
6519
+ if execution.get("design_sha256") != source.get("design_sha256"):
6520
+ matching_events = [
6521
+ event
6522
+ for event in execution.get("events", [])
6523
+ if isinstance(event, dict)
6524
+ and event.get("type") == "spec_revised"
6525
+ and event.get("idempotency_key") == idempotency_key
6526
+ and event.get("requested_task_ids") == requested_task_ids
6527
+ and event.get("run_id") == resolved_task_id
6528
+ ]
6529
+ if len(matching_events) != 1:
6530
+ raise StateError(
6531
+ "Canonical Spec execution baseline no longer matches the bound design."
6532
+ )
6533
+ return execution
6534
+
6535
+ current_revision = int(current_execution_envelope().get("execution_revision", -1))
6536
+ progress = _writeback_progress(task)
6537
+ pending_action = {
6538
+ "kind": "sync-design",
6539
+ "affected_task_ids": requested_task_ids,
6540
+ "summary": summary,
6541
+ "idempotency_key": idempotency_key,
6542
+ "agent": agent,
6543
+ }
6544
+ serialized_pending_action = json.dumps(
6545
+ pending_action, ensure_ascii=False, sort_keys=True
6546
+ )
6547
+ existing_pending = progress.get("pending_action")
6548
+ if isinstance(existing_pending, str) and existing_pending.strip():
6549
+ try:
6550
+ existing_action = json.loads(existing_pending)
6551
+ except json.JSONDecodeError as exc:
6552
+ raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
6553
+ if existing_action != pending_action:
6554
+ raise StateError(
6555
+ "A different Canonical Spec writeback is pending; run "
6556
+ "reconcile-spec-execution before sync-design."
6557
+ )
6558
+ progress.update(
6559
+ {
6560
+ "last_execution_revision": current_revision,
6561
+ "pending_action": serialized_pending_action,
6562
+ "status": "pending",
6563
+ "updated_at": now_iso(),
6564
+ }
6565
+ )
6566
+ write_task(root, resolved_task_id, task)
6567
+
6568
+ def invoke_sync(execution_revision: int) -> dict:
6569
+ return sync_design(
6570
+ spec_path,
6571
+ requested_task_ids,
6572
+ summary,
6573
+ SPEC_WRITEBACK_APP,
6574
+ spec_writeback_agent(agent),
6575
+ str(source.get("design_sha256")),
6576
+ execution_revision,
6577
+ run_id=resolved_task_id,
6578
+ idempotency_key=idempotency_key,
6579
+ )
6580
+
6581
+ try:
6582
+ execution = invoke_sync(current_revision)
6583
+ except ExecutionConflictError:
6584
+ try:
6585
+ execution = invoke_sync(
6586
+ int(current_execution_envelope().get("execution_revision", -1))
6587
+ )
6588
+ except ExecutionStateError as exc:
6589
+ progress["status"] = "error"
6590
+ progress["updated_at"] = now_iso()
6591
+ progress.pop("pending_action", None)
6592
+ write_task(root, resolved_task_id, task)
6593
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
6594
+ except ExecutionConflictError as exc:
6595
+ terminal_conflict = _is_idempotency_key_conflict(exc)
6596
+ progress["status"] = "error" if terminal_conflict else "conflict"
6597
+ progress["updated_at"] = now_iso()
6598
+ if terminal_conflict:
6599
+ progress.pop("pending_action", None)
6600
+ write_task(root, resolved_task_id, task)
6601
+ raise StateError(f"Cannot synchronize Canonical Spec design after CAS retry: {exc}") from exc
6602
+ except ExecutionStateError as exc:
6603
+ progress["status"] = "error"
6604
+ progress["updated_at"] = now_iso()
6605
+ progress.pop("pending_action", None)
6606
+ write_task(root, resolved_task_id, task)
6607
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
6608
+ try:
6609
+ details = show_execution(spec_path)
6610
+ inspection = inspect_spec(
6611
+ spec_path,
6612
+ root,
6613
+ task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
6614
+ task.get("selected_spec_tasks") or [],
6615
+ )
6616
+ except (ExecutionStateError, EasyDevSpecError) as exc:
6617
+ raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
6618
+ if inspection.get("spec_id") != source.get("spec_id"):
6619
+ raise StateError("Synchronized Canonical Spec identity changed unexpectedly.")
6620
+ binding_was_synchronized = (
6621
+ source.get("revision") == inspection.get("revision")
6622
+ and source.get("design_sha256") == inspection.get("design_sha256")
6623
+ )
6624
+ source.update(
6625
+ {
6626
+ "revision": inspection["revision"],
6627
+ "design_sha256": inspection["design_sha256"],
6628
+ "document_sha256": inspection["document_sha256"],
6629
+ "execution_revision": execution["execution_revision"],
6630
+ }
6631
+ )
6632
+ event = _spec_event(execution, idempotency_key)
6633
+ if not binding_was_synchronized:
6634
+ reset_task_ids = set(event.get("task_ids", []))
6635
+ refreshed_dependencies: list[dict] = []
6636
+ for dependency in task.get("spec_dependency_evidence", []):
6637
+ if not isinstance(dependency, dict):
6638
+ continue
6639
+ refreshed = dict(dependency)
6640
+ if refreshed.get("source_task_id") in reset_task_ids:
6641
+ refreshed["status"] = "pending"
6642
+ refreshed["shared_status"] = "pending"
6643
+ for field in ("evidence", "satisfied_at", "satisfied_by"):
6644
+ refreshed.pop(field, None)
6645
+ refreshed_dependencies.append(refreshed)
6646
+ task["spec_dependency_evidence"] = refreshed_dependencies
6647
+ inspect_task_spec(root, task)
6648
+ progress.update(
6649
+ {
6650
+ "last_execution_revision": execution["execution_revision"],
6651
+ "last_event_id": event["event_id"],
6652
+ "last_idempotency_key": idempotency_key,
6653
+ "status": "ok",
6654
+ "updated_at": now_iso(),
6655
+ }
6656
+ )
6657
+ progress.pop("pending_action", None)
6658
+ if task.get("status") not in {"INIT", "ANALYSIS"}:
6659
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
6660
+ task["status"] = "ANALYSIS"
6661
+ append_stage_history(task, "ANALYSIS", agent)
6662
+ task.pop("pending_transition", None)
6663
+ task["last_agent"] = agent
6664
+ already_acknowledged = any(
6665
+ record.get("type") == "spec-design-sync"
6666
+ and record.get("idempotency_key") == idempotency_key
6667
+ for record in execution_records(root, resolved_task_id)
6668
+ )
6669
+ if not already_acknowledged:
6670
+ append_execution_record(
6671
+ root,
6672
+ resolved_task_id,
6673
+ {
6674
+ "type": "spec-design-sync",
6675
+ "affected_task_ids": requested_task_ids,
6676
+ "event_id": event["event_id"],
6677
+ "design_sha256": details["design_sha256"],
6678
+ "execution_revision": execution["execution_revision"],
6679
+ "idempotency_key": idempotency_key,
6680
+ "timestamp": now_iso(),
6681
+ },
6682
+ )
6683
+ write_task(root, resolved_task_id, task)
6684
+ snapshot = snapshot_state(root, session_file, session)
6685
+ snapshot["action"] = "sync-spec-design"
6686
+ return snapshot
6687
+
6688
+
6689
+ def _selected_execution_snapshots(inspection: dict, task: dict) -> dict[str, dict]:
6690
+ selected = set(task.get("selected_spec_tasks") or [])
6691
+ execution = inspection.get("execution")
6692
+ if not isinstance(execution, dict):
6693
+ raise StateError("Canonical Spec shared execution is unavailable.")
6694
+ return {
6695
+ str(snapshot.get("task_id")): snapshot
6696
+ for snapshot in execution.get("tasks", [])
6697
+ if isinstance(snapshot, dict) and snapshot.get("task_id") in selected
6698
+ }
6699
+
6700
+
6701
+ def _shared_attempt_projection(
6702
+ inspection: dict, source_task_id: str
6703
+ ) -> tuple[str | None, set[str]]:
6704
+ execution = inspection.get("execution")
6705
+ if not isinstance(execution, dict):
6706
+ return None, set()
6707
+ events = [event for event in execution.get("events", []) if isinstance(event, dict)]
6708
+ start_index = next(
6709
+ (
6710
+ index
6711
+ for index in range(len(events) - 1, -1, -1)
6712
+ if events[index].get("type") == "task_status_changed"
6713
+ and events[index].get("task_id") == source_task_id
6714
+ and events[index].get("to_status") == "in_progress"
6715
+ ),
6716
+ None,
6717
+ )
6718
+ if start_index is None:
6719
+ return None, set()
6720
+ start_event = events[start_index]
6721
+ completed_steps: set[str] = set()
6722
+ for event in events[start_index + 1 :]:
6723
+ if event.get("type") != "step_status_changed" or event.get("task_id") != source_task_id:
6724
+ continue
6725
+ step_id = str(event.get("step_id") or "")
6726
+ if not step_id:
6727
+ continue
6728
+ if event.get("step_status") == "completed":
6729
+ completed_steps.add(step_id)
6730
+ elif event.get("step_status") == "failed":
6731
+ completed_steps.discard(step_id)
6732
+ return str(start_event.get("event_id") or "") or None, completed_steps
6733
+
6734
+
6735
+ def _snapshot_dependencies_ready(snapshot: dict, all_snapshots: dict[str, dict]) -> bool:
6736
+ for dependency in snapshot.get("dependencies", []):
6737
+ if not isinstance(dependency, dict) or dependency.get("type") not in {"hard", "contract"}:
6738
+ continue
6739
+ if dependency.get("status") == "satisfied":
6740
+ continue
6741
+ if dependency.get("type") == "hard" and all_snapshots.get(
6742
+ str(dependency.get("task_id")), {}
6743
+ ).get("status") == "completed":
6744
+ continue
6745
+ return False
6746
+ return True
6747
+
6748
+
6749
+ def writeback_ready_tasks_for_implement(
6750
+ root: Path,
6751
+ harness_task_id: str,
6752
+ task: dict,
6753
+ agent: str,
6754
+ restart_statuses: set[str] | None = None,
6755
+ ) -> None:
6756
+ inspection, _ = inspect_task_spec(root, task)
6757
+ implement_attempt = 1 + sum(
6758
+ 1
6759
+ for entry in task.get("stage_history", [])
6760
+ if isinstance(entry, dict) and entry.get("stage") == "IMPLEMENT"
6761
+ )
6762
+ all_snapshots = {
6763
+ str(snapshot.get("task_id")): snapshot
6764
+ for snapshot in inspection["execution"].get("tasks", [])
6765
+ if isinstance(snapshot, dict)
6766
+ }
6767
+ selected_snapshots = _selected_execution_snapshots(inspection, task)
6768
+ for source_task_id in task.get("selected_spec_tasks") or []:
6769
+ snapshot = selected_snapshots.get(str(source_task_id))
6770
+ if not snapshot or snapshot.get("status") == "in_progress":
6771
+ continue
6772
+ if restart_statuses is not None and snapshot.get("status") not in restart_statuses:
6773
+ continue
6774
+ if not _snapshot_dependencies_ready(snapshot, all_snapshots):
6775
+ continue
6776
+ key = (
6777
+ f"{harness_task_id}:{source_task_id}:enter-implement:"
6778
+ f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
6779
+ )
6780
+ action = {
6781
+ "kind": "task",
6782
+ "source_task_id": source_task_id,
6783
+ "status": "in_progress",
6784
+ "summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
6785
+ "evidence": [],
6786
+ "idempotency_key": key,
6787
+ "agent": agent,
6788
+ }
6789
+ _execute_spec_writeback(
6790
+ root,
6791
+ harness_task_id,
6792
+ task,
6793
+ action,
6794
+ key,
6795
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
6796
+ stored_spec_path(root, task),
6797
+ str(source_task_id),
6798
+ "in_progress",
6799
+ "Harness entered IMPLEMENT for a dependency-ready Canonical task",
6800
+ SPEC_WRITEBACK_APP,
6801
+ spec_writeback_agent(agent),
6802
+ design_digest,
6803
+ execution_revision,
6804
+ run_id=harness_task_id,
6805
+ idempotency_key=key,
6806
+ ),
6807
+ )
6808
+
6809
+
6810
+ def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> None:
6811
+ inspection, _ = inspect_task_spec(root, task)
6812
+ snapshots = _selected_execution_snapshots(inspection, task)
6813
+ invalid = [
6814
+ f"{task_id}:{snapshots.get(str(task_id), {}).get('status', 'missing')}"
6815
+ for task_id in task.get("selected_spec_tasks") or []
6816
+ if snapshots.get(str(task_id), {}).get("status") not in allowed
6817
+ ]
6818
+ if invalid:
6819
+ raise StateError(
6820
+ "Canonical Spec writeback is incomplete for selected tasks: " + ", ".join(invalid)
6821
+ )
6822
+
6823
+
6824
+ def effective_verification_records(root: Path, task_id: str, task: dict) -> list[dict]:
6825
+ fingerprints = evidence_fingerprints(root, task_id)
6826
+ accepted_fingerprints, _ = accepted_verification_fingerprints(
6827
+ root,
6828
+ task_id,
6829
+ task,
6830
+ fingerprints["implementation_fingerprint"],
6831
+ fingerprints["config_fingerprint"],
6832
+ )
6833
+ latest: dict[tuple[str, str, str], dict] = {}
6834
+ for record in execution_records(root, task_id):
6835
+ if (
6836
+ record.get("type") != "verify"
6837
+ or record.get("implementation_fingerprint") not in accepted_fingerprints
6838
+ or record.get("config_fingerprint") != fingerprints["config_fingerprint"]
6839
+ or record.get("applicable") is False
6840
+ ):
6841
+ continue
6842
+ key = (
6843
+ str(record.get("source_task_id") or ""),
6844
+ str(record.get("repo_id") or ""),
6845
+ str(record.get("command") or record.get("check") or ""),
6846
+ )
6847
+ latest[key] = record
6848
+ return list(latest.values())
6849
+
6850
+
6851
+ def acceptance_spec_evidence(acceptance: dict) -> dict:
6852
+ digest = str(acceptance.get("diff_sha256") or "")
6853
+ reference = (
6854
+ "execution.jsonl#acceptance="
6855
+ + digest
6856
+ + ";authorization="
6857
+ + str(acceptance.get("authorization") or "")
6858
+ + ";approval_mode="
6859
+ + str(acceptance.get("approval_mode") or "")
6860
+ + ";review_policy="
6861
+ + str(acceptance.get("review_policy") or "")
6862
+ + ";verification_policy="
6863
+ + str(acceptance.get("verification_policy") or "")
6864
+ + ";targeted_source_tasks="
6865
+ + ",".join(str(value) for value in acceptance.get("required_targeted_source_tasks", []))
6866
+ )
6867
+ return {
6868
+ "kind": "acceptance",
6869
+ "status": "recorded",
6870
+ "ref": reference,
6871
+ "sha256": digest,
6872
+ }
6873
+
6874
+
6875
+ def writeback_verified_tasks(
6876
+ root: Path,
6877
+ harness_task_id: str,
6878
+ task: dict,
6879
+ agent: str,
6880
+ session_file: str | Path | None = None,
6881
+ ) -> None:
6882
+ inspection, selection = inspect_task_spec(root, task)
6883
+ snapshots = _selected_execution_snapshots(inspection, task)
6884
+ acceptance = latest_acceptance_record(root, harness_task_id, task)
6885
+ if not isinstance(acceptance, dict):
6886
+ raise StateError("Canonical verification writeback requires an acceptance record.")
6887
+ verification_records = effective_verification_records(root, harness_task_id, task)
6888
+ selected_tasks = {
6889
+ str(item.get("task_id")): item
6890
+ for item in selection.get("selected_tasks", [])
6891
+ if isinstance(item, dict)
6892
+ }
6893
+ tests_by_task: dict[str, list[dict]] = {}
6894
+ for test in selection.get("selected_tests", []):
6895
+ if isinstance(test, dict):
6896
+ tests_by_task.setdefault(str(test.get("task_id")), []).append(test)
6897
+ for source_task_id in task.get("selected_spec_tasks") or []:
6898
+ source_task_id = str(source_task_id)
6899
+ status_value = snapshots.get(source_task_id, {}).get("status")
6900
+ if status_value in {"verified", "completed"}:
6901
+ continue
6902
+ if status_value != "implemented":
6903
+ raise StateError(
6904
+ f"Canonical task {source_task_id} must remain implemented until MEMORY entry is applied."
6905
+ )
6906
+ repo_id = str(selected_tasks.get(source_task_id, {}).get("repo_id") or "")
6907
+ evidence = []
6908
+ for test in tests_by_task.get(source_task_id, []):
6909
+ command = str(test.get("command") or "")
6910
+ matching = next(
6911
+ (
6912
+ record
6913
+ for record in verification_records
6914
+ if record.get("passed") is True
6915
+ and str(record.get("source_task_id") or "") == source_task_id
6916
+ and str(record.get("repo_id") or "") == repo_id
6917
+ and str(record.get("command") or "") == command
6918
+ ),
6919
+ None,
6920
+ )
6921
+ if matching is None:
6922
+ raise StateError(
6923
+ f"Canonical Test {test.get('test_id')} has no accepted verification command: {command}"
6924
+ )
6925
+ evidence.append(
6926
+ {
6927
+ "kind": "test",
6928
+ "status": "passed",
6929
+ "ref": f"execution.jsonl#verify;command={command}",
6930
+ "test_id": str(test.get("test_id")),
6931
+ }
6932
+ )
6933
+ evidence.append(acceptance_spec_evidence(acceptance))
6934
+ acceptance_key = str(acceptance.get("diff_sha256") or "")[:16]
6935
+ key = (
6936
+ f"{harness_task_id}:{source_task_id}:verified-after-acceptance:"
6937
+ f"{task['spec_source']['revision']}:{acceptance_key}"
6938
+ )
6939
+ writeback_spec_task(
6940
+ root,
6941
+ source_task_id,
6942
+ "verified",
6943
+ "Harness verification was accepted when the MEMORY boundary was applied",
6944
+ evidence,
6945
+ key,
6946
+ agent,
6947
+ harness_task_id,
6948
+ session_file,
6949
+ )
6950
+
6951
+
6952
+ def writeback_completed_tasks(
6953
+ root: Path,
6954
+ harness_task_id: str,
6955
+ task: dict,
6956
+ agent: str,
6957
+ ) -> None:
6958
+ inspection, _ = inspect_task_spec(root, task)
6959
+ snapshots = _selected_execution_snapshots(inspection, task)
6960
+ acceptance = latest_acceptance_record(root, harness_task_id, task)
6961
+ acceptance_evidence = (
6962
+ [acceptance_spec_evidence(acceptance)] if isinstance(acceptance, dict) else []
6963
+ )
6964
+ for source_task_id in task.get("selected_spec_tasks") or []:
6965
+ status_value = snapshots.get(str(source_task_id), {}).get("status")
6966
+ if status_value == "completed":
6967
+ continue
6968
+ if status_value != "verified":
6969
+ raise StateError(
6970
+ f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
6971
+ )
6972
+ acceptance_key = (
6973
+ str(acceptance.get("diff_sha256") or "")[:16]
6974
+ if isinstance(acceptance, dict)
6975
+ else "legacy"
6976
+ )
6977
+ key = (
6978
+ f"{harness_task_id}:{source_task_id}:complete:"
6979
+ f"{task['spec_source']['revision']}:{acceptance_key}"
6980
+ )
6981
+ action = {
6982
+ "kind": "task",
6983
+ "source_task_id": source_task_id,
6984
+ "status": "completed",
6985
+ "summary": "Harness MEMORY completed and the Canonical task is complete",
6986
+ "evidence": acceptance_evidence,
6987
+ "idempotency_key": key,
6988
+ "agent": agent,
6989
+ }
6990
+ _execute_spec_writeback(
6991
+ root,
6992
+ harness_task_id,
6993
+ task,
6994
+ action,
6995
+ key,
6996
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
6997
+ stored_spec_path(root, task),
6998
+ str(source_task_id),
6999
+ "completed",
7000
+ "Harness MEMORY completed and the Canonical task is complete",
7001
+ SPEC_WRITEBACK_APP,
7002
+ spec_writeback_agent(agent),
7003
+ design_digest,
7004
+ execution_revision,
7005
+ evidence=acceptance_evidence,
7006
+ run_id=harness_task_id,
7007
+ idempotency_key=key,
7008
+ ),
7009
+ )
3846
7010
 
3847
7011
 
3848
- def create_task_from_spec(
7012
+ def cancel_shared_tasks(
3849
7013
  root: Path,
3850
- spec_path: str,
3851
- spec_task_ids: list[str],
3852
- task_id: str,
3853
- task_type: str,
3854
- title: str,
3855
- repo_paths: dict[str, str],
3856
- dependency_evidence: dict[str, str],
7014
+ harness_task_id: str,
7015
+ task: dict,
7016
+ reason: str,
3857
7017
  agent: str,
3858
- set_current: bool = True,
3859
- session_file: str | Path | None = None,
3860
- ) -> dict:
3861
- raw_spec_path = Path(spec_path)
3862
- resolved_spec_path = ensure_path_inside_root(
3863
- root,
3864
- raw_spec_path if raw_spec_path.is_absolute() else root / raw_spec_path,
3865
- "Canonical Spec path",
3866
- )
3867
- try:
3868
- inspection = inspect_spec(
3869
- resolved_spec_path,
7018
+ ) -> None:
7019
+ inspection, _ = inspect_task_spec(root, task)
7020
+ snapshots = _selected_execution_snapshots(inspection, task)
7021
+ for source_task_id in task.get("selected_spec_tasks") or []:
7022
+ current = snapshots.get(str(source_task_id), {}).get("status")
7023
+ if current in {"completed", "cancelled"}:
7024
+ continue
7025
+ if current in {"implemented", "verified"}:
7026
+ blocked_key = f"{harness_task_id}:{source_task_id}:close-blocked"
7027
+ blocked_action = {
7028
+ "kind": "task",
7029
+ "source_task_id": source_task_id,
7030
+ "status": "blocked",
7031
+ "summary": reason,
7032
+ "evidence": [],
7033
+ "idempotency_key": blocked_key,
7034
+ "agent": agent,
7035
+ }
7036
+ _execute_spec_writeback(
7037
+ root,
7038
+ harness_task_id,
7039
+ task,
7040
+ blocked_action,
7041
+ blocked_key,
7042
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
7043
+ stored_spec_path(root, task),
7044
+ str(source_task_id),
7045
+ "blocked",
7046
+ reason,
7047
+ SPEC_WRITEBACK_APP,
7048
+ spec_writeback_agent(agent),
7049
+ design_digest,
7050
+ execution_revision,
7051
+ run_id=harness_task_id,
7052
+ idempotency_key=blocked_key,
7053
+ ),
7054
+ )
7055
+ cancel_key = f"{harness_task_id}:{source_task_id}:cancel"
7056
+ cancel_action = {
7057
+ "kind": "task",
7058
+ "source_task_id": source_task_id,
7059
+ "status": "cancelled",
7060
+ "summary": reason,
7061
+ "evidence": [],
7062
+ "idempotency_key": cancel_key,
7063
+ "agent": agent,
7064
+ }
7065
+ _execute_spec_writeback(
3870
7066
  root,
3871
- repo_paths,
3872
- spec_task_ids,
7067
+ harness_task_id,
7068
+ task,
7069
+ cancel_action,
7070
+ cancel_key,
7071
+ lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
7072
+ stored_spec_path(root, task),
7073
+ str(source_task_id),
7074
+ "cancelled",
7075
+ reason,
7076
+ SPEC_WRITEBACK_APP,
7077
+ spec_writeback_agent(agent),
7078
+ design_digest,
7079
+ execution_revision,
7080
+ run_id=harness_task_id,
7081
+ idempotency_key=cancel_key,
7082
+ ),
3873
7083
  )
3874
- selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
3875
- except EasyDevSpecError as exc:
3876
- raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
3877
-
3878
- selected_repo_ids = set(selection["selected_repo_ids"])
3879
- bindings = [
3880
- binding
3881
- for binding in inspection["repository_bindings"]
3882
- if binding.get("repo_id") in selected_repo_ids
3883
- ]
3884
- if len(bindings) != len(selected_repo_ids):
3885
- raise StateError("Canonical Spec repository bindings do not cover every selected task.")
3886
- stored_repo_paths = {
3887
- str(binding["repo_id"]): str(binding["path"])
3888
- for binding in bindings
3889
- }
3890
- source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
3891
- fields = {
3892
- "repos": list(selection["selected_repo_ids"]),
3893
- "repo_paths": stored_repo_paths,
3894
- "spec_source": {
3895
- "schema": inspection["schema"],
3896
- "spec_id": inspection["spec_id"],
3897
- "revision": inspection["revision"],
3898
- "path": source_path,
3899
- "sha256": inspection["source_sha256"],
3900
- },
3901
- "selected_spec_tasks": selection["selected_task_ids"],
3902
- "spec_repositories": bindings,
3903
- "spec_dependency_evidence": selection["dependency_records"],
3904
- }
3905
- return create_task(
3906
- root,
3907
- task_id,
3908
- task_type,
3909
- title,
3910
- agent,
3911
- set_current,
3912
- session_file,
3913
- fields,
3914
- )
3915
7084
 
3916
7085
 
3917
7086
  def satisfy_spec_dependency(
@@ -3953,7 +7122,42 @@ def satisfy_spec_dependency(
3953
7122
  record["satisfied_at"] = now_iso()
3954
7123
  record["satisfied_by"] = agent
3955
7124
  task["last_agent"] = agent
3956
- write_task(root, resolved_task_id, task)
7125
+ evidence_digest = hashlib.sha256(evidence.strip().encode("utf-8")).hexdigest()[:16]
7126
+ idempotency_key = (
7127
+ f"{resolved_task_id}:{record.get('source_task_id')}:{dependency_task_id}:"
7128
+ f"dependency-satisfied:revision-{task['spec_source']['revision']}:{evidence_digest}"
7129
+ )
7130
+ action = {
7131
+ "kind": "dependency",
7132
+ "source_task_id": str(record.get("source_task_id")),
7133
+ "dependency_task_id": dependency_task_id,
7134
+ "status": "satisfied",
7135
+ "summary": evidence.strip(),
7136
+ "evidence": [{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
7137
+ "idempotency_key": idempotency_key,
7138
+ "agent": agent,
7139
+ }
7140
+ _execute_spec_writeback(
7141
+ root,
7142
+ resolved_task_id,
7143
+ task,
7144
+ action,
7145
+ idempotency_key,
7146
+ lambda design_digest, execution_revision: record_dependency_status(
7147
+ stored_spec_path(root, task),
7148
+ str(record.get("source_task_id")),
7149
+ dependency_task_id,
7150
+ "satisfied",
7151
+ evidence.strip(),
7152
+ SPEC_WRITEBACK_APP,
7153
+ spec_writeback_agent(agent),
7154
+ design_digest,
7155
+ execution_revision,
7156
+ evidence=[{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
7157
+ run_id=resolved_task_id,
7158
+ idempotency_key=idempotency_key,
7159
+ ),
7160
+ )
3957
7161
  snapshot = snapshot_state(root, session_file, session)
3958
7162
  snapshot["action"] = "satisfy-spec-dependency"
3959
7163
  return snapshot
@@ -4040,54 +7244,65 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
4040
7244
  if not plan:
4041
7245
  raise StateError("Cannot calculate workflow floor without a valid execution plan.")
4042
7246
  units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
7247
+ missing_local_baseline = [
7248
+ str(unit.get("id") or "<unknown>")
7249
+ for unit in units
7250
+ if not is_string_list(unit.get("local_baseline"), allow_empty=False)
7251
+ ]
7252
+ if missing_local_baseline:
7253
+ raise StateError(
7254
+ "Workflow plan Units must record a non-empty local_baseline: "
7255
+ + ", ".join(missing_local_baseline)
7256
+ )
4043
7257
  files = {
4044
7258
  str(file_name)
4045
7259
  for unit in units
4046
7260
  for file_name in unit.get("files", [])
4047
7261
  if is_non_empty_string(file_name)
4048
7262
  }
4049
- repositories = task_repository_roots(root, task, plan)
4050
- repos = task.get("repos")
4051
- repo_paths = task.get("repo_paths")
4052
- metadata_repo_count = max(
4053
- len(repos) if isinstance(repos, list) else 0,
4054
- len(repo_paths) if isinstance(repo_paths, dict) else 0,
4055
- )
4056
- repo_count = max(len(repositories), metadata_repo_count)
4057
- risk_text = " ".join(
4058
- [
4059
- str(task.get("title") or ""),
4060
- task_type,
4061
- *files,
4062
- *[
4063
- str(item)
4064
- for unit in units
4065
- for field in ("risks", "contracts")
4066
- for item in unit.get(field, [])
4067
- if is_non_empty_string(item)
4068
- and str(item).strip().lower() not in {"none", "no", "n/a", "无", "无风险"}
4069
- ],
7263
+ repositories = workflow_plan_repository_roots(root, task, plan)
7264
+ ignored_values = {"none", "no", "n/a", "无", "无风险"}
7265
+ risk_values = [
7266
+ str(item)
7267
+ for unit in units
7268
+ for item in unit.get("risks", [])
7269
+ if is_non_empty_string(item) and str(item).strip().lower() not in ignored_values
7270
+ ]
7271
+ contract_values = [
7272
+ str(item)
7273
+ for unit in units
7274
+ for item in unit.get("contracts", [])
7275
+ if is_non_empty_string(item) and str(item).strip().lower() not in ignored_values
7276
+ ]
7277
+ risk_text = NEGATED_HIGH_WORKFLOW_RISK_PATTERN.sub("", " ".join(risk_values))
7278
+ high_risk = bool(HIGH_WORKFLOW_RISK_PATTERN.search(risk_text))
7279
+
7280
+ complexity_reasons: list[str] = []
7281
+ if len(repositories) > 1:
7282
+ complexity_reasons.append("cross-repository-change")
7283
+ if len(units) >= 4 or len(files) >= 10:
7284
+ complexity_reasons.append("broad-change-scope")
7285
+ if WIDE_WORKFLOW_CONTRACT_PATTERN.search(" ".join(contract_values)):
7286
+ complexity_reasons.append("wide-contract-impact")
7287
+ if high_risk and complexity_reasons:
7288
+ return "strict", [
7289
+ "compound-high-risk-and-complexity",
7290
+ "explicit-high-risk-signal",
7291
+ *complexity_reasons,
4070
7292
  ]
4071
- )
4072
- strict_reasons: list[str] = []
4073
- if repo_count > 1:
4074
- strict_reasons.append("cross-repository-scope")
4075
- if len(units) >= 4 or len(files) >= 8:
4076
- strict_reasons.append("broad-change-scope")
4077
- if STRICT_WORKFLOW_RISK_PATTERN.search(risk_text):
4078
- strict_reasons.append("high-risk-contract-or-domain")
4079
- if strict_reasons:
4080
- return "strict", strict_reasons
4081
7293
 
4082
7294
  standard_reasons: list[str] = []
7295
+ if high_risk:
7296
+ standard_reasons.append("bounded-high-risk-change")
7297
+ standard_reasons.extend(complexity_reasons)
4083
7298
  if len(units) > 1:
4084
7299
  standard_reasons.append("multiple-units")
4085
- if len(files) >= 3:
7300
+ if len(files) > 5:
4086
7301
  standard_reasons.append("multi-file-impact")
4087
7302
  if plan.get("strategy") == "parallel":
4088
7303
  standard_reasons.append("parallel-execution")
4089
7304
  if standard_reasons:
4090
- return "standard", standard_reasons
7305
+ return "standard", list(dict.fromkeys(standard_reasons))
4091
7306
  return "fast", ["single-bounded-unit"]
4092
7307
 
4093
7308
 
@@ -4139,9 +7354,14 @@ def freeze_tdd_mode(
4139
7354
  ) -> None:
4140
7355
  behavior = resolve_behavior(root, session)
4141
7356
  task_type = str(task.get("type") or "").strip().lower()
4142
- task["tdd_enabled"] = behavior[8] if task_type not in NO_CODE_TASK_TYPES else False
7357
+ task["tdd_enabled"] = (
7358
+ behavior[8]
7359
+ if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
7360
+ else False
7361
+ )
4143
7362
  task["tdd_coverage_threshold"] = behavior[11]
4144
7363
  if task["tdd_enabled"] is True:
7364
+ require_tdd_readiness(root)
4145
7365
  plan = latest_execution_plan(root, task_id)
4146
7366
  if plan is None:
4147
7367
  raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
@@ -4242,8 +7462,22 @@ def request_transition(
4242
7462
  )
4243
7463
  if previous == "REVIEW" and stage == "VERIFICATION":
4244
7464
  validate_review_readiness(root, resolved_task_id, task)
7465
+ acceptance_drift: dict | None = None
4245
7466
  if previous == "VERIFICATION" and stage == "MEMORY":
4246
- validate_verification_readiness(root, resolved_task_id, task)
7467
+ task = ensure_verification_checkpoint(
7468
+ root, resolved_task_id, task, agent, session_file
7469
+ )
7470
+ acceptance_drift = inspect_acceptance_drift(root, resolved_task_id, task)
7471
+ if acceptance_drift["config_changed"]:
7472
+ raise StateError(
7473
+ "Behavior config changed after verification; rerun verification before MEMORY."
7474
+ )
7475
+ if acceptance_drift["metadata_changed"]:
7476
+ raise StateError(
7477
+ "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
7478
+ )
7479
+ if acceptance_drift["status"] == "clean":
7480
+ validate_verification_readiness(root, resolved_task_id, task)
4247
7481
  existing = task.get("pending_transition")
4248
7482
  if isinstance(existing, dict):
4249
7483
  if existing.get("from") != previous or existing.get("to") != stage:
@@ -4263,6 +7497,8 @@ def request_transition(
4263
7497
 
4264
7498
  snapshot = snapshot_state(root, session_file, session)
4265
7499
  snapshot["action"] = "request-transition"
7500
+ if acceptance_drift is not None:
7501
+ snapshot["acceptance_drift"] = acceptance_drift
4266
7502
  return snapshot
4267
7503
 
4268
7504
 
@@ -4289,14 +7525,31 @@ def apply_transition(
4289
7525
  if task.get("workflow_mode_legacy") is not True:
4290
7526
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4291
7527
  freeze_tdd_mode(root, session, resolved_task_id, task, agent)
7528
+ if stage == "IMPLEMENT" and previous != "IMPLEMENT":
7529
+ if isinstance(task.get("spec_source"), dict):
7530
+ writeback_ready_tasks_for_implement(
7531
+ root,
7532
+ resolved_task_id,
7533
+ task,
7534
+ agent,
7535
+ {"blocked"} if previous in {"REVIEW", "VERIFICATION"} else None,
7536
+ )
4292
7537
  if previous == "REVIEW" and stage == "VERIFICATION":
4293
7538
  validate_review_readiness(root, resolved_task_id, task)
4294
7539
  if previous == "VERIFICATION" and stage == "MEMORY":
4295
7540
  validate_verification_readiness(root, resolved_task_id, task)
7541
+ if isinstance(task.get("spec_source"), dict):
7542
+ writeback_verified_tasks(
7543
+ root, resolved_task_id, task, agent, session_file
7544
+ )
7545
+ task = load_task(root, resolved_task_id) or task
7546
+ require_shared_task_statuses(root, task, {"verified", "completed"})
4296
7547
  if previous == "MEMORY" and stage == "COMPLETE":
4297
7548
  progress = task.get("memory_progress")
4298
7549
  if not isinstance(progress, dict) or progress.get("completed") is not True:
4299
7550
  raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
7551
+ if isinstance(task.get("spec_source"), dict):
7552
+ writeback_completed_tasks(root, resolved_task_id, task, agent)
4300
7553
  if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
4301
7554
  validate_read_only_completion(root, resolved_task_id)
4302
7555
  if previous != stage:
@@ -4311,6 +7564,8 @@ def apply_transition(
4311
7564
  task.pop("workflow_mode_legacy_direct_edge", None)
4312
7565
  if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
4313
7566
  task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
7567
+ if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
7568
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
4314
7569
  task.pop("pending_transition", None)
4315
7570
  if stage == "MEMORY" and previous != stage:
4316
7571
  task["memory_progress"] = {}
@@ -4335,7 +7590,7 @@ def auto_transition(
4335
7590
  task_id: str | None = None,
4336
7591
  session_file: str | Path | None = None,
4337
7592
  ) -> dict:
4338
- session, _, task = resolve_current_task(root, task_id, session_file)
7593
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4339
7594
  previous = str(task.get("status") or "idle")
4340
7595
  task_type = str(task.get("type") or "")
4341
7596
  approval_mode = resolve_approval_mode(root, session)[2]
@@ -4352,6 +7607,43 @@ def auto_transition(
4352
7607
  "A different transition is already pending. Cancel it before automatic transition."
4353
7608
  )
4354
7609
 
7610
+ if previous == "VERIFICATION" and stage == "MEMORY":
7611
+ task = ensure_verification_checkpoint(
7612
+ root, resolved_task_id, task, agent, session_file
7613
+ )
7614
+ drift = inspect_acceptance_drift(root, resolved_task_id, task)
7615
+ if drift["config_changed"]:
7616
+ raise StateError(
7617
+ "Behavior config changed after verification; rerun verification before MEMORY."
7618
+ )
7619
+ if drift["metadata_changed"]:
7620
+ raise StateError(
7621
+ "Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
7622
+ )
7623
+ if drift["changed_files"]:
7624
+ task["pending_transition"] = {
7625
+ "from": previous,
7626
+ "to": stage,
7627
+ "requested_at": now_iso(),
7628
+ "requested_by": agent,
7629
+ "reason": "verification checkpoint drift requires exact user acceptance",
7630
+ "confirmation_override": "evidence-drift",
7631
+ }
7632
+ task["last_agent"] = agent
7633
+ write_task(root, resolved_task_id, task)
7634
+ snapshot = snapshot_state(root, session_file, session)
7635
+ snapshot["action"] = "acceptance-drift"
7636
+ snapshot["acceptance_drift"] = drift
7637
+ return snapshot
7638
+ append_transition_acceptance(
7639
+ root,
7640
+ resolved_task_id,
7641
+ task,
7642
+ agent,
7643
+ approval_mode,
7644
+ "approval-policy",
7645
+ )
7646
+
4355
7647
  snapshot = apply_transition(root, stage, agent, task_id, session_file)
4356
7648
  snapshot["action"] = "auto-transition"
4357
7649
  snapshot["automatic_transition"] = {"from": previous, "to": stage}
@@ -4364,8 +7656,11 @@ def confirm_transition(
4364
7656
  stage: str | None = None,
4365
7657
  task_id: str | None = None,
4366
7658
  session_file: str | Path | None = None,
7659
+ expected_diff_sha256: str | None = None,
7660
+ verification_policy: str | None = None,
7661
+ decision_summary: str | None = None,
4367
7662
  ) -> dict:
4368
- session, _, task = resolve_current_task(root, task_id, session_file)
7663
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4369
7664
  pending = task.get("pending_transition")
4370
7665
  if not isinstance(pending, dict):
4371
7666
  raise StateError("No transition is pending user confirmation.")
@@ -4380,12 +7675,29 @@ def confirm_transition(
4380
7675
  )
4381
7676
  if stage and stage != target:
4382
7677
  raise StateError(f"Pending transition targets {target}, not {stage}.")
4383
- if is_automatic_transition(source, target, task_type, approval_mode):
7678
+ drift_override = pending.get("confirmation_override") == "evidence-drift"
7679
+ if is_automatic_transition(source, target, task_type, approval_mode) and not drift_override:
4384
7680
  raise StateError(
4385
7681
  f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
4386
7682
  "use auto-transition instead."
4387
7683
  )
4388
7684
 
7685
+ if source == "VERIFICATION" and target == "MEMORY":
7686
+ task = ensure_verification_checkpoint(
7687
+ root, resolved_task_id, task, agent, session_file
7688
+ )
7689
+ append_transition_acceptance(
7690
+ root,
7691
+ resolved_task_id,
7692
+ task,
7693
+ agent,
7694
+ approval_mode,
7695
+ "explicit-user",
7696
+ expected_diff_sha256,
7697
+ verification_policy,
7698
+ decision_summary,
7699
+ )
7700
+
4389
7701
  snapshot = apply_transition(root, target, agent, task_id, session_file)
4390
7702
  snapshot["action"] = "confirm-transition"
4391
7703
  snapshot["confirmed_transition"] = {"from": source, "to": target}
@@ -4426,6 +7738,46 @@ def memory_short_complete(
4426
7738
  memory_file.strip(),
4427
7739
  require_current_id=True,
4428
7740
  )
7741
+ acceptance = latest_acceptance_record(root, resolved_task_id, task)
7742
+ if isinstance(acceptance, dict) and acceptance.get("changed_files"):
7743
+ try:
7744
+ memory_text = resolved_memory_path.read_text(encoding="utf-8")
7745
+ except (OSError, UnicodeError) as exc:
7746
+ raise StateError(f"Cannot read short-memory file: {resolved_memory_path}") from exc
7747
+ required_decision_fields = {
7748
+ "diff_sha256": str(acceptance.get("diff_sha256") or ""),
7749
+ "authorization": str(acceptance.get("authorization") or ""),
7750
+ "approval_mode": str(acceptance.get("approval_mode") or ""),
7751
+ "review_policy": str(acceptance.get("review_policy") or ""),
7752
+ "verification_policy": str(acceptance.get("verification_policy") or ""),
7753
+ "summary": str(acceptance.get("summary") or ""),
7754
+ }
7755
+ missing_decision_fields = [
7756
+ field_name
7757
+ for field_name, value in required_decision_fields.items()
7758
+ if not value or value not in memory_text
7759
+ ]
7760
+ missing_changed_files = [
7761
+ str(file_name)
7762
+ for file_name in acceptance.get("changed_files", [])
7763
+ if not is_non_empty_string(file_name) or str(file_name) not in memory_text
7764
+ ]
7765
+ missing_targeted_tasks = [
7766
+ str(source_task_id)
7767
+ for source_task_id in acceptance.get("required_targeted_source_tasks", [])
7768
+ if not is_non_empty_string(source_task_id)
7769
+ or str(source_task_id) not in memory_text
7770
+ ]
7771
+ if missing_decision_fields or missing_changed_files or missing_targeted_tasks:
7772
+ missing_labels = [
7773
+ *missing_decision_fields,
7774
+ *(f"changed_file:{file_name}" for file_name in missing_changed_files),
7775
+ *(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
7776
+ ]
7777
+ raise StateError(
7778
+ "Short memory must record the complete accepted post-verification decision; "
7779
+ "missing: " + ", ".join(missing_labels)
7780
+ )
4429
7781
  progress = task.get("memory_progress")
4430
7782
  if not isinstance(progress, dict):
4431
7783
  progress = {}
@@ -4523,6 +7875,7 @@ def memory_complete(
4523
7875
  action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
4524
7876
  ),
4525
7877
  )
7878
+ validate_recorded_architecture_assessment(root, progress, instruction)
4526
7879
  if action == "distill":
4527
7880
  validate_distillation_file_sets(root, instruction)
4528
7881
  progress["long_memory_action"] = action
@@ -4550,7 +7903,10 @@ def close_current_task(
4550
7903
  task = load_task(root, str(task_id))
4551
7904
  if task is None:
4552
7905
  raise StateError(f"Task not found: {task_id}")
7906
+ if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
7907
+ cancel_shared_tasks(root, str(task_id), task, reason, agent)
4553
7908
  if task.get("status") != "CLOSED":
7909
+ cleanup_verification_checkpoint(root, str(task_id), task)
4554
7910
  task["status"] = "CLOSED"
4555
7911
  append_stage_history(task, "CLOSED", agent)
4556
7912
  task.pop("pending_transition", None)
@@ -4651,6 +8007,19 @@ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
4651
8007
  return mappings
4652
8008
 
4653
8009
 
8010
+ def parse_evidence_args(values: list[str]) -> list[dict]:
8011
+ evidence: list[dict] = []
8012
+ for value in values:
8013
+ try:
8014
+ parsed = json.loads(value)
8015
+ except json.JSONDecodeError as exc:
8016
+ raise StateError(f"--evidence must be a JSON object: {exc}") from exc
8017
+ if not isinstance(parsed, dict):
8018
+ raise StateError("--evidence must be a JSON object.")
8019
+ evidence.append(parsed)
8020
+ return evidence
8021
+
8022
+
4654
8023
  def main() -> int:
4655
8024
  configure_stdio()
4656
8025
  common = argparse.ArgumentParser(add_help=False)
@@ -4667,6 +8036,11 @@ def main() -> int:
4667
8036
  inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
4668
8037
  inspect_spec_parser.add_argument("--spec", required=True)
4669
8038
  inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
8039
+ inspect_spec_parser.add_argument("--spec-task", action="append", default=[])
8040
+ inspect_spec_parser.add_argument("--manifest-only", action="store_true")
8041
+
8042
+ initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
8043
+ initialize_spec.add_argument("--spec", required=True)
4670
8044
 
4671
8045
  select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
4672
8046
  select_spec_scope.add_argument("--spec", required=True)
@@ -4685,11 +8059,71 @@ def main() -> int:
4685
8059
  create_from_spec.add_argument("--task-id", required=True)
4686
8060
  create_from_spec.add_argument("--type", required=True)
4687
8061
  create_from_spec.add_argument("--title", required=True)
4688
- create_from_spec.add_argument("--repo-path", required=True, action="append")
8062
+ create_from_spec.add_argument("--repo-path", action="append", default=[])
4689
8063
  create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
4690
8064
  create_from_spec.add_argument("--agent", required=True)
4691
8065
  create_from_spec.add_argument("--no-set-current", action="store_true")
4692
8066
 
8067
+ rebind_spec = subcommands.add_parser("rebind-spec-source", parents=[common])
8068
+ rebind_spec.add_argument("--spec", required=True)
8069
+ rebind_spec.add_argument("--agent", required=True)
8070
+ rebind_spec.add_argument("--task-id")
8071
+
8072
+ writeback_task = subcommands.add_parser("writeback-spec-task", parents=[common])
8073
+ writeback_task.add_argument("--spec-task", required=True)
8074
+ writeback_task.add_argument(
8075
+ "--status",
8076
+ required=True,
8077
+ choices=[
8078
+ "in_progress",
8079
+ "blocked",
8080
+ "implemented",
8081
+ "verified",
8082
+ "completed",
8083
+ "cancelled",
8084
+ ],
8085
+ )
8086
+ writeback_task.add_argument("--summary", required=True)
8087
+ writeback_task.add_argument("--evidence", action="append", default=[])
8088
+ writeback_task.add_argument("--idempotency-key", required=True)
8089
+ writeback_task.add_argument("--agent", required=True)
8090
+ writeback_task.add_argument("--task-id")
8091
+
8092
+ writeback_step = subcommands.add_parser("writeback-spec-step", parents=[common])
8093
+ writeback_step.add_argument("--spec-task", required=True)
8094
+ writeback_step.add_argument("--step", required=True)
8095
+ writeback_step.add_argument("--status", required=True, choices=["completed", "failed"])
8096
+ writeback_step.add_argument("--summary", required=True)
8097
+ writeback_step.add_argument("--evidence", action="append", default=[])
8098
+ writeback_step.add_argument("--idempotency-key", required=True)
8099
+ writeback_step.add_argument("--agent", required=True)
8100
+ writeback_step.add_argument("--task-id")
8101
+
8102
+ writeback_dependency = subcommands.add_parser(
8103
+ "writeback-spec-dependency", parents=[common]
8104
+ )
8105
+ writeback_dependency.add_argument("--source-task", required=True)
8106
+ writeback_dependency.add_argument("--dependency-task", required=True)
8107
+ writeback_dependency.add_argument(
8108
+ "--status", required=True, choices=["pending", "satisfied"]
8109
+ )
8110
+ writeback_dependency.add_argument("--summary", required=True)
8111
+ writeback_dependency.add_argument("--evidence", action="append", default=[])
8112
+ writeback_dependency.add_argument("--idempotency-key", required=True)
8113
+ writeback_dependency.add_argument("--agent", required=True)
8114
+ writeback_dependency.add_argument("--task-id")
8115
+
8116
+ sync_spec = subcommands.add_parser("sync-spec-design", parents=[common])
8117
+ sync_spec.add_argument("--affected-task", action="append", default=[])
8118
+ sync_spec.add_argument("--summary", required=True)
8119
+ sync_spec.add_argument("--idempotency-key", required=True)
8120
+ sync_spec.add_argument("--agent", required=True)
8121
+ sync_spec.add_argument("--task-id")
8122
+
8123
+ reconcile_spec = subcommands.add_parser("reconcile-spec-execution", parents=[common])
8124
+ reconcile_spec.add_argument("--agent", required=True)
8125
+ reconcile_spec.add_argument("--task-id")
8126
+
4693
8127
  set_current = subcommands.add_parser("set-current", parents=[common])
4694
8128
  set_current.add_argument("--task-id", required=True)
4695
8129
  set_current.add_argument("--agent", required=True)
@@ -4766,6 +8200,18 @@ def main() -> int:
4766
8200
  fingerprints_parser.add_argument("--agent", required=True)
4767
8201
  fingerprints_parser.add_argument("--task-id")
4768
8202
 
8203
+ verification_checkpoint_parser = subcommands.add_parser(
8204
+ "verification-checkpoint", parents=[common]
8205
+ )
8206
+ verification_checkpoint_parser.add_argument("--agent", required=True)
8207
+ verification_checkpoint_parser.add_argument("--task-id")
8208
+
8209
+ inspect_transition_drift_parser = subcommands.add_parser(
8210
+ "inspect-transition-drift", parents=[common]
8211
+ )
8212
+ inspect_transition_drift_parser.add_argument("--agent", required=True)
8213
+ inspect_transition_drift_parser.add_argument("--task-id")
8214
+
4769
8215
  disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
4770
8216
  disable_harness_parser.add_argument("--agent", required=True)
4771
8217
 
@@ -4791,6 +8237,11 @@ def main() -> int:
4791
8237
  confirm_transition_parser.add_argument("--stage")
4792
8238
  confirm_transition_parser.add_argument("--agent", required=True)
4793
8239
  confirm_transition_parser.add_argument("--task-id")
8240
+ confirm_transition_parser.add_argument("--diff-sha256")
8241
+ confirm_transition_parser.add_argument(
8242
+ "--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
8243
+ )
8244
+ confirm_transition_parser.add_argument("--decision-summary")
4794
8245
 
4795
8246
  auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
4796
8247
  auto_transition_parser.add_argument("--stage", required=True)
@@ -4802,6 +8253,11 @@ def main() -> int:
4802
8253
  transition.add_argument("--stage")
4803
8254
  transition.add_argument("--agent", required=True)
4804
8255
  transition.add_argument("--task-id")
8256
+ transition.add_argument("--diff-sha256")
8257
+ transition.add_argument(
8258
+ "--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
8259
+ )
8260
+ transition.add_argument("--decision-summary")
4805
8261
 
4806
8262
  cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
4807
8263
  cancel_transition_parser.add_argument("--agent", required=True)
@@ -4819,6 +8275,18 @@ def main() -> int:
4819
8275
  memory_instruction_parser.add_argument("--agent")
4820
8276
  memory_instruction_parser.add_argument("--task-id")
4821
8277
 
8278
+ memory_architecture_parser = subcommands.add_parser(
8279
+ "memory-architecture-assessment", parents=[common]
8280
+ )
8281
+ memory_architecture_parser.add_argument(
8282
+ "--action", required=True, choices=sorted(ARCHITECTURE_ACTIONS)
8283
+ )
8284
+ memory_architecture_parser.add_argument("--reason", required=True)
8285
+ memory_architecture_parser.add_argument("--evidence", action="append", default=[])
8286
+ memory_architecture_parser.add_argument("--affected-section", action="append", default=[])
8287
+ memory_architecture_parser.add_argument("--agent", required=True)
8288
+ memory_architecture_parser.add_argument("--task-id")
8289
+
4822
8290
  memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
4823
8291
  memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
4824
8292
  memory_complete_parser.add_argument("--agent", required=True)
@@ -4849,9 +8317,8 @@ def main() -> int:
4849
8317
  root = resolve_root(getattr(args, "cwd", None))
4850
8318
  session_file = getattr(args, "session_file", None)
4851
8319
  command = args.command or "snapshot"
4852
- agent = normalize_agent_identity(
4853
- getattr(args, "agent", None) or detect_runtime_agent()
4854
- )
8320
+ agent = resolve_state_agent(getattr(args, "agent", None))
8321
+ validate_session_agent(agent, session_file)
4855
8322
  session_agent = normalize_session_agent(agent)
4856
8323
  visible_agent = None if agent == "unknown" else agent
4857
8324
  if session_file is None and command == "project-init-complete":
@@ -4860,6 +8327,7 @@ def main() -> int:
4860
8327
  )
4861
8328
  if session_file is None and command not in {
4862
8329
  "inspect-dev-spec",
8330
+ "initialize-spec-execution",
4863
8331
  "select-dev-spec-scope",
4864
8332
  "list-tasks",
4865
8333
  "memory-new-id",
@@ -4872,18 +8340,26 @@ def main() -> int:
4872
8340
  if command == "snapshot":
4873
8341
  emit(snapshot_state(root, session_file))
4874
8342
  elif command == "inspect-dev-spec":
4875
- spec_path = Path(args.spec)
4876
- emit(
4877
- inspection_summary(
4878
- inspect_spec(
4879
- spec_path if spec_path.is_absolute() else root / spec_path,
4880
- root,
4881
- parse_mapping_args(args.repo_path, "--repo-path"),
4882
- )
8343
+ spec_path = Path(args.spec).expanduser()
8344
+ if args.manifest_only and args.spec_task:
8345
+ raise StateError("--manifest-only cannot be combined with --spec-task")
8346
+ resolved_spec = spec_path if spec_path.is_absolute() else root / spec_path
8347
+ repo_paths = parse_mapping_args(args.repo_path, "--repo-path")
8348
+ inspection = (
8349
+ inspect_manifest(resolved_spec, root, repo_paths)
8350
+ if args.manifest_only
8351
+ else inspect_spec(
8352
+ resolved_spec,
8353
+ root,
8354
+ repo_paths,
8355
+ args.spec_task or None,
4883
8356
  )
4884
8357
  )
8358
+ emit(inspection_summary(inspection))
8359
+ elif command == "initialize-spec-execution":
8360
+ emit(initialize_spec_execution_state(root, args.spec))
4885
8361
  elif command == "select-dev-spec-scope":
4886
- spec_path = Path(args.spec)
8362
+ spec_path = Path(args.spec).expanduser()
4887
8363
  emit(
4888
8364
  select_consumption_scopes(
4889
8365
  spec_path if spec_path.is_absolute() else root / spec_path,
@@ -4934,6 +8410,100 @@ def main() -> int:
4934
8410
  session_file,
4935
8411
  )
4936
8412
  )
8413
+ elif command == "rebind-spec-source":
8414
+ emit(
8415
+ attach_status_context(
8416
+ root,
8417
+ rebind_spec_source(root, args.spec, agent, args.task_id, session_file),
8418
+ agent,
8419
+ session_file,
8420
+ )
8421
+ )
8422
+ elif command == "writeback-spec-task":
8423
+ emit(
8424
+ attach_status_context(
8425
+ root,
8426
+ writeback_spec_task(
8427
+ root,
8428
+ args.spec_task,
8429
+ args.status,
8430
+ args.summary,
8431
+ parse_evidence_args(args.evidence),
8432
+ args.idempotency_key,
8433
+ agent,
8434
+ args.task_id,
8435
+ session_file,
8436
+ ),
8437
+ agent,
8438
+ session_file,
8439
+ )
8440
+ )
8441
+ elif command == "writeback-spec-step":
8442
+ emit(
8443
+ attach_status_context(
8444
+ root,
8445
+ writeback_spec_step(
8446
+ root,
8447
+ args.spec_task,
8448
+ args.step,
8449
+ args.status,
8450
+ args.summary,
8451
+ parse_evidence_args(args.evidence),
8452
+ args.idempotency_key,
8453
+ agent,
8454
+ args.task_id,
8455
+ session_file,
8456
+ ),
8457
+ agent,
8458
+ session_file,
8459
+ )
8460
+ )
8461
+ elif command == "writeback-spec-dependency":
8462
+ emit(
8463
+ attach_status_context(
8464
+ root,
8465
+ writeback_spec_dependency(
8466
+ root,
8467
+ args.source_task,
8468
+ args.dependency_task,
8469
+ args.status,
8470
+ args.summary,
8471
+ parse_evidence_args(args.evidence),
8472
+ args.idempotency_key,
8473
+ agent,
8474
+ args.task_id,
8475
+ session_file,
8476
+ ),
8477
+ agent,
8478
+ session_file,
8479
+ )
8480
+ )
8481
+ elif command == "sync-spec-design":
8482
+ emit(
8483
+ attach_status_context(
8484
+ root,
8485
+ sync_spec_design_state(
8486
+ root,
8487
+ args.affected_task,
8488
+ args.summary,
8489
+ args.idempotency_key,
8490
+ agent,
8491
+ args.task_id,
8492
+ session_file,
8493
+ ),
8494
+ agent,
8495
+ session_file,
8496
+ )
8497
+ )
8498
+ elif command == "reconcile-spec-execution":
8499
+ emit(
8500
+ attach_status_context(
8501
+ root,
8502
+ reconcile_spec_execution(root, agent, args.task_id, session_file),
8503
+ agent,
8504
+ session_file,
8505
+ )
8506
+ )
4937
8507
  elif command == "set-current":
4938
8508
  emit(
4939
8509
  attach_status_context(
@@ -5095,6 +8665,28 @@ def main() -> int:
5095
8665
  session_file,
5096
8666
  )
5097
8667
  )
8668
+ elif command == "verification-checkpoint":
8669
+ emit(
8670
+ attach_status_context(
8671
+ root,
8672
+ record_verification_checkpoint(
8673
+ root, agent, args.task_id, session_file
8674
+ ),
8675
+ agent,
8676
+ session_file,
8677
+ )
8678
+ )
8679
+ elif command == "inspect-transition-drift":
8680
+ emit(
8681
+ attach_status_context(
8682
+ root,
8683
+ inspect_transition_drift(
8684
+ root, agent, args.task_id, session_file
8685
+ ),
8686
+ agent,
8687
+ session_file,
8688
+ )
8689
+ )
5098
8690
  elif command == "disable-harness":
5099
8691
  emit(
5100
8692
  attach_status_context(
@@ -5151,7 +8743,16 @@ def main() -> int:
5151
8743
  emit(
5152
8744
  attach_status_context(
5153
8745
  root,
5154
- confirm_transition(root, agent, args.stage, args.task_id, session_file),
8746
+ confirm_transition(
8747
+ root,
8748
+ agent,
8749
+ args.stage,
8750
+ args.task_id,
8751
+ session_file,
8752
+ args.diff_sha256,
8753
+ args.verification_policy,
8754
+ args.decision_summary,
8755
+ ),
5155
8756
  agent,
5156
8757
  session_file,
5157
8758
  )
@@ -5200,6 +8801,24 @@ def main() -> int:
5200
8801
  session_file,
5201
8802
  )
5202
8803
  )
8804
+ elif command == "memory-architecture-assessment":
8805
+ emit(
8806
+ attach_status_context(
8807
+ root,
8808
+ record_architecture_assessment(
8809
+ root,
8810
+ args.action,
8811
+ args.reason,
8812
+ args.evidence,
8813
+ args.affected_section,
8814
+ agent,
8815
+ args.task_id,
8816
+ session_file,
8817
+ ),
8818
+ agent,
8819
+ session_file,
8820
+ )
8821
+ )
5203
8822
  elif command == "memory-complete":
5204
8823
  emit(
5205
8824
  attach_status_context(