prizmkit 1.1.106 → 1.1.108

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 (85) hide show
  1. package/bin/create-prizmkit.js +4 -0
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
  4. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
  5. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
  8. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +56 -5
  9. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
  10. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +11 -1
  11. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +298 -63
  12. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +243 -37
  13. package/bundled/dev-pipeline/scripts/continuation.py +0 -5
  14. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +10 -82
  15. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +7 -64
  16. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +40 -63
  17. package/bundled/dev-pipeline/scripts/utils.py +123 -0
  18. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  19. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
  20. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  21. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +36 -35
  22. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +53 -80
  23. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +43 -78
  24. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +5 -17
  25. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +6 -18
  26. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  27. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -14
  28. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  29. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +0 -7
  30. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +0 -7
  31. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +0 -7
  32. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  33. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  34. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  35. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +0 -2
  36. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  37. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +6 -8
  38. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +3 -7
  39. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -16
  40. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -17
  41. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  42. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +2 -9
  43. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -8
  44. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  45. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -10
  46. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -10
  47. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +2 -9
  48. package/bundled/dev-pipeline/templates/sections/phase0-init.md +0 -7
  49. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  50. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  51. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +395 -21
  52. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +50 -14
  53. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +154 -2
  54. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +431 -3
  55. package/bundled/dev-pipeline/tests/test_unified_cli.py +857 -5
  56. package/bundled/skills/_metadata.json +4 -4
  57. package/bundled/skills/app-planner/SKILL.md +1 -1
  58. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  59. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  60. package/bundled/skills/bug-planner/SKILL.md +101 -15
  61. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  62. package/bundled/skills/feature-planner/SKILL.md +87 -8
  63. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  64. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  65. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  66. package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
  67. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
  68. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  69. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  70. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  71. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  72. package/bundled/templates/project-memory-template.md +1 -1
  73. package/package.json +1 -1
  74. package/src/ai-cli-launch.js +194 -0
  75. package/src/config.js +26 -14
  76. package/src/index.js +11 -44
  77. package/src/platforms.js +10 -3
  78. package/src/prompts.js +60 -2
  79. package/src/scaffold.js +26 -14
  80. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  81. package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
  82. package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
  83. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  84. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
  85. package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
@@ -29,10 +29,11 @@ from .gitops import (
29
29
  from .runner_bookkeeping import (
30
30
  commit_final_bookkeeping,
31
31
  commit_pre_branch_bookkeeping,
32
+ commit_preflight_ready_changes,
32
33
  commit_task_branch_changes,
33
34
  commit_wip_changes,
34
35
  )
35
- from .runner_classification import classify_session
36
+ from .runner_classification import classify_session, has_branch_completion_evidence, has_completed_artifact_path
36
37
  from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
37
38
  from .runner_prompts import PromptGenerationResult, generate_prompt
38
39
  from .runner_recovery import run_recovery, run_recovery_detect
@@ -111,6 +112,14 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
111
112
  return PipelineRunResult(False, processed, "get_next_failed", details=(next_result.text,))
112
113
  marker = next_result.stdout.strip()
113
114
  if marker == "PIPELINE_COMPLETE":
115
+ if _has_non_terminal_details(details):
116
+ return PipelineRunResult(
117
+ False,
118
+ processed,
119
+ "pipeline_complete_with_non_terminal_history",
120
+ _last_detail_status(details),
121
+ tuple(details),
122
+ )
114
123
  return PipelineRunResult(True, processed, "pipeline_complete", details=tuple(details))
115
124
  if marker == "PIPELINE_BLOCKED":
116
125
  return PipelineRunResult(False, processed, "pipeline_blocked", details=tuple(details))
@@ -138,6 +147,37 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
138
147
  return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
139
148
 
140
149
 
150
+ _NON_TERMINAL_DETAIL_STATUSES = {
151
+ "pending",
152
+ "retryable",
153
+ "in_progress",
154
+ "infra_error",
155
+ "context_overflow",
156
+ "stalled_context_continuation",
157
+ "crashed",
158
+ "timed_out",
159
+ "failed",
160
+ "needs_info",
161
+ "merge_conflict",
162
+ "interrupted",
163
+ "unknown",
164
+ }
165
+
166
+
167
+ def _detail_status(detail: str) -> str:
168
+ return detail.rsplit(":", 1)[-1] if ":" in detail else detail
169
+
170
+
171
+ def _last_detail_status(details: list[str]) -> str:
172
+ if not details:
173
+ return ""
174
+ return _detail_status(details[-1])
175
+
176
+
177
+ def _has_non_terminal_details(details: list[str]) -> bool:
178
+ return any(_detail_status(detail) in _NON_TERMINAL_DETAIL_STATUSES for detail in details)
179
+
180
+
141
181
  _SEPARATOR = "─" * 52
142
182
 
143
183
 
@@ -190,6 +230,34 @@ def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) ->
190
230
  return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
