easy-coding-harness 0.10.0-beta.8 → 0.10.0-beta.9
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/CHANGELOG.md +16 -0
- package/dist/cli.js +94 -4
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/skills/ec-implementing/SKILL.md +3 -1
- package/templates/common/skills/ec-workflow/SKILL.md +3 -0
- package/templates/main-constraint/AGENTS.md.tpl +8 -2
- package/templates/main-constraint/CLAUDE.md.tpl +5 -2
- package/templates/shared-hooks/easy_coding_state.py +131 -17
package/package.json
CHANGED
|
@@ -40,7 +40,9 @@ Communicate with the user in the user's language.
|
|
|
40
40
|
author value must be `<Current Agent Name> with Easy Coding`, for example
|
|
41
41
|
`Codex with Easy Coding`. `Current Agent Name` means the user-facing host Agent (for example,
|
|
42
42
|
Codex, Claude, or Qoder), never an implementation sub-agent role such as `ec-implementer`.
|
|
43
|
-
|
|
43
|
+
This value is display attribution only: never pass it to the workflow state API's `--agent`,
|
|
44
|
+
which accepts only `claude-code`, `codex`, or `qoder`. Never copy a previous human or Agent name
|
|
45
|
+
into newly authored code.
|
|
44
46
|
9. Every newly added field in a data-bearing model must have a meaningful field-level comment.
|
|
45
47
|
This includes new or extended entity/DO/DTO/VO/BO, request/response, configuration, and similar
|
|
46
48
|
model types. Every new enum member and every new declared constant requires the same treatment.
|
|
@@ -52,6 +52,9 @@ plan decision; Auto continues immediately. Both remove later waiting, not qualit
|
|
|
52
52
|
|
|
53
53
|
## Startup
|
|
54
54
|
|
|
55
|
+
Throughout this skill, `<agent-id>` is the canonical workflow owner ID: `claude-code`, `codex`,
|
|
56
|
+
or `qoder`. Never use a display or source-author attribution such as `Codex with Easy Coding`.
|
|
57
|
+
|
|
55
58
|
1. Read the injected state breadcrumbs or call:
|
|
56
59
|
|
|
57
60
|
```bash
|
|
@@ -115,8 +115,14 @@ First run `ec-init`; daily work goes through `ec-workflow`.
|
|
|
115
115
|
|
|
116
116
|
## Runtime contract
|
|
117
117
|
|
|
118
|
-
- Workflow state operations
|
|
119
|
-
|
|
118
|
+
- Workflow state operations use the script installed for the active host: Codex uses
|
|
119
|
+
`.codex/hooks/easy_coding_state.py`, while Qoder uses its installed `.qoder/hooks/easy_coding_state.py`
|
|
120
|
+
or `.qodercn/hooks/easy_coding_state.py` variant.
|
|
121
|
+
Never substitute one platform's script for the other, and pass only the canonical owner ID
|
|
122
|
+
(`codex` or `qoder`) to `--agent`; display attribution such as `Codex with Easy Coding` is not
|
|
123
|
+
a workflow identity. The installed script's embedded platform identity, canonical owner, and
|
|
124
|
+
injected session namespace must agree. Do not hand-edit session files, `current_task`, task
|
|
125
|
+
`status`, `stage_history`,
|
|
120
126
|
`pending_transition`, `verification_checkpoint`, workflow/TDD proposal or freeze fields,
|
|
121
127
|
`memory_progress`, or `last_agent`.
|
|
122
128
|
- The hook injects `[easy-coding:session-file:P]`; pass that path to the state script with
|
|
@@ -113,8 +113,11 @@ First run `/ec-init`; daily work goes through `/ec-workflow`.
|
|
|
113
113
|
|
|
114
114
|
## Runtime contract
|
|
115
115
|
|
|
116
|
-
- Workflow state operations go through `{{platform_config_dir}}/hooks/easy_coding_state.py
|
|
117
|
-
|
|
116
|
+
- Workflow state operations go through `{{platform_config_dir}}/hooks/easy_coding_state.py` and
|
|
117
|
+
pass only the canonical owner ID `claude-code` to `--agent`; display attribution such as
|
|
118
|
+
`Claude with Easy Coding` is not a workflow identity. The installed script's embedded platform
|
|
119
|
+
identity, canonical owner, and injected session namespace must agree. Do not hand-edit session
|
|
120
|
+
files, `current_task`, task `status`, `stage_history`,
|
|
118
121
|
`pending_transition`, `verification_checkpoint`, workflow/TDD proposal or freeze fields,
|
|
119
122
|
`memory_progress`, or `last_agent`.
|
|
120
123
|
- The hook injects `[easy-coding:session-file:P]`; pass that path to the state script with
|
|
@@ -145,8 +145,18 @@ ACCEPTANCE_SNAPSHOT_SCHEMA = 1
|
|
|
145
145
|
ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
|
|
146
146
|
SESSION_STALE_THRESHOLD_HOURS = 30 * 24
|
|
147
147
|
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
148
|
+
WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
|
|
149
|
+
# 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
|
|
150
|
+
INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
|
|
148
151
|
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
149
152
|
CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
|
|
153
|
+
LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
154
|
+
"claude with easy coding": "claude-code",
|
|
155
|
+
"claude-code with easy coding": "claude-code",
|
|
156
|
+
"claude code with easy coding": "claude-code",
|
|
157
|
+
"codex with easy coding": "codex",
|
|
158
|
+
"qoder with easy coding": "qoder",
|
|
159
|
+
}
|
|
150
160
|
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
151
161
|
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
152
162
|
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
@@ -226,14 +236,25 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
|
|
|
226
236
|
return (2, memory_id)
|
|
227
237
|
|
|
228
238
|
|
|
229
|
-
def
|
|
239
|
+
def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
|
|
230
240
|
raw_agent = str(agent or "unknown").strip()
|
|
231
241
|
normalized = raw_agent.lower()
|
|
232
242
|
# Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
|
|
233
243
|
if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
|
|
234
244
|
return "codex"
|
|
235
|
-
if normalized in
|
|
245
|
+
if normalized in WORKFLOW_AGENT_IDENTITIES:
|
|
236
246
|
return normalized
|
|
247
|
+
if allow_legacy_display:
|
|
248
|
+
return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def normalize_agent_identity(agent: str | None) -> str:
|
|
253
|
+
raw_agent = str(agent or "unknown").strip()
|
|
254
|
+
# 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
|
|
255
|
+
canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
|
|
256
|
+
if canonical is not None:
|
|
257
|
+
return canonical
|
|
237
258
|
return raw_agent
|
|
238
259
|
|
|
239
260
|
|
|
@@ -247,6 +268,9 @@ def agents_equivalent(first: str | None, second: str | None) -> bool:
|
|
|
247
268
|
|
|
248
269
|
|
|
249
270
|
def detect_runtime_agent() -> str:
|
|
271
|
+
if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
|
|
272
|
+
return INSTALLED_WORKFLOW_AGENT
|
|
273
|
+
# 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
|
|
250
274
|
script_path = Path(sys.argv[0]).as_posix()
|
|
251
275
|
if ".qoder/" in script_path or ".qodercn/" in script_path:
|
|
252
276
|
return "qoder"
|
|
@@ -262,6 +286,45 @@ def detect_runtime_agent() -> str:
|
|
|
262
286
|
return "unknown"
|
|
263
287
|
|
|
264
288
|
|
|
289
|
+
def resolve_state_agent(explicit_agent: str | None) -> str:
|
|
290
|
+
runtime_agent = detect_runtime_agent()
|
|
291
|
+
explicit_identity = None
|
|
292
|
+
if explicit_agent is not None:
|
|
293
|
+
explicit_identity = canonical_agent_identity(explicit_agent)
|
|
294
|
+
if explicit_identity is None:
|
|
295
|
+
raise StateError(
|
|
296
|
+
"Workflow --agent must be one of claude-code, codex, or qoder; "
|
|
297
|
+
"display attribution such as 'Codex with Easy Coding' is not an agent identity."
|
|
298
|
+
)
|
|
299
|
+
if runtime_agent in WORKFLOW_AGENT_IDENTITIES:
|
|
300
|
+
if explicit_identity is not None and explicit_identity != runtime_agent:
|
|
301
|
+
raise StateError(
|
|
302
|
+
f"Workflow agent mismatch: script belongs to {runtime_agent}, "
|
|
303
|
+
f"but --agent resolved to {explicit_identity}. Use the active platform's state script."
|
|
304
|
+
)
|
|
305
|
+
return runtime_agent
|
|
306
|
+
return explicit_identity or "unknown"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
|
|
310
|
+
if session_file is None or agent not in WORKFLOW_AGENT_IDENTITIES:
|
|
311
|
+
return
|
|
312
|
+
session_name = Path(str(session_file)).name
|
|
313
|
+
session_agent = next(
|
|
314
|
+
(
|
|
315
|
+
candidate
|
|
316
|
+
for candidate in WORKFLOW_AGENT_IDENTITIES
|
|
317
|
+
if session_name.startswith(f"{candidate}-")
|
|
318
|
+
),
|
|
319
|
+
None,
|
|
320
|
+
)
|
|
321
|
+
if session_agent is not None and session_agent != agent:
|
|
322
|
+
raise StateError(
|
|
323
|
+
f"Workflow session mismatch: session belongs to {session_agent}, "
|
|
324
|
+
f"but the state operation resolved to {agent}. Use the active session's state script."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
265
328
|
def normalize_session_component(value: str) -> str:
|
|
266
329
|
if (
|
|
267
330
|
value not in {".", ".."}
|
|
@@ -1183,10 +1246,18 @@ def normalize_legacy_stage(stage: object) -> object:
|
|
|
1183
1246
|
|
|
1184
1247
|
|
|
1185
1248
|
def normalize_legacy_task(task: dict) -> bool:
|
|
1186
|
-
"""Normalize
|
|
1249
|
+
"""Normalize legacy task state without touching artifacts outside task.json."""
|
|
1187
1250
|
legacy_status = str(task.get("status") or "")
|
|
1188
1251
|
changed = False
|
|
1189
1252
|
|
|
1253
|
+
for field in ("created_by", "last_agent"):
|
|
1254
|
+
normalized_agent = canonical_agent_identity(
|
|
1255
|
+
task.get(field), allow_legacy_display=True
|
|
1256
|
+
)
|
|
1257
|
+
if normalized_agent is not None and normalized_agent != task.get(field):
|
|
1258
|
+
task[field] = normalized_agent
|
|
1259
|
+
changed = True
|
|
1260
|
+
|
|
1190
1261
|
if legacy_status in LEGACY_STAGE_MAP:
|
|
1191
1262
|
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
1192
1263
|
changed = True
|
|
@@ -1202,6 +1273,12 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
1202
1273
|
if mapped_stage != entry.get("stage"):
|
|
1203
1274
|
entry["stage"] = mapped_stage
|
|
1204
1275
|
changed = True
|
|
1276
|
+
normalized_agent = canonical_agent_identity(
|
|
1277
|
+
entry.get("agent"), allow_legacy_display=True
|
|
1278
|
+
)
|
|
1279
|
+
if normalized_agent is not None and normalized_agent != entry.get("agent"):
|
|
1280
|
+
entry["agent"] = normalized_agent
|
|
1281
|
+
changed = True
|
|
1205
1282
|
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
1206
1283
|
changed = True
|
|
1207
1284
|
continue
|
|
@@ -1316,7 +1393,12 @@ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
|
|
|
1316
1393
|
if "stage_history" not in task or not task["stage_history"]:
|
|
1317
1394
|
task["stage_history"] = old_state.get("stage_history", [])
|
|
1318
1395
|
if "last_agent" not in task or not task["last_agent"]:
|
|
1319
|
-
task["last_agent"] =
|
|
1396
|
+
task["last_agent"] = (
|
|
1397
|
+
canonical_agent_identity(
|
|
1398
|
+
old_state.get("last_agent"), allow_legacy_display=True
|
|
1399
|
+
)
|
|
1400
|
+
or agent
|
|
1401
|
+
)
|
|
1320
1402
|
if old_state.get("confirmed_by_user"):
|
|
1321
1403
|
task["confirmed_by_user"] = True
|
|
1322
1404
|
if old_state.get("test_strategy_confirmed"):
|
|
@@ -4683,6 +4765,28 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
|
4683
4765
|
return latest
|
|
4684
4766
|
|
|
4685
4767
|
|
|
4768
|
+
def pending_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
4769
|
+
path = execution_log_path(root, task_id)
|
|
4770
|
+
if not path.exists():
|
|
4771
|
+
return None
|
|
4772
|
+
latest_coordination: dict | None = None
|
|
4773
|
+
try:
|
|
4774
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
4775
|
+
if not line.strip():
|
|
4776
|
+
continue
|
|
4777
|
+
try:
|
|
4778
|
+
record = json.loads(line)
|
|
4779
|
+
except json.JSONDecodeError:
|
|
4780
|
+
continue
|
|
4781
|
+
if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
|
|
4782
|
+
latest_coordination = record
|
|
4783
|
+
except OSError:
|
|
4784
|
+
return None
|
|
4785
|
+
if latest_coordination and latest_coordination.get("type") == "handoff":
|
|
4786
|
+
return latest_coordination
|
|
4787
|
+
return None
|
|
4788
|
+
|
|
4789
|
+
|
|
4686
4790
|
def assert_safe_task_id(task_id: str) -> None:
|
|
4687
4791
|
path = Path(task_id)
|
|
4688
4792
|
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
@@ -4912,9 +5016,10 @@ def build_status_line(
|
|
|
4912
5016
|
if task_id:
|
|
4913
5017
|
status = str(state["status"])
|
|
4914
5018
|
line = f"{status_brand} · `{task_id}` · `{status}`"
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
5019
|
+
handoff = pending_handoff_record(root, str(task_id))
|
|
5020
|
+
handoff_from = handoff.get("from") if handoff else None
|
|
5021
|
+
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
5022
|
+
line += f" · Handoff -> `{handoff_from}`"
|
|
4918
5023
|
if state["is_terminal"] or state["task_missing"]:
|
|
4919
5024
|
line += f" · {HELP_SUFFIX}"
|
|
4920
5025
|
return line
|
|
@@ -4961,9 +5066,10 @@ def build_machine_breadcrumbs(
|
|
|
4961
5066
|
lines.append(f"[current-task:{task_id}]")
|
|
4962
5067
|
if state["task_missing"]:
|
|
4963
5068
|
lines.append(f"[easy-coding:current-task-missing:{task_id}]")
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
5069
|
+
handoff = pending_handoff_record(root, str(task_id))
|
|
5070
|
+
handoff_from = handoff.get("from") if handoff else None
|
|
5071
|
+
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
5072
|
+
lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
|
|
4967
5073
|
pending = state.get("pending_transition")
|
|
4968
5074
|
if isinstance(pending, dict):
|
|
4969
5075
|
source = str(pending.get("from") or stage)
|
|
@@ -5392,11 +5498,21 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
|
|
|
5392
5498
|
session["last_agent"] = agent
|
|
5393
5499
|
write_session(root, session, session_file)
|
|
5394
5500
|
|
|
5501
|
+
claim = {
|
|
5502
|
+
"type": "claim",
|
|
5503
|
+
"agent": agent,
|
|
5504
|
+
"previous_agent": previous_agent,
|
|
5505
|
+
"action": action,
|
|
5506
|
+
"timestamp": now_iso(),
|
|
5507
|
+
}
|
|
5508
|
+
append_execution_record(root, task_id, claim)
|
|
5509
|
+
|
|
5395
5510
|
snapshot = snapshot_state(root, session_file, session)
|
|
5396
5511
|
snapshot["task_id"] = task_id
|
|
5397
5512
|
snapshot["action"] = action
|
|
5398
5513
|
snapshot["previous_agent"] = previous_agent
|
|
5399
5514
|
snapshot["latest_handoff"] = latest_handoff
|
|
5515
|
+
snapshot["claim"] = claim
|
|
5400
5516
|
return snapshot
|
|
5401
5517
|
|
|
5402
5518
|
|
|
@@ -5531,10 +5647,9 @@ SPEC_WRITEBACK_APP = "easy-coding"
|
|
|
5531
5647
|
|
|
5532
5648
|
|
|
5533
5649
|
def spec_writeback_agent(agent: str) -> str:
|
|
5534
|
-
|
|
5535
|
-
if
|
|
5536
|
-
|
|
5537
|
-
normalized = normalize_agent_identity(raw_agent)
|
|
5650
|
+
normalized = canonical_agent_identity(agent)
|
|
5651
|
+
if normalized is None:
|
|
5652
|
+
raise StateError("Canonical Spec attribution requires a canonical workflow agent identity.")
|
|
5538
5653
|
display_name = {
|
|
5539
5654
|
"claude-code": "Claude Code",
|
|
5540
5655
|
"codex": "Codex",
|
|
@@ -8111,9 +8226,8 @@ def main() -> int:
|
|
|
8111
8226
|
root = resolve_root(getattr(args, "cwd", None))
|
|
8112
8227
|
session_file = getattr(args, "session_file", None)
|
|
8113
8228
|
command = args.command or "snapshot"
|
|
8114
|
-
agent =
|
|
8115
|
-
|
|
8116
|
-
)
|
|
8229
|
+
agent = resolve_state_agent(getattr(args, "agent", None))
|
|
8230
|
+
validate_session_agent(agent, session_file)
|
|
8117
8231
|
session_agent = normalize_session_agent(agent)
|
|
8118
8232
|
visible_agent = None if agent == "unknown" else agent
|
|
8119
8233
|
if session_file is None and command == "project-init-complete":
|