prizmkit 1.1.125 → 1.1.126

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 (25) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +49 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +75 -14
  4. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +37 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +267 -0
  6. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +3 -2
  7. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  8. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +6 -18
  9. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -15
  10. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +8 -9
  11. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +9 -9
  12. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +15 -18
  13. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +11 -17
  14. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +9 -9
  15. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +8 -5
  16. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +1 -1
  17. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +1 -1
  18. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +206 -3
  19. package/bundled/dev-pipeline/tests/test_runtime_helper.py +36 -0
  20. package/bundled/dev-pipeline/tests/test_unified_cli.py +10 -7
  21. package/bundled/skills/_metadata.json +1 -1
  22. package/bundled/skills/prizmkit-code-review/SKILL.md +128 -95
  23. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +89 -25
  24. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +246 -114
  25. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.125",
3
- "bundledAt": "2026-07-12T10:06:26.471Z",
4
- "bundledFrom": "ae3f6a3"
2
+ "frameworkVersion": "1.1.126",
3
+ "bundledAt": "2026-07-12T14:55:57.208Z",
4
+ "bundledFrom": "577a809"
5
5
  }
@@ -11,8 +11,23 @@ from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
 
13
13
  from .commands import CommandResult # type: ignore # runtime boundary import
14
- from .gitops import default_branch, list_reset_branches, reset_cleanup_branch
14
+ from .gitops import (
15
+ BranchContext,
16
+ WorktreePolicy,
17
+ default_branch,
18
+ guarded_worktree_remove,
19
+ list_reset_branches,
20
+ reset_cleanup_branch,
21
+ worktree_runtime_context,
22
+ )
15
23
  from .runner_models import RunnerFamily, family_for
24
+ from .task_checkout import (
25
+ CHECKOUT_MODE_WORKTREE,
26
+ TaskCheckout,
27
+ load_task_checkout,
28
+ mark_task_checkout_reset_locked,
29
+ task_checkout_guard,
30
+ )
16
31
 
17
32
 
18
33
  @dataclass(frozen=True)
@@ -128,7 +143,12 @@ def run_reset_command(kind: str, legacy_args: tuple[str, ...], paths) -> Command
128
143
  failed = 0
129
144
  return_branch = default_branch(paths.project_root)
130
145
  for item in items:
131
- ok = _reset_one(family, item, options, paths.project_root, return_branch, lines)
146
+ try:
147
+ with task_checkout_guard(family.state_dir, item.item_id):
148
+ ok = _reset_one(family, item, options, paths.project_root, return_branch, lines)
149
+ except RuntimeError as exc:
150
+ lines.append(f"ERROR: Reset {item.item_id} could not acquire checkout lifecycle: {exc}")
151
+ ok = False
132
152
  if ok:
133
153
  success += 1
134
154
  else:
