easy-coding-harness 0.8.1-beta.0 → 0.8.1-beta.2
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.
- package/CHANGELOG.md +14 -0
- package/dist/cli.js +104 -42
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/settings.json +0 -10
- package/templates/codex/hooks.json +4 -2
- package/templates/common/bundled-skills/ec-init/SKILL.md +23 -3
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +2 -1
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +19 -15
- package/templates/common/skills/ec-git/SKILL.md +15 -6
- package/templates/common/skills/ec-memory/SKILL.md +8 -3
- package/templates/qoder/settings.json +1 -10
- package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +7 -4
- package/templates/runtime/templates/dev-spec-skeleton.md +1 -1
- package/templates/shared-hooks/easy_coding_state.py +328 -11
- package/templates/shared-hooks/inject-subagent-context.py +19 -3
- package/templates/shared-hooks/inject-workflow-state.py +3 -6
- package/templates/shared-hooks/session-start.py +3 -108
|
@@ -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
|
-
|
|
237
|
-
|
|
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
|
-
"
|
|
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: (
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|