prizmkit 1.1.105 → 1.1.106

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.105",
3
- "bundledAt": "2026-07-06T18:17:35.126Z",
4
- "bundledFrom": "e92eb9f"
2
+ "frameworkVersion": "1.1.106",
3
+ "bundledAt": "2026-07-07T02:54:30.507Z",
4
+ "bundledFrom": "8e4fe49"
5
5
  }
@@ -565,7 +565,6 @@ def branch_ensure_return_plan(context: BranchContext) -> GitCommandPlan:
565
565
  ),
566
566
  _git("stash", "push", "-m", "pipeline-ensure-return-stash", allow_failure=True, description="stash checkout blockers"),
567
567
  _git("checkout", context.base_branch, allow_failure=True, description="return to original branch"),
568
- _git("stash", "pop", allow_failure=True, description="restore stashed checkout blockers"),
569
568
  ),
570
569
  never_fails=True,
571
570
  notes=("Always returns success to support trap/finally cleanup paths.",),
@@ -7,10 +7,11 @@ legacy ordering around branch creation and final status updates.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import Sequence
10
11
  from dataclasses import dataclass
11
12
  from pathlib import Path
12
13
 
13
- from .gitops import GitCommandResult, git_status_safe, run_git_command
14
+ from .gitops import HIDDEN_TOOL_WORKTREE_EXCLUDES, GitCommandResult, git_status_safe, run_git_command
14
15
  from .runner_models import RunnerFamily
15
16
 
16
17
 
@@ -27,25 +28,95 @@ class BookkeepingResult:
27
28
  return self.result is None or self.result.return_code == 0
28
29
 
29
30
 
30
- def has_workspace_changes(project_root: Path) -> bool:
31
- """Return true when git status has non-hidden-tool worktree changes."""
32
- return bool(git_status_safe(project_root).strip())
31
+ def _bookkeeping_paths(project_root: Path, family: RunnerFamily, item_id: str) -> tuple[str, ...]:
32
+ return (
33
+ _repo_path(project_root, family.plan_path),
34
+ _repo_path(project_root, family.state_dir / item_id / "status.json"),
35
+ )
36
+
37
+
38
+ def _repo_path(project_root: Path, path: Path) -> str:
39
+ try:
40
+ return path.resolve().relative_to(project_root.resolve()).as_posix()
41
+ except ValueError:
42
+ return path.as_posix()
43
+
44
+
45
+ def _changed_paths_in(project_root: Path, paths: Sequence[str]) -> tuple[str, ...]:
46
+ result = run_git_command(project_root, ("status", "--porcelain", "--", *paths))
47
+ if result.return_code != 0:
48
+ return ()
49
+ changed: list[str] = []
50
+ for line in result.stdout.splitlines():
51
+ path = line[3:].strip()
52
+ if " -> " in path:
53
+ path = path.split(" -> ", 1)[1].strip()
54
+ if path:
55
+ changed.append(path)
56
+ return tuple(changed)
57
+
58
+
59
+ def commit_bookkeeping_changes(
60
+ project_root: Path,
61
+ family: RunnerFamily,
62
+ item_id: str,
63
+ message: str,
64
+ ) -> BookkeepingResult:
65
+ """Stage and commit only runner-owned bookkeeping paths when present."""
66
+ paths = _changed_paths_in(project_root, _bookkeeping_paths(project_root, family, item_id))
67
+ if not paths:
68
+ return BookkeepingResult(attempted=False, committed=False)
69
+ run_git_command(project_root, ("add", "-A", "--", *paths))
70
+ result = run_git_command(project_root, ("commit", "--no-verify", "-m", message))
71
+ return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
33
72
 
34
73
 
35
- def commit_all_changes(project_root: Path, message: str) -> BookkeepingResult:
36
- """Stage and commit current workspace changes when present."""
37
- if not has_workspace_changes(project_root):
74
+ def commit_task_branch_changes(
75
+ project_root: Path,
76
+ family: RunnerFamily,
77
+ item_id: str,
78
+ status: str,
79
+ ) -> BookkeepingResult:
80
+ """Commit task branch code, artifacts, and status without switching branches."""
81
+ message = f"chore({item_id}): save {family.kind} {status} state"
82
+ if status == "completed":
83
+ message = f"chore({item_id}): complete {family.kind} task"
84
+ if not git_status_safe(project_root).strip():
38
85
  return BookkeepingResult(attempted=False, committed=False)
39
- run_git_command(project_root, ("add", "-A", "--", "."))
86
+ run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
40
87
  result = run_git_command(project_root, ("commit", "--no-verify", "-m", message))
41
88
  return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
42
89
 
43
90
 
91
+ def commit_wip_changes(
92
+ project_root: Path,
93
+ family: RunnerFamily,
94
+ item_id: str,
95
+ status: str,
96
+ ) -> BookkeepingResult:
97
+ """Commit all visible task-branch changes as preserved WIP."""
98
+ if not git_status_safe(project_root).strip():
99
+ return BookkeepingResult(attempted=False, committed=False)
100
+ run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
101
+ result = run_git_command(
102
+ project_root,
103
+ (
104
+ "commit",
105
+ "--no-verify",
106
+ "-m",
107
+ f"wip({item_id}): preserve {family.kind} {status} state",
108
+ "-m",
109
+ "Pipeline preserved task branch work without merging it to the base branch.",
110
+ ),
111
+ )
112
+ return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
113
+
114
+
44
115
  def commit_pre_branch_bookkeeping(project_root: Path, family: RunnerFamily, item_id: str) -> BookkeepingResult:
45
- """Commit status/list changes before creating the item dev branch."""
46
- return commit_all_changes(project_root, f"chore({item_id}): mark in_progress")
116
+ """Commit in-progress status on the current task branch."""
117
+ return commit_bookkeeping_changes(project_root, family, item_id, f"chore({item_id}): mark in_progress")
47
118
 
48
119
 
49
120
  def commit_final_bookkeeping(project_root: Path, family: RunnerFamily, item_id: str, status: str) -> BookkeepingResult:
50
- """Commit final list/state changes after returning to the base branch."""
51
- return commit_all_changes(project_root, f"chore({item_id}): update {family.kind} status {status}")
121
+ """Commit final runner-owned status changes on the current task branch."""
122
+ return commit_bookkeeping_changes(project_root, family, item_id, f"chore({item_id}): update {family.kind} status {status}")
@@ -43,10 +43,18 @@ def classify_session(
43
43
  ) -> SessionClassification:
44
44
  """Classify raw AI session signals with durable success precedence."""
45
45
  fingerprint = progress_fingerprint(project_root, prompt)
46
- if _has_commit_since(project_root, base_head):
47
- return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
46
+ has_commit_since_base = _has_commit_since(project_root, base_head)
47
+
48
+ if _is_terminal_success(result) and _has_completed_workflow_artifact(prompt):
49
+ return SessionClassification(
50
+ "success",
51
+ reason="terminal_success_with_completed_checkpoint",
52
+ progress_fingerprint=fingerprint,
53
+ )
48
54
 
49
55
  if _is_context_overflow(result):
56
+ if has_commit_since_base:
57
+ return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
50
58
  no_progress_count = _next_no_progress_count(previous_fingerprint, fingerprint, previous_no_progress_count)
51
59
  if no_progress_count >= 2:
52
60
  return SessionClassification(
@@ -72,6 +80,8 @@ def classify_session(
72
80
  return SessionClassification("timed_out", reason="timeout", progress_fingerprint=fingerprint)
73
81
  if result.exit_code not in (0, None):
74
82
  return SessionClassification("crashed", reason=f"exit_code={result.exit_code}", progress_fingerprint=fingerprint)
83
+ if has_commit_since_base:
84
+ return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
75
85
  if _has_workspace_changes(project_root):
76
86
  _auto_commit_workspace(project_root)
77
87
  return SessionClassification("success", reason="auto_commit_uncommitted_changes", progress_fingerprint=fingerprint)
@@ -100,6 +110,32 @@ def progress_fingerprint(project_root: Path, prompt: PromptGenerationResult | No
100
110
  }
101
111
 
102
112
 
113
+ def _is_terminal_success(result: AISessionResult) -> bool:
114
+ return (
115
+ result.exit_code in (0, None)
116
+ and not result.timed_out
117
+ and result.termination_reason not in {"timeout", "stale_session"}
118
+ and not result.fatal_error_code
119
+ and not result.progress_summary.fatal_error_code
120
+ and result.progress_summary.result_subtype == "success"
121
+ )
122
+
123
+
124
+ def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> bool:
125
+ if prompt is None:
126
+ return False
127
+ if prompt.checkpoint_path and prompt.checkpoint_path.is_file():
128
+ try:
129
+ data = json.loads(prompt.checkpoint_path.read_text(encoding="utf-8"))
130
+ except (OSError, json.JSONDecodeError):
131
+ data = {}
132
+ if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
133
+ return True
134
+ if prompt.artifact_path and (prompt.artifact_path / "completion-summary.json").is_file():
135
+ return True
136
+ return False
137
+
138
+
103
139
  def _next_no_progress_count(previous: object | None, current: object, count: int) -> int:
104
140
  if previous and previous == current:
105
141
  return max(0, count) + 1
@@ -26,7 +26,12 @@ 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
29
+ from .runner_bookkeeping import (
30
+ commit_final_bookkeeping,
31
+ commit_pre_branch_bookkeeping,
32
+ commit_task_branch_changes,
33
+ commit_wip_changes,
34
+ )
30
35
  from .runner_classification import classify_session
31
36
  from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
32
37
  from .runner_prompts import PromptGenerationResult, generate_prompt
@@ -180,7 +185,9 @@ def _is_recovery_side_effect_branch(branch: str, family: RunnerFamily, item_id:
180
185
 
181
186
 
182
187
  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)
188
+ if not branch or _is_recovery_side_effect_branch(branch, family, item_id):
189
+ return False
190
+ return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
184
191
 
185
192
 
186
193
  def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
@@ -313,13 +320,7 @@ def _process_item(
313
320
  use_worktree = _use_worktree_for_family(env, family)
314
321
  execution_root = paths.project_root
315
322
 
316
- _emit_item_header(family, invocation, item_id, initial_metadata)
317
323
  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
324
  if use_worktree:
324
325
  worktree_runtime = worktree_runtime_context(
325
326
  branch_context,
@@ -332,22 +333,37 @@ def _process_item(
332
333
  )
333
334
  setup = ensure_linked_worktree(worktree_runtime)
334
335
  if not setup.ok:
335
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
336
- return _status_from_update(status)
336
+ return "infra_error"
337
337
  worktree_runtime = setup.runtime or worktree_runtime
338
338
  _emit_info(f"Dev branch: {branch_name}")
339
339
  _emit_info(f"Worktree: {worktree_runtime.worktree_path}")
340
340
  support_assets = materialize_worktree_support_assets(worktree_runtime)
341
341
  if not support_assets.ok:
342
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
343
- return _status_from_update(status)
342
+ return _record_setup_failure(
343
+ family,
344
+ invocation,
345
+ item_id,
346
+ paths.project_root,
347
+ worktree_runtime.worktree_path,
348
+ base_branch,
349
+ branch_name,
350
+ worktree_runtime=worktree_runtime,
351
+ )
344
352
  execution_root = worktree_runtime.worktree_path
345
353
  else:
346
354
  branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
347
355
  _emit_branch_setup_result(branch_context, branch_result)
348
356
  if not branch_result.ok:
349
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
350
- return _status_from_update(status)
357
+ return "infra_error"
358
+
359
+ _emit_item_header(family, invocation, item_id, initial_metadata)
360
+ status_root = execution_root if use_worktree else paths.project_root
361
+ status_family = _family_for_execution_root(family, paths.project_root, status_root)
362
+ status_invocation = _invocation_for_execution_family(invocation, status_family)
363
+ start = start_item(status_family, status_invocation, item_id, status_root)
364
+ if not start.ok:
365
+ raise RuntimeError(start.text)
366
+ commit_pre_branch_bookkeeping(status_root, status_family, item_id)
351
367
 
352
368
  previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
353
369
  previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
@@ -368,7 +384,7 @@ def _process_item(
368
384
  "task_type": family.task_type,
369
385
  "active_dev_branch": branch_name,
370
386
  "base_branch": base_branch,
371
- "continuation_summary_path": _continuation_summary_path(paths.project_root, family, item_id, None),
387
+ "continuation_summary_path": _continuation_summary_path(execution_root, family, item_id, None),
372
388
  }
373
389
  prompt = generate_prompt(
374
390
  family,
@@ -417,14 +433,14 @@ def _process_item(
417
433
  )
418
434
  _emit_session_summary(session_result, session_paths, classification)
419
435
  if classification.session_status == "context_overflow":
420
- summary_path = _continuation_summary_path(paths.project_root, family, item_id, prompt)
436
+ summary_path = _continuation_summary_path(execution_root, family, item_id, prompt)
421
437
  update = update_item(
422
- family,
423
- invocation,
438
+ status_family,
439
+ status_invocation,
424
440
  item_id,
425
441
  "context_overflow",
426
442
  session_id,
427
- paths.project_root,
443
+ status_root,
428
444
  active_dev_branch=branch_name,
429
445
  base_branch=base_branch,
430
446
  last_fatal_error_code=classification.fatal_error_code or "context_overflow",
@@ -444,32 +460,65 @@ def _process_item(
444
460
  continue
445
461
 
446
462
  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)
463
+
464
+ _write_session_status(
465
+ session_paths,
466
+ status=final_session_status,
467
+ session_id=session_id,
468
+ task_id=item_id,
469
+ task_type=family.task_type,
470
+ base_branch=base_branch,
471
+ active_dev_branch=branch_name,
472
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
473
+ reason=classification.reason,
474
+ fatal_error_code=classification.fatal_error_code,
475
+ )
476
+
477
+ update = update_item(
478
+ status_family,
479
+ status_invocation,
480
+ item_id,
481
+ final_session_status,
482
+ session_id,
483
+ status_root,
484
+ active_dev_branch=branch_name,
485
+ base_branch=base_branch,
486
+ last_fatal_error_code=classification.fatal_error_code,
487
+ no_progress_count=classification.no_progress_count,
488
+ progress_fingerprint=classification.progress_fingerprint,
489
+ )
490
+ if not update.ok:
491
+ raise RuntimeError(update.text)
492
+ _emit_update_summary(family, item_id, update)
493
+ new_status = _status_from_update(update)
494
+ if final_session_status != "success":
495
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
496
+ _save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
497
+ if use_worktree and worktree_runtime is not None:
460
498
  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
499
  else:
472
500
  branch_ensure_return(paths.project_root, base_branch, branch_name)
501
+ return new_status
502
+
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"
473
522
 
474
523
  _write_session_status(
475
524
  session_paths,
@@ -480,17 +529,16 @@ def _process_item(
480
529
  base_branch=base_branch,
481
530
  active_dev_branch=branch_name,
482
531
  worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
483
- reason=classification.reason,
532
+ reason="merge_conflict",
484
533
  fatal_error_code=classification.fatal_error_code,
485
534
  )
486
-
487
535
  update = update_item(
488
- family,
489
- invocation,
536
+ status_family,
537
+ status_invocation,
490
538
  item_id,
491
539
  final_session_status,
492
540
  session_id,
493
- paths.project_root,
541
+ status_root,
494
542
  active_dev_branch=branch_name,
495
543
  base_branch=base_branch,
496
544
  last_fatal_error_code=classification.fatal_error_code,
@@ -501,7 +549,12 @@ def _process_item(
501
549
  raise RuntimeError(update.text)
502
550
  _emit_update_summary(family, item_id, update)
503
551
  new_status = _status_from_update(update)
504
- commit_final_bookkeeping(paths.project_root, family, item_id, new_status)
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)
505
558
  return new_status
506
559
  except KeyboardInterrupt:
507
560
  _emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
@@ -513,6 +566,52 @@ def _process_item(
513
566
  raise
514
567
 
515
568
 
569
+ def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
570
+ if worktree_runtime is not None:
571
+ worktree_save_wip(worktree_runtime)
572
+ else:
573
+ commit_wip_changes(execution_root, family, item_id, status)
574
+
575
+
576
+ def _record_setup_failure(
577
+ family: RunnerFamily,
578
+ invocation: RunnerInvocation,
579
+ item_id: str,
580
+ project_root: Path,
581
+ status_root: Path,
582
+ base_branch: str,
583
+ branch_name: str,
584
+ *,
585
+ worktree_runtime=None,
586
+ ) -> str:
587
+ """Record setup-time infra failures on a task branch when one exists."""
588
+ if status_root.resolve() == project_root.resolve() and current_branch(project_root) != branch_name:
589
+ return "infra_error"
590
+ status_family = _family_for_execution_root(family, project_root, status_root)
591
+ status_invocation = _invocation_for_execution_family(invocation, status_family)
592
+ start = start_item(status_family, status_invocation, item_id, status_root)
593
+ if start.ok:
594
+ commit_pre_branch_bookkeeping(status_root, status_family, item_id)
595
+ update = update_item(
596
+ status_family,
597
+ status_invocation,
598
+ item_id,
599
+ "infra_error",
600
+ "",
601
+ status_root,
602
+ active_dev_branch=branch_name,
603
+ base_branch=base_branch,
604
+ )
605
+ new_status = _status_from_update(update) if update.ok else "infra_error"
606
+ if update.ok:
607
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
608
+ _save_failure_wip(status_root, family, item_id, new_status, worktree_runtime)
609
+ if worktree_runtime is not None:
610
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
611
+ elif current_branch(project_root) == branch_name:
612
+ branch_ensure_return(project_root, base_branch, branch_name)
613
+ return new_status
614
+
516
615
  def _test_cli(kind: str, paths) -> CommandResult:
517
616
  """Report the AI CLI resolution used by Python runner commands."""
518
617
  config = load_runtime_config(paths)
@@ -539,6 +638,52 @@ def _use_worktree_for_family(env: RunnerEnvironment, family: RunnerFamily) -> bo
539
638
  return env.use_worktree and family.kind in {"feature", "bugfix"}
540
639
 
541
640
 
641
+ def _family_for_execution_root(family: RunnerFamily, project_root: Path, execution_root: Path) -> RunnerFamily:
642
+ """Return family paths rooted in the task execution checkout."""
643
+ if execution_root.resolve() == project_root.resolve():
644
+ return family
645
+ return RunnerFamily(
646
+ kind=family.kind,
647
+ id_prefix=family.id_prefix,
648
+ list_key=family.list_key,
649
+ list_arg=family.list_arg,
650
+ item_id_arg=family.item_id_arg,
651
+ state_dir=_path_in_execution_root(family.state_dir, project_root, execution_root),
652
+ plan_path=_path_in_execution_root(family.plan_path, project_root, execution_root),
653
+ updater_script=family.updater_script,
654
+ prompt_script=family.prompt_script,
655
+ branch_prefix=family.branch_prefix,
656
+ artifact_root_name=family.artifact_root_name,
657
+ )
658
+
659
+
660
+ def _invocation_for_execution_family(invocation: RunnerInvocation, family: RunnerFamily) -> RunnerInvocation:
661
+ return RunnerInvocation(
662
+ action=invocation.action,
663
+ list_path=family.plan_path,
664
+ item_id=invocation.item_id,
665
+ item_filter=invocation.item_filter,
666
+ max_retries=invocation.max_retries,
667
+ max_infra_retries=invocation.max_infra_retries,
668
+ mode=invocation.mode,
669
+ critic=invocation.critic,
670
+ dry_run=invocation.dry_run,
671
+ clean=invocation.clean,
672
+ no_reset=invocation.no_reset,
673
+ resume_phase=invocation.resume_phase,
674
+ help_requested=invocation.help_requested,
675
+ unknown_args=invocation.unknown_args,
676
+ raw_args=invocation.raw_args,
677
+ )
678
+
679
+
680
+ def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path) -> Path:
681
+ try:
682
+ return execution_root / path.resolve().relative_to(project_root.resolve())
683
+ except ValueError:
684
+ return path
685
+
686
+
542
687
  def _write_session_status(
543
688
  session_paths: SessionPaths,
544
689
  *,
@@ -579,8 +724,7 @@ def _status_from_update(update_result) -> str:
579
724
 
580
725
 
581
726
  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}"
727
+ return f"{family.branch_prefix}/{item_id}-{time.strftime('%Y%m%d%H%M%S')}"
584
728
 
585
729
 
586
730
  def _session_id(item_id: str) -> str:
@@ -847,12 +847,24 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
847
847
  invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
848
848
  observed = _install_runner_session_fakes(monkeypatch, runners)
849
849
  plan_names = []
850
+ branch_events = []
851
+ lifecycle_order = []
850
852
 
851
853
  def fake_run_git_plan(project_root, plan):
852
854
  plan_names.append(plan.name)
855
+ if plan.name == "branch_create":
856
+ lifecycle_order.append("branch_create")
857
+ branch_events.append(("create", plan.commands[1].args[2]))
858
+ if plan.name == "branch_merge":
859
+ branch_events.append(("merge", plan.commands[5].args[2]))
853
860
  return SimpleNamespace(ok=True, plan=plan, results=())
854
861
 
862
+ def fake_start_item(*args, **kwargs):
863
+ lifecycle_order.append("start_item")
864
+ return SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"})
865
+
855
866
  monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
867
+ monkeypatch.setattr(runners, "start_item", fake_start_item)
856
868
  monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: (_ for _ in ()).throw(AssertionError("worktree setup should not run")), raising=False)
857
869
 
858
870
  status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
@@ -862,6 +874,62 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
862
874
  assert observed.prompt_calls[0].get("execution_root") == paths.project_root
863
875
  assert "branch_create" in plan_names
864
876
  assert "branch_merge" in plan_names
877
+ assert lifecycle_order[:2] == ["branch_create", "start_item"]
878
+ assert branch_events == [("create", "dev/F-001-test"), ("merge", "dev/F-001-test")]
879
+
880
+
881
+ def test_default_branch_names_keep_timestamp_for_each_runner_family(monkeypatch, tmp_path):
882
+ from prizmkit_runtime import runners
883
+ from prizmkit_runtime.runner_models import family_for
884
+
885
+ monkeypatch.setattr(runners.time, "strftime", lambda fmt: "20260707010203")
886
+ paths = _make_paths(tmp_path)
887
+
888
+ assert runners._default_branch_name(family_for("feature", paths), "F-001") == "dev/F-001-20260707010203"
889
+ assert runners._default_branch_name(family_for("bugfix", paths), "B-001") == "bugfix/B-001-20260707010203"
890
+ assert runners._default_branch_name(family_for("refactor", paths), "R-001") == "refactor/R-001-20260707010203"
891
+
892
+
893
+ @pytest.mark.parametrize(
894
+ ("kind", "item_id", "branch"),
895
+ [
896
+ ("feature", "F-001", "dev/F-001-20260707010203"),
897
+ ("bugfix", "B-001", "bugfix/B-001-20260707010203"),
898
+ ("refactor", "R-001", "refactor/R-001-20260707010203"),
899
+ ],
900
+ )
901
+ def test_no_worktree_failure_stays_on_task_branch_without_merge_for_all_families(monkeypatch, tmp_path, kind, item_id, branch):
902
+ from prizmkit_runtime import runners
903
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
904
+
905
+ monkeypatch.setenv("DEV_BRANCH", branch)
906
+ paths = _make_paths(tmp_path / kind)
907
+ family = family_for(kind, paths)
908
+ invocation = parse_invocation(family, "run", ())
909
+ plan_path = {"feature": paths.feature_plan, "bugfix": paths.bugfix_plan, "refactor": paths.refactor_plan}[kind]
910
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": plan_path})
911
+ _install_runner_session_fakes(monkeypatch, runners, statuses=("crashed",), update_status="pending")
912
+ events = []
913
+ final_commits = []
914
+ wip_commits = []
915
+
916
+ def fake_run_git_plan(project_root, plan):
917
+ events.append(plan.name)
918
+ return SimpleNamespace(ok=True, plan=plan, results=())
919
+
920
+ monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
921
+ monkeypatch.setattr(runners, "branch_ensure_return", lambda project_root, base, task_branch: events.append(("return", base, task_branch)))
922
+ monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda project_root, family, item_id, status: final_commits.append((project_root, item_id, status)))
923
+ monkeypatch.setattr(runners, "commit_wip_changes", lambda project_root, family, item_id, status: wip_commits.append((project_root, item_id, status)))
924
+
925
+ status = runners._process_item(family, invocation, item_id, paths, initial_metadata={})
926
+
927
+ assert status == "pending"
928
+ assert events[0] == "branch_create"
929
+ assert "branch_merge" not in events
930
+ assert events[-1] == ("return", "main", branch)
931
+ assert final_commits == [(paths.project_root, item_id, "pending")]
932
+ assert wip_commits == [(paths.project_root, item_id, "pending")]
865
933
 
866
934
 
867
935
  def test_no_worktree_runner_ignores_recovery_side_effect_branch(monkeypatch, tmp_path):
@@ -956,6 +1024,63 @@ def test_no_worktree_interrupt_saves_wip_and_returns_to_base(monkeypatch, tmp_pa
956
1024
  assert ensure_calls == [("main", "dev/F-001-interrupt")]
957
1025
 
958
1026
 
1027
+ def test_bookkeeping_commits_only_runner_owned_paths(tmp_path):
1028
+ from prizmkit_runtime.runner_bookkeeping import commit_final_bookkeeping
1029
+ from prizmkit_runtime.runner_models import RunnerFamily
1030
+
1031
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1032
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1033
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1034
+ plans = tmp_path / ".prizmkit" / "plans"
1035
+ state = tmp_path / ".prizmkit" / "state" / "features"
1036
+ item_state = state / "F-001"
1037
+ plans.mkdir(parents=True)
1038
+ item_state.mkdir(parents=True)
1039
+ plan = plans / "feature-list.json"
1040
+ plan.write_text('{"features": []}', encoding="utf-8")
1041
+ (item_state / "status.json").write_text("{}", encoding="utf-8")
1042
+ source = tmp_path / "src.txt"
1043
+ source.write_text("base", encoding="utf-8")
1044
+ subprocess.run(["git", "add", ".prizmkit/plans/feature-list.json", ".prizmkit/state/features/F-001/status.json", "src.txt"], cwd=tmp_path, check=True)
1045
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1046
+ plan.write_text('{"features": [{"id": "F-001", "status": "completed"}]}', encoding="utf-8")
1047
+ (item_state / "status.json").write_text('{"last_session_id": "s1"}', encoding="utf-8")
1048
+ source.write_text("dirty source change", encoding="utf-8")
1049
+ family = RunnerFamily(
1050
+ kind="feature",
1051
+ id_prefix="F-",
1052
+ list_key="features",
1053
+ list_arg="--feature-list",
1054
+ item_id_arg="--feature-id",
1055
+ state_dir=state,
1056
+ plan_path=plan,
1057
+ updater_script=tmp_path / "update-feature-status.py",
1058
+ prompt_script=tmp_path / "generate-bootstrap-prompt.py",
1059
+ branch_prefix="dev",
1060
+ artifact_root_name="specs",
1061
+ )
1062
+
1063
+ result = commit_final_bookkeeping(tmp_path, family, "F-001", "completed")
1064
+ committed_files = subprocess.run(
1065
+ ["git", "show", "--name-only", "--format=", "HEAD"],
1066
+ cwd=tmp_path,
1067
+ check=True,
1068
+ stdout=subprocess.PIPE,
1069
+ text=True,
1070
+ ).stdout.splitlines()
1071
+ dirty_files = subprocess.run(
1072
+ ["git", "status", "--porcelain", "--", "src.txt"],
1073
+ cwd=tmp_path,
1074
+ check=True,
1075
+ stdout=subprocess.PIPE,
1076
+ text=True,
1077
+ ).stdout
1078
+
1079
+ assert result.committed is True
1080
+ assert committed_files == [".prizmkit/plans/feature-list.json", ".prizmkit/state/features/F-001/status.json"]
1081
+ assert dirty_files.startswith(" M src.txt")
1082
+
1083
+
959
1084
  def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outcome(monkeypatch, tmp_path):
960
1085
  from prizmkit_runtime import runners
961
1086
  from prizmkit_runtime.runner_models import family_for, parse_invocation
@@ -983,6 +1108,52 @@ def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outco
983
1108
  assert observed.cleanup_calls[0][1] is True
984
1109
 
985
1110
 
1111
+ def test_classification_accepts_terminal_success_with_completed_checkpoint_without_git_delta(tmp_path):
1112
+ from prizmkit_runtime.runner_classification import classify_session
1113
+ from prizmkit_runtime.runner_models import PromptGenerationResult
1114
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1115
+ from prizmkit_runtime.status import ProgressSummary
1116
+
1117
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1118
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1119
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1120
+ (tmp_path / ".gitignore").write_text(".prizmkit/*\n", encoding="utf-8")
1121
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1122
+ subprocess.run(["git", "add", ".gitignore", "base.txt"], cwd=tmp_path, check=True)
1123
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1124
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1125
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
1126
+ artifact_dir.mkdir(parents=True)
1127
+ checkpoint = artifact_dir / "workflow-checkpoint.json"
1128
+ checkpoint.write_text(json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}), encoding="utf-8")
1129
+ (artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
1130
+ log = tmp_path / "session.log"
1131
+ log.write_text("terminal success", encoding="utf-8")
1132
+ raw = AISessionResult(
1133
+ command=AISessionCommand(("fake",), None, "claude", "stdin"),
1134
+ exit_code=0,
1135
+ timed_out=False,
1136
+ termination_reason=None,
1137
+ stale_marker=None,
1138
+ fatal_error_code="",
1139
+ progress_summary=ProgressSummary(result_subtype="success", terminal_result_text="Completed; working tree clean"),
1140
+ session_log=log,
1141
+ backup_log=tmp_path / "backup.log",
1142
+ log_recovery=None,
1143
+ started_epoch=0,
1144
+ ended_epoch=1,
1145
+ )
1146
+ prompt = PromptGenerationResult(
1147
+ output_path=tmp_path / "prompt.md",
1148
+ checkpoint_path=checkpoint,
1149
+ artifact_path=artifact_dir,
1150
+ )
1151
+
1152
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head, prompt=prompt)
1153
+ assert classification.session_status == "success"
1154
+ assert classification.reason == "terminal_success_with_completed_checkpoint"
1155
+
1156
+
986
1157
  def test_classification_preserves_success_before_context_overflow(tmp_path):
987
1158
  from prizmkit_runtime.runner_classification import classify_session
988
1159
  from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
@@ -1021,6 +1192,43 @@ def test_classification_preserves_success_before_context_overflow(tmp_path):
1021
1192
  assert classification.reason == "commit_since_base"
1022
1193
 
1023
1194
 
1195
+ def test_classification_crash_with_commit_since_base_does_not_become_success(tmp_path):
1196
+ from prizmkit_runtime.runner_classification import classify_session
1197
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1198
+ from prizmkit_runtime.status import ProgressSummary
1199
+
1200
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1201
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1202
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1203
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1204
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1205
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1206
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1207
+ (tmp_path / "done.txt").write_text("done", encoding="utf-8")
1208
+ subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
1209
+ subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1210
+ log = tmp_path / "session.log"
1211
+ log.write_text("hard failure", encoding="utf-8")
1212
+ raw = AISessionResult(
1213
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1214
+ exit_code=1,
1215
+ timed_out=False,
1216
+ termination_reason=None,
1217
+ stale_marker=None,
1218
+ fatal_error_code="",
1219
+ progress_summary=ProgressSummary(terminal_result_text="hard failure"),
1220
+ session_log=log,
1221
+ backup_log=tmp_path / "backup.log",
1222
+ log_recovery=None,
1223
+ started_epoch=0,
1224
+ ended_epoch=1,
1225
+ )
1226
+
1227
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
1228
+ assert classification.session_status == "crashed"
1229
+ assert classification.reason == "exit_code=1"
1230
+
1231
+
1024
1232
  def test_classification_stalls_after_repeated_no_progress_context_overflow(tmp_path):
1025
1233
  from prizmkit_runtime.runner_classification import classify_session, progress_fingerprint
1026
1234
  from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
@@ -853,6 +853,7 @@ def test_git_branch_worktree_plans_preserve_shell_invariants(tmp_path):
853
853
  assert ensure.never_fails is True
854
854
  assert any(command.args[:2] == ("rebase", "--abort") for command in ensure.commands)
855
855
  assert any(command.args[:2] == ("add", "-A") for command in ensure.commands)
856
+ assert not any(command.args[:2] == ("stash", "pop") for command in ensure.commands)
856
857
  assert any(".*/worktrees/**" in " ".join(command.args) for command in save_wip.commands)
857
858
  assert worktree.commands[0].args[:2] == ("worktree", "add")
858
859
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.105",
2
+ "version": "1.1.106",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.105",
3
+ "version": "1.1.106",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {