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
@@ -318,6 +318,7 @@ def test_runner_models_parse_legacy_args_and_environment(tmp_path):
318
318
  assert env.stale_kill_threshold_seconds == 9
319
319
  assert env.max_retries == 9
320
320
  assert env.strict_behavior_check is False
321
+ assert not hasattr(env, "max_" + "log_size")
321
322
 
322
323
 
323
324
  def test_runner_environment_disables_worktree_by_default_and_accepts_truthy_opt_in():
@@ -414,10 +415,67 @@ def test_stop_on_failure_does_not_stop_on_retryable_pending(monkeypatch, tmp_pat
414
415
 
415
416
  result = runners._run_pipeline(family, invocation, paths)
416
417
 
418
+ assert processed == ["F-001", "F-002"]
419
+ assert result.completed is False
420
+ assert result.stopped_reason == "pipeline_complete_with_non_terminal_history"
421
+ assert result.last_status == "completed"
422
+ assert result.details == ("F-001:pending", "F-002:completed")
423
+
424
+
425
+ def test_pipeline_complete_rejects_retryable_detail(monkeypatch, tmp_path):
426
+ from prizmkit_runtime import runners
427
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
428
+
429
+ paths = _make_paths(tmp_path)
430
+ family = family_for("feature", paths)
431
+ invocation = parse_invocation(family, "run", ())
432
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
433
+ queue = [
434
+ SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-001"}), data={"feature_id": "F-001"}, text=""),
435
+ SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
436
+ ]
437
+
438
+ monkeypatch.setenv("STOP_ON_FAILURE", "0")
439
+ monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
440
+ monkeypatch.setattr(runners, "_process_item", lambda *args, **kwargs: "retryable")
441
+
442
+ result = runners._run_pipeline(family, invocation, paths)
443
+
444
+ assert result.completed is False
445
+ assert result.stopped_reason == "pipeline_complete_with_non_terminal_history"
446
+ assert result.last_status == "retryable"
447
+ assert result.details == ("F-001:retryable",)
448
+
449
+
450
+ def test_full_list_continues_from_completed_item_to_next_pending(monkeypatch, tmp_path):
451
+ from prizmkit_runtime import runners
452
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
453
+
454
+ paths = _make_paths(tmp_path)
455
+ family = family_for("feature", paths)
456
+ invocation = parse_invocation(family, "run", ())
457
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
458
+ queue = [
459
+ SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-001"}), data={"feature_id": "F-001"}, text=""),
460
+ SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-002"}), data={"feature_id": "F-002"}, text=""),
461
+ SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
462
+ ]
463
+ processed = []
464
+
465
+ monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
466
+
467
+ def fake_process(_family, _invocation, item_id, _paths, *, initial_metadata):
468
+ processed.append(item_id)
469
+ return "completed"
470
+
471
+ monkeypatch.setattr(runners, "_process_item", fake_process)
472
+
473
+ result = runners._run_pipeline(family, invocation, paths)
474
+
417
475
  assert processed == ["F-001", "F-002"]
418
476
  assert result.completed is True
419
477
  assert result.stopped_reason == "pipeline_complete"
420
- assert result.details == ("F-001:pending", "F-002:completed")
478
+ assert result.details == ("F-001:completed", "F-002:completed")
421
479
 
422
480
 
423
481
  def test_stop_on_failure_still_stops_on_terminal_status(monkeypatch, tmp_path):
@@ -546,6 +604,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
546
604
 
547
605
  prompt_calls = []
548
606
  launch_cwds = []
607
+ launch_configs = []
549
608
  updates = []
550
609
  cleanup_calls = []
551
610
  merge_calls = []
@@ -563,6 +622,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
563
622
  )
564
623
  monkeypatch.setattr(runners, "detect_stream_json_support", lambda command, platform: SimpleNamespace(enabled=False))
565
624
  monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"}))
625
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", lambda *args, **kwargs: SimpleNamespace(attempted=False, committed=False, ok=True, result=None))
566
626
  monkeypatch.setattr(runners, "commit_pre_branch_bookkeeping", lambda *args, **kwargs: None)
567
627
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
568
628
  monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
@@ -594,6 +654,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
594
654
 
595
655
  def run(self):
596
656
  launch_cwds.append(self.config.cwd)
657
+ launch_configs.append(self.config)
597
658
  return SimpleNamespace(progress_summary=SimpleNamespace(), fatal_error_code="")
598
659
 
599
660
  def fake_classify(*args, **kwargs):
@@ -632,6 +693,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
632
693
  return SimpleNamespace(
633
694
  prompt_calls=prompt_calls,
634
695
  launch_cwds=launch_cwds,
696
+ launch_configs=launch_configs,
635
697
  updates=updates,
636
698
  cleanup_calls=cleanup_calls,
637
699
  merge_calls=merge_calls,
@@ -639,6 +701,36 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
639
701
  )
640
702
 
641
703
 
704
+
705
+ def test_runner_launch_config_preserves_artifact_paths_live_output_and_split_thresholds(monkeypatch, tmp_path):
706
+ from prizmkit_runtime import runners
707
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
708
+
709
+ monkeypatch.setenv("PRIZMKIT_LIVE_OUTPUT", "0")
710
+ monkeypatch.setenv("HEARTBEAT_INTERVAL", "7")
711
+ monkeypatch.setenv("HEARTBEAT_STALE_THRESHOLD", "11")
712
+ monkeypatch.setenv("STALE_KILL_THRESHOLD", "13")
713
+ paths = _make_paths(tmp_path / "artifact-wiring")
714
+ family = family_for("feature", paths)
715
+ invocation = parse_invocation(family, "run", ())
716
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
717
+ observed = _install_runner_session_fakes(monkeypatch, runners)
718
+ monkeypatch.setattr(runners, "run_git_plan", lambda *args, **kwargs: SimpleNamespace(ok=True, reason="", results=()))
719
+
720
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
721
+
722
+ config = observed.launch_configs[0]
723
+ expected_log = paths.feature_state_dir / "F-001" / "sessions" / "F-001-session-1" / "logs" / "session.log"
724
+ assert status == "completed"
725
+ assert config.live_output is False
726
+ assert config.live_progress_interval_seconds == 7
727
+ assert config.heartbeat_stale_threshold_seconds == 11
728
+ assert config.stale_kill_threshold_seconds == 13
729
+ assert config.log_path == expected_log
730
+ assert config.progress_path == expected_log.with_name("progress.json")
731
+ assert config.heartbeat_path == expected_log.with_name("heartbeat.json")
732
+
733
+
642
734
  @pytest.mark.parametrize(("kind", "item_id", "branch"), [("feature", "F-001", "dev/F-001-test"), ("bugfix", "B-001", "bugfix/B-001-test")])
643
735
  def test_feature_and_bugfix_explicit_worktree_launches_from_worktree_and_renders_worktree_root(monkeypatch, tmp_path, kind, item_id, branch):
644
736
  from prizmkit_runtime import runners
@@ -652,18 +744,26 @@ def test_feature_and_bugfix_explicit_worktree_launches_from_worktree_and_renders
652
744
  invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan if kind == "feature" else paths.bugfix_plan})
653
745
  observed = _install_runner_session_fakes(monkeypatch, runners)
654
746
  setup_calls = []
747
+ lifecycle_order = []
655
748
 
656
749
  def fake_setup(runtime):
750
+ lifecycle_order.append("worktree_setup")
657
751
  setup_calls.append(runtime)
658
752
  runtime.worktree_path.mkdir(parents=True, exist_ok=True)
659
753
  return SimpleNamespace(ok=True, runtime=runtime, reason="")
660
754
 
755
+ def fake_preflight(*args, **kwargs):
756
+ lifecycle_order.append("preflight")
757
+ return SimpleNamespace(attempted=False, committed=False, ok=True, result=None)
758
+
759
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", fake_preflight)
661
760
  monkeypatch.setattr(runners, "ensure_linked_worktree", fake_setup, raising=False)
662
761
 
663
762
  status = runners._process_item(family, invocation, item_id, paths, initial_metadata={})
664
763
 
665
764
  expected_worktree = paths.state_dir / "worktrees" / branch.replace("/", "-")
666
765
  assert status == "completed"
766
+ assert lifecycle_order[:2] == ["preflight", "worktree_setup"]
667
767
  assert setup_calls[0].worktree_path == expected_worktree.resolve()
668
768
  assert observed.launch_cwds == [expected_worktree.resolve()]
669
769
  assert observed.prompt_calls[0]["project_root"] == paths.project_root
@@ -784,6 +884,7 @@ def test_explicit_worktree_materializes_ignored_support_assets_before_prompt_and
784
884
  )
785
885
  monkeypatch.setattr(runners, "detect_stream_json_support", lambda command, platform: SimpleNamespace(enabled=False))
786
886
  monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"}))
887
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", lambda *args, **kwargs: SimpleNamespace(attempted=False, committed=False, ok=True, result=None))
787
888
  monkeypatch.setattr(runners, "commit_pre_branch_bookkeeping", lambda *args, **kwargs: None)
788
889
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
789
890
  monkeypatch.setattr(runners, "_session_id", lambda item_id: f"{item_id}-session")
@@ -863,6 +964,11 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
863
964
  lifecycle_order.append("start_item")
864
965
  return SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"})
865
966
 
967
+ def fake_preflight(*args, **kwargs):
968
+ lifecycle_order.append("preflight")
969
+ return SimpleNamespace(attempted=False, committed=False, ok=True, result=None)
970
+
971
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", fake_preflight)
866
972
  monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
867
973
  monkeypatch.setattr(runners, "start_item", fake_start_item)
868
974
  monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: (_ for _ in ()).throw(AssertionError("worktree setup should not run")), raising=False)
@@ -874,10 +980,178 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
874
980
  assert observed.prompt_calls[0].get("execution_root") == paths.project_root
875
981
  assert "branch_create" in plan_names
876
982
  assert "branch_merge" in plan_names
877
- assert lifecycle_order[:2] == ["branch_create", "start_item"]
983
+ assert lifecycle_order[:3] == ["preflight", "branch_create", "start_item"]
878
984
  assert branch_events == [("create", "dev/F-001-test"), ("merge", "dev/F-001-test")]
879
985
 
880
986
 
987
+ def test_has_branch_completion_evidence_requires_task_branch_ahead_of_base(tmp_path):
988
+ from prizmkit_runtime.runner_classification import has_branch_completion_evidence
989
+
990
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
991
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
992
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
993
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
994
+ artifact_dir.mkdir(parents=True)
995
+ (artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
996
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
997
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
998
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
999
+ subprocess.run(["git", "checkout", "-b", "dev/F-001"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1000
+ (tmp_path / "done.txt").write_text("done", encoding="utf-8")
1001
+ subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
1002
+ subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1003
+
1004
+ assert has_branch_completion_evidence(tmp_path, "main", "dev/F-001", artifact_dir) is True
1005
+
1006
+ subprocess.run(["git", "checkout", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1007
+ (tmp_path / "main.txt").write_text("main", encoding="utf-8")
1008
+ subprocess.run(["git", "add", "main.txt"], cwd=tmp_path, check=True)
1009
+ subprocess.run(["git", "commit", "-m", "advance-main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1010
+ subprocess.run(["git", "branch", "stale/F-001", "HEAD~1"], cwd=tmp_path, check=True)
1011
+
1012
+ assert has_branch_completion_evidence(tmp_path, "main", "stale/F-001", artifact_dir) is False
1013
+
1014
+
1015
+ def test_no_worktree_late_runtime_error_success_updates_and_merges(monkeypatch, tmp_path):
1016
+ from prizmkit_runtime import runners
1017
+ from prizmkit_runtime.runner_models import SessionClassification, family_for, parse_invocation
1018
+
1019
+ monkeypatch.setenv("DEV_BRANCH", "dev/F-001-late-error")
1020
+ paths = _make_paths(tmp_path)
1021
+ family = family_for("feature", paths)
1022
+ invocation = parse_invocation(family, "run", ())
1023
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1024
+ observed = _install_runner_session_fakes(monkeypatch, runners, statuses=("infra_error",))
1025
+ events = []
1026
+
1027
+ def fake_classify(*args, **kwargs):
1028
+ return SessionClassification("success", reason="completed_checkpoint_with_late_runtime_error")
1029
+
1030
+ def fake_run_git_plan(project_root, plan):
1031
+ events.append(plan.name)
1032
+ return SimpleNamespace(ok=True, plan=plan, results=())
1033
+
1034
+ monkeypatch.setattr(runners, "classify_session", fake_classify)
1035
+ monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
1036
+
1037
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
1038
+
1039
+ assert status == "completed"
1040
+ assert observed.updates[-1][0] == "success"
1041
+ assert "branch_merge" in events
1042
+
1043
+
1044
+ def test_no_worktree_recovers_completed_pending_branch_before_new_session(monkeypatch, tmp_path):
1045
+ from prizmkit_runtime import runners
1046
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
1047
+
1048
+ monkeypatch.setenv("DEV_BRANCH", "dev/F-001-recover")
1049
+ paths = _make_paths(tmp_path)
1050
+ family = family_for("feature", paths)
1051
+ invocation = parse_invocation(family, "run", ())
1052
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1053
+ session_dir = paths.feature_state_dir / "F-001" / "sessions" / "F-001-session-old"
1054
+ session_dir.mkdir(parents=True)
1055
+ artifact_dir = paths.project_root / ".prizmkit" / "specs" / "001-low"
1056
+ artifact_dir.mkdir(parents=True)
1057
+ (artifact_dir / "workflow-checkpoint.json").write_text(
1058
+ json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}),
1059
+ encoding="utf-8",
1060
+ )
1061
+ updates = []
1062
+ events = []
1063
+
1064
+ monkeypatch.setattr(
1065
+ runners,
1066
+ "load_runtime_config",
1067
+ lambda paths: SimpleNamespace(ai_client=SimpleNamespace(command="fake-ai", platform="codebuddy"), model="", effort=None),
1068
+ )
1069
+ monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
1070
+ monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not restart completed work")))
1071
+ monkeypatch.setattr(runners, "generate_prompt", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not launch prompt")))
1072
+ monkeypatch.setattr(runners, "has_branch_completion_evidence", lambda *args, **kwargs: True)
1073
+ monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1074
+ monkeypatch.setattr(runners, "commit_task_branch_changes", lambda *args, **kwargs: None)
1075
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", lambda *args, **kwargs: SimpleNamespace(attempted=False, committed=False, ok=True, result=None))
1076
+
1077
+ def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1078
+ updates.append((session_status, session_id, metadata))
1079
+ return SimpleNamespace(ok=True, text="", data={"new_status": "completed"})
1080
+
1081
+ def fake_run_git_plan(project_root, plan):
1082
+ events.append(plan.name)
1083
+ return SimpleNamespace(ok=True, plan=plan, results=())
1084
+
1085
+ monkeypatch.setattr(runners, "update_item", fake_update)
1086
+ monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
1087
+
1088
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={"title": "Low"})
1089
+
1090
+ assert status == "completed"
1091
+ assert updates == [("success", "F-001-session-old", {"active_dev_branch": "dev/F-001-recover", "base_branch": "main"})]
1092
+ assert "branch_merge" in events
1093
+
1094
+
1095
+ def test_worktree_recovers_completed_pending_branch_from_base_session_history(monkeypatch, tmp_path):
1096
+ from prizmkit_runtime import runners
1097
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
1098
+
1099
+ monkeypatch.setenv("USE_WORKTREE", "1")
1100
+ paths = _make_paths(tmp_path)
1101
+ family = family_for("feature", paths)
1102
+ invocation = parse_invocation(family, "run", ())
1103
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1104
+ base_session_dir = paths.feature_state_dir / "F-001" / "sessions" / "F-001-session-old"
1105
+ base_session_dir.mkdir(parents=True)
1106
+ (base_session_dir / "session-status.json").write_text(
1107
+ json.dumps({"base_branch": "main", "active_dev_branch": "dev/F-001-worktree"}),
1108
+ encoding="utf-8",
1109
+ )
1110
+ updates = []
1111
+ cleanup_calls = []
1112
+ merge_calls = []
1113
+
1114
+ monkeypatch.setattr(
1115
+ runners,
1116
+ "load_runtime_config",
1117
+ lambda paths: SimpleNamespace(ai_client=SimpleNamespace(command="fake-ai", platform="codebuddy"), model="", effort=None),
1118
+ )
1119
+ monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
1120
+ monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: SimpleNamespace(ok=True, runtime=runtime, reason=""), raising=False)
1121
+ monkeypatch.setattr(runners, "materialize_worktree_support_assets", lambda runtime: SimpleNamespace(ok=True, reason=""), raising=False)
1122
+ monkeypatch.setattr(runners, "has_completed_artifact_path", lambda artifact: True)
1123
+ monkeypatch.setattr(runners, "has_branch_completion_evidence", lambda *args, **kwargs: True)
1124
+ monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not restart completed work")))
1125
+ monkeypatch.setattr(runners, "generate_prompt", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not launch prompt")))
1126
+ monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1127
+ monkeypatch.setattr(runners, "commit_task_branch_changes", lambda *args, **kwargs: None)
1128
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", lambda *args, **kwargs: SimpleNamespace(attempted=False, committed=False, ok=True, result=None))
1129
+
1130
+ def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1131
+ updates.append((session_status, session_id, project_root, metadata))
1132
+ return SimpleNamespace(ok=True, text="", data={"new_status": "completed"})
1133
+
1134
+ def fake_merge(runtime, *, auto_push=False):
1135
+ merge_calls.append((runtime, auto_push))
1136
+ return SimpleNamespace(ok=True, reason="", runtime=runtime, results=())
1137
+
1138
+ def fake_cleanup(runtime, *, delete_branch=True):
1139
+ cleanup_calls.append((runtime, delete_branch))
1140
+ return SimpleNamespace(ok=True, reason="")
1141
+
1142
+ monkeypatch.setattr(runners, "update_item", fake_update)
1143
+ monkeypatch.setattr(runners, "merge_linked_worktree", fake_merge, raising=False)
1144
+ monkeypatch.setattr(runners, "guarded_worktree_remove", fake_cleanup, raising=False)
1145
+
1146
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={"title": "Low"})
1147
+
1148
+ assert status == "completed"
1149
+ assert updates[0][0] == "success"
1150
+ assert updates[0][1] == "F-001-session-old"
1151
+ assert updates[0][2] != paths.project_root
1152
+ assert merge_calls and cleanup_calls[-1][1] is True
1153
+
1154
+
881
1155
  def test_default_branch_names_keep_timestamp_for_each_runner_family(monkeypatch, tmp_path):
882
1156
  from prizmkit_runtime import runners
883
1157
  from prizmkit_runtime.runner_models import family_for
@@ -963,6 +1237,33 @@ def test_no_worktree_runner_ignores_recovery_side_effect_branch(monkeypatch, tmp
963
1237
  assert observed.updates[-1][2]["base_branch"] == "main"
964
1238
 
965
1239
 
1240
+ def test_no_worktree_preflight_commit_failure_aborts_before_branch_or_status(monkeypatch, tmp_path):
1241
+ from prizmkit_runtime import runners
1242
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
1243
+
1244
+ monkeypatch.setenv("DEV_BRANCH", "dev/F-001-preflight-fail")
1245
+ paths = _make_paths(tmp_path)
1246
+ family = family_for("feature", paths)
1247
+ invocation = parse_invocation(family, "run", ())
1248
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1249
+ events = []
1250
+
1251
+ monkeypatch.setattr(
1252
+ runners,
1253
+ "load_runtime_config",
1254
+ lambda paths: SimpleNamespace(ai_client=SimpleNamespace(command="fake-ai", platform="codebuddy"), model="", effort=None),
1255
+ )
1256
+ monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
1257
+ monkeypatch.setattr(runners, "commit_preflight_ready_changes", lambda *args, **kwargs: SimpleNamespace(attempted=True, committed=False, result=SimpleNamespace(stderr="commit failed\n", stdout="")))
1258
+ monkeypatch.setattr(runners, "run_git_plan", lambda *args, **kwargs: events.append("branch_create"))
1259
+ monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: events.append("start_item"))
1260
+
1261
+ with pytest.raises(RuntimeError, match="Preflight dirty workspace preservation failed"):
1262
+ runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
1263
+
1264
+ assert events == []
1265
+
1266
+
966
1267
  def test_foreground_runner_emits_structured_feature_progress(monkeypatch, tmp_path, capsys):
967
1268
  from prizmkit_runtime import runners
968
1269
  from prizmkit_runtime.runner_models import family_for, parse_invocation
@@ -988,7 +1289,7 @@ def test_foreground_runner_emits_structured_feature_progress(monkeypatch, tmp_pa
988
1289
  assert "────────────────────────────────────────────────────" in captured.err
989
1290
  assert "Feature: F-001 — Readable output" in captured.err
990
1291
  assert "Code retry: 1 / 3" in captured.err
991
- assert "Infrastructure retry: 2 / 3" in captured.err
1292
+ assert "Preflight: no user-visible changes to preserve before feature task F-001" in captured.err
992
1293
  assert "Pipeline mode: lite (Tier 1 — Single Agent)" in captured.err
993
1294
  assert "Agents: 1 (critic: disabled)" in captured.err
994
1295
  assert "Spawning AI CLI session: F-001-session-1" in captured.err
@@ -1108,6 +1409,133 @@ def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outco
1108
1409
  assert observed.cleanup_calls[0][1] is True
1109
1410
 
1110
1411
 
1412
+ def test_classification_accepts_late_infra_error_with_completed_checkpoint_and_commit(tmp_path):
1413
+ from prizmkit_runtime.runner_classification import classify_session
1414
+ from prizmkit_runtime.runner_models import PromptGenerationResult
1415
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1416
+ from prizmkit_runtime.status import ProgressSummary
1417
+
1418
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1419
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1420
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1421
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1422
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1423
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1424
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1425
+ (tmp_path / "done.txt").write_text("done", encoding="utf-8")
1426
+ subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
1427
+ subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1428
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
1429
+ artifact_dir.mkdir(parents=True)
1430
+ checkpoint = artifact_dir / "workflow-checkpoint.json"
1431
+ checkpoint.write_text(json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}), encoding="utf-8")
1432
+ log = tmp_path / "session.log"
1433
+ log.write_text("model quota exceeded; API error after commit", encoding="utf-8")
1434
+ raw = AISessionResult(
1435
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1436
+ exit_code=1,
1437
+ timed_out=False,
1438
+ termination_reason=None,
1439
+ stale_marker=None,
1440
+ fatal_error_code="api_error",
1441
+ progress_summary=ProgressSummary(terminal_result_text="quota exceeded"),
1442
+ session_log=log,
1443
+ backup_log=tmp_path / "backup.log",
1444
+ log_recovery=None,
1445
+ started_epoch=0,
1446
+ ended_epoch=1,
1447
+ )
1448
+ prompt = PromptGenerationResult(output_path=tmp_path / "prompt.md", checkpoint_path=checkpoint, artifact_path=artifact_dir)
1449
+
1450
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head, prompt=prompt)
1451
+
1452
+ assert classification.session_status == "success"
1453
+ assert classification.reason == "completed_checkpoint_with_late_runtime_error"
1454
+
1455
+
1456
+ def test_classification_rejects_late_infra_when_head_diff_has_no_commit_range(tmp_path):
1457
+ from prizmkit_runtime.runner_classification import classify_session
1458
+ from prizmkit_runtime.runner_models import PromptGenerationResult
1459
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1460
+ from prizmkit_runtime.status import ProgressSummary
1461
+
1462
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1463
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1464
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1465
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1466
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1467
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1468
+ subprocess.run(["git", "checkout", "-b", "side"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1469
+ (tmp_path / "side.txt").write_text("side", encoding="utf-8")
1470
+ subprocess.run(["git", "add", "side.txt"], cwd=tmp_path, check=True)
1471
+ subprocess.run(["git", "commit", "-m", "side"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1472
+ side_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1473
+ subprocess.run(["git", "checkout", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1474
+ (tmp_path / "main.txt").write_text("main", encoding="utf-8")
1475
+ subprocess.run(["git", "add", "main.txt"], cwd=tmp_path, check=True)
1476
+ subprocess.run(["git", "commit", "-m", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1477
+ subprocess.run(["git", "checkout", "side"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1478
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
1479
+ artifact_dir.mkdir(parents=True)
1480
+ (artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
1481
+ log = tmp_path / "session.log"
1482
+ log.write_text("quota exceeded", encoding="utf-8")
1483
+ raw = AISessionResult(
1484
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1485
+ exit_code=1,
1486
+ timed_out=False,
1487
+ termination_reason=None,
1488
+ stale_marker=None,
1489
+ fatal_error_code="api_error",
1490
+ progress_summary=ProgressSummary(terminal_result_text="quota exceeded"),
1491
+ session_log=log,
1492
+ backup_log=tmp_path / "backup.log",
1493
+ log_recovery=None,
1494
+ started_epoch=0,
1495
+ ended_epoch=1,
1496
+ )
1497
+ prompt = PromptGenerationResult(output_path=tmp_path / "prompt.md", artifact_path=artifact_dir)
1498
+
1499
+ classification = classify_session(raw, project_root=tmp_path, base_head=side_head, prompt=prompt)
1500
+
1501
+ assert classification.session_status == "infra_error"
1502
+
1503
+
1504
+ def test_classification_keeps_pre_completion_provider_error_as_infra_error(tmp_path):
1505
+ from prizmkit_runtime.runner_classification import classify_session
1506
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1507
+ from prizmkit_runtime.status import ProgressSummary
1508
+
1509
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1510
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1511
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1512
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1513
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1514
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1515
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1516
+ log = tmp_path / "session.log"
1517
+ log.write_text("authentication failed", encoding="utf-8")
1518
+ raw = AISessionResult(
1519
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1520
+ exit_code=1,
1521
+ timed_out=False,
1522
+ termination_reason=None,
1523
+ stale_marker=None,
1524
+ fatal_error_code="authentication_error",
1525
+ progress_summary=ProgressSummary(terminal_result_text="authentication failed"),
1526
+ session_log=log,
1527
+ backup_log=tmp_path / "backup.log",
1528
+ log_recovery=None,
1529
+ started_epoch=0,
1530
+ ended_epoch=1,
1531
+ )
1532
+
1533
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
1534
+
1535
+ assert classification.session_status == "infra_error"
1536
+ assert classification.reason == "ai_runtime_or_provider_error"
1537
+
1538
+
1111
1539
  def test_classification_accepts_terminal_success_with_completed_checkpoint_without_git_delta(tmp_path):
1112
1540
  from prizmkit_runtime.runner_classification import classify_session
1113
1541
  from prizmkit_runtime.runner_models import PromptGenerationResult