prizmkit 1.1.145 → 1.1.146

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.145",
3
- "bundledAt": "2026-07-22T06:54:58.505Z",
4
- "bundledFrom": "1dccf5b"
2
+ "frameworkVersion": "1.1.146",
3
+ "bundledAt": "2026-07-23T01:20:01.078Z",
4
+ "bundledFrom": "248d714"
5
5
  }
@@ -43,10 +43,7 @@ class PreflightWorkspace:
43
43
 
44
44
  @property
45
45
  def preservable(self) -> tuple[str, ...]:
46
- return _unique_paths(
47
- path for path in (*self.tracked, *self.visible_untracked)
48
- if path != PIPELINE_MANIFEST_PATH
49
- )
46
+ return _unique_paths((*self.tracked, *self.visible_untracked))
50
47
 
51
48
 
52
49
  @dataclass(frozen=True)
@@ -95,6 +92,23 @@ def _literal_pathspecs(paths: Sequence[str]) -> tuple[str, ...]:
95
92
  return tuple(f":(top,literal){path}" for path in paths)
96
93
 
97
94
 
95
+ def _preflight_add_paths(
96
+ project_root: Path,
97
+ paths: Sequence[str],
98
+ ) -> tuple[tuple[str, ...], GitCommandResult | None]:
99
+ """Skip paths already staged as deletions while retaining exact path handling."""
100
+ indexed_result = run_git_command(project_root, ("ls-files", "-z", "--", *_literal_pathspecs(paths)))
101
+ if indexed_result.return_code != 0:
102
+ return (), indexed_result
103
+ indexed = set(indexed_result.stdout.split("\0"))
104
+ add_paths = tuple(
105
+ path
106
+ for path in paths
107
+ if path in indexed or (project_root / path).exists() or (project_root / path).is_symlink()
108
+ )
109
+ return add_paths, None
110
+
111
+
98
112
  def _status_record_paths(stdout: str) -> tuple[str, ...]:
99
113
  """Decode exact paths from NUL-delimited porcelain-v1 records."""
100
114
  records = stdout.split("\0")
@@ -153,12 +167,6 @@ def _preflight_integrity_failure(project_root: Path, workspace: PreflightWorkspa
153
167
  return _preflight_failure(
154
168
  f"Refusing to continue because {PIPELINE_MANIFEST_PATH} is not a JSON object"
155
169
  )
156
- if PIPELINE_MANIFEST_PATH in workspace.tracked:
157
- return _preflight_failure(
158
- "Refusing to auto-commit installer-owned .prizmkit/manifest.json; "
159
- "finish or revert the framework install/upgrade before starting a task"
160
- )
161
-
162
170
  marker_paths = []
163
171
  for relative in workspace.preservable:
164
172
  path = project_root / relative
@@ -257,9 +265,13 @@ def commit_preflight_ready_changes(project_root: Path, item_id: str) -> Bookkeep
257
265
  if not paths:
258
266
  return BookkeepingResult(attempted=False, committed=False)
259
267
  pathspecs = _literal_pathspecs(paths)
260
- add_result = run_git_command(project_root, ("add", "-A", "-f", "--", *pathspecs))
261
- if add_result.return_code != 0:
262
- return BookkeepingResult(attempted=True, committed=False, result=add_result)
268
+ add_paths, add_paths_failure = _preflight_add_paths(project_root, paths)
269
+ if add_paths_failure is not None:
270
+ return BookkeepingResult(attempted=True, committed=False, result=add_paths_failure)
271
+ if add_paths:
272
+ add_result = run_git_command(project_root, ("add", "-A", "-f", "--", *_literal_pathspecs(add_paths)))
273
+ if add_result.return_code != 0:
274
+ return BookkeepingResult(attempted=True, committed=False, result=add_result)
263
275
  result = run_git_command(
264
276
  project_root,
265
277
  ("commit", "--no-verify", "-m", f"chore: ready for {item_id}", "--", *pathspecs),
@@ -399,14 +399,65 @@ def parse_stream_progress_lines(lines: Sequence[str]) -> ProgressSummary:
399
399
  )
400
400
 
401
401
 
402
- def _load_progress_tracker_class():
402
+ class _FallbackProgressTracker:
403
+ """Bounded additive progress fallback when the shared parser cannot load."""
404
+
405
+ def __init__(self):
406
+ self._event_type_counts: Counter[str] = Counter()
407
+ self._summary = ProgressSummary()
408
+
409
+ def process_event(self, event: object) -> None:
410
+ line = json.dumps(event, ensure_ascii=False, default=str) if isinstance(event, dict) else str(event)
411
+ current = parse_stream_progress_lines((line,))
412
+ self._event_type_counts.update(current.event_type_counts)
413
+ self._summary = ProgressSummary(
414
+ event_type_counts=dict(self._event_type_counts),
415
+ claude_session_id=current.claude_session_id or self._summary.claude_session_id,
416
+ cb_session_id=current.cb_session_id or self._summary.cb_session_id,
417
+ result_subtype=current.result_subtype or self._summary.result_subtype,
418
+ stop_reason=current.stop_reason or self._summary.stop_reason,
419
+ has_usage=current.has_usage or self._summary.has_usage,
420
+ permission_denial_count=self._summary.permission_denial_count + current.permission_denial_count,
421
+ permission_denials=tuple((*self._summary.permission_denials, *current.permission_denials)[-10:]),
422
+ fatal_error_code=current.fatal_error_code or self._summary.fatal_error_code,
423
+ terminal_result_text=current.terminal_result_text or self._summary.terminal_result_text,
424
+ errors=tuple((*self._summary.errors, *current.errors)[-10:]),
425
+ )
426
+
427
+ def to_dict(self) -> dict[str, object]:
428
+ return self._summary.to_dict()
429
+
430
+
431
+ def _load_stream_parser_classes() -> tuple[type | None, type | None]:
403
432
  parser_path = Path(__file__).resolve().parents[1] / "scripts" / "parse-stream-progress.py"
404
433
  spec = importlib.util.spec_from_file_location("prizmkit_parse_stream_progress", parser_path)
405
434
  if spec is None or spec.loader is None:
406
- return None
435
+ return None, None
407
436
  module = importlib.util.module_from_spec(spec)
408
437
  spec.loader.exec_module(module)
409
- return getattr(module, "ProgressTracker", None)
438
+ return (
439
+ getattr(module, "ProgressTracker", None),
440
+ getattr(module, "SemanticLogNormalizer", None),
441
+ )
442
+
443
+
444
+ def _load_progress_tracker_class():
445
+ """Backward-compatible access to the shared aggregate tracker class."""
446
+ tracker_class, _ = _load_stream_parser_classes()
447
+ return tracker_class
448
+
449
+
450
+ def _fallback_semantic_output(line: str, event: object | None) -> str:
451
+ """Preserve useful output compactly if the shared semantic adapter is unavailable."""
452
+ if not isinstance(event, dict):
453
+ text = line.rstrip("\n")
454
+ return f"[OUTPUT] {text}\n" if text else ""
455
+ event_type = str(event.get("type") or "event")
456
+ value = event.get("error") or event.get("result") or event.get("message")
457
+ if isinstance(value, str) and value.strip():
458
+ label = "[ERROR]" if event.get("error") else "[OUTPUT]"
459
+ return f"{label} {value}\n"
460
+ return f"[OUTPUT] event={event_type}\n"
410
461
 
411
462
 
412
463
  def _write_progress_summary(progress_path: Path | None, summary: ProgressSummary) -> None:
@@ -678,55 +729,85 @@ def _reader_thread(
678
729
  pipe,
679
730
  session_log: Path,
680
731
  backup_log: Path,
681
- output_queue: "queue.Queue[str]",
732
+ result_queue: "queue.Queue[dict[str, object]]",
682
733
  progress_path: Path | None = None,
683
734
  live_output: bool = False,
684
735
  ) -> None:
685
- lines: list[str] = []
686
- tracker = None
687
736
  tracker_lock = threading.Lock()
688
737
  progress_refresh_stop = threading.Event()
689
738
  progress_refresh_thread: threading.Thread | None = None
690
739
  refresh_progress: Callable[[bool], None] | None = None
691
- tracker_class = _load_progress_tracker_class() if progress_path is not None else None
692
- if tracker_class is not None:
693
- try:
694
- tracker = tracker_class(session_log)
695
- except Exception:
696
- tracker = None
697
- if progress_path is not None and tracker is not None:
740
+ tracker_class, normalizer_class = _load_stream_parser_classes()
741
+ try:
742
+ tracker = tracker_class(session_log) if tracker_class is not None else _FallbackProgressTracker()
743
+ except Exception:
744
+ tracker = _FallbackProgressTracker()
745
+ try:
746
+ normalizer = normalizer_class() if normalizer_class is not None else None
747
+ except Exception:
748
+ normalizer = None
749
+ if progress_path is not None:
698
750
  progress_refresh_thread, refresh_progress = _start_idle_progress_refresh(
699
751
  progress_path,
700
752
  tracker,
701
753
  tracker_lock,
702
754
  progress_refresh_stop,
703
755
  )
756
+ final_data: dict[str, object] = {}
704
757
  try:
705
- for line in iter(pipe.readline, ""):
706
- if not line:
707
- break
708
- with session_log.open("a", encoding="utf-8") as main_fh:
709
- main_fh.write(line)
710
- with backup_log.open("a", encoding="utf-8") as backup_fh:
711
- backup_fh.write(line)
712
- output_queue.put(line)
713
- if live_output:
714
- _emit_live_output(line)
715
- lines.append(line)
716
- if progress_path is not None:
717
- if tracker is not None:
718
- stripped = line.strip()
719
- with tracker_lock:
720
- if stripped:
721
- try:
722
- tracker.process_event(json.loads(stripped))
723
- except json.JSONDecodeError:
758
+ with session_log.open("a", encoding="utf-8") as main_fh, backup_log.open("a", encoding="utf-8") as backup_fh:
759
+ for line in iter(pipe.readline, ""):
760
+ if not line:
761
+ break
762
+ stripped = line.strip()
763
+ event: object | None = None
764
+ tracker_failure = ""
765
+ if stripped:
766
+ try:
767
+ event = json.loads(stripped)
768
+ except json.JSONDecodeError:
769
+ event = None
770
+ try:
771
+ with tracker_lock:
772
+ if isinstance(event, dict):
773
+ tracker.process_event(event)
774
+ elif isinstance(tracker, _FallbackProgressTracker):
775
+ tracker.process_event(stripped)
776
+ else:
724
777
  tracker.last_text_snippet = stripped[:120]
725
778
  tracker._detect_terminal_error(stripped, require_error_context=True)
726
- data = tracker.to_dict()
727
- _write_progress_data(progress_path, data)
728
- else:
729
- _write_progress_summary(progress_path, parse_stream_progress_lines(lines))
779
+ except Exception as exc:
780
+ tracker_failure = f"[ERROR] progress parser rejected event={str(event.get('type') or 'unknown') if isinstance(event, dict) else 'text'}: {type(exc).__name__}\n"
781
+ try:
782
+ if normalizer is not None:
783
+ rendered = normalizer.process_event(event) if isinstance(event, dict) else normalizer.process_text(line)
784
+ else:
785
+ rendered = _fallback_semantic_output(line, event)
786
+ except Exception as exc:
787
+ event_name = str(event.get("type") or "unknown") if isinstance(event, dict) else "text"
788
+ rendered = (
789
+ f"[ERROR] semantic log adapter rejected event={event_name}: {type(exc).__name__}\n"
790
+ + _fallback_semantic_output(line, event)
791
+ )
792
+ rendered = tracker_failure + rendered
793
+ if rendered:
794
+ main_fh.write(rendered)
795
+ backup_fh.write(rendered)
796
+ main_fh.flush()
797
+ backup_fh.flush()
798
+ if live_output:
799
+ _emit_live_output(rendered)
800
+ if normalizer is not None:
801
+ trailing = normalizer.finish()
802
+ if trailing:
803
+ main_fh.write(trailing)
804
+ backup_fh.write(trailing)
805
+ main_fh.flush()
806
+ backup_fh.flush()
807
+ if live_output:
808
+ _emit_live_output(trailing)
809
+ with tracker_lock:
810
+ final_data = tracker.to_dict()
730
811
  finally:
731
812
  if refresh_progress is not None:
732
813
  try:
@@ -736,6 +817,16 @@ def _reader_thread(
736
817
  progress_refresh_stop.set()
737
818
  if progress_refresh_thread is not None:
738
819
  progress_refresh_thread.join(timeout=2)
820
+ if not final_data:
821
+ try:
822
+ with tracker_lock:
823
+ final_data = tracker.to_dict()
824
+ except Exception:
825
+ final_data = {}
826
+ try:
827
+ result_queue.put_nowait(final_data)
828
+ except queue.Full:
829
+ pass
739
830
  try:
740
831
  pipe.close()
741
832
  except Exception:
@@ -810,14 +901,14 @@ class AISessionLauncher:
810
901
 
811
902
  handle = launch_process(spec, stderr=subprocess.STDOUT)
812
903
  assert handle.popen is not None
813
- output_queue: queue.Queue[str] = queue.Queue()
904
+ reader_result_queue: queue.Queue[dict[str, object]] = queue.Queue(maxsize=1)
814
905
  reader = threading.Thread(
815
906
  target=_reader_thread,
816
907
  args=(
817
908
  handle.popen.stdout,
818
909
  session_log,
819
910
  backup_log,
820
- output_queue,
911
+ reader_result_queue,
821
912
  self.config.progress_path,
822
913
  self.config.live_output and self.config.verbose and not self.config.suppress_stream_output,
823
914
  ),
@@ -931,19 +1022,24 @@ class AISessionLauncher:
931
1022
  )
932
1023
  termination_reason = "stale_session"
933
1024
  recovery = recover_session_log(session_log, backup_log)
934
- lines = session_log.read_text(encoding="utf-8", errors="ignore").splitlines()
935
- summary = parse_stream_progress_lines(lines)
936
- merged_progress: dict[str, object] = summary.to_dict()
1025
+ try:
1026
+ reader_progress = reader_result_queue.get_nowait()
1027
+ except queue.Empty:
1028
+ reader_progress = {}
1029
+ existing_progress: dict[str, object] = {}
1030
+ if self.config.progress_path is not None and self.config.progress_path.is_file():
1031
+ try:
1032
+ loaded_progress = json.loads(self.config.progress_path.read_text(encoding="utf-8"))
1033
+ if isinstance(loaded_progress, dict):
1034
+ existing_progress = loaded_progress
1035
+ except (OSError, json.JSONDecodeError):
1036
+ existing_progress = {}
1037
+ merged_progress = dict(existing_progress)
1038
+ merged_progress.update(reader_progress)
1039
+ fallback_summary = ProgressSummary()
937
1040
  if self.config.progress_path is not None:
938
- existing_progress = {}
939
- if self.config.progress_path.is_file():
940
- try:
941
- existing_progress = json.loads(self.config.progress_path.read_text(encoding="utf-8"))
942
- except (OSError, json.JSONDecodeError):
943
- existing_progress = {}
944
- merged_progress = _merged_progress(existing_progress, summary)
945
1041
  self.config.progress_path.write_text(json.dumps(merged_progress, indent=2, sort_keys=True) + "\n", encoding="utf-8")
946
- final_summary = _summary_from_progress_dict(merged_progress, summary)
1042
+ final_summary = _summary_from_progress_dict(merged_progress, fallback_summary)
947
1043
  ended = time.time()
948
1044
  result = AISessionResult(
949
1045
  command=command,