prizmkit 1.1.144 → 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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +25 -13
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +145 -49
- package/bundled/dev-pipeline/scripts/parse-stream-progress.py +585 -1
- package/bundled/dev-pipeline/tests/live_ai_cli_log_smoke.py +206 -0
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +279 -12
- package/bundled/dev-pipeline/tests/test_unified_cli.py +484 -0
- package/bundled/skills/_metadata.json +1 -1
- package/package.json +1 -1
package/bundled/VERSION.json
CHANGED
|
@@ -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
|
-
|
|
261
|
-
if
|
|
262
|
-
return BookkeepingResult(attempted=True, committed=False, 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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
tracker.process_event(
|
|
723
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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,
|
|
1042
|
+
final_summary = _summary_from_progress_dict(merged_progress, fallback_summary)
|
|
947
1043
|
ended = time.time()
|
|
948
1044
|
result = AISessionResult(
|
|
949
1045
|
command=command,
|