@jonit-dev/night-watch-cli 1.7.49 → 1.7.50

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.
@@ -304,6 +304,16 @@ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" || true
304
304
 
305
305
  log "START: Processing ${ELIGIBLE_PRD} on branch ${BRANCH_NAME} (worktree: ${WORKTREE_DIR})"
306
306
 
307
+ EXECUTOR_PROMPT_PATH=$(resolve_instruction_path "${PROJECT_DIR}" "prd-executor.md" || true)
308
+ if [ -z "${EXECUTOR_PROMPT_PATH}" ]; then
309
+ log "FAIL: Missing PRD executor instructions. Checked instructions/, .claude/commands/, and bundled templates/"
310
+ restore_issue_to_ready "Failed to locate PRD executor instructions. Checked instructions/, .claude/commands/, and bundled templates/."
311
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
312
+ emit_result "failure" "reason=missing_executor_prompt"
313
+ exit 1
314
+ fi
315
+ EXECUTOR_PROMPT_REF=$(instruction_ref_for_prompt "${PROJECT_DIR}" "${EXECUTOR_PROMPT_PATH}")
316
+
307
317
  if [ -n "${ISSUE_NUMBER}" ]; then
308
318
  PROMPT="Implement the following PRD (GitHub issue #${ISSUE_NUMBER}: ${ISSUE_TITLE_RAW}):
309
319
 
@@ -317,7 +327,7 @@ ${ISSUE_BODY}
317
327
  - Install dependencies if needed and implement in the current worktree only
318
328
 
319
329
  ## Implementation — PRD Executor Workflow
320
- Read .claude/skills/prd-executor/SKILL.md (preferred) or .claude/commands/prd-executor.md (fallback), and follow the FULL execution pipeline:
330
+ Read ${EXECUTOR_PROMPT_REF} and follow the FULL execution pipeline:
321
331
  1. Parse the PRD into phases and extract dependencies
322
332
  2. Build a dependency graph to identify parallelism
323
333
  3. Create a task list with one task per phase
@@ -345,7 +355,7 @@ else
345
355
  - Install dependencies if needed and implement in the current worktree only
346
356
 
347
357
  ## Implementation — PRD Executor Workflow
348
- Read .claude/skills/prd-executor/SKILL.md (preferred) or .claude/commands/prd-executor.md (fallback), and follow the FULL execution pipeline:
358
+ Read ${EXECUTOR_PROMPT_REF} and follow the FULL execution pipeline:
349
359
  1. Parse the PRD into phases and extract dependencies
350
360
  2. Build a dependency graph to identify parallelism
351
361
  3. Create a task list with one task per phase
@@ -51,6 +51,42 @@ resolve_night_watch_cli() {
51
51
  return 1
52
52
  }
53
53
 
