easy-coding-harness 0.5.3 → 0.6.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 (27) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +13 -11
  3. package/dist/cli.js +134 -2
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/claude/agents/ec-implementer.md +3 -0
  7. package/templates/codex/agents/ec-implementer.toml +3 -0
  8. package/templates/common/bundled-skills/ec-init/SKILL.md +1 -1
  9. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +12 -5
  10. package/templates/common/skills/ec-analysis/SKILL.md +87 -51
  11. package/templates/common/skills/ec-brainstorming/SKILL.md +2 -1
  12. package/templates/common/skills/ec-git/SKILL.md +1 -1
  13. package/templates/common/skills/ec-implementing/SKILL.md +42 -13
  14. package/templates/common/skills/ec-memory/SKILL.md +46 -20
  15. package/templates/common/skills/ec-reviewing/SKILL.md +10 -6
  16. package/templates/common/skills/ec-task-close/SKILL.md +1 -1
  17. package/templates/common/skills/ec-task-management/SKILL.md +1 -1
  18. package/templates/common/skills/ec-verification/SKILL.md +25 -26
  19. package/templates/common/skills/ec-workflow/SKILL.md +119 -69
  20. package/templates/main-constraint/AGENTS.md.tpl +20 -12
  21. package/templates/main-constraint/CLAUDE.md.tpl +20 -12
  22. package/templates/qoder/agents/ec-implementer.md +3 -0
  23. package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +2 -0
  24. package/templates/runtime/templates/dev-spec-skeleton.md +41 -46
  25. package/templates/shared-hooks/easy_coding_state.py +1040 -39
  26. package/templates/shared-hooks/inject-workflow-state.py +1 -176
  27. package/templates/shared-hooks/session-start.py +2 -1
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  import argparse
3
+ import hashlib
3
4
  import json
4
5
  import os
6
+ import re
5
7
  from datetime import datetime, timezone
6
8
  from pathlib import Path
7
9
  import sys
@@ -25,7 +27,6 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
25
27
  "### 需求解析",
26
28
  "### 现状",
27
29
  "### 冲突摘要",
28
- "### 待用户决策",
29
30
  "### 影响面分析",
30
31
  "### 改动范围",
31
32
  "### 修改方案",
@@ -37,19 +38,48 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
37
38
  VALID_TRANSITIONS: dict[str, set[str]] = {
38
39
  "idle": {"INIT"},
39
40
  "INIT": {"ANALYSIS", "CLOSED"},
40
- "ANALYSIS": {"WAITING_CONFIRM", "CLOSED"},
41
- "WAITING_CONFIRM": {"IMPLEMENT", "ANALYSIS", "CLOSED"},
42
- "IMPLEMENT": {"REVIEW", "ANALYSIS", "CLOSED"},
41
+ "ANALYSIS": {"IMPLEMENT", "CLOSED"},
42
+ "IMPLEMENT": {"REVIEW", "VERIFICATION", "ANALYSIS", "COMPLETE", "CLOSED"},
43
43
  "REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
44
- "VERIFICATION": {"MEMORY_SHORT", "IMPLEMENT", "CLOSED"},
45
- "MEMORY_SHORT": {"MEMORY_LONG", "CLOSED"},
46
- "MEMORY_LONG": {"COMPLETE", "CLOSED"},
44
+ "VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
45
+ "MEMORY": {"COMPLETE", "CLOSED"},
47
46
  "COMPLETE": set(),
48
47
  "CLOSED": set(),
49
48
  }
50
49
 
50
+ AUTO_TRANSITIONS = {
51
+ ("INIT", "ANALYSIS"),
52
+ ("MEMORY", "COMPLETE"),
53
+ }
54
+ READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
55
+ NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
56
+
57
+ LEGACY_STAGE_MAP = {
58
+ "WAITING_CONFIRM": "ANALYSIS",
59
+ "MEMORY_SHORT": "MEMORY",
60
+ "MEMORY_LONG": "MEMORY",
61
+ }
62
+
51
63
  DEFAULT_SHORT_TERM_MAX = 10
52
64
  DEFAULT_SHORT_TERM_KEEP = 5
65
+ DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
66
+ MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
67
+ TABLE_HEADER_CELLS = {
68
+ "改动文件",
69
+ "改动类型",
70
+ "文件编码",
71
+ "改动核心内容",
72
+ "单元",
73
+ "说明",
74
+ "类型",
75
+ "涉及文件",
76
+ "依赖",
77
+ "测试点",
78
+ "级别",
79
+ "归属单元",
80
+ "方式",
81
+ "验证命令",
82
+ }
53
83
 
54
84
 
55
85
  class StateError(Exception):
@@ -127,52 +157,248 @@ def read_memory_config(root: Path) -> dict[str, int]:
127
157
  parsed = parse_positive_int(value)
128
158
  if parsed is not None:
129
159
  config[key] = parsed
160
+ if config["short_term_keep"] > config["short_term_max"]:
161
+ raise StateError(
162
+ "Invalid memory config in .easy-coding/config.yaml: "
163
+ "memory.short_term_keep must be less than or equal to memory.short_term_max."
164
+ )
130
165
  return config
131
166
 
132
167
 
133
- def count_short_memories(root: Path) -> int:
168
+ def short_memory_entries(root: Path) -> list[dict[str, object]]:
134
169
  short_dir = root / ".easy-coding" / "memory" / "short"
135
170
  if not short_dir.is_dir():
136
- return 0
137
- count = 0
171
+ return []
172
+ entries: list[dict[str, object]] = []
138
173
  for entry in short_dir.glob("*.md"):
139
174
  try:
140
- if is_schema_v2_short_memory(entry.read_text(encoding="utf-8")):
141
- count += 1
175
+ content = entry.read_text(encoding="utf-8")
142
176
  except OSError:
143
177
  continue
144
- return count
178
+ if not is_schema_v2_short_memory(content):
179
+ continue
180
+ frontmatter = parse_short_memory_frontmatter(content)
181
+ resolved_entry = entry.resolve()
182
+ try:
183
+ resolved_entry.relative_to(short_dir.resolve())
184
+ display_entry = resolved_entry.relative_to(root.resolve()).as_posix()
185
+ except ValueError:
186
+ continue
187
+ prefix_text = entry.name.split("_", 1)[0]
188
+ try:
189
+ prefix = int(prefix_text)
190
+ except ValueError:
191
+ prefix = sys.maxsize
192
+ entries.append(
193
+ {
194
+ "path": resolved_entry,
195
+ "display_path": display_entry,
196
+ "date": frontmatter.get("date", ""),
197
+ "prefix": prefix,
198
+ "name": entry.name,
199
+ }
200
+ )
201
+ return sorted(
202
+ entries,
203
+ key=lambda item: (str(item["date"]), int(item["prefix"]), str(item["name"])),
204
+ )
145
205
 
