prizmkit 1.1.105 → 1.1.107

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 (36) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +0 -1
  3. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +83 -12
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +93 -6
  5. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
  6. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +9 -1
  7. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +421 -61
  8. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +102 -17
  9. package/bundled/dev-pipeline/scripts/continuation.py +0 -4
  10. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +7 -39
  11. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +1 -31
  12. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -31
  13. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
  14. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +28 -28
  15. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +36 -52
  16. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +34 -54
  17. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
  18. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
  19. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +2 -3
  20. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +2 -2
  21. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -9
  22. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -10
  23. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -3
  24. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -3
  25. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +49 -2
  26. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +17 -0
  27. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +50 -1
  28. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +560 -1
  29. package/bundled/dev-pipeline/tests/test_unified_cli.py +208 -0
  30. package/bundled/skills/_metadata.json +1 -1
  31. package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
  32. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
  33. package/package.json +1 -1
  34. package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
  35. package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
  36. package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
@@ -26,8 +26,13 @@ from .gitops import (
26
26
  worktree_runtime_context,
27
27
  worktree_save_wip,
28
28
  )
29
- from .runner_bookkeeping import commit_final_bookkeeping, commit_pre_branch_bookkeeping
30
- from .runner_classification import classify_session
29
+ from .runner_bookkeeping import (
30
+ commit_final_bookkeeping,
31
+ commit_pre_branch_bookkeeping,
32
+ commit_task_branch_changes,
33
+ commit_wip_changes,
34
+ )
35
+ from .runner_classification import classify_session, has_branch_completion_evidence, has_completed_artifact_path
31
36
  from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
32
37
  from .runner_prompts import PromptGenerationResult, generate_prompt
33
38
  from .runner_recovery import run_recovery, run_recovery_detect
@@ -106,6 +111,14 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
106
111
  return PipelineRunResult(False, processed, "get_next_failed", details=(next_result.text,))
107
112
  marker = next_result.stdout.strip()
108
113
  if marker == "PIPELINE_COMPLETE":
114
+ if _has_non_terminal_details(details):
115
+ return PipelineRunResult(
116
+ False,
117
+ processed,
118
+ "pipeline_complete_with_non_terminal_history",
119
+ _last_detail_status(details),
120
+ tuple(details),
121
+ )
109
122
  return PipelineRunResult(True, processed, "pipeline_complete", details=tuple(details))
110
123
  if marker == "PIPELINE_BLOCKED":
111
124
  return PipelineRunResult(False, processed, "pipeline_blocked", details=tuple(details))
@@ -133,6 +146,37 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
133
146
  return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
134
147
 
135
148
 
149
+ _NON_TERMINAL_DETAIL_STATUSES = {
150
+ "pending",
151
+ "retryable",
152
+ "in_progress",
153
+ "infra_error",
154
+ "context_overflow",
155
+ "stalled_context_continuation",
156
+ "crashed",
157
+ "timed_out",
158
+ "failed",
159
+ "needs_info",
160
+ "merge_conflict",
161
+ "interrupted",
162
+ "unknown",
163
+ }
164
+
165
+
166
+ def _detail_status(detail: str) -> str:
167
+ return detail.rsplit(":", 1)[-1] if ":" in detail else detail
168
+
169
+
170
+ def _last_detail_status(details: list[str]) -> str:
171
+ if not details:
172
+ return ""
173
+ return _detail_status(details[-1])
174
+
175
+
176
+ def _has_non_terminal_details(details: list[str]) -> bool:
177
+ return any(_detail_status(detail) in _NON_TERMINAL_DETAIL_STATUSES for detail in details)
178
+
179
+
136
180
  _SEPARATOR = "─" * 52
137
181
 
138
182
 
@@ -180,7 +224,37 @@ def _is_recovery_side_effect_branch(branch: str, family: RunnerFamily, item_id:
180
224
 
181
225
 
182
226
  def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) -> bool:
183
- return bool(branch) and branch.startswith(f"{family.branch_prefix}/{item_id}-") and not _is_recovery_side_effect_branch(branch, family, item_id)
227
+ if not branch or _is_recovery_side_effect_branch(branch, family, item_id):
228
+ return False
229
+ return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
230
+
231
+
232
+ def _with_recovery_metadata(family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> dict[str, object]:
233
+ enriched = dict(metadata)
234
+ if enriched.get("active_dev_branch") and enriched.get("base_branch"):
235
+ return enriched
236
+ recovered = _latest_session_status(family.state_dir, item_id)
237
+ for key in ("active_dev_branch", "base_branch"):
238
+ if not enriched.get(key) and recovered.get(key):
239
+ enriched[key] = recovered[key]
240
+ return enriched
241
+
242
+
243
+ def _latest_session_status(state_dir: Path, item_id: str) -> dict[str, object]:
244
+ sessions_dir = state_dir / item_id / "sessions"
245
+ try:
246
+ sessions = sorted(child for child in sessions_dir.iterdir() if child.is_dir())
247
+ except OSError:
248
+ return {}
249
+ for session in reversed(sessions):
250
+ status_path = session / "session-status.json"
251
+ try:
252
+ data = json.loads(status_path.read_text(encoding="utf-8"))
253
+ except (OSError, json.JSONDecodeError):
254
+ continue
255
+ if isinstance(data, dict):
256
+ return data
257
+ return {}
184
258
 
185
259
 
186
260
  def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
@@ -306,20 +380,15 @@ def _process_item(
306
380
  raise RuntimeError("No supported AI CLI command was found")
307
381
 
308
382
  active_branch = current_branch(paths.project_root)
309
- base_branch = _resolve_base_branch(paths.project_root, initial_metadata, active_branch, family, item_id)
310
- branch_name = _resolve_working_branch(family, item_id, initial_metadata, env, active_branch)
383
+ metadata = _with_recovery_metadata(family, item_id, initial_metadata)
384
+ base_branch = _resolve_base_branch(paths.project_root, metadata, active_branch, family, item_id)
385
+ branch_name = _resolve_working_branch(family, item_id, metadata, env, active_branch)
311
386
  branch_context = BranchContext(base_branch, branch_name, paths.project_root)
312
387
  worktree_runtime = None
313
388
  use_worktree = _use_worktree_for_family(env, family)
314
389
  execution_root = paths.project_root
315
390
 
316
- _emit_item_header(family, invocation, item_id, initial_metadata)
317
391
  try:
318
- start = start_item(family, invocation, item_id, paths.project_root)
319
- if not start.ok:
320
- raise RuntimeError(start.text)
321
- commit_pre_branch_bookkeeping(paths.project_root, family, item_id)
322
-
323
392
  if use_worktree:
324
393
  worktree_runtime = worktree_runtime_context(
325
394
  branch_context,
@@ -332,29 +401,74 @@ def _process_item(
332
401
  )
333
402
  setup = ensure_linked_worktree(worktree_runtime)
334
403
  if not setup.ok:
335
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
336
- return _status_from_update(status)
404
+ return "infra_error"
337
405
  worktree_runtime = setup.runtime or worktree_runtime
338
406
  _emit_info(f"Dev branch: {branch_name}")
339
407
  _emit_info(f"Worktree: {worktree_runtime.worktree_path}")
340
408
  support_assets = materialize_worktree_support_assets(worktree_runtime)
341
409
  if not support_assets.ok:
342
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
343
- return _status_from_update(status)
410
+ return _record_setup_failure(
411
+ family,
412
+ invocation,
413
+ item_id,
414
+ paths.project_root,
415
+ worktree_runtime.worktree_path,
416
+ base_branch,
417
+ branch_name,
418
+ worktree_runtime=worktree_runtime,
419
+ )
344
420
  execution_root = worktree_runtime.worktree_path
345
421
  else:
346
422
  branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
347
423
  _emit_branch_setup_result(branch_context, branch_result)
348
424
  if not branch_result.ok:
349
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
350
- return _status_from_update(status)
425
+ return "infra_error"
426
+
427
+ _emit_item_header(family, invocation, item_id, metadata)
428
+ status_root = execution_root if use_worktree else paths.project_root
429
+ status_family = _family_for_execution_root(family, paths.project_root, status_root)
430
+ status_invocation = _invocation_for_execution_family(invocation, status_family)
431
+ recovered_session_id = _latest_session_id(family.state_dir, item_id)
432
+ recovery_artifact = _artifact_path_for_item(execution_root, family, item_id, metadata)
433
+ if recovered_session_id and has_completed_artifact_path(recovery_artifact) and has_branch_completion_evidence(execution_root, base_branch, branch_name, recovery_artifact):
434
+ session_paths = SessionPaths.for_item(family.state_dir, item_id, recovered_session_id)
435
+ _write_session_status(
436
+ session_paths,
437
+ status="success",
438
+ session_id=recovered_session_id,
439
+ task_id=item_id,
440
+ task_type=family.task_type,
441
+ base_branch=base_branch,
442
+ active_dev_branch=branch_name,
443
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
444
+ reason="completed_branch_recovered_from_late_runtime_error",
445
+ )
446
+ return _finalize_recovered_success(
447
+ family,
448
+ item_id,
449
+ paths,
450
+ status_family,
451
+ status_invocation,
452
+ status_root,
453
+ execution_root,
454
+ branch_context,
455
+ branch_name,
456
+ base_branch,
457
+ recovered_session_id,
458
+ session_paths,
459
+ worktree_runtime,
460
+ )
461
+ start = start_item(status_family, status_invocation, item_id, status_root)
462
+ if not start.ok:
463
+ raise RuntimeError(start.text)
464
+ commit_pre_branch_bookkeeping(status_root, status_family, item_id)
351
465
 
352
- previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
353
- previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
354
- continuation_pending = bool(initial_metadata.get("continuation_pending"))
355
- previous_session_id = str(initial_metadata.get("previous_session_id") or initial_metadata.get("last_context_overflow_session_id") or "")
356
- continuation_count = int(initial_metadata.get("continuation_count") or 0)
357
- context_overflow_count = int(initial_metadata.get("context_overflow_count") or 0)
466
+ previous_fingerprint = metadata.get("last_progress_fingerprint")
467
+ previous_no_progress_count = int(metadata.get("no_progress_count") or 0)
468
+ continuation_pending = bool(metadata.get("continuation_pending"))
469
+ previous_session_id = str(metadata.get("previous_session_id") or metadata.get("last_context_overflow_session_id") or "")
470
+ continuation_count = int(metadata.get("continuation_count") or 0)
471
+ context_overflow_count = int(metadata.get("context_overflow_count") or 0)
358
472
 
359
473
  while True:
360
474
  session_id = _session_id(item_id)
@@ -368,7 +482,7 @@ def _process_item(
368
482
  "task_type": family.task_type,
369
483
  "active_dev_branch": branch_name,
370
484
  "base_branch": base_branch,
371
- "continuation_summary_path": _continuation_summary_path(paths.project_root, family, item_id, None),
485
+ "continuation_summary_path": _continuation_summary_path(execution_root, family, item_id, None),
372
486
  }
373
487
  prompt = generate_prompt(
374
488
  family,
@@ -417,14 +531,14 @@ def _process_item(
417
531
  )
418
532
  _emit_session_summary(session_result, session_paths, classification)
419
533
  if classification.session_status == "context_overflow":
420
- summary_path = _continuation_summary_path(paths.project_root, family, item_id, prompt)
534
+ summary_path = _continuation_summary_path(execution_root, family, item_id, prompt)
421
535
  update = update_item(
422
- family,
423
- invocation,
536
+ status_family,
537
+ status_invocation,
424
538
  item_id,
425
539
  "context_overflow",
426
540
  session_id,
427
- paths.project_root,
541
+ status_root,
428
542
  active_dev_branch=branch_name,
429
543
  base_branch=base_branch,
430
544
  last_fatal_error_code=classification.fatal_error_code or "context_overflow",
@@ -444,32 +558,6 @@ def _process_item(
444
558
  continue
445
559
 
446
560
  final_session_status = classification.session_status
447
- if use_worktree and worktree_runtime is not None:
448
- if final_session_status == "success":
449
- _emit_info(f"Merging {branch_name} into {base_branch}...")
450
- merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
451
- _emit_git_plan_output(merge)
452
- if merge.ok:
453
- guarded_worktree_remove(worktree_runtime, delete_branch=True)
454
- _emit_success(f"Merged {branch_name} into {base_branch}")
455
- else:
456
- final_session_status = "merge_conflict"
457
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
458
- else:
459
- worktree_save_wip(worktree_runtime)
460
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
461
- else:
462
- if final_session_status == "success":
463
- _emit_info(f"Merging {branch_name} into {base_branch}...")
464
- merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
465
- _emit_git_plan_output(merge)
466
- if merge.ok:
467
- _emit_success(f"Merged {branch_name} into {base_branch}")
468
- else:
469
- branch_ensure_return(paths.project_root, base_branch, branch_name)
470
- final_session_status = "merge_conflict"
471
- else:
472
- branch_ensure_return(paths.project_root, base_branch, branch_name)
473
561
 
474
562
  _write_session_status(
475
563
  session_paths,
@@ -485,12 +573,12 @@ def _process_item(
485
573
  )
486
574
 
487
575
  update = update_item(
488
- family,
489
- invocation,
576
+ status_family,
577
+ status_invocation,
490
578
  item_id,
491
579
  final_session_status,
492
580
  session_id,
493
- paths.project_root,
581
+ status_root,
494
582
  active_dev_branch=branch_name,
495
583
  base_branch=base_branch,
496
584
  last_fatal_error_code=classification.fatal_error_code,
@@ -501,8 +589,32 @@ def _process_item(
501
589
  raise RuntimeError(update.text)
502
590
  _emit_update_summary(family, item_id, update)
503
591
  new_status = _status_from_update(update)
504
- commit_final_bookkeeping(paths.project_root, family, item_id, new_status)
505
- return new_status
592
+ if final_session_status != "success":
593
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
594
+ _save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
595
+ if use_worktree and worktree_runtime is not None:
596
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
597
+ else:
598
+ branch_ensure_return(paths.project_root, base_branch, branch_name)
599
+ return new_status
600
+
601
+ return _complete_success_merge(
602
+ family,
603
+ item_id,
604
+ new_status,
605
+ paths,
606
+ status_family,
607
+ status_invocation,
608
+ status_root,
609
+ execution_root,
610
+ branch_context,
611
+ branch_name,
612
+ base_branch,
613
+ session_id,
614
+ session_paths,
615
+ worktree_runtime,
616
+ classification_fatal_error_code=classification.fatal_error_code,
617
+ )
506
618
  except KeyboardInterrupt:
507
619
  _emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
508
620
  if use_worktree and worktree_runtime is not None:
@@ -513,6 +625,172 @@ def _process_item(
513
625
  raise
514
626
 
515
627
 
628
+ def _finalize_recovered_success(
629
+ family: RunnerFamily,
630
+ item_id: str,
631
+ paths,
632
+ status_family: RunnerFamily,
633
+ status_invocation: RunnerInvocation,
634
+ status_root: Path,
635
+ execution_root: Path,
636
+ branch_context: BranchContext,
637
+ branch_name: str,
638
+ base_branch: str,
639
+ session_id: str,
640
+ session_paths: SessionPaths,
641
+ worktree_runtime,
642
+ ) -> str:
643
+ update = update_item(
644
+ status_family,
645
+ status_invocation,
646
+ item_id,
647
+ "success",
648
+ session_id,
649
+ status_root,
650
+ active_dev_branch=branch_name,
651
+ base_branch=base_branch,
652
+ )
653
+ if not update.ok:
654
+ raise RuntimeError(update.text)
655
+ _emit_update_summary(family, item_id, update)
656
+ return _complete_success_merge(
657
+ family,
658
+ item_id,
659
+ _status_from_update(update),
660
+ paths,
661
+ status_family,
662
+ status_invocation,
663
+ status_root,
664
+ execution_root,
665
+ branch_context,
666
+ branch_name,
667
+ base_branch,
668
+ session_id,
669
+ session_paths,
670
+ worktree_runtime,
671
+ classification_fatal_error_code="",
672
+ )
673
+
674
+
675
+ def _complete_success_merge(
676
+ family: RunnerFamily,
677
+ item_id: str,
678
+ new_status: str,
679
+ paths,
680
+ status_family: RunnerFamily,
681
+ status_invocation: RunnerInvocation,
682
+ status_root: Path,
683
+ execution_root: Path,
684
+ branch_context: BranchContext,
685
+ branch_name: str,
686
+ base_branch: str,
687
+ session_id: str,
688
+ session_paths: SessionPaths,
689
+ worktree_runtime,
690
+ *,
691
+ classification_fatal_error_code: str = "",
692
+ ) -> str:
693
+ env = RunnerEnvironment.from_env()
694
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
695
+ commit_task_branch_changes(execution_root, family, item_id, new_status)
696
+ if worktree_runtime is not None:
697
+ _emit_info(f"Merging {branch_name} into {base_branch}...")
698
+ merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
699
+ _emit_git_plan_output(merge)
700
+ if merge.ok:
701
+ guarded_worktree_remove(worktree_runtime, delete_branch=True)
702
+ _emit_success(f"Merged {branch_name} into {base_branch}")
703
+ return new_status
704
+ else:
705
+ _emit_info(f"Merging {branch_name} into {base_branch}...")
706
+ merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
707
+ _emit_git_plan_output(merge)
708
+ if merge.ok:
709
+ _emit_success(f"Merged {branch_name} into {base_branch}")
710
+ return new_status
711
+
712
+ _write_session_status(
713
+ session_paths,
714
+ status="merge_conflict",
715
+ session_id=session_id,
716
+ task_id=item_id,
717
+ task_type=family.task_type,
718
+ base_branch=base_branch,
719
+ active_dev_branch=branch_name,
720
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
721
+ reason="merge_conflict",
722
+ fatal_error_code=classification_fatal_error_code,
723
+ )
724
+ update = update_item(
725
+ status_family,
726
+ status_invocation,
727
+ item_id,
728
+ "merge_conflict",
729
+ session_id,
730
+ status_root,
731
+ active_dev_branch=branch_name,
732
+ base_branch=base_branch,
733
+ last_fatal_error_code=classification_fatal_error_code,
734
+ )
735
+ if not update.ok:
736
+ raise RuntimeError(update.text)
737
+ _emit_update_summary(family, item_id, update)
738
+ merged_status = _status_from_update(update)
739
+ commit_final_bookkeeping(status_root, status_family, item_id, merged_status)
740
+ _save_failure_wip(execution_root, family, item_id, merged_status, worktree_runtime)
741
+ if worktree_runtime is not None:
742
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
743
+ else:
744
+ branch_ensure_return(paths.project_root, base_branch, branch_name)
745
+ return merged_status
746
+
747
+
748
+ def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
749
+ if worktree_runtime is not None:
750
+ worktree_save_wip(worktree_runtime)
751
+ else:
752
+ commit_wip_changes(execution_root, family, item_id, status)
753
+
754
+
755
+ def _record_setup_failure(
756
+ family: RunnerFamily,
757
+ invocation: RunnerInvocation,
758
+ item_id: str,
759
+ project_root: Path,
760
+ status_root: Path,
761
+ base_branch: str,
762
+ branch_name: str,
763
+ *,
764
+ worktree_runtime=None,
765
+ ) -> str:
766
+ """Record setup-time infra failures on a task branch when one exists."""
767
+ if status_root.resolve() == project_root.resolve() and current_branch(project_root) != branch_name:
768
+ return "infra_error"
769
+ status_family = _family_for_execution_root(family, project_root, status_root)
770
+ status_invocation = _invocation_for_execution_family(invocation, status_family)
771
+ start = start_item(status_family, status_invocation, item_id, status_root)
772
+ if start.ok:
773
+ commit_pre_branch_bookkeeping(status_root, status_family, item_id)
774
+ update = update_item(
775
+ status_family,
776
+ status_invocation,
777
+ item_id,
778
+ "infra_error",
779
+ "",
780
+ status_root,
781
+ active_dev_branch=branch_name,
782
+ base_branch=base_branch,
783
+ )
784
+ new_status = _status_from_update(update) if update.ok else "infra_error"
785
+ if update.ok:
786
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
787
+ _save_failure_wip(status_root, family, item_id, new_status, worktree_runtime)
788
+ if worktree_runtime is not None:
789
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
790
+ elif current_branch(project_root) == branch_name:
791
+ branch_ensure_return(project_root, base_branch, branch_name)
792
+ return new_status
793
+
516
794
  def _test_cli(kind: str, paths) -> CommandResult:
517
795
  """Report the AI CLI resolution used by Python runner commands."""
518
796
  config = load_runtime_config(paths)
@@ -539,6 +817,89 @@ def _use_worktree_for_family(env: RunnerEnvironment, family: RunnerFamily) -> bo
539
817
  return env.use_worktree and family.kind in {"feature", "bugfix"}
540
818
 
541
819
 
820
+ def _family_for_execution_root(family: RunnerFamily, project_root: Path, execution_root: Path) -> RunnerFamily:
821
+ """Return family paths rooted in the task execution checkout."""
822
+ if execution_root.resolve() == project_root.resolve():
823
+ return family
824
+ return RunnerFamily(
825
+ kind=family.kind,
826
+ id_prefix=family.id_prefix,
827
+ list_key=family.list_key,
828
+ list_arg=family.list_arg,
829
+ item_id_arg=family.item_id_arg,
830
+ state_dir=_path_in_execution_root(family.state_dir, project_root, execution_root),
831
+ plan_path=_path_in_execution_root(family.plan_path, project_root, execution_root),
832
+ updater_script=family.updater_script,
833
+ prompt_script=family.prompt_script,
834
+ branch_prefix=family.branch_prefix,
835
+ artifact_root_name=family.artifact_root_name,
836
+ )
837
+
838
+
839
+ def _invocation_for_execution_family(invocation: RunnerInvocation, family: RunnerFamily) -> RunnerInvocation:
840
+ return RunnerInvocation(
841
+ action=invocation.action,
842
+ list_path=family.plan_path,
843
+ item_id=invocation.item_id,
844
+ item_filter=invocation.item_filter,
845
+ max_retries=invocation.max_retries,
846
+ max_infra_retries=invocation.max_infra_retries,
847
+ mode=invocation.mode,
848
+ critic=invocation.critic,
849
+ dry_run=invocation.dry_run,
850
+ clean=invocation.clean,
851
+ no_reset=invocation.no_reset,
852
+ resume_phase=invocation.resume_phase,
853
+ help_requested=invocation.help_requested,
854
+ unknown_args=invocation.unknown_args,
855
+ raw_args=invocation.raw_args,
856
+ )
857
+
858
+
859
+ def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path) -> Path:
860
+ try:
861
+ return execution_root / path.resolve().relative_to(project_root.resolve())
862
+ except ValueError:
863
+ return path
864
+
865
+
866
+ def _latest_session_id(state_dir: Path, item_id: str) -> str:
867
+ sessions_dir = state_dir / item_id / "sessions"
868
+ try:
869
+ sessions = [child.name for child in sessions_dir.iterdir() if child.is_dir()]
870
+ except OSError:
871
+ return ""
872
+ return sorted(sessions)[-1] if sessions else ""
873
+
874
+
875
+ def _artifact_path_for_item(project_root: Path, family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> Path:
876
+ if family.kind == "feature":
877
+ title = str(metadata.get("title") or "")
878
+ if title:
879
+ return project_root / ".prizmkit" / "specs" / _feature_slug(item_id, title)
880
+ specs = project_root / ".prizmkit" / "specs"
881
+ numeric = item_id.replace("F-", "").replace("f-", "").zfill(3)
882
+ try:
883
+ matches = sorted(path for path in specs.iterdir() if path.is_dir() and path.name.startswith(f"{numeric}-"))
884
+ except OSError:
885
+ matches = []
886
+ if matches:
887
+ return matches[0]
888
+ return project_root / ".prizmkit" / family.artifact_root_name / item_id
889
+
890
+
891
+ def _feature_slug(feature_id: str, title: str) -> str:
892
+ import re
893
+
894
+ numeric = feature_id.replace("F-", "").replace("f-", "").zfill(3)
895
+ cleaned = re.sub(r"[^a-z0-9\s-]", "", title.lower())
896
+ cleaned = re.sub(r"[\s]+", "-", cleaned.strip())
897
+ cleaned = re.sub(r"-+", "-", cleaned).strip("-")
898
+ if not cleaned:
899
+ cleaned = "feature"
900
+ return f"{numeric}-{cleaned}"
901
+
902
+
542
903
  def _write_session_status(
543
904
  session_paths: SessionPaths,
544
905
  *,
@@ -579,8 +940,7 @@ def _status_from_update(update_result) -> str:
579
940
 
580
941
 
581
942
  def _default_branch_name(family: RunnerFamily, item_id: str) -> str:
582
- stamp = time.strftime("%Y%m%d%H%M")
583
- return f"{family.branch_prefix}/{item_id}-{stamp}"
943
+ return f"{family.branch_prefix}/{item_id}-{time.strftime('%Y%m%d%H%M%S')}"
584
944
 
585
945
 
586
946
  def _session_id(item_id: str) -> str: