shipwright-cli 2.4.0 → 3.1.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 (169) hide show
  1. package/README.md +16 -11
  2. package/completions/_shipwright +248 -94
  3. package/completions/shipwright.bash +68 -19
  4. package/completions/shipwright.fish +310 -42
  5. package/config/decision-tiers.json +55 -0
  6. package/config/defaults.json +111 -0
  7. package/config/event-schema.json +218 -0
  8. package/config/policy.json +21 -18
  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 +7 -9
  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 +127 -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 +63 -17
  39. package/scripts/lib/daemon-failure.sh +0 -0
  40. package/scripts/lib/daemon-health.sh +1 -1
  41. package/scripts/lib/daemon-patrol.sh +64 -17
  42. package/scripts/lib/daemon-poll.sh +54 -25
  43. package/scripts/lib/daemon-state.sh +125 -23
  44. package/scripts/lib/daemon-triage.sh +31 -9
  45. package/scripts/lib/decide-autonomy.sh +295 -0
  46. package/scripts/lib/decide-scoring.sh +228 -0
  47. package/scripts/lib/decide-signals.sh +462 -0
  48. package/scripts/lib/fleet-failover.sh +63 -0
  49. package/scripts/lib/helpers.sh +29 -6
  50. package/scripts/lib/pipeline-detection.sh +2 -2
  51. package/scripts/lib/pipeline-github.sh +9 -9
  52. package/scripts/lib/pipeline-intelligence.sh +105 -38
  53. package/scripts/lib/pipeline-quality-checks.sh +17 -16
  54. package/scripts/lib/pipeline-quality.sh +1 -1
  55. package/scripts/lib/pipeline-stages.sh +440 -59
  56. package/scripts/lib/pipeline-state.sh +54 -4
  57. package/scripts/lib/policy.sh +0 -0
  58. package/scripts/lib/test-helpers.sh +247 -0
  59. package/scripts/postinstall.mjs +78 -12
  60. package/scripts/signals/example-collector.sh +36 -0
  61. package/scripts/sw +17 -7
  62. package/scripts/sw-activity.sh +1 -11
  63. package/scripts/sw-adaptive.sh +109 -85
  64. package/scripts/sw-adversarial.sh +4 -14
  65. package/scripts/sw-architecture-enforcer.sh +1 -11
  66. package/scripts/sw-auth.sh +8 -17
  67. package/scripts/sw-autonomous.sh +111 -49
  68. package/scripts/sw-changelog.sh +1 -11
  69. package/scripts/sw-checkpoint.sh +144 -20
  70. package/scripts/sw-ci.sh +2 -12
  71. package/scripts/sw-cleanup.sh +13 -17
  72. package/scripts/sw-code-review.sh +16 -36
  73. package/scripts/sw-connect.sh +5 -12
  74. package/scripts/sw-context.sh +9 -26
  75. package/scripts/sw-cost.sh +17 -18
  76. package/scripts/sw-daemon.sh +76 -71
  77. package/scripts/sw-dashboard.sh +57 -17
  78. package/scripts/sw-db.sh +524 -26
  79. package/scripts/sw-decide.sh +685 -0
  80. package/scripts/sw-decompose.sh +1 -11
  81. package/scripts/sw-deps.sh +15 -25
  82. package/scripts/sw-developer-simulation.sh +1 -11
  83. package/scripts/sw-discovery.sh +138 -30
  84. package/scripts/sw-doc-fleet.sh +7 -17
  85. package/scripts/sw-docs-agent.sh +6 -16
  86. package/scripts/sw-docs.sh +4 -12
  87. package/scripts/sw-doctor.sh +134 -43
  88. package/scripts/sw-dora.sh +11 -19
  89. package/scripts/sw-durable.sh +35 -52
  90. package/scripts/sw-e2e-orchestrator.sh +11 -27
  91. package/scripts/sw-eventbus.sh +115 -115
  92. package/scripts/sw-evidence.sh +114 -30
  93. package/scripts/sw-feedback.sh +3 -13
  94. package/scripts/sw-fix.sh +2 -20
  95. package/scripts/sw-fleet-discover.sh +1 -11
  96. package/scripts/sw-fleet-viz.sh +10 -18
  97. package/scripts/sw-fleet.sh +13 -17
  98. package/scripts/sw-github-app.sh +6 -16
  99. package/scripts/sw-github-checks.sh +1 -11
  100. package/scripts/sw-github-deploy.sh +1 -11
  101. package/scripts/sw-github-graphql.sh +2 -12
  102. package/scripts/sw-guild.sh +1 -11
  103. package/scripts/sw-heartbeat.sh +49 -12
  104. package/scripts/sw-hygiene.sh +45 -43
  105. package/scripts/sw-incident.sh +48 -74
  106. package/scripts/sw-init.sh +35 -37
  107. package/scripts/sw-instrument.sh +1 -11
  108. package/scripts/sw-intelligence.sh +368 -53
  109. package/scripts/sw-jira.sh +5 -14
  110. package/scripts/sw-launchd.sh +2 -12
  111. package/scripts/sw-linear.sh +8 -17
  112. package/scripts/sw-logs.sh +4 -12
  113. package/scripts/sw-loop.sh +905 -104
  114. package/scripts/sw-memory.sh +263 -20
  115. package/scripts/sw-mission-control.sh +2 -12
  116. package/scripts/sw-model-router.sh +73 -34
  117. package/scripts/sw-otel.sh +15 -23
  118. package/scripts/sw-oversight.sh +1 -11
  119. package/scripts/sw-patrol-meta.sh +5 -11
  120. package/scripts/sw-pipeline-composer.sh +7 -17
  121. package/scripts/sw-pipeline-vitals.sh +1 -11
  122. package/scripts/sw-pipeline.sh +550 -122
  123. package/scripts/sw-pm.sh +2 -12
  124. package/scripts/sw-pr-lifecycle.sh +33 -28
  125. package/scripts/sw-predictive.sh +16 -22
  126. package/scripts/sw-prep.sh +6 -16
  127. package/scripts/sw-ps.sh +1 -11
  128. package/scripts/sw-public-dashboard.sh +2 -12
  129. package/scripts/sw-quality.sh +85 -14
  130. package/scripts/sw-reaper.sh +1 -11
  131. package/scripts/sw-recruit.sh +15 -25
  132. package/scripts/sw-regression.sh +11 -21
  133. package/scripts/sw-release-manager.sh +19 -28
  134. package/scripts/sw-release.sh +8 -16
  135. package/scripts/sw-remote.sh +1 -11
  136. package/scripts/sw-replay.sh +48 -44
  137. package/scripts/sw-retro.sh +70 -92
  138. package/scripts/sw-review-rerun.sh +1 -1
  139. package/scripts/sw-scale.sh +174 -41
  140. package/scripts/sw-security-audit.sh +12 -22
  141. package/scripts/sw-self-optimize.sh +239 -23
  142. package/scripts/sw-session.sh +5 -15
  143. package/scripts/sw-setup.sh +8 -18
  144. package/scripts/sw-standup.sh +5 -15
  145. package/scripts/sw-status.sh +32 -23
  146. package/scripts/sw-strategic.sh +129 -13
  147. package/scripts/sw-stream.sh +1 -11
  148. package/scripts/sw-swarm.sh +76 -36
  149. package/scripts/sw-team-stages.sh +10 -20
  150. package/scripts/sw-templates.sh +4 -14
  151. package/scripts/sw-testgen.sh +3 -13
  152. package/scripts/sw-tmux-pipeline.sh +1 -19
  153. package/scripts/sw-tmux-role-color.sh +0 -10
  154. package/scripts/sw-tmux-status.sh +3 -11
  155. package/scripts/sw-tmux.sh +2 -20
  156. package/scripts/sw-trace.sh +1 -19
  157. package/scripts/sw-tracker-github.sh +0 -10
  158. package/scripts/sw-tracker-jira.sh +1 -11
  159. package/scripts/sw-tracker-linear.sh +1 -11
  160. package/scripts/sw-tracker.sh +7 -24
  161. package/scripts/sw-triage.sh +29 -39
  162. package/scripts/sw-upgrade.sh +5 -23
  163. package/scripts/sw-ux.sh +1 -19
  164. package/scripts/sw-webhook.sh +18 -32
  165. package/scripts/sw-widgets.sh +3 -21
  166. package/scripts/sw-worktree.sh +11 -27
  167. package/scripts/update-homebrew-sha.sh +73 -0
  168. package/templates/pipelines/tdd.json +72 -0
  169. 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
 
