prizmkit 1.1.107 → 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 (78) 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_recovery.py +3 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +20 -1
  10. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +151 -30
  11. package/bundled/dev-pipeline/scripts/continuation.py +0 -1
  12. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +6 -46
  13. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +7 -34
  14. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +40 -33
  15. package/bundled/dev-pipeline/scripts/utils.py +123 -0
  16. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  17. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  18. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +8 -7
  19. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +25 -36
  20. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +16 -31
  21. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +4 -16
  22. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -15
  23. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  24. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -13
  25. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  26. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +0 -7
  27. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +0 -7
  28. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +0 -7
  29. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  30. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  31. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  32. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +0 -2
  33. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  34. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +6 -8
  35. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +3 -7
  36. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -7
  37. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +0 -7
  38. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  39. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +2 -9
  40. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -8
  41. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  42. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +0 -7
  43. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +0 -7
  44. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +2 -9
  45. package/bundled/dev-pipeline/templates/sections/phase0-init.md +0 -7
  46. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  47. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  48. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +350 -23
  49. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +36 -17
  50. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +117 -14
  51. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +79 -2
  52. package/bundled/dev-pipeline/tests/test_unified_cli.py +658 -13
  53. package/bundled/skills/_metadata.json +4 -4
  54. package/bundled/skills/app-planner/SKILL.md +1 -1
  55. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  56. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  57. package/bundled/skills/bug-planner/SKILL.md +101 -15
  58. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  59. package/bundled/skills/feature-planner/SKILL.md +87 -8
  60. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  61. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  62. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  63. package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
  64. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  65. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  66. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  67. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  68. package/bundled/templates/project-memory-template.md +1 -1
  69. package/package.json +1 -1
  70. package/src/ai-cli-launch.js +194 -0
  71. package/src/config.js +26 -14
  72. package/src/index.js +11 -44
  73. package/src/platforms.js +10 -3
  74. package/src/prompts.js +60 -2
  75. package/src/scaffold.js +26 -14
  76. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  77. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  78. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
@@ -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,7 +1019,7 @@ 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
 
662
- def test_compact_progress_line_includes_stale_and_switches_glyph(tmp_path):
1022
+ def test_compact_progress_line_hides_stale_until_no_progress(tmp_path):
663
1023
  from prizmkit_runtime.heartbeat import HeartbeatState
664
1024
  from prizmkit_runtime.sessions import _compact_progress_line
665
1025
 
@@ -679,10 +1039,14 @@ def test_compact_progress_line_includes_stale_and_switches_glyph(tmp_path):
679
1039
  encoding="utf-8",
680
1040
  )
681
1041
  running = HeartbeatState(last_seen_epoch=1.0, stale=False, details="progress", stale_seconds=0, log_size=4 * 1024 * 1024)
682
- paused = HeartbeatState(last_seen_epoch=1.0, stale=False, details="no progress", stale_seconds=120, 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)
683
1045
 
684
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)
685
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)
686
1050
 
687
1051
  assert "▶ [HEARTBEAT]" in running_line
688
1052
  assert "log: 4MB" in running_line
@@ -691,12 +1055,18 @@ def test_compact_progress_line_includes_stale_and_switches_glyph(tmp_path):
691
1055
  assert "tool: Read" in running_line
692
1056
  assert "msgs: 465" in running_line
693
1057
  assert "412 tool calls" in running_line
694
- assert "stale: 0m/15m" 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
695
1061
  assert "⏸ [HEARTBEAT]" in paused_line
696
- assert "stale: 2m/15m" 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
697
1066
 
698
1067
 
699
- def test_heartbeat_observation_stale_accumulates_and_resets_on_progress(tmp_path):
1068
+ def test_heartbeat_observation_stale_accumulates_by_elapsed_time_and_resets_on_progress(tmp_path, monkeypatch):
1069
+ import prizmkit_runtime.heartbeat as heartbeat
700
1070
  from prizmkit_runtime.heartbeat import HeartbeatConfig, observe_heartbeat_state
701
1071
 
702
1072
  session_log = tmp_path / "session.log"
@@ -710,19 +1080,164 @@ def test_heartbeat_observation_stale_accumulates_and_resets_on_progress(tmp_path
710
1080
  interval_seconds=60,
711
1081
  stale_after_seconds=15 * 60,
712
1082
  )
1083
+ ticks = iter([1000.0, 1025.0, 1090.0, 1095.0])
1084
+ monkeypatch.setattr(heartbeat.time, "time", lambda: next(ticks))
713
1085
 
714
1086
  first = observe_heartbeat_state(config)
715
1087
  stale = observe_heartbeat_state(config, first)
1088
+ older = observe_heartbeat_state(config, stale)
716
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")
717
- reset = observe_heartbeat_state(config, stale)
1090
+ reset = observe_heartbeat_state(config, older)
718
1091
 
719
1092
  assert first.details == "progress"
720
1093
  assert first.stale_seconds == 0
1094
+ assert first.last_seen_epoch == 1000.0
721
1095
  assert stale.details == "no progress"
722
- assert stale.stale_seconds == 60
1096
+ assert stale.stale_seconds == 25.0
1097
+ assert older.stale_seconds == 90.0
723
1098
  assert reset.details == "progress"
724
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
+
725
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"]
726
1241
 
727
1242
  def test_compact_live_heartbeat_stays_out_of_primary_and_backup_logs(tmp_path, capsys, monkeypatch):
728
1243
  import prizmkit_runtime.sessions as sessions
@@ -770,7 +1285,6 @@ def test_compact_live_heartbeat_stays_out_of_primary_and_backup_logs(tmp_path, c
770
1285
  primary_text = session_log.read_text(encoding="utf-8")
771
1286
  assert result.exit_code == 0
772
1287
  assert "[HEARTBEAT]" in captured.err
773
- assert "stale:" in captured.err
774
1288
  assert "logged-payload" in primary_text
775
1289
  assert "[HEARTBEAT]" not in primary_text
776
1290
  assert "stale:" not in primary_text
@@ -827,6 +1341,7 @@ def test_recovery_sessions_receive_live_heartbeat_configuration(tmp_path, monkey
827
1341
  "verbose": True,
828
1342
  "live_output": True,
829
1343
  "heartbeat_interval_seconds": 7,
1344
+ "heartbeat_stale_threshold_seconds": 21,
830
1345
  "stale_kill_threshold_seconds": 42,
831
1346
  },
832
1347
  )()
@@ -861,6 +1376,7 @@ def test_recovery_sessions_receive_live_heartbeat_configuration(tmp_path, monkey
861
1376
  assert config.verbose is True
862
1377
  assert config.use_stream_json is True
863
1378
  assert config.live_progress_interval_seconds == 7
1379
+ assert config.heartbeat_stale_threshold_seconds == 21
864
1380
  assert config.stale_kill_threshold_seconds == 42
865
1381
  assert config.live_banner is False
866
1382
  assert config.suppress_stream_output is True
@@ -1235,6 +1751,135 @@ def _init_temp_repo(repo):
1235
1751
  subprocess.run(["git", "commit", "-m", "base"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
1236
1752
 
1237
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
+
1238
1883
  def test_materialize_worktree_support_assets_copies_ignored_assets_without_retired_shell_sources(tmp_path):
1239
1884
  from prizmkit_runtime.gitops import (
1240
1885
  BranchContext,