easy-coding-harness 0.1.8 → 0.3.0
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 +120 -0
- package/README.md +109 -135
- package/dist/cli.js +1218 -282
- package/dist/cli.js.map +1 -1
- package/package.json +10 -1
- package/templates/claude/agents/ec-implementer.md +1 -1
- package/templates/claude/agents/ec-reviewer.md +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +174 -0
- package/templates/common/bundled-skills/ec-init/references/memory-migration.md +87 -0
- package/templates/common/bundled-skills/ec-meta/SKILL.md +5 -2
- package/templates/common/bundled-skills/ec-meta/references/customize-local/README.md +2 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +14 -11
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +8 -4
- package/templates/common/skills/ec-analysis/SKILL.md +111 -130
- package/templates/common/skills/ec-brainstorming/SKILL.md +1 -1
- package/templates/common/skills/ec-git/SKILL.md +10 -10
- package/templates/common/skills/ec-implementing/SKILL.md +31 -24
- package/templates/common/skills/ec-memory/SKILL.md +41 -16
- package/templates/common/skills/ec-reviewing/SKILL.md +1 -1
- package/templates/common/skills/ec-task-close/SKILL.md +4 -3
- package/templates/common/skills/ec-task-management/SKILL.md +9 -24
- package/templates/common/skills/ec-verification/SKILL.md +10 -4
- package/templates/common/skills/ec-workflow/SKILL.md +48 -17
- package/templates/main-constraint/AGENTS.md.tpl +16 -3
- package/templates/main-constraint/CLAUDE.md.tpl +16 -3
- package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +75 -0
- package/templates/runtime/memory/long/BUSINESS.md +42 -9
- package/templates/runtime/memory/long/MEMORY.md +37 -2
- package/templates/runtime/memory/long/TECHNICAL.md +45 -1
- package/templates/runtime/templates/dev-spec-skeleton.md +80 -0
- package/templates/shared-hooks/easy_coding_state.py +610 -0
- package/templates/shared-hooks/easy_coding_status.py +72 -81
- package/templates/shared-hooks/inject-subagent-context.py +5 -12
- package/templates/shared-hooks/inject-workflow-state.py +2 -1
- package/templates/shared-hooks/session-start.py +2 -1
- package/templates/common/skills/ec-init/SKILL.md +0 -96
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
|
|
11
|
+
HELP_SUFFIX = (
|
|
12
|
+
"Use `ec-workflow` to start or resume a task, "
|
|
13
|
+
"`ec-brainstorming` to brainstorm, or `ec-task-management` to view tasks"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
17
|
+
"idle": {"INIT"},
|
|
18
|
+
"INIT": {"ANALYSIS", "CLOSED"},
|
|
19
|
+
"ANALYSIS": {"WAITING_CONFIRM", "CLOSED"},
|
|
20
|
+
"WAITING_CONFIRM": {"IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
21
|
+
"IMPLEMENT": {"REVIEW", "ANALYSIS", "CLOSED"},
|
|
22
|
+
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
23
|
+
"VERIFICATION": {"MEMORY_SHORT", "IMPLEMENT", "CLOSED"},
|
|
24
|
+
"MEMORY_SHORT": {"MEMORY_LONG", "CLOSED"},
|
|
25
|
+
"MEMORY_LONG": {"COMPLETE", "CLOSED"},
|
|
26
|
+
"COMPLETE": set(),
|
|
27
|
+
"CLOSED": set(),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DEFAULT_SHORT_TERM_MAX = 10
|
|
31
|
+
DEFAULT_SHORT_TERM_KEEP = 5
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StateError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def configure_stdio() -> None:
|
|
39
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
40
|
+
if hasattr(stream, "reconfigure"):
|
|
41
|
+
stream.reconfigure(encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def now_iso() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_ec_root(start: Path) -> Path | None:
|
|
49
|
+
current = start.resolve()
|
|
50
|
+
while True:
|
|
51
|
+
if (current / ".easy-coding").is_dir():
|
|
52
|
+
return current
|
|
53
|
+
if current == current.parent:
|
|
54
|
+
return None
|
|
55
|
+
current = current.parent
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_json(path: Path) -> dict | None:
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, json.JSONDecodeError):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_positive_int(value: str) -> int | None:
|
|
68
|
+
normalized = value.split("#", 1)[0].strip().strip("'\"")
|
|
69
|
+
try:
|
|
70
|
+
parsed = int(normalized)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
return parsed if parsed >= 0 else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_memory_config(root: Path) -> dict[str, int]:
|
|
77
|
+
config = {
|
|
78
|
+
"short_term_max": DEFAULT_SHORT_TERM_MAX,
|
|
79
|
+
"short_term_keep": DEFAULT_SHORT_TERM_KEEP,
|
|
80
|
+
}
|
|
81
|
+
path = root / ".easy-coding" / "config.yaml"
|
|
82
|
+
try:
|
|
83
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
84
|
+
except OSError:
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
in_memory = False
|
|
88
|
+
memory_indent = 0
|
|
89
|
+
for raw_line in lines:
|
|
90
|
+
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
91
|
+
stripped = without_comment.strip()
|
|
92
|
+
if not stripped:
|
|
93
|
+
continue
|
|
94
|
+
indent = len(without_comment) - len(without_comment.lstrip(" "))
|
|
95
|
+
if stripped == "memory:":
|
|
96
|
+
in_memory = True
|
|
97
|
+
memory_indent = indent
|
|
98
|
+
continue
|
|
99
|
+
if in_memory and indent <= memory_indent:
|
|
100
|
+
in_memory = False
|
|
101
|
+
if not in_memory or ":" not in stripped:
|
|
102
|
+
continue
|
|
103
|
+
key, value = stripped.split(":", 1)
|
|
104
|
+
if key not in config:
|
|
105
|
+
continue
|
|
106
|
+
parsed = parse_positive_int(value)
|
|
107
|
+
if parsed is not None:
|
|
108
|
+
config[key] = parsed
|
|
109
|
+
return config
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def count_short_memories(root: Path) -> int:
|
|
113
|
+
short_dir = root / ".easy-coding" / "memory" / "short"
|
|
114
|
+
if not short_dir.is_dir():
|
|
115
|
+
return 0
|
|
116
|
+
count = 0
|
|
117
|
+
for entry in short_dir.glob("*.md"):
|
|
118
|
+
try:
|
|
119
|
+
if is_schema_v2_short_memory(entry.read_text(encoding="utf-8")):
|
|
120
|
+
count += 1
|
|
121
|
+
except OSError:
|
|
122
|
+
continue
|
|
123
|
+
return count
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_schema_v2_short_memory(content: str) -> bool:
|
|
127
|
+
lines = content.splitlines()
|
|
128
|
+
if not lines or lines[0].strip() != "---":
|
|
129
|
+
return False
|
|
130
|
+
for line in lines[1:]:
|
|
131
|
+
stripped = line.strip()
|
|
132
|
+
if stripped == "---":
|
|
133
|
+
return False
|
|
134
|
+
if not stripped.startswith("memory_schema:"):
|
|
135
|
+
continue
|
|
136
|
+
_, value = stripped.split(":", 1)
|
|
137
|
+
return parse_positive_int(value) == 2
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_memory_long_instruction(root: Path) -> dict:
|
|
142
|
+
config = read_memory_config(root)
|
|
143
|
+
short_count = count_short_memories(root)
|
|
144
|
+
action = "distill" if short_count > config["short_term_max"] else "no-op"
|
|
145
|
+
trim_count = max(0, short_count - config["short_term_keep"]) if action == "distill" else 0
|
|
146
|
+
return {
|
|
147
|
+
"short_count": short_count,
|
|
148
|
+
"short_term_max": config["short_term_max"],
|
|
149
|
+
"short_term_keep": config["short_term_keep"],
|
|
150
|
+
"action": action,
|
|
151
|
+
"trim_count": trim_count,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_json(path: Path, data: dict) -> None:
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
|
|
161
|
+
sessions_dir = (root / ".easy-coding" / "sessions").resolve()
|
|
162
|
+
if session_file:
|
|
163
|
+
path = Path(session_file)
|
|
164
|
+
candidate = path if path.is_absolute() else root / path
|
|
165
|
+
resolved = candidate.resolve()
|
|
166
|
+
try:
|
|
167
|
+
resolved.relative_to(sessions_dir)
|
|
168
|
+
except ValueError as error:
|
|
169
|
+
raise StateError(
|
|
170
|
+
"Unsafe session file path: "
|
|
171
|
+
f"{session_file}. Must be under .easy-coding/sessions/."
|
|
172
|
+
) from error
|
|
173
|
+
if resolved == sessions_dir:
|
|
174
|
+
raise StateError(
|
|
175
|
+
"Unsafe session file path: "
|
|
176
|
+
f"{session_file}. Must be a file under .easy-coding/sessions/."
|
|
177
|
+
)
|
|
178
|
+
return resolved
|
|
179
|
+
return sessions_dir / f"{os.getppid()}.json"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def display_path(root: Path, path: Path) -> str:
|
|
183
|
+
try:
|
|
184
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
185
|
+
except ValueError:
|
|
186
|
+
return path.as_posix()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def default_session() -> dict:
|
|
190
|
+
return {"current_task": None, "created_at": now_iso()}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def clear_session_pointer(session: dict, agent: str | None = None) -> None:
|
|
194
|
+
session["current_task"] = None
|
|
195
|
+
session["last_seen_task"] = None
|
|
196
|
+
session["last_seen_stage"] = "idle"
|
|
197
|
+
if agent:
|
|
198
|
+
session["last_agent"] = agent
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
|
|
202
|
+
return load_json(resolve_session_path(root, session_file))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
|
|
206
|
+
write_json(resolve_session_path(root, session_file), session)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def task_json_path(root: Path, task_id: str) -> Path:
|
|
210
|
+
assert_safe_task_id(task_id)
|
|
211
|
+
return root / ".easy-coding" / "tasks" / task_id / "task.json"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_task(root: Path, task_id: str | None) -> dict | None:
|
|
215
|
+
if not task_id:
|
|
216
|
+
return None
|
|
217
|
+
return load_json(task_json_path(root, str(task_id)))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def write_task(root: Path, task_id: str, task: dict) -> None:
|
|
221
|
+
write_json(task_json_path(root, task_id), task)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def assert_safe_task_id(task_id: str) -> None:
|
|
225
|
+
path = Path(task_id)
|
|
226
|
+
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
227
|
+
raise StateError(f"Unsafe task id: {task_id}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def is_project_init_required(root: Path) -> bool:
|
|
231
|
+
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
232
|
+
return bool(project_init and project_init.get("status") != "COMPLETE")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_pending_init_version(root: Path) -> str | None:
|
|
236
|
+
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
237
|
+
if project_init and project_init.get("pending_init_since"):
|
|
238
|
+
return str(project_init["pending_init_since"])
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def validate_transition(previous: str, current: str) -> str | None:
|
|
243
|
+
if previous == current:
|
|
244
|
+
return None
|
|
245
|
+
allowed = VALID_TRANSITIONS.get(previous, set())
|
|
246
|
+
if current in allowed:
|
|
247
|
+
return None
|
|
248
|
+
return (
|
|
249
|
+
f"ILLEGAL TRANSITION: {previous} -> {current}. "
|
|
250
|
+
f"Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def snapshot_state(
|
|
255
|
+
root: Path,
|
|
256
|
+
session_file: str | Path | None = None,
|
|
257
|
+
session: dict | None = None,
|
|
258
|
+
) -> dict:
|
|
259
|
+
session_path = resolve_session_path(root, session_file)
|
|
260
|
+
resolved_session = session if session is not None else load_session(root, session_path)
|
|
261
|
+
if resolved_session is None:
|
|
262
|
+
resolved_session = default_session()
|
|
263
|
+
|
|
264
|
+
task_id = resolved_session.get("current_task")
|
|
265
|
+
task = load_task(root, str(task_id)) if task_id else None
|
|
266
|
+
missing = bool(task_id and task is None)
|
|
267
|
+
status = "idle"
|
|
268
|
+
if missing:
|
|
269
|
+
status = "MISSING"
|
|
270
|
+
elif task and task.get("status"):
|
|
271
|
+
status = str(task["status"])
|
|
272
|
+
|
|
273
|
+
if task_id and task and status in TERMINAL_STATUSES:
|
|
274
|
+
clear_session_pointer(resolved_session, task.get("last_agent"))
|
|
275
|
+
write_session(root, resolved_session, session_path)
|
|
276
|
+
task_id = None
|
|
277
|
+
task = None
|
|
278
|
+
missing = False
|
|
279
|
+
status = "idle"
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
"session_file": display_path(root, session_path),
|
|
283
|
+
"current_task": str(task_id) if task_id else None,
|
|
284
|
+
"task": task,
|
|
285
|
+
"task_missing": missing,
|
|
286
|
+
"status": status,
|
|
287
|
+
"is_terminal": status in TERMINAL_STATUSES,
|
|
288
|
+
"last_agent": task.get("last_agent") if task else None,
|
|
289
|
+
"project_init_required": is_project_init_required(root),
|
|
290
|
+
"pending_init_version": get_pending_init_version(root),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def list_tasks(root: Path) -> list[dict]:
|
|
295
|
+
tasks_dir = root / ".easy-coding" / "tasks"
|
|
296
|
+
if not tasks_dir.is_dir():
|
|
297
|
+
return []
|
|
298
|
+
items: list[dict] = []
|
|
299
|
+
for entry in sorted(tasks_dir.iterdir(), key=lambda item: item.name):
|
|
300
|
+
if not entry.is_dir():
|
|
301
|
+
continue
|
|
302
|
+
task = load_json(entry / "task.json")
|
|
303
|
+
if not task:
|
|
304
|
+
continue
|
|
305
|
+
status = str(task.get("status") or "PENDING")
|
|
306
|
+
items.append(
|
|
307
|
+
{
|
|
308
|
+
"id": entry.name,
|
|
309
|
+
"title": task.get("title"),
|
|
310
|
+
"type": task.get("type"),
|
|
311
|
+
"status": status,
|
|
312
|
+
"active": status not in TERMINAL_STATUSES,
|
|
313
|
+
"created_at": task.get("created_at"),
|
|
314
|
+
"last_agent": task.get("last_agent"),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
return items
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
|
|
321
|
+
session = load_session(root, session_file)
|
|
322
|
+
if session is None:
|
|
323
|
+
session = default_session()
|
|
324
|
+
if not session.get("created_at"):
|
|
325
|
+
session["created_at"] = now_iso()
|
|
326
|
+
return session
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def set_current_task(root: Path, task_id: str, agent: str, session_file: str | Path | None = None) -> dict:
|
|
330
|
+
task = load_task(root, task_id)
|
|
331
|
+
if task is None:
|
|
332
|
+
raise StateError(f"Task not found: {task_id}")
|
|
333
|
+
session = ensure_session(root, session_file)
|
|
334
|
+
session["current_task"] = task_id
|
|
335
|
+
session["last_seen_task"] = task_id
|
|
336
|
+
session["last_seen_stage"] = str(task.get("status") or "PENDING")
|
|
337
|
+
session["last_agent"] = agent
|
|
338
|
+
write_session(root, session, session_file)
|
|
339
|
+
return snapshot_state(root, session_file, session)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def clear_current_task(root: Path, agent: str, session_file: str | Path | None = None) -> dict:
|
|
343
|
+
session = ensure_session(root, session_file)
|
|
344
|
+
clear_session_pointer(session, agent)
|
|
345
|
+
write_session(root, session, session_file)
|
|
346
|
+
return snapshot_state(root, session_file, session)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def create_task(
|
|
350
|
+
root: Path,
|
|
351
|
+
task_id: str,
|
|
352
|
+
task_type: str,
|
|
353
|
+
title: str,
|
|
354
|
+
agent: str,
|
|
355
|
+
set_current: bool = True,
|
|
356
|
+
session_file: str | Path | None = None,
|
|
357
|
+
) -> dict:
|
|
358
|
+
assert_safe_task_id(task_id)
|
|
359
|
+
if set_current:
|
|
360
|
+
resolve_session_path(root, session_file)
|
|
361
|
+
path = task_json_path(root, task_id)
|
|
362
|
+
if path.exists():
|
|
363
|
+
raise StateError(f"Task already exists: {task_id}")
|
|
364
|
+
timestamp = now_iso()
|
|
365
|
+
task = {
|
|
366
|
+
"type": task_type,
|
|
367
|
+
"title": title,
|
|
368
|
+
"status": "INIT",
|
|
369
|
+
"created_at": timestamp,
|
|
370
|
+
"created_by": agent,
|
|
371
|
+
"last_agent": agent,
|
|
372
|
+
"stage_history": [{"stage": "INIT", "agent": agent, "entered_at": timestamp}],
|
|
373
|
+
"context": {},
|
|
374
|
+
"spawned_from": None,
|
|
375
|
+
"spawned_tasks": [],
|
|
376
|
+
"closed_reason": None,
|
|
377
|
+
"repos": [],
|
|
378
|
+
}
|
|
379
|
+
write_task(root, task_id, task)
|
|
380
|
+
if set_current:
|
|
381
|
+
return set_current_task(root, task_id, agent, session_file)
|
|
382
|
+
return {"task_id": task_id, "task": task}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def append_stage_history(task: dict, stage: str, agent: str) -> None:
|
|
386
|
+
history = task.setdefault("stage_history", [])
|
|
387
|
+
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def transition_task(
|
|
391
|
+
root: Path,
|
|
392
|
+
stage: str,
|
|
393
|
+
agent: str,
|
|
394
|
+
task_id: str | None = None,
|
|
395
|
+
session_file: str | Path | None = None,
|
|
396
|
+
) -> dict:
|
|
397
|
+
if stage not in VALID_TRANSITIONS:
|
|
398
|
+
raise StateError(f"Unknown stage: {stage}")
|
|
399
|
+
session = ensure_session(root, session_file)
|
|
400
|
+
resolved_task_id = task_id or session.get("current_task")
|
|
401
|
+
if not resolved_task_id:
|
|
402
|
+
raise StateError("No current task is set.")
|
|
403
|
+
task = load_task(root, str(resolved_task_id))
|
|
404
|
+
if task is None:
|
|
405
|
+
raise StateError(f"Task not found: {resolved_task_id}")
|
|
406
|
+
|
|
407
|
+
previous = str(task.get("status") or "idle")
|
|
408
|
+
violation = validate_transition(previous, stage)
|
|
409
|
+
if violation:
|
|
410
|
+
raise StateError(violation)
|
|
411
|
+
if previous != stage:
|
|
412
|
+
task["status"] = stage
|
|
413
|
+
append_stage_history(task, stage, agent)
|
|
414
|
+
task["last_agent"] = agent
|
|
415
|
+
write_task(root, str(resolved_task_id), task)
|
|
416
|
+
|
|
417
|
+
if session.get("current_task") == resolved_task_id:
|
|
418
|
+
if stage in TERMINAL_STATUSES:
|
|
419
|
+
clear_session_pointer(session, agent)
|
|
420
|
+
else:
|
|
421
|
+
session["last_seen_task"] = str(resolved_task_id)
|
|
422
|
+
session["last_seen_stage"] = stage
|
|
423
|
+
session["last_agent"] = agent
|
|
424
|
+
write_session(root, session, session_file)
|
|
425
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
426
|
+
if stage == "MEMORY_LONG":
|
|
427
|
+
snapshot["memory_long"] = build_memory_long_instruction(root)
|
|
428
|
+
return snapshot
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def close_current_task(
|
|
432
|
+
root: Path,
|
|
433
|
+
reason: str,
|
|
434
|
+
agent: str,
|
|
435
|
+
session_file: str | Path | None = None,
|
|
436
|
+
) -> dict:
|
|
437
|
+
session = ensure_session(root, session_file)
|
|
438
|
+
task_id = session.get("current_task")
|
|
439
|
+
if not task_id:
|
|
440
|
+
raise StateError("No current task is set.")
|
|
441
|
+
task = load_task(root, str(task_id))
|
|
442
|
+
if task is None:
|
|
443
|
+
raise StateError(f"Task not found: {task_id}")
|
|
444
|
+
if task.get("status") != "CLOSED":
|
|
445
|
+
task["status"] = "CLOSED"
|
|
446
|
+
append_stage_history(task, "CLOSED", agent)
|
|
447
|
+
task["closed_reason"] = reason
|
|
448
|
+
task["last_agent"] = agent
|
|
449
|
+
write_task(root, str(task_id), task)
|
|
450
|
+
clear_session_pointer(session, agent)
|
|
451
|
+
write_session(root, session, session_file)
|
|
452
|
+
return snapshot_state(root, session_file, session)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def project_init_complete(root: Path, agent: str) -> dict:
|
|
456
|
+
task_id = "project-init"
|
|
457
|
+
task = load_task(root, task_id)
|
|
458
|
+
if task is None:
|
|
459
|
+
raise StateError("project-init task not found.")
|
|
460
|
+
if task.get("status") != "COMPLETE":
|
|
461
|
+
task["status"] = "COMPLETE"
|
|
462
|
+
append_stage_history(task, "COMPLETE", agent)
|
|
463
|
+
task["last_agent"] = agent
|
|
464
|
+
task.pop("pending_init_since", None)
|
|
465
|
+
write_task(root, task_id, task)
|
|
466
|
+
return {"task_id": task_id, "task": task}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def set_repo_path(
|
|
470
|
+
root: Path,
|
|
471
|
+
repo: str,
|
|
472
|
+
repo_path: str,
|
|
473
|
+
task_id: str | None = None,
|
|
474
|
+
session_file: str | Path | None = None,
|
|
475
|
+
) -> dict:
|
|
476
|
+
session = ensure_session(root, session_file)
|
|
477
|
+
resolved_task_id = task_id or session.get("current_task")
|
|
478
|
+
if not resolved_task_id:
|
|
479
|
+
raise StateError("No current task is set.")
|
|
480
|
+
task = load_task(root, str(resolved_task_id))
|
|
481
|
+
if task is None:
|
|
482
|
+
raise StateError(f"Task not found: {resolved_task_id}")
|
|
483
|
+
repo_paths = task.setdefault("repo_paths", {})
|
|
484
|
+
repo_paths[repo] = repo_path
|
|
485
|
+
write_task(root, str(resolved_task_id), task)
|
|
486
|
+
return {"task_id": str(resolved_task_id), "repo_paths": repo_paths}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def record_seen_stage(
|
|
490
|
+
root: Path,
|
|
491
|
+
task_id: str | None,
|
|
492
|
+
stage: str,
|
|
493
|
+
session_file: str | Path | None = None,
|
|
494
|
+
) -> str | None:
|
|
495
|
+
if not task_id or stage in {"idle", "MISSING"}:
|
|
496
|
+
return None
|
|
497
|
+
session = ensure_session(root, session_file)
|
|
498
|
+
last_seen_task = session.get("last_seen_task")
|
|
499
|
+
last_seen_stage = session.get("last_seen_stage")
|
|
500
|
+
|
|
501
|
+
violation = None
|
|
502
|
+
if last_seen_task == task_id and last_seen_stage:
|
|
503
|
+
violation = validate_transition(str(last_seen_stage), stage)
|
|
504
|
+
|
|
505
|
+
if last_seen_task != task_id or last_seen_stage != stage:
|
|
506
|
+
session["last_seen_task"] = task_id
|
|
507
|
+
session["last_seen_stage"] = stage
|
|
508
|
+
write_session(root, session, session_file)
|
|
509
|
+
|
|
510
|
+
return violation
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def resolve_root(cwd: str | None) -> Path:
|
|
514
|
+
root = find_ec_root(Path(cwd or os.getcwd()))
|
|
515
|
+
if root is None:
|
|
516
|
+
raise StateError("No .easy-coding directory found from cwd.")
|
|
517
|
+
return root
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def emit(data: dict | list) -> None:
|
|
521
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
525
|
+
parser.add_argument("--cwd", help="Project directory or a path under it.")
|
|
526
|
+
parser.add_argument("--session-file", help="Session file path injected by the hook.")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def main() -> int:
|
|
530
|
+
configure_stdio()
|
|
531
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
532
|
+
add_common_args(common)
|
|
533
|
+
parser = argparse.ArgumentParser(description="Easy Coding runtime state API")
|
|
534
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
535
|
+
|
|
536
|
+
subcommands.add_parser("snapshot", parents=[common])
|
|
537
|
+
subcommands.add_parser("list-tasks", parents=[common])
|
|
538
|
+
|
|
539
|
+
create = subcommands.add_parser("create-task", parents=[common])
|
|
540
|
+
create.add_argument("--task-id", required=True)
|
|
541
|
+
create.add_argument("--type", required=True)
|
|
542
|
+
create.add_argument("--title", required=True)
|
|
543
|
+
create.add_argument("--agent", required=True)
|
|
544
|
+
create.add_argument("--no-set-current", action="store_true")
|
|
545
|
+
|
|
546
|
+
set_current = subcommands.add_parser("set-current", parents=[common])
|
|
547
|
+
set_current.add_argument("--task-id", required=True)
|
|
548
|
+
set_current.add_argument("--agent", required=True)
|
|
549
|
+
|
|
550
|
+
clear_current = subcommands.add_parser("clear-current", parents=[common])
|
|
551
|
+
clear_current.add_argument("--agent", required=True)
|
|
552
|
+
|
|
553
|
+
transition = subcommands.add_parser("transition", parents=[common])
|
|
554
|
+
transition.add_argument("--stage", required=True)
|
|
555
|
+
transition.add_argument("--agent", required=True)
|
|
556
|
+
transition.add_argument("--task-id")
|
|
557
|
+
|
|
558
|
+
close = subcommands.add_parser("close-current", parents=[common])
|
|
559
|
+
close.add_argument("--reason", required=True)
|
|
560
|
+
close.add_argument("--agent", required=True)
|
|
561
|
+
|
|
562
|
+
project_init = subcommands.add_parser("project-init-complete", parents=[common])
|
|
563
|
+
project_init.add_argument("--agent", required=True)
|
|
564
|
+
|
|
565
|
+
repo_path = subcommands.add_parser("set-repo-path", parents=[common])
|
|
566
|
+
repo_path.add_argument("--repo", required=True)
|
|
567
|
+
repo_path.add_argument("--path", required=True)
|
|
568
|
+
repo_path.add_argument("--task-id")
|
|
569
|
+
|
|
570
|
+
args = parser.parse_args()
|
|
571
|
+
try:
|
|
572
|
+
root = resolve_root(getattr(args, "cwd", None))
|
|
573
|
+
session_file = getattr(args, "session_file", None)
|
|
574
|
+
command = args.command or "snapshot"
|
|
575
|
+
if command == "snapshot":
|
|
576
|
+
emit(snapshot_state(root, session_file))
|
|
577
|
+
elif command == "list-tasks":
|
|
578
|
+
emit({"tasks": list_tasks(root)})
|
|
579
|
+
elif command == "create-task":
|
|
580
|
+
emit(
|
|
581
|
+
create_task(
|
|
582
|
+
root,
|
|
583
|
+
args.task_id,
|
|
584
|
+
args.type,
|
|
585
|
+
args.title,
|
|
586
|
+
args.agent,
|
|
587
|
+
not args.no_set_current,
|
|
588
|
+
session_file,
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
elif command == "set-current":
|
|
592
|
+
emit(set_current_task(root, args.task_id, args.agent, session_file))
|
|
593
|
+
elif command == "clear-current":
|
|
594
|
+
emit(clear_current_task(root, args.agent, session_file))
|
|
595
|
+
elif command == "transition":
|
|
596
|
+
emit(transition_task(root, args.stage, args.agent, args.task_id, session_file))
|
|
597
|
+
elif command == "close-current":
|
|
598
|
+
emit(close_current_task(root, args.reason, args.agent, session_file))
|
|
599
|
+
elif command == "project-init-complete":
|
|
600
|
+
emit(project_init_complete(root, args.agent))
|
|
601
|
+
elif command == "set-repo-path":
|
|
602
|
+
emit(set_repo_path(root, args.repo, args.path, args.task_id, session_file))
|
|
603
|
+
return 0
|
|
604
|
+
except StateError as error:
|
|
605
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
606
|
+
return 1
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
if __name__ == "__main__":
|
|
610
|
+
sys.exit(main())
|