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.
Files changed (36) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +109 -135
  3. package/dist/cli.js +1218 -282
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +10 -1
  6. package/templates/claude/agents/ec-implementer.md +1 -1
  7. package/templates/claude/agents/ec-reviewer.md +1 -1
  8. package/templates/common/bundled-skills/ec-init/SKILL.md +174 -0
  9. package/templates/common/bundled-skills/ec-init/references/memory-migration.md +87 -0
  10. package/templates/common/bundled-skills/ec-meta/SKILL.md +5 -2
  11. package/templates/common/bundled-skills/ec-meta/references/customize-local/README.md +2 -1
  12. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +14 -11
  13. package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +8 -4
  14. package/templates/common/skills/ec-analysis/SKILL.md +111 -130
  15. package/templates/common/skills/ec-brainstorming/SKILL.md +1 -1
  16. package/templates/common/skills/ec-git/SKILL.md +10 -10
  17. package/templates/common/skills/ec-implementing/SKILL.md +31 -24
  18. package/templates/common/skills/ec-memory/SKILL.md +41 -16
  19. package/templates/common/skills/ec-reviewing/SKILL.md +1 -1
  20. package/templates/common/skills/ec-task-close/SKILL.md +4 -3
  21. package/templates/common/skills/ec-task-management/SKILL.md +9 -24
  22. package/templates/common/skills/ec-verification/SKILL.md +10 -4
  23. package/templates/common/skills/ec-workflow/SKILL.md +48 -17
  24. package/templates/main-constraint/AGENTS.md.tpl +16 -3
  25. package/templates/main-constraint/CLAUDE.md.tpl +16 -3
  26. package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +75 -0
  27. package/templates/runtime/memory/long/BUSINESS.md +42 -9
  28. package/templates/runtime/memory/long/MEMORY.md +37 -2
  29. package/templates/runtime/memory/long/TECHNICAL.md +45 -1
  30. package/templates/runtime/templates/dev-spec-skeleton.md +80 -0
  31. package/templates/shared-hooks/easy_coding_state.py +610 -0
  32. package/templates/shared-hooks/easy_coding_status.py +72 -81
  33. package/templates/shared-hooks/inject-subagent-context.py +5 -12
  34. package/templates/shared-hooks/inject-workflow-state.py +2 -1
  35. package/templates/shared-hooks/session-start.py +2 -1
  36. 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())