prizmkit 1.1.131 → 1.1.135

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 (65) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/adapters/claude/command-adapter.js +0 -2
  3. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +11 -6
  4. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +2 -9
  5. package/bundled/dev-pipeline/prizmkit_runtime/processes.py +21 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +104 -17
  7. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +3 -2
  8. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +15 -13
  9. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +3 -13
  10. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +5 -4
  11. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -1
  12. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +4 -4
  13. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +9 -9
  14. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +4 -7
  15. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
  16. package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +2 -2
  17. package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +5 -10
  18. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +5 -5
  19. package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +1 -1
  20. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +1 -1
  21. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +2 -3
  22. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +2 -2
  23. package/bundled/dev-pipeline/templates/sections/phase-commit.md +2 -2
  24. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -5
  25. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -5
  26. package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +1 -1
  27. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +5 -5
  28. package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
  29. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  30. package/bundled/dev-pipeline/templates/sections/task-contract.md +4 -0
  31. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +39 -26
  32. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +7 -10
  33. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +3 -3
  34. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +156 -5
  35. package/bundled/dev-pipeline/tests/test_unified_cli.py +34 -7
  36. package/bundled/skills/_metadata.json +3 -3
  37. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +3 -3
  38. package/bundled/skills/prizmkit-code-review/SKILL.md +22 -6
  39. package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +243 -0
  40. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +41 -0
  41. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +3 -3
  42. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +93 -1
  43. package/bundled/skills/prizmkit-committer/SKILL.md +6 -13
  44. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +3 -3
  45. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +3 -3
  46. package/bundled/skills/prizmkit-plan/SKILL.md +17 -3
  47. package/bundled/skills/prizmkit-plan/assets/plan-template.md +18 -0
  48. package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +242 -0
  49. package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +4 -4
  50. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +3 -3
  51. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +3 -3
  52. package/bundled/skills/prizmkit-test/SKILL.md +26 -3
  53. package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +147 -14
  54. package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +1 -1
  55. package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +15 -0
  56. package/bundled/skills/prizmkit-test/references/evidence-protocol.md +6 -1
  57. package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +8 -1
  58. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +5 -2
  59. package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +15 -0
  60. package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +3 -3
  61. package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +1144 -27
  62. package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +336 -18
  63. package/bundled/skills/prizmkit-workflow/SKILL.md +1 -1
  64. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +3 -3
  65. package/package.json +1 -1
@@ -38,14 +38,15 @@ LAYER_ORDER = {
38
38
  "regression-ring": 4,
39
39
  }
40
40
  FINAL_RECORDS = {"validation.json", "verdict.json", "test-report.md"}
41
+ ATTESTATION_MUTABLE = {"validation.json"}
41
42
  COMMON_RECORDS = {
42
43
  "change-classification.json", "scope.json", "target-inventory.json",
43
44
  "environment.json", "executions.json", "verdict.json", "validation.json",
44
45
  "source-change.patch", "test-report.md",
45
46
  }
46
47
  BEHAVIOR_RECORDS = {
47
- "behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
48
- "differential-proof.json",
48
+ "behavior-risk-matrix.json", "test-plan.json",
49
+ "infrastructure-changes.json", "differential-proof.json",
49
50
  }
50
51
  LIGHTWEIGHT_RECORDS = {"lightweight-verification.json"}
51
52
  SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
@@ -54,9 +55,13 @@ BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
54
55
  SNAPSHOT_VOLATILE_ROOTS = (
55
56
  ".prizmkit/state",
56
57
  ".prizmkit/test/evidence",
58
+ ".prizmkit/" + "dev-" + "pipeline",
57
59
  ".claude/worktrees",
60
+ ".agents/worktrees",
61
+ ".codebuddy/worktrees",
62
+ ".codex/worktrees",
58
63
  )
