easy-coding-harness 1.1.0-beta.3 → 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.
@@ -0,0 +1,1193 @@
1
+ """Shared session/configuration storage; safe to import from prompt hooks."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from easy_coding_operation import memo, invalidate_memo
15
+
16
+
17
+ TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
18
+ HELP_SUFFIX = (
19
+ "Use `ec-workflow` to start or resume a task, "
20
+ "`ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, "
21
+ "or `ec-config` to inspect or change modes"
22
+ )
23
+ READY_LINE = f"Ready · {HELP_SUFFIX}"
24
+ WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
25
+ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
26
+ "## 技术方案",
27
+ "### 项目模式",
28
+ "### 任务类型",
29
+ "### 需求解析",
30
+ "### 现状",
31
+ "### 冲突摘要",
32
+ "### 决策闭环",
33
+ "### 影响面分析",
34
+ "### 改动范围",
35
+ "### 修改方案",
36
+ "### 实施拆解",
37
+ "### 测试策略",
38
+ "### Workflow Mode",
39
+ "### 风险与注意事项",
40
+ ]
41
+ VALID_TRANSITIONS: dict[str, set[str]] = {
42
+ "idle": {"INIT"},
43
+ "INIT": {"ANALYSIS", "CLOSED"},
44
+ "ANALYSIS": {"IMPLEMENT", "CLOSED"},
45
+ "IMPLEMENT": {"QUALITY", "ANALYSIS", "CLOSED"},
46
+ "QUALITY": {"MEMORY", "IMPLEMENT", "ANALYSIS", "CLOSED"},
47
+ "MEMORY": {"COMPLETE", "CLOSED"},
48
+ "COMPLETE": set(),
49
+ "CLOSED": set(),
50
+ }
51
+ ALWAYS_AUTO_TRANSITIONS = {
52
+ ("INIT", "ANALYSIS"),
53
+ ("MEMORY", "COMPLETE"),
54
+ }
55
+ TDD_INIT_TASK_TYPE = "tdd-init"
56
+ APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
57
+ CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
58
+ DEFAULT_APPROVAL_MODE = "guard"
59
+ DEFAULT_WORKFLOW_MODE = "adaptive"
60
+ DEFAULT_UNIT_TEST_MODE = "none"
61
+ DEFAULT_UT_COVERAGE_THRESHOLD = 90
62
+ TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
63
+ TDD_READINESS_SCOPE = "changed-production-lines"
64
+ TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
65
+ TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
66
+ TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
67
+ COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
68
+ JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
69
+ GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
70
+ CRITICAL_CONFIRM_TRANSITIONS = {
71
+ ("ANALYSIS", "IMPLEMENT"),
72
+ ("QUALITY", "MEMORY"),
73
+ }
74
+ ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
75
+ LEGACY_STAGE_MAP = {
76
+ "WAITING_CONFIRM": "ANALYSIS",
77
+ "REVIEW": "QUALITY",
78
+ "VERIFICATION": "QUALITY",
79
+ "MEMORY_SHORT": "MEMORY",
80
+ "MEMORY_LONG": "MEMORY",
81
+ }
82
+ SESSION_IDLE_RETENTION_HOURS = 7 * 24
83
+ SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
84
+ MAX_SESSION_FILES = 100
85
+ SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
86
+ WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
87
+ # 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
88
+ INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
89
+ SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
90
+ CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
91
+ LEGACY_DISPLAY_AGENT_IDENTITIES = {
92
+ "claude with easy coding": "claude-code",
93
+ "claude-code with easy coding": "claude-code",
94
+ "claude code with easy coding": "claude-code",
95
+ "codex with easy coding": "codex",
96
+ "qoder with easy coding": "qoder",
97
+ }
98
+ LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
99
+ LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
100
+ LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
101
+ SESSION_COMMAND_LOCK_TIMEOUT_SECONDS = 5.0
102
+ SESSION_COMMAND_LOCK_STALE_SECONDS = 60.0
103
+ SESSION_COMMAND_LOCK_POLL_SECONDS = 0.02
104
+
105
+
106
+ class StateError(Exception):
107
+ pass
108
+
109
+
110
+ def now_iso() -> str:
111
+ return datetime.now(timezone.utc).isoformat()
112
+
113
+
114
+ def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
115
+ raw_agent = str(agent or "unknown").strip()
116
+ normalized = raw_agent.lower()
117
+ # Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
118
+ if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
119
+ return "codex"
120
+ if normalized in WORKFLOW_AGENT_IDENTITIES:
121
+ return normalized
122
+ if allow_legacy_display:
123
+ return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
124
+ return None
125
+
126
+
127
+ def normalize_agent_identity(agent: str | None) -> str:
128
+ raw_agent = str(agent or "unknown").strip()
129
+ # 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
130
+ canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
131
+ if canonical is not None:
132
+ return canonical
133
+ return raw_agent
134
+
135
+
136
+ def normalize_session_agent(agent: str | None) -> str:
137
+ normalized = normalize_agent_identity(agent)
138
+ return normalized if normalized in SESSION_AGENT_NAMESPACES else "unknown"
139
+
140
+
141
+ def agents_equivalent(first: str | None, second: str | None) -> bool:
142
+ return normalize_agent_identity(first) == normalize_agent_identity(second)
143
+
144
+
145
+ def detect_runtime_agent() -> str:
146
+ if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
147
+ return INSTALLED_WORKFLOW_AGENT
148
+ # 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
149
+ script_path = Path(sys.argv[0]).as_posix()
150
+ if ".qoder/" in script_path or ".qodercn/" in script_path:
151
+ return "qoder"
152
+ if ".codex/" in script_path:
153
+ return "codex"
154
+ if ".claude/" in script_path:
155
+ return "claude-code"
156
+ # Qoder CLI 会暴露 Claude 兼容环境变量,专属信号必须优先于兼容信号。
157
+ if os.environ.get("QODER_PROJECT_DIR"):
158
+ return "qoder"
159
+ if os.environ.get("CLAUDE_PROJECT_DIR"):
160
+ return "claude-code"
161
+ return "unknown"
162
+
163
+
164
+ def normalize_session_component(value: str) -> str:
165
+ if (
166
+ value not in {".", ".."}
167
+ and len(value) <= 120
168
+ and SESSION_COMPONENT_PATTERN.fullmatch(value)
169
+ ):
170
+ return value
171
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
172
+ return f"sha256-{digest}"
173
+
174
+
175
+ def hook_session_identity(
176
+ payload: dict,
177
+ agent: str | None,
178
+ ppid: int | None = None,
179
+ ) -> dict:
180
+ namespace = normalize_session_agent(agent)
181
+ raw_session_id = payload.get("session_id") or payload.get("sessionId")
182
+ external_session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
183
+ source = "hook-session-id"
184
+ if not external_session_id and namespace == "codex":
185
+ # Codex App 当前会把 thread ID 暴露在进程环境中;标准 hook session_id 仍保持最高优先级。
186
+ raw_thread_id = (
187
+ payload.get("thread_id")
188
+ or payload.get("threadId")
189
+ or os.environ.get("CODEX_THREAD_ID")
190
+ )
191
+ external_session_id = str(raw_thread_id).strip() if raw_thread_id is not None else ""
192
+ source = "codex-thread-id"
193
+ if external_session_id:
194
+ component = normalize_session_component(external_session_id)
195
+ else:
196
+ component = f"ppid-{ppid if ppid is not None else os.getppid()}"
197
+ source = "legacy-ppid"
198
+ return {
199
+ "agent": namespace,
200
+ "external_session_id": external_session_id or None,
201
+ "session_key": f"{namespace}-{component}",
202
+ "session_source": source,
203
+ }
204
+
205
+
206
+ def load_json(path: Path) -> dict | None:
207
+ return memo(("json", str(path)), lambda: _load_json(path))
208
+
209
+
210
+ def _load_json(path: Path) -> dict | None:
211
+ if not path.exists():
212
+ return None
213
+ try:
214
+ return json.loads(path.read_text(encoding="utf-8"))
215
+ except (OSError, json.JSONDecodeError):
216
+ return None
217
+
218
+
219
+ def parse_ut_threshold(value: object, source: str) -> int:
220
+ if isinstance(value, bool):
221
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
222
+ try:
223
+ threshold = int(str(value))
224
+ except (TypeError, ValueError) as error:
225
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
226
+ if threshold < 1 or threshold > 100:
227
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
228
+ return threshold
229
+
230
+
231
+ def parse_unit_test_mode(value: object, source: str) -> str:
232
+ if not isinstance(value, str) or value not in {"none", "ut", "tdd"}:
233
+ raise StateError(f"Invalid {source}: expected none, ut, or tdd.")
234
+ return str(value)
235
+
236
+
237
+ def read_behavior_file(path: Path) -> tuple[dict, int]:
238
+ return memo(("behavior", str(path)), lambda: _read_behavior_file(path))
239
+
240
+
241
+ def _read_behavior_file(path: Path) -> tuple[dict, int]:
242
+ try:
243
+ lines = path.read_text(encoding="utf-8").splitlines()
244
+ except FileNotFoundError:
245
+ return {}, 0
246
+
247
+ in_behavior = False
248
+ behavior_indent = 0
249
+ behavior: dict[str, str] = {}
250
+ schema_version = 0
251
+ for raw_line in lines:
252
+ without_comment = raw_line.split("#", 1)[0].rstrip()
253
+ stripped = without_comment.strip()
254
+ if not stripped:
255
+ continue
256
+ indent = len(without_comment) - len(without_comment.lstrip(" "))
257
+ if stripped.startswith("behavior:") and stripped != "behavior:":
258
+ raise StateError("Behavior configuration must use an indented YAML mapping; write it with easy-coding config.")
259
+ if stripped == "behavior:":
260
+ in_behavior = True
261
+ behavior_indent = indent
262
+ continue
263
+ if in_behavior and indent <= behavior_indent:
264
+ in_behavior = False
265
+ if not in_behavior and indent == 0 and stripped.startswith("version:"):
266
+ try:
267
+ schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
268
+ except ValueError:
269
+ schema_version = 0
270
+ continue
271
+ if not in_behavior or ":" not in stripped:
272
+ continue
273
+ key, value = stripped.split(":", 1)
274
+ behavior[key] = value.strip().strip("'\"")
275
+
276
+ return behavior, schema_version
277
+
278
+
279
+ def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
280
+ behavior, schema_version = read_behavior_file(root / ".easy-coding" / "config.yaml")
281
+ legacy = behavior.get("confirm_mode")
282
+ approval_mode = behavior.get("approval_mode")
283
+ workflow_mode = behavior.get("workflow_mode")
284
+ if approval_mode is None:
285
+ if legacy == "lite":
286
+ approval_mode = "guard"
287
+ elif legacy in APPROVAL_MODES:
288
+ approval_mode = legacy
289
+ else:
290
+ approval_mode = DEFAULT_APPROVAL_MODE
291
+ if workflow_mode is None:
292
+ workflow_mode = "fast" if legacy == "lite" else DEFAULT_WORKFLOW_MODE
293
+ if approval_mode not in APPROVAL_MODES:
294
+ raise StateError(
295
+ "Invalid behavior.approval_mode in .easy-coding/config.yaml: "
296
+ "expected approve, guard, confirm, or auto."
297
+ )
298
+ if workflow_mode not in CONFIGURED_WORKFLOW_MODES:
299
+ raise StateError(
300
+ "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
301
+ "expected adaptive, fast, standard, or strict."
302
+ )
303
+ if schema_version >= 6:
304
+ unit_test_mode = parse_unit_test_mode(
305
+ behavior.get("unit_test_mode", DEFAULT_UNIT_TEST_MODE), "behavior.unit_test_mode"
306
+ )
307
+ threshold = parse_ut_threshold(
308
+ behavior.get("ut_coverage_threshold", DEFAULT_UT_COVERAGE_THRESHOLD),
309
+ "behavior.ut_coverage_threshold",
310
+ )
311
+ else:
312
+ if schema_version >= 4:
313
+ raise StateError("Run easy-coding upgrade to migrate unit-test settings to schema 6.")
314
+ unit_test_mode = DEFAULT_UNIT_TEST_MODE
315
+ threshold = DEFAULT_UT_COVERAGE_THRESHOLD
316
+ return approval_mode, workflow_mode, unit_test_mode, threshold
317
+
318
+
319
+ def behavior_layers(root: Path, session: dict) -> dict:
320
+ project, _ = read_behavior_file(root / ".easy-coding" / "config.yaml")
321
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
322
+ defaults = {"approval_mode": DEFAULT_APPROVAL_MODE, "cooperate_mode": "default",
323
+ "unit_test_mode": DEFAULT_UNIT_TEST_MODE,
324
+ "ut_coverage_threshold": DEFAULT_UT_COVERAGE_THRESHOLD}
325
+ layers = {"project": project, "local": local, "session": session}
326
+ result = {}
327
+ for key, default in defaults.items():
328
+ source = next((name for name in ("session", "local", "project")
329
+ if layers[name].get(key) is not None), "default")
330
+ value = layers[source][key] if source != "default" else default
331
+ if key == "ut_coverage_threshold":
332
+ value = parse_ut_threshold(value, f"{source} {key}")
333
+ elif key == "unit_test_mode":
334
+ value = parse_unit_test_mode(value, f"{source} {key}")
335
+ elif value not in (APPROVAL_MODES if key == "approval_mode" else {"default", "dispatch"}):
336
+ raise StateError(f"Invalid {source} {key}: {value}")
337
+ result[key] = {"value": value, "source": source}
338
+ return result
339
+
340
+
341
+ def safe_tdd_report_pattern(value: object) -> bool:
342
+ if not is_non_empty_string(value):
343
+ return False
344
+ candidate = Path(str(value))
345
+ return not candidate.is_absolute() and ".." not in candidate.parts
346
+
347
+
348
+ def tdd_gate_uses_task_variables(command: object) -> bool:
349
+ if not is_non_empty_string(command):
350
+ return False
351
+ try:
352
+ tokens = shlex.split(str(command))
353
+ except ValueError:
354
+ return False
355
+ options: dict[str, str] = {}
356
+ for index, token in enumerate(tokens[:-1]):
357
+ if token in {"--base", "--threshold"}:
358
+ options[token] = tokens[index + 1]
359
+ return options.get("--base") in {
360
+ f"${TDD_BASE_VARIABLE}",
361
+ "$" + "{" + TDD_BASE_VARIABLE + "}",
362
+ } and options.get("--threshold") in {
363
+ f"${TDD_THRESHOLD_VARIABLE}",
364
+ "$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
365
+ }
366
+
367
+
368
+ def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
369
+ combined = "\n".join(
370
+ re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
371
+ for line in "\n".join(contents).splitlines()
372
+ )
373
+ lowered = combined.lower()
374
+ reasons: list[str] = []
375
+ for marker in (
376
+ "jacoco",
377
+ "artifacts",
378
+ COVERAGE_TOOL_PATH,
379
+ TDD_BASE_VARIABLE,
380
+ TDD_THRESHOLD_VARIABLE,
381
+ ):
382
+ if marker.lower() not in lowered:
383
+ reasons.append(f"CI files do not contain required marker: {marker}")
384
+ if not tdd_gate_uses_task_variables(combined):
385
+ reasons.append(
386
+ "CI changed-line gate must use the task baseline and threshold variables"
387
+ )
388
+ if re.search(
389
+ r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
390
+ combined,
391
+ re.IGNORECASE,
392
+ ) is None:
393
+ reasons.append("CI files do not declare a TEST-stage job")
394
+ return reasons
395
+
396
+
397
+ def tdd_readiness(root: Path, include_ci: bool = False) -> dict[str, object]:
398
+ receipt = root / TDD_READINESS_PATH
399
+ if not receipt.is_file():
400
+ return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
401
+ try:
402
+ manifest = json.loads(receipt.read_text(encoding="utf-8"))
403
+ except (OSError, UnicodeError, json.JSONDecodeError):
404
+ return {"status": "needs_repair", "reasons": ["TDD readiness receipt is invalid"]}
405
+ if not isinstance(manifest, dict):
406
+ return {
407
+ "status": "needs_repair",
408
+ "reasons": ["TDD readiness receipt must be a JSON object"],
409
+ }
410
+
411
+ reasons: list[str] = []
412
+ if manifest.get("schema") != TDD_READINESS_SCHEMA:
413
+ reasons.append("unsupported readiness schema")
414
+ if manifest.get("provider") != "gitlab":
415
+ reasons.append("readiness provider must be gitlab")
416
+ if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
417
+ reasons.append("coverage scope must be changed-production-lines")
418
+ if manifest.get("historical_coverage_required") is not False:
419
+ reasons.append("historical coverage must remain disabled")
420
+ reports = manifest.get("coverage_report_patterns")
421
+ if not isinstance(reports, list) or not reports or not all(
422
+ safe_tdd_report_pattern(item) for item in reports
423
+ ):
424
+ reasons.append(
425
+ "coverage_report_patterns must contain safe project-relative report patterns"
426
+ )
427
+ gate = manifest.get("changed_line_gate_command")
428
+ if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
429
+ reasons.append("changed-line coverage gate command is missing")
430
+ elif not tdd_gate_uses_task_variables(gate):
431
+ reasons.append(
432
+ "changed-line coverage gate must use the task baseline and threshold variables"
433
+ )
434
+
435
+ contents: dict[str, list[str]] = {
436
+ "build_files": [],
437
+ "tool_files": [],
438
+ }
439
+ if include_ci:
440
+ contents["ci_files"] = []
441
+ for field in contents:
442
+ records = manifest.get(field)
443
+ if not isinstance(records, list) or not records:
444
+ reasons.append(f"{field} must contain at least one file")
445
+ continue
446
+ for record in records:
447
+ if not isinstance(record, dict):
448
+ reasons.append(f"{field} contains an invalid record")
449
+ continue
450
+ file_name = record.get("path")
451
+ if not is_non_empty_string(file_name):
452
+ reasons.append(f"{field} contains an invalid path")
453
+ continue
454
+ candidate = Path(str(file_name))
455
+ if candidate.is_absolute():
456
+ reasons.append(f"readiness file must be project-relative: {file_name}")
457
+ continue
458
+ resolved = (root / candidate).resolve()
459
+ try:
460
+ resolved.relative_to(root.resolve())
461
+ payload = resolved.read_bytes()
462
+ contents[field].append(payload.decode("utf-8"))
463
+ except (OSError, UnicodeError, ValueError):
464
+ reasons.append(f"readiness file is missing or unreadable: {file_name}")
465
+
466
+ manifest_build_files = manifest.get("build_files")
467
+ manifest_ci_files = manifest.get("ci_files")
468
+ manifest_tool_files = manifest.get("tool_files")
469
+ build_paths = {
470
+ Path(str(item.get("path", ""))).name
471
+ for item in manifest_build_files
472
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
473
+ } if isinstance(manifest_build_files, list) else set()
474
+ ci_paths = {
475
+ str(item.get("path", "")).replace("\\", "/")
476
+ for item in manifest_ci_files
477
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
478
+ } if isinstance(manifest_ci_files, list) else set()
479
+ if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
480
+ reasons.append("build_files must include a Maven or Gradle Java build file")
481
+ tool_paths = {
482
+ str(item.get("path", "")).replace("\\", "/")
483
+ for item in manifest_tool_files
484
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
485
+ } if isinstance(manifest_tool_files, list) else set()
486
+ if COVERAGE_TOOL_PATH not in tool_paths:
487
+ reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
488
+ if include_ci:
489
+ if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
490
+ reasons.append("ci_files must include the project-root GitLab CI entry file")
491
+ if not any("jacoco" in content.lower() for content in contents["build_files"]):
492
+ reasons.append("build files do not configure JaCoCo")
493
+ reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
494
+ return {
495
+ "status": "ready" if not reasons else "needs_repair",
496
+ "reasons": list(dict.fromkeys(reasons)),
497
+ }
498
+
499
+
500
+ def resolve_behavior(
501
+ root: Path, session: dict
502
+ ) -> tuple[str, str | None, str, str, str | None, str, str, str | None, str, int, int | None, int]:
503
+ project_approval, project_workflow, project_unit_test, project_threshold = read_project_behavior(root)
504
+ legacy = session.get("confirm_mode")
505
+ session_approval = session.get("approval_mode")
506
+ session_workflow = session.get("workflow_mode")
507
+ session_unit_test = session.get("unit_test_mode")
508
+ session_threshold = session.get("ut_coverage_threshold")
509
+ if session_approval is None:
510
+ if legacy == "lite":
511
+ session_approval = "guard"
512
+ elif legacy in APPROVAL_MODES:
513
+ session_approval = legacy
514
+ if session_workflow is None:
515
+ if legacy == "lite":
516
+ session_workflow = "fast"
517
+ elif legacy in APPROVAL_MODES:
518
+ session_workflow = "adaptive"
519
+ if session_approval is not None and session_approval not in APPROVAL_MODES:
520
+ raise StateError(
521
+ "Invalid session approval_mode: expected approve, guard, confirm, or auto."
522
+ )
523
+ if session_workflow is not None and session_workflow not in CONFIGURED_WORKFLOW_MODES:
524
+ raise StateError(
525
+ "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
526
+ )
527
+ if session_unit_test is not None:
528
+ session_unit_test = parse_unit_test_mode(session_unit_test, "session unit_test_mode")
529
+ if session_threshold is not None:
530
+ session_threshold = parse_ut_threshold(
531
+ session_threshold, "session ut_coverage_threshold"
532
+ )
533
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
534
+ effective = behavior_layers(root, session)
535
+ return (
536
+ project_approval,
537
+ str(session_approval) if session_approval else None,
538
+ str(session_approval or (effective["approval_mode"]["value"] if "approval_mode" in local else project_approval)),
539
+ project_workflow,
540
+ str(session_workflow) if session_workflow else None,
541
+ str(session_workflow or project_workflow),
542
+ project_unit_test,
543
+ session_unit_test,
544
+ session_unit_test if session_unit_test is not None else (
545
+ effective["unit_test_mode"]["value"] if "unit_test_mode" in local else project_unit_test),
546
+ project_threshold,
547
+ session_threshold,
548
+ session_threshold if session_threshold is not None else (
549
+ effective["ut_coverage_threshold"]["value"] if "ut_coverage_threshold" in local else project_threshold),
550
+ )
551
+
552
+
553
+ def migrate_unit_test_settings(record: dict) -> bool:
554
+ changed = False
555
+ if "tdd_enabled" in record:
556
+ enabled = record.pop("tdd_enabled")
557
+ if "unit_test_mode" not in record and isinstance(enabled, bool):
558
+ record["unit_test_mode"] = "tdd" if enabled else "none"
559
+ changed = True
560
+ if "tdd_coverage_threshold" in record:
561
+ threshold = record.pop("tdd_coverage_threshold")
562
+ record.setdefault("ut_coverage_threshold", threshold)
563
+ changed = True
564
+ return changed
565
+
566
+
567
+ def normalize_legacy_stage(stage: object) -> object:
568
+ return LEGACY_STAGE_MAP.get(str(stage), stage)
569
+
570
+
571
+ def normalize_legacy_task(task: dict) -> bool:
572
+ """Normalize legacy task state without touching artifacts outside task.json."""
573
+ legacy_status = str(task.get("status") or "")
574
+ changed = migrate_unit_test_settings(task)
575
+
576
+ for field in ("created_by", "last_agent"):
577
+ normalized_agent = canonical_agent_identity(
578
+ task.get(field), allow_legacy_display=True
579
+ )
580
+ if normalized_agent is not None and normalized_agent != task.get(field):
581
+ task[field] = normalized_agent
582
+ changed = True
583
+
584
+ if legacy_status in LEGACY_STAGE_MAP:
585
+ task["status"] = LEGACY_STAGE_MAP[legacy_status]
586
+ changed = True
587
+
588
+ pending = task.get("pending_transition")
589
+ if isinstance(pending, dict):
590
+ source = normalize_legacy_stage(pending.get("from"))
591
+ target = normalize_legacy_stage(pending.get("to"))
592
+ if source == target:
593
+ task.pop("pending_transition", None)
594
+ changed = True
595
+ elif source != pending.get("from") or target != pending.get("to"):
596
+ task["pending_transition"] = {**pending, "from": source, "to": target}
597
+ changed = True
598
+
599
+ if not isinstance(task.get("quality_checkpoint"), dict) and isinstance(
600
+ task.get("verification_checkpoint"), dict
601
+ ):
602
+ task["quality_checkpoint"] = task["verification_checkpoint"]
603
+ changed = True
604
+ if "verification_checkpoint" in task:
605
+ task.pop("verification_checkpoint")
606
+ changed = True
607
+
608
+ history = task.get("stage_history")
609
+ if isinstance(history, list):
610
+ normalized_history: list[dict] = []
611
+ for raw_entry in history:
612
+ if not isinstance(raw_entry, dict):
613
+ continue
614
+ entry = dict(raw_entry)
615
+ mapped_stage = normalize_legacy_stage(entry.get("stage"))
616
+ if mapped_stage != entry.get("stage"):
617
+ entry["stage"] = mapped_stage
618
+ changed = True
619
+ normalized_agent = canonical_agent_identity(
620
+ entry.get("agent"), allow_legacy_display=True
621
+ )
622
+ if normalized_agent is not None and normalized_agent != entry.get("agent"):
623
+ entry["agent"] = normalized_agent
624
+ changed = True
625
+ if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
626
+ changed = True
627
+ continue
628
+ normalized_history.append(entry)
629
+ if changed:
630
+ task["stage_history"] = normalized_history
631
+
632
+ if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
633
+ requested_by = canonical_agent_identity(
634
+ task.get("last_agent"), allow_legacy_display=True
635
+ ) or "legacy-migration"
636
+ task["pending_transition"] = {
637
+ "from": "ANALYSIS",
638
+ "to": "IMPLEMENT",
639
+ "requested_at": now_iso(),
640
+ "requested_by": requested_by,
641
+ "reason": "migrated-from-WAITING_CONFIRM",
642
+ }
643
+ changed = True
644
+
645
+ if legacy_status == "MEMORY_LONG":
646
+ progress = task.get("memory_progress")
647
+ if not isinstance(progress, dict):
648
+ progress = {}
649
+ if progress.get("short_memory_written") is not True:
650
+ progress["short_memory_written"] = True
651
+ progress["legacy_short_memory_assumed"] = True
652
+ progress["updated_at"] = now_iso()
653
+ task["memory_progress"] = progress
654
+ changed = True
655
+ elif progress.get("legacy_short_memory_assumed") is not True:
656
+ progress["legacy_short_memory_assumed"] = True
657
+ progress["updated_at"] = now_iso()
658
+ task["memory_progress"] = progress
659
+ changed = True
660
+
661
+ return changed
662
+
663
+
664
+ def write_json(path: Path, data: dict) -> None:
665
+ path.parent.mkdir(parents=True, exist_ok=True)
666
+ descriptor, temporary_name = tempfile.mkstemp(
667
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
668
+ )
669
+ temporary_path = Path(temporary_name)
670
+ try:
671
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
672
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
673
+ handle.flush()
674
+ os.fsync(handle.fileno())
675
+ os.replace(temporary_path, path)
676
+ invalidate_memo(("json", str(path)))
677
+ memo(("json", str(path)), lambda: data)
678
+ try:
679
+ directory_descriptor = os.open(path.parent, os.O_RDONLY)
680
+ try:
681
+ os.fsync(directory_descriptor)
682
+ finally:
683
+ os.close(directory_descriptor)
684
+ except OSError:
685
+ # Some platforms do not allow opening directories; file replacement is still atomic.
686
+ pass
687
+ finally:
688
+ if temporary_path.exists():
689
+ temporary_path.unlink()
690
+
691
+
692
+ def session_command_lock_path(root: Path, session_path: Path) -> Path:
693
+ key = hashlib.sha256(str(session_path.resolve()).encode("utf-8")).hexdigest()[:24]
694
+ return root / ".easy-coding" / "sessions" / f".session-{key}.lock"
695
+
696
+
697
+ def acquire_session_command_lock(root: Path, session_path: Path) -> Path:
698
+ lock_path = session_command_lock_path(root, session_path)
699
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
700
+ deadline = time.monotonic() + SESSION_COMMAND_LOCK_TIMEOUT_SECONDS
701
+ while True:
702
+ try:
703
+ lock_path.mkdir()
704
+ return lock_path
705
+ except FileExistsError:
706
+ try:
707
+ if time.time() - lock_path.stat().st_mtime > SESSION_COMMAND_LOCK_STALE_SECONDS:
708
+ lock_path.rmdir()
709
+ continue
710
+ except FileNotFoundError:
711
+ continue
712
+ except OSError:
713
+ pass
714
+ if time.monotonic() >= deadline:
715
+ raise StateError("Timed out waiting for the logical session command lock.")
716
+ time.sleep(SESSION_COMMAND_LOCK_POLL_SECONDS)
717
+ except OSError as exc:
718
+ raise StateError("Cannot acquire the logical session command lock.") from exc
719
+
720
+
721
+ def release_session_command_lock(lock_path: Path | None) -> None:
722
+ if lock_path is None:
723
+ return
724
+ try:
725
+ lock_path.rmdir()
726
+ except OSError:
727
+ pass
728
+
729
+
730
+ def acquire_legacy_state_lock(root: Path) -> Path | None:
731
+ state_path = root / ".easy-coding" / "state.json"
732
+ lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
733
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
734
+ deadline = time.monotonic() + LEGACY_STATE_LOCK_TIMEOUT_SECONDS
735
+
736
+ while state_path.exists() or lock_path.exists():
737
+ try:
738
+ lock_path.mkdir()
739
+ return lock_path
740
+ except FileExistsError:
741
+ try:
742
+ lock_age = time.time() - lock_path.stat().st_mtime
743
+ if lock_age > LEGACY_STATE_LOCK_STALE_SECONDS:
744
+ lock_path.rmdir()
745
+ continue
746
+ except FileNotFoundError:
747
+ continue
748
+ except OSError:
749
+ pass
750
+ if time.monotonic() >= deadline:
751
+ raise StateError("Timed out waiting for legacy state migration lock.")
752
+ time.sleep(LEGACY_STATE_LOCK_POLL_SECONDS)
753
+ except OSError as error:
754
+ raise StateError("Cannot acquire legacy state migration lock.") from error
755
+ return None
756
+
757
+
758
+ def release_legacy_state_lock(lock_path: Path | None) -> None:
759
+ if lock_path is None:
760
+ return
761
+ try:
762
+ lock_path.rmdir()
763
+ except OSError:
764
+ pass
765
+
766
+
767
+ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
768
+ """Prepare old state.json data for the canonical session; the caller commits it first."""
769
+ state_path = root / ".easy-coding" / "state.json"
770
+ old_state = load_json(state_path)
771
+ if old_state is None:
772
+ return None
773
+
774
+ task_id = old_state.get("current_task")
775
+ if task_id:
776
+ task_path = task_json_path(root, str(task_id))
777
+ task = load_json(task_path)
778
+ if task:
779
+ if "stage_history" not in task or not task["stage_history"]:
780
+ task["stage_history"] = old_state.get("stage_history", [])
781
+ if "last_agent" not in task or not task["last_agent"]:
782
+ task["last_agent"] = (
783
+ canonical_agent_identity(
784
+ old_state.get("last_agent"), allow_legacy_display=True
785
+ )
786
+ or agent
787
+ )
788
+ if old_state.get("confirmed_by_user"):
789
+ task["confirmed_by_user"] = True
790
+ if old_state.get("test_strategy_confirmed"):
791
+ task["test_strategy_confirmed"] = True
792
+ if old_state.get("repo_paths"):
793
+ task["repo_paths"] = old_state["repo_paths"]
794
+ normalize_legacy_task(task)
795
+ write_json(task_path, task)
796
+
797
+ return {"current_task": task_id, "created_at": now_iso()}
798
+
799
+
800
+ def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
801
+ sessions_dir = (root / ".easy-coding" / "sessions").resolve()
802
+ if session_file:
803
+ path = Path(session_file)
804
+ candidate = path if path.is_absolute() else root / path
805
+ resolved = candidate.resolve()
806
+ try:
807
+ resolved.relative_to(sessions_dir)
808
+ except ValueError as error:
809
+ raise StateError(
810
+ "Unsafe session file path: "
811
+ f"{session_file}. Must be under .easy-coding/sessions/."
812
+ ) from error
813
+ if resolved == sessions_dir:
814
+ raise StateError(
815
+ "Unsafe session file path: "
816
+ f"{session_file}. Must be a file under .easy-coding/sessions/."
817
+ )
818
+ return resolved
819
+ identity = hook_session_identity({}, detect_runtime_agent())
820
+ return sessions_dir / f"{identity['session_key']}.json"
821
+
822
+
823
+ def resolve_hook_session_path(
824
+ root: Path,
825
+ payload: dict,
826
+ agent: str | None,
827
+ ppid: int | None = None,
828
+ ) -> Path:
829
+ identity = hook_session_identity(payload, agent, ppid)
830
+ return resolve_session_path(root, f".easy-coding/sessions/{identity['session_key']}.json")
831
+
832
+
833
+ def display_path(root: Path, path: Path) -> str:
834
+ try:
835
+ return path.resolve().relative_to(root.resolve()).as_posix()
836
+ except ValueError:
837
+ return path.as_posix()
838
+
839
+
840
+ def default_session() -> dict:
841
+ timestamp = now_iso()
842
+ return {"current_task": None, "created_at": timestamp, "last_active_at": timestamp}
843
+
844
+
845
+ def apply_hook_session_identity(session: dict, identity: dict) -> None:
846
+ timestamp = now_iso()
847
+ if not session.get("created_at"):
848
+ session["created_at"] = timestamp
849
+ session["last_active_at"] = timestamp
850
+ for key in ("agent", "external_session_id", "session_key", "session_source"):
851
+ session[key] = identity.get(key)
852
+
853
+
854
+ def clear_session_pointer(session: dict, agent: str | None = None) -> None:
855
+ session["current_task"] = None
856
+ session["last_seen_task"] = None
857
+ session["last_seen_stage"] = "idle"
858
+ if agent:
859
+ session["last_agent"] = agent
860
+
861
+
862
+ def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
863
+ session = load_json(resolve_session_path(root, session_file))
864
+ return session if isinstance(session, dict) else None
865
+
866
+
867
+ def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
868
+ write_json(resolve_session_path(root, session_file), session)
869
+
870
+
871
+ def migrate_legacy_pid_session(
872
+ root: Path,
873
+ session_path: Path,
874
+ identity: dict,
875
+ ppid: int,
876
+ ) -> dict | None:
877
+ sessions_dir = root / ".easy-coding" / "sessions"
878
+ fallback_path = sessions_dir / f"{identity['agent']}-ppid-{ppid}.json"
879
+ legacy_paths = [fallback_path, sessions_dir / f"{ppid}.json"]
880
+ session_path.parent.mkdir(parents=True, exist_ok=True)
881
+
882
+ for legacy_path in legacy_paths:
883
+ if legacy_path == session_path or not legacy_path.is_file():
884
+ continue
885
+ try:
886
+ legacy_path.replace(session_path)
887
+ invalidate_memo(("json", str(legacy_path)))
888
+ invalidate_memo(("json", str(session_path)))
889
+ except FileNotFoundError:
890
+ continue
891
+ except OSError:
892
+ if session_path.is_file():
893
+ invalidate_memo(("json", str(session_path)))
894
+ break
895
+ continue
896
+ migrated = load_session(root, session_path)
897
+ if migrated is not None:
898
+ return migrated
899
+ return load_session(root, session_path)
900
+
901
+
902
+ def merge_legacy_session(session: dict, legacy_session: dict) -> dict:
903
+ merged = dict(session)
904
+ if not merged.get("current_task") and legacy_session.get("current_task"):
905
+ merged["current_task"] = legacy_session["current_task"]
906
+ if not merged.get("created_at") and legacy_session.get("created_at"):
907
+ merged["created_at"] = legacy_session["created_at"]
908
+ return merged
909
+
910
+
911
+ def ensure_hook_session(
912
+ root: Path,
913
+ payload: dict,
914
+ agent: str | None,
915
+ ppid: int | None = None,
916
+ ) -> tuple[dict, Path]:
917
+ session_path = resolve_hook_session_path(root, payload, agent, ppid)
918
+ lock_path = acquire_session_command_lock(root, session_path)
919
+ try:
920
+ return ensure_hook_session_unlocked(root, payload, agent, ppid)
921
+ finally:
922
+ release_session_command_lock(lock_path)
923
+
924
+
925
+ def ensure_hook_session_unlocked(
926
+ root: Path,
927
+ payload: dict,
928
+ agent: str | None,
929
+ ppid: int | None = None,
930
+ ) -> tuple[dict, Path]:
931
+ identity = hook_session_identity(payload, agent, ppid)
932
+ session_path = resolve_hook_session_path(root, payload, agent, ppid)
933
+ resolved_ppid = ppid if ppid is not None else os.getppid()
934
+ legacy_state_lock = acquire_legacy_state_lock(root)
935
+ try:
936
+ session = load_session(root, session_path)
937
+ legacy_state = (
938
+ migrate_legacy_state(root, str(identity["agent"]))
939
+ if legacy_state_lock is not None
940
+ else None
941
+ )
942
+
943
+ if session is None:
944
+ clean_session_runtime(root, reserve_slots=1)
945
+ session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
946
+ if session is None:
947
+ session = load_session(root, session_path)
948
+ if session is None:
949
+ session = default_session()
950
+ if legacy_state is not None:
951
+ session = merge_legacy_session(session, legacy_state)
952
+
953
+ apply_hook_session_identity(session, identity)
954
+ write_session(root, session, session_path)
955
+ if legacy_state is not None:
956
+ try:
957
+ (root / ".easy-coding" / "state.json").unlink()
958
+ except OSError:
959
+ pass
960
+ return session, session_path
961
+ finally:
962
+ release_legacy_state_lock(legacy_state_lock)
963
+
964
+
965
+ def clean_stale_sessions(
966
+ root: Path,
967
+ threshold_hours: int | None = None,
968
+ idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
969
+ attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
970
+ max_sessions: int = MAX_SESSION_FILES,
971
+ reserve_slots: int = 0,
972
+ ) -> int:
973
+ sessions_dir = root / ".easy-coding" / "sessions"
974
+ if not sessions_dir.is_dir():
975
+ return 0
976
+
977
+ now = datetime.now(timezone.utc)
978
+ if threshold_hours is not None:
979
+ idle_threshold_hours = threshold_hours
980
+ attached_threshold_hours = threshold_hours
981
+ candidates: list[tuple[Path, str, dict, datetime]] = []
982
+ for entry in sessions_dir.iterdir():
983
+ if not entry.is_file() or entry.suffix != ".json":
984
+ continue
985
+ try:
986
+ content = entry.read_text(encoding="utf-8")
987
+ try:
988
+ session = json.loads(content)
989
+ except json.JSONDecodeError:
990
+ session = {}
991
+ if not isinstance(session, dict):
992
+ session = {}
993
+ activity_value = session.get("last_active_at") or session.get("created_at")
994
+ try:
995
+ if not isinstance(activity_value, str):
996
+ raise ValueError
997
+ last_active = datetime.fromisoformat(activity_value)
998
+ if last_active.tzinfo is None:
999
+ last_active = last_active.replace(tzinfo=timezone.utc)
1000
+ except (ValueError, TypeError):
1001
+ last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
1002
+ candidates.append((entry, content, session, last_active))
1003
+ except OSError:
1004
+ continue
1005
+
1006
+ removed: set[Path] = set()
1007
+ for entry, content, session, last_active in candidates:
1008
+ retention_hours = (
1009
+ attached_threshold_hours if session.get("current_task") else idle_threshold_hours
1010
+ )
1011
+ age_hours = (now - last_active).total_seconds() / 3600
1012
+ if age_hours <= retention_hours:
1013
+ continue
1014
+ if unlink_session_if_unchanged(entry, content):
1015
+ removed.add(entry)
1016
+
1017
+ allowed_existing = max(0, max_sessions - reserve_slots)
1018
+ remaining = sorted(
1019
+ (candidate for candidate in candidates if candidate[0] not in removed),
1020
+ key=lambda candidate: candidate[3],
1021
+ )
1022
+ overflow = max(0, len(remaining) - allowed_existing)
1023
+ for entry, content, _session, _last_active in remaining[:overflow]:
1024
+ if unlink_session_if_unchanged(entry, content):
1025
+ removed.add(entry)
1026
+ return len(removed)
1027
+
1028
+
1029
+ def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
1030
+ try:
1031
+ if entry.read_text(encoding="utf-8") != expected_content:
1032
+ return False
1033
+ entry.unlink()
1034
+ invalidate_memo(("json", str(entry)))
1035
+ return True
1036
+ except OSError:
1037
+ # GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
1038
+ return False
1039
+
1040
+
1041
+ def clean_orphan_acceptance_snapshots(root: Path) -> int:
1042
+ acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
1043
+ if not acceptance_dir.is_dir():
1044
+ return 0
1045
+
1046
+ cleaned = 0
1047
+ for entry in acceptance_dir.iterdir():
1048
+ if not entry.is_file() or entry.suffix != ".json":
1049
+ continue
1050
+ task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
1051
+ if task_path.is_file():
1052
+ try:
1053
+ task = json.loads(task_path.read_text(encoding="utf-8"))
1054
+ except (OSError, json.JSONDecodeError):
1055
+ continue
1056
+ if not isinstance(task, dict):
1057
+ continue
1058
+ else:
1059
+ task = None
1060
+
1061
+ checkpoint = None
1062
+ if task is not None:
1063
+ checkpoint = task.get("quality_checkpoint")
1064
+ if not isinstance(checkpoint, dict):
1065
+ checkpoint = task.get("verification_checkpoint")
1066
+ snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
1067
+ referenced = bool(
1068
+ isinstance(snapshot_file, str)
1069
+ and (root / snapshot_file).resolve() == entry.resolve()
1070
+ )
1071
+ terminal = task is not None and task.get("status") in TERMINAL_STATUSES
1072
+ if task is not None and referenced and not terminal:
1073
+ continue
1074
+ try:
1075
+ entry.unlink()
1076
+ cleaned += 1
1077
+ except OSError:
1078
+ # 验收快照清理失败不能阻断新逻辑会话启动。
1079
+ continue
1080
+ return cleaned
1081
+
1082
+
1083
+ def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
1084
+ return {
1085
+ "sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
1086
+ "acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
1087
+ }
1088
+
1089
+
1090
+ def task_json_path(root: Path, task_id: str) -> Path:
1091
+ assert_safe_task_id(task_id)
1092
+ return root / ".easy-coding" / "tasks" / task_id / "task.json"
1093
+
1094
+
1095
+ def load_task(root: Path, task_id: str | None) -> dict | None:
1096
+ if not task_id:
1097
+ return None
1098
+ return load_json(task_json_path(root, str(task_id)))
1099
+
1100
+
1101
+ def execution_log_path(root: Path, task_id: str) -> Path:
1102
+ assert_safe_task_id(task_id)
1103
+ return root / ".easy-coding" / "tasks" / task_id / "execution.jsonl"
1104
+
1105
+
1106
+ def is_non_empty_string(value: object) -> bool:
1107
+ return isinstance(value, str) and bool(value.strip())
1108
+
1109
+
1110
+ def execution_records(root: Path, task_id: str) -> list[dict]:
1111
+ path = execution_log_path(root, task_id)
1112
+ return memo(("execution", str(path)), lambda: _execution_records(path))
1113
+
1114
+
1115
+ def _execution_records(path: Path) -> list[dict]:
1116
+ if not path.exists():
1117
+ return []
1118
+ records: list[dict] = []
1119
+ try:
1120
+ for line in path.read_text(encoding="utf-8").splitlines():
1121
+ if not line.strip():
1122
+ continue
1123
+ record = json.loads(line)
1124
+ if isinstance(record, dict):
1125
+ records.append(record)
1126
+ except (OSError, json.JSONDecodeError):
1127
+ return []
1128
+ return records
1129
+
1130
+
1131
+ def assert_safe_task_id(task_id: str) -> None:
1132
+ path = Path(task_id)
1133
+ if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
1134
+ raise StateError(f"Unsafe task id: {task_id}")
1135
+
1136
+
1137
+ def transition_requires_confirmation(
1138
+ previous: str,
1139
+ current: str,
1140
+ task_type: str,
1141
+ approval_mode: str,
1142
+ ) -> bool:
1143
+ if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
1144
+ return False
1145
+ if current == "CLOSED":
1146
+ return True
1147
+ if approval_mode == "auto":
1148
+ return False
1149
+ if approval_mode == "guard":
1150
+ return (previous, current) in CRITICAL_CONFIRM_TRANSITIONS
1151
+ if approval_mode == "confirm":
1152
+ return (previous, current) == ANALYSIS_CONFIRM_TRANSITION
1153
+ if approval_mode == "approve":
1154
+ return True
1155
+ raise StateError(f"Unknown approval mode: {approval_mode}")
1156
+
1157
+
1158
+ def is_automatic_transition(
1159
+ previous: str,
1160
+ current: str,
1161
+ task_type: str,
1162
+ approval_mode: str,
1163
+ ) -> bool:
1164
+ return not transition_requires_confirmation(previous, current, task_type, approval_mode)
1165
+
1166
+
1167
+ def validate_transition(
1168
+ previous: str,
1169
+ current: str,
1170
+ task_type: str = "",
1171
+ task: dict | None = None,
1172
+ ) -> str | None:
1173
+ if previous == current:
1174
+ return None
1175
+ allowed = set(VALID_TRANSITIONS.get(previous, set()))
1176
+ if previous == "IMPLEMENT":
1177
+ allowed.discard("COMPLETE")
1178
+ if current in allowed:
1179
+ return None
1180
+ return (
1181
+ f"ILLEGAL TRANSITION: {previous} -> {current}. "
1182
+ f"Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
1183
+ )
1184
+
1185
+
1186
+ def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
1187
+ session = load_session(root, session_file)
1188
+ if session is None:
1189
+ session = default_session()
1190
+ if not session.get("created_at"):
1191
+ session["created_at"] = now_iso()
1192
+ session["last_active_at"] = now_iso()
1193
+ return session