prizmkit 1.1.152 → 1.1.154

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 (44) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/README.md +100 -87
  3. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +1 -1
  4. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +14 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +192 -110
  6. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +146 -111
  7. package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +3 -5
  8. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/paths.py +0 -3
  10. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +122 -15
  11. package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +1 -1
  12. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +1 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +19 -52
  14. package/bundled/dev-pipeline/prizmkit_runtime/status.py +106 -1
  15. package/bundled/dev-pipeline/scripts/init-bugfix-pipeline.py +2 -2
  16. package/bundled/dev-pipeline/scripts/parse-stream-progress.py +10 -12
  17. package/bundled/dev-pipeline/scripts/update-bug-status.py +196 -67
  18. package/bundled/dev-pipeline/scripts/update-checkpoint.py +16 -3
  19. package/bundled/dev-pipeline/scripts/update-feature-status.py +45 -117
  20. package/bundled/dev-pipeline/scripts/update-refactor-status.py +45 -119
  21. package/bundled/dev-pipeline/scripts/utils.py +119 -0
  22. package/bundled/dev-pipeline/templates/bug-fix-list-schema.json +3 -2
  23. package/bundled/dev-pipeline/tests/test_auto_skip.py +111 -32
  24. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +148 -12
  25. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +0 -19
  26. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +113 -188
  27. package/bundled/dev-pipeline/tests/test_recovery_workflow.py +211 -0
  28. package/bundled/dev-pipeline/tests/test_reset_modes.py +935 -0
  29. package/bundled/dev-pipeline/tests/test_reset_preserve.py +5 -5
  30. package/bundled/dev-pipeline/tests/test_unified_cli.py +544 -181
  31. package/bundled/skills/_metadata.json +1 -1
  32. package/bundled/skills/bug-planner/references/schema-validation.md +1 -1
  33. package/bundled/skills/bug-planner/scripts/validate-bug-list.py +1 -1
  34. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -13
  35. package/bundled/skills/feature-pipeline-launcher/SKILL.md +22 -14
  36. package/bundled/skills/recovery-workflow/SKILL.md +7 -5
  37. package/bundled/skills/recovery-workflow/evals/evals.json +2 -2
  38. package/bundled/skills/recovery-workflow/references/detection.md +3 -3
  39. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +1 -1
  40. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -13
  41. package/bundled/templates/project-memory-template.md +19 -11
  42. package/package.json +1 -1
  43. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +0 -228
  44. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +0 -767
@@ -54,23 +54,16 @@ def _load_progress_parser_module():
54
54
  return module
55
55
 
56
56
 
57
- def test_cli_help_exposes_python_runtime_command_groups():
57
+ def test_cli_help_exposes_only_operation_first_public_namespaces():
58
58
  result = run_cli("--help")
59
59
 
60
60
  assert result.returncode == 0
61
61
  assert "canonical Python runtime CLI" in result.stdout
62
- assert "without shell wrappers" in result.stdout
63
- for group in [
64
- "feature",
65
- "bugfix",
66
- "refactor",
67
- "recovery",
68
- "reset",
69
- "daemon",
70
- "status",
71
- "diagnostics",
72
- ]:
62
+ assert "operation-first" in result.stdout
63
+ for group in ["run", "status", "reset", "daemon", "diagnostics", "helper"]:
73
64
  assert group in result.stdout
65
+ for removed in ["feature", "bugfix", "refactor", "recovery"]:
66
+ assert removed not in result.stdout.lower()
74
67
  assert "Traceback" not in result.stderr
75
68
 
76
69
 
@@ -79,7 +72,7 @@ def test_cli_help_preserves_runtime_identity_at_narrow_terminal_width():
79
72
 
80
73
  assert result.returncode == 0
81
74
  assert "canonical Python runtime CLI" in result.stdout
82
- assert "without shell wrappers" in result.stdout
75
+ assert "operation-first" in result.stdout
83
76
 
84
77
 
85
78
  def test_module_entrypoint_help_exposes_same_cli():
@@ -99,6 +92,45 @@ def test_module_entrypoint_help_exposes_same_cli():
99
92
  assert "diagnostics" in result.stdout
100
93
 
101
94
 
95
+ @pytest.mark.parametrize(
96
+ "args",
97
+ [
98
+ ("--help",),
99
+ ("run", "--help"),
100
+ ("run", "feature", "--help"),
101
+ ("run", "bugfix", "--help"),
102
+ ("run", "refactor", "--help"),
103
+ ("status", "--help"),
104
+ ("status", "feature", "--help"),
105
+ ("status", "all", "--help"),
106
+ ("reset", "--help"),
107
+ ("reset", "feature", "--help"),
108
+ ("daemon", "--help"),
109
+ ("daemon", "feature", "--help"),
110
+ ("daemon", "feature", "start", "--help"),
111
+ ("daemon", "bugfix", "stop", "--help"),
112
+ ("daemon", "refactor", "status", "--help"),
113
+ ("daemon", "feature", "logs", "--help"),
114
+ ("daemon", "feature", "restart", "--help"),
115
+ ("diagnostics", "--help"),
116
+ ("diagnostics", "ai-cli", "--help"),
117
+ ("helper", "--help"),
118
+ ],
119
+ )
120
+ def test_help_at_every_namespace_depth_is_parser_owned_and_never_dispatches(monkeypatch, args):
121
+ from prizmkit_runtime import cli as runtime_cli
122
+
123
+ dispatched = []
124
+ monkeypatch.setattr(runtime_cli, "ensure_supported_python", lambda: None)
125
+ monkeypatch.setattr(runtime_cli, "_dispatch", lambda parsed: dispatched.append(parsed))
126
+
127
+ with pytest.raises(SystemExit) as exc_info:
128
+ runtime_cli.main(list(args))
129
+
130
+ assert exc_info.value.code == 0
131
+ assert dispatched == []
132
+
133
+
102
134
  def test_invalid_arguments_report_usage_without_traceback():