@@ -254,7 +274,25 @@ def _reset_one(family: RunnerFamily, item: ResetItem, options: ResetOptions, pro
254
274
  else:
255
275
  lines.append(f"Current status: {item.status} (no runtime state file)")
256
276
 
257
- for branch in list_reset_branches(project_root, BRANCH_PREFIX_BY_KIND[family.kind], item.item_id):
277
+ checkout = load_task_checkout(family.state_dir, family.task_type, item.item_id)
278
+ branches = list(list_reset_branches(project_root, BRANCH_PREFIX_BY_KIND[family.kind], item.item_id))
279
+ if checkout is not None and (checkout.is_active or checkout.is_completed):
280
+ if checkout.active_dev_branch not in branches:
281
+ branches.insert(0, checkout.active_dev_branch)
282
+ if checkout.checkout_mode == CHECKOUT_MODE_WORKTREE:
283
+ runtime = worktree_runtime_context(
284
+ BranchContext(checkout.base_branch, checkout.active_dev_branch, project_root),
285
+ WorktreePolicy(True, True, True, Path(checkout.worktree_path).parent),
286
+ )
287
+ if runtime.worktree_path != Path(checkout.worktree_path).resolve():
288
+ lines.append(f"ERROR: Reset {item.item_id} worktree identity resolves to an unexpected path")
289
+ return False
290
+ removal = guarded_worktree_remove(runtime, delete_branch=False)
291
+ if not removal.ok:
292
+ lines.append(f"ERROR: Reset {item.item_id} could not remove linked worktree: {removal.reason}")
293
+ return False
294
+
295
+ for branch in branches:
258
296
  cleanup = reset_cleanup_branch(project_root, branch, return_branch=return_branch, delete=options.clean)
259
297
  lines.append(f"Discarded uncommitted changes on dev branch: {cleanup.branch}")
260
298
  if cleanup.deleted:
@@ -262,11 +300,19 @@ def _reset_one(family: RunnerFamily, item: ResetItem, options: ResetOptions, pro
262
300
  elif not options.clean:
263
301
  lines.append(f"Dev branch preserved for debugging: {cleanup.branch}")
264
302
  lines.extend(f"WARN: {warning}" for warning in cleanup.warnings)
303
+ if cleanup.warnings:
304
+ lines.append(f"ERROR: Reset {item.item_id} branch cleanup failed")
305
+ return False
265
306
 
266
307
  action = "clean" if options.clean else "reset"
267
308
  lines.append(f"{'Cleaning' if options.clean else 'Resetting'} {item.item_id} status...")
268
309
  result = _run_updater(family, action, options, item, project_root)
269
310
  if result.returncode == 0 and _json_without_error(result.stdout):
311
+ try:
312
+ mark_task_checkout_reset_locked(family.state_dir, family.task_type, item.item_id)
313
+ except RuntimeError as exc:
314
+ lines.append(f"ERROR: Reset {item.item_id} checkout identity failed: {exc}")
315
+ return False
270
316
  lines.append(f"{item.item_id} {action} complete: status -> pending, retry count -> 0")
271
317
  return True
272
318
  error = _error_text(result.stdout) or result.stderr.strip() or "unknown"
@@ -40,6 +40,15 @@ from .runner_prompts import PromptGenerationResult, generate_prompt
40
40
  from .runner_recovery import run_recovery, run_recovery_detect
41
41
  from .runner_status import get_next, run_status_action, start_item, status_text, update_item
42
42
  from .sessions import AISessionConfig, AISessionLauncher, detect_stream_json_support
43
+ from .task_checkout import (
44
+ CHECKOUT_MODE_BRANCH,
45
+ CHECKOUT_MODE_WORKTREE,
46
+ TaskCheckout,
47
+ complete_task_checkout_locked,
48
+ load_task_checkout,
49
+ reserve_task_checkout_locked,
50
+ task_checkout_guard,
51
+ )
43
52
 
44
53
 
45
54
  def run_pipeline_command(kind: str, action: str, legacy_args: tuple[str, ...], paths) -> CommandResult:
@@ -247,8 +256,22 @@ def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) ->
247
256
  return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
248
257
 
249
258
 
250
- def _with_recovery_metadata(family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> dict[str, object]:
259
+ def _with_recovery_metadata(
260
+ family: RunnerFamily,
261
+ item_id: str,
262
+ metadata: dict[str, object],
263
+ checkout: TaskCheckout | None = None,
264
+ ) -> dict[str, object]:
251
265
  enriched = dict(metadata)
266
+ checkout = checkout or load_task_checkout(family.state_dir, family.task_type, item_id)
267
+ if checkout is not None:
268
+ if checkout.is_active:
269
+ enriched["active_dev_branch"] = checkout.active_dev_branch
270
+ enriched["base_branch"] = checkout.base_branch
271
+ return enriched
272
+ for key in ("active_dev_branch", "base_branch"):
273
+ enriched.pop(key, None)
274
+ return enriched
252
275
  if enriched.get("active_dev_branch") and enriched.get("base_branch"):
253
276
  return enriched
254
277
  recovered = _latest_session_status(family.state_dir, item_id)
@@ -431,6 +454,21 @@ def _process_item(
431
454
  paths,
432
455
  *,
433
456
  initial_metadata: dict[str, object],
457
+ ) -> str:
458
+ try:
459
+ with task_checkout_guard(family.state_dir, item_id):
460
+ return _process_item_locked(family, invocation, item_id, paths, initial_metadata=initial_metadata)
461
+ except KeyboardInterrupt:
462
+ raise
463
+
464
+
465
+ def _process_item_locked(
466
+ family: RunnerFamily,
467
+ invocation: RunnerInvocation,
468
+ item_id: str,
469
+ paths,
470
+ *,
471
+ initial_metadata: dict[str, object],
434
472
  ) -> str:
435
473
  env = RunnerEnvironment.from_env()
436
474
  config = load_runtime_config(paths)
@@ -438,12 +476,20 @@ def _process_item(
438
476
  raise RuntimeError("No supported AI CLI command was found")
439
477
 
440
478
  active_branch = current_branch(paths.project_root)
441
- metadata = _with_recovery_metadata(family, item_id, initial_metadata)
479
+ checkout = load_task_checkout(family.state_dir, family.task_type, item_id)
480
+ if checkout is not None and checkout.is_completed:
481
+ raise RuntimeError(f"Task {item_id} is completed; run explicit reset or clean before rerunning")
482
+ metadata = _with_recovery_metadata(family, item_id, initial_metadata, checkout)
442
483
  base_branch = _resolve_base_branch(paths.project_root, metadata, active_branch, family, item_id)
443
484
  branch_name = _resolve_working_branch(family, item_id, metadata, env, active_branch)
444
485
  branch_context = BranchContext(base_branch, branch_name, paths.project_root)
445
486
  worktree_runtime = None
446
- use_worktree = _use_worktree_for_family(env, family)
487
+ if checkout is not None and checkout.is_active:
488
+ use_worktree = checkout.uses_worktree
489
+ worktree_path = Path(checkout.worktree_path) if checkout.worktree_path else None
490
+ else:
491
+ use_worktree = _use_worktree_for_family(env, family)
492
+ worktree_path = None
447
493
  execution_root = paths.project_root
448
494
 
449
495
  try:
@@ -468,9 +514,23 @@ def _process_item(
468
514
  use_worktree=True,
469
515
  cleanup_on_success=True,
470
516
  preserve_on_failure=True,
471
- worktree_root=paths.state_dir / "worktrees",
517
+ worktree_root=(worktree_path.parent if worktree_path is not None else paths.state_dir / "worktrees"),
472
518
  ),
473
519
  )
520
+ if worktree_path is not None and worktree_runtime.worktree_path != worktree_path.resolve():
521
+ raise RuntimeError(f"Task {item_id} worktree identity resolves to an unexpected path")
522
+ checkout_candidate = TaskCheckout.active(
523
+ family.task_type,
524
+ item_id,
525
+ branch_name,
526
+ base_branch,
527
+ checkout_mode=CHECKOUT_MODE_WORKTREE if use_worktree else CHECKOUT_MODE_BRANCH,
528
+ worktree_path=str(worktree_runtime.worktree_path) if worktree_runtime is not None else "",
529
+ )
530
+ checkout = reserve_task_checkout_locked(family.state_dir, checkout_candidate)
531
+ _emit_info(f"Checkout: reserved task identity at {family.state_dir / item_id / 'checkout.json'}")
532
+
533
+ if use_worktree:
474
534
  setup = ensure_linked_worktree(worktree_runtime)
475
535
  if not setup.ok:
476
536
  return "infra_error"
@@ -670,9 +730,7 @@ def _process_item(
670
730
  if final_session_status != "success":
671
731
  commit_final_bookkeeping(status_root, status_family, item_id, new_status)
672
732
  _save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
673
- if use_worktree and worktree_runtime is not None:
674
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
675
- else:
733
+ if not use_worktree:
676
734
  branch_ensure_return(paths.project_root, base_branch, branch_name)
677
735
  return new_status
678
736
 
@@ -697,7 +755,6 @@ def _process_item(
697
755
  _emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
698
756
  if use_worktree and worktree_runtime is not None:
699
757
  worktree_save_wip(worktree_runtime)
700
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
701
758
  else:
702
759
  branch_ensure_return(paths.project_root, base_branch, branch_name)
703
760
  raise
@@ -789,6 +846,10 @@ def _complete_success_merge(
789
846
  merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
790
847
  _emit_git_plan_output(merge)
791
848
  if merge.ok:
849
+ checkout = load_task_checkout(family.state_dir, family.task_type, item_id)
850
+ if checkout is None:
851
+ raise RuntimeError(f"Task {item_id} checkout identity disappeared before completion")
852
+ complete_task_checkout_locked(family.state_dir, checkout)
792
853
  guarded_worktree_remove(worktree_runtime, delete_branch=True)
793
854
  _emit_success(f"Merged {branch_name} into {base_branch}")
794
855
  return new_status
@@ -797,6 +858,10 @@ def _complete_success_merge(
797
858
  merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
798
859
  _emit_git_plan_output(merge)
799
860
  if merge.ok:
861
+ checkout = load_task_checkout(family.state_dir, family.task_type, item_id)
862
+ if checkout is None:
863
+ raise RuntimeError(f"Task {item_id} checkout identity disappeared before completion")
864
+ complete_task_checkout_locked(family.state_dir, checkout)
800
865
  _emit_success(f"Merged {branch_name} into {base_branch}")
801
866
  return new_status
802
867
 
@@ -829,9 +894,7 @@ def _complete_success_merge(
829
894
  merged_status = _status_from_update(update)
830
895
  commit_final_bookkeeping(status_root, status_family, item_id, merged_status)
831
896
  _save_failure_wip(execution_root, family, item_id, merged_status, worktree_runtime)
832
- if worktree_runtime is not None:
833
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
834
- else:
897
+ if worktree_runtime is None:
835
898
  branch_ensure_return(paths.project_root, base_branch, branch_name)
836
899
  return merged_status
837
900
 
@@ -876,9 +939,7 @@ def _record_setup_failure(
876
939
  if update.ok:
877
940
  commit_final_bookkeeping(status_root, status_family, item_id, new_status)
878
941
  _save_failure_wip(status_root, family, item_id, new_status, worktree_runtime)
879
- if worktree_runtime is not None:
880
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
881
- elif current_branch(project_root) == branch_name:
942
+ if worktree_runtime is None and current_branch(project_root) == branch_name:
882
943
  branch_ensure_return(project_root, base_branch, branch_name)
883
944
  return new_status
884
945
 
@@ -233,6 +233,39 @@ def _text_contains(args: argparse.Namespace) -> HelperResult:
233
233
  return HelperResult(args.found_marker if found else args.missing_marker, found, {"path": str(target), "pattern": args.pattern})
234
234
 
235
235
 
236
+ def _text_final_verdict(args: argparse.Namespace) -> HelperResult:
237
+ target = _path(args.path)
238
+ if not target.is_file():
239
+ return HelperResult("REVIEW_INCOMPLETE", False, {"path": str(target), "reason": "file_missing"}, 1)
240
+ try:
241
+ lines = target.read_text(encoding="utf-8", errors="ignore").splitlines()
242
+ except OSError as exc:
243
+ return HelperResult("REVIEW_INCOMPLETE", False, {"path": str(target), "reason": type(exc).__name__}, 1)
244
+
245
+ final_indexes = [index for index, line in enumerate(lines) if line.strip() == "## Final Result"]
246
+ if not final_indexes:
247
+ return HelperResult("REVIEW_INCOMPLETE", False, {"path": str(target), "reason": "final_result_missing"}, 1)
248
+
249
+ verdict = ""
250
+ for line in lines[final_indexes[-1] + 1:]:
251
+ stripped = line.strip()
252
+ if stripped.startswith("## "):
253
+ break
254
+ if stripped.startswith("- Verdict:"):
255
+ verdict = stripped[len("- Verdict:"):].strip()
256
+ break
257
+
258
+ markers = {
259
+ "PASS": "REVIEW_PASS",
260
+ "NEEDS_FIXES": "REVIEW_NEEDS_FIXES",
261
+ "BLOCKED": "REVIEW_BLOCKED",
262
+ }
263
+ marker = markers.get(verdict)
264
+ if not marker:
265
+ return HelperResult("REVIEW_INVALID", False, {"path": str(target), "verdict": verdict or None}, 1)
266
+ return HelperResult(marker, True, {"path": str(target), "verdict": verdict})
267
+
268
+
236
269
  def _platform_detect(args: argparse.Namespace) -> HelperResult:
237
270
  paths = resolve_runtime_paths(project_root=args.project_root, pipeline_root=args.pipeline_root)
238
271
  resolution = resolve_platform(paths.project_root)
@@ -411,6 +444,10 @@ def build_parser() -> argparse.ArgumentParser:
411
444
  contains.add_argument("--missing-marker", default="GATE:MISSING")
412
445
  _add_json(contains)
413
446
  contains.set_defaults(func=_text_contains)
447
+ final_verdict = text_sub.add_parser("final-verdict", help="Read the verdict from the last review Final Result section")
448
+ final_verdict.add_argument("path")
449
+ _add_json(final_verdict)
450
+ final_verdict.set_defaults(func=_text_final_verdict)
414
451
 
415
452
  platform = subparsers.add_parser("platform", help="AI platform and skill detection")
416
453
  platform_sub = platform.add_subparsers(dest="action", required=True)
@@ -0,0 +1,267 @@
1
+ """Durable task checkout identity for pipeline branch and worktree reuse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ import time
10
+ from contextlib import contextmanager
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+ from typing import Iterator
14
+
15
+
16
+ CHECKOUT_STATE_ACTIVE = "active"
17
+ CHECKOUT_STATE_COMPLETED = "completed"
18
+ CHECKOUT_STATE_RESET = "reset"
19
+ CHECKOUT_MODE_BRANCH = "branch"
20
+ CHECKOUT_MODE_WORKTREE = "worktree"
21
+
22
+
23
+ class TaskCheckoutError(RuntimeError):
24
+ """Raised when durable checkout identity cannot be coordinated safely."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TaskCheckout:
29
+ """Checkout identity that remains authoritative until explicit reset or clean."""
30
+
31
+ task_type: str
32
+ task_id: str
33
+ state: str
34
+ active_dev_branch: str = ""
35
+ base_branch: str = ""
36
+ checkout_mode: str = CHECKOUT_MODE_BRANCH
37
+ worktree_path: str = ""
38
+
39
+ @classmethod
40
+ def active(
41
+ cls,
42
+ task_type: str,
43
+ task_id: str,
44
+ active_dev_branch: str,
45
+ base_branch: str,
46
+ *,
47
+ checkout_mode: str = CHECKOUT_MODE_BRANCH,
48
+ worktree_path: str = "",
49
+ ) -> "TaskCheckout":
50
+ branch = active_dev_branch.strip()
51
+ base = base_branch.strip()
52
+ mode = checkout_mode.strip()
53
+ path = worktree_path.strip()
54
+ if not branch or not base:
55
+ raise TaskCheckoutError("Active task checkout requires development and base branches")
56
+ if mode not in {CHECKOUT_MODE_BRANCH, CHECKOUT_MODE_WORKTREE}:
57
+ raise TaskCheckoutError(f"Unsupported task checkout mode: {mode or '(empty)'}")
58
+ if mode == CHECKOUT_MODE_WORKTREE and not path:
59
+ raise TaskCheckoutError("Worktree task checkout requires a canonical worktree path")
60
+ if mode == CHECKOUT_MODE_BRANCH:
61
+ path = ""
62
+ return cls(task_type, task_id, CHECKOUT_STATE_ACTIVE, branch, base, mode, path)
63
+
64
+ @classmethod
65
+ def reset(cls, task_type: str, task_id: str) -> "TaskCheckout":
66
+ return cls(task_type=task_type, task_id=task_id, state=CHECKOUT_STATE_RESET)
67
+
68
+ def completed(self) -> "TaskCheckout":
69
+ if not self.is_active:
70
+ raise TaskCheckoutError("Only an active task checkout can be completed")
71
+ return TaskCheckout(
72
+ task_type=self.task_type,
73
+ task_id=self.task_id,
74
+ state=CHECKOUT_STATE_COMPLETED,
75
+ active_dev_branch=self.active_dev_branch,
76
+ base_branch=self.base_branch,
77
+ checkout_mode=self.checkout_mode,
78
+ worktree_path=self.worktree_path,
79
+ )
80
+
81
+ @property
82
+ def is_active(self) -> bool:
83
+ return self.state == CHECKOUT_STATE_ACTIVE
84
+
85
+ @property
86
+ def is_completed(self) -> bool:
87
+ return self.state == CHECKOUT_STATE_COMPLETED
88
+
89
+ @property
90
+ def is_reset(self) -> bool:
91
+ return self.state == CHECKOUT_STATE_RESET
92
+
93
+ @property
94
+ def uses_worktree(self) -> bool:
95
+ return self.checkout_mode == CHECKOUT_MODE_WORKTREE
96
+
97
+
98
+ def task_checkout_path(state_dir: Path | str, task_id: str) -> Path:
99
+ """Return the canonical checkout identity path for one task."""
100
+ return Path(state_dir) / task_id / "checkout.json"
101
+
102
+
103
+ def task_checkout_lock_path(state_dir: Path | str, task_id: str) -> Path:
104
+ """Return the exclusive lifecycle lock directory for one task."""
105
+ return Path(state_dir) / task_id / ".checkout.lock"
106
+
107
+
108
+ def load_task_checkout(state_dir: Path | str, task_type: str, task_id: str) -> TaskCheckout | None:
109
+ """Load and validate checkout identity, returning None only when absent."""
110
+ path = task_checkout_path(state_dir, task_id)
111
+ if not path.is_file():
112
+ return None
113
+ try:
114
+ data = json.loads(path.read_text(encoding="utf-8"))
115
+ except (OSError, json.JSONDecodeError) as exc:
116
+ raise TaskCheckoutError(f"Cannot read task checkout identity {path}: {exc}") from exc
117
+ if not isinstance(data, dict):
118
+ raise TaskCheckoutError(f"Invalid task checkout identity {path}: expected a JSON object")
119
+
120
+ checkout = TaskCheckout(
121
+ task_type=str(data.get("task_type") or ""),
122
+ task_id=str(data.get("task_id") or ""),
123
+ state=str(data.get("state") or ""),
124
+ active_dev_branch=str(data.get("active_dev_branch") or ""),
125
+ base_branch=str(data.get("base_branch") or ""),
126
+ checkout_mode=str(data.get("checkout_mode") or CHECKOUT_MODE_BRANCH),
127
+ worktree_path=str(data.get("worktree_path") or ""),
128
+ )
129
+ if checkout.task_type != task_type or checkout.task_id != task_id:
130
+ raise TaskCheckoutError(f"Task checkout identity mismatch in {path}")
131
+ if checkout.state not in {CHECKOUT_STATE_ACTIVE, CHECKOUT_STATE_COMPLETED, CHECKOUT_STATE_RESET}:
132
+ raise TaskCheckoutError(f"Invalid task checkout state in {path}: {checkout.state or '(empty)'}")
133
+ if checkout.checkout_mode not in {CHECKOUT_MODE_BRANCH, CHECKOUT_MODE_WORKTREE}:
134
+ raise TaskCheckoutError(f"Invalid task checkout mode in {path}: {checkout.checkout_mode or '(empty)'}")
135
+ if checkout.is_active or checkout.is_completed:
136
+ if not checkout.active_dev_branch.strip() or not checkout.base_branch.strip():
137
+ raise TaskCheckoutError(f"Incomplete task checkout identity in {path}")
138
+ if checkout.uses_worktree and not checkout.worktree_path.strip():
139
+ raise TaskCheckoutError(f"Missing worktree path in task checkout identity {path}")
140
+ return checkout
141
+
142
+
143
+ def _process_is_running(pid: int) -> bool:
144
+ if pid <= 0:
145
+ return False
146
+ try:
147
+ os.kill(pid, 0)
148
+ except ProcessLookupError:
149
+ return False
150
+ except PermissionError:
151
+ return True
152
+ except OSError:
153
+ return False
154
+ return True
155
+
156
+
157
+ def _lock_owner_pid(lock_path: Path) -> int:
158
+ for _attempt in range(5):
159
+ try:
160
+ data = json.loads((lock_path / "owner.json").read_text(encoding="utf-8"))
161
+ return int(data.get("pid") or 0) if isinstance(data, dict) else 0
162
+ except FileNotFoundError:
163
+ time.sleep(0.01)
164
+ except (OSError, ValueError, json.JSONDecodeError):
165
+ return 0
166
+ return 0
167
+
168
+
169
+ def _remove_stale_lock(lock_path: Path) -> None:
170
+ stale_path = lock_path.with_name(f"{lock_path.name}.stale-{os.getpid()}-{os.urandom(6).hex()}")
171
+ try:
172
+ os.rename(lock_path, stale_path)
173
+ except FileNotFoundError:
174
+ return
175
+ except OSError as exc:
176
+ raise TaskCheckoutError(f"Cannot claim stale task checkout lock {lock_path}: {exc}") from exc
177
+ shutil.rmtree(stale_path, ignore_errors=True)
178
+
179
+
180
+ @contextmanager
181
+ def task_checkout_guard(state_dir: Path | str, task_id: str) -> Iterator[None]:
182
+ """Exclusively guard one task lifecycle, reclaiming locks from dead processes."""
183
+ lock_path = task_checkout_lock_path(state_dir, task_id)
184
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
185
+ acquired = False
186
+ for _attempt in range(3):
187
+ try:
188
+ lock_path.mkdir()
189
+ (lock_path / "owner.json").write_text(json.dumps({"pid": os.getpid()}) + "\n", encoding="utf-8")
190
+ acquired = True
191
+ break
192
+ except FileExistsError:
193
+ owner_pid = _lock_owner_pid(lock_path)
194
+ if _process_is_running(owner_pid):
195
+ raise TaskCheckoutError(f"Task {task_id} is already running or being reset (PID {owner_pid})")
196
+ _remove_stale_lock(lock_path)
197
+ except OSError as exc:
198
+ raise TaskCheckoutError(f"Cannot acquire task checkout lock {lock_path}: {exc}") from exc
199
+ if not acquired:
200
+ raise TaskCheckoutError(f"Cannot acquire task checkout lock {lock_path}")
201
+ try:
202
+ yield
203
+ finally:
204
+ shutil.rmtree(lock_path, ignore_errors=True)
205
+
206
+
207
+ def persist_task_checkout(state_dir: Path | str, checkout: TaskCheckout) -> Path:
208
+ """Atomically persist checkout identity while the caller holds its task guard."""
209
+ path = task_checkout_path(state_dir, checkout.task_id)
210
+ path.parent.mkdir(parents=True, exist_ok=True)
211
+ descriptor = -1
212
+ temporary = ""
213
+ try:
214
+ descriptor, temporary = tempfile.mkstemp(prefix=".checkout-", suffix=".json", dir=path.parent)
215
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
216
+ descriptor = -1
217
+ json.dump(asdict(checkout), handle, indent=2, ensure_ascii=False)
218
+ handle.write("\n")
219
+ handle.flush()
220
+ os.fsync(handle.fileno())
221
+ os.replace(temporary, path)
222
+ except OSError as exc:
223
+ if descriptor >= 0:
224
+ os.close(descriptor)
225
+ if temporary:
226
+ try:
227
+ os.unlink(temporary)
228
+ except OSError:
229
+ pass
230
+ raise TaskCheckoutError(f"Cannot persist task checkout identity {path}: {exc}") from exc
231
+ return path
232
+
233
+
234
+ def reserve_task_checkout_locked(state_dir: Path | str, candidate: TaskCheckout) -> TaskCheckout:
235
+ """Reserve once under a task guard; matching reservations are idempotent."""
236
+ if not candidate.is_active:
237
+ raise TaskCheckoutError("Only an active checkout can be reserved")
238
+ existing = load_task_checkout(state_dir, candidate.task_type, candidate.task_id)
239
+ if existing is not None and existing.is_completed:
240
+ raise TaskCheckoutError(f"Task {candidate.task_id} is completed; run explicit reset or clean before rerunning")
241
+ if existing is not None and existing.is_active:
242
+ if existing != candidate:
243
+ raise TaskCheckoutError(
244
+ f"Task {candidate.task_id} already owns checkout {existing.active_dev_branch}; refusing {candidate.active_dev_branch}"
245
+ )
246
+ return existing
247
+ persist_task_checkout(state_dir, candidate)
248
+ return candidate
249
+
250
+
251
+ def reserve_task_checkout(state_dir: Path | str, candidate: TaskCheckout) -> TaskCheckout:
252
+ """Reserve a checkout with first-writer-wins synchronization."""
253
+ with task_checkout_guard(state_dir, candidate.task_id):
254
+ return reserve_task_checkout_locked(state_dir, candidate)
255
+
256
+
257
+ def complete_task_checkout_locked(state_dir: Path | str, checkout: TaskCheckout) -> Path:
258
+ """Mark an active checkout terminal after its successful merge."""
259
+ current = load_task_checkout(state_dir, checkout.task_type, checkout.task_id)
260
+ if current != checkout or not checkout.is_active:
261
+ raise TaskCheckoutError(f"Task {checkout.task_id} checkout changed before completion")
262
+ return persist_task_checkout(state_dir, checkout.completed())
263
+
264
+
265
+ def mark_task_checkout_reset_locked(state_dir: Path | str, task_type: str, task_id: str) -> Path:
266
+ """Publish an explicit reset boundary while the caller holds the task guard."""
267
+ return persist_task_checkout(state_dir, TaskCheckout.reset(task_type, task_id))
@@ -97,8 +97,9 @@ Implement the minimal fix (red → green):
97
97
  """\
98
98
  Verify fix quality:
99
99
  1. Run `/prizmkit-code-review` with the current bugfix artifact directory.
100
- 2. The skill launches one fresh read-only Reviewer per completed round; the Main Agent filters and directly fixes accepted findings.
101
- 3. Continue until no accepted finding remains or the ten-round safety fuse returns `NEEDS_FIXES`; stop and retry later if review returns `BLOCKED`.""",
100
+ 2. The Main Agent self-reviews for up to ten rounds; semantic risk limits independent Reviewers to low=0, medium=1, high=2.
101
+ 3. Reviewer 2 requires an accepted high finding from Reviewer 1 plus repair, tests, and Main-Agent reconvergence; Reviewer 3 is forbidden.
102
+ 4. Continue only after the report's last Final Result is valid; preserve append-only progress for recovery.""",
102
103
  ),
103
104
  6: (
104
105
  "User Verification",
@@ -30,7 +30,7 @@ Infer what needs to be done from the feature context above and follow the standa
30
30
 
31
31
  3. **Implement**: Run `/prizmkit-implement` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/` to execute the plan using TDD (write tests first, then implement).
32
32
 
33
- 4. **Review**: Run `/prizmkit-code-review` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/`. The skill uses one fresh read-only Reviewer per completed round, lets the Main Agent filter and directly fix accepted findings, and stops after at most ten completed rounds.
33
+ 4. **Review**: Run `/prizmkit-code-review` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/`. The Main Agent self-reviews for up to ten rounds; semantic risk then limits independent Reviewers to `low=0`, `medium=1`, `high=2`, with Reviewer 2 only after an accepted high finding is repaired and reconverged. Reviewer 3 is forbidden. Require the last `## Final Result` verdict before continuing.
34
34
 
35
35
  5. **Test**: Run `/prizmkit-test scope=this-change artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/` to generate and verify tests only for this feature's changed scope after review. Do not use bugfix/refactor artifact directories for this gate.
36
36
 
@@ -240,27 +240,15 @@ For each Verification Gate from the Task Contract, write one evidence line to Im
240
240
 
241
241
  Review the completed implementation before the scoped feature test gate runs. Use `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/` so the code-review skill can read spec, plan, context snapshot, implementation log, and current diff artifacts. Do not require or read a prior `/prizmkit-test` report here; test generation and test-fix looping happen in the next phase.
242
242
 
243
- Run `/prizmkit-code-review` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/`. The skill chooses a platform-supported fresh read-only Reviewer, prefers direct access to the active checkout, and supplies changed content that a platform-forced worktree cannot see. Do not prescribe Agent launch parameters from this bootstrap prompt.
243
+ Run `/prizmkit-code-review` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/`. The skill first runs a Main-Agent self-review loop for up to ten completed rounds, then semantic risk permits independent Reviewers at limits `low=0`, `medium=1`, `high=2`. High risk starts Reviewer 2 only after an accepted high finding was repaired, tested, and reconverged; Reviewer 3 is forbidden. The skill owns launch parameters, current-checkout preference, forced-worktree changed-content fallback, and append-only report progress.
244
244
 
245
- The skill runs an internal review-fix loop (fresh Reviewer → Main-Agent filter → Main-Agent fix, up to ten completed rounds) and writes `review-report.md` to the artifact directory.
246
-
247
- **Gate Check — Review Report**:
248
- After `/prizmkit-code-review` returns, verify the review report:
245
+ **Gate Check Final Review Result**:
249
246
  ```bash
250
- {{RUNTIME_HELPER_CMD}} text contains .prizmkit/specs/{{FEATURE_SLUG}}/review-report.md --pattern "## Verdict"
247
+ {{RUNTIME_HELPER_CMD}} text final-verdict .prizmkit/specs/{{FEATURE_SLUG}}/review-report.md
251
248
  ```
252
- If GATE:MISSING:
253
- - Do not re-run `/prizmkit-code-review` in an unbounded report-repair loop.
254
- - Perform one bounded status check; retry at most once: inspect the skill output, `review-report.md` path, and any Reviewer agent spawn messages.
255
- - If the missing report is caused by Reviewer agent spawn or review skill error, retry `/prizmkit-code-review` at most once only if it appears transient.
256
- - If the report is still missing after that single check/retry, write `failure-log.md` with the spawn/skill error and last observable state, then stop with a clear recovery failure. Do not synthesize a passing report through Main-Agent self-review.
257
-
258
- Read `review-report.md` and check the Verdict:
259
- - `PASS` → proceed to next phase
260
- - `NEEDS_FIXES` → log the unresolved accepted findings and proceed according to the task failure policy (do not retry externally)
261
- - `BLOCKED` → log the infrastructure reason and stop so the review can be retried
262
-
263
- **CP-3**: Review complete, report written.
249
+ Handle `REVIEW_PASS`, `REVIEW_NEEDS_FIXES`, and `REVIEW_BLOCKED` according to task policy. `REVIEW_INCOMPLETE` or `REVIEW_INVALID` means review did not complete: inspect progress once, write `failure-log.md`, and stop. Do not accept old or incidental verdict text or synthesize a passing result through Main-Agent self-review.
250
+
251
+ **CP-3**: Review has a valid Final Result and the report preserves this execution's progress.
264
252
 
265
253
  ### Scoped Feature Test Gate — PrizmKit Test
266
254