easy-coding-harness 0.8.1-beta.0 → 0.8.1-beta.1

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.
@@ -4,6 +4,9 @@ import hashlib
4
4
  import json
5
5
  import os
6
6
  import re
7
+ import secrets
8
+ import time
9
+ import uuid
7
10
  from datetime import datetime, timezone
8
11
  from pathlib import Path
9
12
  import sys
@@ -66,6 +69,16 @@ LEGACY_STAGE_MAP = {
66
69
 
67
70
  DEFAULT_SHORT_TERM_MAX = 10
68
71
  DEFAULT_SHORT_TERM_KEEP = 5
72
+ SESSION_STALE_THRESHOLD_HOURS = 30 * 24
73
+ SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
74
+ SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
75
+ LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
76
+ LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
77
+ LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
78
+ SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
79
+ r"^SM-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
80
+ )
81
+ LEGACY_SHORT_MEMORY_ID_PATTERN = re.compile(r"^SM-\d{8}-\d+$")
69
82
  DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
70
83
  MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
71
84
  TABLE_HEADER_CELLS = {
@@ -100,6 +113,83 @@ def now_iso() -> str:
100
113
  return datetime.now(timezone.utc).isoformat()
101
114
 
102
115
 
116
+ def generate_short_memory_id() -> str:
117
+ timestamp_ms = time.time_ns() // 1_000_000
118
+ random_bits = secrets.randbits(74)
119
+ random_a = random_bits >> 62
120
+ random_b = random_bits & ((1 << 62) - 1)
121
+ # UUIDv7 用毫秒时间保证可排序,再用 74 位随机数避免多 Agent 并发碰撞。
122
+ value = (timestamp_ms & ((1 << 48) - 1)) << 80
123
+ value |= 0x7 << 76
124
+ value |= random_a << 64
125
+ value |= 0b10 << 62
126
+ value |= random_b
127
+ return f"SM-{uuid.UUID(int=value)}"
128
+
129
+
130
+ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
131
+ # 升级当天可能同时存在旧序号 ID 和新 UUIDv7;旧记录先排,避免新记录被误判为窗口外旧记忆。
132
+ if LEGACY_SHORT_MEMORY_ID_PATTERN.fullmatch(memory_id):
133
+ return (0, memory_id)
134
+ if SHORT_MEMORY_UUID_V7_PATTERN.fullmatch(memory_id):
135
+ return (1, memory_id)
136
+ return (2, memory_id)
137
+
138
+
139
+ def normalize_session_agent(agent: str | None) -> str:
140
+ normalized = str(agent or "unknown").strip().lower()
141
+ return normalized if normalized in SESSION_AGENT_NAMESPACES else "unknown"
142
+
143
+
144
+ def detect_runtime_agent() -> str:
145
+ if os.environ.get("CLAUDE_PROJECT_DIR"):
146
+ return "claude-code"
147
+ if os.environ.get("QODER_PROJECT_DIR"):
148
+ return "qoder"
149
+ script_path = Path(sys.argv[0]).as_posix()
150
+ if ".claude/" in script_path:
151
+ return "claude-code"
152
+ if ".codex/" in script_path:
153
+ return "codex"
154
+ if ".qoder/" in script_path or ".qodercn/" in script_path:
155
+ return "qoder"
156
+ return "unknown"
157
+
158
+
159
+ def normalize_session_component(value: str) -> str:
160
+ if (
161
+ value not in {".", ".."}
162
+ and len(value) <= 120
163
+ and SESSION_COMPONENT_PATTERN.fullmatch(value)
164
+ ):
165
+ return value
166
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
167
+ return f"sha256-{digest}"
168
+
169
+
170
+ # 逻辑会话由 Agent 命名空间与 hook session_id 共同标识;PPID 只用于缺少逻辑 ID 的兼容回退。
171
+ def hook_session_identity(
172
+ payload: dict,
173
+ agent: str | None,
174
+ ppid: int | None = None,
175
+ ) -> dict:
176
+ namespace = normalize_session_agent(agent)
177
+ raw_session_id = payload.get("session_id") or payload.get("sessionId")
178
+ external_session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
179
+ if external_session_id:
180
+ component = normalize_session_component(external_session_id)
181
+ source = "hook-session-id"
182
+ else:
183
+ component = f"ppid-{ppid if ppid is not None else os.getppid()}"
184
+ source = "legacy-ppid"
185
+ return {
186
+ "agent": namespace,
187
+ "external_session_id": external_session_id or None,
188
+ "session_key": f"{namespace}-{component}",
189
+ "session_source": source,
190
+ }
191
+
192
+
103
193
  def find_ec_root(start: Path) -> Path | None:
104
194
  current = start.resolve()
105
195
  while True:
@@ -233,23 +323,26 @@ def short_memory_entries(root: Path) -> list[dict[str, object]]:
233
323
  display_entry = resolved_entry.relative_to(root.resolve()).as_posix()
234
324
  except ValueError:
235
325
  continue
236
- prefix_text = entry.name.split("_", 1)[0]
237
- try:
238
- prefix = int(prefix_text)
239
- except ValueError:
240
- prefix = sys.maxsize
326
+ memory_id = frontmatter.get("id", "")
327
+ id_rank, id_value = short_memory_id_sort_key(memory_id)
241
328
  entries.append(
242
329
  {
243
330
  "path": resolved_entry,
244
331
  "display_path": display_entry,
245
332
  "date": frontmatter.get("date", ""),
246
- "prefix": prefix,
333
+ "id_rank": id_rank,
334
+ "memory_id": id_value,
247
335
  "name": entry.name,
248
336
  }
249
337
  )
250
338
  return sorted(
251
339
  entries,
252
- key=lambda item: (str(item["date"]), int(item["prefix"]), str(item["name"])),
340
+ key=lambda item: (
341
+ str(item["date"]),
342
+ int(item["id_rank"]),
343
+ str(item["memory_id"]),
344
+ str(item["name"]),
345
+ ),
253
346
  )
254
347
 
255
348
 
@@ -297,6 +390,7 @@ def validate_short_memory_file(
297
390
  task_id: str,
298
391
  memory_file: str,
299
392
  expected_sha256: str | None = None,
393
+ require_current_id: bool = False,
300
394
  ) -> tuple[Path, str]:
301
395
  resolved_memory_path = resolve_short_memory_path(root, memory_file)
302
396
  if not resolved_memory_path.is_file():
@@ -313,6 +407,12 @@ def validate_short_memory_file(
313
407
  raise StateError(
314
408
  f"Short-memory source_task {source_task or 'missing'} does not match current task {task_id}."
315
409
  )
410
+ if require_current_id:
411
+ memory_id = frontmatter.get("id", "")
412
+ if not SHORT_MEMORY_UUID_V7_PATTERN.fullmatch(memory_id):
413
+ raise StateError("Short-memory id must use the SM-<UUIDv7> format.")
414
+ if not resolved_memory_path.name.startswith(f"{memory_id}_"):
415
+ raise StateError("Short-memory filename prefix must exactly match its frontmatter id.")
316
416
  digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
317
417
  if expected_sha256 and digest != expected_sha256:
318
418
  raise StateError("Short-memory file changed after its checkpoint was recorded.")
@@ -453,6 +553,71 @@ def write_json(path: Path, data: dict) -> None:
453
553
  path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
454
554
 
455
555
 
556
+ def acquire_legacy_state_lock(root: Path) -> Path | None:
557
+ state_path = root / ".easy-coding" / "state.json"
558
+ lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
559
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
560
+ deadline = time.monotonic() + LEGACY_STATE_LOCK_TIMEOUT_SECONDS
561
+
562
+ while state_path.exists() or lock_path.exists():
563
+ try:
564
+ lock_path.mkdir()
565
+ return lock_path
566
+ except FileExistsError:
567
+ try:
568
+ lock_age = time.time() - lock_path.stat().st_mtime
569
+ if lock_age > LEGACY_STATE_LOCK_STALE_SECONDS:
570
+ lock_path.rmdir()
571
+ continue
572
+ except FileNotFoundError:
573
+ continue
574
+ except OSError:
575
+ pass
576
+ if time.monotonic() >= deadline:
577
+ raise StateError("Timed out waiting for legacy state migration lock.")
578
+ time.sleep(LEGACY_STATE_LOCK_POLL_SECONDS)
579
+ except OSError as error:
580
+ raise StateError("Cannot acquire legacy state migration lock.") from error
581
+ return None
582
+
583
+
584
+ def release_legacy_state_lock(lock_path: Path | None) -> None:
585
+ if lock_path is None:
586
+ return
587
+ try:
588
+ lock_path.rmdir()
589
+ except OSError:
590
+ pass
591
+
592
+
593
+ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
594
+ """Prepare old state.json data for the canonical session; the caller commits it first."""
595
+ state_path = root / ".easy-coding" / "state.json"
596
+ old_state = load_json(state_path)
597
+ if old_state is None:
598
+ return None
599
+
600
+ task_id = old_state.get("current_task")
601
+ if task_id:
602
+ task_path = task_json_path(root, str(task_id))
603
+ task = load_json(task_path)
604
+ if task:
605
+ if "stage_history" not in task or not task["stage_history"]:
606
+ task["stage_history"] = old_state.get("stage_history", [])
607
+ if "last_agent" not in task or not task["last_agent"]:
608
+ task["last_agent"] = old_state.get("last_agent", agent)
609
+ if old_state.get("confirmed_by_user"):
610
+ task["confirmed_by_user"] = True
611
+ if old_state.get("test_strategy_confirmed"):
612
+ task["test_strategy_confirmed"] = True
613
+ if old_state.get("repo_paths"):
614
+ task["repo_paths"] = old_state["repo_paths"]
615
+ normalize_legacy_task(task)
616
+ write_json(task_path, task)
617
+
618
+ return {"current_task": task_id, "created_at": now_iso()}
619
+
620
+
456
621
  def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
457
622
  sessions_dir = (root / ".easy-coding" / "sessions").resolve()
458
623
  if session_file:
@@ -472,7 +637,17 @@ def resolve_session_path(root: Path, session_file: str | Path | None = None) ->
472
637
  f"{session_file}. Must be a file under .easy-coding/sessions/."
473
638
  )
474
639
  return resolved
475
- return sessions_dir / f"{os.getppid()}.json"
640
+ return sessions_dir / f"{detect_runtime_agent()}-ppid-{os.getppid()}.json"
641
+
642
+
643
+ def resolve_hook_session_path(
644
+ root: Path,
645
+ payload: dict,
646
+ agent: str | None,
647
+ ppid: int | None = None,
648
+ ) -> Path:
649
+ identity = hook_session_identity(payload, agent, ppid)
650
+ return resolve_session_path(root, f".easy-coding/sessions/{identity['session_key']}.json")
476
651
 
477
652
 
478
653
  def display_path(root: Path, path: Path) -> str:
@@ -483,7 +658,17 @@ def display_path(root: Path, path: Path) -> str:
483
658
 
484
659
 
485
660
  def default_session() -> dict:
486
- return {"current_task": None, "created_at": now_iso()}
661
+ timestamp = now_iso()
662
+ return {"current_task": None, "created_at": timestamp, "last_active_at": timestamp}
663
+
664
+
665
+ def apply_hook_session_identity(session: dict, identity: dict) -> None:
666
+ timestamp = now_iso()
667
+ if not session.get("created_at"):
668
+ session["created_at"] = timestamp
669
+ session["last_active_at"] = timestamp
670
+ for key in ("agent", "external_session_id", "session_key", "session_source"):
671
+ session[key] = identity.get(key)
487
672
 
488
673
 
489
674
  def clear_session_pointer(session: dict, agent: str | None = None) -> None:
@@ -502,6 +687,115 @@ def write_session(root: Path, session: dict, session_file: str | Path | None = N
502
687
  write_json(resolve_session_path(root, session_file), session)
503
688
 
504
689
 
690
+ def migrate_legacy_pid_session(
691
+ root: Path,
692
+ session_path: Path,
693
+ identity: dict,
694
+ ppid: int,
695
+ ) -> dict | None:
696
+ sessions_dir = root / ".easy-coding" / "sessions"
697
+ fallback_path = sessions_dir / f"{identity['agent']}-ppid-{ppid}.json"
698
+ legacy_paths = [fallback_path, sessions_dir / f"{ppid}.json"]
699
+ session_path.parent.mkdir(parents=True, exist_ok=True)
700
+
701
+ for legacy_path in legacy_paths:
702
+ if legacy_path == session_path or not legacy_path.is_file():
703
+ continue
704
+ try:
705
+ legacy_path.replace(session_path)
706
+ except FileNotFoundError:
707
+ continue
708
+ except OSError:
709
+ if session_path.is_file():
710
+ break
711
+ continue
712
+ migrated = load_session(root, session_path)
713
+ if migrated is not None:
714
+ return migrated
715
+ return load_session(root, session_path)
716
+
717
+
718
+ def merge_legacy_session(session: dict, legacy_session: dict) -> dict:
719
+ merged = dict(session)
720
+ if not merged.get("current_task") and legacy_session.get("current_task"):
721
+ merged["current_task"] = legacy_session["current_task"]
722
+ if not merged.get("created_at") and legacy_session.get("created_at"):
723
+ merged["created_at"] = legacy_session["created_at"]
724
+ return merged
725
+
726
+
727
+ def ensure_hook_session(
728
+ root: Path,
729
+ payload: dict,
730
+ agent: str | None,
731
+ ppid: int | None = None,
732
+ ) -> tuple[dict, Path]:
733
+ identity = hook_session_identity(payload, agent, ppid)
734
+ session_path = resolve_hook_session_path(root, payload, agent, ppid)
735
+ resolved_ppid = ppid if ppid is not None else os.getppid()
736
+ legacy_state_lock = acquire_legacy_state_lock(root)
737
+ try:
738
+ session = load_session(root, session_path)
739
+ legacy_state = (
740
+ migrate_legacy_state(root, str(identity["agent"]))
741
+ if legacy_state_lock is not None
742
+ else None
743
+ )
744
+
745
+ if session is None:
746
+ clean_stale_sessions(root)
747
+ session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
748
+ if session is None:
749
+ session = load_session(root, session_path)
750
+ if session is None:
751
+ session = default_session()
752
+ if legacy_state is not None:
753
+ session = merge_legacy_session(session, legacy_state)
754
+
755
+ apply_hook_session_identity(session, identity)
756
+ write_session(root, session, session_path)
757
+ if legacy_state is not None:
758
+ try:
759
+ (root / ".easy-coding" / "state.json").unlink()
760
+ except OSError:
761
+ pass
762
+ return session, session_path
763
+ finally:
764
+ release_legacy_state_lock(legacy_state_lock)
765
+
766
+
767
+ def clean_stale_sessions(
768
+ root: Path,
769
+ threshold_hours: int = SESSION_STALE_THRESHOLD_HOURS,
770
+ ) -> int:
771
+ sessions_dir = root / ".easy-coding" / "sessions"
772
+ if not sessions_dir.is_dir():
773
+ return 0
774
+
775
+ now = datetime.now(timezone.utc)
776
+ cleaned = 0
777
+ # 逻辑会话不对应独立进程,仅清理长期空闲且没有当前任务的 session。
778
+ for entry in sessions_dir.iterdir():
779
+ if entry.suffix != ".json":
780
+ continue
781
+ try:
782
+ session = json.loads(entry.read_text(encoding="utf-8"))
783
+ if session.get("current_task"):
784
+ continue
785
+ activity_value = session.get("last_active_at") or session.get("created_at") or ""
786
+ last_active = datetime.fromisoformat(str(activity_value))
787
+ if last_active.tzinfo is None:
788
+ last_active = last_active.replace(tzinfo=timezone.utc)
789
+ age_hours = (now - last_active).total_seconds() / 3600
790
+ if age_hours <= threshold_hours:
791
+ continue
792
+ entry.unlink()
793
+ cleaned += 1
794
+ except (OSError, json.JSONDecodeError, ValueError, TypeError):
795
+ continue
796
+ return cleaned
797
+
798
+
505
799
  def task_json_path(root: Path, task_id: str) -> Path:
506
800
  assert_safe_task_id(task_id)
507
801
  return root / ".easy-coding" / "tasks" / task_id / "task.json"
@@ -1246,6 +1540,7 @@ def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
1246
1540
  session = default_session()
1247
1541
  if not session.get("created_at"):
1248
1542
  session["created_at"] = now_iso()
1543
+ session["last_active_at"] = now_iso()
1249
1544
  return session
1250
1545
 
1251
1546
 
@@ -1641,7 +1936,10 @@ def memory_short_complete(
1641
1936
  if not memory_file.strip():
1642
1937
  raise StateError("Short-memory file is required.")
1643
1938
  resolved_memory_path, digest = validate_short_memory_file(
1644
- root, resolved_task_id, memory_file.strip()
1939
+ root,
1940
+ resolved_task_id,
1941
+ memory_file.strip(),
1942
+ require_current_id=True,
1645
1943
  )
1646
1944
  progress = task.get("memory_progress")
1647
1945
  if not isinstance(progress, dict):
@@ -1862,7 +2160,8 @@ def main() -> int:
1862
2160
  parser = argparse.ArgumentParser(description="Easy Coding runtime state API")
1863
2161
  subcommands = parser.add_subparsers(dest="command")
1864
2162
 
1865
- subcommands.add_parser("snapshot", parents=[common])
2163
+ snapshot_parser = subcommands.add_parser("snapshot", parents=[common])
2164
+ snapshot_parser.add_argument("--agent")
1866
2165
 
1867
2166
  list_tasks_parser = subcommands.add_parser("list-tasks", parents=[common])
1868
2167
  list_tasks_parser.add_argument("--agent")
@@ -1934,7 +2233,11 @@ def main() -> int:
1934
2233
  memory_short.add_argument("--agent", required=True)
1935
2234
  memory_short.add_argument("--task-id")
1936
2235
 
2236
+ memory_new_id = subcommands.add_parser("memory-new-id", parents=[common])
2237
+ memory_new_id.add_argument("--agent")
2238
+
1937
2239
  memory_instruction_parser = subcommands.add_parser("memory-instruction", parents=[common])
2240
+ memory_instruction_parser.add_argument("--agent")
1938
2241
  memory_instruction_parser.add_argument("--task-id")
1939
2242
 
1940
2243
  memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
@@ -1952,6 +2255,7 @@ def main() -> int:
1952
2255
  repo_path = subcommands.add_parser("set-repo-path", parents=[common])
1953
2256
  repo_path.add_argument("--repo", required=True)
1954
2257
  repo_path.add_argument("--path", required=True)
2258
+ repo_path.add_argument("--agent")
1955
2259
  repo_path.add_argument("--task-id")
1956
2260
 
1957
2261
  args = parser.parse_args()
@@ -1959,6 +2263,17 @@ def main() -> int:
1959
2263
  root = resolve_root(getattr(args, "cwd", None))
1960
2264
  session_file = getattr(args, "session_file", None)
1961
2265
  command = args.command or "snapshot"
2266
+ agent = normalize_session_agent(getattr(args, "agent", None) or detect_runtime_agent())
2267
+ if session_file is None and command == "project-init-complete":
2268
+ raise StateError(
2269
+ "project-init-complete requires --session-file from the current hook context."
2270
+ )
2271
+ if session_file is None and command not in {"list-tasks", "memory-new-id"}:
2272
+ if agent == "unknown":
2273
+ raise StateError(
2274
+ "Cannot resolve the logical session. Pass --session-file or --agent."
2275
+ )
2276
+ _, session_file = ensure_hook_session(root, {}, agent)
1962
2277
  if command == "snapshot":
1963
2278
  emit(snapshot_state(root, session_file))
1964
2279
  elif command == "list-tasks":
@@ -2095,6 +2410,8 @@ def main() -> int:
2095
2410
  session_file,
2096
2411
  )
2097
2412
  )
2413
+ elif command == "memory-new-id":
2414
+ emit({"memory_id": generate_short_memory_id()})
2098
2415
  elif command == "memory-short-complete":
2099
2416
  emit(
2100
2417
  attach_status_context(
@@ -4,7 +4,7 @@ import os
4
4
  from pathlib import Path
5
5
  import sys
6
6
 
7
- from easy_coding_state import load_session, snapshot_state
7
+ from easy_coding_state import ensure_hook_session, snapshot_state
8
8
 
9
9
 
10
10
  def configure_stdio() -> None:
@@ -30,6 +30,21 @@ def find_ec_root(start: Path) -> Path | None:
30
30
  current = current.parent
31
31
 
32
32
 
33
+ def detect_agent() -> str:
34
+ if os.environ.get("CLAUDE_PROJECT_DIR"):
35
+ return "claude-code"
36
+ if os.environ.get("QODER_PROJECT_DIR"):
37
+ return "qoder"
38
+ hook_path = Path(sys.argv[0]).as_posix()
39
+ if ".claude/" in hook_path:
40
+ return "claude-code"
41
+ if ".codex/" in hook_path:
42
+ return "codex"
43
+ if ".qoder/" in hook_path or ".qodercn/" in hook_path:
44
+ return "qoder"
45
+ return "unknown"
46
+
47
+
33
48
  def emit(event_name: str, context: str) -> None:
34
49
  print(
35
50
  json.dumps(
@@ -54,11 +69,12 @@ def main() -> int:
54
69
  if root is None:
55
70
  return 0
56
71
 
57
- session = load_session(root)
72
+ agent = detect_agent()
73
+ session, session_path = ensure_hook_session(root, payload, agent)
58
74
  if session and session.get("harness_disabled") is True:
59
75
  return 0
60
76
 
61
- state = snapshot_state(root, session=session)
77
+ state = snapshot_state(root, session_path, session)
62
78
  task_id = state.get("current_task")
63
79
  context = [
64
80
  "[easy-coding:subagent-guard]",
@@ -4,7 +4,7 @@ import os
4
4
  from pathlib import Path
5
5
  import sys
6
6
 
7
- from easy_coding_state import load_session
7
+ from easy_coding_state import ensure_hook_session
8
8
  from easy_coding_status import build_status_context
9
9
 
10
10
 
@@ -70,13 +70,10 @@ def main() -> int:
70
70
  if root is None:
71
71
  return 0
72
72
 
73
- session = load_session(root)
74
- if session is None:
75
- session = {"current_task": None, "created_at": ""}
76
-
77
73
  event_name = payload.get("hook_event_name") or payload.get("hookEventName") or "UserPromptSubmit"
78
74
  agent = detect_agent()
79
- emit(event_name, build_status_context(root, session, agent))
75
+ session, session_path = ensure_hook_session(root, payload, agent)
76
+ emit(event_name, build_status_context(root, session, agent, session_path))
80
77
  return 0
81
78
 
82
79
 
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  import json
3
3
  import os
4
- from datetime import datetime, timezone
5
4
  from pathlib import Path
6
5
  import sys
7
6
 
8
- from easy_coding_state import load_session, normalize_legacy_task, write_session
7
+ from easy_coding_state import ensure_hook_session
9
8
  from easy_coding_status import build_status_context
10
9
 
11
10
 
@@ -32,20 +31,6 @@ def find_ec_root(start: Path) -> Path | None:
32
31
  current = current.parent
33
32
 
34
33
 
35
- def load_json(path: Path) -> dict | None:
36
- if not path.exists():
37
- return None
38
- try:
39
- return json.loads(path.read_text(encoding="utf-8"))
40
- except (OSError, json.JSONDecodeError):
41
- return None
42
-
43
-
44
- def write_json(path: Path, data: dict) -> None:
45
- path.parent.mkdir(parents=True, exist_ok=True)
46
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
47
-
48
-
49
34
  def detect_agent() -> str:
50
35
  if os.environ.get("CLAUDE_PROJECT_DIR"):
51
36
  return "claude-code"
@@ -61,75 +46,6 @@ def detect_agent() -> str:
61
46
  return "unknown"
62
47
 
63
48
 
64
- def is_process_alive(pid: int) -> bool:
65
- try:
66
- os.kill(pid, 0)
67
- return True
68
- except (OSError, ProcessLookupError):
69
- return False
70
-
71
-
72
- def clean_stale_sessions(root: Path) -> None:
73
- sessions_dir = root / ".easy-coding" / "sessions"
74
- if not sessions_dir.is_dir():
75
- return
76
-
77
- now = datetime.now(timezone.utc)
78
- threshold_hours = 24
79
-
80
- for entry in sessions_dir.iterdir():
81
- if not entry.suffix == ".json":
82
- continue
83
- try:
84
- data = json.loads(entry.read_text(encoding="utf-8"))
85
- created = datetime.fromisoformat(data.get("created_at", ""))
86
- if created.tzinfo is None:
87
- created = created.replace(tzinfo=timezone.utc)
88
- age_hours = (now - created).total_seconds() / 3600
89
- if age_hours <= threshold_hours:
90
- continue
91
- pid = int(entry.stem)
92
- if is_process_alive(pid):
93
- continue
94
- entry.unlink()
95
- except (OSError, json.JSONDecodeError, ValueError, KeyError):
96
- continue
97
-
98
-
99
- def migrate_legacy_state(root: Path, agent: str) -> dict | None:
100
- """Migrate old-format state.json into session file + task.json. Returns session dict."""
101
- state_path = root / ".easy-coding" / "state.json"
102
- old_state = load_json(state_path)
103
- if old_state is None:
104
- return None
105
-
106
- task_id = old_state.get("current_task")
107
- if task_id:
108
- task_path = root / ".easy-coding" / "tasks" / str(task_id) / "task.json"
109
- task = load_json(task_path)
110
- if task:
111
- if "stage_history" not in task or not task["stage_history"]:
112
- task["stage_history"] = old_state.get("stage_history", [])
113
- if "last_agent" not in task or not task["last_agent"]:
114
- task["last_agent"] = old_state.get("last_agent", agent)
115
- if old_state.get("confirmed_by_user"):
116
- task["confirmed_by_user"] = True
117
- if old_state.get("test_strategy_confirmed"):
118
- task["test_strategy_confirmed"] = True
119
- if old_state.get("repo_paths"):
120
- task["repo_paths"] = old_state["repo_paths"]
121
- normalize_legacy_task(task)
122
- write_json(task_path, task)
123
-
124
- # Remove legacy state.json
125
- try:
126
- state_path.unlink()
127
- except OSError:
128
- pass
129
-
130
- return {"current_task": task_id, "created_at": datetime.now(timezone.utc).isoformat()}
131
-
132
-
133
49
  def emit(event_name: str, context: str) -> None:
134
50
  print(
135
51
  json.dumps(
@@ -156,29 +72,8 @@ def main() -> int:
156
72
 
157
73
  agent = detect_agent()
158
74
  event_name = payload.get("hook_event_name") or payload.get("hookEventName") or "SessionStart"
159
-
160
- # Migrate legacy state.json if present
161
- state_path = root / ".easy-coding" / "state.json"
162
- migrated_session = None
163
- if state_path.exists():
164
- migrated_session = migrate_legacy_state(root, agent)
165
-
166
- # Clean stale session files
167
- clean_stale_sessions(root)
168
-
169
- # Create/overwrite session file for this session
170
- session = load_session(root)
171
- if session is None:
172
- if migrated_session:
173
- session = migrated_session
174
- else:
175
- session = {"current_task": None, "created_at": datetime.now(timezone.utc).isoformat()}
176
- else:
177
- # Refresh created_at on session start (marks session as active)
178
- session["created_at"] = datetime.now(timezone.utc).isoformat()
179
-
180
- write_session(root, session)
181
- emit(event_name, build_status_context(root, session, agent))
75
+ session, session_path = ensure_hook_session(root, payload, agent)
76
+ emit(event_name, build_status_context(root, session, agent, session_path))
182
77
  return 0
183
78
 
184
79