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

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 (39) hide show
  1. package/dist/cli.js +2817 -981
  2. package/dist/commands/cron.d.ts +8 -0
  3. package/dist/commands/cron.d.ts.map +1 -0
  4. package/dist/commands/cron.js +214 -0
  5. package/dist/commands/cron.js.map +1 -0
  6. package/dist/commands/init.d.ts.map +1 -1
  7. package/dist/commands/init.js +28 -31
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/qa.d.ts.map +1 -1
  10. package/dist/commands/qa.js +3 -27
  11. package/dist/commands/qa.js.map +1 -1
  12. package/dist/commands/review.d.ts +20 -0
  13. package/dist/commands/review.d.ts.map +1 -1
  14. package/dist/commands/review.js +98 -18
  15. package/dist/commands/review.js.map +1 -1
  16. package/dist/commands/run.d.ts.map +1 -1
  17. package/dist/commands/run.js +3 -18
  18. package/dist/commands/run.js.map +1 -1
  19. package/dist/commands/shared/env-builder.d.ts +25 -0
  20. package/dist/commands/shared/env-builder.d.ts.map +1 -0
  21. package/dist/commands/shared/env-builder.js +48 -0
  22. package/dist/commands/shared/env-builder.js.map +1 -0
  23. package/dist/commands/slice.d.ts.map +1 -1
  24. package/dist/commands/slice.js +3 -23
  25. package/dist/commands/slice.js.map +1 -1
  26. package/dist/scripts/night-watch-audit-cron.sh +56 -33
  27. package/dist/scripts/night-watch-cron.sh +12 -2
  28. package/dist/scripts/night-watch-helpers.sh +84 -0
  29. package/dist/scripts/night-watch-pr-reviewer-cron.sh +389 -9
  30. package/dist/scripts/night-watch-qa-cron.sh +116 -4
  31. package/dist/templates/audit.md +87 -0
  32. package/dist/templates/executor.md +67 -0
  33. package/dist/templates/night-watch-pr-reviewer.md +153 -135
  34. package/dist/templates/night-watch-slicer.md +1 -1
  35. package/dist/templates/night-watch.md +1 -1
  36. package/dist/templates/pr-reviewer.md +203 -0
  37. package/dist/templates/qa.md +157 -0
  38. package/dist/templates/slicer.md +234 -0
  39. package/package.json +1 -1
@@ -27,6 +27,12 @@ AUTO_MERGE_METHOD="${NW_AUTO_MERGE_METHOD:-squash}"
27
27
  TARGET_PR="${NW_TARGET_PR:-}"
28
28
  PARALLEL_ENABLED="${NW_REVIEWER_PARALLEL:-1}"
29
29
  WORKER_MODE="${NW_REVIEWER_WORKER_MODE:-0}"
