shipwright-cli 2.3.1 → 3.0.0

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 (162) hide show
  1. package/README.md +95 -28
  2. package/completions/_shipwright +1 -1
  3. package/completions/shipwright.bash +3 -8
  4. package/completions/shipwright.fish +1 -1
  5. package/config/defaults.json +111 -0
  6. package/config/event-schema.json +81 -0
  7. package/config/policy.json +155 -2
  8. package/config/policy.schema.json +162 -1
  9. package/dashboard/coverage/coverage-summary.json +14 -0
  10. package/dashboard/public/index.html +1 -1
  11. package/dashboard/server.ts +306 -17
  12. package/dashboard/src/components/charts/bar.test.ts +79 -0
  13. package/dashboard/src/components/charts/donut.test.ts +68 -0
  14. package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
  15. package/dashboard/src/components/charts/sparkline.test.ts +125 -0
  16. package/dashboard/src/core/api.test.ts +309 -0
  17. package/dashboard/src/core/helpers.test.ts +301 -0
  18. package/dashboard/src/core/router.test.ts +307 -0
  19. package/dashboard/src/core/router.ts +7 -0
  20. package/dashboard/src/core/sse.test.ts +144 -0
  21. package/dashboard/src/views/metrics.test.ts +186 -0
  22. package/dashboard/src/views/overview.test.ts +173 -0
  23. package/dashboard/src/views/pipelines.test.ts +183 -0
  24. package/dashboard/src/views/team.test.ts +253 -0
  25. package/dashboard/vitest.config.ts +14 -5
  26. package/docs/TIPS.md +1 -1
  27. package/docs/patterns/README.md +1 -1
  28. package/package.json +15 -5
  29. package/scripts/adapters/docker-deploy.sh +1 -1
  30. package/scripts/adapters/tmux-adapter.sh +11 -1
  31. package/scripts/adapters/wezterm-adapter.sh +1 -1
  32. package/scripts/check-version-consistency.sh +1 -1
  33. package/scripts/lib/architecture.sh +126 -0
  34. package/scripts/lib/bootstrap.sh +75 -0
  35. package/scripts/lib/compat.sh +89 -6
  36. package/scripts/lib/config.sh +91 -0
  37. package/scripts/lib/daemon-adaptive.sh +3 -3
  38. package/scripts/lib/daemon-dispatch.sh +39 -16
  39. package/scripts/lib/daemon-health.sh +1 -1
  40. package/scripts/lib/daemon-patrol.sh +24 -12
  41. package/scripts/lib/daemon-poll.sh +37 -25
  42. package/scripts/lib/daemon-state.sh +115 -23
  43. package/scripts/lib/daemon-triage.sh +30 -8
  44. package/scripts/lib/fleet-failover.sh +63 -0
  45. package/scripts/lib/helpers.sh +30 -6
  46. package/scripts/lib/pipeline-detection.sh +2 -2
  47. package/scripts/lib/pipeline-github.sh +9 -9
  48. package/scripts/lib/pipeline-intelligence.sh +85 -35
  49. package/scripts/lib/pipeline-quality-checks.sh +16 -16
  50. package/scripts/lib/pipeline-quality.sh +1 -1
  51. package/scripts/lib/pipeline-stages.sh +242 -28
  52. package/scripts/lib/pipeline-state.sh +40 -4
  53. package/scripts/lib/test-helpers.sh +247 -0
  54. package/scripts/postinstall.mjs +3 -11
  55. package/scripts/sw +10 -4
  56. package/scripts/sw-activity.sh +1 -11
  57. package/scripts/sw-adaptive.sh +109 -85
  58. package/scripts/sw-adversarial.sh +4 -14
  59. package/scripts/sw-architecture-enforcer.sh +1 -11
  60. package/scripts/sw-auth.sh +8 -17
  61. package/scripts/sw-autonomous.sh +111 -49
  62. package/scripts/sw-changelog.sh +1 -11
  63. package/scripts/sw-checkpoint.sh +144 -20
  64. package/scripts/sw-ci.sh +2 -12
  65. package/scripts/sw-cleanup.sh +13 -17
  66. package/scripts/sw-code-review.sh +16 -36
  67. package/scripts/sw-connect.sh +5 -12
  68. package/scripts/sw-context.sh +9 -26
  69. package/scripts/sw-cost.sh +6 -16
  70. package/scripts/sw-daemon.sh +75 -70
  71. package/scripts/sw-dashboard.sh +57 -17
  72. package/scripts/sw-db.sh +506 -15
  73. package/scripts/sw-decompose.sh +1 -11
  74. package/scripts/sw-deps.sh +15 -25
  75. package/scripts/sw-developer-simulation.sh +1 -11
  76. package/scripts/sw-discovery.sh +112 -30
  77. package/scripts/sw-doc-fleet.sh +7 -17
  78. package/scripts/sw-docs-agent.sh +6 -16
  79. package/scripts/sw-docs.sh +4 -12
  80. package/scripts/sw-doctor.sh +134 -43
  81. package/scripts/sw-dora.sh +11 -19
  82. package/scripts/sw-durable.sh +35 -52
  83. package/scripts/sw-e2e-orchestrator.sh +11 -27
  84. package/scripts/sw-eventbus.sh +115 -115
  85. package/scripts/sw-evidence.sh +748 -0
  86. package/scripts/sw-feedback.sh +3 -13
  87. package/scripts/sw-fix.sh +2 -20
  88. package/scripts/sw-fleet-discover.sh +1 -11
  89. package/scripts/sw-fleet-viz.sh +10 -18
  90. package/scripts/sw-fleet.sh +13 -17
  91. package/scripts/sw-github-app.sh +6 -16
  92. package/scripts/sw-github-checks.sh +1 -11
  93. package/scripts/sw-github-deploy.sh +1 -11
  94. package/scripts/sw-github-graphql.sh +2 -12
  95. package/scripts/sw-guild.sh +1 -11
  96. package/scripts/sw-heartbeat.sh +49 -12
  97. package/scripts/sw-hygiene.sh +45 -43
  98. package/scripts/sw-incident.sh +284 -67
  99. package/scripts/sw-init.sh +35 -37
  100. package/scripts/sw-instrument.sh +1 -11
  101. package/scripts/sw-intelligence.sh +362 -51
  102. package/scripts/sw-jira.sh +5 -14
  103. package/scripts/sw-launchd.sh +2 -12
  104. package/scripts/sw-linear.sh +8 -17
  105. package/scripts/sw-logs.sh +4 -12
  106. package/scripts/sw-loop.sh +641 -90
  107. package/scripts/sw-memory.sh +243 -17
  108. package/scripts/sw-mission-control.sh +2 -12
  109. package/scripts/sw-model-router.sh +73 -34
  110. package/scripts/sw-otel.sh +11 -21
  111. package/scripts/sw-oversight.sh +1 -11
  112. package/scripts/sw-patrol-meta.sh +5 -11
  113. package/scripts/sw-pipeline-composer.sh +7 -17
  114. package/scripts/sw-pipeline-vitals.sh +1 -11
  115. package/scripts/sw-pipeline.sh +478 -122
  116. package/scripts/sw-pm.sh +2 -12
  117. package/scripts/sw-pr-lifecycle.sh +203 -29
  118. package/scripts/sw-predictive.sh +16 -22
  119. package/scripts/sw-prep.sh +6 -16
  120. package/scripts/sw-ps.sh +1 -11
  121. package/scripts/sw-public-dashboard.sh +2 -12
  122. package/scripts/sw-quality.sh +77 -10
  123. package/scripts/sw-reaper.sh +1 -11
  124. package/scripts/sw-recruit.sh +15 -25
  125. package/scripts/sw-regression.sh +11 -21
  126. package/scripts/sw-release-manager.sh +19 -28
  127. package/scripts/sw-release.sh +8 -16
  128. package/scripts/sw-remote.sh +1 -11
  129. package/scripts/sw-replay.sh +48 -44
  130. package/scripts/sw-retro.sh +70 -92
  131. package/scripts/sw-review-rerun.sh +220 -0
  132. package/scripts/sw-scale.sh +109 -32
  133. package/scripts/sw-security-audit.sh +12 -22
  134. package/scripts/sw-self-optimize.sh +239 -23
  135. package/scripts/sw-session.sh +3 -13
  136. package/scripts/sw-setup.sh +8 -18
  137. package/scripts/sw-standup.sh +5 -15
  138. package/scripts/sw-status.sh +32 -23
  139. package/scripts/sw-strategic.sh +129 -13
  140. package/scripts/sw-stream.sh +1 -11
  141. package/scripts/sw-swarm.sh +76 -36
  142. package/scripts/sw-team-stages.sh +10 -20
  143. package/scripts/sw-templates.sh +4 -14
  144. package/scripts/sw-testgen.sh +3 -13
  145. package/scripts/sw-tmux-pipeline.sh +1 -19
  146. package/scripts/sw-tmux-role-color.sh +0 -10
  147. package/scripts/sw-tmux-status.sh +3 -11
  148. package/scripts/sw-tmux.sh +2 -20
  149. package/scripts/sw-trace.sh +1 -19
  150. package/scripts/sw-tracker-github.sh +0 -10
  151. package/scripts/sw-tracker-jira.sh +1 -11
  152. package/scripts/sw-tracker-linear.sh +1 -11
  153. package/scripts/sw-tracker.sh +7 -24
  154. package/scripts/sw-triage.sh +24 -34
  155. package/scripts/sw-upgrade.sh +5 -23
  156. package/scripts/sw-ux.sh +1 -19
  157. package/scripts/sw-webhook.sh +18 -32
  158. package/scripts/sw-widgets.sh +3 -21
  159. package/scripts/sw-worktree.sh +11 -27
  160. package/scripts/update-homebrew-sha.sh +67 -0
  161. package/templates/pipelines/tdd.json +72 -0
  162. package/scripts/sw-pipeline.sh.mock +0 -7
