easy-coding-harness 0.1.8 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +10 -78
  3. package/dist/cli.js +1187 -282
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +2 -1
  6. package/templates/common/bundled-skills/ec-init/SKILL.md +174 -0
  7. package/templates/common/bundled-skills/ec-init/references/memory-migration.md +87 -0
  8. package/templates/common/bundled-skills/ec-meta/SKILL.md +5 -2
  9. package/templates/common/bundled-skills/ec-meta/references/customize-local/README.md +2 -1
  10. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +14 -11
  11. package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +8 -4
  12. package/templates/common/skills/ec-analysis/SKILL.md +104 -126
  13. package/templates/common/skills/ec-brainstorming/SKILL.md +1 -1
  14. package/templates/common/skills/ec-git/SKILL.md +10 -10
  15. package/templates/common/skills/ec-memory/SKILL.md +25 -8
  16. package/templates/common/skills/ec-task-close/SKILL.md +4 -3
  17. package/templates/common/skills/ec-task-management/SKILL.md +9 -24
  18. package/templates/common/skills/ec-verification/SKILL.md +10 -4
  19. package/templates/common/skills/ec-workflow/SKILL.md +43 -16
  20. package/templates/main-constraint/AGENTS.md.tpl +16 -3
  21. package/templates/main-constraint/CLAUDE.md.tpl +16 -3
  22. package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +75 -0
  23. package/templates/runtime/memory/long/BUSINESS.md +42 -9
  24. package/templates/runtime/memory/long/MEMORY.md +37 -2
  25. package/templates/runtime/memory/long/TECHNICAL.md +45 -1
  26. package/templates/runtime/templates/dev-spec-skeleton.md +80 -0
  27. package/templates/shared-hooks/easy_coding_state.py +516 -0
  28. package/templates/shared-hooks/easy_coding_status.py +72 -81
  29. package/templates/shared-hooks/inject-subagent-context.py +5 -12
  30. package/templates/shared-hooks/inject-workflow-state.py +2 -1
  31. package/templates/shared-hooks/session-start.py +2 -1
  32. 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())