30
+ PRD_DIR_REL="${NW_PRD_DIR:-docs/PRDs/night-watch}"
31
+ if [[ "${PRD_DIR_REL}" = /* ]]; then
32
+ PRD_DIR="${PRD_DIR_REL}"
33
+ else
34
+ PRD_DIR="${PROJECT_DIR}/${PRD_DIR_REL}"
35
+ fi
30
36
 
31
37
  # Retry configuration
32
38
  REVIEWER_MAX_RETRIES="${NW_REVIEWER_MAX_RETRIES:-2}"
@@ -124,6 +130,175 @@ append_csv() {
124
130
  fi
125
131
  }
126
132
 
133
+ truncate_for_prompt() {
134
+ local text="${1:-}"
135
+ local limit="${2:-7000}"
136
+ if [ "${#text}" -le "${limit}" ]; then
137
+ printf "%s" "${text}"
138
+ else
139
+ printf '%s\n\n[truncated to %s chars]' "${text:0:${limit}}" "${limit}"
140
+ fi
141
+ }
142
+
143
+ extract_linked_issue_numbers() {
144
+ local body="${1:-}"
145
+ printf '%s\n' "${body}" \
146
+ | grep -Eoi '(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*#[0-9]+' \
147
+ | grep -Eo '[0-9]+' \
148
+ | awk '!seen[$0]++' || true
149
+ }
150
+
151
+ find_prd_file_by_branch() {
152
+ local branch_name="${1:-}"
153
+ local branch_slug="${branch_name#*/}"
154
+ local branch_number=""
155
+ local candidate_dirs=()
156
+ local candidate=""
157
+ local base_name=""
158
+ local dir=""
159
+
160
+ if [ -z "${branch_slug}" ]; then
161
+ branch_slug="${branch_name}"
162
+ fi
163
+ [ -z "${branch_slug}" ] && return 1
164
+
165
+ if [ -d "${PRD_DIR}" ]; then
166
+ candidate_dirs+=("${PRD_DIR}")
167
+ fi
168
+ if [ -d "${PRD_DIR}/done" ]; then
169
+ candidate_dirs+=("${PRD_DIR}/done")
170
+ fi
171
+ [ "${#candidate_dirs[@]}" -eq 0 ] && return 1
172
+
173
+ for dir in "${candidate_dirs[@]}"; do
174
+ if [ -f "${dir}/${branch_slug}.md" ]; then
175
+ printf "%s" "${dir}/${branch_slug}.md"
176
+ return 0
177
+ fi
178
+ done
179
+
180
+ branch_number=$(printf '%s' "${branch_slug}" | grep -oE '^[0-9]+' || true)
181
+ for dir in "${candidate_dirs[@]}"; do
182
+ while IFS= read -r candidate; do
183
+ [ -z "${candidate}" ] && continue
184
+ base_name=$(basename "${candidate}" .md)
185
+ if [[ "${base_name}" == "${branch_slug}"* ]] || [[ "${branch_slug}" == "${base_name}"* ]]; then
186
+ printf "%s" "${candidate}"
187
+ return 0
188
+ fi
189
+ if [ -n "${branch_number}" ] && [[ "${base_name}" == "${branch_number}-"* ]]; then
190
+ printf "%s" "${candidate}"
191
+ return 0
192
+ fi
193
+ done < <(find "${dir}" -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)
194
+ done
195
+
196
+ return 1
197
+ }
198
+
199
+ build_prd_context_for_pr() {
200
+ local pr_number="${1:?PR number required}"
201
+ local pr_payload=""
202
+ local pr_title=""
203
+ local pr_branch=""
204
+ local pr_body=""
205
+ local pr_url=""
206
+ local issue_context=""
207
+ local issue_count=0
208
+ local issue_number=""
209
+ local issue_payload=""
210
+ local issue_title=""
211
+ local issue_body=""
212
+ local issue_excerpt=""
213
+ local prd_file=""
214
+ local prd_payload=""
215
+ local prd_excerpt=""
216
+ local prd_rel_path=""
217
+ local section=""
218
+
219
+ pr_payload=$(gh pr view "${pr_number}" --json title,headRefName,body,url 2>/dev/null || true)
220
+ pr_title=$(printf '%s' "${pr_payload}" | jq -r '.title // ""' 2>/dev/null || echo "")
221
+ pr_branch=$(printf '%s' "${pr_payload}" | jq -r '.headRefName // ""' 2>/dev/null || echo "")
222
+ pr_body=$(printf '%s' "${pr_payload}" | jq -r '.body // ""' 2>/dev/null || echo "")
223
+ pr_url=$(printf '%s' "${pr_payload}" | jq -r '.url // ""' 2>/dev/null || echo "")
224
+
225
+ if [ -n "${pr_body}" ]; then
226
+ while IFS= read -r issue_number; do
227
+ [ -z "${issue_number}" ] && continue
228
+ issue_count=$((issue_count + 1))
229
+ if [ "${issue_count}" -gt 2 ]; then
230
+ break
231
+ fi
232
+
233
+ issue_payload=$(gh issue view "${issue_number}" --json title,body,url 2>/dev/null || true)
234
+ issue_title=$(printf '%s' "${issue_payload}" | jq -r '.title // ""' 2>/dev/null || echo "")
235
+ issue_body=$(printf '%s' "${issue_payload}" | jq -r '.body // ""' 2>/dev/null || echo "")
236
+ [ -z "${issue_body}" ] && continue
237
+
238
+ issue_excerpt=$(truncate_for_prompt "${issue_body}" 4500)
239
+ issue_context="${issue_context}${issue_context:+$'\n\n'}Issue #${issue_number}: ${issue_title}
240
+ ${issue_excerpt}"
241
+ done < <(extract_linked_issue_numbers "${pr_body}")
242
+ fi
243
+
244
+ if [ -z "${issue_context}" ] && [ -n "${pr_branch}" ]; then
245
+ prd_file=$(find_prd_file_by_branch "${pr_branch}" || true)
246
+ if [ -n "${prd_file}" ] && [ -f "${prd_file}" ]; then
247
+ prd_payload=$(cat "${prd_file}" 2>/dev/null || true)
248
+ if [ -n "${prd_payload}" ]; then
249
+ prd_excerpt=$(truncate_for_prompt "${prd_payload}" 4500)
250
+ if [[ "${prd_file}" == "${PROJECT_DIR}/"* ]]; then
251
+ prd_rel_path="${prd_file#${PROJECT_DIR}/}"
252
+ else
253
+ prd_rel_path="${prd_file}"
254
+ fi
255
+ fi
256
+ fi
257
+ fi
258
+
259
+ section="### PR #${pr_number}"
260
+ if [ -n "${pr_title}" ]; then
261
+ section="${section} — ${pr_title}"
262
+ fi
263
+ section="${section}
264
+ - branch: ${pr_branch:-unknown}"
265
+ if [ -n "${pr_url}" ]; then
266
+ section="${section}
267
+ - url: ${pr_url}"
268
+ fi
269
+
270
+ if [ -n "${issue_context}" ]; then
271
+ section="${section}
272
+ - context source: linked GitHub issue body
273
+ ${issue_context}"
274
+ elif [ -n "${prd_excerpt}" ]; then
275
+ section="${section}
276
+ - context source: ${prd_rel_path}
277
+ ${prd_excerpt}"
278
+ else
279
+ section="${section}
280
+ - context source: not found"
281
+ fi
282
+
283
+ printf "%s" "${section}"
284
+ }
285
+
286
+ build_prd_context_prompt() {
287
+ local pr_number=""
288
+ local entry=""
289
+ local combined=""
290
+
291
+ for pr_number in "$@"; do
292
+ [ -z "${pr_number}" ] && continue
293
+ entry=$(build_prd_context_for_pr "${pr_number}")
294
+ [ -z "${entry}" ] && continue
295
+ combined="${combined}${combined:+$'\n\n'}${entry}"
296
+ done
297
+
298
+ [ -z "${combined}" ] && return 0
299
+ printf '\n\n## PRD Context\nUse this product context while reviewing and fixing PRs.\n%s\n' "${combined}"
300
+ }
301
+
127
302
  # Extract the latest review score from PR comments
