prizmkit 1.1.139 → 1.1.141

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 (74) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +41 -121
  3. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -6
  4. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +1 -2
  5. package/bundled/dev-pipeline/prizmkit_runtime/test_result.py +150 -0
  6. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +21 -804
  7. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +4 -6
  8. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +1 -1
  9. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +4 -6
  10. package/bundled/dev-pipeline/scripts/prompt_framework.py +56 -55
  11. package/bundled/dev-pipeline/scripts/update-bug-status.py +1 -2
  12. package/bundled/dev-pipeline/scripts/update-checkpoint.py +14 -96
  13. package/bundled/dev-pipeline/scripts/update-feature-status.py +1 -2
  14. package/bundled/dev-pipeline/scripts/update-refactor-status.py +1 -2
  15. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +3 -3
  16. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +7 -18
  17. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -6
  18. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -5
  19. package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +1 -1
  20. package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
  21. package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +1 -1
  22. package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +13 -10
  23. package/bundled/dev-pipeline/templates/sections/bugfix-reminders.md +1 -1
  24. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -9
  25. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +1 -1
  26. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +1 -1
  27. package/bundled/dev-pipeline/templates/sections/phase-commit.md +1 -1
  28. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +2 -2
  29. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -1
  30. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +1 -1
  31. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +1 -1
  32. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +14 -26
  33. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +3 -3
  34. package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +13 -10
  35. package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
  36. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +287 -384
  37. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +119 -684
  38. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +17 -40
  39. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +20 -23
  40. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +29 -32
  41. package/bundled/skills/_metadata.json +6 -6
  42. package/bundled/skills/prizmkit/SKILL.md +8 -13
  43. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +61 -80
  44. package/bundled/skills/prizmkit-code-review/SKILL.md +3 -3
  45. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +61 -80
  46. package/bundled/skills/prizmkit-committer/SKILL.md +3 -3
  47. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +61 -80
  48. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +61 -80
  49. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +61 -80
  50. package/bundled/skills/prizmkit-retrospective/SKILL.md +2 -2
  51. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +61 -80
  52. package/bundled/skills/prizmkit-test/SKILL.md +217 -187
  53. package/bundled/skills/prizmkit-test/references/external-contract-mock-guidance.md +119 -0
  54. package/bundled/skills/prizmkit-test/references/independent-test-review.md +269 -0
  55. package/bundled/skills/prizmkit-test/references/test-coverage-model.md +193 -0
  56. package/bundled/skills/prizmkit-test/references/test-report-template.md +164 -79
  57. package/bundled/skills/prizmkit-workflow/SKILL.md +15 -23
  58. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +61 -80
  59. package/package.json +1 -1
  60. package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +0 -476
  61. package/bundled/skills/prizmkit-test/assets/behavior-risk-matrix.schema.json +0 -116
  62. package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +0 -111
  63. package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +0 -66
  64. package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +0 -104
  65. package/bundled/skills/prizmkit-test/references/contract-mock-protocol.md +0 -65
  66. package/bundled/skills/prizmkit-test/references/evidence-protocol.md +0 -208
  67. package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +0 -80
  68. package/bundled/skills/prizmkit-test/references/examples.md +0 -100
  69. package/bundled/skills/prizmkit-test/references/service-boundary-test-catalog.md +0 -228
  70. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +0 -118
  71. package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +0 -110
  72. package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +0 -181
  73. package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +0 -1851
  74. package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +0 -1793
