shipwright-cli 3.0.0 → 3.2.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 (143) hide show
  1. package/README.md +21 -7
  2. package/completions/_shipwright +247 -93
  3. package/completions/shipwright.bash +69 -15
  4. package/completions/shipwright.fish +309 -41
  5. package/config/decision-tiers.json +55 -0
  6. package/config/defaults.json +25 -2
  7. package/config/event-schema.json +142 -5
  8. package/config/policy.json +8 -0
  9. package/dashboard/public/index.html +6 -0
  10. package/dashboard/public/styles.css +76 -0
  11. package/dashboard/server.ts +51 -0
  12. package/dashboard/src/core/api.ts +5 -0
  13. package/dashboard/src/types/api.ts +10 -0
  14. package/dashboard/src/views/metrics.ts +69 -1
  15. package/package.json +3 -3
  16. package/scripts/lib/architecture.sh +2 -1
  17. package/scripts/lib/bootstrap.sh +0 -0
  18. package/scripts/lib/config.sh +0 -0
  19. package/scripts/lib/daemon-adaptive.sh +4 -2
  20. package/scripts/lib/daemon-dispatch.sh +24 -1
  21. package/scripts/lib/daemon-failure.sh +0 -0
  22. package/scripts/lib/daemon-health.sh +0 -0
  23. package/scripts/lib/daemon-patrol.sh +42 -7
  24. package/scripts/lib/daemon-poll.sh +17 -0
  25. package/scripts/lib/daemon-state.sh +17 -0
  26. package/scripts/lib/daemon-triage.sh +1 -1
  27. package/scripts/lib/decide-autonomy.sh +295 -0
  28. package/scripts/lib/decide-scoring.sh +228 -0
  29. package/scripts/lib/decide-signals.sh +462 -0
  30. package/scripts/lib/fleet-failover.sh +0 -0
  31. package/scripts/lib/helpers.sh +19 -18
  32. package/scripts/lib/pipeline-detection.sh +1 -1
  33. package/scripts/lib/pipeline-github.sh +0 -0
  34. package/scripts/lib/pipeline-intelligence.sh +23 -4
  35. package/scripts/lib/pipeline-quality-checks.sh +11 -6
  36. package/scripts/lib/pipeline-quality.sh +0 -0
  37. package/scripts/lib/pipeline-stages.sh +330 -33
  38. package/scripts/lib/pipeline-state.sh +14 -0
  39. package/scripts/lib/policy.sh +0 -0
  40. package/scripts/lib/test-helpers.sh +0 -0
  41. package/scripts/postinstall.mjs +75 -1
  42. package/scripts/signals/example-collector.sh +36 -0
  43. package/scripts/sw +8 -4
  44. package/scripts/sw-activity.sh +1 -7
  45. package/scripts/sw-adaptive.sh +7 -7
  46. package/scripts/sw-adversarial.sh +1 -1
  47. package/scripts/sw-architecture-enforcer.sh +1 -1
  48. package/scripts/sw-auth.sh +1 -1
  49. package/scripts/sw-autonomous.sh +1 -1
  50. package/scripts/sw-changelog.sh +1 -1
  51. package/scripts/sw-checkpoint.sh +1 -1
  52. package/scripts/sw-ci.sh +11 -6
  53. package/scripts/sw-cleanup.sh +1 -1
  54. package/scripts/sw-code-review.sh +36 -17
  55. package/scripts/sw-connect.sh +1 -1
  56. package/scripts/sw-context.sh +1 -1
  57. package/scripts/sw-cost.sh +71 -5
  58. package/scripts/sw-daemon.sh +6 -3
  59. package/scripts/sw-dashboard.sh +1 -1
  60. package/scripts/sw-db.sh +53 -38
  61. package/scripts/sw-decide.sh +685 -0
  62. package/scripts/sw-decompose.sh +1 -1
  63. package/scripts/sw-deps.sh +1 -1
  64. package/scripts/sw-developer-simulation.sh +1 -1
  65. package/scripts/sw-discovery.sh +80 -4
  66. package/scripts/sw-doc-fleet.sh +1 -1
  67. package/scripts/sw-docs-agent.sh +1 -1
  68. package/scripts/sw-docs.sh +1 -1
  69. package/scripts/sw-doctor.sh +1 -1
  70. package/scripts/sw-dora.sh +1 -1
  71. package/scripts/sw-durable.sh +9 -5
  72. package/scripts/sw-e2e-orchestrator.sh +1 -1
  73. package/scripts/sw-eventbus.sh +7 -4
  74. package/scripts/sw-evidence.sh +1 -1
  75. package/scripts/sw-feedback.sh +1 -1
  76. package/scripts/sw-fix.sh +1 -1
  77. package/scripts/sw-fleet-discover.sh +1 -1
  78. package/scripts/sw-fleet-viz.sh +6 -4
  79. package/scripts/sw-fleet.sh +1 -1
  80. package/scripts/sw-github-app.sh +3 -2
  81. package/scripts/sw-github-checks.sh +1 -1
  82. package/scripts/sw-github-deploy.sh +1 -1
  83. package/scripts/sw-github-graphql.sh +1 -1
  84. package/scripts/sw-guild.sh +1 -1
  85. package/scripts/sw-heartbeat.sh +1 -1
  86. package/scripts/sw-hygiene.sh +5 -3
  87. package/scripts/sw-incident.sh +9 -5
  88. package/scripts/sw-init.sh +1 -1
  89. package/scripts/sw-instrument.sh +1 -1
  90. package/scripts/sw-intelligence.sh +11 -6
  91. package/scripts/sw-jira.sh +1 -1
  92. package/scripts/sw-launchd.sh +1 -1
  93. package/scripts/sw-linear.sh +1 -1
  94. package/scripts/sw-logs.sh +1 -1
  95. package/scripts/sw-loop.sh +338 -32
  96. package/scripts/sw-memory.sh +23 -6
  97. package/scripts/sw-mission-control.sh +1 -1
  98. package/scripts/sw-model-router.sh +3 -2
  99. package/scripts/sw-otel.sh +8 -4
  100. package/scripts/sw-oversight.sh +1 -1
  101. package/scripts/sw-pipeline-composer.sh +3 -1
  102. package/scripts/sw-pipeline-vitals.sh +11 -6
  103. package/scripts/sw-pipeline.sh +92 -8
  104. package/scripts/sw-pm.sh +5 -4
  105. package/scripts/sw-pr-lifecycle.sh +7 -4
  106. package/scripts/sw-predictive.sh +11 -5
  107. package/scripts/sw-prep.sh +1 -1
  108. package/scripts/sw-ps.sh +1 -1
  109. package/scripts/sw-public-dashboard.sh +3 -2
  110. package/scripts/sw-quality.sh +21 -10
  111. package/scripts/sw-reaper.sh +1 -1
  112. package/scripts/sw-recruit.sh +1 -1
  113. package/scripts/sw-regression.sh +1 -1
  114. package/scripts/sw-release-manager.sh +1 -1
  115. package/scripts/sw-release.sh +1 -1
  116. package/scripts/sw-remote.sh +1 -1
  117. package/scripts/sw-replay.sh +1 -1
  118. package/scripts/sw-retro.sh +1 -1
  119. package/scripts/sw-review-rerun.sh +1 -1
  120. package/scripts/sw-scale.sh +69 -11
  121. package/scripts/sw-security-audit.sh +1 -1
  122. package/scripts/sw-self-optimize.sh +168 -4
  123. package/scripts/sw-session.sh +3 -3
  124. package/scripts/sw-setup.sh +1 -1
  125. package/scripts/sw-standup.sh +1 -1
  126. package/scripts/sw-status.sh +1 -1
  127. package/scripts/sw-strategic.sh +11 -6
  128. package/scripts/sw-stream.sh +7 -4
  129. package/scripts/sw-swarm.sh +3 -2
  130. package/scripts/sw-team-stages.sh +1 -1
  131. package/scripts/sw-templates.sh +3 -3
  132. package/scripts/sw-testgen.sh +11 -6
  133. package/scripts/sw-tmux-pipeline.sh +1 -1
  134. package/scripts/sw-tmux.sh +35 -1
  135. package/scripts/sw-trace.sh +1 -1
  136. package/scripts/sw-tracker.sh +1 -1
  137. package/scripts/sw-triage.sh +7 -7
  138. package/scripts/sw-upgrade.sh +1 -1
  139. package/scripts/sw-ux.sh +1 -1
  140. package/scripts/sw-webhook.sh +3 -2
  141. package/scripts/sw-widgets.sh +7 -4
  142. package/scripts/sw-worktree.sh +1 -1
  143. package/scripts/update-homebrew-sha.sh +21 -15
