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.
- package/README.md +12 -3
- package/assets/docs/README.md +13 -4
- package/assets/policies/allowed_paths.yaml +1 -0
- package/assets/policies/phase_contracts.yaml +131 -12
- package/assets/skills/pjsdlc_architect_design/SKILL.md +3 -0
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +10 -5
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +7 -3
- package/assets/skills/pjsdlc_reviewer/SKILL.md +2 -2
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +3 -3
- package/assets/skills/pjsdlc_tester/SKILL.md +4 -3
- package/assets/templates/EVIDENCE_INDEX_TEMPLATE.md +18 -0
- package/assets/templates/EXPLORATION_APPENDIX_TEMPLATE.md +24 -0
- package/assets/templates/IMPLEMENTATION_DOC_TEMPLATE.md +63 -10
- package/assets/templates/PLAN_TEMPLATE.yaml +48 -1
- package/assets/templates/RUNBOOK_TEMPLATE.md +52 -0
- package/assets/tools/harness_utils.py +470 -17
- package/assets/tools/transition.py +24 -31
- package/assets/tools/validate_design.py +5 -0
- package/assets/tools/validate_harness.py +15 -1
- package/assets/tools/validate_prompt_language.py +1 -1
- package/assets/tools/validate_rfc.py +5 -0
- package/dist/lib/init.js +2 -1
- package/dist/lib/validators.js +811 -21
- package/package.json +1 -1
|
@@ -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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"] =
|
|
58
|
-
|
|
59
|
-
|
|
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
|