54
+ resolve_instruction_path() {
55
+ local project_dir="${1:?project_dir required}"
56
+ local instruction_file="${2:?instruction_file required}"
57
+ local script_dir=""
58
+ local candidate=""
59
+
60
+ if [ -n "${SCRIPT_DIR:-}" ]; then
61
+ script_dir="${SCRIPT_DIR}"
62
+ else
63
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
64
+ fi
65
+
66
+ for candidate in \
67
+ "${project_dir}/instructions/${instruction_file}" \
68
+ "${project_dir}/.claude/commands/${instruction_file}" \
69
+ "${script_dir}/../templates/${instruction_file}"; do
70
+ if [ -f "${candidate}" ]; then
71
+ printf "%s" "${candidate}"
72
+ return 0
73
+ fi
74
+ done
75
+
76
+ return 1
77
+ }
78
+
79
+ instruction_ref_for_prompt() {
80
+ local root_dir="${1:?root_dir required}"
81
+ local instruction_path="${2:?instruction_path required}"
82
+
83
+ if [[ "${instruction_path}" == "${root_dir}/"* ]]; then
84
+ printf "%s" "${instruction_path#${root_dir}/}"
85
+ else
86
+ printf "%s" "${instruction_path}"
87
+ fi
88
+ }
89
+
54
90
  night_watch_history() {
55
91
  local cli_bin
56
92
  cli_bin=$(resolve_night_watch_cli) || return 127
@@ -143,6 +143,135 @@ get_pr_score() {
143
143
  | grep -oP '\d+(?=/100)' || echo ""
144
144
  }
145
145
 
146
+ # Count failed CI checks for a PR.
147
+ # Uses JSON fields when available (more reliable across check name/status formats),
148
+ # then falls back to text parsing for older/mocked gh outputs.
149
+ get_pr_failed_ci_checks() {
150
+ local pr_number="${1:?PR number required}"
151
+ local failed_count=""
152
+
153
+ failed_count="$(
154
+ gh pr checks "${pr_number}" --json bucket,state,conclusion --jq '
155
+ [ .[]
156
+ | (.bucket // "" | ascii_downcase) as $bucket
157
+ | (.state // "" | ascii_downcase) as $state
158
+ | (.conclusion // "" | ascii_downcase) as $conclusion
159
+ | select(
160
+ $bucket == "fail" or
161
+ $bucket == "cancel" or
162
+ $state == "failure" or
163
+ $state == "error" or
164
+ $state == "cancelled" or
165
+ $conclusion == "failure" or
166
+ $conclusion == "error" or
167
+ $conclusion == "cancelled" or
168
+ $conclusion == "timed_out" or
169
+ $conclusion == "action_required" or
170
+ $conclusion == "startup_failure" or
171
+ $conclusion == "stale"
172
+ )
173
+ ] | length
174
+ ' 2>/dev/null || true
175
+ )"
176
+
177
+ if [[ "${failed_count}" =~ ^[0-9]+$ ]]; then
178
+ echo "${failed_count}"
179
+ return 0
180
+ fi
181
+
182
+ failed_count=$(
183
+ gh pr checks "${pr_number}" 2>/dev/null \
184
+ | grep -Eci 'fail|error|cancel|timed[_ -]?out|action_required|startup_failure|stale' || true
185
+ )
186
+
187
+ if [[ "${failed_count}" =~ ^[0-9]+$ ]]; then
188
+ echo "${failed_count}"
189
+ else
190
+ echo "0"
191
+ fi
192
+ }
193
+
194
+ # Return a semicolon-separated summary of failing CI checks for a PR.
195
+ # Format: "<check name> [state=<state>, conclusion=<conclusion>]"
196
+ get_pr_failed_ci_summary() {
197
+ local pr_number="${1:?PR number required}"
198
+ local failed_summary=""
199
+
200
+ failed_summary="$(
201
+ gh pr checks "${pr_number}" --json name,bucket,state,conclusion --jq '
202
+ [ .[]
203
+ | (.bucket // "" | ascii_downcase) as $bucket
204
+ | (.state // "" | ascii_downcase) as $state
205
+ | (.conclusion // "" | ascii_downcase) as $conclusion
206
+ | select(
207
+ $bucket == "fail" or
208
+ $bucket == "cancel" or
209
+ $state == "failure" or
210
+ $state == "error" or
211
+ $state == "cancelled" or
212
+ $conclusion == "failure" or
213
+ $conclusion == "error" or
214
+ $conclusion == "cancelled" or
215
+ $conclusion == "timed_out" or
216
+ $conclusion == "action_required" or
217
+ $conclusion == "startup_failure" or
218
+ $conclusion == "stale"
219
+ )
220
+ | "\(.name // "unknown") [state=\(.state // "unknown"), conclusion=\(.conclusion // "unknown")]"
221
+ ] | join("; ")
222
+ ' 2>/dev/null || true
223
+ )"
224
+
225
+ if [ -n "${failed_summary}" ]; then
226
+ echo "${failed_summary}"
227
+ return 0
228
+ fi
229
+
230
+ # Fallback for older/mocked outputs where JSON fields aren't available.
231
+ failed_summary=$(
232
+ gh pr checks "${pr_number}" 2>/dev/null \
233
+ | grep -Ei 'fail|error|cancel|timed[_ -]?out|action_required|startup_failure|stale' \
234
+ | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]][[:space:]]*/ /g' \
235
+ | paste -sd '; ' - || true
236
+ )
237
+
238
+ echo "${failed_summary}"
239
+ }
240
+
241
+ # Clean up reviewer-managed worktrees.
242
+ # - Always removes the caller's runner worktree when runner_scope is provided.
243
+ # - Only non-worker/controller processes perform broad cleanup to avoid
244
+ # parallel workers deleting each other's active worktrees.
245
+ cleanup_reviewer_worktrees() {
246
+ local runner_scope="${1:-}"
247
+
248
+ if [ -n "${runner_scope}" ]; then
249
+ cleanup_worktrees "${PROJECT_DIR}" "${runner_scope}"
250
+ fi
251
+
252
+ if [ "${WORKER_MODE}" = "1" ]; then
253
+ return 0
254
+ fi
255
+
256
+ # Remove per-PR reviewer worktrees created by prompts from older runs.
257
+ cleanup_worktrees "${PROJECT_DIR}" "${PROJECT_NAME}-nw-review-"
258
+
259
+ # Remove legacy reviewer worktree naming used in some older prompt variants.
260
+ local escaped_project_name
261
+ escaped_project_name=$(printf '%s\n' "${PROJECT_NAME}" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')
262
+ git -C "${PROJECT_DIR}" worktree list --porcelain 2>/dev/null \
263
+ | grep '^worktree ' \
264
+ | awk '{print $2}' \
265
+ | while read -r wt; do
266
+ local wt_basename
267
+ wt_basename=$(basename "${wt}")
268
+ if printf '%s\n' "${wt_basename}" | grep -Eq "^${escaped_project_name}-pr-?[0-9]+$"; then
269
+ log "CLEANUP: Removing legacy reviewer worktree ${wt}"
270
+ git -C "${PROJECT_DIR}" worktree remove --force "${wt}" 2>/dev/null || true
271
+ fi
272
+ done || true
273
+ }
274
+
146
275
  # Validate provider
147
276
  if ! validate_provider "${PROVIDER_CMD}"; then
148
277
  echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
@@ -221,9 +350,14 @@ while IFS=$'\t' read -r pr_number pr_branch; do
221
350
  continue
222
351
  fi
223
352
 
224
- FAILED_CHECKS=$(gh pr checks "${pr_number}" 2>/dev/null | grep -ci 'fail' || true)
353
+ FAILED_CHECKS=$(get_pr_failed_ci_checks "${pr_number}")
225
354
  if [ "${FAILED_CHECKS}" -gt 0 ]; then
226
- log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s)"
355
+ FAILED_SUMMARY=$(get_pr_failed_ci_summary "${pr_number}")
356
+ if [ -n "${FAILED_SUMMARY}" ]; then
357
+ log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s): ${FAILED_SUMMARY}"
358
+ else
359
+ log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s)"
360
+ fi
227
361
  NEEDS_WORK=1
228
362
  PRS_NEEDING_WORK="${PRS_NEEDING_WORK} #${pr_number}"
229
363
  continue
@@ -325,6 +459,10 @@ fi
325
459
 
326
460
  log "START: Found PR(s) needing work:${PRS_NEEDING_WORK}"
327
461
 
462
+ # Remove stale reviewer worktrees from previous interrupted runs.
463
+ # Worker processes skip broad cleanup to avoid parallel interference.
464
+ cleanup_reviewer_worktrees
465
+
328
466
  # Convert "#12 #34" into ["12", "34"] for worker fan-out.
329
467
  PR_NUMBER_ARRAY=()
330
468
  for pr_token in ${PRS_NEEDING_WORK}; do
@@ -355,7 +493,14 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
355
493
  declare -a WORKER_PRS=()
356
494
  declare -a WORKER_OUTPUTS=()
357
495
 
496
+ WORKER_IDX=0
497
+ WORKER_STAGGER_DELAY="${NW_REVIEWER_WORKER_STAGGER:-60}"
358
498
  for pr_number in "${PR_NUMBER_ARRAY[@]}"; do
499
+ if [ "${WORKER_IDX}" -gt 0 ]; then
500
+ log "PARALLEL: Staggering worker launch by ${WORKER_STAGGER_DELAY}s (worker $((WORKER_IDX + 1))/${#PR_NUMBER_ARRAY[@]})"
501
+ sleep "${WORKER_STAGGER_DELAY}"
502
+ fi
503
+
359
504
  worker_output=$(mktemp "/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}-pr-${pr_number}.XXXXXX")
360
505
  WORKER_OUTPUTS+=("${worker_output}")
361
506
  WORKER_PRS+=("${pr_number}")
@@ -370,6 +515,7 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
370
515
  worker_pid=$!
371
516
  WORKER_PIDS+=("${worker_pid}")
372
517
  log "PARALLEL: Worker PID ${worker_pid} started for PR #${pr_number}"
518
+ WORKER_IDX=$((WORKER_IDX + 1))
373
519
  done
374
520
 
375
521
  EXIT_CODE=0
@@ -439,6 +585,10 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
439
585
  fi
440
586
  done
441
587
 
588
+ # Parent/controller process cleans up any per-PR reviewer worktrees that
589
+ # worker runs may have left behind.
590
+ cleanup_reviewer_worktrees
591
+
442
592
  emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}" "${MAX_WORKER_ATTEMPTS}" "${MAX_WORKER_FINAL_SCORE}"
443
593
  exit 0
444
594
  fi
@@ -449,7 +599,7 @@ if [ -n "${TARGET_PR}" ]; then
449
599
  fi
450
600
  REVIEW_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${REVIEW_WORKTREE_BASENAME}"
451
601
 
452
- cleanup_worktrees "${PROJECT_DIR}" "${REVIEW_WORKTREE_BASENAME}"
602
+ cleanup_reviewer_worktrees "${REVIEW_WORKTREE_BASENAME}"
453
603
 
454
604
  # Dry-run mode: print diagnostics and exit
455
605
  if [ "${NW_DRY_RUN:-0}" = "1" ]; then
@@ -479,12 +629,38 @@ if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFA
479
629
  exit 1
480
630
  fi
481
631
 
632
+ REVIEWER_PROMPT_PATH=$(resolve_instruction_path "${REVIEW_WORKTREE_DIR}" "night-watch-pr-reviewer.md" || true)
633
+ if [ -z "${REVIEWER_PROMPT_PATH}" ]; then
634
+ log "FAIL: Missing reviewer prompt file. Checked instructions/, .claude/commands/, and bundled templates/"
635
+ emit_result "failure" "reason=missing_reviewer_prompt"
636
+ exit 1
637
+ fi
638
+ REVIEWER_PROMPT_BASE=$(cat "${REVIEWER_PROMPT_PATH}")
639
+ REVIEWER_PROMPT_REF=$(instruction_ref_for_prompt "${REVIEW_WORKTREE_DIR}" "${REVIEWER_PROMPT_PATH}")
640
+ log "INFO: Using reviewer prompt from ${REVIEWER_PROMPT_REF}"
641
+
482
642
  EXIT_CODE=0
483
643
  ATTEMPTS_MADE=1
484
644
  FINAL_SCORE=""
485
645
  TARGET_SCOPE_PROMPT=""
486
646
  if [ -n "${TARGET_PR}" ]; then
487
647
  TARGET_SCOPE_PROMPT=$'\n\n## Target Scope\n- Only process PR #'"${TARGET_PR}"$'.\n- Ignore all other PRs.\n- If this PR no longer needs work, stop immediately.\n'
648
+
649
+ TARGET_MERGE_STATE=$(gh pr view "${TARGET_PR}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo "UNKNOWN")
650
+ TARGET_FAILED_CHECKS=$(get_pr_failed_ci_summary "${TARGET_PR}")
651
+ TARGET_SCORE=$(get_pr_score "${TARGET_PR}")
652
+
653
+ TARGET_SCOPE_PROMPT+=$'\n## Preflight Data (from CLI)\n- mergeStateStatus: '"${TARGET_MERGE_STATE}"$'\n'
654
+ if [ -n "${TARGET_FAILED_CHECKS}" ]; then
655
+ TARGET_SCOPE_PROMPT+=$'- failing checks: '"${TARGET_FAILED_CHECKS}"$'\n'
656
+ else
657
+ TARGET_SCOPE_PROMPT+=$'- failing checks: none detected\n'
658
+ fi
659
+ if [ -n "${TARGET_SCORE}" ]; then
660
+ TARGET_SCOPE_PROMPT+=$'- latest review score: '"${TARGET_SCORE}"$'/100\n'
661
+ else
662
+ TARGET_SCOPE_PROMPT+=$'- latest review score: not found\n'
663
+ fi
488
664
  fi
489
665
 
490
666
  # ── Retry Loop for Targeted PR Review ──────────────────────────────────────────
@@ -518,13 +694,14 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
518
694
  fi
519
695
 
520
696
  log "RETRY: Starting attempt ${ATTEMPT}/${TOTAL_ATTEMPTS} (timeout: ${ATTEMPT_TIMEOUT}s)"
697
+ LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
698
+ REVIEWER_PROMPT="${REVIEWER_PROMPT_BASE}${TARGET_SCOPE_PROMPT}"
521
699
 
522
700
  case "${PROVIDER_CMD}" in
523
701
  claude)
524
- CLAUDE_PROMPT="/night-watch-pr-reviewer${TARGET_SCOPE_PROMPT}"
525
702
  if (
526
703
  cd "${REVIEW_WORKTREE_DIR}" && timeout "${ATTEMPT_TIMEOUT}" \
527
- claude -p "${CLAUDE_PROMPT}" \
704
+ claude -p "${REVIEWER_PROMPT}" \
528
705
  --dangerously-skip-permissions \
529
706
  >> "${LOG_FILE}" 2>&1
530
707
  ); then
@@ -534,12 +711,11 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
534
711
  fi
535
712
  ;;
536
713
  codex)
537
- CODEX_PROMPT="$(cat "${REVIEW_WORKTREE_DIR}/.claude/commands/night-watch-pr-reviewer.md")${TARGET_SCOPE_PROMPT}"
538
714
  if (
539
715
  cd "${REVIEW_WORKTREE_DIR}" && timeout "${ATTEMPT_TIMEOUT}" \
540
716
  codex --quiet \
541
717
  --yolo \
542
- --prompt "${CODEX_PROMPT}" \
718
+ --prompt "${REVIEWER_PROMPT}" \
543
719
  >> "${LOG_FILE}" 2>&1
544
720
  ); then
545
721
  EXIT_CODE=0
@@ -553,8 +729,16 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
553
729
  ;;
554
730
  esac
555
731
 
556
- # If provider failed (non-zero exit), don't retry
732
+ # If provider failed (non-zero exit), check for rate limit before giving up
557
733
  if [ "${EXIT_CODE}" -ne 0 ]; then
734
+ if [ "${EXIT_CODE}" -ne 124 ] && \
735
+ check_rate_limited "${LOG_FILE}" "${LOG_LINE_BEFORE}" && \
736
+ [ -n "${TARGET_PR}" ] && \
737
+ [ "${ATTEMPT}" -lt "${TOTAL_ATTEMPTS}" ]; then
738
+ log "RATE-LIMITED: 429 detected for PR #${TARGET_PR} (attempt ${ATTEMPT}/${TOTAL_ATTEMPTS}), retrying in 120s..."
739
+ sleep 120
740
+ continue
741
+ fi
558
742
  log "RETRY: Provider exited with code ${EXIT_CODE}, not retrying"
559
743
  break
560
744
  fi
@@ -585,7 +769,7 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
585
769
  fi
586
770
  done
587
771
 
588
- cleanup_worktrees "${PROJECT_DIR}" "${REVIEW_WORKTREE_BASENAME}"
772
+ cleanup_reviewer_worktrees "${REVIEW_WORKTREE_BASENAME}"
589
773
 
590
774
  # ── Auto-merge eligible PRs ─────────────────────────────────────────────────────
591
775
  # After the reviewer completes, check for PRs that are merge-ready and queue them
@@ -217,11 +217,21 @@ Artifacts: ${QA_ARTIFACTS}"
217
217
  continue
218
218
  fi
219
219
 
220
+ QA_PROMPT_PATH=$(resolve_instruction_path "${QA_WORKTREE_DIR}" "night-watch-qa.md" || true)
221
+ if [ -z "${QA_PROMPT_PATH}" ]; then
222
+ log "FAIL: Missing QA prompt file for PR #${pr_num}. Checked instructions/, .claude/commands/, and bundled templates/"
223
+ EXIT_CODE=1
224
+ break
225
+ fi
226
+ QA_PROMPT=$(cat "${QA_PROMPT_PATH}")
227
+ QA_PROMPT_REF=$(instruction_ref_for_prompt "${QA_WORKTREE_DIR}" "${QA_PROMPT_PATH}")
228
+ log "QA: PR #${pr_num} — using prompt from ${QA_PROMPT_REF}"
229
+
220
230
  case "${PROVIDER_CMD}" in
221
231
  claude)
222
232
  if (
223
233
  cd "${QA_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
224
- claude -p "/night-watch-qa" \
234
+ claude -p "${QA_PROMPT}" \
225
235
  --dangerously-skip-permissions \
226
236
  >> "${LOG_FILE}" 2>&1
227
237
  ); then
@@ -241,7 +251,7 @@ Artifacts: ${QA_ARTIFACTS}"
241
251
  cd "${QA_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
242
252
  codex --quiet \
243
253
  --yolo \
244
- --prompt "$(cat "${QA_WORKTREE_DIR}/.claude/commands/night-watch-qa.md")" \
254
+ --prompt "${QA_PROMPT}" \
245
255
  >> "${LOG_FILE}" 2>&1
246
256
  ); then
247
257
  log "QA: PR #${pr_num} — provider completed successfully"