easy-coding-harness 0.6.0 → 0.7.0
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 +26 -0
- package/README.md +24 -8
- package/dist/cli.js +309 -168
- package/dist/cli.js.map +1 -1
- 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 +83 -48
- 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 +41 -13
- package/templates/common/skills/ec-memory/SKILL.md +11 -7
- package/templates/common/skills/ec-no-harness/SKILL.md +39 -0
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -5
- package/templates/common/skills/ec-task-management/SKILL.md +24 -2
- package/templates/common/skills/ec-verification/SKILL.md +23 -19
- package/templates/common/skills/ec-workflow/SKILL.md +98 -50
- package/templates/main-constraint/AGENTS.md.tpl +21 -14
- package/templates/main-constraint/CLAUDE.md.tpl +21 -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 +690 -16
- package/templates/shared-hooks/inject-subagent-context.py +6 -2
|
@@ -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,19 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
|
47
47
|
"CLOSED": set(),
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
ALWAYS_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
|
+
CONFIRM_MODES = {"approve", "guard", "auto"}
|
|
57
|
+
DEFAULT_CONFIRM_MODE = "guard"
|
|
58
|
+
GUARD_CONFIRM_TRANSITIONS = {
|
|
59
|
+
("ANALYSIS", "IMPLEMENT"),
|
|
60
|
+
("VERIFICATION", "MEMORY"),
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
LEGACY_STAGE_MAP = {
|
|
51
64
|
"WAITING_CONFIRM": "ANALYSIS",
|
|
52
65
|
"MEMORY_SHORT": "MEMORY",
|
|
@@ -55,6 +68,24 @@ LEGACY_STAGE_MAP = {
|
|
|
55
68
|
|
|
56
69
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
57
70
|
DEFAULT_SHORT_TERM_KEEP = 5
|
|
71
|
+
DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
|
|
72
|
+
MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
|
73
|
+
TABLE_HEADER_CELLS = {
|
|
74
|
+
"改动文件",
|
|
75
|
+
"改动类型",
|
|
76
|
+
"文件编码",
|
|
77
|
+
"改动核心内容",
|
|
78
|
+
"单元",
|
|
79
|
+
"说明",
|
|
80
|
+
"类型",
|
|
81
|
+
"涉及文件",
|
|
82
|
+
"依赖",
|
|
83
|
+
"测试点",
|
|
84
|
+
"级别",
|
|
85
|
+
"归属单元",
|
|
86
|
+
"方式",
|
|
87
|
+
"验证命令",
|
|
88
|
+
}
|
|
58
89
|
|
|
59
90
|
|
|
60
91
|
class StateError(Exception):
|
|
@@ -140,6 +171,51 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
140
171
|
return config
|
|
141
172
|
|
|
142
173
|
|
|
174
|
+
def read_project_confirm_mode(root: Path) -> str:
|
|
175
|
+
path = root / ".easy-coding" / "config.yaml"
|
|
176
|
+
try:
|
|
177
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
178
|
+
except OSError:
|
|
179
|
+
return DEFAULT_CONFIRM_MODE
|
|
180
|
+
|
|
181
|
+
in_behavior = False
|
|
182
|
+
behavior_indent = 0
|
|
183
|
+
for raw_line in lines:
|
|
184
|
+
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
185
|
+
stripped = without_comment.strip()
|
|
186
|
+
if not stripped:
|
|
187
|
+
continue
|
|
188
|
+
indent = len(without_comment) - len(without_comment.lstrip(" "))
|
|
189
|
+
if stripped == "behavior:":
|
|
190
|
+
in_behavior = True
|
|
191
|
+
behavior_indent = indent
|
|
192
|
+
continue
|
|
193
|
+
if in_behavior and indent <= behavior_indent:
|
|
194
|
+
in_behavior = False
|
|
195
|
+
if not in_behavior or ":" not in stripped:
|
|
196
|
+
continue
|
|
197
|
+
key, value = stripped.split(":", 1)
|
|
198
|
+
if key != "confirm_mode":
|
|
199
|
+
continue
|
|
200
|
+
mode = value.strip().strip("'\"")
|
|
201
|
+
if mode not in CONFIRM_MODES:
|
|
202
|
+
raise StateError(
|
|
203
|
+
"Invalid behavior.confirm_mode in .easy-coding/config.yaml: "
|
|
204
|
+
"expected approve, guard, or auto."
|
|
205
|
+
)
|
|
206
|
+
return mode
|
|
207
|
+
return DEFAULT_CONFIRM_MODE
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve_confirm_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
|
|
211
|
+
project_mode = read_project_confirm_mode(root)
|
|
212
|
+
session_mode = session.get("confirm_mode")
|
|
213
|
+
if session_mode is not None and session_mode not in CONFIRM_MODES:
|
|
214
|
+
raise StateError("Invalid session confirm_mode: expected approve, guard, or auto.")
|
|
215
|
+
effective_mode = str(session_mode or project_mode)
|
|
216
|
+
return project_mode, str(session_mode) if session_mode else None, effective_mode
|
|
217
|
+
|
|
218
|
+
|
|
143
219
|
def short_memory_entries(root: Path) -> list[dict[str, object]]:
|
|
144
220
|
short_dir = root / ".easy-coding" / "memory" / "short"
|
|
145
221
|
if not short_dir.is_dir():
|
|
@@ -455,6 +531,378 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
|
455
531
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
456
532
|
|
|
457
533
|
|
|
534
|
+
def is_non_empty_string(value: object) -> bool:
|
|
535
|
+
return isinstance(value, str) and bool(value.strip())
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def is_string_list(value: object, allow_empty: bool = True) -> bool:
|
|
539
|
+
return (
|
|
540
|
+
isinstance(value, list)
|
|
541
|
+
and (allow_empty or len(value) > 0)
|
|
542
|
+
and all(is_non_empty_string(item) for item in value)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
|
|
547
|
+
remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
|
|
548
|
+
resolved: set[str] = set()
|
|
549
|
+
while remaining:
|
|
550
|
+
ready = {
|
|
551
|
+
unit_id for unit_id, dependencies in remaining.items() if dependencies.issubset(resolved)
|
|
552
|
+
}
|
|
553
|
+
if not ready:
|
|
554
|
+
return False
|
|
555
|
+
resolved.update(ready)
|
|
556
|
+
for unit_id in ready:
|
|
557
|
+
remaining.pop(unit_id)
|
|
558
|
+
return True
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def is_valid_execution_plan(plan: object, allow_empty_files: bool = False) -> bool:
|
|
562
|
+
if not isinstance(plan, dict):
|
|
563
|
+
return False
|
|
564
|
+
strategy = plan.get("strategy")
|
|
565
|
+
units = plan.get("units")
|
|
566
|
+
if strategy not in {"single", "sequential", "parallel"} or not isinstance(units, list):
|
|
567
|
+
return False
|
|
568
|
+
if not units or (strategy == "single" and len(units) != 1):
|
|
569
|
+
return False
|
|
570
|
+
if strategy == "parallel" and len(units) < 2:
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
unit_ids: list[str] = []
|
|
574
|
+
has_empty_file_scope = False
|
|
575
|
+
for unit in units:
|
|
576
|
+
if not isinstance(unit, dict):
|
|
577
|
+
return False
|
|
578
|
+
if not all(is_non_empty_string(unit.get(field)) for field in ("id", "title", "type")):
|
|
579
|
+
return False
|
|
580
|
+
if not is_string_list(unit.get("files"), allow_empty=allow_empty_files):
|
|
581
|
+
return False
|
|
582
|
+
if not unit["files"]:
|
|
583
|
+
has_empty_file_scope = True
|
|
584
|
+
if not is_string_list(unit.get("depends_on")):
|
|
585
|
+
return False
|
|
586
|
+
for optional_list in ("rules_sections", "abstract_modules"):
|
|
587
|
+
if optional_list in unit and not is_string_list(unit.get(optional_list)):
|
|
588
|
+
return False
|
|
589
|
+
unit_ids.append(str(unit["id"]))
|
|
590
|
+
|
|
591
|
+
if has_empty_file_scope and (not allow_empty_files or strategy != "single" or len(units) != 1):
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
if len(set(unit_ids)) != len(unit_ids):
|
|
595
|
+
return False
|
|
596
|
+
known_ids = set(unit_ids)
|
|
597
|
+
dependencies_by_unit: dict[str, set[str]] = {}
|
|
598
|
+
for unit in units:
|
|
599
|
+
unit_id = str(unit["id"])
|
|
600
|
+
dependencies = set(unit["depends_on"])
|
|
601
|
+
if unit_id in dependencies or not dependencies.issubset(known_ids):
|
|
602
|
+
return False
|
|
603
|
+
dependencies_by_unit[unit_id] = dependencies
|
|
604
|
+
if not has_acyclic_dependencies(dependencies_by_unit):
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
if strategy == "parallel":
|
|
608
|
+
parallel_groups = plan.get("parallel_groups")
|
|
609
|
+
if not isinstance(parallel_groups, list) or not parallel_groups:
|
|
610
|
+
return False
|
|
611
|
+
grouped_ids: list[str] = []
|
|
612
|
+
group_levels: set[int] = set()
|
|
613
|
+
level_by_unit: dict[str, int] = {}
|
|
614
|
+
for group in parallel_groups:
|
|
615
|
+
level = group.get("level") if isinstance(group, dict) else None
|
|
616
|
+
if (
|
|
617
|
+
not isinstance(group, dict)
|
|
618
|
+
or type(level) is not int
|
|
619
|
+
or level < 0
|
|
620
|
+
or level in group_levels
|
|
621
|
+
or not is_string_list(group.get("units"), allow_empty=False)
|
|
622
|
+
):
|
|
623
|
+
return False
|
|
624
|
+
group_levels.add(level)
|
|
625
|
+
grouped_ids.extend(group["units"])
|
|
626
|
+
for unit_id in group["units"]:
|
|
627
|
+
level_by_unit[unit_id] = level
|
|
628
|
+
if len(grouped_ids) != len(set(grouped_ids)) or set(grouped_ids) != known_ids:
|
|
629
|
+
return False
|
|
630
|
+
for unit_id, dependencies in dependencies_by_unit.items():
|
|
631
|
+
if any(level_by_unit[dependency] >= level_by_unit[unit_id] for dependency in dependencies):
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
return True
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def is_read_only_execution_plan(plan: object) -> bool:
|
|
638
|
+
return (
|
|
639
|
+
is_valid_execution_plan(plan, allow_empty_files=True)
|
|
640
|
+
and isinstance(plan, dict)
|
|
641
|
+
and plan.get("strategy") == "single"
|
|
642
|
+
and len(plan["units"]) == 1
|
|
643
|
+
and plan["units"][0].get("files") == []
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
648
|
+
path = execution_log_path(root, task_id)
|
|
649
|
+
if not path.exists():
|
|
650
|
+
return False
|
|
651
|
+
latest_plan: dict | None = None
|
|
652
|
+
try:
|
|
653
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
654
|
+
if not line.strip():
|
|
655
|
+
continue
|
|
656
|
+
try:
|
|
657
|
+
record = json.loads(line)
|
|
658
|
+
except json.JSONDecodeError:
|
|
659
|
+
return False
|
|
660
|
+
if isinstance(record, dict) and record.get("type") == "plan":
|
|
661
|
+
latest_plan = record
|
|
662
|
+
except OSError:
|
|
663
|
+
return False
|
|
664
|
+
task = load_task(root, task_id)
|
|
665
|
+
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
666
|
+
if task_type in NO_CODE_TASK_TYPES:
|
|
667
|
+
return is_read_only_execution_plan(latest_plan)
|
|
668
|
+
return is_valid_execution_plan(latest_plan)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def validate_read_only_completion(root: Path, task_id: str) -> None:
|
|
672
|
+
task = load_task(root, task_id)
|
|
673
|
+
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
674
|
+
reasons: list[str] = []
|
|
675
|
+
if task_type not in NO_CODE_TASK_TYPES:
|
|
676
|
+
reasons.append("task type is not doc, analysis, or report")
|
|
677
|
+
|
|
678
|
+
path = execution_log_path(root, task_id)
|
|
679
|
+
records: list[dict] = []
|
|
680
|
+
if not path.exists():
|
|
681
|
+
reasons.append("execution.jsonl is missing")
|
|
682
|
+
else:
|
|
683
|
+
try:
|
|
684
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
685
|
+
if not line.strip():
|
|
686
|
+
continue
|
|
687
|
+
record = json.loads(line)
|
|
688
|
+
if not isinstance(record, dict):
|
|
689
|
+
reasons.append("execution.jsonl contains a non-object record")
|
|
690
|
+
break
|
|
691
|
+
records.append(record)
|
|
692
|
+
except (OSError, json.JSONDecodeError):
|
|
693
|
+
reasons.append("execution.jsonl cannot be read as valid JSONL")
|
|
694
|
+
|
|
695
|
+
latest_plan_index: int | None = None
|
|
696
|
+
for index, record in enumerate(records):
|
|
697
|
+
if record.get("type") == "plan":
|
|
698
|
+
latest_plan_index = index
|
|
699
|
+
|
|
700
|
+
unit_id = ""
|
|
701
|
+
if latest_plan_index is None:
|
|
702
|
+
reasons.append("execution.jsonl has no plan record")
|
|
703
|
+
else:
|
|
704
|
+
plan = records[latest_plan_index]
|
|
705
|
+
if not is_read_only_execution_plan(plan):
|
|
706
|
+
reasons.append("latest plan record is invalid")
|
|
707
|
+
else:
|
|
708
|
+
units = plan["units"]
|
|
709
|
+
unit_id = str(units[0]["id"])
|
|
710
|
+
|
|
711
|
+
unit_records: list[dict] = []
|
|
712
|
+
if latest_plan_index is not None and unit_id:
|
|
713
|
+
for record in records[latest_plan_index + 1 :]:
|
|
714
|
+
if record.get("unit_id") == unit_id and record.get("type") in {"dispatch", "result"}:
|
|
715
|
+
unit_records.append(record)
|
|
716
|
+
latest_result = (
|
|
717
|
+
unit_records[-1]
|
|
718
|
+
if unit_records and unit_records[-1].get("type") == "result"
|
|
719
|
+
else None
|
|
720
|
+
)
|
|
721
|
+
if latest_result is None:
|
|
722
|
+
reasons.append("latest read-only unit has no result record")
|
|
723
|
+
else:
|
|
724
|
+
matching_dispatch = unit_records[-2] if len(unit_records) >= 2 else None
|
|
725
|
+
if matching_dispatch is None or matching_dispatch.get("type") != "dispatch":
|
|
726
|
+
reasons.append("latest read-only result has no matching dispatch record")
|
|
727
|
+
elif not is_non_empty_string(matching_dispatch.get("timestamp")):
|
|
728
|
+
reasons.append("latest read-only dispatch record has no timestamp")
|
|
729
|
+
if latest_result.get("changed_files") != []:
|
|
730
|
+
reasons.append("read-only result must contain changed_files:[]")
|
|
731
|
+
if not is_non_empty_string(latest_result.get("deliverable")):
|
|
732
|
+
reasons.append("read-only result must contain a non-empty deliverable")
|
|
733
|
+
if latest_result.get("issues") != []:
|
|
734
|
+
reasons.append("read-only result must contain issues:[]")
|
|
735
|
+
if latest_result.get("needs_attention") != []:
|
|
736
|
+
reasons.append("read-only result must contain needs_attention:[]")
|
|
737
|
+
|
|
738
|
+
if reasons:
|
|
739
|
+
raise StateError(
|
|
740
|
+
"Read-only IMPLEMENT cannot complete before its report is ready: " + "; ".join(reasons)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def markdown_headings(content: str) -> list[tuple[int, int, str]]:
|
|
745
|
+
headings: list[tuple[int, int, str]] = []
|
|
746
|
+
fence_marker: str | None = None
|
|
747
|
+
for index, line in enumerate(content.splitlines()):
|
|
748
|
+
stripped = line.lstrip()
|
|
749
|
+
if stripped.startswith(("```", "~~~")):
|
|
750
|
+
marker = stripped[:3]
|
|
751
|
+
if fence_marker is None:
|
|
752
|
+
fence_marker = marker
|
|
753
|
+
elif fence_marker == marker:
|
|
754
|
+
fence_marker = None
|
|
755
|
+
continue
|
|
756
|
+
if fence_marker is not None:
|
|
757
|
+
continue
|
|
758
|
+
match = MARKDOWN_HEADING_PATTERN.match(line.strip())
|
|
759
|
+
if match:
|
|
760
|
+
headings.append((index, len(match.group(1)), match.group(2).strip()))
|
|
761
|
+
return headings
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def has_meaningful_markdown_body(content: str) -> bool:
|
|
765
|
+
for line in content.splitlines():
|
|
766
|
+
stripped = line.strip()
|
|
767
|
+
if not stripped or MARKDOWN_HEADING_PATTERN.match(stripped):
|
|
768
|
+
continue
|
|
769
|
+
if stripped.startswith(">"):
|
|
770
|
+
continue
|
|
771
|
+
if re.fullmatch(r"[-|: ]+", stripped):
|
|
772
|
+
continue
|
|
773
|
+
if stripped == "(若 single:单一实施单元,派发 1 个子代理执行)":
|
|
774
|
+
continue
|
|
775
|
+
if stripped.startswith("|") and stripped.endswith("|"):
|
|
776
|
+
cells = {cell.strip() for cell in stripped.strip("|").split("|") if cell.strip()}
|
|
777
|
+
if cells and cells.issubset(TABLE_HEADER_CELLS):
|
|
778
|
+
continue
|
|
779
|
+
plain = re.sub(r"[`*_]", "", stripped)
|
|
780
|
+
plain = re.sub(r"^[-+]\s*", "", plain).strip()
|
|
781
|
+
if re.fullmatch(r"[^::|]+[::]", plain):
|
|
782
|
+
continue
|
|
783
|
+
return True
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[str]]:
|
|
788
|
+
lines = content.splitlines()
|
|
789
|
+
headings = markdown_headings(content)
|
|
790
|
+
missing: list[str] = []
|
|
791
|
+
empty: list[str] = []
|
|
792
|
+
|
|
793
|
+
title_heading = next(
|
|
794
|
+
(
|
|
795
|
+
(line_index, level, title)
|
|
796
|
+
for line_index, level, title in headings
|
|
797
|
+
if level == 2 and (title == "技术方案" or title.startswith(("技术方案:", "技术方案:")))
|
|
798
|
+
),
|
|
799
|
+
None,
|
|
800
|
+
)
|
|
801
|
+
if title_heading is None:
|
|
802
|
+
missing.append("## 技术方案")
|
|
803
|
+
else:
|
|
804
|
+
title = title_heading[2]
|
|
805
|
+
title_value = title.removeprefix("技术方案").lstrip("::").strip()
|
|
806
|
+
if not title_value:
|
|
807
|
+
empty.append("## 技术方案")
|
|
808
|
+
|
|
809
|
+
for header in MANDATORY_DEV_SPEC_HEADERS[1:]:
|
|
810
|
+
expected_title = header.removeprefix("### ")
|
|
811
|
+
heading_index = next(
|
|
812
|
+
(
|
|
813
|
+
index
|
|
814
|
+
for index, (_, level, title) in enumerate(headings)
|
|
815
|
+
if level == 3 and title == expected_title
|
|
816
|
+
),
|
|
817
|
+
None,
|
|
818
|
+
)
|
|
819
|
+
if heading_index is None:
|
|
820
|
+
missing.append(header)
|
|
821
|
+
continue
|
|
822
|
+
line_index, level, _ = headings[heading_index]
|
|
823
|
+
next_line_index = len(lines)
|
|
824
|
+
for candidate_line, candidate_level, _ in headings[heading_index + 1 :]:
|
|
825
|
+
if candidate_level <= level:
|
|
826
|
+
next_line_index = candidate_line
|
|
827
|
+
break
|
|
828
|
+
body = "\n".join(lines[line_index + 1 : next_line_index])
|
|
829
|
+
if not has_meaningful_markdown_body(body):
|
|
830
|
+
empty.append(header)
|
|
831
|
+
|
|
832
|
+
return missing, empty
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def validate_analysis_readiness(root: Path, task_id: str) -> None:
|
|
836
|
+
task_dir = task_json_path(root, task_id).parent
|
|
837
|
+
task = load_task(root, task_id)
|
|
838
|
+
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
839
|
+
is_read_only_task = task_type in NO_CODE_TASK_TYPES
|
|
840
|
+
dev_spec = task_dir / "dev-spec.md"
|
|
841
|
+
skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
|
|
842
|
+
test_strategy = task_dir / "test-strategy.md"
|
|
843
|
+
reasons: list[str] = []
|
|
844
|
+
|
|
845
|
+
dev_spec_content = ""
|
|
846
|
+
if not dev_spec.exists():
|
|
847
|
+
reasons.append("dev-spec.md is missing")
|
|
848
|
+
else:
|
|
849
|
+
try:
|
|
850
|
+
dev_spec_content = dev_spec.read_text(encoding="utf-8")
|
|
851
|
+
if not dev_spec_content.strip():
|
|
852
|
+
reasons.append("dev-spec.md is empty")
|
|
853
|
+
except OSError:
|
|
854
|
+
reasons.append("dev-spec.md cannot be read")
|
|
855
|
+
|
|
856
|
+
if dev_spec_content:
|
|
857
|
+
missing_headers, empty_sections = validate_mandatory_dev_spec_sections(dev_spec_content)
|
|
858
|
+
if is_read_only_task:
|
|
859
|
+
empty_sections = [
|
|
860
|
+
header for header in empty_sections if header != "### 改动范围"
|
|
861
|
+
]
|
|
862
|
+
if missing_headers:
|
|
863
|
+
reasons.append(
|
|
864
|
+
"dev-spec.md is missing mandatory headers: "
|
|
865
|
+
+ ", ".join(header.lstrip("# ") for header in missing_headers)
|
|
866
|
+
)
|
|
867
|
+
if empty_sections:
|
|
868
|
+
reasons.append(
|
|
869
|
+
"dev-spec.md has empty mandatory sections: "
|
|
870
|
+
+ ", ".join(header.lstrip("# ") for header in empty_sections)
|
|
871
|
+
)
|
|
872
|
+
if "[阶段:ANALYSIS]" in dev_spec_content or "### 待用户决策" in dev_spec_content:
|
|
873
|
+
reasons.append("dev-spec.md contains forbidden analysis-only sections")
|
|
874
|
+
|
|
875
|
+
if not skeleton.exists():
|
|
876
|
+
reasons.append("dev-spec skeleton template is missing")
|
|
877
|
+
else:
|
|
878
|
+
try:
|
|
879
|
+
skeleton_content = skeleton.read_text(encoding="utf-8")
|
|
880
|
+
if not DEV_SPEC_PLACEHOLDER_PATTERN.search(skeleton_content):
|
|
881
|
+
reasons.append("dev-spec skeleton template has no EC_TODO markers")
|
|
882
|
+
if DEV_SPEC_PLACEHOLDER_PATTERN.search(dev_spec_content):
|
|
883
|
+
reasons.append("dev-spec.md contains unresolved template placeholders")
|
|
884
|
+
except OSError:
|
|
885
|
+
reasons.append("dev-spec skeleton template cannot be read")
|
|
886
|
+
|
|
887
|
+
if not has_valid_execution_plan(root, task_id):
|
|
888
|
+
reasons.append("execution.jsonl has no valid plan record")
|
|
889
|
+
if is_read_only_task:
|
|
890
|
+
if test_strategy.exists():
|
|
891
|
+
reasons.append("read-only task must not create test-strategy.md")
|
|
892
|
+
else:
|
|
893
|
+
try:
|
|
894
|
+
if not test_strategy.exists() or not test_strategy.read_text(encoding="utf-8").strip():
|
|
895
|
+
reasons.append("test-strategy.md is missing or empty")
|
|
896
|
+
except OSError:
|
|
897
|
+
reasons.append("test-strategy.md cannot be read")
|
|
898
|
+
|
|
899
|
+
if reasons:
|
|
900
|
+
raise StateError(
|
|
901
|
+
"ANALYSIS cannot advance to IMPLEMENT before analysis artifacts are ready: "
|
|
902
|
+
+ "; ".join(reasons)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
458
906
|
def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
459
907
|
path = execution_log_path(root, task_id)
|
|
460
908
|
if not path.exists():
|
|
@@ -493,10 +941,43 @@ def get_pending_init_version(root: Path) -> str | None:
|
|
|
493
941
|
return None
|
|
494
942
|
|
|
495
943
|
|
|
496
|
-
def
|
|
944
|
+
def transition_requires_confirmation(
|
|
945
|
+
previous: str,
|
|
946
|
+
current: str,
|
|
947
|
+
task_type: str,
|
|
948
|
+
confirm_mode: str,
|
|
949
|
+
) -> bool:
|
|
950
|
+
if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
|
|
951
|
+
return False
|
|
952
|
+
if current == "CLOSED":
|
|
953
|
+
return True
|
|
954
|
+
if confirm_mode == "auto":
|
|
955
|
+
return False
|
|
956
|
+
if confirm_mode == "guard":
|
|
957
|
+
return (previous, current) in GUARD_CONFIRM_TRANSITIONS
|
|
958
|
+
if confirm_mode == "approve":
|
|
959
|
+
return True
|
|
960
|
+
raise StateError(f"Unknown confirm mode: {confirm_mode}")
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def is_automatic_transition(
|
|
964
|
+
previous: str,
|
|
965
|
+
current: str,
|
|
966
|
+
task_type: str,
|
|
967
|
+
confirm_mode: str,
|
|
968
|
+
) -> bool:
|
|
969
|
+
return not transition_requires_confirmation(previous, current, task_type, confirm_mode)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def validate_transition(previous: str, current: str, task_type: str = "") -> str | None:
|
|
497
973
|
if previous == current:
|
|
498
974
|
return None
|
|
499
|
-
|
|
975
|
+
normalized_task_type = task_type.strip().lower()
|
|
976
|
+
allowed = set(VALID_TRANSITIONS.get(previous, set()))
|
|
977
|
+
if previous == "IMPLEMENT" and normalized_task_type in NO_CODE_TASK_TYPES:
|
|
978
|
+
allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
|
|
979
|
+
elif previous == "IMPLEMENT":
|
|
980
|
+
allowed.discard("COMPLETE")
|
|
500
981
|
if current in allowed:
|
|
501
982
|
return None
|
|
502
983
|
return (
|
|
@@ -532,6 +1013,10 @@ def snapshot_state(
|
|
|
532
1013
|
missing = False
|
|
533
1014
|
status = "idle"
|
|
534
1015
|
|
|
1016
|
+
project_confirm_mode, session_confirm_mode, effective_confirm_mode = resolve_confirm_mode(
|
|
1017
|
+
root, resolved_session
|
|
1018
|
+
)
|
|
1019
|
+
|
|
535
1020
|
return {
|
|
536
1021
|
"session_file": display_path(root, session_path),
|
|
537
1022
|
"current_task": str(task_id) if task_id else None,
|
|
@@ -544,6 +1029,10 @@ def snapshot_state(
|
|
|
544
1029
|
"last_agent": task.get("last_agent") if task else None,
|
|
545
1030
|
"project_init_required": is_project_init_required(root),
|
|
546
1031
|
"pending_init_version": get_pending_init_version(root),
|
|
1032
|
+
"project_confirm_mode": project_confirm_mode,
|
|
1033
|
+
"session_confirm_mode": session_confirm_mode,
|
|
1034
|
+
"effective_confirm_mode": effective_confirm_mode,
|
|
1035
|
+
"harness_disabled": resolved_session.get("harness_disabled") is True,
|
|
547
1036
|
}
|
|
548
1037
|
|
|
549
1038
|
|
|
@@ -589,7 +1078,11 @@ def build_machine_breadcrumbs(
|
|
|
589
1078
|
task = state["task"]
|
|
590
1079
|
stage = str(state["status"]) if task else "idle"
|
|
591
1080
|
resolved_session_file = str(state["session_file"])
|
|
592
|
-
lines = [
|
|
1081
|
+
lines = [
|
|
1082
|
+
f"[workflow-state:{stage}]",
|
|
1083
|
+
f"[easy-coding:session-file:{resolved_session_file}]",
|
|
1084
|
+
f"[easy-coding:confirm-mode:{state['effective_confirm_mode']}]",
|
|
1085
|
+
]
|
|
593
1086
|
|
|
594
1087
|
if task_id:
|
|
595
1088
|
lines.append(f"[current-task:{task_id}]")
|
|
@@ -604,7 +1097,16 @@ def build_machine_breadcrumbs(
|
|
|
604
1097
|
target = str(pending.get("to") or "")
|
|
605
1098
|
if target:
|
|
606
1099
|
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
607
|
-
|
|
1100
|
+
task_type = str(task.get("type") or "") if task else ""
|
|
1101
|
+
if is_automatic_transition(
|
|
1102
|
+
source,
|
|
1103
|
+
target,
|
|
1104
|
+
task_type,
|
|
1105
|
+
str(state["effective_confirm_mode"]),
|
|
1106
|
+
):
|
|
1107
|
+
lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
|
|
1108
|
+
else:
|
|
1109
|
+
lines.append("[easy-coding:transition-confirmation-required]")
|
|
608
1110
|
|
|
609
1111
|
if is_project_init_required(root):
|
|
610
1112
|
lines.append("[easy-coding:init-required]")
|
|
@@ -648,6 +1150,14 @@ def build_status_context(
|
|
|
648
1150
|
agent: str | None = None,
|
|
649
1151
|
session_file: str | Path | None = None,
|
|
650
1152
|
) -> str:
|
|
1153
|
+
if session.get("harness_disabled") is True:
|
|
1154
|
+
session_path = resolve_session_path(root, session_file)
|
|
1155
|
+
return "\n".join(
|
|
1156
|
+
[
|
|
1157
|
+
"[easy-coding:no-harness]",
|
|
1158
|
+
f"[easy-coding:session-file:{display_path(root, session_path)}]",
|
|
1159
|
+
]
|
|
1160
|
+
)
|
|
651
1161
|
return "\n".join(
|
|
652
1162
|
[
|
|
653
1163
|
build_status_line(root, session, agent, session_file),
|
|
@@ -667,7 +1177,7 @@ def attach_status_context(
|
|
|
667
1177
|
if session is None:
|
|
668
1178
|
session = default_session()
|
|
669
1179
|
context = build_status_context(root, session, agent, resolved_session_file)
|
|
670
|
-
first_line = context.splitlines()[0] if context else ""
|
|
1180
|
+
first_line = context.splitlines()[0] if context.startswith("> ") else ""
|
|
671
1181
|
enriched = dict(data)
|
|
672
1182
|
enriched["status_line"] = first_line
|
|
673
1183
|
enriched["status_context"] = context
|
|
@@ -742,6 +1252,55 @@ def clear_current_task(root: Path, agent: str, session_file: str | Path | None =
|
|
|
742
1252
|
return snapshot_state(root, session_file, session)
|
|
743
1253
|
|
|
744
1254
|
|
|
1255
|
+
def set_session_confirm_mode(
|
|
1256
|
+
root: Path,
|
|
1257
|
+
mode: str,
|
|
1258
|
+
agent: str,
|
|
1259
|
+
session_file: str | Path | None = None,
|
|
1260
|
+
) -> dict:
|
|
1261
|
+
if mode not in CONFIRM_MODES:
|
|
1262
|
+
raise StateError("Invalid confirm mode: expected approve, guard, or auto.")
|
|
1263
|
+
session = ensure_session(root, session_file)
|
|
1264
|
+
session["confirm_mode"] = mode
|
|
1265
|
+
session["last_agent"] = agent
|
|
1266
|
+
write_session(root, session, session_file)
|
|
1267
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1268
|
+
snapshot["action"] = "set-confirm-mode"
|
|
1269
|
+
return snapshot
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def clear_session_confirm_mode(
|
|
1273
|
+
root: Path,
|
|
1274
|
+
agent: str,
|
|
1275
|
+
session_file: str | Path | None = None,
|
|
1276
|
+
) -> dict:
|
|
1277
|
+
session = ensure_session(root, session_file)
|
|
1278
|
+
session.pop("confirm_mode", None)
|
|
1279
|
+
session["last_agent"] = agent
|
|
1280
|
+
write_session(root, session, session_file)
|
|
1281
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1282
|
+
snapshot["action"] = "clear-confirm-mode"
|
|
1283
|
+
return snapshot
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def set_harness_disabled(
|
|
1287
|
+
root: Path,
|
|
1288
|
+
disabled: bool,
|
|
1289
|
+
agent: str,
|
|
1290
|
+
session_file: str | Path | None = None,
|
|
1291
|
+
) -> dict:
|
|
1292
|
+
session = ensure_session(root, session_file)
|
|
1293
|
+
if disabled:
|
|
1294
|
+
session["harness_disabled"] = True
|
|
1295
|
+
else:
|
|
1296
|
+
session.pop("harness_disabled", None)
|
|
1297
|
+
session["last_agent"] = agent
|
|
1298
|
+
write_session(root, session, session_file)
|
|
1299
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1300
|
+
snapshot["action"] = "disable-harness" if disabled else "enable-harness"
|
|
1301
|
+
return snapshot
|
|
1302
|
+
|
|
1303
|
+
|
|
745
1304
|
def handoff_task(
|
|
746
1305
|
root: Path,
|
|
747
1306
|
agent: str,
|
|
@@ -881,17 +1440,21 @@ def request_transition(
|
|
|
881
1440
|
raise StateError(f"Unknown stage: {stage}")
|
|
882
1441
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
883
1442
|
previous = str(task.get("status") or "idle")
|
|
1443
|
+
task_type = str(task.get("type") or "")
|
|
1444
|
+
confirm_mode = resolve_confirm_mode(root, session)[2]
|
|
884
1445
|
if previous == stage:
|
|
885
1446
|
raise StateError(f"Transition target must differ from current stage: {stage}")
|
|
886
1447
|
|
|
887
|
-
violation = validate_transition(previous, stage)
|
|
1448
|
+
violation = validate_transition(previous, stage, task_type)
|
|
888
1449
|
if violation:
|
|
889
1450
|
raise StateError(violation)
|
|
890
|
-
if previous
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1451
|
+
if is_automatic_transition(previous, stage, task_type, confirm_mode):
|
|
1452
|
+
raise StateError(
|
|
1453
|
+
f"Transition {previous} -> {stage} is automatic in {confirm_mode} mode; "
|
|
1454
|
+
"use auto-transition instead."
|
|
1455
|
+
)
|
|
1456
|
+
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
1457
|
+
validate_analysis_readiness(root, resolved_task_id)
|
|
895
1458
|
existing = task.get("pending_transition")
|
|
896
1459
|
if isinstance(existing, dict):
|
|
897
1460
|
if existing.get("from") != previous or existing.get("to") != stage:
|
|
@@ -926,9 +1489,18 @@ def apply_transition(
|
|
|
926
1489
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
927
1490
|
|
|
928
1491
|
previous = str(task.get("status") or "idle")
|
|
929
|
-
|
|
1492
|
+
task_type = str(task.get("type") or "")
|
|
1493
|
+
violation = validate_transition(previous, stage, task_type)
|
|
930
1494
|
if violation:
|
|
931
1495
|
raise StateError(violation)
|
|
1496
|
+
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
1497
|
+
validate_analysis_readiness(root, resolved_task_id)
|
|
1498
|
+
if previous == "MEMORY" and stage == "COMPLETE":
|
|
1499
|
+
progress = task.get("memory_progress")
|
|
1500
|
+
if not isinstance(progress, dict) or progress.get("completed") is not True:
|
|
1501
|
+
raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
|
|
1502
|
+
if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
|
|
1503
|
+
validate_read_only_completion(root, resolved_task_id)
|
|
932
1504
|
if previous != stage:
|
|
933
1505
|
task["status"] = stage
|
|
934
1506
|
append_stage_history(task, stage, agent)
|
|
@@ -949,6 +1521,36 @@ def apply_transition(
|
|
|
949
1521
|
return snapshot_state(root, session_file, session)
|
|
950
1522
|
|
|
951
1523
|
|
|
1524
|
+
def auto_transition(
|
|
1525
|
+
root: Path,
|
|
1526
|
+
stage: str,
|
|
1527
|
+
agent: str,
|
|
1528
|
+
task_id: str | None = None,
|
|
1529
|
+
session_file: str | Path | None = None,
|
|
1530
|
+
) -> dict:
|
|
1531
|
+
session, _, task = resolve_current_task(root, task_id, session_file)
|
|
1532
|
+
previous = str(task.get("status") or "idle")
|
|
1533
|
+
task_type = str(task.get("type") or "")
|
|
1534
|
+
confirm_mode = resolve_confirm_mode(root, session)[2]
|
|
1535
|
+
if not is_automatic_transition(previous, stage, task_type, confirm_mode):
|
|
1536
|
+
raise StateError(
|
|
1537
|
+
f"Automatic transition is not allowed in {confirm_mode} mode: {previous} -> {stage}."
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
pending = task.get("pending_transition")
|
|
1541
|
+
if isinstance(pending, dict) and (
|
|
1542
|
+
pending.get("from") != previous or pending.get("to") != stage
|
|
1543
|
+
):
|
|
1544
|
+
raise StateError(
|
|
1545
|
+
"A different transition is already pending. Cancel it before automatic transition."
|
|
1546
|
+
)
|
|
1547
|
+
|
|
1548
|
+
snapshot = apply_transition(root, stage, agent, task_id, session_file)
|
|
1549
|
+
snapshot["action"] = "auto-transition"
|
|
1550
|
+
snapshot["automatic_transition"] = {"from": previous, "to": stage}
|
|
1551
|
+
return snapshot
|
|
1552
|
+
|
|
1553
|
+
|
|
952
1554
|
def confirm_transition(
|
|
953
1555
|
root: Path,
|
|
954
1556
|
agent: str,
|
|
@@ -956,11 +1558,13 @@ def confirm_transition(
|
|
|
956
1558
|
task_id: str | None = None,
|
|
957
1559
|
session_file: str | Path | None = None,
|
|
958
1560
|
) -> dict:
|
|
959
|
-
|
|
1561
|
+
session, _, task = resolve_current_task(root, task_id, session_file)
|
|
960
1562
|
pending = task.get("pending_transition")
|
|
961
1563
|
if not isinstance(pending, dict):
|
|
962
1564
|
raise StateError("No transition is pending user confirmation.")
|
|
963
1565
|
previous = str(task.get("status") or "idle")
|
|
1566
|
+
task_type = str(task.get("type") or "")
|
|
1567
|
+
confirm_mode = resolve_confirm_mode(root, session)[2]
|
|
964
1568
|
source = str(pending.get("from") or "")
|
|
965
1569
|
target = str(pending.get("to") or "")
|
|
966
1570
|
if source != previous:
|
|
@@ -969,6 +1573,11 @@ def confirm_transition(
|
|
|
969
1573
|
)
|
|
970
1574
|
if stage and stage != target:
|
|
971
1575
|
raise StateError(f"Pending transition targets {target}, not {stage}.")
|
|
1576
|
+
if is_automatic_transition(source, target, task_type, confirm_mode):
|
|
1577
|
+
raise StateError(
|
|
1578
|
+
f"Transition {source} -> {target} is automatic in {confirm_mode} mode; "
|
|
1579
|
+
"use auto-transition instead."
|
|
1580
|
+
)
|
|
972
1581
|
|
|
973
1582
|
snapshot = apply_transition(root, target, agent, task_id, session_file)
|
|
974
1583
|
snapshot["action"] = "confirm-transition"
|
|
@@ -1191,7 +1800,9 @@ def record_seen_stage(
|
|
|
1191
1800
|
|
|
1192
1801
|
violation = None
|
|
1193
1802
|
if last_seen_task == task_id and last_seen_stage:
|
|
1194
|
-
|
|
1803
|
+
task = load_task(root, task_id)
|
|
1804
|
+
task_type = str(task.get("type") or "") if task else ""
|
|
1805
|
+
violation = validate_transition(str(last_seen_stage), stage, task_type)
|
|
1195
1806
|
|
|
1196
1807
|
if last_seen_task != task_id or last_seen_stage != stage:
|
|
1197
1808
|
session["last_seen_task"] = task_id
|
|
@@ -1243,6 +1854,19 @@ def main() -> int:
|
|
|
1243
1854
|
clear_current = subcommands.add_parser("clear-current", parents=[common])
|
|
1244
1855
|
clear_current.add_argument("--agent", required=True)
|
|
1245
1856
|
|
|
1857
|
+
set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
|
|
1858
|
+
set_confirm_mode_parser.add_argument("--mode", required=True, choices=sorted(CONFIRM_MODES))
|
|
1859
|
+
set_confirm_mode_parser.add_argument("--agent", required=True)
|
|
1860
|
+
|
|
1861
|
+
clear_confirm_mode_parser = subcommands.add_parser("clear-confirm-mode", parents=[common])
|
|
1862
|
+
clear_confirm_mode_parser.add_argument("--agent", required=True)
|
|
1863
|
+
|
|
1864
|
+
disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
|
|
1865
|
+
disable_harness_parser.add_argument("--agent", required=True)
|
|
1866
|
+
|
|
1867
|
+
enable_harness_parser = subcommands.add_parser("enable-harness", parents=[common])
|
|
1868
|
+
enable_harness_parser.add_argument("--agent", required=True)
|
|
1869
|
+
|
|
1246
1870
|
handoff = subcommands.add_parser("handoff-task", parents=[common])
|
|
1247
1871
|
handoff.add_argument("--agent", required=True)
|
|
1248
1872
|
handoff.add_argument("--summary", required=True)
|
|
@@ -1263,6 +1887,11 @@ def main() -> int:
|
|
|
1263
1887
|
confirm_transition_parser.add_argument("--agent", required=True)
|
|
1264
1888
|
confirm_transition_parser.add_argument("--task-id")
|
|
1265
1889
|
|
|
1890
|
+
auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
|
|
1891
|
+
auto_transition_parser.add_argument("--stage", required=True)
|
|
1892
|
+
auto_transition_parser.add_argument("--agent", required=True)
|
|
1893
|
+
auto_transition_parser.add_argument("--task-id")
|
|
1894
|
+
|
|
1266
1895
|
# Compatibility alias: pre-0.6 callers still consume the pending gate instead of bypassing it.
|
|
1267
1896
|
transition = subcommands.add_parser("transition", parents=[common])
|
|
1268
1897
|
transition.add_argument("--stage")
|
|
@@ -1342,6 +1971,42 @@ def main() -> int:
|
|
|
1342
1971
|
session_file,
|
|
1343
1972
|
)
|
|
1344
1973
|
)
|
|
1974
|
+
elif command == "set-confirm-mode":
|
|
1975
|
+
emit(
|
|
1976
|
+
attach_status_context(
|
|
1977
|
+
root,
|
|
1978
|
+
set_session_confirm_mode(root, args.mode, args.agent, session_file),
|
|
1979
|
+
args.agent,
|
|
1980
|
+
session_file,
|
|
1981
|
+
)
|
|
1982
|
+
)
|
|
1983
|
+
elif command == "clear-confirm-mode":
|
|
1984
|
+
emit(
|
|
1985
|
+
attach_status_context(
|
|
1986
|
+
root,
|
|
1987
|
+
clear_session_confirm_mode(root, args.agent, session_file),
|
|
1988
|
+
args.agent,
|
|
1989
|
+
session_file,
|
|
1990
|
+
)
|
|
1991
|
+
)
|
|
1992
|
+
elif command == "disable-harness":
|
|
1993
|
+
emit(
|
|
1994
|
+
attach_status_context(
|
|
1995
|
+
root,
|
|
1996
|
+
set_harness_disabled(root, True, args.agent, session_file),
|
|
1997
|
+
args.agent,
|
|
1998
|
+
session_file,
|
|
1999
|
+
)
|
|
2000
|
+
)
|
|
2001
|
+
elif command == "enable-harness":
|
|
2002
|
+
emit(
|
|
2003
|
+
attach_status_context(
|
|
2004
|
+
root,
|
|
2005
|
+
set_harness_disabled(root, False, args.agent, session_file),
|
|
2006
|
+
args.agent,
|
|
2007
|
+
session_file,
|
|
2008
|
+
)
|
|
2009
|
+
)
|
|
1345
2010
|
elif command == "handoff-task":
|
|
1346
2011
|
emit(
|
|
1347
2012
|
attach_status_context(
|
|
@@ -1385,6 +2050,15 @@ def main() -> int:
|
|
|
1385
2050
|
session_file,
|
|
1386
2051
|
)
|
|
1387
2052
|
)
|
|
2053
|
+
elif command == "auto-transition":
|
|
2054
|
+
emit(
|
|
2055
|
+
attach_status_context(
|
|
2056
|
+
root,
|
|
2057
|
+
auto_transition(root, args.stage, args.agent, args.task_id, session_file),
|
|
2058
|
+
args.agent,
|
|
2059
|
+
session_file,
|
|
2060
|
+
)
|
|
2061
|
+
)
|
|
1388
2062
|
elif command == "cancel-transition":
|
|
1389
2063
|
emit(
|
|
1390
2064
|
attach_status_context(
|