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.
@@ -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 validate_transition(previous: str, current: str) -> str | None:
893
+ def is_automatic_transition(previous: str, current: str, task_type: str = "") -> bool:
894
+ if (previous, current) in AUTO_TRANSITIONS:
895
+ return True
896
+ return (
897
+ (previous, current) == READ_ONLY_COMPLETION_TRANSITION
898
+ and task_type.strip().lower() in NO_CODE_TASK_TYPES
899
+ )
900
+
901
+
902
+ def validate_transition(previous: str, current: str, task_type: str = "") -> str | None:
497
903
  if previous == current:
498
904
  return None
499
- allowed = VALID_TRANSITIONS.get(previous, set())
905
+ normalized_task_type = task_type.strip().lower()
906
+ allowed = set(VALID_TRANSITIONS.get(previous, set()))
907
+ if previous == "IMPLEMENT" and normalized_task_type in NO_CODE_TASK_TYPES:
908
+ allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
909
+ elif previous == "IMPLEMENT":
910
+ allowed.discard("COMPLETE")
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
- lines.append("[easy-coding:transition-confirmation-required]")
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 == "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
-
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
- violation = validate_transition(previous, stage)
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
- violation = validate_transition(str(last_seen_stage), stage)
1652
+ task = load_task(root, task_id)
1653
+ task_type = str(task.get("type") or "") if task else ""
1654
+ violation = validate_transition(str(last_seen_stage), stage, task_type)
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(