easy-coding-harness 1.1.0-beta.2 → 1.1.0-beta.4

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.
@@ -41,51 +41,119 @@ from easy_dev_spec_execution import (
41
41
  )
42
42
  from easy_dev_spec_protocol import split_execution_region
43
43
 
44
-
45
- TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
46
- HELP_SUFFIX = (
47
- "Use `ec-workflow` to start or resume a task, "
48
- "`ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, "
49
- "or `ec-config` to inspect or change modes"
44
+ from easy_coding_store import (
45
+ ALWAYS_AUTO_TRANSITIONS,
46
+ ANALYSIS_CONFIRM_TRANSITION,
47
+ APPROVAL_MODES,
48
+ CODEX_AGENT_PATH_PATTERN,
49
+ CONFIGURED_WORKFLOW_MODES,
50
+ COVERAGE_TOOL_PATH,
51
+ CRITICAL_CONFIRM_TRANSITIONS,
52
+ DEFAULT_APPROVAL_MODE,
53
+ DEFAULT_UNIT_TEST_MODE,
54
+ DEFAULT_UT_COVERAGE_THRESHOLD,
55
+ DEFAULT_WORKFLOW_MODE,
56
+ GITLAB_CI_ENTRY_FILES,
57
+ HELP_SUFFIX,
58
+ INSTALLED_WORKFLOW_AGENT,
59
+ JAVA_BUILD_FILE_NAMES,
60
+ LEGACY_DISPLAY_AGENT_IDENTITIES,
61
+ LEGACY_STAGE_MAP,
62
+ LEGACY_STATE_LOCK_POLL_SECONDS,
63
+ LEGACY_STATE_LOCK_STALE_SECONDS,
64
+ LEGACY_STATE_LOCK_TIMEOUT_SECONDS,
65
+ MANDATORY_DEV_SPEC_HEADERS,
66
+ MAX_SESSION_FILES,
67
+ READY_LINE,
68
+ SESSION_AGENT_NAMESPACES,
69
+ SESSION_ATTACHED_RETENTION_HOURS,
70
+ SESSION_COMMAND_LOCK_POLL_SECONDS,
71
+ SESSION_COMMAND_LOCK_STALE_SECONDS,
72
+ SESSION_COMMAND_LOCK_TIMEOUT_SECONDS,
73
+ SESSION_COMPONENT_PATTERN,
74
+ SESSION_IDLE_RETENTION_HOURS,
75
+ StateError,
76
+ TDD_BASE_VARIABLE,
77
+ TDD_INIT_TASK_TYPE,
78
+ TDD_READINESS_PATH,
79
+ TDD_READINESS_SCHEMA,
80
+ TDD_READINESS_SCOPE,
81
+ TDD_THRESHOLD_VARIABLE,
82
+ TERMINAL_STATUSES,
83
+ VALID_TRANSITIONS,
84
+ WAITING_INIT_LINE,
85
+ WORKFLOW_AGENT_IDENTITIES,
86
+ _execution_records,
87
+ _read_behavior_file,
88
+ acquire_legacy_state_lock,
89
+ acquire_session_command_lock,
90
+ agents_equivalent,
91
+ apply_hook_session_identity,
92
+ assert_safe_task_id,
93
+ behavior_layers,
94
+ canonical_agent_identity,
95
+ clean_orphan_acceptance_snapshots,
96
+ clean_session_runtime,
97
+ clean_stale_sessions,
98
+ clear_session_pointer,
99
+ default_session,
100
+ detect_runtime_agent,
101
+ display_path,
102
+ ensure_hook_session,
103
+ ensure_hook_session_unlocked,
104
+ ensure_session,
105
+ execution_log_path,
106
+ execution_records,
107
+ hook_session_identity,
108
+ is_automatic_transition,
109
+ is_non_empty_string,
110
+ load_json,
111
+ load_session,
112
+ load_task,
113
+ merge_legacy_session,
114
+ migrate_legacy_pid_session,
115
+ migrate_legacy_state,
116
+ migrate_unit_test_settings,
117
+ normalize_agent_identity,
118
+ normalize_legacy_stage,
119
+ normalize_legacy_task,
120
+ normalize_session_agent,
121
+ normalize_session_component,
122
+ now_iso,
123
+ parse_unit_test_mode,
124
+ parse_ut_threshold,
125
+ read_behavior_file,
126
+ read_project_behavior,
127
+ release_legacy_state_lock,
128
+ release_session_command_lock,
129
+ resolve_behavior,
130
+ resolve_hook_session_path,
131
+ resolve_session_path,
132
+ safe_tdd_report_pattern,
133
+ session_command_lock_path,
134
+ task_json_path,
135
+ tdd_ci_contract_reasons,
136
+ tdd_gate_uses_task_variables,
137
+ tdd_readiness,
138
+ transition_requires_confirmation,
139
+ unlink_session_if_unchanged,
140
+ validate_transition,
141
+ write_json,
142
+ write_session,
143
+ )
144
+ from easy_coding_status import (
145
+ build_machine_breadcrumbs,
146
+ build_status_context,
147
+ build_status_line,
148
+ get_pending_init_version,
149
+ is_project_init_required,
150
+ pending_handoff_record,
151
+ record_seen_stage,
152
+ snapshot_state,
153
+ spec_task_summary,
50
154
  )
51
- READY_LINE = f"Ready · {HELP_SUFFIX}"
52
- WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
53
-
54
- MANDATORY_DEV_SPEC_HEADERS: list[str] = [
55
- "## 技术方案",
56
- "### 项目模式",
57
- "### 任务类型",
58
- "### 需求解析",
59
- "### 现状",
60
- "### 冲突摘要",
61
- "### 决策闭环",
62
- "### 影响面分析",
63
- "### 改动范围",
64
- "### 修改方案",
65
- "### 实施拆解",
66
- "### 测试策略",
67
- "### Workflow Mode",
68
- "### 风险与注意事项",
69
- ]
70
-
71
- VALID_TRANSITIONS: dict[str, set[str]] = {
72
- "idle": {"INIT"},
73
- "INIT": {"ANALYSIS", "CLOSED"},
74
- "ANALYSIS": {"IMPLEMENT", "CLOSED"},
75
- "IMPLEMENT": {"QUALITY", "ANALYSIS", "CLOSED"},
76
- "QUALITY": {"MEMORY", "IMPLEMENT", "ANALYSIS", "CLOSED"},
77
- "MEMORY": {"COMPLETE", "CLOSED"},
78
- "COMPLETE": set(),
79
- "CLOSED": set(),
80
- }
81
155
 
82
- ALWAYS_AUTO_TRANSITIONS = {
83
- ("INIT", "ANALYSIS"),
84
- ("MEMORY", "COMPLETE"),
85
- }
86
- TDD_INIT_TASK_TYPE = "tdd-init"
87
- APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
88
- CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
156
+
89
157
  WORKFLOW_MODES = {"fast", "standard", "strict"}
90
158
  WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
91
159
  STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
@@ -124,31 +192,7 @@ WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
124
192
  r"(cross[-_ ]?repo|public[-_ ]?(api|contract)|跨仓|公共接口|公共契约)",
125
193
  re.IGNORECASE,
126
194
  )
127
- DEFAULT_APPROVAL_MODE = "guard"
128
- DEFAULT_WORKFLOW_MODE = "adaptive"
129
- DEFAULT_UNIT_TEST_MODE = "none"
130
- DEFAULT_UT_COVERAGE_THRESHOLD = 90
131
- TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
132
- TDD_READINESS_SCOPE = "changed-production-lines"
133
- TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
134
- TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
135
- TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
136
- COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
137
- JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
138
- GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
139
- CRITICAL_CONFIRM_TRANSITIONS = {
140
- ("ANALYSIS", "IMPLEMENT"),
141
- ("QUALITY", "MEMORY"),
142
- }
143
- ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
144
-
145
- LEGACY_STAGE_MAP = {
146
- "WAITING_CONFIRM": "ANALYSIS",
147
- "REVIEW": "QUALITY",
148
- "VERIFICATION": "QUALITY",
149
- "MEMORY_SHORT": "MEMORY",
150
- "MEMORY_LONG": "MEMORY",
151
- }
195
+
152
196
 
153
197
  DEFAULT_SHORT_TERM_MAX = 10
154
198
  DEFAULT_SHORT_TERM_KEEP = 5
@@ -160,28 +204,6 @@ ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
160
204
  ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
161
205
  ACCEPTANCE_SNAPSHOT_SCHEMA = 1
162
206
  ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
163
- SESSION_IDLE_RETENTION_HOURS = 7 * 24
164
- SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
165
- MAX_SESSION_FILES = 100
166
- SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
167
- WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
168
- # 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
169
- INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
170
- SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
171
- CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
172
- LEGACY_DISPLAY_AGENT_IDENTITIES = {
173
- "claude with easy coding": "claude-code",
174
- "claude-code with easy coding": "claude-code",
175
- "claude code with easy coding": "claude-code",
176
- "codex with easy coding": "codex",
177
- "qoder with easy coding": "qoder",
178
- }
179
- LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
180
- LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
181
- LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
182
- SESSION_COMMAND_LOCK_TIMEOUT_SECONDS = 5.0
183
- SESSION_COMMAND_LOCK_STALE_SECONDS = 60.0
184
- SESSION_COMMAND_LOCK_POLL_SECONDS = 0.02
185
207
  SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
186
208
  r"^SM-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
187
209
  )
@@ -221,20 +243,12 @@ TABLE_HEADER_CELLS = {
221
243
  }
222
244
 
223
245
 
224
- class StateError(Exception):
225
- pass
226
-
227
-
228
246
  def configure_stdio() -> None:
229
247
  for stream in (sys.stdin, sys.stdout, sys.stderr):
230
248
  if hasattr(stream, "reconfigure"):
231
249
  stream.reconfigure(encoding="utf-8")
232
250
 
233
251
 
234
- def now_iso() -> str:
235
- return datetime.now(timezone.utc).isoformat()
236
-
237
-
238
252
  def generate_short_memory_id() -> str:
239
253
  timestamp_ms = time.time_ns() // 1_000_000
240
254
  random_bits = secrets.randbits(74)
@@ -258,56 +272,6 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
258
272
  return (2, memory_id)
259
273
 
260
274
 
261
- def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
262
- raw_agent = str(agent or "unknown").strip()
263
- normalized = raw_agent.lower()
264
- # Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
265
- if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
266
- return "codex"
267
- if normalized in WORKFLOW_AGENT_IDENTITIES:
268
- return normalized
269
- if allow_legacy_display:
270
- return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
271
- return None
272
-
273
-
274
- def normalize_agent_identity(agent: str | None) -> str:
275
- raw_agent = str(agent or "unknown").strip()
276
- # 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
277
- canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
278
- if canonical is not None:
279
- return canonical
280
- return raw_agent
281
-
282
-
283
- def normalize_session_agent(agent: str | None) -> str:
284
- normalized = normalize_agent_identity(agent)
285
- return normalized if normalized in SESSION_AGENT_NAMESPACES else "unknown"
286
-
287
-
288
- def agents_equivalent(first: str | None, second: str | None) -> bool:
289
- return normalize_agent_identity(first) == normalize_agent_identity(second)
290
-
291
-
292
- def detect_runtime_agent() -> str:
293
- if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
294
- return INSTALLED_WORKFLOW_AGENT
295
- # 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
296
- script_path = Path(sys.argv[0]).as_posix()
297
- if ".qoder/" in script_path or ".qodercn/" in script_path:
298
- return "qoder"
299
- if ".codex/" in script_path:
300
- return "codex"
301
- if ".claude/" in script_path:
302
- return "claude-code"
303
- # Qoder CLI 会暴露 Claude 兼容环境变量,专属信号必须优先于兼容信号。
304
- if os.environ.get("QODER_PROJECT_DIR"):
305
- return "qoder"
306
- if os.environ.get("CLAUDE_PROJECT_DIR"):
307
- return "claude-code"
308
- return "unknown"
309
-
310
-
311
275
  def resolve_state_agent(explicit_agent: str | None) -> str:
312
276
  runtime_agent = detect_runtime_agent()
313
277
  explicit_identity = None
@@ -347,47 +311,7 @@ def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
347
311
  )
348
312
 
349
313
 
350
- def normalize_session_component(value: str) -> str:
351
- if (
352
- value not in {".", ".."}
353
- and len(value) <= 120
354
- and SESSION_COMPONENT_PATTERN.fullmatch(value)
355
- ):
356
- return value
357
- digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
358
- return f"sha256-{digest}"
359
-
360
-
361
314
  # 逻辑会话由 Agent 命名空间与平台会话 ID 共同标识;PPID 只用于缺少逻辑 ID 的兼容回退。
362
- def hook_session_identity(
363
- payload: dict,
364
- agent: str | None,
365
- ppid: int | None = None,
366
- ) -> dict:
367
- namespace = normalize_session_agent(agent)
368
- raw_session_id = payload.get("session_id") or payload.get("sessionId")
369
- external_session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
370
- source = "hook-session-id"
371
- if not external_session_id and namespace == "codex":
372
- # Codex App 当前会把 thread ID 暴露在进程环境中;标准 hook session_id 仍保持最高优先级。
373
- raw_thread_id = (
374
- payload.get("thread_id")
375
- or payload.get("threadId")
376
- or os.environ.get("CODEX_THREAD_ID")
377
- )
378
- external_session_id = str(raw_thread_id).strip() if raw_thread_id is not None else ""
379
- source = "codex-thread-id"
380
- if external_session_id:
381
- component = normalize_session_component(external_session_id)
382
- else:
383
- component = f"ppid-{ppid if ppid is not None else os.getppid()}"
384
- source = "legacy-ppid"
385
- return {
386
- "agent": namespace,
387
- "external_session_id": external_session_id or None,
388
- "session_key": f"{namespace}-{component}",
389
- "session_source": source,
390
- }
391
315
 
392
316
 
393
317
  def find_ec_root(start: Path) -> Path | None:
@@ -400,15 +324,6 @@ def find_ec_root(start: Path) -> Path | None:
400
324
  current = current.parent
401
325
 
402
326
 
403
- def load_json(path: Path) -> dict | None:
404
- if not path.exists():
405
- return None
406
- try:
407
- return json.loads(path.read_text(encoding="utf-8"))
408
- except (OSError, json.JSONDecodeError):
409
- return None
410
-
411
-
412
327
  def parse_positive_int(value: str) -> int | None:
413
328
  normalized = value.split("#", 1)[0].strip().strip("'\"")
414
329
  try:
@@ -459,287 +374,6 @@ def read_memory_config(root: Path) -> dict[str, int]:
459
374
  return config
460
375
 
461
376
 
462
- def parse_ut_threshold(value: object, source: str) -> int:
463
- if isinstance(value, bool):
464
- raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
465
- try:
466
- threshold = int(str(value))
467
- except (TypeError, ValueError) as error:
468
- raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
469
- if threshold < 1 or threshold > 100:
470
- raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
471
- return threshold
472
-
473
-
474
- def parse_unit_test_mode(value: object, source: str) -> str:
475
- if not isinstance(value, str) or value not in {"none", "ut", "tdd"}:
476
- raise StateError(f"Invalid {source}: expected none, ut, or tdd.")
477
- return str(value)
478
-
479
-
480
- def read_behavior_file(path: Path) -> tuple[dict, int]:
481
- return memo(("behavior", str(path)), lambda: _read_behavior_file(path))
482
-
483
-
484
- def _read_behavior_file(path: Path) -> tuple[dict, int]:
485
- try:
486
- lines = path.read_text(encoding="utf-8").splitlines()
487
- except FileNotFoundError:
488
- return {}, 0
489
-
490
- in_behavior = False
491
- behavior_indent = 0
492
- behavior: dict[str, str] = {}
493
- schema_version = 0
494
- for raw_line in lines:
495
- without_comment = raw_line.split("#", 1)[0].rstrip()
496
- stripped = without_comment.strip()
497
- if not stripped:
498
- continue
499
- indent = len(without_comment) - len(without_comment.lstrip(" "))
500
- if stripped.startswith("behavior:") and stripped != "behavior:":
501
- raise StateError("Behavior configuration must use an indented YAML mapping; write it with easy-coding config.")
502
- if stripped == "behavior:":
503
- in_behavior = True
504
- behavior_indent = indent
505
- continue
506
- if in_behavior and indent <= behavior_indent:
507
- in_behavior = False
508
- if not in_behavior and indent == 0 and stripped.startswith("version:"):
509
- try:
510
- schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
511
- except ValueError:
512
- schema_version = 0
513
- continue
514
- if not in_behavior or ":" not in stripped:
515
- continue
516
- key, value = stripped.split(":", 1)
517
- behavior[key] = value.strip().strip("'\"")
518
-
519
- return behavior, schema_version
520
-
521
-
522
- def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
523
- behavior, schema_version = read_behavior_file(root / ".easy-coding" / "config.yaml")
524
- legacy = behavior.get("confirm_mode")
525
- approval_mode = behavior.get("approval_mode")
526
- workflow_mode = behavior.get("workflow_mode")
527
- if approval_mode is None:
528
- if legacy == "lite":
529
- approval_mode = "guard"
530
- elif legacy in APPROVAL_MODES:
531
- approval_mode = legacy
532
- else:
533
- approval_mode = DEFAULT_APPROVAL_MODE
534
- if workflow_mode is None:
535
- workflow_mode = "fast" if legacy == "lite" else DEFAULT_WORKFLOW_MODE
536
- if approval_mode not in APPROVAL_MODES:
537
- raise StateError(
538
- "Invalid behavior.approval_mode in .easy-coding/config.yaml: "
539
- "expected approve, guard, confirm, or auto."
540
- )
541
- if workflow_mode not in CONFIGURED_WORKFLOW_MODES:
542
- raise StateError(
543
- "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
544
- "expected adaptive, fast, standard, or strict."
545
- )
546
- if schema_version >= 6:
547
- unit_test_mode = parse_unit_test_mode(
548
- behavior.get("unit_test_mode", DEFAULT_UNIT_TEST_MODE), "behavior.unit_test_mode"
549
- )
550
- threshold = parse_ut_threshold(
551
- behavior.get("ut_coverage_threshold", DEFAULT_UT_COVERAGE_THRESHOLD),
552
- "behavior.ut_coverage_threshold",
553
- )
554
- else:
555
- if schema_version >= 4:
556
- raise StateError("Run easy-coding upgrade to migrate unit-test settings to schema 6.")
557
- unit_test_mode = DEFAULT_UNIT_TEST_MODE
558
- threshold = DEFAULT_UT_COVERAGE_THRESHOLD
559
- return approval_mode, workflow_mode, unit_test_mode, threshold
560
-
561
-
562
- def behavior_layers(root: Path, session: dict) -> dict:
563
- project, _ = read_behavior_file(root / ".easy-coding" / "config.yaml")
564
- local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
565
- defaults = {"approval_mode": DEFAULT_APPROVAL_MODE, "cooperate_mode": "default",
566
- "unit_test_mode": DEFAULT_UNIT_TEST_MODE,
567
- "ut_coverage_threshold": DEFAULT_UT_COVERAGE_THRESHOLD}
568
- layers = {"project": project, "local": local, "session": session}
569
- result = {}
570
- for key, default in defaults.items():
571
- source = next((name for name in ("session", "local", "project")
572
- if layers[name].get(key) is not None), "default")
573
- value = layers[source][key] if source != "default" else default
574
- if key == "ut_coverage_threshold":
575
- value = parse_ut_threshold(value, f"{source} {key}")
576
- elif key == "unit_test_mode":
577
- value = parse_unit_test_mode(value, f"{source} {key}")
578
- elif value not in (APPROVAL_MODES if key == "approval_mode" else {"default", "dispatch"}):
579
- raise StateError(f"Invalid {source} {key}: {value}")
580
- result[key] = {"value": value, "source": source}
581
- return result
582
-
583
-
584
- def safe_tdd_report_pattern(value: object) -> bool:
585
- if not is_non_empty_string(value):
586
- return False
587
- candidate = Path(str(value))
588
- return not candidate.is_absolute() and ".." not in candidate.parts
589
-
590
-
591
- def tdd_gate_uses_task_variables(command: object) -> bool:
592
- if not is_non_empty_string(command):
593
- return False
594
- try:
595
- tokens = shlex.split(str(command))
596
- except ValueError:
597
- return False
598
- options: dict[str, str] = {}
599
- for index, token in enumerate(tokens[:-1]):
600
- if token in {"--base", "--threshold"}:
601
- options[token] = tokens[index + 1]
602
- return options.get("--base") in {
603
- f"${TDD_BASE_VARIABLE}",
604
- "$" + "{" + TDD_BASE_VARIABLE + "}",
605
- } and options.get("--threshold") in {
606
- f"${TDD_THRESHOLD_VARIABLE}",
607
- "$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
608
- }
609
-
610
-
611
- def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
612
- combined = "\n".join(
613
- re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
614
- for line in "\n".join(contents).splitlines()
615
- )
616
- lowered = combined.lower()
617
- reasons: list[str] = []
618
- for marker in (
619
- "jacoco",
620
- "artifacts",
621
- COVERAGE_TOOL_PATH,
622
- TDD_BASE_VARIABLE,
623
- TDD_THRESHOLD_VARIABLE,
624
- ):
625
- if marker.lower() not in lowered:
626
- reasons.append(f"CI files do not contain required marker: {marker}")
627
- if not tdd_gate_uses_task_variables(combined):
628
- reasons.append(
629
- "CI changed-line gate must use the task baseline and threshold variables"
630
- )
631
- if re.search(
632
- r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
633
- combined,
634
- re.IGNORECASE,
635
- ) is None:
636
- reasons.append("CI files do not declare a TEST-stage job")
637
- return reasons
638
-
639
-
640
- def tdd_readiness(root: Path, include_ci: bool = False) -> dict[str, object]:
641
- receipt = root / TDD_READINESS_PATH
642
- if not receipt.is_file():
643
- return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
644
- try:
645
- manifest = json.loads(receipt.read_text(encoding="utf-8"))
646
- except (OSError, UnicodeError, json.JSONDecodeError):
647
- return {"status": "needs_repair", "reasons": ["TDD readiness receipt is invalid"]}
648
- if not isinstance(manifest, dict):
649
- return {
650
- "status": "needs_repair",
651
- "reasons": ["TDD readiness receipt must be a JSON object"],
652
- }
653
-
654
- reasons: list[str] = []
655
- if manifest.get("schema") != TDD_READINESS_SCHEMA:
656
- reasons.append("unsupported readiness schema")
657
- if manifest.get("provider") != "gitlab":
658
- reasons.append("readiness provider must be gitlab")
659
- if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
660
- reasons.append("coverage scope must be changed-production-lines")
661
- if manifest.get("historical_coverage_required") is not False:
662
- reasons.append("historical coverage must remain disabled")
663
- reports = manifest.get("coverage_report_patterns")
664
- if not isinstance(reports, list) or not reports or not all(
665
- safe_tdd_report_pattern(item) for item in reports
666
- ):
667
- reasons.append(
668
- "coverage_report_patterns must contain safe project-relative report patterns"
669
- )
670
- gate = manifest.get("changed_line_gate_command")
671
- if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
672
- reasons.append("changed-line coverage gate command is missing")
673
- elif not tdd_gate_uses_task_variables(gate):
674
- reasons.append(
675
- "changed-line coverage gate must use the task baseline and threshold variables"
676
- )
677
-
678
- contents: dict[str, list[str]] = {
679
- "build_files": [],
680
- "tool_files": [],
681
- }
682
- if include_ci:
683
- contents["ci_files"] = []
684
- for field in contents:
685
- records = manifest.get(field)
686
- if not isinstance(records, list) or not records:
687
- reasons.append(f"{field} must contain at least one file")
688
- continue
689
- for record in records:
690
- if not isinstance(record, dict):
691
- reasons.append(f"{field} contains an invalid record")
692
- continue
693
- file_name = record.get("path")
694
- if not is_non_empty_string(file_name):
695
- reasons.append(f"{field} contains an invalid path")
696
- continue
697
- candidate = Path(str(file_name))
698
- if candidate.is_absolute():
699
- reasons.append(f"readiness file must be project-relative: {file_name}")
700
- continue
701
- resolved = (root / candidate).resolve()
702
- try:
703
- resolved.relative_to(root.resolve())
704
- payload = resolved.read_bytes()
705
- contents[field].append(payload.decode("utf-8"))
706
- except (OSError, UnicodeError, ValueError):
707
- reasons.append(f"readiness file is missing or unreadable: {file_name}")
708
-
709
- manifest_build_files = manifest.get("build_files")
710
- manifest_ci_files = manifest.get("ci_files")
711
- manifest_tool_files = manifest.get("tool_files")
712
- build_paths = {
713
- Path(str(item.get("path", ""))).name
714
- for item in manifest_build_files
715
- if isinstance(item, dict) and is_non_empty_string(item.get("path"))
716
- } if isinstance(manifest_build_files, list) else set()
717
- ci_paths = {
718
- str(item.get("path", "")).replace("\\", "/")
719
- for item in manifest_ci_files
720
- if isinstance(item, dict) and is_non_empty_string(item.get("path"))
721
- } if isinstance(manifest_ci_files, list) else set()
722
- if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
723
- reasons.append("build_files must include a Maven or Gradle Java build file")
724
- tool_paths = {
725
- str(item.get("path", "")).replace("\\", "/")
726
- for item in manifest_tool_files
727
- if isinstance(item, dict) and is_non_empty_string(item.get("path"))
728
- } if isinstance(manifest_tool_files, list) else set()
729
- if COVERAGE_TOOL_PATH not in tool_paths:
730
- reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
731
- if include_ci:
732
- if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
733
- reasons.append("ci_files must include the project-root GitLab CI entry file")
734
- if not any("jacoco" in content.lower() for content in contents["build_files"]):
735
- reasons.append("build files do not configure JaCoCo")
736
- reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
737
- return {
738
- "status": "ready" if not reasons else "needs_repair",
739
- "reasons": list(dict.fromkeys(reasons)),
740
- }
741
-
742
-
743
377
  def require_tdd_readiness(root: Path) -> None:
