prizmkit 1.1.129 → 1.1.131

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.
Files changed (88) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/.env.example +1 -1
  3. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +445 -8
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +87 -66
  5. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +53 -3
  6. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +55 -25
  7. package/bundled/dev-pipeline/prizmkit_runtime/runner_status.py +4 -0
  8. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +15 -1
  9. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +398 -6
  10. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +61 -3
  11. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +64 -4
  12. package/bundled/dev-pipeline/scripts/prompt_framework.py +156 -7
  13. package/bundled/dev-pipeline/scripts/update-bug-status.py +53 -0
  14. package/bundled/dev-pipeline/scripts/update-checkpoint.py +294 -69
  15. package/bundled/dev-pipeline/scripts/update-feature-status.py +57 -0
  16. package/bundled/dev-pipeline/scripts/update-refactor-status.py +53 -0
  17. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +2 -2
  18. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +20 -14
  19. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +21 -4
  20. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +18 -0
  21. package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
  22. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +5 -15
  23. package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +21 -0
  24. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +10 -10
  25. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +16 -0
  26. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +26 -23
  27. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +12 -8
  28. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +1 -1
  29. package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +21 -0
  30. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +362 -3
  31. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +404 -24
  32. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +132 -0
  33. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +91 -0
  34. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +411 -18
  35. package/bundled/dev-pipeline/tests/test_unified_cli.py +3 -3
  36. package/bundled/skills/_metadata.json +62 -25
  37. package/bundled/skills/app-planner/SKILL.md +4 -3
  38. package/bundled/skills/bug-fix-workflow/SKILL.md +89 -180
  39. package/bundled/skills/bug-planner/SKILL.md +12 -27
  40. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +131 -220
  41. package/bundled/skills/bugfix-pipeline-launcher/references/configuration.md +36 -90
  42. package/bundled/skills/feature-pipeline-launcher/SKILL.md +129 -271
  43. package/bundled/skills/feature-pipeline-launcher/references/configuration.md +35 -76
  44. package/bundled/skills/feature-planner/SKILL.md +27 -22
  45. package/bundled/skills/feature-planner/assets/planning-guide.md +4 -4
  46. package/bundled/skills/feature-workflow/SKILL.md +94 -121
  47. package/bundled/skills/prizmkit/SKILL.md +130 -94
  48. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +181 -0
  49. package/bundled/skills/prizmkit-code-review/SKILL.md +114 -84
  50. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +1 -1
  51. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +181 -0
  52. package/bundled/skills/prizmkit-committer/SKILL.md +131 -47
  53. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +181 -0
  54. package/bundled/skills/prizmkit-implement/SKILL.md +99 -73
  55. package/bundled/skills/prizmkit-implement/references/implementation-subagent-procedure.md +1 -1
  56. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +181 -0
  57. package/bundled/skills/prizmkit-init/SKILL.md +5 -5
  58. package/bundled/skills/prizmkit-plan/SKILL.md +132 -105
  59. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +181 -0
  60. package/bundled/skills/prizmkit-retrospective/SKILL.md +111 -53
  61. package/bundled/skills/prizmkit-retrospective/references/knowledge-injection-steps.md +7 -6
  62. package/bundled/skills/prizmkit-retrospective/references/structural-sync-steps.md +5 -4
  63. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +181 -0
  64. package/bundled/skills/prizmkit-test/SKILL.md +28 -6
  65. package/bundled/skills/prizmkit-test/references/examples.md +1 -1
  66. package/bundled/skills/prizmkit-test/references/test-report-template.md +1 -1
  67. package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +181 -0
  68. package/bundled/skills/prizmkit-workflow/SKILL.md +266 -0
  69. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +181 -0
  70. package/bundled/skills/recovery-workflow/SKILL.md +62 -241
  71. package/bundled/skills/recovery-workflow/evals/evals.json +17 -33
  72. package/bundled/skills/recovery-workflow/references/detection.md +28 -52
  73. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +105 -439
  74. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +136 -275
  75. package/bundled/skills/refactor-pipeline-launcher/references/configuration.md +53 -88
  76. package/bundled/skills/refactor-planner/SKILL.md +12 -57
  77. package/bundled/skills/refactor-planner/references/behavior-preservation.md +111 -230
  78. package/bundled/skills/refactor-planner/references/error-recovery.md +37 -0
  79. package/bundled/skills/refactor-planner/references/planning-phases.md +11 -17
  80. package/bundled/skills/refactor-planner/scripts/validate-and-generate-refactor.py +1 -1
  81. package/bundled/skills/refactor-workflow/SKILL.md +98 -139
  82. package/bundled/templates/project-memory-template.md +4 -4
  83. package/package.json +1 -1
  84. package/src/index.js +4 -0
  85. package/src/prompts.js +10 -3
  86. package/src/scaffold.js +1 -1
  87. package/bundled/dev-pipeline/tests/test-deploy-safety.sh +0 -223
  88. package/bundled/skills/feature-pipeline-launcher/scripts/preflight-check.py +0 -462
