@jonit-dev/night-watch-cli 1.5.6 → 1.5.8

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.
@@ -15,18 +15,18 @@ set -euo pipefail
15
15
  PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
16
16
  PROJECT_NAME=$(basename "${PROJECT_DIR}")
17
17
  PRD_DIR_REL="${NW_PRD_DIR:-docs/PRDs/night-watch}"
18
+ if [[ "${PRD_DIR_REL}" = /* ]]; then
19
+ PRD_DIR="${PRD_DIR_REL}"
20
+ else
21
+ PRD_DIR="${PROJECT_DIR}/${PRD_DIR_REL}"
22
+ fi
18
23
  LOG_DIR="${PROJECT_DIR}/logs"
19
24
  LOG_FILE="${LOG_DIR}/night-watch.log"
20
- LOCK_FILE=""
25
+ # NOTE: Lock file path must match LOCK_FILE_PREFIX in src/constants.ts
26
+ LOCK_FILE="/tmp/night-watch-${PROJECT_NAME}.lock"
21
27
  MAX_RUNTIME="${NW_MAX_RUNTIME:-7200}" # 2 hours
22
28
  MAX_LOG_SIZE="524288" # 512 KB
23
29
  PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
24
- RUNTIME_MIRROR_DIR=""
25
- RUNTIME_PROJECT_DIR=""
26
- PRD_DIR=""
27
- ORIGINAL_PRD_DIR=""
28
- ELIGIBLE_PRD=""
29
- CLAIMED=0
30
30
 
31
31
  # Ensure NVM / Node / Claude are on PATH
32
32
  export NVM_DIR="${HOME}/.nvm"
@@ -41,8 +41,6 @@ mkdir -p "${LOG_DIR}"
41
41
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42
42
  # shellcheck source=night-watch-helpers.sh
43
43
  source "${SCRIPT_DIR}/night-watch-helpers.sh"
44
- PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
45
- LOCK_FILE="/tmp/night-watch-${PROJECT_RUNTIME_KEY}.lock"
46
44
 
47
45
  # Validate provider
48
46
  if ! validate_provider "${PROVIDER_CMD}"; then
@@ -56,53 +54,7 @@ if ! acquire_lock "${LOCK_FILE}"; then
56
54
  exit 0
57
55
  fi
58
56
 
59
- cleanup_on_exit() {
60
- rm -f "${LOCK_FILE}"
61
-
62
- if [ "${CLAIMED}" = "1" ] && [ -n "${ELIGIBLE_PRD}" ] && [ -n "${PRD_DIR}" ]; then
63
- release_claim "${PRD_DIR}" "${ELIGIBLE_PRD}" || true
64
- if [ -n "${ORIGINAL_PRD_DIR}" ] && [ "${ORIGINAL_PRD_DIR}" != "${PRD_DIR}" ]; then
65
- release_claim "${ORIGINAL_PRD_DIR}" "${ELIGIBLE_PRD}" || true
66
- fi
67
- fi
68
-
69
- if [ -n "${RUNTIME_MIRROR_DIR}" ] && [ -n "${RUNTIME_PROJECT_DIR}" ]; then
70
- cleanup_runtime_workspace "${RUNTIME_MIRROR_DIR}" "${RUNTIME_PROJECT_DIR}" || true
71
- fi
72
- }
73
-
74
- trap cleanup_on_exit EXIT
75
-
76
- if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
77
- DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
78
- else
79
- DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
80
- fi
81
-
82
- runtime_info=()
83
- if mapfile -t runtime_info < <(prepare_runtime_workspace "${PROJECT_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"); then
84
- RUNTIME_MIRROR_DIR="${runtime_info[0]:-}"
85
- RUNTIME_PROJECT_DIR="${runtime_info[1]:-}"
86
- else
87
- log "FAIL: Could not prepare runtime workspace for ${PROJECT_DIR}"
88
- exit 1
89
- fi
90
-
91
- if [ -z "${RUNTIME_MIRROR_DIR}" ] || [ -z "${RUNTIME_PROJECT_DIR}" ]; then
92
- log "FAIL: Runtime workspace paths are missing"
93
- exit 1
94
- fi
95
-
96
- if [[ "${PRD_DIR_REL}" = /* ]]; then
97
- PRD_DIR="${PRD_DIR_REL}"
98
- ORIGINAL_PRD_DIR="${PRD_DIR_REL}"
99
- else
100
- PRD_DIR="${RUNTIME_PROJECT_DIR}/${PRD_DIR_REL}"
101
- ORIGINAL_PRD_DIR="${PROJECT_DIR}/${PRD_DIR_REL}"
102
- fi
103
-
104
- # Promote any pending-review PRDs whose PRs have been merged
105
- promote_merged_prds "${ORIGINAL_PRD_DIR}" "${PROJECT_DIR}" || true
57
+ cleanup_worktrees "${PROJECT_DIR}"
106
58
 
107
59
  ELIGIBLE_PRD=$(find_eligible_prd "${PRD_DIR}" "${MAX_RUNTIME}" "${PROJECT_DIR}")
108
60
 
@@ -113,30 +65,35 @@ fi
113
65
 
114
66
  # Claim the PRD to prevent other runs from selecting it
115
67
  claim_prd "${PRD_DIR}" "${ELIGIBLE_PRD}"
116
- # Also mirror claim to original project dir so the status dashboard can see it
117
- if [ "${ORIGINAL_PRD_DIR}" != "${PRD_DIR}" ]; then
118
- claim_prd "${ORIGINAL_PRD_DIR}" "${ELIGIBLE_PRD}" || true
119
- fi
120
- CLAIMED=1
68
+
69
+ # Update EXIT trap to also release claim
70
+ trap "rm -f '${LOCK_FILE}'; release_claim '${PRD_DIR}' '${ELIGIBLE_PRD}'" EXIT
121
71
 
122
72
  PRD_NAME="${ELIGIBLE_PRD%.md}"
123
73
  BRANCH_NAME="night-watch/${PRD_NAME}"
124
-
125
- log "START: Processing ${ELIGIBLE_PRD} on branch ${BRANCH_NAME} in runtime workspace ${RUNTIME_PROJECT_DIR}"
126
-
127
- if ! prepare_branch_checkout "${RUNTIME_PROJECT_DIR}" "${BRANCH_NAME}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
128
- log "FAIL: Could not prepare branch ${BRANCH_NAME} in runtime workspace"
129
- night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
130
- exit 1
74
+ WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${PROJECT_NAME}-nw-${PRD_NAME}"
75
+ BOOKKEEP_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${PROJECT_NAME}-nw-bookkeeping"
76
+ if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
77
+ DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
78
+ else
79
+ DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
80
+ fi
81
+ if [[ "${PRD_DIR_REL}" = /* ]]; then
82
+ BOOKKEEP_PRD_DIR="${PRD_DIR_REL}"
83
+ else
84
+ BOOKKEEP_PRD_DIR="${BOOKKEEP_WORKTREE_DIR}/${PRD_DIR_REL}"
131
85
  fi
132
86
 
87
+ log "START: Processing ${ELIGIBLE_PRD} on branch ${BRANCH_NAME} (worktree: ${WORKTREE_DIR})"
88
+
133
89
  PROMPT="Implement the PRD at docs/PRDs/night-watch/${ELIGIBLE_PRD}
134
90
 
135
91
  ## Setup
136
- - You are already inside an isolated runtime workspace: ${RUNTIME_PROJECT_DIR}
137
- - Current branch is already prepared: ${BRANCH_NAME}
138
- - Do not run git checkout/switch in ${PROJECT_DIR}
139
- - Do not create or remove worktrees; the runtime controller handles isolation and cleanup
92
+ - You are already inside an isolated worktree at: ${WORKTREE_DIR}
93
+ - Current branch is already checked out: ${BRANCH_NAME}
94
+ - Do NOT run git checkout/switch in ${PROJECT_DIR}
95
+ - Do NOT create or remove worktrees; the cron script manages that
96
+ - Install dependencies if needed and implement in the current worktree only
140
97
 
141
98
  ## Implementation — PRD Executor Workflow
142
99
  Read .claude/commands/prd-executor.md and follow its FULL execution pipeline:
@@ -164,11 +121,18 @@ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
164
121
  echo "Provider: ${PROVIDER_CMD}"
165
122
  echo "Eligible PRD: ${ELIGIBLE_PRD}"
166
123
  echo "Branch: ${BRANCH_NAME}"
167
- echo "Runtime Dir: ${RUNTIME_PROJECT_DIR}"
124
+ echo "Worktree: ${WORKTREE_DIR}"
125
+ echo "Bookkeeping: ${BOOKKEEP_WORKTREE_DIR}"
168
126
  echo "Timeout: ${MAX_RUNTIME}s"
169
127
  exit 0
170
128
  fi
171
129
 
130
+ if ! prepare_branch_worktree "${PROJECT_DIR}" "${WORKTREE_DIR}" "${BRANCH_NAME}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
131
+ log "FAIL: Unable to create isolated worktree ${WORKTREE_DIR} for ${BRANCH_NAME}"
132
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
133
+ exit 1
134
+ fi
135
+
172
136
  # Sandbox: prevent the agent from modifying crontab during execution
173
137
  export NW_EXECUTION_CONTEXT=agent
174
138
 
@@ -186,7 +150,7 @@ while [ "${ATTEMPT}" -lt "${MAX_RETRIES}" ]; do
186
150
  case "${PROVIDER_CMD}" in
187
151
  claude)
188
152
  if (
189
- cd "${RUNTIME_PROJECT_DIR}" && timeout "${MAX_RUNTIME}" \
153
+ cd "${WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
190
154
  claude -p "${PROMPT}" \
191
155
  --dangerously-skip-permissions \
192
156
  >> "${LOG_FILE}" 2>&1
@@ -198,7 +162,7 @@ while [ "${ATTEMPT}" -lt "${MAX_RETRIES}" ]; do
198
162
  ;;
199
163
  codex)
200
164
  if (
201
- cd "${RUNTIME_PROJECT_DIR}" && timeout "${MAX_RUNTIME}" \
165
+ cd "${WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
202
166
  codex --quiet \
203
167
  --yolo \
204
168
  --prompt "${PROMPT}" \
@@ -239,20 +203,33 @@ while [ "${ATTEMPT}" -lt "${MAX_RETRIES}" ]; do
239
203
  done
240
204
 
241
205
  if [ ${EXIT_CODE} -eq 0 ]; then
242
- PR_EXISTS=$(cd "${RUNTIME_PROJECT_DIR}" && gh pr list --state open --json headRefName --jq '.[].headRefName' 2>/dev/null | grep -cF "${BRANCH_NAME}" || echo "0")
206
+ PR_EXISTS=$(gh pr list --state open --json headRefName --jq '.[].headRefName' 2>/dev/null | grep -cF "${BRANCH_NAME}" || echo "0")
243
207
  if [ "${PR_EXISTS}" -gt 0 ]; then
244
208
  release_claim "${PRD_DIR}" "${ELIGIBLE_PRD}"
245
- CLAIMED=0
246
- if ! checkout_default_branch "${RUNTIME_PROJECT_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
247
- log "WARN: Could not switch runtime workspace to ${DEFAULT_BRANCH}; PRD not moved to done/"
248
- exit 0
209
+ # NOTE: PRDs are moved to done/ immediately when a PR is opened rather than waiting for
210
+ # the PR to merge. This is intentional — once a PR exists, the agent's implementation
211
+ # work is complete and it should not pick up this PRD again. Human review takes it from here.
212
+ if prepare_detached_worktree "${PROJECT_DIR}" "${BOOKKEEP_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
213
+ if mark_prd_done "${BOOKKEEP_PRD_DIR}" "${ELIGIBLE_PRD}"; then
214
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" success --exit-code 0 2>/dev/null || true
215
+ if [[ "${PRD_DIR_REL}" = /* ]]; then
216
+ git -C "${BOOKKEEP_WORKTREE_DIR}" add -A "${PRD_DIR_REL}" || true
217
+ else
218
+ git -C "${BOOKKEEP_WORKTREE_DIR}" add -A "${PRD_DIR_REL}/" || true
219
+ fi
220
+ git -C "${BOOKKEEP_WORKTREE_DIR}" commit -m "chore: mark ${ELIGIBLE_PRD} as done (PR opened on ${BRANCH_NAME})
221
+
222
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" || true
223
+ git -C "${BOOKKEEP_WORKTREE_DIR}" push origin "HEAD:${DEFAULT_BRANCH}" || true
224
+ log "DONE: ${ELIGIBLE_PRD} implemented, PR opened, PRD moved to done/"
225
+ else
226
+ log "WARN: Failed to move ${ELIGIBLE_PRD} to done/ in bookkeeping worktree"
227
+ fi
228
+ else
229
+ log "WARN: Unable to prepare bookkeeping worktree for ${ELIGIBLE_PRD}"
249
230
  fi
250
-
251
- mark_prd_pending_review "${ORIGINAL_PRD_DIR}" "${ELIGIBLE_PRD}" "${PROJECT_DIR}" "${BRANCH_NAME}"
252
- night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" success --exit-code 0 2>/dev/null || true
253
- log "PENDING-REVIEW: ${ELIGIBLE_PRD} implemented, PR opened, waiting for merge"
254
231
  else
255
- log "WARN: ${PROVIDER_CMD} exited 0 but no PR found on ${BRANCH_NAME} — PRD NOT moved to pending-review"
232
+ log "WARN: ${PROVIDER_CMD} exited 0 but no PR found on ${BRANCH_NAME} — PRD NOT moved to done"
256
233
  fi
257
234
  elif [ ${EXIT_CODE} -eq 124 ]; then
258
235
  log "TIMEOUT: Night watch killed after ${MAX_RUNTIME}s while processing ${ELIGIBLE_PRD}"
@@ -261,3 +238,5 @@ else
261
238
  log "FAIL: Night watch exited with code ${EXIT_CODE} while processing ${ELIGIBLE_PRD}"
262
239
  night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code "${EXIT_CODE}" 2>/dev/null || true
263
240
  fi
241
+
242
+ cleanup_worktrees "${PROJECT_DIR}"
@@ -292,149 +292,78 @@ cleanup_worktrees() {
292
292
  local project_dir="${1:?project_dir required}"
293
293
  local project_name
294
294
  project_name=$(basename "${project_dir}")
295
- local marker="${2:-${project_name}-nw}"
296
295
 
297
296
  git -C "${project_dir}" worktree list --porcelain 2>/dev/null \
298
297
  | grep '^worktree ' \
299
298
  | awk '{print $2}' \
300
- | grep "${marker}" \
299
+ | grep "${project_name}-nw" \
301
300
  | while read -r wt; do
302
301
  log "CLEANUP: Removing leftover worktree ${wt}"
303
302
  git -C "${project_dir}" worktree remove --force "${wt}" 2>/dev/null || true
304
303
  done || true
305
304
  }
306
305
 
307
- # ── Runtime workspace isolation ───────────────────────────────────────────────
308
-
309
- project_runtime_key() {
306
+ # Pick the best available ref for creating a new detached worktree.
307
+ resolve_worktree_base_ref() {
310
308
  local project_dir="${1:?project_dir required}"
311
- local project_name
312
- local hash
313
- project_name=$(basename "${project_dir}")
314
-
315
- if command -v sha1sum >/dev/null 2>&1; then
316
- hash=$(printf "%s" "${project_dir}" | sha1sum | awk '{print $1}')
317
- elif command -v shasum >/dev/null 2>&1; then
318
- hash=$(printf "%s" "${project_dir}" | shasum | awk '{print $1}')
319
- else
320
- hash=$(printf "%s" "${project_dir}" | cksum | awk '{print $1}')
321
- fi
322
-
323
- printf "%s-%s" "${project_name}" "${hash:0:12}"
324
- }
325
-
326
- resolve_runtime_base_ref() {
327
- local git_dir="${1:?git_dir required}"
328
309
  local default_branch="${2:?default_branch required}"
329
310
 
330
- if git -C "${git_dir}" rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null; then
331
- printf "%s" "refs/remotes/origin/${default_branch}"
311
+ if git -C "${project_dir}" rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null; then
312
+ printf "%s" "origin/${default_branch}"
332
313
  return 0
333
314
  fi
334
315
 
335
- if git -C "${git_dir}" rev-parse --verify --quiet "refs/heads/${default_branch}" >/dev/null; then
336
- printf "%s" "refs/heads/${default_branch}"
316
+ if git -C "${project_dir}" rev-parse --verify --quiet "refs/heads/${default_branch}" >/dev/null; then
317
+ printf "%s" "${default_branch}"
337
318
  return 0
338
319
  fi
339
320
 
340
- if git -C "${git_dir}" rev-parse --verify --quiet "refs/remotes/origin/HEAD" >/dev/null; then
341
- printf "%s" "refs/remotes/origin/HEAD"
321
+ if git -C "${project_dir}" rev-parse --verify --quiet "refs/remotes/origin/HEAD" >/dev/null; then
322
+ printf "%s" "origin/HEAD"
342
323
  return 0
343
324
  fi
344
325
 
345
326
  return 1
346
327
  }
347
328
 
348
- prepare_runtime_workspace() {
329
+ # Create an isolated worktree on a branch without checking out that branch
330
+ # in the user's current project directory.
331
+ prepare_branch_worktree() {
349
332
  local project_dir="${1:?project_dir required}"
350
- local default_branch="${2:?default_branch required}"
351
- local log_file="${3:-${LOG_FILE:-/dev/null}}"
352
- local runtime_root="${NW_RUNTIME_ROOT:-${HOME}/.night-watch/runtime}"
353
- local runtime_key
354
- local runtime_base
355
- local mirror_dir
356
- local runs_dir
357
- local worktree_dir
358
- local clone_source=""
359
- local base_ref=""
360
-
361
- runtime_key=$(project_runtime_key "${project_dir}")
362
- runtime_base="${runtime_root}/${runtime_key}"
363
- mirror_dir="${runtime_base}/mirror.git"
364
- runs_dir="${runtime_base}/runs"
365
- worktree_dir="${runs_dir}/run-$(date +%Y%m%d-%H%M%S)-$$"
366
-
367
- mkdir -p "${runs_dir}"
368
-
369
- if [ ! -d "${mirror_dir}" ]; then
370
- clone_source=$(git -C "${project_dir}" config --get remote.origin.url 2>/dev/null || echo "")
371
-
372
- if [ -n "${clone_source}" ]; then
373
- if ! git clone --mirror "${clone_source}" "${mirror_dir}" >> "${log_file}" 2>&1; then
374
- git clone --mirror "${project_dir}" "${mirror_dir}" >> "${log_file}" 2>&1
375
- fi
376
- else
377
- git clone --mirror "${project_dir}" "${mirror_dir}" >> "${log_file}" 2>&1
378
- fi
379
- fi
380
-
381
- git -C "${mirror_dir}" remote update --prune >> "${log_file}" 2>&1 || true
382
-
383
- base_ref=$(resolve_runtime_base_ref "${mirror_dir}" "${default_branch}") || return 1
384
- git -C "${mirror_dir}" worktree add --detach "${worktree_dir}" "${base_ref}" >> "${log_file}" 2>&1
385
-
386
- printf "%s\n%s\n" "${mirror_dir}" "${worktree_dir}"
387
- }
388
-
389
- cleanup_runtime_workspace() {
390
- local mirror_dir="${1:?mirror_dir required}"
391
333
  local worktree_dir="${2:?worktree_dir required}"
392
-
393
- git -C "${mirror_dir}" worktree remove --force "${worktree_dir}" 2>/dev/null || true
394
- git -C "${mirror_dir}" worktree prune 2>/dev/null || true
395
- }
396
-
397
- prepare_branch_checkout() {
398
- local repo_dir="${1:?repo_dir required}"
399
- local branch_name="${2:?branch_name required}"
400
- local default_branch="${3:?default_branch required}"
401
- local log_file="${4:-${LOG_FILE:-/dev/null}}"
334
+ local branch_name="${3:?branch_name required}"
335
+ local default_branch="${4:?default_branch required}"
336
+ local log_file="${5:-${LOG_FILE:-/dev/null}}"
402
337
  local base_ref=""
403
338
 
404
- git -C "${repo_dir}" fetch origin "${default_branch}" "${branch_name}" >> "${log_file}" 2>&1 || true
339
+ git -C "${project_dir}" fetch origin "${default_branch}" >> "${log_file}" 2>&1 || true
340
+ base_ref=$(resolve_worktree_base_ref "${project_dir}" "${default_branch}") || return 1
405
341
 
406
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/heads/${branch_name}" >/dev/null; then
407
- git -C "${repo_dir}" checkout "${branch_name}" >> "${log_file}" 2>&1
342
+ if git -C "${project_dir}" rev-parse --verify --quiet "refs/heads/${branch_name}" >/dev/null; then
343
+ git -C "${project_dir}" worktree add "${worktree_dir}" "${branch_name}" >> "${log_file}" 2>&1
408
344
  return $?
409
345
  fi
410
346
 
411
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/remotes/origin/${branch_name}" >/dev/null; then
412
- git -C "${repo_dir}" checkout -b "${branch_name}" "origin/${branch_name}" >> "${log_file}" 2>&1
347
+ if git -C "${project_dir}" rev-parse --verify --quiet "refs/remotes/origin/${branch_name}" >/dev/null; then
348
+ git -C "${project_dir}" worktree add -b "${branch_name}" "${worktree_dir}" "origin/${branch_name}" >> "${log_file}" 2>&1
413
349
  return $?
414
350
  fi
415
351
 
416
- base_ref=$(resolve_runtime_base_ref "${repo_dir}" "${default_branch}") || return 1
417
- git -C "${repo_dir}" checkout -b "${branch_name}" "${base_ref}" >> "${log_file}" 2>&1
352
+ git -C "${project_dir}" worktree add -b "${branch_name}" "${worktree_dir}" "${base_ref}" >> "${log_file}" 2>&1
418
353
  }
419
354
 
420
- checkout_default_branch() {
421
- local repo_dir="${1:?repo_dir required}"
422
- local default_branch="${2:?default_branch required}"
423
- local log_file="${3:-${LOG_FILE:-/dev/null}}"
424
-
425
- git -C "${repo_dir}" fetch origin "${default_branch}" >> "${log_file}" 2>&1 || true
426
-
427
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null; then
428
- git -C "${repo_dir}" checkout -B "${default_branch}" "origin/${default_branch}" >> "${log_file}" 2>&1
429
- return $?
430
- fi
355
+ # Create an isolated detached worktree (useful for reviewer/controller flows).
356
+ prepare_detached_worktree() {
357
+ local project_dir="${1:?project_dir required}"
358
+ local worktree_dir="${2:?worktree_dir required}"
359
+ local default_branch="${3:?default_branch required}"
360
+ local log_file="${4:-${LOG_FILE:-/dev/null}}"
361
+ local base_ref=""
431
362
 
432
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/heads/${default_branch}" >/dev/null; then
433
- git -C "${repo_dir}" checkout "${default_branch}" >> "${log_file}" 2>&1
434
- return $?
435
- fi
363
+ git -C "${project_dir}" fetch origin "${default_branch}" >> "${log_file}" 2>&1 || true
364
+ base_ref=$(resolve_worktree_base_ref "${project_dir}" "${default_branch}") || return 1
436
365
 
437
- git -C "${repo_dir}" checkout -B "${default_branch}" HEAD >> "${log_file}" 2>&1
366
+ git -C "${project_dir}" worktree add --detach "${worktree_dir}" "${base_ref}" >> "${log_file}" 2>&1
438
367
  }
439
368
 
440
369
  # ── Mark PRD as done ─────────────────────────────────────────────────────────
@@ -456,62 +385,6 @@ mark_prd_done() {
456
385
  fi
457
386
  }
458
387
 
459
- mark_prd_pending_review() {
460
- local prd_dir="${1:?prd_dir required}"
461
- local prd_file="${2:?prd_file required}"
462
- local project_dir="${3:?project_dir required}"
463
- local branch_name="${4:-}"
464
- local prd_name="${prd_file%.md}"
465
-
466
- local cli_bin
467
- cli_bin=$(resolve_night_watch_cli) || {
468
- log "WARN: Could not resolve night-watch CLI — skipping prd-state set"
469
- return 1
470
- }
471
-
472
- "${cli_bin}" prd-state set "${project_dir}" "${prd_name}" --branch "${branch_name}" 2>/dev/null
473
- log "PENDING-REVIEW: ${prd_file} marked as pending-review in prd-states.json"
474
- }
475
-
476
- # Check prd-states.json for pending-review PRDs and promote any whose PR has been merged to done/
477
- promote_merged_prds() {
478
- local original_prd_dir="${1:?original_prd_dir required}"
479
- local project_dir="${2:?project_dir required}"
480
- local done_dir="${original_prd_dir}/done"
481
-
482
- local cli_bin
483
- cli_bin=$(resolve_night_watch_cli) || return 0
484
-
485
- local pending_prds
486
- pending_prds=$("${cli_bin}" prd-state list "${project_dir}" --status pending-review 2>/dev/null || echo "")
487
- [ -z "${pending_prds}" ] && return 0
488
-
489
- local merged_branches
490
- merged_branches=$(cd "${project_dir}" && gh pr list --state merged --json headRefName --jq '.[].headRefName' 2>/dev/null || true)
491
- [ -z "${merged_branches}" ] && return 0
492
-
493
- while IFS= read -r prd_name; do
494
- [ -z "${prd_name}" ] && continue
495
- local prd_file="${prd_name}.md"
496
- local branch_name="night-watch/${prd_name}"
497
-
498
- if echo "${merged_branches}" | grep -qF "${branch_name}"; then
499
- mkdir -p "${done_dir}"
500
- if [ -f "${original_prd_dir}/${prd_file}" ]; then
501
- mv "${original_prd_dir}/${prd_file}" "${done_dir}/${prd_file}"
502
- "${cli_bin}" prd-state clear "${project_dir}" "${prd_name}" 2>/dev/null || true
503
- log "MERGED: ${prd_name} PR was merged — moved to done/ and cleared from prd-states.json"
504
- else
505
- # File already gone (e.g. moved manually) — just clear the state
506
- "${cli_bin}" prd-state clear "${project_dir}" "${prd_name}" 2>/dev/null || true
507
- log "MERGED: ${prd_name} PR was merged — cleared from prd-states.json (file not found)"
508
- fi
509
- fi
510
- done <<< "${pending_prds}"
511
-
512
- return 0
513
- }
514
-
515
388
  # ── Rate limit detection ────────────────────────────────────────────────────
516
389
 
517
390
  # Check if the last N lines of the log contain a 429 rate limit error.
@@ -15,14 +15,13 @@ PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
15
15
  PROJECT_NAME=$(basename "${PROJECT_DIR}")
16
16
  LOG_DIR="${PROJECT_DIR}/logs"
17
17
  LOG_FILE="${LOG_DIR}/night-watch-pr-reviewer.log"
18
- LOCK_FILE=""
18
+ # NOTE: Lock file path must match LOCK_FILE_PREFIX in src/constants.ts (reviewer: prefix + "pr-reviewer-" + name)
19
+ LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_NAME}.lock"
19
20
  MAX_RUNTIME="${NW_REVIEWER_MAX_RUNTIME:-3600}" # 1 hour
20
21
  MAX_LOG_SIZE="524288" # 512 KB
21
22
  PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
22
23
  MIN_REVIEW_SCORE="${NW_MIN_REVIEW_SCORE:-80}"
23
24
  BRANCH_PATTERNS_RAW="${NW_BRANCH_PATTERNS:-feat/,night-watch/}"
24
- RUNTIME_MIRROR_DIR=""
25
- RUNTIME_PROJECT_DIR=""
26
25
 
27
26
  # Ensure NVM / Node / Claude are on PATH
28
27
  export NVM_DIR="${HOME}/.nvm"
@@ -36,8 +35,6 @@ mkdir -p "${LOG_DIR}"
36
35
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
37
36
  # shellcheck source=night-watch-helpers.sh
38
37
  source "${SCRIPT_DIR}/night-watch-helpers.sh"
39
- PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
40
- LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}.lock"
41
38
 
42
39
  # Validate provider
43
40
  if ! validate_provider "${PROVIDER_CMD}"; then
@@ -51,37 +48,7 @@ if ! acquire_lock "${LOCK_FILE}"; then
51
48
  exit 0
52
49
  fi
53
50
 
54
- cleanup_on_exit() {
55
- rm -f "${LOCK_FILE}"
56
-
57
- if [ -n "${RUNTIME_MIRROR_DIR}" ] && [ -n "${RUNTIME_PROJECT_DIR}" ]; then
58
- cleanup_runtime_workspace "${RUNTIME_MIRROR_DIR}" "${RUNTIME_PROJECT_DIR}" || true
59
- fi
60
- }
61
-
62
- trap cleanup_on_exit EXIT
63
-
64
- if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
65
- DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
66
- else
67
- DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
68
- fi
69
-
70
- runtime_info=()
71
- if mapfile -t runtime_info < <(prepare_runtime_workspace "${PROJECT_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"); then
72
- RUNTIME_MIRROR_DIR="${runtime_info[0]:-}"
73
- RUNTIME_PROJECT_DIR="${runtime_info[1]:-}"
74
- else
75
- log "FAIL: Could not prepare runtime workspace for reviewer"
76
- exit 1
77
- fi
78
-
79
- if [ -z "${RUNTIME_MIRROR_DIR}" ] || [ -z "${RUNTIME_PROJECT_DIR}" ]; then
80
- log "FAIL: Runtime workspace paths are missing for reviewer"
81
- exit 1
82
- fi
83
-
84
- cd "${RUNTIME_PROJECT_DIR}"
51
+ cd "${PROJECT_DIR}"
85
52
 
86
53
  # Convert comma-separated branch prefixes into a regex that matches branch starts.
87
54
  BRANCH_REGEX=""
@@ -154,41 +121,42 @@ if [ "${NEEDS_WORK}" -eq 0 ]; then
154
121
  exit 0
155
122
  fi
156
123
 
124
+ if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
125
+ DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
126
+ else
127
+ DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
128
+ fi
129
+ REVIEW_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${PROJECT_NAME}-nw-review-runner"
130
+
157
131
  log "START: Found PR(s) needing work:${PRS_NEEDING_WORK}"
158
132
 
133
+ cleanup_worktrees "${PROJECT_DIR}"
134
+
159
135
  # Dry-run mode: print diagnostics and exit
160
136
  if [ "${NW_DRY_RUN:-0}" = "1" ]; then
161
137
  echo "=== Dry Run: PR Reviewer ==="
162
138
  echo "Provider: ${PROVIDER_CMD}"
163
139
  echo "Branch Patterns: ${BRANCH_PATTERNS_RAW}"
164
140
  echo "Min Review Score: ${MIN_REVIEW_SCORE}"
165
- echo "Default Branch: ${DEFAULT_BRANCH}"
166
- echo "Runtime Dir: ${RUNTIME_PROJECT_DIR}"
167
141
  echo "Open PRs needing work:${PRS_NEEDING_WORK}"
142
+ echo "Default Branch: ${DEFAULT_BRANCH}"
143
+ echo "Review Worktree: ${REVIEW_WORKTREE_DIR}"
168
144
  echo "Timeout: ${MAX_RUNTIME}s"
169
145
  exit 0
170
146
  fi
171
147
 
172
- if [ -f "${RUNTIME_PROJECT_DIR}/.claude/commands/night-watch-pr-reviewer.md" ]; then
173
- REVIEW_WORKFLOW=$(cat "${RUNTIME_PROJECT_DIR}/.claude/commands/night-watch-pr-reviewer.md")
174
- else
175
- REVIEW_WORKFLOW=$(cat "${SCRIPT_DIR}/../templates/night-watch-pr-reviewer.md")
148
+ if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
149
+ log "FAIL: Unable to create isolated reviewer worktree ${REVIEW_WORKTREE_DIR}"
150
+ exit 1
176
151
  fi
177
152
 
178
- REVIEW_PROMPT="You are running in an isolated runtime workspace at ${RUNTIME_PROJECT_DIR}.
179
- Do not run git checkout/switch in ${PROJECT_DIR}.
180
- Do not create or remove worktrees; the runtime controller handles that.
181
- Apply all fixes only inside the current runtime workspace.
182
-
183
- ${REVIEW_WORKFLOW}"
184
-
185
153
  EXIT_CODE=0
186
154
 
187
155
  case "${PROVIDER_CMD}" in
188
156
  claude)
189
157
  if (
190
- cd "${RUNTIME_PROJECT_DIR}" && timeout "${MAX_RUNTIME}" \
191
- claude -p "${REVIEW_PROMPT}" \
158
+ cd "${REVIEW_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
159
+ claude -p "/night-watch-pr-reviewer" \
192
160
  --dangerously-skip-permissions \
193
161
  >> "${LOG_FILE}" 2>&1
194
162
  ); then
@@ -199,10 +167,10 @@ case "${PROVIDER_CMD}" in
199
167
  ;;
200
168
  codex)
201
169
  if (
202
- cd "${RUNTIME_PROJECT_DIR}" && timeout "${MAX_RUNTIME}" \
170
+ cd "${REVIEW_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
203
171
  codex --quiet \
204
172
  --yolo \
205
- --prompt "${REVIEW_PROMPT}" \
173
+ --prompt "$(cat "${REVIEW_WORKTREE_DIR}/.claude/commands/night-watch-pr-reviewer.md")" \
206
174
  >> "${LOG_FILE}" 2>&1
207
175
  ); then
208
176
  EXIT_CODE=0
@@ -216,6 +184,8 @@ case "${PROVIDER_CMD}" in
216
184
  ;;
217
185
  esac
218
186
 
187
+ cleanup_worktrees "${PROJECT_DIR}"
188
+
219
189
  if [ ${EXIT_CODE} -eq 0 ]; then
220
190
  log "DONE: PR reviewer completed successfully"
221
191
  elif [ ${EXIT_CODE} -eq 124 ]; then
@@ -67,14 +67,15 @@ A PR needs attention if **either** the review score is below 80 **or** any CI jo
67
67
 
68
68
  4. **Fix the PR**:
69
69
 
70
- a. **Use the pre-provisioned runtime workspace**:
71
- - You are already running in an isolated runtime workspace.
72
- - Do **not** run `git checkout`/`git switch` in the original project directory.
73
- - Do **not** create/remove worktrees manually; the runtime controller handles isolation and cleanup.
74
-
75
- b. **Check out the PR branch inside the runtime workspace**:
70
+ a. **Create an isolated review worktree**:
76
71
  ```
77
72
  git fetch origin
73
+ git worktree add --detach ../${PROJECT_NAME}-nw-review-<branch-name> origin/${DEFAULT_BRANCH}
74
+ ```
75
+
76
+ b. **Check out the PR branch inside that worktree**:
77
+ ```
78
+ cd ../${PROJECT_NAME}-nw-review-<branch-name>
78
79
  git checkout <branch-name>
79
80
  git pull origin <branch-name>
80
81
  ```
@@ -135,6 +136,10 @@ A PR needs attention if **either** the review score is below 80 **or** any CI jo
135
136
  Night Watch PR Reviewer"
136
137
  ```
137
138
 
139
+ h. **Clean up worktree**: `git worktree remove ../${PROJECT_NAME}-nw-review-<branch-name>`
140
+
138
141
  5. **Repeat** for all open PRs that need work.
139
142
 
143
+ 6. When done, return to ${DEFAULT_BRANCH}: `git checkout ${DEFAULT_BRANCH}`
144
+
140
145
  Start now. Check for open PRs that need review feedback addressed or CI failures fixed.