prizmkit 1.1.107 → 1.1.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bin/create-prizmkit.js +4 -0
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +20 -0
  4. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +1 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
  6. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +21 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
  8. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +1 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
  10. package/bundled/dev-pipeline/prizmkit_runtime/platform_detection.py +208 -0
  11. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
  12. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +3 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +20 -1
  14. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +508 -0
  15. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +151 -30
  16. package/bundled/dev-pipeline/scripts/continuation.py +0 -1
  17. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +8 -46
  18. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -34
  19. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +42 -33
  20. package/bundled/dev-pipeline/scripts/prizmkit-runtime-helper.py +27 -0
  21. package/bundled/dev-pipeline/scripts/utils.py +94 -0
  22. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  23. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  24. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +39 -65
  25. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +58 -96
  26. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +54 -95
  27. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -18
  28. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -17
  29. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  30. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -13
  31. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  32. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +31 -61
  33. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +21 -37
  34. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +22 -69
  35. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  36. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  37. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  38. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -3
  39. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  40. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +7 -9
  41. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +4 -8
  42. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -7
  43. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +0 -7
  44. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  45. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +3 -10
  46. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +2 -9
  47. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  48. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -8
  49. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -8
  50. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +9 -15
  51. package/bundled/dev-pipeline/templates/sections/phase0-init.md +1 -8
  52. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  53. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  54. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  55. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +392 -23
  56. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +37 -17
  57. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +118 -14
  58. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +79 -2
  59. package/bundled/dev-pipeline/tests/test_runtime_helper.py +211 -0
  60. package/bundled/dev-pipeline/tests/test_unified_cli.py +663 -13
  61. package/bundled/skills/_metadata.json +4 -4
  62. package/bundled/skills/app-planner/SKILL.md +1 -1
  63. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  64. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  65. package/bundled/skills/bug-planner/SKILL.md +101 -15
  66. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  67. package/bundled/skills/feature-planner/SKILL.md +87 -8
  68. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  69. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  70. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  71. package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
  72. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  73. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  74. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  75. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  76. package/bundled/templates/project-memory-template.md +1 -1
  77. package/package.json +1 -1
  78. package/src/ai-cli-launch.js +194 -0
  79. package/src/config.js +26 -14
  80. package/src/index.js +11 -44
  81. package/src/platforms.js +10 -3
  82. package/src/prompts.js +60 -2
  83. package/src/scaffold.js +26 -14
  84. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  85. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  86. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
@@ -6,6 +6,7 @@ import importlib.util
6
6
  import json
7
7
  import os
8
8
  import queue
9
+ import shlex
9
10
  import shutil
10
11
  import subprocess
11
12
  import sys
@@ -17,6 +18,7 @@ from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
 
19
20
  from .heartbeat import HeartbeatConfig, HeartbeatMonitor, HeartbeatState, observe_heartbeat_state, write_stale_marker
21
+ from .launch_profiles import HeadlessLaunchProfile
20
22
  from .processes import ProcessHandle, ProcessSpec, launch_process, terminate_process_tree
21
23
  from .status import ProgressSummary
22
24
 
@@ -52,12 +54,14 @@ class AISessionConfig:
52
54
  permission_mode: str = "dangerously-skip-permissions"
53
55
  total_timeout_seconds: float | None = None
54
56
  stale_kill_threshold_seconds: float = 0
57
+ heartbeat_stale_threshold_seconds: float = 0
55
58
  stale_kill_grace_seconds: float = 10
56
59
  live_progress_interval_seconds: float = 30
57
60
  live_banner: bool = True
58
61
  suppress_stream_output: bool = False
59
62
  codex_subagent_timeout_seconds: int = 3300
60
63
  extra_args: tuple[str, ...] = ()
64
+ launch_profile: HeadlessLaunchProfile | None = None
61
65
  environment_overrides: Mapping[str, str] = field(default_factory=dict)
62
66
 
63
67
 
@@ -69,6 +73,7 @@ class AISessionCommand:
69
73
  stdin_text: str | None
70
74
  provider: str
71
75
  prompt_mode: str
76
+ prompt_text: str | None = None
72
77
 
73
78
 
74
79
  @dataclass(frozen=True)