@@ -24,17 +24,23 @@ Usage:
24
24
 
25
25
  import argparse
26
26
  import glob
27
+ import hashlib
27
28
  import json
28
29
  import os
29
30
  import re
31
+ import subprocess
30
32
  import sys
31
33
 
32
34
  from utils import enrich_global_context, helper_replacements, load_json_file, resolve_prompt_platform, setup_logging
33
35
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
34
- from prompt_framework import browser_enabled_from_item, checkpoint_group, checkpoint_skip, checkpoint_step, detect_dev_server_port, generate_checkpoint_from_sections, merge_checkpoint_state as merge_shared_checkpoint_state
36
+ from prompt_framework import browser_enabled_from_item, checkpoint_group, checkpoint_skip, checkpoint_step, detect_dev_server_port, generate_checkpoint_from_sections, merge_checkpoint_state as merge_shared_checkpoint_state, pipeline_checkpoint_metadata, route_pipeline_repair
35
37
 
36
38
 
37
39
  DEFAULT_MAX_RETRIES = 3
40
+ MAX_OUTER_REPAIR_ROUNDS = 3
41
+ EVIDENCE_FINAL_RECORDS = {"validation.json", "verdict.json", "test-report.md"}
42
+ EVIDENCE_SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
43
+ EVIDENCE_TARGET_HASH_KEYS = ("source", "tests", "contracts", "lockfiles", "environment", "plan")
38
44
 
39
45
  LOGGER = setup_logging("generate-bootstrap-prompt")
40
46
 
@@ -679,7 +685,9 @@ SECTION_TO_SKILL = {
679
685
  "phase-prizmkit-test": checkpoint_step(
680
686
  "prizmkit-test", "Scoped Feature Test Gate",
681
687
  [".prizmkit/specs/{slug}/test-report-path.txt",
682
- ".prizmkit/test/*/test-report.md"],
688
+ ".prizmkit/test/evidence/*/manifest.json",
689
+ ".prizmkit/test/evidence/*/verdict.json",
690
+ ".prizmkit/test/evidence/*/validation.json"],
683
691
  ),
684
692
  "phase-review": checkpoint_step("prizmkit-code-review", "Code Review", []),
685
693
  "phase-browser": checkpoint_step("browser-verification", "Browser Verification", []),
@@ -716,6 +724,366 @@ def _read_text(path):
716
724
  return ""
717
725
 
718
726
 
