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
@@ -171,12 +171,12 @@ def test_runtime_config_matches_canonical_ai_cli_precedence(tmp_path):
171
171
  pipeline = project / ".prizmkit" / "dev-pipeline"
172
172
  pipeline.mkdir(parents=True)
173
173
  prizmkit_dir = project / ".prizmkit"
174
- (prizmkit_dir / "config.json").write_text(json.dumps({"ai_cli": "config-cbc", "platform": "claude"}), encoding="utf-8")
174
+ (prizmkit_dir / "config.json").write_text(json.dumps({"ai_cli": "cbc", "platform": "claude"}), encoding="utf-8")
175
175
  (prizmkit_dir / "manifest.json").write_text(json.dumps({"platform": "codex"}), encoding="utf-8")
176
176
  paths = resolve_runtime_paths(pipeline_root=pipeline)
177
177
 
178
178
  config_cli = load_runtime_config(paths, env={"PATH": ""}).ai_client
179
- assert config_cli.command == "config-cbc"
179
+ assert config_cli.command == "cbc"
180
180
  assert config_cli.platform == "codebuddy"
181
181
  assert config_cli.source == "prizmkit-config:ai_cli"
182
182
 
@@ -195,6 +195,73 @@ def test_runtime_config_matches_canonical_ai_cli_precedence(tmp_path):
195
195
  assert legacy_alias.source == "environment:CODEBUDDY_CLI"
196
196
 
197
197
 
198
+ def test_runtime_config_resolves_custom_ai_cli_launch_profile(tmp_path):
199
+ from prizmkit_runtime.config import RuntimeConfigError, load_runtime_config
200
+ from prizmkit_runtime.paths import resolve_runtime_paths
201
+
202
+ project = tmp_path / "project"
203
+ pipeline = project / ".prizmkit" / "dev-pipeline"
204
+ pipeline.mkdir(parents=True)
205
+ prizmkit_dir = project / ".prizmkit"
206
+ (prizmkit_dir / "config.json").write_text(
207
+ json.dumps(
208
+ {
209
+ "ai_cli": "tclaude",
210
+ "ai_cli_launch": {
211
+ "profile": "custom",
212
+ "prompt_arg": "-i",
213
+ "extra_args": ["--verbose", "--path", "two words"],
214
+ },
215
+ }
216
+ ),
217
+ encoding="utf-8",
218
+ )
219
+ paths = resolve_runtime_paths(pipeline_root=pipeline)
220
+
221
+ config = load_runtime_config(paths, env={"PATH": ""})
222
+
223
+ assert config.ai_client.command == "tclaude"
224
+ assert config.ai_client.platform == "custom"
225
+ assert config.ai_client.source == "prizmkit-config:ai_cli_launch"
226
+ assert config.ai_client.launch_profile is not None
227
+ assert config.ai_client.launch_profile.prompt_arg == "-i"
228
+ assert config.ai_client.launch_profile.extra_args == ("--verbose", "--path", "two words")
229
+
230
+ (prizmkit_dir / "config.json").write_text(json.dumps({"ai_cli": "tclaude"}), encoding="utf-8")
231
+ with pytest.raises(RuntimeConfigError, match="prompt injection argument"):
232
+ load_runtime_config(paths, env={"PATH": ""})
233
+
234
+
235
+ (prizmkit_dir / "config.json").write_text(
236
+ json.dumps(
237
+ {
238
+ "ai_cli": "claude",
239
+ "ai_cli_launch": {"profile": "custom", "prompt_arg": "-i", "extra_args": ["--stale"]},
240
+ }
241
+ ),
242
+ encoding="utf-8",
243
+ )
244
+ built_in = load_runtime_config(paths, env={"PATH": ""}).ai_client
245
+ assert built_in.command == "claude"
246
+ assert built_in.platform == "claude"
247
+ assert built_in.source == "prizmkit-config:ai_cli"
248
+ assert built_in.launch_profile is None
249
+
250
+ (prizmkit_dir / "config.json").write_text(
251
+ json.dumps({"ai_cli": "tclaude", "ai_cli_launch": {"prompt_arg": "-i"}}),
252
+ encoding="utf-8",
253
+ )
254
+ with pytest.raises(RuntimeConfigError, match="profile"):
255
+ load_runtime_config(paths, env={"PATH": ""})
256
+
257
+
258
+ env_override = load_runtime_config(paths, env={"AI_CLI": "claude", "PATH": ""}).ai_client
259
+ assert env_override.command == "claude"
260
+ assert env_override.platform == "claude"
261
+ assert env_override.source == "environment:AI_CLI"
262
+ assert env_override.launch_profile is None
263
+
264
+
198
265
  def test_runtime_config_supplied_env_does_not_leak_ambient_path(tmp_path):
199
266
  from prizmkit_runtime.config import load_runtime_config
200
267
  from prizmkit_runtime.paths import resolve_runtime_paths
@@ -393,7 +460,7 @@ def test_interoperability_preserves_existing_utility_mapping():
393
460
  assert actual_scripts == mapped_scripts | explicitly_library_only
394
461
  assert explicitly_library_only == {"continuation.py", "utils.py"}
395
462
  assert "parse-stream-progress.py" in mapped_scripts
396
- assert "prizmkit-test-gate.py" in mapped_scripts
463
+ assert "prizmkit-test-gate.py" not in mapped_scripts
397
464
 
398
465
 
399
466
  def test_boundary_dataclasses_are_cohesive_and_frozen():
@@ -445,14 +512,14 @@ def test_runtime_config_loads_prizmkit_env_and_validates_effort(tmp_path):
445
512
  pipeline.mkdir(parents=True)
446
513
  prizmkit_dir = project / ".prizmkit"
447
514
  (prizmkit_dir / ".env").write_text(
448
- "AI_CLI=env-claude\nPRIZMKIT_EFFORT=max\nMODEL=env-model\n",
515
+ "AI_CLI=claude\nPRIZMKIT_EFFORT=max\nMODEL=env-model\n",
449
516
  encoding="utf-8",
450
517
  )
451
518
  paths = resolve_runtime_paths(pipeline_root=pipeline)
452
519
 
453
520
  config = load_runtime_config(paths, env={"PATH": ""})
454
521
 
455
- assert config.ai_client.command == "env-claude"
522
+ assert config.ai_client.command == "claude"
456
523
  assert config.ai_client.platform == "claude"
457
524
  assert config.ai_client.source == "environment:AI_CLI"
458
525
  assert config.model == "env-model"
@@ -541,6 +608,212 @@ def test_ai_session_command_builders_match_unix_runtime_snapshot(tmp_path):
541
608
  assert codebuddy.stdin_text == "hello"
542
609
 
543
610
 
611
+ def test_custom_ai_session_command_builder_injects_prompt_and_tokenizes_extra_args(tmp_path):
612
+ from prizmkit_runtime.launch_profiles import HeadlessLaunchProfile
613
+ from prizmkit_runtime.sessions import AISessionConfig, build_ai_session_command
614
+
615
+ prompt = tmp_path / "prompt.md"
616
+ prompt.write_text("injected prompt", encoding="utf-8")
617
+ project = tmp_path / "project"
618
+ project.mkdir()
619
+
620
+ command = build_ai_session_command(
621
+ AISessionConfig(
622
+ cli_command="tclaude",
623
+ platform="custom",
624
+ model="ignored-model",
625
+ prompt_path=prompt,
626
+ cwd=project,
627
+ effort="ignored-effort",
628
+ use_stream_json=True,
629
+ launch_profile=HeadlessLaunchProfile(
630
+ profile="custom",
631
+ prompt_arg="-i",
632
+ extra_args=("--verbose", "--path", "two words"),
633
+ ),
634
+ )
635
+ )
636
+
637
+ assert command.argv == ("tclaude", "-i", "injected prompt", "--verbose", "--path", "two words")
638
+ assert command.stdin_text is None
639
+ assert command.provider == "custom"
640
+ assert command.prompt_mode == "argument"
641
+
642
+
643
+ def test_start_command_serialization_redacts_prompt_and_preserves_provider_flags(tmp_path):
644
+ from prizmkit_runtime.launch_profiles import HeadlessLaunchProfile
645
+ from prizmkit_runtime.sessions import AISessionConfig, build_ai_session_command, format_start_command
646
+
647
+ prompt = tmp_path / "prompt.md"
648
+ secret_prompt = "secret prompt body with spaces"
649
+ prompt.write_text(secret_prompt, encoding="utf-8")
650
+ project = tmp_path / "project"
651
+ project.mkdir()
652
+
653
+ claude = format_start_command(
654
+ build_ai_session_command(
655
+ AISessionConfig(
656
+ cli_command="claude",
657
+ platform="claude",
658
+ model=None,
659
+ prompt_path=prompt,
660
+ cwd=project,
661
+ )
662
+ )
663
+ )
664
+ assert "claude -p <prompt>" in claude
665
+ assert "--disallowedTools EnterWorktree" in claude
666
+ assert secret_prompt not in claude
667
+
668
+ codebuddy = format_start_command(
669
+ build_ai_session_command(
670
+ AISessionConfig(
671
+ cli_command="cbc",
672
+ platform="codebuddy",
673
+ model=None,
674
+ prompt_path=prompt,
675
+ cwd=project,
676
+ )
677
+ )
678
+ )
679
+ assert codebuddy.startswith("cbc --print -y")
680
+ assert "--disallowedTools EnterWorktree" in codebuddy
681
+ assert secret_prompt not in codebuddy
682
+
683
+ codex = format_start_command(
684
+ build_ai_session_command(
685
+ AISessionConfig(
686
+ cli_command="codex",
687
+ platform="codex",
688
+ model=None,
689
+ prompt_path=prompt,
690
+ cwd=project,
691
+ )
692
+ )
693
+ )
694
+ assert "--profile no-worktree" in codex
695
+ assert "exec --cd" in codex
696
+ assert codex.endswith(" -")
697
+ assert secret_prompt not in codex
698
+
699
+ custom = format_start_command(
700
+ build_ai_session_command(
701
+ AISessionConfig(
702
+ cli_command="tclaude",
703
+ platform="custom",
704
+ model=None,
705
+ prompt_path=prompt,
706
+ cwd=project,
707
+ launch_profile=HeadlessLaunchProfile(
708
+ profile="custom",
709
+ prompt_arg="-i",
710
+ extra_args=("--custom-flag", "two words", secret_prompt),
711
+ ),
712
+ )
713
+ )
714
+ )
715
+ assert custom == "tclaude -i <prompt> --custom-flag 'two words' <prompt>"
716
+ assert custom.count("<prompt>") == 2
717
+ assert secret_prompt not in custom
718
+
719
+
720
+ def test_session_preamble_writes_redacted_start_command_for_all_launch_profiles(tmp_path, monkeypatch):
721
+ import prizmkit_runtime.sessions as sessions
722
+ from prizmkit_runtime.launch_profiles import HeadlessLaunchProfile
723
+
724
+ fake_cli = tmp_path / "fake_cli.py"
725
+ fake_cli.write_text(
726
+ "#!/usr/bin/env python3\n"
727
+ "import json, sys\n"
728
+ "_stdin = sys.stdin.read()\n"
729
+ "print(json.dumps({'type':'result','subtype':'success','usage':{'tokens':1},'result':'ok'}), flush=True)\n",
730
+ encoding="utf-8",
731
+ )
732
+ fake_cli.chmod(0o755)
733
+
734
+ backup_texts = {}
735
+ original_recover = sessions.recover_session_log
736
+
737
+ def spy_recover(primary, backup, min_size=1024):
738
+ if backup.is_file():
739
+ backup_texts[str(backup)] = backup.read_text(encoding="utf-8")
740
+ return original_recover(primary, backup, min_size)
741
+
742
+ monkeypatch.setattr(sessions, "recover_session_log", spy_recover)
743
+
744
+ cases = [
745
+ ("claude", "claude", None, "--disallowedTools EnterWorktree", "-p <prompt>"),
746
+ ("codebuddy", "codebuddy", None, "--disallowedTools EnterWorktree", "--print -y"),
747
+ ("codex", "codex", None, "--profile no-worktree", "exec --cd"),
748
+ (
749
+ "custom",
750
+ "custom",
751
+ HeadlessLaunchProfile(profile="custom", prompt_arg="-i", extra_args=("--custom-flag", "two words")),
752
+ "--custom-flag 'two words'",
753
+ "-i <prompt>",
754
+ ),
755
+ ]
756
+
757
+ for label, platform, launch_profile, expected_flag, expected_prompt_marker in cases:
758
+ secret_prompt = f"secret prompt body for {label}"
759
+ prompt = tmp_path / f"{label}-prompt.md"
760
+ prompt.write_text(secret_prompt, encoding="utf-8")
761
+ session_log = tmp_path / "state" / "sessions" / f"sess-{label}" / "logs" / "session.log"
762
+ backup_log = tmp_path / "home" / ".prizmkit" / "session-backups" / f"sess-{label}.log"
763
+
764
+ result = sessions.AISessionLauncher(
765
+ sessions.AISessionConfig(
766
+ cli_command=str(fake_cli),
767
+ platform=platform,
768
+ model=None,
769
+ prompt_path=prompt,
770
+ cwd=tmp_path,
771
+ log_path=session_log,
772
+ backup_log_path=backup_log,
773
+ launch_profile=launch_profile,
774
+ )
775
+ ).run()
776
+
777
+ assert result.exit_code == 0
778
+ primary_text = session_log.read_text(encoding="utf-8")
779
+ backup_text = backup_texts[str(backup_log)]
780
+ for text in (primary_text, backup_text):
781
+ lines = text.splitlines()
782
+ assert lines[0] == "=== PRIZMKIT SESSION START ==="
783
+ assert lines[1] == "Prompt: <prompt>"
784
+ assert lines[2] == "Model: default"
785
+ assert lines[3].startswith("StartCommand: ")
786
+ assert expected_flag in lines[3]
787
+ assert expected_prompt_marker in lines[3]
788
+ assert lines[4].startswith("Started at epoch: ")
789
+ assert lines[5] == "--- OUTPUT BELOW ---"
790
+ assert secret_prompt not in "\n".join(lines[:6])
791
+ assert str(prompt) not in "\n".join(lines[:6])
792
+
793
+
794
+ def test_headless_subagent_prompt_guidance_keeps_no_worktree_boundary_visible():
795
+ guidance_files = [
796
+ REPO_ROOT / "dev-pipeline" / "templates" / "sections" / "phase-critic-plan.md",
797
+ REPO_ROOT / "dev-pipeline" / "templates" / "sections" / "phase-critic-plan-full.md",
798
+ REPO_ROOT / "dev-pipeline" / "templates" / "agent-prompts" / "critic-plan-challenge.md",
799
+ REPO_ROOT / "dev-pipeline" / "templates" / "bootstrap-tier2.md",
800
+ REPO_ROOT / "dev-pipeline" / "templates" / "bootstrap-tier3.md",
801
+ REPO_ROOT / "core" / "skills" / "prizmkit-skill" / "prizmkit-code-review" / "SKILL.md",
802
+ ]
803
+
804
+ for path in guidance_files:
805
+ text = path.read_text(encoding="utf-8")
806
+ assert "default/no worktree" in text, path
807
+ assert "do not request worktree isolation" in text.lower(), path
808
+ assert ".claude/worktrees/..." in text, path
809
+ assert ".prizmkit/state/worktrees/..." in text, path
810
+ assert "USE_WORKTREE" in text, path
811
+
812
+ for path in guidance_files[:-1]:
813
+ text = path.read_text(encoding="utf-8")
814
+ assert "Read existing source files in the modules this plan touches" not in text, path
815
+
816
+
544
817
  def test_progress_parser_adds_required_metadata(tmp_path):
545
818
  import importlib.util
546
819
 
@@ -617,6 +890,93 @@ def test_session_dual_writes_progress_and_recovers_backup(tmp_path):
617
890
  assert second_log.read_text(encoding="utf-8") == "larger recovered log"
618
891
 
619
892
 
893
+ def test_custom_session_launch_writes_existing_logs_and_progress(tmp_path):
894
+ from prizmkit_runtime.launch_profiles import HeadlessLaunchProfile
895
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
896
+
897
+ fake_cli = tmp_path / "custom_cli.py"
898
+ fake_cli.write_text(
899
+ "#!/usr/bin/env python3\n"
900
+ "import json, sys\n"
901
+ "assert sys.argv[1] == '-i'\n"
902
+ "assert sys.argv[2] == 'hello custom'\n"
903
+ "assert sys.argv[3:] == ['--verbose', '--path', 'two words']\n"
904
+ "print(json.dumps({'type':'result','subtype':'success','usage':{'tokens':1},'result':'ok'}), flush=True)\n",
905
+ encoding="utf-8",
906
+ )
907
+ fake_cli.chmod(0o755)
908
+ prompt = tmp_path / "prompt.md"
909
+ prompt.write_text("hello custom", encoding="utf-8")
910
+ session_log = tmp_path / "state" / "sessions" / "sess-custom" / "logs" / "session.log"
911
+ progress = session_log.with_name("progress.json")
912
+
913
+ result = AISessionLauncher(
914
+ AISessionConfig(
915
+ cli_command=str(fake_cli),
916
+ platform="custom",
917
+ model=None,
918
+ prompt_path=prompt,
919
+ cwd=tmp_path,
920
+ log_path=session_log,
921
+ progress_path=progress,
922
+ launch_profile=HeadlessLaunchProfile(
923
+ profile="custom",
924
+ prompt_arg="-i",
925
+ extra_args=("--verbose", "--path", "two words"),
926
+ ),
927
+ )
928
+ ).run()
929
+
930
+ assert result.exit_code == 0
931
+ assert result.command.argv[1:4] == ("-i", "hello custom", "--verbose")
932
+ assert session_log.exists()
933
+ assert progress.exists()
934
+ progress_data = json.loads(progress.read_text(encoding="utf-8"))
935
+ assert progress_data["event_type_counts"]["result"] == 1
936
+ assert progress_data["has_usage"] is True
937
+
938
+
939
+ @pytest.mark.parametrize("platform", ["claude", "codex", "codebuddy"])
940
+ def test_builtin_provider_launches_write_session_artifacts(tmp_path, platform):
941
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
942
+
943
+ fake_cli = tmp_path / f"{platform}_cli.py"
944
+ fake_cli.write_text(
945
+ "#!/usr/bin/env python3\n"
946
+ "import json, sys\n"
947
+ "_stdin = sys.stdin.read()\n"
948
+ "print(json.dumps({'type':'result','subtype':'success','usage':{'tokens':1},'result':'ok'}), flush=True)\n",
949
+ encoding="utf-8",
950
+ )
951
+ fake_cli.chmod(0o755)
952
+ prompt = tmp_path / f"{platform}-prompt.md"
953
+ prompt.write_text(f"hello {platform}", encoding="utf-8")
954
+ session_log = tmp_path / "state" / "sessions" / f"sess-{platform}" / "logs" / "session.log"
955
+ progress = session_log.with_name("progress.json")
956
+ heartbeat = session_log.with_name("heartbeat.json")
957
+
958
+ result = AISessionLauncher(
959
+ AISessionConfig(
960
+ cli_command=str(fake_cli),
961
+ platform=platform,
962
+ model=None,
963
+ prompt_path=prompt,
964
+ cwd=tmp_path,
965
+ log_path=session_log,
966
+ progress_path=progress,
967
+ heartbeat_path=heartbeat,
968
+ use_stream_json=True,
969
+ )
970
+ ).run()
971
+
972
+ progress_data = json.loads(progress.read_text(encoding="utf-8"))
973
+ assert result.exit_code == 0
974
+ assert session_log.exists()
975
+ assert heartbeat.exists()
976
+ assert progress_data["event_type_counts"]["result"] == 1
977
+ assert progress_data["has_usage"] is True
978
+
979
+
620
980
  def test_session_live_output_prints_paths_and_verbose_cli_output(tmp_path, capsys):
621
981
  from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
622
982
 
@@ -659,6 +1019,369 @@ def test_session_live_output_prints_paths_and_verbose_cli_output(tmp_path, capsy
659
1019
  assert "[prizmkit] AI session finished" in captured.err
660
1020
 
661
1021
 
1022
+ def test_compact_progress_line_hides_stale_until_no_progress(tmp_path):
1023
+ from prizmkit_runtime.heartbeat import HeartbeatState
1024
+ from prizmkit_runtime.sessions import _compact_progress_line
1025
+
1026
+ session_log = tmp_path / "session.log"
1027
+ progress = tmp_path / "progress.json"
1028
+ progress.write_text(
1029
+ json.dumps(
1030
+ {
1031
+ "child_total_bytes": 2 * 1024 * 1024,
1032
+ "subagent_spawn_count": 5,
1033
+ "current_phase": "retrospective",
1034
+ "current_tool": "Read",
1035
+ "message_count": 465,
1036
+ "total_tool_calls": 412,
1037
+ }
1038
+ ),
1039
+ encoding="utf-8",
1040
+ )
1041
+ running = HeartbeatState(last_seen_epoch=1.0, stale=False, details="progress", stale_seconds=0, log_size=4 * 1024 * 1024)
1042
+ brief_pause = HeartbeatState(last_seen_epoch=1.0, stale=False, details="no progress", stale_seconds=125, log_size=4 * 1024 * 1024)
1043
+ paused = HeartbeatState(last_seen_epoch=1.0, stale=True, details="no progress", stale_seconds=15 * 60, log_size=4 * 1024 * 1024)
1044
+ resumed = HeartbeatState(last_seen_epoch=3.0, stale=False, details="progress", stale_seconds=0, log_size=5 * 1024 * 1024)
1045
+
1046
+ running_line = _compact_progress_line(0, session_log, progress, running, stale_threshold_seconds=15 * 60)
1047
+ brief_pause_line = _compact_progress_line(0, session_log, progress, brief_pause, stale_threshold_seconds=15 * 60)
1048
+ paused_line = _compact_progress_line(0, session_log, progress, paused, stale_threshold_seconds=15 * 60)
1049
+ resumed_line = _compact_progress_line(0, session_log, progress, resumed, stale_threshold_seconds=15 * 60)
1050
+
1051
+ assert "▶ [HEARTBEAT]" in running_line
1052
+ assert "log: 4MB" in running_line
1053
+ assert "child: 2MB/5" in running_line
1054
+ assert "phase: retrospective" in running_line
1055
+ assert "tool: Read" in running_line
1056
+ assert "msgs: 465" in running_line
1057
+ assert "412 tool calls" in running_line
1058
+ assert "stale:" not in running_line
1059
+ assert "▶ [HEARTBEAT]" in brief_pause_line
1060
+ assert "stale:" not in brief_pause_line
1061
+ assert "⏸ [HEARTBEAT]" in paused_line
1062
+ assert "stale: 15m/15m" in paused_line
1063
+ assert "▶ [HEARTBEAT]" in resumed_line
1064
+ assert "log: 5MB" in resumed_line
1065
+ assert "stale:" not in resumed_line
1066
+
1067
+
1068
+ def test_heartbeat_observation_stale_accumulates_by_elapsed_time_and_resets_on_progress(tmp_path, monkeypatch):
1069
+ import prizmkit_runtime.heartbeat as heartbeat
1070
+ from prizmkit_runtime.heartbeat import HeartbeatConfig, observe_heartbeat_state
1071
+
1072
+ session_log = tmp_path / "session.log"
1073
+ progress = tmp_path / "progress.json"
1074
+ session_log.write_text("initial\n", encoding="utf-8")
1075
+ progress.write_text('{"current_phase":"plan","current_tool":"Read","message_count":1,"total_tool_calls":1}\n', encoding="utf-8")
1076
+ config = HeartbeatConfig(
1077
+ heartbeat_file=tmp_path / "heartbeat.json",
1078
+ session_log=session_log,
1079
+ progress_file=progress,
1080
+ interval_seconds=60,
1081
+ stale_after_seconds=15 * 60,
1082
+ )
1083
+ ticks = iter([1000.0, 1025.0, 1090.0, 1095.0])
1084
+ monkeypatch.setattr(heartbeat.time, "time", lambda: next(ticks))
1085
+
1086
+ first = observe_heartbeat_state(config)
1087
+ stale = observe_heartbeat_state(config, first)
1088
+ older = observe_heartbeat_state(config, stale)
1089
+ progress.write_text('{"current_phase":"plan","current_tool":"Edit","message_count":1,"total_tool_calls":2,"child_total_bytes":10}\n', encoding="utf-8")
1090
+ reset = observe_heartbeat_state(config, older)
1091
+
1092
+ assert first.details == "progress"
1093
+ assert first.stale_seconds == 0
1094
+ assert first.last_seen_epoch == 1000.0
1095
+ assert stale.details == "no progress"
1096
+ assert stale.stale_seconds == 25.0
1097
+ assert older.stale_seconds == 90.0
1098
+ assert reset.details == "progress"
1099
+ assert reset.stale_seconds == 0
1100
+ assert reset.last_seen_epoch == 1095.0
1101
+
1102
+
1103
+
1104
+ def test_heartbeat_state_tracks_progress_json_child_activity(tmp_path, monkeypatch):
1105
+ import prizmkit_runtime.heartbeat as heartbeat
1106
+ from prizmkit_runtime.heartbeat import HeartbeatConfig, observe_heartbeat_state
1107
+
1108
+ progress = tmp_path / "progress.json"
1109
+ progress.write_text(
1110
+ json.dumps({"child_activity_signature": "child:1", "child_total_bytes": 10}),
1111
+ encoding="utf-8",
1112
+ )
1113
+ config = HeartbeatConfig(
1114
+ heartbeat_file=tmp_path / "heartbeat.json",
1115
+ session_log=tmp_path / "missing-session.log",
1116
+ progress_file=progress,
1117
+ stale_after_seconds=60,
1118
+ )
1119
+ ticks = iter([10.0, 20.0, 25.0])
1120
+ monkeypatch.setattr(heartbeat.time, "time", lambda: next(ticks))
1121
+
1122
+ first = observe_heartbeat_state(config)
1123
+ quiet = observe_heartbeat_state(config, first)
1124
+ progress.write_text(
1125
+ json.dumps({"child_activity_signature": "child:2", "child_total_bytes": 20}),
1126
+ encoding="utf-8",
1127
+ )
1128
+ child_progress = observe_heartbeat_state(config, quiet)
1129
+
1130
+ assert quiet.details == "no progress"
1131
+ assert quiet.stale_seconds == 10.0
1132
+ assert child_progress.details == "progress"
1133
+ assert child_progress.stale_seconds == 0
1134
+ assert child_progress.child_activity_signature == "child:2"
1135
+
1136
+
1137
+ def test_heartbeat_monitor_stale_kill_uses_elapsed_no_progress_threshold(tmp_path):
1138
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
1139
+
1140
+ fake_cli = tmp_path / "quiet_cli.py"
1141
+ fake_cli.write_text(
1142
+ "#!/usr/bin/env python3\n"
1143
+ "import time\n"
1144
+ "time.sleep(5)\n",
1145
+ encoding="utf-8",
1146
+ )
1147
+ fake_cli.chmod(0o755)
1148
+ prompt = tmp_path / "prompt.md"
1149
+ prompt.write_text("hello", encoding="utf-8")
1150
+ session_log = tmp_path / "state" / "sessions" / "sess-stale" / "logs" / "session.log"
1151
+ progress = session_log.with_name("progress.json")
1152
+ heartbeat = session_log.with_name("heartbeat.json")
1153
+
1154
+ result = AISessionLauncher(
1155
+ AISessionConfig(
1156
+ cli_command=str(fake_cli),
1157
+ platform="codebuddy",
1158
+ model=None,
1159
+ prompt_path=prompt,
1160
+ cwd=tmp_path,
1161
+ log_path=session_log,
1162
+ progress_path=progress,
1163
+ heartbeat_path=heartbeat,
1164
+ stale_kill_threshold_seconds=0.3,
1165
+ heartbeat_stale_threshold_seconds=0.2,
1166
+ stale_kill_grace_seconds=0.1,
1167
+ )
1168
+ ).run()
1169
+
1170
+ marker = session_log.parent / "stale-kill.json"
1171
+ marker_data = json.loads(marker.read_text(encoding="utf-8"))
1172
+ assert result.termination_reason == "stale_session"
1173
+ assert result.stale_marker == marker
1174
+ assert marker_data["reason"] == "stale_session"
1175
+ assert marker_data["threshold"] == pytest.approx(0.3)
1176
+ assert marker_data["stale_seconds"] >= 0
1177
+
1178
+
1179
+ def test_stale_marker_preserves_subsecond_threshold(tmp_path):
1180
+ from prizmkit_runtime.heartbeat import write_stale_marker
1181
+
1182
+ marker = write_stale_marker(
1183
+ tmp_path,
1184
+ reason="stale_session",
1185
+ stale_seconds=0.4,
1186
+ threshold=0.3,
1187
+ )
1188
+ data = json.loads(marker.read_text(encoding="utf-8"))
1189
+
1190
+ assert data["stale_seconds"] == pytest.approx(0.4)
1191
+ assert data["threshold"] == pytest.approx(0.3)
1192
+
1193
+
1194
+ def test_idle_progress_refresh_updates_child_activity_without_parent_output(tmp_path, monkeypatch):
1195
+ import prizmkit_runtime.sessions as sessions
1196
+
1197
+ progress = tmp_path / "progress.json"
1198
+ tracker_lock = sessions.threading.Lock()
1199
+ stop_event = sessions.threading.Event()
1200
+
1201
+ class FakeTracker:
1202
+ def __init__(self):
1203
+ self.calls = 0
1204
+
1205
+ def to_dict(self):
1206
+ self.calls += 1
1207
+ return {
1208
+ "message_count": 1,
1209
+ "event_type_counts": {},
1210
+ "current_tool": "Task",
1211
+ "current_phase": "implement",
1212
+ "total_tool_calls": 1,
1213
+ "active_subagent_count": 1,
1214
+ "subagent_spawn_count": 1,
1215
+ "child_activity_signature": f"child:{self.calls}",
1216
+ "child_total_bytes": self.calls,
1217
+ "fatal_error_code": "",
1218
+ "terminal_result_text": "",
1219
+ "errors": [],
1220
+ }
1221
+
1222
+ thread, refresh = sessions._start_idle_progress_refresh(
1223
+ progress,
1224
+ FakeTracker(),
1225
+ tracker_lock,
1226
+ stop_event,
1227
+ interval_seconds=0.1,
1228
+ )
1229
+ try:
1230
+ refresh(True)
1231
+ first = json.loads(progress.read_text(encoding="utf-8"))
1232
+ time.sleep(0.25)
1233
+ second = json.loads(progress.read_text(encoding="utf-8"))
1234
+ finally:
1235
+ stop_event.set()
1236
+ thread.join(timeout=1)
1237
+
1238
+ assert first["child_activity_signature"].startswith("child:")
1239
+ assert second["child_activity_signature"] != first["child_activity_signature"]
1240
+ assert second["child_total_bytes"] > first["child_total_bytes"]
1241
+
1242
+ def test_compact_live_heartbeat_stays_out_of_primary_and_backup_logs(tmp_path, capsys, monkeypatch):
1243
+ import prizmkit_runtime.sessions as sessions
1244
+
1245
+ fake_cli = tmp_path / "slow_cli.py"
1246
+ fake_cli.write_text(
1247
+ "#!/usr/bin/env python3\n"
1248
+ "import time\n"
1249
+ "print('logged-payload', flush=True)\n"
1250
+ "time.sleep(0.45)\n",
1251
+ encoding="utf-8",
1252
+ )
1253
+ fake_cli.chmod(0o755)
1254
+ prompt = tmp_path / "prompt.md"
1255
+ prompt.write_text("hello", encoding="utf-8")
1256
+ session_log = tmp_path / "state" / "sessions" / "sess-heartbeat" / "logs" / "session.log"
1257
+ backup_log = tmp_path / "home" / ".prizmkit" / "session-backups" / "sess-heartbeat.log"
1258
+ progress = session_log.with_name("progress.json")
1259
+ backup_text = {}
1260
+ original_recover = sessions.recover_session_log
1261
+
1262
+ def spy_recover(primary, backup, min_size=1024):
1263
+ if backup.is_file():
1264
+ backup_text["content"] = backup.read_text(encoding="utf-8")
1265
+ return original_recover(primary, backup, min_size)
1266
+
1267
+ monkeypatch.setattr(sessions, "recover_session_log", spy_recover)
1268
+ result = sessions.AISessionLauncher(
1269
+ sessions.AISessionConfig(
1270
+ cli_command=str(fake_cli),
1271
+ platform="codebuddy",
1272
+ model=None,
1273
+ prompt_path=prompt,
1274
+ cwd=tmp_path,
1275
+ log_path=session_log,
1276
+ backup_log_path=backup_log,
1277
+ progress_path=progress,
1278
+ live_output=True,
1279
+ live_progress_interval_seconds=0.1,
1280
+ stale_kill_threshold_seconds=15 * 60,
1281
+ )
1282
+ ).run()
1283
+
1284
+ captured = capsys.readouterr()
1285
+ primary_text = session_log.read_text(encoding="utf-8")
1286
+ assert result.exit_code == 0
1287
+ assert "[HEARTBEAT]" in captured.err
1288
+ assert "logged-payload" in primary_text
1289
+ assert "[HEARTBEAT]" not in primary_text
1290
+ assert "stale:" not in primary_text
1291
+ assert "[HEARTBEAT]" not in backup_text.get("content", "")
1292
+ assert "stale:" not in backup_text.get("content", "")
1293
+
1294
+
1295
+ def test_recovery_sessions_receive_live_heartbeat_configuration(tmp_path, monkeypatch):
1296
+ import prizmkit_runtime.runner_recovery as runner_recovery
1297
+
1298
+ project = tmp_path / "project"
1299
+ pipeline = tmp_path / "pipeline"
1300
+ recovery_state = tmp_path / "recovery"
1301
+ project.mkdir()
1302
+ pipeline.mkdir()
1303
+ recovery_state.mkdir()
1304
+ paths = type(
1305
+ "Paths",
1306
+ (),
1307
+ {"project_root": project, "pipeline_root": pipeline, "recovery_state_dir": recovery_state},
1308
+ )()
1309
+ captured_config = {}
1310
+
1311
+ monkeypatch.setattr(
1312
+ runner_recovery,
1313
+ "run_recovery_detect",
1314
+ lambda _paths: (0, "{}", "", {"detected": True, "workflow_type": "feature"}),
1315
+ )
1316
+ monkeypatch.setattr(
1317
+ runner_recovery,
1318
+ "load_runtime_config",
1319
+ lambda _paths: type(
1320
+ "Config",
1321
+ (),
1322
+ {
1323
+ "ai_client": type("Client", (), {"command": "fake-cli", "platform": "codebuddy"})(),
1324
+ "model": "model",
1325
+ "effort": "high",
1326
+ },
1327
+ )(),
1328
+ )
1329
+ monkeypatch.setattr(
1330
+ runner_recovery,
1331
+ "RunnerEnvironment",
1332
+ type(
1333
+ "EnvFactory",
1334
+ (),
1335
+ {
1336
+ "from_env": staticmethod(
1337
+ lambda: type(
1338
+ "Env",
1339
+ (),
1340
+ {
1341
+ "verbose": True,
1342
+ "live_output": True,
1343
+ "heartbeat_interval_seconds": 7,
1344
+ "heartbeat_stale_threshold_seconds": 21,
1345
+ "stale_kill_threshold_seconds": 42,
1346
+ },
1347
+ )()
1348
+ )
1349
+ },
1350
+ ),
1351
+ )
1352
+ monkeypatch.setattr(runner_recovery, "detect_stream_json_support", lambda *_args: type("Support", (), {"enabled": True})())
1353
+ monkeypatch.setattr(
1354
+ runner_recovery.subprocess,
1355
+ "run",
1356
+ lambda *_args, **_kwargs: type("Completed", (), {"returncode": 0, "stdout": "0", "stderr": ""})(),
1357
+ )
1358
+ monkeypatch.setattr(runner_recovery, "_recovery_outcome", lambda *_args: "no_changes")
1359
+
1360
+ class FakeLauncher:
1361
+ def __init__(self, config):
1362
+ captured_config["config"] = config
1363
+
1364
+ def run(self):
1365
+ return type("Result", (), {"exit_code": 0})()
1366
+
1367
+ monkeypatch.setattr(runner_recovery, "AISessionLauncher", FakeLauncher)
1368
+
1369
+ code, stdout, stderr = runner_recovery.run_recovery(paths)
1370
+ config = captured_config["config"]
1371
+
1372
+ assert code == 0
1373
+ assert stdout == "no_changes\n"
1374
+ assert stderr == ""
1375
+ assert config.live_output is True
1376
+ assert config.verbose is True
1377
+ assert config.use_stream_json is True
1378
+ assert config.live_progress_interval_seconds == 7
1379
+ assert config.heartbeat_stale_threshold_seconds == 21
1380
+ assert config.stale_kill_threshold_seconds == 42
1381
+ assert config.live_banner is False
1382
+ assert config.suppress_stream_output is True
1383
+
1384
+
662
1385
  def test_session_live_output_does_not_forward_cli_output_without_verbose(tmp_path, capsys):
663
1386
  from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
664
1387
 
@@ -1028,6 +1751,135 @@ def _init_temp_repo(repo):
1028
1751
  subprocess.run(["git", "commit", "-m", "base"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
1029
1752
 
1030
1753
 
1754
+ def _head_commit_message(repo):
1755
+ return subprocess.run(
1756
+ ["git", "log", "-1", "--format=%s"],
1757
+ cwd=repo,
1758
+ check=True,
1759
+ stdout=subprocess.PIPE,
1760
+ text=True,
1761
+ ).stdout.strip()
1762
+
1763
+
1764
+ def _head_files(repo):
1765
+ return set(
1766
+ subprocess.run(
1767
+ ["git", "show", "--name-only", "--format=", "HEAD"],
1768
+ cwd=repo,
1769
+ check=True,
1770
+ stdout=subprocess.PIPE,
1771
+ text=True,
1772
+ ).stdout.split()
1773
+ )
1774
+
1775
+
1776
+ def _short_status(repo):
1777
+ return subprocess.run(
1778
+ ["git", "status", "--short"],
1779
+ cwd=repo,
1780
+ check=True,
1781
+ stdout=subprocess.PIPE,
1782
+ text=True,
1783
+ ).stdout
1784
+
1785
+
1786
+ def test_preflight_ready_commit_preserves_dirty_unstaged_visible_changes(tmp_path):
1787
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
1788
+
1789
+ repo = tmp_path / "repo"
1790
+ _init_temp_repo(repo)
1791
+ (repo / "visible.txt").write_text("dirty\n", encoding="utf-8")
1792
+
1793
+ result = commit_preflight_ready_changes(repo, "F-001")
1794
+
1795
+ assert result.committed
1796
+ assert _head_commit_message(repo) == "chore: ready for F-001"
1797
+ assert _head_files(repo) == {"visible.txt"}
1798
+ assert _short_status(repo) == ""
1799
+
1800
+
1801
+ def test_preflight_ready_commit_preserves_dirty_staged_visible_changes(tmp_path):
1802
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
1803
+
1804
+ repo = tmp_path / "repo"
1805
+ _init_temp_repo(repo)
1806
+ (repo / "staged.txt").write_text("staged\n", encoding="utf-8")
1807
+ subprocess.run(["git", "add", "staged.txt"], cwd=repo, check=True)
1808
+
1809
+ result = commit_preflight_ready_changes(repo, "B-002")
1810
+
1811
+ assert result.committed
1812
+ assert _head_commit_message(repo) == "chore: ready for B-002"
1813
+ assert _head_files(repo) == {"staged.txt"}
1814
+ assert _short_status(repo) == ""
1815
+
1816
+
1817
+ def test_preflight_ready_commit_skips_clean_workspace(tmp_path):
1818
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
1819
+
1820
+ repo = tmp_path / "repo"
1821
+ _init_temp_repo(repo)
1822
+ before = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1823
+
1824
+ result = commit_preflight_ready_changes(repo, "R-003")
1825
+
1826
+ after = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1827
+ assert not result.attempted
1828
+ assert not result.committed
1829
+ assert after == before
1830
+
1831
+
1832
+ def test_preflight_ready_commit_skips_hidden_support_asset_only_changes(tmp_path):
1833
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
1834
+
1835
+ repo = tmp_path / "repo"
1836
+ _init_temp_repo(repo)
1837
+ (repo / ".claude").mkdir()
1838
+ (repo / ".claude" / "settings.local.json").write_text("{}\n", encoding="utf-8")
1839
+ before = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1840
+
1841
+ result = commit_preflight_ready_changes(repo, "F-004")
1842
+
1843
+ after = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
1844
+ assert not result.attempted
1845
+ assert after == before
1846
+ assert (repo / ".claude" / "settings.local.json").is_file()
1847
+
1848
+
1849
+ def test_preflight_ready_commit_excludes_already_staged_hidden_support_assets(tmp_path):
1850
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
1851
+
1852
+ repo = tmp_path / "repo"
1853
+ _init_temp_repo(repo)
1854
+ (repo / ".claude").mkdir()
1855
+ (repo / ".claude" / "agent.md").write_text("hidden\n", encoding="utf-8")
1856
+ subprocess.run(["git", "add", "-f", ".claude/agent.md"], cwd=repo, check=True)
1857
+ (repo / "visible.txt").write_text("visible\n", encoding="utf-8")
1858
+
1859
+ result = commit_preflight_ready_changes(repo, "F-005")
1860
+
1861
+ assert result.committed
1862
+ assert _head_commit_message(repo) == "chore: ready for F-005"
1863
+ assert _head_files(repo) == {"visible.txt"}
1864
+ assert "A .claude/agent.md" in _short_status(repo)
1865
+
1866
+
1867
+ def test_preflight_ready_commit_excludes_ignored_tool_worktree_directories(tmp_path):
1868
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
1869
+
1870
+ repo = tmp_path / "repo"
1871
+ _init_temp_repo(repo)
1872
+ (repo / ".cache" / "worktrees" / "agent").mkdir(parents=True)
1873
+ (repo / ".cache" / "worktrees" / "agent" / "artifact.txt").write_text("tool\n", encoding="utf-8")
1874
+ (repo / "visible.txt").write_text("visible\n", encoding="utf-8")
1875
+
1876
+ result = commit_preflight_ready_changes(repo, "F-006")
1877
+
1878
+ assert result.committed
1879
+ assert _head_files(repo) == {"visible.txt"}
1880
+ assert (repo / ".cache" / "worktrees" / "agent" / "artifact.txt").is_file()
1881
+
1882
+
1031
1883
  def test_materialize_worktree_support_assets_copies_ignored_assets_without_retired_shell_sources(tmp_path):
1032
1884
  from prizmkit_runtime.gitops import (
1033
1885
  BranchContext,