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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easy-coding-harness",
3
- "version": "0.10.0-beta.8",
3
+ "version": "0.10.0-beta.9",
4
4
  "description": "CLI scaffold for installing Easy Coding harness files into agent-native directories.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- Never copy a previous human or Agent name into newly authored code.
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 go through `{{platform_config_dir}}/hooks/easy_coding_state.py`;
119
- do not hand-edit session files, `current_task`, task `status`, `stage_history`,
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
- do not hand-edit session files, `current_task`, task `status`, `stage_history`,
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 normalize_agent_identity(agent: str | None) -> str:
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 SESSION_AGENT_NAMESPACES:
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 pre-0.6 stage names without touching task artifacts outside task.json."""
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"] = old_state.get("last_agent", 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
- last_agent = state.get("last_agent")
4916
- if agent and last_agent and not agents_equivalent(last_agent, agent):
4917
- line += f" · Handoff -> `{last_agent}`"
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
- last_agent = state.get("last_agent")
4965
- if agent and last_agent and not agents_equivalent(last_agent, agent):
4966
- lines.append(f"[easy-coding:handoff-from:{last_agent}]")
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
- raw_agent = str(agent).strip()
5535
- if raw_agent.endswith(" with Easy Coding"):
5536
- return raw_agent
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 = normalize_agent_identity(
8115
- getattr(args, "agent", None) or detect_runtime_agent()
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":