146
206
 
147
- def is_schema_v2_short_memory(content: str) -> bool:
207
+ def count_short_memories(root: Path) -> int:
208
+ return len(short_memory_entries(root))
209
+
210
+
211
+ def parse_short_memory_frontmatter(content: str) -> dict[str, str]:
148
212
  lines = content.splitlines()
149
213
  if not lines or lines[0].strip() != "---":
150
- return False
214
+ return {}
215
+ frontmatter: dict[str, str] = {}
151
216
  for line in lines[1:]:
152
217
  stripped = line.strip()
153
218
  if stripped == "---":
154
- return False
155
- if not stripped.startswith("memory_schema:"):
219
+ return frontmatter
220
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
156
221
  continue
157
- _, value = stripped.split(":", 1)
158
- return parse_positive_int(value) == 2
159
- return False
222
+ key, value = stripped.split(":", 1)
223
+ frontmatter[key.strip()] = value.strip().strip("'\"")
224
+ return {}
225
+
226
+
227
+ def is_schema_v2_short_memory(content: str) -> bool:
228
+ frontmatter = parse_short_memory_frontmatter(content)
229
+ return parse_positive_int(frontmatter.get("memory_schema", "")) == 2
230
+
231
+
232
+ def resolve_short_memory_path(root: Path, memory_file: str) -> Path:
233
+ short_dir = (root / ".easy-coding" / "memory" / "short").resolve()
234
+ memory_path = Path(memory_file.strip())
235
+ candidate = memory_path if memory_path.is_absolute() else root / memory_path
236
+ resolved_memory_path = candidate.resolve()
237
+ try:
238
+ resolved_memory_path.relative_to(short_dir)
239
+ except ValueError as error:
240
+ raise StateError("Short-memory file must be under .easy-coding/memory/short/.") from error
241
+ if resolved_memory_path.suffix != ".md":
242
+ raise StateError(f"Short-memory file must be Markdown: {memory_file.strip()}")
243
+ return resolved_memory_path
160
244
 
161
245
 
162
- def build_memory_long_instruction(root: Path) -> dict:
246
+ def validate_short_memory_file(
247
+ root: Path,
248
+ task_id: str,
249
+ memory_file: str,
250
+ expected_sha256: str | None = None,
251
+ ) -> tuple[Path, str]:
252
+ resolved_memory_path = resolve_short_memory_path(root, memory_file)
253
+ if not resolved_memory_path.is_file():
254
+ raise StateError(f"Short-memory file not found: {memory_file.strip()}")
255
+ try:
256
+ content = resolved_memory_path.read_text(encoding="utf-8")
257
+ except OSError as error:
258
+ raise StateError(f"Cannot read short-memory file: {memory_file.strip()}") from error
259
+ frontmatter = parse_short_memory_frontmatter(content)
260
+ if parse_positive_int(frontmatter.get("memory_schema", "")) != 2:
261
+ raise StateError("Short-memory file must declare memory_schema: 2.")
262
+ source_task = frontmatter.get("source_task", "")
263
+ if source_task != task_id:
264
+ raise StateError(
265
+ f"Short-memory source_task {source_task or 'missing'} does not match current task {task_id}."
266
+ )
267
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
268
+ if expected_sha256 and digest != expected_sha256:
269
+ raise StateError("Short-memory file changed after its checkpoint was recorded.")
270
+ return resolved_memory_path, digest
271
+
272
+
273
+ def validate_recorded_short_memory(
274
+ root: Path,
275
+ task_id: str,
276
+ progress: dict,
277
+ allow_missing_after_distill: bool = False,
278
+ ) -> None:
279
+ if progress.get("legacy_short_memory_assumed") is True:
280
+ return
281
+ memory_file = progress.get("short_memory_file")
282
+ expected_sha256 = progress.get("short_memory_sha256")
283
+ if not isinstance(memory_file, str) or not memory_file.strip():
284
+ raise StateError("Recorded short-memory checkpoint is missing its file path.")
285
+ if not isinstance(expected_sha256, str) or not expected_sha256:
286
+ raise StateError("Recorded short-memory checkpoint is missing its content fingerprint.")
287
+ resolved_memory_path = resolve_short_memory_path(root, memory_file)
288
+ if allow_missing_after_distill and not resolved_memory_path.exists():
289
+ return
290
+ validate_short_memory_file(root, task_id, memory_file, expected_sha256)
291
+
292
+
293
+ def build_memory_instruction(
294
+ root: Path,
295
+ checkpoint_file: str | None = None,
296
+ legacy_checkpoint: bool = False,
297
+ ) -> dict:
163
298
  config = read_memory_config(root)
164
- short_count = count_short_memories(root)
299
+ entries = short_memory_entries(root)
300
+ short_count = len(entries)
165
301
  action = "distill" if short_count > config["short_term_max"] else "no-op"
166
302
  trim_count = max(0, short_count - config["short_term_keep"]) if action == "distill" else 0
303
+ candidate_files = [str(entry["display_path"]) for entry in entries[:trim_count]]
304
+ kept_files = [str(entry["display_path"]) for entry in entries[trim_count:]]
305
+ if legacy_checkpoint:
306
+ checkpoint_disposition = "legacy"
307
+ elif checkpoint_file in candidate_files:
308
+ checkpoint_disposition = "candidate"
309
+ elif checkpoint_file in kept_files:
310
+ checkpoint_disposition = "kept"
311
+ else:
312
+ raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
167
313
  return {
168
314
  "short_count": short_count,
169
315
  "short_term_max": config["short_term_max"],
170
316
  "short_term_keep": config["short_term_keep"],
171
317
  "action": action,
172
318
  "trim_count": trim_count,
319
+ "candidate_files": candidate_files,
320
+ "kept_files": kept_files,
321
+ "checkpoint_disposition": checkpoint_disposition,
173
322
  }
174
323
 
175
324
 
