easy-coding-harness 0.1.7 → 0.2.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.
- package/CHANGELOG.md +110 -0
- package/README.md +10 -65
- package/dist/cli.js +1227 -299
- package/dist/cli.js.map +1 -1
- package/package.json +2 -1
- package/templates/claude/agents/ec-fixer.md +36 -0
- package/templates/codex/agents/ec-fixer.toml +25 -0
- 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 +104 -126
- 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 +26 -3
- package/templates/common/skills/ec-memory/SKILL.md +56 -19
- package/templates/common/skills/ec-reviewing/SKILL.md +39 -10
- 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 +22 -11
- package/templates/common/skills/ec-workflow/SKILL.md +51 -21
- package/templates/main-constraint/AGENTS.md.tpl +16 -3
- package/templates/main-constraint/CLAUDE.md.tpl +16 -3
- package/templates/qoder/agents/ec-fixer.md +36 -0
- 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 +516 -0
- package/templates/shared-hooks/easy_coding_status.py +77 -38
- package/templates/shared-hooks/inject-subagent-context.py +4 -11
- package/templates/shared-hooks/inject-workflow-state.py +5 -14
- package/templates/shared-hooks/session-start.py +90 -16
- package/templates/common/skills/ec-init/SKILL.md +0 -96
|
@@ -0,0 +1,516 @@
|
|
|
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
|
+
|
|
31
|
+
class StateError(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def configure_stdio() -> None:
|
|
36
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
37
|
+
if hasattr(stream, "reconfigure"):
|
|
38
|
+
stream.reconfigure(encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now_iso() -> str:
|
|
42
|
+
return datetime.now(timezone.utc).isoformat()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_ec_root(start: Path) -> Path | None:
|
|
46
|
+
current = start.resolve()
|
|
47
|
+
while True:
|
|
48
|
+
if (current / ".easy-coding").is_dir():
|
|
49
|
+
return current
|
|
50
|
+
if current == current.parent:
|
|
51
|
+
return None
|
|
52
|
+
current = current.parent
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_json(path: Path) -> dict | None:
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_json(path: Path, data: dict) -> None:
|
|
65
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
|
|
70
|
+
sessions_dir = (root / ".easy-coding" / "sessions").resolve()
|
|
71
|
+
if session_file:
|
|
72
|
+
path = Path(session_file)
|
|
73
|
+
candidate = path if path.is_absolute() else root / path
|
|
74
|
+
resolved = candidate.resolve()
|
|
75
|
+
try:
|
|
76
|
+
resolved.relative_to(sessions_dir)
|
|
77
|
+
except ValueError as error:
|
|
78
|
+
raise StateError(
|
|
79
|
+
"Unsafe session file path: "
|
|
80
|
+
f"{session_file}. Must be under .easy-coding/sessions/."
|
|
81
|
+
) from error
|
|
82
|
+
if resolved == sessions_dir:
|
|
83
|
+
raise StateError(
|
|
84
|
+
"Unsafe session file path: "
|
|
85
|
+
f"{session_file}. Must be a file under .easy-coding/sessions/."
|
|
86
|
+
)
|
|
87
|
+
return resolved
|
|
88
|
+
return sessions_dir / f"{os.getppid()}.json"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def display_path(root: Path, path: Path) -> str:
|
|
92
|
+
try:
|
|
93
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
94
|
+
except ValueError:
|
|
95
|
+
return path.as_posix()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def default_session() -> dict:
|
|
99
|
+
return {"current_task": None, "created_at": now_iso()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def clear_session_pointer(session: dict, agent: str | None = None) -> None:
|
|
103
|
+
session["current_task"] = None
|
|
104
|
+
session["last_seen_task"] = None
|
|
105
|
+
session["last_seen_stage"] = "idle"
|
|
106
|
+
if agent:
|
|
107
|
+
session["last_agent"] = agent
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
|
|
111
|
+
return load_json(resolve_session_path(root, session_file))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
|
|
115
|
+
write_json(resolve_session_path(root, session_file), session)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def task_json_path(root: Path, task_id: str) -> Path:
|
|
119
|
+
assert_safe_task_id(task_id)
|
|
120
|
+
return root / ".easy-coding" / "tasks" / task_id / "task.json"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_task(root: Path, task_id: str | None) -> dict | None:
|
|
124
|
+
if not task_id:
|
|
125
|
+
return None
|
|
126
|
+
return load_json(task_json_path(root, str(task_id)))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def write_task(root: Path, task_id: str, task: dict) -> None:
|
|
130
|
+
write_json(task_json_path(root, task_id), task)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def assert_safe_task_id(task_id: str) -> None:
|
|
134
|
+
path = Path(task_id)
|
|
135
|
+
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
136
|
+
raise StateError(f"Unsafe task id: {task_id}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_project_init_required(root: Path) -> bool:
|
|
140
|
+
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
141
|
+
return bool(project_init and project_init.get("status") != "COMPLETE")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_pending_init_version(root: Path) -> str | None:
|
|
145
|
+
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
146
|
+
if project_init and project_init.get("pending_init_since"):
|
|
147
|
+
return str(project_init["pending_init_since"])
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_transition(previous: str, current: str) -> str | None:
|
|
152
|
+
if previous == current:
|
|
153
|
+
return None
|
|
154
|
+
allowed = VALID_TRANSITIONS.get(previous, set())
|
|
155
|
+
if current in allowed:
|
|
156
|
+
return None
|
|
157
|
+
return (
|
|
158
|
+
f"ILLEGAL TRANSITION: {previous} -> {current}. "
|
|
159
|
+
f"Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def snapshot_state(
|
|
164
|
+
root: Path,
|
|
165
|
+
session_file: str | Path | None = None,
|
|
166
|
+
session: dict | None = None,
|
|
167
|
+
) -> dict:
|
|
168
|
+
session_path = resolve_session_path(root, session_file)
|
|
169
|
+
resolved_session = session if session is not None else load_session(root, session_path)
|
|
170
|
+
if resolved_session is None:
|
|
171
|
+
resolved_session = default_session()
|
|
172
|
+
|
|
173
|
+
task_id = resolved_session.get("current_task")
|
|
174
|
+
task = load_task(root, str(task_id)) if task_id else None
|
|
175
|
+
missing = bool(task_id and task is None)
|
|
176
|
+
status = "idle"
|
|
177
|
+
if missing:
|
|
178
|
+
status = "MISSING"
|
|
179
|
+
elif task and task.get("status"):
|
|
180
|
+
status = str(task["status"])
|
|
181
|
+
|
|
182
|
+
if task_id and task and status in TERMINAL_STATUSES:
|
|
183
|
+
clear_session_pointer(resolved_session, task.get("last_agent"))
|
|
184
|
+
write_session(root, resolved_session, session_path)
|
|
185
|
+
task_id = None
|
|
186
|
+
task = None
|
|
187
|
+
missing = False
|
|
188
|
+
status = "idle"
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"session_file": display_path(root, session_path),
|
|
192
|
+
"current_task": str(task_id) if task_id else None,
|
|
193
|
+
"task": task,
|
|
194
|
+
"task_missing": missing,
|
|
195
|
+
"status": status,
|
|
196
|
+
"is_terminal": status in TERMINAL_STATUSES,
|
|
197
|
+
"last_agent": task.get("last_agent") if task else None,
|
|
198
|
+
"project_init_required": is_project_init_required(root),
|
|
199
|
+
"pending_init_version": get_pending_init_version(root),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def list_tasks(root: Path) -> list[dict]:
|
|
204
|
+
tasks_dir = root / ".easy-coding" / "tasks"
|
|
205
|
+
if not tasks_dir.is_dir():
|
|
206
|
+
return []
|
|
207
|
+
items: list[dict] = []
|
|
208
|
+
for entry in sorted(tasks_dir.iterdir(), key=lambda item: item.name):
|
|
209
|
+
if not entry.is_dir():
|
|
210
|
+
continue
|
|
211
|
+
task = load_json(entry / "task.json")
|
|
212
|
+
if not task:
|
|
213
|
+
continue
|
|
214
|
+
status = str(task.get("status") or "PENDING")
|
|
215
|
+
items.append(
|
|
216
|
+
{
|
|
217
|
+
"id": entry.name,
|
|
218
|
+
"title": task.get("title"),
|
|
219
|
+
"type": task.get("type"),
|
|
220
|
+
"status": status,
|
|
221
|
+
"active": status not in TERMINAL_STATUSES,
|
|
222
|
+
"created_at": task.get("created_at"),
|
|
223
|
+
"last_agent": task.get("last_agent"),
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return items
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
|
|
230
|
+
session = load_session(root, session_file)
|
|
231
|
+
if session is None:
|
|
232
|
+
session = default_session()
|
|
233
|
+
if not session.get("created_at"):
|
|
234
|
+
session["created_at"] = now_iso()
|
|
235
|
+
return session
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def set_current_task(root: Path, task_id: str, agent: str, session_file: str | Path | None = None) -> dict:
|
|
239
|
+
task = load_task(root, task_id)
|
|
240
|
+
if task is None:
|
|
241
|
+
raise StateError(f"Task not found: {task_id}")
|
|
242
|
+
session = ensure_session(root, session_file)
|
|
243
|
+
session["current_task"] = task_id
|
|
244
|
+
session["last_seen_task"] = task_id
|
|
245
|
+
session["last_seen_stage"] = str(task.get("status") or "PENDING")
|
|
246
|
+
session["last_agent"] = agent
|
|
247
|
+
write_session(root, session, session_file)
|
|
248
|
+
return snapshot_state(root, session_file, session)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def clear_current_task(root: Path, agent: str, session_file: str | Path | None = None) -> dict:
|
|
252
|
+
session = ensure_session(root, session_file)
|
|
253
|
+
clear_session_pointer(session, agent)
|
|
254
|
+
write_session(root, session, session_file)
|
|
255
|
+
return snapshot_state(root, session_file, session)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def create_task(
|
|
259
|
+
root: Path,
|
|
260
|
+
task_id: str,
|
|
261
|
+
task_type: str,
|
|
262
|
+
title: str,
|
|
263
|
+
agent: str,
|
|
264
|
+
set_current: bool = True,
|
|
265
|
+
session_file: str | Path | None = None,
|
|
266
|
+
) -> dict:
|
|
267
|
+
assert_safe_task_id(task_id)
|
|
268
|
+
if set_current:
|
|
269
|
+
resolve_session_path(root, session_file)
|
|
270
|
+
path = task_json_path(root, task_id)
|
|
271
|
+
if path.exists():
|
|
272
|
+
raise StateError(f"Task already exists: {task_id}")
|
|
273
|
+
timestamp = now_iso()
|
|
274
|
+
task = {
|
|
275
|
+
"type": task_type,
|
|
276
|
+
"title": title,
|
|
277
|
+
"status": "INIT",
|
|
278
|
+
"created_at": timestamp,
|
|
279
|
+
"created_by": agent,
|
|
280
|
+
"last_agent": agent,
|
|
281
|
+
"stage_history": [{"stage": "INIT", "agent": agent, "entered_at": timestamp}],
|
|
282
|
+
"context": {},
|
|
283
|
+
"spawned_from": None,
|
|
284
|
+
"spawned_tasks": [],
|
|
285
|
+
"closed_reason": None,
|
|
286
|
+
"repos": [],
|
|
287
|
+
}
|
|
288
|
+
write_task(root, task_id, task)
|
|
289
|
+
if set_current:
|
|
290
|
+
return set_current_task(root, task_id, agent, session_file)
|
|
291
|
+
return {"task_id": task_id, "task": task}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def append_stage_history(task: dict, stage: str, agent: str) -> None:
|
|
295
|
+
history = task.setdefault("stage_history", [])
|
|
296
|
+
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def transition_task(
|
|
300
|
+
root: Path,
|
|
301
|
+
stage: str,
|
|
302
|
+
agent: str,
|
|
303
|
+
task_id: str | None = None,
|
|
304
|
+
session_file: str | Path | None = None,
|
|
305
|
+
) -> dict:
|
|
306
|
+
if stage not in VALID_TRANSITIONS:
|
|
307
|
+
raise StateError(f"Unknown stage: {stage}")
|
|
308
|
+
session = ensure_session(root, session_file)
|
|
309
|
+
resolved_task_id = task_id or session.get("current_task")
|
|
310
|
+
if not resolved_task_id:
|
|
311
|
+
raise StateError("No current task is set.")
|
|
312
|
+
task = load_task(root, str(resolved_task_id))
|
|
313
|
+
if task is None:
|
|
314
|
+
raise StateError(f"Task not found: {resolved_task_id}")
|
|
315
|
+
|
|
316
|
+
previous = str(task.get("status") or "idle")
|
|
317
|
+
violation = validate_transition(previous, stage)
|
|
318
|
+
if violation:
|
|
319
|
+
raise StateError(violation)
|
|
320
|
+
if previous != stage:
|
|
321
|
+
task["status"] = stage
|
|
322
|
+
append_stage_history(task, stage, agent)
|
|
323
|
+
task["last_agent"] = agent
|
|
324
|
+
write_task(root, str(resolved_task_id), task)
|
|
325
|
+
|
|
326
|
+
if session.get("current_task") == resolved_task_id:
|
|
327
|
+
if stage in TERMINAL_STATUSES:
|
|
328
|
+
clear_session_pointer(session, agent)
|
|
329
|
+
else:
|
|
330
|
+
session["last_seen_task"] = str(resolved_task_id)
|
|
331
|
+
session["last_seen_stage"] = stage
|
|
332
|
+
session["last_agent"] = agent
|
|
333
|
+
write_session(root, session, session_file)
|
|
334
|
+
return snapshot_state(root, session_file, session)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def close_current_task(
|
|
338
|
+
root: Path,
|
|
339
|
+
reason: str,
|
|
340
|
+
agent: str,
|
|
341
|
+
session_file: str | Path | None = None,
|
|
342
|
+
) -> dict:
|
|
343
|
+
session = ensure_session(root, session_file)
|
|
344
|
+
task_id = session.get("current_task")
|
|
345
|
+
if not task_id:
|
|
346
|
+
raise StateError("No current task is set.")
|
|
347
|
+
task = load_task(root, str(task_id))
|
|
348
|
+
if task is None:
|
|
349
|
+
raise StateError(f"Task not found: {task_id}")
|
|
350
|
+
if task.get("status") != "CLOSED":
|
|
351
|
+
task["status"] = "CLOSED"
|
|
352
|
+
append_stage_history(task, "CLOSED", agent)
|
|
353
|
+
task["closed_reason"] = reason
|
|
354
|
+
task["last_agent"] = agent
|
|
355
|
+
write_task(root, str(task_id), task)
|
|
356
|
+
clear_session_pointer(session, agent)
|
|
357
|
+
write_session(root, session, session_file)
|
|
358
|
+
return snapshot_state(root, session_file, session)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def project_init_complete(root: Path, agent: str) -> dict:
|
|
362
|
+
task_id = "project-init"
|
|
363
|
+
task = load_task(root, task_id)
|
|
364
|
+
if task is None:
|
|
365
|
+
raise StateError("project-init task not found.")
|
|
366
|
+
if task.get("status") != "COMPLETE":
|
|
367
|
+
task["status"] = "COMPLETE"
|
|
368
|
+
append_stage_history(task, "COMPLETE", agent)
|
|
369
|
+
task["last_agent"] = agent
|
|
370
|
+
task.pop("pending_init_since", None)
|
|
371
|
+
write_task(root, task_id, task)
|
|
372
|
+
return {"task_id": task_id, "task": task}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def set_repo_path(
|
|
376
|
+
root: Path,
|
|
377
|
+
repo: str,
|
|
378
|
+
repo_path: str,
|
|
379
|
+
task_id: str | None = None,
|
|
380
|
+
session_file: str | Path | None = None,
|
|
381
|
+
) -> dict:
|
|
382
|
+
session = ensure_session(root, session_file)
|
|
383
|
+
resolved_task_id = task_id or session.get("current_task")
|
|
384
|
+
if not resolved_task_id:
|
|
385
|
+
raise StateError("No current task is set.")
|
|
386
|
+
task = load_task(root, str(resolved_task_id))
|
|
387
|
+
if task is None:
|
|
388
|
+
raise StateError(f"Task not found: {resolved_task_id}")
|
|
389
|
+
repo_paths = task.setdefault("repo_paths", {})
|
|
390
|
+
repo_paths[repo] = repo_path
|
|
391
|
+
write_task(root, str(resolved_task_id), task)
|
|
392
|
+
return {"task_id": str(resolved_task_id), "repo_paths": repo_paths}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def record_seen_stage(
|
|
396
|
+
root: Path,
|
|
397
|
+
task_id: str | None,
|
|
398
|
+
stage: str,
|
|
399
|
+
session_file: str | Path | None = None,
|
|
400
|
+
) -> str | None:
|
|
401
|
+
if not task_id or stage in {"idle", "MISSING"}:
|
|
402
|
+
return None
|
|
403
|
+
session = ensure_session(root, session_file)
|
|
404
|
+
last_seen_task = session.get("last_seen_task")
|
|
405
|
+
last_seen_stage = session.get("last_seen_stage")
|
|
406
|
+
|
|
407
|
+
violation = None
|
|
408
|
+
if last_seen_task == task_id and last_seen_stage:
|
|
409
|
+
violation = validate_transition(str(last_seen_stage), stage)
|
|
410
|
+
|
|
411
|
+
if last_seen_task != task_id or last_seen_stage != stage:
|
|
412
|
+
session["last_seen_task"] = task_id
|
|
413
|
+
session["last_seen_stage"] = stage
|
|
414
|
+
write_session(root, session, session_file)
|
|
415
|
+
|
|
416
|
+
return violation
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def resolve_root(cwd: str | None) -> Path:
|
|
420
|
+
root = find_ec_root(Path(cwd or os.getcwd()))
|
|
421
|
+
if root is None:
|
|
422
|
+
raise StateError("No .easy-coding directory found from cwd.")
|
|
423
|
+
return root
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def emit(data: dict | list) -> None:
|
|
427
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
431
|
+
parser.add_argument("--cwd", help="Project directory or a path under it.")
|
|
432
|
+
parser.add_argument("--session-file", help="Session file path injected by the hook.")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def main() -> int:
|
|
436
|
+
configure_stdio()
|
|
437
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
438
|
+
add_common_args(common)
|
|
439
|
+
parser = argparse.ArgumentParser(description="Easy Coding runtime state API")
|
|
440
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
441
|
+
|
|
442
|
+
subcommands.add_parser("snapshot", parents=[common])
|
|
443
|
+
subcommands.add_parser("list-tasks", parents=[common])
|
|
444
|
+
|
|
445
|
+
create = subcommands.add_parser("create-task", parents=[common])
|
|
446
|
+
create.add_argument("--task-id", required=True)
|
|
447
|
+
create.add_argument("--type", required=True)
|
|
448
|
+
create.add_argument("--title", required=True)
|
|
449
|
+
create.add_argument("--agent", required=True)
|
|
450
|
+
create.add_argument("--no-set-current", action="store_true")
|
|
451
|
+
|
|
452
|
+
set_current = subcommands.add_parser("set-current", parents=[common])
|
|
453
|
+
set_current.add_argument("--task-id", required=True)
|
|
454
|
+
set_current.add_argument("--agent", required=True)
|
|
455
|
+
|
|
456
|
+
clear_current = subcommands.add_parser("clear-current", parents=[common])
|
|
457
|
+
clear_current.add_argument("--agent", required=True)
|
|
458
|
+
|
|
459
|
+
transition = subcommands.add_parser("transition", parents=[common])
|
|
460
|
+
transition.add_argument("--stage", required=True)
|
|
461
|
+
transition.add_argument("--agent", required=True)
|
|
462
|
+
transition.add_argument("--task-id")
|
|
463
|
+
|
|
464
|
+
close = subcommands.add_parser("close-current", parents=[common])
|
|
465
|
+
close.add_argument("--reason", required=True)
|
|
466
|
+
close.add_argument("--agent", required=True)
|
|
467
|
+
|
|
468
|
+
project_init = subcommands.add_parser("project-init-complete", parents=[common])
|
|
469
|
+
project_init.add_argument("--agent", required=True)
|
|
470
|
+
|
|
471
|
+
repo_path = subcommands.add_parser("set-repo-path", parents=[common])
|
|
472
|
+
repo_path.add_argument("--repo", required=True)
|
|
473
|
+
repo_path.add_argument("--path", required=True)
|
|
474
|
+
repo_path.add_argument("--task-id")
|
|
475
|
+
|
|
476
|
+
args = parser.parse_args()
|
|
477
|
+
try:
|
|
478
|
+
root = resolve_root(getattr(args, "cwd", None))
|
|
479
|
+
session_file = getattr(args, "session_file", None)
|
|
480
|
+
command = args.command or "snapshot"
|
|
481
|
+
if command == "snapshot":
|
|
482
|
+
emit(snapshot_state(root, session_file))
|
|
483
|
+
elif command == "list-tasks":
|
|
484
|
+
emit({"tasks": list_tasks(root)})
|
|
485
|
+
elif command == "create-task":
|
|
486
|
+
emit(
|
|
487
|
+
create_task(
|
|
488
|
+
root,
|
|
489
|
+
args.task_id,
|
|
490
|
+
args.type,
|
|
491
|
+
args.title,
|
|
492
|
+
args.agent,
|
|
493
|
+
not args.no_set_current,
|
|
494
|
+
session_file,
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
elif command == "set-current":
|
|
498
|
+
emit(set_current_task(root, args.task_id, args.agent, session_file))
|
|
499
|
+
elif command == "clear-current":
|
|
500
|
+
emit(clear_current_task(root, args.agent, session_file))
|
|
501
|
+
elif command == "transition":
|
|
502
|
+
emit(transition_task(root, args.stage, args.agent, args.task_id, session_file))
|
|
503
|
+
elif command == "close-current":
|
|
504
|
+
emit(close_current_task(root, args.reason, args.agent, session_file))
|
|
505
|
+
elif command == "project-init-complete":
|
|
506
|
+
emit(project_init_complete(root, args.agent))
|
|
507
|
+
elif command == "set-repo-path":
|
|
508
|
+
emit(set_repo_path(root, args.repo, args.path, args.task_id, session_file))
|
|
509
|
+
return 0
|
|
510
|
+
except StateError as error:
|
|
511
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
512
|
+
return 1
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
sys.exit(main())
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import json
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
|
|
3
|
+
from easy_coding_state import (
|
|
4
|
+
HELP_SUFFIX,
|
|
5
|
+
get_pending_init_version,
|
|
6
|
+
is_project_init_required,
|
|
7
|
+
record_seen_stage,
|
|
8
|
+
snapshot_state,
|
|
9
|
+
)
|
|
10
|
+
|
|
4
11
|
|
|
5
12
|
READY_LINE = (
|
|
6
13
|
"> **Easy Coding** · Ready · Use `ec-workflow` to start or resume a task, "
|
|
@@ -8,66 +15,98 @@ READY_LINE = (
|
|
|
8
15
|
)
|
|
9
16
|
WAITING_INIT_LINE = "> **Easy Coding** · Waiting init · Use `ec-init` to initialize"
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def resolve_task_status(root: Path, state: dict, task_id: str | None) -> str:
|
|
33
|
-
task = load_task(root, task_id)
|
|
34
|
-
if task and task.get("status"):
|
|
35
|
-
return str(task["status"])
|
|
36
|
-
return str(state.get("current_stage") or "idle")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def build_status_line(root: Path, state: dict, agent: str | None = None) -> str:
|
|
40
|
-
task_id = state.get("current_task")
|
|
18
|
+
MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
19
|
+
"## 技术方案",
|
|
20
|
+
"### 项目模式",
|
|
21
|
+
"### 任务类型",
|
|
22
|
+
"### 需求解析",
|
|
23
|
+
"### 现状",
|
|
24
|
+
"### 冲突摘要",
|
|
25
|
+
"### 待用户决策",
|
|
26
|
+
"### 影响面分析",
|
|
27
|
+
"### 改动范围",
|
|
28
|
+
"### 修改方案",
|
|
29
|
+
"### 实施拆解",
|
|
30
|
+
"### 测试策略",
|
|
31
|
+
"### 风险与注意事项",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def build_status_line(root: Path, session: dict, agent: str | None = None) -> str:
|
|
35
|
+
state = snapshot_state(root, session=session)
|
|
36
|
+
task_id = state["current_task"]
|
|
41
37
|
if task_id:
|
|
42
|
-
status =
|
|
38
|
+
status = str(state["status"])
|
|
43
39
|
line = f"> **Easy Coding** · `{task_id}` · `{status}`"
|
|
44
40
|
last_agent = state.get("last_agent")
|
|
45
41
|
if agent and last_agent and last_agent != agent:
|
|
46
42
|
line += f" · Handoff -> `{last_agent}`"
|
|
43
|
+
if state["is_terminal"] or state["task_missing"]:
|
|
44
|
+
line += f" · {HELP_SUFFIX}"
|
|
47
45
|
return line
|
|
48
46
|
|
|
49
47
|
if is_project_init_required(root):
|
|
50
48
|
return WAITING_INIT_LINE
|
|
51
49
|
|
|
50
|
+
pending = get_pending_init_version(root)
|
|
51
|
+
if pending:
|
|
52
|
+
return (
|
|
53
|
+
f"> **Easy Coding** · Waiting init · "
|
|
54
|
+
f"Upgrade to v{pending} — run `ec-init` to adapt"
|
|
55
|
+
)
|
|
56
|
+
|
|
52
57
|
return READY_LINE
|
|
53
58
|
|
|
54
59
|
|
|
55
|
-
def build_machine_breadcrumbs(root: Path,
|
|
56
|
-
|
|
57
|
-
task_id = state
|
|
58
|
-
|
|
60
|
+
def build_machine_breadcrumbs(root: Path, session: dict, agent: str | None = None) -> list[str]:
|
|
61
|
+
state = snapshot_state(root, session=session)
|
|
62
|
+
task_id = state["current_task"]
|
|
63
|
+
task = state["task"]
|
|
64
|
+
stage = str(state["status"]) if task else "idle"
|
|
65
|
+
lines = [f"[workflow-state:{stage}]", f"[easy-coding:session-file:{state['session_file']}]"]
|
|
59
66
|
|
|
60
67
|
if task_id:
|
|
61
68
|
lines.append(f"[current-task:{task_id}]")
|
|
69
|
+
if state["task_missing"]:
|
|
70
|
+
lines.append(f"[easy-coding:current-task-missing:{task_id}]")
|
|
62
71
|
last_agent = state.get("last_agent")
|
|
63
72
|
if agent and last_agent and last_agent != agent:
|
|
64
73
|
lines.append(f"[easy-coding:handoff-from:{last_agent}]")
|
|
65
74
|
|
|
66
75
|
if is_project_init_required(root):
|
|
67
76
|
lines.append("[easy-coding:init-required]")
|
|
77
|
+
else:
|
|
78
|
+
pending = get_pending_init_version(root)
|
|
79
|
+
if pending:
|
|
80
|
+
lines.append(f"[easy-coding:upgrade-init-pending:{pending}]")
|
|
81
|
+
|
|
82
|
+
# Stage-specific reminders
|
|
83
|
+
if stage == "ANALYSIS" and task_id:
|
|
84
|
+
dev_spec = root / ".easy-coding" / "tasks" / str(task_id) / "dev-spec.md"
|
|
85
|
+
if dev_spec.exists():
|
|
86
|
+
try:
|
|
87
|
+
content = dev_spec.read_text(encoding="utf-8")
|
|
88
|
+
missing = [h for h in MANDATORY_DEV_SPEC_HEADERS if h not in content]
|
|
89
|
+
if missing:
|
|
90
|
+
names = ",".join(h.lstrip("#").strip() for h in missing)
|
|
91
|
+
lines.append(f"[easy-coding:analysis-template-drift:missing:{names}]")
|
|
92
|
+
else:
|
|
93
|
+
lines.append("[easy-coding:analysis-template-ok]")
|
|
94
|
+
except OSError:
|
|
95
|
+
lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
|
|
96
|
+
else:
|
|
97
|
+
lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
|
|
98
|
+
|
|
99
|
+
# State machine validation
|
|
100
|
+
if task_id and task and task.get("status"):
|
|
101
|
+
current_stage = str(task["status"])
|
|
102
|
+
last_seen = session.get("last_seen_stage")
|
|
103
|
+
violation = record_seen_stage(root, str(task_id), current_stage)
|
|
104
|
+
if violation:
|
|
105
|
+
lines.append(f"[ILLEGAL-TRANSITION:{last_seen}->{current_stage}]")
|
|
106
|
+
lines.append(f"[easy-coding:transition-error:{violation}]")
|
|
68
107
|
|
|
69
108
|
return lines
|
|
70
109
|
|
|
71
110
|
|
|
72
|
-
def build_status_context(root: Path,
|
|
73
|
-
return "\n".join([build_status_line(root,
|
|
111
|
+
def build_status_context(root: Path, session: dict, agent: str | None = None) -> str:
|
|
112
|
+
return "\n".join([build_status_line(root, session, agent), *build_machine_breadcrumbs(root, session, agent)])
|