prizmkit 1.1.101 → 1.1.104

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 (33) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +1 -1
  3. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +14 -2
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_status.py +9 -3
  5. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +5 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +62 -4
  7. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +13 -13
  8. package/bundled/dev-pipeline/templates/agent-prompts/dev-implement.md +1 -1
  9. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +1 -1
  10. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +2 -2
  11. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +6 -7
  12. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +1 -1
  13. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +1 -1
  14. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +7 -7
  15. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +7 -7
  16. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +8 -17
  17. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +2 -2
  18. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -3
  19. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -3
  20. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +3 -8
  21. package/bundled/dev-pipeline/templates/sections/test-failure-recovery-agent.md +2 -2
  22. package/bundled/dev-pipeline/templates/sections/test-failure-recovery-lite.md +1 -1
  23. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +11 -7
  24. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +107 -11
  25. package/bundled/dev-pipeline/tests/test_unified_cli.py +76 -0
  26. package/bundled/skills/_metadata.json +1 -1
  27. package/package.json +1 -1
  28. package/src/config.js +9 -7
  29. package/src/external-skills.js +14 -14
  30. package/src/manifest.js +8 -3
  31. package/src/platforms.js +25 -0
  32. package/src/scaffold.js +11 -6
  33. package/src/upgrade.js +11 -2
@@ -1,6 +1,6 @@
1
1
  ## Test Failure Recovery Protocol
2
2
 
3
- Use this protocol whenever implementation or review tests fail. Its purpose is to distinguish tolerated pre-existing failures from blockers introduced by this session.
3
+ Use this protocol whenever the post-review PrizmKit Test gate fails. Its purpose is to distinguish tolerated pre-existing failures from blockers introduced by this session.
4
4
 
5
5
  ### Failure Classes
6
6
 
@@ -25,7 +25,7 @@ After **6 total test-fix rounds**, STOP trial-and-error. Write failure-log listi
25
25
 
26
26
  ### Success Rule
27
27
 
28
- Proceed to review only when:
28
+ Proceed to retrospective/commit only when:
29
29
 
30
30
  1. all new regressions are fixed;
31
31
  2. baseline failures are documented;
@@ -1,6 +1,6 @@
1
1
  ## Test Failure Recovery Protocol
2
2
 
3
- Use this protocol whenever implementation tests fail. Its purpose is to distinguish tolerated pre-existing failures from blockers introduced by this session.
3
+ Use this protocol whenever the post-review PrizmKit Test gate fails. Its purpose is to distinguish tolerated pre-existing failures from blockers introduced by this session.
4
4
 
5
5
  ### Failure Classes
6
6
 
@@ -406,7 +406,7 @@ class TestProcessModeBlocks:
406
406
  # ---------------------------------------------------------------------------
407
407
 
408
408
  class TestScopedFeatureTestGate:
409
- def test_standard_sections_insert_prizmkit_test_between_implement_and_review(self):
409
+ def test_standard_sections_insert_prizmkit_test_after_review(self):
410
410
  sections_dir = Path(__file__).resolve().parents[1] / "templates" / "sections"
411
411
  sections = assemble_sections(
412
412
  "standard",
@@ -419,7 +419,7 @@ class TestScopedFeatureTestGate:
419
419
  names = [name for name, _ in sections]
420
420
 
421
421
  assert "phase-prizmkit-test" in names
422
- assert names.index("phase-implement") < names.index("phase-prizmkit-test") < names.index("phase-review")
422
+ assert names.index("phase-implement") < names.index("phase-review") < names.index("phase-prizmkit-test")
423
423
 
424
424
  rendered = "\n".join(content for _, content in sections)
425
425
  assert "prizmkit-test-gate.py" in rendered
@@ -430,8 +430,12 @@ class TestScopedFeatureTestGate:
430
430
  assert ".prizmkit/refactor/" in rendered # explicitly forbidden in the gate wording
431
431
  assert "latest_test_report=$(python3" not in rendered
432
432
  assert "boundary_columns_ok=$(awk" not in rendered
433
+ assert "Runs tests using `TEST_CMD` after each task" not in rendered
434
+ assert "Run the full test suite" not in rendered
435
+ assert "before code review" not in rendered
436
+ assert "Precondition: the `prizmkit-test` checkpoint is completed" not in rendered
433
437
 
434
- def test_lite_sections_include_prizmkit_test_before_commit(self):
438
+ def test_lite_sections_include_review_before_prizmkit_test_and_commit(self):
435
439
  sections_dir = Path(__file__).resolve().parents[1] / "templates" / "sections"
436
440
  sections = assemble_sections(
437
441
  "lite",
@@ -444,10 +448,10 @@ class TestScopedFeatureTestGate:
444
448
  names = [name for name, _ in sections]
445
449
 
446
450
  assert "phase-prizmkit-test" in names
447
- assert "phase-review" not in names
448
- assert names.index("phase-implement") < names.index("phase-prizmkit-test") < names.index("phase-commit")
451
+ assert "phase-review" in names
452
+ assert names.index("phase-implement") < names.index("phase-review") < names.index("phase-prizmkit-test") < names.index("phase-commit")
449
453
 
450
- def test_checkpoint_orders_prizmkit_test_between_implement_and_review(self):
454
+ def test_checkpoint_orders_prizmkit_test_after_review(self):
451
455
  sections_dir = Path(__file__).resolve().parents[1] / "templates" / "sections"
452
456
  sections = assemble_sections(
453
457
  "standard",
@@ -468,7 +472,7 @@ class TestScopedFeatureTestGate:
468
472
  )
469
473
  skills = [step["skill"] for step in checkpoint["steps"]]
470
474
 
471
- assert skills.index("prizmkit-implement") < skills.index("prizmkit-test") < skills.index("prizmkit-code-review")
475
+ assert skills.index("prizmkit-implement") < skills.index("prizmkit-code-review") < skills.index("prizmkit-test")
472
476
  test_step = next(step for step in checkpoint["steps"] if step["skill"] == "prizmkit-test")
473
477
  assert test_step["required_artifacts"] == [
474
478
  ".prizmkit/specs/123-scope-test/test-report-path.txt",
@@ -103,7 +103,7 @@ def test_python_parity_suite_declares_coverage_for_all_legacy_unix_snapshot_scen
103
103
  "envResolution": {
104
104
  "verboseAndStreamJson": ["test_python_env_resolution_scenarios_match_snapshot_contracts"],
105
105
  "effortModelAndCli": ["test_ai_session_command_builders_match_unix_runtime_snapshot"],
106
- "worktreeIgnored": ["test_runner_environment_enables_worktree_by_default_and_accepts_falsey_opt_out"],
106
+ "worktreeIgnored": ["test_runner_environment_disables_worktree_by_default_and_accepts_truthy_opt_in"],
107
107
  "autoPushFlag": ["test_successful_worktree_session_merges_cleans_branch_and_honors_auto_push"],
108
108
  },
109
109
  "aiCliCommands": {
@@ -291,7 +291,7 @@ def test_runner_models_parse_legacy_args_and_environment(tmp_path):
291
291
  invocation = parse_invocation(
292
292
  family,
293
293
  "run",
294
- ("custom-list.json", "F-2", "--features", "F-003:F-001", "--max-infra-retries=5", "--mode", "full", "--critic"),
294
+ ("custom-list.json", "F-2", "--features", "F-003:F-001", "--max-retries", "7", "--max-infra-retries=5", "--mode", "full", "--critic"),
295
295
  )
296
296
  env = RunnerEnvironment.from_env(
297
297
  {
@@ -300,6 +300,7 @@ def test_runner_models_parse_legacy_args_and_environment(tmp_path):
300
300
  "DEV_BRANCH": "dev/custom",
301
301
  "VERBOSE": "1",
302
302
  "STALE_KILL_THRESHOLD": "9",
303
+ "MAX_RETRIES": "9",
303
304
  "STRICT_BEHAVIOR_CHECK": "false",
304
305
  }
305
306
  )
@@ -307,6 +308,7 @@ def test_runner_models_parse_legacy_args_and_environment(tmp_path):
307
308
  assert invocation.list_path == Path("custom-list.json")
308
309
  assert invocation.item_id == "F-002"
309
310
  assert invocation.item_filter == "F-003:F-001"
311
+ assert invocation.max_retries == 7
310
312
  assert invocation.max_infra_retries == 5
311
313
  assert invocation.mode == "full"
312
314
  assert invocation.critic is True
@@ -314,16 +316,18 @@ def test_runner_models_parse_legacy_args_and_environment(tmp_path):
314
316
  assert env.auto_push is True
315
317
  assert env.dev_branch == "dev/custom"
316
318
  assert env.stale_kill_threshold_seconds == 9
319
+ assert env.max_retries == 9
317
320
  assert env.strict_behavior_check is False
318
321
 
319
322
 
320
- def test_runner_environment_enables_worktree_by_default_and_accepts_falsey_opt_out():
323
+ def test_runner_environment_disables_worktree_by_default_and_accepts_truthy_opt_in():
321
324
  from prizmkit_runtime.runner_models import RunnerEnvironment
322
325
 
323
- assert RunnerEnvironment.from_env({}).use_worktree is True
324
- assert RunnerEnvironment.from_env({"USE_WORKTREE": "1"}).use_worktree is True
325
- assert RunnerEnvironment.from_env({"USE_WORKTREE": "true"}).use_worktree is True
326
- assert RunnerEnvironment.from_env({"USE_WORKTREE": "unexpected"}).use_worktree is True
326
+ assert RunnerEnvironment.from_env({}).use_worktree is False
327
+ assert RunnerEnvironment.from_env({"USE_WORKTREE": "unexpected"}).use_worktree is False
328
+
329
+ for truthy in ("1", "true", "yes", "on"):
330
+ assert RunnerEnvironment.from_env({"USE_WORKTREE": truthy}).use_worktree is True
327
331
 
328
332
  for falsey in ("0", "false", "no", "off"):
329
333
  assert RunnerEnvironment.from_env({"USE_WORKTREE": falsey}).use_worktree is False
@@ -355,6 +359,91 @@ def test_status_bridge_preserves_feature_filter_selection(tmp_path):
355
359
  assert update.data["context_overflow_count"] == 1
356
360
 
357
361
 
362
+ def test_status_bridge_passes_max_retries_from_invocation_or_environment(monkeypatch, tmp_path):
363
+ import subprocess
364
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
365
+ from prizmkit_runtime.runner_status import status_text
366
+
367
+ paths = _make_paths(tmp_path)
368
+ family = family_for("feature", paths)
369
+ explicit = parse_invocation(family, "run", ("--max-retries", "8"))
370
+ explicit = explicit.__class__(**{**explicit.__dict__, "list_path": paths.feature_plan})
371
+ fallback = parse_invocation(family, "run", ())
372
+ fallback = fallback.__class__(**{**fallback.__dict__, "list_path": paths.feature_plan})
373
+ calls = []
374
+
375
+ def fake_run(command, **kwargs):
376
+ calls.append(tuple(command))
377
+ return subprocess.CompletedProcess(command, 0, stdout="PIPELINE_COMPLETE\n", stderr="")
378
+
379
+ monkeypatch.setattr(subprocess, "run", fake_run)
380
+ monkeypatch.setenv("MAX_RETRIES", "6")
381
+
382
+ status_text(family, explicit, paths.project_root)
383
+ status_text(family, fallback, paths.project_root)
384
+
385
+ first = calls[0]
386
+ second = calls[1]
387
+ assert first[first.index("--max-retries") + 1] == "8"
388
+ assert second[second.index("--max-retries") + 1] == "6"
389
+
390
+
391
+ def test_stop_on_failure_does_not_stop_on_retryable_pending(monkeypatch, tmp_path):
392
+ from prizmkit_runtime import runners
393
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
394
+
395
+ paths = _make_paths(tmp_path)
396
+ family = family_for("feature", paths)
397
+ invocation = parse_invocation(family, "run", ())
398
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
399
+ queue = [
400
+ SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-001"}), data={"feature_id": "F-001"}, text=""),
401
+ SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-002"}), data={"feature_id": "F-002"}, text=""),
402
+ SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
403
+ ]
404
+ processed = []
405
+
406
+ monkeypatch.setenv("STOP_ON_FAILURE", "1")
407
+ monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
408
+
409
+ def fake_process(_family, _invocation, item_id, _paths, *, initial_metadata):
410
+ processed.append(item_id)
411
+ return "pending" if item_id == "F-001" else "completed"
412
+
413
+ monkeypatch.setattr(runners, "_process_item", fake_process)
414
+
415
+ result = runners._run_pipeline(family, invocation, paths)
416
+
417
+ assert processed == ["F-001", "F-002"]
418
+ assert result.completed is True
419
+ assert result.stopped_reason == "pipeline_complete"
420
+ assert result.details == ("F-001:pending", "F-002:completed")
421
+
422
+
423
+ def test_stop_on_failure_still_stops_on_terminal_status(monkeypatch, tmp_path):
424
+ from prizmkit_runtime import runners
425
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
426
+
427
+ paths = _make_paths(tmp_path)
428
+ family = family_for("feature", paths)
429
+ invocation = parse_invocation(family, "run", ())
430
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
431
+
432
+ monkeypatch.setenv("STOP_ON_FAILURE", "1")
433
+ monkeypatch.setattr(
434
+ runners,
435
+ "get_next",
436
+ lambda *args, **kwargs: SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-001"}), data={"feature_id": "F-001"}, text=""),
437
+ )
438
+ monkeypatch.setattr(runners, "_process_item", lambda *args, **kwargs: "failed")
439
+
440
+ result = runners._run_pipeline(family, invocation, paths)
441
+
442
+ assert result.completed is False
443
+ assert result.stopped_reason == "stop_on_failure"
444
+ assert result.last_status == "failed"
445
+
446
+
358
447
  def test_prompt_generation_result_preserves_per_item_model(monkeypatch, tmp_path):
359
448
  from prizmkit_runtime.runner_models import SessionPaths, family_for, parse_invocation
360
449
  from prizmkit_runtime.runner_prompts import generate_prompt
@@ -551,10 +640,11 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
551
640
 
552
641
 
553
642
  @pytest.mark.parametrize(("kind", "item_id", "branch"), [("feature", "F-001", "dev/F-001-test"), ("bugfix", "B-001", "bugfix/B-001-test")])
554
- def test_feature_and_bugfix_default_worktree_launches_from_worktree_and_renders_worktree_root(monkeypatch, tmp_path, kind, item_id, branch):
643
+ def test_feature_and_bugfix_explicit_worktree_launches_from_worktree_and_renders_worktree_root(monkeypatch, tmp_path, kind, item_id, branch):
555
644
  from prizmkit_runtime import runners
556
645
  from prizmkit_runtime.runner_models import family_for, parse_invocation
557
646
 
647
+ monkeypatch.setenv("USE_WORKTREE", "1")
558
648
  monkeypatch.setenv("DEV_BRANCH", branch)
559
649
  paths = _make_paths(tmp_path / kind)
560
650
  family = family_for(kind, paths)
@@ -584,6 +674,7 @@ def test_successful_worktree_session_merges_cleans_branch_and_honors_auto_push(m
584
674
  from prizmkit_runtime import runners
585
675
  from prizmkit_runtime.runner_models import family_for, parse_invocation
586
676
 
677
+ monkeypatch.setenv("USE_WORKTREE", "1")
587
678
  monkeypatch.setenv("DEV_BRANCH", "dev/F-001-test")
588
679
  monkeypatch.setenv("AUTO_PUSH", "1")
589
680
  paths = _make_paths(tmp_path)
@@ -604,6 +695,7 @@ def test_worktree_merge_conflict_writes_session_status_and_updates_merge_conflic
604
695
  from prizmkit_runtime import runners
605
696
  from prizmkit_runtime.runner_models import family_for, parse_invocation
606
697
 
698
+ monkeypatch.setenv("USE_WORKTREE", "1")
607
699
  monkeypatch.setenv("DEV_BRANCH", "dev/F-001-test")
608
700
  paths = _make_paths(tmp_path)
609
701
  family = family_for("feature", paths)
@@ -631,6 +723,7 @@ def test_terminal_worktree_failure_saves_wip_and_removes_worktree_without_deleti
631
723
  from prizmkit_runtime import runners
632
724
  from prizmkit_runtime.runner_models import family_for, parse_invocation
633
725
 
726
+ monkeypatch.setenv("USE_WORKTREE", "1")
634
727
  monkeypatch.setenv("DEV_BRANCH", "dev/F-001-test")
635
728
  paths = _make_paths(tmp_path)
636
729
  family = family_for("feature", paths)
@@ -647,7 +740,7 @@ def test_terminal_worktree_failure_saves_wip_and_removes_worktree_without_deleti
647
740
  assert observed.updates[-1][0] == "crashed"
648
741
 
649
742
 
650
- def test_default_worktree_materializes_ignored_support_assets_before_prompt_and_launch(monkeypatch, tmp_path):
743
+ def test_explicit_worktree_materializes_ignored_support_assets_before_prompt_and_launch(monkeypatch, tmp_path):
651
744
  from prizmkit_runtime import runners
652
745
  from prizmkit_runtime.paths import resolve_runtime_paths
653
746
  from prizmkit_runtime.runner_models import PromptGenerationResult, SessionClassification, family_for, parse_invocation
@@ -678,6 +771,7 @@ def test_default_worktree_materializes_ignored_support_assets_before_prompt_and_
678
771
  invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
679
772
  launch_cwds = []
680
773
 
774
+ monkeypatch.setenv("USE_WORKTREE", "1")
681
775
  monkeypatch.setenv("DEV_BRANCH", "dev/F-001-support-assets")
682
776
  monkeypatch.setattr(
683
777
  runners,
@@ -742,11 +836,10 @@ def test_default_worktree_materializes_ignored_support_assets_before_prompt_and_
742
836
  assert not launch_cwds[0].exists()
743
837
 
744
838
 
745
- def test_use_worktree_false_preserves_main_worktree_branch_launch(monkeypatch, tmp_path):
839
+ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch, tmp_path):
746
840
  from prizmkit_runtime import runners
747
841
  from prizmkit_runtime.runner_models import family_for, parse_invocation
748
842
 
749
- monkeypatch.setenv("USE_WORKTREE", "0")
750
843
  monkeypatch.setenv("DEV_BRANCH", "dev/F-001-test")
751
844
  paths = _make_paths(tmp_path)
752
845
  family = family_for("feature", paths)
@@ -775,6 +868,7 @@ def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outco
775
868
  from prizmkit_runtime import runners
776
869
  from prizmkit_runtime.runner_models import family_for, parse_invocation
777
870
 
871
+ monkeypatch.setenv("USE_WORKTREE", "1")
778
872
  monkeypatch.setenv("DEV_BRANCH", "dev/F-001-test")
779
873
  paths = _make_paths(tmp_path)
780
874
  family = family_for("feature", paths)
@@ -1029,6 +1123,7 @@ def test_python_daemon_controlled_start_status_stop_matches_snapshot(tmp_path):
1029
1123
  "#!/usr/bin/env python3\n"
1030
1124
  "import os, sys, time\n"
1031
1125
  "print('payload env=' + os.environ.get('FOO', ''))\n"
1126
+ "print('payload live=' + os.environ.get('PRIZMKIT_LIVE_OUTPUT', ''))\n"
1032
1127
  "print('payload args=' + ' '.join(sys.argv[1:]))\n"
1033
1128
  "sys.stdout.flush()\n"
1034
1129
  "time.sleep(30)\n",
@@ -1059,6 +1154,7 @@ def test_python_daemon_controlled_start_status_stop_matches_snapshot(tmp_path):
1059
1154
  assert json.loads(stop.render())["message"] in {"stopped", "already exited", "not running"}
1060
1155
  log_text = (paths.feature_state_dir / "pipeline-daemon.log").read_text(encoding="utf-8")
1061
1156
  assert "FOO=bar" in log_text or "payload env=bar" in log_text
1157
+ assert "payload live=0" in log_text
1062
1158
  assert "--project-root" in log_text
1063
1159
 
1064
1160
 
@@ -617,6 +617,82 @@ def test_session_dual_writes_progress_and_recovers_backup(tmp_path):
617
617
  assert second_log.read_text(encoding="utf-8") == "larger recovered log"
618
618
 
619
619
 
620
+ def test_session_live_output_prints_paths_and_verbose_cli_output(tmp_path, capsys):
621
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
622
+
623
+ fake_cli = tmp_path / "live_cli.py"
624
+ fake_cli.write_text(
625
+ "#!/usr/bin/env python3\n"
626
+ "print('live-line', flush=True)\n",
627
+ encoding="utf-8",
628
+ )
629
+ fake_cli.chmod(0o755)
630
+ prompt = tmp_path / "prompt.md"
631
+ prompt.write_text("hello", encoding="utf-8")
632
+ session_log = tmp_path / "state" / "sessions" / "sess-live" / "logs" / "session.log"
633
+ progress = session_log.with_name("progress.json")
634
+ heartbeat = session_log.with_name("heartbeat.json")
635
+
636
+ result = AISessionLauncher(
637
+ AISessionConfig(
638
+ cli_command=str(fake_cli),
639
+ platform="codebuddy",
640
+ model=None,
641
+ prompt_path=prompt,
642
+ cwd=tmp_path,
643
+ log_path=session_log,
644
+ progress_path=progress,
645
+ heartbeat_path=heartbeat,
646
+ verbose=True,
647
+ live_output=True,
648
+ )
649
+ ).run()
650
+
651
+ captured = capsys.readouterr()
652
+ assert result.exit_code == 0
653
+ assert "live-line" in captured.out
654
+ assert "[prizmkit] AI session started" in captured.err
655
+ assert f"[prizmkit] Prompt: {prompt}" in captured.err
656
+ assert f"[prizmkit] Session log: {session_log}" in captured.err
657
+ assert f"[prizmkit] Progress: {progress}" in captured.err
658
+ assert f"[prizmkit] Heartbeat: {heartbeat}" in captured.err
659
+ assert "[prizmkit] AI session finished" in captured.err
660
+
661
+
662
+ def test_session_live_output_does_not_forward_cli_output_without_verbose(tmp_path, capsys):
663
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
664
+
665
+ fake_cli = tmp_path / "quiet_cli.py"
666
+ fake_cli.write_text(
667
+ "#!/usr/bin/env python3\n"
668
+ "print('hidden-line', flush=True)\n",
669
+ encoding="utf-8",
670
+ )
671
+ fake_cli.chmod(0o755)
672
+ prompt = tmp_path / "prompt.md"
673
+ prompt.write_text("hello", encoding="utf-8")
674
+ session_log = tmp_path / "state" / "sessions" / "sess-quiet" / "logs" / "session.log"
675
+
676
+ result = AISessionLauncher(
677
+ AISessionConfig(
678
+ cli_command=str(fake_cli),
679
+ platform="codebuddy",
680
+ model=None,
681
+ prompt_path=prompt,
682
+ cwd=tmp_path,
683
+ log_path=session_log,
684
+ live_output=True,
685
+ )
686
+ ).run()
687
+
688
+ captured = capsys.readouterr()
689
+ assert result.exit_code == 0
690
+ assert "hidden-line" not in captured.out
691
+ assert "hidden-line" in session_log.read_text(encoding="utf-8")
692
+ assert "[prizmkit] AI session started" in captured.err
693
+ assert "[prizmkit] AI session finished" in captured.err
694
+
695
+
620
696
  def test_session_final_progress_merge_preserves_tracker_fatal_error(tmp_path):
621
697
  from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
622
698
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.101",
2
+ "version": "1.1.104",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.101",
3
+ "version": "1.1.104",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  confirmTeamMode,
55
55
  confirmPipeline,
56
56
  } from './prompts.js';
57
- import { expandPlatforms, isKnownPlatform, platformLabel, projectMemoryFile } from './platforms.js';
57
+ import { isKnownPlatform, platformLabel, projectMemoryFile, resolvePayloadPlatforms } from './platforms.js';
58
58
  import { normalizeRuntime, runtimeLabel } from './runtimes.js';
59
59
 
60
60
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -490,9 +490,9 @@ export async function runConfig(directory, options = {}) {
490
490
  }
491
491
 
492
492
  // Warn about destructive changes
493
- if (newConfig.platform !== currentConfig.platform) {
494
- const oldPlatforms = expandPlatforms(currentConfig.platform);
495
- const newPlatforms = expandPlatforms(newConfig.platform);
493
+ if (newConfig.platform !== currentConfig.platform || newConfig.aiCli !== currentConfig.aiCli) {
494
+ const oldPlatforms = resolvePayloadPlatforms(currentConfig.platform, currentConfig.aiCli);
495
+ const newPlatforms = resolvePayloadPlatforms(newConfig.platform, newConfig.aiCli);
496
496
  const dropping = oldPlatforms.filter(p => !newPlatforms.includes(p));
497
497
  if (dropping.length) {
498
498
  console.log(chalk.yellow(`\n ⚠ 将移除 ${dropping.map(platformLabel).join(', ')} 的 PrizmKit 文件`));
@@ -521,8 +521,8 @@ export async function runConfig(directory, options = {}) {
521
521
 
522
522
  console.log(chalk.bold('\n 应用配置变更...\n'));
523
523
 
524
- const oldPlatforms = expandPlatforms(currentConfig.platform);
525
- const newPlatforms = expandPlatforms(newConfig.platform);
524
+ const oldPlatforms = resolvePayloadPlatforms(currentConfig.platform, currentConfig.aiCli);
525
+ const newPlatforms = resolvePayloadPlatforms(newConfig.platform, newConfig.aiCli);
526
526
  const platformsToRemove = oldPlatforms.filter(p => !newPlatforms.includes(p));
527
527
  const platformsToAdd = newPlatforms.filter(p => !oldPlatforms.includes(p));
528
528
  const platformsToUpdate = newPlatforms.filter(p => oldPlatforms.includes(p));
@@ -554,7 +554,7 @@ export async function runConfig(directory, options = {}) {
554
554
  version: pkg.version, platform: newConfig.platform, suite: newConfig.suite,
555
555
  skills: newSkillList, agents: newAgentFiles, rules: newRuleFiles,
556
556
  pipeline: newPipelineFiles, team: newConfig.team, aiCli: newConfig.aiCli,
557
- runtime: newConfig.runtime, rulesPreset: newConfig.rules, extras: oldManifest?.files?.extras || [],
557
+ runtime: newConfig.runtime, rulesPreset: newConfig.rules, extras: oldManifest?.files?.extras || [], payloadPlatforms: newPlatforms,
558
558
  });
559
559
  const diff = diffManifest(oldManifest, newTempManifest);
560
560
 
@@ -580,6 +580,7 @@ export async function runConfig(directory, options = {}) {
580
580
  || newConfig.runtime !== currentConfig.runtime
581
581
  || newConfig.suite !== currentConfig.suite
582
582
  || newConfig.rules !== currentConfig.rules
583
+ || newConfig.aiCli !== currentConfig.aiCli
583
584
  || newConfig.team !== currentConfig.team;
584
585
 
585
586
  for (const p of allTargetPlatforms) {
@@ -675,6 +676,7 @@ export async function runConfig(directory, options = {}) {
675
676
  team: newConfig.team,
676
677
  aiCli: newConfig.aiCli,
677
678
  rulesPreset: newConfig.rules,
679
+ payloadPlatforms: newPlatforms,
678
680
  extras: oldManifest?.files?.extras || [],
679
681
  });
680
682
 
@@ -14,7 +14,7 @@
14
14
  import { execSync } from 'node:child_process';
15
15
  import path from 'path';
16
16
  import fs from 'fs-extra';
17
- import { expandPlatforms } from './platforms.js';
17
+ import { resolvePayloadPlatforms } from './platforms.js';
18
18
 
19
19
  /**
20
20
  * @param {Object} skill - Skill definition from external_skills.known
@@ -23,9 +23,9 @@ import { expandPlatforms } from './platforms.js';
23
23
  * skill.installAll {boolean} - If true, install all skills from the repo
24
24
  * @param {string} platform - 'claude' | 'codebuddy' | 'codex' | 'all' ('both' supported only for legacy manifests)
25
25
  * @param {string} projectRoot - Target project root
26
- * @param {boolean} dryRun
26
+ * @param {string} [aiCli] - optional AI CLI command that may require an additional payload platform
27
27
  */
28
- export async function installExternalSkill(skill, platform, projectRoot, dryRun) {
28
+ export async function installExternalSkill(skill, platform, projectRoot, dryRun, aiCli = '') {
29
29
  if (!skill.repo) {
30
30
  throw new Error(`Skill "${skill.name}" has no repo URL defined`);
31
31
  }
@@ -40,7 +40,7 @@ export async function installExternalSkill(skill, platform, projectRoot, dryRun)
40
40
  if (skill.installAll) {
41
41
  console.log(` [dry-run] → all skills from ${skill.repo}`);
42
42
  } else {
43
- const targets = getTargetDirs(platform, projectRoot)
43
+ const targets = getTargetDirs(platform, projectRoot, aiCli)
44
44
  .map(d => path.relative(projectRoot, path.join(d, skill.name, 'SKILL.md')));
45
45
  for (const t of targets) console.log(` [dry-run] → ${t}`);
46
46
  }
@@ -61,7 +61,7 @@ export async function installExternalSkill(skill, platform, projectRoot, dryRun)
61
61
  .filter(d => d.isDirectory())
62
62
  .map(d => d.name);
63
63
 
64
- for (const targetDir of getTargetDirs(platform, projectRoot)) {
64
+ for (const targetDir of getTargetDirs(platform, projectRoot, aiCli)) {
65
65
  for (const skillName of skillDirs) {
66
66
  const srcDir = path.join(agentsSkillsDir, skillName);
67
67
  const destDir = path.join(targetDir, skillName);
@@ -81,7 +81,7 @@ export async function installExternalSkill(skill, platform, projectRoot, dryRun)
81
81
 
82
82
  const content = await fs.readFile(canonicalSkillMd, 'utf8');
83
83
 
84
- for (const targetDir of getTargetDirs(platform, projectRoot)) {
84
+ for (const targetDir of getTargetDirs(platform, projectRoot, aiCli)) {
85
85
  const skillDir = path.join(targetDir, skill.name);
86
86
  if (path.resolve(skillDir) === path.resolve(path.dirname(canonicalSkillMd))) {
87
87
  continue;
@@ -94,7 +94,7 @@ export async function installExternalSkill(skill, platform, projectRoot, dryRun)
94
94
 
95
95
  // Clean up npx skills artifacts after copying. Preserve .agents/skills when
96
96
  // Codex is selected because it is the official repository skill location.
97
- if (!expandPlatforms(platform).includes('codex')) {
97
+ if (!resolvePayloadPlatforms(platform, aiCli).includes('codex')) {
98
98
  await fs.remove(path.join(projectRoot, '.agents')).catch(() => {});
99
99
  }
100
100
  await fs.remove(path.join(projectRoot, 'skills-lock.json')).catch(() => {});
@@ -104,15 +104,15 @@ export async function installExternalSkill(skill, platform, projectRoot, dryRun)
104
104
  * Remove all artifacts created by `npx skills` that are not needed by PrizmKit.
105
105
  * Call this once after all external skills have been installed.
106
106
  *
107
- * @param {string} projectRoot
108
107
  * @param {string} platform - 'claude' | 'codebuddy' | 'codex' | 'all' ('both' supported only for legacy manifests)
108
+ * @param {string} [aiCli] - optional AI CLI command that may require an additional payload platform
109
109
  */
110
- export async function cleanExternalSkillArtifacts(projectRoot, platform) {
110
+ export async function cleanExternalSkillArtifacts(projectRoot, platform, aiCli = '') {
111
111
  const keepDirs = new Set(
112
- getTargetDirs(platform, projectRoot).map(d => path.resolve(d))
112
+ getTargetDirs(platform, projectRoot, aiCli).map(d => path.resolve(d))
113
113
  );
114
114
 
115
- const selectedPlatforms = expandPlatforms(platform);
115
+ const selectedPlatforms = resolvePayloadPlatforms(platform, aiCli);
116
116
  const dirsToCheck = [
117
117
  selectedPlatforms.includes('codex') ? null : '.agents',
118
118
  '.agent',
@@ -152,7 +152,7 @@ export async function cleanExternalSkillArtifacts(projectRoot, platform) {
152
152
  const abs = path.join(projectRoot, dir);
153
153
  if (!await fs.pathExists(abs)) continue;
154
154
  // Only remove if this platform dir is entirely unneeded
155
- const isKept = getTargetDirs(platform, projectRoot)
155
+ const isKept = getTargetDirs(platform, projectRoot, aiCli)
156
156
  .some(d => path.resolve(d).startsWith(path.resolve(abs) + path.sep));
157
157
  if (isKept) continue;
158
158
  try {
@@ -166,8 +166,8 @@ export async function cleanExternalSkillArtifacts(projectRoot, platform) {
166
166
  await fs.remove(path.join(projectRoot, 'skills-lock.json')).catch(() => {});
167
167
  }
168
168
 
169
- function getTargetDirs(platform, projectRoot) {
170
- return expandPlatforms(platform).map(p => {
169
+ function getTargetDirs(platform, projectRoot, aiCli = '') {
170
+ return resolvePayloadPlatforms(platform, aiCli).map(p => {
171
171
  if (p === 'codebuddy') return path.join(projectRoot, '.codebuddy', 'skills');
172
172
  if (p === 'codex') return path.join(projectRoot, '.agents', 'skills');
173
173
  return path.join(projectRoot, '.claude', 'skills');
package/src/manifest.js CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import fs from 'fs-extra';
11
11
  import path from 'path';
12
- import { expandPlatforms, projectMemoryFile } from './platforms.js';
12
+ import { projectMemoryFile, resolvePayloadPlatforms } from './platforms.js';
13
13
  import { normalizeRuntime } from './runtimes.js';
14
14
 
15
15
  const MANIFEST_FILE = 'manifest.json';
@@ -58,7 +58,7 @@ export async function writeManifest(projectRoot, data) {
58
58
  * @param {boolean} params.team - whether team config was installed
59
59
  * @param {string} [params.aiCli] - AI CLI command
60
60
  * @param {string} [params.rulesPreset] - rules preset name
61
- * @param {string[]} [params.extras] - installed extras (e.g. ['playwright-cli'])
61
+ * @param {string[]} [params.payloadPlatforms] - concrete platform payloads installed for platform + aiCli
62
62
  * @returns {Object} manifest
63
63
  */
64
64
  export function buildManifest({
@@ -74,8 +74,12 @@ export function buildManifest({
74
74
  aiCli,
75
75
  rulesPreset,
76
76
  extras,
77
+ payloadPlatforms,
77
78
  }) {
78
79
  const now = new Date().toISOString();
80
+ const installedPayloadPlatforms = payloadPlatforms?.length
81
+ ? [...new Set(payloadPlatforms)]
82
+ : resolvePayloadPlatforms(platform, aiCli);
79
83
  return {
80
84
  version,
81
85
  installedAt: now,
@@ -95,7 +99,8 @@ export function buildManifest({
95
99
  rules: [...rules],
96
100
  pipeline: pipeline ? [...pipeline] : [],
97
101
  extras: extras ? [...extras] : [],
98
- other: [...new Set(expandPlatforms(platform).map(projectMemoryFile).filter(Boolean))],
102
+ payloadPlatforms: installedPayloadPlatforms,
103
+ other: [...new Set(installedPayloadPlatforms.map(projectMemoryFile).filter(Boolean))],
99
104
  },
100
105
  };
101
106
  }
package/src/platforms.js CHANGED
@@ -10,6 +10,31 @@ export function expandPlatforms(platform) {
10
10
  return PLATFORM_GROUPS[platform] || LEGACY_PLATFORM_GROUPS[platform] || [platform];
11
11
  }
12
12
 
13
+ export function platformFromAiCli(aiCli) {
14
+ if (!aiCli || typeof aiCli !== 'string') return null;
15
+ const token = aiCli.trim().split(/\s+/)[0]?.replace(/^['"]|['"]$/g, '');
16
+ const name = token.split(/[\\/]/).pop()?.toLowerCase() || '';
17
+ if (name.includes('claude')) return 'claude';
18
+ if (name.includes('codex')) return 'codex';
19
+ if (name.includes('cbc') || name.includes('codebuddy')) return 'codebuddy';
20
+ return null;
21
+ }
22
+
23
+ export function uniquePlatforms(platforms) {
24
+ return [...new Set(platforms.filter(p => PLATFORM_IDS.includes(p)))];
25
+ }
26
+
27
+ export function resolvePayloadPlatforms(platform, aiCli = '') {
28
+ const selected = uniquePlatforms(expandPlatforms(platform));
29
+ const cliPlatform = platformFromAiCli(aiCli);
30
+ return uniquePlatforms([...selected, cliPlatform].filter(Boolean));
31
+ }
32
+
33
+ export function payloadPlatformLabel(platform, aiCli = '') {
34
+ const payload = resolvePayloadPlatforms(platform, aiCli);
35
+ return payload.map(platformLabel).join(' + ');
36
+ }
37
+
13
38
  export function isKnownPlatform(platform) {
14
39
  return PLATFORM_IDS.includes(platform) || Object.hasOwn(PLATFORM_GROUPS, platform);
15
40
  }