727
+ def _canonical_sha256(value):
728
+ """Return the protocol's compact, key-sorted JSON SHA-256."""
729
+ payload = json.dumps(
730
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
731
+ )
732
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
733
+
734
+
735
+ def _file_sha256(path):
736
+ """Hash a file without loading large evidence records into memory."""
737
+ digest = hashlib.sha256()
738
+ try:
739
+ with open(path, "rb") as f:
740
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
741
+ digest.update(chunk)
742
+ except OSError:
743
+ return ""
744
+ return digest.hexdigest()
745
+
746
+
747
+ def _load_strict_json(path):
748
+ """Load JSON while rejecting duplicate keys and non-JSON constants."""
749
+ def reject_duplicates(pairs):
750
+ result = {}
751
+ for key, value in pairs:
752
+ if key in result:
753
+ raise ValueError("duplicate object key: {}".format(key))
754
+ result[key] = value
755
+ return result
756
+
757
+ def reject_constant(value):
758
+ raise ValueError("non-JSON numeric constant: {}".format(value))
759
+
760
+ try:
761
+ with open(path, "r", encoding="utf-8") as f:
762
+ return json.load(
763
+ f, object_pairs_hook=reject_duplicates,
764
+ parse_constant=reject_constant,
765
+ )
766
+ except (OSError, ValueError, TypeError):
767
+ return None
768
+
769
+
770
+ def _is_sha256(value):
771
+ return isinstance(value, str) and bool(EVIDENCE_SHA256_RE.fullmatch(value))
772
+
773
+
774
+ def _path_is_within(root, candidate):
775
+ """Return whether candidate resolves below root without escaping it."""
776
+ try:
777
+ return os.path.commonpath([
778
+ os.path.realpath(root), os.path.realpath(candidate),
779
+ ]) == os.path.realpath(root)
780
+ except ValueError:
781
+ return False
782
+
783
+
784
+ def _resolve_test_evidence_validator(project_root):
785
+ """Return the installed validator path used by the current project."""
786
+ candidates = [
787
+ os.path.join(
788
+ project_root, ".claude", "command-assets", "prizmkit-test",
789
+ "scripts", "validate_test_evidence.py",
790
+ ),
791
+ os.path.join(
792
+ project_root, ".agents", "skills", "prizmkit-test", "scripts",
793
+ "validate_test_evidence.py",
794
+ ),
795
+ os.path.join(
796
+ project_root, ".codebuddy", "skills", "prizmkit-test", "scripts",
797
+ "validate_test_evidence.py",
798
+ ),
799
+ ]
800
+ return next((path for path in candidates if os.path.isfile(path)), "")
801
+
802
+
803
+ def _evidence_validator_passes(project_root, evidence_root, validation):
804
+ """Re-run the authoritative L1 validator before accepting any verdict."""
805
+ validator_path = _resolve_test_evidence_validator(project_root)
806
+ if (not validator_path or
807
+ validation.get("validator_sha256") != _file_sha256(validator_path)):
808
+ return False
809
+ try:
810
+ result = subprocess.run(
811
+ [sys.executable, validator_path,
812
+ "--evidence-dir", evidence_root,
813
+ "--project-root", project_root],
814
+ stdout=subprocess.DEVNULL,
815
+ stderr=subprocess.DEVNULL,
816
+ timeout=120,
817
+ check=False,
818
+ )
819
+ except (OSError, subprocess.SubprocessError):
820
+ return False
821
+ return result.returncode == 0
822
+
823
+
824
+ def _evidence_package_from_pointer(project_root, pointer_artifact):
825
+ """Resolve a Feature report pointer to the structured evidence package."""
826
+ pointer_path = os.path.join(project_root, pointer_artifact)
827
+ pointed = _read_text(pointer_path).strip().splitlines()
828
+ if not pointed:
829
+ return None, None
830
+
831
+ report_path = pointed[0].strip()
832
+ if not os.path.isabs(report_path):
833
+ report_path = os.path.join(project_root, report_path)
834
+ evidence_root = os.path.dirname(report_path)
835
+ expected_root = os.path.join(project_root, ".prizmkit", "test", "evidence")
836
+ if not _path_is_within(expected_root, evidence_root):
837
+ return None, None
838
+ relative_root = os.path.relpath(evidence_root, expected_root)
839
+ if (not relative_root or relative_root.startswith("..") or
840
+ os.sep in relative_root):
841
+ return None, None
842
+ if os.path.basename(report_path) != "test-report.md":
843
+ return None, None
844
+ if not os.path.isfile(report_path):
845
+ return None, None
846
+ return os.path.realpath(evidence_root), os.path.realpath(report_path)
847
+
848
+
849
+ def _manifest_entries(evidence_root, manifest):
850
+ """Validate the manifest file list and return path -> entry."""
851
+ entries = manifest.get("files")
852
+ if not isinstance(entries, list):
853
+ return None
854
+ result = {}
855
+ for entry in entries:
856
+ if not isinstance(entry, dict):
857
+ return None
858
+ relative = entry.get("path")
859
+ if (not isinstance(relative, str) or not relative or
860
+ os.path.isabs(relative) or relative in result or
861
+ ".." in relative.split("/") or "\\" in relative):
862
+ return None
863
+ path = os.path.join(evidence_root, relative)
864
+ if not _path_is_within(evidence_root, path) or not os.path.isfile(path):
865
+ return None
866
+ if not _is_sha256(entry.get("sha256")):
867
+ return None
868
+ if not isinstance(entry.get("produced_by"), str) or not entry["produced_by"].strip():
869
+ return None
870
+ if _file_sha256(path) != entry["sha256"]:
871
+ return None
872
+ result[relative] = entry
873
+
874
+ actual = set()
875
+ for root, _dirs, files in os.walk(evidence_root):
876
+ for filename in files:
877
+ relative = os.path.relpath(os.path.join(root, filename), evidence_root)
878
+ if relative != "manifest.json":
879
+ actual.add(relative)
880
+ return result if set(result) == actual else None
881
+
882
+
883
+ def _extract_report_scope(report_text):
884
+ """Read only the derived report's artifact identity, never its verdict."""
885
+ in_scope = False
886
+ for raw_line in report_text.splitlines():
887
+ line = raw_line.strip()
888
+ if line == "## Scope":
889
+ in_scope = True
890
+ continue
891
+ if in_scope and line.startswith("## "):
892
+ break
893
+ if in_scope and line.startswith("- Artifact Dir:"):
894
+ return line.split(":", 1)[1].strip()
895
+ return ""
896
+
897
+
898
+ def _validate_target_hashes(evidence_root, project_root, manifest, entry_map):
899
+ """Validate live inventoried files against the manifest target hashes."""
900
+ inventory_path = os.path.join(evidence_root, "target-inventory.json")
901
+ environment_path = os.path.join(evidence_root, "environment.json")
902
+ inventory = _load_strict_json(inventory_path)
903
+ environment = _load_strict_json(environment_path)
904
+ if not isinstance(inventory, dict) or not isinstance(environment, dict):
905
+ return False
906
+ if "target-inventory.json" not in entry_map or "environment.json" not in entry_map:
907
+ return False
908
+
909
+ categories = inventory.get("categories")
910
+ expected = set(EVIDENCE_TARGET_HASH_KEYS[:4])
911
+ if not isinstance(categories, dict) or set(categories) != expected:
912
+ return False
913
+ aggregates = {}
914
+ for category in sorted(expected):
915
+ records = categories.get(category)
916
+ if not isinstance(records, list):
917
+ return False
918
+ normalized = []
919
+ for record in records:
920
+ if not isinstance(record, dict):
921
+ return False
922
+ relative = record.get("path")
923
+ expected_hash = record.get("sha256")
924
+ if (not isinstance(relative, str) or os.path.isabs(relative) or
925
+ not _is_sha256(expected_hash)):
926
+ return False
927
+ live_path = os.path.join(project_root, relative)
928
+ if (not _path_is_within(project_root, live_path) or
929
+ not os.path.isfile(live_path) or
930
+ _file_sha256(live_path) != expected_hash):
931
+ return False
932
+ normalized.append({"path": relative, "sha256": expected_hash})
933
+ aggregates[category] = _canonical_sha256(
934
+ sorted(normalized, key=lambda item: item["path"]),
935
+ )
936
+
937
+ aggregates["environment"] = _canonical_sha256(environment)
938
+ plan_inputs = inventory.get("plan_inputs")
939
+ if not isinstance(plan_inputs, dict) or not plan_inputs:
940
+ return False
941
+ aggregates["plan"] = _canonical_sha256(plan_inputs)
942
+ target_hashes = manifest.get("target_hashes")
943
+ return target_hashes == aggregates
944
+
945
+
946
+ def validate_feature_test_evidence(project_root, artifact_dir):
947
+ """Return structured Feature test evidence, or None when it is unsafe."""
948
+ pointer_artifact = os.path.join(artifact_dir, "test-report-path.txt")
949
+ evidence_root, report_path = _evidence_package_from_pointer(
950
+ project_root, pointer_artifact,
951
+ )
952
+ if not evidence_root or not report_path:
953
+ return None
954
+
955
+ marker_path = os.path.join(project_root, artifact_dir, ".prizmkit-test-started")
956
+ if not os.path.isfile(marker_path):
957
+ return None
958
+ try:
959
+ if os.path.getmtime(report_path) < os.path.getmtime(marker_path):
960
+ return None
961
+ except OSError:
962
+ return None
963
+
964
+ manifest = _load_strict_json(os.path.join(evidence_root, "manifest.json"))
965
+ verdict = _load_strict_json(os.path.join(evidence_root, "verdict.json"))
966
+ validation = _load_strict_json(os.path.join(evidence_root, "validation.json"))
967
+ if not all(isinstance(record, dict) for record in (manifest, verdict, validation)):
968
+ return None
969
+ entry_map = _manifest_entries(evidence_root, manifest)
970
+ if entry_map is None or not {"verdict.json", "validation.json"}.issubset(entry_map):
971
+ return None
972
+
973
+ identity = manifest.get("evidence_id_inputs")
974
+ evidence_id = manifest.get("evidence_id")
975
+ if (not isinstance(identity, dict) or not _is_sha256(identity.get("working_diff_sha256")) or
976
+ not _is_sha256(identity.get("scope_sha256")) or
977
+ not isinstance(identity.get("baseline_commit"), str) or
978
+ evidence_id != _canonical_sha256(identity) or
979
+ os.path.basename(evidence_root) != evidence_id):
980
+ return None
981
+ if (manifest.get("protocol_version") != "1.0" or
982
+ manifest.get("sensitivity") != "project-controlled" or
983
+ manifest.get("environment_claim") != "mocked-code-level-only" or
984
+ manifest.get("compatibility") != "legacy-test-report-interface-not-supported" or
985
+ manifest.get("final_verdict") not in {"TEST_PASS", "TEST_FAIL", "TEST_BLOCKED"}):
986
+ return None
987
+ stages = manifest.get("stages")
988
+ stage_names = [stage.get("name") for stage in stages] if isinstance(stages, list) else []
989
+ if stage_names != [
990
+ "CHANGE_CLASSIFY", "SCOPE_DISCOVER", "CONTRACT_MODEL", "TEST_PLAN",
991
+ "INFRA_READY", "TEST_BUILD", "EXECUTE_PROVE", "EVIDENCE_PACKAGE",
992
+ "EVIDENCE_VALIDATE"]:
993
+ return None
994
+ for stage in stages:
995
+ if (not isinstance(stage, dict) or stage.get("status") not in {"complete", "not_applicable"} or
996
+ not _is_sha256(stage.get("input_sha256")) or
997
+ not isinstance(stage.get("outputs"), list)):
998
+ return None
999
+ if (not _is_sha256(manifest.get("working_diff_sha256")) or
1000
+ manifest.get("working_diff_sha256") != identity.get("working_diff_sha256")):
1001
+ return None
1002
+
1003
+ final_verdict = manifest["final_verdict"]
1004
+ if (verdict.get("verdict") != final_verdict or
1005
+ verdict.get("testing_domain_only") is not True or
1006
+ verdict.get("authorizes_commit_or_release") is not False or
1007
+ verdict.get("real_environment_validated") is not False or
1008
+ verdict.get("repairs_business_defects") is not False or
1009
+ not isinstance(verdict.get("blockers"), list) or
1010
+ not isinstance(verdict.get("reproduced_failures"), list)):
1011
+ return None
1012
+ if final_verdict == "TEST_PASS" and (
1013
+ verdict.get("code_evidence_replayable") is not True or
1014
+ verdict.get("blockers") or verdict.get("reproduced_failures")):
1015
+ return None
1016
+ if final_verdict == "TEST_FAIL" and not verdict.get("reproduced_failures"):
1017
+ return None
1018
+ if final_verdict == "TEST_BLOCKED" and not verdict.get("blockers"):
1019
+ return None
1020
+
1021
+ payload = sorted(
1022
+ ({"path": path, "sha256": entry["sha256"]}
1023
+ for path, entry in entry_map.items()
1024
+ if path not in EVIDENCE_FINAL_RECORDS),
1025
+ key=lambda item: item["path"],
1026
+ )
1027
+ if (validation.get("validator") != "validate_test_evidence.py" or
1028
+ not _is_sha256(validation.get("validator_sha256")) or
1029
+ validation.get("payload_sha256") != _canonical_sha256(payload) or
1030
+ validation.get("result") != "passed" or
1031
+ validation.get("verdict") != final_verdict or
1032
+ validation.get("semantics") != "integrity-and-protocol-validation-not-hostile-producer-proof" or
1033
+ not isinstance(validation.get("replay_receipt_ids"), list)):
1034
+ return None
1035
+ if not _evidence_validator_passes(project_root, evidence_root, validation):
1036
+ return None
1037
+
1038
+ report_scope = _normalize_artifact_dir(_extract_report_scope(_read_text(report_path)))
1039
+ expected_scope = _normalize_artifact_dir(artifact_dir)
1040
+ if report_scope != expected_scope:
1041
+ return None
1042
+ if not _validate_target_hashes(evidence_root, project_root, manifest, entry_map):
1043
+ return None
1044
+ scope = _load_strict_json(os.path.join(evidence_root, "scope.json"))
1045
+ if not isinstance(scope, dict) or identity.get("scope_sha256") != _canonical_sha256(scope):
1046
+ return None
1047
+
1048
+ state_path = os.path.join(
1049
+ project_root, ".prizmkit", "state", "workflows",
1050
+ os.path.basename(artifact_dir) + ".json",
1051
+ )
1052
+ state = _load_strict_json(state_path) if os.path.isfile(state_path) else {}
1053
+ if state and (not isinstance(state, dict) or state.get("stage_result") not in {None, final_verdict, "TEST_PASS", "TEST_FAIL", "TEST_BLOCKED"}):
1054
+ return None
1055
+ return {
1056
+ "evidence_id": evidence_id,
1057
+ "evidence_dir": os.path.relpath(evidence_root, project_root),
1058
+ "manifest_path": os.path.relpath(os.path.join(evidence_root, "manifest.json"), project_root),
1059
+ "verdict_path": os.path.relpath(os.path.join(evidence_root, "verdict.json"), project_root),
1060
+ "validation_path": os.path.relpath(os.path.join(evidence_root, "validation.json"), project_root),
1061
+ "verdict": final_verdict,
1062
+ "repair_scope": state.get("repair_scope") if isinstance(state, dict) else None,
1063
+ "repair_round": state.get("repair_round", 0) if isinstance(state, dict) else 0,
1064
+ "target_hashes": manifest.get("target_hashes"),
1065
+ }
1066
+
1067
+
1068
+ def route_feature_repair(stage_result, repair_scope=None, repair_round=0):
1069
+ """Map Feature review/test outcomes to the shared safe route contract."""
1070
+ return route_pipeline_repair(stage_result, repair_scope, repair_round)
1071
+
1072
+
1073
+ def feature_checkpoint_metadata(artifact_dir, evidence=None, stage="plan",
1074
+ status="PLAN_READY", stage_result=None,
1075
+ repair_scope=None, repair_round=0,
1076
+ next_stage="prizmkit-implement",
1077
+ resume_from="prizmkit-implement"):
1078
+ """Build Feature L4 handoff metadata through the shared semantic contract."""
1079
+ return pipeline_checkpoint_metadata(
1080
+ artifact_dir, evidence=evidence, stage=stage, status=status,
1081
+ stage_result=stage_result, repair_scope=repair_scope,
1082
+ repair_round=repair_round, next_stage=next_stage,
1083
+ resume_from=resume_from,
1084
+ )
1085
+
1086
+
719
1087
  def _normalize_artifact_dir(path):
