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.
- package/bin/create-prizmkit.js +4 -0
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
- package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
- package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
- package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +56 -5
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
- package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +11 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +298 -63
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +243 -37
- package/bundled/dev-pipeline/scripts/continuation.py +0 -5
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +10 -82
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +7 -64
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +40 -63
- package/bundled/dev-pipeline/scripts/utils.py +123 -0
- package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
- package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +36 -35
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +53 -80
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +43 -78
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +5 -17
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +6 -18
- package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -14
- package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
- package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +0 -2
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +6 -8
- package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +3 -7
- package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -16
- package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -17
- package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +2 -9
- package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -8
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -10
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -10
- package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +2 -9
- package/bundled/dev-pipeline/templates/sections/phase0-init.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
- package/bundled/dev-pipeline/tests/conftest.py +0 -1
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +395 -21
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +50 -14
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +154 -2
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +431 -3
- package/bundled/dev-pipeline/tests/test_unified_cli.py +857 -5
- package/bundled/skills/_metadata.json +4 -4
- package/bundled/skills/app-planner/SKILL.md +1 -1
- package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
- package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
- package/bundled/skills/bug-planner/SKILL.md +101 -15
- package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
- package/bundled/skills/feature-planner/SKILL.md +87 -8
- package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
- package/bundled/skills/feature-workflow/SKILL.md +100 -81
- package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
- package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
- package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
- package/bundled/skills/refactor-planner/SKILL.md +88 -7
- package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
- package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
- package/bundled/skills/refactor-workflow/SKILL.md +116 -57
- package/bundled/templates/project-memory-template.md +1 -1
- package/package.json +1 -1
- package/src/ai-cli-launch.js +194 -0
- package/src/config.js +26 -14
- package/src/index.js +11 -44
- package/src/platforms.js +10 -3
- package/src/prompts.js +60 -2
- package/src/scaffold.js +26 -14
- package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
- package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
- package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
- package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
- package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
- 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": "
|
|
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 == "
|
|
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=
|
|
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 == "
|
|
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,
|