@@ -106,7 +111,7 @@ HelpRunner = Callable[[Sequence[str]], str]
106
111
 
107
112
  def _normalize_platform(platform: str | None) -> str:
108
113
  normalized = (platform or "").strip().lower()
109
- return normalized if normalized in {"claude", "codex", "codebuddy"} else "codebuddy"
114
+ return normalized if normalized in {"claude", "codex", "codebuddy", "custom"} else "codebuddy"
110
115
 
111
116
 
112
117
  def _prompt_text(path: Path) -> str:
@@ -156,6 +161,11 @@ def build_ai_session_command(config: AISessionConfig) -> AISessionCommand:
156
161
  """Build provider-specific AI CLI argv without spawning a process."""
157
162
  platform = _normalize_platform(config.platform)
158
163
  prompt = _prompt_text(config.prompt_path)
164
+ if config.launch_profile is not None and config.launch_profile.is_custom:
165
+ argv = [config.cli_command, config.launch_profile.prompt_arg, prompt]
166
+ argv.extend(config.launch_profile.extra_args)
167
+ argv.extend(config.extra_args)
168
+ return AISessionCommand(tuple(argv), None, "custom", "argument", prompt)
159
169
  if platform == "claude":
160
170
  argv = [
161
171
  config.cli_command,
@@ -174,7 +184,7 @@ def build_ai_session_command(config: AISessionConfig) -> AISessionCommand:
174
184
  if config.model:
175
185
  argv.extend(["--model", config.model])
176
186
  argv.extend(config.extra_args)
177
- return AISessionCommand(tuple(argv), None, "claude", "argument")
187
+ return AISessionCommand(tuple(argv), None, "claude", "argument", prompt)
178
188
 
179
189
  if platform == "codex":
180
190
  argv = [
@@ -197,7 +207,7 @@ def build_ai_session_command(config: AISessionConfig) -> AISessionCommand:
197
207
  argv.extend(["--config", f"model_reasoning_effort={config.effort}"])
198
208
  argv.extend(config.extra_args)
199
209
  argv.append("-")
200
- return AISessionCommand(tuple(argv), prompt, "codex", "stdin")
210
+ return AISessionCommand(tuple(argv), prompt, "codex", "stdin", prompt)
201
211
 
202
212
  argv = [config.cli_command, "--print", "-y", "--disallowedTools", "EnterWorktree"]
203
213
  if config.verbose:
@@ -209,7 +219,18 @@ def build_ai_session_command(config: AISessionConfig) -> AISessionCommand:
209
219
  if config.model:
210
220
  argv.extend(["--model", config.model])
211
221
  argv.extend(config.extra_args)
212
- return AISessionCommand(tuple(argv), prompt, "codebuddy", "stdin")
222
+ return AISessionCommand(tuple(argv), prompt, "codebuddy", "stdin", prompt)
223
+
224
+
225
+ def format_start_command(command: AISessionCommand) -> str:
226
+ """Return a readable command string with the injected prompt redacted."""
227
+ redacted: list[str] = []
228
+ for token in command.argv:
229
+ if command.prompt_text is not None and token == command.prompt_text:
230
+ redacted.append("<prompt>")
231
+ else:
232
+ redacted.append(token)
233
+ return " ".join("<prompt>" if token == "<prompt>" else shlex.quote(token) for token in redacted)
213
234
 
214
235
 
215
236
  def default_backup_log_path(session_log: Path, home: Path | None = None) -> Path:
@@ -220,15 +241,16 @@ def default_backup_log_path(session_log: Path, home: Path | None = None) -> Path
220
241
  return root / ".prizmkit" / "session-backups" / f"{session_id}.log"
221
242
 
222
243
 
223
- def write_session_preamble(session_log: Path, backup_log: Path, prompt_path: Path, model: str | None) -> None:
244
+ def write_session_preamble(session_log: Path, backup_log: Path, command: AISessionCommand, model: str | None) -> None:
224
245
  """Write the session preamble to both primary and backup logs."""
225
246
  session_log.parent.mkdir(parents=True, exist_ok=True)
226
247
  backup_log.parent.mkdir(parents=True, exist_ok=True)
227
248
  preamble = "\n".join(
228
249
  [
229
250
  "=== PRIZMKIT SESSION START ===",
230
- f"Prompt: {prompt_path}",
251
+ "Prompt: <prompt>",
231
252
  f"Model: {model or 'default'}",
253
+ f"StartCommand: {format_start_command(command)}",
232
254
  f"Started at epoch: {time.time():.6f}",
233
255
  "--- OUTPUT BELOW ---",
234
256
  "",
@@ -368,6 +390,60 @@ def _write_progress_summary(progress_path: Path | None, summary: ProgressSummary
368
390
  progress_path.write_text(json.dumps(summary.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
369
391
 
370
392
 
393
+ def _progress_refresh_key(data: Mapping[str, object]) -> tuple[object, ...]:
394
+ event_counts = data.get("event_type_counts")
395
+ return (
396
+ int(data.get("message_count") or 0),
397
+ tuple(sorted(event_counts.items())) if isinstance(event_counts, Mapping) else (),
398
+ str(data.get("current_tool") or ""),
399
+ str(data.get("current_phase") or ""),
400
+ int(data.get("total_tool_calls") or 0),
401
+ int(data.get("active_subagent_count") or 0),
402
+ int(data.get("subagent_spawn_count") or 0),
403
+ str(data.get("child_activity_signature") or ""),
404
+ int(data.get("child_total_bytes") or 0),
405
+ str(data.get("fatal_error_code") or ""),
406
+ str(data.get("terminal_result_text") or ""),
407
+ tuple(str(item) for item in data.get("errors", []) if item) if isinstance(data.get("errors"), list) else (),
408
+ )
409
+
410
+
411
+ def _write_progress_data(progress_path: Path, data: Mapping[str, object]) -> None:
412
+ progress_path.parent.mkdir(parents=True, exist_ok=True)
413
+ progress_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
414
+
415
+
416
+ def _start_idle_progress_refresh(
417
+ progress_path: Path,
418
+ tracker: object,
419
+ tracker_lock: threading.Lock,
420
+ stop_event: threading.Event,
421
+ interval_seconds: float = 1.0,
422
+ ) -> tuple[threading.Thread, Callable[[bool], None]]:
423
+ """Refresh progress.json from child activity while parent output is idle."""
424
+ last_key: tuple[object, ...] | None = None
425
+
426
+ def maybe_write(force: bool = False) -> None:
427
+ nonlocal last_key
428
+ with tracker_lock:
429
+ data = tracker.to_dict()
430
+ key = _progress_refresh_key(data)
431
+ if force or key != last_key:
432
+ _write_progress_data(progress_path, data)
433
+ last_key = key
434
+
435
+ def refresh_loop() -> None:
436
+ while not stop_event.wait(max(0.1, interval_seconds)):
437
+ try:
438
+ maybe_write(False)
439
+ except Exception:
440
+ continue
441
+
442
+ thread = threading.Thread(target=refresh_loop, name="prizmkit-progress-refresh", daemon=True)
443
+ thread.start()
444
+ return thread, maybe_write
445
+
446
+
371
447
  def _emit_live_line(message: str) -> None:
372
448
  """Print foreground runner status without interfering with file logging."""
373
449
  print(message, file=sys.stderr, flush=True)
@@ -394,7 +470,13 @@ def _format_elapsed(seconds: float) -> str:
394
470
 
395
471
 
396
472
  def _format_stale_minutes(seconds: float) -> str:
397
- return f"{max(0, int(seconds)) // 60}m"
473
+ total = max(0, int(seconds))
474
+ minutes, remainder = divmod(total, 60)
475
+ if minutes == 0:
476
+ return f"{remainder}s"
477
+ if remainder == 0:
478
+ return f"{minutes}m"
479
+ return f"{minutes}m{remainder:02d}s"
398
480
 
399
481
 
400
482
  def _read_json_object(path: Path | None) -> dict[str, object]:
@@ -447,8 +529,13 @@ def _compact_progress_line(
447
529
  log_size = session_log.stat().st_size
448
530
  except OSError:
449
531
  log_size = 0
450
- glyph = "⏸" if heartbeat_state is not None and heartbeat_state.details == "no progress" else "▶"
451
532
  stale_seconds = heartbeat_state.stale_seconds if heartbeat_state is not None else 0
533
+ show_stale = bool(
534
+ heartbeat_state is not None
535
+ and heartbeat_state.details == "no progress"
536
+ and (heartbeat_state.stale or (stale_threshold_seconds > 0 and stale_seconds >= stale_threshold_seconds))
537
+ )
538
+ glyph = "⏸" if show_stale else "▶"
452
539
  parts = [
453
540
  f" {glyph} [HEARTBEAT] {_format_elapsed(time.time() - started_epoch)}",
454
541
  f"log: {_format_bytes(log_size)}",
@@ -472,7 +559,9 @@ def _compact_progress_line(
472
559
  tool_calls = int(data.get("total_tool_calls") or 0)
473
560
  if tool_calls:
474
561
  parts.append(f"{tool_calls} tool calls")
475
- parts.append(f"stale: {_format_stale_minutes(stale_seconds)}/{_format_stale_minutes(stale_threshold_seconds)}")
562
+ if show_stale:
563
+ threshold = f"/{_format_stale_minutes(stale_threshold_seconds)}" if stale_threshold_seconds > 0 else ""
564
+ parts.append(f"stale: {_format_stale_minutes(stale_seconds)}{threshold}")
476
565
  return " | ".join(parts)
477
566
 
478
567
 
@@ -539,12 +628,23 @@ def _reader_thread(
539
628
  ) -> None:
540
629
  lines: list[str] = []
541
630
  tracker = None
631
+ tracker_lock = threading.Lock()
632
+ progress_refresh_stop = threading.Event()
633
+ progress_refresh_thread: threading.Thread | None = None
634
+ refresh_progress: Callable[[bool], None] | None = None
542
635
  tracker_class = _load_progress_tracker_class() if progress_path is not None else None
543
636
  if tracker_class is not None:
544
637
  try:
545
638
  tracker = tracker_class(session_log)
546
639
  except Exception:
547
640
  tracker = None
641
+ if progress_path is not None and tracker is not None:
642
+ progress_refresh_thread, refresh_progress = _start_idle_progress_refresh(
643
+ progress_path,
644
+ tracker,
645
+ tracker_lock,
646
+ progress_refresh_stop,
647
+ )
548
648
  try:
549
649
  for line in iter(pipe.readline, ""):
550
650
  if not line:
@@ -560,18 +660,26 @@ def _reader_thread(
560
660
  if progress_path is not None:
561
661
  if tracker is not None:
562
662
  stripped = line.strip()
563
- if stripped:
564
- try:
565
- tracker.process_event(json.loads(stripped))
566
- except json.JSONDecodeError:
567
- tracker.last_text_snippet = stripped[:120]
568
- tracker._detect_terminal_error(stripped, require_error_context=True)
569
- data = tracker.to_dict()
570
- progress_path.parent.mkdir(parents=True, exist_ok=True)
571
- progress_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
663
+ with tracker_lock:
664
+ if stripped:
665
+ try:
666
+ tracker.process_event(json.loads(stripped))
667
+ except json.JSONDecodeError:
668
+ tracker.last_text_snippet = stripped[:120]
669
+ tracker._detect_terminal_error(stripped, require_error_context=True)
670
+ data = tracker.to_dict()
671
+ _write_progress_data(progress_path, data)
572
672
  else:
573
673
  _write_progress_summary(progress_path, parse_stream_progress_lines(lines))
574
674
  finally:
675
+ if refresh_progress is not None:
676
+ try:
677
+ refresh_progress(True)
678
+ except Exception:
679
+ pass
680
+ progress_refresh_stop.set()
681
+ if progress_refresh_thread is not None:
682
+ progress_refresh_thread.join(timeout=2)
575
683
  try:
576
684
  pipe.close()
577
685
  except Exception:
@@ -632,7 +740,7 @@ class AISessionLauncher:
632
740
  command = build_ai_session_command(self.config)
633
741
  session_log = self.config.log_path or self.config.cwd / ".prizmkit" / "state" / "session.log"
634
742
  backup_log = self.config.backup_log_path or default_backup_log_path(session_log)
635
- write_session_preamble(session_log, backup_log, self.config.prompt_path, self.config.model)
743
+ write_session_preamble(session_log, backup_log, command, self.config.model)
636
744
 
637
745
  spec = ProcessSpec(
638
746
  command=command.argv,
@@ -667,9 +775,12 @@ class AISessionLauncher:
667
775
 
668
776
  heartbeat = None
669
777
  live_progress_interval = max(1.0, float(self.config.live_progress_interval_seconds or 30))
670
- stale_threshold_seconds = max(0.0, float(self.config.stale_kill_threshold_seconds or 0))
778
+ kill_threshold_seconds = max(0.0, float(self.config.stale_kill_threshold_seconds or 0))
779
+ stale_threshold_seconds = max(0.0, float(self.config.heartbeat_stale_threshold_seconds or 0))
780
+ if stale_threshold_seconds <= 0:
781
+ stale_threshold_seconds = kill_threshold_seconds
671
782
  heartbeat_path = self.config.heartbeat_path
672
- if heartbeat_path is None and self.config.stale_kill_threshold_seconds > 0:
783
+ if heartbeat_path is None and kill_threshold_seconds > 0:
673
784
  heartbeat_path = session_log.with_name("heartbeat.json")
674
785
  if heartbeat_path is not None:
675
786
  heartbeat = HeartbeatMonitor(
@@ -681,7 +792,7 @@ class AISessionLauncher:
681
792
  marker_dir=session_log.parent,
682
793
  interval_seconds=0.1,
683
794
  stale_after_seconds=stale_threshold_seconds,
684
- kill_after_seconds=stale_threshold_seconds,
795
+ kill_after_seconds=kill_threshold_seconds,
685
796
  grace_seconds=self.config.stale_kill_grace_seconds,
686
797
  ),
687
798
  )
@@ -727,27 +838,37 @@ class AISessionLauncher:
727
838
  terminate_process_tree(handle, "interrupted", grace_seconds=self.config.stale_kill_grace_seconds)
728
839
  raise
729
840
  if self.config.live_output and (not self.config.verbose or self.config.suppress_stream_output):
730
- last_progress_line, display_heartbeat_state = _emit_progress_tick(
731
- started,
841
+ final_heartbeat_state = _display_heartbeat_state(
732
842
  session_log,
733
843
  self.config.progress_path,
734
- last_progress_line,
735
- monitor_state=heartbeat.state if heartbeat is not None else None,
736
- fallback_state=display_heartbeat_state,
844
+ heartbeat.state if heartbeat is not None else None,
845
+ display_heartbeat_state,
737
846
  interval_seconds=live_progress_interval,
738
847
  stale_threshold_seconds=stale_threshold_seconds,
739
848
  )
849
+ display_heartbeat_state = final_heartbeat_state
850
+ if final_heartbeat_state.details == "progress":
851
+ final_line = _compact_progress_line(
852
+ started,
853
+ session_log,
854
+ self.config.progress_path,
855
+ final_heartbeat_state,
856
+ stale_threshold_seconds=stale_threshold_seconds,
857
+ )
858
+ if final_line != last_progress_line:
859
+ _emit_live_line(final_line)
860
+ last_progress_line = final_line
740
861
  reader.join(timeout=5)
741
862
  heartbeat_result = heartbeat.stop() if heartbeat is not None else None
742
863
  if heartbeat_result and heartbeat_result.killed:
743
864
  termination_reason = heartbeat_result.reason
744
865
  stale_marker = heartbeat_result.marker_path
745
- if termination_reason is None and self.config.stale_kill_threshold_seconds > 0 and handle.popen.returncode is None:
866
+ if termination_reason is None and kill_threshold_seconds > 0 and handle.popen.returncode is None:
746
867
  stale_marker = write_stale_marker(
747
868
  session_log.parent,
748
869
  reason="stale_session",
749
- stale_seconds=self.config.stale_kill_threshold_seconds,
750
- threshold=self.config.stale_kill_threshold_seconds,
870
+ stale_seconds=kill_threshold_seconds,
871
+ threshold=kill_threshold_seconds,
751
872
  )
752
873
  termination_reason = "stale_session"
753
874
  recovery = recover_session_log(session_log, backup_log)
@@ -294,7 +294,6 @@ def _build_addendum(context):
294
294
  "- Continue from the first pending/in-progress checkpoint step: `{}`.".format(cursor["first_cursor"]),
295
295
  "- Do not read the previous full `session.log` unless explicitly necessary.",
296
296
  "- If previous runtime context is needed, prefer synthesized artifacts and summaries over logs.",
297
- "- Do not attempt `/compact` as the recovery mechanism.",
298
297
  "",
299
298
  "### Preserved Durable Context",
300
299
  "",
@@ -27,7 +27,7 @@ import os
27
27
  import re
28
28
  import sys
29
29
 
30
- from utils import enrich_global_context, load_json_file, read_platform_conventions, setup_logging
30
+ from utils import enrich_global_context, helper_replacements, load_json_file, resolve_prompt_platform, setup_logging
31
31
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
32
32
 
33
33
 
@@ -173,16 +173,6 @@ def _parse_positive_int_env(name, default):
173
173
 
174
174
 
175
175
 
176
- def load_log_size_section(script_dir):
177
- """Load passive checkpoint guidance for normal prompt generation."""
178
- section_path = os.path.join(
179
- script_dir, "..", "templates", "sections", "log-size-awareness.md",
180
- )
181
- content, err = read_text_file(section_path)
182
- if err:
183
- return ""
184
- return content
185
-
186
176
  def format_acceptance_criteria(criteria):
187
177
  """Format acceptance criteria as a markdown bullet list."""
188
178
  if not criteria:
@@ -650,7 +640,7 @@ def determine_pipeline_mode(complexity):
650
640
  SECTION_TO_SKILL = {
651
641
  "phase0-init": ("prizmkit-init", "Project Bootstrap",
652
642
  [".prizmkit/prizm-docs/root.prizm", ".prizmkit/config.json"]),
653
- "phase0-test-baseline": ("test-baseline", "Detect Test Commands", []),
643
+ "phase0-test-baseline": None,
654
644
  "phase-context-snapshot": ("context-snapshot", "Build Context Snapshot",
655
645
  [".prizmkit/specs/{slug}/context-snapshot.md"]),
656
646
  "phase-specify-plan": ("context-snapshot-and-plan", "Specify & Plan",
@@ -1443,11 +1433,6 @@ def assemble_sections(pipeline_mode, sections_dir, init_done, is_resume,
1443
1433
  sections.append(("context-budget-rules",
1444
1434
  load_section(sections_dir, "context-budget-rules.md")))
1445
1435
 
1446
- # --- Session checkpoint guidance ---
1447
- if os.path.isfile(os.path.join(sections_dir, "log-size-awareness.md")):
1448
- sections.append(("log-size-awareness",
1449
- load_section(sections_dir, "log-size-awareness.md")))
1450
-
1451
1436
  # --- Directory Convention (tier-specific) ---
1452
1437
  if pipeline_mode == "lite":
1453
1438
  dc_file = "directory-convention-lite.md"
@@ -1692,27 +1677,12 @@ def build_replacements(args, feature, features, global_context, script_dir):
1692
1677
  """Build the full dict of placeholder -> replacement value."""
1693
1678
  project_root = _args_project_root(args, script_dir)
1694
1679
 
1695
- # Resolve paths - platform-aware agent/team resolution
1696
- platform = os.environ.get("PRIZMKIT_PLATFORM", "")
1680
+ # Resolve paths - platform-aware agent/team resolution.
1681
+ # Mixed `--platform all` installs must render commands for the actual AI
1682
+ # environment, not whichever platform directory happens to be checked first.
1683
+ platform = resolve_prompt_platform(project_root)
1697
1684
  home_dir = os.path.expanduser("~")
1698
1685
 
1699
- # Auto-detect platform if not set
1700
- if not platform:
1701
- has_codex = os.path.isdir(os.path.join(project_root, ".codex", "agents"))
1702
- has_claude = os.path.isdir(os.path.join(project_root, ".claude", "agents"))
1703
- has_codebuddy = os.path.isdir(os.path.join(project_root, ".codebuddy", "agents"))
1704
- if has_codex:
1705
- platform = "codex"
1706
- elif has_claude:
1707
- platform = "claude"
1708
- elif has_codebuddy:
1709
- platform = "codebuddy"
1710
- else:
1711
- raise RuntimeError(
1712
- "PrizmKit agents not found. None of .codex/agents/, .claude/agents/, or .codebuddy/agents/ exists. "
1713
- "Run `npx prizmkit install` first, or set PRIZMKIT_PLATFORM=codex|claude|codebuddy explicitly."
1714
- )
1715
-
1716
1686
  if platform == "claude":
1717
1687
  # Claude Code: agents in .claude/agents/, no native team config
1718
1688
  agents_dir = os.path.join(project_root, ".claude", "agents")
@@ -1922,7 +1892,6 @@ def build_replacements(args, feature, features, global_context, script_dir):
1922
1892
  dev_url = f"http://localhost:{dev_port}"
1923
1893
 
1924
1894
  replacements = {
1925
- "{{LOG_SIZE_AWARENESS}}": load_log_size_section(script_dir),
1926
1895
  "{{RUN_ID}}": args.run_id,
1927
1896
  "{{SESSION_ID}}": args.session_id,
1928
1897
  "{{FEATURE_ID}}": args.feature_id,
@@ -1938,7 +1907,6 @@ def build_replacements(args, feature, features, global_context, script_dir):
1938
1907
  ),
1939
1908
  "{{GLOBAL_CONTEXT}}": format_global_context(global_context, project_root),
1940
1909
  "{{PROJECT_BRIEF}}": _read_project_brief(project_root),
1941
- "{{PLATFORM_CONVENTIONS}}": read_platform_conventions(project_root),
1942
1910
  "{{TEAM_CONFIG_PATH}}": team_config_path,
1943
1911
  "{{DEV_SUBAGENT_PATH}}": dev_subagent,
1944
1912
  "{{REVIEWER_SUBAGENT_PATH}}": reviewer_subagent,
@@ -1973,6 +1941,8 @@ def build_replacements(args, feature, features, global_context, script_dir):
1973
1941
  ),
1974
1942
  }
1975
1943
 
1944
+ replacements.update(helper_replacements(script_dir))
1945
+
1976
1946
  return replacements, effective_resume, browser_enabled, browser_tool
1977
1947
 
1978
1948
 
@@ -2058,9 +2028,6 @@ def main():
2058
2028
  agent_prompt_replacements = load_active_agent_prompts(templates_dir)
2059
2029
  replacements.update(agent_prompt_replacements)
2060
2030
 
2061
- if is_continuation(args):
2062
- replacements["{{LOG_SIZE_AWARENESS}}"] = ""
2063
-
2064
2031
  # Extract state needed for assembly
2065
2032
  pipeline_mode = replacements.get("{{PIPELINE_MODE}}", "lite")
2066
2033
  init_done = replacements.get("{{INIT_DONE}}", "false") == "true"
@@ -2080,11 +2047,6 @@ def main():
2080
2047
  retry_count=int(args.retry_count),
2081
2048
  browser_tool=browser_tool,
2082
2049
  )
2083
- if is_continuation(args):
2084
- sections = [
2085
- (name, content) for name, content in sections
2086
- if name != "log-size-awareness"
2087
- ]
2088
2050
  rendered = render_from_sections(sections, replacements)
2089
2051
  except FileNotFoundError as exc:
2090
2052
  LOGGER.warning(
@@ -19,7 +19,7 @@ import os
19
19
  import re
20
20
  import sys
21
21
 
22
- from utils import enrich_global_context, load_json_file, read_platform_conventions, setup_logging
22
+ from utils import enrich_global_context, helper_replacements, load_json_file, read_checkpoint_system_section, resolve_prompt_platform, setup_logging
23
23
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
24
24
 
25
25
 
@@ -77,19 +77,6 @@ def _parse_positive_int_env(name, default):
77
77
  return parsed
78
78
 
79
79
 
80
- def load_log_size_section(script_dir):
81
- """Load passive checkpoint guidance for normal prompt generation."""
82
- section_path = os.path.join(
83
- script_dir, "..", "templates", "sections", "log-size-awareness.md",
84
- )
85
- if not os.path.isfile(section_path):
86
- return ""
87
-
88
- content, err = read_text_file(section_path)
89
- if err:
90
- return ""
91
- return content
92
-
93
80
  def find_bug(bugs, bug_id):
94
81
  """Find and return the bug dict matching the given ID."""
95
82
  for bug in bugs:
@@ -261,17 +248,9 @@ def build_replacements(args, bug, global_context, script_dir):
261
248
  project_root = _args_project_root(args, script_dir)
262
249
 
263
250
  # Platform-aware agent/team path resolution
264
- platform = os.environ.get("PRIZMKIT_PLATFORM", "")
251
+ platform = resolve_prompt_platform(project_root)
265
252
  home_dir = os.path.expanduser("~")
266
253
 
267
- if not platform:
268
- if os.path.isdir(os.path.join(project_root, ".codex", "agents")):
269
- platform = "codex"
270
- elif os.path.isdir(os.path.join(project_root, ".claude", "agents")):
271
- platform = "claude"
272
- else:
273
- platform = "codebuddy"
274
-
275
254
  if platform == "claude":
276
255
  agents_dir = os.path.join(project_root, ".claude", "agents")
277
256
  team_config_path = os.path.join(project_root, ".claude", "team-info.json")
@@ -348,7 +327,6 @@ def build_replacements(args, bug, global_context, script_dir):
348
327
  browser_verify_steps = " # (reproduce bug and verify fix)"
349
328
 
350
329
  replacements = {
351
- "{{LOG_SIZE_AWARENESS}}": load_log_size_section(script_dir),
352
330
  "{{SESSION_LOG_PATH}}": os.path.join(
353
331
  ".prizmkit", "state", "bugfix", args.bug_id,
354
332
  "sessions", args.session_id, "logs", "session.log",
@@ -368,12 +346,15 @@ def build_replacements(args, bug, global_context, script_dir):
368
346
  ),
369
347
  "{{ENVIRONMENT}}": format_environment(bug.get("environment")),
370
348
  "{{GLOBAL_CONTEXT}}": format_global_context(global_context, project_root),
371
- "{{PLATFORM_CONVENTIONS}}": read_platform_conventions(project_root),
372
349
  "{{TEAM_CONFIG_PATH}}": team_config_path,
373
350
  "{{DEV_SUBAGENT_PATH}}": dev_subagent,
374
351
  "{{REVIEWER_SUBAGENT_PATH}}": reviewer_subagent,
375
352
  "{{SESSION_STATUS_PATH}}": session_status_path,
376
353
  "{{PROJECT_ROOT}}": project_root,
354
+ "{{CHECKPOINT_PATH}}": os.path.join(
355
+ ".prizmkit", "bugfix", args.bug_id, "workflow-checkpoint.json",
356
+ ),
357
+ "{{CHECKPOINT_SYSTEM}}": read_checkpoint_system_section(script_dir),
377
358
  "{{FIX_SCOPE}}": fix_scope,
378
359
  "{{TIMESTAMP}}": "", # Placeholder, agent fills in the timestamp
379
360
  "{{BROWSER_ENABLED}}": "true" if browser_enabled else "false",
@@ -381,6 +362,8 @@ def build_replacements(args, bug, global_context, script_dir):
381
362
  "{{BROWSER_VERIFY_STEPS}}": browser_verify_steps,
382
363
  }
383
364
 
365
+ replacements.update(helper_replacements(script_dir))
366
+
384
367
  return replacements
385
368
 
386
369
 
@@ -629,15 +612,6 @@ def main():
629
612
 
630
613
  # Build replacements
631
614
  replacements = build_replacements(args, bug, global_context, script_dir)
632
- if is_continuation(args):
633
- replacements["{{LOG_SIZE_AWARENESS}}"] = ""
634
-
635
- # Add checkpoint path to replacements
636
- checkpoint_rel = os.path.join(
637
- ".prizmkit", "bugfix", args.bug_id, "workflow-checkpoint.json",
638
- )
639
- replacements["{{CHECKPOINT_PATH}}"] = checkpoint_rel
640
-
641
615
  # Render the template
642
616
  rendered = render_template(template_content, replacements, bug)
643
617
 
@@ -648,6 +622,7 @@ def main():
648
622
 
649
623
  # Generate checkpoint file
650
624
  project_root = _args_project_root(args, script_dir)
625
+ checkpoint_rel = replacements["{{CHECKPOINT_PATH}}"]
651
626
  checkpoint_path = os.path.join(project_root, checkpoint_rel)
652
627
  checkpoint_dir = os.path.dirname(checkpoint_path)
653
628
  os.makedirs(checkpoint_dir, exist_ok=True)