prizmkit 1.1.86 → 1.1.88

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 (45) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +462 -4
  3. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +446 -0
  4. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  5. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +14 -0
  6. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +18 -3
  7. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +18 -3
  8. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +2 -2
  9. package/bundled/dev-pipeline/templates/sections/phase-commit.md +2 -2
  10. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +39 -0
  11. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -1
  12. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -1
  13. package/bundled/dev-pipeline/tests/conftest.py +1 -0
  14. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +434 -0
  15. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +107 -0
  16. package/bundled/dev-pipeline-windows/scripts/generate-bootstrap-prompt.py +462 -4
  17. package/bundled/dev-pipeline-windows/scripts/prizmkit-test-gate.py +446 -0
  18. package/bundled/dev-pipeline-windows/templates/bootstrap-prompt.md +1 -1
  19. package/bundled/dev-pipeline-windows/templates/bootstrap-tier1.md +14 -0
  20. package/bundled/dev-pipeline-windows/templates/bootstrap-tier2.md +18 -3
  21. package/bundled/dev-pipeline-windows/templates/bootstrap-tier3.md +18 -3
  22. package/bundled/dev-pipeline-windows/templates/sections/phase-commit-full.md +2 -2
  23. package/bundled/dev-pipeline-windows/templates/sections/phase-commit.md +2 -2
  24. package/bundled/dev-pipeline-windows/templates/sections/phase-prizmkit-test.md +39 -0
  25. package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +5 -1
  26. package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +5 -1
  27. package/bundled/skills/_metadata.json +1 -1
  28. package/bundled/skills/prizmkit-code-review/SKILL.md +7 -2
  29. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +6 -2
  30. package/bundled/skills/prizmkit-test/SKILL.md +106 -14
  31. package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +220 -0
  32. package/bundled/skills/prizmkit-test/references/examples.md +82 -9
  33. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +68 -12
  34. package/bundled/skills/prizmkit-test/references/test-report-template.md +73 -9
  35. package/bundled/skills/prizmkit-test/scripts/validate_boundary_report.py +347 -0
  36. package/bundled/skills-windows/prizmkit-code-review/SKILL.md +7 -2
  37. package/bundled/skills-windows/prizmkit-code-review/references/reviewer-agent-prompt.md +6 -2
  38. package/bundled/skills-windows/prizmkit-test/SKILL.md +106 -14
  39. package/bundled/skills-windows/prizmkit-test/references/boundary-coverage-protocol.md +220 -0
  40. package/bundled/skills-windows/prizmkit-test/references/examples.md +82 -9
  41. package/bundled/skills-windows/prizmkit-test/references/service-boundary-test-catalog.md +4 -4
  42. package/bundled/skills-windows/prizmkit-test/references/test-generation-steps.md +68 -12
  43. package/bundled/skills-windows/prizmkit-test/references/test-report-template.md +73 -9
  44. package/bundled/skills-windows/prizmkit-test/scripts/validate_boundary_report.py +347 -0
  45. package/package.json +1 -1