103
135
  result = run_cli("unknown-command")
104
136
 
@@ -108,6 +140,80 @@ def test_invalid_arguments_report_usage_without_traceback():
108
140
  assert "Traceback" not in result.stderr
109
141
 
110
142
 
143
+ @pytest.mark.parametrize(
144
+ "args",
145
+ [
146
+ ("recovery",),
147
+ ("recovery", "detect"),
148
+ ("recovery", "run"),
149
+ ("recovery", "describe"),
150
+ ("recovery", "run", "--dry-run"),
151
+ ],
152
+ )
153
+ def test_former_recovery_invocations_are_unknown_commands(args):
154
+ result = run_cli(*args)
155
+
156
+ assert result.returncode == 2
157
+ assert "invalid choice: 'recovery'" in result.stderr
158
+ assert "Traceback" not in result.stderr
159
+
160
+
161
+ def test_former_recovery_invocation_never_reaches_dispatch(monkeypatch):
162
+ from prizmkit_runtime import cli as runtime_cli
163
+
164
+ dispatched = []
165
+ monkeypatch.setattr(runtime_cli, "ensure_supported_python", lambda: None)
166
+ monkeypatch.setattr(runtime_cli, "_dispatch", lambda args: dispatched.append(args))
167
+
168
+ with pytest.raises(SystemExit) as exc_info:
169
+ runtime_cli.main(["recovery", "run", "--dry-run"])
170
+
171
+ assert exc_info.value.code == 2
172
+ assert dispatched == []
173
+
174
+
175
+ @pytest.mark.parametrize(
176
+ "args",
177
+ [
178
+ ("feature", "run"),
179
+ ("feature", "status"),
180
+ ("feature", "reset"),
181
+ ("feature", "unskip"),
182
+ ("feature", "test-cli"),
183
+ ("feature", "describe"),
184
+ ("bugfix", "run"),
185
+ ("bugfix", "status"),
186
+ ("bugfix", "reset"),
187
+ ("bugfix", "unskip"),
188
+ ("bugfix", "describe"),
189
+ ("refactor", "run"),
190
+ ("refactor", "status"),
191
+ ("refactor", "reset"),
192
+ ("refactor", "unskip"),
193
+ ("refactor", "describe"),
194
+ ("reset", "describe"),
195
+ ("daemon", "feature", "describe"),
196
+ ("diagnostics", "describe"),
197
+ ("status", "all", "custom-plan.json"),
198
+ ("run", "feature", "--unknown-option"),
199
+ ("run", "feature", "F-001", "plan.json", "extra"),
200
+ ("daemon", "feature", "status", "extra"),
201
+ ],
202
+ )
203
+ def test_removed_or_invalid_forms_fail_in_parser_without_runtime_dispatch(monkeypatch, args):
204
+ from prizmkit_runtime import cli as runtime_cli
205
+
206
+ dispatched = []
207
+ monkeypatch.setattr(runtime_cli, "ensure_supported_python", lambda: None)
208
+ monkeypatch.setattr(runtime_cli, "_dispatch", lambda parsed: dispatched.append(parsed))
209
+
210
+ with pytest.raises(SystemExit) as exc_info:
211
+ runtime_cli.main(list(args))
212
+
213
+ assert exc_info.value.code == 2
214
+ assert dispatched == []
215
+
216
+
111
217
  def test_python_version_guard_mentions_required_version():
112
218
  from prizmkit_runtime.compat import UnsupportedPythonVersion, ensure_supported_python
113
219
 
@@ -136,6 +242,7 @@ def test_runtime_paths_support_framework_source_layout():
136
242
  assert paths.refactor_plan == paths.plans_dir / "refactor-list.json"
137
243
  assert paths.feature_state_dir == paths.state_dir / "features"
138
244
  assert paths.specs_dir == paths.prizmkit_dir / "specs"
245
+ assert not hasattr(paths, "recovery_state_dir")
139
246
  assert all(isinstance(value, Path) for value in paths.path_values())
140
247
 
141
248
 
@@ -154,6 +261,22 @@ def test_runtime_paths_support_installed_layout(tmp_path):
154
261
  assert paths.logs_dir == paths.state_dir / "logs"
155
262
 
156
263
 
264
+ def test_runtime_diagnostics_leave_historical_recovery_state_untouched(tmp_path):
265
+ project = tmp_path / "project"
266
+ historical = project / ".prizmkit" / "state" / "recovery" / "historical-session"
267
+ historical.mkdir(parents=True)
268
+ marker = historical / "marker.json"
269
+ marker.write_bytes(b'{"preserve": true}\n')
270
+ before = {path.relative_to(project).as_posix(): path.read_bytes() for path in historical.rglob("*") if path.is_file()}
271
+
272
+ result = run_cli("--project-root", str(project), "diagnostics", "paths")
273
+
274
+ after = {path.relative_to(project).as_posix(): path.read_bytes() for path in historical.rglob("*") if path.is_file()}
275
+ assert result.returncode == 0
276
+ assert before == after
277
+ assert marker.is_file()
278
+
279
+
157
280
  def test_runtime_config_models_environment_and_ai_resolution():
158
281
  from prizmkit_runtime.config import AIClientResolution, RuntimeEnvironment, load_runtime_config
159
282
  from prizmkit_runtime.paths import resolve_runtime_paths
@@ -298,59 +421,51 @@ def test_runtime_config_supplied_env_does_not_leak_ambient_path(tmp_path):
298
421
 
299
422
 
300
423
 
301
- from prizmkit_runtime.commands import list_runtime_commands
424
+ def test_runtime_command_registry_contains_only_executing_operation_first_entries():
425
+ from prizmkit_runtime.commands import find_runtime_command, list_runtime_commands
302
426
 
303
427
  command_keys = {command.key for command in list_runtime_commands()}
304
-
305
428
  expected = {
306
- ("feature", None, "run"),
307
- ("feature", None, "status"),
308
- ("feature", None, "unskip"),
309
- ("feature", None, "test-cli"),
310
- ("feature", None, "describe"),
311
- ("bugfix", None, "run"),
312
- ("bugfix", None, "status"),
313
- ("bugfix", None, "unskip"),
314
- ("bugfix", None, "describe"),
315
- ("refactor", None, "run"),
316
- ("refactor", None, "status"),
317
- ("refactor", None, "unskip"),
318
- ("refactor", None, "describe"),
319
- ("recovery", None, "run"),
320
- ("recovery", None, "detect"),
321
- ("recovery", None, "describe"),
322
- ("reset", "feature", "reset"),
323
- ("reset", "bugfix", "reset"),
324
- ("reset", "refactor", "reset"),
325
- ("reset", None, "describe"),
326
- ("status", "feature", "status"),
327
- ("status", "bugfix", "status"),
328
- ("status", "refactor", "status"),
329
- ("status", "all", "status"),
330
- ("diagnostics", None, "paths"),
331
- ("diagnostics", None, "entrypoints"),
332
- ("diagnostics", None, "version"),
429
+ *(('run', family, 'run') for family in ('feature', 'bugfix', 'refactor')),
430
+ *(('status', family, 'status') for family in ('feature', 'bugfix', 'refactor', 'all')),
431
+ *(('reset', family, 'reset') for family in ('feature', 'bugfix', 'refactor')),
432
+ *(('daemon', family, action)
433
+ for family in ('feature', 'bugfix', 'refactor')
434
+ for action in ('start', 'stop', 'status', 'logs', 'restart')),
435
+ *(('diagnostics', None, action)
436
+ for action in ('paths', 'entrypoints', 'version', 'ai-cli')),
437
+ ('helper', None, 'run'),
333
438
  }
334
- for target in ["feature", "bugfix", "refactor"]:
335
- for action in ["start", "stop", "status", "logs", "restart", "describe"]:
336
- expected.add(("daemon", target, action))
337
-
338
- assert expected.issubset(command_keys)
339
439
 
440
+ assert command_keys == expected
441
+ assert all(command.current_entrypoint for command in list_runtime_commands())
442
+ assert all(command.action != "describe" for command in list_runtime_commands())
443
+ assert all(command.group not in {"feature", "bugfix", "refactor", "recovery"} for command in list_runtime_commands())
444
+ for removed in [
445
+ ("feature", "run", None),
446
+ ("feature", "test-cli", None),
447
+ ("reset", "describe", None),
448
+ ("daemon", "describe", "feature"),
449
+ ("recovery", "detect", None),
450
+ ]:
451
+ with pytest.raises(KeyError, match="Unknown runtime command"):
452
+ find_runtime_command(removed[0], removed[1], removed[2])
340
453
 
341
- def test_python_runtime_command_output_describes_canonical_boundary():
342
- result = run_cli("feature", "describe")
343
454
 
344
- assert result.returncode == 0
345
- assert "Python runtime command contract" in result.stdout
346
- assert "canonical PrizmKit Python runtime" in result.stdout
347
- assert "current entrypoint" in result.stdout
348
- assert "python-runtime-canonical" in result.stdout
349
- assert "fully replaces" not in result.stdout.lower()
350
- assert "shell runtime remains canonical" not in result.stdout
455
+ def test_removed_describe_commands_are_rejected():
456
+ for args in [
457
+ ("feature", "describe"),
458
+ ("reset", "describe"),
459
+ ("daemon", "feature", "describe"),
460
+ ("diagnostics", "describe"),
461
+ ]:
462
+ result = run_cli(*args)
463
+ assert result.returncode == 2
464
+ assert "invalid choice" in result.stderr
465
+ assert "Python runtime command contract" not in result.stdout
351
466
 
352
467
 
353
- def test_option_first_runtime_arguments_are_forwarded(monkeypatch):
468
+ def test_operation_first_runtime_arguments_are_validated_then_forwarded_exactly(monkeypatch):
354
469
  from prizmkit_runtime import cli as runtime_cli
355
470
 
356
471
  captured = []
@@ -362,8 +477,16 @@ def test_option_first_runtime_arguments_are_forwarded(monkeypatch):
362
477
  monkeypatch.setattr(runtime_cli, "ensure_supported_python", lambda: None)
363
478
  monkeypatch.setattr(runtime_cli, "handle_runtime_command", fake_handle)
364
479
 
365
- assert runtime_cli.main(["recovery", "run", "--dry-run"]) == 0
366
- assert captured[-1] == ("recovery", "run", None, ("--dry-run",))
480
+ run_args = [
481
+ "run", "feature", "F-001", "custom.json", "--features", "F-001:F-005",
482
+ "--max-retries", "5", "--max-infra-retries", "4", "--mode", "full",
483
+ "--resume-phase", "6", "--dry-run",
484
+ ]
485
+ assert runtime_cli.main(run_args) == 0
486
+ assert captured[-1] == ("run", "run", "feature", tuple(run_args[2:]))
487
+
488
+ assert runtime_cli.main(["status", "bugfix", "bugs.json"]) == 0
489
+ assert captured[-1] == ("status", "status", "bugfix", ("bugs.json",))
367
490
 
368
491
  assert runtime_cli.main(["daemon", "feature", "start", "--mode", "standard"]) == 0
369
492
  assert captured[-1] == ("daemon", "start", "feature", ("--mode", "standard"))
@@ -371,67 +494,104 @@ def test_option_first_runtime_arguments_are_forwarded(monkeypatch):
371
494
  assert runtime_cli.main(["daemon", "feature", "logs", "--follow"]) == 0
372
495
  assert captured[-1] == ("daemon", "logs", "feature", ("--follow",))
373
496
 
374
- assert runtime_cli.main(["reset", "feature", "--failed"]) == 0
375
- assert captured[-1] == ("reset", "reset", "feature", ("--failed",))
497
+ assert runtime_cli.main(["reset", "feature", "--failed", "--preserve-runtime"]) == 0
498
+ assert captured[-1] == ("reset", "reset", "feature", ("--failed", "--preserve-runtime"))
499
+
500
+ interspersed_reset = ["reset", "feature", "F-001", "--fresh-checkout", "custom.json"]
501
+ assert runtime_cli.main(interspersed_reset) == 0
502
+ assert captured[-1] == (
503
+ "reset", "reset", "feature", ("F-001", "--fresh-checkout", "custom.json")
504
+ )
376
505
 
377
- assert runtime_cli.main(["--project-root", "feature", "feature", "describe"]) == 0
378
- assert captured[-1] == ("feature", "describe", None, ())
506
+ interspersed_daemon = ["daemon", "feature", "start", "--mode", "full", "custom.json"]
507
+ assert runtime_cli.main(interspersed_daemon) == 0
508
+ assert captured[-1] == (
509
+ "daemon", "start", "feature", ("--mode", "full", "custom.json")
510
+ )
379
511
 
512
+ assert runtime_cli.main(["--project-root", "feature", "run", "feature", "--dry-run"]) == 0
513
+ assert captured[-1] == ("run", "run", "feature", ("--dry-run",))
380
514
 
381
- def test_unskip_dispatches_status_updater_not_contract(monkeypatch):
382
- from prizmkit_runtime import runners
383
- from prizmkit_runtime.commands import handle_runtime_command
384
- from prizmkit_runtime.paths import resolve_runtime_paths
385
515
 
386
- class FakeStatus:
387
- return_code = 0
388
- stdout = "unskipped\n"
389
- stderr = ""
516
+ @pytest.mark.parametrize(
517
+ ("family", "task_id", "extra_args"),
518
+ [
519
+ ("feature", "F-007", ("--features", "F-007", "--dry-run")),
520
+ ("bugfix", "B-007", ("--mode", "full", "--max-retries", "4")),
521
+ ("refactor", "R-007", ("--resume-phase", "6", "--max-infra-retries", "5")),
522
+ ],
523
+ )
524
+ def test_run_family_dispatch_preserves_each_family_argument_tail(
525
+ monkeypatch, family, task_id, extra_args
526
+ ):
527
+ from prizmkit_runtime import cli as runtime_cli
390
528
 
391
529
  captured = []
530
+ monkeypatch.setattr(runtime_cli, "ensure_supported_python", lambda: None)
531
+ monkeypatch.setattr(
532
+ runtime_cli,
533
+ "handle_runtime_command",
534
+ lambda group, action, **kwargs: captured.append(
535
+ (group, action, kwargs["target"], kwargs["legacy_args"])
536
+ ) or runtime_cli.CommandResult(0, "ok", "ok"),
537
+ )
538
+ argv = ["run", family, task_id, *extra_args]
392
539
 
393
- def fake_run_status_action(family, action, invocation, *, item_id="", project_root=None, **kwargs):
394
- captured.append((family.kind, action, invocation.item_id, item_id, project_root))
395
- return FakeStatus()
540
+ assert runtime_cli.main(argv) == 0
541
+ assert captured == [("run", "run", family, (task_id, *extra_args))]
396
542
 
397
- monkeypatch.setattr(runners, "run_status_action", fake_run_status_action)
398
- paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT)
399
543
 
400
- result = handle_runtime_command("feature", "unskip", paths=paths, legacy_args=("F-005",))
544
+ @pytest.mark.parametrize("family", ("feature", "bugfix", "refactor"))
545
+ @pytest.mark.parametrize("legacy_action", ("reset", "un" + "skip"))
546
+ def test_family_level_reset_aliases_are_not_public_commands(family, legacy_action):
547
+ result = run_cli(family, legacy_action)
401
548
 
402
- assert result.exit_code == 0
403
- assert result.title == "Python feature unskip"
404
- assert "unskipped" in result.render()
405
- assert "Python runtime command contract" not in result.render()
406
- assert captured == [("feature", "unskip", "F-005", "F-005", REPO_ROOT.resolve())]
549
+ assert result.returncode == 2
550
+ assert "invalid choice" in result.stderr
407
551
 
408
552
 
409
- def test_test_cli_reports_ai_resolution_not_contract(monkeypatch):
410
- from prizmkit_runtime import runners
411
- from prizmkit_runtime.commands import handle_runtime_command
553
+ def test_diagnostics_ai_cli_reports_family_neutral_resolution(monkeypatch):
554
+ from prizmkit_runtime import config
555
+ from prizmkit_runtime.commands import handle_diagnostics
412
556
  from prizmkit_runtime.paths import resolve_runtime_paths
413
557
 
414
558
  class FakeAIClient:
415
559
  command = "claude"
416
560
  platform = "claude"
417
561
  source = "test"
418
- notes = ()
562
+ notes = ("configured globally",)
419
563
 
420
564
  class FakeConfig:
421
565
  ai_client = FakeAIClient()
422
566
  runtime_mode = "python-runtime-canonical"
423
567
  model = "claude-sonnet-4.6"
424
568
 
425
- monkeypatch.setattr(runners, "load_runtime_config", lambda paths: FakeConfig())
569
+ monkeypatch.setattr(config, "load_runtime_config", lambda paths: FakeConfig())
426
570
  paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT)
