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.
- package/README.md +13 -5
- package/assets/agents/AGENTS_CORE.md +7 -1
- package/assets/docs/README.md +14 -6
- package/assets/policies/phase_contracts.yaml +136 -12
- package/assets/skills/pjsdlc_architect_design/SKILL.md +7 -1
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +10 -6
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +5 -4
- package/assets/skills/pjsdlc_manager/SKILL.md +9 -6
- 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 +8 -5
- package/assets/templates/EVIDENCE_INDEX_TEMPLATE.md +2 -1
- package/assets/templates/EXPLORATION_APPENDIX_TEMPLATE.md +2 -0
- package/assets/templates/IMPLEMENTATION_DOC_TEMPLATE.md +27 -6
- package/assets/templates/PLAN_TEMPLATE.yaml +31 -1
- package/assets/templates/RUNBOOK_TEMPLATE.md +10 -5
- package/assets/templates/TEST_REPORT_TEMPLATE.md +1 -0
- package/assets/tools/harness_utils.py +388 -18
- package/assets/tools/transition.py +24 -31
- package/assets/tools/validate_design.py +5 -0
- package/assets/tools/validate_harness.py +14 -1
- package/assets/tools/validate_prompt_language.py +1 -1
- package/assets/tools/validate_rfc.py +5 -0
- package/dist/lib/init.js +1 -1
- package/dist/lib/validators.js +567 -6
- 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,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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
from harness_utils import
|
|
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")
|
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`],
|