325
+ def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
326
+ candidate_files = instruction.get("candidate_files")
327
+ kept_files = instruction.get("kept_files")
328
+ if not isinstance(candidate_files, list) or not all(
329
+ isinstance(item, str) for item in candidate_files
330
+ ):
331
+ raise StateError("Memory instruction is missing its frozen candidate file set.")
332
+ if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
333
+ raise StateError("Memory instruction is missing its frozen kept file set.")
334
+ for memory_file in candidate_files:
335
+ if resolve_short_memory_path(root, memory_file).exists():
336
+ raise StateError(f"Distillation candidate was not consumed: {memory_file}")
337
+ for memory_file in kept_files:
338
+ if not resolve_short_memory_path(root, memory_file).is_file():
339
+ raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
340
+
341
+
342
+ def normalize_legacy_stage(stage: object) -> object:
343
+ return LEGACY_STAGE_MAP.get(str(stage), stage)
344
+
345
+
346
+ def normalize_legacy_task(task: dict) -> bool:
347
+ """Normalize pre-0.6 stage names without touching task artifacts outside task.json."""
348
+ legacy_status = str(task.get("status") or "")
349
+ changed = False
350
+
351
+ if legacy_status in LEGACY_STAGE_MAP:
352
+ task["status"] = LEGACY_STAGE_MAP[legacy_status]
353
+ changed = True
354
+
355
+ history = task.get("stage_history")
356
+ if isinstance(history, list):
357
+ normalized_history: list[dict] = []
358
+ for raw_entry in history:
359
+ if not isinstance(raw_entry, dict):
360
+ continue
361
+ entry = dict(raw_entry)
362
+ mapped_stage = normalize_legacy_stage(entry.get("stage"))
363
+ if mapped_stage != entry.get("stage"):
364
+ entry["stage"] = mapped_stage
365
+ changed = True
366
+ if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
367
+ changed = True
368
+ continue
369
+ normalized_history.append(entry)
370
+ if changed:
371
+ task["stage_history"] = normalized_history
372
+
373
+ if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
374
+ task["pending_transition"] = {
375
+ "from": "ANALYSIS",
376
+ "to": "IMPLEMENT",
377
+ "requested_at": now_iso(),
378
+ "requested_by": str(task.get("last_agent") or "legacy-migration"),
379
+ "reason": "migrated-from-WAITING_CONFIRM",
380
+ }
381
+ changed = True
382
+
383
+ if legacy_status == "MEMORY_LONG":
384
+ progress = task.get("memory_progress")
385
+ if not isinstance(progress, dict):
386
+ progress = {}
387
+ if progress.get("short_memory_written") is not True:
388
+ progress["short_memory_written"] = True
389
+ progress["legacy_short_memory_assumed"] = True
390
+ progress["updated_at"] = now_iso()
391
+ task["memory_progress"] = progress
392
+ changed = True
393
+ elif progress.get("legacy_short_memory_assumed") is not True:
394
+ progress["legacy_short_memory_assumed"] = True
395
+ progress["updated_at"] = now_iso()
396
+ task["memory_progress"] = progress
397
+ changed = True
398
+
399
+ return changed
400
+
401
+
176
402
  def write_json(path: Path, data: dict) -> None:
177
403
  path.parent.mkdir(parents=True, exist_ok=True)
178
404
  path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -254,6 +480,378 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
254
480
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
255
481
 
256
482
 