191
231
 
192
232
 
233
+ def _with_recovery_metadata(family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> dict[str, object]:
234
+ enriched = dict(metadata)
235
+ if enriched.get("active_dev_branch") and enriched.get("base_branch"):
236
+ return enriched
237
+ recovered = _latest_session_status(family.state_dir, item_id)
238
+ for key in ("active_dev_branch", "base_branch"):
239
+ if not enriched.get(key) and recovered.get(key):
240
+ enriched[key] = recovered[key]
241
+ return enriched
242
+
243
+
244
+ def _latest_session_status(state_dir: Path, item_id: str) -> dict[str, object]:
245
+ sessions_dir = state_dir / item_id / "sessions"
246
+ try:
247
+ sessions = sorted(child for child in sessions_dir.iterdir() if child.is_dir())
248
+ except OSError:
249
+ return {}
250
+ for session in reversed(sessions):
251
+ status_path = session / "session-status.json"
252
+ try:
253
+ data = json.loads(status_path.read_text(encoding="utf-8"))
254
+ except (OSError, json.JSONDecodeError):
255
+ continue
256
+ if isinstance(data, dict):
257
+ return data
258
+ return {}
259
+
260
+
193
261
  def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
194
262
  recorded = str(metadata.get("base_branch") or "").strip()
195
263
  if recorded:
@@ -313,14 +381,29 @@ def _process_item(
313
381
  raise RuntimeError("No supported AI CLI command was found")
314
382
 
315
383
  active_branch = current_branch(paths.project_root)
316
- base_branch = _resolve_base_branch(paths.project_root, initial_metadata, active_branch, family, item_id)
317
- branch_name = _resolve_working_branch(family, item_id, initial_metadata, env, active_branch)
384
+ metadata = _with_recovery_metadata(family, item_id, initial_metadata)
385
+ base_branch = _resolve_base_branch(paths.project_root, metadata, active_branch, family, item_id)
386
+ branch_name = _resolve_working_branch(family, item_id, metadata, env, active_branch)
318
387
  branch_context = BranchContext(base_branch, branch_name, paths.project_root)
319
388
  worktree_runtime = None
320
389
  use_worktree = _use_worktree_for_family(env, family)
321
390
  execution_root = paths.project_root
322
391
 
323
392
  try:
393
+ preflight = commit_preflight_ready_changes(paths.project_root, item_id)
394
+ if preflight.committed:
395
+ _emit_info(f"Preflight: preserved existing user-visible changes before {family.kind} task {item_id}")
396
+ elif preflight.attempted:
397
+ detail = ""
398
+ if preflight.result is not None:
399
+ detail = (preflight.result.stderr or preflight.result.stdout).strip()
400
+ _emit_warning(f"Preflight: failed to preserve existing changes before {family.kind} task {item_id}")
401
+ if detail:
402
+ _emit_warning(detail.splitlines()[0][:500])
403
+ raise RuntimeError(f"Preflight dirty workspace preservation failed before {family.kind} task {item_id}")
404
+ else:
405
+ _emit_info(f"Preflight: no user-visible changes to preserve before {family.kind} task {item_id}")
406
+
324
407
  if use_worktree:
325
408
  worktree_runtime = worktree_runtime_context(
326
409
  branch_context,
@@ -356,21 +439,51 @@ def _process_item(
356
439
  if not branch_result.ok:
357
440
  return "infra_error"
358
441
 
359
- _emit_item_header(family, invocation, item_id, initial_metadata)
442
+ _emit_item_header(family, invocation, item_id, metadata)
360
443
  status_root = execution_root if use_worktree else paths.project_root
361
444
  status_family = _family_for_execution_root(family, paths.project_root, status_root)
362
445
  status_invocation = _invocation_for_execution_family(invocation, status_family)
446
+ recovered_session_id = _latest_session_id(family.state_dir, item_id)
447
+ recovery_artifact = _artifact_path_for_item(execution_root, family, item_id, metadata)
448
+ if recovered_session_id and has_completed_artifact_path(recovery_artifact) and has_branch_completion_evidence(execution_root, base_branch, branch_name, recovery_artifact):
449
+ session_paths = SessionPaths.for_item(family.state_dir, item_id, recovered_session_id)
450
+ _write_session_status(
451
+ session_paths,
452
+ status="success",
453
+ session_id=recovered_session_id,
454
+ task_id=item_id,
455
+ task_type=family.task_type,
456
+ base_branch=base_branch,
457
+ active_dev_branch=branch_name,
458
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
459
+ reason="completed_branch_recovered_from_late_runtime_error",
460
+ )
461
+ return _finalize_recovered_success(
462
+ family,
463
+ item_id,
464
+ paths,
465
+ status_family,
466
+ status_invocation,
467
+ status_root,
468
+ execution_root,
469
+ branch_context,
470
+ branch_name,
471
+ base_branch,
472
+ recovered_session_id,
473
+ session_paths,
474
+ worktree_runtime,
475
+ )
363
476
  start = start_item(status_family, status_invocation, item_id, status_root)
364
477
  if not start.ok:
365
478
  raise RuntimeError(start.text)
366
479
  commit_pre_branch_bookkeeping(status_root, status_family, item_id)
367
480
 
368
- previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
369
- previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
370
- continuation_pending = bool(initial_metadata.get("continuation_pending"))
371
- previous_session_id = str(initial_metadata.get("previous_session_id") or initial_metadata.get("last_context_overflow_session_id") or "")
372
- continuation_count = int(initial_metadata.get("continuation_count") or 0)
373
- context_overflow_count = int(initial_metadata.get("context_overflow_count") or 0)
481
+ previous_fingerprint = metadata.get("last_progress_fingerprint")
482
+ previous_no_progress_count = int(metadata.get("no_progress_count") or 0)
483
+ continuation_pending = bool(metadata.get("continuation_pending"))
484
+ previous_session_id = str(metadata.get("previous_session_id") or metadata.get("last_context_overflow_session_id") or "")
485
+ continuation_count = int(metadata.get("continuation_count") or 0)
486
+ context_overflow_count = int(metadata.get("context_overflow_count") or 0)
374
487
 
375
488
  while True:
376
489
  session_id = _session_id(item_id)
@@ -401,7 +514,9 @@ def _process_item(
401
514
  if invocation.dry_run:
402
515
  return "dry_run"
403
516
  _emit_prompt_summary(prompt, session_id)
404
- stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
517
+ stream_json = False
518
+ if getattr(config.ai_client, "launch_profile", None) is None:
519
+ stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
405
520
  base_head = run_git_command(execution_root, ("rev-parse", "HEAD")).stdout.strip()
406
521
  session_result = AISessionLauncher(
407
522
  AISessionConfig(
@@ -417,7 +532,9 @@ def _process_item(
417
532
  verbose=env.verbose,
418
533
  live_output=env.live_output,
419
534
  use_stream_json=stream_json,
535
+ launch_profile=getattr(config.ai_client, "launch_profile", None),
420
536
  stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
537
+ heartbeat_stale_threshold_seconds=env.heartbeat_stale_threshold_seconds,
421
538
  live_progress_interval_seconds=env.heartbeat_interval_seconds,
422
539
  live_banner=False,
423
540
  suppress_stream_output=True,
@@ -500,62 +617,23 @@ def _process_item(
500
617
  branch_ensure_return(paths.project_root, base_branch, branch_name)
501
618
  return new_status
502
619
 
503
- commit_final_bookkeeping(status_root, status_family, item_id, new_status)
504
- commit_task_branch_changes(execution_root, family, item_id, new_status)
505
- if use_worktree and worktree_runtime is not None:
506
- _emit_info(f"Merging {branch_name} into {base_branch}...")
507
- merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
508
- _emit_git_plan_output(merge)
509
- if merge.ok:
510
- guarded_worktree_remove(worktree_runtime, delete_branch=True)
511
- _emit_success(f"Merged {branch_name} into {base_branch}")
512
- return new_status
513
- final_session_status = "merge_conflict"
514
- else:
515
- _emit_info(f"Merging {branch_name} into {base_branch}...")
516
- merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
517
- _emit_git_plan_output(merge)
518
- if merge.ok:
519
- _emit_success(f"Merged {branch_name} into {base_branch}")
520
- return new_status
521
- final_session_status = "merge_conflict"
522
-
523
- _write_session_status(
524
- session_paths,
525
- status=final_session_status,
526
- session_id=session_id,
527
- task_id=item_id,
528
- task_type=family.task_type,
529
- base_branch=base_branch,
530
- active_dev_branch=branch_name,
531
- worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
532
- reason="merge_conflict",
533
- fatal_error_code=classification.fatal_error_code,
534
- )
535
- update = update_item(
620
+ return _complete_success_merge(
621
+ family,
622
+ item_id,
623
+ new_status,
624
+ paths,
536
625
  status_family,
537
626
  status_invocation,
538
- item_id,
539
- final_session_status,
540
- session_id,
541
627
  status_root,
542
- active_dev_branch=branch_name,
543
- base_branch=base_branch,
544
- last_fatal_error_code=classification.fatal_error_code,
545
- no_progress_count=classification.no_progress_count,
546
- progress_fingerprint=classification.progress_fingerprint,
628
+ execution_root,
629
+ branch_context,
630
+ branch_name,
631
+ base_branch,
632
+ session_id,
633
+ session_paths,
634
+ worktree_runtime,
635
+ classification_fatal_error_code=classification.fatal_error_code,
547
636
  )
548
- if not update.ok:
549
- raise RuntimeError(update.text)
550
- _emit_update_summary(family, item_id, update)
551
- new_status = _status_from_update(update)
552
- commit_final_bookkeeping(status_root, status_family, item_id, new_status)
553
- _save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
554
- if use_worktree and worktree_runtime is not None:
555
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
556
- else:
557
- branch_ensure_return(paths.project_root, base_branch, branch_name)
558
- return new_status
559
637
  except KeyboardInterrupt:
560
638
  _emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
561
639
  if use_worktree and worktree_runtime is not None:
@@ -566,6 +644,126 @@ def _process_item(
566
644
  raise
567
645
 
568
646
 
647
+ def _finalize_recovered_success(
648
+ family: RunnerFamily,
649
+ item_id: str,
650
+ paths,
651
+ status_family: RunnerFamily,
652
+ status_invocation: RunnerInvocation,
653
+ status_root: Path,
654
+ execution_root: Path,
655
+ branch_context: BranchContext,
656
+ branch_name: str,
657
+ base_branch: str,
658
+ session_id: str,
659
+ session_paths: SessionPaths,
660
+ worktree_runtime,
661
+ ) -> str:
662
+ update = update_item(
663
+ status_family,
664
+ status_invocation,
665
+ item_id,
666
+ "success",
667
+ session_id,
668
+ status_root,
669
+ active_dev_branch=branch_name,
670
+ base_branch=base_branch,
671
+ )
672
+ if not update.ok:
673
+ raise RuntimeError(update.text)
674
+ _emit_update_summary(family, item_id, update)
675
+ return _complete_success_merge(
676
+ family,
677
+ item_id,
678
+ _status_from_update(update),
679
+ paths,
680
+ status_family,
681
+ status_invocation,
682
+ status_root,
683
+ execution_root,
684
+ branch_context,
685
+ branch_name,
686
+ base_branch,
687
+ session_id,
688
+ session_paths,
689
+ worktree_runtime,
690
+ classification_fatal_error_code="",
691
+ )
692
+
693
+
694
+ def _complete_success_merge(
695
+ family: RunnerFamily,
696
+ item_id: str,
697
+ new_status: str,
698
+ paths,
699
+ status_family: RunnerFamily,
700
+ status_invocation: RunnerInvocation,
701
+ status_root: Path,
702
+ execution_root: Path,
703
+ branch_context: BranchContext,
704
+ branch_name: str,
705
+ base_branch: str,
706
+ session_id: str,
707
+ session_paths: SessionPaths,
708
+ worktree_runtime,
709
+ *,
710
+ classification_fatal_error_code: str = "",
711
+ ) -> str:
712
+ env = RunnerEnvironment.from_env()
713
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
714
+ commit_task_branch_changes(execution_root, family, item_id, new_status)
715
+ if worktree_runtime is not None:
716
+ _emit_info(f"Merging {branch_name} into {base_branch}...")
717
+ merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
718
+ _emit_git_plan_output(merge)
719
+ if merge.ok:
720
+ guarded_worktree_remove(worktree_runtime, delete_branch=True)
721
+ _emit_success(f"Merged {branch_name} into {base_branch}")
722
+ return new_status
723
+ else:
724
+ _emit_info(f"Merging {branch_name} into {base_branch}...")
725
+ merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
726
+ _emit_git_plan_output(merge)
727
+ if merge.ok:
728
+ _emit_success(f"Merged {branch_name} into {base_branch}")
729
+ return new_status
730
+
731
+ _write_session_status(
732
+ session_paths,
733
+ status="merge_conflict",
734
+ session_id=session_id,
735
+ task_id=item_id,
736
+ task_type=family.task_type,
737
+ base_branch=base_branch,
738
+ active_dev_branch=branch_name,
739
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
740
+ reason="merge_conflict",
741
+ fatal_error_code=classification_fatal_error_code,
742
+ )
743
+ update = update_item(
744
+ status_family,
745
+ status_invocation,
746
+ item_id,
747
+ "merge_conflict",
748
+ session_id,
749
+ status_root,
750
+ active_dev_branch=branch_name,
751
+ base_branch=base_branch,
752
+ last_fatal_error_code=classification_fatal_error_code,
753
+ )
754
+ if not update.ok:
755
+ raise RuntimeError(update.text)
756
+ _emit_update_summary(family, item_id, update)
757
+ merged_status = _status_from_update(update)
758
+ commit_final_bookkeeping(status_root, status_family, item_id, merged_status)
759
+ _save_failure_wip(execution_root, family, item_id, merged_status, worktree_runtime)
760
+ if worktree_runtime is not None:
761
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
762
+ else:
763
+ branch_ensure_return(paths.project_root, base_branch, branch_name)
764
+ return merged_status
765
+
766
+
569
767
  def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
570
768
  if worktree_runtime is not None:
571
769
  worktree_save_wip(worktree_runtime)
@@ -684,6 +882,43 @@ def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path
684
882
  return path
685
883
 
686
884
 
885
+ def _latest_session_id(state_dir: Path, item_id: str) -> str:
886
+ sessions_dir = state_dir / item_id / "sessions"
887
+ try:
888
+ sessions = [child.name for child in sessions_dir.iterdir() if child.is_dir()]
889
+ except OSError:
890
+ return ""
891
+ return sorted(sessions)[-1] if sessions else ""
892
+
893
+
894
+ def _artifact_path_for_item(project_root: Path, family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> Path:
895
+ if family.kind == "feature":
896
+ title = str(metadata.get("title") or "")
897
+ if title:
898
+ return project_root / ".prizmkit" / "specs" / _feature_slug(item_id, title)
899
+ specs = project_root / ".prizmkit" / "specs"
900
+ numeric = item_id.replace("F-", "").replace("f-", "").zfill(3)
901
+ try:
902
+ matches = sorted(path for path in specs.iterdir() if path.is_dir() and path.name.startswith(f"{numeric}-"))
903
+ except OSError:
904
+ matches = []
905
+ if matches:
906
+ return matches[0]
907
+ return project_root / ".prizmkit" / family.artifact_root_name / item_id
908
+
909
+
910
+ def _feature_slug(feature_id: str, title: str) -> str:
911
+ import re
912
+
913
+ numeric = feature_id.replace("F-", "").replace("f-", "").zfill(3)
914
+ cleaned = re.sub(r"[^a-z0-9\s-]", "", title.lower())
915
+ cleaned = re.sub(r"[\s]+", "-", cleaned.strip())
916
+ cleaned = re.sub(r"-+", "-", cleaned).strip("-")
917
+ if not cleaned:
918
+ cleaned = "feature"
919
+ return f"{numeric}-{cleaned}"
920
+
921
+
687
922
  def _write_session_status(
688
923
  session_paths: SessionPaths,
689
924
  *,