prizmkit 1.1.70 → 1.1.74

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.
Files changed (57) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/agents/prizm-dev-team-dev.md +11 -1
  3. package/bundled/dev-pipeline/lib/common.sh +427 -0
  4. package/bundled/dev-pipeline/lib/heartbeat.sh +101 -36
  5. package/bundled/dev-pipeline/run-feature.sh +109 -29
  6. package/bundled/dev-pipeline/scripts/parse-stream-progress.py +198 -3
  7. package/bundled/dev-pipeline/scripts/update-feature-status.py +27 -3
  8. package/bundled/dev-pipeline/templates/agent-prompts/dev-implement.md +21 -0
  9. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +1 -1
  10. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +5 -9
  11. package/bundled/dev-pipeline/templates/sections/feature-context.md +3 -18
  12. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +11 -0
  13. package/bundled/dev-pipeline/templates/sections/phase-commit.md +11 -0
  14. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +1 -1
  15. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +6 -12
  16. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +10 -3
  17. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +1 -0
  18. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +4 -8
  19. package/bundled/dev-pipeline-windows/lib/common.ps1 +61 -1
  20. package/bundled/dev-pipeline-windows/lib/pipeline.ps1 +325 -16
  21. package/bundled/dev-pipeline-windows/scripts/parse-stream-progress.py +198 -3
  22. package/bundled/dev-pipeline-windows/scripts/update-feature-status.py +27 -3
  23. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-implement.md +21 -0
  24. package/bundled/dev-pipeline-windows/templates/agent-prompts/reviewer-review.md +1 -1
  25. package/bundled/dev-pipeline-windows/templates/bootstrap-prompt.md +27 -0
  26. package/bundled/dev-pipeline-windows/templates/bootstrap-tier1.md +543 -14
  27. package/bundled/dev-pipeline-windows/templates/bootstrap-tier2.md +664 -14
  28. package/bundled/dev-pipeline-windows/templates/bootstrap-tier3.md +741 -14
  29. package/bundled/dev-pipeline-windows/templates/bugfix-bootstrap-prompt.md +2 -2
  30. package/bundled/dev-pipeline-windows/templates/feature-list-schema.json +1 -1
  31. package/bundled/dev-pipeline-windows/templates/refactor-bootstrap-prompt.md +1 -1
  32. package/bundled/dev-pipeline-windows/templates/refactor-list-schema.json +1 -1
  33. package/bundled/dev-pipeline-windows/templates/sections/context-budget-rules.md +3 -3
  34. package/bundled/dev-pipeline-windows/templates/sections/failure-capture.md +1 -1
  35. package/bundled/dev-pipeline-windows/templates/sections/feature-context.md +3 -18
  36. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-auto.md +239 -40
  37. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-opencli.md +75 -26
  38. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification.md +142 -36
  39. package/bundled/dev-pipeline-windows/templates/sections/phase-commit-full.md +13 -2
  40. package/bundled/dev-pipeline-windows/templates/sections/phase-commit.md +12 -1
  41. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-agent-suffix.md +1 -1
  42. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-base.md +7 -17
  43. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-lite-suffix.md +10 -3
  44. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan-full.md +1 -1
  45. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan.md +1 -1
  46. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-agent.md +3 -1
  47. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-full.md +7 -3
  48. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-lite.md +1 -3
  49. package/bundled/dev-pipeline-windows/templates/sections/phase-plan-agent.md +1 -1
  50. package/bundled/dev-pipeline-windows/templates/sections/phase-plan-lite.md +1 -1
  51. package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +1 -1
  52. package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +2 -2
  53. package/bundled/dev-pipeline-windows/templates/sections/phase-specify-plan-full.md +13 -17
  54. package/bundled/dev-pipeline-windows/templates/sections/phase0-test-baseline.md +2 -4
  55. package/bundled/dev-pipeline-windows/templates/sections/subagent-timeout-recovery.md +1 -1
  56. package/bundled/skills/_metadata.json +1 -1
  57. package/package.json +1 -1
@@ -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.74",
3
+ "bundledAt": "2026-06-12T13:22:51.065Z",
4
+ "bundledFrom": "8817522"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: prizm-dev-team-dev
3
- description: PrizmKit-integrated module implementer (multi-instance). Follows /prizmkit-implement workflow with TDD, marks tasks [x] in plan.md Tasks section, works within assigned Git worktrees. Use when implementing specific feature modules.
3
+ description: PrizmKit-integrated module implementer (multi-instance). Follows /prizmkit-implement workflow with TDD, marks tasks [x] in plan.md Tasks section. Works directly in the repository; uses git worktree only if explicitly instructed. Use when implementing specific feature modules.
4
4
  tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskGet, TaskUpdate, TaskList, SendMessage
5
5
  disallowedTools: Agent
6
6
  model: inherit
@@ -86,6 +86,16 @@ DEV-17: DO NOT re-read source files already listed in context-snapshot.md Sectio
86
86
  DEV-18: When tests fail, run `$TEST_CMD 2>&1 | tee /tmp/test-out.txt` ONCE, then grep `/tmp/test-out.txt` for failure details. Never re-run the full test suite just to apply a different grep filter to its output.
87
87
  DEV-19: Before writing any `.prizmkit/prizm-docs/` file, check if it exists. If it exists: only update durable fields (KEY_FILES, INTERFACES, DEPENDENCIES, file counts, RULES, TRAPS, DECISIONS) — never overwrite the full file. Never add CHANGELOG, UPDATED/date fields, or workflow metadata. Only create new L2 docs for sub-modules you are actively creating in this session.
88
88
  DEV-20: Internal tracking IDs are not product copy. Before writing UI text or UI-copy assertions, translate references like `F-003 guard` into product-language behavior such as `the high-risk guard`. Add regression coverage when a feature touches user-visible text.
89
+ DEV-21: Before any Read with offset + limit, compute offset + limit. If the last tool_result for this file shows it has N lines, offset MUST be < N. Never request an offset >= known file length.
90
+ DEV-22: When Edit fails with 'String to replace not found':
91
+ 1. STOP editing immediately — do NOT retry the same Edit.
92
+ 2. Run `grep -n` to locate the exact line of the target text.
93
+ 3. Read with offset = max(grep_line - 20, 0), limit = 50.
94
+ 4. Copy the exact text from the Read result into the Edit old_string.
95
+ 5. Never guess or extrapolate an offset — grep first, then read, then edit.
96
+ DEV-23: If 3 consecutive Read calls to the same file return 'shorter than provided offset' or 'Wasted call', STOP all work on that file. Send ESCALATION with the file path, current line count, and the offsets you attempted. The orchestrator will provide the correct content.
97
+ DEV-24: Before editing a large file (>1000 lines), verify you know its current line count from the most recent tool_result. Old line counts from earlier turns may be stale if you have since edited the file.
98
+ DEV-25: After every 3 successful Edit operations on a file, run the relevant test command for that file once to validate your changes compile and behave correctly. Do not defer all testing to the end.
89
99
  ```
90
100
 
91
101
  ### Workflow
@@ -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
@@ -90,9 +90,79 @@ PY
90
90
  fi
91
91
  prev_child_activity_signature="$child_activity_signature"
92
92
 
93
+ local effective_stale_kill_threshold="$stale_kill_threshold"
94
+ if [[ $stale_kill_threshold -gt 0 && -f "$progress_json" ]]; then
95
+ local codex_wait_threshold
96
+ codex_wait_threshold=$(python3 - "$progress_json" "$stale_kill_threshold" <<'PY' 2>/dev/null || true
97
+ import json
98
+ import os
99
+ import sys
100
+
101
+ progress_path = sys.argv[1]
102
+ base_threshold = int(sys.argv[2])
103
+
104
+ with open(progress_path, "r", encoding="utf-8") as fh:
105
+ progress = json.load(fh)
106
+
107
+ spawn_count = 0
108
+ for tool in progress.get("tool_calls", []):
109
+ if isinstance(tool, dict) and tool.get("name") == "spawn_agent":
110
+ try:
111
+ spawn_count += int(tool.get("count", 0))
112
+ except (TypeError, ValueError):
113
+ pass
114
+
115
+ if (
116
+ progress.get("event_format") == "codex-json"
117
+ and progress.get("current_tool") == "wait"
118
+ and spawn_count > 0
119
+ ):
120
+ configured = os.environ.get("CODEX_WAIT_STALE_KILL_THRESHOLD", "")
121
+ try:
122
+ wait_threshold = int(configured)
123
+ except ValueError:
124
+ wait_threshold = max(base_threshold * 4, 3600)
125
+ if wait_threshold > base_threshold:
126
+ print(wait_threshold)
127
+ PY
128
+ )
129
+ if [[ "$codex_wait_threshold" =~ ^[0-9]+$ && "$codex_wait_threshold" -gt "$stale_kill_threshold" ]]; then
130
+ effective_stale_kill_threshold="$codex_wait_threshold"
131
+ fi
132
+ fi
133
+
134
+ # Check for error-loop: agent is actively producing output but results are
135
+ # all read-offset errors or wasted calls. This is a stuck agent that appears
136
+ # "active" by log growth but is accomplishing nothing.
137
+ local error_loop_detected=false
138
+ if [[ $effective_stale_kill_threshold -gt 0 && $growth -gt 0 && -f "$progress_json" ]]; then
139
+ local error_loop_flag
140
+ error_loop_flag=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
141
+ import json, sys
142
+ try:
143
+ with open(sys.argv[1], encoding="utf-8") as fh:
144
+ progress = json.load(fh)
145
+ except Exception:
146
+ raise SystemExit(0)
147
+ errors = progress.get("errors", [])
148
+ if isinstance(errors, list) and len(errors) >= 5:
149
+ recent = errors[-5:]
150
+ if all(isinstance(e, dict) and e.get("type") in ("read_offset_overflow", "wasted_call") for e in recent):
151
+ print("error_loop")
152
+ PY
153
+ )
154
+ if [[ "$error_loop_flag" == "error_loop" ]]; then
155
+ error_loop_detected=true
156
+ fi
157
+ fi
158
+
93
159
  # Track progress staleness. Parent sessions can sit in a wait/polling
94
160
  # tool while child transcripts keep growing, so child activity counts.
95
- if [[ $growth -eq 0 && $child_growth -eq 0 ]]; then
161
+ # Error loops bypass normal growth-as-progress because the log is only
162
+ # growing with repeated failed reads or wasted calls.
163
+ if [[ "$error_loop_detected" == "true" ]]; then
164
+ stale_seconds=$effective_stale_kill_threshold
165
+ elif [[ $growth -eq 0 && $child_growth -eq 0 ]]; then
96
166
  stale_seconds=$((stale_seconds + heartbeat_interval))
97
167
  else
98
168
  stale_seconds=0
@@ -132,44 +202,39 @@ PY
132
202
  status_icon="${YELLOW}⏸${NC}"
133
203
  fi
134
204
 
135
- local effective_stale_kill_threshold="$stale_kill_threshold"
136
- if [[ $stale_kill_threshold -gt 0 && -f "$progress_json" ]]; then
137
- local codex_wait_threshold
138
- codex_wait_threshold=$(python3 - "$progress_json" "$stale_kill_threshold" <<'PY' 2>/dev/null || true
205
+ # Fatal provider/runtime errors are terminal; do not wait for the
206
+ # stale window when progress.json already proves the model cannot
207
+ # continue (for example context_too_large).
208
+ if [[ -f "$progress_json" ]]; then
209
+ local fatal_error_code=""
210
+ fatal_error_code=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
139
211
  import json
140
- import os
141
212
  import sys
142
-
143
- progress_path = sys.argv[1]
144
- base_threshold = int(sys.argv[2])
145
-
146
- with open(progress_path, "r", encoding="utf-8") as fh:
147
- progress = json.load(fh)
148
-
149
- spawn_count = 0
150
- for tool in progress.get("tool_calls", []):
151
- if isinstance(tool, dict) and tool.get("name") == "spawn_agent":
152
- try:
153
- spawn_count += int(tool.get("count", 0))
154
- except (TypeError, ValueError):
155
- pass
156
-
157
- if (
158
- progress.get("event_format") == "codex-json"
159
- and progress.get("current_tool") == "wait"
160
- and spawn_count > 0
161
- ):
162
- configured = os.environ.get("CODEX_WAIT_STALE_KILL_THRESHOLD", "")
163
- try:
164
- wait_threshold = int(configured)
165
- except ValueError:
166
- wait_threshold = max(base_threshold * 4, 3600)
167
- if wait_threshold > base_threshold:
168
- print(wait_threshold)
213
+ try:
214
+ with open(sys.argv[1], encoding="utf-8") as fh:
215
+ progress = json.load(fh)
216
+ except Exception:
217
+ raise SystemExit(0)
218
+ code = progress.get("fatal_error_code") or ""
219
+ if code:
220
+ print(code)
169
221
  PY
170
- )
171
- if [[ "$codex_wait_threshold" =~ ^[0-9]+$ && "$codex_wait_threshold" -gt "$stale_kill_threshold" ]]; then
172
- effective_stale_kill_threshold="$codex_wait_threshold"
222
+ )
223
+ if [[ -n "$fatal_error_code" ]]; then
224
+ echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}FATAL: ${fatal_error_code}${NC}"
225
+ local _marker_dir
226
+ _marker_dir="$(dirname "$session_log")"
227
+ 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
228
+ 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
229
+ kill -TERM "$cli_pid" 2>/dev/null || true
230
+ local fatal_kill_grace_seconds="${STALE_KILL_GRACE_SECONDS:-10}"
231
+ if [[ $fatal_kill_grace_seconds -gt 0 ]]; then
232
+ sleep "$fatal_kill_grace_seconds"
233
+ fi
234
+ if kill -0 "$cli_pid" 2>/dev/null; then
235
+ kill -9 "$cli_pid" 2>/dev/null || true
236
+ fi
237
+ break
173
238
  fi
174
239
  fi
175
240