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.
@@ -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 validate_transition(previous: str, current: str) -> str | None:
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
- allowed = VALID_TRANSITIONS.get(previous, set())
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 = [f"[workflow-state:{stage}]", f"[easy-coding:session-file:{resolved_session_file}]"]
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
- lines.append("[easy-coding:transition-confirmation-required]")
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 == "MEMORY" and stage == "COMPLETE":
891
- progress = task.get("memory_progress")
892
- if not isinstance(progress, dict) or progress.get("completed") is not True:
893
- raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
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
- violation = validate_transition(previous, stage)
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
- _, _, task = resolve_current_task(root, task_id, session_file)
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
- violation = validate_transition(str(last_seen_stage), stage)
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(