720
1088
  """Normalize report artifact_dir values for comparison."""
721
1089
  return path.strip().replace("\\", "/").rstrip("/")
@@ -1091,8 +1459,12 @@ def _scoped_report_fresh(project_root, pointer_artifact, report_path, report_tex
1091
1459
  return True
1092
1460
 
1093
1461
 
1094
- def _validate_scoped_test_report(project_root, artifacts):
1095
- """Validate the feature prizmkit-test pointer and scoped PASS report."""
1462
+ def _validate_legacy_scoped_test_report(project_root, artifacts):
1463
+ """Validate the legacy Markdown report for old callers only.
1464
+
1465
+ Feature checkpoints must use ``_validate_scoped_test_report`` below; this
1466
+ retained helper is intentionally not used by Feature prompt generation.
1467
+ """
1096
1468
  pointer_artifacts = [a for a in artifacts
1097
1469
  if a.endswith("/test-report-path.txt")]
1098
1470
  if not pointer_artifacts:
@@ -1130,6 +1502,16 @@ def _validate_scoped_test_report(project_root, artifacts):
1130
1502
  report_path, report_text))
1131
1503
 
1132
1504
 
1505
+ def _validate_scoped_test_report(project_root, artifacts):
1506
+ """Validate current Feature structured evidence, never Markdown authority."""
1507
+ pointer_artifacts = [a for a in artifacts if a.endswith("/test-report-path.txt")]
1508
+ if not pointer_artifacts:
1509
+ return False
1510
+ return validate_feature_test_evidence(
1511
+ project_root, os.path.dirname(pointer_artifacts[0]),
1512
+ ) is not None
1513
+
1514
+
1133
1515
  def _completed_step_artifacts_valid(project_root, skill_key, artifacts):
1134
1516
  """Return whether a completed checkpoint step can be preserved."""
1135
1517
  if not all(_artifact_exists(project_root, a) for a in artifacts):
@@ -1637,8 +2019,8 @@ def build_replacements(args, feature, features, global_context, script_dir):
1637
2019
  effective_resume = "6"
1638
2020
 
1639
2021
 
1640
- # Browser verification defaults to a mandatory attempt. It is disabled only
1641
- # by BROWSER_VERIFY=false or browser_interaction.enabled=false.
2022
+ # Browser verification is opt-in through a browser_interaction object and
2023
+ # can always be disabled globally with BROWSER_VERIFY=false.
1642
2024
  browser_enabled, browser_tool, browser_verify_steps, browser_blocking = browser_enabled_from_item(feature)
1643
2025
 
1644
2026
  if browser_tool not in ("playwright-cli", "opencli", "auto"):
@@ -1886,6 +2268,16 @@ def main():
1886
2268
  checkpoint_dir, "workflow-checkpoint.json",
1887
2269
  )
1888
2270
 
2271
+ # L4 metadata remains separate from the L1 workflow-state contract.
2272
+ checkpoint["feature_state"] = feature_checkpoint_metadata(
2273
+ os.path.join(".prizmkit", "specs", feature_slug),
2274
+ stage="plan",
2275
+ status="PLAN_READY",
2276
+ stage_result=None,
2277
+ next_stage="prizmkit-implement",
2278
+ resume_from="prizmkit-implement",
2279
+ )
2280
+
1889
2281
  # On resume, merge existing completed state (with artifact validation)