@@ -1184,6 +1196,23 @@ daemon_poll_loop() {
1184
1196
  daemon_patrol --once || daemon_log WARN "daemon_patrol failed — continuing"
1185
1197
  LAST_PATROL_EPOCH=$now_e
1186
1198
  fi
1199
+
1200
+ # Decision engine cycle (if enabled)
1201
+ local _decision_enabled
1202
+ _decision_enabled=$(policy_get ".decision.enabled" "false" 2>/dev/null || echo "false")
1203
+ if [[ "$_decision_enabled" == "true" ]]; then
1204
+ local _decision_interval
1205
+ _decision_interval=$(policy_get ".decision.cycle_interval_seconds" "1800" 2>/dev/null || echo "1800")
1206
+ local _last_decision_epoch="${_LAST_DECISION_EPOCH:-0}"
1207
+ if [[ $((now_e - _last_decision_epoch)) -ge "$_decision_interval" ]]; then
1208
+ daemon_log INFO "Running decision engine cycle"
1209
+ if [[ -f "$SCRIPT_DIR/sw-decide.sh" ]]; then
1210
+ DECISION_ENGINE_ENABLED=true bash "$SCRIPT_DIR/sw-decide.sh" run --once 2>/dev/null || \
1211
+ daemon_log WARN "Decision engine cycle failed — continuing"
1212
+ fi
1213
+ _LAST_DECISION_EPOCH=$now_e
1214
+ fi
1215
+ fi
1187
1216
  fi
1188
1217
 
1189
1218
  # ── Adaptive poll interval: adjust sleep based on queue state ──
@@ -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
@@ -352,12 +390,27 @@ init_state() {
352
390
  atomic_write_state "$init_json"
353
391
  ) 200>"$lock_file"
354
392
  else
393
+ # Validate existing state file JSON before using it
394
+ if ! jq '.' "$STATE_FILE" >/dev/null 2>&1; then
395
+ daemon_log WARN "Corrupted state file detected — backing up and resetting"
396
+ cp "$STATE_FILE" "${STATE_FILE}.corrupted.$(date +%s)" 2>/dev/null || true
397
+ rm -f "$STATE_FILE"
398
+ # Re-initialize as fresh state (recursive call with file removed)
399
+ init_state
400
+ return
401
+ fi
402
+
355
403
  # Update PID and start time in existing state
356
404
  locked_state_update \
357
405
  --arg pid "$$" \
358
406
  --arg started "$(now_iso)" \
359
407
  '.pid = ($pid | tonumber) | .started_at = $started'
360
408
  fi
409
+
410
+ # Ensure DB schema is initialized when available
411
+ if type migrate_schema >/dev/null 2>&1 && db_available 2>/dev/null; then
412
+ migrate_schema 2>/dev/null || true
413
+ fi
361
414
  }
