prizmkit 1.1.106 → 1.1.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/bin/create-prizmkit.js +4 -0
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
  4. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
  5. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
  8. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +56 -5
  9. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
  10. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +11 -1
  11. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +298 -63
  12. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +243 -37
  13. package/bundled/dev-pipeline/scripts/continuation.py +0 -5
  14. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +10 -82
  15. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +7 -64
  16. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +40 -63
  17. package/bundled/dev-pipeline/scripts/utils.py +123 -0
  18. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  19. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
  20. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  21. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +36 -35
  22. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +53 -80
  23. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +43 -78
  24. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +5 -17
  25. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +6 -18
  26. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  27. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -14
  28. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  29. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +0 -7
  30. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +0 -7
  31. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +0 -7
  32. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  33. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  34. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  35. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +0 -2
  36. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  37. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +6 -8
  38. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +3 -7
  39. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -16
  40. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -17
  41. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  42. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +2 -9
  43. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -8
  44. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  45. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -10
  46. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -10
  47. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +2 -9
  48. package/bundled/dev-pipeline/templates/sections/phase0-init.md +0 -7
  49. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  50. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  51. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +395 -21
  52. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +50 -14
  53. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +154 -2
  54. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +431 -3
  55. package/bundled/dev-pipeline/tests/test_unified_cli.py +857 -5
  56. package/bundled/skills/_metadata.json +4 -4
  57. package/bundled/skills/app-planner/SKILL.md +1 -1
  58. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  59. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  60. package/bundled/skills/bug-planner/SKILL.md +101 -15
  61. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  62. package/bundled/skills/feature-planner/SKILL.md +87 -8
  63. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  64. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  65. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  66. package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
  67. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
  68. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  69. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  70. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  71. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  72. package/bundled/templates/project-memory-template.md +1 -1
  73. package/package.json +1 -1
  74. package/src/ai-cli-launch.js +194 -0
  75. package/src/config.js +26 -14
  76. package/src/index.js +11 -44
  77. package/src/platforms.js +10 -3
  78. package/src/prompts.js +60 -2
  79. package/src/scaffold.js +26 -14
  80. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  81. package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
  82. package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
  83. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  84. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
  85. package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
@@ -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
@@ -16,7 +17,8 @@ from collections.abc import Callable, Mapping, Sequence
16
17
  from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
 
19
- from .heartbeat import HeartbeatConfig, HeartbeatMonitor, write_stale_marker
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)
@@ -393,6 +469,16 @@ def _format_elapsed(seconds: float) -> str:
393
469
  return f"{minutes}m{remainder:02d}s"
394
470
 
395
471
 
472
+ def _format_stale_minutes(seconds: float) -> str:
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"
480
+
481
+
396
482
  def _read_json_object(path: Path | None) -> dict[str, object]:
397
483
  if path is None or not path.is_file():
398
484
  return {}
@@ -403,14 +489,55 @@ def _read_json_object(path: Path | None) -> dict[str, object]:
403
489
  return data if isinstance(data, dict) else {}
404
490
 
405
491
 
406
- def _compact_progress_line(started_epoch: float, session_log: Path, progress_path: Path | None) -> str:
492
+ def _display_heartbeat_state(
493
+ session_log: Path,
494
+ progress_path: Path | None,
495
+ monitor_state: HeartbeatState | None,
496
+ fallback_state: HeartbeatState | None,
497
+ *,
498
+ interval_seconds: float,
499
+ stale_threshold_seconds: float,
500
+ ) -> HeartbeatState:
501
+ if monitor_state is not None and monitor_state.last_seen_epoch is not None:
502
+ return monitor_state
503
+ return observe_heartbeat_state(
504
+ HeartbeatConfig(
505
+ heartbeat_file=session_log.with_name("heartbeat.json"),
506
+ session_log=session_log,
507
+ progress_file=progress_path,
508
+ interval_seconds=interval_seconds,
509
+ stale_after_seconds=stale_threshold_seconds,
510
+ kill_after_seconds=0,
511
+ ),
512
+ fallback_state,
513
+ )
514
+
515
+
516
+ def _compact_progress_line(
517
+ started_epoch: float,
518
+ session_log: Path,
519
+ progress_path: Path | None,
520
+ heartbeat_state: HeartbeatState | None = None,
521
+ *,
522
+ stale_threshold_seconds: float = 0,
523
+ ) -> str:
407
524
  data = _read_json_object(progress_path)
408
- try:
409
- log_size = session_log.stat().st_size
410
- except OSError:
411
- log_size = 0
525
+ if heartbeat_state is not None:
526
+ log_size = heartbeat_state.log_size
527
+ else:
528
+ try:
529
+ log_size = session_log.stat().st_size
530
+ except OSError:
531
+ log_size = 0
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 "▶"
412
539
  parts = [
413
- f" [HEARTBEAT] {_format_elapsed(time.time() - started_epoch)}",
540
+ f" {glyph} [HEARTBEAT] {_format_elapsed(time.time() - started_epoch)}",
414
541
  f"log: {_format_bytes(log_size)}",
415
542
  ]
416
543
  child_bytes = int(data.get("child_total_bytes") or 0)
@@ -432,15 +559,42 @@ def _compact_progress_line(started_epoch: float, session_log: Path, progress_pat
432
559
  tool_calls = int(data.get("total_tool_calls") or 0)
433
560
  if tool_calls:
434
561
  parts.append(f"{tool_calls} tool calls")
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}")
435
565
  return " | ".join(parts)
436
566
 
437
567
 
438
- def _emit_progress_tick(started_epoch: float, session_log: Path, progress_path: Path | None, last_line: str) -> str:
439
- line = _compact_progress_line(started_epoch, session_log, progress_path)
568
+ def _emit_progress_tick(
569
+ started_epoch: float,
570
+ session_log: Path,
571
+ progress_path: Path | None,
572
+ last_line: str,
573
+ *,
574
+ monitor_state: HeartbeatState | None = None,
575
+ fallback_state: HeartbeatState | None = None,
576
+ interval_seconds: float = 30,
577
+ stale_threshold_seconds: float = 0,
578
+ ) -> tuple[str, HeartbeatState]:
579
+ heartbeat_state = _display_heartbeat_state(
580
+ session_log,
581
+ progress_path,
582
+ monitor_state,
583
+ fallback_state,
584
+ interval_seconds=interval_seconds,
585
+ stale_threshold_seconds=stale_threshold_seconds,
586
+ )
587
+ line = _compact_progress_line(
588
+ started_epoch,
589
+ session_log,
590
+ progress_path,
591
+ heartbeat_state,
592
+ stale_threshold_seconds=stale_threshold_seconds,
593
+ )
440
594
  if line != last_line:
441
595
  _emit_live_line(line)
442
- return line
443
- return last_line
596
+ return line, heartbeat_state
597
+ return last_line, heartbeat_state
444
598
 
445
599
 
446
600
  def _emit_session_start(config: AISessionConfig, command: AISessionCommand, session_log: Path, backup_log: Path) -> None:
@@ -474,12 +628,23 @@ def _reader_thread(
474
628
  ) -> None:
475
629
  lines: list[str] = []
476
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
477
635
  tracker_class = _load_progress_tracker_class() if progress_path is not None else None
478
636
  if tracker_class is not None:
479
637
  try:
480
638
  tracker = tracker_class(session_log)
481
639
  except Exception:
482
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
+ )
483
648
  try:
484
649
  for line in iter(pipe.readline, ""):
485
650
  if not line:
@@ -495,18 +660,26 @@ def _reader_thread(
495
660
  if progress_path is not None:
496
661
  if tracker is not None:
497
662
  stripped = line.strip()
498
- if stripped:
499
- try:
500
- tracker.process_event(json.loads(stripped))
501
- except json.JSONDecodeError:
502
- tracker.last_text_snippet = stripped[:120]
503
- tracker._detect_terminal_error(stripped, require_error_context=True)
504
- data = tracker.to_dict()
505
- progress_path.parent.mkdir(parents=True, exist_ok=True)
506
- 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)
507
672
  else:
508
673
  _write_progress_summary(progress_path, parse_stream_progress_lines(lines))
509
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)
510
683
  try:
511
684
  pipe.close()
512
685
  except Exception:
@@ -567,7 +740,7 @@ class AISessionLauncher:
567
740
  command = build_ai_session_command(self.config)
568
741
  session_log = self.config.log_path or self.config.cwd / ".prizmkit" / "state" / "session.log"
569
742
  backup_log = self.config.backup_log_path or default_backup_log_path(session_log)
570
- 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)
571
744
 
572
745
  spec = ProcessSpec(
573
746
  command=command.argv,
@@ -601,8 +774,13 @@ class AISessionLauncher:
601
774
  handle.popen.stdin.close()
602
775
 
603
776
  heartbeat = None
777
+ live_progress_interval = max(1.0, float(self.config.live_progress_interval_seconds or 30))
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
604
782
  heartbeat_path = self.config.heartbeat_path
605
- if heartbeat_path is None and self.config.stale_kill_threshold_seconds > 0:
783
+ if heartbeat_path is None and kill_threshold_seconds > 0:
606
784
  heartbeat_path = session_log.with_name("heartbeat.json")
607
785
  if heartbeat_path is not None:
608
786
  heartbeat = HeartbeatMonitor(
@@ -613,8 +791,8 @@ class AISessionLauncher:
613
791
  progress_file=self.config.progress_path,
614
792
  marker_dir=session_log.parent,
615
793
  interval_seconds=0.1,
616
- stale_after_seconds=self.config.stale_kill_threshold_seconds,
617
- kill_after_seconds=self.config.stale_kill_threshold_seconds,
794
+ stale_after_seconds=stale_threshold_seconds,
795
+ kill_after_seconds=kill_threshold_seconds,
618
796
  grace_seconds=self.config.stale_kill_grace_seconds,
619
797
  ),
620
798
  )
@@ -626,6 +804,7 @@ class AISessionLauncher:
626
804
  stale_marker = None
627
805
  last_progress_line = ""
628
806
  last_progress_emit = 0.0
807
+ display_heartbeat_state = None
629
808
  try:
630
809
  while True:
631
810
  timeout = 0.2
@@ -642,27 +821,54 @@ class AISessionLauncher:
642
821
  break
643
822
  except subprocess.TimeoutExpired:
644
823
  if self.config.live_output and (not self.config.verbose or self.config.suppress_stream_output):
645
- interval = max(1.0, float(self.config.live_progress_interval_seconds or 30))
646
824
  now = time.time()
647
- if last_progress_emit == 0.0 or now - last_progress_emit >= interval:
648
- last_progress_line = _emit_progress_tick(started, session_log, self.config.progress_path, last_progress_line)
825
+ if last_progress_emit == 0.0 or now - last_progress_emit >= live_progress_interval:
826
+ last_progress_line, display_heartbeat_state = _emit_progress_tick(
827
+ started,
828
+ session_log,
829
+ self.config.progress_path,
830
+ last_progress_line,
831
+ monitor_state=heartbeat.state if heartbeat is not None else None,
832
+ fallback_state=display_heartbeat_state,
833
+ interval_seconds=live_progress_interval,
834
+ stale_threshold_seconds=stale_threshold_seconds,
835
+ )
649
836
  last_progress_emit = now
650
837
  except KeyboardInterrupt:
651
838
  terminate_process_tree(handle, "interrupted", grace_seconds=self.config.stale_kill_grace_seconds)
652
839
  raise
653
840
  if self.config.live_output and (not self.config.verbose or self.config.suppress_stream_output):
