agent-project-sdlc 0.1.22 → 0.1.24

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,27 @@ 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
+ ]
378
402
  RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS = {"external_provider_live", "deployed_runtime", "business_handoff_ready"}
379
403
  RESUME_CAPSULE_REQUIRED_TARGET_KINDS = {"cloud_vm", "managed_service", "browser", "worker"}
380
404
  RESUME_CAPSULE_FIELDS = [
@@ -441,6 +465,182 @@ def requires_resume_capsule(task: dict[str, Any]) -> bool:
441
465
  return required in RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS or kind in RESUME_CAPSULE_REQUIRED_TARGET_KINDS
442
466
 
443
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
+
444
644
  def self_test_contract_errors_for_task(task: dict[str, Any]) -> list[str]:
445
645
  task_id = str(task.get("id") or "Task")
446
646
  required_for_runnable = needs_runnable_task_contract(task)
@@ -483,24 +683,28 @@ def self_test_contract_errors_for_task(task: dict[str, Any]) -> list[str]:
483
683
  errors.append(f"{task_id} self_test_contract.required_gates must also appear in task required_gates: {gate}")
484
684
 
485
685
  scenarios = contract.get("scenarios")
686
+ scenario_ids: set[str] = set()
486
687
  if not isinstance(scenarios, list) or not scenarios:
487
688
  errors.append(f"{task_id} self_test_contract.scenarios must be a non-empty list")
488
- return errors
489
- seen: set[str] = set()
490
- for index, scenario in enumerate(scenarios):
491
- if not isinstance(scenario, dict):
492
- errors.append(f"{task_id} self_test_contract.scenarios[{index}] must be a mapping")
493
- continue
494
- scenario_id = str(scenario.get("id") or "").strip()
495
- if not scenario_id:
496
- errors.append(f"{task_id} self_test_contract.scenarios[{index}].id must be set")
497
- elif scenario_id in seen:
498
- errors.append(f"{task_id} self_test_contract scenario id must be unique: {scenario_id}")
499
- seen.add(scenario_id)
500
- for field in ["entry", "expected_exit", "evidence"]:
501
- value = str(scenario.get(field) or "").strip()
502
- if not value or is_placeholder_evidence(value):
503
- 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)))
504
708
  return errors
505
709
 
506
710
 
@@ -643,12 +847,178 @@ def load_lifecycle() -> dict[str, Any]:
643
847
  return data
644
848
 
645
849
 
646
- def load_phase_contracts() -> dict[str, Any]:
850
+ def load_phase_contract_data() -> dict[str, Any]:
647
851
  data = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.yaml")
648
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()
649
858
  return data["phases"]
650
859
 
651
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
+
652
1022
  def load_plan(path: str = ".codex/state/plan.yaml") -> dict[str, Any]:
653
1023
  data = load_yaml(path)
654
1024
  require(isinstance(data, dict), f"{path} must be a mapping")
@@ -731,7 +1101,7 @@ def validate_resume_capsule_contract(data: dict[str, Any]) -> None:
731
1101
  do_not_retry = as_string_list(capsule.get("do_not_retry"))
732
1102
  require(
733
1103
  do_not_retry and not any(is_placeholder_evidence(item) for item in do_not_retry),
734
- f"{current_task_id} resume_capsule.do_not_retry must list concrete paths or attempts not to repeat",
1104
+ f"{current_task_id} resume_capsule.do_not_retry must list concrete paths, attempts, or strategy-changing constraints not to repeat",
735
1105
  )
736
1106
 
737
1107
  refs = as_string_list(capsule.get("recovery_refs"))
@@ -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
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env python3
2
- from harness_utils import load_lifecycle, load_phase_contracts, load_yaml, repo_path, require, require_paths, run_main
2
+ from harness_utils import (
3
+ load_lifecycle,
4
+ load_phase_contract_data,
5
+ load_phase_contracts,
6
+ load_yaml,
7
+ phase_transition_contract_errors,
8
+ repo_path,
9
+ require,
10
+ require_paths,
11
+ run_main,
12
+ )
3
13
 
4
14
 
5
15
  def main() -> None:
@@ -37,6 +47,7 @@ def main() -> None:
37
47
  require_paths(required_files + required_dirs)
38
48
 
39
49
  lifecycle = load_lifecycle()
50
+ phase_contract_data = load_phase_contract_data()
40
51
  phases = load_phase_contracts()
41
52
  load_yaml(".codex/pjsdlc_managed/policies/gates.yaml")
42
53
  load_yaml(".codex/pjsdlc_managed/policies/allowed_paths.yaml")
@@ -44,6 +55,8 @@ def main() -> None:
44
55
 
45
56
  current_phase = lifecycle.get("current_phase")
46
57
  require(current_phase in phases, f"Lifecycle current_phase is not declared: {current_phase}")
58
+ for error in phase_transition_contract_errors(phase_contract_data, require_transitions=True):
59
+ require(False, error)
47
60
 
48
61
  for phase_name, contract in phases.items():
49
62
  skill = contract.get("skill")
@@ -68,7 +68,7 @@ YAML_KEYWORDS = {
68
68
  "next_task_sequence",
69
69
  "tasks",
70
70
  ],
71
- "phase_contracts": ["phases"],
71
+ "phase_contracts": ["phases", "transitions"],
72
72
  }
73
73
 
74
74
 
@@ -30,6 +30,11 @@ SELF_TEST_TRIGGER_TERMS = [
30
30
  "handoff",
31
31
  "blocker",
32
32
  "module key test path",
33
+ "module key test graph",
34
+ "module_key_test_graph",
35
+ "checkpoint",
36
+ "observable exit",
37
+ "evidence refs",
33
38
  "test route",
34
39
  "test path",
35
40
  "debug path",
package/dist/lib/init.js CHANGED
@@ -53,7 +53,7 @@ async function createProjectState(projectRoot, root, report) {
53
53
  const files = [
54
54
  [
55
55
  harnessPath(root, "state", "lifecycle.yaml"),
56
- `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "SPRINTING"\nactive_role: "developer"\nactive_skill: "pjsdlc_dev_sprint"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "REVIEWING"\n`
56
+ `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "SPRINTING"\nactive_role: "developer"\nactive_skill: "pjsdlc_dev_sprint"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "REVIEWING"\n - "RFC_RECALIBRATION"\n - "BLOCKED"\n`
57
57
  ],
58
58
  [harnessPath(root, "state", "plan.yaml"), `current_task_id: ""\nnext_task_sequence: 1\ntasks: []\n`],
59
59
  [harnessPath(root, "state", "plan.draft.yaml"), `next_task_sequence: 1\ntasks: []\n`],