362
415
 
363
416
  update_state_field() {
@@ -369,13 +422,15 @@ update_state_field() {
369
422
  # ─── Inflight Check ─────────────────────────────────────────────────────────
370
423
 
371
424
  daemon_is_inflight() {
372
- local issue_num="$1"
425
+ local issue_key="$1"
426
+ local issue_num="$issue_key"
427
+ [[ "$issue_key" == *:* ]] && issue_num="${issue_key##*:}"
373
428
 
374
429
  if [[ ! -f "$STATE_FILE" ]]; then
375
430
  return 1
376
431
  fi
377
432
 
378
- # Check active_jobs
433
+ # Check active_jobs (stored with numeric .issue)
379
434
  local active_match
380
435
  active_match=$(jq -r --argjson num "$issue_num" \
381
436
  '.active_jobs[] | select(.issue == $num) | .issue' \
@@ -384,10 +439,10 @@ daemon_is_inflight() {
384
439
  return 0
385
440
  fi
386
441
 
387
- # Check queued
442
+ # Check queued (stores full key e.g. "owner/repo:42" or "42")
388
443
  local queued_match
389
- queued_match=$(jq -r --argjson num "$issue_num" \
390
- '.queued[] | select(. == $num)' \
444
+ queued_match=$(jq -r --arg key "$issue_key" \
445
+ '.queued[] | select(. == $key)' \
391
446
  "$STATE_FILE" 2>/dev/null || true)
392
447
  if [[ -n "$queued_match" ]]; then
393
448
  return 0
@@ -417,7 +472,7 @@ locked_get_active_count() {
417
472
  local count
418
473
  count=$(
419
474
  (
420
- if command -v flock &>/dev/null; then
475
+ if command -v flock >/dev/null 2>&1; then
421
476
  flock -w 5 200 2>/dev/null || {
422
477
  daemon_log WARN "locked_get_active_count: lock timeout — returning MAX_PARALLEL as safe default" >&2
423
478
  echo "$MAX_PARALLEL"
@@ -433,13 +488,30 @@ locked_get_active_count() {
433
488
  # ─── Queue Management ───────────────────────────────────────────────────────
434
489
 
435
490
  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)"
491
+ local issue_key="$1"
492
+ locked_state_update --arg key "$issue_key" \
493
+ '.queued += [$key] | .queued |= unique'
494
+ if type db_enqueue_issue >/dev/null 2>&1; then
495
+ db_enqueue_issue "$issue_key" 2>/dev/null || true
496
+ fi
497
+ daemon_log INFO "Queued issue ${issue_key} (at capacity)"
440
498
  }
441
499
 
442
500
  dequeue_next() {
501
+ # Try DB first when available
502
+ if type db_dequeue_next >/dev/null 2>&1 && db_available 2>/dev/null; then
503
+ local next
504
+ next=$(db_dequeue_next 2>/dev/null || true)
505
+ if [[ -n "$next" ]]; then
506
+ # Also update JSON file for backward compat
507
+ if [[ -f "$STATE_FILE" ]]; then
508
+ locked_state_update --arg key "$next" '.queued = [.queued[] | select(. != $key)]'
509
+ fi
510
+ echo "$next"
511
+ return
512
+ fi
513
+ fi
514
+
443
515
  if [[ ! -f "$STATE_FILE" ]]; then
444
516
  return
445
517
  fi
@@ -447,8 +519,10 @@ dequeue_next() {
447
519
  local next
448
520
  next=$(jq -r '.queued[0] // empty' "$STATE_FILE" 2>/dev/null || true)
449
521
  if [[ -n "$next" ]]; then
450
- # Remove from queue (locked to prevent race with enqueue)
451
522
  locked_state_update '.queued = .queued[1:]'
523
+ if type db_remove_from_queue >/dev/null 2>&1; then
524
+ db_remove_from_queue "$next" 2>/dev/null || true
525
+ fi
452
526
  echo "$next"
453
527
  fi
454
528
  }
@@ -496,6 +570,22 @@ untrack_priority_job() {
496
570
 
497
571
  # ─── Distributed Issue Claiming ───────────────────────────────────────────
498
572
 
573
+ # Verify we have exclusive claim: exactly one claimed:* label matching our machine
574
+ _verify_claim_exclusive() {
575
+ local issue_num="$1" machine_name="$2"
576
+ local claimed_labels
577
+ claimed_labels=$(gh issue view "$issue_num" --json labels --jq \
578
+ '[.labels[].name | select(startswith("claimed:"))]' 2>/dev/null || echo "[]")
579
+ local count
580
+ count=$(echo "$claimed_labels" | jq 'length' 2>/dev/null || echo "0")
581
+ if [[ "$count" != "1" ]]; then
582
+ return 1 # Competing claims (multiple or none)
583
+ fi
584
+ local sole_claim
585
+ sole_claim=$(echo "$claimed_labels" | jq -r '.[0]' 2>/dev/null || echo "")
586
+ [[ "$sole_claim" == "claimed:${machine_name}" ]]
587
+ }
588
+
499
589
  claim_issue() {
500
590
  local issue_num="$1"
501
591
  local machine_name="$2"
@@ -509,9 +599,15 @@ claim_issue() {
509
599
  -d "$(jq -n --argjson issue "$issue_num" --arg machine "$machine_name" \
510
600
  '{issue: $issue, machine: $machine}')" 2>/dev/null || echo "")
511
601
 
512
- if [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == true' &>/dev/null; then
602
+ if [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == true' >/dev/null 2>&1; then
603
+ # VERIFY: re-read labels, ensure only our claim exists
604
+ if ! _verify_claim_exclusive "$issue_num" "$machine_name"; then
605
+ daemon_log INFO "Issue #${issue_num} claim race lost (competing claim) — removing our label"
606
+ gh issue edit "$issue_num" --remove-label "claimed:${machine_name}" 2>/dev/null || true
607
+ return 1
608
+ fi
513
609
  return 0
514
- elif [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == false' &>/dev/null; then
610
+ elif [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == false' >/dev/null 2>&1; then
515
611
  local claimed_by
516
612
  claimed_by=$(echo "$resp" | jq -r '.claimed_by // "another machine"')
517
613
  daemon_log INFO "Issue #${issue_num} claimed by ${claimed_by} (via dashboard)"
@@ -530,6 +626,12 @@ claim_issue() {
530
626
  fi
531
627
 
532
628
  gh issue edit "$issue_num" --add-label "claimed:${machine_name}" 2>/dev/null || return 1
629
+ # VERIFY: re-read labels, ensure only our claim exists
630
+ if ! _verify_claim_exclusive "$issue_num" "$machine_name"; then
631
+ daemon_log INFO "Issue #${issue_num} claim race lost (competing claim) — removing our label"
632
+ gh issue edit "$issue_num" --remove-label "claimed:${machine_name}" 2>/dev/null || true
633
+ return 1
634
+ fi
533
635
  return 0
534
636
  }
535
637
 
@@ -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=""
@@ -246,7 +250,7 @@ select_pipeline_template() {
246
250
  _dora_events=$(tail -500 "${EVENTS_FILE:-$HOME/.shipwright/events.jsonl}" \
247
251
  | grep '"type":"pipeline.completed"' 2>/dev/null \
248
252
  | tail -5 || true)
249
- _dora_total=$(echo "$_dora_events" | grep -c '.' 2>/dev/null || echo "0")
253
+ _dora_total=$(echo "$_dora_events" | grep -c '.' 2>/dev/null || true)
250
254
  _dora_total="${_dora_total:-0}"
251
255
  if [[ "$_dora_total" -ge 3 ]]; then
252
256
  _dora_failures=$(echo "$_dora_events" | grep -c '"result":"failure"' 2>/dev/null || true)
@@ -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