easy-coding-harness 0.1.6 → 0.1.8

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.
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: ec-fixer
3
+ model: sonnet
4
+ description: Easy Coding fix sub-agent. Applies targeted fixes to specific issues identified during review. Returns structured results with changed files.
5
+ ---
6
+
7
+ You are an Easy Coding fix sub-agent. You receive a fix card listing specific issues
8
+ (with file:line locations) and apply the fixes. Your reply content IS the return value,
9
+ not a message to a human.
10
+
11
+ ## Hard constraints
12
+
13
+ - Fix ONLY the issues listed in the fix card. Do not refactor or "improve" surrounding code.
14
+ - Modify ONLY the files listed in the fix card's scope.
15
+ - Do not call any Skill tool.
16
+ - Do not read `.qoder/skills/`, `.agents/skills/`, or any `.easy-coding/` file.
17
+ - Make no workflow stage-transition decisions.
18
+ - Preserve file encoding.
19
+
20
+ ## Output (return exactly this structure)
21
+
22
+ ```json
23
+ {
24
+ "changed_files": ["file1.ts", "file2.ts"],
25
+ "fixes_applied": [
26
+ {"file": "file1.ts", "line": 42, "original_issue": "...", "fix_description": "..."}
27
+ ],
28
+ "issues": [],
29
+ "needs_attention": []
30
+ }
31
+ ```
32
+
33
+ - `changed_files`: files you actually modified
34
+ - `fixes_applied`: what you fixed with file:line reference
35
+ - `issues`: problems you hit that prevented a fix (empty if none)
36
+ - `needs_attention`: anything requiring a design decision that should escalate to the user
@@ -1,13 +1,28 @@
1
1
  import json
2
+ import os
2
3
  from pathlib import Path
3
4
 
4
5
 
5
6
  READY_LINE = (
6
- "> **Easy Coding** · Ready · Use `ec-analysis` to start requirement analysis, "
7
+ "> **Easy Coding** · Ready · Use `ec-workflow` to start or resume a task, "
7
8
  "`ec-brainstorming` to brainstorm, or `ec-task-management` to view tasks"
8
9
  )
9
10
  WAITING_INIT_LINE = "> **Easy Coding** · Waiting init · Use `ec-init` to initialize"
10
11
 
12
+ VALID_TRANSITIONS: dict[str, set[str]] = {
13
+ "idle": {"INIT"},
14
+ "INIT": {"ANALYSIS", "CLOSED"},
15
+ "ANALYSIS": {"WAITING_CONFIRM", "CLOSED"},
16
+ "WAITING_CONFIRM": {"IMPLEMENT", "ANALYSIS", "CLOSED"},
17
+ "IMPLEMENT": {"REVIEW", "ANALYSIS", "CLOSED"},
18
+ "REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
19
+ "VERIFICATION": {"MEMORY_SHORT", "IMPLEMENT", "CLOSED"},
20
+ "MEMORY_SHORT": {"MEMORY_LONG", "CLOSED"},
21
+ "MEMORY_LONG": {"COMPLETE", "CLOSED"},
22
+ "COMPLETE": set(),
23
+ "CLOSED": set(),
24
+ }
25
+
11
26
 
12
27
  def load_json(path: Path) -> dict | None:
13
28
  if not path.exists():
@@ -24,26 +39,42 @@ def load_task(root: Path, task_id: str | None) -> dict | None:
24
39
  return load_json(root / ".easy-coding" / "tasks" / task_id / "task.json")
25
40
 
26
41
 
42
+ def load_session(root: Path) -> dict | None:
43
+ ppid = os.getppid()
44
+ return load_json(root / ".easy-coding" / "sessions" / f"{ppid}.json")
45
+
46
+
47
+ def write_session(root: Path, session: dict) -> None:
48
+ ppid = os.getppid()
49
+ session_path = root / ".easy-coding" / "sessions" / f"{ppid}.json"
50
+ session_path.parent.mkdir(parents=True, exist_ok=True)
51
+ session_path.write_text(json.dumps(session, indent=2, ensure_ascii=False), encoding="utf-8")
52
+
53
+
27
54
  def is_project_init_required(root: Path) -> bool:
28
55
  project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
29
56
  return bool(project_init and project_init.get("status") != "COMPLETE")
30
57
 
31
58
 
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")
59
+ def validate_transition(previous: str, current: str) -> str | None:
60
+ if previous == current:
61
+ return None
62
+ allowed = VALID_TRANSITIONS.get(previous, set())
63
+ if current in allowed:
64
+ return None
65
+ return f"ILLEGAL TRANSITION: {previous} -> {current}. Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
37
66
 
38
67
 
39
- def build_status_line(root: Path, state: dict, agent: str | None = None) -> str:
40
- task_id = state.get("current_task")
68
+ def build_status_line(root: Path, session: dict, agent: str | None = None) -> str:
69
+ task_id = session.get("current_task")
41
70
  if task_id:
