@jonit-dev/night-watch-cli 1.5.7 → 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}"
@@ -238,9 +238,6 @@ find_eligible_prd() {
238
238
  local open_branches
239
239
  open_branches=$(gh pr list --state open --json headRefName --jq '.[].headRefName' 2>/dev/null || echo "")
240
240
 
241
- local cli_bin
242
- cli_bin=$(resolve_night_watch_cli 2>/dev/null) || true
243
-
244
241
  for prd_path in ${prd_files}; do
245
242
  local prd_file
246
243
  prd_file=$(basename "${prd_path}")
@@ -264,12 +261,6 @@ find_eligible_prd() {
264
261
  continue
265
262
  fi
266
263
 
267
- # Skip if marked pending-review in prd-states.json
268
- if [ -n "${project_dir}" ] && [ -n "${cli_bin}" ] && "${cli_bin}" prd-state list "${project_dir}" --status pending-review 2>/dev/null | grep -qF "${prd_name}"; then
269
- log "SKIP-PRD: ${prd_file} — pending-review in prd-states.json"
270
- continue
271
- fi
272
-
273
264
  # Check dependencies
274
265
  local depends_on
275
266
  depends_on=$(grep -i 'depends on' "${prd_path}" 2>/dev/null \
@@ -301,149 +292,78 @@ cleanup_worktrees() {
301
292
  local project_dir="${1:?project_dir required}"
302
293
  local project_name
303
294
  project_name=$(basename "${project_dir}")
304
- local marker="${2:-${project_name}-nw}"
305
295
 
306
296
  git -C "${project_dir}" worktree list --porcelain 2>/dev/null \
307
297
  | grep '^worktree ' \
308
298
  | awk '{print $2}' \
309
- | grep "${marker}" \
299
+ | grep "${project_name}-nw" \
310
300
  | while read -r wt; do
311
301
  log "CLEANUP: Removing leftover worktree ${wt}"
312
302
  git -C "${project_dir}" worktree remove --force "${wt}" 2>/dev/null || true
313
303
  done || true
314
304
  }
315
305
 
316
- # ── Runtime workspace isolation ───────────────────────────────────────────────
317
-
318
- project_runtime_key() {
306
+ # Pick the best available ref for creating a new detached worktree.
307
+ resolve_worktree_base_ref() {
319
308
  local project_dir="${1:?project_dir required}"
320
- local project_name
321
- local hash
322
- project_name=$(basename "${project_dir}")
323
-
324
- if command -v sha1sum >/dev/null 2>&1; then
325
- hash=$(printf "%s" "${project_dir}" | sha1sum | awk '{print $1}')
326
- elif command -v shasum >/dev/null 2>&1; then
327
- hash=$(printf "%s" "${project_dir}" | shasum | awk '{print $1}')
328
- else
329
- hash=$(printf "%s" "${project_dir}" | cksum | awk '{print $1}')
330
- fi
331
-
332
- printf "%s-%s" "${project_name}" "${hash:0:12}"
333
- }
334
-
335
- resolve_runtime_base_ref() {
336
- local git_dir="${1:?git_dir required}"
337
309
  local default_branch="${2:?default_branch required}"
338
310
 
339
- if git -C "${git_dir}" rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null; then
340
- 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}"
341
313
  return 0
342
314
  fi
343
315
 
344
- if git -C "${git_dir}" rev-parse --verify --quiet "refs/heads/${default_branch}" >/dev/null; then
345
- 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}"
346
318
  return 0
347
319
  fi
348
320
 
349
- if git -C "${git_dir}" rev-parse --verify --quiet "refs/remotes/origin/HEAD" >/dev/null; then
350
- 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"
351
323
  return 0
352
324
  fi
353
325
 
354
326
  return 1
355
327
  }
356
328
 
357
- 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() {
358
332
  local project_dir="${1:?project_dir required}"
359
- local default_branch="${2:?default_branch required}"
360
- local log_file="${3:-${LOG_FILE:-/dev/null}}"
361
- local runtime_root="${NW_RUNTIME_ROOT:-${HOME}/.night-watch/runtime}"
362
- local runtime_key
363
- local runtime_base
364
- local mirror_dir
365
- local runs_dir
366
- local worktree_dir
367
- local clone_source=""
368
- local base_ref=""
369
-
370
- runtime_key=$(project_runtime_key "${project_dir}")
371
- runtime_base="${runtime_root}/${runtime_key}"
372
- mirror_dir="${runtime_base}/mirror.git"
373
- runs_dir="${runtime_base}/runs"
374
- worktree_dir="${runs_dir}/run-$(date +%Y%m%d-%H%M%S)-$$"
375
-
376
- mkdir -p "${runs_dir}"
377
-
378
- if [ ! -d "${mirror_dir}" ]; then
379
- clone_source=$(git -C "${project_dir}" config --get remote.origin.url 2>/dev/null || echo "")
380
-
381
- if [ -n "${clone_source}" ]; then
382
- if ! git clone --mirror "${clone_source}" "${mirror_dir}" >> "${log_file}" 2>&1; then
383
- git clone --mirror "${project_dir}" "${mirror_dir}" >> "${log_file}" 2>&1
384
- fi
385
- else
386
- git clone --mirror "${project_dir}" "${mirror_dir}" >> "${log_file}" 2>&1
387
- fi
388
- fi
389
-
390
- git -C "${mirror_dir}" remote update --prune >> "${log_file}" 2>&1 || true
391
-
392
- base_ref=$(resolve_runtime_base_ref "${mirror_dir}" "${default_branch}") || return 1
393
- git -C "${mirror_dir}" worktree add --detach "${worktree_dir}" "${base_ref}" >> "${log_file}" 2>&1
394
-
395
- printf "%s\n%s\n" "${mirror_dir}" "${worktree_dir}"
396
- }
397
-
398
- cleanup_runtime_workspace() {
399
- local mirror_dir="${1:?mirror_dir required}"
400
333
  local worktree_dir="${2:?worktree_dir required}"
401
-
402
- git -C "${mirror_dir}" worktree remove --force "${worktree_dir}" 2>/dev/null || true
403
- git -C "${mirror_dir}" worktree prune 2>/dev/null || true
404
- }
405
-
406
- prepare_branch_checkout() {
407
- local repo_dir="${1:?repo_dir required}"
408
- local branch_name="${2:?branch_name required}"
409
- local default_branch="${3:?default_branch required}"
410
- 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}}"
411
337
  local base_ref=""
412
338
 
413
- 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
414
341
 
415
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/heads/${branch_name}" >/dev/null; then
416
- 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
417
344
  return $?
418
345
  fi
419
346
 
420
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/remotes/origin/${branch_name}" >/dev/null; then
421
- 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
422
349
  return $?
423
350
  fi
424
351
 
425
- base_ref=$(resolve_runtime_base_ref "${repo_dir}" "${default_branch}") || return 1
426
- 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
427
353
  }
