agent-project-sdlc 0.1.22 → 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.
@@ -1,6 +1,6 @@
1
1
  # [Runtime / Live Smoke] Evidence Index
2
2
 
3
- 本文件只保存证据指针和缺口,不把证据正文塞回 implementation doc 主线。
3
+ 本文件只保存证据指针和缺口,不把证据正文塞回 implementation doc 主线或 `Development Self-Test Report`。
4
4
 
5
5
  | Scenario | Status | Evidence File / System | Gap / Next Action |
6
6
  |---|---|---|---|
@@ -11,6 +11,7 @@
11
11
  - Temporary evidence:
12
12
  - Stable artifact / CI / release record:
13
13
  - Evidence that must not be copied into main docs:
14
+ - Development Self-Test Report reference format:
14
15
 
15
16
  ## Missing Evidence
16
17
 
@@ -20,3 +20,5 @@
20
20
  | Decision | Promoted To |
21
21
  |---|---|
22
22
  | | `plan.yaml#resume_capsule` / runbook / implementation doc |
23
+
24
+ 凡会改变下一步动作的判断,必须 promoted 到 `plan.yaml#resume_capsule.do_not_retry` 或 runbook 顶部 `Hard Constraints`;本 appendix 只能保留推导和历史背景。
@@ -60,31 +60,37 @@ Input
60
60
  - Operator runbook: `.docs/09_runbooks/...`
61
61
  - Credential reference: Keychain item name 或 secret reference name only;不要记录明文密钥。
62
62
  - Command/UI channel:
63
+ - Hard Constraints: 会改变下一步动作的判断必须提升到这里和 `plan.yaml#resume_capsule.do_not_retry`;不要只埋在 evidence、notes 或 appendix。
63
64
  - Do-not-retry summary: fallback / diagnostic 只写一句结论,详细内容进 exploration appendix 或 git history。
64
65
 
65
66
  ## 8. Development Self-Test Report(开发自测报告)
66
67
 
67
- 本节只证明模块入口、核心路径、出口和最小证据,不是 debug log、operator log、runbook 或探索流水。
68
+ 本节是开发阶段可执行交接卡,只证明模块应用入口、核心路径、出口和最小证据指针。目标控制在几十行;high-risk runtime/live 场景包含 `Gate Breakdown` 时也不要超过 120 行。本节不是 debug log、operator log、runbook、evidence dump 或探索流水。
68
69
 
69
70
  - Report Status: PASS | BLOCKED | IN_PROGRESS | STALE
70
71
  - Contract Source:
72
+ - Module Application Entry:
73
+ - Module Key Test Path: local start / invocation -> all self-test scenarios -> all task/module promised runnable entries -> actual internal key paths / boundaries / checkpoints -> observable completion evidence
74
+ - Module Key Test Graph: required only when `self_test_contract.graph_required: true` or `module_key_test_graph` exists; keep it as a compact DAG pointer list/table covering entry, checkpoints, scenario nodes, observable exit, and evidence refs.
71
75
  - Scenario Results:
72
76
  - Executed Gates:
73
- - Module Key Test Path: local start / invocation -> all self-test scenarios -> all task/module promised runnable entries -> actual internal key paths / boundaries / checkpoints -> observable completion evidence
74
- - Actual Evidence:
75
- - Missing / Blockers:
77
+ - Observable Exit:
78
+ - Current Blocker:
76
79
  - Testing Handoff Readiness:
80
+ - Evidence Index Refs: `.docs/09_runbooks/..._evidence.md` 或外部 artifact / CI / command output path;不要复制证据正文。
77
81
 
78
82
  保留:
79
83
  - Runnable Entry / Module Key Test Path / Observable Exit
80
- - Scenario Results / Executed Gates / Actual Evidence
81
- - Missing / Blockers / Testing Handoff Readiness
84
+ - Scenario Results / Executed Gates / Evidence Index Refs
85
+ - Current Blocker / Testing Handoff Readiness
82
86
 
83
87
  不保留:
84
88
  - 每次工具探索的完整流水
85
89
  - debug log、operator log、历史操作日记或 runbook 正文
86
90
  - fallback / diagnostic 的长篇命令、截图过程或 UI 细节
87
91
  - 与当前恢复路径无关的旧失败通道;只在 appendix 或 git history 保留
92
+ - `Actual Evidence` 正文字段;证据正文进入 Evidence Index 或外部 artifact,本节只留 refs
93
+ - high-risk implementation doc 主线不得新增 `Evidence Dump`、`Operator Log`、`Failed Attempts`、`Screenshot Index` 等章节;这些只能进入 runbook / evidence index / exploration appendix
88
94
 
89
95
  ### Gate Breakdown(Gate 分层)
90
96
 
@@ -99,6 +105,21 @@ Input
99
105
  |---|---|---|---|---|
100
106
  | | | | | |
101
107
 
