prizmkit 1.1.105 → 1.1.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +0 -1
  3. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +83 -12
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +93 -6
  5. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
  6. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +9 -1
  7. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +421 -61
  8. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +102 -17
  9. package/bundled/dev-pipeline/scripts/continuation.py +0 -4
  10. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +7 -39
  11. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +1 -31
  12. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -31
  13. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
  14. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +28 -28
  15. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +36 -52
  16. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +34 -54
  17. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
  18. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
  19. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +2 -3
  20. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +2 -2
  21. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -9
  22. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -10
  23. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -3
  24. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -3
  25. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +49 -2
  26. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +17 -0
  27. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +50 -1
  28. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +560 -1
  29. package/bundled/dev-pipeline/tests/test_unified_cli.py +208 -0
  30. package/bundled/skills/_metadata.json +1 -1
  31. package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
  32. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
  33. package/package.json +1 -1
  34. package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
  35. package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
  36. package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
@@ -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):
@@ -847,12 +905,24 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
847
905
  invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
848
906
  observed = _install_runner_session_fakes(monkeypatch, runners)
849
907
  plan_names = []
908
+ branch_events = []
909
+ lifecycle_order = []
850
910
 
851
911
  def fake_run_git_plan(project_root, plan):
852
912
  plan_names.append(plan.name)
913
+ if plan.name == "branch_create":
914
+ lifecycle_order.append("branch_create")
915
+ branch_events.append(("create", plan.commands[1].args[2]))
916
+ if plan.name == "branch_merge":
917
+ branch_events.append(("merge", plan.commands[5].args[2]))
853
918
  return SimpleNamespace(ok=True, plan=plan, results=())
854
919
 
920
+ def fake_start_item(*args, **kwargs):
921
+ lifecycle_order.append("start_item")
922
+ return SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"})
923
+
855
924
  monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
925
+ monkeypatch.setattr(runners, "start_item", fake_start_item)
856
926
  monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: (_ for _ in ()).throw(AssertionError("worktree setup should not run")), raising=False)
857
927
 
858
928
  status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
@@ -862,6 +932,228 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
862
932
  assert observed.prompt_calls[0].get("execution_root") == paths.project_root
863
933
  assert "branch_create" in plan_names
864
934
  assert "branch_merge" in plan_names
935
+ assert lifecycle_order[:2] == ["branch_create", "start_item"]
936
+ assert branch_events == [("create", "dev/F-001-test"), ("merge", "dev/F-001-test")]
937
+
938
+
939
+ def test_has_branch_completion_evidence_requires_task_branch_ahead_of_base(tmp_path):
940
+ from prizmkit_runtime.runner_classification import has_branch_completion_evidence
941
+
942
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
943
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
944
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
945
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
946
+ artifact_dir.mkdir(parents=True)
947
+ (artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
948
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
949
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
950
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
951
+ subprocess.run(["git", "checkout", "-b", "dev/F-001"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
952
+ (tmp_path / "done.txt").write_text("done", encoding="utf-8")
953
+ subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
954
+ subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
955
+
956
+ assert has_branch_completion_evidence(tmp_path, "main", "dev/F-001", artifact_dir) is True
957
+
958
+ subprocess.run(["git", "checkout", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
959
+ (tmp_path / "main.txt").write_text("main", encoding="utf-8")
960
+ subprocess.run(["git", "add", "main.txt"], cwd=tmp_path, check=True)
961
+ subprocess.run(["git", "commit", "-m", "advance-main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
962
+ subprocess.run(["git", "branch", "stale/F-001", "HEAD~1"], cwd=tmp_path, check=True)
963
+
964
+ assert has_branch_completion_evidence(tmp_path, "main", "stale/F-001", artifact_dir) is False
965
+
966
+
967
+ def test_no_worktree_late_runtime_error_success_updates_and_merges(monkeypatch, tmp_path):
968
+ from prizmkit_runtime import runners
969
+ from prizmkit_runtime.runner_models import SessionClassification, family_for, parse_invocation
970
+
971
+ monkeypatch.setenv("DEV_BRANCH", "dev/F-001-late-error")
972
+ paths = _make_paths(tmp_path)
973
+ family = family_for("feature", paths)
974
+ invocation = parse_invocation(family, "run", ())
975
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
976
+ observed = _install_runner_session_fakes(monkeypatch, runners, statuses=("infra_error",))
977
+ events = []
978
+
979
+ def fake_classify(*args, **kwargs):
980
+ return SessionClassification("success", reason="completed_checkpoint_with_late_runtime_error")
981
+
982
+ def fake_run_git_plan(project_root, plan):
983
+ events.append(plan.name)
984
+ return SimpleNamespace(ok=True, plan=plan, results=())
985
+
986
+ monkeypatch.setattr(runners, "classify_session", fake_classify)
987
+ monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
988
+
989
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
990
+
991
+ assert status == "completed"
992
+ assert observed.updates[-1][0] == "success"
993
+ assert "branch_merge" in events
994
+
995
+
996
+ def test_no_worktree_recovers_completed_pending_branch_before_new_session(monkeypatch, tmp_path):
997
+ from prizmkit_runtime import runners
998
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
999
+
1000
+ monkeypatch.setenv("DEV_BRANCH", "dev/F-001-recover")
1001
+ paths = _make_paths(tmp_path)
1002
+ family = family_for("feature", paths)
1003
+ invocation = parse_invocation(family, "run", ())
1004
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1005
+ session_dir = paths.feature_state_dir / "F-001" / "sessions" / "F-001-session-old"
1006
+ session_dir.mkdir(parents=True)
1007
+ artifact_dir = paths.project_root / ".prizmkit" / "specs" / "001-low"
1008
+ artifact_dir.mkdir(parents=True)
1009
+ (artifact_dir / "workflow-checkpoint.json").write_text(
1010
+ json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}),
1011
+ encoding="utf-8",
1012
+ )
1013
+ updates = []
1014
+ events = []
1015
+
1016
+ monkeypatch.setattr(
1017
+ runners,
1018
+ "load_runtime_config",
1019
+ lambda paths: SimpleNamespace(ai_client=SimpleNamespace(command="fake-ai", platform="codebuddy"), model="", effort=None),
1020
+ )
1021
+ monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
1022
+ monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not restart completed work")))
1023
+ monkeypatch.setattr(runners, "generate_prompt", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not launch prompt")))
1024
+ monkeypatch.setattr(runners, "has_branch_completion_evidence", lambda *args, **kwargs: True)
1025
+ monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1026
+ monkeypatch.setattr(runners, "commit_task_branch_changes", lambda *args, **kwargs: None)
1027
+
1028
+ def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1029
+ updates.append((session_status, session_id, metadata))
1030
+ return SimpleNamespace(ok=True, text="", data={"new_status": "completed"})
1031
+
1032
+ def fake_run_git_plan(project_root, plan):
1033
+ events.append(plan.name)
1034
+ return SimpleNamespace(ok=True, plan=plan, results=())
1035
+
1036
+ monkeypatch.setattr(runners, "update_item", fake_update)
1037
+ monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
1038
+
1039
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={"title": "Low"})
1040
+
1041
+ assert status == "completed"
1042
+ assert updates == [("success", "F-001-session-old", {"active_dev_branch": "dev/F-001-recover", "base_branch": "main"})]
1043
+ assert "branch_merge" in events
1044
+
1045
+
1046
+ def test_worktree_recovers_completed_pending_branch_from_base_session_history(monkeypatch, tmp_path):
1047
+ from prizmkit_runtime import runners
1048
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
1049
+
1050
+ monkeypatch.setenv("USE_WORKTREE", "1")
1051
+ paths = _make_paths(tmp_path)
1052
+ family = family_for("feature", paths)
1053
+ invocation = parse_invocation(family, "run", ())
1054
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1055
+ base_session_dir = paths.feature_state_dir / "F-001" / "sessions" / "F-001-session-old"
1056
+ base_session_dir.mkdir(parents=True)
1057
+ (base_session_dir / "session-status.json").write_text(
1058
+ json.dumps({"base_branch": "main", "active_dev_branch": "dev/F-001-worktree"}),
1059
+ encoding="utf-8",
1060
+ )
1061
+ updates = []
1062
+ cleanup_calls = []
1063
+ merge_calls = []
1064
+
1065
+ monkeypatch.setattr(
1066
+ runners,
1067
+ "load_runtime_config",
1068
+ lambda paths: SimpleNamespace(ai_client=SimpleNamespace(command="fake-ai", platform="codebuddy"), model="", effort=None),
1069
+ )
1070
+ monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
1071
+ monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: SimpleNamespace(ok=True, runtime=runtime, reason=""), raising=False)
1072
+ monkeypatch.setattr(runners, "materialize_worktree_support_assets", lambda runtime: SimpleNamespace(ok=True, reason=""), raising=False)
1073
+ monkeypatch.setattr(runners, "has_completed_artifact_path", lambda artifact: True)
1074
+ monkeypatch.setattr(runners, "has_branch_completion_evidence", lambda *args, **kwargs: True)
1075
+ monkeypatch.setattr(runners, "start_item", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not restart completed work")))
1076
+ monkeypatch.setattr(runners, "generate_prompt", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not launch prompt")))
1077
+ monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1078
+ monkeypatch.setattr(runners, "commit_task_branch_changes", lambda *args, **kwargs: None)
1079
+
1080
+ def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1081
+ updates.append((session_status, session_id, project_root, metadata))
1082
+ return SimpleNamespace(ok=True, text="", data={"new_status": "completed"})
1083
+
1084
+ def fake_merge(runtime, *, auto_push=False):
1085
+ merge_calls.append((runtime, auto_push))
1086
+ return SimpleNamespace(ok=True, reason="", runtime=runtime, results=())
1087
+
1088
+ def fake_cleanup(runtime, *, delete_branch=True):
1089
+ cleanup_calls.append((runtime, delete_branch))
1090
+ return SimpleNamespace(ok=True, reason="")
1091
+
1092
+ monkeypatch.setattr(runners, "update_item", fake_update)
1093
+ monkeypatch.setattr(runners, "merge_linked_worktree", fake_merge, raising=False)
1094
+ monkeypatch.setattr(runners, "guarded_worktree_remove", fake_cleanup, raising=False)
1095
+
1096
+ status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={"title": "Low"})
1097
+
1098
+ assert status == "completed"
1099
+ assert updates[0][0] == "success"
1100
+ assert updates[0][1] == "F-001-session-old"
1101
+ assert updates[0][2] != paths.project_root
1102
+ assert merge_calls and cleanup_calls[-1][1] is True
1103
+
1104
+
1105
+ def test_default_branch_names_keep_timestamp_for_each_runner_family(monkeypatch, tmp_path):
1106
+ from prizmkit_runtime import runners
1107
+ from prizmkit_runtime.runner_models import family_for
1108
+
1109
+ monkeypatch.setattr(runners.time, "strftime", lambda fmt: "20260707010203")
1110
+ paths = _make_paths(tmp_path)
1111
+
1112
+ assert runners._default_branch_name(family_for("feature", paths), "F-001") == "dev/F-001-20260707010203"
1113
+ assert runners._default_branch_name(family_for("bugfix", paths), "B-001") == "bugfix/B-001-20260707010203"
1114
+ assert runners._default_branch_name(family_for("refactor", paths), "R-001") == "refactor/R-001-20260707010203"
1115
+
1116
+
1117
+ @pytest.mark.parametrize(
1118
+ ("kind", "item_id", "branch"),
1119
+ [
1120
+ ("feature", "F-001", "dev/F-001-20260707010203"),
1121
+ ("bugfix", "B-001", "bugfix/B-001-20260707010203"),
1122
+ ("refactor", "R-001", "refactor/R-001-20260707010203"),
1123
+ ],
1124
+ )
1125
+ def test_no_worktree_failure_stays_on_task_branch_without_merge_for_all_families(monkeypatch, tmp_path, kind, item_id, branch):
1126
+ from prizmkit_runtime import runners
1127
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
1128
+
1129
+ monkeypatch.setenv("DEV_BRANCH", branch)
1130
+ paths = _make_paths(tmp_path / kind)
1131
+ family = family_for(kind, paths)
1132
+ invocation = parse_invocation(family, "run", ())
1133
+ plan_path = {"feature": paths.feature_plan, "bugfix": paths.bugfix_plan, "refactor": paths.refactor_plan}[kind]
1134
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": plan_path})
1135
+ _install_runner_session_fakes(monkeypatch, runners, statuses=("crashed",), update_status="pending")
1136
+ events = []
1137
+ final_commits = []
1138
+ wip_commits = []
1139
+
1140
+ def fake_run_git_plan(project_root, plan):
1141
+ events.append(plan.name)
1142
+ return SimpleNamespace(ok=True, plan=plan, results=())
1143
+
1144
+ monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
1145
+ monkeypatch.setattr(runners, "branch_ensure_return", lambda project_root, base, task_branch: events.append(("return", base, task_branch)))
1146
+ monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda project_root, family, item_id, status: final_commits.append((project_root, item_id, status)))
1147
+ monkeypatch.setattr(runners, "commit_wip_changes", lambda project_root, family, item_id, status: wip_commits.append((project_root, item_id, status)))
1148
+
1149
+ status = runners._process_item(family, invocation, item_id, paths, initial_metadata={})
1150
+
1151
+ assert status == "pending"
1152
+ assert events[0] == "branch_create"
1153
+ assert "branch_merge" not in events
1154
+ assert events[-1] == ("return", "main", branch)
1155
+ assert final_commits == [(paths.project_root, item_id, "pending")]
1156
+ assert wip_commits == [(paths.project_root, item_id, "pending")]
865
1157
 
866
1158
 
867
1159
  def test_no_worktree_runner_ignores_recovery_side_effect_branch(monkeypatch, tmp_path):
@@ -956,6 +1248,63 @@ def test_no_worktree_interrupt_saves_wip_and_returns_to_base(monkeypatch, tmp_pa
956
1248
  assert ensure_calls == [("main", "dev/F-001-interrupt")]
957
1249
 
958
1250
 
1251
+ def test_bookkeeping_commits_only_runner_owned_paths(tmp_path):
1252
+ from prizmkit_runtime.runner_bookkeeping import commit_final_bookkeeping
1253
+ from prizmkit_runtime.runner_models import RunnerFamily
1254
+
1255
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1256
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1257
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1258
+ plans = tmp_path / ".prizmkit" / "plans"
1259
+ state = tmp_path / ".prizmkit" / "state" / "features"
1260
+ item_state = state / "F-001"
1261
+ plans.mkdir(parents=True)
1262
+ item_state.mkdir(parents=True)
1263
+ plan = plans / "feature-list.json"
1264
+ plan.write_text('{"features": []}', encoding="utf-8")
1265
+ (item_state / "status.json").write_text("{}", encoding="utf-8")
1266
+ source = tmp_path / "src.txt"
1267
+ source.write_text("base", encoding="utf-8")
1268
+ subprocess.run(["git", "add", ".prizmkit/plans/feature-list.json", ".prizmkit/state/features/F-001/status.json", "src.txt"], cwd=tmp_path, check=True)
1269
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1270
+ plan.write_text('{"features": [{"id": "F-001", "status": "completed"}]}', encoding="utf-8")
1271
+ (item_state / "status.json").write_text('{"last_session_id": "s1"}', encoding="utf-8")
1272
+ source.write_text("dirty source change", encoding="utf-8")
1273
+ family = RunnerFamily(
1274
+ kind="feature",
1275
+ id_prefix="F-",
1276
+ list_key="features",
1277
+ list_arg="--feature-list",
1278
+ item_id_arg="--feature-id",
1279
+ state_dir=state,
1280
+ plan_path=plan,
1281
+ updater_script=tmp_path / "update-feature-status.py",
1282
+ prompt_script=tmp_path / "generate-bootstrap-prompt.py",
1283
+ branch_prefix="dev",
1284
+ artifact_root_name="specs",
1285
+ )
1286
+
1287
+ result = commit_final_bookkeeping(tmp_path, family, "F-001", "completed")
1288
+ committed_files = subprocess.run(
1289
+ ["git", "show", "--name-only", "--format=", "HEAD"],
1290
+ cwd=tmp_path,
1291
+ check=True,
1292
+ stdout=subprocess.PIPE,
1293
+ text=True,
1294
+ ).stdout.splitlines()
1295
+ dirty_files = subprocess.run(
1296
+ ["git", "status", "--porcelain", "--", "src.txt"],
1297
+ cwd=tmp_path,
1298
+ check=True,
1299
+ stdout=subprocess.PIPE,
1300
+ text=True,
1301
+ ).stdout
1302
+
1303
+ assert result.committed is True
1304
+ assert committed_files == [".prizmkit/plans/feature-list.json", ".prizmkit/state/features/F-001/status.json"]
1305
+ assert dirty_files.startswith(" M src.txt")
1306
+
1307
+
959
1308
  def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outcome(monkeypatch, tmp_path):
960
1309
  from prizmkit_runtime import runners
961
1310
  from prizmkit_runtime.runner_models import family_for, parse_invocation
@@ -983,6 +1332,179 @@ def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outco
983
1332
  assert observed.cleanup_calls[0][1] is True
984
1333
 
985
1334
 
1335
+ def test_classification_accepts_late_infra_error_with_completed_checkpoint_and_commit(tmp_path):
1336
+ from prizmkit_runtime.runner_classification import classify_session
1337
+ from prizmkit_runtime.runner_models import PromptGenerationResult
1338
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1339
+ from prizmkit_runtime.status import ProgressSummary
1340
+
1341
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1342
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1343
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1344
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1345
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1346
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1347
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1348
+ (tmp_path / "done.txt").write_text("done", encoding="utf-8")
1349
+ subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
1350
+ subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1351
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
1352
+ artifact_dir.mkdir(parents=True)
1353
+ checkpoint = artifact_dir / "workflow-checkpoint.json"
1354
+ checkpoint.write_text(json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}), encoding="utf-8")
1355
+ log = tmp_path / "session.log"
1356
+ log.write_text("model quota exceeded; API error after commit", encoding="utf-8")
1357
+ raw = AISessionResult(
1358
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1359
+ exit_code=1,
1360
+ timed_out=False,
1361
+ termination_reason=None,
1362
+ stale_marker=None,
1363
+ fatal_error_code="api_error",
1364
+ progress_summary=ProgressSummary(terminal_result_text="quota exceeded"),
1365
+ session_log=log,
1366
+ backup_log=tmp_path / "backup.log",
1367
+ log_recovery=None,
1368
+ started_epoch=0,
1369
+ ended_epoch=1,
1370
+ )
1371
+ prompt = PromptGenerationResult(output_path=tmp_path / "prompt.md", checkpoint_path=checkpoint, artifact_path=artifact_dir)
1372
+
1373
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head, prompt=prompt)
1374
+
1375
+ assert classification.session_status == "success"
1376
+ assert classification.reason == "completed_checkpoint_with_late_runtime_error"
1377
+
1378
+
1379
+ def test_classification_rejects_late_infra_when_head_diff_has_no_commit_range(tmp_path):
1380
+ from prizmkit_runtime.runner_classification import classify_session
1381
+ from prizmkit_runtime.runner_models import PromptGenerationResult
1382
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1383
+ from prizmkit_runtime.status import ProgressSummary
1384
+
1385
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1386
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1387
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1388
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1389
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1390
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1391
+ subprocess.run(["git", "checkout", "-b", "side"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1392
+ (tmp_path / "side.txt").write_text("side", encoding="utf-8")
1393
+ subprocess.run(["git", "add", "side.txt"], cwd=tmp_path, check=True)
1394
+ subprocess.run(["git", "commit", "-m", "side"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1395
+ side_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1396
+ subprocess.run(["git", "checkout", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1397
+ (tmp_path / "main.txt").write_text("main", encoding="utf-8")
1398
+ subprocess.run(["git", "add", "main.txt"], cwd=tmp_path, check=True)
1399
+ subprocess.run(["git", "commit", "-m", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1400
+ subprocess.run(["git", "checkout", "side"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1401
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
1402
+ artifact_dir.mkdir(parents=True)
1403
+ (artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
1404
+ log = tmp_path / "session.log"
1405
+ log.write_text("quota exceeded", encoding="utf-8")
1406
+ raw = AISessionResult(
1407
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1408
+ exit_code=1,
1409
+ timed_out=False,
1410
+ termination_reason=None,
1411
+ stale_marker=None,
1412
+ fatal_error_code="api_error",
1413
+ progress_summary=ProgressSummary(terminal_result_text="quota exceeded"),
1414
+ session_log=log,
1415
+ backup_log=tmp_path / "backup.log",
1416
+ log_recovery=None,
1417
+ started_epoch=0,
1418
+ ended_epoch=1,
1419
+ )
1420
+ prompt = PromptGenerationResult(output_path=tmp_path / "prompt.md", artifact_path=artifact_dir)
1421
+
1422
+ classification = classify_session(raw, project_root=tmp_path, base_head=side_head, prompt=prompt)
1423
+
1424
+ assert classification.session_status == "infra_error"
1425
+
1426
+
1427
+ def test_classification_keeps_pre_completion_provider_error_as_infra_error(tmp_path):
1428
+ from prizmkit_runtime.runner_classification import classify_session
1429
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1430
+ from prizmkit_runtime.status import ProgressSummary
1431
+
1432
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1433
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1434
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1435
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1436
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1437
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1438
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1439
+ log = tmp_path / "session.log"
1440
+ log.write_text("authentication failed", encoding="utf-8")
1441
+ raw = AISessionResult(
1442
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1443
+ exit_code=1,
1444
+ timed_out=False,
1445
+ termination_reason=None,
1446
+ stale_marker=None,
1447
+ fatal_error_code="authentication_error",
1448
+ progress_summary=ProgressSummary(terminal_result_text="authentication failed"),
1449
+ session_log=log,
1450
+ backup_log=tmp_path / "backup.log",
1451
+ log_recovery=None,
1452
+ started_epoch=0,
1453
+ ended_epoch=1,
1454
+ )
1455
+
1456
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
1457
+
1458
+ assert classification.session_status == "infra_error"
1459
+ assert classification.reason == "ai_runtime_or_provider_error"
1460
+
1461
+
1462
+ def test_classification_accepts_terminal_success_with_completed_checkpoint_without_git_delta(tmp_path):
1463
+ from prizmkit_runtime.runner_classification import classify_session
1464
+ from prizmkit_runtime.runner_models import PromptGenerationResult
1465
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1466
+ from prizmkit_runtime.status import ProgressSummary
1467
+
1468
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1469
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1470
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1471
+ (tmp_path / ".gitignore").write_text(".prizmkit/*\n", encoding="utf-8")
1472
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1473
+ subprocess.run(["git", "add", ".gitignore", "base.txt"], cwd=tmp_path, check=True)
1474
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1475
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1476
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
1477
+ artifact_dir.mkdir(parents=True)
1478
+ checkpoint = artifact_dir / "workflow-checkpoint.json"
1479
+ checkpoint.write_text(json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}), encoding="utf-8")
1480
+ (artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
1481
+ log = tmp_path / "session.log"
1482
+ log.write_text("terminal success", encoding="utf-8")
1483
+ raw = AISessionResult(
1484
+ command=AISessionCommand(("fake",), None, "claude", "stdin"),
1485
+ exit_code=0,
1486
+ timed_out=False,
1487
+ termination_reason=None,
1488
+ stale_marker=None,
1489
+ fatal_error_code="",
1490
+ progress_summary=ProgressSummary(result_subtype="success", terminal_result_text="Completed; working tree clean"),
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(
1498
+ output_path=tmp_path / "prompt.md",
1499
+ checkpoint_path=checkpoint,
1500
+ artifact_path=artifact_dir,
1501
+ )
1502
+
1503
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head, prompt=prompt)
1504
+ assert classification.session_status == "success"
1505
+ assert classification.reason == "terminal_success_with_completed_checkpoint"
1506
+
1507
+
986
1508
  def test_classification_preserves_success_before_context_overflow(tmp_path):
987
1509
  from prizmkit_runtime.runner_classification import classify_session
988
1510
  from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
@@ -1021,6 +1543,43 @@ def test_classification_preserves_success_before_context_overflow(tmp_path):
1021
1543
  assert classification.reason == "commit_since_base"
1022
1544
 
1023
1545
 
1546
+ def test_classification_crash_with_commit_since_base_does_not_become_success(tmp_path):
1547
+ from prizmkit_runtime.runner_classification import classify_session
1548
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
1549
+ from prizmkit_runtime.status import ProgressSummary
1550
+
1551
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1552
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
1553
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
1554
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
1555
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
1556
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1557
+ base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1558
+ (tmp_path / "done.txt").write_text("done", encoding="utf-8")
1559
+ subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
1560
+ subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
1561
+ log = tmp_path / "session.log"
1562
+ log.write_text("hard failure", encoding="utf-8")
1563
+ raw = AISessionResult(
1564
+ command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
1565
+ exit_code=1,
1566
+ timed_out=False,
1567
+ termination_reason=None,
1568
+ stale_marker=None,
1569
+ fatal_error_code="",
1570
+ progress_summary=ProgressSummary(terminal_result_text="hard failure"),
1571
+ session_log=log,
1572
+ backup_log=tmp_path / "backup.log",
1573
+ log_recovery=None,
1574
+ started_epoch=0,
1575
+ ended_epoch=1,
1576
+ )
1577
+
1578
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
1579
+ assert classification.session_status == "crashed"
1580
+ assert classification.reason == "exit_code=1"
1581
+
1582
+
1024
1583
  def test_classification_stalls_after_repeated_no_progress_context_overflow(tmp_path):
1025
1584
  from prizmkit_runtime.runner_classification import classify_session, progress_fingerprint
1026
1585
  from prizmkit_runtime.sessions import AISessionCommand, AISessionResult