prizmkit 1.1.107 → 1.1.109

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 (86) hide show
  1. package/bin/create-prizmkit.js +4 -0
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +20 -0
  4. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +1 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
  6. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +21 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
  8. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +1 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
  10. package/bundled/dev-pipeline/prizmkit_runtime/platform_detection.py +208 -0
  11. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
  12. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +3 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +20 -1
  14. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +508 -0
  15. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +151 -30
  16. package/bundled/dev-pipeline/scripts/continuation.py +0 -1
  17. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +8 -46
  18. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -34
  19. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +42 -33
  20. package/bundled/dev-pipeline/scripts/prizmkit-runtime-helper.py +27 -0
  21. package/bundled/dev-pipeline/scripts/utils.py +94 -0
  22. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  23. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  24. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +39 -65
  25. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +58 -96
  26. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +54 -95
  27. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -18
  28. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -17
  29. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  30. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -13
  31. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  32. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +31 -61
  33. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +21 -37
  34. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +22 -69
  35. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  36. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  37. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  38. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -3
  39. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  40. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +7 -9
  41. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +4 -8
  42. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -7
  43. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +0 -7
  44. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  45. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +3 -10
  46. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +2 -9
  47. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  48. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -8
  49. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -8
  50. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +9 -15
  51. package/bundled/dev-pipeline/templates/sections/phase0-init.md +1 -8
  52. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  53. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  54. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  55. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +392 -23
  56. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +37 -17
  57. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +118 -14
  58. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +79 -2
  59. package/bundled/dev-pipeline/tests/test_runtime_helper.py +211 -0
  60. package/bundled/dev-pipeline/tests/test_unified_cli.py +663 -13
  61. package/bundled/skills/_metadata.json +4 -4
  62. package/bundled/skills/app-planner/SKILL.md +1 -1
  63. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  64. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  65. package/bundled/skills/bug-planner/SKILL.md +101 -15
  66. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  67. package/bundled/skills/feature-planner/SKILL.md +87 -8
  68. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  69. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  70. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  71. package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
  72. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  73. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  74. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  75. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  76. package/bundled/templates/project-memory-template.md +1 -1
  77. package/package.json +1 -1
  78. package/src/ai-cli-launch.js +194 -0
  79. package/src/config.js +26 -14
  80. package/src/index.js +11 -44
  81. package/src/platforms.js +10 -3
  82. package/src/prompts.js +60 -2
  83. package/src/scaffold.js +26 -14
  84. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  85. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  86. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
@@ -7,7 +7,6 @@ from continuation import append_continuation_handoff
7
7
  from generate_refactor_prompt import (
8
8
  build_replacements,
9
9
  generate_refactor_checkpoint,
10
- load_log_size_section,
11
10
  merge_refactor_checkpoint_state,
12
11
  )
13
12
 
@@ -35,7 +34,7 @@ class TestRefactorContinuationHandoff:
35
34
 
36
35
  assert prompt.startswith("REFACTOR PROMPT")
37
36
  assert "This is a fresh headless AI CLI session for the same task." in prompt
38
- assert "Do not attempt `/compact` as the recovery mechanism." in prompt
37
+ assert "Do not attempt `/compact` as the recovery mechanism." not in prompt
39
38
  assert ("Log " + "monitor") not in prompt
40
39
  assert ("log-" + "monitor") not in prompt
41
40
  assert (tmp_path / ".prizmkit/specs/R-007/continuation-summary.md").exists()
@@ -54,19 +53,103 @@ class TestRefactorCheckpointMerge:
54
53
  assert merged["steps"][1]["status"] == "pending"
55
54
 
56
55
 
56
+ class TestExplicitProjectRoot:
57
+ def test_build_replacements_infers_project_root_from_refactor_list(self, tmp_path, monkeypatch):
58
+ monkeypatch.delenv("PRIZMKIT_PLATFORM", raising=False)
59
+ project = tmp_path / "project"
60
+ plan_path = project / ".prizmkit" / "plans" / "refactor-list.json"
61
+ plan_path.parent.mkdir(parents=True)
62
+ (project / ".prizmkit" / "config.json").write_text(
63
+ '{"platform":"claude","ai_cli":"claude"}', encoding="utf-8"
64
+ )
65
+ args = Namespace(
66
+ refactor_list=str(plan_path),
67
+ refactor_id="R-001",
68
+ session_id="session-1",
69
+ run_id="run-1",
70
+ retry_count="0",
71
+ resume_phase="null",
72
+ state_dir=str(project / ".prizmkit" / "state" / "refactor"),
73
+ output=str(project / "out.md"),
74
+ template=None,
75
+ mode=None,
76
+ critic=None,
77
+ )
78
+ refactor = {"id": "R-001", "title": "Restructure", "description": "d"}
79
+
80
+ replacements = build_replacements(
81
+ args, refactor, [refactor], {}, str(Path("dev-pipeline/scripts").resolve())
82
+ )
83
+
84
+ assert replacements["{{PROJECT_ROOT}}"] == str(project.resolve())
85
+ assert replacements["{{REVIEWER_SUBAGENT_PATH}}"].endswith(
86
+ ".claude/agents/prizm-dev-team-reviewer.md"
87
+ )
88
+ assert replacements["{{REVIEWER_SUBAGENT_PATH}}"].startswith(str(project.resolve()))
89
+ assert replacements["{{SESSION_STATUS_PATH}}"] == str(
90
+ project / ".prizmkit" / "state" / "refactor" / "R-001" /
91
+ "sessions" / "session-1" / "session-status.json"
92
+ )
93
+
94
+ def test_build_replacements_uses_cli_project_root_for_rendered_paths(self, tmp_path, monkeypatch):
95
+ monkeypatch.setenv("PRIZMKIT_PLATFORM", "claude")
96
+ execution_root = tmp_path / "worktree"
97
+ main_checkout = tmp_path / "main"
98
+ execution_root.mkdir()
99
+ main_checkout.mkdir()
100
+ args = Namespace(
101
+ refactor_list=str(main_checkout / ".prizmkit" / "plans" / "refactor-list.json"),
102
+ refactor_id="R-001",
103
+ session_id="session-1",
104
+ run_id="run-1",
105
+ retry_count="0",
106
+ resume_phase="null",
107
+ state_dir=str(main_checkout / ".prizmkit" / "state" / "refactor"),
108
+ output=str(main_checkout / ".prizmkit" / "state" / "refactor" / "R-001" / "sessions" / "session-1" / "bootstrap-prompt.md"),
109
+ template=None,
110
+ mode=None,
111
+ critic=None,
112
+ project_root=str(execution_root),
113
+ )
114
+ refactor = {"id": "R-001", "title": "Worktree Refactor", "description": "d"}
115
+
116
+ replacements = build_replacements(
117
+ args,
118
+ refactor,
119
+ [refactor],
120
+ {},
121
+ str(main_checkout / "dev-pipeline" / "scripts"),
122
+ )
123
+
124
+ assert replacements["{{PROJECT_ROOT}}"] == str(execution_root.resolve())
125
+ assert replacements["{{SESSION_STATUS_PATH}}"] == str(
126
+ main_checkout / ".prizmkit" / "state" / "refactor" / "R-001" /
127
+ "sessions" / "session-1" / "session-status.json"
128
+ )
129
+ assert not replacements["{{SESSION_STATUS_PATH}}"].startswith(str(execution_root.resolve()))
130
+ assert replacements["{{DEV_SUBAGENT_PATH}}"].startswith(str(execution_root.resolve()))
131
+ assert "### Checkpoint Update Helper" in replacements["{{CHECKPOINT_SYSTEM}}"]
132
+ assert "{{CHECKPOINT_PYTHON_CMD}}" in replacements["{{CHECKPOINT_SYSTEM}}"]
133
+ assert "pipeline progress mechanism" not in replacements["{{CHECKPOINT_SYSTEM}}"]
134
+ assert "prizmkit-runtime-helper.py" in replacements["{{RUNTIME_HELPER_CMD}}"]
135
+
136
+
57
137
  class TestRefactorPromptMaxLogRemoval:
58
- def test_refactor_log_size_section_is_checkpoint_guidance_without_max_log_env(self):
59
- content = load_log_size_section(str(Path("dev-pipeline/scripts").resolve()))
60
-
61
- assert "passive checkpoint guidance" in content
62
- assert "Headless recovery must rely on durable checkpoints" in content
63
- assert "fresh continuation session" in content
64
- assert REMOVED_MAX_LOG_ENV not in content
65
- assert REMOVED_MAX_LOG_HUMAN not in content
66
- assert ("maximum-" + "log-size") not in content
67
- assert ("log size " + "limit") not in content.lower()
68
-
69
- def test_refactor_replacements_keep_session_log_without_max_log_placeholders(self):
138
+ def test_refactor_source_has_no_deleted_log_size_injection(self):
139
+ deleted_section = "log-size-" + "awareness.md"
140
+ loader = "load_" + "log_size_section"
141
+ placeholder = "{{LOG_" + "SIZE_AWARENESS}}"
142
+ deleted_title = "Session " + "Checkpoint Awareness"
143
+ source = Path("dev-pipeline/scripts/generate-refactor-prompt.py").read_text(encoding="utf-8")
144
+ template = Path("dev-pipeline/templates/refactor-bootstrap-prompt.md").read_text(encoding="utf-8")
145
+
146
+ assert (Path("dev-pipeline/templates/sections") / deleted_section).exists() is False
147
+ for content in (source, template):
148
+ for forbidden in (deleted_section, loader, placeholder, deleted_title):
149
+ assert forbidden not in content
150
+
151
+ def test_refactor_replacements_keep_session_log_without_max_log_placeholders(self, monkeypatch):
152
+ monkeypatch.setenv("PRIZMKIT_PLATFORM", "claude")
70
153
  args = Namespace(
71
154
  refactor_id="R-001",
72
155
  session_id="session-1",
@@ -90,3 +173,24 @@ class TestRefactorPromptMaxLogRemoval:
90
173
  )
91
174
  assert "{{" + REMOVED_MAX_LOG_ENV + "}}" not in replacements
92
175
  assert "{{" + REMOVED_MAX_LOG_HUMAN + "}}" not in replacements
176
+ assert "{{LOG_" + "SIZE_AWARENESS}}" not in replacements
177
+
178
+
179
+ HEADLESS_FORBIDDEN_PROMPT_STRINGS = [
180
+ "### Interactive Claude Code Note",
181
+ "### Project Conventions",
182
+ "Scaffold / generated file awareness",
183
+ "### Resume Behavior",
184
+ "[NEEDS CLARIFICATION]",
185
+ "pause for interactive input",
186
+ "prizmkit-test-gate.py",
187
+ "/compact",
188
+ "Session " + "Checkpoint Awareness",
189
+ "{{LOG_" + "SIZE_AWARENESS}}",
190
+ ]
191
+
192
+
193
+ def test_template_source_is_clean_for_headless_prompt_contract():
194
+ content = Path("dev-pipeline/templates/refactor-bootstrap-prompt.md").read_text(encoding="utf-8")
195
+ for forbidden in HEADLESS_FORBIDDEN_PROMPT_STRINGS:
196
+ assert forbidden not in content
@@ -604,6 +604,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
604
604
 
605
605
  prompt_calls = []
606
606
  launch_cwds = []
607
+ launch_configs = []
607
608
  updates = []
608
609
  cleanup_calls = []
609
610
  merge_calls = []
@@ -621,6 +622,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
621
622
  )
622
623
  monkeypatch.setattr(runners, "detect_stream_json_support", lambda command, platform: SimpleNamespace(enabled=False))
623
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))
624
626
  monkeypatch.setattr(runners, "commit_pre_branch_bookkeeping", lambda *args, **kwargs: None)
625
627
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
626
628
  monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
@@ -652,6 +654,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
652
654
 
653
655
  def run(self):
654
656
  launch_cwds.append(self.config.cwd)
657
+ launch_configs.append(self.config)
655
658
  return SimpleNamespace(progress_summary=SimpleNamespace(), fatal_error_code="")
656
659
 
657
660
  def fake_classify(*args, **kwargs):
@@ -690,6 +693,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
690
693
  return SimpleNamespace(
691
694
  prompt_calls=prompt_calls,
692
695
  launch_cwds=launch_cwds,
696
+ launch_configs=launch_configs,
693
697
  updates=updates,
694
698
  cleanup_calls=cleanup_calls,
695
699
  merge_calls=merge_calls,
@@ -697,6 +701,36 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
697
701
  )
698
702
 
699
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
+
700
734
  @pytest.mark.parametrize(("kind", "item_id", "branch"), [("feature", "F-001", "dev/F-001-test"), ("bugfix", "B-001", "bugfix/B-001-test")])
701
735
  def test_feature_and_bugfix_explicit_worktree_launches_from_worktree_and_renders_worktree_root(monkeypatch, tmp_path, kind, item_id, branch):
702
736
  from prizmkit_runtime import runners
@@ -710,18 +744,26 @@ def test_feature_and_bugfix_explicit_worktree_launches_from_worktree_and_renders
710
744
  invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan if kind == "feature" else paths.bugfix_plan})
711
745
  observed = _install_runner_session_fakes(monkeypatch, runners)
712
746
  setup_calls = []
747
+ lifecycle_order = []
713
748
 
714
749
  def fake_setup(runtime):
750
+ lifecycle_order.append("worktree_setup")
715
751
  setup_calls.append(runtime)
716
752
  runtime.worktree_path.mkdir(parents=True, exist_ok=True)
717
753
  return SimpleNamespace(ok=True, runtime=runtime, reason="")
718
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)
719
760
  monkeypatch.setattr(runners, "ensure_linked_worktree", fake_setup, raising=False)
720
761
 
721
762
  status = runners._process_item(family, invocation, item_id, paths, initial_metadata={})
722
763
 
723
764
  expected_worktree = paths.state_dir / "worktrees" / branch.replace("/", "-")
724
765
  assert status == "completed"
766
+ assert lifecycle_order[:2] == ["preflight", "worktree_setup"]
725
767
  assert setup_calls[0].worktree_path == expected_worktree.resolve()
726
768
  assert observed.launch_cwds == [expected_worktree.resolve()]
727
769
  assert observed.prompt_calls[0]["project_root"] == paths.project_root
@@ -842,6 +884,7 @@ def test_explicit_worktree_materializes_ignored_support_assets_before_prompt_and
842
884
  )
843
885
  monkeypatch.setattr(runners, "detect_stream_json_support", lambda command, platform: SimpleNamespace(enabled=False))
844
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))
845
888
  monkeypatch.setattr(runners, "commit_pre_branch_bookkeeping", lambda *args, **kwargs: None)
846
889
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
847
890
  monkeypatch.setattr(runners, "_session_id", lambda item_id: f"{item_id}-session")
@@ -921,6 +964,11 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
921
964
  lifecycle_order.append("start_item")
922
965
  return SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"})
923
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)
924
972
  monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
925
973
  monkeypatch.setattr(runners, "start_item", fake_start_item)
926
974
  monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: (_ for _ in ()).throw(AssertionError("worktree setup should not run")), raising=False)
@@ -932,7 +980,7 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
932
980
  assert observed.prompt_calls[0].get("execution_root") == paths.project_root
933
981
  assert "branch_create" in plan_names
934
982
  assert "branch_merge" in plan_names
935
- assert lifecycle_order[:2] == ["branch_create", "start_item"]
983
+ assert lifecycle_order[:3] == ["preflight", "branch_create", "start_item"]
936
984
  assert branch_events == [("create", "dev/F-001-test"), ("merge", "dev/F-001-test")]
937
985
 
938
986
 
@@ -1024,6 +1072,7 @@ def test_no_worktree_recovers_completed_pending_branch_before_new_session(monkey
1024
1072
  monkeypatch.setattr(runners, "has_branch_completion_evidence", lambda *args, **kwargs: True)
1025
1073
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1026
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))
1027
1076
 
1028
1077
  def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1029
1078
  updates.append((session_status, session_id, metadata))
@@ -1076,6 +1125,7 @@ def test_worktree_recovers_completed_pending_branch_from_base_session_history(mo
1076
1125
  monkeypatch.setattr(runners, "generate_prompt", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not launch prompt")))
1077
1126
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1078
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))
1079
1129
 
1080
1130
  def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1081
1131
  updates.append((session_status, session_id, project_root, metadata))
@@ -1187,6 +1237,33 @@ def test_no_worktree_runner_ignores_recovery_side_effect_branch(monkeypatch, tmp
1187
1237
  assert observed.updates[-1][2]["base_branch"] == "main"
1188
1238
 
1189
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
+
1190
1267
  def test_foreground_runner_emits_structured_feature_progress(monkeypatch, tmp_path, capsys):
1191
1268
  from prizmkit_runtime import runners
1192
1269
  from prizmkit_runtime.runner_models import family_for, parse_invocation
@@ -1212,7 +1289,7 @@ def test_foreground_runner_emits_structured_feature_progress(monkeypatch, tmp_pa
1212
1289
  assert "────────────────────────────────────────────────────" in captured.err
1213
1290
  assert "Feature: F-001 — Readable output" in captured.err
1214
1291
  assert "Code retry: 1 / 3" in captured.err
1215
- 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
1216
1293
  assert "Pipeline mode: lite (Tier 1 — Single Agent)" in captured.err
1217
1294
  assert "Agents: 1 (critic: disabled)" in captured.err
1218
1295
  assert "Spawning AI CLI session: F-001-session-1" in captured.err
@@ -0,0 +1,211 @@
1
+ """Tests for the cross-platform prompt runtime helper CLI."""
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[2]
11
+ PIPELINE_ROOT = REPO_ROOT / "dev-pipeline"
12
+ HELPER_SCRIPT = PIPELINE_ROOT / "scripts" / "prizmkit-runtime-helper.py"
13
+ CLI_PATH = PIPELINE_ROOT / "cli.py"
14
+
15
+ if str(PIPELINE_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(PIPELINE_ROOT))
17
+
18
+
19
+ def run_helper(*args, cwd=None):
20
+ return subprocess.run(
21
+ [sys.executable, str(HELPER_SCRIPT), *args],
22
+ cwd=cwd or REPO_ROOT,
23
+ text=True,
24
+ stdout=subprocess.PIPE,
25
+ stderr=subprocess.PIPE,
26
+ check=False,
27
+ )
28
+
29
+
30
+ def first_line(result):
31
+ return result.stdout.splitlines()[0]
32
+
33
+
34
+ class TestArtifactAndTextHelpers:
35
+ def test_exists_ensure_dir_touch_list_tree_and_source_files(self, tmp_path):
36
+ missing = run_helper("artifact", "exists", str(tmp_path / "missing"))
37
+ assert first_line(missing) == "MISSING"
38
+
39
+ ready = run_helper("artifact", "ensure-dir", str(tmp_path / "src"))
40
+ assert first_line(ready) == "DIR_READY"
41
+ touched = run_helper("artifact", "touch", str(tmp_path / "src" / "app.py"))
42
+ assert first_line(touched) == "TOUCHED"
43
+ (tmp_path / "src" / "README.md").write_text("hello helper\n", encoding="utf-8")
44
+ (tmp_path / "src" / "ignored.bin").write_bytes(b"\x00")
45
+
46
+ listing = run_helper("artifact", "list", str(tmp_path / "src"))
47
+ assert first_line(listing) == "LIST"
48
+ assert "file:README.md" in listing.stdout
49
+ assert "file:app.py" in listing.stdout
50
+
51
+ tree = run_helper("artifact", "tree", str(tmp_path), "--max-depth", "2")
52
+ assert first_line(tree) == "TREE"
53
+ assert "entry: src/" in tree.stdout
54
+ assert "entry: src/app.py" in tree.stdout
55
+
56
+ sources = run_helper("artifact", "source-files", str(tmp_path), "--ext", ".py", "--json")
57
+ payload = json.loads(sources.stdout)
58
+ assert payload["marker"] == "SOURCE_FILES"
59
+ assert payload["files"] == ["src/app.py"]
60
+
61
+ def test_text_search_count_and_contains_markers(self, tmp_path):
62
+ target = tmp_path / "notes.md"
63
+ target.write_text("alpha\nbeta alpha\n", encoding="utf-8")
64
+
65
+ search = run_helper("text", "search", str(tmp_path), "--pattern", "alpha")
66
+ assert first_line(search) == "FOUND"
67
+ assert "count: 2" in search.stdout
68
+
69
+ count = run_helper("text", "count", str(tmp_path), "--pattern", "alpha")
70
+ assert first_line(count) == "COUNT"
71
+ assert "count: 2" in count.stdout
72
+
73
+ contains = run_helper("text", "contains", str(target), "--pattern", "beta")
74
+ assert first_line(contains) == "GATE:PASS"
75
+ missing = run_helper("text", "contains", str(target), "--pattern", "gamma")
76
+ assert first_line(missing) == "GATE:MISSING"
77
+
78
+
79
+ class TestPlatformExecutableAndPortHelpers:
80
+ def test_platform_detection_and_skill_lookup_follow_config(self, tmp_path, monkeypatch):
81
+ project = tmp_path / "project"
82
+ skill_dir = project / ".agents" / "skills" / "playwright-cli"
83
+ skill_dir.mkdir(parents=True)
84
+ (project / ".prizmkit").mkdir()
85
+ (project / ".prizmkit" / "config.json").write_text(
86
+ json.dumps({"platform": "codex", "ai_cli": "codex"}), encoding="utf-8"
87
+ )
88
+ monkeypatch.delenv("PRIZMKIT_PLATFORM", raising=False)
89
+
90
+ detect = run_helper("platform", "detect", "--project-root", str(project), "--json")
91
+ payload = json.loads(detect.stdout)
92
+ assert payload["marker"] == "CODEX"
93
+ assert payload["platform"] == "codex"
94
+ assert payload["source"] == "config:ai_cli"
95
+
96
+ skill = run_helper("platform", "skill", "playwright-cli", "--project-root", str(project), "--json")
97
+ payload = json.loads(skill.stdout)
98
+ assert payload["marker"] == "SKILL_EXISTS"
99
+ assert payload["platform"] == "codex"
100
+ assert payload["path"].endswith(".agents/skills/playwright-cli")
101
+
102
+ def test_codebuddy_cli_env_wins_over_filesystem_markers(self, tmp_path, monkeypatch):
103
+ from prizmkit_runtime.platform_detection import resolve_platform
104
+
105
+ project = tmp_path / "project"
106
+ (project / ".claude" / "agents").mkdir(parents=True)
107
+ monkeypatch.delenv("PRIZMKIT_PLATFORM", raising=False)
108
+ resolution = resolve_platform(project, env={"CODEBUDDY_CLI": "cbc"})
109
+
110
+ assert resolution.platform == "codebuddy"
111
+ assert resolution.source == "environment:CODEBUDDY_CLI"
112
+
113
+ def test_executable_check_and_version_use_shutil_and_direct_subprocess(self, tmp_path):
114
+ executable = tmp_path / "tool.py"
115
+ executable.write_text("#!/usr/bin/env python3\nprint('tool 1.2.3')\n", encoding="utf-8")
116
+ executable.chmod(0o755)
117
+ env_path = f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}"
118
+
119
+ result = subprocess.run(
120
+ [sys.executable, str(HELPER_SCRIPT), "executable", "check", "tool.py"],
121
+ env={**os.environ, "PATH": env_path},
122
+ text=True,
123
+ stdout=subprocess.PIPE,
124
+ stderr=subprocess.PIPE,
125
+ check=False,
126
+ )
127
+ assert first_line(result) == "INSTALLED"
128
+
129
+ version = subprocess.run(
130
+ [sys.executable, str(HELPER_SCRIPT), "executable", "version", "tool.py", "--arg=--version"],
131
+ env={**os.environ, "PATH": env_path},
132
+ text=True,
133
+ stdout=subprocess.PIPE,
134
+ stderr=subprocess.PIPE,
135
+ check=False,
136
+ )
137
+ assert first_line(version) == "VERSION"
138
+ assert "version: tool 1.2.3" in version.stdout
139
+
140
+ def test_port_check_reports_free_and_in_use(self):
141
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
142
+ sock.bind(("127.0.0.1", 0))
143
+ sock.listen(1)
144
+ port = sock.getsockname()[1]
145
+ try:
146
+ in_use = run_helper("port", "check", str(port))
147
+ assert first_line(in_use) == "PORT_IN_USE"
148
+ finally:
149
+ sock.close()
150
+ free = run_helper("port", "check", str(port))
151
+ assert first_line(free) == "PORT_FREE"
152
+
153
+
154
+ class TestProcessAndEntrypointHelpers:
155
+ def test_process_start_status_and_cleanup(self, tmp_path):
156
+ sleeper = tmp_path / "sleep.py"
157
+ sleeper.write_text("import time\ntime.sleep(30)\n", encoding="utf-8")
158
+ pid_file = tmp_path / "server.pid"
159
+
160
+ start = run_helper(
161
+ "process",
162
+ "start",
163
+ "--pid-file",
164
+ str(pid_file),
165
+ "--",
166
+ sys.executable,
167
+ str(sleeper),
168
+ )
169
+ assert first_line(start) == "PROCESS_STARTED"
170
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
171
+
172
+ status = run_helper("process", "status-pid", str(pid))
173
+ assert first_line(status) == "PROCESS_RUNNING"
174
+
175
+ cleanup = run_helper("process", "cleanup-pid", str(pid), "--grace-seconds", "0.2")
176
+ assert first_line(cleanup) in {"PROCESS_TERMINATED", "PROCESS_NOT_RUNNING"}
177
+
178
+ def test_cleanup_port_is_deterministic_without_lsof(self):
179
+ result = run_helper("process", "cleanup-port", "9")
180
+ assert first_line(result) in {"PORT_FREE", "UNSUPPORTED"}
181
+ assert "lsof" not in result.stdout.lower()
182
+
183
+ def test_canonical_cli_exposes_helper_group(self, tmp_path):
184
+ existing = tmp_path / "exists.txt"
185
+ existing.write_text("ok", encoding="utf-8")
186
+ result = subprocess.run(
187
+ [sys.executable, str(CLI_PATH), "helper", "artifact", "exists", str(existing)],
188
+ cwd=REPO_ROOT,
189
+ text=True,
190
+ stdout=subprocess.PIPE,
191
+ stderr=subprocess.PIPE,
192
+ check=False,
193
+ )
194
+ assert result.returncode == 0
195
+ assert first_line(result) == "EXISTS"
196
+
197
+ def test_web_wait_ready_accepts_local_http_server(self, tmp_path):
198
+ server = tmp_path / "server.py"
199
+ server.write_text(
200
+ "from http.server import HTTPServer, BaseHTTPRequestHandler\n"
201
+ "class H(BaseHTTPRequestHandler):\n"
202
+ " def do_GET(self):\n"
203
+ " self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n"
204
+ " def log_message(self, *args): pass\n"
205
+ "HTTPServer(('127.0.0.1', 0), H).serve_forever()\n",
206
+ encoding="utf-8",
207
+ )
208
+ # Use helper process start for coverage, then skip fixed URL because the
209
+ # child selects an ephemeral port that is not externally reported.
210
+ result = run_helper("web", "wait-ready", "--url", "http://127.0.0.1:9", "--timeout", "0.1", "--interval", "0.05")
211
+ assert first_line(result) == "NOT_READY"