@@ -282,7 +282,30 @@ daemon_reap_completed() {
282
282
 
283
283
  # Check if process is still running
284
284
  if kill -0 "$pid" 2>/dev/null; then
285
- continue
285
+ # Guard against PID reuse: if job has been running > 6 hours and
286
+ # the process tree doesn't contain sw-pipeline/sw-loop, it's stale
287
+ local _started_at _start_e _age_s
288
+ _started_at=$(echo "$job" | jq -r '.started_at // empty')
289
+ if [[ -n "$_started_at" ]]; then
290
+ _start_e=$(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")
291
+ _age_s=$(( $(now_epoch) - ${_start_e:-0} ))
292
+ if [[ "$_age_s" -gt 21600 ]]; then # 6 hours
293
+ # Verify this PID is actually our pipeline (not a reused PID)
294
+ local _proc_cmd
295
+ _proc_cmd=$(ps -p "$pid" -o command= 2>/dev/null || true)
296
+ if [[ -z "$_proc_cmd" ]] || ! echo "$_proc_cmd" | grep -qE 'sw-pipeline|sw-loop|claude' 2>/dev/null; then
297
+ daemon_log WARN "Stale job #${issue_num}: PID $pid running ${_age_s}s but not a pipeline process — force-reaping"
298
+ emit_event "daemon.stale_dead" "issue=$issue_num" "pid=$pid" "elapsed_s=$_age_s"
299
+ # Fall through to reap logic
300
+ else
301
+ continue
302
+ fi
303
+ else
304
+ continue
305
+ fi
306
+ else
307
+ continue
308
+ fi
286
309
  fi
287
310
 
288
311
  # Process is dead — determine exit code
File without changes
File without changes
@@ -3,6 +3,28 @@
3
3
  [[ -n "${_DAEMON_PATROL_LOADED:-}" ]] && return 0
4
4
  _DAEMON_PATROL_LOADED=1
5
5
 
6
+ # ─── Decision Engine Signal Mode ─────────────────────────────────────────────
7
+ # When DECISION_ENGINE_ENABLED=true, patrol writes candidates to the pending
8
+ # signals file instead of creating GitHub issues directly. The decision engine
9
+ # collects, scores, and acts on these signals with tiered autonomy.
10
+ SIGNALS_PENDING_FILE="${HOME}/.shipwright/signals/pending.jsonl"
11
+
12
+ _patrol_emit_signal() {
13
+ local id="$1" signal="$2" category="$3" title="$4" description="$5"
14
+ local risk="${6:-50}" confidence="${7:-0.80}" dedup_key="$8"
15
+ mkdir -p "$(dirname "$SIGNALS_PENDING_FILE")"
16
+ local ts
17
+ ts=$(now_iso)
18
+ local candidate
19
+ candidate=$(jq -n \
20
+ --arg id "$id" --arg signal "$signal" --arg category "$category" \
21
+ --arg title "$title" --arg desc "$description" \
22
+ --argjson risk "$risk" --arg conf "$confidence" \
23
+ --arg dedup "$dedup_key" --arg ts "$ts" \
24
+ '{id:$id, signal:$signal, category:$category, title:$title, description:$desc, evidence:{}, risk_score:$risk, confidence:$conf, dedup_key:$dedup, collected_at:$ts}')
25
+ echo "$candidate" >> "$SIGNALS_PENDING_FILE"
26
+ }
27
+
6
28
  patrol_build_labels() {
7
29
  local check_label="$1"
8
30
  local labels="${PATROL_LABEL},${check_label}"
@@ -76,8 +98,16 @@ daemon_patrol() {
76
98
  findings=$((findings + 1))
77
99
  emit_event "patrol.finding" "check=security" "severity=$severity" "package=$name"
78
100
 
79
- # Check if issue already exists
80
- if [[ "$NO_GITHUB" != "true" ]] && [[ "$dry_run" != "true" ]]; then
101
+ # Route to decision engine or create issue directly
102
+ if [[ "${DECISION_ENGINE_ENABLED:-false}" == "true" ]]; then
103
+ local _cat="security_patch"
104
+ [[ "$severity" == "critical" ]] && _cat="security_critical"
105
+ _patrol_emit_signal "sec-${name}" "security" "$_cat" \
106
+ "Security: ${title} in ${name}" \
107
+ "Fix ${severity} vulnerability in ${name}" \
108
+ "$([[ "$severity" == "critical" ]] && echo 80 || echo 50)" \
109
+ "0.95" "security:${name}:${title}"
110
+ elif [[ "$NO_GITHUB" != "true" ]] && [[ "$dry_run" != "true" ]]; then
81
111
  local existing
82
112
  existing=$(gh issue list --label "$PATROL_LABEL" --label "security" \
83
113
  --search "Security: $name" --json number -q 'length' 2>/dev/null || echo "0")
@@ -202,8 +232,13 @@ Auto-detected by \`shipwright daemon patrol\`." \
202
232
  fi
203
233
  done < <(echo "$outdated_json" | jq -c 'to_entries[]' 2>/dev/null)
204
234
 
205
- # Create a single issue for all stale deps
206
- if [[ "$findings" -gt 0 ]] && [[ "$NO_GITHUB" != "true" ]] && [[ "$dry_run" != "true" ]]; then
235
+ # Route to decision engine or create issue
236
+ if [[ "$findings" -gt 0 ]] && [[ "${DECISION_ENGINE_ENABLED:-false}" == "true" ]]; then
237
+ _patrol_emit_signal "deps-stale-${findings}" "deps" "deps_major" \
238
+ "Update ${findings} stale dependencies" \
239
+ "Packages 2+ major versions behind" \
240
+ 45 "0.90" "deps:stale:${findings}"
241
+ elif [[ "$findings" -gt 0 ]] && [[ "$NO_GITHUB" != "true" ]] && [[ "$dry_run" != "true" ]]; then
207
242
  local existing
208
243
  existing=$(gh issue list --label "$PATROL_LABEL" --label "dependencies" \
209
244
  --search "Stale dependencies" --json number -q 'length' 2>/dev/null || echo "0")
@@ -814,12 +849,12 @@ Auto-detected by \`shipwright daemon patrol\` on $(now_iso)." \
814
849
  if [[ ! -f "$scripts_dir/sw-${name}-test.sh" ]]; then
815
850
  # Count usage across other scripts
816
851
  local usage_count
817
- usage_count=$(grep -rl "sw-${name}" "$scripts_dir"/sw-*.sh 2>/dev/null | grep -cv "$basename" 2>/dev/null || echo "0")
852
+ usage_count=$(grep -rl "sw-${name}" "$scripts_dir"/sw-*.sh 2>/dev/null | grep -cv "$basename" 2>/dev/null || true)
818
853
  usage_count=${usage_count:-0}
819
854
 
820
855
  local line_count
821
- line_count=$(wc -l < "$script" 2>/dev/null | tr -d ' ' || echo "0")
822
- line_count=${line_count:-0}
856
+ line_count=$(wc -l < "$script" 2>/dev/null | tr -d ' ' || true)
857
+ line_count="${line_count:-0}"
823
858
 
824
859
  untested_entries="${untested_entries}${usage_count}|${basename}|${line_count}\n"
825
860
  findings=$((findings + 1))
@@ -1196,6 +1196,23 @@ daemon_poll_loop() {
1196
1196
  daemon_patrol --once || daemon_log WARN "daemon_patrol failed — continuing"
1197
1197
  LAST_PATROL_EPOCH=$now_e
1198
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
1199
1216
  fi
1200
1217
 
1201
1218
  # ── Adaptive poll interval: adjust sleep based on queue state ──
@@ -390,6 +390,16 @@ init_state() {
390
390
  atomic_write_state "$init_json"
391
391
  ) 200>"$lock_file"
392
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
+
393
403
  # Update PID and start time in existing state
394
404
  locked_state_update \
395
405
  --arg pid "$$" \
@@ -448,6 +458,13 @@ get_active_count() {
448
458
  echo 0
449
459
  return
450
460
  fi
461
+ # Validate state file JSON before parsing (mid-flight corruption check)
462
+ if ! jq empty "$STATE_FILE" 2>/dev/null; then
463
+ daemon_log WARN "State file corrupted mid-flight — backing up and resetting"
464
+ cp "$STATE_FILE" "${STATE_FILE}.corrupted.$(date +%s)" 2>/dev/null || true
465
+ init_state
466
+ return
467
+ fi
451
468
  jq -r '.active_jobs | length' "$STATE_FILE" 2>/dev/null || echo 0
452
469
  }
453
470
 
@@ -250,7 +250,7 @@ select_pipeline_template() {
250
250
  _dora_events=$(tail -500 "${EVENTS_FILE:-$HOME/.shipwright/events.jsonl}" \
251
251
  | grep '"type":"pipeline.completed"' 2>/dev/null \
252
252
  | tail -5 || true)
253
- _dora_total=$(echo "$_dora_events" | grep -c '.' 2>/dev/null || echo "0")
253
+ _dora_total=$(echo "$_dora_events" | grep -c '.' 2>/dev/null || true)
254
254
  _dora_total="${_dora_total:-0}"
255
255
  if [[ "$_dora_total" -ge 3 ]]; then
256
256
  _dora_failures=$(echo "$_dora_events" | grep -c '"result":"failure"' 2>/dev/null || true)
@@ -0,0 +1,295 @@
1
+ # decide-autonomy.sh — Tier enforcement & rate limiting for the decision engine
2
+ # Source from sw-decide.sh. Requires helpers.sh, policy.sh.
3
+ [[ -n "${_DECIDE_AUTONOMY_LOADED:-}" ]] && return 0
4
+ _DECIDE_AUTONOMY_LOADED=1
5
+
6
+ # ─── State ────────────────────────────────────────────────────────────────────
7
+ DECISIONS_DIR="${HOME}/.shipwright/decisions"
8
+ HALT_FILE="${DECISIONS_DIR}/halt.json"
9
+ LAST_DECISION_FILE="${DECISIONS_DIR}/last-decision.json"
10
+ OUTCOMES_FILE="${DECISIONS_DIR}/outcomes.jsonl"
11
+
12
+ _ensure_decisions_dir() {
13
+ mkdir -p "$DECISIONS_DIR"
14
+ }
15
+
16
+ _daily_log_file() {
17
+ echo "${DECISIONS_DIR}/daily-log-$(date -u +%Y-%m-%d).jsonl"
18
+ }
19
+
20
+ # ─── Tier Configuration ──────────────────────────────────────────────────────
21
+
22
+ TIERS_DATA=""
23
+ CATEGORY_RULES=""
24
+ TIER_LIMITS=""
25
+
26
+ autonomy_load_tiers() {
27
+ local tiers_path="${TIERS_FILE:-}"
28
+ if [[ -z "$tiers_path" ]]; then
29
+ # Try repo-relative, then policy
30
+ local repo_dir="${_REPO_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || echo '.')}"
31
+ tiers_path="${repo_dir}/config/decision-tiers.json"
32
+ if [[ ! -f "$tiers_path" ]]; then
33
+ tiers_path=$(policy_get ".decision.tiers_file" "config/decision-tiers.json")
34
+ [[ "$tiers_path" != /* ]] && tiers_path="${repo_dir}/${tiers_path}"
35
+ fi
36
+ fi
37
+
38
+ if [[ ! -f "$tiers_path" ]]; then
39
+ return 1
40
+ fi
41
+
42
+ TIERS_FILE="$tiers_path"
43
+ TIERS_DATA=$(cat "$tiers_path")
44
+ CATEGORY_RULES=$(echo "$TIERS_DATA" | jq -c '.category_rules // {}')
45
+ TIER_LIMITS=$(echo "$TIERS_DATA" | jq -c '.limits // {}')
46
+ return 0
47
+ }
48
+
49
+ # ─── Tier Resolution ─────────────────────────────────────────────────────────
50
+
51
+ autonomy_resolve_tier() {
52
+ local category="$1"
53
+ if [[ -z "$CATEGORY_RULES" ]]; then
54
+ echo "draft"
55
+ return
56
+ fi
57
+ local tier
58
+ tier=$(echo "$CATEGORY_RULES" | jq -r --arg cat "$category" '.[$cat].tier // "draft"')
59
+ echo "${tier:-draft}"
60
+ }
61
+
62
+ autonomy_get_labels() {
63
+ local tier="$1"
64
+ if [[ -z "$TIERS_DATA" ]]; then
65
+ echo ""
66
+ return
67
+ fi
68
+ echo "$TIERS_DATA" | jq -r --arg t "$tier" '.tiers[$t].labels // [] | join(",")'
69
+ }
70
+
71
+ autonomy_get_template() {
72
+ local tier="$1"
73
+ if [[ -z "$TIERS_DATA" ]]; then
74
+ echo "standard"
75
+ return
76
+ fi
77
+ local tmpl
78
+ tmpl=$(echo "$TIERS_DATA" | jq -r --arg t "$tier" '.tiers[$t].pipeline_template // "standard"')
79
+ [[ "$tmpl" == "null" ]] && tmpl=""
80
+ echo "$tmpl"
81
+ }
82
+
83
+ # ─── Budget Checks ───────────────────────────────────────────────────────────
84
+
85
+ autonomy_check_budget() {
86
+ local tier="$1"
87
+ _ensure_decisions_dir
88
+
89
+ local daily_log
90
+ daily_log=$(_daily_log_file)
91
+
92
+ # Count today's issues created
93
+ local today_count=0
94
+ if [[ -f "$daily_log" ]]; then
95
+ today_count=$(jq -s '[.[] | select(.action == "issue_created" or .action == "draft_written")] | length' "$daily_log" 2>/dev/null || echo "0")
96
+ fi
97
+
98
+ local max_issues
99
+ max_issues=$(echo "${TIER_LIMITS:-{}}" | jq -r '.max_issues_per_day // 15')
100
+
101
+ if [[ "$today_count" -ge "$max_issues" ]]; then
102
+ return 1
103
+ fi
104
+
105
+ # Check cost budget
106
+ local max_cost
107
+ max_cost=$(echo "${TIER_LIMITS:-{}}" | jq -r '.max_cost_per_day_usd // 25')
108
+ local today_cost=0
109
+ if [[ -f "$daily_log" ]]; then
110
+ today_cost=$(jq -s '[.[] | .estimated_cost_usd // 0] | add // 0' "$daily_log" 2>/dev/null || echo "0")
111
+ fi
112
+
113
+ # Only check cost for auto tier (propose/draft are cheap)
114
+ if [[ "$tier" == "auto" ]]; then
115
+ local cost_exceeded
116
+ cost_exceeded=$(echo "$today_cost $max_cost" | awk '{print ($1 >= $2) ? "true" : "false"}')
117
+ if [[ "$cost_exceeded" == "true" ]]; then
118
+ return 1
119
+ fi
120
+ fi
121
+
122
+ return 0
123
+ }
124
+
125
+ # ─── Rate Limiting ────────────────────────────────────────────────────────────
126
+
127
+ autonomy_check_rate_limit() {
128
+ [[ ! -f "$LAST_DECISION_FILE" ]] && return 0
129
+
130
+ local last_epoch
131
+ last_epoch=$(jq -r '.epoch // 0' "$LAST_DECISION_FILE" 2>/dev/null || echo "0")
132
+ local now_e
133
+ now_e=$(now_epoch)
134
+
135
+ local cooldown
136
+ cooldown=$(echo "${TIER_LIMITS:-{}}" | jq -r '.cooldown_seconds // 300')
137
+
138
+ local elapsed=$((now_e - last_epoch))
139
+ if [[ "$elapsed" -lt "$cooldown" ]]; then
140
+ return 1
141
+ fi
142
+ return 0
143
+ }
144
+
145
+ # ─── Halt Management ─────────────────────────────────────────────────────────
146
+
147
+ autonomy_check_halt() {
148
+ [[ -f "$HALT_FILE" ]] && return 1
149
+ return 0
150
+ }
151
+
152
+ autonomy_halt() {
153
+ _ensure_decisions_dir
154
+ local reason="${1:-manual halt}"
155
+ local tmp
156
+ tmp=$(mktemp)
157
+ jq -n --arg reason "$reason" --arg ts "$(now_iso)" --argjson epoch "$(now_epoch)" \
158
+ '{halted: true, reason: $reason, halted_at: $ts, epoch: $epoch}' > "$tmp" && mv "$tmp" "$HALT_FILE"
159
+ emit_event "decision.halted" "reason=$reason"
160
+ }
161
+
162
+ autonomy_resume() {
163
+ if [[ -f "$HALT_FILE" ]]; then
164
+ rm -f "$HALT_FILE"
165
+ emit_event "decision.resumed"
166
+ fi
167
+ }
168
+
169
+ # ─── Consecutive Failure Tracking ─────────────────────────────────────────────
170
+
171
+ autonomy_check_consecutive_failures() {
172
+ _ensure_decisions_dir
173
+ local daily_log
174
+ daily_log=$(_daily_log_file)
175
+ [[ ! -f "$daily_log" ]] && return 0
176
+
177
+ local max_consecutive
178
+ max_consecutive=$(echo "${TIER_LIMITS:-{}}" | jq -r '.halt_after_consecutive_failures // 3')
179
+
180
+ # Get the last N decisions and check if all failed
181
+ local recent
182
+ recent=$(jq -s --argjson n "$max_consecutive" '. | reverse | .[:$n]' "$daily_log" 2>/dev/null || echo '[]')
183
+ local count
184
+ count=$(echo "$recent" | jq 'length' 2>/dev/null || echo "0")
185
+ [[ "$count" -lt "$max_consecutive" ]] && return 0
186
+
187
+ local all_failed
188
+ all_failed=$(echo "$recent" | jq --argjson n "$max_consecutive" \
189
+ '[.[] | select(.outcome == "failure")] | length == $n' 2>/dev/null || echo "false")
190
+
191
+ if [[ "$all_failed" == "true" ]]; then
192
+ autonomy_halt "Halted: ${max_consecutive} consecutive failures"
193
+ return 1
194
+ fi
195
+ return 0
196
+ }
197
+
198
+ # ─── Risk Ceiling ─────────────────────────────────────────────────────────────
199
+
200
+ autonomy_check_risk_ceiling() {
201
+ local category="$1"
202
+ local risk_score="$2"
203
+ [[ -z "$CATEGORY_RULES" ]] && return 0
204
+
205
+ local ceiling
206
+ ceiling=$(echo "$CATEGORY_RULES" | jq -r --arg cat "$category" '.[$cat].risk_ceiling // 100')
207
+
208
+ if [[ "$risk_score" -gt "$ceiling" ]]; then
209
+ return 1
210
+ fi
211
+ return 0
212
+ }
213
+
214
+ # ─── Decision Recording ──────────────────────────────────────────────────────
215
+
216
+ autonomy_record_decision() {
217
+ local decision_json="$1"
218
+ _ensure_decisions_dir
219
+
220
+ local daily_log
221
+ daily_log=$(_daily_log_file)
222
+
223
+ # Append to daily log (atomic via tmp + append)
224
+ echo "$decision_json" >> "$daily_log"
225
+
226
+ # Update last-decision pointer
227
+ local tmp
228
+ tmp=$(mktemp)
229
+ echo "$decision_json" | jq '. + {epoch: (now | floor)}' > "$tmp" && mv "$tmp" "$LAST_DECISION_FILE"
230
+
231
+ # Rotate old daily logs (keep 30 days)
232
+ find "$DECISIONS_DIR" -name "daily-log-*.jsonl" -mtime +30 -delete 2>/dev/null || true
233
+ }
234
+
235
+ autonomy_record_outcome() {
236
+ local decision_id="$1"
237
+ local result="$2"
238
+ local detail="${3:-}"
239
+ _ensure_decisions_dir
240
+
241
+ local outcome
242
+ outcome=$(jq -n \
243
+ --arg id "$decision_id" \
244
+ --arg result "$result" \
245
+ --arg detail "$detail" \
246
+ --arg ts "$(now_iso)" \
247
+ '{decision_id: $id, result: $result, detail: $detail, recorded_at: $ts}')
248
+
249
+ echo "$outcome" >> "$OUTCOMES_FILE"
250
+
251
+ # Update daily log entry with outcome
252
+ local daily_log
253
+ daily_log=$(_daily_log_file)
254
+ if [[ -f "$daily_log" ]]; then
255
+ local tmp
256
+ tmp=$(mktemp)
257
+ jq --arg id "$decision_id" --arg res "$result" \
258
+ 'if .id == $id then . + {outcome: $res} else . end' \
259
+ "$daily_log" > "$tmp" && mv "$tmp" "$daily_log" || rm -f "$tmp"
260
+ fi
261
+ }
262
+
263
+ # ─── Daily Summary ────────────────────────────────────────────────────────────
264
+
265
+ autonomy_daily_summary() {
266
+ _ensure_decisions_dir
267
+ local daily_log
268
+ daily_log=$(_daily_log_file)
269
+
270
+ if [[ ! -f "$daily_log" ]]; then
271
+ jq -n '{date: (now | strftime("%Y-%m-%d")), total: 0, auto: 0, propose: 0, draft: 0, budget_remaining: {issues: 15, cost_usd: 25}}'
272
+ return
273
+ fi
274
+
275
+ local max_issues max_cost
276
+ max_issues=$(echo "${TIER_LIMITS:-{}}" | jq -r '.max_issues_per_day // 15')
277
+ max_cost=$(echo "${TIER_LIMITS:-{}}" | jq -r '.max_cost_per_day_usd // 25')
278
+
279
+ jq -s --argjson mi "$max_issues" --arg mc "$max_cost" '
280
+ {
281
+ date: (now | strftime("%Y-%m-%d")),
282
+ total: length,
283
+ auto: [.[] | select(.tier == "auto")] | length,
284
+ propose: [.[] | select(.tier == "propose")] | length,
285
+ draft: [.[] | select(.tier == "draft")] | length,
286
+ successes: [.[] | select(.outcome == "success")] | length,
287
+ failures: [.[] | select(.outcome == "failure")] | length,
288
+ budget_remaining: {
289
+ issues: ($mi - length),
290
+ cost_usd: ($mc - ([.[] | .estimated_cost_usd // 0] | add // 0))
291
+ },
292
+ halted: false
293
+ }
294
+ ' "$daily_log" 2>/dev/null || echo '{}'
295
+ }