128
303
  # Returns empty string if no score found
129
304
  get_pr_score() {
@@ -143,6 +318,135 @@ get_pr_score() {
143
318
  | grep -oP '\d+(?=/100)' || echo ""
144
319
  }
145
320
 
321
+ # Count failed CI checks for a PR.
322
+ # Uses JSON fields when available (more reliable across check name/status formats),
323
+ # then falls back to text parsing for older/mocked gh outputs.
324
+ get_pr_failed_ci_checks() {
325
+ local pr_number="${1:?PR number required}"
326
+ local failed_count=""
327
+
328
+ failed_count="$(
329
+ gh pr checks "${pr_number}" --json bucket,state,conclusion --jq '
330
+ [ .[]
331
+ | (.bucket // "" | ascii_downcase) as $bucket
332
+ | (.state // "" | ascii_downcase) as $state
333
+ | (.conclusion // "" | ascii_downcase) as $conclusion
334
+ | select(
335
+ $bucket == "fail" or
336
+ $bucket == "cancel" or
337
+ $state == "failure" or
338
+ $state == "error" or
339
+ $state == "cancelled" or
340
+ $conclusion == "failure" or
341
+ $conclusion == "error" or
342
+ $conclusion == "cancelled" or
343
+ $conclusion == "timed_out" or
344
+ $conclusion == "action_required" or
345
+ $conclusion == "startup_failure" or
346
+ $conclusion == "stale"
347
+ )
348
+ ] | length
349
+ ' 2>/dev/null || true
350
+ )"
351
+
352
+ if [[ "${failed_count}" =~ ^[0-9]+$ ]]; then
353
+ echo "${failed_count}"
354
+ return 0
355
+ fi
356
+
357
+ failed_count=$(
358
+ gh pr checks "${pr_number}" 2>/dev/null \
359
+ | grep -Eci 'fail|error|cancel|timed[_ -]?out|action_required|startup_failure|stale' || true
360
+ )
361
+
362
+ if [[ "${failed_count}" =~ ^[0-9]+$ ]]; then
363
+ echo "${failed_count}"
364
+ else
365
+ echo "0"
366
+ fi
367
+ }
368
+
369
+ # Return a semicolon-separated summary of failing CI checks for a PR.
370
+ # Format: "<check name> [state=<state>, conclusion=<conclusion>]"
371
+ get_pr_failed_ci_summary() {
372
+ local pr_number="${1:?PR number required}"
373
+ local failed_summary=""
374
+
375
+ failed_summary="$(
376
+ gh pr checks "${pr_number}" --json name,bucket,state,conclusion --jq '
377
+ [ .[]
378
+ | (.bucket // "" | ascii_downcase) as $bucket
379
+ | (.state // "" | ascii_downcase) as $state
380
+ | (.conclusion // "" | ascii_downcase) as $conclusion
381
+ | select(
382
+ $bucket == "fail" or
383
+ $bucket == "cancel" or
384
+ $state == "failure" or
385
+ $state == "error" or
386
+ $state == "cancelled" or
387
+ $conclusion == "failure" or
388
+ $conclusion == "error" or
389
+ $conclusion == "cancelled" or
390
+ $conclusion == "timed_out" or
391
+ $conclusion == "action_required" or
392
+ $conclusion == "startup_failure" or
393
+ $conclusion == "stale"
394
+ )
395
+ | "\(.name // "unknown") [state=\(.state // "unknown"), conclusion=\(.conclusion // "unknown")]"
396
+ ] | join("; ")
397
+ ' 2>/dev/null || true
398
+ )"
399
+
400
+ if [ -n "${failed_summary}" ]; then
401
+ echo "${failed_summary}"
402
+ return 0
403
+ fi
404
+
405
+ # Fallback for older/mocked outputs where JSON fields aren't available.
406
+ failed_summary=$(
407
+ gh pr checks "${pr_number}" 2>/dev/null \
408
+ | grep -Ei 'fail|error|cancel|timed[_ -]?out|action_required|startup_failure|stale' \
409
+ | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]][[:space:]]*/ /g' \
410
+ | paste -sd '; ' - || true
411
+ )
412
+
413
+ echo "${failed_summary}"
414
+ }
415
+
416
+ # Clean up reviewer-managed worktrees.
417
+ # - Always removes the caller's runner worktree when runner_scope is provided.
418
+ # - Only non-worker/controller processes perform broad cleanup to avoid
419
+ # parallel workers deleting each other's active worktrees.
420
+ cleanup_reviewer_worktrees() {
421
+ local runner_scope="${1:-}"
422
+
423
+ if [ -n "${runner_scope}" ]; then
424
+ cleanup_worktrees "${PROJECT_DIR}" "${runner_scope}"
425
+ fi
426
+
427
+ if [ "${WORKER_MODE}" = "1" ]; then
428
+ return 0
429
+ fi
430
+
431
+ # Remove per-PR reviewer worktrees created by prompts from older runs.
432
+ cleanup_worktrees "${PROJECT_DIR}" "${PROJECT_NAME}-nw-review-"
433
+
434
+ # Remove legacy reviewer worktree naming used in some older prompt variants.
435
+ local escaped_project_name
436
+ escaped_project_name=$(printf '%s\n' "${PROJECT_NAME}" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')
437
+ git -C "${PROJECT_DIR}" worktree list --porcelain 2>/dev/null \
438
+ | grep '^worktree ' \
439
+ | awk '{print $2}' \
440
+ | while read -r wt; do
441
+ local wt_basename
442
+ wt_basename=$(basename "${wt}")
443
+ if printf '%s\n' "${wt_basename}" | grep -Eq "^${escaped_project_name}-pr-?[0-9]+$"; then
444
+ log "CLEANUP: Removing legacy reviewer worktree ${wt}"
445
+ git -C "${PROJECT_DIR}" worktree remove --force "${wt}" 2>/dev/null || true
446
+ fi
447
+ done || true
448
+ }
449
+
146
450
  # Validate provider
147
451
  if ! validate_provider "${PROVIDER_CMD}"; then
148
452
  echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
@@ -221,9 +525,14 @@ while IFS=$'\t' read -r pr_number pr_branch; do
221
525
  continue
222
526
  fi
223
527
 
224
- FAILED_CHECKS=$(gh pr checks "${pr_number}" 2>/dev/null | grep -ci 'fail' || true)
528
+ FAILED_CHECKS=$(get_pr_failed_ci_checks "${pr_number}")
225
529
  if [ "${FAILED_CHECKS}" -gt 0 ]; then
226
- log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s)"
530
+ FAILED_SUMMARY=$(get_pr_failed_ci_summary "${pr_number}")
531
+ if [ -n "${FAILED_SUMMARY}" ]; then
532
+ log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s): ${FAILED_SUMMARY}"
533
+ else
534
+ log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s)"
535
+ fi
227
536
  NEEDS_WORK=1
228
537
  PRS_NEEDING_WORK="${PRS_NEEDING_WORK} #${pr_number}"
229
538
  continue
@@ -325,6 +634,10 @@ fi
325
634
 
326
635
  log "START: Found PR(s) needing work:${PRS_NEEDING_WORK}"
327
636
 
637
+ # Remove stale reviewer worktrees from previous interrupted runs.
638
+ # Worker processes skip broad cleanup to avoid parallel interference.
639
+ cleanup_reviewer_worktrees
640
+
328
641
  # Convert "#12 #34" into ["12", "34"] for worker fan-out.
329
642
  PR_NUMBER_ARRAY=()
330
643
  for pr_token in ${PRS_NEEDING_WORK}; do
@@ -355,7 +668,14 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
355
668
  declare -a WORKER_PRS=()
356
669
  declare -a WORKER_OUTPUTS=()
357
670
 
671
+ WORKER_IDX=0
672
+ WORKER_STAGGER_DELAY="${NW_REVIEWER_WORKER_STAGGER:-60}"
358
673
  for pr_number in "${PR_NUMBER_ARRAY[@]}"; do
674
+ if [ "${WORKER_IDX}" -gt 0 ]; then
675
+ log "PARALLEL: Staggering worker launch by ${WORKER_STAGGER_DELAY}s (worker $((WORKER_IDX + 1))/${#PR_NUMBER_ARRAY[@]})"
676
+ sleep "${WORKER_STAGGER_DELAY}"
677
+ fi
678
+
359
679
  worker_output=$(mktemp "/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}-pr-${pr_number}.XXXXXX")
360
680
  WORKER_OUTPUTS+=("${worker_output}")
361
681
  WORKER_PRS+=("${pr_number}")
@@ -370,6 +690,7 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
370
690
  worker_pid=$!
371
691
  WORKER_PIDS+=("${worker_pid}")
372
692
  log "PARALLEL: Worker PID ${worker_pid} started for PR #${pr_number}"
693
+ WORKER_IDX=$((WORKER_IDX + 1))
373
694
  done
374
695
 
375
696
  EXIT_CODE=0
@@ -439,6 +760,10 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
439
760
  fi
440
761
  done
441
762
 
763
+ # Parent/controller process cleans up any per-PR reviewer worktrees that
764
+ # worker runs may have left behind.
765
+ cleanup_reviewer_worktrees
766
+
442
767
  emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}" "${MAX_WORKER_ATTEMPTS}" "${MAX_WORKER_FINAL_SCORE}"