108
+ ### Module Key Test Graph(复杂 / high-risk 路径需要)
109
+
110
+ 只记录实际 handoff path 的 DAG 骨架和 evidence pointer;不要放 command output、截图过程、operator log、debug log、runbook 正文、失败探索或历史流水。
111
+
112
+ | Node ID | Kind | Label | Scenario Ref | Expected Exit | Evidence Ref |
113
+ |---|---|---|---|---|---|
114
+ | entry-local-start | entry | | | | |
115
+ | scenario-st-001 | scenario | | ST-001 | | `.docs/09_runbooks/...#ST-001` |
116
+ | exit-observable | observable_exit | | | | |
117
+
118
+ | From | To |
119
+ |---|---|
120
+ | entry-local-start | scenario-st-001 |
121
+ | scenario-st-001 | exit-observable |
122
+
102
123
  ## 9. Testing Handoff Contract(测试交接合同)
103
124
 
104
125
  - Entry:
@@ -12,7 +12,7 @@ next_task_sequence: 2
12
12
  # blocker: "current blocker, or none with context"
13
13
  # last_passed_gate: "last concrete PASS gate or checkpoint"
14
14
  # do_not_retry:
15
- # - "known failed path or repeated trap to avoid"
15
+ # - "known failed path, repeated trap, or strategy-changing hard constraint; e.g. if PC WeChat shows QR after confirmed login, first classify rule_assumption_gap vs operator_induced_logout_or_session_reset before rescanning"
16
16
  # recovery_refs:
17
17
  # - ".docs/04_implementation/example.md"
18
18
  # - ".docs/09_runbooks/example_live_smoke_runbook.md"
@@ -93,6 +93,36 @@ tasks:
93
93
  runnable_entry: "command / URL / endpoint / worker command"
94
94
  observable_exit: "response / page state / side effect / log / artifact"
95
95
  module_key_test_path: "local start command / URL -> all self-test scenarios -> all task/module promised runnable entries -> internal key paths / boundaries / checkpoints -> observable completion evidence"
96
+ # Set graph_required: true for complex/high-risk handoff paths:
97
+ # scenario >= 3, multiple branches/entries, runtime/live/provider/browser/worker,
98
+ # or paths that Review/Testing must consume explicitly. The graph is a
99
+ # lightweight DAG handoff skeleton, not an execution trace, runbook, log,
100
+ # evidence dump, or graph engine input.
101
+ graph_required: false
102
+ module_key_test_graph:
103
+ nodes:
104
+ - id: "entry-local-start"
105
+ kind: "entry"
106
+ label: "local start command / URL"
107
+ - id: "checkpoint-core-path"
108
+ kind: "checkpoint"
109
+ label: "core module boundary or checkpoint"
110
+ - id: "scenario-st-001"
111
+ kind: "scenario"
112
+ label: "ST-001 expected behavior"
113
+ scenario_ref: "ST-001"
114
+ expected_exit: "observable response / side effect / page state"
115
+ evidence_ref: ".docs/09_runbooks/example_evidence.md#ST-001"
116
+ - id: "exit-observable"
117
+ kind: "observable_exit"
118
+ label: "observable response / side effect / page state"
119
+ edges:
120
+ - from: "entry-local-start"
121
+ to: "checkpoint-core-path"
122
+ - from: "checkpoint-core-path"
123
+ to: "scenario-st-001"
124
+ - from: "scenario-st-001"
125
+ to: "exit-observable"
96
126
  required_gates:
97
127
  - "npm run smoke"
98
128
  scenarios:
@@ -10,7 +10,12 @@
10
10
  - Last known good checkpoint:
11
11
  - Primary blocker:
12
12
 
13
- ## 2. Operator Path
13
+ ## 2. Hard Constraints
14
+
15
+ - 会改变下一步动作的判断必须写在这里,并同步到 `plan.yaml#resume_capsule.do_not_retry` 或 implementation doc `Current Operator Path`。
16
+ - Example: PC 微信已登录后再次出现 QR 时,先判定 `rule_assumption_gap` vs `operator_induced_logout_or_session_reset`,不得直接进入重新扫码流程。
17
+
18
+ ## 3. Operator Path
14
19
 
15
20
  ```txt
16
21
  canonical:
@@ -21,26 +26,26 @@ UI channel:
21
26
  do not prefer:
22
27
  ```
23
28
 
24
- ## 3. Preconditions
29
+ ## 4. Preconditions
25
30
 
26
31
  - Required access:
27
32
  - Required local tools:
28
33
  - Required remote services:
29
34
  - Safety / cleanup notes:
30
35
 
31
- ## 4. Resume Steps
36
+ ## 5. Resume Steps
32
37
 
33
38
  1.
34
39
  2.
35
40
  3.
36
41
 
37
- ## 5. Fallbacks And Diagnostics
42
+ ## 6. Fallbacks And Diagnostics
38
43
 
39
44
  - Preferred fallback:
40
45
  - Diagnostic-only paths:
41
46
  - Do not retry:
42
47
 
43
- ## 6. Linked Evidence
48
+ ## 7. Linked Evidence
44
49
 
45
50
  - Evidence index:
46
51
  - Exploration appendix:
@@ -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"))