@@ -23,24 +23,30 @@ Usage:
23
23
  """
24
24
 
25
25
  import argparse
26
- import glob
27
- import hashlib
28
26
  import json
29
27
  import os
30
28
  import re
31
- import subprocess
32
29
  import sys
33
30
 
34
31
  from utils import enrich_global_context, helper_replacements, load_json_file, resolve_prompt_platform, setup_logging
35
32
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
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
33
+ from prompt_framework import (
34
+ artifact_exists,
35
+ browser_enabled_from_item,
36
+ checkpoint_group,
37
+ checkpoint_skip,
38
+ checkpoint_step,
39
+ completed_test_artifacts_valid,
40
+ detect_dev_server_port,
41
+ generate_checkpoint_from_sections,
42
+ merge_checkpoint_state as merge_shared_checkpoint_state,
43
+ pipeline_checkpoint_metadata,
44
+ route_pipeline_repair,
45
+ )
37
46
 
38
47
 
39
48
  DEFAULT_MAX_RETRIES = 3
40
49
  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")
44
50
 
45
51
  LOGGER = setup_logging("generate-bootstrap-prompt")
46
52
 
@@ -684,10 +690,8 @@ SECTION_TO_SKILL = {
684
690
  "phase-implement": checkpoint_step("prizmkit-implement", "Implement", []),
685
691
  "phase-prizmkit-test": checkpoint_step(
686
692
  "prizmkit-test", "Scoped Feature Test Gate",
687
- [".prizmkit/specs/{slug}/test-report-path.txt",
688
- ".prizmkit/test/evidence/*/manifest.json",
689
- ".prizmkit/test/evidence/*/verdict.json",
690
- ".prizmkit/test/evidence/*/validation.json"],
693
+ [".prizmkit/specs/{slug}/test-report.md",
694
+ ".prizmkit/specs/{slug}/test-result.json"],
691
695
  ),
692
696
  "phase-review": checkpoint_step("prizmkit-code-review", "Code Review", []),
693
697
  "phase-browser": checkpoint_step("browser-verification", "Browser Verification", []),
@@ -707,817 +711,30 @@ def _resolve_artifacts(artifact_templates, slug):
707
711
  return [a.replace("{slug}", slug) for a in artifact_templates]
708
712
 
709
713
 
710
- def _artifact_exists(project_root, artifact):
711
- """Return whether an artifact path or glob exists under project_root."""
712
- full_path = os.path.join(project_root, artifact)
713
- if glob.has_magic(full_path):
714
- return bool(glob.glob(full_path))
715
- return os.path.exists(full_path)
716
-
717
-
718
- def _read_text(path):
719
- """Read a UTF-8 text file, returning an empty string on IO errors."""
720
- try:
721
- with open(path, "r", encoding="utf-8") as f:
722
- return f.read()
723
- except IOError:
724
- return ""
725
-
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 isinstance(stage.get("outputs"), list)):
997
- return None
998
- if (not _is_sha256(manifest.get("working_diff_sha256")) or
999
- manifest.get("working_diff_sha256") != identity.get("working_diff_sha256")):
1000
- return None
1001
-
1002
- final_verdict = manifest["final_verdict"]
1003
- if (verdict.get("verdict") != final_verdict or
1004
- verdict.get("testing_domain_only") is not True or
1005
- verdict.get("authorizes_commit_or_release") is not False or
1006
- verdict.get("real_environment_validated") is not False or
1007
- verdict.get("repairs_business_defects") is not False or
1008
- not isinstance(verdict.get("blockers"), list) or
1009
- not isinstance(verdict.get("reproduced_failures"), list)):
1010
- return None
1011
- if final_verdict == "TEST_PASS" and (
1012
- verdict.get("code_evidence_replayable") is not True or
1013
- verdict.get("blockers") or verdict.get("reproduced_failures")):
1014
- return None
1015
- if final_verdict == "TEST_FAIL" and not verdict.get("reproduced_failures"):
1016
- return None
1017
- if final_verdict == "TEST_BLOCKED" and not verdict.get("blockers"):
1018
- return None
1019
-
1020
- payload = sorted(
1021
- ({"path": path, "sha256": entry["sha256"]}
1022
- for path, entry in entry_map.items()
1023
- if path not in EVIDENCE_FINAL_RECORDS),
1024
- key=lambda item: item["path"],
1025
- )
1026
- if (validation.get("validator") != "validate_test_evidence.py" or
1027
- not _is_sha256(validation.get("validator_sha256")) or
1028
- validation.get("payload_sha256") != _canonical_sha256(payload) or
1029
- validation.get("result") != "passed" or
1030
- validation.get("verdict") != final_verdict or
1031
- validation.get("semantics") != "integrity-and-protocol-validation-not-hostile-producer-proof" or
1032
- not isinstance(validation.get("replay_receipt_ids"), list)):
1033
- return None
1034
- if not _evidence_validator_passes(project_root, evidence_root, validation):
1035
- return None
1036
-
1037
- report_scope = _normalize_artifact_dir(_extract_report_scope(_read_text(report_path)))
1038
- expected_scope = _normalize_artifact_dir(artifact_dir)
1039
- if report_scope != expected_scope:
1040
- return None
1041
- if not _validate_target_hashes(evidence_root, project_root, manifest, entry_map):
1042
- return None
1043
- scope = _load_strict_json(os.path.join(evidence_root, "scope.json"))
1044
- if not isinstance(scope, dict) or identity.get("scope_sha256") != _canonical_sha256(scope):
1045
- return None
1046
-
1047
- state_path = os.path.join(
1048
- project_root, ".prizmkit", "state", "workflows",
1049
- os.path.basename(artifact_dir) + ".json",
1050
- )
1051
- state = _load_strict_json(state_path) if os.path.isfile(state_path) else {}
1052
- if state and (not isinstance(state, dict) or state.get("stage_result") not in {None, final_verdict, "TEST_PASS", "TEST_FAIL", "TEST_BLOCKED"}):
1053
- return None
1054
- return {
1055
- "evidence_id": evidence_id,
1056
- "evidence_dir": os.path.relpath(evidence_root, project_root),
1057
- "manifest_path": os.path.relpath(os.path.join(evidence_root, "manifest.json"), project_root),
1058
- "verdict_path": os.path.relpath(os.path.join(evidence_root, "verdict.json"), project_root),
1059
- "validation_path": os.path.relpath(os.path.join(evidence_root, "validation.json"), project_root),
1060
- "verdict": final_verdict,
1061
- "repair_scope": state.get("repair_scope") if isinstance(state, dict) else None,
1062
- "repair_round": state.get("repair_round", 0) if isinstance(state, dict) else 0,
1063
- "target_hashes": manifest.get("target_hashes"),
1064
- }
1065
-
1066
-
1067
714
  def route_feature_repair(stage_result, repair_scope=None, repair_round=0):
1068
715
  """Map Feature review/test outcomes to the shared safe route contract."""
1069
716
  return route_pipeline_repair(stage_result, repair_scope, repair_round)
1070
717
 
1071
718
 
1072
- def feature_checkpoint_metadata(artifact_dir, evidence=None, stage="plan",
719
+ def feature_checkpoint_metadata(artifact_dir, stage="plan",
1073
720
  status="PLAN_READY", stage_result=None,
1074
721
  repair_scope=None, repair_round=0,
1075
722
  next_stage="prizmkit-implement",
1076
723
  resume_from="prizmkit-implement"):
1077
- """Build Feature L4 handoff metadata through the shared semantic contract."""
724
+ """Build Feature caller handoff metadata through the shared semantic contract."""
1078
725
  return pipeline_checkpoint_metadata(
1079
- artifact_dir, evidence=evidence, stage=stage, status=status,
726
+ artifact_dir, stage=stage, status=status,
1080
727
  stage_result=stage_result, repair_scope=repair_scope,
1081
728
  repair_round=repair_round, next_stage=next_stage,
1082
729
  resume_from=resume_from,
1083
730
  )
1084
731
 
1085
732
 
1086
- def _normalize_artifact_dir(path):
1087
- """Normalize report artifact_dir values for comparison."""
1088
- return path.strip().replace("\\", "/").rstrip("/")
1089
-
1090
-
1091
- def _report_path_matches_artifacts(project_root, report_path, artifacts):
1092
- """Return whether report_path is an in-project test report artifact."""
1093
- test_root = os.path.realpath(
1094
- os.path.join(project_root, ".prizmkit", "test")
1095
- )
1096
- resolved_report = os.path.realpath(report_path)
1097
-
1098
- try:
1099
- if os.path.commonpath([test_root, resolved_report]) != test_root:
1100
- return False
1101
- except ValueError:
1102
- return False
1103
-
1104
- report_artifacts = [a for a in artifacts if a.endswith("test-report.md")]
1105
- for artifact in report_artifacts:
1106
- artifact_path = os.path.join(project_root, artifact)
1107
- if glob.has_magic(artifact_path):
1108
- candidates = glob.glob(artifact_path)
1109
- else:
1110
- candidates = [artifact_path]
1111
- for candidate in candidates:
1112
- if os.path.realpath(candidate) == resolved_report:
1113
- return True
1114
- return False
1115
-
1116
-
1117
- def _extract_scope_field(report_text, field_name):
1118
- """Extract a '- Field: value' entry from the report Scope section."""
1119
- in_scope = False
1120
- prefix = "- {}:".format(field_name)
1121
- for raw_line in report_text.splitlines():
1122
- line = raw_line.strip()
1123
- if line == "## Scope":
1124
- in_scope = True
1125
- continue
1126
- if in_scope and line.startswith("## "):
1127
- break
1128
- if in_scope and line.startswith(prefix):
1129
- return line[len(prefix):].strip()
1130
- return ""
1131
-
1132
-
1133
- def _extract_report_verdict(report_text):
1134
- """Extract the first non-empty line after the report Verdict heading."""
1135
- in_verdict = False
1136
- for raw_line in report_text.splitlines():
1137
- line = raw_line.strip()
1138
- if line == "## Verdict":
1139
- in_verdict = True
1140
- continue
1141
- if in_verdict:
1142
- if line.startswith("## "):
1143
- break
1144
- if line:
1145
- return line
1146
- return ""
1147
-
1148
-
1149
- def _extract_report_section(report_text, heading):
1150
- """Extract text under a level-2 markdown heading."""
1151
- in_section = False
1152
- lines = []
1153
- for raw_line in report_text.splitlines():
1154
- line = raw_line.strip()
1155
- if line == heading:
1156
- in_section = True
1157
- continue
1158
- if in_section and line.startswith("## "):
1159
- break
1160
- if in_section:
1161
- lines.append(raw_line)
1162
- return "\n".join(lines)
1163
-
1164
-
1165
- def _discover_openapi_file(project_root):
1166
- """Return the first common OpenAPI/Swagger file under project_root."""
1167
- candidates = [
1168
- "openapi.yaml", "openapi.yml", "swagger.yaml", "swagger.yml",
1169
- "docs/openapi.yaml", "docs/openapi.yml",
1170
- "docs/swagger.yaml", "docs/swagger.yml",
1171
- "api/openapi.yaml", "api/openapi.yml",
1172
- "api/swagger.yaml", "api/swagger.yml",
1173
- ]
1174
- for candidate in candidates:
1175
- full_path = os.path.join(project_root, candidate)
1176
- if os.path.isfile(full_path):
1177
- return full_path
1178
- return ""
1179
-
1180
-
1181
- def _normalize_api_path(path):
1182
- """Normalize API path tokens for exact comparison."""
1183
- normalized = path.strip().strip("'\"`")
1184
- if len(normalized) > 1:
1185
- normalized = normalized.rstrip("/")
1186
- return normalized
1187
-
1188
-
1189
- def _extract_openapi_operations(openapi_text):
1190
- """Extract OpenAPI method+path operations without requiring PyYAML."""
1191
- methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
1192
- operations = []
1193
- in_paths = False
1194
- paths_indent = 0
1195
- current_path = ""
1196
- current_path_indent = 0
1197
- path_key_re = re.compile(
1198
- r"^\s*(?:(['\"])(/.*?)\1|(/[^:\s]+))\s*:\s*(?:#.*)?$"
1199
- )
1200
- method_re = re.compile(r"^\s*([A-Za-z]+)\s*:\s*(?:#.*)?$")
1201
-
1202
- for raw_line in openapi_text.splitlines():
1203
- stripped = raw_line.strip()
1204
- if not stripped or stripped.startswith("#"):
1205
- continue
1206
-
1207
- indent = len(raw_line) - len(raw_line.lstrip())
1208
- if not in_paths:
1209
- if re.match(r"^\s*paths\s*:\s*(?:#.*)?$", raw_line):
1210
- in_paths = True
1211
- paths_indent = indent
1212
- continue
1213
-
1214
- if indent <= paths_indent:
1215
- break
1216
-
1217
- path_match = path_key_re.match(raw_line)
1218
- if path_match:
1219
- current_path = path_match.group(2) or path_match.group(3) or ""
1220
- current_path_indent = indent
1221
- continue
1222
-
1223
- method_match = method_re.match(raw_line)
1224
- if current_path and method_match and indent > current_path_indent:
1225
- method = method_match.group(1).upper()
1226
- if method in methods:
1227
- operations.append((method, _normalize_api_path(current_path)))
1228
-
1229
- return operations
1230
-
1231
-
1232
- def _extract_matrix_operations(rows):
1233
- """Extract exact method+path tokens from Boundary Matrix rows."""
1234
- operations = set()
1235
- path_only = set()
1236
- token_re = re.compile(
1237
- r"(?:(\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b)\s+)?(/[^\s,`|)]+)",
1238
- flags=re.IGNORECASE,
1239
- )
1240
- for row in rows:
1241
- interface = row.get("Interface", "")
1242
- for match in token_re.finditer(interface):
1243
- method = match.group(1).upper() if match.group(1) else ""
1244
- path = _normalize_api_path(match.group(2))
1245
- if not path:
1246
- continue
1247
- if method:
1248
- operations.add((method, path))
1249
- else:
1250
- path_only.add(path)
1251
- return operations, path_only
1252
-
1253
-
1254
- def _boundary_report_sections_valid(project_root, report_text):
1255
- """Return whether required boundary sections do not report failure."""
1256
- required_columns = [
1257
- "Module",
1258
- "Interface",
1259
- "Service Type",
1260
- "Happy Path",
1261
- "Request Validation",
1262
- "Auth / Permission / Ownership",
1263
- "Domain Invariants",
1264
- "Collection / Pagination",
1265
- "Date / Time",
1266
- "State Transitions",
1267
- "Dependency Failure",
1268
- "Response Contract",
1269
- "N/A Reasons",
1270
- "Final Status",
1271
- ]
1272
- category_columns = [
1273
- "Happy Path",
1274
- "Request Validation",
1275
- "Auth / Permission / Ownership",
1276
- "Domain Invariants",
1277
- "Collection / Pagination",
1278
- "Date / Time",
1279
- "State Transitions",
1280
- "Dependency Failure",
1281
- "Response Contract",
1282
- ]
1283
- allowed_final_statuses = {
1284
- "boundary-covered",
1285
- "generated-needs-review",
1286
- "skipped-with-reason",
1287
- }
1288
- incomplete_markers = {
1289
- "boundary-missing",
1290
- "happy-path-only",
1291
- "unresolved",
1292
- }
1293
-
1294
- matrix_section = _extract_report_section(report_text, "## Boundary Matrix")
1295
- if not matrix_section:
1296
- return False
1297
- matrix_lines = [line.strip() for line in matrix_section.splitlines()
1298
- if line.strip().startswith("|")]
1299
- headers = []
1300
- rows = []
1301
- for line in matrix_lines:
1302
- cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
1303
- if not headers:
1304
- if "Interface" in cells and "Final Status" in cells:
1305
- headers = cells
1306
- continue
1307
- if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
1308
- continue
1309
- if len(cells) < len(headers):
1310
- cells.extend([""] * (len(headers) - len(cells)))
1311
- row = {header: cells[i] if i < len(cells) else ""
1312
- for i, header in enumerate(headers)}
1313
- if row.get("Interface", "").strip(".` ") in {"", "..."}:
1314
- continue
1315
- rows.append(row)
1316
-
1317
- if not headers or not rows:
1318
- return False
1319
- if any(column not in headers for column in required_columns):
1320
- return False
1321
-
1322
- for row in rows:
1323
- final_status = row.get("Final Status", "").strip().lower()
1324
- if final_status not in allowed_final_statuses:
1325
- return False
1326
- for column in category_columns:
1327
- if not row.get(column, "").strip():
1328
- return False
1329
- for value in row.values():
1330
- normalized = value.strip().lower()
1331
- if any(marker in normalized for marker in incomplete_markers):
1332
- return False
1333
-
1334
- openapi_file = _discover_openapi_file(project_root)
1335
- if openapi_file:
1336
- operations = _extract_openapi_operations(_read_text(openapi_file))
1337
- matrix_operations, _matrix_path_only = _extract_matrix_operations(rows)
1338
- for method, path in operations:
1339
- if (method, path) not in matrix_operations:
1340
- return False
1341
-
1342
- gate_section = _extract_report_section(report_text,
1343
- "## Boundary Completion Gate")
1344
- if not gate_section:
1345
- return False
1346
- completion_gate_passed = False
1347
- boundary_missing_seen = False
1348
- happy_path_only_seen = False
1349
- for raw_line in gate_section.splitlines():
1350
- line = raw_line.strip()
1351
- match = re.match(r"-?\s*(Boundary-missing|Happy-path-only):\s*(\d+)\s*$",
1352
- line, re.IGNORECASE)
1353
- if match:
1354
- if match.group(1).lower() == "boundary-missing":
1355
- boundary_missing_seen = True
1356
- if match.group(1).lower() == "happy-path-only":
1357
- happy_path_only_seen = True
1358
- if int(match.group(2)) > 0:
1359
- return False
1360
- if re.match(r"-?\s*Completion gate passed:\s*yes\s*$",
1361
- line, re.IGNORECASE):
1362
- completion_gate_passed = True
1363
- if re.match(r"-?\s*Completion gate passed:\s*no\b",
1364
- line, re.IGNORECASE):
1365
- return False
1366
- if not (completion_gate_passed and boundary_missing_seen and
1367
- happy_path_only_seen):
1368
- return False
1369
-
1370
- validation_section = _extract_report_section(report_text,
1371
- "## Boundary Validation")
1372
- if not validation_section:
1373
- return False
1374
- validator_result = ""
1375
- for raw_line in validation_section.splitlines():
1376
- line = raw_line.strip()
1377
- match = re.match(r"-?\s*Validator result:\s*(\S+)",
1378
- line, re.IGNORECASE)
1379
- if match:
1380
- validator_result = match.group(1).strip().lower()
1381
- break
1382
-
1383
- return validator_result == "passed"
1384
-
1385
-
1386
- def _extract_scope_list(report_text, list_name):
1387
- """Extract a nested file list from the report Scope section."""
1388
- in_scope = False
1389
- in_list = False
1390
- list_marker = "- {}:".format(list_name)
1391
- values = []
1392
- for raw_line in report_text.splitlines():
1393
- line = raw_line.strip()
1394
- if line == "## Scope":
1395
- in_scope = True
1396
- continue
1397
- if in_scope and line.startswith("## "):
1398
- break
1399
- if not in_scope:
1400
- continue
1401
- if line == list_marker:
1402
- in_list = True
1403
- continue
1404
- if in_list:
1405
- if raw_line.startswith(" -") or raw_line.startswith("\t-"):
1406
- value = line[2:].strip().strip("`")
1407
- if value and value not in {"{...}", "..."}:
1408
- values.append(value)
1409
- continue
1410
- if line.startswith("- "):
1411
- break
1412
- return values
1413
-
1414
-
1415
- def _scoped_report_fresh(project_root, pointer_artifact, report_path, report_text):
1416
- """Return whether the report is newer than the gate marker and in-scope files."""
1417
- marker_path = os.path.join(
1418
- project_root,
1419
- os.path.dirname(pointer_artifact),
1420
- ".prizmkit-test-started",
1421
- )
1422
- if not os.path.exists(marker_path):
1423
- return False
1424
-
1425
- try:
1426
- report_mtime = os.path.getmtime(report_path)
1427
- marker_mtime = os.path.getmtime(marker_path)
1428
- except OSError:
1429
- return False
1430
-
1431
- if report_mtime < marker_mtime:
1432
- return False
1433
-
1434
- project_root_real = os.path.realpath(project_root)
1435
- source_files = _extract_scope_list(report_text, "In-Scope Source Files")
1436
- test_files = _extract_scope_list(report_text, "In-Scope Test Files")
1437
- if not source_files or not test_files:
1438
- return False
1439
-
1440
- ignored = {"none", "n/a", "not applicable", "(none)", "{...}", "..."}
1441
- for listed_file in source_files + test_files:
1442
- normalized = listed_file.strip().strip("`")
1443
- if not normalized or normalized.lower() in ignored:
1444
- return False
1445
- candidate = (normalized if os.path.isabs(normalized)
1446
- else os.path.join(project_root, normalized))
1447
- candidate_real = os.path.realpath(candidate)
1448
- try:
1449
- if os.path.commonpath([project_root_real, candidate_real]) != project_root_real:
1450
- return False
1451
- except ValueError:
1452
- return False
1453
- if not os.path.isfile(candidate_real):
1454
- return False
1455
- if os.path.getmtime(candidate_real) > report_mtime:
1456
- return False
1457
-
1458
- return True
1459
-
1460
-
1461
- def _validate_legacy_scoped_test_report(project_root, artifacts):
1462
- """Validate the legacy Markdown report for old callers only.
1463
-
1464
- Feature checkpoints must use ``_validate_scoped_test_report`` below; this
1465
- retained helper is intentionally not used by Feature prompt generation.
1466
- """
1467
- pointer_artifacts = [a for a in artifacts
1468
- if a.endswith("/test-report-path.txt")]
1469
- if not pointer_artifacts:
1470
- return False
1471
-
1472
- pointer_artifact = pointer_artifacts[0]
1473
- pointer_path = os.path.join(project_root, pointer_artifact)
1474
- pointed_report = _read_text(pointer_path).strip().splitlines()
1475
- if not pointed_report:
1476
- return False
1477
-
1478
- report_path = pointed_report[0].strip()
1479
- if not os.path.isabs(report_path):
1480
- report_path = os.path.join(project_root, report_path)
1481
- if not os.path.exists(report_path):
1482
- return False
1483
- if not _report_path_matches_artifacts(project_root, report_path, artifacts):
1484
- return False
1485
-
1486
- report_text = _read_text(report_path)
1487
- mode = _extract_scope_field(report_text, "Mode")
1488
- artifact_dir = _extract_scope_field(report_text, "Artifact Dir")
1489
- verdict = _extract_report_verdict(report_text)
1490
-
1491
- expected_artifact_dir = _normalize_artifact_dir(
1492
- os.path.dirname(pointer_artifact)
1493
- )
1494
- actual_artifact_dir = _normalize_artifact_dir(artifact_dir)
1495
-
1496
- return (mode == "this-change" and
1497
- actual_artifact_dir == expected_artifact_dir and
1498
- verdict == "PASS" and
1499
- _boundary_report_sections_valid(project_root, report_text) and
1500
- _scoped_report_fresh(project_root, pointer_artifact,
1501
- report_path, report_text))
1502
-
1503
-
1504
- def _validate_scoped_test_report(project_root, artifacts):
1505
- """Validate current Feature structured evidence, never Markdown authority."""
1506
- pointer_artifacts = [a for a in artifacts if a.endswith("/test-report-path.txt")]
1507
- if not pointer_artifacts:
1508
- return False
1509
- return validate_feature_test_evidence(
1510
- project_root, os.path.dirname(pointer_artifacts[0]),
1511
- ) is not None
1512
-
1513
-
1514
733
  def _completed_step_artifacts_valid(project_root, skill_key, artifacts):
1515
734
  """Return whether a completed checkpoint step can be preserved."""
1516
- if not all(_artifact_exists(project_root, a) for a in artifacts):
1517
- return False
1518
735
  if skill_key == "prizmkit-test":
1519
- return _validate_scoped_test_report(project_root, artifacts)
1520
- return True
736
+ return completed_test_artifacts_valid(project_root, artifacts)
737
+ return all(artifact_exists(project_root, artifact) for artifact in artifacts)
1521
738
 
1522
739
 
1523
740
  def generate_checkpoint_definition(sections, pipeline_mode, workflow_type,
@@ -2257,7 +1474,7 @@ def main():
2257
1474
  checkpoint_dir, "workflow-checkpoint.json",
2258
1475
  )
2259
1476
 
2260
- # L4 metadata remains separate from the L1 workflow-state contract.
1477
+ # Pipeline metadata remains separate from the skill workflow-state contract.
2261
1478
  checkpoint["feature_state"] = feature_checkpoint_metadata(
2262
1479
  os.path.join(".prizmkit", "specs", feature_slug),
2263
1480
  stage="plan",