prizmkit 1.1.124 → 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 (32) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/adapters/claude/command-adapter.js +4 -6
  3. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +49 -3
  4. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +75 -14
  5. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +37 -0
  6. package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +267 -0
  7. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +4 -4
  8. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  9. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +5 -16
  10. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -14
  11. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +8 -4
  12. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +9 -8
  13. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +12 -15
  14. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +11 -16
  15. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +9 -8
  16. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +8 -4
  17. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +1 -1
  18. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +1 -1
  19. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +206 -3
  20. package/bundled/dev-pipeline/tests/test_runtime_helper.py +36 -0
  21. package/bundled/dev-pipeline/tests/test_unified_cli.py +21 -12
  22. package/bundled/skills/_metadata.json +4 -4
  23. package/bundled/skills/prizmkit-code-review/SKILL.md +130 -103
  24. package/bundled/skills/prizmkit-code-review/assets/reviewer-role.json +3 -5
  25. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +89 -38
  26. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +44 -62
  27. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +249 -143
  28. package/package.json +1 -1
  29. package/bundled/skills/prizmkit-code-review/references/reviewer-execution-protocol.md +0 -179
  30. package/bundled/skills/prizmkit-code-review/references/reviewer-workspace-protocol.md +0 -118
  31. package/bundled/skills/prizmkit-code-review/scripts/check_loop.py +0 -380
  32. package/bundled/skills/prizmkit-code-review/scripts/workspace_snapshot.py +0 -222
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.124",
3
- "bundledAt": "2026-07-12T05:54:30.044Z",
4
- "bundledFrom": "f15301f"
2
+ "frameworkVersion": "1.1.126",
3
+ "bundledAt": "2026-07-12T14:55:57.208Z",
4
+ "bundledFrom": "577a809"
5
5
  }
@@ -46,10 +46,8 @@ function buildReviewerRoleDefinition(roleManifest) {
46
46
  throw new Error(`Reviewer role must make ${capability} unavailable`);
47
47
  }
48
48
  }
49
- if (roleManifest.execution_mode !== 'foreground-single-authority'
50
- || roleManifest.max_descendant_execution_units !== 0
51
- || roleManifest.max_delegation_depth !== 0) {
52
- throw new Error('Reviewer role violates the single-authority execution topology');
49
+ if (roleManifest.execution_mode !== 'foreground-independent-review') {
50
+ throw new Error('Reviewer role must use foreground independent review');
53
51
  }
54
52
 
55
53
  const tools = (roleManifest.observational_capabilities || []).map(capability => {
@@ -64,14 +62,14 @@ name: ${roleManifest.role_name}
64
62
  description: ${roleManifest.description}
65
63
  tools: ${tools.join(', ')}
66
64
  permissionMode: dontAsk
67
- maxTurns: 200
65
+ maxTurns: 80
68
66
  ---
69
67
 
70
68
  You are a capability-restricted read-only Reviewer execution role.
71
69
 
72
70
  Read and follow the complete prompt supplied by the Main Agent. Perform the complete review yourself. Your tool allowlist intentionally excludes workspace mutation, shell execution, skill invocation, downstream execution creation, delegation-equivalent behavior, and orchestration re-entry.
73
71
 
74
- If the supplied review cannot be completed with the available observational capabilities and current execution context, return \`REVIEW_INFRASTRUCTURE_BLOCKED\`. Do not delegate, request a helper, mutate the workspace, or claim \`PASS\` from incomplete work.
72
+ If the supplied review cannot be completed with the available read-only capabilities and current context, return \`BLOCKED\` with the missing capability or context. Do not delegate, request a helper, mutate the workspace, or claim \`PASS\` from incomplete work.
75
73
  `;
76
74
  }
77
75
 
@@ -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))
@@ -96,10 +96,10 @@ Implement the minimal fix (red → green):
96
96
  "Review",
97
97
  """\
98
98
  Verify fix quality:
99
- 1. Self-review: does the fix address root cause (not just symptom)?
100
- Edge cases covered? Follows project conventions?
101
- 2. Run full test suite one final time
102
- 3. If any issues found, fix and re-review (max 3 rounds)""",
99
+ 1. Run `/prizmkit-code-review` with the current bugfix artifact directory.
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.""",
103
103
  ),
104
104
  6: (
105
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}}/` to review and auto-fix changes against the spec (internal review-fix loop, max 3 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