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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +14 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runner_status.py +9 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +5 -1
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +62 -4
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +13 -13
- package/bundled/dev-pipeline/templates/agent-prompts/dev-implement.md +1 -1
- package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +2 -2
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +6 -7
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +7 -7
- package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +7 -7
- package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +8 -17
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -3
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -3
- package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +3 -8
- package/bundled/dev-pipeline/templates/sections/test-failure-recovery-agent.md +2 -2
- package/bundled/dev-pipeline/templates/sections/test-failure-recovery-lite.md +1 -1
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +11 -7
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +107 -11
- package/bundled/dev-pipeline/tests/test_unified_cli.py +76 -0
- package/bundled/skills/_metadata.json +1 -1
- package/package.json +1 -1
- package/src/config.js +9 -7
- package/src/external-skills.js +14 -14
- package/src/manifest.js +8 -3
- package/src/platforms.js +25 -0
- package/src/scaffold.js +11 -6
- package/src/upgrade.js +11 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
## Test Failure Recovery Protocol
|
|
2
2
|
|
|
3
|
-
Use this protocol whenever
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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"
|
|
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
|
|
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-
|
|
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": ["
|
|
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
|
|
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
|
|
324
|
-
assert RunnerEnvironment.from_env({"USE_WORKTREE": "
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
confirmTeamMode,
|
|
55
55
|
confirmPipeline,
|
|
56
56
|
} from './prompts.js';
|
|
57
|
-
import {
|
|
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 =
|
|
495
|
-
const newPlatforms =
|
|
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 =
|
|
525
|
-
const newPlatforms =
|
|
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
|
|
package/src/external-skills.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 (!
|
|
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 =
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
}
|