483
+ def is_non_empty_string(value: object) -> bool:
484
+ return isinstance(value, str) and bool(value.strip())
485
+
486
+
487
+ def is_string_list(value: object, allow_empty: bool = True) -> bool:
488
+ return (
489
+ isinstance(value, list)
490
+ and (allow_empty or len(value) > 0)
491
+ and all(is_non_empty_string(item) for item in value)
492
+ )
493
+
494
+
495
+ def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
496
+ remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
497
+ resolved: set[str] = set()
498
+ while remaining:
499
+ ready = {
500
+ unit_id for unit_id, dependencies in remaining.items() if dependencies.issubset(resolved)
501
+ }
502
+ if not ready:
503
+ return False
504
+ resolved.update(ready)
505
+ for unit_id in ready:
506
+ remaining.pop(unit_id)
507
+ return True
508
+
509
+
510
+ def is_valid_execution_plan(plan: object, allow_empty_files: bool = False) -> bool:
511
+ if not isinstance(plan, dict):
512
+ return False
513
+ strategy = plan.get("strategy")
514
+ units = plan.get("units")
515
+ if strategy not in {"single", "sequential", "parallel"} or not isinstance(units, list):
516
+ return False
517
+ if not units or (strategy == "single" and len(units) != 1):
518
+ return False
519
+ if strategy == "parallel" and len(units) < 2:
520
+ return False
521
+
522
+ unit_ids: list[str] = []
523
+ has_empty_file_scope = False
524
+ for unit in units:
525
+ if not isinstance(unit, dict):
526
+ return False
527
+ if not all(is_non_empty_string(unit.get(field)) for field in ("id", "title", "type")):
528
+ return False
529
+ if not is_string_list(unit.get("files"), allow_empty=allow_empty_files):
530
+ return False
531
+ if not unit["files"]:
532
+ has_empty_file_scope = True
533
+ if not is_string_list(unit.get("depends_on")):
534
+ return False
535
+ for optional_list in ("rules_sections", "abstract_modules"):
536
+ if optional_list in unit and not is_string_list(unit.get(optional_list)):
537
+ return False
538
+ unit_ids.append(str(unit["id"]))
539
+
540
+ if has_empty_file_scope and (not allow_empty_files or strategy != "single" or len(units) != 1):
541
+ return False
542
+
543
+ if len(set(unit_ids)) != len(unit_ids):
544
+ return False
545
+ known_ids = set(unit_ids)
546
+ dependencies_by_unit: dict[str, set[str]] = {}
547
+ for unit in units:
548
+ unit_id = str(unit["id"])
549
+ dependencies = set(unit["depends_on"])
550
+ if unit_id in dependencies or not dependencies.issubset(known_ids):
551
+ return False
552
+ dependencies_by_unit[unit_id] = dependencies
553
+ if not has_acyclic_dependencies(dependencies_by_unit):
554
+ return False
555
+
556
+ if strategy == "parallel":
557
+ parallel_groups = plan.get("parallel_groups")
558
+ if not isinstance(parallel_groups, list) or not parallel_groups:
559
+ return False
560
+ grouped_ids: list[str] = []
561
+ group_levels: set[int] = set()
562
+ level_by_unit: dict[str, int] = {}
563
+ for group in parallel_groups:
564
+ level = group.get("level") if isinstance(group, dict) else None
565
+ if (
566
+ not isinstance(group, dict)
567
+ or type(level) is not int
568
+ or level < 0
569
+ or level in group_levels
570
+ or not is_string_list(group.get("units"), allow_empty=False)
571
+ ):
572
+ return False
573
+ group_levels.add(level)
574
+ grouped_ids.extend(group["units"])
575
+ for unit_id in group["units"]:
576
+ level_by_unit[unit_id] = level
577
+ if len(grouped_ids) != len(set(grouped_ids)) or set(grouped_ids) != known_ids:
578
+ return False
579
+ for unit_id, dependencies in dependencies_by_unit.items():
580
+ if any(level_by_unit[dependency] >= level_by_unit[unit_id] for dependency in dependencies):
581
+ return False
582
+
583
+ return True
584
+
585
+
586
+ def is_read_only_execution_plan(plan: object) -> bool:
587
+ return (
588
+ is_valid_execution_plan(plan, allow_empty_files=True)
589
+ and isinstance(plan, dict)
590
+ and plan.get("strategy") == "single"
591
+ and len(plan["units"]) == 1
592
+ and plan["units"][0].get("files") == []
593
+ )
594
+
595
+
596
+ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
597
+ path = execution_log_path(root, task_id)
598
+ if not path.exists():
599
+ return False
600
+ latest_plan: dict | None = None
601
+ try:
602
+ for line in path.read_text(encoding="utf-8").splitlines():
603
+ if not line.strip():
604
+ continue
605
+ try:
606
+ record = json.loads(line)
607
+ except json.JSONDecodeError:
608
+ return False
609
+ if isinstance(record, dict) and record.get("type") == "plan":
610
+ latest_plan = record
611
+ except OSError:
612
+ return False
613
+ task = load_task(root, task_id)
614
+ task_type = str(task.get("type") or "").strip().lower() if task else ""
615
+ if task_type in NO_CODE_TASK_TYPES:
616
+ return is_read_only_execution_plan(latest_plan)
617
+ return is_valid_execution_plan(latest_plan)
618
+
619
+
620
+ def validate_read_only_completion(root: Path, task_id: str) -> None:
621
+ task = load_task(root, task_id)
622
+ task_type = str(task.get("type") or "").strip().lower() if task else ""
623
+ reasons: list[str] = []
624
+ if task_type not in NO_CODE_TASK_TYPES:
625
+ reasons.append("task type is not doc, analysis, or report")
626
+
627
+ path = execution_log_path(root, task_id)
628
+ records: list[dict] = []
629
+ if not path.exists():
630
+ reasons.append("execution.jsonl is missing")
631
+ else:
632
+ try:
633
+ for line in path.read_text(encoding="utf-8").splitlines():
634
+ if not line.strip():
635
+ continue
636
+ record = json.loads(line)
637
+ if not isinstance(record, dict):
638
+ reasons.append("execution.jsonl contains a non-object record")
639
+ break
640
+ records.append(record)
641
+ except (OSError, json.JSONDecodeError):
642
+ reasons.append("execution.jsonl cannot be read as valid JSONL")
643
+
644
+ latest_plan_index: int | None = None
645
+ for index, record in enumerate(records):
646
+ if record.get("type") == "plan":
647
+ latest_plan_index = index
648
+
649
+ unit_id = ""
650
+ if latest_plan_index is None:
651
+ reasons.append("execution.jsonl has no plan record")
652
+ else:
653
+ plan = records[latest_plan_index]
654
+ if not is_read_only_execution_plan(plan):
655
+ reasons.append("latest plan record is invalid")
656
+ else:
657
+ units = plan["units"]
658
+ unit_id = str(units[0]["id"])
659
+
660
+ unit_records: list[dict] = []
661
+ if latest_plan_index is not None and unit_id:
662
+ for record in records[latest_plan_index + 1 :]:
663
+ if record.get("unit_id") == unit_id and record.get("type") in {"dispatch", "result"}:
664
+ unit_records.append(record)
665
+ latest_result = (
666
+ unit_records[-1]
667
+ if unit_records and unit_records[-1].get("type") == "result"
668
+ else None
669
+ )
670
+ if latest_result is None:
671
+ reasons.append("latest read-only unit has no result record")
672
+ else:
673
+ matching_dispatch = unit_records[-2] if len(unit_records) >= 2 else None
674
+ if matching_dispatch is None or matching_dispatch.get("type") != "dispatch":
675
+ reasons.append("latest read-only result has no matching dispatch record")
676
+ elif not is_non_empty_string(matching_dispatch.get("timestamp")):
677
+ reasons.append("latest read-only dispatch record has no timestamp")
678
+ if latest_result.get("changed_files") != []:
679
+ reasons.append("read-only result must contain changed_files:[]")
680
+ if not is_non_empty_string(latest_result.get("deliverable")):
681
+ reasons.append("read-only result must contain a non-empty deliverable")
682
+ if latest_result.get("issues") != []:
683
+ reasons.append("read-only result must contain issues:[]")
684
+ if latest_result.get("needs_attention") != []:
685
+ reasons.append("read-only result must contain needs_attention:[]")
686
+
687
+ if reasons:
688
+ raise StateError(
689
+ "Read-only IMPLEMENT cannot complete before its report is ready: " + "; ".join(reasons)
690
+ )
691
+
692
+
693
+ def markdown_headings(content: str) -> list[tuple[int, int, str]]:
694
+ headings: list[tuple[int, int, str]] = []
695
+ fence_marker: str | None = None
696
+ for index, line in enumerate(content.splitlines()):
697
+ stripped = line.lstrip()
698
+ if stripped.startswith(("```", "~~~")):
699
+ marker = stripped[:3]
700
+ if fence_marker is None:
701
+ fence_marker = marker
702
+ elif fence_marker == marker:
703
+ fence_marker = None
704
+ continue
705
+ if fence_marker is not None:
706
+ continue
707
+ match = MARKDOWN_HEADING_PATTERN.match(line.strip())
708
+ if match:
709
+ headings.append((index, len(match.group(1)), match.group(2).strip()))
710
+ return headings
711
+
712
+
713
+ def has_meaningful_markdown_body(content: str) -> bool:
714
+ for line in content.splitlines():
715
+ stripped = line.strip()
716
+ if not stripped or MARKDOWN_HEADING_PATTERN.match(stripped):
717
+ continue
718
+ if stripped.startswith(">"):
719
+ continue
720
+ if re.fullmatch(r"[-|: ]+", stripped):
721
+ continue
722
+ if stripped == "(若 single:单一实施单元,派发 1 个子代理执行)":
723
+ continue
724
+ if stripped.startswith("|") and stripped.endswith("|"):
725
+ cells = {cell.strip() for cell in stripped.strip("|").split("|") if cell.strip()}
726
+ if cells and cells.issubset(TABLE_HEADER_CELLS):
727
+ continue
728
+ plain = re.sub(r"[`*_]", "", stripped)
729
+ plain = re.sub(r"^[-+]\s*", "", plain).strip()
730
+ if re.fullmatch(r"[^::|]+[::]", plain):
731
+ continue
732
+ return True
733
+ return False
734
+
735
+
736
+ def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[str]]:
737
+ lines = content.splitlines()
738
+ headings = markdown_headings(content)
739
+ missing: list[str] = []
740
+ empty: list[str] = []
741
+
742
+ title_heading = next(
743
+ (
744
+ (line_index, level, title)
745
+ for line_index, level, title in headings
746
+ if level == 2 and (title == "技术方案" or title.startswith(("技术方案:", "技术方案:")))
747
+ ),
748
+ None,
749
+ )
750
+ if title_heading is None:
751
+ missing.append("## 技术方案")
752
+ else:
753
+ title = title_heading[2]
754
+ title_value = title.removeprefix("技术方案").lstrip("::").strip()
755
+ if not title_value:
756
+ empty.append("## 技术方案")
757
+
758
+ for header in MANDATORY_DEV_SPEC_HEADERS[1:]:
759
+ expected_title = header.removeprefix("### ")
760
+ heading_index = next(
761
+ (
762
+ index
763
+ for index, (_, level, title) in enumerate(headings)
764
+ if level == 3 and title == expected_title
765
+ ),
766
+ None,
767
+ )
768
+ if heading_index is None:
769
+ missing.append(header)
770
+ continue
771
+ line_index, level, _ = headings[heading_index]
772
+ next_line_index = len(lines)
773
+ for candidate_line, candidate_level, _ in headings[heading_index + 1 :]:
774
+ if candidate_level <= level:
775
+ next_line_index = candidate_line
776
+ break
777
+ body = "\n".join(lines[line_index + 1 : next_line_index])
778
+ if not has_meaningful_markdown_body(body):
779
+ empty.append(header)
780
+
781
+ return missing, empty
782
+
783
+
784
+ def validate_analysis_readiness(root: Path, task_id: str) -> None:
785
+ task_dir = task_json_path(root, task_id).parent
786
+ task = load_task(root, task_id)
787
+ task_type = str(task.get("type") or "").strip().lower() if task else ""
788
+ is_read_only_task = task_type in NO_CODE_TASK_TYPES
789
+ dev_spec = task_dir / "dev-spec.md"
790
+ skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
791
+ test_strategy = task_dir / "test-strategy.md"
792
+ reasons: list[str] = []
793
+
794
+ dev_spec_content = ""
795
+ if not dev_spec.exists():
796
+ reasons.append("dev-spec.md is missing")
797
+ else:
798
+ try:
799
+ dev_spec_content = dev_spec.read_text(encoding="utf-8")
800
+ if not dev_spec_content.strip():
801
+ reasons.append("dev-spec.md is empty")
802
+ except OSError:
803
+ reasons.append("dev-spec.md cannot be read")
804
+
805
+ if dev_spec_content:
806
+ missing_headers, empty_sections = validate_mandatory_dev_spec_sections(dev_spec_content)
807
+ if is_read_only_task:
808
+ empty_sections = [
809
+ header for header in empty_sections if header != "### 改动范围"
810
+ ]
811
+ if missing_headers:
812
+ reasons.append(
813
+ "dev-spec.md is missing mandatory headers: "
814
+ + ", ".join(header.lstrip("# ") for header in missing_headers)
815
+ )
816
+ if empty_sections:
817
+ reasons.append(
818
+ "dev-spec.md has empty mandatory sections: "
819
+ + ", ".join(header.lstrip("# ") for header in empty_sections)
820
+ )
821
+ if "[阶段:ANALYSIS]" in dev_spec_content or "### 待用户决策" in dev_spec_content:
822
+ reasons.append("dev-spec.md contains forbidden analysis-only sections")
823
+
824
+ if not skeleton.exists():
825
+ reasons.append("dev-spec skeleton template is missing")
826
+ else:
827
+ try:
828
+ skeleton_content = skeleton.read_text(encoding="utf-8")
829
+ if not DEV_SPEC_PLACEHOLDER_PATTERN.search(skeleton_content):
830
+ reasons.append("dev-spec skeleton template has no EC_TODO markers")
831
+ if DEV_SPEC_PLACEHOLDER_PATTERN.search(dev_spec_content):
832
+ reasons.append("dev-spec.md contains unresolved template placeholders")
833
+ except OSError:
834
+ reasons.append("dev-spec skeleton template cannot be read")
835
+
836
+ if not has_valid_execution_plan(root, task_id):
837
+ reasons.append("execution.jsonl has no valid plan record")
838
+ if is_read_only_task:
839
+ if test_strategy.exists():
840
+ reasons.append("read-only task must not create test-strategy.md")
841
+ else:
842
+ try:
843
+ if not test_strategy.exists() or not test_strategy.read_text(encoding="utf-8").strip():
844
+ reasons.append("test-strategy.md is missing or empty")
845
+ except OSError:
846
+ reasons.append("test-strategy.md cannot be read")
847
+
848
+ if reasons:
849
+ raise StateError(
850
+ "ANALYSIS cannot advance to IMPLEMENT before analysis artifacts are ready: "
851
+ + "; ".join(reasons)
852
+ )
853
+
854
+
257
855
  def latest_handoff_record(root: Path, task_id: str) -> dict | None:
