easy-coding-harness 0.6.0 → 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.
- package/CHANGELOG.md +16 -0
- package/README.md +12 -8
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +3 -0
- package/templates/codex/agents/ec-implementer.toml +3 -0
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +7 -3
- package/templates/common/skills/ec-analysis/SKILL.md +80 -45
- package/templates/common/skills/ec-brainstorming/SKILL.md +1 -1
- package/templates/common/skills/ec-git/SKILL.md +1 -1
- package/templates/common/skills/ec-implementing/SKILL.md +40 -12
- package/templates/common/skills/ec-memory/SKILL.md +9 -5
- package/templates/common/skills/ec-reviewing/SKILL.md +4 -3
- package/templates/common/skills/ec-task-management/SKILL.md +1 -1
- package/templates/common/skills/ec-verification/SKILL.md +11 -7
- package/templates/common/skills/ec-workflow/SKILL.md +80 -38
- package/templates/main-constraint/AGENTS.md.tpl +18 -14
- package/templates/main-constraint/CLAUDE.md.tpl +18 -14
- package/templates/qoder/agents/ec-implementer.md +3 -0
- package/templates/runtime/templates/dev-spec-skeleton.md +41 -46
- package/templates/shared-hooks/easy_coding_state.py +487 -13
|
@@ -3,6 +3,7 @@ import argparse
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
import sys
|
|
@@ -26,7 +27,6 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
|
26
27
|
"### 需求解析",
|
|
27
28
|
"### 现状",
|
|
28
29
|
"### 冲突摘要",
|
|
29
|
-
"### 待用户决策",
|
|
30
30
|
"### 影响面分析",
|
|
31
31
|
"### 改动范围",
|
|
32
32
|
"### 修改方案",
|
|
@@ -39,7 +39,7 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
|
39
39
|
"idle": {"INIT"},
|
|
40
40
|
"INIT": {"ANALYSIS", "CLOSED"},
|
|
41
41
|
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
42
|
-
"IMPLEMENT": {"REVIEW", "ANALYSIS", "CLOSED"},
|
|
42
|
+
"IMPLEMENT": {"REVIEW", "VERIFICATION", "ANALYSIS", "COMPLETE", "CLOSED"},
|
|
43
43
|
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
44
44
|
"VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
|
|
45
45
|
"MEMORY": {"COMPLETE", "CLOSED"},
|
|
@@ -47,6 +47,13 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
|
47
47
|
"CLOSED": set(),
|
|
48
48
|
}
|
|
49
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
|
+
|
|
50
57
|
LEGACY_STAGE_MAP = {
|
|
51
58
|
"WAITING_CONFIRM": "ANALYSIS",
|
|
52
59
|
"MEMORY_SHORT": "MEMORY",
|
|
@@ -55,6 +62,24 @@ LEGACY_STAGE_MAP = {
|
|
|
55
62
|
|
|
56
63
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
57
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
|
+
}
|
|
58
83
|
|
|
59
84
|
|
|
60
85
|
class StateError(Exception):
|
|
@@ -455,6 +480,378 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
|
455
480
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
456
481
|
|
|
457
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
|
+
|
|
458
855
|
def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
459
856
|
path = execution_log_path(root, task_id)
|
|
460
857
|
if not path.exists():
|
|
@@ -493,10 +890,24 @@ def get_pending_init_version(root: Path) -> str | None:
|
|
|
493
890
|
return None
|
|
494
891
|
|
|
495
892
|
|
|
496
|
-
def
|
|
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:
|
|
497
903
|
if previous == current:
|
|
498
904
|
return None
|
|
499
|
-
|
|
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")
|
|
500
911
|
if current in allowed:
|
|
501
912
|
return None
|
|
502
913
|
return (
|
|
@@ -604,7 +1015,11 @@ def build_machine_breadcrumbs(
|
|
|
604
1015
|
target = str(pending.get("to") or "")
|
|
605
1016
|
if target:
|
|
606
1017
|
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
607
|
-
|
|
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]")
|
|
608
1023
|
|
|
609
1024
|
if is_project_init_required(root):
|
|
610
1025
|
lines.append("[easy-coding:init-required]")
|
|
@@ -881,17 +1296,19 @@ def request_transition(
|
|
|
881
1296
|
raise StateError(f"Unknown stage: {stage}")
|
|
882
1297
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
883
1298
|
previous = str(task.get("status") or "idle")
|
|
1299
|
+
task_type = str(task.get("type") or "")
|
|
884
1300
|
if previous == stage:
|
|
885
1301
|
raise StateError(f"Transition target must differ from current stage: {stage}")
|
|
886
1302
|
|
|
887
|
-
violation = validate_transition(previous, stage)
|
|
1303
|
+
violation = validate_transition(previous, stage, task_type)
|
|
888
1304
|
if violation:
|
|
889
1305
|
raise StateError(violation)
|
|
890
|
-
if previous
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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)
|
|
895
1312
|
existing = task.get("pending_transition")
|
|
896
1313
|
if isinstance(existing, dict):
|
|
897
1314
|
if existing.get("from") != previous or existing.get("to") != stage:
|
|
@@ -926,9 +1343,18 @@ def apply_transition(
|
|
|
926
1343
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
927
1344
|
|
|
928
1345
|
previous = str(task.get("status") or "idle")
|
|
929
|
-
|
|
1346
|
+
task_type = str(task.get("type") or "")
|
|
1347
|
+
violation = validate_transition(previous, stage, task_type)
|
|
930
1348
|
if violation:
|
|
931
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)
|
|
932
1358
|
if previous != stage:
|
|
933
1359
|
task["status"] = stage
|
|
934
1360
|
append_stage_history(task, stage, agent)
|
|
@@ -949,6 +1375,33 @@ def apply_transition(
|
|
|
949
1375
|
return snapshot_state(root, session_file, session)
|
|
950
1376
|
|
|
951
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
|
+
|
|
952
1405
|
def confirm_transition(
|
|
953
1406
|
root: Path,
|
|
954
1407
|
agent: str,
|
|
@@ -961,6 +1414,7 @@ def confirm_transition(
|
|
|
961
1414
|
if not isinstance(pending, dict):
|
|
962
1415
|
raise StateError("No transition is pending user confirmation.")
|
|
963
1416
|
previous = str(task.get("status") or "idle")
|
|
1417
|
+
task_type = str(task.get("type") or "")
|
|
964
1418
|
source = str(pending.get("from") or "")
|
|
965
1419
|
target = str(pending.get("to") or "")
|
|
966
1420
|
if source != previous:
|
|
@@ -969,6 +1423,10 @@ def confirm_transition(
|
|
|
969
1423
|
)
|
|
970
1424
|
if stage and stage != target:
|
|
971
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
|
+
)
|
|
972
1430
|
|
|
973
1431
|
snapshot = apply_transition(root, target, agent, task_id, session_file)
|
|
974
1432
|
snapshot["action"] = "confirm-transition"
|
|
@@ -1191,7 +1649,9 @@ def record_seen_stage(
|
|
|
1191
1649
|
|
|
1192
1650
|
violation = None
|
|
1193
1651
|
if last_seen_task == task_id and last_seen_stage:
|
|
1194
|
-
|
|
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)
|
|
1195
1655
|
|
|
1196
1656
|
if last_seen_task != task_id or last_seen_stage != stage:
|
|
1197
1657
|
session["last_seen_task"] = task_id
|
|
@@ -1263,6 +1723,11 @@ def main() -> int:
|
|
|
1263
1723
|
confirm_transition_parser.add_argument("--agent", required=True)
|
|
1264
1724
|
confirm_transition_parser.add_argument("--task-id")
|
|
1265
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
|
+
|
|
1266
1731
|
# Compatibility alias: pre-0.6 callers still consume the pending gate instead of bypassing it.
|
|
1267
1732
|
transition = subcommands.add_parser("transition", parents=[common])
|
|
1268
1733
|
transition.add_argument("--stage")
|
|
@@ -1385,6 +1850,15 @@ def main() -> int:
|
|
|
1385
1850
|
session_file,
|
|
1386
1851
|
)
|
|
1387
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
|
+
)
|
|
1388
1862
|
elif command == "cancel-transition":
|
|
1389
1863
|
emit(
|
|
1390
1864
|
attach_status_context(
|