agent-project-sdlc 0.1.21 → 0.1.23

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.
@@ -55,6 +55,9 @@ TASK_PHASES = {
55
55
  "RELEASING",
56
56
  "RFC_RECALIBRATION",
57
57
  }
58
+ RESERVED_SUSPENDED_PHASE_TARGET = "<suspended_phase>"
59
+ TRANSITION_KINDS = {"normal", "return", "interrupt", "resume"}
60
+ LEGACY_RFC_INTERRUPT_SOURCES = {"SPRINTING", "REVIEWING", "TESTING", "RELEASING"}
58
61
 
59
62
 
60
63
  class HarnessError(RuntimeError):
@@ -375,6 +378,41 @@ CALLABLE_TASK_TERMS = [
375
378
  "队列",
376
379
  ]
377
380
  SELF_TEST_CONTRACT_STATUSES = {"required", "not_applicable"}
381
+ SELF_TEST_GRAPH_NODE_KINDS = {"entry", "checkpoint", "branch", "scenario", "observable_exit"}
382
+ SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT = 12
383
+ SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT = 25
384
+ SELF_TEST_GRAPH_EVIDENCE_BODY_TERMS = [
385
+ "```",
386
+ "|---",
387
+ "actual evidence",
388
+ "command transcript",
389
+ "full command output",
390
+ "stdout",
391
+ "stderr",
392
+ "traceback",
393
+ "debug log",
394
+ "operator log",
395
+ "evidence dump",
396
+ "screenshot process",
397
+ "截图过程",
398
+ "调试日志",
399
+ "操作日志",
400
+ "证据正文",
401
+ ]
402
+ RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS = {"external_provider_live", "deployed_runtime", "business_handoff_ready"}
403
+ RESUME_CAPSULE_REQUIRED_TARGET_KINDS = {"cloud_vm", "managed_service", "browser", "worker"}
404
+ RESUME_CAPSULE_FIELDS = [
405
+ "task_id",
406
+ "state",
407
+ "canonical_path",
408
+ "next_step",
409
+ "blocker",
410
+ "last_passed_gate",
411
+ "do_not_retry",
412
+ "recovery_refs",
413
+ ]
414
+ RUNBOOK_DOC_PREFIX = ".docs/09_runbooks/"
415
+ MAX_WORKING_NOTES = 8
378
416
 
379
417
 
380
418
  def as_string_list(value: Any) -> list[str]:
@@ -417,6 +455,192 @@ def needs_runnable_task_contract(task: dict[str, Any]) -> bool:
417
455
  return contains_any(context, APPLICATION_READINESS_TASK_TERMS + PAGE_TASK_TERMS + CALLABLE_TASK_TERMS)
418
456
 
419
457
 
