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.
- package/README.md +27 -0
- package/dist/cli.js +49 -25
- package/dist/cli.js.map +1 -1
- package/package.json +1 -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-meta/references/local-architecture/README.md +5 -0
- package/templates/common/skills/ec-analysis/SKILL.md +62 -33
- package/templates/common/skills/ec-brainstorming/SKILL.md +16 -4
- package/templates/common/skills/ec-implementing/SKILL.md +26 -3
- package/templates/common/skills/ec-memory/SKILL.md +31 -11
- package/templates/common/skills/ec-reviewing/SKILL.md +39 -10
- package/templates/common/skills/ec-task-close/SKILL.md +2 -0
- package/templates/common/skills/ec-task-management/SKILL.md +5 -3
- package/templates/common/skills/ec-verification/SKILL.md +12 -7
- package/templates/common/skills/ec-workflow/SKILL.md +58 -13
- package/templates/main-constraint/AGENTS.md.tpl +2 -2
- package/templates/main-constraint/CLAUDE.md.tpl +2 -2
- package/templates/qoder/agents/ec-fixer.md +36 -0
- package/templates/shared-hooks/easy_coding_status.py +68 -20
- package/templates/shared-hooks/inject-subagent-context.py +2 -2
- package/templates/shared-hooks/inject-workflow-state.py +5 -15
- package/templates/shared-hooks/session-start.py +90 -17
|
@@ -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-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
40
|
-
task_id =
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
73
|
-
return "\n".join([build_status_line(root,
|
|
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
|
-
|
|
65
|
-
task_id =
|
|
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
|
-
|
|
83
|
-
if
|
|
84
|
-
|
|
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,
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|