shipwright-cli 1.9.0 → 2.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 (117) hide show
  1. package/.claude/hooks/post-tool-use.sh +12 -5
  2. package/README.md +114 -36
  3. package/completions/_shipwright +212 -32
  4. package/completions/shipwright.bash +97 -25
  5. package/docs/strategy/01-market-research.md +619 -0
  6. package/docs/strategy/02-mission-and-brand.md +587 -0
  7. package/docs/strategy/03-gtm-and-roadmap.md +759 -0
  8. package/docs/strategy/QUICK-START.txt +289 -0
  9. package/docs/strategy/README.md +172 -0
  10. package/package.json +4 -2
  11. package/scripts/sw +217 -2
  12. package/scripts/sw-activity.sh +500 -0
  13. package/scripts/sw-adaptive.sh +925 -0
  14. package/scripts/sw-adversarial.sh +1 -1
  15. package/scripts/sw-architecture-enforcer.sh +1 -1
  16. package/scripts/sw-auth.sh +613 -0
  17. package/scripts/sw-autonomous.sh +664 -0
  18. package/scripts/sw-changelog.sh +704 -0
  19. package/scripts/sw-checkpoint.sh +79 -1
  20. package/scripts/sw-ci.sh +602 -0
  21. package/scripts/sw-cleanup.sh +192 -7
  22. package/scripts/sw-code-review.sh +637 -0
  23. package/scripts/sw-connect.sh +1 -1
  24. package/scripts/sw-context.sh +605 -0
  25. package/scripts/sw-cost.sh +1 -1
  26. package/scripts/sw-daemon.sh +812 -138
  27. package/scripts/sw-dashboard.sh +1 -1
  28. package/scripts/sw-db.sh +540 -0
  29. package/scripts/sw-decompose.sh +539 -0
  30. package/scripts/sw-deps.sh +551 -0
  31. package/scripts/sw-developer-simulation.sh +1 -1
  32. package/scripts/sw-discovery.sh +412 -0
  33. package/scripts/sw-docs-agent.sh +539 -0
  34. package/scripts/sw-docs.sh +1 -1
  35. package/scripts/sw-doctor.sh +59 -1
  36. package/scripts/sw-dora.sh +615 -0
  37. package/scripts/sw-durable.sh +710 -0
  38. package/scripts/sw-e2e-orchestrator.sh +535 -0
  39. package/scripts/sw-eventbus.sh +393 -0
  40. package/scripts/sw-feedback.sh +471 -0
  41. package/scripts/sw-fix.sh +1 -1
  42. package/scripts/sw-fleet-discover.sh +567 -0
  43. package/scripts/sw-fleet-viz.sh +404 -0
  44. package/scripts/sw-fleet.sh +8 -1
  45. package/scripts/sw-github-app.sh +596 -0
  46. package/scripts/sw-github-checks.sh +1 -1
  47. package/scripts/sw-github-deploy.sh +1 -1
  48. package/scripts/sw-github-graphql.sh +1 -1
  49. package/scripts/sw-guild.sh +569 -0
  50. package/scripts/sw-heartbeat.sh +1 -1
  51. package/scripts/sw-hygiene.sh +559 -0
  52. package/scripts/sw-incident.sh +617 -0
  53. package/scripts/sw-init.sh +88 -1
  54. package/scripts/sw-instrument.sh +699 -0
  55. package/scripts/sw-intelligence.sh +1 -1
  56. package/scripts/sw-jira.sh +1 -1
  57. package/scripts/sw-launchd.sh +366 -31
  58. package/scripts/sw-linear.sh +1 -1
  59. package/scripts/sw-logs.sh +1 -1
  60. package/scripts/sw-loop.sh +507 -51
  61. package/scripts/sw-memory.sh +198 -3
  62. package/scripts/sw-mission-control.sh +487 -0
  63. package/scripts/sw-model-router.sh +545 -0
  64. package/scripts/sw-otel.sh +596 -0
  65. package/scripts/sw-oversight.sh +689 -0
  66. package/scripts/sw-pipeline-composer.sh +8 -8
  67. package/scripts/sw-pipeline-vitals.sh +1096 -0
  68. package/scripts/sw-pipeline.sh +2451 -180
  69. package/scripts/sw-pm.sh +693 -0
  70. package/scripts/sw-pr-lifecycle.sh +522 -0
  71. package/scripts/sw-predictive.sh +1 -1
  72. package/scripts/sw-prep.sh +1 -1
  73. package/scripts/sw-ps.sh +4 -3
  74. package/scripts/sw-public-dashboard.sh +798 -0
  75. package/scripts/sw-quality.sh +595 -0
  76. package/scripts/sw-reaper.sh +5 -3
  77. package/scripts/sw-recruit.sh +573 -0
  78. package/scripts/sw-regression.sh +642 -0
  79. package/scripts/sw-release-manager.sh +736 -0
  80. package/scripts/sw-release.sh +706 -0
  81. package/scripts/sw-remote.sh +1 -1
  82. package/scripts/sw-replay.sh +520 -0
  83. package/scripts/sw-retro.sh +691 -0
  84. package/scripts/sw-scale.sh +444 -0
  85. package/scripts/sw-security-audit.sh +505 -0
  86. package/scripts/sw-self-optimize.sh +109 -8
  87. package/scripts/sw-session.sh +31 -9
  88. package/scripts/sw-setup.sh +1 -1
  89. package/scripts/sw-standup.sh +712 -0
  90. package/scripts/sw-status.sh +192 -1
  91. package/scripts/sw-strategic.sh +658 -0
  92. package/scripts/sw-stream.sh +450 -0
  93. package/scripts/sw-swarm.sh +583 -0
  94. package/scripts/sw-team-stages.sh +511 -0
  95. package/scripts/sw-templates.sh +1 -1
  96. package/scripts/sw-testgen.sh +515 -0
  97. package/scripts/sw-tmux-pipeline.sh +554 -0
  98. package/scripts/sw-tmux.sh +1 -1
  99. package/scripts/sw-trace.sh +485 -0
  100. package/scripts/sw-tracker-github.sh +188 -0
  101. package/scripts/sw-tracker-jira.sh +172 -0
  102. package/scripts/sw-tracker-linear.sh +251 -0
  103. package/scripts/sw-tracker.sh +117 -2
  104. package/scripts/sw-triage.sh +603 -0
  105. package/scripts/sw-upgrade.sh +1 -1
  106. package/scripts/sw-ux.sh +677 -0
  107. package/scripts/sw-webhook.sh +627 -0
  108. package/scripts/sw-widgets.sh +530 -0
  109. package/scripts/sw-worktree.sh +1 -1
  110. package/templates/pipelines/autonomous.json +8 -1
  111. package/templates/pipelines/cost-aware.json +21 -0
  112. package/templates/pipelines/deployed.json +40 -6
  113. package/templates/pipelines/enterprise.json +16 -2
  114. package/templates/pipelines/fast.json +19 -0
  115. package/templates/pipelines/full.json +16 -2
  116. package/templates/pipelines/hotfix.json +19 -0
  117. package/templates/pipelines/standard.json +19 -0