443
768
  exit 0
444
769
  fi
@@ -449,7 +774,7 @@ if [ -n "${TARGET_PR}" ]; then
449
774
  fi
450
775
  REVIEW_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${REVIEW_WORKTREE_BASENAME}"
451
776
 
452
- cleanup_worktrees "${PROJECT_DIR}" "${REVIEW_WORKTREE_BASENAME}"
777
+ cleanup_reviewer_worktrees "${REVIEW_WORKTREE_BASENAME}"
453
778
 
454
779
  # Dry-run mode: print diagnostics and exit
455
780
  if [ "${NW_DRY_RUN:-0}" = "1" ]; then
@@ -479,12 +804,59 @@ if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFA
479
804
  exit 1
480
805
  fi
481
806
 
807
+ REVIEWER_PROMPT_PATH=$(resolve_instruction_path_with_fallback "${REVIEW_WORKTREE_DIR}" "pr-reviewer.md" "night-watch-pr-reviewer.md" || true)
808
+ if [ -z "${REVIEWER_PROMPT_PATH}" ]; then
809
+ log "FAIL: Missing reviewer prompt file. Checked pr-reviewer.md/night-watch-pr-reviewer.md in instructions/, .claude/commands/, and bundled templates/"
810
+ emit_result "failure" "reason=missing_reviewer_prompt"
811
+ exit 1
812
+ fi
813
+ REVIEWER_PROMPT_BUNDLED_NAME="pr-reviewer.md"
814
+ if [[ "${REVIEWER_PROMPT_PATH}" == */night-watch-pr-reviewer.md ]]; then
815
+ REVIEWER_PROMPT_BUNDLED_NAME="night-watch-pr-reviewer.md"
816
+ fi
817
+ REVIEWER_PROMPT_PATH=$(prefer_bundled_prompt_if_legacy_command "${REVIEW_WORKTREE_DIR}" "${REVIEWER_PROMPT_PATH}" "${REVIEWER_PROMPT_BUNDLED_NAME}")
818
+ REVIEWER_PROMPT_BASE=$(cat "${REVIEWER_PROMPT_PATH}")
819
+ REVIEWER_PROMPT_REF=$(instruction_ref_for_prompt "${REVIEW_WORKTREE_DIR}" "${REVIEWER_PROMPT_PATH}")
820
+ log "INFO: Using reviewer prompt from ${REVIEWER_PROMPT_REF}"
821
+
482
822
  EXIT_CODE=0
483
823
  ATTEMPTS_MADE=1
484
824
  FINAL_SCORE=""
485
825
  TARGET_SCOPE_PROMPT=""
486
826
  if [ -n "${TARGET_PR}" ]; then
487
827
  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'
828
+
829
+ TARGET_MERGE_STATE=$(gh pr view "${TARGET_PR}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo "UNKNOWN")
830
+ TARGET_FAILED_CHECKS=$(get_pr_failed_ci_summary "${TARGET_PR}")
831
+ TARGET_SCORE=$(get_pr_score "${TARGET_PR}")
832
+
833
+ TARGET_SCOPE_PROMPT+=$'\n## Preflight Data (from CLI)\n- mergeStateStatus: '"${TARGET_MERGE_STATE}"$'\n'
834
+ if [ -n "${TARGET_FAILED_CHECKS}" ]; then
835
+ TARGET_SCOPE_PROMPT+=$'- failing checks: '"${TARGET_FAILED_CHECKS}"$'\n'
836
+ else
837
+ TARGET_SCOPE_PROMPT+=$'- failing checks: none detected\n'
838
+ fi
839
+ if [ -n "${TARGET_SCORE}" ]; then
840
+ TARGET_SCOPE_PROMPT+=$'- latest review score: '"${TARGET_SCORE}"$'/100\n'
841
+ else
842
+ TARGET_SCOPE_PROMPT+=$'- latest review score: not found\n'
843
+ fi
844
+ fi
845
+
846
+ PRD_CONTEXT_PROMPT=""
847
+ if [ -n "${TARGET_PR}" ]; then
848
+ PRD_CONTEXT_PROMPT=$(build_prd_context_prompt "${TARGET_PR}")
849
+ elif [ "${#PR_NUMBER_ARRAY[@]}" -gt 0 ]; then
850
+ PRD_CONTEXT_PROMPT=$(build_prd_context_prompt "${PR_NUMBER_ARRAY[@]}")
851
+ fi
852
+ if [ -n "${PRD_CONTEXT_PROMPT}" ]; then
853
+ if [ -n "${TARGET_PR}" ]; then
854
+ log "INFO: Added PRD context for PR #${TARGET_PR}"
855
+ else
856
+ log "INFO: Added PRD context for ${#PR_NUMBER_ARRAY[@]} PR(s)"
857
+ fi
858
+ else
859
+ log "WARN: No PRD context found for current reviewer scope"
488
860
  fi
489
861
 
490
862
  # ── Retry Loop for Targeted PR Review ──────────────────────────────────────────
@@ -518,13 +890,14 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
518
890
  fi
519
891
 
520
892
  log "RETRY: Starting attempt ${ATTEMPT}/${TOTAL_ATTEMPTS} (timeout: ${ATTEMPT_TIMEOUT}s)"
893
+ LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
894
+ REVIEWER_PROMPT="${REVIEWER_PROMPT_BASE}${TARGET_SCOPE_PROMPT}${PRD_CONTEXT_PROMPT}"
521
895
 
522
896
  case "${PROVIDER_CMD}" in
523
897
  claude)
524
- CLAUDE_PROMPT="/night-watch-pr-reviewer${TARGET_SCOPE_PROMPT}"
525
898
  if (
526
899
  cd "${REVIEW_WORKTREE_DIR}" && timeout "${ATTEMPT_TIMEOUT}" \
527
- claude -p "${CLAUDE_PROMPT}" \
900
+ claude -p "${REVIEWER_PROMPT}" \
528
901
  --dangerously-skip-permissions \
529
902
  >> "${LOG_FILE}" 2>&1
530
903
  ); then
@@ -534,12 +907,11 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
534
907
  fi
535
908
  ;;
536
909
  codex)
537
- CODEX_PROMPT="$(cat "${REVIEW_WORKTREE_DIR}/.claude/commands/night-watch-pr-reviewer.md")${TARGET_SCOPE_PROMPT}"
538
910
  if (
539
911
  cd "${REVIEW_WORKTREE_DIR}" && timeout "${ATTEMPT_TIMEOUT}" \
540
912
  codex --quiet \
541
913
  --yolo \
542
- --prompt "${CODEX_PROMPT}" \
914
+ --prompt "${REVIEWER_PROMPT}" \
543
915
  >> "${LOG_FILE}" 2>&1
544
916
  ); then
545
917
  EXIT_CODE=0
@@ -553,8 +925,16 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
553
925
  ;;
554
926
  esac
555
927
 
556
- # If provider failed (non-zero exit), don't retry
928
+ # If provider failed (non-zero exit), check for rate limit before giving up
557
929
  if [ "${EXIT_CODE}" -ne 0 ]; then
930
+ if [ "${EXIT_CODE}" -ne 124 ] && \
931
+ check_rate_limited "${LOG_FILE}" "${LOG_LINE_BEFORE}" && \
932
+ [ -n "${TARGET_PR}" ] && \
933
+ [ "${ATTEMPT}" -lt "${TOTAL_ATTEMPTS}" ]; then
934
+ log "RATE-LIMITED: 429 detected for PR #${TARGET_PR} (attempt ${ATTEMPT}/${TOTAL_ATTEMPTS}), retrying in 120s..."
935
+ sleep 120
936
+ continue
937
+ fi
558
938
  log "RETRY: Provider exited with code ${EXIT_CODE}, not retrying"
559
939
  break
560
940
  fi
@@ -585,7 +965,7 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
585
965
  fi
586
966
  done
587
967
 
588
- cleanup_worktrees "${PROJECT_DIR}" "${REVIEW_WORKTREE_BASENAME}"
968
+ cleanup_reviewer_worktrees "${REVIEW_WORKTREE_BASENAME}"
589
969
 
590
970
  # ── Auto-merge eligible PRs ─────────────────────────────────────────────────────
591
971
  # After the reviewer completes, check for PRs that are merge-ready and queue them
@@ -53,6 +53,90 @@ emit_result() {
53
53
  fi
54
54
  }
55
55
 
56
+ decode_base64_value() {
57
+ local value="${1:-}"
58
+ if [ -z "${value}" ]; then
59
+ return 0
60
+ fi
61
+ if printf '%s' "${value}" | base64 --decode >/dev/null 2>&1; then
62
+ printf '%s' "${value}" | base64 --decode
63
+ else
64
+ printf '%s' "${value}" | base64 -d 2>/dev/null || true
65
+ fi
66
+ }
67
+
68
+ get_pr_comment_bodies_base64() {
69
+ local pr_number="${1:?PR number required}"
70
+ gh pr view "${pr_number}" --json comments --jq '.comments[]?.body | @base64' 2>/dev/null || true
71
+ if [ -n "${REPO:-}" ]; then
72
+ gh api "repos/${REPO}/issues/${pr_number}/comments" --jq '.[].body | @base64' 2>/dev/null || true
73
+ fi
74
+ }
75
+
76
+ get_latest_qa_comment_body() {
77
+ local pr_number="${1:?PR number required}"
78
+ local latest=""
79
+ local encoded=""
80
+ local decoded=""
81
+
82
+ while IFS= read -r encoded; do
83
+ [ -z "${encoded}" ] && continue
84
+ decoded=$(decode_base64_value "${encoded}")
85
+ if printf '%s' "${decoded}" | grep -q '<!-- night-watch-qa-marker -->'; then
86
+ latest="${decoded}"
87
+ fi
88
+ done < <(get_pr_comment_bodies_base64 "${pr_number}")
89
+
90
+ printf "%s" "${latest}"
91
+ }
92
+
93
+ pr_has_qa_generated_files() {
94
+ local pr_number="${1:?PR number required}"
95
+ gh pr view "${pr_number}" --json files --jq '.files[]?.path' 2>/dev/null \
96
+ | grep -Eq '^(qa-artifacts/|tests/.*/qa/)'
97
+ }
98
+
99
+ provider_output_looks_invalid() {
100
+ local from_line="${1:-0}"
101
+ if [ ! -f "${LOG_FILE}" ]; then
102
+ return 1
103
+ fi
104
+
105
+ tail -n "+$((from_line + 1))" "${LOG_FILE}" 2>/dev/null \
106
+ | grep -Eqi 'Unknown skill:|session is in a broken state|working directory .* no longer exists|Please restart this session'
107
+ }
108
+
109
+ validate_qa_evidence() {
110
+ local pr_number="${1:?PR number required}"
111
+ local qa_comment=""
112
+
113
+ qa_comment=$(get_latest_qa_comment_body "${pr_number}")
114
+ if [ -z "${qa_comment}" ]; then
115
+ log "FAIL-QA-EVIDENCE: PR #${pr_number} has no QA marker comment (<!-- night-watch-qa-marker -->)"
116
+ return 1
117
+ fi
118
+
119
+ if printf '%s' "${qa_comment}" | grep -Eqi 'QA: No tests needed for this PR|No tests needed'; then
120
+ return 0
121
+ fi
122
+
123
+ if ! pr_has_qa_generated_files "${pr_number}"; then
124
+ log "FAIL-QA-EVIDENCE: PR #${pr_number} has QA marker comment but no qa-artifacts/ or tests/*/qa/ files"
125
+ return 1
126
+ fi
127
+
128
+ if [ "${QA_ARTIFACTS}" = "screenshot" ] || [ "${QA_ARTIFACTS}" = "both" ]; then
129
+ if printf '%s' "${qa_comment}" | grep -q '#### UI Tests (Playwright)'; then
130
+ if ! printf '%s' "${qa_comment}" | grep -Eq '!\[[^]]*\]\([^)]*qa-artifacts/[^)]*\)'; then
131
+ log "FAIL-QA-EVIDENCE: PR #${pr_number} reports UI tests but comment lacks screenshot links to qa-artifacts/"
132
+ return 1
133
+ fi
134
+ fi
135
+ fi
136
+
137
+ return 0
138
+ }
139
+
56
140
  # Validate provider
57
141
  if ! validate_provider "${PROVIDER_CMD}"; then
58
142
  echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
@@ -217,15 +301,32 @@ Artifacts: ${QA_ARTIFACTS}"
217
301
  continue
218
302
  fi
219
303
 
304
+ QA_PROMPT_PATH=$(resolve_instruction_path_with_fallback "${QA_WORKTREE_DIR}" "qa.md" "night-watch-qa.md" || true)
305
+ if [ -z "${QA_PROMPT_PATH}" ]; then
306
+ log "FAIL: Missing QA prompt file for PR #${pr_num}. Checked qa.md/night-watch-qa.md in instructions/, .claude/commands/, and bundled templates/"
307
+ EXIT_CODE=1
308
+ break
309
+ fi
310
+ QA_PROMPT_BUNDLED_NAME="qa.md"
311
+ if [[ "${QA_PROMPT_PATH}" == */night-watch-qa.md ]]; then
312
+ QA_PROMPT_BUNDLED_NAME="night-watch-qa.md"
313
+ fi
314
+ QA_PROMPT_PATH=$(prefer_bundled_prompt_if_legacy_command "${QA_WORKTREE_DIR}" "${QA_PROMPT_PATH}" "${QA_PROMPT_BUNDLED_NAME}")
315
+ QA_PROMPT=$(cat "${QA_PROMPT_PATH}")
316
+ QA_PROMPT_REF=$(instruction_ref_for_prompt "${QA_WORKTREE_DIR}" "${QA_PROMPT_PATH}")
317
+ log "QA: PR #${pr_num} — using prompt from ${QA_PROMPT_REF}"
318
+
319
+ LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
320
+ PROVIDER_OK=0
220
321
  case "${PROVIDER_CMD}" in
221
322
  claude)
222
323
  if (
223
324
  cd "${QA_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
224
- claude -p "/night-watch-qa" \
325
+ claude -p "${QA_PROMPT}" \
225
326
  --dangerously-skip-permissions \
226
327
  >> "${LOG_FILE}" 2>&1
227
328
  ); then
228
- log "QA: PR #${pr_num} — provider completed successfully"
329
+ PROVIDER_OK=1
229
330
  else
230
331
  local_exit=$?
231
332
  log "QA: PR #${pr_num} — provider exited with code ${local_exit}"
@@ -241,10 +342,10 @@ Artifacts: ${QA_ARTIFACTS}"
241
342
  cd "${QA_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
242
343
  codex --quiet \
243
344
  --yolo \
244
- --prompt "$(cat "${QA_WORKTREE_DIR}/.claude/commands/night-watch-qa.md")" \
345
+ --prompt "${QA_PROMPT}" \
245
346
  >> "${LOG_FILE}" 2>&1
246
347
  ); then
247
- log "QA: PR #${pr_num} — provider completed successfully"
348
+ PROVIDER_OK=1
248
349
  else
249
350
  local_exit=$?
250
351
  log "QA: PR #${pr_num} — provider exited with code ${local_exit}"
@@ -261,6 +362,17 @@ Artifacts: ${QA_ARTIFACTS}"
261
362
  ;;
262
363
  esac
263
364
 
365
+ if [ "${PROVIDER_OK}" -eq 1 ]; then
366
+ if provider_output_looks_invalid "${LOG_LINE_BEFORE}"; then
367
+ log "FAIL-QA-EVIDENCE: PR #${pr_num} provider output indicates an invalid automation run"
368
+ EXIT_CODE=1
369
+ elif ! validate_qa_evidence "${pr_num}"; then
370
+ EXIT_CODE=1
371
+ else
372
+ log "QA: PR #${pr_num} — provider completed with verifiable QA evidence"
373
+ fi
374
+ fi
375
+
264
376
  cleanup_worktrees "${PROJECT_DIR}"
265
377
  done
266
378