@jonit-dev/night-watch-cli 1.7.35 → 1.7.36

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.
package/dist/cli.js CHANGED
@@ -14,6 +14,10 @@ import "reflect-metadata";
14
14
  import "reflect-metadata";
15
15
  import "reflect-metadata";
16
16
  import "reflect-metadata";
17
+ import "reflect-metadata";
18
+ import "reflect-metadata";
19
+ import "reflect-metadata";
20
+ import "reflect-metadata";
17
21
  import * as fs from "fs";
18
22
  import * as path from "path";
19
23
  import { fileURLToPath } from "url";
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Night Watch Audit Cron Runner (project-agnostic)
5
+ # Usage: night-watch-audit-cron.sh /path/to/project
6
+ #
7
+ # NOTE: This script expects environment variables to be set by the caller.
8
+ # The Node.js CLI will inject config values via environment variables.
9
+ # Required env vars (with defaults shown):
10
+ # NW_AUDIT_MAX_RUNTIME=1800 - Maximum runtime in seconds (30 minutes)
11
+ # NW_PROVIDER_CMD=claude - AI provider CLI to use (claude, codex, etc.)
12
+ # NW_DRY_RUN=0 - Set to 1 for dry-run mode (prints diagnostics only)
13
+
14
+ PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
15
+ PROJECT_NAME=$(basename "${PROJECT_DIR}")
16
+ LOG_DIR="${PROJECT_DIR}/logs"
17
+ LOG_FILE="${LOG_DIR}/audit.log"
18
+ REPORT_FILE="${PROJECT_DIR}/logs/audit-report.md"
19
+ MAX_RUNTIME="${NW_AUDIT_MAX_RUNTIME:-1800}" # 30 minutes
20
+ MAX_LOG_SIZE="524288" # 512 KB
21
+ PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
22
+
23
+ # Ensure NVM / Node / Claude are on PATH
24
+ export NVM_DIR="${HOME}/.nvm"
25
+ [ -s "${NVM_DIR}/nvm.sh" ] && . "${NVM_DIR}/nvm.sh"
26
+
27
+ mkdir -p "${LOG_DIR}"
28
+
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ # shellcheck source=night-watch-helpers.sh
31
+ source "${SCRIPT_DIR}/night-watch-helpers.sh"
32
+ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
33
+ # NOTE: Lock file path must match auditLockPath() in src/utils/status-data.ts
34
+ LOCK_FILE="/tmp/night-watch-audit-${PROJECT_RUNTIME_KEY}.lock"
35
+ AUDIT_PROMPT_TEMPLATE="${SCRIPT_DIR}/../templates/night-watch-audit.md"
36
+
37
+ emit_result() {
38
+ local status="${1:?status required}"
39
+ local details="${2:-}"
40
+ if [ -n "${details}" ]; then
41
+ echo "NIGHT_WATCH_RESULT:${status}|${details}"
42
+ else
43
+ echo "NIGHT_WATCH_RESULT:${status}"
44
+ fi
45
+ }
46
+
47
+ # Validate provider
48
+ if ! validate_provider "${PROVIDER_CMD}"; then
49
+ echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
50
+ emit_result "failure" "reason=unknown_provider"
51
+ exit 1
52
+ fi
53
+
54
+ rotate_log
55
+
56
+ if ! acquire_lock "${LOCK_FILE}"; then
57
+ emit_result "skip_locked"
58
+ exit 0
59
+ fi
60
+
61
+ # Dry-run mode: print diagnostics and exit
62
+ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
63
+ echo "=== Dry Run: Code Auditor ==="
64
+ echo "Provider: ${PROVIDER_CMD}"
65
+ echo "Max Runtime: ${MAX_RUNTIME}s"
66
+ echo "Report File: ${REPORT_FILE}"
67
+ echo "Prompt Template: ${AUDIT_PROMPT_TEMPLATE}"
68
+ emit_result "skip_dry_run"
69
+ exit 0
70
+ fi
71
+
72
+ if [ ! -f "${AUDIT_PROMPT_TEMPLATE}" ]; then
73
+ log "FAIL: Missing bundled audit prompt template at ${AUDIT_PROMPT_TEMPLATE}"
74
+ emit_result "failure_missing_prompt"
75
+ exit 1
76
+ fi
77
+
78
+ AUDIT_PROMPT="$(cat "${AUDIT_PROMPT_TEMPLATE}")"
79
+
80
+ if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
81
+ DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
82
+ else
83
+ DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
84
+ fi
85
+
86
+ AUDIT_WORKTREE_BASENAME="${PROJECT_NAME}-nw-audit-runner"
87
+ AUDIT_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${AUDIT_WORKTREE_BASENAME}"
88
+
89
+ cleanup_worktrees "${PROJECT_DIR}" "${AUDIT_WORKTREE_BASENAME}"
90
+
91
+ if ! prepare_detached_worktree "${PROJECT_DIR}" "${AUDIT_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
92
+ log "FAIL: Unable to create isolated audit worktree ${AUDIT_WORKTREE_DIR}"
93
+ emit_result "failure" "reason=worktree_setup_failed"
94
+ exit 1
95
+ fi
96
+
97
+ # Ensure the logs dir exists inside the worktree so the provider can write the report
98
+ mkdir -p "${AUDIT_WORKTREE_DIR}/logs"
99
+
100
+ log "START: Running code audit for ${PROJECT_NAME} (provider: ${PROVIDER_CMD})"
101
+
102
+ EXIT_CODE=0
103
+
104
+ case "${PROVIDER_CMD}" in
105
+ claude)
106
+ if (
107
+ cd "${AUDIT_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
108
+ claude -p "${AUDIT_PROMPT}" \
109
+ --dangerously-skip-permissions \
110
+ >> "${LOG_FILE}" 2>&1
111
+ ); then
112
+ EXIT_CODE=0
113
+ else
114
+ EXIT_CODE=$?
115
+ fi
116
+ ;;
117
+ codex)
118
+ if (
119
+ cd "${AUDIT_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
120
+ codex --quiet \
121
+ --yolo \
122
+ --prompt "${AUDIT_PROMPT}" \
123
+ >> "${LOG_FILE}" 2>&1
124
+ ); then
125
+ EXIT_CODE=0
126
+ else
127
+ EXIT_CODE=$?
128
+ fi
129
+ ;;
130
+ *)
131
+ log "ERROR: Unknown provider: ${PROVIDER_CMD}"
132
+ emit_result "failure" "reason=unknown_provider"
133
+ exit 1
134
+ ;;
135
+ esac
136
+
137
+ # Copy report back to project dir (if it was written in the worktree)
138
+ WORKTREE_REPORT="${AUDIT_WORKTREE_DIR}/logs/audit-report.md"
139
+ if [ -f "${WORKTREE_REPORT}" ]; then
140
+ cp "${WORKTREE_REPORT}" "${REPORT_FILE}"
141
+ log "INFO: Audit report copied to ${REPORT_FILE}"
142
+ fi
143
+
144
+ cleanup_worktrees "${PROJECT_DIR}" "${AUDIT_WORKTREE_BASENAME}"
145
+
146
+ if [ "${EXIT_CODE}" -eq 0 ]; then
147
+ if [ ! -f "${REPORT_FILE}" ]; then
148
+ log "FAIL: Audit provider exited 0 but no report was generated at ${REPORT_FILE}"
149
+ emit_result "failure_no_report"
150
+ exit 1
151
+ fi
152
+
153
+ if grep -q "NO_ISSUES_FOUND" "${REPORT_FILE}" 2>/dev/null; then
154
+ log "DONE: Audit complete — no actionable issues found"
155
+ emit_result "skip_clean"
156
+ else
157
+ log "DONE: Audit complete — report written to ${REPORT_FILE}"
158
+ emit_result "success_audit"
159
+ fi
160
+ elif [ "${EXIT_CODE}" -eq 124 ]; then
161
+ log "TIMEOUT: Audit killed after ${MAX_RUNTIME}s"
162
+ emit_result "timeout"
163
+ else
164
+ log "FAIL: Audit exited with code ${EXIT_CODE}"
165
+ emit_result "failure" "provider_exit=${EXIT_CODE}"
166
+ fi
167
+
168
+ exit "${EXIT_CODE}"
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Night Watch Cron Runner (project-agnostic)
5
+ # Usage: night-watch-cron.sh /path/to/project
6
+ # Finds the next eligible PRD and passes it to the configured AI provider for implementation.
7
+ #
8
+ # NOTE: This script expects environment variables to be set by the caller.
9
+ # The Node.js CLI will inject config values via environment variables.
10
+ # Required env vars (with defaults shown):
11
+ # NW_MAX_RUNTIME=7200 - Maximum runtime in seconds (2 hours)
12
+ # NW_PROVIDER_CMD=claude - AI provider CLI to use (claude, codex, etc.)
13
+ # NW_DRY_RUN=0 - Set to 1 for dry-run mode (prints diagnostics only)
14
+
15
+ PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
16
+ PROJECT_NAME=$(basename "${PROJECT_DIR}")
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
23
+ LOG_DIR="${PROJECT_DIR}/logs"
24
+ LOG_FILE="${LOG_DIR}/executor.log"
25
+ MAX_RUNTIME="${NW_MAX_RUNTIME:-7200}" # 2 hours
26
+ MAX_LOG_SIZE="524288" # 512 KB
27
+ PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
28
+ BRANCH_PREFIX="${NW_BRANCH_PREFIX:-night-watch}"
29
+
30
+ # Ensure NVM / Node / Claude are on PATH
31
+ export NVM_DIR="${HOME}/.nvm"
32
+ [ -s "${NVM_DIR}/nvm.sh" ] && . "${NVM_DIR}/nvm.sh"
33
+
34
+ # NOTE: Environment variables should be set by the caller (Node.js CLI).
35
+ # The .env.night-watch sourcing has been removed - config is now injected via env vars.
36
+
37
+ mkdir -p "${LOG_DIR}"
38
+
39
+ # Load shared helpers
40
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
41
+ # shellcheck source=night-watch-helpers.sh
42
+ source "${SCRIPT_DIR}/night-watch-helpers.sh"
43
+ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
44
+ # NOTE: Lock file path must match executorLockPath() in src/utils/status-data.ts
45
+ LOCK_FILE="/tmp/night-watch-${PROJECT_RUNTIME_KEY}.lock"
46
+
47
+ emit_result() {
48
+ local status="${1:?status required}"
49
+ local details="${2:-}"
50
+ if [ "${RATE_LIMIT_FALLBACK_TRIGGERED:-0}" = "1" ]; then
51
+ if [ -n "${details}" ]; then
52
+ details="${details}|rate_limit_fallback=1"
53
+ else
54
+ details="rate_limit_fallback=1"
55
+ fi
56
+ fi
57
+ if [ -n "${details}" ]; then
58
+ echo "NIGHT_WATCH_RESULT:${status}|${details}"
59
+ else
60
+ echo "NIGHT_WATCH_RESULT:${status}"
61
+ fi
62
+ }
63
+
64
+ # Validate provider
65
+ if ! validate_provider "${PROVIDER_CMD}"; then
66
+ echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
67
+ exit 1
68
+ fi
69
+
70
+ rotate_log
71
+
72
+ if ! acquire_lock "${LOCK_FILE}"; then
73
+ emit_result "skip_locked"
74
+ exit 0
75
+ fi
76
+
77
+ cleanup_worktrees "${PROJECT_DIR}"
78
+
79
+ ISSUE_NUMBER="" # board mode: GitHub issue number
80
+ ISSUE_BODY="" # board mode: issue body (PRD content)
81
+ ISSUE_TITLE_RAW="" # board mode: issue title
82
+ NW_CLI="" # board mode: resolved night-watch CLI binary
83
+
84
+ restore_issue_to_ready() {
85
+ local reason="${1:-Execution failed before implementation started.}"
86
+ if [ -n "${ISSUE_NUMBER}" ] && [ -n "${NW_CLI}" ]; then
87
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Ready" 2>>"${LOG_FILE}" || true
88
+ "${NW_CLI}" board comment "${ISSUE_NUMBER}" --body "${reason}" 2>>"${LOG_FILE}" || true
89
+ fi
90
+ }
91
+
92
+ if [ "${NW_BOARD_ENABLED:-}" = "true" ]; then
93
+ # Board mode: discover next task from GitHub Projects board
94
+ NW_CLI=$(resolve_night_watch_cli 2>/dev/null || true)
95
+ if [ -z "${NW_CLI}" ]; then
96
+ log "ERROR: Cannot resolve night-watch CLI for board mode"
97
+ exit 1
98
+ fi
99
+
100
+ if [ -n "${NW_TARGET_ISSUE:-}" ]; then
101
+ # Targeted issue pickup: use specified issue directly (already "In Progress" from Slack trigger)
102
+ ISSUE_NUMBER="${NW_TARGET_ISSUE}"
103
+ log "BOARD: Using targeted issue #${ISSUE_NUMBER} (from NW_TARGET_ISSUE)"
104
+ ISSUE_JSON=$(gh issue view "${ISSUE_NUMBER}" --json number,title,body 2>/dev/null || true)
105
+ if [ -z "${ISSUE_JSON}" ]; then
106
+ log "ERROR: Cannot fetch issue #${ISSUE_NUMBER} via gh"
107
+ exit 1
108
+ fi
109
+ ISSUE_TITLE_RAW=$(printf '%s' "${ISSUE_JSON}" | jq -r '.title // empty' 2>/dev/null || true)
110
+ ISSUE_BODY=$(printf '%s' "${ISSUE_JSON}" | jq -r '.body // empty' 2>/dev/null || true)
111
+ else
112
+ ISSUE_JSON=$(find_eligible_board_issue)
113
+ if [ -z "${ISSUE_JSON}" ]; then
114
+ log "SKIP: No eligible issues in Ready column (board mode)"
115
+ emit_result "skip_no_eligible_prd"
116
+ exit 0
117
+ fi
118
+ ISSUE_NUMBER=$(printf '%s' "${ISSUE_JSON}" | jq -r '.number // empty' 2>/dev/null || true)
119
+ ISSUE_TITLE_RAW=$(printf '%s' "${ISSUE_JSON}" | jq -r '.title // empty' 2>/dev/null || true)
120
+ ISSUE_BODY=$(printf '%s' "${ISSUE_JSON}" | jq -r '.body // empty' 2>/dev/null || true)
121
+ if [ -z "${ISSUE_NUMBER}" ]; then
122
+ log "ERROR: Board mode: failed to parse issue number from JSON"
123
+ exit 1
124
+ fi
125
+ # Move issue to In Progress (claim it on the board)
126
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "In Progress" 2>>"${LOG_FILE}" || \
127
+ log "WARN: Failed to move issue #${ISSUE_NUMBER} to In Progress"
128
+ fi
129
+
130
+ if [ -z "${ISSUE_NUMBER}" ]; then
131
+ log "ERROR: Board mode: no issue number resolved"
132
+ exit 1
133
+ fi
134
+ # Slugify title for branch naming
135
+ ELIGIBLE_PRD="${ISSUE_NUMBER}-$(printf '%s' "${ISSUE_TITLE_RAW}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-\|-$//g')"
136
+ log "BOARD: Processing issue #${ISSUE_NUMBER}: ${ISSUE_TITLE_RAW}"
137
+ trap "rm -f '${LOCK_FILE}'" EXIT
138
+ else
139
+ # Filesystem mode: scan PRD directory
140
+ ELIGIBLE_PRD=$(find_eligible_prd "${PRD_DIR}" "${MAX_RUNTIME}" "${PROJECT_DIR}")
141
+ if [ -z "${ELIGIBLE_PRD}" ]; then
142
+ log "SKIP: No eligible PRDs (all done, in-progress, or blocked)"
143
+ emit_result "skip_no_eligible_prd"
144
+ exit 0
145
+ fi
146
+ # Claim the PRD to prevent other runs from selecting it
147
+ claim_prd "${PRD_DIR}" "${ELIGIBLE_PRD}"
148
+ # Update EXIT trap to also release claim
149
+ trap "rm -f '${LOCK_FILE}'; release_claim '${PRD_DIR}' '${ELIGIBLE_PRD}'" EXIT
150
+ fi
151
+
152
+ PRD_NAME="${ELIGIBLE_PRD%.md}"
153
+ BRANCH_NAME="${BRANCH_PREFIX}/${PRD_NAME}"
154
+ WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${PROJECT_NAME}-nw-${PRD_NAME}"
155
+ BOOKKEEP_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${PROJECT_NAME}-nw-bookkeeping"
156
+ if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
157
+ DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
158
+ else
159
+ DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
160
+ fi
161
+ if [[ "${PRD_DIR_REL}" = /* ]]; then
162
+ BOOKKEEP_PRD_DIR="${PRD_DIR_REL}"
163
+ else
164
+ BOOKKEEP_PRD_DIR="${BOOKKEEP_WORKTREE_DIR}/${PRD_DIR_REL}"
165
+ fi
166
+
167
+ count_prs_for_branch() {
168
+ local pr_state="${1:?pr_state required}"
169
+ local branch_name="${2:?branch_name required}"
170
+ local count
171
+ count=$(
172
+ { gh pr list --state "${pr_state}" --json headRefName --jq '.[].headRefName' 2>/dev/null || true; } \
173
+ | { grep -xF "${branch_name}" || true; } \
174
+ | wc -l \
175
+ | tr -d '[:space:]'
176
+ )
177
+ echo "${count:-0}"
178
+ }
179
+
180
+ finalize_prd_done() {
181
+ local reason="${1:?reason required}"
182
+
183
+ release_claim "${PRD_DIR}" "${ELIGIBLE_PRD}"
184
+ # NOTE: PRDs are moved to done/ immediately when a PR is opened (or already merged)
185
+ # rather than waiting for reviewer/merge loops.
186
+ if prepare_detached_worktree "${PROJECT_DIR}" "${BOOKKEEP_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
187
+ if mark_prd_done "${BOOKKEEP_PRD_DIR}" "${ELIGIBLE_PRD}"; then
188
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" success --exit-code 0 2>/dev/null || true
189
+ if [[ "${PRD_DIR_REL}" = /* ]]; then
190
+ git -C "${BOOKKEEP_WORKTREE_DIR}" add -A "${PRD_DIR_REL}" || true
191
+ else
192
+ git -C "${BOOKKEEP_WORKTREE_DIR}" add -A "${PRD_DIR_REL}/" || true
193
+ fi
194
+ git -C "${BOOKKEEP_WORKTREE_DIR}" commit -m "chore: mark ${ELIGIBLE_PRD} as done (${reason})
195
+
196
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" || true
197
+ git -C "${BOOKKEEP_WORKTREE_DIR}" push origin "HEAD:${DEFAULT_BRANCH}" || true
198
+ log "DONE: ${ELIGIBLE_PRD} ${reason}, PRD moved to done/"
199
+ return 0
200
+ fi
201
+ log "WARN: Failed to move ${ELIGIBLE_PRD} to done/ in bookkeeping worktree"
202
+ return 1
203
+ fi
204
+
205
+ log "WARN: Unable to prepare bookkeeping worktree for ${ELIGIBLE_PRD}"
206
+ return 1
207
+ }
208
+
209
+ log "START: Processing ${ELIGIBLE_PRD} on branch ${BRANCH_NAME} (worktree: ${WORKTREE_DIR})"
210
+
211
+ if [ -n "${ISSUE_NUMBER}" ]; then
212
+ PROMPT="Implement the following PRD (GitHub issue #${ISSUE_NUMBER}: ${ISSUE_TITLE_RAW}):
213
+
214
+ ${ISSUE_BODY}
215
+
216
+ ## Setup
217
+ - You are already inside an isolated worktree at: ${WORKTREE_DIR}
218
+ - Current branch is already checked out: ${BRANCH_NAME}
219
+ - Do NOT run git checkout/switch in ${PROJECT_DIR}
220
+ - Do NOT create or remove worktrees; the cron script manages that
221
+ - Install dependencies if needed and implement in the current worktree only
222
+
223
+ ## Implementation — PRD Executor Workflow
224
+ Read .claude/commands/prd-executor.md and follow its FULL execution pipeline:
225
+ 1. Parse the PRD into phases and extract dependencies
226
+ 2. Build a dependency graph to identify parallelism
227
+ 3. Create a task list with one task per phase
228
+ 4. Execute phases in parallel waves using agent swarms — launch ALL independent phases concurrently
229
+ 5. Run the project's verify/test command between waves to catch issues early
230
+ 6. After all phases complete, run final verification and fix any issues
231
+ Follow all CLAUDE.md conventions (if present).
232
+
233
+ ## Finalize
234
+ - Commit all changes, push, and open a PR:
235
+ git push -u origin ${BRANCH_NAME}
236
+ gh pr create --title \"feat: <short title>\" --body \"Closes #${ISSUE_NUMBER}
237
+
238
+ <summary>\"
239
+ - Do NOT process any other issues — only issue #${ISSUE_NUMBER}"
240
+ else
241
+ PROMPT_PRD_PATH="${PRD_DIR_REL}/${ELIGIBLE_PRD}"
242
+ PROMPT="Implement the PRD at ${PROMPT_PRD_PATH}
243
+
244
+ ## Setup
245
+ - You are already inside an isolated worktree at: ${WORKTREE_DIR}
246
+ - Current branch is already checked out: ${BRANCH_NAME}
247
+ - Do NOT run git checkout/switch in ${PROJECT_DIR}
248
+ - Do NOT create or remove worktrees; the cron script manages that
249
+ - Install dependencies if needed and implement in the current worktree only
250
+
251
+ ## Implementation — PRD Executor Workflow
252
+ Read .claude/commands/prd-executor.md and follow its FULL execution pipeline:
253
+ 1. Parse the PRD into phases and extract dependencies
254
+ 2. Build a dependency graph to identify parallelism
255
+ 3. Create a task list with one task per phase
256
+ 4. Execute phases in parallel waves using agent swarms — launch ALL independent phases concurrently
257
+ 5. Run the project's verify/test command between waves to catch issues early
258
+ 6. After all phases complete, run final verification and fix any issues
259
+ Follow all CLAUDE.md conventions (if present).
260
+
261
+ ## Finalize
262
+ - Commit all changes, push, and open a PR:
263
+ git push -u origin ${BRANCH_NAME}
264
+ gh pr create --title \"feat: <short title>\" --body \"<summary referencing PRD>\"
265
+ - Do NOT move the PRD to done/ — the cron script handles that
266
+ - Do NOT process any other PRDs — only ${ELIGIBLE_PRD}"
267
+ fi
268
+
269
+ # Dry-run mode: print diagnostics and exit
270
+ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
271
+ log "DRY-RUN: Would process ${ELIGIBLE_PRD}"
272
+ log "DRY-RUN: Provider: ${PROVIDER_CMD}"
273
+ log "DRY-RUN: Runtime: ${MAX_RUNTIME}s"
274
+ echo "=== Dry Run: PRD Executor ==="
275
+ echo "Provider: ${PROVIDER_CMD}"
276
+ echo "Eligible PRD: ${ELIGIBLE_PRD}"
277
+ echo "Branch: ${BRANCH_NAME}"
278
+ echo "Worktree: ${WORKTREE_DIR}"
279
+ echo "Bookkeeping: ${BOOKKEEP_WORKTREE_DIR}"
280
+ echo "Timeout: ${MAX_RUNTIME}s"
281
+ exit 0
282
+ fi
283
+
284
+ # If this PRD already has a merged PR for its branch, finalize it immediately.
285
+ MERGED_PR_COUNT=$(count_prs_for_branch merged "${BRANCH_NAME}")
286
+ if [ "${MERGED_PR_COUNT}" -gt 0 ]; then
287
+ log "INFO: Found merged PR for ${BRANCH_NAME}; skipping provider run"
288
+ if [ -n "${ISSUE_NUMBER}" ]; then
289
+ # Board mode: move issue to Done
290
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Done" 2>>"${LOG_FILE}" || true
291
+ emit_result "success_already_merged" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
292
+ exit 0
293
+ elif finalize_prd_done "already merged on ${BRANCH_NAME}"; then
294
+ emit_result "success_already_merged" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
295
+ exit 0
296
+ fi
297
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
298
+ emit_result "failure_finalize" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
299
+ exit 1
300
+ fi
301
+
302
+ if ! prepare_branch_worktree "${PROJECT_DIR}" "${WORKTREE_DIR}" "${BRANCH_NAME}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
303
+ log "FAIL: Unable to create isolated worktree ${WORKTREE_DIR} for ${BRANCH_NAME}"
304
+ restore_issue_to_ready "Failed to prepare worktree for branch ${BRANCH_NAME}. Moved back to Ready for retry."
305
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
306
+ exit 1
307
+ fi
308
+
309
+ # Sandbox: prevent the agent from modifying crontab during execution
310
+ export NW_EXECUTION_CONTEXT=agent
311
+
312
+ MAX_RETRIES="${NW_MAX_RETRIES:-3}"
313
+ if ! [[ "${MAX_RETRIES}" =~ ^[0-9]+$ ]] || [ "${MAX_RETRIES}" -lt 1 ]; then
314
+ MAX_RETRIES=1
315
+ fi
316
+ BACKOFF_BASE=300 # 5 minutes in seconds
317
+ EXIT_CODE=0
318
+ ATTEMPT=0
319
+ RATE_LIMIT_FALLBACK_TRIGGERED=0
320
+
321
+ while [ "${ATTEMPT}" -lt "${MAX_RETRIES}" ]; do
322
+ EXIT_CODE=0
323
+ # Capture log position before this attempt so check_rate_limited only
324
+ # scans lines written by the current invocation (not leftover 429s from
325
+ # previous runs that would cause false-positive rate-limit retries).
326
+ LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
327
+
328
+ case "${PROVIDER_CMD}" in
329
+ claude)
330
+ if (
331
+ cd "${WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
332
+ claude -p "${PROMPT}" \
333
+ --dangerously-skip-permissions \
334
+ >> "${LOG_FILE}" 2>&1
335
+ ); then
336
+ EXIT_CODE=0
337
+ else
338
+ EXIT_CODE=$?
339
+ fi
340
+ ;;
341
+ codex)
342
+ if (
343
+ cd "${WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
344
+ codex --quiet \
345
+ --yolo \
346
+ --prompt "${PROMPT}" \
347
+ >> "${LOG_FILE}" 2>&1
348
+ ); then
349
+ EXIT_CODE=0
350
+ else
351
+ EXIT_CODE=$?
352
+ fi
353
+ ;;
354
+ *)
355
+ log "ERROR: Unknown provider: ${PROVIDER_CMD}"
356
+ exit 1
357
+ ;;
358
+ esac
359
+
360
+ # Success or timeout — don't retry
361
+ if [ ${EXIT_CODE} -eq 0 ] || [ ${EXIT_CODE} -eq 124 ]; then
362
+ break
363
+ fi
364
+
365
+ # Check if this was a rate limit (429) error (only in lines from this attempt)
366
+ if check_rate_limited "${LOG_FILE}" "${LOG_LINE_BEFORE}"; then
367
+ # If fallback is enabled, skip proxy retries and switch to native Claude immediately
368
+ if [ "${NW_FALLBACK_ON_RATE_LIMIT:-}" = "true" ] && [ "${PROVIDER_CMD}" = "claude" ]; then
369
+ log "RATE-LIMITED: Proxy quota exhausted — triggering native Claude fallback"
370
+ RATE_LIMIT_FALLBACK_TRIGGERED=1
371
+ break
372
+ fi
373
+ ATTEMPT=$((ATTEMPT + 1))
374
+ if [ "${ATTEMPT}" -ge "${MAX_RETRIES}" ]; then
375
+ log "RATE-LIMITED: All ${MAX_RETRIES} attempts exhausted for ${ELIGIBLE_PRD}"
376
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" rate_limited --exit-code "${EXIT_CODE}" --attempt "${ATTEMPT}" 2>/dev/null || true
377
+ break
378
+ fi
379
+ BACKOFF=$(( BACKOFF_BASE * (1 << (ATTEMPT - 1)) ))
380
+ BACKOFF_MIN=$(( BACKOFF / 60 ))
381
+ log "RATE-LIMITED: Attempt ${ATTEMPT}/${MAX_RETRIES}, retrying in ${BACKOFF_MIN}m"
382
+ sleep "${BACKOFF}"
383
+ else
384
+ # Non-retryable failure
385
+ break
386
+ fi
387
+ done
388
+
389
+ # ── Native Claude fallback ────────────────────────────────────────────────────
390
+ # When the proxy returns 429 and fallbackOnRateLimit is enabled, re-run the
391
+ # same prompt with native Claude (OAuth), bypassing the proxy entirely.
392
+ if [ "${RATE_LIMIT_FALLBACK_TRIGGERED}" = "1" ]; then
393
+ FALLBACK_MODEL="${NW_CLAUDE_MODEL_ID:-claude-sonnet-4-6}"
394
+ log "RATE-LIMIT-FALLBACK: Running native Claude (${FALLBACK_MODEL})"
395
+
396
+ # Send immediate Telegram warning (fire-and-forget)
397
+ send_rate_limit_fallback_warning "${FALLBACK_MODEL}" "$(basename "${PROJECT_DIR}")"
398
+
399
+ LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
400
+
401
+ if (
402
+ cd "${WORKTREE_DIR}" && \
403
+ unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN \
404
+ ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL && \
405
+ timeout "${MAX_RUNTIME}" \
406
+ claude -p "${PROMPT}" \
407
+ --dangerously-skip-permissions \
408
+ --model "${FALLBACK_MODEL}" \
409
+ >> "${LOG_FILE}" 2>&1
410
+ ); then
411
+ EXIT_CODE=0
412
+ else
413
+ EXIT_CODE=$?
414
+ fi
415
+
416
+ log "RATE-LIMIT-FALLBACK: Native Claude exited with code ${EXIT_CODE}"
417
+ fi
418
+
419
+ if [ ${EXIT_CODE} -eq 0 ]; then
420
+ OPEN_PR_COUNT=$(count_prs_for_branch open "${BRANCH_NAME}")
421
+ if [ "${OPEN_PR_COUNT}" -gt 0 ]; then
422
+ if [ -n "${ISSUE_NUMBER}" ]; then
423
+ # Board mode: move to Review and comment with PR URL
424
+ PR_URL=$(gh pr list --state open --json headRefName,url \
425
+ --jq ".[] | select(.headRefName == \"${BRANCH_NAME}\") | .url" 2>/dev/null || true)
426
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Review" 2>>"${LOG_FILE}" || true
427
+ if [ -n "${PR_URL}" ]; then
428
+ "${NW_CLI}" board comment "${ISSUE_NUMBER}" --body "PR opened: ${PR_URL}" 2>>"${LOG_FILE}" || true
429
+ fi
430
+ emit_result "success_open_pr" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
431
+ elif finalize_prd_done "implemented, PR opened on ${BRANCH_NAME}"; then
432
+ emit_result "success_open_pr" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
433
+ else
434
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
435
+ emit_result "failure_finalize" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
436
+ EXIT_CODE=1
437
+ fi
438
+ else
439
+ MERGED_PR_COUNT=$(count_prs_for_branch merged "${BRANCH_NAME}")
440
+ if [ "${MERGED_PR_COUNT}" -gt 0 ]; then
441
+ if [ -n "${ISSUE_NUMBER}" ]; then
442
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Done" 2>>"${LOG_FILE}" || true
443
+ emit_result "success_already_merged" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
444
+ elif finalize_prd_done "already merged on ${BRANCH_NAME}"; then
445
+ emit_result "success_already_merged" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
446
+ else
447
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
448
+ emit_result "failure_finalize" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
449
+ EXIT_CODE=1
450
+ fi
451
+ else
452
+ log "WARN: ${PROVIDER_CMD} exited 0 but no open/merged PR found on ${BRANCH_NAME} — recording cooldown to avoid repeated stuck runs"
453
+ if [ -n "${ISSUE_NUMBER}" ]; then
454
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Ready" 2>>"${LOG_FILE}" || true
455
+ "${NW_CLI}" board comment "${ISSUE_NUMBER}" \
456
+ --body "Execution completed but no PR was found. Moved back to Ready for retry." 2>>"${LOG_FILE}" || true
457
+ fi
458
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code 1 2>/dev/null || true
459
+ emit_result "failure_no_pr_after_success" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
460
+ EXIT_CODE=1
461
+ fi
462
+ fi
463
+ elif [ ${EXIT_CODE} -eq 124 ]; then
464
+ log "TIMEOUT: Night watch killed after ${MAX_RUNTIME}s while processing ${ELIGIBLE_PRD}"
465
+ if [ -n "${ISSUE_NUMBER}" ]; then
466
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Ready" 2>>"${LOG_FILE}" || true
467
+ "${NW_CLI}" board comment "${ISSUE_NUMBER}" \
468
+ --body "Execution timed out after ${MAX_RUNTIME}s. Moved back to Ready for retry." 2>>"${LOG_FILE}" || true
469
+ fi
470
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" timeout --exit-code 124 2>/dev/null || true
471
+ emit_result "timeout" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
472
+ else
473
+ log "FAIL: Night watch exited with code ${EXIT_CODE} while processing ${ELIGIBLE_PRD}"
474
+ if [ -n "${ISSUE_NUMBER}" ]; then
475
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "Ready" 2>>"${LOG_FILE}" || true
476
+ "${NW_CLI}" board comment "${ISSUE_NUMBER}" \
477
+ --body "Execution failed with exit code ${EXIT_CODE}. Moved back to Ready for retry." 2>>"${LOG_FILE}" || true
478
+ fi
479
+ night_watch_history record "${PROJECT_DIR}" "${ELIGIBLE_PRD}" failure --exit-code "${EXIT_CODE}" 2>/dev/null || true
480
+ emit_result "failure" "prd=${ELIGIBLE_PRD}|branch=${BRANCH_NAME}"
481
+ fi
482
+
483
+ cleanup_worktrees "${PROJECT_DIR}"
484
+ exit "${EXIT_CODE}"