shipwright-cli 2.4.0 → 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 (161) hide show
  1. package/README.md +16 -11
  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 +13 -18
  8. package/dashboard/coverage/coverage-summary.json +14 -0
  9. package/dashboard/public/index.html +1 -1
  10. package/dashboard/server.ts +306 -17
  11. package/dashboard/src/components/charts/bar.test.ts +79 -0
  12. package/dashboard/src/components/charts/donut.test.ts +68 -0
  13. package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
  14. package/dashboard/src/components/charts/sparkline.test.ts +125 -0
  15. package/dashboard/src/core/api.test.ts +309 -0
  16. package/dashboard/src/core/helpers.test.ts +301 -0
  17. package/dashboard/src/core/router.test.ts +307 -0
  18. package/dashboard/src/core/router.ts +7 -0
  19. package/dashboard/src/core/sse.test.ts +144 -0
  20. package/dashboard/src/views/metrics.test.ts +186 -0
  21. package/dashboard/src/views/overview.test.ts +173 -0
  22. package/dashboard/src/views/pipelines.test.ts +183 -0
  23. package/dashboard/src/views/team.test.ts +253 -0
  24. package/dashboard/vitest.config.ts +14 -5
  25. package/docs/TIPS.md +1 -1
  26. package/docs/patterns/README.md +1 -1
  27. package/package.json +5 -7
  28. package/scripts/adapters/docker-deploy.sh +1 -1
  29. package/scripts/adapters/tmux-adapter.sh +11 -1
  30. package/scripts/adapters/wezterm-adapter.sh +1 -1
  31. package/scripts/check-version-consistency.sh +1 -1
  32. package/scripts/lib/architecture.sh +126 -0
  33. package/scripts/lib/bootstrap.sh +75 -0
  34. package/scripts/lib/compat.sh +89 -6
  35. package/scripts/lib/config.sh +91 -0
  36. package/scripts/lib/daemon-adaptive.sh +3 -3
  37. package/scripts/lib/daemon-dispatch.sh +39 -16
  38. package/scripts/lib/daemon-health.sh +1 -1
  39. package/scripts/lib/daemon-patrol.sh +24 -12
  40. package/scripts/lib/daemon-poll.sh +37 -25
  41. package/scripts/lib/daemon-state.sh +115 -23
  42. package/scripts/lib/daemon-triage.sh +30 -8
  43. package/scripts/lib/fleet-failover.sh +63 -0
  44. package/scripts/lib/helpers.sh +30 -6
  45. package/scripts/lib/pipeline-detection.sh +2 -2
  46. package/scripts/lib/pipeline-github.sh +9 -9
  47. package/scripts/lib/pipeline-intelligence.sh +85 -35
  48. package/scripts/lib/pipeline-quality-checks.sh +16 -16
  49. package/scripts/lib/pipeline-quality.sh +1 -1
  50. package/scripts/lib/pipeline-stages.sh +242 -28
  51. package/scripts/lib/pipeline-state.sh +40 -4
  52. package/scripts/lib/test-helpers.sh +247 -0
  53. package/scripts/postinstall.mjs +3 -11
  54. package/scripts/sw +10 -4
  55. package/scripts/sw-activity.sh +1 -11
  56. package/scripts/sw-adaptive.sh +109 -85
  57. package/scripts/sw-adversarial.sh +4 -14
  58. package/scripts/sw-architecture-enforcer.sh +1 -11
  59. package/scripts/sw-auth.sh +8 -17
  60. package/scripts/sw-autonomous.sh +111 -49
  61. package/scripts/sw-changelog.sh +1 -11
  62. package/scripts/sw-checkpoint.sh +144 -20
  63. package/scripts/sw-ci.sh +2 -12
  64. package/scripts/sw-cleanup.sh +13 -17
  65. package/scripts/sw-code-review.sh +16 -36
  66. package/scripts/sw-connect.sh +5 -12
  67. package/scripts/sw-context.sh +9 -26
  68. package/scripts/sw-cost.sh +6 -16
  69. package/scripts/sw-daemon.sh +75 -70
  70. package/scripts/sw-dashboard.sh +57 -17
  71. package/scripts/sw-db.sh +506 -15
  72. package/scripts/sw-decompose.sh +1 -11
  73. package/scripts/sw-deps.sh +15 -25
  74. package/scripts/sw-developer-simulation.sh +1 -11
  75. package/scripts/sw-discovery.sh +112 -30
  76. package/scripts/sw-doc-fleet.sh +7 -17
  77. package/scripts/sw-docs-agent.sh +6 -16
  78. package/scripts/sw-docs.sh +4 -12
  79. package/scripts/sw-doctor.sh +134 -43
  80. package/scripts/sw-dora.sh +11 -19
  81. package/scripts/sw-durable.sh +35 -52
  82. package/scripts/sw-e2e-orchestrator.sh +11 -27
  83. package/scripts/sw-eventbus.sh +115 -115
  84. package/scripts/sw-evidence.sh +114 -30
  85. package/scripts/sw-feedback.sh +3 -13
  86. package/scripts/sw-fix.sh +2 -20
  87. package/scripts/sw-fleet-discover.sh +1 -11
  88. package/scripts/sw-fleet-viz.sh +10 -18
  89. package/scripts/sw-fleet.sh +13 -17
  90. package/scripts/sw-github-app.sh +6 -16
  91. package/scripts/sw-github-checks.sh +1 -11
  92. package/scripts/sw-github-deploy.sh +1 -11
  93. package/scripts/sw-github-graphql.sh +2 -12
  94. package/scripts/sw-guild.sh +1 -11
  95. package/scripts/sw-heartbeat.sh +49 -12
  96. package/scripts/sw-hygiene.sh +45 -43
  97. package/scripts/sw-incident.sh +48 -74
  98. package/scripts/sw-init.sh +35 -37
  99. package/scripts/sw-instrument.sh +1 -11
  100. package/scripts/sw-intelligence.sh +362 -51
  101. package/scripts/sw-jira.sh +5 -14
  102. package/scripts/sw-launchd.sh +2 -12
  103. package/scripts/sw-linear.sh +8 -17
  104. package/scripts/sw-logs.sh +4 -12
  105. package/scripts/sw-loop.sh +641 -90
  106. package/scripts/sw-memory.sh +243 -17
  107. package/scripts/sw-mission-control.sh +2 -12
  108. package/scripts/sw-model-router.sh +73 -34
  109. package/scripts/sw-otel.sh +11 -21
  110. package/scripts/sw-oversight.sh +1 -11
  111. package/scripts/sw-patrol-meta.sh +5 -11
  112. package/scripts/sw-pipeline-composer.sh +7 -17
  113. package/scripts/sw-pipeline-vitals.sh +1 -11
  114. package/scripts/sw-pipeline.sh +478 -122
  115. package/scripts/sw-pm.sh +2 -12
  116. package/scripts/sw-pr-lifecycle.sh +27 -25
  117. package/scripts/sw-predictive.sh +16 -22
  118. package/scripts/sw-prep.sh +6 -16
  119. package/scripts/sw-ps.sh +1 -11
  120. package/scripts/sw-public-dashboard.sh +2 -12
  121. package/scripts/sw-quality.sh +77 -10
  122. package/scripts/sw-reaper.sh +1 -11
  123. package/scripts/sw-recruit.sh +15 -25
  124. package/scripts/sw-regression.sh +11 -21
  125. package/scripts/sw-release-manager.sh +19 -28
  126. package/scripts/sw-release.sh +8 -16
  127. package/scripts/sw-remote.sh +1 -11
  128. package/scripts/sw-replay.sh +48 -44
  129. package/scripts/sw-retro.sh +70 -92
  130. package/scripts/sw-review-rerun.sh +1 -1
  131. package/scripts/sw-scale.sh +109 -32
  132. package/scripts/sw-security-audit.sh +12 -22
  133. package/scripts/sw-self-optimize.sh +239 -23
  134. package/scripts/sw-session.sh +3 -13
  135. package/scripts/sw-setup.sh +8 -18
  136. package/scripts/sw-standup.sh +5 -15
  137. package/scripts/sw-status.sh +32 -23
  138. package/scripts/sw-strategic.sh +129 -13
  139. package/scripts/sw-stream.sh +1 -11
  140. package/scripts/sw-swarm.sh +76 -36
  141. package/scripts/sw-team-stages.sh +10 -20
  142. package/scripts/sw-templates.sh +4 -14
  143. package/scripts/sw-testgen.sh +3 -13
  144. package/scripts/sw-tmux-pipeline.sh +1 -19
  145. package/scripts/sw-tmux-role-color.sh +0 -10
  146. package/scripts/sw-tmux-status.sh +3 -11
  147. package/scripts/sw-tmux.sh +2 -20
  148. package/scripts/sw-trace.sh +1 -19
  149. package/scripts/sw-tracker-github.sh +0 -10
  150. package/scripts/sw-tracker-jira.sh +1 -11
  151. package/scripts/sw-tracker-linear.sh +1 -11
  152. package/scripts/sw-tracker.sh +7 -24
  153. package/scripts/sw-triage.sh +24 -34
  154. package/scripts/sw-upgrade.sh +5 -23
  155. package/scripts/sw-ux.sh +1 -19
  156. package/scripts/sw-webhook.sh +18 -32
  157. package/scripts/sw-widgets.sh +3 -21
  158. package/scripts/sw-worktree.sh +11 -27
  159. package/scripts/update-homebrew-sha.sh +67 -0
  160. package/templates/pipelines/tdd.json +72 -0
  161. package/scripts/sw-pipeline.sh.mock +0 -7