654
- _emit_progress_tick(started, session_log, self.config.progress_path, last_progress_line)
841
+ final_heartbeat_state = _display_heartbeat_state(
842
+ session_log,
843
+ self.config.progress_path,
844
+ heartbeat.state if heartbeat is not None else None,
845
+ display_heartbeat_state,
846
+ interval_seconds=live_progress_interval,
847
+ stale_threshold_seconds=stale_threshold_seconds,
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
655
861
  reader.join(timeout=5)
656
862
  heartbeat_result = heartbeat.stop() if heartbeat is not None else None
657
863
  if heartbeat_result and heartbeat_result.killed:
658
864
  termination_reason = heartbeat_result.reason
659
865
  stale_marker = heartbeat_result.marker_path
660
- 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:
661
867
  stale_marker = write_stale_marker(
662
868
  session_log.parent,
663
869
  reason="stale_session",
664
- stale_seconds=self.config.stale_kill_threshold_seconds,
665
- threshold=self.config.stale_kill_threshold_seconds,
870
+ stale_seconds=kill_threshold_seconds,
871
+ threshold=kill_threshold_seconds,
666
872
  )
667
873
  termination_reason = "stale_session"
668
874
  recovery = recover_session_log(session_log, backup_log)
@@ -158,8 +158,6 @@ def runtime_paths(args, project_root, task_type, task_id, previous_session_id):
158
158
  "bootstrap_prompt": _rel_path(project_root, output_path),
159
159
  "session_log": _rel_path(project_root, os.path.join(logs_dir, "session.log")),
160
160
  "progress_json": _rel_path(project_root, os.path.join(logs_dir, "progress.json")),
161
- "log_monitor_status": _rel_path(project_root, os.path.join(logs_dir, "log-monitor-status.txt")),
162
- "log_monitor_baseline": _rel_path(project_root, os.path.join(logs_dir, "log-monitor-baseline.txt")),
163
161
  "pid_file": _rel_path(project_root, os.path.join(logs_dir, "ai.pid")),
164
162
  "session_status": _rel_path(project_root, session_status),
165
163
  "previous_session_log": _rel_path(project_root, previous_log),
@@ -296,7 +294,6 @@ def _build_addendum(context):
296
294
  "- Continue from the first pending/in-progress checkpoint step: `{}`.".format(cursor["first_cursor"]),
297
295
  "- Do not read the previous full `session.log` unless explicitly necessary.",
298
296
  "- If previous runtime context is needed, prefer synthesized artifacts and summaries over logs.",
299
- "- Do not attempt `/compact` as the recovery mechanism.",
300
297
  "",
301
298
  "### Preserved Durable Context",
302
299
  "",
@@ -321,8 +318,6 @@ def _build_addendum(context):
321
318
  "- Generated bootstrap prompt path: `{}`".format(runtime["bootstrap_prompt"]),
322
319
  "- Session log path: `{}`".format(runtime["session_log"]),
323
320
  "- Progress JSON path: `{}`".format(runtime["progress_json"]),
324
- "- Log monitor status path: `{}`".format(runtime["log_monitor_status"]),
325
- "- Log monitor baseline path: `{}`".format(runtime["log_monitor_baseline"]),
326
321
  "- PID file: `{}`".format(runtime["pid_file"]),
327
322
  "- Session status file: `{}`".format(runtime["session_status"]),
328
323
  "",
@@ -27,12 +27,11 @@ 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, 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
 
34
34
  DEFAULT_MAX_RETRIES = 3
35
- DEFAULT_MAX_LOG_SIZE = 2097152
36
35
 
37
36
  LOGGER = setup_logging("generate-bootstrap-prompt")
38
37
 
@@ -161,27 +160,6 @@ def compute_feature_slug(feature_id, title):
161
160
  return "{}-{}".format(numeric, slug)
162
161
 
163
162
 
164
- def _format_bytes(n):
165
- """Format a byte count to a human-readable string."""
166
- if n >= 1048576:
167
- return "{}MB".format(n // 1048576)
168
- elif n >= 1024:
169
- return "{}KB".format(n // 1024)
170
- return "{}B".format(n)
171
-
172
-
173
- def _parse_non_negative_int_env(name, default):
174
- """Parse a non-negative integer environment variable."""
175
- value = os.environ.get(name, str(default))
176
- try:
177
- parsed = int(value)
178
- except ValueError:
179
- emit_failure("{} must be a non-negative integer".format(name))
180
- if parsed < 0:
181
- emit_failure("{} must be a non-negative integer".format(name))
182
- return parsed
183
-
184
-
185
163
  def _parse_positive_int_env(name, default):
186
164
  """Parse a positive integer environment variable."""
187
165
  value = os.environ.get(name, str(default))
@@ -195,20 +173,6 @@ def _parse_positive_int_env(name, default):
195
173
 
196
174
 
197
175
 
198
- def load_log_size_section(script_dir):
199
- """Load the optional log-size awareness prompt section."""
200
- max_log_size = _parse_non_negative_int_env("MAX_LOG_SIZE", DEFAULT_MAX_LOG_SIZE)
201
- if max_log_size == 0:
202
- return ""
203
-
204
- section_path = os.path.join(
205
- script_dir, "..", "templates", "sections", "log-size-awareness.md",
206
- )
207
- content, err = read_text_file(section_path)
208
- if err:
209
- return ""
210
- return content
211
-
212
176
  def format_acceptance_criteria(criteria):
213
177
  """Format acceptance criteria as a markdown bullet list."""
214
178
  if not criteria:
@@ -676,7 +640,7 @@ def determine_pipeline_mode(complexity):
676
640
  SECTION_TO_SKILL = {
677
641
  "phase0-init": ("prizmkit-init", "Project Bootstrap",
678
642
  [".prizmkit/prizm-docs/root.prizm", ".prizmkit/config.json"]),
679
- "phase0-test-baseline": ("test-baseline", "Detect Test Commands", []),
643
+ "phase0-test-baseline": None,
680
644
  "phase-context-snapshot": ("context-snapshot", "Build Context Snapshot",
681
645
  [".prizmkit/specs/{slug}/context-snapshot.md"]),
682
646
  "phase-specify-plan": ("context-snapshot-and-plan", "Specify & Plan",
@@ -1299,17 +1263,16 @@ def load_section(sections_dir, name):
1299
1263
  ACTIVE_AGENT_PROMPTS = {
1300
1264
  "critic-plan-challenge.md": "{{AGENT_PROMPT_CRITIC_PLAN_CHALLENGE}}",
1301
1265
  "reviewer-review.md": "{{AGENT_PROMPT_REVIEWER_REVIEW}}",
1302
- "dev-fix.md": "{{AGENT_PROMPT_DEV_FIX}}",
1303
- "dev-resume.md": "{{AGENT_PROMPT_DEV_RESUME}}",
1304
1266
  }
1305
1267
 
1306
1268
 
1307
1269
  def load_active_agent_prompts(templates_dir):
1308
1270
  """Load explicit active agent prompt templates.
1309
1271
 
1310
- The retired top-level Dev implementation prompt is intentionally not loaded;
1311
- feature implementation is performed by the main orchestrator. Remaining
1312
- prompt placeholders support Critic/Reviewer and review-fix helper flows.
1272
+ Retired Dev implementation and review-fix prompts are intentionally not
1273
+ loaded; feature implementation and accepted review fixes are performed by
1274
+ the main orchestrator. Remaining prompt placeholders support Critic and
1275
+ read-only Reviewer handoffs.
1313
1276
  """
1314
1277
  agent_prompts_dir = os.path.join(templates_dir, "agent-prompts")
1315
1278
  if not os.path.isdir(agent_prompts_dir):
@@ -1470,12 +1433,6 @@ def assemble_sections(pipeline_mode, sections_dir, init_done, is_resume,
1470
1433
  sections.append(("context-budget-rules",
1471
1434
  load_section(sections_dir, "context-budget-rules.md")))
1472
1435
 
1473
- # --- Log Size Awareness (only disabled when MAX_LOG_SIZE is explicitly 0) ---
1474
- max_log_size = _parse_non_negative_int_env("MAX_LOG_SIZE", DEFAULT_MAX_LOG_SIZE)
1475
- if max_log_size != 0 and os.path.isfile(os.path.join(sections_dir, "log-size-awareness.md")):
1476
- sections.append(("log-size-awareness",
1477
- load_section(sections_dir, "log-size-awareness.md")))
1478
-
1479
1436
  # --- Directory Convention (tier-specific) ---
1480
1437
  if pipeline_mode == "lite":
1481
1438
  dc_file = "directory-convention-lite.md"
@@ -1720,27 +1677,12 @@ def build_replacements(args, feature, features, global_context, script_dir):
1720
1677
  """Build the full dict of placeholder -> replacement value."""
1721
1678
  project_root = _args_project_root(args, script_dir)
1722
1679
 
1723
- # Resolve paths - platform-aware agent/team resolution
1724
- 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)
1725
1684
  home_dir = os.path.expanduser("~")
1726
1685
 
1727
- # Auto-detect platform if not set
1728
- if not platform:
1729
- has_codex = os.path.isdir(os.path.join(project_root, ".codex", "agents"))
1730
- has_claude = os.path.isdir(os.path.join(project_root, ".claude", "agents"))
1731
- has_codebuddy = os.path.isdir(os.path.join(project_root, ".codebuddy", "agents"))
1732
- if has_codex:
1733
- platform = "codex"
1734
- elif has_claude:
1735
- platform = "claude"
1736
- elif has_codebuddy:
1737
- platform = "codebuddy"
1738
- else:
1739
- raise RuntimeError(
1740
- "PrizmKit agents not found. None of .codex/agents/, .claude/agents/, or .codebuddy/agents/ exists. "
1741
- "Run `npx prizmkit install` first, or set PRIZMKIT_PLATFORM=codex|claude|codebuddy explicitly."
1742
- )
1743
-
1744
1686
  if platform == "claude":
1745
1687
  # Claude Code: agents in .claude/agents/, no native team config
1746
1688
  agents_dir = os.path.join(project_root, ".claude", "agents")
@@ -1949,10 +1891,7 @@ def build_replacements(args, feature, features, global_context, script_dir):
1949
1891
  pass # Keep default 3000 on any error
1950
1892
  dev_url = f"http://localhost:{dev_port}"
1951
1893
 
1952
- max_log_size = _parse_non_negative_int_env("MAX_LOG_SIZE", DEFAULT_MAX_LOG_SIZE)
1953
-
1954
1894
  replacements = {
1955
- "{{LOG_SIZE_AWARENESS}}": load_log_size_section(script_dir),
1956
1895
  "{{RUN_ID}}": args.run_id,
1957
1896
  "{{SESSION_ID}}": args.session_id,
1958
1897
  "{{FEATURE_ID}}": args.feature_id,
@@ -1968,7 +1907,6 @@ def build_replacements(args, feature, features, global_context, script_dir):
1968
1907
  ),
1969
1908
  "{{GLOBAL_CONTEXT}}": format_global_context(global_context, project_root),
1970
1909
  "{{PROJECT_BRIEF}}": _read_project_brief(project_root),
1971
- "{{PLATFORM_CONVENTIONS}}": read_platform_conventions(project_root),
1972
1910
  "{{TEAM_CONFIG_PATH}}": team_config_path,
1973
1911
  "{{DEV_SUBAGENT_PATH}}": dev_subagent,
1974
1912
  "{{REVIEWER_SUBAGENT_PATH}}": reviewer_subagent,
@@ -1997,8 +1935,6 @@ def build_replacements(args, feature, features, global_context, script_dir):
1997
1935
  "{{COVERAGE_TARGET}}": coverage_target,
1998
1936
  "{{DEV_PORT}}": dev_port,
1999
1937
  "{{DEV_URL}}": dev_url,
2000
- "{{MAX_LOG_SIZE}}": str(max_log_size),
2001
- "{{MAX_LOG_SIZE_HUMAN}}": _format_bytes(max_log_size),
2002
1938
  "{{SESSION_LOG_PATH}}": os.path.join(
2003
1939
  ".prizmkit", "state", "features", args.feature_id,
2004
1940
  "sessions", args.session_id, "logs", "session.log",
@@ -2090,9 +2026,6 @@ def main():
2090
2026
  agent_prompt_replacements = load_active_agent_prompts(templates_dir)
2091
2027
  replacements.update(agent_prompt_replacements)
2092
2028
 
2093
- if is_continuation(args):
2094
- replacements["{{LOG_SIZE_AWARENESS}}"] = ""
2095
-
2096
2029
  # Extract state needed for assembly
2097
2030
  pipeline_mode = replacements.get("{{PIPELINE_MODE}}", "lite")
2098
2031
  init_done = replacements.get("{{INIT_DONE}}", "false") == "true"
@@ -2112,11 +2045,6 @@ def main():
2112
2045
  retry_count=int(args.retry_count),
2113
2046
  browser_tool=browser_tool,
2114
2047
  )
2115
- if is_continuation(args):
2116
- sections = [
2117
- (name, content) for name, content in sections
2118
- if name != "log-size-awareness"
2119
- ]
2120
2048
  rendered = render_from_sections(sections, replacements)
2121
2049
  except FileNotFoundError as exc:
2122
2050
  LOGGER.warning(