428
354
 
429
- checkout_default_branch() {
430
- local repo_dir="${1:?repo_dir required}"
431
- local default_branch="${2:?default_branch required}"
432
- local log_file="${3:-${LOG_FILE:-/dev/null}}"
433
-
434
- git -C "${repo_dir}" fetch origin "${default_branch}" >> "${log_file}" 2>&1 || true
435
-
436
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null; then
437
- git -C "${repo_dir}" checkout -B "${default_branch}" "origin/${default_branch}" >> "${log_file}" 2>&1
438
- return $?
439
- 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=""
440
362
 
441
- if git -C "${repo_dir}" rev-parse --verify --quiet "refs/heads/${default_branch}" >/dev/null; then
442
- git -C "${repo_dir}" checkout "${default_branch}" >> "${log_file}" 2>&1
443
- return $?
444
- 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
445
365
 
446
- 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
447
367
  }
448
368
 
449
369
  # ── Mark PRD as done ─────────────────────────────────────────────────────────
@@ -465,62 +385,6 @@ mark_prd_done() {
465
385
  fi
466
386
  }
467
387
 
468
- mark_prd_pending_review() {
469
- local prd_dir="${1:?prd_dir required}"
470
- local prd_file="${2:?prd_file required}"
471
- local project_dir="${3:?project_dir required}"
472
- local branch_name="${4:-}"
473
- local prd_name="${prd_file%.md}"
474
-
475
- local cli_bin
476
- cli_bin=$(resolve_night_watch_cli) || {
477
- log "WARN: Could not resolve night-watch CLI — skipping prd-state set"
478
- return 1
479
- }
480
-
481
- "${cli_bin}" prd-state set "${project_dir}" "${prd_name}" --branch "${branch_name}" 2>/dev/null
482
- log "PENDING-REVIEW: ${prd_file} marked as pending-review in prd-states.json"
483
- }
484
-
485
- # Check prd-states.json for pending-review PRDs and promote any whose PR has been merged to done/
486
- promote_merged_prds() {
487
- local original_prd_dir="${1:?original_prd_dir required}"
488
- local project_dir="${2:?project_dir required}"
489
- local done_dir="${original_prd_dir}/done"
490
-
491
- local cli_bin
492
- cli_bin=$(resolve_night_watch_cli) || return 0
493
-
494
- local pending_prds
495
- pending_prds=$("${cli_bin}" prd-state list "${project_dir}" --status pending-review 2>/dev/null || echo "")
496
- [ -z "${pending_prds}" ] && return 0
497
-
498
- local merged_branches
499
- merged_branches=$(cd "${project_dir}" && gh pr list --state merged --json headRefName --jq '.[].headRefName' 2>/dev/null || true)
500
- [ -z "${merged_branches}" ] && return 0
501
-
502
- while IFS= read -r prd_name; do
503
- [ -z "${prd_name}" ] && continue
504
- local prd_file="${prd_name}.md"
505
- local branch_name="night-watch/${prd_name}"
506
-
507
- if echo "${merged_branches}" | grep -qF "${branch_name}"; then
508
- mkdir -p "${done_dir}"
509
- if [ -f "${original_prd_dir}/${prd_file}" ]; then
510
- mv "${original_prd_dir}/${prd_file}" "${done_dir}/${prd_file}"
511
- "${cli_bin}" prd-state clear "${project_dir}" "${prd_name}" 2>/dev/null || true
512
- log "MERGED: ${prd_name} PR was merged — moved to done/ and cleared from prd-states.json"
513
- else
514
- # File already gone (e.g. moved manually) — just clear the state
515
- "${cli_bin}" prd-state clear "${project_dir}" "${prd_name}" 2>/dev/null || true
516
- log "MERGED: ${prd_name} PR was merged — cleared from prd-states.json (file not found)"
517
- fi
518
- fi
519
- done <<< "${pending_prds}"
520
-
521
- return 0
522
- }
523
-
524
388
  # ── Rate limit detection ────────────────────────────────────────────────────
525
389
 
526
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