744
378
  readiness = tdd_readiness(root)
745
379
  if readiness["status"] != "ready":
@@ -748,78 +382,11 @@ def require_tdd_readiness(root: Path) -> None:
748
382
  raise StateError(f"{action}: {reasons}. TDD settings are unchanged.")
749
383
 
750
384
 
751
- def resolve_behavior(
752
- root: Path, session: dict
753
- ) -> tuple[str, str | None, str, str, str | None, str, str, str | None, str, int, int | None, int]:
754
- project_approval, project_workflow, project_unit_test, project_threshold = read_project_behavior(root)
755
- legacy = session.get("confirm_mode")
756
- session_approval = session.get("approval_mode")
757
- session_workflow = session.get("workflow_mode")
758
- session_unit_test = session.get("unit_test_mode")
759
- session_threshold = session.get("ut_coverage_threshold")
760
- if session_approval is None:
761
- if legacy == "lite":
762
- session_approval = "guard"
763
- elif legacy in APPROVAL_MODES:
764
- session_approval = legacy
765
- if session_workflow is None:
766
- if legacy == "lite":
767
- session_workflow = "fast"
768
- elif legacy in APPROVAL_MODES:
769
- session_workflow = "adaptive"
770
- if session_approval is not None and session_approval not in APPROVAL_MODES:
771
- raise StateError(
772
- "Invalid session approval_mode: expected approve, guard, confirm, or auto."
773
- )
774
- if session_workflow is not None and session_workflow not in CONFIGURED_WORKFLOW_MODES:
775
- raise StateError(
776
- "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
777
- )
778
- if session_unit_test is not None:
779
- session_unit_test = parse_unit_test_mode(session_unit_test, "session unit_test_mode")
780
- if session_threshold is not None:
781
- session_threshold = parse_ut_threshold(
782
- session_threshold, "session ut_coverage_threshold"
783
- )
784
- local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
785
- effective = behavior_layers(root, session)
786
- return (
787
- project_approval,
788
- str(session_approval) if session_approval else None,
789
- str(session_approval or (effective["approval_mode"]["value"] if "approval_mode" in local else project_approval)),
790
- project_workflow,
791
- str(session_workflow) if session_workflow else None,
792
- str(session_workflow or project_workflow),
793
- project_unit_test,
794
- session_unit_test,
795
- session_unit_test if session_unit_test is not None else (
796
- effective["unit_test_mode"]["value"] if "unit_test_mode" in local else project_unit_test),
797
- project_threshold,
798
- session_threshold,
799
- session_threshold if session_threshold is not None else (
800
- effective["ut_coverage_threshold"]["value"] if "ut_coverage_threshold" in local else project_threshold),
801
- )
802
-
803
-
804
385
  def resolve_approval_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
805
386
  behavior = resolve_behavior(root, session)
806
387
  return behavior[0], behavior[1], behavior[2]
807
388
 
808
389
 
809
- def migrate_unit_test_settings(record: dict) -> bool:
810
- changed = False
811
- if "tdd_enabled" in record:
812
- enabled = record.pop("tdd_enabled")
813
- if "unit_test_mode" not in record and isinstance(enabled, bool):
814
- record["unit_test_mode"] = "tdd" if enabled else "none"
815
- changed = True
816
- if "tdd_coverage_threshold" in record:
817
- threshold = record.pop("tdd_coverage_threshold")
818
- record.setdefault("ut_coverage_threshold", threshold)
819
- changed = True
820
- return changed
821
-
822
-
823
390
  def materialize_legacy_session_behavior(session: dict) -> None:
824
391
  migrate_unit_test_settings(session)
825
392
  legacy = session.get("confirm_mode")