258
856
  path = execution_log_path(root, task_id)
259
857
  if not path.exists():
@@ -292,10 +890,24 @@ def get_pending_init_version(root: Path) -> str | None:
292
890
  return None
293
891
 
294
892
 
295
- def validate_transition(previous: str, current: str) -> str | None:
893
+ def is_automatic_transition(previous: str, current: str, task_type: str = "") -> bool:
894
+ if (previous, current) in AUTO_TRANSITIONS:
895
+ return True
896
+ return (
897
+ (previous, current) == READ_ONLY_COMPLETION_TRANSITION
898
+ and task_type.strip().lower() in NO_CODE_TASK_TYPES
899
+ )
900
+
901
+
902
+ def validate_transition(previous: str, current: str, task_type: str = "") -> str | None:
296
903
  if previous == current:
297
904
  return None
298
- allowed = VALID_TRANSITIONS.get(previous, set())
905
+ normalized_task_type = task_type.strip().lower()
906
+ allowed = set(VALID_TRANSITIONS.get(previous, set()))
907
+ if previous == "IMPLEMENT" and normalized_task_type in NO_CODE_TASK_TYPES:
908
+ allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
909
+ elif previous == "IMPLEMENT":
910
+ allowed.discard("COMPLETE")
299
911
  if current in allowed:
300
912
  return None