@@ -0,0 +1,1096 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright vitals — Pipeline Vitals Engine ║
4
+ # ║ Real-time health scoring · Adaptive limits · Budget trajectory ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ set -euo pipefail
7
+ trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
8
+
9
+ VERSION="2.0.0"
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
12
+
13
+ # ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
14
+ CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
15
+ PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
16
+ BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
17
+ GREEN='\033[38;2;74;222;128m' # success
18
+ YELLOW='\033[38;2;250;204;21m' # warning
19
+ RED='\033[38;2;248;113;113m' # error
20
+ DIM='\033[2m'
21
+ BOLD='\033[1m'
22
+ RESET='\033[0m'
23
+
24
+ # ─── Cross-platform compatibility ──────────────────────────────────────────
25
+ # shellcheck source=lib/compat.sh
26
+ [[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
27
+
28
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
29
+ info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
30
+ success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
31
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
32
+ error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
33
+
34
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
35
+ now_epoch() { date +%s; }
36
+
37
+ # ─── Structured Event Log ──────────────────────────────────────────────────
38
+ EVENTS_FILE="${HOME}/.shipwright/events.jsonl"
39
+
40
+ emit_event() {
41
+ local event_type="$1"
42
+ shift
43
+ local json_fields=""
44
+ for kv in "$@"; do
45
+ local key="${kv%%=*}"
46
+ local val="${kv#*=}"
47
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
48
+ json_fields="${json_fields},\"${key}\":${val}"
49
+ else
50
+ val="${val//\"/\\\"}"
51
+ json_fields="${json_fields},\"${key}\":\"${val}\""
52
+ fi
53
+ done
54
+ mkdir -p "$(dirname "$EVENTS_FILE")"
55
+ local tmp_event="${EVENTS_FILE}.tmp.$$"
56
+ printf '{"ts":"%s","type":"%s"%s}\n' "$(now_iso)" "$event_type" "$json_fields" >> "$tmp_event" 2>/dev/null \
57
+ && cat "$tmp_event" >> "$EVENTS_FILE" 2>/dev/null
58
+ rm -f "$tmp_event"
59
+ }
60
+
61
+ # ─── Constants ──────────────────────────────────────────────────────────────
62
+ PROGRESS_DIR="${HOME}/.shipwright/progress"
63
+ COST_DIR="${HOME}/.shipwright"
64
+ COST_FILE="${COST_DIR}/costs.json"
65
+ BUDGET_FILE="${COST_DIR}/budget.json"
66
+ OPTIMIZATION_DIR="${HOME}/.shipwright/optimization"
67
+
68
+ # Signal weights for health score (configurable via env vars)
69
+ WEIGHT_MOMENTUM="${VITALS_WEIGHT_MOMENTUM:-35}"
70
+ WEIGHT_CONVERGENCE="${VITALS_WEIGHT_CONVERGENCE:-30}"
71
+ WEIGHT_BUDGET="${VITALS_WEIGHT_BUDGET:-20}"
72
+ WEIGHT_ERROR_MATURITY="${VITALS_WEIGHT_ERROR_MATURITY:-15}"
73
+
74
+ # ─── Helper: safe numeric extraction ────────────────────────────────────────
75
+ _safe_num() {
76
+ local val="${1:-0}"
77
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
78
+ echo "$val"
79
+ else
80
+ echo "0"
81
+ fi
82
+ }
83
+
84
+ # ─── Momentum Score ─────────────────────────────────────────────────────────
85
+ # Compares current snapshot to previous snapshots to detect forward progress
86
+ _compute_momentum() {
87
+ local progress_file="$1"
88
+ local current_stage="${2:-unknown}"
89
+ local current_iteration="${3:-0}"
90
+ local current_diff="${4:-0}"
91
+
92
+ # No history — assume neutral
93
+ if [[ ! -f "$progress_file" ]]; then
94
+ echo "50"
95
+ return
96
+ fi
97
+
98
+ local snapshots_count
99
+ snapshots_count=$(jq '.snapshots | length' "$progress_file" 2>/dev/null || echo "0")
100
+ snapshots_count=$(_safe_num "$snapshots_count")
101
+
102
+ if [[ "$snapshots_count" -lt 2 ]]; then
103
+ # If we have 1 snapshot, check if stage advanced from intake
104
+ if [[ "$snapshots_count" -eq 1 ]]; then
105
+ local last_stage
106
+ last_stage=$(jq -r '.snapshots[-1].stage // ""' "$progress_file" 2>/dev/null || echo "")
107
+ if [[ -n "$last_stage" && "$last_stage" != "intake" && "$last_stage" != "unknown" ]]; then
108
+ echo "60"
109
+ return
110
+ fi
111
+ fi
112
+ echo "50"
113
+ return
114
+ fi
115
+
116
+ # Get last 3 snapshots for comparison
117
+ local score=50
118
+
119
+ local prev_stage prev_iteration prev_diff no_progress_count
120
+ prev_stage=$(jq -r '.snapshots[-1].stage // "unknown"' "$progress_file" 2>/dev/null || echo "unknown")
121
+ prev_iteration=$(jq -r '.snapshots[-1].iteration // 0' "$progress_file" 2>/dev/null || echo "0")
122
+ prev_iteration=$(_safe_num "$prev_iteration")
123
+ prev_diff=$(jq -r '.snapshots[-1].diff_lines // 0' "$progress_file" 2>/dev/null || echo "0")
124
+ prev_diff=$(_safe_num "$prev_diff")
125
+ no_progress_count=$(jq -r '.no_progress_count // 0' "$progress_file" 2>/dev/null || echo "0")
126
+ no_progress_count=$(_safe_num "$no_progress_count")
127
+
128
+ # Stage advancement: +30 points
129
+ if [[ "$current_stage" != "$prev_stage" && "$current_stage" != "unknown" ]]; then
130
+ score=$((score + 30))
131
+ fi
132
+
133
+ # Iteration progress: +10 points per increment
134
+ current_iteration=$(_safe_num "$current_iteration")
135
+ local iter_delta=$((current_iteration - prev_iteration))
136
+ if [[ "$iter_delta" -gt 0 ]]; then
137
+ local iter_bonus=$((iter_delta * 10))
138
+ [[ "$iter_bonus" -gt 30 ]] && iter_bonus=30
139
+ score=$((score + iter_bonus))
140
+ fi
141
+
142
+ # Diff growth: +5 points per 50 new lines
143
+ current_diff=$(_safe_num "$current_diff")
144
+ local diff_delta=$((current_diff - prev_diff))
145
+ if [[ "$diff_delta" -gt 0 ]]; then
146
+ local diff_bonus=$(( (diff_delta / 50) * 5 ))
147
+ [[ "$diff_bonus" -gt 20 ]] && diff_bonus=20
148
+ score=$((score + diff_bonus))
149
+ fi
150
+
151
+ # No change penalty: -20 per stagnant check
152
+ if [[ "$no_progress_count" -gt 0 ]]; then
153
+ local stagnant_penalty=$((no_progress_count * 20))
154
+ score=$((score - stagnant_penalty))
155
+ fi
156
+
157
+ # Clamp to 0-100
158
+ [[ "$score" -lt 0 ]] && score=0
159
+ [[ "$score" -gt 100 ]] && score=100
160
+
161
+ echo "$score"
162
+ }
163
+
164
+ # ─── Convergence Score ──────────────────────────────────────────────────────
165
+ # Tracks error/issue counts trend across cycles
166
+ _compute_convergence() {
167
+ local error_log="$1"
168
+ local progress_file="$2"
169
+
170
+ # No error log — assume perfect convergence
171
+ if [[ ! -f "$error_log" ]]; then
172
+ echo "100"
173
+ return
174
+ fi
175
+
176
+ local total_errors
177
+ total_errors=$(wc -l < "$error_log" 2>/dev/null | tr -d ' ' || echo "0")
178
+ total_errors=$(_safe_num "$total_errors")
179
+
180
+ if [[ "$total_errors" -eq 0 ]]; then
181
+ echo "100"
182
+ return
183
+ fi
184
+
185
+ # Compare error counts across snapshots to detect trend
186
+ if [[ -f "$progress_file" ]]; then
187
+ local snapshots_count
188
+ snapshots_count=$(jq '.snapshots | length' "$progress_file" 2>/dev/null || echo "0")
189
+ snapshots_count=$(_safe_num "$snapshots_count")
190
+
191
+ if [[ "$snapshots_count" -ge 2 ]]; then
192
+ # Count errors with non-empty signatures in snapshots
193
+ local early_errors late_errors
194
+ early_errors=$(jq '[.snapshots[:($snapshots_count/2 | floor)] | .[] | select(.last_error != "")] | length' \
195
+ --argjson snapshots_count "$snapshots_count" "$progress_file" 2>/dev/null || echo "0")
196
+ early_errors=$(_safe_num "$early_errors")
197
+ late_errors=$(jq '[.snapshots[($snapshots_count/2 | floor):] | .[] | select(.last_error != "")] | length' \
198
+ --argjson snapshots_count "$snapshots_count" "$progress_file" 2>/dev/null || echo "0")
199
+ late_errors=$(_safe_num "$late_errors")
200
+
201
+ if [[ "$early_errors" -gt 0 ]]; then
202
+ local reduction_pct=$(( (early_errors - late_errors) * 100 / early_errors ))
203
+ if [[ "$reduction_pct" -gt 50 ]]; then
204
+ echo "100"
205
+ return
206
+ elif [[ "$reduction_pct" -gt 0 ]]; then
207
+ echo "75"
208
+ return
209
+ elif [[ "$reduction_pct" -eq 0 ]]; then
210
+ echo "40"
211
+ return
212
+ fi
213
+ fi
214
+ fi
215
+ fi
216
+
217
+ # Fallback: decreasing = good, based on recent no_progress_count
218
+ if [[ -f "$progress_file" ]]; then
219
+ local no_prog
220
+ no_prog=$(jq -r '.no_progress_count // 0' "$progress_file" 2>/dev/null || echo "0")
221
+ no_prog=$(_safe_num "$no_prog")
222
+ if [[ "$no_prog" -ge 3 ]]; then
223
+ echo "10"
224
+ return
225
+ fi
226
+ fi
227
+
228
+ echo "40"
229
+ }
230
+
231
+ # ─── Budget Score ───────────────────────────────────────────────────────────
232
+ # Calculates budget health based on remaining vs burn rate
233
+ _compute_budget() {
234
+ local cost_file="${COST_FILE}"
235
+ local budget_file="${BUDGET_FILE}"
236
+
237
+ # Check if budget is enabled
238
+ if [[ ! -f "$budget_file" ]]; then
239
+ echo "100"
240
+ return
241
+ fi
242
+
243
+ local budget_enabled budget_usd
244
+ budget_enabled=$(jq -r '.enabled' "$budget_file" 2>/dev/null || echo "false")
245
+ budget_usd=$(jq -r '.daily_budget_usd' "$budget_file" 2>/dev/null || echo "0")
246
+
247
+ if [[ "$budget_enabled" != "true" || "$budget_usd" == "0" ]]; then
248
+ echo "100"
249
+ return
250
+ fi
251
+
252
+ # Calculate today's spending
253
+ local today_start
254
+ today_start=$(date -u +"%Y-%m-%dT00:00:00Z")
255
+ local today_epoch
256
+ today_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$today_start" +%s 2>/dev/null \
257
+ || date -u -d "$today_start" +%s 2>/dev/null || echo "0")
258
+
259
+ local today_spent="0"
260
+ if [[ -f "$cost_file" ]]; then
261
+ today_spent=$(jq --argjson cutoff "$today_epoch" \
262
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0' \
263
+ "$cost_file" 2>/dev/null || echo "0")
264
+ fi
265
+ today_spent=$(_safe_num "$today_spent")
266
+ budget_usd=$(_safe_num "$budget_usd")
267
+
268
+ if [[ "$budget_usd" == "0" ]]; then
269
+ echo "100"
270
+ return
271
+ fi
272
+
273
+ # Score = remaining / budget * 100
274
+ local remaining_pct
275
+ remaining_pct=$(awk -v budget="$budget_usd" -v spent="$today_spent" \
276
+ 'BEGIN { if (budget > 0) printf "%.0f", ((budget - spent) / budget) * 100; else print 100 }')
277
+ remaining_pct=$(_safe_num "$remaining_pct")
278
+
279
+ [[ "$remaining_pct" -lt 0 ]] && remaining_pct=0
280
+ [[ "$remaining_pct" -gt 100 ]] && remaining_pct=100
281
+
282
+ echo "$remaining_pct"
283
+ }
284
+
285
+ # ─── Error Maturity Score ───────────────────────────────────────────────────
286
+ # High unique/total ratio = new problems = lower score
287
+ # Low unique/total ratio = same issues = depends on convergence
288
+ _compute_error_maturity() {
289
+ local error_log="$1"
290
+
291
+ if [[ ! -f "$error_log" ]]; then
292
+ echo "80"
293
+ return
294
+ fi
295
+
296
+ local total_errors
297
+ total_errors=$(wc -l < "$error_log" 2>/dev/null | tr -d ' ' || echo "0")
298
+ total_errors=$(_safe_num "$total_errors")
299
+
300
+ if [[ "$total_errors" -eq 0 ]]; then
301
+ echo "80"
302
+ return
303
+ fi
304
+
305
+ # Count unique error signatures
306
+ local unique_errors
307
+ unique_errors=$(jq -r '.signature // "unknown"' "$error_log" 2>/dev/null | sort -u | wc -l | tr -d ' ' || echo "0")
308
+ unique_errors=$(_safe_num "$unique_errors")
309
+
310
+ if [[ "$unique_errors" -eq 0 ]]; then
311
+ echo "80"
312
+ return
313
+ fi
314
+
315
+ # Ratio: unique / total. Low ratio = same issues repeating
316
+ # High ratio (>0.8) = lots of different errors = unstable (score 20)
317
+ # Medium ratio (0.4-0.8) = some variety (score 50)
318
+ # Low ratio (<0.4) = stuck on same issues (score 60, mature but stuck)
319
+ local ratio_pct=$(( unique_errors * 100 / total_errors ))
320
+
321
+ if [[ "$ratio_pct" -gt 80 ]]; then
322
+ echo "20"
323
+ elif [[ "$ratio_pct" -gt 40 ]]; then
324
+ echo "50"
325
+ else
326
+ echo "60"
327
+ fi
328
+ }
329
+
330
+ # ─── File Locking Helpers ──────────────────────────────────────────────────
331
+ _vitals_acquire_lock() {
332
+ local lockfile="$1.lock"
333
+ local fd=200
334
+ eval "exec $fd>\"$lockfile\""
335
+ flock -w 5 "$fd" || { warn "Vitals lock timeout"; return 1; }
336
+ }
337
+ _vitals_release_lock() {
338
+ local fd=200
339
+ flock -u "$fd" 2>/dev/null || true
340
+ }
341
+
342
+ # ═══════════════════════════════════════════════════════════════════════════
343
+ # pipeline_emit_progress_snapshot
344
+ # Records a point-in-time snapshot for progress tracking
345
+ # Args: <issue_num> <stage> <iteration> <diff_lines> <files_changed> <last_error>
346
+ # Side effect: writes to ~/.shipwright/progress/issue-<N>.json
347
+ # ═══════════════════════════════════════════════════════════════════════════
348
+ pipeline_emit_progress_snapshot() {
349
+ local issue_num="${1:-}"
350
+ local stage="${2:-unknown}"
351
+ local iteration="${3:-0}"
352
+ local diff_lines="${4:-0}"
353
+ local files_changed="${5:-0}"
354
+ local last_error="${6:-}"
355
+
356
+ [[ -z "$issue_num" ]] && return 0
357
+
358
+ mkdir -p "$PROGRESS_DIR"
359
+ local progress_file="${PROGRESS_DIR}/issue-${issue_num}.json"
360
+
361
+ # Acquire lock
362
+ _vitals_acquire_lock "$progress_file" || return 1
363
+
364
+ # Build new snapshot entry
365
+ local snapshot_json
366
+ snapshot_json=$(jq -n \
367
+ --arg stage "$stage" \
368
+ --argjson iteration "$(_safe_num "$iteration")" \
369
+ --argjson diff_lines "$(_safe_num "$diff_lines")" \
370
+ --argjson files_changed "$(_safe_num "$files_changed")" \
371
+ --arg last_error "$last_error" \
372
+ --arg ts "$(now_iso)" \
373
+ '{
374
+ stage: $stage,
375
+ iteration: $iteration,
376
+ diff_lines: $diff_lines,
377
+ files_changed: $files_changed,
378
+ last_error: $last_error,
379
+ ts: $ts
380
+ }')
381
+
382
+ # Initialize file if missing
383
+ if [[ ! -f "$progress_file" ]]; then
384
+ echo '{"snapshots":[],"no_progress_count":0}' > "$progress_file"
385
+ fi
386
+
387
+ # Determine if progress was made (stage or iteration advanced)
388
+ local prev_stage prev_iteration no_progress_count
389
+ prev_stage=$(jq -r '.snapshots[-1].stage // ""' "$progress_file" 2>/dev/null || echo "")
390
+ prev_iteration=$(jq -r '.snapshots[-1].iteration // -1' "$progress_file" 2>/dev/null || echo "-1")
391
+ prev_iteration=$(_safe_num "$prev_iteration")
392
+ no_progress_count=$(jq -r '.no_progress_count // 0' "$progress_file" 2>/dev/null || echo "0")
393
+ no_progress_count=$(_safe_num "$no_progress_count")
394
+
395
+ local cur_iter_num
396
+ cur_iter_num=$(_safe_num "$iteration")
397
+
398
+ if [[ "$stage" != "$prev_stage" || "$cur_iter_num" -gt "$prev_iteration" ]]; then
399
+ no_progress_count=0
400
+ else
401
+ no_progress_count=$((no_progress_count + 1))
402
+ fi
403
+
404
+ # Append snapshot, cap at 20 entries, update no_progress_count
405
+ local tmp_pf="${progress_file}.tmp.$$"
406
+ jq --argjson snap "$snapshot_json" \
407
+ --argjson npc "$no_progress_count" \
408
+ '.snapshots += [$snap] | .snapshots = .snapshots[-20:] | .no_progress_count = $npc' \
409
+ "$progress_file" > "$tmp_pf" 2>/dev/null && mv "$tmp_pf" "$progress_file" || {
410
+ rm -f "$tmp_pf"
411
+ _vitals_release_lock
412
+ return 1
413
+ }
414
+
415
+ _vitals_release_lock
416
+
417
+ emit_event "vitals.snapshot" \
418
+ "issue=$issue_num" \
419
+ "stage=$stage" \
420
+ "iteration=$iteration" \
421
+ "diff_lines=$diff_lines" \
422
+ "no_progress=$no_progress_count"
423
+ }
424
+
425
+ # ═══════════════════════════════════════════════════════════════════════════
426
+ # pipeline_compute_vitals
427
+ # Main entry: computes composite health score from 4 weighted signals
428
+ # Args: [pipeline_state_file] [artifacts_dir] [issue_number]
429
+ # Output: JSON to stdout
430
+ # ═══════════════════════════════════════════════════════════════════════════
431
+ pipeline_compute_vitals() {
432
+ local state_file="${1:-${PIPELINE_STATE:-${REPO_DIR}/.claude/pipeline-state.md}}"
433
+ local artifacts_dir="${2:-${REPO_DIR}/.claude/pipeline-artifacts}"
434
+ local issue_num="${3:-}"
435
+
436
+ # ── Read current pipeline state ──
437
+ local current_stage="unknown" current_iteration=0 elapsed="0s"
438
+ if [[ -f "$state_file" ]]; then
439
+ current_stage=$(grep -m1 '^current_stage:' "$state_file" 2>/dev/null | sed 's/^current_stage: *//' || echo "unknown")
440
+ [[ -z "$current_stage" ]] && current_stage="unknown"
441
+
442
+ local stage_progress
443
+ stage_progress=$(grep -m1 '^stage_progress:' "$state_file" 2>/dev/null | sed 's/^stage_progress: *//' || echo "")
444
+ if [[ "$stage_progress" =~ iteration\ ([0-9]+) ]]; then
445
+ current_iteration="${BASH_REMATCH[1]}"
446
+ fi
447
+
448
+ elapsed=$(grep -m1 '^elapsed:' "$state_file" 2>/dev/null | sed 's/^elapsed: *//' || echo "0s")
449
+ fi
450
+
451
+ # ── Detect issue number if not provided ──
452
+ if [[ -z "$issue_num" && -f "$state_file" ]]; then
453
+ issue_num=$(grep -m1 '^issue:' "$state_file" 2>/dev/null | sed 's/^issue: *//' | tr -d '"' || echo "")
454
+ fi
455
+
456
+ # ── Determine progress file ──
457
+ local progress_file=""
458
+ if [[ -n "$issue_num" ]]; then
459
+ progress_file="${PROGRESS_DIR}/issue-${issue_num}.json"
460
+ fi
461
+
462
+ # ── Error log ──
463
+ local error_log="${artifacts_dir}/error-log.jsonl"
464
+
465
+ # ── Get diff stats from git ──
466
+ local current_diff=0
467
+ current_diff=$(cd "$REPO_DIR" && git diff --stat 2>/dev/null | tail -1 | grep -o '[0-9]* insertion' | grep -o '[0-9]*' || echo "0")
468
+ [[ -z "$current_diff" ]] && current_diff=0
469
+
470
+ # ── Compute individual signals ──
471
+ local momentum convergence budget_score error_maturity
472
+ momentum=$(_compute_momentum "${progress_file}" "$current_stage" "$current_iteration" "$current_diff")
473
+ convergence=$(_compute_convergence "$error_log" "$progress_file")
474
+ budget_score=$(_compute_budget)
475
+ error_maturity=$(_compute_error_maturity "$error_log")
476
+
477
+ # ── Weighted composite score ──
478
+ local health_score=$(( (momentum * WEIGHT_MOMENTUM + convergence * WEIGHT_CONVERGENCE + budget_score * WEIGHT_BUDGET + error_maturity * WEIGHT_ERROR_MATURITY) / 100 ))
479
+ [[ "$health_score" -lt 0 ]] && health_score=0
480
+ [[ "$health_score" -gt 100 ]] && health_score=100
481
+
482
+ # ── Previous score for trajectory ──
483
+ local prev_score=""
484
+ if [[ -n "$progress_file" && -f "$progress_file" ]]; then
485
+ prev_score=$(jq -r '.last_health_score // ""' "$progress_file" 2>/dev/null || echo "")
486
+ fi
487
+
488
+ # ── Verdict ──
489
+ local verdict
490
+ verdict=$(pipeline_health_verdict "$health_score" "$prev_score")
491
+
492
+ # ── Recommended action ──
493
+ local recommended_action="continue"
494
+ case "$verdict" in
495
+ continue) recommended_action="continue" ;;
496
+ warn) recommended_action="extend patience, monitor closely" ;;
497
+ intervene) recommended_action="prepare intervention, consider reducing scope" ;;
498
+ abort) recommended_action="abort pipeline, escalate to human" ;;
499
+ esac
500
+
501
+ # ── Store health score in progress file for trajectory tracking ──
502
+ if [[ -n "$progress_file" && -f "$progress_file" ]]; then
503
+ if _vitals_acquire_lock "$progress_file" 2>/dev/null; then
504
+ local tmp_pf="${progress_file}.tmp.$$"
505
+ jq --argjson score "$health_score" '.last_health_score = $score' \
506
+ "$progress_file" > "$tmp_pf" 2>/dev/null && mv "$tmp_pf" "$progress_file" || rm -f "$tmp_pf"
507
+ _vitals_release_lock
508
+ fi
509
+ fi
510
+
511
+ # ── Budget details ──
512
+ local remaining_budget="unlimited" today_spent="0.00"
513
+ if [[ -f "$BUDGET_FILE" ]]; then
514
+ local be
515
+ be=$(jq -r '.enabled' "$BUDGET_FILE" 2>/dev/null || echo "false")
516
+ if [[ "$be" == "true" ]]; then
517
+ local bu
518
+ bu=$(jq -r '.daily_budget_usd' "$BUDGET_FILE" 2>/dev/null || echo "0")
519
+ local today_start
520
+ today_start=$(date -u +"%Y-%m-%dT00:00:00Z")
521
+ local today_epoch
522
+ today_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$today_start" +%s 2>/dev/null \
523
+ || date -u -d "$today_start" +%s 2>/dev/null || echo "0")
524
+ if [[ -f "$COST_FILE" ]]; then
525
+ today_spent=$(jq --argjson cutoff "$today_epoch" \
526
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0' \
527
+ "$COST_FILE" 2>/dev/null || echo "0")
528
+ fi
529
+ remaining_budget=$(awk -v b="$bu" -v s="$today_spent" 'BEGIN { printf "%.2f", b - s }')
530
+ fi
531
+ fi
532
+
533
+ # ── Error counts ──
534
+ local total_errors=0 unique_errors=0
535
+ if [[ -f "$error_log" ]]; then
536
+ total_errors=$(wc -l < "$error_log" 2>/dev/null | tr -d ' ' || echo "0")
537
+ unique_errors=$(jq -r '.signature // "unknown"' "$error_log" 2>/dev/null | sort -u | wc -l | tr -d ' ' || echo "0")
538
+ fi
539
+
540
+ # ── Output JSON ──
541
+ jq -n \
542
+ --argjson health_score "$health_score" \
543
+ --arg verdict "$verdict" \
544
+ --arg recommended_action "$recommended_action" \
545
+ --argjson momentum "$momentum" \
546
+ --argjson convergence "$convergence" \
547
+ --argjson budget_score "$budget_score" \
548
+ --argjson error_maturity "$error_maturity" \
549
+ --arg current_stage "$current_stage" \
550
+ --argjson current_iteration "$current_iteration" \
551
+ --arg elapsed "$elapsed" \
552
+ --arg prev_score "${prev_score:-}" \
553
+ --arg remaining_budget "$remaining_budget" \
554
+ --arg today_spent "$today_spent" \
555
+ --argjson total_errors "$total_errors" \
556
+ --argjson unique_errors "$unique_errors" \
557
+ --arg issue "${issue_num:-}" \
558
+ --arg ts "$(now_iso)" \
559
+ '{
560
+ health_score: $health_score,
561
+ verdict: $verdict,
562
+ recommended_action: $recommended_action,
563
+ signals: {
564
+ momentum: $momentum,
565
+ convergence: $convergence,
566
+ budget: $budget_score,
567
+ error_maturity: $error_maturity
568
+ },
569
+ pipeline: {
570
+ stage: $current_stage,
571
+ iteration: $current_iteration,
572
+ elapsed: $elapsed,
573
+ issue: $issue
574
+ },
575
+ budget: {
576
+ remaining: $remaining_budget,
577
+ today_spent: $today_spent
578
+ },
579
+ errors: {
580
+ total: $total_errors,
581
+ unique: $unique_errors
582
+ },
583
+ prev_score: (if $prev_score == "" then null else ($prev_score | tonumber) end),
584
+ ts: $ts
585
+ }'
586
+ }
587
+
588
+ # ═══════════════════════════════════════════════════════════════════════════
589
+ # pipeline_health_verdict
590
+ # Maps health score to action, considering trajectory
591
+ # Args: current_score [previous_score]
592
+ # Output: continue | warn | intervene | abort
593
+ # ═══════════════════════════════════════════════════════════════════════════
594
+ pipeline_health_verdict() {
595
+ local current_score="${1:-50}"
596
+ local prev_score="${2:-}"
597
+
598
+ current_score=$(_safe_num "$current_score")
599
+
600
+ # Determine trajectory
601
+ local trajectory="stable"
602
+ if [[ -n "$prev_score" && "$prev_score" != "" ]]; then
603
+ prev_score=$(_safe_num "$prev_score")
604
+ if [[ "$current_score" -gt "$prev_score" ]]; then
605
+ trajectory="improving"
606
+ elif [[ "$current_score" -lt "$prev_score" ]]; then
607
+ trajectory="declining"
608
+ fi
609
+ fi
610
+
611
+ # Score-based verdict with trajectory adjustment
612
+ if [[ "$current_score" -ge 70 ]]; then
613
+ echo "continue"
614
+ elif [[ "$current_score" -ge 50 ]]; then
615
+ # Sluggish zone: extend patience if improving
616
+ if [[ "$trajectory" == "improving" ]]; then
617
+ echo "continue"
618
+ else
619
+ echo "warn"
620
+ fi
621
+ elif [[ "$current_score" -ge 30 ]]; then
622
+ # Stalling zone: escalate faster if declining
623
+ if [[ "$trajectory" == "declining" ]]; then
624
+ echo "intervene"
625
+ elif [[ "$trajectory" == "improving" ]]; then
626
+ echo "warn"
627
+ else
628
+ echo "intervene"
629
+ fi
630
+ else
631
+ # Critical zone
632
+ echo "abort"
633
+ fi
634
+ }
635
+
636
+ # ═══════════════════════════════════════════════════════════════════════════
637
+ # pipeline_adaptive_limit
638
+ # Determines cycle limit dynamically based on vitals + learned model
639
+ # Args: loop_type (build_test|compound_quality) [vitals_json]
640
+ # Output: integer cycle limit
641
+ # ═══════════════════════════════════════════════════════════════════════════
642
+ pipeline_adaptive_limit() {
643
+ local loop_type="${1:-build_test}"
644
+ local vitals_json="${2:-}"
645
+
646
+ # Start with learned iteration model
647
+ local model_file="${OPTIMIZATION_DIR}/iteration-model.json"
648
+ local base_limit=5
649
+
650
+ if [[ -f "$model_file" ]]; then
651
+ local learned
652
+ learned=$(jq -r --arg ctx "$loop_type" '.[$ctx].recommended_cycles // 0' "$model_file" 2>/dev/null || echo "0")
653
+ learned=$(_safe_num "$learned")
654
+ if [[ "$learned" -gt 0 ]]; then
655
+ base_limit="$learned"
656
+ fi
657
+ fi
658
+
659
+ # Get template max (hard ceiling = 2x template max)
660
+ local hard_ceiling=$((base_limit * 2))
661
+ [[ "$hard_ceiling" -lt 4 ]] && hard_ceiling=4
662
+
663
+ # If no vitals provided, return base
664
+ if [[ -z "$vitals_json" ]]; then
665
+ echo "$base_limit"
666
+ return
667
+ fi
668
+
669
+ # Extract vitals signals
670
+ local health convergence budget_s
671
+ health=$(echo "$vitals_json" | jq -r '.health_score // 50' 2>/dev/null || echo "50")
672
+ health=$(_safe_num "$health")
673
+ convergence=$(echo "$vitals_json" | jq -r '.signals.convergence // 50' 2>/dev/null || echo "50")
674
+ convergence=$(_safe_num "$convergence")
675
+ budget_s=$(echo "$vitals_json" | jq -r '.signals.budget // 100' 2>/dev/null || echo "100")
676
+ budget_s=$(_safe_num "$budget_s")
677
+
678
+ local limit="$base_limit"
679
+
680
+ # Health > 70 + convergence > 60: allow +1 beyond model
681
+ if [[ "$health" -gt 70 && "$convergence" -gt 60 ]]; then
682
+ limit=$((base_limit + 1))
683
+ fi
684
+
685
+ # Health < 40: cap at current cycle (don't extend)
686
+ if [[ "$health" -lt 40 ]]; then
687
+ # Keep base_limit, don't extend
688
+ limit="$base_limit"
689
+ fi
690
+
691
+ # Budget score < 30: hard stop at minimum
692
+ if [[ "$budget_s" -lt 30 ]]; then
693
+ limit=1
694
+ fi
695
+
696
+ # Never exceed hard ceiling
697
+ [[ "$limit" -gt "$hard_ceiling" ]] && limit="$hard_ceiling"
698
+ [[ "$limit" -lt 1 ]] && limit=1
699
+
700
+ echo "$limit"
701
+ }
702
+
703
+ # ═══════════════════════════════════════════════════════════════════════════
704
+ # pipeline_budget_trajectory
705
+ # Predicts if pipeline can afford to finish
706
+ # Output: ok | warn | stop
707
+ # ═══════════════════════════════════════════════════════════════════════════
708
+ pipeline_budget_trajectory() {
709
+ local state_file="${1:-${PIPELINE_STATE:-${REPO_DIR}/.claude/pipeline-state.md}}"
710
+
711
+ # Check if budget is enabled
712
+ if [[ ! -f "$BUDGET_FILE" ]]; then
713
+ echo "ok"
714
+ return
715
+ fi
716
+
717
+ local budget_enabled
718
+ budget_enabled=$(jq -r '.enabled' "$BUDGET_FILE" 2>/dev/null || echo "false")
719
+ if [[ "$budget_enabled" != "true" ]]; then
720
+ echo "ok"
721
+ return
722
+ fi
723
+
724
+ # Get remaining budget
725
+ local budget_usd today_spent remaining_budget
726
+ budget_usd=$(jq -r '.daily_budget_usd' "$BUDGET_FILE" 2>/dev/null || echo "0")
727
+ budget_usd=$(_safe_num "$budget_usd")
728
+
729
+ if [[ "$budget_usd" == "0" ]]; then
730
+ echo "ok"
731
+ return
732
+ fi
733
+
734
+ local today_start
735
+ today_start=$(date -u +"%Y-%m-%dT00:00:00Z")
736
+ local today_epoch
737
+ today_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$today_start" +%s 2>/dev/null \
738
+ || date -u -d "$today_start" +%s 2>/dev/null || echo "0")
739
+
740
+ today_spent="0"
741
+ if [[ -f "$COST_FILE" ]]; then
742
+ today_spent=$(jq --argjson cutoff "$today_epoch" \
743
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0' \
744
+ "$COST_FILE" 2>/dev/null || echo "0")
745
+ fi
746
+ today_spent=$(_safe_num "$today_spent")
747
+
748
+ remaining_budget=$(awk -v b="$budget_usd" -v s="$today_spent" 'BEGIN { printf "%.2f", b - s }')
749
+
750
+ # Calculate average cost per stage from events
751
+ local avg_cost_per_stage="0"
752
+ if [[ -f "$EVENTS_FILE" ]]; then
753
+ avg_cost_per_stage=$(grep '"type":"cost.record"' "$EVENTS_FILE" 2>/dev/null \
754
+ | jq -r '.cost_usd // 0' 2>/dev/null \
755
+ | awk '{ sum += $1; count++ } END { if (count > 0) printf "%.2f", sum/count; else print "0.50" }' \
756
+ || echo "0.50")
757
+ fi
758
+ avg_cost_per_stage=$(_safe_num "$avg_cost_per_stage")
759
+ # Default to 0.50 if no data
760
+ if awk -v c="$avg_cost_per_stage" 'BEGIN { exit !(c <= 0) }'; then
761
+ avg_cost_per_stage="0.50"
762
+ fi
763
+
764
+ # Count remaining stages
765
+ local remaining_stages=6
766
+ if [[ -f "$state_file" ]]; then
767
+ local current_stage
768
+ current_stage=$(grep -m1 '^current_stage:' "$state_file" 2>/dev/null | sed 's/^current_stage: *//' || echo "")
769
+ case "$current_stage" in
770
+ intake) remaining_stages=11 ;;
771
+ plan) remaining_stages=10 ;;
772
+ design) remaining_stages=9 ;;
773
+ build) remaining_stages=8 ;;
774
+ test) remaining_stages=7 ;;
775
+ review) remaining_stages=6 ;;
776
+ compound_quality) remaining_stages=5 ;;
777
+ pr) remaining_stages=4 ;;
778
+ merge) remaining_stages=3 ;;
779
+ deploy) remaining_stages=2 ;;
780
+ validate) remaining_stages=1 ;;
781
+ monitor) remaining_stages=0 ;;
782
+ esac
783
+ fi
784
+
785
+ # Predict: can we afford to finish?
786
+ local needed
787
+ needed=$(awk -v avg="$avg_cost_per_stage" -v stages="$remaining_stages" -v factor="1.5" \
788
+ 'BEGIN { printf "%.2f", avg * stages * factor }')
789
+
790
+ local can_afford
791
+ can_afford=$(awk -v rem="$remaining_budget" -v need="$needed" 'BEGIN { print (rem >= need) ? "yes" : "no" }')
792
+
793
+ local min_threshold
794
+ min_threshold=$(awk -v avg="$avg_cost_per_stage" 'BEGIN { printf "%.2f", avg * 2 }')
795
+
796
+ local above_min
797
+ above_min=$(awk -v rem="$remaining_budget" -v min="$min_threshold" 'BEGIN { print (rem >= min) ? "yes" : "no" }')
798
+
799
+ if [[ "$above_min" == "no" ]]; then
800
+ echo "stop"
801
+ elif [[ "$can_afford" == "no" ]]; then
802
+ echo "warn"
803
+ else
804
+ echo "ok"
805
+ fi
806
+ }
807
+
808
+ # ═══════════════════════════════════════════════════════════════════════════
809
+ # vitals_dashboard
810
+ # CLI output for `shipwright vitals`
811
+ # ═══════════════════════════════════════════════════════════════════════════
812
+ vitals_dashboard() {
813
+ local state_file="${1:-${PIPELINE_STATE:-${REPO_DIR}/.claude/pipeline-state.md}}"
814
+ local artifacts_dir="${2:-${REPO_DIR}/.claude/pipeline-artifacts}"
815
+ local issue_num="${3:-}"
816
+
817
+ # Compute vitals
818
+ local vitals
819
+ vitals=$(pipeline_compute_vitals "$state_file" "$artifacts_dir" "$issue_num")
820
+
821
+ # Extract fields
822
+ local health_score verdict recommended_action
823
+ health_score=$(echo "$vitals" | jq -r '.health_score')
824
+ verdict=$(echo "$vitals" | jq -r '.verdict')
825
+ recommended_action=$(echo "$vitals" | jq -r '.recommended_action')
826
+
827
+ local momentum convergence budget_s error_maturity
828
+ momentum=$(echo "$vitals" | jq -r '.signals.momentum')
829
+ convergence=$(echo "$vitals" | jq -r '.signals.convergence')
830
+ budget_s=$(echo "$vitals" | jq -r '.signals.budget')
831
+ error_maturity=$(echo "$vitals" | jq -r '.signals.error_maturity')
832
+
833
+ local stage iteration elapsed issue_display
834
+ stage=$(echo "$vitals" | jq -r '.pipeline.stage')
835
+ iteration=$(echo "$vitals" | jq -r '.pipeline.iteration')
836
+ elapsed=$(echo "$vitals" | jq -r '.pipeline.elapsed')
837
+ issue_display=$(echo "$vitals" | jq -r '.pipeline.issue')
838
+
839
+ local remaining_budget today_spent
840
+ remaining_budget=$(echo "$vitals" | jq -r '.budget.remaining')
841
+ today_spent=$(echo "$vitals" | jq -r '.budget.today_spent')
842
+
843
+ local total_errors unique_errors
844
+ total_errors=$(echo "$vitals" | jq -r '.errors.total')
845
+ unique_errors=$(echo "$vitals" | jq -r '.errors.unique')
846
+
847
+ local prev_score
848
+ prev_score=$(echo "$vitals" | jq -r '.prev_score // "none"')
849
+
850
+ # ── Color for health score ──
851
+ local score_color="$GREEN"
852
+ if [[ "$health_score" -lt 30 ]]; then
853
+ score_color="$RED"
854
+ elif [[ "$health_score" -lt 50 ]]; then
855
+ score_color="$YELLOW"
856
+ elif [[ "$health_score" -lt 70 ]]; then
857
+ score_color="$BLUE"
858
+ fi
859
+
860
+ # ── Verdict label ──
861
+ local verdict_label
862
+ case "$verdict" in
863
+ continue) verdict_label="${GREEN}healthy${RESET}" ;;
864
+ warn) verdict_label="${YELLOW}sluggish${RESET}" ;;
865
+ intervene) verdict_label="${RED}stalling${RESET}" ;;
866
+ abort) verdict_label="${RED}${BOLD}critical${RESET}" ;;
867
+ *) verdict_label="${DIM}unknown${RESET}" ;;
868
+ esac
869
+
870
+ # ── Header ──
871
+ echo ""
872
+ local title="Pipeline Vitals"
873
+ if [[ -n "$issue_display" && "$issue_display" != "" ]]; then
874
+ title="Pipeline Vitals — issue #${issue_display}"
875
+ fi
876
+ echo -e "${CYAN}${BOLD} ${title}${RESET}"
877
+ echo -e "${DIM} ══════════════════════════════════════════${RESET}"
878
+ echo ""
879
+
880
+ # ── Health Score ──
881
+ printf " ${BOLD}Health Score:${RESET} ${score_color}${BOLD}%d${RESET}/100 (%b)\n" "$health_score" "$verdict_label"
882
+
883
+ # ── Signal details ──
884
+ local m_desc c_desc b_desc e_desc
885
+
886
+ # Momentum description
887
+ if [[ "$momentum" -ge 70 ]]; then
888
+ m_desc="advancing"
889
+ elif [[ "$momentum" -ge 40 ]]; then
890
+ m_desc="steady"
891
+ else
892
+ m_desc="stagnant"
893
+ fi
894
+ if [[ "$stage" != "unknown" ]]; then
895
+ m_desc="${m_desc} (${stage})"
896
+ fi
897
+
898
+ # Convergence description
899
+ if [[ "$convergence" -ge 70 ]]; then
900
+ c_desc="issues decreasing"
901
+ elif [[ "$convergence" -ge 40 ]]; then
902
+ c_desc="flat"
903
+ else
904
+ c_desc="issues increasing"
905
+ fi
906
+
907
+ # Budget description
908
+ if [[ "$remaining_budget" == "unlimited" ]]; then
909
+ b_desc="no budget set"
910
+ else
911
+ b_desc="\$${remaining_budget} remaining (\$${today_spent} burned)"
912
+ fi
913
+
914
+ # Error maturity description
915
+ e_desc="${unique_errors} unique / ${total_errors} total"
916
+
917
+ printf " ${DIM}Momentum:${RESET} %3d ${DIM}%s${RESET}\n" "$momentum" "$m_desc"
918
+ printf " ${DIM}Convergence:${RESET} %3d ${DIM}%s${RESET}\n" "$convergence" "$c_desc"
919
+ printf " ${DIM}Budget:${RESET} %3d ${DIM}%s${RESET}\n" "$budget_s" "$b_desc"
920
+ printf " ${DIM}Error Maturity:${RESET}%3d ${DIM}%s${RESET}\n" "$error_maturity" "$e_desc"
921
+ echo ""
922
+
923
+ # ── Trajectory ──
924
+ if [[ "$prev_score" != "none" && "$prev_score" != "null" ]]; then
925
+ local trajectory_label trajectory_color
926
+ prev_score=$(_safe_num "$prev_score")
927
+ if [[ "$health_score" -gt "$prev_score" ]]; then
928
+ trajectory_label="improving"
929
+ trajectory_color="$GREEN"
930
+ elif [[ "$health_score" -lt "$prev_score" ]]; then
931
+ trajectory_label="declining"
932
+ trajectory_color="$RED"
933
+ else
934
+ trajectory_label="stable"
935
+ trajectory_color="$DIM"
936
+ fi
937
+ printf " ${BOLD}Trajectory:${RESET} ${trajectory_color}%s${RESET} ${DIM}(was %d → %d)${RESET}\n" \
938
+ "$trajectory_label" "$prev_score" "$health_score"
939
+ fi
940
+
941
+ printf " ${BOLD}Recommendation:${RESET} %s\n" "$recommended_action"
942
+ echo ""
943
+
944
+ # ── Active pipeline info ──
945
+ if [[ "$stage" != "unknown" ]]; then
946
+ printf " ${DIM}Active: stage=%s iter=%d elapsed=%s${RESET}\n" "$stage" "$iteration" "$elapsed"
947
+ echo ""
948
+ fi
949
+
950
+ # ── Budget trajectory ──
951
+ local bt
952
+ bt=$(pipeline_budget_trajectory "$state_file")
953
+ if [[ "$bt" == "warn" ]]; then
954
+ echo -e " ${YELLOW}${BOLD}⚠${RESET} ${YELLOW}Budget trajectory: may not have enough to finish${RESET}"
955
+ echo ""
956
+ elif [[ "$bt" == "stop" ]]; then
957
+ echo -e " ${RED}${BOLD}✗${RESET} ${RED}Budget trajectory: insufficient funds to continue${RESET}"
958
+ echo ""
959
+ fi
960
+ }
961
+
962
+ # ═══════════════════════════════════════════════════════════════════════════
963
+ # pipeline_check_health_gate
964
+ # Returns 0 if health is above threshold, 1 if below
965
+ # Args: [state_file] [artifacts_dir] [issue_number]
966
+ # ═══════════════════════════════════════════════════════════════════════════
967
+ pipeline_check_health_gate() {
968
+ local state_file="${1:-}"
969
+ local artifacts_dir="${2:-}"
970
+ local issue="${3:-}"
971
+ local threshold="${VITALS_GATE_THRESHOLD:-40}"
972
+
973
+ local vitals_json
974
+ vitals_json=$(pipeline_compute_vitals "$state_file" "$artifacts_dir" "$issue" 2>/dev/null) || return 0
975
+
976
+ local health
977
+ health=$(echo "$vitals_json" | jq -r '.health_score // 50' 2>/dev/null) || health=50
978
+ health=$(_safe_num "$health")
979
+
980
+ if [[ "$health" -lt "$threshold" ]]; then
981
+ warn "Health gate: score ${health} < threshold ${threshold}"
982
+ return 1
983
+ fi
984
+ return 0
985
+ }
986
+
987
+ # ─── Help ───────────────────────────────────────────────────────────────────
988
+ show_help() {
989
+ echo ""
990
+ echo -e "${CYAN}${BOLD} Shipwright Pipeline Vitals${RESET} ${DIM}v${VERSION}${RESET}"
991
+ echo -e "${DIM} ══════════════════════════════════════════${RESET}"
992
+ echo ""
993
+ echo -e " ${BOLD}USAGE${RESET}"
994
+ echo -e " shipwright vitals [options]"
995
+ echo ""
996
+ echo -e " ${BOLD}OPTIONS${RESET}"
997
+ echo -e " ${CYAN}--issue${RESET} <N> Issue number to check"
998
+ echo -e " ${CYAN}--state${RESET} <path> Pipeline state file (default: .claude/pipeline-state.md)"
999
+ echo -e " ${CYAN}--artifacts${RESET} <path> Artifacts directory (default: .claude/pipeline-artifacts)"
1000
+ echo -e " ${CYAN}--json${RESET} Output raw JSON instead of dashboard"
1001
+ echo -e " ${CYAN}--score${RESET} Output only the health score (0-100)"
1002
+ echo -e " ${CYAN}--verdict${RESET} Output only the verdict"
1003
+ echo -e " ${CYAN}--budget${RESET} Output only budget trajectory (ok/warn/stop)"
1004
+ echo -e " ${CYAN}--help${RESET} Show this help"
1005
+ echo ""
1006
+ echo -e " ${BOLD}SIGNALS${RESET} ${DIM}(weighted composite)${RESET}"
1007
+ echo -e " ${DIM}Momentum (35%) Stage advancement, iteration progress, diff growth${RESET}"
1008
+ echo -e " ${DIM}Convergence (30%) Error count trend across cycles${RESET}"
1009
+ echo -e " ${DIM}Budget (20%) Remaining budget vs burn rate${RESET}"
1010
+ echo -e " ${DIM}Error Maturity(15%) Unique errors vs total errors${RESET}"
1011
+ echo ""
1012
+ echo -e " ${BOLD}EXAMPLES${RESET}"
1013
+ echo -e " ${DIM}shipwright vitals${RESET} # Dashboard view"
1014
+ echo -e " ${DIM}shipwright vitals --issue 42${RESET} # Check specific issue"
1015
+ echo -e " ${DIM}shipwright vitals --json${RESET} # Machine-readable output"
1016
+ echo -e " ${DIM}shipwright vitals --score${RESET} # Just the number"
1017
+ echo ""
1018
+ }
1019
+
1020
+ # ─── CLI Entry Point ────────────────────────────────────────────────────────
1021
+ main() {
1022
+ local issue_num="" state_file="" artifacts_dir="" output_mode="dashboard"
1023
+
1024
+ while [[ $# -gt 0 ]]; do
1025
+ case "$1" in
1026
+ --issue|-i)
1027
+ issue_num="$2"
1028
+ shift 2
1029
+ ;;
1030
+ --state)
1031
+ state_file="$2"
1032
+ shift 2
1033
+ ;;
1034
+ --artifacts)
1035
+ artifacts_dir="$2"
1036
+ shift 2
1037
+ ;;
1038
+ --json)
1039
+ output_mode="json"
1040
+ shift
1041
+ ;;
1042
+ --score)
1043
+ output_mode="score"
1044
+ shift
1045
+ ;;
1046
+ --verdict)
1047
+ output_mode="verdict"
1048
+ shift
1049
+ ;;
1050
+ --budget)
1051
+ output_mode="budget"
1052
+ shift
1053
+ ;;
1054
+ --help|-h|help)
1055
+ show_help
1056
+ return 0
1057
+ ;;
1058
+ *)
1059
+ error "Unknown option: $1"
1060
+ show_help
1061
+ return 1
1062
+ ;;
1063
+ esac
1064
+ done
1065
+
1066
+ # Defaults
1067
+ state_file="${state_file:-${PIPELINE_STATE:-${REPO_DIR}/.claude/pipeline-state.md}}"
1068
+ artifacts_dir="${artifacts_dir:-${REPO_DIR}/.claude/pipeline-artifacts}"
1069
+
1070
+ case "$output_mode" in
1071
+ dashboard)
1072
+ vitals_dashboard "$state_file" "$artifacts_dir" "$issue_num"
1073
+ ;;
1074
+ json)
1075
+ pipeline_compute_vitals "$state_file" "$artifacts_dir" "$issue_num"
1076
+ ;;
1077
+ score)
1078
+ local vitals
1079
+ vitals=$(pipeline_compute_vitals "$state_file" "$artifacts_dir" "$issue_num")
1080
+ echo "$vitals" | jq -r '.health_score'
1081
+ ;;
1082
+ verdict)
1083
+ local vitals
1084
+ vitals=$(pipeline_compute_vitals "$state_file" "$artifacts_dir" "$issue_num")
1085
+ echo "$vitals" | jq -r '.verdict'
1086
+ ;;
1087
+ budget)
1088
+ pipeline_budget_trajectory "$state_file"
1089
+ ;;
1090
+ esac
1091
+ }
1092
+
1093
+ # Only run main when executed directly, not when sourced
1094
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
1095
+ main "$@"
1096
+ fi