42
- status = resolve_task_status(root, state, str(task_id))
71
+ task = load_task(root, str(task_id))
72
+ status = str(task["status"]) if task and task.get("status") else "PENDING"
43
73
  line = f"> **Easy Coding** · `{task_id}` · `{status}`"
44
- last_agent = state.get("last_agent")
45
- if agent and last_agent and last_agent != agent:
46
- line += f" · Handoff -> `{last_agent}`"
74
+ if task:
75
+ last_agent = task.get("last_agent")
76
+ if agent and last_agent and last_agent != agent:
77
+ line += f" · Handoff -> `{last_agent}`"
47
78
  return line
48
79
 
49
80
  if is_project_init_required(root):
@@ -52,22 +83,39 @@ def build_status_line(root: Path, state: dict, agent: str | None = None) -> str:
52
83
  return READY_LINE
53
84
 
54
85
 
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")
86
+ def build_machine_breadcrumbs(root: Path, session: dict, agent: str | None = None) -> list[str]:
87
+ task_id = session.get("current_task")
88
+ task = load_task(root, str(task_id)) if task_id else None
89
+ stage = str(task["status"]) if task and task.get("status") else "idle"
58
90
  lines = [f"[workflow-state:{stage}]"]
59
91
 
60
92
  if task_id:
61
93
  lines.append(f"[current-task:{task_id}]")
62
- last_agent = state.get("last_agent")
63
- if agent and last_agent and last_agent != agent:
64
- lines.append(f"[easy-coding:handoff-from:{last_agent}]")
94
+ if task:
95
+ last_agent = task.get("last_agent")
96
+ if agent and last_agent and last_agent != agent:
97
+ lines.append(f"[easy-coding:handoff-from:{last_agent}]")
65
98
 
66
99
  if is_project_init_required(root):
67
100
  lines.append("[easy-coding:init-required]")
68
101
 
102
+ # State machine validation
103
+ last_seen = session.get("last_seen_stage")
104
+ if last_seen and task and task.get("status"):
105
+ current_stage = str(task["status"])
106
+ violation = validate_transition(last_seen, current_stage)
107
+ if violation:
108
+ lines.append(f"[ILLEGAL-TRANSITION:{last_seen}->{current_stage}]")
109
+ lines.append(f"[easy-coding:transition-error:{violation}]")
110
+ if last_seen != current_stage:
111
+ session["last_seen_stage"] = current_stage
112
+ write_session(root, session)
113
+ elif task and task.get("status"):
114
+ session["last_seen_stage"] = str(task["status"])
115
+ write_session(root, session)
116
+
69
117
  return lines
70
118
 
71
119
 
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)])
120
+ def build_status_context(root: Path, session: dict, agent: str | None = None) -> str:
121
+ return "\n".join([build_status_line(root, session, agent), *build_machine_breadcrumbs(root, session, agent)])
@@ -61,8 +61,8 @@ def main() -> int:
61
61
  if root is None:
62
62
  return 0
63
63
 
