prizmkit 1.1.122 → 1.1.123

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.122",
3
- "bundledAt": "2026-07-11T17:19:34.691Z",
4
- "bundledFrom": "fbcb3f4"
2
+ "frameworkVersion": "1.1.123",
3
+ "bundledAt": "2026-07-11T17:38:07.991Z",
4
+ "bundledFrom": "9a8c719"
5
5
  }
@@ -107,31 +107,39 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
107
107
  from .runner_models import PipelineRunResult
108
108
 
109
109
  processed = 0
110
- details: list[str] = []
110
+ item_statuses: dict[str, str] = {}
111
111
  if invocation.item_id:
112
112
  try:
113
113
  status = _process_item(family, invocation, invocation.item_id, paths, initial_metadata={})
114
114
  except KeyboardInterrupt:
115
- return PipelineRunResult(False, 1, "interrupted", "interrupted", tuple(details))
116
- return PipelineRunResult(status == "completed", 1, "single_item_done", status, tuple(details))
115
+ return PipelineRunResult(False, 1, "interrupted", "interrupted")
116
+ return PipelineRunResult(status == "completed", 1, "single_item_done", status)
117
117
 
118
118
  while True:
119
119
  next_result = get_next(family, invocation, paths.project_root)
120
+ details = _render_item_statuses(item_statuses)
120
121
  if not next_result.ok:
121
122
  return PipelineRunResult(False, processed, "get_next_failed", details=(next_result.text,))
122
123
  marker = next_result.stdout.strip()
123
124
  if marker == "PIPELINE_COMPLETE":
124
- if _has_non_terminal_details(details):
125
+ blocking_statuses = _blocking_statuses(family, item_statuses)
126
+ if blocking_statuses:
125
127
  return PipelineRunResult(
126
128
  False,
127
129
  processed,
128
130
  "pipeline_complete_with_non_terminal_history",
129
- _last_detail_status(details),
130
- tuple(details),
131
+ blocking_statuses[-1],
132
+ details,
131
133
  )
132
- return PipelineRunResult(True, processed, "pipeline_complete", details=tuple(details))
134
+ return PipelineRunResult(
135
+ True,
136
+ processed,
137
+ "pipeline_complete",
138
+ _last_item_status(item_statuses),
139
+ details,
140
+ )
133
141
  if marker == "PIPELINE_BLOCKED":
134
- return PipelineRunResult(False, processed, "pipeline_blocked", details=tuple(details))
142
+ return PipelineRunResult(False, processed, "pipeline_blocked", details=details)
135
143
  if not isinstance(next_result.data, dict):
136
144
  return PipelineRunResult(False, processed, "invalid_get_next_output", details=(marker[:500],))
137
145
  item_id = str(next_result.data.get(f"{family.kind}_id") or next_result.data.get("feature_id") or next_result.data.get("bug_id") or next_result.data.get("refactor_id") or "")
@@ -141,50 +149,50 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
141
149
  final_status = _process_item(family, invocation, item_id, paths, initial_metadata=next_result.data)
142
150
  except KeyboardInterrupt:
143
151
  processed += 1
144
- details.append(f"{item_id}:interrupted")
145
- return PipelineRunResult(False, processed, "interrupted", "interrupted", tuple(details))
152
+ item_statuses[item_id] = "interrupted"
153
+ return PipelineRunResult(
154
+ False,
155
+ processed,
156
+ "interrupted",
157
+ "interrupted",
158
+ _render_item_statuses(item_statuses),
159
+ )
146
160
  processed += 1
147
- details.append(f"{item_id}:{final_status}")
148
- if final_status == "completed":
149
- _emit_info(f"Pausing 5s before next {family.kind}...")
161
+ item_statuses[item_id] = final_status
162
+ if final_status in _completion_compatible_statuses(family):
163
+ if final_status == "completed":
164
+ _emit_info(f"Pausing 5s before next {family.kind}...")
150
165
  continue
151
166
  if final_status == "pending":
152
167
  continue
168
+ details = _render_item_statuses(item_statuses)
153
169
  if RunnerEnvironment.from_env().stop_on_failure:
154
- return PipelineRunResult(False, processed, "stop_on_failure", final_status, tuple(details))
170
+ return PipelineRunResult(False, processed, "stop_on_failure", final_status, details)
155
171
  if final_status in {"failed", "needs_info"}:
156
- return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
172
+ return PipelineRunResult(False, processed, "item_failed", final_status, details)
173
+
157
174
 
175
+ _COMPLETION_COMPATIBLE_STATUSES = {"completed", "skipped", "auto_skipped"}
158
176
 
159
- _NON_TERMINAL_DETAIL_STATUSES = {
160
- "pending",
161
- "retryable",
162
- "in_progress",
163
- "infra_error",
164
- "context_overflow",
165
- "stalled_context_continuation",
166
- "crashed",
167
- "timed_out",
168
- "failed",
169
- "needs_info",
170
- "merge_conflict",
171
- "interrupted",
172
- "unknown",
173
- }
174
177
 
178
+ def _completion_compatible_statuses(family: RunnerFamily) -> set[str]:
179
+ statuses = set(_COMPLETION_COMPATIBLE_STATUSES)
180
+ if family.kind == "feature":
181
+ statuses.add("split")
182
+ return statuses
175
183
 
176
- def _detail_status(detail: str) -> str:
177
- return detail.rsplit(":", 1)[-1] if ":" in detail else detail
178
184
 
185
+ def _render_item_statuses(item_statuses: Mapping[str, str]) -> tuple[str, ...]:
186
+ return tuple(f"{item_id}:{status}" for item_id, status in item_statuses.items())
179
187
 
180
- def _last_detail_status(details: list[str]) -> str:
181
- if not details:
182
- return ""
183
- return _detail_status(details[-1])
188
+
189
+ def _last_item_status(item_statuses: Mapping[str, str]) -> str:
190
+ return next(reversed(item_statuses.values()), "")
184
191
 
185
192
 
186
- def _has_non_terminal_details(details: list[str]) -> bool:
187
- return any(_detail_status(detail) in _NON_TERMINAL_DETAIL_STATUSES for detail in details)
193
+ def _blocking_statuses(family: RunnerFamily, item_statuses: Mapping[str, str]) -> tuple[str, ...]:
194
+ compatible = _completion_compatible_statuses(family)
195
+ return tuple(status for status in item_statuses.values() if status not in compatible)
188
196
 
189
197
 
190
198
  _SEPARATOR = "─" * 52
@@ -627,39 +627,194 @@ def test_status_bridge_passes_max_retries_from_invocation_or_environment(monkeyp
627
627
  assert second[second.index("--max-retries") + 1] == "6"
628
628
 
629
629
 
630
- def test_stop_on_failure_does_not_stop_on_retryable_pending(monkeypatch, tmp_path):
630
+ @pytest.mark.parametrize(
631
+ ("kind", "item_key"),
632
+ (("feature", "feature_id"), ("bugfix", "bug_id"), ("refactor", "refactor_id")),
633
+ )
634
+ def test_pipeline_aggregate_replaces_same_item_status_in_first_seen_order(monkeypatch, tmp_path, kind, item_key):
631
635
  from prizmkit_runtime import runners
632
636
  from prizmkit_runtime.runner_models import family_for, parse_invocation
633
637
 
634
638
  paths = _make_paths(tmp_path)
635
- family = family_for("feature", paths)
639
+ family = family_for(kind, paths)
636
640
  invocation = parse_invocation(family, "run", ())
637
- invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
641
+ statuses = iter(("completed", "pending", "completed"))
638
642
  queue = [
639
- SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-001"}), data={"feature_id": "F-001"}, text=""),
640
- SimpleNamespace(ok=True, stdout=json.dumps({"feature_id": "F-002"}), data={"feature_id": "F-002"}, text=""),
643
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-049"}, text=""),
644
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-050"}, text=""),
645
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-050"}, text=""),
641
646
  SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
642
647
  ]
643
- processed = []
644
648
 
645
- monkeypatch.setenv("STOP_ON_FAILURE", "1")
646
649
  monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
650
+ monkeypatch.setattr(runners, "_process_item", lambda *args, **kwargs: next(statuses))
651
+ monkeypatch.setattr(runners, "_emit_info", lambda *args, **kwargs: None)
647
652
 
648
- def fake_process(_family, _invocation, item_id, _paths, *, initial_metadata):
649
- processed.append(item_id)
650
- return "pending" if item_id == "F-001" else "completed"
653
+ result = runners._run_pipeline(family, invocation, paths)
654
+
655
+ assert result.processed == 3
656
+ assert result.completed is True
657
+ assert result.stopped_reason == "pipeline_complete"
658
+ assert result.last_status == "completed"
659
+ assert result.details == ("F-049:completed", "F-050:completed")
651
660
 
652
- monkeypatch.setattr(runners, "_process_item", fake_process)
661
+
662
+ @pytest.mark.parametrize(
663
+ ("kind", "item_key"),
664
+ (("feature", "feature_id"), ("bugfix", "bug_id"), ("refactor", "refactor_id")),
665
+ )
666
+ def test_pipeline_aggregate_keeps_distinct_unresolved_item_blocking(monkeypatch, tmp_path, kind, item_key):
667
+ from prizmkit_runtime import runners
668
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
669
+
670
+ paths = _make_paths(tmp_path)
671
+ family = family_for(kind, paths)
672
+ invocation = parse_invocation(family, "run", ())
673
+ statuses = iter(("pending", "completed"))
674
+ queue = [
675
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-001"}, text=""),
676
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-002"}, text=""),
677
+ SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
678
+ ]
679
+
680
+ monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
681
+ monkeypatch.setattr(runners, "_process_item", lambda *args, **kwargs: next(statuses))
682
+ monkeypatch.setattr(runners, "_emit_info", lambda *args, **kwargs: None)
653
683
 
654
684
  result = runners._run_pipeline(family, invocation, paths)
655
685
 
656
- assert processed == ["F-001", "F-002"]
686
+ assert result.processed == 2
657
687
  assert result.completed is False
658
688
  assert result.stopped_reason == "pipeline_complete_with_non_terminal_history"
659
- assert result.last_status == "completed"
689
+ assert result.last_status == "pending"
660
690
  assert result.details == ("F-001:pending", "F-002:completed")
661
691
 
662
692
 
693
+ @pytest.mark.parametrize(
694
+ ("kind", "item_key"),
695
+ (("feature", "feature_id"), ("bugfix", "bug_id"), ("refactor", "refactor_id")),
696
+ )
697
+ def test_pipeline_aggregate_repeated_terminal_update_remains_unique(monkeypatch, tmp_path, kind, item_key):
698
+ from prizmkit_runtime import runners
699
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
700
+
701
+ paths = _make_paths(tmp_path)
702
+ family = family_for(kind, paths)
703
+ invocation = parse_invocation(family, "run", ())
704
+ queue = [
705
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-001"}, text=""),
706
+ SimpleNamespace(ok=True, stdout="", data={item_key: "F-001"}, text=""),
707
+ SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
708
+ ]
709
+
710
+ monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
711
+ monkeypatch.setattr(runners, "_process_item", lambda *args, **kwargs: "completed")
712
+ monkeypatch.setattr(runners, "_emit_info", lambda *args, **kwargs: None)
713
+
714
+ result = runners._run_pipeline(family, invocation, paths)
715
+
716
+ assert result.processed == 2
717
+ assert result.details == ("F-001:completed",)
718
+ assert result.last_status == "completed"
719
+
720
+
721
+ @pytest.mark.parametrize("kind", ("feature", "bugfix", "refactor"))
722
+ def test_pipeline_aggregate_empty_authoritative_completion(monkeypatch, tmp_path, kind):
723
+ from prizmkit_runtime import runners
724
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
725
+
726
+ paths = _make_paths(tmp_path)
727
+ family = family_for(kind, paths)
728
+ invocation = parse_invocation(family, "run", ())
729
+ monkeypatch.setattr(
730
+ runners,
731
+ "get_next",
732
+ lambda *args, **kwargs: SimpleNamespace(
733
+ ok=True,
734
+ stdout="PIPELINE_COMPLETE\n",
735
+ data=None,
736
+ text="PIPELINE_COMPLETE",
737
+ ),
738
+ )
739
+
740
+ result = runners._run_pipeline(family, invocation, paths)
741
+
742
+ assert result.processed == 0
743
+ assert result.completed is True
744
+ assert result.stopped_reason == "pipeline_complete"
745
+ assert result.last_status == ""
746
+ assert result.details == ()
747
+
748
+
749
+ @pytest.mark.parametrize(
750
+ ("kind", "status", "expected_completed"),
751
+ (
752
+ ("feature", "completed", True),
753
+ ("feature", "skipped", True),
754
+ ("feature", "auto_skipped", True),
755
+ ("feature", "split", True),
756
+ ("bugfix", "completed", True),
757
+ ("bugfix", "skipped", True),
758
+ ("bugfix", "auto_skipped", True),
759
+ ("bugfix", "split", False),
760
+ ("refactor", "completed", True),
761
+ ("refactor", "skipped", True),
762
+ ("refactor", "auto_skipped", True),
763
+ ("refactor", "split", False),
764
+ ),
765
+ )
766
+ def test_pipeline_aggregate_completion_compatible_statuses(monkeypatch, tmp_path, kind, status, expected_completed):
767
+ from prizmkit_runtime import runners
768
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
769
+
770
+ paths = _make_paths(tmp_path)
771
+ family = family_for(kind, paths)
772
+ invocation = parse_invocation(family, "run", ())
773
+ item_key = {"feature": "feature_id", "bugfix": "bug_id", "refactor": "refactor_id"}[kind]
774
+ queue = [
775
+ SimpleNamespace(ok=True, stdout="", data={item_key: "X-001"}, text=""),
776
+ SimpleNamespace(ok=True, stdout="PIPELINE_COMPLETE\n", data=None, text="PIPELINE_COMPLETE"),
777
+ ]
778
+
779
+ monkeypatch.setenv("STOP_ON_FAILURE", "0")
780
+ monkeypatch.setattr(runners, "get_next", lambda *args, **kwargs: queue.pop(0))
781
+ monkeypatch.setattr(runners, "_process_item", lambda *args, **kwargs: status)
782
+
783
+ result = runners._run_pipeline(family, invocation, paths)
784
+
785
+ assert result.completed is expected_completed
786
+ assert result.stopped_reason == (
787
+ "pipeline_complete" if expected_completed else "pipeline_complete_with_non_terminal_history"
788
+ )
789
+ assert result.last_status == status
790
+ assert result.details == (f"X-001:{status}",)
791
+
792
+
793
+ @pytest.mark.parametrize("kind", ("feature", "bugfix", "refactor"))
794
+ def test_run_pipeline_command_omits_empty_last_status_detail(monkeypatch, tmp_path, kind):
795
+ from prizmkit_runtime import runners
796
+ from prizmkit_runtime.runner_models import PipelineRunResult
797
+
798
+ paths = _make_paths(tmp_path)
799
+ plan_path = {
800
+ "feature": paths.feature_plan,
801
+ "bugfix": paths.bugfix_plan,
802
+ "refactor": paths.refactor_plan,
803
+ }[kind]
804
+ plan_path.write_text("{}", encoding="utf-8")
805
+ monkeypatch.setattr(
806
+ runners,
807
+ "_run_pipeline",
808
+ lambda *args, **kwargs: PipelineRunResult(True, 0, "pipeline_complete"),
809
+ )
810
+
811
+ result = runners.run_pipeline_command(kind, "run", (), paths)
812
+
813
+ assert result.exit_code == 0
814
+ assert result.message == "processed=0 completed=True stopped_reason=pipeline_complete"
815
+ assert result.details == ()
816
+
817
+
663
818
  def test_pipeline_complete_rejects_retryable_detail(monkeypatch, tmp_path):
664
819
  from prizmkit_runtime import runners
665
820
  from prizmkit_runtime.runner_models import family_for, parse_invocation
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.122",
2
+ "version": "1.1.123",
3
3
  "skills": {
4
4
  "prizmkit": {
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.122",
3
+ "version": "1.1.123",
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": {