@@ -21,6 +21,7 @@ Usage:
21
21
  """
22
22
 
23
23
  import argparse
24
+ import glob
24
25
  import json
25
26
  import os
26
27
  import re
@@ -661,6 +662,11 @@ SECTION_TO_SKILL = {
661
662
  [".prizmkit/specs/{slug}/plan.md"]),
662
663
  "phase-critic-plan": ("critic-plan-review", "Critic: Plan Review", []),
663
664
  "phase-implement": ("prizmkit-implement", "Implement + Test", []),
665
+ "phase-prizmkit-test": (
666
+ "prizmkit-test", "Scoped Feature Test Gate",
667
+ [".prizmkit/specs/{slug}/test-report-path.txt",
668
+ ".prizmkit/test/*/test-report.md"],
669
+ ),
664
670
  "phase-review": ("prizmkit-code-review", "Code Review", []),
665
671
  "phase-browser": ("browser-verification", "Browser Verification", []),
666
672
  "phase-commit": None, # special: split into retrospective + committer
@@ -678,6 +684,446 @@ def _resolve_artifacts(artifact_templates, slug):
678
684
  return [a.replace("{slug}", slug) for a in artifact_templates]
679
685
 
680
686
 
687
+ def _artifact_exists(project_root, artifact):
688
+ """Return whether an artifact path or glob exists under project_root."""
689
+ full_path = os.path.join(project_root, artifact)
690
+ if glob.has_magic(full_path):
691
+ return bool(glob.glob(full_path))
692
+ return os.path.exists(full_path)
693
+
694
+
695
+ def _read_text(path):
696
+ """Read a UTF-8 text file, returning an empty string on IO errors."""
697
+ try:
698
+ with open(path, "r", encoding="utf-8") as f:
699
+ return f.read()
700
+ except IOError:
701
+ return ""
702
+
703
+
704
+ def _normalize_artifact_dir(path):
705
+ """Normalize report artifact_dir values for comparison."""
706
+ return path.strip().replace("\\", "/").rstrip("/")
707
+
708
+
709
+ def _report_path_matches_artifacts(project_root, report_path, artifacts):
710
+ """Return whether report_path is an in-project test report artifact."""
711
+ test_root = os.path.realpath(
712
+ os.path.join(project_root, ".prizmkit", "test")
713
+ )
714
+ resolved_report = os.path.realpath(report_path)
715
+
716
+ try:
717
+ if os.path.commonpath([test_root, resolved_report]) != test_root:
718
+ return False
719
+ except ValueError:
720
+ return False
721
+
722
+ report_artifacts = [a for a in artifacts if a.endswith("test-report.md")]
723
+ for artifact in report_artifacts:
724
+ artifact_path = os.path.join(project_root, artifact)
725
+ if glob.has_magic(artifact_path):
726
+ candidates = glob.glob(artifact_path)
727
+ else:
728
+ candidates = [artifact_path]
729
+ for candidate in candidates:
730
+ if os.path.realpath(candidate) == resolved_report:
731
+ return True
732
+ return False
733
+
734
+
735
+ def _extract_scope_field(report_text, field_name):
736
+ """Extract a '- Field: value' entry from the report Scope section."""
737
+ in_scope = False
738
+ prefix = "- {}:".format(field_name)
739
+ for raw_line in report_text.splitlines():
740
+ line = raw_line.strip()
741
+ if line == "## Scope":
742
+ in_scope = True
743
+ continue
744
+ if in_scope and line.startswith("## "):
745
+ break
746
+ if in_scope and line.startswith(prefix):
747
+ return line[len(prefix):].strip()
748
+ return ""
749
+
750
+
751
+ def _extract_report_verdict(report_text):
752
+ """Extract the first non-empty line after the report Verdict heading."""
753
+ in_verdict = False
754
+ for raw_line in report_text.splitlines():
755
+ line = raw_line.strip()
756
+ if line == "## Verdict":
757
+ in_verdict = True
758
+ continue
759
+ if in_verdict:
760
+ if line.startswith("## "):
761
+ break
762
+ if line:
763
+ return line
764
+ return ""
765
+
766
+
767
+ def _extract_report_section(report_text, heading):
768
+ """Extract text under a level-2 markdown heading."""
769
+ in_section = False
770
+ lines = []
771
+ for raw_line in report_text.splitlines():
772
+ line = raw_line.strip()
773
+ if line == heading:
774
+ in_section = True
775
+ continue
776
+ if in_section and line.startswith("## "):
777
+ break
778
+ if in_section:
779
+ lines.append(raw_line)
780
+ return "\n".join(lines)
781
+
782
+
783
+ def _discover_openapi_file(project_root):
784
+ """Return the first common OpenAPI/Swagger file under project_root."""
785
+ candidates = [
786
+ "openapi.yaml", "openapi.yml", "swagger.yaml", "swagger.yml",
787
+ "docs/openapi.yaml", "docs/openapi.yml",
788
+ "docs/swagger.yaml", "docs/swagger.yml",
789
+ "api/openapi.yaml", "api/openapi.yml",
790
+ "api/swagger.yaml", "api/swagger.yml",
791
+ ]
792
+ for candidate in candidates:
793
+ full_path = os.path.join(project_root, candidate)
794
+ if os.path.isfile(full_path):
795
+ return full_path
796
+ return ""
797
+
798
+
799
+ def _normalize_api_path(path):
800
+ """Normalize API path tokens for exact comparison."""
801
+ normalized = path.strip().strip("'\"`")
802
+ if len(normalized) > 1:
803
+ normalized = normalized.rstrip("/")
804
+ return normalized
805
+
806
+
807
+ def _extract_openapi_operations(openapi_text):
808
+ """Extract OpenAPI method+path operations without requiring PyYAML."""
809
+ methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
810
+ operations = []
811
+ in_paths = False
812
+ paths_indent = 0
813
+ current_path = ""
814
+ current_path_indent = 0
815
+ path_key_re = re.compile(
816
+ r"^\s*(?:(['\"])(/.*?)\1|(/[^:\s]+))\s*:\s*(?:#.*)?$"
817
+ )
818
+ method_re = re.compile(r"^\s*([A-Za-z]+)\s*:\s*(?:#.*)?$")
819
+
820
+ for raw_line in openapi_text.splitlines():
821
+ stripped = raw_line.strip()
822
+ if not stripped or stripped.startswith("#"):
823
+ continue
824
+
825
+ indent = len(raw_line) - len(raw_line.lstrip())
826
+ if not in_paths:
827
+ if re.match(r"^\s*paths\s*:\s*(?:#.*)?$", raw_line):
828
+ in_paths = True
829
+ paths_indent = indent
830
+ continue
831
+
832
+ if indent <= paths_indent:
833
+ break
834
+
835
+ path_match = path_key_re.match(raw_line)
836
+ if path_match:
837
+ current_path = path_match.group(2) or path_match.group(3) or ""
838
+ current_path_indent = indent
839
+ continue
840
+
841
+ method_match = method_re.match(raw_line)
842
+ if current_path and method_match and indent > current_path_indent:
843
+ method = method_match.group(1).upper()
844
+ if method in methods:
845
+ operations.append((method, _normalize_api_path(current_path)))
846
+
847
+ return operations
848
+
849
+
850
+ def _extract_matrix_operations(rows):
851
+ """Extract exact method+path tokens from Boundary Matrix rows."""
852
+ operations = set()
853
+ path_only = set()
854
+ token_re = re.compile(
855
+ r"(?:(\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b)\s+)?(/[^\s,`|)]+)",
856
+ flags=re.IGNORECASE,
857
+ )
858
+ for row in rows:
859
+ interface = row.get("Interface", "")
860
+ for match in token_re.finditer(interface):
861
+ method = match.group(1).upper() if match.group(1) else ""
862
+ path = _normalize_api_path(match.group(2))
863
+ if not path:
864
+ continue
865
+ if method:
866
+ operations.add((method, path))
867
+ else:
868
+ path_only.add(path)
869
+ return operations, path_only
870
+
871
+
872
+ def _boundary_report_sections_valid(project_root, report_text):
873
+ """Return whether required boundary sections do not report failure."""
874
+ required_columns = [
875
+ "Module",
876
+ "Interface",
877
+ "Service Type",
878
+ "Happy Path",
879
+ "Request Validation",
880
+ "Auth / Permission / Ownership",
881
+ "Domain Invariants",
882
+ "Collection / Pagination",
883
+ "Date / Time",
884
+ "State Transitions",
885
+ "Dependency Failure",
886
+ "Response Contract",
887
+ "N/A Reasons",
888
+ "Final Status",
889
+ ]
890
+ category_columns = [
891
+ "Happy Path",
892
+ "Request Validation",
893
+ "Auth / Permission / Ownership",
894
+ "Domain Invariants",
895
+ "Collection / Pagination",
896
+ "Date / Time",
897
+ "State Transitions",
898
+ "Dependency Failure",
899
+ "Response Contract",
900
+ ]
901
+ allowed_final_statuses = {
902
+ "boundary-covered",
903
+ "generated-needs-review",
904
+ "skipped-with-reason",
905
+ }
906
+ incomplete_markers = {
907
+ "boundary-missing",
908
+ "happy-path-only",
909
+ "unresolved",
910
+ }
911
+
912
+ matrix_section = _extract_report_section(report_text, "## Boundary Matrix")
913
+ if not matrix_section:
914
+ return False
915
+ matrix_lines = [line.strip() for line in matrix_section.splitlines()
916
+ if line.strip().startswith("|")]
917
+ headers = []
918
+ rows = []
919
+ for line in matrix_lines:
920
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
921
+ if not headers:
922
+ if "Interface" in cells and "Final Status" in cells:
923
+ headers = cells
924
+ continue
925
+ if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
926
+ continue
927
+ if len(cells) < len(headers):
928
+ cells.extend([""] * (len(headers) - len(cells)))
929
+ row = {header: cells[i] if i < len(cells) else ""
930
+ for i, header in enumerate(headers)}
931
+ if row.get("Interface", "").strip(".` ") in {"", "..."}:
932
+ continue
933
+ rows.append(row)
934
+
935
+ if not headers or not rows:
936
+ return False
937
+ if any(column not in headers for column in required_columns):
938
+ return False
939
+
940
+ for row in rows:
941
+ final_status = row.get("Final Status", "").strip().lower()
942
+ if final_status not in allowed_final_statuses:
943
+ return False
944
+ for column in category_columns:
945
+ if not row.get(column, "").strip():
946
+ return False
947
+ for value in row.values():
948
+ normalized = value.strip().lower()
949
+ if any(marker in normalized for marker in incomplete_markers):
950
+ return False
951
+
952
+ openapi_file = _discover_openapi_file(project_root)
953
+ if openapi_file:
954
+ operations = _extract_openapi_operations(_read_text(openapi_file))
955
+ matrix_operations, _matrix_path_only = _extract_matrix_operations(rows)
956
+ for method, path in operations:
957
+ if (method, path) not in matrix_operations:
958
+ return False
959
+
960
+ gate_section = _extract_report_section(report_text,
961
+ "## Boundary Completion Gate")
962
+ if not gate_section:
963
+ return False
964
+ completion_gate_passed = False
965
+ boundary_missing_seen = False
966
+ happy_path_only_seen = False
967
+ for raw_line in gate_section.splitlines():
968
+ line = raw_line.strip()
969
+ match = re.match(r"-?\s*(Boundary-missing|Happy-path-only):\s*(\d+)\s*$",
970
+ line, re.IGNORECASE)
971
+ if match:
972
+ if match.group(1).lower() == "boundary-missing":
973
+ boundary_missing_seen = True
974
+ if match.group(1).lower() == "happy-path-only":
975
+ happy_path_only_seen = True
976
+ if int(match.group(2)) > 0:
977
+ return False
978
+ if re.match(r"-?\s*Completion gate passed:\s*yes\s*$",
979
+ line, re.IGNORECASE):
980
+ completion_gate_passed = True
981
+ if re.match(r"-?\s*Completion gate passed:\s*no\b",
982
+ line, re.IGNORECASE):
983
+ return False
984
+ if not (completion_gate_passed and boundary_missing_seen and
985
+ happy_path_only_seen):
986
+ return False
987
+
988
+ validation_section = _extract_report_section(report_text,
989
+ "## Boundary Validation")
990
+ if not validation_section:
991
+ return False
992
+ validator_result = ""
993
+ for raw_line in validation_section.splitlines():
994
+ line = raw_line.strip()
995
+ match = re.match(r"-?\s*Validator result:\s*(\S+)",
996
+ line, re.IGNORECASE)
997
+ if match:
998
+ validator_result = match.group(1).strip().lower()
999
+ break
1000
+
1001
+ return validator_result == "passed"
1002
+
1003
+
1004
+ def _extract_scope_list(report_text, list_name):
1005
+ """Extract a nested file list from the report Scope section."""
1006
+ in_scope = False
1007
+ in_list = False
1008
+ list_marker = "- {}:".format(list_name)
1009
+ values = []
1010
+ for raw_line in report_text.splitlines():
1011
+ line = raw_line.strip()
1012
+ if line == "## Scope":
1013
+ in_scope = True
1014
+ continue
1015
+ if in_scope and line.startswith("## "):
1016
+ break
1017
+ if not in_scope:
1018
+ continue
1019
+ if line == list_marker:
1020
+ in_list = True
1021
+ continue
1022
+ if in_list:
1023
+ if raw_line.startswith(" -") or raw_line.startswith("\t-"):
1024
+ value = line[2:].strip().strip("`")
1025
+ if value and value not in {"{...}", "..."}:
1026
+ values.append(value)
1027
+ continue
1028
+ if line.startswith("- "):
1029
+ break
1030
+ return values
1031
+
1032
+
1033
+ def _scoped_report_fresh(project_root, pointer_artifact, report_path, report_text):
1034
+ """Return whether the report is newer than the gate marker and in-scope files."""
1035
+ marker_path = os.path.join(
1036
+ project_root,
1037
+ os.path.dirname(pointer_artifact),
1038
+ ".prizmkit-test-started",
1039
+ )
1040
+ if not os.path.exists(marker_path):
1041
+ return False
1042
+
1043
+ try:
1044
+ report_mtime = os.path.getmtime(report_path)
1045
+ marker_mtime = os.path.getmtime(marker_path)
1046
+ except OSError:
1047
+ return False
1048
+
1049
+ if report_mtime < marker_mtime:
1050
+ return False
1051
+
1052
+ project_root_real = os.path.realpath(project_root)
1053
+ source_files = _extract_scope_list(report_text, "In-Scope Source Files")
1054
+ test_files = _extract_scope_list(report_text, "In-Scope Test Files")
1055
+ if not source_files or not test_files:
1056
+ return False
1057
+
1058
+ ignored = {"none", "n/a", "not applicable", "(none)", "{...}", "..."}
1059
+ for listed_file in source_files + test_files:
1060
+ normalized = listed_file.strip().strip("`")
1061
+ if not normalized or normalized.lower() in ignored:
1062
+ return False
1063
+ candidate = (normalized if os.path.isabs(normalized)
1064
+ else os.path.join(project_root, normalized))
1065
+ candidate_real = os.path.realpath(candidate)
1066
+ try:
1067
+ if os.path.commonpath([project_root_real, candidate_real]) != project_root_real:
1068
+ return False
1069
+ except ValueError:
1070
+ return False
1071
+ if not os.path.isfile(candidate_real):
1072
+ return False
1073
+ if os.path.getmtime(candidate_real) > report_mtime:
1074
+ return False
1075
+
1076
+ return True
1077
+
1078
+
1079
+ def _validate_scoped_test_report(project_root, artifacts):
1080
+ """Validate the feature prizmkit-test pointer and scoped PASS report."""
1081
+ pointer_artifacts = [a for a in artifacts
1082
+ if a.endswith("/test-report-path.txt")]
1083
+ if not pointer_artifacts:
1084
+ return False
1085
+
1086
+ pointer_artifact = pointer_artifacts[0]
1087
+ pointer_path = os.path.join(project_root, pointer_artifact)
1088
+ pointed_report = _read_text(pointer_path).strip().splitlines()
1089
+ if not pointed_report:
1090
+ return False
1091
+
1092
+ report_path = pointed_report[0].strip()
1093
+ if not os.path.isabs(report_path):
1094
+ report_path = os.path.join(project_root, report_path)
1095
+ if not os.path.exists(report_path):
1096
+ return False
1097
+ if not _report_path_matches_artifacts(project_root, report_path, artifacts):
1098
+ return False
1099
+
1100
+ report_text = _read_text(report_path)
1101
+ mode = _extract_scope_field(report_text, "Mode")
1102
+ artifact_dir = _extract_scope_field(report_text, "Artifact Dir")
1103
+ verdict = _extract_report_verdict(report_text)
1104
+
1105
+ expected_artifact_dir = _normalize_artifact_dir(
1106
+ os.path.dirname(pointer_artifact)
1107
+ )
1108
+ actual_artifact_dir = _normalize_artifact_dir(artifact_dir)
1109
+
1110
+ return (mode == "this-change" and
1111
+ actual_artifact_dir == expected_artifact_dir and
1112
+ verdict == "PASS" and
1113
+ _boundary_report_sections_valid(project_root, report_text) and
1114
+ _scoped_report_fresh(project_root, pointer_artifact,
1115
+ report_path, report_text))
1116
+
1117
+
1118
+ def _completed_step_artifacts_valid(project_root, skill_key, artifacts):
1119
+ """Return whether a completed checkpoint step can be preserved."""
1120
+ if not all(_artifact_exists(project_root, a) for a in artifacts):
1121
+ return False
1122
+ if skill_key == "prizmkit-test":
1123
+ return _validate_scoped_test_report(project_root, artifacts)
1124
+ return True
1125
+
1126
+
681
1127
  def generate_checkpoint_definition(sections, pipeline_mode, workflow_type,
682
1128
  item_id, item_slug, session_id,
683
1129
  init_done=False):
@@ -765,17 +1211,24 @@ def merge_checkpoint_state(existing, fresh, project_root):
765
1211
  """
