prizmkit 1.1.130 → 1.1.134

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 (119) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/adapters/claude/command-adapter.js +0 -2
  3. package/bundled/dev-pipeline/.env.example +1 -1
  4. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +11 -6
  5. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +445 -8
  6. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +2 -9
  7. package/bundled/dev-pipeline/prizmkit_runtime/processes.py +21 -1
  8. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +87 -66
  9. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +53 -3
  10. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +55 -25
  11. package/bundled/dev-pipeline/prizmkit_runtime/runner_status.py +4 -0
  12. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +119 -18
  13. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +3 -2
  14. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +411 -17
  15. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +57 -14
  16. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +5 -4
  17. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +55 -2
  18. package/bundled/dev-pipeline/scripts/prompt_framework.py +152 -1
  19. package/bundled/dev-pipeline/scripts/update-bug-status.py +53 -0
  20. package/bundled/dev-pipeline/scripts/update-checkpoint.py +294 -69
  21. package/bundled/dev-pipeline/scripts/update-feature-status.py +57 -0
  22. package/bundled/dev-pipeline/scripts/update-refactor-status.py +53 -0
  23. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +5 -5
  24. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +28 -22
  25. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +24 -10
  26. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +20 -2
  27. package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +2 -2
  28. package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
  29. package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +5 -10
  30. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +9 -19
  31. package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +21 -0
  32. package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +1 -1
  33. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +10 -10
  34. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +1 -1
  35. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +15 -0
  36. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +2 -2
  37. package/bundled/dev-pipeline/templates/sections/phase-commit.md +2 -2
  38. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +26 -23
  39. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -5
  40. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +15 -11
  41. package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +1 -1
  42. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +6 -6
  43. package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +21 -0
  44. package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
  45. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  46. package/bundled/dev-pipeline/templates/sections/task-contract.md +4 -0
  47. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +362 -3
  48. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +349 -48
  49. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +129 -0
  50. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +91 -0
  51. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +567 -23
  52. package/bundled/dev-pipeline/tests/test_unified_cli.py +37 -10
  53. package/bundled/skills/_metadata.json +63 -26
  54. package/bundled/skills/app-planner/SKILL.md +4 -3
  55. package/bundled/skills/bug-fix-workflow/SKILL.md +89 -180
  56. package/bundled/skills/bug-planner/SKILL.md +12 -27
  57. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +131 -220
  58. package/bundled/skills/bugfix-pipeline-launcher/references/configuration.md +36 -90
  59. package/bundled/skills/feature-pipeline-launcher/SKILL.md +129 -271
  60. package/bundled/skills/feature-pipeline-launcher/references/configuration.md +35 -76
  61. package/bundled/skills/feature-planner/SKILL.md +27 -22
  62. package/bundled/skills/feature-planner/assets/planning-guide.md +4 -4
  63. package/bundled/skills/feature-workflow/SKILL.md +94 -121
  64. package/bundled/skills/prizmkit/SKILL.md +130 -94
  65. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +181 -0
  66. package/bundled/skills/prizmkit-code-review/SKILL.md +132 -86
  67. package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +243 -0
  68. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +42 -1
  69. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +181 -0
  70. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +93 -1
  71. package/bundled/skills/prizmkit-committer/SKILL.md +125 -48
  72. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +181 -0
  73. package/bundled/skills/prizmkit-implement/SKILL.md +99 -73
  74. package/bundled/skills/prizmkit-implement/references/implementation-subagent-procedure.md +1 -1
  75. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +181 -0
  76. package/bundled/skills/prizmkit-init/SKILL.md +5 -5
  77. package/bundled/skills/prizmkit-plan/SKILL.md +146 -105
  78. package/bundled/skills/prizmkit-plan/assets/plan-template.md +18 -0
  79. package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +242 -0
  80. package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +4 -4
  81. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +181 -0
  82. package/bundled/skills/prizmkit-retrospective/SKILL.md +111 -53
  83. package/bundled/skills/prizmkit-retrospective/references/knowledge-injection-steps.md +7 -6
  84. package/bundled/skills/prizmkit-retrospective/references/structural-sync-steps.md +5 -4
  85. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +181 -0
  86. package/bundled/skills/prizmkit-test/SKILL.md +52 -6
  87. package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +63 -12
  88. package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +1 -1
  89. package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +13 -0
  90. package/bundled/skills/prizmkit-test/references/evidence-protocol.md +6 -1
  91. package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +8 -1
  92. package/bundled/skills/prizmkit-test/references/examples.md +1 -1
  93. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +4 -1
  94. package/bundled/skills/prizmkit-test/references/test-report-template.md +1 -1
  95. package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +15 -0
  96. package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +181 -0
  97. package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +744 -23
  98. package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +194 -15
  99. package/bundled/skills/prizmkit-workflow/SKILL.md +266 -0
  100. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +181 -0
  101. package/bundled/skills/recovery-workflow/SKILL.md +62 -241
  102. package/bundled/skills/recovery-workflow/evals/evals.json +17 -33
  103. package/bundled/skills/recovery-workflow/references/detection.md +28 -52
  104. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +105 -439
  105. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +136 -275
  106. package/bundled/skills/refactor-pipeline-launcher/references/configuration.md +53 -88
  107. package/bundled/skills/refactor-planner/SKILL.md +12 -57
  108. package/bundled/skills/refactor-planner/references/behavior-preservation.md +111 -230
  109. package/bundled/skills/refactor-planner/references/error-recovery.md +37 -0
  110. package/bundled/skills/refactor-planner/references/planning-phases.md +11 -17
  111. package/bundled/skills/refactor-planner/scripts/validate-and-generate-refactor.py +1 -1
  112. package/bundled/skills/refactor-workflow/SKILL.md +98 -139
  113. package/bundled/templates/project-memory-template.md +4 -4
  114. package/package.json +1 -1
  115. package/src/index.js +4 -0
  116. package/src/prompts.js +10 -3
  117. package/src/scaffold.js +1 -1
  118. package/bundled/dev-pipeline/tests/test-deploy-safety.sh +0 -223
  119. package/bundled/skills/feature-pipeline-launcher/scripts/preflight-check.py +0 -462
@@ -2,10 +2,14 @@
2
2
  """Build runner-generated PrizmKit test evidence from schema-shaped requests.
3
3
 
4
4
  Usage:
5
+ python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR init [--baseline COMMIT]
6
+ python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR capture-change
5
7
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR inventory --request REQUEST
6
8
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
7
9
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
8
10
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
11
+ python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR finalize --request REQUEST
12
+ python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR render-report
9
13
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
10
14
 
11
15
  Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
@@ -35,13 +39,23 @@ STAGES = [
35
39
  "EVIDENCE_VALIDATE",
36
40
  ]
37
41
  CATEGORIES = ("source", "tests", "contracts", "lockfiles")
42
+ LAYERS = (
43
+ "focused", "module-component", "contract-integration",
44
+ "affected-module-regression", "regression-ring",
45
+ )
38
46
  BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
39
47
  SNAPSHOT_VOLATILE_ROOTS = (
40
48
  ".prizmkit/state",
41
49
  ".prizmkit/test/evidence",
50
+ ".prizmkit/" + "dev-" + "pipeline",
42
51
  ".claude/worktrees",
52
+ ".agents/worktrees",
53
+ ".codebuddy/worktrees",
54
+ ".codex/worktrees",
43
55
  )
44
- SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
56
+ SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
57
+ INVENTORY_RESERVED_ROOTS = SNAPSHOT_VOLATILE_ROOTS
58
+ FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
45
59
 
46
60
 
47
61
  class RequestError(Exception):
@@ -127,6 +141,167 @@ def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> l
127
141
  return value
128
142
 
129
143
 
144
+ def normalize_relative(value: str) -> str:
145
+ normalized = Path(value).as_posix()
146
+ if normalized == "." or normalized.startswith("../") or "/../" in normalized:
147
+ raise RequestError(f"path is not project-relative: {value}")
148
+ return normalized
149
+
150
+
151
+ def is_reserved_inventory_path(relative: str) -> bool:
152
+ path = Path(relative)
153
+ if any(part in SNAPSHOT_VOLATILE_NAMES for part in path.parts):
154
+ return True
155
+ return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
156
+
157
+
158
+ def project_git(project_root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[bytes]:
159
+ result = subprocess.run(
160
+ ["git", "-C", str(project_root), *args],
161
+ capture_output=True,
162
+ check=False,
163
+ )
164
+ if check and result.returncode != 0:
165
+ raise RequestError(f"git {' '.join(args)} failed: {result.stderr.decode(errors='replace')}")
166
+ return result
167
+
168
+
169
+ def git_baseline(project_root: Path, requested: str | None = None) -> str:
170
+ baseline = requested or "HEAD"
171
+ result = project_git(project_root, "rev-parse", "--verify", baseline)
172
+ if result.returncode != 0:
173
+ raise RequestError(f"baseline commit is not resolvable: {baseline}")
174
+ return result.stdout.decode().strip()
175
+
176
+
177
+ def git_status_entries(project_root: Path) -> list[dict[str, Any]]:
178
+ result = project_git(project_root, "status", "--porcelain=v1", "-z", "--untracked-files=all", check=True)
179
+ raw = result.stdout
180
+ entries: list[dict[str, Any]] = []
181
+ tokens = raw.split(b"\0")
182
+ index = 0
183
+ while index < len(tokens):
184
+ token = tokens[index]
185
+ index += 1
186
+ if not token:
187
+ continue
188
+ status = token[:2].decode(errors="replace")
189
+ path = token[3:].decode(errors="replace")
190
+ old_path = None
191
+ if "R" in status or "C" in status:
192
+ if index >= len(tokens):
193
+ raise RequestError("git status returned an incomplete rename/copy record")
194
+ old_path = tokens[index].decode(errors="replace")
195
+ index += 1
196
+ relative = normalize_relative(path)
197
+ if not is_reserved_inventory_path(relative):
198
+ record: dict[str, Any] = {"path": relative, "status": status, "old_path": old_path}
199
+ entries.append(record)
200
+ if old_path is not None:
201
+ old_relative = normalize_relative(old_path)
202
+ if not is_reserved_inventory_path(old_relative):
203
+ entries.append({"path": old_relative, "status": status, "old_path": old_relative, "role": "source"})
204
+ return sorted(entries, key=lambda item: (item["path"], item.get("old_path") or ""))
205
+
206
+
207
+ def git_diff_entries(project_root: Path, baseline: str) -> list[dict[str, Any]]:
208
+ result = project_git(
209
+ project_root, "diff", "--name-status", "-z", "--find-renames", "--find-copies",
210
+ baseline, "--", ".",
211
+ ":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
212
+ ":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
213
+ check=True,
214
+ )
215
+ tokens = result.stdout.split(b"\0")
216
+ entries: list[dict[str, Any]] = []
217
+ index = 0
218
+ while index < len(tokens):
219
+ status_token = tokens[index].decode(errors="replace")
220
+ index += 1
221
+ if not status_token:
222
+ continue
223
+ if index >= len(tokens):
224
+ raise RequestError("git diff returned an incomplete changed-file record")
225
+ first = normalize_relative(tokens[index].decode(errors="replace"))
226
+ index += 1
227
+ status = status_token[0]
228
+ old_path = None
229
+ if status in {"R", "C"}:
230
+ if index >= len(tokens):
231
+ raise RequestError("git diff returned an incomplete rename/copy record")
232
+ old_path = first
233
+ first = normalize_relative(tokens[index].decode(errors="replace"))
234
+ index += 1
235
+ if not is_reserved_inventory_path(first):
236
+ entries.append({"path": first, "status": status, "old_path": old_path})
237
+ if old_path and not is_reserved_inventory_path(old_path):
238
+ entries.append({"path": old_path, "status": status, "old_path": old_path, "role": "source"})
239
+ return entries
240
+
241
+
242
+ def merge_changed_file_entries(
243
+ baseline_entries: list[dict[str, Any]],
244
+ working_entries: list[dict[str, Any]],
245
+ ) -> list[dict[str, Any]]:
246
+ merged: dict[tuple[str, str | None, str | None], dict[str, Any]] = {}
247
+ for entry in baseline_entries + working_entries:
248
+ key = (entry["path"], entry.get("old_path"), entry.get("role"))
249
+ existing = merged.get(key)
250
+ if existing is None:
251
+ merged[key] = dict(entry)
252
+ continue
253
+ statuses = {existing.get("status", "").strip(), entry.get("status", "").strip()}
254
+ if "D" in statuses:
255
+ existing["status"] = "D"
256
+ elif "R" in statuses:
257
+ existing["status"] = "R"
258
+ elif "C" in statuses:
259
+ existing["status"] = "C"
260
+ elif statuses:
261
+ existing["status"] = "M"
262
+ return sorted(merged.values(), key=lambda item: (item["path"], item.get("old_path") or "", item.get("role") or ""))
263
+
264
+
265
+ def canonical_change_patch(project_root: Path, baseline: str) -> tuple[bytes, list[dict[str, Any]]]:
266
+ status_entries = git_status_entries(project_root)
267
+ baseline_entries = git_diff_entries(project_root, baseline)
268
+ status_entries = merge_changed_file_entries(baseline_entries, status_entries)
269
+ tracked = project_git(
270
+ project_root, "diff", "--binary", "--full-index", "--find-renames", "--find-copies",
271
+ baseline, "--", ".",
272
+ ":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
273
+ ":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
274
+ )
275
+ if tracked.returncode != 0:
276
+ raise RequestError(f"cannot capture git diff: {tracked.stderr.decode(errors='replace')}")
277
+ chunks = [tracked.stdout]
278
+ untracked = [
279
+ item["path"] for item in status_entries
280
+ if item["status"].strip() == "??" and item.get("old_path") is None
281
+ ]
282
+ for relative in sorted(untracked):
283
+ candidate = confined(project_root, relative, must_exist=True)
284
+ if candidate.is_file():
285
+ result = subprocess.run(
286
+ ["git", "diff", "--no-index", "--binary", "/dev/null", "--", relative],
287
+ cwd=project_root,
288
+ capture_output=True,
289
+ check=False,
290
+ )
291
+ if result.returncode not in (0, 1):
292
+ raise RequestError(f"cannot capture untracked file: {relative}")
293
+ chunks.append(result.stdout)
294
+ patch = b"".join(chunk for chunk in chunks if chunk)
295
+ return patch, status_entries
296
+
297
+
298
+ def write_text_atomic(path: Path, value: str) -> None:
299
+ path.parent.mkdir(parents=True, exist_ok=True)
300
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
301
+ temporary.write_text(value, encoding="utf-8")
302
+ os.replace(temporary, path)
303
+
304
+
130
305
  def validate_external_targets(value: Any) -> list[dict[str, Any]]:
131
306
  if not isinstance(value, list):
132
307
  raise RequestError("external_targets must be an array")
@@ -151,7 +326,14 @@ def validate_external_targets(value: Any) -> list[dict[str, Any]]:
151
326
  return value
152
327
 
153
328
 
154
- def validate_execution_request(request: Any) -> dict[str, Any]:
329
+ def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
330
+ path = evidence_dir / "test-plan.json"
331
+ if not path.exists():
332
+ return None
333
+ return ensure_object(load_json(path), "test plan")
334
+
335
+
336
+ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
155
337
  request = ensure_object(request, "execution request")
156
338
  required = {
157
339
  "request_version", "purpose", "command", "cwd", "environment",
@@ -175,7 +357,24 @@ def validate_execution_request(request: Any) -> dict[str, Any]:
175
357
  if not isinstance(name, str):
176
358
  raise RequestError("tool probe names must be strings")
177
359
  ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
178
- ensure_string_list(request["test_ids"], "test_ids")
360
+ test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
361
+ if len(set(test_ids)) != len(test_ids):
362
+ raise RequestError("execution request test_ids contains duplicates")
363
+ if request["layer"] not in LAYERS and request["layer"] != "lightweight":
364
+ raise RequestError(f"unknown execution layer: {request['layer']}")
365
+ if plan is not None:
366
+ tests = plan.get("tests") if isinstance(plan, dict) else None
367
+ known = {item.get("id") for item in tests if isinstance(item, dict)} if isinstance(tests, list) else set()
368
+ unknown = sorted(set(test_ids) - known)
369
+ if unknown:
370
+ raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
371
+ test_map = {item.get("id"): item for item in tests if isinstance(item, dict)} if isinstance(tests, list) else {}
372
+ invalid_layer = sorted(
373
+ test_id for test_id in test_ids
374
+ if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
375
+ )
376
+ if invalid_layer:
377
+ raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
179
378
  validate_external_targets(request["external_targets"])
180
379
  if "timeout_seconds" in request and (isinstance(request["timeout_seconds"], bool) or not isinstance(request["timeout_seconds"], (int, float)) or request["timeout_seconds"] <= 0):
181
380
  raise RequestError("timeout_seconds must be a positive number")
@@ -215,11 +414,60 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
215
414
  entries = []
216
415
  for candidate in sorted(paths):
217
416
  relative = candidate.relative_to(project_root).as_posix()
218
- if relative not in exclusions:
417
+ if relative not in exclusions and not is_reserved_inventory_path(relative):
219
418
  entries.append({"path": relative, "sha256": file_sha256(candidate)})
220
419
  return entries
221
420
 
222
421
 
422
+ def validate_category_disjointness(output_categories: dict[str, list[dict[str, str]]]) -> None:
423
+ membership: dict[str, str] = {}
424
+ for category, entries in output_categories.items():
425
+ for entry in entries:
426
+ path = entry["path"]
427
+ previous = membership.get(path)
428
+ if previous is not None:
429
+ raise RequestError(f"inventory path appears in multiple categories: {path} ({previous}, {category})")
430
+ membership[path] = category
431
+
432
+
433
+ def validate_module_root_request(
434
+ project_root: Path,
435
+ evidence_dir: Path,
436
+ module_roots: list[Any],
437
+ changed_files: list[str],
438
+ ) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]:
439
+ normalized_roots: list[str] = []
440
+ explicit_changed: dict[str, list[str]] = {}
441
+ for item in module_roots:
442
+ if isinstance(item, str):
443
+ relative = normalize_relative(item)
444
+ changed_set: list[str] = []
445
+ elif isinstance(item, dict) and set(item) <= {"path", "changed_files"} and isinstance(item.get("path"), str):
446
+ relative = normalize_relative(item["path"])
447
+ changed_set = ensure_string_list(item.get("changed_files", []), f"module root {relative} changed_files")
448
+ else:
449
+ raise RequestError("module_roots entries must be paths or {path, changed_files} objects")
450
+ confined(project_root, relative, must_exist=True)
451
+ if relative in normalized_roots:
452
+ raise RequestError(f"duplicate module root: {relative}")
453
+ for changed in changed_set:
454
+ changed = normalize_relative(changed)
455
+ if not path_is_below_or_equal(changed, relative):
456
+ raise RequestError(f"module root changed file lies outside root: {changed}")
457
+ if changed not in changed_files:
458
+ raise RequestError(f"module root changed file is not in changed_files: {changed}")
459
+ normalized_roots.append(relative)
460
+ explicit_changed[relative] = sorted(changed_set)
461
+ enumerated = enumerate_module_root_files(project_root, evidence_dir, normalized_roots)
462
+ return normalized_roots, explicit_changed, enumerated
463
+
464
+
465
+ def path_is_below_or_equal(path: str, root: str) -> bool:
466
+ normalized_path = Path(path)
467
+ normalized_root = Path(root)
468
+ return normalized_path == normalized_root or normalized_root in normalized_path.parents
469
+
470
+
223
471
  def enumerate_module_root_files(
224
472
  project_root: Path,
225
473
  evidence_dir: Path,
@@ -257,28 +505,49 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
257
505
  for exclusion in exclusions:
258
506
  if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
259
507
  raise RequestError("each inventory exclusion requires path, reason, and evidence")
260
- confined(project_root, exclusion["path"])
508
+ relative = normalize_relative(exclusion["path"])
509
+ if is_reserved_inventory_path(relative):
510
+ raise RequestError(f"reserved inventory path must be automatically excluded: {relative}")
511
+ confined(project_root, relative)
261
512
  if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
262
513
  raise RequestError("inventory exclusion reason is too short")
263
514
  if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
264
515
  raise RequestError("inventory exclusion lacks evidence")
265
- exclusion_paths.add(Path(exclusion["path"]).as_posix())
266
- changed_files = ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)
267
- module_roots = ensure_string_list(request["module_roots"], "module_roots", allow_empty=False)
516
+ exclusion_paths.add(relative)
517
+ changed_files = sorted({normalize_relative(item) for item in ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)})
518
+ if len(changed_files) != len(request["changed_files"]):
519
+ raise RequestError("changed_files contains duplicates")
520
+ capture_path = evidence_dir / "change-capture.json"
521
+ captured_deleted: set[str] = set()
522
+ if capture_path.is_file():
523
+ capture = ensure_object(load_json(capture_path), "change capture")
524
+ for entry in capture.get("changed_files", []):
525
+ if isinstance(entry, dict) and entry.get("status", "").strip() == "D":
526
+ captured_deleted.add(entry.get("path"))
527
+ if isinstance(entry, dict) and entry.get("role") == "source":
528
+ captured_deleted.add(entry.get("path"))
268
529
  for relative in changed_files:
269
- confined(project_root, relative, must_exist=relative not in exclusion_paths)
270
- for relative in module_roots:
271
- confined(project_root, relative, must_exist=True)
530
+ confined(
531
+ project_root,
532
+ relative,
533
+ must_exist=relative not in exclusion_paths and relative not in captured_deleted,
534
+ )
535
+ raw_module_roots = request["module_roots"]
536
+ if not isinstance(raw_module_roots, list) or not raw_module_roots:
537
+ raise RequestError("module_roots must be a non-empty array")
538
+ module_roots, explicit_changed, module_root_files = validate_module_root_request(
539
+ project_root, evidence_dir, raw_module_roots, changed_files,
540
+ )
272
541
  output_categories = {
273
542
  name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
274
543
  for name in CATEGORIES
275
544
  }
545
+ validate_category_disjointness(output_categories)
276
546
  inventoried_paths = {
277
547
  entry["path"]
278
548
  for entries in output_categories.values()
279
549
  for entry in entries
280
550
  }
281
- module_root_files = enumerate_module_root_files(project_root, evidence_dir, module_roots)
282
551
  missing_from_inventory = sorted({
283
552
  relative
284
553
  for files in module_root_files.values()
@@ -290,6 +559,9 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
290
559
  "module root contains files that are neither inventoried nor excluded: "
291
560
  + ", ".join(missing_from_inventory[:20])
292
561
  )
562
+ for root, changed_set in explicit_changed.items():
563
+ if changed_set and not set(changed_set) <= set(module_root_files[root]):
564
+ raise RequestError(f"module root changed_files contains a path outside enumerated root: {root}")
293
565
  output = {
294
566
  "inventory_request_path": evidence_relative(evidence_dir, request_path),
295
567
  "inventory_request_sha256": file_sha256(request_path),
@@ -297,6 +569,7 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
297
569
  "changed_files": changed_files,
298
570
  "module_roots": module_roots,
299
571
  "module_root_files": module_root_files,
572
+ "module_root_changed_files": explicit_changed,
300
573
  "exclusions": sorted(exclusion_paths),
301
574
  "discovery_evidence": request["discovery_evidence"],
302
575
  "plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
@@ -390,7 +663,7 @@ def execute_request(
390
663
  isolation: dict[str, Any] | None = None,
391
664
  selected_execution: bool = True,
392
665
  ) -> dict[str, Any]:
393
- request = validate_execution_request(load_json(request_path))
666
+ request = validate_execution_request(load_json(request_path), plan=plan_for_execution(evidence_dir))
394
667
  root = execution_root or project_root
395
668
  cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
396
669
  complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
@@ -501,12 +774,61 @@ def materialize_request(evidence_dir: Path, name: str, request: dict[str, Any])
501
774
  return path
502
775
 
503
776
 
777
+ def validate_not_applicable(value: Any) -> dict[str, Any]:
778
+ value = ensure_object(value, "differential N/A decision")
779
+ required = {"reason", "rationale", "evidence", "considered_signals", "conflicts"}
780
+ if set(value) != required:
781
+ raise RequestError(f"differential N/A fields must be exactly {sorted(required)}")
782
+ if value["reason"] not in {"new-behavior", "textual-contract"}:
783
+ raise RequestError("differential N/A reason must be new-behavior or textual-contract")
784
+ if not isinstance(value["rationale"], str) or len(value["rationale"].strip()) < 16:
785
+ raise RequestError("differential N/A rationale is too short")
786
+ ensure_string_list(value["evidence"], "differential N/A evidence", allow_empty=False)
787
+ ensure_string_list(value["considered_signals"], "differential N/A considered_signals", allow_empty=False)
788
+ if not isinstance(value["conflicts"], list):
789
+ raise RequestError("differential N/A conflicts must be an array")
790
+ if value["reason"] == "new-behavior" and "no stable pre-change observable" not in value["rationale"].lower():
791
+ raise RequestError("new-behavior differential N/A must explain that no stable pre-change observable exists")
792
+ if value["reason"] == "textual-contract" and "textual" not in value["rationale"].lower():
793
+ raise RequestError("textual-contract differential N/A must explain why behavior is textual")
794
+ return value
795
+
796
+
797
+ def append_proof(evidence_dir: Path, proof: dict[str, Any]) -> None:
798
+ proof_path = evidence_dir / "differential-proof.json"
799
+ proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
800
+ if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
801
+ raise RequestError("differential-proof.json has invalid append target")
802
+ if any(item.get("behavior_id") == proof.get("behavior_id") for item in proof_record["proofs"] if isinstance(item, dict)):
803
+ raise RequestError(f"duplicate differential proof: {proof.get('behavior_id')}")
804
+ proof_record["proofs"].append(proof)
805
+ write_json_atomic(proof_path, proof_record)
806
+
807
+
504
808
  def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
505
809
  request = ensure_object(load_json(request_path), "differential request")
506
810
  required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "current_tree_sha256", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
811
+ na_required = {"request_version", "behavior_id", "method", "not_applicable"}
812
+ if set(request) == na_required:
813
+ if request["request_version"] != "1.0" or request["method"] != "not-applicable":
814
+ raise RequestError("invalid differential N/A request")
815
+ not_applicable = validate_not_applicable(request["not_applicable"])
816
+ proof = {
817
+ "behavior_id": request["behavior_id"], "classification": "NOT_APPLICABLE",
818
+ "method": None, "differential_request_path": evidence_relative(evidence_dir, request_path),
819
+ "differential_request_sha256": file_sha256(request_path), "baseline_commit": None,
820
+ "current_tree_sha256": None, "baseline_execution_id": None, "mutation_execution_id": None,
821
+ "current_execution_id": None, "failure_reason_matched": False, "cleanup_succeeded": True,
822
+ "project_tree_before_sha256": None, "project_tree_after_sha256": None, "isolation_tree_sha256": None,
823
+ "mutation_apply_sha256": None, "mutation_restore_sha256": None, "not_applicable": not_applicable,
824
+ }
825
+ append_proof(evidence_dir, proof)
826
+ return proof
507
827
  if set(request) != required or request["request_version"] != "1.0":
508
828
  raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
509
- execution_request = validate_execution_request(request["execution_request"])
829
+ execution_request = validate_execution_request(
830
+ request["execution_request"], plan=plan_for_execution(evidence_dir),
831
+ )
510
832
  expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
511
833
  overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
512
834
  for relative in overlay_paths:
@@ -597,15 +919,379 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
597
919
  "mutation_restore_sha256": mutation_restore_sha,
598
920
  "not_applicable": None,
599
921
  }
600
- proof_path = evidence_dir / "differential-proof.json"
601
- proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
602
- if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
603
- raise RequestError("differential-proof.json has invalid append target")
604
- proof_record["proofs"].append(proof)
605
- write_json_atomic(proof_path, proof_record)
922
+ append_proof(evidence_dir, proof)
606
923
  return proof
607
924
 
608
925
 
926
+ def run_capture_change(
927
+ project_root: Path,
928
+ evidence_dir: Path,
929
+ baseline: str | None,
930
+ output_name: str,
931
+ ) -> Path:
932
+ resolved_baseline = git_baseline(project_root, baseline)
933
+ patch, status_entries = canonical_change_patch(project_root, resolved_baseline)
934
+ patch_path = confined(evidence_dir, "source-change.patch")
935
+ patch_path.parent.mkdir(parents=True, exist_ok=True)
936
+ patch_path.write_bytes(patch)
937
+ capture = {
938
+ "capture_format": "prizmkit-canonical-change-v1",
939
+ "baseline_commit": resolved_baseline,
940
+ "patch_path": evidence_relative(evidence_dir, patch_path),
941
+ "patch_sha256": file_sha256(patch_path),
942
+ "changed_files": status_entries,
943
+ "source_tree_sha256": tree_sha256(project_root, [evidence_dir]),
944
+ }
945
+ output = confined(evidence_dir, output_name)
946
+ write_json_atomic(output, capture)
947
+ return output
948
+
949
+
950
+ def run_init(project_root: Path, evidence_dir: Path, baseline: str | None, output_name: str) -> Path:
951
+ if evidence_dir.exists() and any(evidence_dir.iterdir()):
952
+ if (evidence_dir / "manifest.json").exists():
953
+ raise RequestError("evidence package is finalized; initialize a new evidence directory")
954
+ raise RequestError("evidence directory is not empty; initialize a new evidence directory")
955
+ evidence_dir.mkdir(parents=True, exist_ok=True)
956
+ for directory in ("requests", "receipts", "raw", "generated-tests", "contracts", "runner"):
957
+ (evidence_dir / directory).mkdir(parents=True, exist_ok=True)
958
+ write_json_atomic(evidence_dir / "executions.json", [])
959
+ write_json_atomic(evidence_dir / "differential-proof.json", {"proofs": []})
960
+ write_json_atomic(evidence_dir / "lifecycle.json", {
961
+ "lifecycle_format": "prizmkit-canonical-lifecycle-v1",
962
+ "status": "initialized",
963
+ "baseline_commit": git_baseline(project_root, baseline),
964
+ "artifact_dir": None,
965
+ "finalized": False,
966
+ "immutable_identity": None,
967
+ })
968
+ output = confined(evidence_dir, output_name)
969
+ write_json_atomic(output, {
970
+ "command": "init",
971
+ "evidence_dir": ".",
972
+ "baseline_commit": git_baseline(project_root, baseline),
973
+ "next": "capture-change",
974
+ })
975
+ return output
976
+
977
+
978
+ def load_lifecycle(evidence_dir: Path, *, required: bool = False) -> dict[str, Any]:
979
+ path = evidence_dir / "lifecycle.json"
980
+ if not path.exists():
981
+ if required:
982
+ raise RequestError("evidence package is not initialized; run init first")
983
+ return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
984
+ return ensure_object(load_json(path), "lifecycle")
985
+
986
+
987
+ def require_mutable_lifecycle(evidence_dir: Path) -> dict[str, Any]:
988
+ lifecycle_path = evidence_dir / "lifecycle.json"
989
+ if not lifecycle_path.exists():
990
+ if (evidence_dir / "manifest.json").exists():
991
+ raise RequestError(
992
+ "evidence package is finalized and immutable; re-init a new evidence package"
993
+ )
994
+ return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
995
+ lifecycle = load_lifecycle(evidence_dir, required=True)
996
+ if lifecycle.get("finalized") is True:
997
+ raise RequestError(
998
+ "evidence package is finalized and immutable; re-init a new evidence package"
999
+ )
1000
+ return lifecycle
1001
+
1002
+
1003
+ def run_render_report(evidence_dir: Path, output_name: str) -> Path:
1004
+ manifest = ensure_object(load_json(evidence_dir / "manifest.json"), "manifest")
1005
+ verdict = ensure_object(load_json(evidence_dir / "verdict.json"), "verdict")
1006
+ validation_path = evidence_dir / "validation.json"
1007
+ validation = ensure_object(load_json(validation_path), "validation") if validation_path.exists() else None
1008
+ lifecycle_path = evidence_dir / "lifecycle.json"
1009
+ lifecycle = ensure_object(load_json(lifecycle_path), "lifecycle") if lifecycle_path.exists() else {}
1010
+ proofs_path = evidence_dir / "differential-proof.json"
1011
+ proofs = load_json(proofs_path).get("proofs", []) if proofs_path.exists() else []
1012
+ executions = load_json(evidence_dir / "executions.json")
1013
+ artifact_dir = lifecycle.get("artifact_dir")
1014
+ lines = [
1015
+ "# Test Evidence Report",
1016
+ "",
1017
+ "Derived from structured evidence",
1018
+ f"Evidence ID: {manifest.get('evidence_id')}",
1019
+ f"Verdict: {verdict.get('verdict')}",
1020
+ f"Validator result: {validation.get('result') if validation else 'pending'}",
1021
+ "sensitivity=project-controlled",
1022
+ "The project owns access control, retention, and upload policy.",
1023
+ "Mocked code-level evidence does not verify a real deployed environment.",
1024
+ "",
1025
+ "## Scope",
1026
+ f"- Artifact Dir: {artifact_dir or 'not provided'}",
1027
+ "",
1028
+ f"Execution receipts: {len(executions) if isinstance(executions, list) else 0}",
1029
+ f"Differential proofs: {len(proofs) if isinstance(proofs, list) else 0}",
1030
+ "The legacy test-report interface is not supported.",
1031
+ "",
1032
+ ]
1033
+ output = confined(evidence_dir, output_name)
1034
+ rendered = "\n".join(lines)
1035
+ lifecycle_finalized = lifecycle.get("finalized") is True
1036
+ if lifecycle_finalized:
1037
+ if not output.is_file() or output.read_text(encoding="utf-8") != rendered:
1038
+ raise RequestError(
1039
+ "evidence package is finalized and immutable; re-init a new package before changing the report"
1040
+ )
1041
+ return output
1042
+ write_text_atomic(output, rendered)
1043
+ return output
1044
+
1045
+
1046
+ def aggregate_inventory(entries: list[dict[str, str]]) -> str:
1047
+ return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
1048
+
1049
+
1050
+ def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
1051
+ tests = plan.get("tests")
1052
+ if not isinstance(tests, list):
1053
+ return
1054
+ generated = evidence_dir / "generated-tests"
1055
+ generated.mkdir(parents=True, exist_ok=True)
1056
+ for test in tests:
1057
+ if not isinstance(test, dict):
1058
+ continue
1059
+ project_path = test.get("project_path")
1060
+ if not isinstance(project_path, str):
1061
+ continue
1062
+ status = test.get("change_status", test.get("origin", "existing"))
1063
+ if status not in {"existing", "added", "modified"}:
1064
+ raise RequestError(f"unknown test change_status: {test.get('id')}")
1065
+ if status == "existing":
1066
+ test["snapshot_path"] = None
1067
+ continue
1068
+ source = confined(project_root, project_path, must_exist=True)
1069
+ destination_relative = test.get("snapshot_path")
1070
+ if not isinstance(destination_relative, str) or not destination_relative.startswith("generated-tests/"):
1071
+ destination_relative = f"generated-tests/{Path(project_path).name}"
1072
+ destination = confined(evidence_dir, destination_relative)
1073
+ destination.parent.mkdir(parents=True, exist_ok=True)
1074
+ shutil.copy2(source, destination)
1075
+ test["snapshot_path"] = destination_relative
1076
+ write_json_atomic(evidence_dir / "test-plan.json", plan)
1077
+
1078
+
1079
+ def stage_output_owner(relative: str, change_class: str) -> str:
1080
+ if relative in {"change-classification.json"}:
1081
+ return "CHANGE_CLASSIFY"
1082
+ if relative in {"scope.json", "target-inventory.json", "environment.json", "source-change.patch", "change-capture.json"} or relative.startswith("requests/"):
1083
+ return "SCOPE_DISCOVER"
1084
+ if relative in {"behavior-risk-matrix.json"}:
1085
+ return "CONTRACT_MODEL"
1086
+ if relative in {"test-plan.json"}:
1087
+ return "TEST_PLAN"
1088
+ if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
1089
+ return "INFRA_READY"
1090
+ if relative.startswith("generated-tests/"):
1091
+ return "TEST_BUILD"
1092
+ if relative in {"executions.json", "differential-proof.json"} or relative.startswith("receipts/") or relative.startswith("raw/") or relative.startswith("runner/"):
1093
+ return "EXECUTE_PROVE"
1094
+ if relative in FINAL_RECORDS or relative == "lifecycle.json" or relative == "init.json" or relative == "finalize.json":
1095
+ return "EVIDENCE_PACKAGE"
1096
+ return "EVIDENCE_PACKAGE"
1097
+
1098
+
1099
+ def build_manifest(evidence_dir: Path, *, evidence_id: str, identity: dict[str, Any], target_hashes: dict[str, str], change_class: str, baseline_commit: str, final_verdict: str) -> dict[str, Any]:
1100
+ file_paths = sorted(
1101
+ path.relative_to(evidence_dir).as_posix()
1102
+ for path in evidence_dir.rglob("*")
1103
+ if path.is_file() and path.name != "manifest.json"
1104
+ )
1105
+ files = [
1106
+ {"path": relative, "sha256": file_sha256(evidence_dir / relative), "produced_by": stage_output_owner(relative, change_class)}
1107
+ for relative in file_paths
1108
+ ]
1109
+ outputs_by_stage = {stage: [] for stage in STAGES}
1110
+ for entry in files:
1111
+ outputs_by_stage.setdefault(entry["produced_by"], []).append(entry["path"])
1112
+ predecessor_files: list[dict[str, str]] = []
1113
+ stages = []
1114
+ for stage in STAGES:
1115
+ outputs = sorted(outputs_by_stage.get(stage, []))
1116
+ stages.append({
1117
+ "name": stage,
1118
+ "status": "complete",
1119
+ "input_sha256": canonical_sha256({
1120
+ "stage": stage,
1121
+ "target_hashes": target_hashes,
1122
+ "predecessor_outputs": sorted(predecessor_files, key=lambda item: item["path"]),
1123
+ }),
1124
+ "outputs": outputs,
1125
+ "not_applicable": None,
1126
+ })
1127
+ predecessor_files.extend(
1128
+ {"path": output, "sha256": next(item["sha256"] for item in files if item["path"] == output)}
1129
+ for output in outputs if output not in FINAL_RECORDS
1130
+ )
1131
+ return {
1132
+ "protocol_version": "1.0",
1133
+ "evidence_id": evidence_id,
1134
+ "evidence_id_inputs": identity,
1135
+ "sensitivity": "project-controlled",
1136
+ "environment_claim": "mocked-code-level-only",
1137
+ "change_class": change_class,
1138
+ "baseline_commit": baseline_commit,
1139
+ "working_diff_sha256": identity["working_diff_sha256"],
1140
+ "target_hashes": target_hashes,
1141
+ "stages": stages,
1142
+ "files": files,
1143
+ "final_verdict": final_verdict,
1144
+ "compatibility": "legacy-test-report-interface-not-supported",
1145
+ }
1146
+
1147
+
1148
+ def write_handoff_pointer(evidence_dir: Path, artifact_dir: str | None, project_root: Path | None = None) -> None:
1149
+ if not artifact_dir:
1150
+ return
1151
+ artifact = Path(artifact_dir)
1152
+ if not artifact.is_absolute() and project_root is not None:
1153
+ artifact = project_root / artifact
1154
+ artifact = artifact.resolve()
1155
+ if not artifact.is_dir():
1156
+ raise RequestError(f"artifact_dir does not exist: {artifact_dir}")
1157
+ pointer = artifact / "test-report-path.txt"
1158
+ write_text_atomic(pointer, str((evidence_dir / "test-report.md").resolve()) + "\n")
1159
+
1160
+
1161
+ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any], output_name: str) -> Path:
1162
+ allowed = {"request_version", "change_class", "baseline_commit", "scope_path", "environment_path", "artifact_dir", "blockers", "repair_scope"}
1163
+ if set(request) - allowed:
1164
+ raise RequestError(f"finalize request contains unsupported fields: {sorted(set(request) - allowed)}")
1165
+ if request.get("request_version", "1.0") != "1.0":
1166
+ raise RequestError("unsupported finalize request version")
1167
+ lifecycle = load_lifecycle(evidence_dir, required=True)
1168
+ if lifecycle.get("finalized") is True:
1169
+ raise RequestError("evidence identity is finalized; re-init a new package before changing scope or source")
1170
+ capture_path = evidence_dir / "change-capture.json"
1171
+ if not capture_path.exists():
1172
+ run_capture_change(project_root, evidence_dir, request.get("baseline_commit"), "change-capture.json")
1173
+ capture = ensure_object(load_json(capture_path), "change capture")
1174
+ baseline = capture.get("baseline_commit")
1175
+ patch_path = evidence_dir / "source-change.patch"
1176
+ if not isinstance(baseline, str) or not patch_path.is_file():
1177
+ raise RequestError("canonical change capture is incomplete")
1178
+ change_class = request.get("change_class", "behavior")
1179
+ if change_class not in {"behavior", "lightweight"}:
1180
+ raise RequestError("change_class must be behavior or lightweight")
1181
+ artifact_dir = request.get("artifact_dir")
1182
+ if artifact_dir is not None and not isinstance(artifact_dir, str):
1183
+ raise RequestError("artifact_dir must be a project-relative directory")
1184
+ if artifact_dir is not None:
1185
+ artifact = confined(project_root, artifact_dir, must_exist=True, directory=True)
1186
+ lifecycle["artifact_dir"] = artifact.relative_to(project_root).as_posix()
1187
+ scope_relative = request.get("scope_path", "scope.json")
1188
+ scope = ensure_object(load_json(request_file(evidence_dir, scope_relative)), "scope")
1189
+ inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1190
+ environment_path = evidence_dir / request.get("environment_path", "environment.json")
1191
+ environment = load_json(environment_path) if environment_path.exists() else {}
1192
+ plan = load_json(evidence_dir / "test-plan.json") if (evidence_dir / "test-plan.json").exists() else None
1193
+ if isinstance(plan, dict):
1194
+ normalize_test_snapshots(project_root, evidence_dir, plan)
1195
+ categories = inventory.get("categories", {})
1196
+ target_hashes = {
1197
+ category: aggregate_inventory(categories.get(category, []))
1198
+ for category in CATEGORIES
1199
+ }
1200
+ target_hashes["environment"] = canonical_sha256(environment)
1201
+ target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
1202
+ scope["target_hashes"] = target_hashes
1203
+ write_json_atomic(evidence_dir / scope_relative, scope)
1204
+ identity = {
1205
+ "baseline_commit": baseline,
1206
+ "working_diff_sha256": file_sha256(patch_path),
1207
+ "scope_sha256": canonical_sha256(scope),
1208
+ }
1209
+ evidence_id = canonical_sha256(identity)
1210
+ executions = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
1211
+ blockers = list(request.get("blockers", [])) if isinstance(request.get("blockers", []), list) else []
1212
+ required_records = {
1213
+ "change-classification.json", "scope.json", "target-inventory.json", "environment.json",
1214
+ "source-change.patch",
1215
+ }
1216
+ if change_class == "behavior":
1217
+ required_records.update({
1218
+ "behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
1219
+ "differential-proof.json",
1220
+ })
1221
+ else:
1222
+ required_records.add("lightweight-verification.json")
1223
+ missing_records = sorted(
1224
+ relative for relative in required_records
1225
+ if not (evidence_dir / relative).is_file()
1226
+ )
1227
+ if missing_records:
1228
+ blockers.append(
1229
+ "required evidence records are missing: " + ", ".join(missing_records)
1230
+ )
1231
+ selected_failures = [item for item in executions if isinstance(item, dict) and item.get("selected_execution") is True and item.get("reliable") is True and item.get("exit_code") != 0]
1232
+ if selected_failures and not request.get("repair_scope"):
1233
+ blockers.append("reliable failure exists but repair_scope is not evidence-backed")
1234
+ verdict = "TEST_FAIL" if selected_failures and request.get("repair_scope") else ("TEST_BLOCKED" if blockers else "TEST_PASS")
1235
+ verdict_record: dict[str, Any] = {
1236
+ "verdict": verdict,
1237
+ "testing_domain_only": True,
1238
+ "authorizes_commit_or_release": False,
1239
+ "real_environment_validated": False,
1240
+ "repairs_business_defects": False,
1241
+ "code_evidence_replayable": verdict == "TEST_PASS",
1242
+ "blockers": blockers if verdict == "TEST_BLOCKED" else [],
1243
+ "reproduced_failures": [item["execution_id"] for item in selected_failures] if verdict == "TEST_FAIL" else [],
1244
+ }
1245
+ if verdict == "TEST_FAIL" and request.get("repair_scope"):
1246
+ verdict_record["repair_scope"] = request["repair_scope"]
1247
+ write_json_atomic(evidence_dir / "verdict.json", verdict_record)
1248
+ write_json_atomic(evidence_dir / "validation.json", {})
1249
+ write_json_atomic(evidence_dir / "lifecycle.json", {
1250
+ **lifecycle,
1251
+ "status": "finalizing",
1252
+ "finalized": False,
1253
+ "immutable_identity": identity,
1254
+ })
1255
+ manifest = build_manifest(
1256
+ evidence_dir, evidence_id=evidence_id, identity=identity,
1257
+ target_hashes=target_hashes, change_class=change_class,
1258
+ baseline_commit=baseline, final_verdict=verdict,
1259
+ )
1260
+ write_json_atomic(evidence_dir / "manifest.json", manifest)
1261
+ run_render_report(evidence_dir, "test-report.md")
1262
+ write_json_atomic(evidence_dir / "lifecycle.json", {
1263
+ **lifecycle,
1264
+ "status": "finalized",
1265
+ "finalized": True,
1266
+ "immutable_identity": identity,
1267
+ })
1268
+ output = confined(evidence_dir, output_name)
1269
+ write_json_atomic(output, {
1270
+ "command": "finalize",
1271
+ "evidence_id": evidence_id,
1272
+ "manifest_path": "manifest.json",
1273
+ "verdict_path": "verdict.json",
1274
+ "report_path": "test-report.md",
1275
+ "validation_path": "validation.json",
1276
+ "next": "validate_test_evidence.py --attest",
1277
+ })
1278
+ manifest = build_manifest(
1279
+ evidence_dir, evidence_id=evidence_id, identity=identity,
1280
+ target_hashes=target_hashes, change_class=change_class,
1281
+ baseline_commit=baseline, final_verdict=verdict,
1282
+ )
1283
+ write_json_atomic(evidence_dir / "manifest.json", manifest)
1284
+ destination = evidence_dir.parent / evidence_id
1285
+ if destination != evidence_dir:
1286
+ if destination.exists():
1287
+ raise RequestError(
1288
+ f"evidence identity already exists at {destination}; re-init a new package"
1289
+ )
1290
+ os.replace(evidence_dir, destination)
1291
+ write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
1292
+ return destination / output_name
1293
+
1294
+
609
1295
  def run_resume(
610
1296
  project_root: Path,
611
1297
  evidence_dir: Path,
@@ -615,6 +1301,11 @@ def run_resume(
615
1301
  ) -> Path:
616
1302
  manifest = ensure_object(load_json(manifest_path), "manifest")
617
1303
  inventory = ensure_object(load_json(inventory_path), "inventory")
1304
+ lifecycle = load_lifecycle(evidence_dir)
1305
+ if lifecycle.get("finalized") is True:
1306
+ raise RequestError(
1307
+ "evidence package is finalized and immutable; re-init a new evidence package before resume"
1308
+ )
618
1309
  categories = ensure_object(inventory.get("categories"), "inventory categories")
619
1310
  environment_path = evidence_dir / "environment.json"
620
1311
  environment = load_json(environment_path) if environment_path.exists() else {}
@@ -683,6 +1374,12 @@ def main() -> int:
683
1374
  parser.add_argument("--project-root", required=True)
684
1375
  parser.add_argument("--evidence-dir", required=True)
685
1376
  subparsers = parser.add_subparsers(dest="subcommand", required=True)
1377
+ init = subparsers.add_parser("init")
1378
+ init.add_argument("--baseline")
1379
+ init.add_argument("--output", default="init.json")
1380
+ capture = subparsers.add_parser("capture-change")
1381
+ capture.add_argument("--baseline")
1382
+ capture.add_argument("--output", default="change-capture.json")
686
1383
  inventory = subparsers.add_parser("inventory")
687
1384
  inventory.add_argument("--request", required=True)
688
1385
  inventory.add_argument("--output", default="target-inventory.json")
@@ -692,6 +1389,11 @@ def main() -> int:
692
1389
  group.add_argument("--replay-receipt")
693
1390
  differential = subparsers.add_parser("differential")
694
1391
  differential.add_argument("--request", required=True)
1392
+ finalize = subparsers.add_parser("finalize")
1393
+ finalize.add_argument("--request", required=True, help="semantic finalization request")
1394
+ finalize.add_argument("--output", default="finalize.json")
1395
+ report = subparsers.add_parser("render-report")
1396
+ report.add_argument("--output", default="test-report.md")
695
1397
  resume = subparsers.add_parser("resume")
696
1398
  resume.add_argument("--manifest", required=True)
697
1399
  resume.add_argument("--inventory", required=True)
@@ -703,11 +1405,19 @@ def main() -> int:
703
1405
  if not project_root.is_dir():
704
1406
  raise RequestError(f"project root does not exist: {project_root}")
705
1407
  evidence_dir.relative_to(project_root)
706
- evidence_dir.mkdir(parents=True, exist_ok=True)
707
- if args.subcommand == "inventory":
1408
+ if args.subcommand == "init":
1409
+ output = run_init(project_root, evidence_dir, args.baseline, args.output)
1410
+ print(f"Evidence initialized: {output}")
1411
+ elif args.subcommand == "capture-change":
1412
+ require_mutable_lifecycle(evidence_dir)
1413
+ output = run_capture_change(project_root, evidence_dir, args.baseline, args.output)
1414
+ print(f"Change captured: {output}")
1415
+ elif args.subcommand == "inventory":
1416
+ require_mutable_lifecycle(evidence_dir)
708
1417
  output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
709
1418
  print(f"Inventory written: {output}")
710
1419
  elif args.subcommand == "execute":
1420
+ require_mutable_lifecycle(evidence_dir)
711
1421
  if args.request:
712
1422
  receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
713
1423
  else:
@@ -721,9 +1431,20 @@ def main() -> int:
721
1431
  receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
722
1432
  print(json.dumps(receipt, ensure_ascii=False))
723
1433
  elif args.subcommand == "differential":
1434
+ require_mutable_lifecycle(evidence_dir)
724
1435
  proof = run_differential(project_root, evidence_dir, request_file(evidence_dir, args.request))
725
1436
  print(json.dumps(proof, ensure_ascii=False))
726
- else:
1437
+ elif args.subcommand == "render-report":
1438
+ load_lifecycle(evidence_dir)
1439
+ output = run_render_report(evidence_dir, args.output)
1440
+ print(f"Report rendered: {output}")
1441
+ elif args.subcommand == "finalize":
1442
+ load_lifecycle(evidence_dir, required=True)
1443
+ request = ensure_object(load_json(request_file(evidence_dir, args.request)), "finalize request")
1444
+ output = run_finalize(project_root, evidence_dir, request, args.output)
1445
+ print(f"Evidence finalized: {output}")
1446
+ elif args.subcommand == "resume":
1447
+ load_lifecycle(evidence_dir)
727
1448
  output = run_resume(
728
1449
  project_root, evidence_dir, request_file(evidence_dir, args.manifest),
729
1450
  request_file(evidence_dir, args.inventory), args.output,