prizmkit 1.1.106 → 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 (34) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +56 -5
  3. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +9 -1
  5. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +278 -62
  6. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +102 -17
  7. package/bundled/dev-pipeline/scripts/continuation.py +0 -4
  8. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +7 -39
  9. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +1 -31
  10. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -31
  11. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
  12. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +28 -28
  13. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +36 -52
  14. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +34 -54
  15. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
  16. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
  17. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +2 -3
  18. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +2 -2
  19. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -9
  20. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -10
  21. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -3
  22. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -3
  23. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +49 -2
  24. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +17 -0
  25. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +50 -1
  26. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +352 -1
  27. package/bundled/dev-pipeline/tests/test_unified_cli.py +207 -0
  28. package/bundled/skills/_metadata.json +1 -1
  29. package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
  30. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
  31. package/package.json +1 -1
  32. package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
  33. package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
  34. package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.106",
3
- "bundledAt": "2026-07-07T02:54:30.507Z",
4
- "bundledFrom": "8e4fe49"
2
+ "frameworkVersion": "1.1.107",
3
+ "bundledAt": "2026-07-07T11:02:06.367Z",
4
+ "bundledFrom": "93afdd5"
5
5
  }
@@ -29,6 +29,10 @@ INFRA_MARKERS = (
29
29
  "authentication",
30
30
  "unauthorized",
31
31
  "api error",
32
+ "quota",
33
+ "usage limit",
34
+ "credit balance",
35
+ "billing",
32
36
  )
33
37
 
34
38
 
@@ -52,6 +56,13 @@ def classify_session(
52
56
  progress_fingerprint=fingerprint,
53
57
  )
54
58
 
59
+ if _has_late_runtime_error_after_completion(result, prompt, has_commit_since_base):
60
+ return SessionClassification(
61
+ "success",
62
+ reason="completed_checkpoint_with_late_runtime_error",
63
+ progress_fingerprint=fingerprint,
64
+ )
65
+
55
66
  if _is_context_overflow(result):
56
67
  if has_commit_since_base:
57
68
  return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
@@ -121,6 +132,16 @@ def _is_terminal_success(result: AISessionResult) -> bool:
121
132
  )
122
133
 
123
134
 
135
+ def _has_late_runtime_error_after_completion(
136
+ result: AISessionResult,
137
+ prompt: PromptGenerationResult | None,
138
+ has_commit_since_base: bool,
139
+ ) -> bool:
140
+ if not has_commit_since_base or not _has_completed_workflow_artifact(prompt):
141
+ return False
142
+ return _is_context_overflow(result) or _is_infra_error(result)
143
+
144
+
124
145
  def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> bool:
125
146
  if prompt is None:
126
147
  return False
@@ -131,11 +152,32 @@ def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> b
131
152
  data = {}
132
153
  if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
133
154
  return True
134
- if prompt.artifact_path and (prompt.artifact_path / "completion-summary.json").is_file():
155
+ if prompt.artifact_path and has_completed_artifact_path(prompt.artifact_path):
135
156
  return True
136
157
  return False
137
158
 
138
159
 
160
+ def has_branch_completion_evidence(project_root: Path, base_branch: str, working_branch: str, artifact_path: Path | None) -> bool:
161
+ """Return true when a recorded task branch already has completed work to merge."""
162
+ if not base_branch or not working_branch or not has_completed_artifact_path(artifact_path):
163
+ return False
164
+ return _has_commit_range(project_root, base_branch, working_branch)
165
+
166
+
167
+ def has_completed_artifact_path(artifact_path: Path | None) -> bool:
168
+ if artifact_path is None:
169
+ return False
170
+ checkpoint = artifact_path / "workflow-checkpoint.json"
171
+ if checkpoint.is_file():
172
+ try:
173
+ data = json.loads(checkpoint.read_text(encoding="utf-8"))
174
+ except (OSError, json.JSONDecodeError):
175
+ data = {}
176
+ if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
177
+ return True
178
+ return (artifact_path / "completion-summary.json").is_file()
179
+
180
+
139
181
  def _next_no_progress_count(previous: object | None, current: object, count: int) -> int:
140
182
  if previous and previous == current:
141
183
  return max(0, count) + 1
@@ -161,11 +203,20 @@ def _is_infra_error(result: AISessionResult) -> bool:
161
203
  return bool(result.fatal_error_code and result.fatal_error_code != CONTEXT_OVERFLOW) or any(marker in text for marker in INFRA_MARKERS)
162
204
 
163
205
 
164
- def _has_commit_since(project_root: Path, base_head: str) -> bool:
165
- if not base_head:
206
+ def _has_commit_range(project_root: Path, start_ref: str, end_ref: str = "HEAD") -> bool:
207
+ if not start_ref or not end_ref:
166
208
  return False
167
- current = run_git_command(project_root, ("rev-parse", "HEAD")).stdout.strip()
168
- return bool(current and current != base_head)
209
+ result = run_git_command(project_root, ("rev-list", "--count", f"{start_ref}..{end_ref}"))
210
+ if result.return_code != 0:
211
+ return False
212
+ try:
213
+ return int(result.stdout.strip() or "0") > 0
214
+ except ValueError:
215
+ return False
216
+
217
+
218
+ def _has_commit_since(project_root: Path, base_head: str) -> bool:
219
+ return _has_commit_range(project_root, base_head, "HEAD")
169
220
 
170
221
 
171
222
  def _has_workspace_changes(project_root: Path) -> bool:
@@ -45,7 +45,6 @@ class RunnerEnvironment:
45
45
  heartbeat_interval_seconds: int = 30
46
46
  heartbeat_stale_threshold_seconds: int = 600
47
47
  stale_kill_threshold_seconds: int = 600
48
- max_log_size: int | None = None
49
48
  max_retries: int = 3
50
49
  strict_behavior_check: bool = True
51
50
 
@@ -67,7 +66,6 @@ class RunnerEnvironment:
67
66
  _int_value(values, "HEARTBEAT_TIMEOUT_SECONDS", 600),
68
67
  ),
69
68
  stale_kill_threshold_seconds=_int_value(values, "STALE_KILL_THRESHOLD", 600),
70
- max_log_size=_optional_int(values.get("MAX_LOG_SIZE")),
71
69
  max_retries=max(0, _int_value(values, "MAX_RETRIES", 3)),
72
70
  strict_behavior_check=not _falsey(values.get("STRICT_BEHAVIOR_CHECK")),
73
71
  )
@@ -349,9 +347,3 @@ def _safe_int(value: str | None, default: int) -> int:
349
347
 
350
348
  def _int_value(values: Mapping[str, str], name: str, default: int) -> int:
351
349
  return _safe_int(values.get(name), default)
352
-
353
-
354
- def _optional_int(value: str | None) -> int | None:
355
- if value is None or str(value).strip() == "":
356
- return None
357
- return _safe_int(value, 0)
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from .config import load_runtime_config
13
13
  from .paths import RuntimePaths
14
14
  from .runner_models import RunnerEnvironment, SessionPaths
15
- from .sessions import AISessionConfig, AISessionLauncher
15
+ from .sessions import AISessionConfig, AISessionLauncher, detect_stream_json_support
16
16
 
17
17
 
18
18
  @dataclass(frozen=True)
@@ -122,6 +122,9 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
122
122
  if not config.ai_client.command:
123
123
  return 1, "", "No supported AI CLI command was found for recovery\n"
124
124
  env = RunnerEnvironment.from_env()
125
+ stream_json = False
126
+ if env.live_output:
127
+ stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
125
128
  launcher = AISessionLauncher(
126
129
  AISessionConfig(
127
130
  cli_command=config.ai_client.command,
@@ -134,7 +137,12 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
134
137
  heartbeat_path=session_paths.heartbeat_json,
135
138
  effort=config.effort,
136
139
  verbose=env.verbose,
140
+ live_output=env.live_output,
141
+ use_stream_json=stream_json,
137
142
  stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
143
+ live_progress_interval_seconds=env.heartbeat_interval_seconds,
144
+ live_banner=False,
145
+ suppress_stream_output=True,
138
146
  )
139
147
  )
140
148
  result = launcher.run()
@@ -32,7 +32,7 @@ from .runner_bookkeeping import (
32
32
  commit_task_branch_changes,
33
33
  commit_wip_changes,
34
34
  )
35
- from .runner_classification import classify_session
35
+ from .runner_classification import classify_session, has_branch_completion_evidence, has_completed_artifact_path
36
36
  from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
37
37
  from .runner_prompts import PromptGenerationResult, generate_prompt
38
38
  from .runner_recovery import run_recovery, run_recovery_detect
@@ -111,6 +111,14 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
111
111
  return PipelineRunResult(False, processed, "get_next_failed", details=(next_result.text,))
112
112
  marker = next_result.stdout.strip()
113
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
+ )
114
122
  return PipelineRunResult(True, processed, "pipeline_complete", details=tuple(details))
115
123
  if marker == "PIPELINE_BLOCKED":
116
124
  return PipelineRunResult(False, processed, "pipeline_blocked", details=tuple(details))
@@ -138,6 +146,37 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
138
146
  return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
139
147
 
140
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
+
141
180
  _SEPARATOR = "─" * 52
142
181
 
143
182
 
@@ -190,6 +229,34 @@ def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) ->
190
229
  return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
191
230
 
192
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 {}
258
+
259
+
193
260
  def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
194
261
  recorded = str(metadata.get("base_branch") or "").strip()
195
262
  if recorded:
@@ -313,8 +380,9 @@ def _process_item(
313
380
  raise RuntimeError("No supported AI CLI command was found")
314
381
 
315
382
  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)
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)
318
386
  branch_context = BranchContext(base_branch, branch_name, paths.project_root)
319
387
  worktree_runtime = None
320
388
  use_worktree = _use_worktree_for_family(env, family)
@@ -356,21 +424,51 @@ def _process_item(
356
424
  if not branch_result.ok:
357
425
  return "infra_error"
358
426
 
359
- _emit_item_header(family, invocation, item_id, initial_metadata)
427
+ _emit_item_header(family, invocation, item_id, metadata)
360
428
  status_root = execution_root if use_worktree else paths.project_root
361
429
  status_family = _family_for_execution_root(family, paths.project_root, status_root)
362
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
+ )
363
461
  start = start_item(status_family, status_invocation, item_id, status_root)
364
462
  if not start.ok:
365
463
  raise RuntimeError(start.text)
366
464
  commit_pre_branch_bookkeeping(status_root, status_family, item_id)
367
465
 
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)
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)
374
472
 
375
473
  while True:
376
474
  session_id = _session_id(item_id)
@@ -500,62 +598,23 @@ def _process_item(
500
598
  branch_ensure_return(paths.project_root, base_branch, branch_name)
501
599
  return new_status
502
600
 
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(
601
+ return _complete_success_merge(
602
+ family,
603
+ item_id,
604
+ new_status,
605
+ paths,
536
606
  status_family,
537
607
  status_invocation,
538
- item_id,
539
- final_session_status,
540
- session_id,
541
608
  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,
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,
547
617
  )
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
618
  except KeyboardInterrupt:
560
619
  _emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
561
620
  if use_worktree and worktree_runtime is not None:
@@ -566,6 +625,126 @@ def _process_item(
566
625
  raise
567
626
 
568
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
+
569
748
  def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
570
749
  if worktree_runtime is not None:
571
750
  worktree_save_wip(worktree_runtime)
@@ -684,6 +863,43 @@ def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path
684
863
  return path
685
864
 
686
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
+
687
903
  def _write_session_status(
688
904
  session_paths: SessionPaths,
689
905
  *,