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
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.86",
3
- "bundledAt": "2026-06-30T15:38:43.553Z",
4
- "bundledFrom": "d83ca7c"
2
+ "frameworkVersion": "1.1.88",
3
+ "bundledAt": "2026-07-01T15:23:58.266Z",
4
+ "bundledFrom": "2512a9a"
5
5
  }
@@ -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
@@ -623,6 +624,11 @@ SECTION_TO_SKILL = {
623
624
  [".prizmkit/specs/{slug}/plan.md"]),
624
625
  "phase-critic-plan": ("critic-plan-review", "Critic: Plan Review", []),
625
626
  "phase-implement": ("prizmkit-implement", "Implement + Test", []),
627
+ "phase-prizmkit-test": (
628
+ "prizmkit-test", "Scoped Feature Test Gate",
629
+ [".prizmkit/specs/{slug}/test-report-path.txt",
630
+ ".prizmkit/test/*/test-report.md"],
631
+ ),
626
632
  "phase-review": ("prizmkit-code-review", "Code Review", []),
627
633
  "phase-browser": ("browser-verification", "Browser Verification", []),
628
634
  "phase-commit": None, # special: split into retrospective + committer
@@ -640,6 +646,446 @@ def _resolve_artifacts(artifact_templates, slug):
640
646
  return [a.replace("{slug}", slug) for a in artifact_templates]
641
647
 
642
648
 
649
+ def _artifact_exists(project_root, artifact):
650
+ """Return whether an artifact path or glob exists under project_root."""
651
+ full_path = os.path.join(project_root, artifact)
652
+ if glob.has_magic(full_path):
653
+ return bool(glob.glob(full_path))
654
+ return os.path.exists(full_path)
655
+
656
+
657
+ def _read_text(path):
658
+ """Read a UTF-8 text file, returning an empty string on IO errors."""
659
+ try:
660
+ with open(path, "r", encoding="utf-8") as f:
661
+ return f.read()
662
+ except IOError:
663
+ return ""
664
+
665
+
666
+ def _normalize_artifact_dir(path):
667
+ """Normalize report artifact_dir values for comparison."""
668
+ return path.strip().replace("\\", "/").rstrip("/")
669
+
670
+
671
+ def _report_path_matches_artifacts(project_root, report_path, artifacts):
672
+ """Return whether report_path is an in-project test report artifact."""
673
+ test_root = os.path.realpath(
674
+ os.path.join(project_root, ".prizmkit", "test")
675
+ )
676
+ resolved_report = os.path.realpath(report_path)
677
+
678
+ try:
679
+ if os.path.commonpath([test_root, resolved_report]) != test_root:
680
+ return False
681
+ except ValueError:
682
+ return False
683
+
684
+ report_artifacts = [a for a in artifacts if a.endswith("test-report.md")]
685
+ for artifact in report_artifacts:
686
+ artifact_path = os.path.join(project_root, artifact)
687
+ if glob.has_magic(artifact_path):
688
+ candidates = glob.glob(artifact_path)
689
+ else:
690
+ candidates = [artifact_path]
691
+ for candidate in candidates:
692
+ if os.path.realpath(candidate) == resolved_report:
693
+ return True
694
+ return False
695
+
696
+
697
+ def _extract_scope_field(report_text, field_name):
698
+ """Extract a '- Field: value' entry from the report Scope section."""
699
+ in_scope = False
700
+ prefix = "- {}:".format(field_name)
701
+ for raw_line in report_text.splitlines():
702
+ line = raw_line.strip()
703
+ if line == "## Scope":
704
+ in_scope = True
705
+ continue
706
+ if in_scope and line.startswith("## "):
707
+ break
708
+ if in_scope and line.startswith(prefix):
709
+ return line[len(prefix):].strip()
710
+ return ""
711
+
712
+
713
+ def _extract_report_verdict(report_text):
714
+ """Extract the first non-empty line after the report Verdict heading."""
715
+ in_verdict = False
716
+ for raw_line in report_text.splitlines():
717
+ line = raw_line.strip()
718
+ if line == "## Verdict":
719
+ in_verdict = True
720
+ continue
721
+ if in_verdict:
722
+ if line.startswith("## "):
723
+ break
724
+ if line:
725
+ return line
726
+ return ""
727
+
728
+
729
+ def _extract_report_section(report_text, heading):
730
+ """Extract text under a level-2 markdown heading."""
731
+ in_section = False
732
+ lines = []
733
+ for raw_line in report_text.splitlines():
734
+ line = raw_line.strip()
735
+ if line == heading:
736
+ in_section = True
737
+ continue
738
+ if in_section and line.startswith("## "):
739
+ break
740
+ if in_section:
741
+ lines.append(raw_line)
742
+ return "\n".join(lines)
743
+
744
+
745
+ def _discover_openapi_file(project_root):
746
+ """Return the first common OpenAPI/Swagger file under project_root."""
747
+ candidates = [
748
+ "openapi.yaml", "openapi.yml", "swagger.yaml", "swagger.yml",
749
+ "docs/openapi.yaml", "docs/openapi.yml",
750
+ "docs/swagger.yaml", "docs/swagger.yml",
751
+ "api/openapi.yaml", "api/openapi.yml",
752
+ "api/swagger.yaml", "api/swagger.yml",
753
+ ]
754
+ for candidate in candidates:
755
+ full_path = os.path.join(project_root, candidate)
756
+ if os.path.isfile(full_path):
757
+ return full_path
758
+ return ""
759
+
760
+
761
+ def _normalize_api_path(path):
762
+ """Normalize API path tokens for exact comparison."""
763
+ normalized = path.strip().strip("'\"`")
764
+ if len(normalized) > 1:
765
+ normalized = normalized.rstrip("/")
766
+ return normalized
767
+
768
+
769
+ def _extract_openapi_operations(openapi_text):
770
+ """Extract OpenAPI method+path operations without requiring PyYAML."""
771
+ methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
772
+ operations = []
773
+ in_paths = False
774
+ paths_indent = 0
775
+ current_path = ""
776
+ current_path_indent = 0
777
+ path_key_re = re.compile(
778
+ r"^\s*(?:(['\"])(/.*?)\1|(/[^:\s]+))\s*:\s*(?:#.*)?$"
779
+ )
780
+ method_re = re.compile(r"^\s*([A-Za-z]+)\s*:\s*(?:#.*)?$")
781
+
782
+ for raw_line in openapi_text.splitlines():
783
+ stripped = raw_line.strip()
784
+ if not stripped or stripped.startswith("#"):
785
+ continue
786
+
787
+ indent = len(raw_line) - len(raw_line.lstrip())
788
+ if not in_paths:
789
+ if re.match(r"^\s*paths\s*:\s*(?:#.*)?$", raw_line):
790
+ in_paths = True
791
+ paths_indent = indent
792
+ continue
793
+
794
+ if indent <= paths_indent:
795
+ break
796
+
797
+ path_match = path_key_re.match(raw_line)
798
+ if path_match:
799
+ current_path = path_match.group(2) or path_match.group(3) or ""
800
+ current_path_indent = indent
801
+ continue
802
+
803
+ method_match = method_re.match(raw_line)
804
+ if current_path and method_match and indent > current_path_indent:
805
+ method = method_match.group(1).upper()
806
+ if method in methods:
807
+ operations.append((method, _normalize_api_path(current_path)))
808
+
809
+ return operations
810
+
811
+
812
+ def _extract_matrix_operations(rows):
813
+ """Extract exact method+path tokens from Boundary Matrix rows."""
814
+ operations = set()
815
+ path_only = set()
816
+ token_re = re.compile(
817
+ r"(?:(\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b)\s+)?(/[^\s,`|)]+)",
818
+ flags=re.IGNORECASE,
819
+ )
820
+ for row in rows:
821
+ interface = row.get("Interface", "")
822
+ for match in token_re.finditer(interface):
823
+ method = match.group(1).upper() if match.group(1) else ""
824
+ path = _normalize_api_path(match.group(2))
825
+ if not path:
826
+ continue
827
+ if method:
828
+ operations.add((method, path))
829
+ else:
830
+ path_only.add(path)
831
+ return operations, path_only
832
+
833
+
834
+ def _boundary_report_sections_valid(project_root, report_text):
835
+ """Return whether required boundary sections do not report failure."""
836
+ required_columns = [
837
+ "Module",
838
+ "Interface",
839
+ "Service Type",
840
+ "Happy Path",
841
+ "Request Validation",
842
+ "Auth / Permission / Ownership",
843
+ "Domain Invariants",
844
+ "Collection / Pagination",
845
+ "Date / Time",
846
+ "State Transitions",
847
+ "Dependency Failure",
848
+ "Response Contract",
849
+ "N/A Reasons",
850
+ "Final Status",
851
+ ]
852
+ category_columns = [
853
+ "Happy Path",
854
+ "Request Validation",
855
+ "Auth / Permission / Ownership",
856
+ "Domain Invariants",
857
+ "Collection / Pagination",
858
+ "Date / Time",
859
+ "State Transitions",
860
+ "Dependency Failure",
861
+ "Response Contract",
862
+ ]
863
+ allowed_final_statuses = {
864
+ "boundary-covered",
865
+ "generated-needs-review",
866
+ "skipped-with-reason",
867
+ }
868
+ incomplete_markers = {
869
+ "boundary-missing",
870
+ "happy-path-only",
871
+ "unresolved",
872
+ }
873
+
874
+ matrix_section = _extract_report_section(report_text, "## Boundary Matrix")
875
+ if not matrix_section:
876
+ return False
877
+ matrix_lines = [line.strip() for line in matrix_section.splitlines()
878
+ if line.strip().startswith("|")]
879
+ headers = []
880
+ rows = []
881
+ for line in matrix_lines:
882
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
883
+ if not headers:
884
+ if "Interface" in cells and "Final Status" in cells:
885
+ headers = cells
886
+ continue
887
+ if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
888
+ continue
889
+ if len(cells) < len(headers):
890
+ cells.extend([""] * (len(headers) - len(cells)))
891
+ row = {header: cells[i] if i < len(cells) else ""
892
+ for i, header in enumerate(headers)}
893
+ if row.get("Interface", "").strip(".` ") in {"", "..."}:
894
+ continue
895
+ rows.append(row)
896
+
897
+ if not headers or not rows:
898
+ return False
899
+ if any(column not in headers for column in required_columns):
900
+ return False
901
+
902
+ for row in rows:
903
+ final_status = row.get("Final Status", "").strip().lower()
904
+ if final_status not in allowed_final_statuses:
905
+ return False
906
+ for column in category_columns:
907
+ if not row.get(column, "").strip():
908
+ return False
909
+ for value in row.values():
910
+ normalized = value.strip().lower()
911
+ if any(marker in normalized for marker in incomplete_markers):
912
+ return False
913
+
914
+ openapi_file = _discover_openapi_file(project_root)
915
+ if openapi_file:
916
+ operations = _extract_openapi_operations(_read_text(openapi_file))
917
+ matrix_operations, _matrix_path_only = _extract_matrix_operations(rows)
918
+ for method, path in operations:
919
+ if (method, path) not in matrix_operations:
920
+ return False
921
+
922
+ gate_section = _extract_report_section(report_text,
923
+ "## Boundary Completion Gate")
924
+ if not gate_section:
925
+ return False
926
+ completion_gate_passed = False
927
+ boundary_missing_seen = False
928
+ happy_path_only_seen = False
929
+ for raw_line in gate_section.splitlines():
930
+ line = raw_line.strip()
931
+ match = re.match(r"-?\s*(Boundary-missing|Happy-path-only):\s*(\d+)\s*$",
932
+ line, re.IGNORECASE)
933
+ if match:
934
+ if match.group(1).lower() == "boundary-missing":
935
+ boundary_missing_seen = True
936
+ if match.group(1).lower() == "happy-path-only":
937
+ happy_path_only_seen = True
938
+ if int(match.group(2)) > 0:
939
+ return False
940
+ if re.match(r"-?\s*Completion gate passed:\s*yes\s*$",
941
+ line, re.IGNORECASE):
942
+ completion_gate_passed = True
943
+ if re.match(r"-?\s*Completion gate passed:\s*no\b",
944
+ line, re.IGNORECASE):
945
+ return False
946
+ if not (completion_gate_passed and boundary_missing_seen and
947
+ happy_path_only_seen):
948
+ return False
949
+
950
+ validation_section = _extract_report_section(report_text,
951
+ "## Boundary Validation")
952
+ if not validation_section:
953
+ return False
954
+ validator_result = ""
955
+ for raw_line in validation_section.splitlines():
956
+ line = raw_line.strip()
957
+ match = re.match(r"-?\s*Validator result:\s*(\S+)",
958
+ line, re.IGNORECASE)
959
+ if match:
960
+ validator_result = match.group(1).strip().lower()
961
+ break
962
+
963
+ return validator_result == "passed"
964
+
965
+
966
+ def _extract_scope_list(report_text, list_name):
967
+ """Extract a nested file list from the report Scope section."""
968
+ in_scope = False
969
+ in_list = False
970
+ list_marker = "- {}:".format(list_name)
971
+ values = []
972
+ for raw_line in report_text.splitlines():
973
+ line = raw_line.strip()
974
+ if line == "## Scope":
975
+ in_scope = True
976
+ continue
977
+ if in_scope and line.startswith("## "):
978
+ break
979
+ if not in_scope:
980
+ continue
981
+ if line == list_marker:
982
+ in_list = True
983
+ continue
984
+ if in_list:
985
+ if raw_line.startswith(" -") or raw_line.startswith("\t-"):
986
+ value = line[2:].strip().strip("`")
987
+ if value and value not in {"{...}", "..."}:
988
+ values.append(value)
989
+ continue
990
+ if line.startswith("- "):
991
+ break
992
+ return values
993
+
994
+
995
+ def _scoped_report_fresh(project_root, pointer_artifact, report_path, report_text):
996
+ """Return whether the report is newer than the gate marker and in-scope files."""
997
+ marker_path = os.path.join(
998
+ project_root,
999
+ os.path.dirname(pointer_artifact),
1000
+ ".prizmkit-test-started",
1001
+ )
1002
+ if not os.path.exists(marker_path):
1003
+ return False
1004
+
1005
+ try:
1006
+ report_mtime = os.path.getmtime(report_path)
1007
+ marker_mtime = os.path.getmtime(marker_path)
1008
+ except OSError:
1009
+ return False
1010
+
1011
+ if report_mtime < marker_mtime:
1012
+ return False
1013
+
1014
+ project_root_real = os.path.realpath(project_root)
1015
+ source_files = _extract_scope_list(report_text, "In-Scope Source Files")
1016
+ test_files = _extract_scope_list(report_text, "In-Scope Test Files")
1017
+ if not source_files or not test_files:
1018
+ return False
1019
+
1020
+ ignored = {"none", "n/a", "not applicable", "(none)", "{...}", "..."}
1021
+ for listed_file in source_files + test_files:
1022
+ normalized = listed_file.strip().strip("`")
1023
+ if not normalized or normalized.lower() in ignored:
1024
+ return False
1025
+ candidate = (normalized if os.path.isabs(normalized)
1026
+ else os.path.join(project_root, normalized))
1027
+ candidate_real = os.path.realpath(candidate)
1028
+ try:
1029
+ if os.path.commonpath([project_root_real, candidate_real]) != project_root_real:
1030
+ return False
1031
+ except ValueError:
1032
+ return False
1033
+ if not os.path.isfile(candidate_real):
1034
+ return False
1035
+ if os.path.getmtime(candidate_real) > report_mtime:
1036
+ return False
1037
+
1038
+ return True
1039
+
1040
+
1041
+ def _validate_scoped_test_report(project_root, artifacts):
1042
+ """Validate the feature prizmkit-test pointer and scoped PASS report."""
1043
+ pointer_artifacts = [a for a in artifacts
1044
+ if a.endswith("/test-report-path.txt")]
1045
+ if not pointer_artifacts:
1046
+ return False
1047
+
1048
+ pointer_artifact = pointer_artifacts[0]
1049
+ pointer_path = os.path.join(project_root, pointer_artifact)
1050
+ pointed_report = _read_text(pointer_path).strip().splitlines()
1051
+ if not pointed_report:
1052
+ return False
1053
+
1054
+ report_path = pointed_report[0].strip()
1055
+ if not os.path.isabs(report_path):
1056
+ report_path = os.path.join(project_root, report_path)
1057
+ if not os.path.exists(report_path):
1058
+ return False
1059
+ if not _report_path_matches_artifacts(project_root, report_path, artifacts):
1060
+ return False
1061
+
1062
+ report_text = _read_text(report_path)
1063
+ mode = _extract_scope_field(report_text, "Mode")
1064
+ artifact_dir = _extract_scope_field(report_text, "Artifact Dir")
1065
+ verdict = _extract_report_verdict(report_text)
1066
+
1067
+ expected_artifact_dir = _normalize_artifact_dir(
1068
+ os.path.dirname(pointer_artifact)
1069
+ )
1070
+ actual_artifact_dir = _normalize_artifact_dir(artifact_dir)
1071
+
1072
+ return (mode == "this-change" and
1073
+ actual_artifact_dir == expected_artifact_dir and
1074
+ verdict == "PASS" and
1075
+ _boundary_report_sections_valid(project_root, report_text) and
1076
+ _scoped_report_fresh(project_root, pointer_artifact,
1077
+ report_path, report_text))
1078
+
1079
+
1080
+ def _completed_step_artifacts_valid(project_root, skill_key, artifacts):
1081
+ """Return whether a completed checkpoint step can be preserved."""
1082
+ if not all(_artifact_exists(project_root, a) for a in artifacts):
1083
+ return False
1084
+ if skill_key == "prizmkit-test":
1085
+ return _validate_scoped_test_report(project_root, artifacts)
1086
+ return True
1087
+
1088
+
643
1089
  def generate_checkpoint_definition(sections, pipeline_mode, workflow_type,
644
1090
  item_id, item_slug, session_id,
645
1091
  init_done=False):