59
- SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
64
+ SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
60
65
  RISK_CONFLICT_PATTERNS = {
61
66
  "permission": re.compile(r"auth|tenant|role|permission|access|acl|rbac", re.I),
62
67
  "concurrency": re.compile(r"lock|shared[ _-]?state|worker|queue|concurr|parallel|race", re.I),
@@ -219,6 +224,18 @@ def validate_schema(
219
224
  """Validate the strict JSON-Schema subset used by skill-owned schemas."""
220
225
  schema = resolve_ref(schema, root_schema)
221
226
 
227
+ one_of = schema.get("oneOf")
228
+ if isinstance(one_of, list):
229
+ matching = 0
230
+ for branch in one_of:
231
+ if not isinstance(branch, dict):
232
+ continue
233
+ branch_errors: list[str] = []
234
+ validate_schema(value, branch, root_schema, location, branch_errors)
235
+ if not branch_errors:
236
+ matching += 1
237
+ require(matching == 1, f"{location}: value must match exactly one schema branch", errors)
238
+
222
239
  for child in schema.get("allOf", []):
223
240
  if isinstance(child, dict):
224
241
  validate_schema(value, child, root_schema, location, errors)
@@ -341,6 +358,31 @@ def require_manifest_entry(
341
358
  return path
342
359
 
343
360
 
361
+ def validate_differential_na(
362
+ value: Any,
363
+ location: str,
364
+ errors: list[str],
365
+ ) -> None:
366
+ """Validate the differential-only N/A shape without weakening risk-cell N/A."""
367
+ if not isinstance(value, dict):
368
+ errors.append(f"{location}: differential N/A must be an object")
369
+ return
370
+ required = {"reason", "rationale", "evidence", "considered_signals", "conflicts"}
371
+ require(set(value) == required, f"{location}: differential N/A fields are incomplete", errors)
372
+ require(value.get("reason") in {"new-behavior", "textual-contract"}, f"{location}: invalid differential N/A reason", errors)
373
+ rationale = value.get("rationale")
374
+ require(isinstance(rationale, str) and len(rationale.strip()) >= 16, f"{location}: differential N/A rationale is too short", errors)
375
+ if value.get("reason") == "new-behavior" and isinstance(rationale, str):
376
+ require("no stable pre-change observable" in rationale.lower(), f"{location}: new behavior N/A lacks baseline rationale", errors)
377
+ if value.get("reason") == "textual-contract" and isinstance(rationale, str):
378
+ require("textual" in rationale.lower(), f"{location}: textual contract N/A lacks textual rationale", errors)
379
+ evidence = value.get("evidence")
380
+ require(isinstance(evidence, list) and bool(evidence) and all(isinstance(item, str) and item.strip() for item in evidence), f"{location}: differential N/A needs evidence strings", errors)
381
+ signals = value.get("considered_signals")
382
+ require(isinstance(signals, list) and bool(signals) and all(isinstance(item, str) and item.strip() for item in signals), f"{location}: differential N/A needs considered signals", errors)
383
+ require(isinstance(value.get("conflicts"), list), f"{location}: differential N/A conflicts must be an array", errors)
384
+
385
+
344
386
  def structured_na(
345
387
  value: Any,
346
388
  records_schema: dict[str, Any],
@@ -503,6 +545,25 @@ def validate_manifest(
503
545
  return entry_map
504
546
 
505
547
 
548
+ def get_captured_deleted_paths(root: Path) -> set[str]:
549
+ capture_path = root / "change-capture.json"
550
+ if not capture_path.is_file():
551
+ return set()
552
+ try:
553
+ capture = load_json(capture_path)
554
+ except EvidenceError:
555
+ return set()
556
+ if not isinstance(capture, dict):
557
+ return set()
558
+ return {
559
+ item["path"]
560
+ for item in capture.get("changed_files", [])
561
+ if isinstance(item, dict)
562
+ and isinstance(item.get("path"), str)
563
+ and (item.get("status", "").strip() == "D" or item.get("role") == "source")
564
+ }
565
+
566
+
506
567
  def validate_inventory(
507
568
  root: Path,
508
569
  project_root: Path,
@@ -569,6 +630,7 @@ def validate_inventory(
569
630
  require(plan.get("plan_inputs") == plan_inputs, "test plan inputs do not match target inventory", errors)
570
631
 
571
632
  patch = require_manifest_entry("source-change.patch", entry_map, root, errors, "source-change patch")
633
+ captured_deleted_paths = get_captured_deleted_paths(root)
572
634
  if patch is not None:
573
635
  require(manifest.get("working_diff_sha256") == file_sha256(patch), "working_diff_sha256 does not match source-change.patch", errors)
574
636
 
@@ -579,7 +641,19 @@ def validate_inventory(
579
641
  request = load_json(request_path)
580
642
  if isinstance(request, dict):
581
643
  require(request.get("changed_files") == inventory.get("changed_files"), "inventory request changed_files mismatch", errors)
582
- require(request.get("module_roots") == inventory.get("module_roots"), "inventory request module_roots mismatch", errors)
644
+ request_roots = request.get("module_roots")
645
+ normalized_request_roots = []
646
+ if isinstance(request_roots, list):
647
+ for item in request_roots:
648
+ if isinstance(item, str):
649
+ normalized_request_roots.append(item)
650
+ elif isinstance(item, dict) and isinstance(item.get("path"), str):
651
+ normalized_request_roots.append(item["path"])
652
+ require(
653
+ normalized_request_roots == inventory.get("module_roots"),
654
+ "inventory request module_roots mismatch",
655
+ errors,
656
+ )
583
657
  require(request.get("exclusions") == scope.get("exclusions"), "inventory request exclusions mismatch", errors)
584
658
  require(request.get("plan_inputs") == plan_inputs, "inventory request plan_inputs mismatch", errors)
585
659
  else:
@@ -594,6 +668,7 @@ def validate_scope(
594
668
  inventory_by_category: dict[str, dict[str, str]],
595
669
  project_root: Path,
596
670
  evidence_root: Path,
671
+ captured_deleted_paths: set[str],
597
672
  errors: list[str],
598
673
  ) -> None:
599
674
  changed_files = scope.get("changed_files")
@@ -614,8 +689,14 @@ def validate_scope(
614
689
  if relative in exclusion_paths:
615
690
  require(relative not in inventory_paths, f"excluded changed file is also inventoried: {relative}", errors)
616
691
  else:
617
- require(candidate is not None and candidate.is_file(), f"changed file is not live: {relative}", errors)
618
- require(relative in inventory_paths, f"changed file is neither inventoried nor excluded: {relative}", errors)
692
+ candidate = safe_path(project_root, relative, errors, "changed file")
693
+ deleted = relative in captured_deleted_paths
694
+ require(
695
+ deleted or (candidate is not None and candidate.is_file()),
696
+ f"changed file is not live: {relative}",
697
+ errors,
698
+ )
699
+ require(relative in inventory_paths or deleted, f"changed file is neither inventoried nor excluded: {relative}", errors)
619
700
 
620
701
  roots = scope.get("module_roots")
621
702
  require(isinstance(roots, list) and bool(roots), "scope module_roots is empty", errors)
@@ -695,7 +776,15 @@ def validate_requests_and_receipts(
695
776
  if not isinstance(request, dict):
696
777
  errors.append(f"request must be an object: {relative}")
697
778
  continue
698
- if "execution_request" in request:
779
+ if set(request) == {"request_version", "layers", "tests"}:
780
+ validate_definition(request, "testPreparationRequest", records_schema, relative, errors)
781
+ elif request.get("method") == "not-applicable":
782
+ validate_definition(request, "differentialRequest", records_schema, relative, errors)
783
+ not_applicable = request.get("not_applicable")
784
+ if isinstance(not_applicable, dict):
785
+ validate_differential_na(not_applicable, f"{relative}.not_applicable", errors)
786
+ differential_requests[relative] = request
787
+ elif "execution_request" in request:
699
788
  validate_definition(request, "differentialRequest", records_schema, relative, errors)
700
789
  nested = request.get("execution_request")
701
790
  if isinstance(nested, dict):
@@ -707,6 +796,17 @@ def validate_requests_and_receipts(
707
796
  validate_execution_request_semantics(request, relative, errors)
708
797
  validate_external_targets(request.get("external_targets"), f"{relative}.external_targets", errors)
709
798
  execution_requests[relative] = request
799
+ elif set(request) == {
800
+ "request_version", "change_class", "artifact_dir",
801
+ } or (
802
+ "request_version" in request
803
+ and "change_class" in request
804
+ and set(request) <= {
805
+ "request_version", "change_class", "baseline_commit", "scope_path",
806
+ "environment_path", "artifact_dir", "blockers", "repair_scope",
807
+ }
808
+ ):
809
+ require(request.get("request_version", "1.0") == "1.0", f"unsupported finalize request version: {relative}", errors)
710
810
  elif set(request) == {
711
811
  "request_version", "categories", "changed_files", "module_roots",
712
812
  "exclusions", "discovery_evidence", "plan_inputs",
@@ -837,13 +937,103 @@ def validate_execution_request_semantics(
837
937
  environment = value.get("environment")
838
938
  require(isinstance(environment, dict) and all(isinstance(key, str) and isinstance(item, str) for key, item in environment.items()), f"{location}: environment must contain complete string values", errors)
839
939
  test_ids = value.get("test_ids")
840
- require(isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids), f"{location}: test_ids must be string IDs", errors)
940
+ test_paths = value.get("test_paths")
941
+ auto_bind = value.get("auto_bind") is True
942
+ require(
943
+ (isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids))
944
+ or (isinstance(test_paths, list) and all(isinstance(item, str) and item for item in test_paths))
945
+ or auto_bind,
946
+ f"{location}: execution must provide test_ids, test_paths, or auto_bind",
947
+ errors,
948
+ )
841
949
  if receipt:
842
950
  require(type(value.get("selected_execution")) is bool, f"{location}: selected_execution must be boolean", errors)
843
951
  require(type(value.get("reliable")) is bool, f"{location}: reliable must be boolean", errors)
844
952
  require(type(value.get("exit_code")) is int and not isinstance(value.get("exit_code"), bool), f"{location}: exit_code must be an integer", errors)
845
953
 
846
954
 
955
+ def changed_test_statuses(root: Path) -> dict[str, str]:
956
+ capture_path = root / "change-capture.json"
957
+ if not capture_path.is_file():
958
+ return {}
959
+ try:
960
+ capture = load_json(capture_path)
961
+ except EvidenceError:
962
+ return {}
963
+ statuses: dict[str, str] = {}
964
+ for item in capture.get("changed_files", []) if isinstance(capture, dict) else []:
965
+ if not isinstance(item, dict) or not isinstance(item.get("path"), str) or item.get("role") == "source":
966
+ continue
967
+ status = str(item.get("status", "")).strip()
968
+ if status == "??" or status.startswith("A"):
969
+ statuses[item["path"]] = "added"
970
+ elif status.startswith("M") or status.endswith("M") or status.startswith(("R", "C")):
971
+ statuses[item["path"]] = "modified"
972
+ return statuses
973
+
974
+
975
+ def validate_canonical_capture(
976
+ root: Path,
977
+ project_root: Path,
978
+ capture: dict[str, Any],
979
+ inventory: dict[str, Any],
980
+ scope: dict[str, Any],
981
+ errors: list[str],
982
+ ) -> None:
983
+ patch_relative = capture.get("patch_path")
984
+ patch = safe_path(root, patch_relative, errors, "canonical change patch") if isinstance(patch_relative, str) else None
985
+ require(patch_relative == "source-change.patch", "canonical capture patch_path must be source-change.patch", errors)
986
+ if patch is not None:
987
+ require(file_sha256(patch) == capture.get("patch_sha256"), "canonical capture patch hash mismatch", errors)
988
+ require(
989
+ capture.get("source_tree_sha256") == tree_sha256(project_root, root),
990
+ "canonical change capture source tree is stale",
991
+ errors,
992
+ )
993
+ captured_paths = {
994
+ item.get("path")
995
+ for item in capture.get("changed_files", [])
996
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
997
+ }
998
+ inventory_changed = set(inventory.get("changed_files", [])) if isinstance(inventory.get("changed_files"), list) else set()
999
+ scope_changed = set(scope.get("changed_files", [])) if isinstance(scope.get("changed_files"), list) else set()
1000
+ require(captured_paths == inventory_changed, "canonical capture and inventory changed_files do not match", errors)
1001
+ require(captured_paths == scope_changed, "canonical capture and scope changed_files do not match", errors)
1002
+
1003
+
1004
+ def validate_test_preparation(
1005
+ root: Path,
1006
+ project_root: Path,
1007
+ preparation: dict[str, Any],
1008
+ plan: dict[str, Any],
1009
+ inventory_by_category: dict[str, dict[str, str]],
1010
+ entry_map: dict[str, dict[str, Any]],
1011
+ records_schema: dict[str, Any],
1012
+ errors: list[str],
1013
+ ) -> None:
1014
+ validate_definition(preparation, "testPreparation", records_schema, "test-preparation", errors)
1015
+ request_relative = preparation.get("request_path")
1016
+ request_path = require_manifest_entry(request_relative, entry_map, root, errors, "test preparation request") if isinstance(request_relative, str) else None
1017
+ if request_path is not None:
1018
+ require(file_sha256(request_path) == preparation.get("request_sha256"), "test preparation request hash mismatch", errors)
1019
+ prepared_tests = preparation.get("tests")
1020
+ require(prepared_tests == plan.get("tests"), "test preparation tests do not match test plan", errors)
1021
+ statuses = preparation.get("changed_test_statuses")
1022
+ require(isinstance(statuses, dict), "test preparation changed_test_statuses must be an object", errors)
1023
+ if not isinstance(statuses, dict):
1024
+ return
1025
+ for relative, status in statuses.items():
1026
+ require(relative in inventory_by_category.get("tests", {}), f"test preparation status is not in test inventory: {relative}", errors)
1027
+ require(status in {"added", "modified"}, f"test preparation has invalid changed test status: {relative}", errors)
1028
+ for test in prepared_tests if isinstance(prepared_tests, list) else []:
1029
+ if not isinstance(test, dict):
1030
+ continue
1031
+ relative = test.get("project_path")
1032
+ if isinstance(relative, str):
1033
+ expected = statuses.get(relative, "existing")
1034
+ require(test.get("change_status") == expected, f"test preparation status mismatch: {relative}", errors)
1035
+
1036
+
847
1037
  def validate_plan_and_scope_bindings(
848
1038
  root: Path,
849
1039
  project_root: Path,
@@ -905,6 +1095,8 @@ def validate_plan_and_scope_bindings(
905
1095
  for test_id in receipt.get("test_ids", []):
906
1096
  if isinstance(test_id, str):
907
1097
  selected_by_test.setdefault(test_id, []).append(receipt)
1098
+ changed_tests = changed_test_statuses(root)
1099
+ planned_test_paths: set[str] = set()
908
1100
  known_behavior_ids = {item.get("id") for item in matrix.get("behaviors", []) if isinstance(item, dict)}
909
1101
  for test in tests:
910
1102
  if not isinstance(test, dict):
@@ -920,14 +1112,24 @@ def validate_plan_and_scope_bindings(
920
1112
  for behavior_id in behavior_ids if isinstance(behavior_ids, list) else []:
921
1113
  require(behavior_id in known_behavior_ids, f"planned test maps unknown behavior: {test_id}/{behavior_id}", errors)
922
1114
  project_relative = test.get("project_path")
1115
+ planned_test_paths.add(project_relative)
923
1116
  inventory_relative = test.get("inventory_path")
924
1117
  snapshot_relative = test.get("snapshot_path")
1118
+ inferred_status = changed_tests.get(project_relative, "existing")
1119
+ change_status = test.get("change_status", "modified" if isinstance(snapshot_relative, str) else "existing")
1120
+ if changed_tests:
1121
+ require(change_status == inferred_status, f"planned test change_status does not match canonical capture: {test_id}", errors)
1122
+ require(change_status in {"existing", "added", "modified"}, f"planned test has invalid change_status: {test_id}", errors)
925
1123
  project_path = safe_path(project_root, project_relative, errors, f"planned test {test_id} project_path")
926
1124
  require(project_path is not None and project_path.is_file(), f"planned test live path is missing: {test_id}", errors)
927
1125
  require(inventory_relative == project_relative, f"planned test inventory path differs from live project path: {test_id}", errors)
928
1126
  require(inventory_relative in inventory_by_category.get("tests", {}), f"planned test is not in the test inventory: {test_id}", errors)
929
- snapshot_path = require_manifest_entry(snapshot_relative, entry_map, root, errors, f"planned test snapshot {test_id}") if isinstance(snapshot_relative, str) else None
930
- require(isinstance(snapshot_relative, str) and path_under(snapshot_relative, "generated-tests"), f"planned test snapshot must be under generated-tests/: {test_id}", errors)
1127
+ if change_status == "existing":
1128
+ require(snapshot_relative is None, f"existing test must not have a generated snapshot: {test_id}", errors)
1129
+ snapshot_path = None
1130
+ else:
1131
+ snapshot_path = require_manifest_entry(snapshot_relative, entry_map, root, errors, f"planned test snapshot {test_id}") if isinstance(snapshot_relative, str) else None
1132
+ require(isinstance(snapshot_relative, str) and path_under(snapshot_relative, "generated-tests"), f"planned test snapshot must be under generated-tests/: {test_id}", errors)
931
1133
  if project_path is not None and project_path.is_file() and snapshot_path is not None and snapshot_path.is_file():
932
1134
  require(file_sha256(project_path) == file_sha256(snapshot_path), f"generated test snapshot differs from live test: {test_id}", errors)
933
1135
  test_layers = test.get("layers")
@@ -939,6 +1141,19 @@ def validate_plan_and_scope_bindings(
939
1141
  for receipt in matching_receipts:
940
1142
  require(receipt.get("layer") in test_layers, f"planned test receipt uses an unplanned layer: {test_id}", errors)
941
1143
 
1144
+ for relative, status in changed_tests.items():
1145
+ if relative in inventory_by_category.get("tests", {}) and relative not in planned_test_paths:
1146
+ errors.append(f"changed native test is not bound by test plan: {relative}")
1147
+ generated_dir = root / "generated-tests"
1148
+ referenced_snapshots = {
1149
+ test.get("snapshot_path") for test in tests if isinstance(test, dict) and isinstance(test.get("snapshot_path"), str)
1150
+ }
1151
+ if generated_dir.is_dir():
1152
+ actual_snapshots = {
1153
+ path.relative_to(root).as_posix() for path in generated_dir.rglob("*") if path.is_file()
1154
+ }
1155
+ for relative in sorted(actual_snapshots - referenced_snapshots):
1156
+ errors.append(f"generated test snapshot is not bound by test plan: {relative}")
942
1157
  for ring in scope.get("regression_ring", []):
943
1158
  if not isinstance(ring, dict):
944
1159
  continue
@@ -1082,7 +1297,7 @@ def validate_differential_proofs(
1082
1297
  proof_by_behavior[behavior_id] = item
1083
1298
  classification = item.get("classification")
1084
1299
  if classification == "NOT_APPLICABLE":
1085
- structured_na(item.get("not_applicable"), records_schema, f"differential proof {behavior_id} not_applicable", errors)
1300
+ validate_differential_na(item.get("not_applicable"), f"differential proof {behavior_id} not_applicable", errors)
1086
1301
  for key in (
1087
1302
  "method", "differential_request_path", "differential_request_sha256", "baseline_commit",
1088
1303
  "current_tree_sha256", "baseline_execution_id", "mutation_execution_id", "current_execution_id",
@@ -1245,6 +1460,13 @@ def validate_verdict_and_attestation(
1245
1460
  require(bool(verdict_record.get("blockers")), "TEST_BLOCKED lacks blocker reasons", errors)
1246
1461
  elif verdict == "TEST_FAIL":
1247
1462
  require(bool(verdict_record.get("reproduced_failures")), "TEST_FAIL lacks reproduced failures", errors)
1463
+ require(
1464
+ verdict_record.get("repair_scope") in {
1465
+ "test-infrastructure", "production", "runtime", "schema", "dependency", "public-interface",
1466
+ },
1467
+ "TEST_FAIL lacks a valid evidence-backed repair_scope",
1468
+ errors,
1469
+ )
1248
1470
 
1249
1471
  if verify_attestation:
1250
1472
  validate_definition(validation, "validation", records_schema, "validation", errors)
@@ -1264,6 +1486,10 @@ def validate_verdict_and_attestation(
1264
1486
  require(validation.get("semantics") == "integrity-and-protocol-validation-not-hostile-producer-proof", "validation attestation semantics exceed integrity/protocol validation", errors)
1265
1487
  require(validation.get("replay_receipt_ids") == replayed_ids, "validation replay receipt IDs do not match actual replay executions", errors)
1266
1488
 
1489
+ if root.joinpath("lifecycle.json").is_file():
1490
+ expected_report = render_report_text(root, manifest, verdict_record, validation)
1491
+ require(report == expected_report, "derived report does not match canonical rendering", errors)
1492
+
1267
1493
  fragments = [
1268
1494
  f"Verdict: {verdict}", f"Evidence ID: {manifest.get('evidence_id')}",
1269
1495
  "Derived from structured evidence", "sensitivity=project-controlled",
@@ -1299,9 +1525,26 @@ def validate_evidence(
1299
1525
 
1300
1526
  entry_map = validate_manifest(root, manifest, manifest_schema, records_schema, errors, ignored_hash_paths)
1301
1527
  change_class = manifest.get("change_class")
1302
- required = COMMON_RECORDS | (BEHAVIOR_RECORDS if change_class == "behavior" else LIGHTWEIGHT_RECORDS)
1528
+ final_verdict = manifest.get("final_verdict")
1529
+ canonical_lifecycle = (root / "lifecycle.json").is_file()
1530
+ required = COMMON_RECORDS
1531
+ if canonical_lifecycle:
1532
+ required = required | {"change-capture.json"}
1533
+ if final_verdict != "TEST_BLOCKED":
1534
+ required = required | (BEHAVIOR_RECORDS if change_class == "behavior" else LIGHTWEIGHT_RECORDS)
1535
+ if canonical_lifecycle and change_class == "behavior":
1536
+ required = required | {"test-preparation.json"}
1303
1537
  for relative in required:
1304
1538
  require(relative in entry_map, f"required evidence record not hashed: {relative}", errors)
1539
+ if canonical_lifecycle:
1540
+ capture_path = root / "change-capture.json"
1541
+ if capture_path.is_file():
1542
+ try:
1543
+ capture = load_json(capture_path)
1544
+ validate_definition(capture, "changeCapture", records_schema, "change-capture", errors)
1545
+ validate_canonical_capture(root, project_root, capture, inventory, scope, errors)
1546
+ except EvidenceError as exc:
1547
+ errors.append(str(exc))
1305
1548
  validate_definition(classification, "changeClassification", records_schema, "change-classification", errors)
1306
1549
  validate_definition(scope, "scope", records_schema, "scope", errors)
1307
1550
  validate_definition(inventory, "targetInventory", records_schema, "target-inventory", errors)
@@ -1309,20 +1552,37 @@ def validate_evidence(
1309
1552
  require(classification.get("full_protocol_required") is (change_class == "behavior"), "change classification protocol decision is inconsistent", errors)
1310
1553
 
1311
1554
  plan: dict[str, Any] | None = None
1555
+ preparation: dict[str, Any] | None = None
1312
1556
  if change_class == "behavior":
1313
1557
  try:
1314
1558
  plan = load_json(root / "test-plan.json")
1559
+ if canonical_lifecycle:
1560
+ preparation = load_json(root / "test-preparation.json")
1315
1561
  except EvidenceError as exc:
1316
1562
  errors.append(str(exc))
1563
+ plan = preparation = None
1317
1564
  if isinstance(plan, dict):
1318
1565
  validate_definition(plan, "testPlan", records_schema, "test-plan", errors)
1319
1566
  else:
1320
1567
  errors.append("test-plan.json must be an object")
1321
1568
  plan = None
1569
+ if canonical_lifecycle:
1570
+ if isinstance(preparation, dict):
1571
+ pass
1572
+ else:
1573
+ errors.append("test-preparation.json must be an object")
1322
1574
 
1323
1575
  inventory_by_category, _ = validate_inventory(root, project_root, manifest, inventory, scope, plan, entry_map, errors)
1576
+ if canonical_lifecycle and change_class == "behavior" and isinstance(preparation, dict):
1577
+ validate_test_preparation(
1578
+ root, project_root, preparation, plan or {}, inventory_by_category,
1579
+ entry_map, records_schema, errors,
1580
+ )
1324
1581
  require(manifest.get("evidence_id_inputs", {}).get("scope_sha256") == canonical_sha256(scope), "scope identity hash mismatch", errors)
1325
- validate_scope(scope, inventory, inventory_by_category, project_root, root, errors)
1582
+ validate_scope(
1583
+ scope, inventory, inventory_by_category, project_root, root,
1584
+ get_captured_deleted_paths(root), errors,
1585
+ )
1326
1586
  execution_requests, differential_requests, executions = validate_requests_and_receipts(root, entry_map, records_schema, errors)
1327
1587
 
1328
1588
  if change_class == "behavior":
@@ -1373,6 +1633,64 @@ def validate_evidence(
1373
1633
  return errors
1374
1634
 
1375
1635
 
1636
+ def atomic_write_text(path: Path, value: str) -> None:
1637
+ path.parent.mkdir(parents=True, exist_ok=True)
1638
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
1639
+ try:
1640
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1641
+ handle.write(value)
1642
+ os.replace(temporary_name, path)
1643
+ except BaseException:
1644
+ try:
1645
+ os.unlink(temporary_name)
1646
+ except FileNotFoundError:
1647
+ pass
1648
+ raise
1649
+
1650
+
1651
+ def render_report_text(
1652
+ root: Path,
1653
+ manifest: dict[str, Any],
1654
+ verdict: dict[str, Any],
1655
+ validation: dict[str, Any],
1656
+ ) -> str:
1657
+ lifecycle_path = root / "lifecycle.json"
1658
+ lifecycle = load_json(lifecycle_path) if lifecycle_path.exists() else {}
1659
+ proof_path = root / "differential-proof.json"
1660
+ proof_record = load_json(proof_path) if proof_path.exists() else {}
1661
+ executions_record = load_json(root / "executions.json")
1662
+ proofs = proof_record.get("proofs", []) if isinstance(proof_record, dict) else []
1663
+ executions = executions_record if isinstance(executions_record, list) else []
1664
+ artifact_dir = lifecycle.get("artifact_dir") if isinstance(lifecycle, dict) else None
1665
+ lines = [
1666
+ "# Test Evidence Report",
1667
+ "",
1668
+ "Derived from structured evidence",
1669
+ f"Evidence ID: {manifest.get('evidence_id')}",
1670
+ f"Verdict: {verdict.get('verdict')}",
1671
+ f"Validator result: {validation.get('result', 'pending')}",
1672
+ "sensitivity=project-controlled",
1673
+ "The project owns access control, retention, and upload policy.",
1674
+ "Mocked code-level evidence does not verify a real deployed environment.",
1675
+ "",
1676
+ "## Scope",
1677
+ f"- Artifact Dir: {artifact_dir or 'not provided'}",
1678
+ "",
1679
+ f"Execution receipts: {len(executions)}",
1680
+ f"Differential proofs: {len(proofs) if isinstance(proofs, list) else 0}",
1681
+ "The legacy test-report interface is not supported.",
1682
+ "",
1683
+ ]
1684
+ return "\n".join(lines)
1685
+
1686
+
1687
+ def refresh_final_record_hashes(root: Path, manifest: dict[str, Any]) -> None:
1688
+ for entry in manifest.get("files", []):
1689
+ if isinstance(entry, dict) and entry.get("path") in FINAL_RECORDS:
1690
+ entry["sha256"] = file_sha256(root / entry["path"])
1691
+ atomic_write_json(root / "manifest.json", manifest)
1692
+
1693
+
1376
1694
  def main() -> int:
1377
1695
  parser = argparse.ArgumentParser(description="Validate a PrizmKit test evidence package")
1378
1696
  parser.add_argument("--evidence-dir", required=True, help="Path to .prizmkit/test/evidence/<evidence-id>")
@@ -1388,7 +1706,7 @@ def main() -> int:
1388
1706
  except IndexError:
1389
1707
  print("Test evidence validation: FAIL\n- cannot infer project root from evidence directory")
1390
1708
  return 1
1391
- errors = validate_evidence(root, project_root, FINAL_RECORDS if args.attest else None)
1709
+ errors = validate_evidence(root, project_root, ATTESTATION_MUTABLE if args.attest else None)
1392
1710
  if errors:
1393
1711
  print("Test evidence validation: FAIL")
1394
1712
  for error in errors:
@@ -1396,6 +1714,7 @@ def main() -> int:
1396
1714
  return 1
1397
1715
 
1398
1716
  manifest = load_json(root / "manifest.json")
1717
+ verdict_record = load_json(root / "verdict.json")
1399
1718
  if args.attest:
1400
1719
  executions = load_json(root / "executions.json")
1401
1720
  replayed_ids = [
@@ -1421,10 +1740,9 @@ def main() -> int:
1421
1740
  "replay_receipt_ids": replayed_ids,
1422
1741
  }
1423
1742
  atomic_write_json(root / "validation.json", validation)
1424
- for entry in manifest["files"]:
1425
- if entry["path"] in FINAL_RECORDS:
1426
- entry["sha256"] = file_sha256(root / entry["path"])
1427
- atomic_write_json(root / "manifest.json", manifest)
1743
+ report_text = render_report_text(root, manifest, verdict_record, validation)
1744
+ atomic_write_text(root / "test-report.md", report_text)
1745
+ refresh_final_record_hashes(root, manifest)
1428
1746
  errors = validate_evidence(root, project_root)
1429
1747
  if errors:
1430
1748
  print("Test evidence validation: FAIL")
@@ -199,7 +199,7 @@ Interactive execution requires the committer to:
199
199
  4. wait for explicit user confirmation from the current user;
200
200
  5. create and verify the local commit only after confirmation.
201
201
 
202
- Trusted headless execution is a separate atomic-stage path. It requires a host-defined non-interactive `mode`, a trusted `owner` identifier, `local_commit_authorized=true`, and `push_authorized=false`; it does not ask or wait and never pushes. Unknown headless contexts are blocked. Remote push is never part of this composite workflow's automatic path.
202
+ Trusted headless execution is a separate atomic-stage path. It requires a host-defined non-interactive `mode`, a trusted `owner` identifier, and `local_commit_authorized=true`; it does not ask or wait. Unknown headless contexts are blocked. Remote publication is a separate host-runtime operation and is never decided by this composite workflow.
203
203
 
204
204
  ## Automatic Handoff and Manual Fallback
205
205
 
@@ -157,12 +157,12 @@ Trusted headless execution:
157
157
  ```text
158
158
  a trusted host explicitly authorizes the current local task commit
159
159
  → host injects mode=<host-defined-headless-mode>, owner=<trusted-host-identifier>,
160
- local_commit_authorized=true, push_authorized=false
160
+ local_commit_authorized=true
161
161
  → committer verifies gates and commits automatically
162
- → no question, wait, or push is permitted
162
+ → no question or wait is permitted
163
163
  ```
164
164
 
165
- A self-declared or otherwise untrusted headless context does not authorize a commit. Push is never implied by either mode.
165
+ A self-declared or otherwise untrusted headless context does not authorize a commit. Remote publication remains a separate host-runtime decision.
166
166
 
167
167
  ## Recovery
168
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.131",
3
+ "version": "1.1.135",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {