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.
Files changed (37) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +10 -65
  3. package/dist/cli.js +1227 -299
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +2 -1
  6. package/templates/claude/agents/ec-fixer.md +36 -0
  7. package/templates/codex/agents/ec-fixer.toml +25 -0
  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 +104 -126
  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 +26 -3
  18. package/templates/common/skills/ec-memory/SKILL.md +56 -19
  19. package/templates/common/skills/ec-reviewing/SKILL.md +39 -10
  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 +22 -11
  23. package/templates/common/skills/ec-workflow/SKILL.md +51 -21
  24. package/templates/main-constraint/AGENTS.md.tpl +16 -3
  25. package/templates/main-constraint/CLAUDE.md.tpl +16 -3
  26. package/templates/qoder/agents/ec-fixer.md +36 -0
  27. package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +75 -0
  28. package/templates/runtime/memory/long/BUSINESS.md +42 -9
  29. package/templates/runtime/memory/long/MEMORY.md +37 -2
  30. package/templates/runtime/memory/long/TECHNICAL.md +45 -1
  31. package/templates/runtime/templates/dev-spec-skeleton.md +80 -0
  32. package/templates/shared-hooks/easy_coding_state.py +516 -0
  33. package/templates/shared-hooks/easy_coding_status.py +77 -38
  34. package/templates/shared-hooks/inject-subagent-context.py +4 -11
  35. package/templates/shared-hooks/inject-workflow-state.py +5 -14
  36. package/templates/shared-hooks/session-start.py +90 -16
  37. 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
- def load_json(path: Path) -> dict | None:
13
- if not path.exists():
14
- return None
15
- try:
16
- return json.loads(path.read_text(encoding="utf-8"))
17
- except (OSError, json.JSONDecodeError):
18
- return None
19
-
20
-
21
- def load_task(root: Path, task_id: str | None) -> dict | None:
22
- if not task_id:
23
- return None
24
- return load_json(root / ".easy-coding" / "tasks" / task_id / "task.json")
25
-
26
-
27
- def is_project_init_required(root: Path) -> bool:
28
- project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
29
- return bool(project_init and project_init.get("status") != "COMPLETE")
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 = resolve_task_status(root, state, str(task_id))
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, state: dict, agent: str | None = None) -> list[str]:
56
- stage = state.get("current_stage") or "idle"
57
- task_id = state.get("current_task")
58
- lines = [f"[workflow-state:{stage}]"]
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, state: dict, agent: str | None = None) -> str:
73
- return "\n".join([build_status_line(root, state, agent), *build_machine_breadcrumbs(root, state, agent)])
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)])