1890
2282
  if (is_resume or is_continuation(args)) and os.path.exists(checkpoint_path):
1891
2283
  try:
@@ -22,9 +22,11 @@ from prompt_framework import (
22
22
  merge_checkpoint_state,
23
23
  mode_agent_count,
24
24
  normalize_mode,
25
+ pipeline_checkpoint_metadata,
25
26
  read_text_file,
26
27
  render_from_sections,
27
28
  replace_placeholders,
29
+ route_pipeline_repair,
28
30
  validate_rendered,
29
31
  write_output,
30
32
  )
@@ -34,6 +36,30 @@ from utils import enrich_global_context, helper_replacements, load_json_file, re
34
36
  LOGGER = setup_logging("generate-bugfix-prompt")
35
37
 
36
38
 
39
+ def route_bugfix_repair(stage_result, repair_scope=None, repair_round=0):
40
+ """Map Bugfix review/test outcomes to the shared safe route contract."""
41
+ return route_pipeline_repair(stage_result, repair_scope, repair_round)
42
+
43
+
44
+ def bugfix_checkpoint_metadata(artifact_dir, evidence=None, stage="plan",
45
+ status="PLAN_READY", stage_result=None,
46
+ repair_scope=None, repair_round=0,
47
+ next_stage="prizmkit-implement",
48
+ resume_from="prizmkit-implement"):
49
+ """Build Bugfix L4 handoff metadata while preserving Bugfix L1 state."""
50
+ return pipeline_checkpoint_metadata(
51
+ artifact_dir,
52
+ evidence=evidence,
53
+ stage=stage,
54
+ status=status,
55
+ stage_result=stage_result,
56
+ repair_scope=repair_scope,
57
+ repair_round=repair_round,
58
+ next_stage=next_stage,
59
+ resume_from=resume_from,
60
+ )
61
+
62
+
37
63
  # Section name -> checkpoint contract.