427
571
 
428
- result = handle_runtime_command("feature", "test-cli", paths=paths)
572
+ result = handle_diagnostics("ai-cli", paths)
429
573
  rendered = result.render()
430
574
 
431
575
  assert result.exit_code == 0
432
- assert result.title == "Python feature AI CLI test"
576
+ assert result.title == "Python runtime AI CLI diagnostics"
433
577
  assert "detected_cli: claude" in rendered
434
- assert "Python runtime command contract" not in rendered
578
+ assert "platform: claude" in rendered
579
+ assert "source: test" in rendered
580
+ assert "runtime_mode: python-runtime-canonical" in rendered
581
+ assert "model: claude-sonnet-4.6" in rendered
582
+ assert "note: configured globally" in rendered
583
+ assert "feature" not in result.title.lower()
584
+
585
+
586
+ def test_diagnostics_help_exposes_only_real_diagnostic_actions():
587
+ result = run_cli("diagnostics", "--help")
588
+
589
+ assert result.returncode == 0
590
+ for action in ("paths", "entrypoints", "version", "ai-cli"):
591
+ assert action in result.stdout
592
+ assert "describe" not in result.stdout
593
+ assert "commands" not in result.stdout
594
+ assert "test-cli" not in result.stdout
435
595
 
436
596
 
437
597
  def test_diagnostics_paths_and_entrypoints_are_standard_library_cli_actions():
@@ -444,8 +604,180 @@ def test_diagnostics_paths_and_entrypoints_are_standard_library_cli_actions():
444
604
  assert entrypoints_result.returncode == 0
445
605
  assert "Python runtime entrypoint inventory" in entrypoints_result.stdout
446
606
  assert "runtime:cli:" in entrypoints_result.stdout
607
+ assert "command:run:feature: python3 dev-pipeline/cli.py run feature" in entrypoints_result.stdout
608
+ assert "command:status:all: python3 dev-pipeline/cli.py status all" in entrypoints_result.stdout
609
+ assert "command:diagnostics:ai-cli:" in entrypoints_result.stdout
610
+ assert " describe" not in entrypoints_result.stdout
611
+ assert "test-cli" not in entrypoints_result.stdout
447
612
  assert "run-feature.sh" not in entrypoints_result.stdout
448
613
  assert "generate-bootstrap-prompt.py" in entrypoints_result.stdout