301
913
  return (
@@ -335,6 +947,8 @@ def snapshot_state(
335
947
  "session_file": display_path(root, session_path),
336
948
  "current_task": str(task_id) if task_id else None,
337
949
  "task": task,
950
+ "pending_transition": task.get("pending_transition") if task else None,
951
+ "memory_progress": task.get("memory_progress") if task else None,
338
952
  "task_missing": missing,
339
953
  "status": status,
340
954
  "is_terminal": status in TERMINAL_STATUSES,
@@ -395,6 +1009,17 @@ def build_machine_breadcrumbs(
395
1009
  last_agent = state.get("last_agent")
396
1010
  if agent and last_agent and last_agent != agent:
397
1011
  lines.append(f"[easy-coding:handoff-from:{last_agent}]")
1012
+ pending = state.get("pending_transition")
1013
+ if isinstance(pending, dict):
1014
+ source = str(pending.get("from") or stage)
1015
+ target = str(pending.get("to") or "")
1016
+ if target:
1017
+ lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
1018
+ task_type = str(task.get("type") or "") if task else ""
1019
+ if is_automatic_transition(source, target, task_type):
1020
+ lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
1021
+ else:
1022
+ lines.append("[easy-coding:transition-confirmation-required]")
398
1023
 
399
1024
  if is_project_init_required(root):
400
1025
  lines.append("[easy-coding:init-required]")
@@ -644,15 +1269,11 @@ def append_stage_history(task: dict, stage: str, agent: str) -> None:
644
1269
  history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
645
1270
 
646
1271
 
647
- def transition_task(
1272
+ def resolve_current_task(
648
1273
  root: Path,
649
- stage: str,
650
- agent: str,
651
1274
  task_id: str | None = None,
652
1275
  session_file: str | Path | None = None,
653
- ) -> dict:
654
- if stage not in VALID_TRANSITIONS:
655
- raise StateError(f"Unknown stage: {stage}")
1276
+ ) -> tuple[dict, str, dict]:
656
1277
  session = ensure_session(root, session_file)
657
1278
  resolved_task_id = task_id or session.get("current_task")
658
1279
  if not resolved_task_id:
@@ -660,28 +1281,298 @@ def transition_task(
660
1281
  task = load_task(root, str(resolved_task_id))
661
1282
  if task is None:
662
1283
  raise StateError(f"Task not found: {resolved_task_id}")
1284
+ return session, str(resolved_task_id), task
1285
+
1286
+
1287
+ def request_transition(
1288
+ root: Path,
1289
+ stage: str,
1290
+ agent: str,
1291
+ task_id: str | None = None,
1292
+ session_file: str | Path | None = None,
1293
+ reason: str | None = None,
1294
+ ) -> dict:
1295
+ if stage not in VALID_TRANSITIONS:
1296
+ raise StateError(f"Unknown stage: {stage}")
1297
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1298
+ previous = str(task.get("status") or "idle")
1299
+ task_type = str(task.get("type") or "")
1300
+ if previous == stage:
1301
+ raise StateError(f"Transition target must differ from current stage: {stage}")
1302
+
1303
+ violation = validate_transition(previous, stage, task_type)
1304
+ if violation:
1305
+ raise StateError(violation)
1306
+ if is_automatic_transition(previous, stage, task_type):
1307
+ raise StateError(
1308
+ f"Transition {previous} -> {stage} is automatic; use auto-transition instead."
1309
+ )
1310
+ if previous == "ANALYSIS" and stage == "IMPLEMENT":
1311
+ validate_analysis_readiness(root, resolved_task_id)
1312
+ existing = task.get("pending_transition")
1313
+ if isinstance(existing, dict):
1314
+ if existing.get("from") != previous or existing.get("to") != stage:
1315
+ raise StateError(
1316
+ "A different transition is already pending. Cancel it before requesting another."
1317
+ )
1318
+ else:
1319
+ task["pending_transition"] = {
1320
+ "from": previous,
1321
+ "to": stage,
1322
+ "requested_at": now_iso(),
1323
+ "requested_by": agent,
1324
+ **({"reason": reason.strip()} if reason and reason.strip() else {}),
1325
+ }
1326
+ task["last_agent"] = agent
1327
+ write_task(root, resolved_task_id, task)
1328
+
1329
+ snapshot = snapshot_state(root, session_file, session)
1330
+ snapshot["action"] = "request-transition"
1331
+ return snapshot
1332
+
1333
+
1334
+ def apply_transition(
1335
+ root: Path,
1336
+ stage: str,
1337
+ agent: str,
1338
+ task_id: str | None = None,
1339
+ session_file: str | Path | None = None,
1340
+ ) -> dict:
1341
+ if stage not in VALID_TRANSITIONS:
1342
+ raise StateError(f"Unknown stage: {stage}")
1343
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
663
1344
 
664
1345
  previous = str(task.get("status") or "idle")
665
- violation = validate_transition(previous, stage)
1346
+ task_type = str(task.get("type") or "")
1347
+ violation = validate_transition(previous, stage, task_type)
666
1348
  if violation:
667
1349
  raise StateError(violation)
1350
+ if previous == "ANALYSIS" and stage == "IMPLEMENT":
1351
+ validate_analysis_readiness(root, resolved_task_id)
1352
+ if previous == "MEMORY" and stage == "COMPLETE":
1353
+ progress = task.get("memory_progress")
1354
+ if not isinstance(progress, dict) or progress.get("completed") is not True:
1355
+ raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
1356
+ if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
1357
+ validate_read_only_completion(root, resolved_task_id)
668
1358
  if previous != stage:
669
1359
  task["status"] = stage
670
1360
  append_stage_history(task, stage, agent)
1361
+ task.pop("pending_transition", None)
1362
+ if stage == "MEMORY" and previous != stage:
1363
+ task["memory_progress"] = {}
671
1364
  task["last_agent"] = agent
672
- write_task(root, str(resolved_task_id), task)
1365
+ write_task(root, resolved_task_id, task)
673
1366
 
674
1367
  if session.get("current_task") == resolved_task_id:
675
1368
  if stage in TERMINAL_STATUSES:
676
1369
  clear_session_pointer(session, agent)
677
1370
  else:
678
- session["last_seen_task"] = str(resolved_task_id)
1371
+ session["last_seen_task"] = resolved_task_id
679
1372
  session["last_seen_stage"] = stage
680
1373
  session["last_agent"] = agent
681
1374
  write_session(root, session, session_file)
1375
+ return snapshot_state(root, session_file, session)
1376
+
1377
+
1378
+ def auto_transition(
1379
+ root: Path,
1380
+ stage: str,
1381
+ agent: str,
1382
+ task_id: str | None = None,
1383
+ session_file: str | Path | None = None,
1384
+ ) -> dict:
1385
+ _, _, task = resolve_current_task(root, task_id, session_file)
1386
+ previous = str(task.get("status") or "idle")
1387
+ task_type = str(task.get("type") or "")
1388
+ if not is_automatic_transition(previous, stage, task_type):
1389
+ raise StateError(f"Automatic transition is not allowed: {previous} -> {stage}.")
1390
+
1391
+ pending = task.get("pending_transition")
1392
+ if isinstance(pending, dict) and (
1393
+ pending.get("from") != previous or pending.get("to") != stage
1394
+ ):
1395
+ raise StateError(
1396
+ "A different transition is already pending. Cancel it before automatic transition."
1397
+ )
1398
+
1399
+ snapshot = apply_transition(root, stage, agent, task_id, session_file)
1400
+ snapshot["action"] = "auto-transition"
1401
+ snapshot["automatic_transition"] = {"from": previous, "to": stage}
1402
+ return snapshot
1403
+
1404
+
1405
+ def confirm_transition(
1406
+ root: Path,
1407
+ agent: str,
1408
+ stage: str | None = None,
1409
+ task_id: str | None = None,
1410
+ session_file: str | Path | None = None,
1411
+ ) -> dict:
1412
+ _, _, task = resolve_current_task(root, task_id, session_file)
1413
+ pending = task.get("pending_transition")
1414
+ if not isinstance(pending, dict):
1415
+ raise StateError("No transition is pending user confirmation.")
1416
+ previous = str(task.get("status") or "idle")
1417
+ task_type = str(task.get("type") or "")
1418
+ source = str(pending.get("from") or "")
1419
+ target = str(pending.get("to") or "")
1420
+ if source != previous:
1421
+ raise StateError(
1422
+ f"Pending transition source {source or 'missing'} does not match current stage {previous}."
1423
+ )
1424
+ if stage and stage != target:
1425
+ raise StateError(f"Pending transition targets {target}, not {stage}.")
1426
+ if is_automatic_transition(source, target, task_type):
1427
+ raise StateError(
1428
+ f"Transition {source} -> {target} is automatic; use auto-transition instead."
1429
+ )
1430
+
1431
+ snapshot = apply_transition(root, target, agent, task_id, session_file)
1432
+ snapshot["action"] = "confirm-transition"
1433
+ snapshot["confirmed_transition"] = {"from": source, "to": target}
1434
+ return snapshot
1435
+
1436
+
1437
+ def cancel_transition(
1438
+ root: Path,
1439
+ agent: str,
1440
+ task_id: str | None = None,
1441
+ session_file: str | Path | None = None,
1442
+ ) -> dict:
1443
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1444
+ pending = task.pop("pending_transition", None)
1445
+ task["last_agent"] = agent
1446
+ write_task(root, resolved_task_id, task)
1447
+ snapshot = snapshot_state(root, session_file, session)
1448
+ snapshot["action"] = "cancel-transition"
1449
+ snapshot["cancelled_transition"] = pending
1450
+ return snapshot
1451
+
1452
+
1453
+ def memory_short_complete(
1454
+ root: Path,
1455
+ memory_file: str,
1456
+ agent: str,
1457
+ task_id: str | None = None,
1458
+ session_file: str | Path | None = None,
1459
+ ) -> dict:
1460
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1461
+ if task.get("status") != "MEMORY":
1462
+ raise StateError("Short-memory progress can only be recorded during MEMORY.")
1463
+ if not memory_file.strip():
1464
+ raise StateError("Short-memory file is required.")
1465
+ resolved_memory_path, digest = validate_short_memory_file(
1466
+ root, resolved_task_id, memory_file.strip()
1467
+ )
1468
+ progress = task.get("memory_progress")
1469
+ if not isinstance(progress, dict):
1470
+ progress = {}
1471
+ checkpoint_file = display_path(root, resolved_memory_path)
1472
+ if (
1473
+ progress.get("short_memory_written") is True
1474
+ and progress.get("legacy_short_memory_assumed") is not True
1475
+ ):
1476
+ if (
1477
+ progress.get("short_memory_file") == checkpoint_file
1478
+ and progress.get("short_memory_sha256") == digest
1479
+ ):
1480
+ snapshot = snapshot_state(root, session_file, session)
1481
+ snapshot["action"] = "memory-short-complete"
1482
+ snapshot["checkpoint_unchanged"] = True
1483
+ return snapshot
1484
+ raise StateError("A different short-memory checkpoint is already recorded for this task.")
1485
+ progress["short_memory_written"] = True
1486
+ progress["short_memory_file"] = checkpoint_file
1487
+ progress["short_memory_sha256"] = digest
1488
+ progress.pop("legacy_short_memory_assumed", None)
1489
+ progress["updated_at"] = now_iso()
1490
+ task["memory_progress"] = progress
1491
+ task["last_agent"] = agent
1492
+ write_task(root, resolved_task_id, task)
1493
+ snapshot = snapshot_state(root, session_file, session)
1494
+ snapshot["action"] = "memory-short-complete"
1495
+ return snapshot
1496
+
1497
+
1498
+ def memory_instruction(
1499
+ root: Path,
1500
+ task_id: str | None = None,
1501
+ session_file: str | Path | None = None,
1502
+ ) -> dict:
1503
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1504
+ if task.get("status") != "MEMORY":
1505
+ raise StateError("Memory instruction is only available during MEMORY.")
1506
+ progress = task.get("memory_progress")
1507
+ if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
1508
+ raise StateError("Write and record the short memory before requesting memory instruction.")
1509
+ instruction = progress.get("instruction")
1510
+ allow_missing_checkpoint = bool(
1511
+ isinstance(instruction, dict)
1512
+ and instruction.get("action") == "distill"
1513
+ and instruction.get("checkpoint_disposition") == "candidate"
1514
+ )
1515
+ validate_recorded_short_memory(
1516
+ root,
1517
+ resolved_task_id,
1518
+ progress,
1519
+ allow_missing_after_distill=allow_missing_checkpoint,
1520
+ )
1521
+ if not isinstance(instruction, dict):
1522
+ legacy_checkpoint = progress.get("legacy_short_memory_assumed") is True
1523
+ checkpoint_file = None if legacy_checkpoint else str(progress.get("short_memory_file"))
1524
+ instruction = build_memory_instruction(root, checkpoint_file, legacy_checkpoint)
1525
+ progress["instruction"] = instruction
1526
+ progress["updated_at"] = now_iso()
1527
+ task["memory_progress"] = progress
1528
+ write_task(root, resolved_task_id, task)
1529
+ snapshot = snapshot_state(root, session_file, session)
1530
+ snapshot["memory"] = instruction
1531
+ snapshot["action"] = "memory-instruction"
1532
+ return snapshot
1533
+
1534
+
1535
+ def memory_complete(
1536
+ root: Path,
1537
+ action: str,
1538
+ agent: str,
1539
+ task_id: str | None = None,
1540
+ session_file: str | Path | None = None,
1541
+ ) -> dict:
1542
+ if action not in {"no-op", "distill"}:
1543
+ raise StateError(f"Unknown memory action: {action}")
1544
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1545
+ if task.get("status") != "MEMORY":
1546
+ raise StateError("Memory completion can only be recorded during MEMORY.")
1547
+ progress = task.get("memory_progress")
1548
+ if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
1549
+ raise StateError("Short memory must be recorded before MEMORY can complete.")
1550
+ instruction = progress.get("instruction")
1551
+ if not isinstance(instruction, dict):
1552
+ raise StateError("Request the authoritative memory instruction before completing MEMORY.")
1553
+ if instruction["action"] != action:
1554
+ raise StateError(
1555
+ f"Memory action {action} does not match authoritative instruction {instruction['action']}."
1556
+ )
1557
+ validate_recorded_short_memory(
1558
+ root,
1559
+ resolved_task_id,
1560
+ progress,
1561
+ allow_missing_after_distill=(
1562
+ action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
1563
+ ),
1564
+ )
1565
+ if action == "distill":
1566
+ validate_distillation_file_sets(root, instruction)
1567
+ progress["long_memory_action"] = action
1568
+ progress["completed"] = True
1569
+ progress["updated_at"] = now_iso()
1570
+ task["memory_progress"] = progress
1571
+ task["last_agent"] = agent
1572
+ write_task(root, resolved_task_id, task)
682
1573
  snapshot = snapshot_state(root, session_file, session)
683
- if stage == "MEMORY_LONG":
684
- snapshot["memory_long"] = build_memory_long_instruction(root)
1574
+ snapshot["memory"] = instruction
1575
+ snapshot["action"] = "memory-complete"
685
1576
  return snapshot
686
1577
 
687
1578
 
@@ -701,6 +1592,7 @@ def close_current_task(
701
1592
  if task.get("status") != "CLOSED":
702
1593
  task["status"] = "CLOSED"
703
1594
  append_stage_history(task, "CLOSED", agent)
1595
+ task.pop("pending_transition", None)
704
1596
  task["closed_reason"] = reason
705
1597
  task["last_agent"] = agent
706
1598
  write_task(root, str(task_id), task)
@@ -757,7 +1649,9 @@ def record_seen_stage(
757
1649
 
758
1650
  violation = None
759
1651
  if last_seen_task == task_id and last_seen_stage:
760
- violation = validate_transition(str(last_seen_stage), stage)
1652
+ task = load_task(root, task_id)
1653
+ task_type = str(task.get("type") or "") if task else ""
1654
+ violation = validate_transition(str(last_seen_stage), stage, task_type)
761
1655
 
762
1656
  if last_seen_task != task_id or last_seen_stage != stage:
763
1657
  session["last_seen_task"] = task_id
@@ -818,11 +1712,45 @@ def main() -> int:
818
1712
  claim.add_argument("--task-id", required=True)
819
1713
  claim.add_argument("--agent", required=True)
820
1714
 
1715
+ request = subcommands.add_parser("request-transition", parents=[common])
1716
+ request.add_argument("--stage", required=True)
1717
+ request.add_argument("--agent", required=True)
1718
+ request.add_argument("--task-id")
1719
+ request.add_argument("--reason")
1720
+
1721
+ confirm_transition_parser = subcommands.add_parser("confirm-transition", parents=[common])
1722
+ confirm_transition_parser.add_argument("--stage")
1723
+ confirm_transition_parser.add_argument("--agent", required=True)
1724
+ confirm_transition_parser.add_argument("--task-id")
1725
+
1726
+ auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
1727
+ auto_transition_parser.add_argument("--stage", required=True)
1728
+ auto_transition_parser.add_argument("--agent", required=True)
1729
+ auto_transition_parser.add_argument("--task-id")
1730
+
1731
+ # Compatibility alias: pre-0.6 callers still consume the pending gate instead of bypassing it.
821
1732
  transition = subcommands.add_parser("transition", parents=[common])
822
- transition.add_argument("--stage", required=True)
1733
+ transition.add_argument("--stage")
823
1734
  transition.add_argument("--agent", required=True)
824
1735
  transition.add_argument("--task-id")
825
1736
 
1737
+ cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
1738
+ cancel_transition_parser.add_argument("--agent", required=True)
1739
+ cancel_transition_parser.add_argument("--task-id")
1740
+
1741
+ memory_short = subcommands.add_parser("memory-short-complete", parents=[common])
1742
+ memory_short.add_argument("--file", required=True)
1743
+ memory_short.add_argument("--agent", required=True)
1744
+ memory_short.add_argument("--task-id")
1745
+
1746
+ memory_instruction_parser = subcommands.add_parser("memory-instruction", parents=[common])
1747
+ memory_instruction_parser.add_argument("--task-id")
1748
+
1749
+ memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
1750
+ memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
1751
+ memory_complete_parser.add_argument("--agent", required=True)
1752
+ memory_complete_parser.add_argument("--task-id")
1753
+
826
1754
  close = subcommands.add_parser("close-current", parents=[common])
827
1755
  close.add_argument("--reason", required=True)
828
1756
  close.add_argument("--agent", required=True)
@@ -897,11 +1825,84 @@ def main() -> int:
897
1825
  session_file,
898
1826
  )
899
1827
  )
900
- elif command == "transition":
1828
+ elif command == "request-transition":
1829
+ emit(
1830
+ attach_status_context(
1831
+ root,
1832
+ request_transition(
1833
+ root,
1834
+ args.stage,
1835
+ args.agent,
1836
+ args.task_id,
1837
+ session_file,
1838
+ args.reason,
1839
+ ),
1840
+ args.agent,
1841
+ session_file,
1842
+ )
1843
+ )
1844
+ elif command in {"confirm-transition", "transition"}:
1845
+ emit(
1846
+ attach_status_context(
1847
+ root,
1848
+ confirm_transition(root, args.agent, args.stage, args.task_id, session_file),
1849
+ args.agent,
1850
+ session_file,
1851
+ )
1852
+ )
1853
+ elif command == "auto-transition":
1854
+ emit(
1855
+ attach_status_context(
1856
+ root,
1857
+ auto_transition(root, args.stage, args.agent, args.task_id, session_file),
1858
+ args.agent,
1859
+ session_file,
1860
+ )
1861
+ )
1862
+ elif command == "cancel-transition":
1863
+ emit(
1864
+ attach_status_context(
1865
+ root,
1866
+ cancel_transition(root, args.agent, args.task_id, session_file),
1867
+ args.agent,
1868
+ session_file,
1869
+ )
1870
+ )
1871
+ elif command == "memory-short-complete":
1872
+ emit(
1873
+ attach_status_context(
1874
+ root,
1875
+ memory_short_complete(
1876
+ root,
1877
+ args.file,
1878
+ args.agent,
1879
+ args.task_id,
1880
+ session_file,
1881
+ ),
1882
+ args.agent,
1883
+ session_file,
1884
+ )
1885
+ )
1886
+ elif command == "memory-instruction":
1887
+ emit(
1888
+ attach_status_context(
1889
+ root,
1890
+ memory_instruction(root, args.task_id, session_file),
1891
+ None,
1892
+ session_file,
1893
+ )
1894
+ )
1895
+ elif command == "memory-complete":
901
1896
  emit(
902
1897
  attach_status_context(
903
1898
  root,
904
- transition_task(root, args.stage, args.agent, args.task_id, session_file),
1899
+ memory_complete(
1900
+ root,
1901
+ args.action,
1902
+ args.agent,
1903
+ args.task_id,
1904
+ session_file,
1905
+ ),
905
1906
  args.agent,
906
1907
  session_file,
907
1908
  )