458
+ def requires_resume_capsule(task: dict[str, Any]) -> bool:
459
+ if task.get("phase") != "SPRINTING":
460
+ return False
461
+ evidence_level = task.get("evidence_level")
462
+ target_runtime = task.get("target_runtime_environment")
463
+ required = str(evidence_level.get("required") or "") if isinstance(evidence_level, dict) else ""
464
+ kind = str(target_runtime.get("kind") or "") if isinstance(target_runtime, dict) else ""
465
+ return required in RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS or kind in RESUME_CAPSULE_REQUIRED_TARGET_KINDS
466
+
467
+
468
+ def self_test_graph_evidence_ref_is_body(value: str) -> bool:
469
+ stripped = value.strip()
470
+ lowered = stripped.lower()
471
+ return "\n" in stripped or len(stripped) > 180 or any(term in lowered for term in SELF_TEST_GRAPH_EVIDENCE_BODY_TERMS)
472
+
473
+
474
+ def graph_has_cycle(node_ids: set[str], adjacency: dict[str, list[str]]) -> bool:
475
+ visiting: set[str] = set()
476
+ visited: set[str] = set()
477
+
478
+ def visit(node_id: str) -> bool:
479
+ if node_id in visiting:
480
+ return True
481
+ if node_id in visited:
482
+ return False
483
+ visiting.add(node_id)
484
+ for target in adjacency.get(node_id, []):
485
+ if visit(target):
486
+ return True
487
+ visiting.remove(node_id)
488
+ visited.add(node_id)
489
+ return False
490
+
491
+ return any(visit(node_id) for node_id in node_ids if node_id not in visited)
492
+
493
+
494
+ def reachable_from(entry_id: str, adjacency: dict[str, list[str]]) -> set[str]:
495
+ reached: set[str] = set()
496
+ stack = [entry_id]
497
+ while stack:
498
+ node_id = stack.pop()
499
+ if node_id in reached:
500
+ continue
501
+ reached.add(node_id)
502
+ stack.extend(adjacency.get(node_id, []))
503
+ return reached
504
+
505
+
506
+ def nodes_that_can_reach_exits(node_ids: set[str], adjacency: dict[str, list[str]], exits: set[str]) -> set[str]:
507
+ reverse: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
508
+ for source, targets in adjacency.items():
509
+ for target in targets:
510
+ reverse.setdefault(target, []).append(source)
511
+ reached: set[str] = set()
512
+ stack = list(exits)
513
+ while stack:
514
+ node_id = stack.pop()
515
+ if node_id in reached:
516
+ continue
517
+ reached.add(node_id)
518
+ stack.extend(reverse.get(node_id, []))
519
+ return reached
520
+
521
+
522
+ def self_test_graph_errors_for_contract(
523
+ task_id: str,
524
+ contract: dict[str, Any],
525
+ scenario_ids: set[str],
526
+ high_risk_runtime: bool,
527
+ ) -> list[str]:
528
+ errors: list[str] = []
529
+ graph_required = contract.get("graph_required")
530
+ if graph_required is not None and not isinstance(graph_required, bool):
531
+ errors.append(f"{task_id} self_test_contract.graph_required must be a boolean when set")
532
+ graph = contract.get("module_key_test_graph")
533
+ if graph_required is True and not isinstance(graph, dict):
534
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph is required when graph_required is true")
535
+ if graph is None:
536
+ return errors
537
+ if not isinstance(graph, dict):
538
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph must be a mapping")
539
+ return errors
540
+
541
+ nodes = graph.get("nodes")
542
+ edges = graph.get("edges")
543
+ if not isinstance(nodes, list) or not nodes:
544
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.nodes must be a non-empty list")
545
+ return errors
546
+ if not isinstance(edges, list) or not edges:
547
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.edges must be a non-empty list")
548
+ return errors
549
+
550
+ node_limit = SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT if high_risk_runtime else SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT
551
+ if len(nodes) > node_limit:
552
+ errors.append(
553
+ f"{task_id} self_test_contract.module_key_test_graph has {len(nodes)} nodes; keep ordinary graphs <= {SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT} nodes and high-risk graphs <= {SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT} nodes"
554
+ )
555
+
556
+ node_ids: set[str] = set()
557
+ entry_ids: list[str] = []
558
+ observable_exit_ids: set[str] = set()
559
+ scenario_nodes_by_ref: dict[str, list[str]] = {}
560
+ adjacency: dict[str, list[str]] = {}
561
+
562
+ for index, node in enumerate(nodes):
563
+ if not isinstance(node, dict):
564
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.nodes[{index}] must be a mapping")
565
+ continue
566
+ node_id = str(node.get("id") or "").strip()
567
+ kind = str(node.get("kind") or "").strip()
568
+ label = str(node.get("label") or "").strip()
569
+ if not node_id:
570
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.nodes[{index}].id must be set")
571
+ continue
572
+ if node_id in node_ids:
573
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph node id must be unique: {node_id}")
574
+ node_ids.add(node_id)
575
+ adjacency.setdefault(node_id, [])
576
+ if kind not in SELF_TEST_GRAPH_NODE_KINDS:
577
+ errors.append(
578
+ f"{task_id} self_test_contract.module_key_test_graph.nodes[{node_id}].kind must be one of {', '.join(sorted(SELF_TEST_GRAPH_NODE_KINDS))}"
579
+ )
580
+ if not label or is_placeholder_evidence(label):
581
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.nodes[{node_id}].label must be concrete")
582
+ if kind == "entry":
583
+ entry_ids.append(node_id)
584
+ if kind == "observable_exit":
585
+ observable_exit_ids.add(node_id)
586
+ if kind == "scenario":
587
+ scenario_ref = str(node.get("scenario_ref") or "").strip()
588
+ if not scenario_ref:
589
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph scenario node {node_id} must set scenario_ref")
590
+ elif scenario_ref not in scenario_ids:
591
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph scenario node {node_id} references unknown scenario: {scenario_ref}")
592
+ else:
593
+ scenario_nodes_by_ref.setdefault(scenario_ref, []).append(node_id)
594
+ evidence_ref = node.get("evidence_ref")
595
+ if evidence_ref is not None:
596
+ evidence_ref_text = str(evidence_ref).strip()
597
+ if not evidence_ref_text or is_placeholder_evidence(evidence_ref_text) or self_test_graph_evidence_ref_is_body(evidence_ref_text):
598
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.nodes[{node_id}].evidence_ref must be a short evidence pointer, not evidence body")
599
+
600
+ if len(entry_ids) != 1:
601
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph must have exactly one entry node")
602
+ if not observable_exit_ids:
603
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph must have at least one observable_exit node")
604
+
605
+ for index, edge in enumerate(edges):
606
+ if not isinstance(edge, dict):
607
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph.edges[{index}] must be a mapping")
608
+ continue
609
+ source = str(edge.get("from") or "").strip()
610
+ target = str(edge.get("to") or "").strip()
611
+ if source not in node_ids:
612
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph edge references unknown from node: {source or '<empty>'}")
613
+ continue
614
+ if target not in node_ids:
615
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph edge references unknown to node: {target or '<empty>'}")
616
+ continue
617
+ adjacency.setdefault(source, []).append(target)
618
+
619
+ if graph_has_cycle(node_ids, adjacency):
620
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph must be a DAG; cycles are not allowed")
621
+
622
+ reached_from_entry: set[str] = set()
623
+ if len(entry_ids) == 1:
624
+ reached_from_entry = reachable_from(entry_ids[0], adjacency)
625
+ unreachable = sorted(node_ids - reached_from_entry)
626
+ if unreachable:
627
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph nodes must be reachable from entry: {', '.join(unreachable)}")
628
+ can_reach_exit = nodes_that_can_reach_exits(node_ids, adjacency, observable_exit_ids)
629
+
630
+ for scenario_id in sorted(scenario_ids):
631
+ scenario_node_ids = scenario_nodes_by_ref.get(scenario_id, [])
632
+ if not scenario_node_ids:
633
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph must include a scenario node for {scenario_id}")
634
+ continue
635
+ for node_id in scenario_node_ids:
636
+ if reached_from_entry and node_id not in reached_from_entry:
637
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph scenario {scenario_id} must be reachable from entry")
638
+ if node_id not in can_reach_exit:
639
+ errors.append(f"{task_id} self_test_contract.module_key_test_graph scenario {scenario_id} must reach an observable_exit")
640
+
641
+ return errors
642
+
643
+
420
644
  def self_test_contract_errors_for_task(task: dict[str, Any]) -> list[str]:
421
645
  task_id = str(task.get("id") or "Task")
422
646
  required_for_runnable = needs_runnable_task_contract(task)
@@ -459,24 +683,28 @@ def self_test_contract_errors_for_task(task: dict[str, Any]) -> list[str]:
459
683
  errors.append(f"{task_id} self_test_contract.required_gates must also appear in task required_gates: {gate}")
460
684
 
461
685
  scenarios = contract.get("scenarios")
686
+ scenario_ids: set[str] = set()
462
687
  if not isinstance(scenarios, list) or not scenarios:
463
688
  errors.append(f"{task_id} self_test_contract.scenarios must be a non-empty list")
464
- return errors
465
- seen: set[str] = set()
466
- for index, scenario in enumerate(scenarios):
467
- if not isinstance(scenario, dict):
468
- errors.append(f"{task_id} self_test_contract.scenarios[{index}] must be a mapping")
469
- continue
470
- scenario_id = str(scenario.get("id") or "").strip()
471
- if not scenario_id:
472
- errors.append(f"{task_id} self_test_contract.scenarios[{index}].id must be set")
473
- elif scenario_id in seen:
474
- errors.append(f"{task_id} self_test_contract scenario id must be unique: {scenario_id}")
475
- seen.add(scenario_id)
476
- for field in ["entry", "expected_exit", "evidence"]:
477
- value = str(scenario.get(field) or "").strip()
478
- if not value or is_placeholder_evidence(value):
479
- errors.append(f"{task_id} self_test_contract.scenarios[{scenario_id or index}].{field} must be concrete")
689
+ else:
690
+ seen: set[str] = set()
691
+ for index, scenario in enumerate(scenarios):
692
+ if not isinstance(scenario, dict):
693
+ errors.append(f"{task_id} self_test_contract.scenarios[{index}] must be a mapping")
694
+ continue
695
+ scenario_id = str(scenario.get("id") or "").strip()
696
+ if not scenario_id:
697
+ errors.append(f"{task_id} self_test_contract.scenarios[{index}].id must be set")
698
+ elif scenario_id in seen:
699
+ errors.append(f"{task_id} self_test_contract scenario id must be unique: {scenario_id}")
700
+ else:
701
+ scenario_ids.add(scenario_id)
702
+ seen.add(scenario_id)
703
+ for field in ["entry", "expected_exit", "evidence"]:
704
+ value = str(scenario.get(field) or "").strip()
705
+ if not value or is_placeholder_evidence(value):
706
+ errors.append(f"{task_id} self_test_contract.scenarios[{scenario_id or index}].{field} must be concrete")
707
+ errors.extend(self_test_graph_errors_for_contract(task_id, contract, scenario_ids, requires_resume_capsule(task)))
480
708
  return errors