@@ -1260,586 +827,53 @@ def validate_recorded_architecture_assessment(root: Path, progress: dict, instru
1260
827
  raise StateError("Recorded architecture assessment trigger does not match its instruction.")
1261
828
  reason = assessment.get("reason")
1262
829
  if not isinstance(reason, str) or not reason.strip():
1263
- raise StateError("Recorded architecture assessment is missing its reason.")
1264
- evidence = assessment.get("evidence")
1265
- if not isinstance(evidence, list) or not evidence or not all(
1266
- isinstance(item, str) for item in evidence
1267
- ):
1268
- raise StateError("Recorded architecture assessment has invalid evidence.")
1269
- if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
1270
- raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
1271
- affected_sections = assessment.get("affected_sections")
1272
- if not isinstance(affected_sections, list) or not all(
1273
- isinstance(item, str) and item.strip() for item in affected_sections
1274
- ):
1275
- raise StateError("Recorded architecture assessment has invalid affected sections.")
1276
- if action == "no-op" and affected_sections:
1277
- raise StateError("Recorded architecture no-op must not declare affected sections.")
1278
- if action in {"backfill", "update"} and not affected_sections:
1279
- raise StateError("Recorded architecture backfill/update requires affected sections.")
1280
- abstract, changelog = validate_architecture_action_result(
1281
- root, assessment_instruction, action
1282
- )
1283
- if assessment.get("abstract_sha256") != abstract.get("sha256"):
1284
- raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
1285
- if assessment.get("changelog_sha256") != changelog.get("sha256"):
1286
- raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
1287
-
1288
-
1289
- def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
1290
- candidate_files = instruction.get("candidate_files")
1291
- kept_files = instruction.get("kept_files")
1292
- if not isinstance(candidate_files, list) or not all(
1293
- isinstance(item, str) for item in candidate_files
1294
- ):
1295
- raise StateError("Memory instruction is missing its frozen candidate file set.")
1296
- if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
1297
- raise StateError("Memory instruction is missing its frozen kept file set.")
1298
- for memory_file in candidate_files:
1299
- if resolve_short_memory_path(root, memory_file).exists():
1300
- raise StateError(f"Distillation candidate was not consumed: {memory_file}")
1301
- for memory_file in kept_files:
1302
- if not resolve_short_memory_path(root, memory_file).is_file():
1303
- raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
1304
-
1305
-
1306
- def normalize_legacy_stage(stage: object) -> object:
1307
- return LEGACY_STAGE_MAP.get(str(stage), stage)
1308
-
1309
-
1310
- def normalize_legacy_task(task: dict) -> bool:
1311
- """Normalize legacy task state without touching artifacts outside task.json."""
1312
- legacy_status = str(task.get("status") or "")
1313
- changed = migrate_unit_test_settings(task)
1314
-
1315
- for field in ("created_by", "last_agent"):
1316
- normalized_agent = canonical_agent_identity(
1317
- task.get(field), allow_legacy_display=True
1318
- )
1319
- if normalized_agent is not None and normalized_agent != task.get(field):
1320
- task[field] = normalized_agent
1321
- changed = True
1322
-
1323
- if legacy_status in LEGACY_STAGE_MAP:
1324
- task["status"] = LEGACY_STAGE_MAP[legacy_status]
1325
- changed = True
1326
-
1327
- pending = task.get("pending_transition")
1328
- if isinstance(pending, dict):
1329
- source = normalize_legacy_stage(pending.get("from"))
1330
- target = normalize_legacy_stage(pending.get("to"))
1331
- if source == target:
1332
- task.pop("pending_transition", None)
1333
- changed = True
1334
- elif source != pending.get("from") or target != pending.get("to"):
1335
- task["pending_transition"] = {**pending, "from": source, "to": target}
1336
- changed = True
1337
-
1338
- if not isinstance(task.get("quality_checkpoint"), dict) and isinstance(
1339
- task.get("verification_checkpoint"), dict
1340
- ):
1341
- task["quality_checkpoint"] = task["verification_checkpoint"]
1342
- changed = True
1343
- if "verification_checkpoint" in task:
1344
- task.pop("verification_checkpoint")
1345
- changed = True
1346
-
1347
- history = task.get("stage_history")
1348
- if isinstance(history, list):
1349
- normalized_history: list[dict] = []
1350
- for raw_entry in history:
1351
- if not isinstance(raw_entry, dict):
1352
- continue
1353
- entry = dict(raw_entry)
1354
- mapped_stage = normalize_legacy_stage(entry.get("stage"))
1355
- if mapped_stage != entry.get("stage"):
1356
- entry["stage"] = mapped_stage
1357
- changed = True
1358
- normalized_agent = canonical_agent_identity(
1359
- entry.get("agent"), allow_legacy_display=True
1360
- )
1361
- if normalized_agent is not None and normalized_agent != entry.get("agent"):
1362
- entry["agent"] = normalized_agent
1363
- changed = True
1364
- if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
1365
- changed = True
1366
- continue
1367
- normalized_history.append(entry)
1368
- if changed:
1369
- task["stage_history"] = normalized_history
1370
-
1371
- if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
1372
- requested_by = canonical_agent_identity(
1373
- task.get("last_agent"), allow_legacy_display=True
1374
- ) or "legacy-migration"
1375
- task["pending_transition"] = {
1376
- "from": "ANALYSIS",
1377
- "to": "IMPLEMENT",
1378
- "requested_at": now_iso(),
1379
- "requested_by": requested_by,
1380
- "reason": "migrated-from-WAITING_CONFIRM",
1381
- }
1382
- changed = True
1383
-
1384
- if legacy_status == "MEMORY_LONG":
1385
- progress = task.get("memory_progress")
1386
- if not isinstance(progress, dict):
1387
- progress = {}
1388
- if progress.get("short_memory_written") is not True:
1389
- progress["short_memory_written"] = True
1390
- progress["legacy_short_memory_assumed"] = True
1391
- progress["updated_at"] = now_iso()
1392
- task["memory_progress"] = progress
1393
- changed = True
1394
- elif progress.get("legacy_short_memory_assumed") is not True:
1395
- progress["legacy_short_memory_assumed"] = True
1396
- progress["updated_at"] = now_iso()
1397
- task["memory_progress"] = progress
1398
- changed = True
1399
-
1400
- return changed
1401
-
1402
-
1403
- def write_json(path: Path, data: dict) -> None:
1404
- path.parent.mkdir(parents=True, exist_ok=True)
1405
- descriptor, temporary_name = tempfile.mkstemp(
1406
- prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
1407
- )
1408
- temporary_path = Path(temporary_name)
1409
- try:
1410
- with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
1411
- handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
1412
- handle.flush()
1413
- os.fsync(handle.fileno())
1414
- os.replace(temporary_path, path)
1415
- try:
1416
- directory_descriptor = os.open(path.parent, os.O_RDONLY)
1417
- try:
1418
- os.fsync(directory_descriptor)
1419
- finally:
1420
- os.close(directory_descriptor)
1421
- except OSError:
1422
- # Some platforms do not allow opening directories; file replacement is still atomic.
1423
- pass
1424
- finally:
1425
- if temporary_path.exists():
1426
- temporary_path.unlink()
1427
-
1428
-
1429
- def session_command_lock_path(root: Path, session_path: Path) -> Path:
1430
- key = hashlib.sha256(str(session_path.resolve()).encode("utf-8")).hexdigest()[:24]
1431
- return root / ".easy-coding" / "sessions" / f".session-{key}.lock"
1432
-
1433
-
1434
- def acquire_session_command_lock(root: Path, session_path: Path) -> Path:
1435
- lock_path = session_command_lock_path(root, session_path)
1436
- lock_path.parent.mkdir(parents=True, exist_ok=True)
1437
- deadline = time.monotonic() + SESSION_COMMAND_LOCK_TIMEOUT_SECONDS
1438
- while True:
1439
- try:
1440
- lock_path.mkdir()
1441
- return lock_path
1442
- except FileExistsError:
1443
- try:
1444
- if time.time() - lock_path.stat().st_mtime > SESSION_COMMAND_LOCK_STALE_SECONDS:
1445
- lock_path.rmdir()
1446
- continue
1447
- except FileNotFoundError:
1448
- continue
1449
- except OSError:
1450
- pass
1451
- if time.monotonic() >= deadline:
1452
- raise StateError("Timed out waiting for the logical session command lock.")
1453
- time.sleep(SESSION_COMMAND_LOCK_POLL_SECONDS)
1454
- except OSError as exc:
1455
- raise StateError("Cannot acquire the logical session command lock.") from exc
1456
-
1457
-
1458
- def release_session_command_lock(lock_path: Path | None) -> None:
1459
- if lock_path is None:
1460
- return
1461
- try:
1462
- lock_path.rmdir()
1463
- except OSError:
1464
- pass
1465
-
1466
-
1467
- def acquire_legacy_state_lock(root: Path) -> Path | None:
1468
- state_path = root / ".easy-coding" / "state.json"
1469
- lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
1470
- lock_path.parent.mkdir(parents=True, exist_ok=True)
1471
- deadline = time.monotonic() + LEGACY_STATE_LOCK_TIMEOUT_SECONDS
1472
-
1473
- while state_path.exists() or lock_path.exists():
1474
- try:
1475
- lock_path.mkdir()
1476
- return lock_path
1477
- except FileExistsError:
1478
- try:
1479
- lock_age = time.time() - lock_path.stat().st_mtime
1480
- if lock_age > LEGACY_STATE_LOCK_STALE_SECONDS:
1481
- lock_path.rmdir()
1482
- continue
1483
- except FileNotFoundError:
1484
- continue
1485
- except OSError:
1486
- pass
1487
- if time.monotonic() >= deadline:
1488
- raise StateError("Timed out waiting for legacy state migration lock.")
1489
- time.sleep(LEGACY_STATE_LOCK_POLL_SECONDS)
1490
- except OSError as error:
1491
- raise StateError("Cannot acquire legacy state migration lock.") from error
1492
- return None
1493
-
1494
-
1495
- def release_legacy_state_lock(lock_path: Path | None) -> None:
1496
- if lock_path is None:
1497
- return
1498
- try:
1499
- lock_path.rmdir()
1500
- except OSError:
1501
- pass
1502
-
1503
-
1504
- def migrate_legacy_state(root: Path, agent: str) -> dict | None:
1505
- """Prepare old state.json data for the canonical session; the caller commits it first."""
1506
- state_path = root / ".easy-coding" / "state.json"
1507
- old_state = load_json(state_path)
1508
- if old_state is None:
1509
- return None
1510
-
1511
- task_id = old_state.get("current_task")
1512
- if task_id:
1513
- task_path = task_json_path(root, str(task_id))
1514
- task = load_json(task_path)
1515
- if task:
1516
- if "stage_history" not in task or not task["stage_history"]:
1517
- task["stage_history"] = old_state.get("stage_history", [])
1518
- if "last_agent" not in task or not task["last_agent"]:
1519
- task["last_agent"] = (
1520
- canonical_agent_identity(
1521
- old_state.get("last_agent"), allow_legacy_display=True
1522
- )
1523
- or agent
1524
- )
1525
- if old_state.get("confirmed_by_user"):
1526
- task["confirmed_by_user"] = True
1527
- if old_state.get("test_strategy_confirmed"):
1528
- task["test_strategy_confirmed"] = True
1529
- if old_state.get("repo_paths"):
1530
- task["repo_paths"] = old_state["repo_paths"]
1531
- normalize_legacy_task(task)
1532
- write_json(task_path, task)
1533
-
1534
- return {"current_task": task_id, "created_at": now_iso()}
1535
-
1536
-
1537
- def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
1538
- sessions_dir = (root / ".easy-coding" / "sessions").resolve()
1539
- if session_file:
1540
- path = Path(session_file)
1541
- candidate = path if path.is_absolute() else root / path
1542
- resolved = candidate.resolve()
1543
- try:
1544
- resolved.relative_to(sessions_dir)
1545
- except ValueError as error:
1546
- raise StateError(
1547
- "Unsafe session file path: "
1548
- f"{session_file}. Must be under .easy-coding/sessions/."
1549
- ) from error
1550
- if resolved == sessions_dir:
1551
- raise StateError(
1552
- "Unsafe session file path: "
1553
- f"{session_file}. Must be a file under .easy-coding/sessions/."
1554
- )
1555
- return resolved
1556
- identity = hook_session_identity({}, detect_runtime_agent())
1557
- return sessions_dir / f"{identity['session_key']}.json"
1558
-
1559
-
1560
- def resolve_hook_session_path(
1561
- root: Path,
1562
- payload: dict,
1563
- agent: str | None,
1564
- ppid: int | None = None,
1565
- ) -> Path:
1566
- identity = hook_session_identity(payload, agent, ppid)
1567
- return resolve_session_path(root, f".easy-coding/sessions/{identity['session_key']}.json")
1568
-
1569
-
1570
- def display_path(root: Path, path: Path) -> str:
1571
- try:
1572
- return path.resolve().relative_to(root.resolve()).as_posix()
1573
- except ValueError:
1574
- return path.as_posix()
1575
-
1576
-
1577
- def default_session() -> dict:
1578
- timestamp = now_iso()
1579
- return {"current_task": None, "created_at": timestamp, "last_active_at": timestamp}
1580
-
1581
-
1582
- def apply_hook_session_identity(session: dict, identity: dict) -> None:
1583
- timestamp = now_iso()
1584
- if not session.get("created_at"):
1585
- session["created_at"] = timestamp
1586
- session["last_active_at"] = timestamp
1587
- for key in ("agent", "external_session_id", "session_key", "session_source"):
1588
- session[key] = identity.get(key)
1589
-
1590
-
1591
- def clear_session_pointer(session: dict, agent: str | None = None) -> None:
1592
- session["current_task"] = None
1593
- session["last_seen_task"] = None
1594
- session["last_seen_stage"] = "idle"
1595
- if agent:
1596
- session["last_agent"] = agent
1597
-
1598
-
1599
- def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
1600
- session = load_json(resolve_session_path(root, session_file))
1601
- return session if isinstance(session, dict) else None
1602
-
1603
-
1604
- def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
1605
- write_json(resolve_session_path(root, session_file), session)
1606
-
1607
-
1608
- def migrate_legacy_pid_session(
1609
- root: Path,
1610
- session_path: Path,
1611
- identity: dict,
1612
- ppid: int,
1613
- ) -> dict | None:
1614
- sessions_dir = root / ".easy-coding" / "sessions"
1615
- fallback_path = sessions_dir / f"{identity['agent']}-ppid-{ppid}.json"
1616
- legacy_paths = [fallback_path, sessions_dir / f"{ppid}.json"]
1617
- session_path.parent.mkdir(parents=True, exist_ok=True)
1618
-
1619
- for legacy_path in legacy_paths:
1620
- if legacy_path == session_path or not legacy_path.is_file():
1621
- continue
1622
- try:
1623
- legacy_path.replace(session_path)
1624
- except FileNotFoundError:
1625
- continue
1626
- except OSError:
1627
- if session_path.is_file():
1628
- break
1629
- continue
1630
- migrated = load_session(root, session_path)
1631
- if migrated is not None:
1632
- return migrated
1633
- return load_session(root, session_path)
1634
-
1635
-
1636
- def merge_legacy_session(session: dict, legacy_session: dict) -> dict:
1637
- merged = dict(session)
1638
- if not merged.get("current_task") and legacy_session.get("current_task"):
1639
- merged["current_task"] = legacy_session["current_task"]
1640
- if not merged.get("created_at") and legacy_session.get("created_at"):
1641
- merged["created_at"] = legacy_session["created_at"]
1642
- return merged
1643
-
1644
-
1645
- def ensure_hook_session(
1646
- root: Path,
1647
- payload: dict,
1648
- agent: str | None,
1649
- ppid: int | None = None,
1650
- ) -> tuple[dict, Path]:
1651
- session_path = resolve_hook_session_path(root, payload, agent, ppid)
1652
- lock_path = acquire_session_command_lock(root, session_path)
1653
- try:
1654
- return ensure_hook_session_unlocked(root, payload, agent, ppid)
1655
- finally:
1656
- release_session_command_lock(lock_path)
1657
-
1658
-
1659
- def ensure_hook_session_unlocked(
1660
- root: Path,
1661
- payload: dict,
1662
- agent: str | None,
1663
- ppid: int | None = None,
1664
- ) -> tuple[dict, Path]:
1665
- identity = hook_session_identity(payload, agent, ppid)
1666
- session_path = resolve_hook_session_path(root, payload, agent, ppid)
1667
- resolved_ppid = ppid if ppid is not None else os.getppid()
1668
- legacy_state_lock = acquire_legacy_state_lock(root)
1669
- try:
1670
- session = load_session(root, session_path)
1671
- legacy_state = (
1672
- migrate_legacy_state(root, str(identity["agent"]))
1673
- if legacy_state_lock is not None
1674
- else None
1675
- )
1676
-
1677
- if session is None:
1678
- clean_session_runtime(root, reserve_slots=1)
1679
- session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
1680
- if session is None:
1681
- session = load_session(root, session_path)
1682
- if session is None:
1683
- session = default_session()
1684
- if legacy_state is not None:
1685
- session = merge_legacy_session(session, legacy_state)
1686
-
1687
- apply_hook_session_identity(session, identity)
1688
- write_session(root, session, session_path)
1689
- if legacy_state is not None:
1690
- try:
1691
- (root / ".easy-coding" / "state.json").unlink()
1692
- except OSError:
1693
- pass
1694
- return session, session_path
1695
- finally:
1696
- release_legacy_state_lock(legacy_state_lock)
1697
-
1698
-
1699
- def clean_stale_sessions(
1700
- root: Path,
1701
- threshold_hours: int | None = None,
1702
- idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
1703
- attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
1704
- max_sessions: int = MAX_SESSION_FILES,
1705
- reserve_slots: int = 0,
1706
- ) -> int:
1707
- sessions_dir = root / ".easy-coding" / "sessions"
1708
- if not sessions_dir.is_dir():
1709
- return 0
1710
-
1711
- now = datetime.now(timezone.utc)
1712
- if threshold_hours is not None:
1713
- idle_threshold_hours = threshold_hours
1714
- attached_threshold_hours = threshold_hours
1715
- candidates: list[tuple[Path, str, dict, datetime]] = []
1716
- for entry in sessions_dir.iterdir():
1717
- if not entry.is_file() or entry.suffix != ".json":
1718
- continue
1719
- try:
1720
- content = entry.read_text(encoding="utf-8")
1721
- try:
1722
- session = json.loads(content)
1723
- except json.JSONDecodeError:
1724
- session = {}
1725
- if not isinstance(session, dict):
1726
- session = {}
1727
- activity_value = session.get("last_active_at") or session.get("created_at")
1728
- try:
1729
- if not isinstance(activity_value, str):
1730
- raise ValueError
1731
- last_active = datetime.fromisoformat(activity_value)
1732
- if last_active.tzinfo is None:
1733
- last_active = last_active.replace(tzinfo=timezone.utc)
1734
- except (ValueError, TypeError):
1735
- last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
1736
- candidates.append((entry, content, session, last_active))
1737
- except OSError:
1738
- continue
1739
-
1740
- removed: set[Path] = set()
1741
- for entry, content, session, last_active in candidates:
1742
- retention_hours = (
1743
- attached_threshold_hours if session.get("current_task") else idle_threshold_hours
1744
- )
1745
- age_hours = (now - last_active).total_seconds() / 3600
1746
- if age_hours <= retention_hours:
1747
- continue
1748
- if unlink_session_if_unchanged(entry, content):
1749
- removed.add(entry)
1750
-
1751
- allowed_existing = max(0, max_sessions - reserve_slots)
1752
- remaining = sorted(
1753
- (candidate for candidate in candidates if candidate[0] not in removed),
1754
- key=lambda candidate: candidate[3],
1755
- )
1756
- overflow = max(0, len(remaining) - allowed_existing)
1757
- for entry, content, _session, _last_active in remaining[:overflow]:
1758
- if unlink_session_if_unchanged(entry, content):
1759
- removed.add(entry)
1760
- return len(removed)
1761
-
1762
-
1763
- def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
1764
- try:
1765
- if entry.read_text(encoding="utf-8") != expected_content:
1766
- return False
1767
- entry.unlink()
1768
- return True
1769
- except OSError:
1770
- # GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
1771
- return False
1772
-
1773
-
1774
- def clean_orphan_acceptance_snapshots(root: Path) -> int:
1775
- acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
1776
- if not acceptance_dir.is_dir():
1777
- return 0
1778
-
1779
- cleaned = 0
1780
- for entry in acceptance_dir.iterdir():
1781
- if not entry.is_file() or entry.suffix != ".json":
1782
- continue
1783
- task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
1784
- if task_path.is_file():
1785
- try:
1786
- task = json.loads(task_path.read_text(encoding="utf-8"))
1787
- except (OSError, json.JSONDecodeError):
1788
- continue
1789
- if not isinstance(task, dict):
1790
- continue
1791
- else:
1792
- task = None
1793
-
1794
- checkpoint = None
1795
- if task is not None:
1796
- checkpoint = task.get("quality_checkpoint")
1797
- if not isinstance(checkpoint, dict):
1798
- checkpoint = task.get("verification_checkpoint")
1799
- snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
1800
- referenced = bool(
1801
- isinstance(snapshot_file, str)
1802
- and (root / snapshot_file).resolve() == entry.resolve()
1803
- )
1804
- terminal = task is not None and task.get("status") in TERMINAL_STATUSES
1805
- if task is not None and referenced and not terminal:
1806
- continue
1807
- try:
1808
- entry.unlink()
1809
- cleaned += 1
1810
- except OSError:
1811
- # 验收快照清理失败不能阻断新逻辑会话启动。
1812
- continue
1813
- return cleaned
1814
-
1815
-
1816
- def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
1817
- return {
1818
- "sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
1819
- "acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
1820
- }
1821
-
1822
-
1823
- def task_json_path(root: Path, task_id: str) -> Path:
1824
- assert_safe_task_id(task_id)
1825
- return root / ".easy-coding" / "tasks" / task_id / "task.json"
830
+ raise StateError("Recorded architecture assessment is missing its reason.")
831
+ evidence = assessment.get("evidence")
832
+ if not isinstance(evidence, list) or not evidence or not all(
833
+ isinstance(item, str) for item in evidence
834
+ ):
835
+ raise StateError("Recorded architecture assessment has invalid evidence.")
836
+ if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
837
+ raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
838
+ affected_sections = assessment.get("affected_sections")
839
+ if not isinstance(affected_sections, list) or not all(
840
+ isinstance(item, str) and item.strip() for item in affected_sections
841
+ ):
842
+ raise StateError("Recorded architecture assessment has invalid affected sections.")
843
+ if action == "no-op" and affected_sections:
844
+ raise StateError("Recorded architecture no-op must not declare affected sections.")
845
+ if action in {"backfill", "update"} and not affected_sections:
846
+ raise StateError("Recorded architecture backfill/update requires affected sections.")
847
+ abstract, changelog = validate_architecture_action_result(
848
+ root, assessment_instruction, action
849
+ )
850
+ if assessment.get("abstract_sha256") != abstract.get("sha256"):
851
+ raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
852
+ if assessment.get("changelog_sha256") != changelog.get("sha256"):
853
+ raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
1826
854
 
1827
855
 
1828
- def load_task(root: Path, task_id: str | None) -> dict | None:
1829
- if not task_id:
1830
- return None
1831
- return load_json(task_json_path(root, str(task_id)))
856
+ def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
857
+ candidate_files = instruction.get("candidate_files")
858
+ kept_files = instruction.get("kept_files")
859
+ if not isinstance(candidate_files, list) or not all(
860
+ isinstance(item, str) for item in candidate_files
861
+ ):
862
+ raise StateError("Memory instruction is missing its frozen candidate file set.")
863
+ if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
864
+ raise StateError("Memory instruction is missing its frozen kept file set.")
865
+ for memory_file in candidate_files:
866
+ if resolve_short_memory_path(root, memory_file).exists():
867
+ raise StateError(f"Distillation candidate was not consumed: {memory_file}")
868
+ for memory_file in kept_files:
869
+ if not resolve_short_memory_path(root, memory_file).is_file():
870
+ raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
1832
871
 
1833
872
 
1834
873
  def write_task(root: Path, task_id: str, task: dict) -> None:
1835
874
  write_json(task_json_path(root, task_id), task)
1836
875
 
1837
876
 
1838
- def execution_log_path(root: Path, task_id: str) -> Path:
1839
- assert_safe_task_id(task_id)
1840
- return root / ".easy-coding" / "tasks" / task_id / "execution.jsonl"
1841
-
1842
-
1843
877
  def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1844
878
  path = execution_log_path(root, task_id)
1845
879
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -1854,10 +888,6 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1854
888
  invalidate_memo(("plan", str(path)))
1855
889
 
1856
890
 
1857
- def is_non_empty_string(value: object) -> bool:
1858
- return isinstance(value, str) and bool(value.strip())
1859
-
1860
-
1861
891
  def is_string_list(value: object, allow_empty: bool = True) -> bool:
1862
892
  return (
1863
893
  isinstance(value, list)
@@ -2323,7 +1353,7 @@ def resume_spec_context(
2323
1353
  def require_spec_context(
2324
1354
  root: Path, task: dict, agent: str, session_file: str | Path | None = None,
2325
1355
  *, allow_pending_hard_dependencies: bool = False,
2326
- ) -> None:
1356
+ ) -> dict | None:
2327
1357
  if not isinstance(task.get("spec_source"), dict):
2328
1358
  return
2329
1359
  if isinstance(task.get("spec_change"), dict):
@@ -2344,6 +1374,8 @@ def require_spec_context(
2344
1374
  if not isinstance(receipt, dict) or any(receipt.get(key) != value for key, value in expected.items()):
2345
1375
  raise StateError("Current session must consume the bound Canonical Spec via resume-spec-context before advancing.")
2346
1376
 
1377
+ return inspection
1378
+
2347
1379
 
2348
1380
  def refresh_correction_plan(root: Path, task_id: str, task: dict, inspection: dict) -> None:
2349
1381
  plan = latest_execution_plan(root, task_id)
@@ -2686,27 +1718,6 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
2686
1718
  return True
2687
1719
 
2688
1720
 
2689
- def execution_records(root: Path, task_id: str) -> list[dict]:
2690
- path = execution_log_path(root, task_id)
2691
- return memo(("execution", str(path)), lambda: _execution_records(path))
2692
-
2693
-
2694
- def _execution_records(path: Path) -> list[dict]:
2695
- if not path.exists():
2696
- return []
2697
- records: list[dict] = []
2698
- try:
2699
- for line in path.read_text(encoding="utf-8").splitlines():
2700
- if not line.strip():
2701
- continue
2702
- record = json.loads(line)
2703
- if isinstance(record, dict):
2704
- records.append(record)
2705
- except (OSError, json.JSONDecodeError):
2706
- return []
2707
- return records
2708
-
2709
-
2710
1721
  def latest_execution_plan(root: Path, task_id: str) -> dict | None:
2711
1722
  return memo(("plan", str(execution_log_path(root, task_id))),
2712
1723
  lambda: _latest_execution_plan(root, task_id))
@@ -2995,43 +2006,30 @@ def is_easy_coding_state_path(
2995
2006
 
2996
2007
  def git_index_entries(
2997
2008
  repository: Path, pathspecs: list[str]
2998
- ) -> dict[bytes, tuple[bytes, bytes]]:
2009
+ ) -> dict[bytes, tuple[bytes, bytes, bytes]]:
2999
2010
  result = run_git(
3000
2011
  repository,
3001
2012
  "ls-files",
3002
2013
  "--stage",
2014
+ "-v",
3003
2015
  "-z",
3004
2016
  "--",
3005
2017
  *pathspecs,
3006
2018
  )
3007
2019
  if result is None or result.returncode != 0:
3008
2020
  return {}
3009
- entries: dict[bytes, tuple[bytes, bytes]] = {}
2021
+ entries: dict[bytes, tuple[bytes, bytes, bytes]] = {}
3010
2022
  for raw_entry in filter(None, result.stdout.split(b"\0")):
3011
2023
  try:
3012
2024
  metadata, raw_path = raw_entry.split(b"\t", 1)
3013
- mode, object_id, stage = metadata.split()
2025
+ tag, mode, object_id, stage = metadata.split()
3014
2026
  except ValueError:
3015
2027
  continue
3016
2028
  if stage == b"0":
3017
- entries[raw_path] = (mode, object_id)
2029
+ entries[raw_path] = (mode, object_id, tag)
3018
2030
  return entries
3019
2031
 
3020
2032
 
3021
- def git_worktree_blob_oid(repository: Path, relative_name: str) -> bytes | None:
3022
- result = run_git(
3023
- repository,
3024
- "hash-object",
3025
- f"--path={relative_name}",
3026
- "--",
3027
- relative_name,
3028
- )
3029
- if result is None or result.returncode != 0:
3030
- return None
3031
- object_id = result.stdout.strip()
3032
- return object_id or None
3033
-
3034
-
3035
2033
  def worktree_git_mode(path: Path) -> bytes:
3036
2034
  if path.is_symlink():
3037
2035
  return b"120000"
@@ -3041,141 +2039,6 @@ def worktree_git_mode(path: Path) -> bytes:
3041
2039
  return b"<missing-mode>"
3042
2040
 
3043
2041
 
3044
- def update_git_repository_content_fingerprint(
3045
- digest,
3046
- root: Path,
3047
- repository: Path,
3048
- scopes: list[Path],
3049
- visited: set[tuple[Path, tuple[Path, ...]]],
3050
- ) -> None:
3051
- normalized_repository = repository.resolve()
3052
- normalized_scopes = tuple(scope.resolve() for scope in scopes)
3053
- visit_key = (normalized_repository, normalized_scopes)
3054
- if visit_key in visited:
3055
- digest.update(b"<git-scope-cycle>\0")
3056
- return
3057
- visited.add(visit_key)
3058
- try:
3059
- digest.update(b"git-repository\0")
3060
- digest.update(os.fsencode(display_path(root, normalized_repository)))
3061
- digest.update(b"\0")
3062
- pathspecs = repository_scope_pathspecs(
3063
- normalized_repository, list(normalized_scopes)
3064
- )
3065
- for scope in normalized_scopes:
3066
- relative_scope = scope.relative_to(normalized_repository).as_posix()
3067
- digest.update(b"git-scope\0")
3068
- digest.update(os.fsencode(relative_scope))
3069
- digest.update(b"\0")
3070
-
3071
- index_entries = git_index_entries(normalized_repository, pathspecs)
3072
- listed = run_git(
3073
- normalized_repository,
3074
- "ls-files",
3075
- "--cached",
3076
- "--others",
3077
- "--exclude-standard",
3078
- "-z",
3079
- "--",
3080
- *pathspecs,
3081
- )
3082
- modified = run_git(
3083
- normalized_repository,
3084
- "diff-files",
3085
- "--name-only",
3086
- "-z",
3087
- "--ignore-submodules=none",
3088
- "--",
3089
- *pathspecs,
3090
- )
3091
- if listed is None or listed.returncode != 0:
3092
- digest.update(b"<git-files-error>\0")
3093
- return
3094
- if modified is None or modified.returncode != 0:
3095
- digest.update(b"<git-diff-files-error>\0")
3096
- return
3097
- modified_paths = set(filter(None, modified.stdout.split(b"\0")))
3098
-
3099
- for raw_path in sorted(set(filter(None, listed.stdout.split(b"\0")))):
3100
- relative_name = os.fsdecode(raw_path)
3101
- if is_easy_coding_state_path(
3102
- normalized_repository, relative_name, list(normalized_scopes)
3103
- ):
3104
- continue
3105
- candidate = normalized_repository / relative_name
3106
- index_entry = index_entries.get(raw_path)
3107
- if index_entry is not None and index_entry[0] == b"160000":
3108
- digest.update(b"git-entry\0")
3109
- digest.update(raw_path)
3110
- digest.update(b"\0gitlink\0")
3111
- submodule_root = git_repository_root(candidate)
3112
- if (
3113
- submodule_root is not None
3114
- and submodule_root.resolve() == candidate.resolve()
3115
- ):
3116
- update_git_repository_content_fingerprint(
3117
- digest,
3118
- root,
3119
- submodule_root,
3120
- [submodule_root],
3121
- visited,
3122
- )
3123
- else:
3124
- digest.update(index_entry[1])
3125
- digest.update(b"\0")
3126
- continue
3127
-
3128
- exists = candidate.exists() or candidate.is_symlink()
3129
- if not exists:
3130
- if raw_path in modified_paths or index_entry is None:
3131
- # A worktree deletion is canonically absent before and after staging.
3132
- continue
3133
- # Sparse or otherwise intentionally absent tracked files retain index content.
3134
- mode, object_id = index_entry
3135
- elif index_entry is not None and raw_path not in modified_paths:
3136
- mode, object_id = index_entry
3137
- else:
3138
- mode = worktree_git_mode(candidate)
3139
- object_id = git_worktree_blob_oid(
3140
- normalized_repository, relative_name
3141
- )
3142
- if object_id is None:
3143
- try:
3144
- content = (
3145
- os.fsencode(os.readlink(candidate))
3146
- if candidate.is_symlink()
3147
- else candidate.read_bytes()
3148
- )
3149
- except OSError:
3150
- content = b"<missing>"
3151
- object_id = hashlib.sha256(content).hexdigest().encode("ascii")
3152
-
3153
- digest.update(b"git-entry\0")
3154
- digest.update(raw_path)
3155
- digest.update(b"\0")
3156
- digest.update(mode)
3157
- digest.update(b"\0")
3158
- digest.update(object_id)
3159
- digest.update(b"\0")
3160
- finally:
3161
- visited.remove(visit_key)
3162
-
3163
-
3164
- def update_git_worktree_fingerprint(
3165
- digest,
3166
- root: Path,
3167
- task: dict | None,
3168
- plan: dict,
3169
- ) -> None:
3170
- visited: set[tuple[Path, tuple[Path, ...]]] = set()
3171
- for repository, scopes in task_repository_scopes(root, task, plan):
3172
- if not scopes:
3173
- continue
3174
- update_git_repository_content_fingerprint(
3175
- digest, root, repository, scopes, visited
3176
- )
3177
-
3178
-
3179
2042
  def tdd_infrastructure_fingerprint(repositories: set[Path]) -> str:
3180
2043
  # 初始化快照不参与就绪判断;当前输入同时约束验收和跨仓证据继承。
3181
2044
  digest = hashlib.sha256()
@@ -4455,7 +3318,9 @@ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> s
4455
3318
  return canonical_json_sha256(contract)
4456
3319
 
4457
3320
 
4458
- def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
3321
+ def acceptance_repository_entries(
3322
+ repository: Path, scopes: list[Path], previous: dict[str, dict] | None = None,
3323
+ ) -> list[dict]:
4459
3324
  pathspecs = repository_scope_pathspecs(repository, scopes)
4460
3325
  index_entries = git_index_entries(repository, pathspecs)
4461
3326
  listed = run_git(
@@ -4488,6 +3353,8 @@ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[
4488
3353
  continue
4489
3354
  candidate = repository / relative_name
4490
3355
  index_entry = index_entries.get(raw_path)
3356
+ # 小写标记表示 assume-unchanged,S 表示 skip-worktree;Git 不检查其工作区变化。
3357
+ trusted_index = index_entry is not None and index_entry[2] == b"H"
4491
3358
  if index_entry is not None and index_entry[0] == b"160000":
4492
3359
  entries.append(
4493
3360
  {
@@ -4510,6 +3377,17 @@ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[
4510
3377
  }
4511
3378
  )
4512
3379
  continue
3380
+ mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
3381
+ prior = (previous or {}).get(relative_name)
3382
+ if (
3383
+ prior is not None and prior.get("exists") is True
3384
+ and prior.get("mode") == mode and trusted_index
3385
+ and raw_path not in modified_paths
3386
+ and prior.get("git_oid") == index_entry[1].decode("ascii")
3387
+ ):
3388
+ # Git 对象与工作区均未变化,沿用冻结快照的内容摘要。
3389
+ entries.append(prior)
3390
+ continue
4513
3391
  try:
4514
3392
  content = (
4515
3393
  os.fsencode(os.readlink(candidate))
@@ -4518,14 +3396,13 @@ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[
4518
3396
  )
4519
3397
  except OSError as exc:
4520
3398
  raise StateError(f"Cannot read verification snapshot file: {relative_name}") from exc
4521
- mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
4522
3399
  entry = {
4523
3400
  "path": relative_name,
4524
3401
  "exists": True,
4525
3402
  "mode": mode,
4526
3403
  "sha256": hashlib.sha256(content).hexdigest(),
4527
3404
  }
4528
- if index_entry is not None and raw_path not in modified_paths:
3405
+ if trusted_index and raw_path not in modified_paths:
4529
3406
  entry["git_oid"] = index_entry[1].decode("ascii", errors="replace")
4530
3407
  else:
4531
3408
  # 仅无法从 Git object 还原的工作区内容进入被忽略的临时快照。
@@ -4609,13 +3486,19 @@ def acceptance_filesystem_repositories(
4609
3486
  return repositories
4610
3487
 
4611
3488
 
4612
- def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
3489
+ def build_acceptance_snapshot(
3490
+ root: Path, task_id: str, task: dict, baseline: dict | None = None,
3491
+ ) -> dict:
4613
3492
  plan = latest_execution_plan(root, task_id)
4614
3493
  if plan is None:
4615
3494
  raise StateError("Cannot capture verification snapshot without a valid plan.")
4616
3495
  fingerprints = evidence_fingerprints(root, task_id)
4617
3496
  repository_scopes = task_repository_scopes(root, task, plan)
4618
3497
  repositories = []
3498
+ previous = {
3499
+ repo["root"]: {entry["path"]: entry for entry in repo["entries"]}
3500
+ for repo in (baseline or {}).get("repositories", [])
3501
+ }
4619
3502
  for repository, scopes in repository_scopes:
4620
3503
  repositories.append(
4621
3504
  {
@@ -4624,7 +3507,9 @@ def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
4624
3507
  "scopes": [
4625
3508
  scope.relative_to(repository.resolve()).as_posix() for scope in scopes
4626
3509
  ],
4627
- "entries": acceptance_repository_entries(repository.resolve(), scopes),
3510
+ "entries": acceptance_repository_entries(
3511
+ repository.resolve(), scopes, previous.get(str(repository.resolve())),
3512
+ ),
4628
3513
  }
4629
3514
  )
4630
3515
  repositories.extend(acceptance_filesystem_repositories(root, plan, repository_scopes))
@@ -4720,7 +3605,7 @@ def acceptance_change_patch(
4720
3605
  def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
4721
3606
  checkpoint = task.get("quality_checkpoint")
4722
3607
  baseline = load_acceptance_snapshot(root, task)
4723
- current = build_acceptance_snapshot(root, task_id, task)
3608
+ current = build_acceptance_snapshot(root, task_id, task, baseline)
4724
3609
  baseline_entries = acceptance_snapshot_entries(baseline)
4725
3610
  current_entries = acceptance_snapshot_entries(current)
4726
3611
  changes: list[dict] = []
@@ -4963,8 +3848,10 @@ def append_transition_acceptance(
4963
3848
  expected_diff_sha256: str | None = None,
4964
3849
  verification_policy: str | None = None,
4965
3850
  summary: str | None = None,
3851
+ *, drift: dict | None = None,
4966
3852
  ) -> dict:
4967
- drift = inspect_acceptance_drift(root, task_id, task)
3853
+ if drift is None:
3854
+ drift = inspect_acceptance_drift(root, task_id, task)
4968
3855
  if drift["config_changed"]:
4969
3856
  raise StateError(
4970
3857
  "Behavior config changed after quality checks; rerun QUALITY before MEMORY."
@@ -7014,421 +5901,6 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
7014
5901
  if r.get("type") == "handoff"), None)
7015
5902
 
7016
5903
 
7017
- def pending_handoff_record(root: Path, task_id: str) -> dict | None:
7018
- latest = next((r for r in reversed(execution_records(root, task_id))
7019
- if r.get("type") in {"handoff", "claim"}), None)
7020
- return latest if latest and latest["type"] == "handoff" else None
7021
-
7022
-
7023
- def assert_safe_task_id(task_id: str) -> None:
7024
- path = Path(task_id)
7025
- if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
7026
- raise StateError(f"Unsafe task id: {task_id}")
7027
-
7028
-
7029
- def is_project_init_required(root: Path) -> bool:
7030
- project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
7031
- return bool(project_init and project_init.get("status") != "COMPLETE")
7032
-
7033
-
7034
- def get_pending_init_version(root: Path) -> str | None:
7035
- project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
7036
- if project_init and project_init.get("pending_init_since"):
7037
- return str(project_init["pending_init_since"])
7038
- return None
7039
-
7040
-
7041
- def spec_task_summary(task: dict | None) -> dict | None:
7042
- if not task or not isinstance(task.get("spec_source"), dict):
7043
- return None
7044
- dependencies = task.get("spec_dependency_evidence")
7045
- pending_dependencies = [
7046
- {
7047
- "source_task_id": record.get("source_task_id"),
7048
- "task_id": record.get("task_id"),
7049
- "dependency_type": record.get("dependency_type"),
7050
- "required_evidence": record.get("required_evidence"),
7051
- }
7052
- for record in dependencies or []
7053
- if isinstance(record, dict) and record.get("status") == "pending"
7054
- ]
7055
- return {
7056
- "source": task["spec_source"],
7057
- "selected_spec_tasks": task.get("selected_spec_tasks", []),
7058
- "repositories": task.get("spec_repositories", []),
7059
- "pending_dependencies": pending_dependencies,
7060
- "writeback": task.get("spec_writeback_progress"),
7061
- "context": task.get("spec_context"),
7062
- "pending_change": task.get("spec_change"),
7063
- }
7064
-
7065
-
7066
- def transition_requires_confirmation(
7067
- previous: str,
7068
- current: str,
7069
- task_type: str,
7070
- approval_mode: str,
7071
- ) -> bool:
7072
- if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
7073
- return False
7074
- if current == "CLOSED":
7075
- return True
7076
- if approval_mode == "auto":
7077
- return False
7078
- if approval_mode == "guard":
7079
- return (previous, current) in CRITICAL_CONFIRM_TRANSITIONS
7080
- if approval_mode == "confirm":
7081
- return (previous, current) == ANALYSIS_CONFIRM_TRANSITION
7082
- if approval_mode == "approve":
7083
- return True
7084
- raise StateError(f"Unknown approval mode: {approval_mode}")
7085
-
7086
-
7087
- def is_automatic_transition(
7088
- previous: str,
7089
- current: str,
7090
- task_type: str,
7091
- approval_mode: str,
7092
- ) -> bool:
7093
- return not transition_requires_confirmation(previous, current, task_type, approval_mode)
7094
-
7095
-
7096
- def validate_transition(
7097
- previous: str,
7098
- current: str,
7099
- task_type: str = "",
7100
- task: dict | None = None,
7101
- ) -> str | None:
7102
- if previous == current:
7103
- return None
7104
- allowed = set(VALID_TRANSITIONS.get(previous, set()))
7105
- if previous == "IMPLEMENT":
7106
- allowed.discard("COMPLETE")
7107
- if current in allowed:
7108
- return None
7109
- return (
7110
- f"ILLEGAL TRANSITION: {previous} -> {current}. "
7111
- f"Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
7112
- )
7113
-
7114
-
7115
- def snapshot_state(
7116
- root: Path,
7117
- session_file: str | Path | None = None,
7118
- session: dict | None = None,
7119
- ) -> dict:
7120
- session_path = resolve_session_path(root, session_file)
7121
- resolved_session = session if session is not None else load_session(root, session_path)
7122
- if resolved_session is None:
7123
- resolved_session = default_session()
7124
-
7125
- task_id = resolved_session.get("current_task")
7126
- task = load_task(root, str(task_id)) if task_id else None
7127
- missing = bool(task_id and task is None)
7128
- status = "idle"
7129
- if missing:
7130
- status = "MISSING"
7131
- elif task and task.get("status"):
7132
- status = str(task["status"])
7133
-
7134
- if task_id and task and status in TERMINAL_STATUSES:
7135
- clear_session_pointer(resolved_session, task.get("last_agent"))
7136
- write_session(root, resolved_session, session_path)
7137
- task_id = None
7138
- task = None
7139
- missing = False
7140
- status = "idle"
7141
-
7142
- (
7143
- project_approval_mode,
7144
- session_approval_mode,
7145
- effective_approval_mode,
7146
- project_workflow_mode,
7147
- session_workflow_mode,
7148
- configured_workflow_mode,
7149
- project_unit_test_mode,
7150
- session_unit_test_mode,
7151
- effective_unit_test_mode,
7152
- project_ut_coverage_threshold,
7153
- session_ut_coverage_threshold,
7154
- effective_ut_coverage_threshold,
7155
- ) = resolve_behavior(root, resolved_session)
7156
- concrete_workflow_mode = None
7157
- if task:
7158
- concrete_workflow_mode = task.get("workflow_mode")
7159
- proposal = task.get("workflow_mode_proposal")
7160
- if concrete_workflow_mode is None and isinstance(proposal, dict):
7161
- concrete_workflow_mode = proposal.get("selected_mode")
7162
- task_unit_test_mode = task.get("unit_test_mode") if task else None
7163
- task_ut_coverage_threshold = task.get("ut_coverage_threshold") if task else None
7164
- frozen_unit_test = bool(
7165
- task
7166
- and status not in {"ANALYSIS", "INIT"}
7167
- and task_unit_test_mode in {"none", "ut", "tdd"}
7168
- )
7169
- is_tdd_init = bool(
7170
- task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
7171
- )
7172
- displayed_unit_test_mode = (
7173
- "none" if is_tdd_init else task_unit_test_mode if frozen_unit_test else effective_unit_test_mode
7174
- )
7175
- displayed_ut_threshold = (
7176
- task_ut_coverage_threshold
7177
- if frozen_unit_test and isinstance(task_ut_coverage_threshold, int)
7178
- else effective_ut_coverage_threshold
7179
- )
7180
- should_check_readiness = bool(
7181
- effective_unit_test_mode in {"ut", "tdd"} or task_unit_test_mode in {"ut", "tdd"} or is_tdd_init
7182
- )
7183
- readiness = (
7184
- tdd_readiness(root)
7185
- if should_check_readiness
7186
- else {"status": "not_checked", "reasons": []}
7187
- )
7188
-
7189
- layers = behavior_layers(root, resolved_session)
7190
- local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
7191
- return {
7192
- "session_file": display_path(root, session_path),
7193
- "behavior_sources": {key: item["source"] for key, item in layers.items()},
7194
- "local_behavior": local,
7195
- "effective_cooperate_mode": layers["cooperate_mode"]["value"],
7196
- "cooperation": task.get("cooperation") if task else None,
7197
- "quality_repair": task.get("quality_repair") if task else None,
7198
- "continuation": task.get("continuation") if task else None,
7199
- "current_task": str(task_id) if task_id else None,
7200
- "task": task,
7201
- "pending_transition": task.get("pending_transition") if task else None,
7202
- "memory_progress": task.get("memory_progress") if task else None,
7203
- "task_missing": missing,
7204
- "status": status,
7205
- "is_terminal": status in TERMINAL_STATUSES,
7206
- "last_agent": task.get("last_agent") if task else None,
7207
- "project_init_required": is_project_init_required(root),
7208
- "pending_init_version": get_pending_init_version(root),
7209
- "project_approval_mode": project_approval_mode,
7210
- "session_approval_mode": session_approval_mode,
7211
- "effective_approval_mode": effective_approval_mode,
7212
- "project_workflow_mode": project_workflow_mode,
7213
- "session_workflow_mode": session_workflow_mode,
7214
- "configured_workflow_mode": configured_workflow_mode,
7215
- "concrete_workflow_mode": concrete_workflow_mode,
7216
- "project_unit_test_mode": project_unit_test_mode,
7217
- "session_unit_test_mode": session_unit_test_mode,
7218
- "effective_unit_test_mode": effective_unit_test_mode,
7219
- "project_ut_coverage_threshold": project_ut_coverage_threshold,
7220
- "session_ut_coverage_threshold": session_ut_coverage_threshold,
7221
- "effective_ut_coverage_threshold": effective_ut_coverage_threshold,
7222
- "task_unit_test_mode": task_unit_test_mode,
7223
- "task_ut_coverage_threshold": task_ut_coverage_threshold,
7224
- "task_tdd_baselines": task.get("tdd_baselines") if task else None,
7225
- "displayed_unit_test_mode": displayed_unit_test_mode,
7226
- "displayed_ut_coverage_threshold": displayed_ut_threshold,
7227
- "unit_test_readiness_status": readiness["status"],
7228
- "unit_test_readiness_reasons": readiness["reasons"],
7229
- "spec_summary": spec_task_summary(task),
7230
- # Compatibility output aliases for pre-0.9 clients.
7231
- "project_confirm_mode": project_approval_mode,
7232
- "session_confirm_mode": session_approval_mode,
7233
- "effective_confirm_mode": effective_approval_mode,
7234
- "harness_disabled": resolved_session.get("harness_disabled") is True,
7235
- "lite_mode": resolved_session.get("lite_mode") is True,
7236
- "lite_proposal": resolved_session.get("lite_proposal"),
7237
- }
7238
-
7239
-
7240
- def build_status_line(
7241
- root: Path,
7242
- session: dict,
7243
- agent: str | None = None,
7244
- session_file: str | Path | None = None,
7245
- state: dict | None = None,
7246
- ) -> str:
7247
- state = state if state is not None else snapshot_state(root, session_file, session)
7248
- if state["lite_mode"]:
7249
- lite_state = (
7250
- "Awaiting Confirmation"
7251
- if isinstance(state.get("lite_proposal"), dict)
7252
- and not state["lite_proposal"].get("confirmed_at")
7253
- else "Ready"
7254
- )
7255
- return (
7256
- f"> **Easy Coding** · **Lite Direct** · {lite_state} · "
7257
- "No Task / Quality / Memory · Use `ec-lite` to exit"
7258
- )
7259
- approval = str(state["effective_approval_mode"]).capitalize()
7260
- workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
7261
- status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
7262
- if state["effective_cooperate_mode"] == "dispatch":
7263
- status_brand += " · **Dispatch**"
7264
- if state["displayed_unit_test_mode"] in {"ut", "tdd"}:
7265
- status_brand += f" · **{state['displayed_unit_test_mode'].upper()}**"
7266
- task_id = state["current_task"]
7267
- if task_id:
7268
- status = str(state["status"])
7269
- line = f"{status_brand} · `{task_id}` · `{status}`"
7270
- handoff = pending_handoff_record(root, str(task_id))
7271
- handoff_from = handoff.get("from") if handoff else None
7272
- if agent and handoff_from and not agents_equivalent(handoff_from, agent):
7273
- line += f" · Handoff -> `{handoff_from}`"
7274
- if state["is_terminal"] or state["task_missing"]:
7275
- line += f" · {HELP_SUFFIX}"
7276
- return line
7277
-
7278
- if is_project_init_required(root):
7279
- return f"{status_brand} · {WAITING_INIT_LINE}"
7280
-
7281
- pending = get_pending_init_version(root)
7282
- if pending:
7283
- return (
7284
- f"{status_brand} · Waiting init · "
7285
- f"Upgrade to v{pending} — run `ec-init` to adapt"
7286
- )
7287
-
7288
- return f"{status_brand} · {READY_LINE}"
7289
-
7290
-
7291
- def build_machine_breadcrumbs(
7292
- root: Path,
7293
- session: dict,
7294
- agent: str | None = None,
7295
- session_file: str | Path | None = None,
7296
- state: dict | None = None,
7297
- ) -> list[str]:
7298
- state = state if state is not None else snapshot_state(root, session_file, session)
7299
- task_id = state["current_task"]
7300
- task = state["task"]
7301
- stage = str(state["status"]) if task else "idle"
7302
- resolved_session_file = str(state["session_file"])
7303
- lines = [
7304
- f"[workflow-state:{stage}]",
7305
- f"[easy-coding:session-file:{resolved_session_file}]",
7306
- f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
7307
- f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
7308
- f"[easy-coding:cooperate-mode:{state['effective_cooperate_mode']}]",
7309
- ]
7310
- if state.get("concrete_workflow_mode"):
7311
- lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
7312
- if state.get("displayed_unit_test_mode") in {"ut", "tdd"}:
7313
- lines.append(f"[easy-coding:unit-test-mode:{state['displayed_unit_test_mode']}]")
7314
- lines.append(
7315
- f"[easy-coding:ut-coverage-threshold:{state['displayed_ut_coverage_threshold']}]"
7316
- )
7317
-
7318
- if task_id:
7319
- lines.append(f"[current-task:{task_id}]")
7320
- continuation = state.get("continuation") or {}
7321
- if continuation.get("next_action"):
7322
- lines.append(f"[easy-coding:next-action:{continuation['next_action']}]")
7323
- if continuation.get("stop_after"):
7324
- lines.append(f"[easy-coding:stop-after:{continuation['stop_after']}]")
7325
- if task and isinstance(task.get("spec_source"), dict):
7326
- source = task["spec_source"]
7327
- lines.append(f"[easy-coding:spec:{source.get('spec_id')}:revision:{source.get('revision')}]")
7328
- lines.append("[easy-coding:spec-context:reuse-current-session-or-resume-if-missing]")
7329
- if task.get("spec_change"):
7330
- lines.append("[easy-coding:spec-change:pending-sync-spec-design]")
7331
- if (task.get("spec_writeback_progress") or {}).get("pending_action"):
7332
- lines.append("[easy-coding:spec-writeback:reconcile-spec-execution-required]")
7333
- if state["task_missing"]:
7334
- lines.append(f"[easy-coding:current-task-missing:{task_id}]")
7335
- handoff = pending_handoff_record(root, str(task_id))
7336
- handoff_from = handoff.get("from") if handoff else None
7337
- if agent and handoff_from and not agents_equivalent(handoff_from, agent):
7338
- lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
7339
- pending = state.get("pending_transition")
7340
- if isinstance(pending, dict):
7341
- source = str(pending.get("from") or stage)
7342
- target = str(pending.get("to") or "")
7343
- if target:
7344
- lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
7345
- task_type = str(task.get("type") or "") if task else ""
7346
- if pending.get("confirmation_override") == "evidence-drift":
7347
- lines.append(
7348
- "[easy-coding:acceptance-drift-confirmation-required]"
7349
- )
7350
- lines.append("[easy-coding:transition-confirmation-required]")
7351
- elif is_automatic_transition(
7352
- source,
7353
- target,
7354
- task_type,
7355
- str(state["effective_approval_mode"]),
7356
- ):
7357
- lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
7358
- else:
7359
- lines.append("[easy-coding:transition-confirmation-required]")
7360
-
7361
- if is_project_init_required(root):
7362
- lines.append("[easy-coding:init-required]")
7363
- else:
7364
- pending = get_pending_init_version(root)
7365
- if pending:
7366
- lines.append(f"[easy-coding:upgrade-init-pending:{pending}]")
7367
-
7368
- # Stage-specific reminders
7369
- if stage == "ANALYSIS" and task_id:
7370
- dev_spec = root / ".easy-coding" / "tasks" / str(task_id) / "dev-spec.md"
7371
- if dev_spec.exists():
7372
- try:
7373
- content = dev_spec.read_text(encoding="utf-8")
7374
- missing = [h for h in MANDATORY_DEV_SPEC_HEADERS if h not in content]
7375
- if missing:
7376
- names = ",".join(h.lstrip("#").strip() for h in missing)
7377
- lines.append(f"[easy-coding:analysis-template-drift:missing:{names}]")
7378
- else:
7379
- lines.append("[easy-coding:analysis-template-ok]")
7380
- except OSError:
7381
- lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
7382
- else:
7383
- lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
7384
-
7385
- # State machine validation
7386
- if task_id and task and task.get("status"):
7387
- current_stage = str(task["status"])
7388
- last_seen = session.get("last_seen_stage")
7389
- violation = record_seen_stage(root, str(task_id), current_stage, resolved_session_file)
7390
- if violation:
7391
- lines.append(f"[ILLEGAL-TRANSITION:{last_seen}->{current_stage}]")
7392
- lines.append(f"[easy-coding:transition-error:{violation}]")
7393
-
7394
- return lines
7395
-
7396
-
7397
- def build_status_context(
7398
- root: Path,
7399
- session: dict,
7400
- agent: str | None = None,
7401
- session_file: str | Path | None = None,
7402
- state: dict | None = None,
7403
- ) -> str:
7404
- if session.get("harness_disabled") is True:
7405
- session_path = resolve_session_path(root, session_file)
7406
- return "\n".join(
7407
- [
7408
- "[easy-coding:no-harness]",
7409
- f"[easy-coding:session-file:{display_path(root, session_path)}]",
7410
- ]
7411
- )
7412
- if session.get("lite_mode") is True:
7413
- session_path = resolve_session_path(root, session_file)
7414
- proposal = session.get("lite_proposal")
7415
- lines = [
7416
- build_status_line(root, session, agent, session_file),
7417
- "[easy-coding:lite-direct]",
7418
- f"[easy-coding:session-file:{display_path(root, session_path)}]",
7419
- ]
7420
- if isinstance(proposal, dict):
7421
- lines.append(f"[easy-coding:lite-proposal:{proposal.get('digest', 'missing')}]")
7422
- return "\n".join(lines)
7423
- state = state if state is not None else snapshot_state(root, session_file, session)
7424
- return "\n".join(
7425
- [
7426
- build_status_line(root, session, agent, session_file, state),
7427
- *build_machine_breadcrumbs(root, session, agent, session_file, state),
7428
- ]
7429
- )
7430
-
7431
-
7432
5904
  def attach_status_context(
7433
5905
  root: Path,
7434
5906
  data: dict,
@@ -7488,16 +5960,6 @@ def list_tasks(root: Path, agent: str | None = None) -> list[dict]:
7488
5960
  return items
7489
5961
 
7490
5962
 
7491
- def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
7492
- session = load_session(root, session_file)
7493
- if session is None:
7494
- session = default_session()
7495
- if not session.get("created_at"):
7496
- session["created_at"] = now_iso()
7497
- session["last_active_at"] = now_iso()
7498
- return session
7499
-
7500
-
7501
5963
  def set_current_task(root: Path, task_id: str, agent: str, session_file: str | Path | None = None) -> dict:
7502
5964
  task = load_task(root, task_id)
7503
5965
  if task is None:
@@ -8379,13 +6841,14 @@ def _execute_spec_writeback(
8379
6841
  action: dict,
8380
6842
  idempotency_key: str,
8381
6843
  invoke,
8382
- *, allow_pending_hard_dependencies: bool = False,
6844
+ *, allow_pending_hard_dependencies: bool = False, inspection: dict | None = None,
8383
6845
  ) -> dict:
8384
- inspection, _ = inspect_task_spec(
8385
- root, task, allow_pending_hard_dependencies=(
8386
- allow_pending_hard_dependencies or action.get("kind") == "dependency"
8387
- ),
8388
- )
6846
+ if inspection is None:
6847
+ inspection, _ = inspect_task_spec(
6848
+ root, task, allow_pending_hard_dependencies=(
6849
+ allow_pending_hard_dependencies or action.get("kind") == "dependency"
6850
+ ),
6851
+ )
8389
6852
  source = task["spec_source"]
8390
6853
  progress = _writeback_progress(task)
8391
6854
  serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
@@ -8463,23 +6926,14 @@ def _execute_spec_writeback(
8463
6926
  raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
8464
6927
 
8465
6928
  event = _spec_event(execution, idempotency_key)
8466
- try:
8467
- details = show_execution(stored_spec_path(root, task))
8468
- except ExecutionStateError as exc:
8469
- raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
8470
- source.update(
8471
- {
8472
- "revision": details["design_revision"],
8473
- "design_sha256": details["design_sha256"],
8474
- "document_sha256": details["document_sha256"],
8475
- "execution_revision": execution["execution_revision"],
8476
- }
8477
- )
8478
- inspect_task_spec(
6929
+ # 写后只消费一次真实文件,同时检查设计未漂移、事件可见并刷新元数据。
6930
+ confirmed, _ = inspect_task_spec(
8479
6931
  root, task, allow_pending_hard_dependencies=(
8480
6932
  allow_pending_hard_dependencies or action.get("kind") == "dependency"
8481
6933
  ),
8482
6934
  )
6935
+ if _spec_event(confirmed["execution"], idempotency_key)["event_id"] != event["event_id"]:
6936
+ raise StateError("Canonical Spec writeback event changed before acknowledgment.")
8483
6937
  progress.update(
8484
6938
  {
8485
6939
  "last_execution_revision": execution["execution_revision"],
@@ -8522,7 +6976,7 @@ def writeback_spec_task(
8522
6976
  *, event_agent: str | None = None,
8523
6977
  ) -> dict:
8524
6978
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8525
- require_spec_context(root, task, agent, session_file)
6979
+ inspection = require_spec_context(root, task, agent, session_file)
8526
6980
  writer_agent = event_agent or agent
8527
6981
  if source_task_id not in set(task.get("selected_spec_tasks") or []):
8528
6982
  raise StateError("Canonical source task is outside the Harness task selection.")
@@ -8554,6 +7008,7 @@ def writeback_spec_task(
8554
7008
  run_id=resolved_task_id,
8555
7009
  idempotency_key=idempotency_key,
8556
7010
  ),
7011
+ inspection=inspection,
8557
7012
  )
8558
7013
  snapshot = snapshot_state(root, session_file, session)
8559
7014
  snapshot["spec_writeback"] = acknowledgment
@@ -8575,7 +7030,7 @@ def writeback_spec_step(
8575
7030
  *, event_agent: str | None = None,
8576
7031
  ) -> dict:
8577
7032
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8578
- require_spec_context(root, task, agent, session_file)
7033
+ inspection = require_spec_context(root, task, agent, session_file)
8579
7034
  writer_agent = event_agent or agent
8580
7035
  if source_task_id not in set(task.get("selected_spec_tasks") or []):
8581
7036
  raise StateError("Canonical source task is outside the Harness task selection.")
@@ -8609,6 +7064,7 @@ def writeback_spec_step(
8609
7064
  run_id=resolved_task_id,
8610
7065
  idempotency_key=idempotency_key,
8611
7066
  ),
7067
+ inspection=inspection,
8612
7068
  )
8613
7069
  snapshot = snapshot_state(root, session_file, session)
8614
7070
  snapshot["spec_writeback"] = acknowledgment
@@ -8630,7 +7086,7 @@ def writeback_spec_dependency(
8630
7086
  *, event_agent: str | None = None,
8631
7087
  ) -> dict:
8632
7088
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8633
- require_spec_context(root, task, agent, session_file, allow_pending_hard_dependencies=True)
7089
+ inspection = require_spec_context(root, task, agent, session_file, allow_pending_hard_dependencies=True)
8634
7090
  writer_agent = event_agent or agent
8635
7091
  if source_task_id not in set(task.get("selected_spec_tasks") or []):
8636
7092
  raise StateError("Canonical source task is outside the Harness task selection.")
@@ -8664,6 +7120,7 @@ def writeback_spec_dependency(
8664
7120
  run_id=resolved_task_id,
8665
7121
  idempotency_key=idempotency_key,
8666
7122
  ),
7123
+ inspection=inspection,
8667
7124
  )
8668
7125
  snapshot = snapshot_state(root, session_file, session)
8669
7126
  snapshot["spec_writeback"] = acknowledgment
@@ -9150,6 +7607,7 @@ def sync_spec_design_state(
9150
7607
  try:
9151
7608
  from easy_dev_spec_protocol import split_execution_region
9152
7609
 
7610
+
9153
7611
  _, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
9154
7612
  except (OSError, UnicodeError, ValueError) as exc:
9155
7613
  raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
@@ -10347,6 +8805,7 @@ def auto_transition(
10347
8805
  agent,
10348
8806
  approval_mode,
10349
8807
  "approval-policy",
8808
+ drift=drift,
10350
8809
  )
10351
8810
 
10352
8811
  snapshot = apply_transition(root, stage, agent, task_id, session_file)
@@ -10460,46 +8919,6 @@ def memory_short_complete(
10460
8919
  memory_file.strip(),
10461
8920
  require_current_id=True,
10462
8921
  )
10463
- acceptance = latest_acceptance_record(root, resolved_task_id, task)
10464
- if isinstance(acceptance, dict) and acceptance.get("changed_files"):
10465
- try:
10466
- memory_text = resolved_memory_path.read_text(encoding="utf-8")
10467
- except (OSError, UnicodeError) as exc:
10468
- raise StateError(f"Cannot read short-memory file: {resolved_memory_path}") from exc
10469
- required_decision_fields = {
10470
- "diff_sha256": str(acceptance.get("diff_sha256") or ""),
10471
- "authorization": str(acceptance.get("authorization") or ""),
10472
- "approval_mode": str(acceptance.get("approval_mode") or ""),
10473
- "review_policy": str(acceptance.get("review_policy") or ""),
10474
- "verification_policy": str(acceptance.get("verification_policy") or ""),
10475
- "summary": str(acceptance.get("summary") or ""),
10476
- }
10477
- missing_decision_fields = [
10478
- field_name
10479
- for field_name, value in required_decision_fields.items()
10480
- if not value or value not in memory_text
10481
- ]
10482
- missing_changed_files = [
10483
- str(file_name)
10484
- for file_name in acceptance.get("changed_files", [])
10485
- if not is_non_empty_string(file_name) or str(file_name) not in memory_text
10486
- ]
10487
- missing_targeted_tasks = [
10488
- str(source_task_id)
10489
- for source_task_id in acceptance.get("required_targeted_source_tasks", [])
10490
- if not is_non_empty_string(source_task_id)
10491
- or str(source_task_id) not in memory_text
10492
- ]
10493
- if missing_decision_fields or missing_changed_files or missing_targeted_tasks:
10494
- missing_labels = [
10495
- *missing_decision_fields,
10496
- *(f"changed_file:{file_name}" for file_name in missing_changed_files),
10497
- *(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
10498
- ]
10499
- raise StateError(
10500
- "Short memory must record the complete accepted post-quality decision; "
10501
- "missing: " + ", ".join(missing_labels)
10502
- )
10503
8922
  progress = task.get("memory_progress")
10504
8923
  if not isinstance(progress, dict):
10505
8924
  progress = {}
@@ -10690,32 +9109,6 @@ def set_repo_path(
10690
9109
  return {"task_id": str(resolved_task_id), "repo_paths": repo_paths}
10691
9110
 
10692
9111
 
10693
- def record_seen_stage(
10694
- root: Path,
10695
- task_id: str | None,
10696
- stage: str,
10697
- session_file: str | Path | None = None,
10698
- ) -> str | None:
10699
- if not task_id or stage in {"idle", "MISSING"}:
10700
- return None
10701
- session = ensure_session(root, session_file)
10702
- last_seen_task = session.get("last_seen_task")
10703
- last_seen_stage = session.get("last_seen_stage")
10704
-
10705
- violation = None
10706
- if last_seen_task == task_id and last_seen_stage:
10707
- task = load_task(root, task_id)
10708
- task_type = str(task.get("type") or "") if task else ""
10709
- violation = validate_transition(str(last_seen_stage), stage, task_type, task)
10710
-
10711
- if last_seen_task != task_id or last_seen_stage != stage:
10712
- session["last_seen_task"] = task_id
10713
- session["last_seen_stage"] = stage
10714
- write_session(root, session, session_file)
10715
-
10716
- return violation
10717
-
10718
-
10719
9112
  def resolve_root(cwd: str | None) -> Path:
10720
9113
  root = find_ec_root(Path(cwd or os.getcwd()))
10721
9114
  if root is None:
@@ -11564,6 +9957,13 @@ def main() -> int:
11564
9957
  if isinstance(results, list) else record_check(root, task_id, task, args.prepared_id, results, agent))
11565
9958
  else:
11566
9959
  result = begin_correction(root, task_id, task, args.file, args.summary, args.risk, agent)
9960
+ if command in {"prepare-check", "record-check"}:
9961
+ for item in result if isinstance(result, list) else [result]:
9962
+ item["task_id"] = task_id
9963
+ item["stage"] = task.get("status")
9964
+ item["next_action"] = (
9965
+ "reuse-check" if item.get("reusable") else "run-check"
9966
+ ) if command == "prepare-check" else "continue"
11567
9967
  emit(result)
11568
9968
  elif command in {"start-quality-repair", "complete-quality-repair"}:
11569
9969
  if command == "start-quality-repair":