64
- state = load_json(root / ".easy-coding" / "state.json") or {}
65
- task_id = state.get("current_task")
64
+ session = load_json(root / ".easy-coding" / "sessions" / f"{os.getppid()}.json") or {}
65
+ task_id = session.get("current_task")
66
66
  context = [
67
67
  "[easy-coding:subagent-guard]",
68
68
  "Sub-agents must follow the task card, stay within the allowed file scope, and return structured results.",
@@ -4,7 +4,7 @@ import os
4
4
  from pathlib import Path
5
5
  import sys
6
6
 
7
- from easy_coding_status import build_status_context
7
+ from easy_coding_status import build_status_context, load_session
8
8
 
9
9
 
10
10
  def configure_stdio() -> None:
@@ -30,16 +30,6 @@ def find_ec_root(start: Path) -> Path | None:
30
30
  current = current.parent
31
31
 
32
32
 
33
- def load_state(root: Path) -> dict | None:
34
- state_path = root / ".easy-coding" / "state.json"
35
- if not state_path.exists():
36
- return {"current_stage": "idle", "current_task": None}
37
- try:
38
- return json.loads(state_path.read_text(encoding="utf-8"))
39
- except (OSError, json.JSONDecodeError):
40
- return None
41
-
42
-
43
33
  def detect_agent() -> str:
44
34
  if os.environ.get("CLAUDE_PROJECT_DIR"):
45
35
  return "claude-code"
@@ -79,12 +69,12 @@ def main() -> int:
79
69
  if root is None:
80
70
  return 0
81
71
 
82
- state = load_state(root)
83
- if state is None:
84
- return 0
72
+ session = load_session(root)
73
+ if session is None:
74
+ session = {"current_task": None, "created_at": ""}
85
75
 
86
76
  event_name = payload.get("hook_event_name") or payload.get("hookEventName") or "UserPromptSubmit"
87
- emit(event_name, build_status_context(root, state, detect_agent()))
77
+ emit(event_name, build_status_context(root, session, detect_agent()))
88
78
  return 0
89
79
 
90
80
 
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
  import json
3
3
  import os
4
+ from datetime import datetime, timezone
4
5
  from pathlib import Path
5
6
  import sys
6
7
 
7
- from easy_coding_status import build_status_context
8
+ from easy_coding_status import build_status_context, load_session, write_session
8
9
 
9
10
 
10
11
  def configure_stdio() -> None:
@@ -59,16 +60,72 @@ def detect_agent() -> str:
59
60
  return "unknown"
60
61
 
61
62
 
62
- def default_state(agent: str) -> dict:
63
- return {
64
- "current_stage": "idle",
65
- "current_task": None,
66
- "last_agent": agent,
67
- "stage_history": [],
68
- "confirmed_by_user": False,
69
- "test_strategy_confirmed": False,
70
- "repo_paths": {},
71
- }
63
+ def is_process_alive(pid: int) -> bool:
64
+ try:
65
+ os.kill(pid, 0)
66
+ return True
67
+ except (OSError, ProcessLookupError):
68
+ return False
69
+
70
+
71
+ def clean_stale_sessions(root: Path) -> None:
72
+ sessions_dir = root / ".easy-coding" / "sessions"
73
+ if not sessions_dir.is_dir():
74
+ return
75
+
76
+ now = datetime.now(timezone.utc)
77
+ threshold_hours = 24
78
+
79
+ for entry in sessions_dir.iterdir():
80
+ if not entry.suffix == ".json":
81
+ continue
82
+ try:
83
+ data = json.loads(entry.read_text(encoding="utf-8"))
84
+ created = datetime.fromisoformat(data.get("created_at", ""))
85
+ if created.tzinfo is None:
86
+ created = created.replace(tzinfo=timezone.utc)
87
+ age_hours = (now - created).total_seconds() / 3600
88
+ if age_hours <= threshold_hours:
89
+ continue
90
+ pid = int(entry.stem)
91
+ if is_process_alive(pid):
92
+ continue
93
+ entry.unlink()
94
+ except (OSError, json.JSONDecodeError, ValueError, KeyError):
95
+ continue
96
+
97
+
98
+ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
99
+ """Migrate old-format state.json into session file + task.json. Returns session dict."""
100
+ state_path = root / ".easy-coding" / "state.json"
101
+ old_state = load_json(state_path)
102
+ if old_state is None:
103
+ return None
104
+
105
+ task_id = old_state.get("current_task")
106
+ if task_id:
107
+ task_path = root / ".easy-coding" / "tasks" / str(task_id) / "task.json"
108
+ task = load_json(task_path)
109
+ if task:
110
+ if "stage_history" not in task or not task["stage_history"]:
111
+ task["stage_history"] = old_state.get("stage_history", [])
112
+ if "last_agent" not in task or not task["last_agent"]:
113
+ task["last_agent"] = old_state.get("last_agent", agent)
114
+ if old_state.get("confirmed_by_user"):
115
+ task["confirmed_by_user"] = True
116
+ if old_state.get("test_strategy_confirmed"):
117
+ task["test_strategy_confirmed"] = True
118
+ if old_state.get("repo_paths"):
119
+ task["repo_paths"] = old_state["repo_paths"]
120
+ write_json(task_path, task)
121
+
122
+ # Remove legacy state.json
123
+ try:
124
+ state_path.unlink()
125
+ except OSError:
126
+ pass
127
+
128
+ return {"current_task": task_id, "created_at": datetime.now(timezone.utc).isoformat()}
72
129
 
73
130
 
74
131
  def emit(event_name: str, context: str) -> None:
@@ -97,13 +154,29 @@ def main() -> int:
97
154
 
98
155
  agent = detect_agent()
99
156
  event_name = payload.get("hook_event_name") or payload.get("hookEventName") or "SessionStart"
100
- state_path = root / ".easy-coding" / "state.json"
101
- state = load_json(state_path)
102
- if state is None:
103
- state = default_state(agent)
104
- write_json(state_path, state)
105
157
 
106
- emit(event_name, build_status_context(root, state, agent))
158
+ # Migrate legacy state.json if present
159
+ state_path = root / ".easy-coding" / "state.json"
160
+ migrated_session = None
161
+ if state_path.exists():
162
+ migrated_session = migrate_legacy_state(root, agent)
163
+
164
+ # Clean stale session files
165
+ clean_stale_sessions(root)
166
+
167
+ # Create/overwrite session file for this session
168
+ session = load_session(root)
169
+ if session is None:
170
+ if migrated_session:
171
+ session = migrated_session
172
+ else:
173
+ session = {"current_task": None, "created_at": datetime.now(timezone.utc).isoformat()}
174
+ else:
175
+ # Refresh created_at on session start (marks session as active)
176
+ session["created_at"] = datetime.now(timezone.utc).isoformat()
177
+
178
+ write_session(root, session)
179
+ emit(event_name, build_status_context(root, session, agent))
107
180
  return 0
108
181
 
109
182