481
709
 
482
710
 
@@ -619,12 +847,178 @@ def load_lifecycle() -> dict[str, Any]:
619
847
  return data
620
848
 
621
849
 
622
- def load_phase_contracts() -> dict[str, Any]:
850
+ def load_phase_contract_data() -> dict[str, Any]:
623
851
  data = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.yaml")
624
852
  require(isinstance(data, dict) and isinstance(data.get("phases"), dict), "phase_contracts.yaml must contain phases")
853
+ return data
854
+
855
+
856
+ def load_phase_contracts() -> dict[str, Any]:
857
+ data = load_phase_contract_data()
625
858
  return data["phases"]
626
859
 
627
860
 
861
+ def legacy_phase_transition_edges(phases: dict[str, Any]) -> list[dict[str, Any]]:
862
+ edges: list[dict[str, Any]] = []
863
+ for phase_name, contract in phases.items():
864
+ if not isinstance(contract, dict):
865
+ continue
866
+ next_phase = contract.get("next")
867
+ if next_phase:
868
+ edges.append({"from": str(phase_name), "to": str(next_phase), "trigger": "advance", "kind": "normal"})
869
+ for return_phase in contract.get("returns") or []:
870
+ if return_phase:
871
+ edges.append({"from": str(phase_name), "to": str(return_phase), "trigger": "return", "kind": "return"})
872
+
873
+ if "RFC_RECALIBRATION" in phases:
874
+ for phase_name in sorted(LEGACY_RFC_INTERRUPT_SOURCES & set(phases.keys())):
875
+ edges.append(
876
+ {
877
+ "from": phase_name,
878
+ "to": "RFC_RECALIBRATION",
879
+ "trigger": "requirement_change",
880
+ "kind": "interrupt",
881
+ "effects": {"set_suspended_phase": True},
882
+ }
883
+ )
884
+ if "BLOCKED" in phases:
885
+ for phase_name in phases:
886
+ if phase_name == "BLOCKED":
887
+ continue
888
+ edges.append(
889
+ {
890
+ "from": str(phase_name),
891
+ "to": "BLOCKED",
892
+ "trigger": "blocked",
893
+ "kind": "interrupt",
894
+ "effects": {"set_suspended_phase": True},
895
+ }
896
+ )
897
+ edges.append(
898
+ {
899
+ "from": "BLOCKED",
900
+ "to": RESERVED_SUSPENDED_PHASE_TARGET,
901
+ "trigger": "resume",
902
+ "kind": "resume",
903
+ "effects": {"clear_suspended_phase": True},
904
+ }
905
+ )
906
+ return edges
907
+
908
+
909
+ def phase_transition_edges(contract_data: dict[str, Any]) -> list[dict[str, Any]]:
910
+ phases = contract_data.get("phases")
911
+ require(isinstance(phases, dict), "phase_contracts.yaml must contain phases")
912
+ transitions = contract_data.get("transitions")
913
+ if isinstance(transitions, list):
914
+ return [edge for edge in transitions if isinstance(edge, dict)]
915
+ return legacy_phase_transition_edges(phases)
916
+
917
+
918
+ def resolve_phase_transition_target(edge: dict[str, Any], suspended_phase: str = "") -> str:
919
+ target = str(edge.get("to") or "")
920
+ if target == RESERVED_SUSPENDED_PHASE_TARGET:
921
+ return suspended_phase
922
+ return target
923
+
924
+
925
+ def phase_transition_targets(contract_data: dict[str, Any], phase_name: str, suspended_phase: str = "") -> list[str]:
926
+ targets: list[str] = []
927
+ for edge in phase_transition_edges(contract_data):
928
+ if str(edge.get("from") or "") != phase_name:
929
+ continue
930
+ target = resolve_phase_transition_target(edge, suspended_phase)
931
+ if target:
932
+ targets.append(target)
933
+ return list(dict.fromkeys(targets))
934
+
935
+
936
+ def find_phase_transition(
937
+ contract_data: dict[str, Any],
938
+ from_phase: str,
939
+ to_phase: str,
940
+ suspended_phase: str = "",
941
+ ) -> dict[str, Any] | None:
942
+ for edge in phase_transition_edges(contract_data):
943
+ if str(edge.get("from") or "") != from_phase:
944
+ continue
945
+ if resolve_phase_transition_target(edge, suspended_phase) == to_phase:
946
+ return edge
947
+ return None
948
+
949
+
950
+ def phase_transition_contract_errors(contract_data: dict[str, Any], require_transitions: bool = True) -> list[str]:
951
+ errors: list[str] = []
952
+ phases = contract_data.get("phases")
953
+ if not isinstance(phases, dict):
954
+ return ["phase_contracts.yaml must contain phases"]
955
+
956
+ for phase_name, contract in phases.items():
957
+ if not isinstance(contract, dict):
958
+ errors.append(f"{phase_name} phase contract must be a mapping")
959
+ continue
960
+ for legacy_key in ["next", "returns"]:
961
+ if legacy_key in contract:
962
+ errors.append(f"{phase_name} must not define legacy {legacy_key}; use top-level transitions")
963
+
964
+ transitions = contract_data.get("transitions")
965
+ if not isinstance(transitions, list):
966
+ if require_transitions:
967
+ errors.append("phase_contracts.yaml must contain top-level transitions")
968
+ return errors
969
+
970
+ phase_names = set(str(name) for name in phases.keys())
971
+ seen: set[tuple[str, str, str]] = set()
972
+ outgoing: set[str] = set()
973
+ for index, edge in enumerate(transitions, start=1):
974
+ prefix = f"transition #{index}"
975
+ if not isinstance(edge, dict):
976
+ errors.append(f"{prefix} must be a mapping")
977
+ continue
978
+ missing = [field for field in ["from", "to", "trigger", "kind"] if not str(edge.get(field) or "").strip()]
979
+ for field in missing:
980
+ errors.append(f"{prefix} missing {field}")
981
+ if missing:
982
+ continue
983
+
984
+ from_phase = str(edge["from"])
985
+ to_phase = str(edge["to"])
986
+ trigger = str(edge["trigger"])
987
+ kind = str(edge["kind"])
988
+ if from_phase not in phase_names:
989
+ errors.append(f"{prefix} from references unknown phase: {from_phase}")
990
+ if to_phase == RESERVED_SUSPENDED_PHASE_TARGET:
991
+ if from_phase != "BLOCKED" or kind != "resume":
992
+ errors.append(f"{prefix} may use {RESERVED_SUSPENDED_PHASE_TARGET} only for BLOCKED resume")
993
+ elif to_phase not in phase_names:
994
+ errors.append(f"{prefix} to references unknown phase: {to_phase}")
995
+ if kind not in TRANSITION_KINDS:
996
+ errors.append(f"{prefix} has invalid kind: {kind}")
997
+
998
+ key = (from_phase, to_phase, trigger)
999
+ if key in seen:
1000
+ errors.append(f"{prefix} duplicates transition {from_phase} -> {to_phase} ({trigger})")
1001
+ seen.add(key)
1002
+ outgoing.add(from_phase)
1003
+
1004
+ effects = edge.get("effects")
1005
+ if effects is None:
1006
+ continue
1007
+ if not isinstance(effects, dict):
1008
+ errors.append(f"{prefix} effects must be a mapping")
1009
+ continue
1010
+ for effect_name, effect_value in effects.items():
1011
+ if effect_name not in {"set_suspended_phase", "clear_suspended_phase"}:
1012
+ errors.append(f"{prefix} has unknown effect: {effect_name}")
1013
+ if not isinstance(effect_value, bool):
1014
+ errors.append(f"{prefix} effect {effect_name} must be boolean")
1015
+
1016
+ for phase_name in phase_names:
1017
+ if phase_name not in outgoing:
1018
+ errors.append(f"{phase_name} must have at least one outgoing transition")
1019
+ return errors
1020
+
1021
+
628
1022
  def load_plan(path: str = ".codex/state/plan.yaml") -> dict[str, Any]:
629
1023
  data = load_yaml(path)
630
1024
  require(isinstance(data, dict), f"{path} must be a mapping")
@@ -658,6 +1052,16 @@ def validate_task_shape(task: dict[str, Any], index: int) -> None:
658
1052
  require(isinstance(task["allowed_paths"], list) and task["allowed_paths"], f"{task['id']} must define allowed_paths")
659
1053
  require(isinstance(task["required_gates"], list) and task["required_gates"], f"{task['id']} must define required_gates")
660
1054
  require(isinstance(task["acceptance_criteria"], list) and task["acceptance_criteria"], f"{task['id']} must define acceptance_criteria")
1055
+ if "working_notes" in task:
1056
+ require(
1057
+ isinstance(task["working_notes"], (list, str)),
1058
+ f"{task['id']} working_notes must be a short string or list with at most {MAX_WORKING_NOTES} items",
1059
+ )
1060
+ note_count = len(task["working_notes"]) if isinstance(task["working_notes"], list) else (1 if str(task["working_notes"]).strip() else 0)
1061
+ require(
1062
+ note_count <= MAX_WORKING_NOTES,
1063
+ f"{task['id']} working_notes must stay resume-first and contain at most {MAX_WORKING_NOTES} items; found {note_count}",
1064
+ )
661
1065
  for error in self_test_contract_errors_for_task(task):