766
1212
  existing_status = {}
767
1213
  existing_artifacts = {}
1214
+ fresh_artifacts = {step["skill"]: step.get("required_artifacts", [])
1215
+ for step in fresh.get("steps", [])}
768
1216
  for step in existing.get("steps", []):
769
1217
  existing_status[step["skill"]] = step["status"]
770
1218
  existing_artifacts[step["skill"]] = step.get("required_artifacts", [])
771
1219
 
772
- # Determine which completed steps have valid artifacts
1220
+ # Determine which completed steps have valid artifacts. Prefer the freshly
1221
+ # generated artifact contract so newly added gates invalidate older
1222
+ # checkpoint entries that only satisfied a weaker contract.
773
1223
  valid_completed = set()
774
1224
  for skill_key, status in existing_status.items():
775
1225
  if status == "completed":
776
- artifacts = existing_artifacts.get(skill_key, [])
777
- if all(os.path.exists(os.path.join(project_root, a))
778
- for a in artifacts):
1226
+ artifacts = fresh_artifacts.get(
1227
+ skill_key, existing_artifacts.get(skill_key, [])
1228
+ )
1229
+ if _completed_step_artifacts_valid(project_root,
1230
+ skill_key,
1231
+ artifacts):
779
1232
  valid_completed.add(skill_key)
780
1233
  else:
781
1234
  LOGGER.warning(
@@ -1083,6 +1536,11 @@ def assemble_sections(pipeline_mode, sections_dir, init_done, is_resume,
1083
1536
  load_section(sections_dir,
1084
1537
  "test-failure-recovery-agent.md")))
1085
1538
 
1539
+ # --- Scoped Feature Test Gate ---
1540
+ sections.append(("phase-prizmkit-test",
1541
+ load_section(sections_dir,
1542
+ "phase-prizmkit-test.md")))
1543
+
1086
1544
  # Verification Gates are included in Task Contract. Keep AC in one place so
1087
1545
  # background context and implementation prompts cannot redefine scope.
1088
1546
  # --- Review (only for agent tiers) ---