@@ -53,7 +53,7 @@ daemon_poll_issues() {
53
53
  --owner "$ORG" \
54
54
  --state open \
55
55
  --json repository,number,title,labels,body,createdAt \
56
- --limit 20 2>/dev/null) || {
56
+ --limit "${ISSUE_LIMIT:-100}" 2>/dev/null) || {
57
57
  # Handle rate limiting with exponential backoff
58
58
  if [[ $BACKOFF_SECS -eq 0 ]]; then
59
59
  BACKOFF_SECS=30
@@ -80,7 +80,7 @@ daemon_poll_issues() {
80
80
  --label "$WATCH_LABEL" \
81
81
  --state open \
82
82
  --json number,title,labels,body,createdAt \
83
- --limit 20 2>/dev/null) || {
83
+ --limit 100 2>/dev/null) || {
84
84
  # Handle rate limiting with exponential backoff
85
85
  if [[ $BACKOFF_SECS -eq 0 ]]; then
86
86
  BACKOFF_SECS=30
@@ -212,18 +212,22 @@ daemon_poll_issues() {
212
212
  while IFS='|' read -r score issue_num repo_name; do
213
213
  [[ -z "$issue_num" ]] && continue
214
214
 
215
+ local issue_key
216
+ issue_key="$issue_num"
217
+ [[ -n "$repo_name" ]] && issue_key="${repo_name}:${issue_num}"
218
+
215
219
  local issue_title labels_csv
216
- issue_title=$(echo "$issues_json" | jq -r --argjson n "$issue_num" '.[] | select(.number == $n) | .title')
217
- labels_csv=$(echo "$issues_json" | jq -r --argjson n "$issue_num" '.[] | select(.number == $n) | [.labels[].name] | join(",")')
220
+ issue_title=$(echo "$issues_json" | jq -r --argjson n "$issue_num" --arg repo "$repo_name" '.[] | select(.number == $n) | select($repo == "" or (.repository.nameWithOwner // "") == $repo) | .title')
221
+ labels_csv=$(echo "$issues_json" | jq -r --argjson n "$issue_num" --arg repo "$repo_name" '.[] | select(.number == $n) | select($repo == "" or (.repository.nameWithOwner // "") == $repo) | [.labels[].name] | join(",")')
218
222
 
219
- # Cache title in state for dashboard visibility
223
+ # Cache title in state for dashboard visibility (use issue_key for org mode)
220
224
  if [[ -n "$issue_title" ]]; then
221
- locked_state_update --arg num "$issue_num" --arg title "$issue_title" \
225
+ locked_state_update --arg num "$issue_key" --arg title "$issue_title" \
222
226
  '.titles[$num] = $title'
223
227
  fi
224
228
 
225
229
  # Skip if already inflight
226
- if daemon_is_inflight "$issue_num"; then
230
+ if daemon_is_inflight "$issue_key"; then
227
231
  continue
228
232
  fi
229
233
 
@@ -263,7 +267,7 @@ daemon_poll_issues() {
263
267
  # Check capacity
264
268
  active_count=$(locked_get_active_count)
265
269
  if [[ "$active_count" -ge "$MAX_PARALLEL" ]]; then
266
- enqueue_issue "$issue_num"
270
+ enqueue_issue "$issue_key"
267
271
  continue
268
272
  fi
269
273
 
@@ -308,24 +312,26 @@ daemon_poll_issues() {
308
312
  local drain_active
309
313
  drain_active=$(locked_get_active_count)
310
314
  while [[ "$drain_active" -lt "$MAX_PARALLEL" ]]; do
311
- local drain_issue
312
- drain_issue=$(dequeue_next)
313
- [[ -z "$drain_issue" ]] && break
315
+ local drain_issue_key
316
+ drain_issue_key=$(dequeue_next)
317
+ [[ -z "$drain_issue_key" ]] && break
318
+ local drain_issue_num="$drain_issue_key" drain_repo=""
319
+ [[ "$drain_issue_key" == *:* ]] && drain_repo="${drain_issue_key%%:*}" && drain_issue_num="${drain_issue_key##*:}"
314
320
  local drain_title
315
- drain_title=$(jq -r --arg n "$drain_issue" '.titles[$n] // ""' "$STATE_FILE" 2>/dev/null || true)
321
+ drain_title=$(jq -r --arg n "$drain_issue_key" '.titles[$n] // ""' "$STATE_FILE" 2>/dev/null || true)
316
322
 
317
323
  local drain_labels drain_score drain_template
318
- drain_labels=$(echo "$issues_json" | jq -r --argjson n "$drain_issue" \
319
- '.[] | select(.number == $n) | [.labels[].name] | join(",")' 2>/dev/null || echo "")
320
- drain_score=$(echo "$sorted_order" | grep "|${drain_issue}|" | cut -d'|' -f1 || echo "50")
324
+ drain_labels=$(echo "$issues_json" | jq -r --argjson n "$drain_issue_num" --arg repo "$drain_repo" \
325
+ '.[] | select(.number == $n) | select($repo == "" or (.repository.nameWithOwner // "") == $repo) | [.labels[].name] | join(",")' 2>/dev/null || echo "")
326
+ drain_score=$(echo "$sorted_order" | grep "|${drain_issue_num}|" | cut -d'|' -f1 || echo "50")
321
327
  drain_template=$(select_pipeline_template "$drain_labels" "${drain_score:-50}" 2>/dev/null | tail -1)
322
328
  drain_template=$(printf '%s' "$drain_template" | sed $'s/\x1b\\[[0-9;]*m//g' | tr -cd '[:alnum:]-_')
323
329
  [[ -z "$drain_template" ]] && drain_template="$PIPELINE_TEMPLATE"
324
330
 
325
- daemon_log INFO "Draining queue: issue #${drain_issue}, template=${drain_template}"
331
+ daemon_log INFO "Draining queue: issue #${drain_issue_num}${drain_repo:+, repo=${drain_repo}}, template=${drain_template}"
326
332
  local orig_template="$PIPELINE_TEMPLATE"
327
333
  PIPELINE_TEMPLATE="$drain_template"
328
- daemon_spawn_pipeline "$drain_issue" "$drain_title"
334
+ daemon_spawn_pipeline "$drain_issue_num" "$drain_title" "$drain_repo"
329
335
  PIPELINE_TEMPLATE="$orig_template"
330
336
  drain_active=$(locked_get_active_count)
331
337
  done
@@ -692,7 +698,7 @@ daemon_auto_scale() {
692
698
 
693
699
  # ── Vitals-driven scaling factor ──
694
700
  local max_by_vitals="$MAX_WORKERS"
695
- if type pipeline_compute_vitals &>/dev/null 2>&1 && [[ -f "$STATE_FILE" ]]; then
701
+ if type pipeline_compute_vitals >/dev/null 2>&1 && [[ -f "$STATE_FILE" ]]; then
696
702
  local _total_health=0 _health_count=0
697
703
  while IFS= read -r _job; do
698
704
  local _job_issue _job_worktree
@@ -813,7 +819,7 @@ daemon_self_optimize() {
813
819
  fi
814
820
 
815
821
  # ── Intelligence-powered optimization (if enabled) ──
816
- if [[ "${OPTIMIZATION_ENABLED:-false}" == "true" ]] && type optimize_full_analysis &>/dev/null 2>&1; then
822
+ if [[ "${OPTIMIZATION_ENABLED:-false}" == "true" ]] && type optimize_full_analysis >/dev/null 2>&1; then
817
823
  daemon_log INFO "Running intelligence-powered optimization"
818
824
  optimize_full_analysis 2>/dev/null || {
819
825
  daemon_log WARN "Intelligence optimization failed — falling back to DORA-based tuning"
@@ -968,7 +974,7 @@ daemon_cleanup_stale() {
968
974
  now_e=$(now_epoch)
969
975
 
970
976
  # ── 1. Clean old git worktrees ──
971
- if command -v git &>/dev/null; then
977
+ if command -v git >/dev/null 2>&1; then
972
978
  while IFS= read -r line; do
973
979
  local wt_path
974
980
  wt_path=$(echo "$line" | awk '{print $1}')
@@ -976,7 +982,7 @@ daemon_cleanup_stale() {
976
982
  [[ "$wt_path" == *"daemon-issue-"* ]] || continue
977
983
  # Check worktree age via directory mtime
978
984
  local mtime
979
- mtime=$(stat -f '%m' "$wt_path" 2>/dev/null || stat -c '%Y' "$wt_path" 2>/dev/null || echo "0")
985
+ mtime=$(file_mtime "$wt_path")
980
986
  if [[ $((now_e - mtime)) -gt $age_secs ]]; then
981
987
  daemon_log INFO "Removing stale worktree: ${wt_path}"
982
988
  git worktree remove "$wt_path" --force 2>/dev/null || true
@@ -1003,7 +1009,7 @@ daemon_cleanup_stale() {
1003
1009
  while IFS= read -r artifact_dir; do
1004
1010
  [[ -d "$artifact_dir" ]] || continue
1005
1011
  local mtime
1006
- mtime=$(stat -f '%m' "$artifact_dir" 2>/dev/null || stat -c '%Y' "$artifact_dir" 2>/dev/null || echo "0")
1012
+ mtime=$(file_mtime "$artifact_dir")
1007
1013
  if [[ $((now_e - mtime)) -gt $age_secs ]]; then
1008
1014
  daemon_log INFO "Removing stale artifact: ${artifact_dir}"
1009
1015
  rm -rf "$artifact_dir"
@@ -1013,7 +1019,7 @@ daemon_cleanup_stale() {
1013
1019
  fi
1014
1020
 
1015
1021
  # ── 3. Clean orphaned daemon/* branches (no matching worktree or active job) ──
1016
- if command -v git &>/dev/null; then
1022
+ if command -v git >/dev/null 2>&1; then
1017
1023
  while IFS= read -r branch; do
1018
1024
  [[ -z "$branch" ]] && continue
1019
1025
  branch="${branch## }" # trim leading spaces
@@ -1075,7 +1081,7 @@ daemon_cleanup_stale() {
1075
1081
  ps_status=$(sed -n 's/^status: *//p' "$pipeline_state" 2>/dev/null | head -1 | tr -d ' ')
1076
1082
  if [[ "$ps_status" == "running" ]]; then
1077
1083
  local ps_mtime
1078
- ps_mtime=$(stat -f '%m' "$pipeline_state" 2>/dev/null || stat -c '%Y' "$pipeline_state" 2>/dev/null || echo "0")
1084
+ ps_mtime=$(file_mtime "$pipeline_state")
1079
1085
  local ps_age=$((now_e - ps_mtime))
1080
1086
  # If pipeline-state.md has been "running" for more than 2 hours and no active job
1081
1087
  if [[ "$ps_age" -gt 7200 ]]; then
@@ -1098,7 +1104,7 @@ daemon_cleanup_stale() {
1098
1104
  fi
1099
1105
 
1100
1106
  # ── 7. Clean remote branches for merged pipeline/* branches ──
1101
- if command -v git &>/dev/null && [[ "${NO_GITHUB:-}" != "true" ]]; then
1107
+ if command -v git >/dev/null 2>&1 && [[ "${NO_GITHUB:-}" != "true" ]]; then
1102
1108
  while IFS= read -r branch; do
1103
1109
  [[ -z "$branch" ]] && continue
1104
1110
  branch="${branch## }"
@@ -1138,6 +1144,12 @@ daemon_poll_loop() {
1138
1144
  daemon_reap_completed || daemon_log WARN "daemon_reap_completed failed — continuing"
1139
1145
  daemon_health_check || daemon_log WARN "daemon_health_check failed — continuing"
1140
1146
 
1147
+ # Fleet failover: re-queue work from offline machines
1148
+ if [[ -f "$HOME/.shipwright/machines.json" ]]; then
1149
+ [[ -f "$SCRIPT_DIR/lib/fleet-failover.sh" ]] && source "$SCRIPT_DIR/lib/fleet-failover.sh" 2>/dev/null || true
1150
+ fleet_failover_check 2>/dev/null || true
1151
+ fi
1152
+
1141
1153
  # Increment cycle counter (must be before all modulo checks)
1142
1154
  POLL_CYCLE_COUNT=$((POLL_CYCLE_COUNT + 1))
1143
1155
 
@@ -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
+ }