662
1066
  require(False, error)
663
1067
  for error in testing_boundary_errors_for_allowed_paths(task):
@@ -672,6 +1076,54 @@ def task_sequence_number(task_id: str) -> int:
672
1076
  return int(match.group(1)) if match else 0
673
1077
 
674
1078
 
1079
+ def validate_resume_capsule_contract(data: dict[str, Any]) -> None:
1080
+ current_task_id = str(data.get("current_task_id") or "")
1081
+ current_task = task_by_id(data, current_task_id) if current_task_id else None
1082
+ if not current_task or current_task.get("status") not in OPEN_TASK_STATUSES or current_task.get("phase") != "SPRINTING":
1083
+ require("resume_capsule" not in data, "plan.yaml resume_capsule must only be present for the current open SPRINTING task")
1084
+ return
1085
+
1086
+ capsule = data.get("resume_capsule")
1087
+ if not requires_resume_capsule(current_task):
1088
+ if capsule is not None:
1089
+ require(isinstance(capsule, dict), f"{current_task_id} resume_capsule must be a mapping when present")
1090
+ return
1091
+
1092
+ require(isinstance(capsule, dict), f"{current_task_id} high-risk runtime task must define top-level resume_capsule")
1093
+ for field in RESUME_CAPSULE_FIELDS:
1094
+ require(field in capsule, f"{current_task_id} resume_capsule missing field: {field}")
1095
+
1096
+ require(str(capsule.get("task_id") or "").strip() == current_task_id, f"{current_task_id} resume_capsule.task_id must match current_task_id")
1097
+ for field in ["state", "canonical_path", "next_step", "blocker", "last_passed_gate"]:
1098
+ value = str(capsule.get(field) or "").strip()
1099
+ require(value and not is_placeholder_evidence(value), f"{current_task_id} resume_capsule.{field} must contain concrete recovery information")
1100
+
1101
+ do_not_retry = as_string_list(capsule.get("do_not_retry"))
1102
+ require(
1103
+ do_not_retry and not any(is_placeholder_evidence(item) for item in do_not_retry),
1104
+ f"{current_task_id} resume_capsule.do_not_retry must list concrete paths, attempts, or strategy-changing constraints not to repeat",
1105
+ )
1106
+
1107
+ refs = as_string_list(capsule.get("recovery_refs"))
1108
+ require(refs, f"{current_task_id} resume_capsule.recovery_refs must link implementation doc and runbook/evidence documents")
1109
+ implementation_doc = str(current_task.get("implementation_doc") or "").strip()
1110
+ if implementation_doc:
1111
+ require(
1112
+ implementation_doc in refs,
1113
+ f"{current_task_id} resume_capsule.recovery_refs must include current implementation_doc {implementation_doc}",
1114
+ )
1115
+ require(
1116
+ any(ref.startswith(RUNBOOK_DOC_PREFIX) for ref in refs),
1117
+ f"{current_task_id} resume_capsule.recovery_refs must include a runbook/evidence document under {RUNBOOK_DOC_PREFIX}",
1118
+ )
1119
+ for ref in refs:
1120
+ require(
1121
+ ref.startswith(".docs/04_implementation/") or ref.startswith(RUNBOOK_DOC_PREFIX),
1122
+ f"{current_task_id} resume_capsule.recovery_refs may only point to implementation docs or runbook/evidence docs: {ref}",
1123
+ )
1124
+ require(repo_path(ref).exists(), f"{current_task_id} resume_capsule recovery_ref does not exist: {ref}")
1125
+
1126
+
675
1127
  def validate_plan_contract(data: dict[str, Any], allow_open: bool) -> None:
676
1128
  lifecycle = load_lifecycle()
677
1129
  current_phase = str(lifecycle.get("current_phase") or "")
@@ -695,6 +1147,7 @@ def validate_plan_contract(data: dict[str, Any], allow_open: bool) -> None:
695
1147
  current_task_id = data.get("current_task_id") or ""
696
1148
  if current_task_id:
697
1149
  require(task_by_id(data, current_task_id), f"current_task_id does not match a task: {current_task_id}")
1150
+ validate_resume_capsule_contract(data)
698
1151
 
699
1152
  open_tasks = [task.get("id") for task in tasks if task.get("status") in OPEN_TASK_STATUSES]
700
1153
  if not allow_open:
@@ -1,19 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- from harness_utils import dump_yaml, load_lifecycle, load_phase_contracts, make_arg_parser, require, run_main
3
-
4
-
5
- RFC_INTERRUPT_SOURCES = {"SPRINTING", "REVIEWING", "TESTING", "RELEASING"}
6
-
7
-
8
- def phase_targets(phase: dict) -> list[str]:
9
- targets: list[str] = []
10
- next_phase = phase.get("next")
11
- if next_phase:
12
- targets.append(str(next_phase))
13
- for return_phase in phase.get("returns") or []:
14
- if return_phase:
15
- targets.append(str(return_phase))
16
- return list(dict.fromkeys(targets))
2
+ from harness_utils import (
3
+ dump_yaml,
4
+ find_phase_transition,
5
+ load_lifecycle,
6
+ load_phase_contract_data,
7
+ make_arg_parser,
8
+ phase_transition_targets,
9
+ require,
10
+ run_main,
11
+ )
17
12
 
18
13
 
19
14
  def main() -> None:
@@ -24,29 +19,25 @@ def main() -> None:
24
19
  args = parser.parse_args()
25
20
 
26
21
  lifecycle = load_lifecycle()
27
- phases = load_phase_contracts()
22
+ contract_data = load_phase_contract_data()
23
+ phases = contract_data["phases"]
28
24
  target = args.to
29
25
  current = lifecycle.get("current_phase")
30
26
  require(target in phases, f"Unknown target phase: {target}")
31
27
  require(current in phases, f"Current phase is not declared in phase_contracts.yaml: {current}")
32
28
 
33
- legal = set(lifecycle.get("allowed_next_phases") or [])
34
- legal.update(phase_targets(phases[current]))
35
- if target == "RFC_RECALIBRATION" and current in RFC_INTERRUPT_SOURCES:
36
- legal.add(target)
37
- if target == "BLOCKED":
38
- legal.add(target)
39
29
  suspended = lifecycle.get("suspended_phase")
40
- if current == "BLOCKED" and suspended:
41
- legal.add(suspended)
30
+ legal = set(phase_transition_targets(contract_data, str(current), str(suspended or "")))
31
+ transition = find_phase_transition(contract_data, str(current), target, str(suspended or ""))
42
32
 
43
33
  require(args.force or target in legal, f"Illegal transition {current} -> {target}. Legal: {sorted(legal)}")
44
34
 
45
- if target in {"RFC_RECALIBRATION", "BLOCKED"} and current not in {"RFC_RECALIBRATION", "BLOCKED"}:
35
+ effects = transition.get("effects") if transition else {}
36
+ if not isinstance(effects, dict):
37
+ effects = {}
38
+ if effects.get("set_suspended_phase"):
46
39
  lifecycle["suspended_phase"] = current
47
- elif current == "RFC_RECALIBRATION" and target == "SPRINTING":
48
- lifecycle["suspended_phase"] = ""
49
- elif suspended and target == suspended:
40
+ if effects.get("clear_suspended_phase"):
50
41
  lifecycle["suspended_phase"] = ""
51
42
 
52
43
  phase = phases[target]
@@ -54,9 +45,11 @@ def main() -> None:
54
45
  lifecycle["active_role"] = phase.get("role", "")
55
46
  lifecycle["active_skill"] = phase.get("skill", "")
56
47
 
57
- lifecycle["allowed_next_phases"] = phase_targets(phase)
58
- if target == "BLOCKED" and lifecycle.get("suspended_phase"):
59
- lifecycle["allowed_next_phases"] = [lifecycle["suspended_phase"]]
48
+ lifecycle["allowed_next_phases"] = phase_transition_targets(
49
+ contract_data,
50
+ target,
51
+ str(lifecycle.get("suspended_phase") or ""),
52
+ )
60
53
 
61
54
  dump_yaml(lifecycle, ".codex/state/lifecycle.yaml")
62
55
  print(f"Transitioned {current} -> {target}")
@@ -129,6 +129,11 @@ def validate_self_test_contract_tech_plan_binding(task: dict, normalized_tech_re
129
129
  contains_any(section, ["module key test path", "模块关键测试路径"]),
130
130
  f"Draft task {task_id} tech plan Development Self-Test Contract must include Module key test path: {source}",
131
131
  )
132
+ if contract.get("graph_required") is True:
133
+ require(
134
+ contains_any(section, ["module key test graph", "module_key_test_graph", "模块关键测试图"]),
135
+ f"Draft task {task_id} tech plan Development Self-Test Contract must include Module Key Test Graph when graph_required is true: {source}",
136
+ )
132
137
  for scenario in contract.get("scenarios") or []:
133
138
  if not isinstance(scenario, dict):
134
139
  continue