614
+ assert "generate-recovery-prompt.py" not in entrypoints_result.stdout
615
+
616
+
617
+ def _write_family_status_plan(paths, family, task_id):
618
+ plan_path, key = {
619
+ "feature": (paths.feature_plan, "features"),
620
+ "bugfix": (paths.bugfix_plan, "bugs"),
621
+ "refactor": (paths.refactor_plan, "refactors"),
622
+ }[family]
623
+ plan_path.parent.mkdir(parents=True, exist_ok=True)
624
+ plan_path.write_text(json.dumps({key: [{"id": task_id, "status": "pending"}]}), encoding="utf-8")
625
+ return plan_path
626
+
627
+
628
+ def _file_bytes_under(root):
629
+ if not root.exists():
630
+ return {}
631
+ return {
632
+ path.relative_to(root).as_posix(): path.read_bytes()
633
+ for path in root.rglob("*")
634
+ if path.is_file()
635
+ }
636
+
637
+
638
+ def test_status_all_aggregates_configured_families_in_fixed_order_and_missing_is_nonfatal(
639
+ tmp_path, monkeypatch
640
+ ):
641
+ from prizmkit_runtime import runners
642
+ from prizmkit_runtime.commands import CommandResult, handle_runtime_command
643
+ from prizmkit_runtime.paths import resolve_runtime_paths
644
+
645
+ paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT, project_root=tmp_path)
646
+ _write_family_status_plan(paths, "feature", "F-001")
647
+ _write_family_status_plan(paths, "refactor", "R-001")
648
+ before = _file_bytes_under(tmp_path)
649
+ calls = []
650
+
651
+ def fake_status(family, action, legacy_args, runtime_paths):
652
+ calls.append((family, action, legacy_args, runtime_paths))
653
+ return CommandResult(0, f"Python {family} status", f"normal {family} status")
654
+
655
+ monkeypatch.setattr(runners, "run_pipeline_command", fake_status)
656
+ result = handle_runtime_command("status", "status", target="all", paths=paths)
657
+ rendered = result.render()
658
+
659
+ assert result.exit_code == 0
660
+ assert rendered.index("FEATURE\n=======") < rendered.index("BUGFIX\n======")
661
+ assert rendered.index("BUGFIX\n======") < rendered.index("REFACTOR\n========")
662
+ assert "normal feature status" in rendered
663
+ assert "normal refactor status" in rendered
664
+ assert "BUGFIX\n======\nnot configured: plan list not found" in rendered
665
+ assert [(family, action, args) for family, action, args, _paths in calls] == [
666
+ ("feature", "status", ()),
667
+ ("refactor", "status", ()),
668
+ ]
669
+ assert _file_bytes_under(tmp_path) == before
670
+ assert not paths.feature_state_dir.exists()
671
+ assert not paths.bugfix_state_dir.exists()
672
+ assert not paths.refactor_state_dir.exists()
673
+
674
+
675
+ def test_status_all_uses_real_family_status_handlers_without_filesystem_mutation(tmp_path):
676
+ from prizmkit_runtime.paths import resolve_runtime_paths
677
+
678
+ paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT, project_root=tmp_path)
679
+ feature_plan = _write_family_status_plan(paths, "feature", "F-001")
680
+ refactor_plan = _write_family_status_plan(paths, "refactor", "R-001")
681
+ before = {
682
+ path.relative_to(tmp_path).as_posix(): (path.read_bytes(), path.stat().st_mtime_ns)
683
+ for path in (feature_plan, refactor_plan)
684
+ }
685
+
686
+ result = run_cli("--project-root", str(tmp_path), "status", "all")
687
+
688
+ after = {
689
+ path.relative_to(tmp_path).as_posix(): (path.read_bytes(), path.stat().st_mtime_ns)
690
+ for path in (feature_plan, refactor_plan)
691
+ }
692
+ assert result.returncode == 0, result.stderr
693
+ assert "FEATURE\n=======" in result.stdout
694
+ assert "Python feature status" in result.stdout
695
+ assert "BUGFIX\n======\nnot configured: plan list not found" in result.stdout
696
+ assert "REFACTOR\n========" in result.stdout
697
+ assert "Python refactor status" in result.stdout
698
+ assert after == before
699
+ assert not paths.feature_state_dir.exists()
700
+ assert not paths.bugfix_state_dir.exists()
701
+ assert not paths.refactor_state_dir.exists()
702
+
703
+
704
+ def test_status_all_reports_plan_and_handler_errors_but_renders_unaffected_sections(
705
+ tmp_path, monkeypatch
706
+ ):
707
+ from prizmkit_runtime import runners
708
+ from prizmkit_runtime.commands import CommandResult, handle_runtime_command
709
+ from prizmkit_runtime.paths import resolve_runtime_paths
710
+
711
+ paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT, project_root=tmp_path)
712
+ _write_family_status_plan(paths, "feature", "F-001")
713
+ paths.bugfix_plan.parent.mkdir(parents=True, exist_ok=True)
714
+ paths.bugfix_plan.write_text("{malformed", encoding="utf-8")
715
+ _write_family_status_plan(paths, "refactor", "R-001")
716
+
717
+ def fake_status(family, action, legacy_args, runtime_paths):
718
+ assert action == "status"
719
+ if family == "refactor":
720
+ raise RuntimeError("family handler exploded")
721
+ return CommandResult(0, f"Python {family} status", f"normal {family} status")
722
+
723
+ monkeypatch.setattr(runners, "run_pipeline_command", fake_status)
724
+ result = handle_runtime_command("status", "status", target="all", paths=paths)
725
+ rendered = result.render()
726
+
727
+ assert result.exit_code != 0
728
+ assert "FEATURE\n=======\nPython feature status\nnormal feature status" in rendered
729
+ assert "BUGFIX\n======\nerror: malformed plan list:" in rendered
730
+ assert "REFACTOR\n========\nerror: family handler exploded" in rendered
731
+
732
+
733
+ def test_status_all_reports_malformed_runtime_unparseable_and_nonzero_results(
734
+ tmp_path, monkeypatch
735
+ ):
736
+ from prizmkit_runtime import runners
737
+ from prizmkit_runtime.commands import CommandResult, handle_runtime_command
738
+ from prizmkit_runtime.paths import resolve_runtime_paths
739
+
740
+ paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT, project_root=tmp_path)
741
+ _write_family_status_plan(paths, "feature", "F-001")
742
+ _write_family_status_plan(paths, "bugfix", "B-001")
743
+ _write_family_status_plan(paths, "refactor", "R-001")
744
+ malformed_state = paths.feature_state_dir / "F-001" / "status.json"
745
+ malformed_state.parent.mkdir(parents=True, exist_ok=True)
746
+ malformed_state.write_text("not-json", encoding="utf-8")
747
+
748
+ def fake_status(family, action, legacy_args, runtime_paths):
749
+ if family == "bugfix":
750
+ return object()
751
+ return CommandResult(7 if family == "refactor" else 0, f"Python {family} status", "status output")
752
+
753
+ monkeypatch.setattr(runners, "run_pipeline_command", fake_status)
754
+ result = handle_runtime_command("status", "status", target="all", paths=paths)
755
+ rendered = result.render()
756
+
757
+ assert result.exit_code != 0
758
+ assert "FEATURE\n=======\nerror: malformed runtime state F-001/status.json" in rendered
759
+ assert "BUGFIX\n======\nerror: unparseable configured family result" in rendered
760
+ assert "REFACTOR\n========\nerror: Python refactor status\nstatus output" in rendered
761
+
762
+
763
+ def test_status_all_direct_api_rejects_family_specific_arguments_without_calling_handlers(
764
+ tmp_path, monkeypatch
765
+ ):
766
+ from prizmkit_runtime import runners
767
+ from prizmkit_runtime.commands import handle_runtime_command
768
+ from prizmkit_runtime.paths import resolve_runtime_paths
769
+
770
+ paths = resolve_runtime_paths(pipeline_root=PIPELINE_ROOT, project_root=tmp_path)
771
+ called = []
772
+ monkeypatch.setattr(runners, "run_pipeline_command", lambda *args: called.append(args))
773
+
774
+ result = handle_runtime_command(
775
+ "status", "status", target="all", paths=paths, legacy_args=("custom.json",)
776
+ )
777
+
778
+ assert result.exit_code == 2
779
+ assert "does not accept a plan path" in result.render()
780
+ assert called == []
449
781
 
450
782
 
451
783
  def test_interoperability_preserves_existing_utility_mapping():
@@ -481,9 +813,55 @@ def test_interoperability_preserves_existing_utility_mapping():
481
813
  assert actual_scripts == mapped_scripts | explicitly_library_only
482
814
  assert explicitly_library_only == {"continuation.py", "prompt_framework.py", "utils.py"}
483
815
  assert "parse-stream-progress.py" in mapped_scripts
816
+ assert "generate-recovery-prompt" not in UTILITY_ENTRYPOINTS
484
817
  assert "prizmkit-test-gate.py" not in mapped_scripts
485
818
 
486
819
 
820
+ def test_obsolete_runtime_recovery_ai_session_sources_are_absent():
821
+ assert not (PIPELINE_ROOT / "prizmkit_runtime" / "runner_recovery.py").exists()
822
+ assert not (PIPELINE_ROOT / "scripts" / "generate-recovery-prompt.py").exists()
823
+
824
+ forbidden_by_source = {
825
+ "prizmkit_runtime/cli.py": ('"recovery"',),
826
+ "prizmkit_runtime/commands.py": ('"recovery": ("run"', 'group == "recovery"'),
827
+ "prizmkit_runtime/runners.py": ("runner_recovery", "run_recovery_command"),
828
+ "prizmkit_runtime/paths.py": ("recovery_state_dir",),
829
+ "prizmkit_runtime/interoperability.py": ("generate-recovery-prompt",),
830
+ }
831
+ for relative_path, forbidden_tokens in forbidden_by_source.items():
832
+ content = (PIPELINE_ROOT / relative_path).read_text(encoding="utf-8")
833
+ for token in forbidden_tokens:
834
+ assert token not in content, f"{relative_path} still contains {token}"
835
+
836
+
837
+ @pytest.mark.parametrize(
838
+ ("path", "ordinary_command"),
839
+ [
840
+ (REPO_ROOT / "README.md", "python3 ./.prizmkit/dev-pipeline/cli.py run feature F-003"),
841
+ (
842
+ REPO_ROOT / "core" / "templates" / "project-memory-template.md",
843
+ "python3 ./.prizmkit/dev-pipeline/cli.py run <family> <task-id>",
844
+ ),
845
+ (
846
+ REPO_ROOT
847
+ / "core"
848
+ / "skills"
849
+ / "orchestration-skill"
850
+ / "workflows"
851
+ / "recovery-workflow"
852
+ / "SKILL.md",
853
+ "python3 ./.prizmkit/dev-pipeline/cli.py run feature F-001",
854
+ ),
855
+ ],
856
+ )
857
+ def test_canonical_interrupted_task_guidance_uses_recovery_workflow(path, ordinary_command):
858
+ content = path.read_text(encoding="utf-8")
859
+
860
+ assert "recovery-workflow" in content
861
+ assert ordinary_command in content
862
+ assert "cli.py recovery" not in content
863
+
864
+
487
865
  def test_boundary_dataclasses_are_cohesive_and_frozen():
488
866
  from prizmkit_runtime.gitops import BranchContext, WorktreePolicy
489
867
  from prizmkit_runtime.heartbeat import HeartbeatConfig, HeartbeatState
@@ -1017,6 +1395,31 @@ def test_progress_parser_keeps_pi_length_as_nonfatal_diagnostic_metadata():
1017
1395
  assert data["terminal_success_at"]
1018
1396
 
1019
1397
 
1398
+ @pytest.mark.parametrize("role", ("assistant", "toolResult"))
1399
+ def test_progress_parser_does_not_promote_pi_diagnostic_content_to_fatal_error(role):
1400
+ module = _load_progress_parser_module()
1401
+ tracker = module.ProgressTracker()
1402
+ tracker.process_event({"type": "session", "version": 3, "id": "pi-session", "cwd": "/tmp/project"})
1403
+ tracker.process_event({"type": "agent_start"})
1404
+ tracker.process_event({
1405
+ "type": "message_end",
1406
+ "message": {
1407
+ "role": role,
1408
+ "content": [{
1409
+ "type": "text",
1410
+ "text": 'continuation_reason = "context_overflow"\nerror = _error_text(result)',
1411
+ }],
1412
+ "stopReason": "toolUse",
1413
+ },
1414
+ })
1415
+
1416
+ data = tracker.to_dict()
1417
+
1418
+ assert data["api_error_code"] == ""
1419
+ assert data["fatal_error_code"] == ""
1420
+ assert data["last_result_is_error"] is False
1421
+
1422
+
1020
1423
  def test_progress_parser_recovers_from_retrying_pi_attempt_before_final_success():
1021
1424
  module = _load_progress_parser_module()
1022
1425
  tracker = module.ProgressTracker()
@@ -1677,6 +2080,57 @@ def test_session_persists_normalized_stream_without_raw_history(tmp_path, monkey
1677
2080
  assert not backup_log.exists()
1678
2081
 
1679
2082
 
2083
+ def test_pi_session_diagnostic_tool_output_does_not_trigger_fatal_termination(tmp_path):
2084
+ import prizmkit_runtime.sessions as sessions
2085
+
2086
+ fake_cli = tmp_path / "pi_diagnostic_cli.py"
2087
+ fake_cli.write_text(
2088
+ "#!/usr/bin/env python3\n"
2089
+ "import json, sys, time\n"
2090
+ "_prompt = sys.stdin.read()\n"
2091
+ "source = 'continuation_reason = \\\"context_overflow\\\"\\nerror = _error_text(result)'\n"
2092
+ "events = [\n"
2093
+ " {'type':'session','id':'s1'},\n"
2094
+ " {'type':'agent_start'},\n"
2095
+ " {'type':'turn_start'},\n"
2096
+ " {'type':'tool_execution_start','toolCallId':'c1','toolName':'read','args':{'path':'runtime.py'}},\n"
2097
+ " {'type':'tool_execution_end','toolCallId':'c1','toolName':'read','result':{'content':[{'type':'text','text':source}]},'isError':False},\n"
2098
+ " {'type':'message_end','message':{'role':'toolResult','toolCallId':'c1','content':[{'type':'text','text':source}],'stopReason':'toolUse'}},\n"
2099
+ "]\n"
2100
+ "for event in events: print(json.dumps(event), flush=True)\n"
2101
+ "time.sleep(1.5)\n"
2102
+ "message = {'role':'assistant','content':[{'type':'text','text':'Done'}],'stopReason':'stop'}\n"
2103
+ "print(json.dumps({'type':'message_end','message':message}), flush=True)\n"
2104
+ "print(json.dumps({'type':'agent_end','willRetry':False,'messages':[message]}), flush=True)\n",
2105
+ encoding="utf-8",
2106
+ )
2107
+ fake_cli.chmod(0o755)
2108
+ prompt = tmp_path / "prompt.md"
2109
+ prompt.write_text("hello", encoding="utf-8")
2110
+ session_log = tmp_path / "state" / "sessions" / "sess-diagnostic" / "logs" / "session.log"
2111
+ progress = session_log.with_name("progress.json")
2112
+
2113
+ result = sessions.AISessionLauncher(
2114
+ sessions.AISessionConfig(
2115
+ cli_command=str(fake_cli),
2116
+ platform="pi",
2117
+ model=None,
2118
+ prompt_path=prompt,
2119
+ cwd=tmp_path,
2120
+ log_path=session_log,
2121
+ progress_path=progress,
2122
+ )
2123
+ ).run()
2124
+
2125
+ progress_data = json.loads(progress.read_text(encoding="utf-8"))
2126
+ assert result.exit_code == 0
2127
+ assert result.fatal_error_code == ""
2128
+ assert result.progress_summary.result_subtype == "success"
2129
+ assert progress_data["fatal_error_code"] == ""
2130
+ assert not session_log.with_name("fatal-error.json").exists()
2131
+ assert not session_log.with_name("stale-kill.json").exists()
2132
+
2133
+
1680
2134
  def test_custom_session_launch_writes_existing_logs_and_progress(tmp_path):
1681
2135
  from prizmkit_runtime.launch_profiles import HeadlessLaunchProfile
1682
2136
  from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
@@ -2122,97 +2576,6 @@ def test_compact_live_heartbeat_stays_out_of_primary_and_backup_logs(tmp_path, c
2122
2576
  assert "stale:" not in backup_text.get("content", "")
2123
2577
 
2124
2578
 
2125
- def test_recovery_sessions_receive_live_heartbeat_configuration(tmp_path, monkeypatch):
2126
- import prizmkit_runtime.runner_recovery as runner_recovery
2127
-
2128
- project = tmp_path / "project"
2129
- pipeline = tmp_path / "pipeline"
2130
- recovery_state = tmp_path / "recovery"
2131
- project.mkdir()
2132
- pipeline.mkdir()
2133
- recovery_state.mkdir()
2134
- paths = type(
2135
- "Paths",
2136
- (),
2137
- {"project_root": project, "pipeline_root": pipeline, "recovery_state_dir": recovery_state},
2138
- )()
2139
- captured_config = {}
2140
-
2141
- monkeypatch.setattr(
2142
- runner_recovery,
2143
- "run_recovery_detect",
2144
- lambda _paths: (0, "{}", "", {"detected": True, "workflow_type": "feature"}),
2145
- )
2146
- monkeypatch.setattr(
2147
- runner_recovery,
2148
- "load_runtime_config",
2149
- lambda _paths: type(
2150
- "Config",
2151
- (),
2152
- {
2153
- "ai_client": type("Client", (), {"command": "fake-cli", "platform": "codebuddy"})(),
2154
- "model": "model",
2155
- "effort": "high",
2156
- },
2157
- )(),
2158
- )
2159
- monkeypatch.setattr(
2160
- runner_recovery,
2161
- "RunnerEnvironment",
2162
- type(
2163
- "EnvFactory",
2164
- (),
2165
- {
2166
- "from_env": staticmethod(
2167
- lambda: type(
2168
- "Env",
2169
- (),
2170
- {
2171
- "verbose": True,
2172
- "live_output": True,
2173
- "heartbeat_interval_seconds": 7,
2174
- "heartbeat_stale_threshold_seconds": 21,
2175
- "stale_kill_threshold_seconds": 42,
2176
- },
2177
- )()
2178
- )
2179
- },
2180
- ),
2181
- )
2182
- monkeypatch.setattr(runner_recovery, "detect_stream_json_support", lambda *_args: type("Support", (), {"enabled": True})())
2183
- monkeypatch.setattr(
2184
- runner_recovery.subprocess,
2185
- "run",
2186
- lambda *_args, **_kwargs: type("Completed", (), {"returncode": 0, "stdout": "0", "stderr": ""})(),
2187
- )
2188
- monkeypatch.setattr(runner_recovery, "_recovery_outcome", lambda *_args, **_kwargs: ("no_changes", {}))
2189
-
2190
- class FakeLauncher:
2191
- def __init__(self, config):
2192
- captured_config["config"] = config
2193
-
2194
- def run(self):
2195
- return type("Result", (), {"exit_code": 0})()
2196
-
2197
- monkeypatch.setattr(runner_recovery, "AISessionLauncher", FakeLauncher)
2198
-
2199
- code, stdout, stderr = runner_recovery.run_recovery(paths)
2200
- config = captured_config["config"]
2201
-
2202
- assert code == 0
2203
- assert stdout == "no_changes\n"
2204
- assert stderr == ""
2205
- assert config.live_output is True
2206
- assert config.verbose is True
2207
- assert config.use_stream_json is True
2208
- assert config.live_progress_interval_seconds == 7
2209
- assert config.heartbeat_stale_threshold_seconds == 21
2210
- assert config.stale_kill_threshold_seconds == 42
2211
- assert config.live_banner is False
2212
- assert config.suppress_stream_output is True
2213
-
2214
-
2215
-
2216
2579
  def test_session_summary_reports_normalized_subagents_and_child_liveness(tmp_path, capsys):
2217
2580
  import prizmkit_runtime.runners as runners
2218
2581
  from prizmkit_runtime.runner_models import SessionPaths