prizmkit 1.1.70 → 1.1.72

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.70",
3
- "bundledAt": "2026-06-10T03:59:21.944Z",
4
- "bundledFrom": "e948b58"
2
+ "frameworkVersion": "1.1.72",
3
+ "bundledAt": "2026-06-11T12:58:26.870Z",
4
+ "bundledFrom": "099177b"
5
5
  }
@@ -444,6 +444,433 @@ prizm_detect_infra_error() {
444
444
  return 1
445
445
  }
446
446
 
447
+ # Detect AI runtime/provider request failures that are not caused by generated
448
+ # project code. Unlike generic infra errors, these can be deterministic for the
449
+ # current transcript (for example context_too_large), so the runner must first
450
+ # check semantic completion before deciding whether to retry.
451
+ prizm_detect_ai_runtime_error() {
452
+ local session_log="${1:-}"
453
+ local progress_json="${2:-}"
454
+
455
+ if [[ -n "$progress_json" && -f "$progress_json" ]]; then
456
+ local fatal_error_code
457
+ fatal_error_code=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
458
+ import json
459
+ import sys
460
+
461
+ try:
462
+ with open(sys.argv[1], encoding="utf-8") as fh:
463
+ progress = json.load(fh)
464
+ except Exception:
465
+ raise SystemExit(0)
466
+
467
+ code = progress.get("fatal_error_code")
468
+ if code:
469
+ print(str(code))
470
+ PY
471
+ )
472
+ if [[ -n "$fatal_error_code" ]]; then
473
+ return 0
474
+ fi
475
+ fi
476
+
477
+ local haystack=""
478
+ if [[ -n "$session_log" && -f "$session_log" ]]; then
479
+ haystack="$(tail -c 65536 "$session_log" 2>/dev/null || true)"
480
+ fi
481
+ if [[ -n "$progress_json" && -f "$progress_json" ]]; then
482
+ haystack+=$'\n'
483
+ haystack+="$(cat "$progress_json" 2>/dev/null || true)"
484
+ fi
485
+
486
+ [[ -n "$haystack" ]] || return 1
487
+
488
+ if printf '%s' "$haystack" | grep -Eiq \
489
+ 'context_too_large|model_context_window_exceeded|input exceeds the context window|context window of this model|context window (was )?exceeded|exceeded (the )?context window|invalid_request_error.*context window|context window.*invalid_request_error'; then
490
+ if printf '%s' "$haystack" | grep -Eiq \
491
+ 'api error|invalid_request_error|api_error_status|api_error_code|status[[:space:]]*[:=]?[[:space:]]*(400|413)|last_result_is_error[[:space:]"'\'':=]+true|is_error[[:space:]"'\'':=]+true'; then
492
+ return 0
493
+ fi
494
+ fi
495
+
496
+ return 1
497
+ }
498
+
499
+ prizm_feature_slug_from_list() {
500
+ local feature_list="$1"
501
+ local feature_id="$2"
502
+ python3 - "$feature_list" "$feature_id" <<'PY'
503
+ import json
504
+ import re
505
+ import sys
506
+
507
+ feature_list, feature_id = sys.argv[1], sys.argv[2]
508
+ with open(feature_list, encoding="utf-8") as fh:
509
+ data = json.load(fh)
510
+ for feature in data.get("features", []):
511
+ if feature.get("id") == feature_id:
512
+ number = feature.get("id", "").replace("F-", "").replace("f-", "").zfill(3)
513
+ title = str(feature.get("title", "")).lower()
514
+ title = re.sub(r"[^a-z0-9\s-]", "", title)
515
+ title = re.sub(r"[\s]+", "-", title.strip())
516
+ title = re.sub(r"-+", "-", title).strip("-")
517
+ print(f"{number}-{title}" if title else number)
518
+ raise SystemExit(0)
519
+ raise SystemExit(1)
520
+ PY
521
+ }
522
+
523
+ prizm_checkpoint_all_complete() {
524
+ local checkpoint_file="$1"
525
+ [[ -f "$checkpoint_file" ]] || return 1
526
+ python3 - "$checkpoint_file" <<'PY'
527
+ import json
528
+ import sys
529
+
530
+ try:
531
+ with open(sys.argv[1], encoding="utf-8") as fh:
532
+ data = json.load(fh)
533
+ except Exception:
534
+ raise SystemExit(2)
535
+ steps = data.get("steps")
536
+ if not isinstance(steps, list) or not steps:
537
+ raise SystemExit(1)
538
+ for step in steps:
539
+ if not isinstance(step, dict) or step.get("status") not in ("completed", "skipped"):
540
+ raise SystemExit(1)
541
+ raise SystemExit(0)
542
+ PY
543
+ }
544
+
545
+ # Return the successful feature commit SHA if one exists in base_ref..HEAD.
546
+ # Prefer messages containing the feature ID. If checkpoint is complete, allow an
547
+ # older prompt variant only when the non-WIP commit identifies the feature title.
548
+ prizm_find_feature_commit() {
549
+ local project_root="$1"
550
+ local base_ref="$2"
551
+ local feature_id="$3"
552
+ local allow_title_fallback="${4:-false}"
553
+ local feature_title="${5:-}"
554
+
555
+ git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 1
556
+
557
+ local range="${base_ref}..HEAD"
558
+ if [[ -z "$base_ref" ]] || ! git -C "$project_root" rev-parse --verify "$base_ref" >/dev/null 2>&1; then
559
+ range="HEAD"
560
+ fi
561
+
562
+ local feature_commit
563
+ feature_commit=$(git -C "$project_root" log "$range" --format='%H%x09%s' 2>/dev/null \
564
+ | awk -F '\t' -v fid="$feature_id" 'index($2, fid) > 0 && $2 !~ /^wip(\(|:)/ { print $1; exit }')
565
+ if [[ -n "$feature_commit" ]]; then
566
+ printf '%s\n' "$feature_commit"
567
+ return 0
568
+ fi
569
+
570
+ if [[ "$allow_title_fallback" == "true" && -n "$feature_title" ]]; then
571
+ feature_commit=$(git -C "$project_root" log "$range" --format='%H%x09%s' 2>/dev/null \
572
+ | python3 -c '
573
+ import re
574
+ import sys
575
+
576
+ title = sys.argv[1]
577
+
578
+ def words(text):
579
+ return [w for w in re.split(r"[^a-z0-9]+", text.lower()) if len(w) >= 3]
580
+
581
+ title_words = words(title)
582
+ if not title_words:
583
+ raise SystemExit(1)
584
+ # Require all title words for short titles, or a strong majority for longer
585
+ # titles so punctuation/articles do not make older commits unmatchable.
586
+ required = len(title_words) if len(title_words) <= 3 else max(3, int(len(title_words) * 0.75 + 0.999))
587
+ for line in sys.stdin:
588
+ commit, sep, subject = line.rstrip("\n").partition("\t")
589
+ if not sep or re.match(r"^wip(\(|:)", subject):
590
+ continue
591
+ subject_words = set(words(subject))
592
+ if sum(1 for word in title_words if word in subject_words) >= required:
593
+ print(commit)
594
+ raise SystemExit(0)
595
+ raise SystemExit(1)
596
+ ' "$feature_title")
597
+ if [[ -n "$feature_commit" ]]; then
598
+ printf '%s\n' "$feature_commit"
599
+ return 0
600
+ fi
601
+ fi
602
+
603
+ return 1
604
+ }
605
+
606
+ # Semantic completion means the durable workflow checkpoint is complete and a
607
+ # non-WIP feature commit exists. This intentionally runs before exit-code based
608
+ # failure classification so delayed post-commit model errors cannot strand a
609
+ # completed feature on its dev branch.
610
+ PRIZM_SEMANTIC_FEATURE_SLUG=""
611
+ PRIZM_SEMANTIC_COMMIT_SHA=""
612
+ prizm_feature_semantically_complete() {
613
+ local feature_list="$1"
614
+ local feature_id="$2"
615
+ local project_root="$3"
616
+ local base_ref="$4"
617
+ local prizmkit_dir="$5"
618
+
619
+ PRIZM_SEMANTIC_FEATURE_SLUG=""
620
+ PRIZM_SEMANTIC_COMMIT_SHA=""
621
+
622
+ local feature_slug
623
+ feature_slug=$(prizm_feature_slug_from_list "$feature_list" "$feature_id" 2>/dev/null) || return 1
624
+ local checkpoint_file="$prizmkit_dir/specs/${feature_slug}/workflow-checkpoint.json"
625
+ prizm_checkpoint_all_complete "$checkpoint_file" || return 1
626
+
627
+ local feature_title
628
+ feature_title=$(python3 - "$feature_list" "$feature_id" <<'PY' 2>/dev/null || true
629
+ import json
630
+ import sys
631
+ with open(sys.argv[1], encoding="utf-8") as fh:
632
+ data = json.load(fh)
633
+ for feature in data.get("features", []):
634
+ if feature.get("id") == sys.argv[2]:
635
+ print(feature.get("title", ""))
636
+ break
637
+ PY
638
+ )
639
+
640
+ local commit_sha
641
+ commit_sha=$(prizm_find_feature_commit "$project_root" "$base_ref" "$feature_id" true "$feature_title" 2>/dev/null) || return 1
642
+
643
+ PRIZM_SEMANTIC_FEATURE_SLUG="$feature_slug"
644
+ PRIZM_SEMANTIC_COMMIT_SHA="$commit_sha"
645
+ return 0
646
+ }
647
+
648
+ prizm_preserve_post_completion_dirty() {
649
+ local project_root="$1"
650
+ local artifact_dir="$2"
651
+ local feature_id="$3"
652
+ local session_id="${4:-}"
653
+
654
+ git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 1
655
+
656
+ local dirty_status
657
+ dirty_status=$(git -C "$project_root" status --porcelain --untracked-files=all 2>/dev/null || true)
658
+ [[ -n "$dirty_status" ]] || return 0
659
+
660
+ mkdir -p "$artifact_dir" || return 1
661
+
662
+ local status_file="$artifact_dir/post-completion-status.txt"
663
+ local patch_file="$artifact_dir/post-completion-dirty.patch"
664
+ local staged_patch_file="$artifact_dir/post-completion-staged.patch"
665
+ local untracked_manifest="$artifact_dir/post-completion-untracked.txt"
666
+ local findings_file="$artifact_dir/post-completion-findings.md"
667
+ local untracked_dir="$artifact_dir/untracked"
668
+
669
+ printf '%s\n' "$dirty_status" > "$status_file" || return 1
670
+ git -C "$project_root" diff --binary > "$patch_file" 2>/dev/null || return 1
671
+ git -C "$project_root" diff --cached --binary > "$staged_patch_file" 2>/dev/null || return 1
672
+
673
+ : > "$untracked_manifest" || return 1
674
+ local untracked_tmp
675
+ untracked_tmp=$(mktemp 2>/dev/null || mktemp -t prizm-untracked) || return 1
676
+ git -C "$project_root" ls-files --others --exclude-standard -z > "$untracked_tmp" 2>/dev/null || {
677
+ rm -f "$untracked_tmp"
678
+ return 1
679
+ }
680
+
681
+ if [[ -s "$untracked_tmp" ]]; then
682
+ mkdir -p "$untracked_dir" || {
683
+ rm -f "$untracked_tmp"
684
+ return 1
685
+ }
686
+ while IFS= read -r -d '' rel_path; do
687
+ [[ -n "$rel_path" ]] || continue
688
+ printf '%s\n' "$rel_path" >> "$untracked_manifest" || {
689
+ rm -f "$untracked_tmp"
690
+ return 1
691
+ }
692
+ local source_path="$project_root/$rel_path"
693
+ local dest_path="$untracked_dir/$rel_path"
694
+ mkdir -p "$(dirname "$dest_path")" || {
695
+ rm -f "$untracked_tmp"
696
+ return 1
697
+ }
698
+ if [[ -f "$source_path" ]]; then
699
+ cp -p "$source_path" "$dest_path" || {
700
+ rm -f "$untracked_tmp"
701
+ return 1
702
+ }
703
+ elif [[ -d "$source_path" ]]; then
704
+ mkdir -p "$dest_path" || {
705
+ rm -f "$untracked_tmp"
706
+ return 1
707
+ }
708
+ fi
709
+ done < "$untracked_tmp"
710
+ fi
711
+
712
+ cat > "$findings_file" <<EOF
713
+ # Post-completion dirty changes preserved
714
+
715
+ - Feature: $feature_id
716
+ - Session: ${session_id:-unknown}
717
+ - Reason: workflow checkpoint and feature commit were already complete, but delayed post-commit activity left the working tree dirty.
718
+
719
+ ## Recovery guidance
720
+
721
+ The finalized feature commit was kept unchanged for merge. Review these follow-up artifacts separately; do not assume they were merged:
722
+
723
+ - \`post-completion-status.txt\` — original dirty working tree status
724
+ - \`post-completion-dirty.patch\` — unstaged tracked changes
725
+ - \`post-completion-staged.patch\` — staged changes
726
+ - \`post-completion-untracked.txt\` and \`untracked/\` — untracked files copied before cleanup
727
+ EOF
728
+
729
+ git -C "$project_root" reset --hard >/dev/null 2>&1 || {
730
+ rm -f "$untracked_tmp"
731
+ return 1
732
+ }
733
+
734
+ while IFS= read -r -d '' rel_path; do
735
+ [[ -n "$rel_path" ]] || continue
736
+ case "$rel_path" in
737
+ .prizmkit/*) continue ;;
738
+ esac
739
+ rm -f "$project_root/$rel_path" 2>/dev/null || true
740
+ done < "$untracked_tmp"
741
+ rm -f "$untracked_tmp"
742
+
743
+ dirty_status=$(git -C "$project_root" status --porcelain --untracked-files=all 2>/dev/null | grep -v '^?? .prizmkit/' || true)
744
+ [[ -z "$dirty_status" ]] || return 1
745
+
746
+ return 0
747
+ }
748
+
749
+ prizm_synthesize_failure_log() {
750
+ local failure_log="$1"
751
+ local feature_id="$2"
752
+ local session_id="$3"
753
+ local session_status="$4"
754
+ local exit_code="$5"
755
+ local stale_kill_marker="$6"
756
+ local progress_json="$7"
757
+ local checkpoint_file="$8"
758
+ local project_root="$9"
759
+ local base_ref="${10:-}"
760
+
761
+ [[ -n "$failure_log" ]] || return 0
762
+ [[ -f "$failure_log" ]] && return 0
763
+ mkdir -p "$(dirname "$failure_log")" || return 0
764
+
765
+ local progress_summary="Progress data unavailable."
766
+ if [[ -f "$progress_json" ]]; then
767
+ progress_summary=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
768
+ import json
769
+ import sys
770
+ try:
771
+ with open(sys.argv[1], encoding="utf-8") as fh:
772
+ data = json.load(fh)
773
+ except Exception as exc:
774
+ print(f"Progress parse error: {exc}")
775
+ raise SystemExit(0)
776
+ fields = [
777
+ ("fatal_error_code", data.get("fatal_error_code")),
778
+ ("api_error_status", data.get("api_error_status")),
779
+ ("api_error_code", data.get("api_error_code")),
780
+ ("current_phase", data.get("current_phase")),
781
+ ("current_tool", data.get("current_tool")),
782
+ ("last_text_snippet", data.get("last_text_snippet")),
783
+ ("terminal_result_text", data.get("terminal_result_text")),
784
+ ]
785
+ for key, value in fields:
786
+ if value not in (None, "", []):
787
+ text = str(value).replace("\n", " ")
788
+ print(f"- {key}: {text[:500]}")
789
+ PY
790
+ )
791
+ [[ -n "$progress_summary" ]] || progress_summary="Progress data contained no terminal fields."
792
+ fi
793
+
794
+ local stale_summary="No stale-kill marker."
795
+ if [[ -f "$stale_kill_marker" ]]; then
796
+ stale_summary="$(cat "$stale_kill_marker" 2>/dev/null || true)"
797
+ fi
798
+
799
+ local checkpoint_summary="No checkpoint file found."
800
+ if [[ -f "$checkpoint_file" ]]; then
801
+ checkpoint_summary=$(python3 - "$checkpoint_file" <<'PY' 2>/dev/null || true
802
+ import json
803
+ import sys
804
+ try:
805
+ with open(sys.argv[1], encoding="utf-8") as fh:
806
+ data = json.load(fh)
807
+ except Exception as exc:
808
+ print(f"Checkpoint parse error: {exc}")
809
+ raise SystemExit(0)
810
+ steps = data.get("steps") or []
811
+ complete = sum(1 for step in steps if isinstance(step, dict) and step.get("status") in ("completed", "skipped"))
812
+ print(f"{complete}/{len(steps)} steps completed_or_skipped")
813
+ for step in steps:
814
+ if isinstance(step, dict) and step.get("status") not in ("completed", "skipped"):
815
+ print(f"- incomplete: {step.get('id')} {step.get('skill')} = {step.get('status')}")
816
+ PY
817
+ )
818
+ fi
819
+
820
+ local latest_commit="unavailable"
821
+ local feature_commit="no"
822
+ if git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
823
+ latest_commit=$(git -C "$project_root" rev-parse --short HEAD 2>/dev/null || echo unavailable)
824
+ if prizm_find_feature_commit "$project_root" "$base_ref" "$feature_id" false >/dev/null 2>&1; then
825
+ feature_commit="yes"
826
+ fi
827
+ fi
828
+
829
+ local dirty_summary="unavailable"
830
+ dirty_summary=$(git -C "$project_root" status --short 2>/dev/null || true)
831
+ [[ -n "$dirty_summary" ]] || dirty_summary="clean"
832
+
833
+ cat > "$failure_log" <<EOF
834
+ # Runtime-synthesized failure log
835
+
836
+ ## Session
837
+
838
+ - feature_id: $feature_id
839
+ - session_id: ${session_id:-unknown}
840
+ - session_status: $session_status
841
+ - exit_code: $exit_code
842
+
843
+ ## Stale kill marker
844
+
845
+ \`\`\`json
846
+ $stale_summary
847
+ \`\`\`
848
+
849
+ ## Progress
850
+
851
+ $progress_summary
852
+
853
+ ## Checkpoint
854
+
855
+ $checkpoint_summary
856
+
857
+ ## Git state
858
+
859
+ - feature_commit_exists: $feature_commit
860
+ - latest_commit: $latest_commit
861
+
862
+ \`\`\`text
863
+ $dirty_summary
864
+ \`\`\`
865
+
866
+ ## Recommended recovery action
867
+
868
+ - If this is an AI runtime/provider error before checkpoint completion, retry the session with a fresh context.
869
+ - If checkpoint completion and a feature commit both exist, inspect post-completion artifacts and finalize manually rather than rebuilding from scratch.
870
+ - If the working tree is dirty, preserve or review those changes before any reset or merge.
871
+ EOF
872
+ }
873
+
447
874
  prizm_extract_update_new_status() {
448
875
  python3 -c "
449
876
  import json, sys
@@ -173,6 +173,42 @@ PY
173
173
  fi
174
174
  fi
175
175
 
176
+ # Fatal provider/runtime errors are terminal; do not wait for the
177
+ # stale window when progress.json already proves the model cannot
178
+ # continue (for example context_too_large).
179
+ if [[ -f "$progress_json" ]]; then
180
+ local fatal_error_code=""
181
+ fatal_error_code=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
182
+ import json
183
+ import sys
184
+ try:
185
+ with open(sys.argv[1], encoding="utf-8") as fh:
186
+ progress = json.load(fh)
187
+ except Exception:
188
+ raise SystemExit(0)
189
+ code = progress.get("fatal_error_code") or ""
190
+ if code:
191
+ print(code)
192
+ PY
193
+ )
194
+ if [[ -n "$fatal_error_code" ]]; then
195
+ echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}FATAL: ${fatal_error_code}${NC}"
196
+ local _marker_dir
197
+ _marker_dir="$(dirname "$session_log")"
198
+ echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"${fatal_error_code}\", \"fatal_error_code\": \"${fatal_error_code}\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/fatal-error.json" 2>/dev/null || true
199
+ echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"${fatal_error_code}\", \"fatal_error_code\": \"${fatal_error_code}\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/stale-kill.json" 2>/dev/null || true
200
+ kill -TERM "$cli_pid" 2>/dev/null || true
201
+ local fatal_kill_grace_seconds="${STALE_KILL_GRACE_SECONDS:-10}"
202
+ if [[ $fatal_kill_grace_seconds -gt 0 ]]; then
203
+ sleep "$fatal_kill_grace_seconds"
204
+ fi
205
+ if kill -0 "$cli_pid" 2>/dev/null; then
206
+ kill -9 "$cli_pid" 2>/dev/null || true
207
+ fi
208
+ break
209
+ fi
210
+ fi
211
+
176
212
  # Stale-kill: auto-terminate process if no progress for too long.
177
213
  # Parent sessions can wait on spawned work; child transcript growth
178
214
  # counts as progress above, while silent waits still use the active