@@ -3,6 +3,10 @@
3
3
  [[ -n "${_DAEMON_STATE_LOADED:-}" ]] && return 0
4
4
  _DAEMON_STATE_LOADED=1
5
5
 
6
+ # SQLite persistence (DB as primary read path)
7
+ _DAEMON_STATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ [[ -f "${_DAEMON_STATE_DIR}/../sw-db.sh" ]] && source "${_DAEMON_STATE_DIR}/../sw-db.sh"
9
+
6
10
  daemon_log() {
7
11
  local level="$1"
8
12
  shift
@@ -58,8 +62,8 @@ notify() {
58
62
  -d "$payload" "$SLACK_WEBHOOK" >/dev/null 2>&1 || true
59
63
  fi
60
64
 
61
- # Custom webhook (env var SHIPWRIGHT_WEBHOOK_URL, with CCT_WEBHOOK_URL fallback)
62
- local _webhook_url="${SHIPWRIGHT_WEBHOOK_URL:-${CCT_WEBHOOK_URL:-}}"
65
+ # Custom webhook (env var SHIPWRIGHT_WEBHOOK_URL)
66
+ local _webhook_url="${SHIPWRIGHT_WEBHOOK_URL:-}"
63
67
  if [[ -n "$_webhook_url" ]]; then
64
68
  local payload
65
69
  payload=$(jq -n \
@@ -123,7 +127,7 @@ daemon_preflight_auth_check() {
123
127
 
124
128
  # gh auth check
125
129
  if [[ "${NO_GITHUB:-false}" != "true" ]]; then
126
- if ! gh auth status &>/dev/null 2>&1; then
130
+ if ! gh auth status >/dev/null 2>&1; then
127
131
  daemon_log ERROR "GitHub auth check failed — auto-pausing daemon"
128
132
  local pause_json
129
133
  pause_json=$(jq -n --arg reason "gh_auth_failure" --arg ts "$(now_iso)" \
@@ -189,7 +193,7 @@ preflight_checks() {
189
193
  local optional_tools=("tmux" "curl")
190
194
 
191
195
  for tool in "${required_tools[@]}"; do
192
- if command -v "$tool" &>/dev/null; then
196
+ if command -v "$tool" >/dev/null 2>&1; then
193
197
  echo -e " ${GREEN}✓${RESET} $tool"
194
198
  else
195
199
  echo -e " ${RED}✗${RESET} $tool ${RED}(required)${RESET}"
@@ -198,7 +202,7 @@ preflight_checks() {
198
202
  done
199
203
 
200
204
  for tool in "${optional_tools[@]}"; do
201
- if command -v "$tool" &>/dev/null; then
205
+ if command -v "$tool" >/dev/null 2>&1; then
202
206
  echo -e " ${GREEN}✓${RESET} $tool"
203
207
  else
204
208
  echo -e " ${DIM}○${RESET} $tool ${DIM}(optional — some features disabled)${RESET}"
@@ -207,7 +211,7 @@ preflight_checks() {
207
211
 
208
212
  # 2. Git state
209
213
  echo ""
210
- if git rev-parse --is-inside-work-tree &>/dev/null; then
214
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
211
215
  echo -e " ${GREEN}✓${RESET} Inside git repo"
212
216
  else
213
217
  echo -e " ${RED}✗${RESET} Not inside a git repository"
@@ -215,7 +219,7 @@ preflight_checks() {
215
219
  fi
216
220
 
217
221
  # Check base branch exists
218
- if git rev-parse --verify "$BASE_BRANCH" &>/dev/null; then
222
+ if git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1; then
219
223
  echo -e " ${GREEN}✓${RESET} Base branch: $BASE_BRANCH"
220
224
  else
221
225
  echo -e " ${RED}✗${RESET} Base branch not found: $BASE_BRANCH"
@@ -224,7 +228,7 @@ preflight_checks() {
224
228
 
225
229
  # 3. GitHub auth (required for daemon — it needs to poll issues)
226
230
  if [[ "$NO_GITHUB" != "true" ]]; then
227
- if gh auth status &>/dev/null 2>&1; then
231
+ if gh auth status >/dev/null 2>&1; then
228
232
  echo -e " ${GREEN}✓${RESET} GitHub authenticated"
229
233
  else
230
234
  echo -e " ${RED}✗${RESET} GitHub not authenticated (required for daemon)"
@@ -286,15 +290,48 @@ atomic_write_state() {
286
290
  }
287
291
  }
288
292
 
293
+ # Sync active_jobs from state JSON to DB (dual-write, best-effort)
294
+ _sync_state_to_db() {
295
+ local state_json="$1"
296
+ [[ -z "$state_json" ]] && return 0
297
+ if ! type db_save_job >/dev/null 2>&1 || ! db_available 2>/dev/null; then
298
+ return 0
299
+ fi
300
+ local start_epoch job_id
301
+ while IFS= read -r job; do
302
+ [[ -z "$job" || "$job" == "null" ]] && continue
303
+ local issue title pid worktree branch template goal started_at
304
+ issue=$(echo "$job" | jq -r '.issue // 0' 2>/dev/null)
305
+ title=$(echo "$job" | jq -r '.title // ""' 2>/dev/null)
306
+ pid=$(echo "$job" | jq -r '.pid // 0' 2>/dev/null)
307
+ worktree=$(echo "$job" | jq -r '.worktree // ""' 2>/dev/null)
308
+ branch=$(echo "$job" | jq -r '.branch // ""' 2>/dev/null)
309
+ template=$(echo "$job" | jq -r '.template // "autonomous"' 2>/dev/null)
310
+ goal=$(echo "$job" | jq -r '.goal // ""' 2>/dev/null)
311
+ started_at=$(echo "$job" | jq -r '.started_at // ""' 2>/dev/null)
312
+ if [[ -z "$issue" || "$issue" == "0" ]] || [[ ! "$issue" =~ ^[0-9]+$ ]]; then
313
+ continue
314
+ fi
315
+ start_epoch=0
316
+ if [[ -n "$started_at" ]]; then
317
+ start_epoch=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$started_at" +%s 2>/dev/null || date -d "$started_at" +%s 2>/dev/null || echo "0")
318
+ fi
319
+ [[ -z "$start_epoch" ]] && start_epoch=0
320
+ job_id="daemon-${issue}-${start_epoch}"
321
+ db_save_job "$job_id" "$issue" "$title" "$pid" "$worktree" "$branch" "$template" "$goal" 2>/dev/null || true
322
+ done < <(echo "$state_json" | jq -c '.active_jobs[]? // empty' 2>/dev/null || true)
323
+ }
324
+
289
325
  # Locked read-modify-write: prevents TOCTOU race on state file.
290
326
  # Usage: locked_state_update '.queued += [42]'
291
327
  # The jq expression is applied to the current state file atomically.
328
+ # Dual-write: also syncs active_jobs to DB when available.
292
329
  locked_state_update() {
293
330
  local jq_expr="$1"
294
331
  shift
295
332
  local lock_file="${STATE_FILE}.lock"
296
333
  (
297
- if command -v flock &>/dev/null; then
334
+ if command -v flock >/dev/null 2>&1; then
298
335
  flock -w 5 200 2>/dev/null || {
299
336
  daemon_log ERROR "locked_state_update: lock acquisition timed out — aborting"
300
337
  return 1
@@ -309,6 +346,7 @@ locked_state_update() {
309
346
  daemon_log ERROR "locked_state_update: atomic_write_state failed"
310
347
  return 1
311
348
  }
349
+ _sync_state_to_db "$tmp" 2>/dev/null || true
312
350
  ) 200>"$lock_file"
313
351
  }
314
352
 
@@ -343,7 +381,7 @@ init_state() {
343
381
  }')
344
382
  local lock_file="${STATE_FILE}.lock"
345
383
  (
346
- if command -v flock &>/dev/null; then
384
+ if command -v flock >/dev/null 2>&1; then
347
385
  flock -w 5 200 2>/dev/null || {
348
386
  daemon_log ERROR "init_state: lock acquisition timed out"
349
387
  return 1
@@ -358,6 +396,11 @@ init_state() {
358
396
  --arg started "$(now_iso)" \
359
397
  '.pid = ($pid | tonumber) | .started_at = $started'
360
398
  fi
399
+
400
+ # Ensure DB schema is initialized when available
401
+ if type migrate_schema >/dev/null 2>&1 && db_available 2>/dev/null; then
402
+ migrate_schema 2>/dev/null || true
403
+ fi
361
404
  }
362
405
 
363
406
  update_state_field() {
@@ -369,13 +412,15 @@ update_state_field() {
369
412
  # ─── Inflight Check ─────────────────────────────────────────────────────────
370
413
 
371
414
  daemon_is_inflight() {
372
- local issue_num="$1"
415
+ local issue_key="$1"
416
+ local issue_num="$issue_key"
417
+ [[ "$issue_key" == *:* ]] && issue_num="${issue_key##*:}"
373
418
 
374
419
  if [[ ! -f "$STATE_FILE" ]]; then
375
420
  return 1
376
421
  fi
377
422
 
378
- # Check active_jobs
423
+ # Check active_jobs (stored with numeric .issue)
379
424
  local active_match
380
425
  active_match=$(jq -r --argjson num "$issue_num" \
381
426
  '.active_jobs[] | select(.issue == $num) | .issue' \
@@ -384,10 +429,10 @@ daemon_is_inflight() {
384
429
  return 0
385
430
  fi
386
431
 
387
- # Check queued
432
+ # Check queued (stores full key e.g. "owner/repo:42" or "42")
388
433
  local queued_match
389
- queued_match=$(jq -r --argjson num "$issue_num" \
390
- '.queued[] | select(. == $num)' \
434
+ queued_match=$(jq -r --arg key "$issue_key" \
435
+ '.queued[] | select(. == $key)' \
391
436
  "$STATE_FILE" 2>/dev/null || true)
392
437
  if [[ -n "$queued_match" ]]; then
393
438
  return 0
@@ -417,7 +462,7 @@ locked_get_active_count() {
417
462
  local count
418
463
  count=$(
419
464
  (
420
- if command -v flock &>/dev/null; then
465
+ if command -v flock >/dev/null 2>&1; then
421
466
  flock -w 5 200 2>/dev/null || {
422
467
  daemon_log WARN "locked_get_active_count: lock timeout — returning MAX_PARALLEL as safe default" >&2
423
468
  echo "$MAX_PARALLEL"
@@ -433,13 +478,30 @@ locked_get_active_count() {
433
478
  # ─── Queue Management ───────────────────────────────────────────────────────
434
479
 
435
480
  enqueue_issue() {
436
- local issue_num="$1"
437
- locked_state_update --argjson num "$issue_num" \
438
- '.queued += [$num] | .queued |= unique'
439
- daemon_log INFO "Queued issue #${issue_num} (at capacity)"
481
+ local issue_key="$1"
482
+ locked_state_update --arg key "$issue_key" \
483
+ '.queued += [$key] | .queued |= unique'
484
+ if type db_enqueue_issue >/dev/null 2>&1; then
485
+ db_enqueue_issue "$issue_key" 2>/dev/null || true
486
+ fi
487
+ daemon_log INFO "Queued issue ${issue_key} (at capacity)"
440
488
  }
441
489
 
442
490
  dequeue_next() {
491
+ # Try DB first when available
492
+ if type db_dequeue_next >/dev/null 2>&1 && db_available 2>/dev/null; then
493
+ local next
494
+ next=$(db_dequeue_next 2>/dev/null || true)
495
+ if [[ -n "$next" ]]; then
496
+ # Also update JSON file for backward compat
497
+ if [[ -f "$STATE_FILE" ]]; then
498
+ locked_state_update --arg key "$next" '.queued = [.queued[] | select(. != $key)]'
499
+ fi
500
+ echo "$next"
501
+ return
502
+ fi
503
+ fi
504
+
443
505
  if [[ ! -f "$STATE_FILE" ]]; then
444
506
  return
445
507
  fi
@@ -447,8 +509,10 @@ dequeue_next() {
447
509
  local next
448
510
  next=$(jq -r '.queued[0] // empty' "$STATE_FILE" 2>/dev/null || true)
449
511
  if [[ -n "$next" ]]; then
450
- # Remove from queue (locked to prevent race with enqueue)
451
512
  locked_state_update '.queued = .queued[1:]'
513
+ if type db_remove_from_queue >/dev/null 2>&1; then
514
+ db_remove_from_queue "$next" 2>/dev/null || true
515
+ fi
452
516
  echo "$next"
453
517
  fi
454
518
  }
@@ -496,6 +560,22 @@ untrack_priority_job() {
496
560
 
497
561
  # ─── Distributed Issue Claiming ───────────────────────────────────────────
498
562
 
563
+ # Verify we have exclusive claim: exactly one claimed:* label matching our machine
564
+ _verify_claim_exclusive() {
565
+ local issue_num="$1" machine_name="$2"
566
+ local claimed_labels
567
+ claimed_labels=$(gh issue view "$issue_num" --json labels --jq \
568
+ '[.labels[].name | select(startswith("claimed:"))]' 2>/dev/null || echo "[]")
569
+ local count
570
+ count=$(echo "$claimed_labels" | jq 'length' 2>/dev/null || echo "0")
571
+ if [[ "$count" != "1" ]]; then
572
+ return 1 # Competing claims (multiple or none)
573
+ fi
574
+ local sole_claim
575
+ sole_claim=$(echo "$claimed_labels" | jq -r '.[0]' 2>/dev/null || echo "")
576
+ [[ "$sole_claim" == "claimed:${machine_name}" ]]
577
+ }
578
+
499
579
  claim_issue() {
500
580
  local issue_num="$1"
501
581
  local machine_name="$2"
@@ -509,9 +589,15 @@ claim_issue() {
509
589
  -d "$(jq -n --argjson issue "$issue_num" --arg machine "$machine_name" \
510
590
  '{issue: $issue, machine: $machine}')" 2>/dev/null || echo "")
511
591
 
512
- if [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == true' &>/dev/null; then
592
+ if [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == true' >/dev/null 2>&1; then
593
+ # VERIFY: re-read labels, ensure only our claim exists
594
+ if ! _verify_claim_exclusive "$issue_num" "$machine_name"; then
595
+ daemon_log INFO "Issue #${issue_num} claim race lost (competing claim) — removing our label"
596
+ gh issue edit "$issue_num" --remove-label "claimed:${machine_name}" 2>/dev/null || true
597
+ return 1
598
+ fi
513
599
  return 0
514
- elif [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == false' &>/dev/null; then
600
+ elif [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == false' >/dev/null 2>&1; then
515
601
  local claimed_by
516
602
  claimed_by=$(echo "$resp" | jq -r '.claimed_by // "another machine"')
517
603
  daemon_log INFO "Issue #${issue_num} claimed by ${claimed_by} (via dashboard)"
@@ -530,6 +616,12 @@ claim_issue() {
530
616
  fi
531
617
 
532
618
  gh issue edit "$issue_num" --add-label "claimed:${machine_name}" 2>/dev/null || return 1
619
+ # VERIFY: re-read labels, ensure only our claim exists
620
+ if ! _verify_claim_exclusive "$issue_num" "$machine_name"; then
621
+ daemon_log INFO "Issue #${issue_num} claim race lost (competing claim) — removing our label"
622
+ gh issue edit "$issue_num" --remove-label "claimed:${machine_name}" 2>/dev/null || true
623
+ return 1
624
+ fi
533
625
  return 0
534
626
  }
535
627
 
@@ -21,7 +21,7 @@ triage_score_issue() {
21
21
  issue_body=$(echo "$issue_json" | jq -r '.body // ""')
22
22
 
23
23
  # ── Intelligence-powered triage (if enabled) ──
24
- if [[ "${INTELLIGENCE_ENABLED:-false}" == "true" ]] && type intelligence_analyze_issue &>/dev/null 2>&1; then
24
+ if [[ "${INTELLIGENCE_ENABLED:-false}" == "true" ]] && type intelligence_analyze_issue >/dev/null 2>&1; then
25
25
  daemon_log INFO "Intelligence: using AI triage (intelligence enabled)" >&2
26
26
  local analysis
27
27
  analysis=$(intelligence_analyze_issue "$issue_json" 2>/dev/null || echo "")
@@ -143,10 +143,14 @@ triage_score_issue() {
143
143
  # Check if this issue blocks others (search issue references)
144
144
  if [[ "$NO_GITHUB" != "true" ]]; then
145
145
  local mentions
146
- mentions=$(gh api "repos/{owner}/{repo}/issues/${issue_num}/timeline" --paginate -q '
147
- [.[] | select(.event == "cross-referenced") | .source.issue.body // ""] |
148
- map(select(test("blocked by #'"${issue_num}"'|depends on #'"${issue_num}"'"; "i"))) | length
149
- ' 2>/dev/null || echo "0")
146
+ local repo_nwo
147
+ repo_nwo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")
148
+ if [[ -n "$repo_nwo" ]]; then
149
+ mentions=$(gh api "repos/${repo_nwo}/issues/${issue_num}/timeline" --paginate -q '
150
+ [.[] | select(.event == "cross-referenced") | .source.issue.body // ""] |
151
+ map(select(test("blocked by #'"${issue_num}"'|depends on #'"${issue_num}"'"; "i"))) | length
152
+ ' 2>/dev/null || echo "0")
153
+ fi
150
154
  mentions=${mentions:-0}
151
155
  if [[ "$mentions" -gt 0 ]]; then
152
156
  dep_score=15
@@ -212,7 +216,7 @@ select_pipeline_template() {
212
216
  fi
213
217
 
214
218
  # ── Intelligence-composed pipeline (if enabled) ──
215
- if [[ "${COMPOSER_ENABLED:-false}" == "true" ]] && type composer_create_pipeline &>/dev/null 2>&1; then
219
+ if [[ "${COMPOSER_ENABLED:-false}" == "true" ]] && type composer_create_pipeline >/dev/null 2>&1; then
216
220
  daemon_log INFO "Intelligence: using AI pipeline composition (composer enabled)" >&2
217
221
  local analysis="${INTELLIGENCE_ANALYSIS:-{}}"
218
222
  local repo_context=""
@@ -301,8 +305,8 @@ select_pipeline_template() {
301
305
  fi
302
306
 
303
307
  # ── Branch protection escalation (highest priority) ──
304
- if type gh_branch_protection &>/dev/null 2>&1 && [[ "${NO_GITHUB:-false}" != "true" ]]; then
305
- if type _gh_detect_repo &>/dev/null 2>&1; then
308
+ if type gh_branch_protection >/dev/null 2>&1 && [[ "${NO_GITHUB:-false}" != "true" ]]; then
309
+ if type _gh_detect_repo >/dev/null 2>&1; then
306
310
  _gh_detect_repo 2>/dev/null || true
307
311
  fi
308
312
  local gh_owner="${GH_OWNER:-}" gh_repo="${GH_REPO:-}"
@@ -386,6 +390,24 @@ select_pipeline_template() {
386
390
  fi
387
391
  fi
388
392
 
393
+ # ── Thompson sampling (outcome-based learning, when DB available) ──
394
+ if type thompson_select_template >/dev/null 2>&1; then
395
+ local _complexity="medium"
396
+ [[ "$score" -ge 70 ]] && _complexity="low"
397
+ [[ "$score" -lt 40 ]] && _complexity="high"
398
+ local _thompson_result
399
+ _thompson_result=$(thompson_select_template "$_complexity" 2>/dev/null || echo "")
400
+ if [[ -n "${_thompson_result:-}" && "${_thompson_result:-}" != "standard" ]]; then
401
+ daemon_log INFO "Thompson sampling: $_thompson_result (complexity=$_complexity)" >&2
402
+ echo "$_thompson_result"
403
+ return
404
+ fi
405
+ if [[ -n "${_thompson_result:-}" ]]; then
406
+ echo "$_thompson_result"
407
+ return
408
+ fi
409
+ fi
410
+
389
411
  # ── Learned template weights ──
390
412
  local _tw_file="${HOME}/.shipwright/optimization/template-weights.json"
391
413
  if [[ -f "$_tw_file" ]]; then
@@ -0,0 +1,63 @@
1
+ # fleet-failover.sh — Re-queue work from offline fleet machines
2
+ # When a machine goes offline, release its claimed issues so they can be picked up again.
3
+ # Source from daemon-poll or sw-fleet. Works standalone with gh + jq.
4
+ [[ -n "${_FLEET_FAILOVER_LOADED:-}" ]] && return 0
5
+ _FLEET_FAILOVER_LOADED=1
6
+
7
+ fleet_failover_check() {
8
+ local health_file="$HOME/.shipwright/machine-health.json"
9
+ [[ ! -f "$health_file" ]] && return 0
10
+
11
+ [[ "${NO_GITHUB:-false}" == "true" ]] && return 0
12
+ command -v gh >/dev/null 2>&1 || return 0
13
+ command -v jq >/dev/null 2>&1 || return 0
14
+
15
+ # Find offline machines (health file: .[machine_name] = {status, checked_at})
16
+ local offline_machines
17
+ offline_machines=$(jq -r 'to_entries[] | select(.value.status == "offline") | .key' "$health_file" 2>/dev/null)
18
+ [[ -z "$offline_machines" ]] && return 0
19
+
20
+ while IFS= read -r machine; do
21
+ [[ -z "$machine" ]] && continue
22
+
23
+ # Find issues claimed by this offline machine via GitHub label
24
+ local orphaned_issues
25
+ orphaned_issues=$(gh search issues \
26
+ "label:claimed:${machine}" \
27
+ is:open \
28
+ --json number,repository \
29
+ --limit 100 2>/dev/null | jq -r '.[] | "\(.repository.nameWithOwner):\(.number)"' 2>/dev/null)
30
+ [[ -z "$orphaned_issues" ]] && continue
31
+
32
+ while IFS= read -r issue_key; do
33
+ [[ -z "$issue_key" ]] && continue
34
+
35
+ local issue_num="${issue_key##*:}"
36
+ local repo="${issue_key%:*}"
37
+ [[ "$repo" == "$issue_key" ]] && repo=""
38
+
39
+ # Log and emit
40
+ if [[ "$(type -t info 2>/dev/null)" == "function" ]]; then
41
+ info "Failover: re-queuing issue #${issue_num} from offline machine ${machine}"
42
+ fi
43
+ if [[ "$(type -t emit_event 2>/dev/null)" == "function" ]]; then
44
+ emit_event "fleet.failover" "{\"issue\":\"$issue_num\",\"from_machine\":\"$machine\"}"
45
+ fi
46
+
47
+ # Release the claim (remove label) — idempotent
48
+ if [[ -n "$repo" ]]; then
49
+ gh issue edit "$issue_num" --repo "$repo" --remove-label "claimed:${machine}" 2>/dev/null || true
50
+ else
51
+ gh issue edit "$issue_num" --remove-label "claimed:${machine}" 2>/dev/null || true
52
+ fi
53
+
54
+ # When running in daemon context: enqueue so we pick it up if we watch this repo
55
+ # In org mode WATCH_MODE=org, enqueue uses owner/repo:num; in repo mode just num
56
+ if [[ -f "${STATE_FILE:-$HOME/.shipwright/daemon-state.json}" ]] && type enqueue_issue >/dev/null 2>&1; then
57
+ local queue_key="$issue_num"
58
+ [[ -n "$repo" ]] && queue_key="${repo}:${issue_num}"
59
+ enqueue_issue "$queue_key" 2>/dev/null || true
60
+ fi
61
+ done <<< "$orphaned_issues"
62
+ done <<< "$offline_machines"
63
+ }
@@ -64,7 +64,7 @@ emit_event() {
64
64
  shift
65
65
 
66
66
  # Try SQLite first (via sw-db.sh's db_add_event)
67
- if type db_add_event &>/dev/null; then
67
+ if type db_add_event >/dev/null 2>&1; then
68
68
  db_add_event "$event_type" "$@" 2>/dev/null || true
69
69
  fi
70
70
 
@@ -76,7 +76,10 @@ emit_event() {
76
76
  if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
77
77
  json_fields="${json_fields},\"${key}\":${val}"
78
78
  else
79
- val="${val//\"/\\\"}"
79
+ val="${val//\\/\\\\}" # escape backslashes first
80
+ val="${val//\"/\\\"}" # then quotes
81
+ val="${val//$'\n'/\\n}" # then newlines
82
+ val="${val//$'\t'/\\t}" # then tabs
80
83
  json_fields="${json_fields},\"${key}\":\"${val}\""
81
84
  fi
82
85
  done
@@ -85,11 +88,20 @@ emit_event() {
85
88
  # Use flock to prevent concurrent write corruption
86
89
  local _lock_file="${EVENTS_FILE}.lock"
87
90
  (
88
- if command -v flock &>/dev/null; then
91
+ if command -v flock >/dev/null 2>&1; then
89
92
  flock -w 2 200 2>/dev/null || true
90
93
  fi
91
94
  echo "$_event_line" >> "$EVENTS_FILE"
92
95
  ) 200>"$_lock_file"
96
+
97
+ # Optional schema validation (dev mode only)
98
+ if [[ -n "${SHIPWRIGHT_DEV:-}" && -n "${_CONFIG_REPO_DIR:-}" && -f "${_CONFIG_REPO_DIR}/config/event-schema.json" ]]; then
99
+ local known_types
100
+ known_types=$(jq -r '.event_types | keys[]' "${_CONFIG_REPO_DIR}/config/event-schema.json" 2>/dev/null || true)
101
+ if [[ -n "$known_types" ]] && ! echo "$known_types" | grep -qx "$event_type"; then
102
+ echo "WARN: Unknown event type '$event_type'" >&2
103
+ fi
104
+ fi
93
105
  }
94
106
 
95
107
  # Rotate a JSONL file to keep it within max_lines.
@@ -103,9 +115,7 @@ with_retry() {
103
115
  local attempt=1
104
116
  local delay=1
105
117
  while [[ "$attempt" -le "$max_attempts" ]]; do
106
- if "$@"; then
107
- return 0
108
- fi
118
+ "$@" && return 0
109
119
  local exit_code=$?
110
120
  if [[ "$attempt" -lt "$max_attempts" ]]; then
111
121
  warn "Attempt $attempt/$max_attempts failed (exit $exit_code), retrying in ${delay}s..."
@@ -187,3 +197,17 @@ _sw_github_url() {
187
197
  repo="$(_sw_github_repo)"
188
198
  echo "https://github.com/${repo}"
189
199
  }
200
+
201
+ # ─── Network Safe Wrappers (config-aware timeouts) ─────────────────────────────
202
+ # Use SHIPWRIGHT_* env vars if set; otherwise _config_get_int when config.sh is loaded
203
+ # Usage: _curl_safe [curl args...] | _gh_safe [gh args...]
204
+ _curl_safe() {
205
+ local ct="${SHIPWRIGHT_CONNECT_TIMEOUT:-$(_config_get_int "network.connect_timeout" 10 2>/dev/null || echo 10)}"
206
+ local mt="${SHIPWRIGHT_MAX_TIME:-$(_config_get_int "network.max_time" 60 2>/dev/null || echo 60)}"
207
+ curl --connect-timeout "$ct" --max-time "$mt" "$@"
208
+ }
209
+
210
+ _gh_safe() {
211
+ local gh_timeout="${SHIPWRIGHT_GH_TIMEOUT:-$(_config_get_int "network.gh_timeout" 30 2>/dev/null || echo 30)}"
212
+ GH_HTTP_TIMEOUT="$gh_timeout" _timeout "$gh_timeout" gh "$@"
213
+ }
@@ -101,7 +101,7 @@ detect_project_lang() {
101
101
  fi
102
102
 
103
103
  # Intelligence: holistic analysis for polyglot/monorepo detection
104
- if [[ "$detected" == "unknown" ]] && type intelligence_search_memory &>/dev/null 2>&1 && command -v claude &>/dev/null; then
104
+ if [[ "$detected" == "unknown" ]] && type intelligence_search_memory >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then
105
105
  local config_files
106
106
  config_files=$(ls "$root" 2>/dev/null | grep -E '\.(json|toml|yaml|yml|xml|gradle|lock|mod)$' | head -15)
107
107
  if [[ -n "$config_files" ]]; then
@@ -221,7 +221,7 @@ detect_task_type() {
221
221
  local goal="$1"
222
222
 
223
223
  # Intelligence: Claude classification with confidence score
224
- if type intelligence_search_memory &>/dev/null 2>&1 && command -v claude &>/dev/null; then
224
+ if type intelligence_search_memory >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then
225
225
  local ai_result
226
226
  ai_result=$(claude --print --output-format text -p "Classify this task into exactly ONE category. Reply in format: CATEGORY|CONFIDENCE (0-100)
227
227
 
@@ -9,14 +9,14 @@ gh_init() {
9
9
  return
10
10
  fi
11
11
 
12
- if ! command -v gh &>/dev/null; then
12
+ if ! command -v gh >/dev/null 2>&1; then
13
13
  GH_AVAILABLE=false
14
14
  warn "gh CLI not found — GitHub integration disabled"
15
15
  return
16
16
  fi
17
17
 
18
18
  # Check if authenticated
19
- if ! gh auth status &>/dev/null 2>&1; then
19
+ if ! gh auth status >/dev/null 2>&1; then
20
20
  GH_AVAILABLE=false
21
21
  warn "gh not authenticated — GitHub integration disabled"
22
22
  return
@@ -46,7 +46,7 @@ gh_init() {
46
46
  gh_comment_issue() {
47
47
  [[ "$GH_AVAILABLE" != "true" ]] && return 0
48
48
  local issue_num="$1" body="$2"
49
- gh issue comment "$issue_num" --body "$body" 2>/dev/null || true
49
+ _timeout 30 gh issue comment "$issue_num" --body "$body" 2>/dev/null || true
50
50
  }
51
51
 
52
52
  # Post a progress-tracking comment and save its ID for later updates
@@ -56,7 +56,7 @@ gh_post_progress() {
56
56
  local issue_num="$1" body="$2"
57
57
  local result
58
58
  result=$(gh api "repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_num}/comments" \
59
- -f body="$body" --jq '.id' 2>/dev/null) || true
59
+ -f body="$body" --jq '.id' --timeout 30 2>/dev/null) || true
60
60
  if [[ -n "$result" && "$result" != "null" ]]; then
61
61
  PROGRESS_COMMENT_ID="$result"
62
62
  fi
@@ -68,7 +68,7 @@ gh_update_progress() {
68
68
  [[ "$GH_AVAILABLE" != "true" || -z "$PROGRESS_COMMENT_ID" ]] && return 0
69
69
  local body="$1"
70
70
  gh api "repos/${REPO_OWNER}/${REPO_NAME}/issues/comments/${PROGRESS_COMMENT_ID}" \
71
- -X PATCH -f body="$body" 2>/dev/null || true
71
+ -X PATCH -f body="$body" --timeout 30 2>/dev/null || true
72
72
  }
73
73
 
74
74
  # Add labels to an issue or PR
@@ -77,7 +77,7 @@ gh_add_labels() {
77
77
  [[ "$GH_AVAILABLE" != "true" ]] && return 0
78
78
  local issue_num="$1" labels="$2"
79
79
  [[ -z "$labels" ]] && return 0
80
- gh issue edit "$issue_num" --add-label "$labels" 2>/dev/null || true
80
+ _timeout 30 gh issue edit "$issue_num" --add-label "$labels" 2>/dev/null || true
81
81
  }
82
82
 
83
83
  # Remove a label from an issue
@@ -85,7 +85,7 @@ gh_add_labels() {
85
85
  gh_remove_label() {
86
86
  [[ "$GH_AVAILABLE" != "true" ]] && return 0
87
87
  local issue_num="$1" label="$2"
88
- gh issue edit "$issue_num" --remove-label "$label" 2>/dev/null || true
88
+ _timeout 30 gh issue edit "$issue_num" --remove-label "$label" 2>/dev/null || true
89
89
  }
90
90
 
91
91
  # Self-assign an issue
@@ -93,7 +93,7 @@ gh_remove_label() {
93
93
  gh_assign_self() {
94
94
  [[ "$GH_AVAILABLE" != "true" ]] && return 0
95
95
  local issue_num="$1"
96
- gh issue edit "$issue_num" --add-assignee "@me" 2>/dev/null || true
96
+ _timeout 30 gh issue edit "$issue_num" --add-assignee "@me" 2>/dev/null || true
97
97
  }
98
98
 
99
99
  # Get full issue metadata as JSON
@@ -101,7 +101,7 @@ gh_assign_self() {
101
101
  gh_get_issue_meta() {
102
102
  [[ "$GH_AVAILABLE" != "true" ]] && return 0
103
103
  local issue_num="$1"
104
- gh issue view "$issue_num" --json title,body,labels,milestone,assignees,comments,number,state 2>/dev/null || true
104
+ _timeout 30 gh issue view "$issue_num" --json title,body,labels,milestone,assignees,comments,number,state 2>/dev/null || true
105
105
  }
106
106
 
107
107
  # Build a progress table for GitHub comment