@@ -727,17 +1173,24 @@ def merge_checkpoint_state(existing, fresh, project_root):
727
1173
  """
728
1174
  existing_status = {}
729
1175
  existing_artifacts = {}
1176
+ fresh_artifacts = {step["skill"]: step.get("required_artifacts", [])
1177
+ for step in fresh.get("steps", [])}
730
1178
  for step in existing.get("steps", []):
731
1179
  existing_status[step["skill"]] = step["status"]
732
1180
  existing_artifacts[step["skill"]] = step.get("required_artifacts", [])
733
1181
 
734
- # Determine which completed steps have valid artifacts
1182
+ # Determine which completed steps have valid artifacts. Prefer the freshly
1183
+ # generated artifact contract so newly added gates invalidate older
1184
+ # checkpoint entries that only satisfied a weaker contract.
735
1185
  valid_completed = set()
736
1186
  for skill_key, status in existing_status.items():
737
1187
  if status == "completed":
738
- artifacts = existing_artifacts.get(skill_key, [])
739
- if all(os.path.exists(os.path.join(project_root, a))
740
- for a in artifacts):
1188
+ artifacts = fresh_artifacts.get(
1189
+ skill_key, existing_artifacts.get(skill_key, [])
1190
+ )
1191
+ if _completed_step_artifacts_valid(project_root,
1192
+ skill_key,
1193
+ artifacts):
741
1194
  valid_completed.add(skill_key)
742
1195
  else:
743
1196
  LOGGER.warning(
@@ -1045,6 +1498,11 @@ def assemble_sections(pipeline_mode, sections_dir, init_done, is_resume,
1045
1498
  load_section(sections_dir,
1046
1499
  "test-failure-recovery-agent.md")))
1047
1500
 
1501
+ # --- Scoped Feature Test Gate ---
1502
+ sections.append(("phase-prizmkit-test",
1503
+ load_section(sections_dir,
1504
+ "phase-prizmkit-test.md")))
1505
+
1048
1506
  # Verification Gates are included in Task Contract. Keep AC in one place so
1049
1507
  # background context and implementation prompts cannot redefine scope.
1050
1508
  # --- Review (only for agent tiers) ---