38
64
  BUGFIX_SECTION_TO_SKILL = {
39
65
  "bugfix-phase-init": checkpoint_step("prizmkit-init", "Initialize", [".prizmkit/bugfix/{slug}"]),
@@ -43,6 +69,14 @@ BUGFIX_SECTION_TO_SKILL = {
43
69
  ),
44
70
  "bugfix-phase-implement": checkpoint_step("prizmkit-implement", "Implement Fix", []),
45
71
  "bugfix-phase-review": checkpoint_step("prizmkit-code-review", "Code Review", []),
72
+ "bugfix-phase-test": checkpoint_step(
73
+ "prizmkit-test", "Post-Review Test Gate",
74
+ [
75
+ ".prizmkit/test/evidence/*/manifest.json",
76
+ ".prizmkit/test/evidence/*/verdict.json",
77
+ ".prizmkit/test/evidence/*/validation.json",
78
+ ],
79
+ ),
46
80
  "phase-browser": checkpoint_step("browser-verification", "Browser Verification", []),
47
81
  "bugfix-phase-commit-report": checkpoint_group([
48
82
  ("prizmkit-retrospective", "Retrospective", []),
@@ -362,7 +396,7 @@ def _bugfix_header(pipeline_mode):
362
396
  return title + "\n" + desc + "\n"
363
397
 
364
398
 
365
- def assemble_bugfix_sections(pipeline_mode, sections_dir, browser_enabled, manual_gate):
399
+ def assemble_bugfix_sections(pipeline_mode, sections_dir, browser_enabled, manual_gate, browser_tool="auto"):
366
400
  sections = [
367
401
  ("header", _bugfix_header(pipeline_mode)),
368
402
  ("bugfix-session-context", load_section(sections_dir, "bugfix-session-context.md")),
@@ -378,9 +412,14 @@ def assemble_bugfix_sections(pipeline_mode, sections_dir, browser_enabled, manua
378
412
  sections.extend([
379
413
  ("bugfix-phase-implement", load_section(sections_dir, "bugfix-phase-implement.md")),
380
414
  ("bugfix-phase-review", load_section(sections_dir, "bugfix-phase-review.md")),
415
+ ("bugfix-phase-test", load_section(sections_dir, "bugfix-phase-test.md")),
381
416
  ])
382
417
  if browser_enabled:
383
- sections.append(("phase-browser", load_section(sections_dir, "phase-browser-verification-auto.md")))
418
+ browser_section_file = {
419
+ "playwright-cli": "phase-browser-verification.md",
420
+ "opencli": "phase-browser-verification-opencli.md",
421
+ }.get(browser_tool, "phase-browser-verification-auto.md")
422
+ sections.append(("phase-browser", load_section(sections_dir, browser_section_file)))
384
423
  if manual_gate:
385
424
  sections.append(("bugfix-manual-gate", load_section(sections_dir, "bugfix-phase-manual-verification.md")))
386
425
  sections.extend([
@@ -391,6 +430,7 @@ def assemble_bugfix_sections(pipeline_mode, sections_dir, browser_enabled, manua
391
430
  ])
392
431
  return sections
393
432
  sections.extend([
433
+ ("headless-commit-authorization", load_section(sections_dir, "headless-commit-authorization.md")),
394
434
  ("bugfix-phase-commit-report", load_section(sections_dir, "bugfix-phase-commit-report.md")),
395
435
  ("bugfix-session-status", load_section(sections_dir, "bugfix-session-status.md")),
396
436
  ("bugfix-critical-paths", load_section(sections_dir, "bugfix-critical-paths.md")),
@@ -408,6 +448,7 @@ def generate_bugfix_checkpoint(bug_id, session_id, pipeline_mode="standard", sec
408
448
  ("bugfix-phase-diagnose-plan", ""),
409
449
  ("bugfix-phase-implement", ""),
410
450
  ("bugfix-phase-review", ""),
451
+ ("bugfix-phase-test", ""),
411
452
  ("bugfix-phase-commit-report", ""),
412
453
  ]
413
454
  return generate_checkpoint_from_sections(
@@ -424,7 +465,11 @@ def generate_bugfix_checkpoint(bug_id, session_id, pipeline_mode="standard", sec
424
465
 
425
466
  def merge_bugfix_checkpoint_state(existing, fresh, project_root):
426
467
  """Merge existing bugfix checkpoint state into fresh definition."""
427
- return merge_checkpoint_state(existing, fresh, project_root, logger=LOGGER)
468
+ merged = merge_checkpoint_state(existing, fresh, project_root, logger=LOGGER)
469
+ existing_state = existing.get("bugfix_state")
470
+ if isinstance(existing_state, dict):
471
+ merged["bugfix_state"] = existing_state
472
+ return merged
428
473
 
429
474
 
430
475
  def emit_failure(message):
@@ -463,6 +508,7 @@ def main():
463
508
  try:
464
509
  sections = assemble_bugfix_sections(
465
510
  pipeline_mode, sections_dir, browser_enabled, manual_gate,
511
+ browser_tool=replacements["{{BROWSER_TOOL}}"],
466
512
  )
467
513
  rendered = render_from_sections(sections, replacements)
468
514
  except FileNotFoundError as exc:
@@ -498,6 +544,18 @@ def main():
498
544
  checkpoint = generate_bugfix_checkpoint(
499
545
  args.bug_id, args.session_id, pipeline_mode, sections if use_sections else None,
500
546
  )
547
+ if not any(step.get("skill") == "prizmkit-test" for step in checkpoint["steps"]):
548
+ emit_failure("Bugfix checkpoint is missing the mandatory prizmkit-test stage")
549
+ checkpoint["bugfix_state"] = bugfix_checkpoint_metadata(
550
+ replacements["{{ARTIFACT_DIR}}"],
551
+ stage="plan",
552
+ status="PLAN_READY",
553
+ stage_result=None,
554
+ repair_scope=None,
555
+ repair_round=0,
556
+ next_stage="prizmkit-implement",
557
+ resume_from="prizmkit-implement",
558
+ )
501
559
 
502
560
  if (args.resume_phase != "null" or is_continuation(args)) and os.path.exists(checkpoint_path):
503
561
  try: