prizmkit 1.1.90 → 1.1.92

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.90",
3
- "bundledAt": "2026-07-01T16:16:33.956Z",
4
- "bundledFrom": "917deef"
2
+ "frameworkVersion": "1.1.92",
3
+ "bundledAt": "2026-07-02T15:14:07.667Z",
4
+ "bundledFrom": "edf0ddb"
5
5
  }
@@ -211,18 +211,21 @@ branch_save_wip() {
211
211
  return 0
212
212
  fi
213
213
 
214
- # Check if there are any uncommitted changes (tracked or untracked, excluding gitignored)
214
+ # Check if there are any uncommitted changes (tracked or untracked,
215
+ # excluding gitignored and root-level hidden tool temporary worktrees)
215
216
  local has_changes
216
- has_changes=$(git -C "$project_root" status --porcelain 2>/dev/null || true)
217
+ has_changes=$(prizm_git_status_safe "$project_root" || true)
217
218
  if [[ -z "$has_changes" ]]; then
218
219
  return 0
219
220
  fi
220
221
 
221
222
  log_warn "Saving uncommitted work-in-progress on branch: $dev_branch"
222
223
 
223
- # Stage all changes (tracked + untracked, respects .gitignore)
224
- if ! git -C "$project_root" add -A 2>/dev/null; then
225
- log_warn "git add -A failed uncommitted work may be lost on branch switch"
224
+ # Stage all changes (tracked + untracked, respects .gitignore), excluding
225
+ # root-level hidden tool temporary worktrees so nested agent repos are not
226
+ # recorded as accidental gitlinks in the user's project.
227
+ if ! prizm_git_add_all_safe "$project_root"; then
228
+ log_warn "git add failed — uncommitted work may be lost on branch switch"
226
229
  return 0
227
230
  fi
228
231
 
@@ -23,6 +23,128 @@ log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*
23
23
  log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*"; }
24
24
  log_success() { echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*"; }
25
25
 
26
+ prizm_require_positive_int() {
27
+ local option_name="$1"
28
+ local option_value="$2"
29
+ if [[ ! "$option_value" =~ ^[1-9][0-9]*$ ]]; then
30
+ log_error "$option_name must be a positive integer: $option_value"
31
+ exit 1
32
+ fi
33
+ }
34
+
35
+ # Ignore temporary git worktrees created under root-level hidden AI tool dirs
36
+ # matching .<tool>/worktree(s), without hiding normal source directories.
37
+ PRIZM_GIT_HIDDEN_TOOL_WORKTREE_EXCLUDES=(
38
+ ':(top,exclude,glob).*/worktree'
39
+ ':(top,exclude,glob).*/worktree/**'
40
+ ':(top,exclude,glob).*/worktrees'
41
+ ':(top,exclude,glob).*/worktrees/**'
42
+ )
43
+
44
+ prizm_git_status_safe() {
45
+ local project_root="$1"
46
+ git -C "$project_root" status --porcelain -- . \
47
+ "${PRIZM_GIT_HIDDEN_TOOL_WORKTREE_EXCLUDES[@]}" 2>/dev/null
48
+ }
49
+
50
+ prizm_git_add_all_safe() {
51
+ local project_root="$1"
52
+ git -C "$project_root" add -A -- . \
53
+ "${PRIZM_GIT_HIDDEN_TOOL_WORKTREE_EXCLUDES[@]}" 2>/dev/null
54
+ }
55
+
56
+ prizm_git_relative_path() {
57
+ local project_root="$1"
58
+ local target_path="$2"
59
+ python3 - "$project_root" "$target_path" <<'PY' 2>/dev/null || true
60
+ import os
61
+ import sys
62
+ root = os.path.abspath(sys.argv[1])
63
+ target = os.path.abspath(sys.argv[2])
64
+ print(os.path.relpath(target, root).replace(os.sep, "/"))
65
+ PY
66
+ }
67
+
68
+ prizm_git_status_non_bookkeeping() {
69
+ local project_root="$1"
70
+ local state_dir="$2"
71
+ local list_path="$3"
72
+
73
+ local state_rel list_rel
74
+ state_rel="$(prizm_git_relative_path "$project_root" "$state_dir")"
75
+ list_rel="$(prizm_git_relative_path "$project_root" "$list_path")"
76
+
77
+ local line path
78
+ while IFS= read -r line; do
79
+ [[ -n "$line" ]] || continue
80
+ path="${line:3}"
81
+ if [[ "$path" == *" -> "* ]]; then
82
+ path="${path##* -> }"
83
+ fi
84
+ path="${path%\"}"
85
+ path="${path#\"}"
86
+ path="${path#./}"
87
+ if [[ -n "$list_rel" && "$path" == "$list_rel" ]]; then
88
+ continue
89
+ fi
90
+ if [[ -n "$state_rel" && ( "$path" == "$state_rel" || "$path" == "$state_rel/"* ) ]]; then
91
+ continue
92
+ fi
93
+ printf '%s\n' "$line"
94
+ done < <(prizm_git_status_safe "$project_root")
95
+ }
96
+
97
+ prizm_git_status_bookkeeping() {
98
+ local project_root="$1"
99
+ local state_dir="$2"
100
+ local list_path="$3"
101
+
102
+ local state_rel list_rel
103
+ state_rel="$(prizm_git_relative_path "$project_root" "$state_dir")"
104
+ list_rel="$(prizm_git_relative_path "$project_root" "$list_path")"
105
+
106
+ local line path
107
+ while IFS= read -r line; do
108
+ [[ -n "$line" ]] || continue
109
+ path="${line:3}"
110
+ if [[ "$path" == *" -> "* ]]; then
111
+ path="${path##* -> }"
112
+ fi
113
+ path="${path%\"}"
114
+ path="${path#\"}"
115
+ path="${path#./}"
116
+ if [[ -n "$list_rel" && "$path" == "$list_rel" ]]; then
117
+ printf '%s\n' "$line"
118
+ continue
119
+ fi
120
+ if [[ -n "$state_rel" && ( "$path" == "$state_rel" || "$path" == "$state_rel/"* ) ]]; then
121
+ printf '%s\n' "$line"
122
+ fi
123
+ done < <(prizm_git_status_safe "$project_root")
124
+ }
125
+
126
+ prizm_git_commit_paths() {
127
+ local project_root="$1"
128
+ local message="$2"
129
+ shift 2
130
+
131
+ local paths=()
132
+ local path rel_path
133
+ for path in "$@"; do
134
+ [[ -n "$path" && -e "$path" ]] || continue
135
+ rel_path="$(prizm_git_relative_path "$project_root" "$path")"
136
+ [[ -n "$rel_path" ]] || continue
137
+ paths+=("$rel_path")
138
+ done
139
+ [[ ${#paths[@]} -gt 0 ]] || return 0
140
+
141
+ git -C "$project_root" add -- "${paths[@]}" 2>/dev/null || true
142
+ if ! git -C "$project_root" diff --cached --quiet -- "${paths[@]}" 2>/dev/null; then
143
+ git -C "$project_root" commit --no-verify -m "$message" -- "${paths[@]}" 2>/dev/null || true
144
+ fi
145
+ }
146
+
147
+
26
148
  # ============================================================
27
149
  # Path resolution (single source of truth)
28
150
  # ============================================================
@@ -43,7 +43,10 @@ start_heartbeat() {
43
43
  local prev_size=0
44
44
  local prev_max_size=0
45
45
  local prev_child_activity_signature=""
46
+ local prev_progress_signature=""
47
+ local prev_error_loop_signature=""
46
48
  local stale_seconds=0
49
+ local stale_reason="stale_session"
47
50
  while kill -0 "$cli_pid" 2>/dev/null; do
48
51
  sleep "$heartbeat_interval"
49
52
  elapsed=$((elapsed + heartbeat_interval))
@@ -77,9 +80,10 @@ start_heartbeat() {
77
80
  local child_activity_signature=""
78
81
  local child_total_bytes=0
79
82
  local child_session_count=0
83
+ local progress_signature=""
80
84
  if [[ -f "$progress_json" ]]; then
81
- local child_activity_data
82
- child_activity_data=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
85
+ local progress_activity_data
86
+ progress_activity_data=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
83
87
  import json
84
88
  import sys
85
89
 
@@ -89,14 +93,31 @@ try:
89
93
  except Exception:
90
94
  sys.exit(0)
91
95
 
92
- signature = str(progress.get("child_activity_signature") or "")
96
+ child_signature = str(progress.get("child_activity_signature") or "")
93
97
  total_bytes = int(progress.get("child_total_bytes") or 0)
94
98
  session_count = len(progress.get("child_session_files") or [])
95
- print(f"{signature}\t{total_bytes}\t{session_count}")
99
+ progress_signature = json.dumps(
100
+ [
101
+ progress.get("current_phase") or "",
102
+ progress.get("current_tool") or "",
103
+ int(progress.get("message_count") or 0),
104
+ int(progress.get("total_tool_calls") or 0),
105
+ int(progress.get("active_subagent_count") or 0),
106
+ int(progress.get("subagent_spawn_count") or 0),
107
+ child_signature,
108
+ total_bytes,
109
+ session_count,
110
+ progress.get("fatal_error_code") or "",
111
+ progress.get("terminal_result_text") or "",
112
+ ],
113
+ separators=(",", ":"),
114
+ )
115
+ sep = "\x1f"
116
+ print(sep.join([child_signature, str(total_bytes), str(session_count), progress_signature]))
96
117
  PY
97
118
  )
98
- if [[ -n "$child_activity_data" ]]; then
99
- IFS=$'\t' read -r child_activity_signature child_total_bytes child_session_count <<< "$child_activity_data"
119
+ if [[ -n "$progress_activity_data" ]]; then
120
+ IFS=$'\037' read -r child_activity_signature child_total_bytes child_session_count progress_signature <<< "$progress_activity_data"
100
121
  fi
101
122
  fi
102
123
 
@@ -106,6 +127,12 @@ PY
106
127
  fi
107
128
  prev_child_activity_signature="$child_activity_signature"
108
129
 
130
+ local progress_advanced=0
131
+ if [[ -n "$progress_signature" && "$progress_signature" != "$prev_progress_signature" ]]; then
132
+ progress_advanced=1
133
+ fi
134
+ prev_progress_signature="$progress_signature"
135
+
109
136
  local effective_stale_kill_threshold="$stale_kill_threshold"
110
137
  if [[ $stale_kill_threshold -gt 0 && -f "$progress_json" ]]; then
111
138
  local extended_threshold
@@ -172,8 +199,11 @@ PY
172
199
 
173
200
  # Check for error-loop: agent is actively producing output but results are
174
201
  # all read-offset errors or wasted calls. This is a stuck agent that appears
175
- # "active" by log growth but is accomplishing nothing.
202
+ # "active" by log growth but is accomplishing nothing. Only a newly advancing
203
+ # error signature counts; stale historical errors must not turn later normal
204
+ # output into a stale kill.
176
205
  local error_loop_detected=false
206
+ local error_loop_signature=""
177
207
  if [[ $effective_stale_kill_threshold -gt 0 && $growth -gt 0 && -f "$progress_json" ]]; then
178
208
  local error_loop_flag
179
209
  error_loop_flag=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
@@ -187,35 +217,44 @@ errors = progress.get("errors", [])
187
217
  if isinstance(errors, list) and len(errors) >= 5:
188
218
  recent = errors[-5:]
189
219
  if all(isinstance(e, dict) and e.get("type") in ("read_offset_overflow", "wasted_call") for e in recent):
190
- print("error_loop")
220
+ print(json.dumps(recent, sort_keys=True, separators=(",", ":")))
191
221
  PY
192
222
  )
193
- if [[ "$error_loop_flag" == "error_loop" ]]; then
194
- error_loop_detected=true
223
+ if [[ -n "$error_loop_flag" ]]; then
224
+ error_loop_signature="$error_loop_flag"
225
+ if [[ -n "$prev_error_loop_signature" && "$error_loop_signature" != "$prev_error_loop_signature" ]]; then
226
+ error_loop_detected=true
227
+ fi
195
228
  fi
196
229
  fi
230
+ prev_error_loop_signature="$error_loop_signature"
197
231
 
198
232
  # Track progress staleness. Parent sessions can sit in a wait/polling
199
- # tool while child transcripts keep growing, so child activity counts.
200
- # Error loops bypass normal growth-as-progress because the log is only
201
- # growing with repeated failed reads or wasted calls.
233
+ # tool while child transcripts or structured progress counters keep
234
+ # advancing, so both child activity and progress.json state changes
235
+ # count as progress. Error loops bypass normal growth-as-progress
236
+ # because the log is only growing with repeated failed reads or wasted
237
+ # calls.
202
238
  #
203
239
  # When the main session log has been truncated (e.g. by subagent stdout
204
- # redirection severing the file descriptor), use child activity as the
205
- # primary progress signal — the parent process is still running and children
206
- # are still producing output, so this is not a true stall.
240
+ # redirection severing the file descriptor), use structured progress and
241
+ # child activity as the primary progress signals — the parent process is
242
+ # still running and children may still be producing output.
207
243
  if [[ "$error_loop_detected" == "true" ]]; then
244
+ stale_reason="error_loop"
208
245
  stale_seconds=$effective_stale_kill_threshold
209
246
  elif [[ "$log_truncated" == "true" ]]; then
210
- # Log truncated: rely on child growth and CLI process liveness.
211
- # Don't increment staleness the process is still working.
212
- if [[ $child_growth -gt 0 ]]; then
247
+ stale_reason="stale_session"
248
+ if [[ $progress_advanced -gt 0 || $child_growth -gt 0 ]]; then
213
249
  stale_seconds=0
250
+ else
251
+ stale_seconds=$((stale_seconds + heartbeat_interval))
214
252
  fi
215
- # If neither log nor children grow, this is a real stall
216
- elif [[ $growth -le 0 && $child_growth -eq 0 ]]; then
253
+ elif [[ $growth -le 0 && $child_growth -eq 0 && $progress_advanced -eq 0 ]]; then
254
+ stale_reason="stale_session"
217
255
  stale_seconds=$((stale_seconds + heartbeat_interval))
218
256
  else
257
+ stale_reason="stale_session"
219
258
  stale_seconds=0
220
259
  fi
221
260
 
@@ -251,7 +290,7 @@ PY
251
290
  local status_icon
252
291
  if $log_truncated; then
253
292
  status_icon="${RED}⚠${NC}"
254
- elif [[ $growth -gt 0 || $child_growth -gt 0 ]]; then
293
+ elif [[ $growth -gt 0 || $child_growth -gt 0 || $progress_advanced -gt 0 ]]; then
255
294
  status_icon="${GREEN}▶${NC}"
256
295
  else
257
296
  status_icon="${YELLOW}⏸${NC}"
@@ -299,14 +338,19 @@ PY
299
338
  # stale window to surface stuck agents promptly.
300
339
  if [[ $effective_stale_kill_threshold -gt 0 && $stale_seconds -ge $effective_stale_kill_threshold ]]; then
301
340
  local stale_mins=$((stale_seconds / 60))
302
- echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}STALE-KILL: no progress for ${stale_mins}m (threshold: ${effective_stale_kill_threshold}s)${NC}"
303
- echo -e " ${RED}[HEARTBEAT]${NC} Killing AI CLI process $cli_pid (stale session)..."
341
+ if [[ "$stale_reason" == "error_loop" ]]; then
342
+ echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}STALE-KILL: repeated read-offset/wasted-call error loop (threshold: ${effective_stale_kill_threshold}s)${NC}"
343
+ echo -e " ${RED}[HEARTBEAT]${NC} Killing AI CLI process $cli_pid (error loop)..."
344
+ else
345
+ echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}STALE-KILL: no progress for ${stale_mins}m (threshold: ${effective_stale_kill_threshold}s)${NC}"
346
+ echo -e " ${RED}[HEARTBEAT]${NC} Killing AI CLI process $cli_pid (stale session)..."
347
+ fi
304
348
  # Write the marker before killing. Some CLIs exit quickly, and the
305
349
  # parent runner may stop this heartbeat process immediately after
306
350
  # wait(1) returns.
307
351
  local _marker_dir
308
352
  _marker_dir="$(dirname "$session_log")"
309
- echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"stale_session\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/stale-kill.json" 2>/dev/null || true
353
+ echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"${stale_reason}\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/stale-kill.json" 2>/dev/null || true
310
354
  kill -TERM "$cli_pid" 2>/dev/null || true
311
355
  # Give process 10s to exit gracefully, then force kill
312
356
  local stale_kill_grace_seconds="${STALE_KILL_GRACE_SECONDS:-10}"