prizmkit 1.1.20 → 1.1.23

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.20",
3
- "bundledAt": "2026-04-11T05:49:50.851Z",
4
- "bundledFrom": "13e5e58"
2
+ "frameworkVersion": "1.1.23",
3
+ "bundledAt": "2026-04-11T14:45:05.180Z",
4
+ "bundledFrom": "fbba566"
5
5
  }
@@ -2,18 +2,21 @@
2
2
  # ============================================================
3
3
  # dev-pipeline/lib/branch.sh - Git Branch Lifecycle Library
4
4
  #
5
- # Shared by run-feature.sh and run-bugfix.sh for branch-based serial
6
- # development. Each pipeline run creates a dev branch and all
7
- # features/bugs commit directly on it in sequence.
5
+ # Shared by run-feature.sh, run-bugfix.sh, and run-refactor.sh
6
+ # for branch-based serial development. Each pipeline run creates
7
+ # a dev branch and all features/bugs/refactors commit on it.
8
8
  #
9
9
  # Functions:
10
- # branch_create — Create and checkout a new branch
11
- # branch_return — Checkout back to original branch
12
- # branch_merge — Merge dev branch into original and optionally push
10
+ # branch_create — Create and checkout a new branch
11
+ # branch_return — Checkout back to original branch
12
+ # branch_merge — Merge dev branch into original and optionally push
13
+ # branch_ensure_return — Guaranteed return to original branch (try/finally)
13
14
  #
14
15
  # Environment:
15
- # DEV_BRANCH — Optional custom branch name override
16
- # AUTO_PUSH — Set to 1 to auto-push after successful feature
16
+ # _ORIGINAL_BRANCH Set by caller before branch_create
17
+ # _DEV_BRANCH_NAME Set by caller after branch_create
18
+ # DEV_BRANCH Optional custom branch name override
19
+ # AUTO_PUSH Set to 1 to auto-push after successful feature
17
20
  # ============================================================
18
21
 
19
22
  # branch_create <project_root> <branch_name> <source_branch>
@@ -80,11 +83,15 @@ branch_return() {
80
83
  #
81
84
  # Merges dev_branch into original_branch, then optionally pushes.
82
85
  # Steps:
83
- # 1. Checkout original_branch
86
+ # 1. Stash tracked dirty files (NOT untracked — .prizmkit/state/ is gitignored)
84
87
  # 2. Rebase dev_branch onto original_branch (handles diverged main)
85
88
  # 3. Fast-forward merge original_branch to rebased dev tip
86
89
  # 4. Push to remote if auto_push == "1"
87
90
  # 5. Delete dev_branch (local only, it's been merged)
91
+ # 6. Restore stashed files
92
+ #
93
+ # IMPORTANT: On failure, caller MUST still call branch_ensure_return()
94
+ # to guarantee return to the original branch.
88
95
  #
89
96
  # Returns 0 on success, 1 on failure.
90
97
  branch_merge() {
@@ -93,16 +100,21 @@ branch_merge() {
93
100
  local original_branch="$3"
94
101
  local auto_push="${4:-0}"
95
102
 
96
- # Step 1: Checkout original branch
97
- # Stash any uncommitted changes (e.g. untracked state/ files) so checkout is not blocked
103
+ # Step 1: Stash any tracked uncommitted changes so checkout is not blocked.
104
+ # Only stash tracked changes (not untracked). Untracked files like
105
+ # .prizmkit/state/ are gitignored and survive checkout without issue.
106
+ # Using --include-untracked causes stash pop conflicts and can lose
107
+ # state/ files that are needed for pipeline status tracking.
98
108
  local had_stash=false
99
- local remaining_dirty
100
- remaining_dirty=$(git -C "$project_root" status --porcelain 2>/dev/null || true)
101
- if [[ -n "$remaining_dirty" ]]; then
102
- if git -C "$project_root" stash push --include-untracked -m "pipeline-merge-stash" 2>/dev/null; then
109
+ local tracked_dirty
110
+ tracked_dirty=$(git -C "$project_root" diff --name-only 2>/dev/null || true)
111
+ local staged_dirty
112
+ staged_dirty=$(git -C "$project_root" diff --cached --name-only 2>/dev/null || true)
113
+ if [[ -n "$tracked_dirty" || -n "$staged_dirty" ]]; then
114
+ if git -C "$project_root" stash push -m "pipeline-merge-stash" 2>/dev/null; then
103
115
  had_stash=true
104
116
  else
105
- log_warn "git stash failed — uncommitted changes may not be preserved during merge"
117
+ log_warn "git stash failed — uncommitted tracked changes may not be preserved during merge"
106
118
  had_stash=false
107
119
  fi
108
120
  fi
@@ -116,14 +128,21 @@ branch_merge() {
116
128
  log_error "Rebase of $dev_branch onto $original_branch failed — resolve manually:"
117
129
  log_error " git rebase --abort # then resolve conflicts and retry"
118
130
  git -C "$project_root" rebase --abort 2>/dev/null || true
119
- git -C "$project_root" checkout "$dev_branch" 2>/dev/null || true
120
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
131
+ if [[ "$had_stash" == true ]]; then
132
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
133
+ log_warn "git stash pop failed after rebase abort — run 'git stash list' to check"
134
+ fi
135
+ fi
121
136
  return 1
122
137
  fi
123
138
  # After the rebase we are on dev_branch — checkout original for the fast-forward
124
139
  if ! git -C "$project_root" checkout "$original_branch" 2>/dev/null; then
125
140
  log_error "Failed to checkout $original_branch for merge"
126
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
141
+ if [[ "$had_stash" == true ]]; then
142
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
143
+ log_warn "git stash pop failed after checkout failure — run 'git stash list' to check"
144
+ fi
145
+ fi
127
146
  return 1
128
147
  fi
129
148
 
@@ -131,8 +150,11 @@ branch_merge() {
131
150
  if ! git -C "$project_root" merge --ff-only "$dev_branch" 2>&1; then
132
151
  log_error "Merge failed after rebase — this should not happen, resolve manually:"
133
152
  log_error " git checkout $original_branch && git rebase $dev_branch"
134
- git -C "$project_root" checkout "$dev_branch" 2>/dev/null || true
135
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
153
+ if [[ "$had_stash" == true ]]; then
154
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
155
+ log_warn "git stash pop failed after merge failure — run 'git stash list' to check"
156
+ fi
157
+ fi
136
158
  return 1
137
159
  fi
138
160
 
@@ -152,8 +174,150 @@ branch_merge() {
152
174
  git -C "$project_root" branch -d "$dev_branch" 2>/dev/null && \
153
175
  log_info "Deleted merged branch: $dev_branch" || true
154
176
 
155
- # Step 6: Restore stashed state/ files
156
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
177
+ # Step 6: Restore stashed files
178
+ if [[ "$had_stash" == true ]]; then
179
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
180
+ log_warn "git stash pop failed after merge — stashed changes may be lost. Run 'git stash list' to check."
181
+ fi
182
+ fi
183
+
184
+ return 0
185
+ }
186
+
187
+ # branch_save_wip <project_root> <dev_branch>
188
+ #
189
+ # Saves any uncommitted work-in-progress on the dev branch before returning
190
+ # to the original branch. Called during interrupt/crash cleanup to preserve
191
+ # partially completed AI work that hasn't been committed yet.
192
+ #
193
+ # Commits ALL changes (tracked + untracked, excluding gitignored) with a
194
+ # "wip:" prefix message so it's easy to identify and squash later.
195
+ #
196
+ # Safe to call when the working tree is clean — it simply does nothing.
197
+ # Never fails — errors are logged but the function always returns 0.
198
+ branch_save_wip() {
199
+ local project_root="$1"
200
+ local dev_branch="$2"
201
+
202
+ # Nothing to save if dev_branch is empty
203
+ if [[ -z "$dev_branch" ]]; then
204
+ return 0
205
+ fi
206
+
207
+ # Verify we're actually on the dev branch
208
+ local current_branch
209
+ current_branch=$(git -C "$project_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
210
+ if [[ "$current_branch" != "$dev_branch" ]]; then
211
+ return 0
212
+ fi
213
+
214
+ # Check if there are any uncommitted changes (tracked or untracked, excluding gitignored)
215
+ local has_changes
216
+ has_changes=$(git -C "$project_root" status --porcelain 2>/dev/null || true)
217
+ if [[ -z "$has_changes" ]]; then
218
+ return 0
219
+ fi
220
+
221
+ log_warn "Saving uncommitted work-in-progress on branch: $dev_branch"
222
+
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"
226
+ return 0
227
+ fi
228
+
229
+ # Commit with WIP marker
230
+ if git -C "$project_root" commit --no-verify \
231
+ -m "wip($dev_branch): interrupted — uncommitted work saved" \
232
+ -m "Pipeline was interrupted by signal. This commit preserves work-in-progress." \
233
+ -m "To resume: git checkout $dev_branch" 2>/dev/null; then
234
+ log_info "Saved uncommitted work on branch $dev_branch"
235
+ else
236
+ log_warn "git commit failed — uncommitted work may be lost on branch switch"
237
+ fi
238
+
239
+ return 0
240
+ }
241
+
242
+ # branch_ensure_return <project_root> <original_branch> [dev_branch]
243
+ #
244
+ # GUARANTEED return to the original branch. Like a try/finally block.
245
+ # Must be called in EVERY exit path: success, failure, interrupt, crash.
246
+ # This is the single point of truth for "always go back to original branch".
247
+ #
248
+ # If dev_branch is provided and we're currently on it, any uncommitted
249
+ # work is saved as a WIP commit before switching (via branch_save_wip).
250
+ #
251
+ # Handles:
252
+ # - Saving uncommitted WIP on dev branch (if dev_branch provided)
253
+ # - Aborting any in-progress rebase (leftover from branch_merge failure)
254
+ # - Stashing any tracked dirty files that block checkout
255
+ # - Checking out original_branch
256
+ # - Restoring stashed files
257
+ # - Logging for diagnostics
258
+ #
259
+ # Never fails — errors are logged but the function always returns 0
260
+ # so it can be used in cleanup traps without breaking error handling.
261
+ branch_ensure_return() {
262
+ local project_root="$1"
263
+ local original_branch="$2"
264
+ local dev_branch="${3:-}"
265
+
266
+ # If original_branch is empty or unset, nothing to return to
267
+ if [[ -z "$original_branch" ]]; then
268
+ return 0
269
+ fi
270
+
271
+ # Abort any in-progress rebase (can happen if branch_merge failed mid-way)
272
+ if git -C "$project_root" rebase --show-current-patch >/dev/null 2>&1; then
273
+ log_warn "Aborting in-progress rebase..."
274
+ git -C "$project_root" rebase --abort 2>/dev/null || true
275
+ fi
276
+
277
+ # Check current branch
278
+ local current_branch
279
+ current_branch=$(git -C "$project_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
280
+
281
+ if [[ "$current_branch" == "$original_branch" ]]; then
282
+ return 0
283
+ fi
284
+
285
+ # Save any uncommitted WIP on dev branch before switching away
286
+ # Use dev_branch if provided; otherwise infer from current_branch
287
+ local _wip_branch="${dev_branch:-$current_branch}"
288
+ if [[ -n "$_wip_branch" && "$_wip_branch" != "$original_branch" ]]; then
289
+ branch_save_wip "$project_root" "$_wip_branch"
290
+ fi
291
+
292
+ log_info "Ensuring return to original branch: $original_branch (currently on: ${current_branch:-unknown})"
293
+
294
+ # Stash any tracked dirty files that would block checkout
295
+ # (branch_save_wip should have committed everything, but this is a safety net
296
+ # in case the commit failed or new files appeared)
297
+ local had_stash=false
298
+ local tracked_dirty
299
+ tracked_dirty=$(git -C "$project_root" diff --name-only 2>/dev/null || true)
300
+ local staged_dirty
301
+ staged_dirty=$(git -C "$project_root" diff --cached --name-only 2>/dev/null || true)
302
+ if [[ -n "$tracked_dirty" || -n "$staged_dirty" ]]; then
303
+ if git -C "$project_root" stash push -m "pipeline-ensure-return-stash" 2>/dev/null; then
304
+ had_stash=true
305
+ fi
306
+ fi
307
+
308
+ # Checkout original branch
309
+ if git -C "$project_root" checkout "$original_branch" 2>/dev/null; then
310
+ log_info "Returned to branch: $original_branch"
311
+ else
312
+ log_error "Failed to checkout $original_branch — manual recovery needed"
313
+ fi
314
+
315
+ # Restore stashed files
316
+ if [[ "$had_stash" == true ]]; then
317
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
318
+ log_warn "git stash pop failed during branch return — stashed changes may be lost. Run 'git stash list' to check."
319
+ fi
320
+ fi
157
321
 
158
322
  return 0
159
323
  }
@@ -139,22 +139,20 @@ fi
139
139
  BUG_IDS=()
140
140
 
141
141
  if [[ -n "$FILTER_MODE" ]]; then
142
- # Filter by status from .prizmkit/state/bugfix/bugs/*/status.json
142
+ # Filter by status from bug-fix-list.json (single source of truth)
143
143
  while IFS= read -r bid; do
144
144
  [[ -n "$bid" ]] && BUG_IDS+=("$bid")
145
145
  done < <(python3 -c "
146
- import json, os, sys
147
- state_dir = '$STATE_DIR'
146
+ import json, sys
148
147
  filter_mode = '$FILTER_MODE'
149
- bugs_dir = os.path.join(state_dir, 'bugs')
150
- if not os.path.isdir(bugs_dir):
151
- sys.exit(0)
152
- for bid in sorted(os.listdir(bugs_dir)):
153
- status_file = os.path.join(bugs_dir, bid, 'status.json')
154
- if not os.path.isfile(status_file):
148
+ bug_list = '$BUG_LIST'
149
+ with open(bug_list) as f:
150
+ data = json.load(f)
151
+ for bug in data.get('bugs', []):
152
+ if not isinstance(bug, dict):
155
153
  continue
156
- with open(status_file) as f:
157
- status = json.load(f).get('status', '')
154
+ bid = bug.get('id', '')
155
+ status = bug.get('status', '')
158
156
  if filter_mode == 'auto_skipped' and status == 'auto_skipped':
159
157
  print(bid)
160
158
  elif filter_mode == 'failed' and status == 'failed':
@@ -244,13 +242,23 @@ sys.exit(1)
244
242
  echo -e "${BOLD}════════════════════════════════════════════════════${NC}"
245
243
 
246
244
  STATUS_FILE="$STATE_DIR/bugs/$CUR_BUG_ID/status.json"
245
+ # Read status from bug-fix-list.json (single source of truth)
246
+ CURRENT_STATUS=$(python3 -c "
247
+ import json, sys
248
+ with open('$BUG_LIST') as f:
249
+ data = json.load(f)
250
+ for bug in data.get('bugs', []):
251
+ if isinstance(bug, dict) and bug.get('id') == '$CUR_BUG_ID':
252
+ print(bug.get('status', '?'))
253
+ sys.exit(0)
254
+ print('?')
255
+ " 2>/dev/null || echo "?")
247
256
  if [[ -f "$STATUS_FILE" ]]; then
248
- CURRENT_STATUS=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(d.get('status','?'))")
249
257
  CURRENT_RETRY=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(d.get('retry_count',0))")
250
258
  SESSION_COUNT=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(len(d.get('sessions',[])))")
251
259
  log_info "Current status: $CURRENT_STATUS (retry $CURRENT_RETRY, $SESSION_COUNT sessions)"
252
260
  else
253
- log_info "No status file found (never executed)"
261
+ log_info "Current status: $CURRENT_STATUS (no runtime state file)"
254
262
  fi
255
263
 
256
264
  BUGFIX_DIR="$PROJECT_ROOT/.prizmkit/bugfix/$CUR_BUG_ID"
@@ -139,22 +139,20 @@ fi
139
139
  FEATURE_IDS=()
140
140
 
141
141
  if [[ -n "$FILTER_MODE" ]]; then
142
- # Filter by status from state/features/*/status.json
142
+ # Filter by status from feature-list.json (single source of truth)
143
143
  while IFS= read -r fid; do
144
144
  [[ -n "$fid" ]] && FEATURE_IDS+=("$fid")
145
145
  done < <(python3 -c "
146
- import json, os, sys
147
- state_dir = '$STATE_DIR'
146
+ import json, sys
148
147
  filter_mode = '$FILTER_MODE'
149
- features_dir = os.path.join(state_dir, 'features')
150
- if not os.path.isdir(features_dir):
151
- sys.exit(0)
152
- for fid in sorted(os.listdir(features_dir)):
153
- status_file = os.path.join(features_dir, fid, 'status.json')
154
- if not os.path.isfile(status_file):
148
+ feature_list = '$FEATURE_LIST'
149
+ with open(feature_list) as f:
150
+ data = json.load(f)
151
+ for feat in data.get('features', []):
152
+ if not isinstance(feat, dict):
155
153
  continue
156
- with open(status_file) as f:
157
- status = json.load(f).get('status', '')
154
+ fid = feat.get('id', '')
155
+ status = feat.get('status', '')
158
156
  if filter_mode == 'auto_skipped' and status == 'auto_skipped':
159
157
  print(fid)
160
158
  elif filter_mode == 'failed' and status == 'failed':
@@ -253,13 +251,23 @@ sys.exit(1)
253
251
  echo -e "${BOLD}════════════════════════════════════════════════════${NC}"
254
252
 
255
253
  STATUS_FILE="$STATE_DIR/features/$CUR_FEATURE_ID/status.json"
254
+ # Read status from feature-list.json (single source of truth)
255
+ CURRENT_STATUS=$(python3 -c "
256
+ import json, sys
257
+ with open('$FEATURE_LIST') as f:
258
+ data = json.load(f)
259
+ for feat in data.get('features', []):
260
+ if isinstance(feat, dict) and feat.get('id') == '$CUR_FEATURE_ID':
261
+ print(feat.get('status', '?'))
262
+ sys.exit(0)
263
+ print('?')
264
+ " 2>/dev/null || echo "?")
256
265
  if [[ -f "$STATUS_FILE" ]]; then
257
- CURRENT_STATUS=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(d.get('status','?'))")
258
266
  CURRENT_RETRY=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(d.get('retry_count',0))")
259
267
  SESSION_COUNT=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(len(d.get('sessions',[])))")
260
268
  log_info "Current status: $CURRENT_STATUS (retry $CURRENT_RETRY, $SESSION_COUNT sessions)"
261
269
  else
262
- log_info "No status file found (never executed)"
270
+ log_info "Current status: $CURRENT_STATUS (no runtime state file)"
263
271
  fi
264
272
 
265
273
  SPECS_DIR="$PROJECT_ROOT/.prizmkit/specs/$FEATURE_SLUG"
@@ -129,22 +129,20 @@ fi
129
129
  REFACTOR_IDS=()
130
130
 
131
131
  if [[ -n "$FILTER_MODE" ]]; then
132
- # Filter by status from .prizmkit/state/refactor/refactors/*/status.json
132
+ # Filter by status from refactor-list.json (single source of truth)
133
133
  while IFS= read -r rid; do
134
134
  [[ -n "$rid" ]] && REFACTOR_IDS+=("$rid")
135
135
  done < <(python3 -c "
136
- import json, os, sys
137
- state_dir = '$STATE_DIR'
136
+ import json, sys
138
137
  filter_mode = '$FILTER_MODE'
139
- refactors_dir = os.path.join(state_dir, 'refactors')
140
- if not os.path.isdir(refactors_dir):
141
- sys.exit(0)
142
- for rid in sorted(os.listdir(refactors_dir)):
143
- status_file = os.path.join(refactors_dir, rid, 'status.json')
144
- if not os.path.isfile(status_file):
138
+ refactor_list = '$REFACTOR_LIST'
139
+ with open(refactor_list) as f:
140
+ data = json.load(f)
141
+ for r in data.get('refactors', []):
142
+ if not isinstance(r, dict):
145
143
  continue
146
- with open(status_file) as f:
147
- status = json.load(f).get('status', '')
144
+ rid = r.get('id', '')
145
+ status = r.get('status', '')
148
146
  if filter_mode == 'auto_skipped' and status == 'auto_skipped':
149
147
  print(rid)
150
148
  elif filter_mode == 'failed' and status == 'failed':
@@ -242,13 +240,23 @@ sys.exit(1)
242
240
  echo -e "${BOLD}════════════════════════════════════════════════════${NC}"
243
241
 
244
242
  STATUS_FILE="$STATE_DIR/refactors/$CUR_REFACTOR_ID/status.json"
243
+ # Read status from refactor-list.json (single source of truth)
244
+ CURRENT_STATUS=$(python3 -c "
245
+ import json, sys
246
+ with open('$REFACTOR_LIST') as f:
247
+ data = json.load(f)
248
+ for r in data.get('refactors', []):
249
+ if isinstance(r, dict) and r.get('id') == '$CUR_REFACTOR_ID':
250
+ print(r.get('status', '?'))
251
+ sys.exit(0)
252
+ print('?')
253
+ " 2>/dev/null || echo "?")
245
254
  if [[ -f "$STATUS_FILE" ]]; then
246
- CURRENT_STATUS=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(d.get('status','?'))")
247
255
  CURRENT_RETRY=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(d.get('retry_count',0))")
248
256
  SESSION_COUNT=$(python3 -c "import json; d=json.load(open('$STATUS_FILE')); print(len(d.get('sessions',[])))")
249
257
  log_info "Current status: $CURRENT_STATUS (retry $CURRENT_RETRY, $SESSION_COUNT sessions)"
250
258
  else
251
- log_info "No status file found (never executed)"
259
+ log_info "Current status: $CURRENT_STATUS (no runtime state file)"
252
260
  fi
253
261
 
254
262
  SPECS_DIR="$PROJECT_ROOT/.prizmkit/specs/$REFACTOR_SLUG"