carson 3.22.0 → 3.23.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +19 -20
  3. data/MANUAL.md +76 -65
  4. data/README.md +42 -50
  5. data/RELEASE.md +24 -1
  6. data/SKILL.md +1 -1
  7. data/VERSION +1 -1
  8. data/carson.gemspec +3 -4
  9. data/hooks/command-guard +1 -1
  10. data/hooks/pre-push +17 -20
  11. data/lib/carson/adapters/agent.rb +2 -2
  12. data/lib/carson/branch.rb +38 -0
  13. data/lib/carson/cli.rb +45 -30
  14. data/lib/carson/config.rb +80 -29
  15. data/lib/carson/delivery.rb +64 -0
  16. data/lib/carson/ledger.rb +305 -0
  17. data/lib/carson/repository.rb +47 -0
  18. data/lib/carson/revision.rb +30 -0
  19. data/lib/carson/runtime/audit.rb +43 -17
  20. data/lib/carson/runtime/deliver.rb +163 -149
  21. data/lib/carson/runtime/govern.rb +233 -357
  22. data/lib/carson/runtime/housekeep.rb +233 -27
  23. data/lib/carson/runtime/local/onboard.rb +29 -29
  24. data/lib/carson/runtime/local/prune.rb +120 -35
  25. data/lib/carson/runtime/local/sync.rb +29 -7
  26. data/lib/carson/runtime/local/template.rb +30 -12
  27. data/lib/carson/runtime/local/worktree.rb +37 -442
  28. data/lib/carson/runtime/review/gate_support.rb +144 -12
  29. data/lib/carson/runtime/review/sweep_support.rb +2 -2
  30. data/lib/carson/runtime/review/utility.rb +1 -1
  31. data/lib/carson/runtime/review.rb +21 -77
  32. data/lib/carson/runtime/setup.rb +25 -33
  33. data/lib/carson/runtime/status.rb +96 -212
  34. data/lib/carson/runtime.rb +39 -4
  35. data/lib/carson/worktree.rb +497 -0
  36. data/lib/carson.rb +6 -0
  37. metadata +37 -17
  38. data/.github/copilot-instructions.md +0 -1
  39. data/.github/pull_request_template.md +0 -12
  40. data/templates/.github/AGENTS.md +0 -1
  41. data/templates/.github/CLAUDE.md +0 -1
  42. data/templates/.github/carson.md +0 -47
  43. data/templates/.github/copilot-instructions.md +0 -1
  44. data/templates/.github/pull_request_template.md +0 -12
@@ -1,5 +1,5 @@
1
- # Carson govern — portfolio-level triage loop.
2
- # Scans repos, lists open PRs, classifies each, takes the right action, reports.
1
+ # Carson govern — portfolio-wide oversight over branch deliveries.
2
+ # Govern reassesses queued/gated deliveries, records revision cycles, and integrates one ready delivery at a time.
3
3
  require "json"
4
4
  require "time"
5
5
  require "fileutils"
@@ -10,13 +10,7 @@ module Carson
10
10
  GOVERN_REPORT_MD = "govern_latest.md".freeze
11
11
  GOVERN_REPORT_JSON = "govern_latest.json".freeze
12
12
 
13
- TRIAGE_READY = "ready".freeze
14
- TRIAGE_CI_FAILING = "ci_failing".freeze
15
- TRIAGE_REVIEW_BLOCKED = "review_blocked".freeze
16
- TRIAGE_NEEDS_ATTENTION = "needs_attention".freeze
17
-
18
- # Portfolio-level entry point. Scans configured repos (or current repo)
19
- # and triages all open PRs. Returns EXIT_OK/EXIT_ERROR.
13
+ # Portfolio-level entry point. Scans governed repos (or the current repo) and advances deliveries.
20
14
  def govern!( dry_run: false, json_output: false, loop_seconds: nil )
21
15
  if loop_seconds
22
16
  govern_loop!( dry_run: dry_run, json_output: json_output, loop_seconds: loop_seconds )
@@ -27,334 +21,283 @@ module Carson
27
21
 
28
22
  def govern_cycle!( dry_run:, json_output: )
29
23
  print_header "Carson Govern"
30
- repos = governed_repo_paths
31
- if repos.empty?
32
- puts_line "governing current repository: #{repo_root}"
33
- repos = [ repo_root ]
34
- else
35
- puts_line "governing #{repos.length} repo#{plural_suffix( count: repos.length )}"
36
- end
24
+ repositories = governed_repo_paths
25
+ repositories = [ repo_root ] if repositories.empty?
26
+ puts_line "governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
37
27
 
38
- portfolio_report = {
28
+ report = {
39
29
  cycle_at: Time.now.utc.iso8601,
40
30
  dry_run: dry_run,
41
- repos: []
31
+ repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run ) }
42
32
  }
43
33
 
44
- repos.each do |repo_path|
45
- repo_report = govern_repo!( repo_path: repo_path, dry_run: dry_run )
46
- portfolio_report[ :repos ] << repo_report
47
- end
48
-
49
- write_govern_report( report: portfolio_report )
50
-
34
+ write_govern_report( report: report )
51
35
  if json_output
52
- puts_line JSON.pretty_generate( portfolio_report )
36
+ output.puts JSON.pretty_generate( report )
53
37
  else
54
- print_govern_summary( report: portfolio_report )
38
+ print_govern_summary( report: report )
55
39
  end
56
40
 
57
41
  EXIT_OK
58
42
  rescue StandardError => exception
59
- puts_line "ERROR: govern failed #{exception.message}"
43
+ puts_line "Govern did not complete: #{exception.message}"
60
44
  EXIT_ERROR
61
45
  end
62
46
 
63
47
  def govern_loop!( dry_run:, json_output:, loop_seconds: )
64
- print_header "⧓ Carson Govern — loop mode (every #{loop_seconds}s)"
65
48
  cycle_count = 0
66
49
  loop do
67
50
  cycle_count += 1
68
51
  puts_line ""
69
- puts_line "── cycle #{cycle_count} at #{Time.now.utc.strftime( "%Y-%m-%d %H:%M:%S UTC" )} ──"
70
- begin
71
- govern_cycle!( dry_run: dry_run, json_output: json_output )
72
- rescue StandardError => exception
73
- puts_line "ERROR: cycle #{cycle_count} failed — #{exception.message}"
74
- end
75
- puts_line "sleeping #{loop_seconds}s until next cycle…"
52
+ puts_line "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
53
+ govern_cycle!( dry_run: dry_run, json_output: json_output )
76
54
  sleep loop_seconds
77
55
  end
78
56
  rescue Interrupt
79
- puts_line ""
80
- puts_line "⧓ govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}."
57
+ puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
81
58
  EXIT_OK
82
59
  end
83
60
 
84
61
  private
85
62
 
86
- # Resolves the list of repo paths to govern from config.
87
63
  def governed_repo_paths
88
64
  config.govern_repos.map do |path|
89
65
  expanded = File.expand_path( path )
90
- unless Dir.exist?( expanded )
91
- puts_line "WARN: governed repo path does not exist: #{expanded}"
92
- next nil
93
- end
66
+ next nil unless Dir.exist?( expanded )
94
67
  expanded
95
68
  end.compact
96
69
  end
97
70
 
98
- # Governs a single repository: list open PRs, triage each.
99
71
  def govern_repo!( repo_path:, dry_run: )
100
- puts_line ""
101
- puts_line "--- #{repo_path} ---"
72
+ scoped_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
73
+ repository = Repository.new( path: repo_path, authority: scoped_runtime.config.govern_authority, runtime: scoped_runtime )
74
+ deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
75
+
102
76
  repo_report = {
103
- repo: repo_path,
104
- prs: [],
77
+ repository: repository.name,
78
+ path: repo_path,
79
+ authority: repository.authority,
80
+ deliveries: [],
105
81
  error: nil
106
82
  }
107
83
 
108
- unless Dir.exist?( repo_path )
109
- repo_report[ :error ] = "path does not exist"
110
- puts_line "ERROR: #{repo_path} does not exist"
84
+ if deliveries.empty?
85
+ puts_line "#{repository.name}: no active deliveries"
111
86
  return repo_report
112
87
  end
113
88
 
114
- prs = list_open_prs( repo_path: repo_path )
115
- if prs.nil?
116
- repo_report[ :error ] = "failed to list open PRs"
117
- puts_line "ERROR: failed to list open PRs for #{repo_path}"
118
- return repo_report
119
- end
89
+ puts_line "#{repository.name}: #{deliveries.length} active deliver#{plural_suffix( count: deliveries.length )}"
120
90
 
121
- if prs.empty?
122
- puts_line "no open PRs"
123
- return repo_report
124
- end
91
+ reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
92
+ next_integration_id = reconciled.find( &:ready? )&.id
125
93
 
126
- puts_line "open PRs: #{prs.length}"
127
- prs.each do |pr|
128
- pr_report = triage_pr!( pr: pr, repo_path: repo_path, dry_run: dry_run )
129
- repo_report[ :prs ] << pr_report
94
+ reconciled.each do |delivery|
95
+ delivery_report = scoped_runtime.send(
96
+ :decide_delivery_action,
97
+ delivery: delivery,
98
+ repo_path: repo_path,
99
+ dry_run: dry_run,
100
+ next_integration_id: next_integration_id
101
+ )
102
+ repo_report[ :deliveries ] << delivery_report
130
103
  end
131
104
 
132
105
  repo_report
133
- end
134
-
135
- # Lists open PRs via gh CLI.
136
- def list_open_prs( repo_path: )
137
- stdout_text, stderr_text, status = Open3.capture3(
138
- "gh", "pr", "list", "--state", "open",
139
- "--json", "number,title,headRefName,statusCheckRollup,reviewDecision,url,updatedAt",
140
- chdir: repo_path
141
- )
142
- unless status.success?
143
- error_text = stderr_text.to_s.strip
144
- puts_line "gh pr list failed: #{error_text}" unless error_text.empty?
145
- return nil
106
+ rescue StandardError => exception
107
+ if defined?( repo_report ) && repo_report.is_a?( Hash )
108
+ repo_report[ :error ] = exception.message
109
+ repo_report
110
+ else
111
+ { repository: File.basename( repo_path ), path: repo_path, deliveries: [], error: exception.message }
146
112
  end
147
- JSON.parse( stdout_text )
148
- rescue JSON::ParserError => exception
149
- puts_line "gh pr list returned invalid JSON: #{exception.message}"
150
- nil
151
113
  end
152
114
 
153
- # Classifies a PR and takes appropriate action.
154
- def triage_pr!( pr:, repo_path:, dry_run: )
155
- number = pr[ "number" ]
156
- title = pr[ "title" ].to_s
157
- branch = pr[ "headRefName" ].to_s
158
- url = pr[ "url" ].to_s
159
-
160
- pr_report = {
161
- number: number,
162
- title: title,
163
- branch: branch,
164
- url: url,
165
- classification: nil,
166
- action: nil,
167
- detail: nil
168
- }
169
-
170
- classification, detail = classify_pr( pr: pr, repo_path: repo_path )
171
- pr_report[ :classification ] = classification
172
- pr_report[ :detail ] = detail
173
-
174
- action = decide_action( classification: classification, dry_run: dry_run )
175
- pr_report[ :action ] = action
176
-
177
- puts_line " PR ##{number} (#{branch}): #{classification} → #{action}"
178
- puts_line " #{detail}" unless detail.to_s.empty?
179
-
180
- execute_action!( action: action, pr: pr, repo_path: repo_path, dry_run: dry_run ) unless dry_run
181
-
182
- pr_report
183
- end
184
-
185
- TRIAGE_PENDING = "pending".freeze
186
-
187
- # Classifies PR state by checking CI, review status, and audit readiness.
188
- def classify_pr( pr:, repo_path: )
189
- ci_status = check_ci_status( pr: pr )
190
- if ci_status == :pending && within_check_wait?( pr: pr )
191
- return [ TRIAGE_PENDING, "checks still settling (within check_wait window)" ]
115
+ def reconcile_delivery!( delivery: )
116
+ branch = Repository.new( path: repo_root, authority: config.govern_authority, runtime: self ).branch( delivery.branch ).reload
117
+ if branch.head && branch.head != delivery.head
118
+ return ledger.update_delivery(
119
+ delivery: delivery,
120
+ status: "superseded",
121
+ superseded_at: Time.now.utc.iso8601,
122
+ summary: "branch head advanced to #{branch.head}; run carson deliver again"
123
+ )
192
124
  end
193
- return [ TRIAGE_CI_FAILING, "CI checks failing or pending" ] unless ci_status == :green
194
125
 
195
- review_decision = pr[ "reviewDecision" ].to_s.upcase
196
- if review_decision == "CHANGES_REQUESTED"
197
- return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
126
+ pr_state = pull_request_state( number: delivery.pull_request_number )
127
+ if pr_state && pr_state[ "state" ] == "MERGED"
128
+ return ledger.update_delivery(
129
+ delivery: delivery,
130
+ status: "integrated",
131
+ integrated_at: Time.now.utc.iso8601,
132
+ summary: "integrated into #{config.main_branch}"
133
+ )
198
134
  end
199
135
 
200
- review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
201
- return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
202
-
203
- [ TRIAGE_READY, "all gates pass" ]
204
- end
205
-
206
- # Checks CI status from PR's statusCheckRollup.
207
- def check_ci_status( pr: )
208
- checks = Array( pr[ "statusCheckRollup" ] )
209
- return :green if checks.empty?
210
-
211
- has_failure = checks.any? { check_state_failing?( state: it[ "state" ].to_s ) || check_conclusion_failing?( conclusion: it[ "conclusion" ].to_s ) }
212
- return :red if has_failure
213
-
214
- has_pending = checks.any? { check_state_pending?( state: it[ "state" ].to_s ) }
215
- return :pending if has_pending
216
-
217
- :green
218
- end
136
+ if pr_state && pr_state[ "state" ] == "CLOSED"
137
+ return ledger.update_delivery(
138
+ delivery: delivery,
139
+ status: "failed",
140
+ cause: "policy",
141
+ summary: "pull request closed without integration"
142
+ )
143
+ end
219
144
 
220
- def check_state_failing?( state: )
221
- [ "FAILURE", "ERROR" ].include?( state.upcase )
145
+ assess_delivery!( delivery: delivery, branch_name: delivery.branch )
222
146
  end
223
147
 
224
- def check_conclusion_failing?( conclusion: )
225
- [ "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED" ].include?( conclusion.upcase )
226
- end
148
+ def decide_delivery_action( delivery:, repo_path:, dry_run:, next_integration_id: )
149
+ report = {
150
+ id: delivery.id,
151
+ branch: delivery.branch,
152
+ status: delivery.status,
153
+ summary: delivery.summary,
154
+ revision_count: delivery.revision_count,
155
+ action: "none"
156
+ }
227
157
 
228
- def check_state_pending?( state: )
229
- [ "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED" ].include?( state.upcase )
230
- end
158
+ if delivery.superseded? || delivery.integrated? || delivery.failed?
159
+ return report
160
+ end
231
161
 
232
- # Checks review gate status. Returns [:pass/:fail, detail].
233
- def check_review_gate_status( pr:, repo_path: )
234
- review_decision = pr[ "reviewDecision" ].to_s.upcase
235
- case review_decision
236
- when "APPROVED"
237
- [ :pass, "approved" ]
238
- when "CHANGES_REQUESTED"
239
- [ :fail, "changes requested" ]
240
- when "REVIEW_REQUIRED"
241
- [ :fail, "review required" ]
242
- else
243
- [ :pass, "no review policy or approved" ]
162
+ if delivery.ready? && delivery.id == next_integration_id
163
+ report[ :action ] = dry_run ? "would_integrate" : "integrate"
164
+ report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
165
+ return report
244
166
  end
245
- end
246
167
 
247
- # Maps classification to action.
248
- def decide_action( classification:, dry_run: )
249
- case classification
250
- when TRIAGE_READY
251
- dry_run ? "would_merge" : "merge"
252
- when TRIAGE_CI_FAILING
253
- dry_run ? "would_dispatch_ci_fix" : "dispatch_ci_fix"
254
- when TRIAGE_REVIEW_BLOCKED
255
- dry_run ? "would_dispatch_review_fix" : "dispatch_review_fix"
256
- when TRIAGE_PENDING
257
- "skip"
258
- when TRIAGE_NEEDS_ATTENTION
259
- "escalate"
260
- else
261
- "skip"
168
+ if delivery.blocked?
169
+ if delivery.revision_count >= 3
170
+ report[ :action ] = dry_run ? "would_escalate" : "escalate"
171
+ report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
172
+ else
173
+ report[ :action ] = dry_run ? "would_revise" : "revise"
174
+ report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
175
+ end
262
176
  end
177
+
178
+ report
263
179
  end
264
180
 
265
- # Executes the decided action on a PR.
266
- def execute_action!( action:, pr:, repo_path:, dry_run: )
181
+ def execute_delivery_action!( action:, delivery:, repo_path:, dry_run: )
182
+ return delivery if dry_run
183
+
267
184
  case action
268
- when "merge"
269
- merge_if_ready!( pr: pr, repo_path: repo_path )
270
- when "dispatch_ci_fix"
271
- dispatch_agent!( pr: pr, repo_path: repo_path, objective: "fix_ci" )
272
- when "dispatch_review_fix"
273
- dispatch_agent!( pr: pr, repo_path: repo_path, objective: "address_review" )
185
+ when "integrate"
186
+ integrate_delivery!( delivery: delivery, repo_path: repo_path )
187
+ when "revise"
188
+ revise_delivery!( delivery: delivery, repo_path: repo_path )
274
189
  when "escalate"
275
- puts_line " ESCALATE: PR ##{pr[ 'number' ]} needs human attention"
190
+ escalate_delivery!( delivery: delivery, reason: "revision limit reached" )
191
+ else
192
+ delivery
276
193
  end
277
194
  end
278
195
 
279
- # Merges a PR that has passed all gates.
280
- # Omits --delete-branch (fails inside worktrees). Cleanup via `carson prune`.
281
- def merge_if_ready!( pr:, repo_path: )
282
- unless config.govern_auto_merge
283
- puts_line " merge authority disabled; skipping merge"
284
- return
285
- end
286
-
287
- method = config.govern_merge_method
288
- number = pr[ "number" ]
289
- stdout_text, stderr_text, status = Open3.capture3(
290
- "gh", "pr", "merge", number.to_s,
291
- "--#{method}",
292
- chdir: repo_path
196
+ def integrate_delivery!( delivery:, repo_path: )
197
+ result = {}
198
+ prepared = ledger.update_delivery(
199
+ delivery: delivery,
200
+ status: "integrating",
201
+ summary: "integrating into #{config.main_branch}"
293
202
  )
294
- if status.success?
295
- puts_line " merged PR ##{number} via #{method}"
203
+ merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
204
+ if merge_exit == EXIT_OK
205
+ integrated = ledger.update_delivery(
206
+ delivery: prepared,
207
+ status: "integrated",
208
+ integrated_at: Time.now.utc.iso8601,
209
+ summary: "integrated into #{config.main_branch}"
210
+ )
296
211
  housekeep_repo!( repo_path: repo_path )
212
+ integrated
297
213
  else
298
- error_text = stderr_text.to_s.strip
299
- puts_line " merge failed: #{error_text}"
214
+ ledger.update_delivery(
215
+ delivery: prepared,
216
+ status: "gated",
217
+ cause: "policy",
218
+ summary: result.fetch( :error, "merge failed" )
219
+ )
300
220
  end
301
221
  end
302
222
 
303
- # Dispatches an agent to fix an issue on a PR.
304
- def dispatch_agent!( pr:, repo_path:, objective: )
305
- state = load_dispatch_state
306
- state_key = dispatch_state_key( pr: pr, repo_path: repo_path )
307
-
308
- existing = state[ state_key ]
309
- if existing && existing[ "status" ] == "running"
310
- puts_line " agent already dispatched for #{objective}; skipping"
311
- return
312
- end
313
-
223
+ def revise_delivery!( delivery:, repo_path: )
314
224
  provider = select_agent_provider
315
- unless provider
316
- puts_line " no agent provider available; escalating"
317
- return
318
- end
225
+ return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
226
+ return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
319
227
 
320
- context = evidence( pr: pr, repo_path: repo_path, objective: objective )
228
+ objective = revision_objective( cause: delivery.cause )
229
+ context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
321
230
  work_order = Adapters::Agent::WorkOrder.new(
322
231
  repo: repo_path,
323
- branch: pr[ "headRefName" ].to_s,
324
- pr_number: pr[ "number" ],
232
+ branch: delivery.branch,
233
+ pr_number: delivery.pull_request_number,
325
234
  objective: objective,
326
235
  context: context,
327
236
  acceptance_checks: nil
328
237
  )
329
238
 
330
- puts_line " dispatching #{provider} agent for #{objective}"
331
- adapter = build_agent_adapter( provider: provider, repo_path: repo_path )
332
- result = adapter.dispatch( work_order: work_order )
239
+ result = build_agent_adapter( provider: provider, repo_path: delivery.worktree_path ).dispatch( work_order: work_order )
240
+ revision = ledger.record_revision(
241
+ delivery: delivery,
242
+ cause: delivery.cause || "policy",
243
+ provider: provider,
244
+ status: revision_status_for( result: result ),
245
+ summary: result.summary
246
+ )
333
247
 
334
- state[ state_key ] = {
335
- "objective" => objective,
336
- "provider" => provider,
337
- "dispatched_at" => Time.now.utc.iso8601,
338
- "status" => result.status == "done" ? "done" : "failed",
339
- "summary" => result.summary
340
- }
341
- save_dispatch_state( state: state )
248
+ if revision.completed?
249
+ updated = ledger.update_delivery(
250
+ delivery: delivery,
251
+ status: "gated",
252
+ summary: "revision #{revision.number} completed waiting for reassessment",
253
+ revision_count: revision.number
254
+ )
255
+ return reconcile_delivery!( delivery: updated )
256
+ end
342
257
 
343
- puts_line " agent result: #{result.status} #{result.summary.to_s[0, 120]}"
258
+ if revision.number >= 3
259
+ escalate_delivery!( delivery: delivery, reason: "revision #{revision.number} failed: #{result.summary}" )
260
+ else
261
+ ledger.update_delivery(
262
+ delivery: delivery,
263
+ status: "gated",
264
+ summary: "revision #{revision.number} failed: #{result.summary}",
265
+ revision_count: revision.number
266
+ )
267
+ end
344
268
  end
345
269
 
346
- # Runs sync + prune in the given repo after a successful merge.
347
- def housekeep_repo!( repo_path: )
348
- scoped_runtime = if repo_path == self.repo_root
349
- self
350
- else
351
- Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
270
+ def escalate_delivery!( delivery:, reason: )
271
+ ledger.update_delivery(
272
+ delivery: delivery,
273
+ status: "escalated",
274
+ cause: delivery.cause || "policy",
275
+ summary: reason
276
+ )
277
+ end
278
+
279
+ def revision_objective( cause: )
280
+ case cause
281
+ when "ci" then "fix_ci"
282
+ when "review" then "address_review"
283
+ else "fix_audit"
284
+ end
285
+ end
286
+
287
+ def revision_status_for( result: )
288
+ case result.status
289
+ when "done" then "completed"
290
+ when "timeout" then "stalled"
291
+ else "failed"
352
292
  end
293
+ end
294
+
295
+ def housekeep_repo!( repo_path: )
296
+ scoped_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
353
297
  sync_status = scoped_runtime.sync!
354
298
  scoped_runtime.prune! if sync_status == EXIT_OK
355
299
  end
356
300
 
357
- # Selects which agent provider to use based on config and availability.
358
301
  def select_agent_provider
359
302
  provider = config.govern_agent_provider
360
303
  case provider
@@ -387,48 +330,26 @@ module Carson
387
330
  end
388
331
  end
389
332
 
390
- # Dispatch state persistence.
391
- def load_dispatch_state
392
- path = config.govern_dispatch_state_path
393
- return {} unless File.file?( path )
394
-
395
- JSON.parse( File.read( path ) )
396
- rescue JSON::ParserError
397
- {}
398
- end
399
-
400
- def save_dispatch_state( state: )
401
- path = config.govern_dispatch_state_path
402
- FileUtils.mkdir_p( File.dirname( path ) )
403
- File.write( path, JSON.pretty_generate( state ) )
404
- end
405
-
406
- def dispatch_state_key( pr:, repo_path: )
407
- dir_name = File.basename( repo_path )
408
- "#{dir_name}##{pr[ 'number' ]}"
409
- end
410
-
411
- # Evidence gathering — builds structured context Hash for agent work orders.
412
- def evidence( pr:, repo_path:, objective: )
413
- context = { title: pr.fetch( "title", "" ) }
333
+ def evidence( delivery:, repo_path:, objective: )
334
+ context = { title: delivery.summary.to_s }
414
335
  case objective
415
336
  when "fix_ci"
416
- context.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
337
+ context.merge!( ci_evidence( delivery: delivery, repo_path: repo_path ) )
417
338
  when "address_review"
418
- context.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
339
+ context.merge!( review_evidence( delivery: delivery, repo_path: repo_path ) )
419
340
  end
420
- prior = prior_attempt( pr: pr, repo_path: repo_path )
341
+ prior = prior_attempt( delivery: delivery )
421
342
  context[ :prior_attempt ] = prior if prior
422
343
  context
423
344
  rescue StandardError => exception
424
- puts_line " evidence gathering failed: #{exception.message}"
425
- { title: pr.fetch( "title", "" ) }
345
+ puts_line "evidence gathering failed for #{delivery.branch}: #{exception.message}"
346
+ { title: delivery.summary.to_s }
426
347
  end
427
348
 
428
349
  CI_LOG_LIMIT = 8_000
429
350
 
430
- def ci_evidence( pr:, repo_path: )
431
- branch = pr[ "headRefName" ].to_s
351
+ def ci_evidence( delivery:, repo_path: )
352
+ branch = delivery.branch
432
353
  stdout_text, _, status = Open3.capture3(
433
354
  "gh", "run", "list",
434
355
  "--branch", branch,
@@ -444,17 +365,10 @@ module Carson
444
365
 
445
366
  run_id = runs.first[ "databaseId" ].to_s
446
367
  run_url = runs.first[ "url" ].to_s
447
-
448
- log_stdout, _, log_status = Open3.capture3(
449
- "gh", "run", "view", run_id, "--log-failed",
450
- chdir: repo_path
451
- )
368
+ log_stdout, _, log_status = Open3.capture3( "gh", "run", "view", run_id, "--log-failed", chdir: repo_path )
452
369
  return { ci_run_url: run_url } unless log_status.success?
453
370
 
454
371
  { ci_logs: truncate_log( text: log_stdout ), ci_run_url: run_url }
455
- rescue StandardError => exception
456
- puts_line " ci_evidence failed: #{exception.message}"
457
- {}
458
372
  end
459
373
 
460
374
  def truncate_log( text:, limit: CI_LOG_LIMIT )
@@ -463,14 +377,13 @@ module Carson
463
377
  text[ -limit.. ]
464
378
  end
465
379
 
466
- def review_evidence( pr:, repo_path: )
467
- scoped_runtime = scoped_runtime( repo_path: repo_path )
468
- owner, repo = scoped_runtime.send( :repository_coordinates )
469
- pr_number = pr[ "number" ]
470
- details = scoped_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: pr_number )
380
+ def review_evidence( delivery:, repo_path: )
381
+ repo_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
382
+ owner, repo = repo_runtime.send( :repository_coordinates )
383
+ details = repo_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: delivery.pull_request_number )
471
384
  pr_author = details.dig( :author, :login ).to_s
472
- threads = scoped_runtime.send( :unresolved_thread_entries, details: details )
473
- top_level = scoped_runtime.send( :actionable_top_level_items, details: details, pr_author: pr_author )
385
+ threads = repo_runtime.send( :unresolved_thread_entries, details: details )
386
+ top_level = repo_runtime.send( :actionable_top_level_items, details: details, pr_author: pr_author )
474
387
 
475
388
  findings = []
476
389
  threads.each do |entry|
@@ -483,23 +396,12 @@ module Carson
483
396
  end
484
397
 
485
398
  { review_findings: findings }
486
- rescue StandardError => exception
487
- puts_line " review_evidence failed: #{exception.message}"
488
- {}
489
- end
490
-
491
- def scoped_runtime( repo_path: )
492
- return self if repo_path == self.repo_root
493
- Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
494
399
  end
495
400
 
496
- def prior_attempt( pr:, repo_path: )
497
- state = load_dispatch_state
498
- key = dispatch_state_key( pr: pr, repo_path: repo_path )
499
- existing = state[ key ]
500
- return nil unless existing
501
- return nil unless existing[ "status" ] == "failed"
502
- { summary: existing[ "summary" ].to_s, dispatched_at: existing[ "dispatched_at" ].to_s }
401
+ def prior_attempt( delivery: )
402
+ revision = ledger.revisions_for_delivery( delivery_id: delivery.id ).last
403
+ return nil unless revision&.failed?
404
+ { summary: revision.summary.to_s, dispatched_at: revision.started_at.to_s }
503
405
  end
504
406
 
505
407
  def thread_body( details:, url: )
@@ -521,30 +423,11 @@ module Carson
521
423
  ""
522
424
  end
523
425
 
524
- # Check wait: returns true if the PR was updated within the configured wait window.
525
- def within_check_wait?( pr: )
526
- wait = config.govern_check_wait
527
- return false if wait <= 0
528
-
529
- updated_at_text = pr[ "updatedAt" ].to_s.strip
530
- return false if updated_at_text.empty?
531
-
532
- updated_at = Time.parse( updated_at_text )
533
- ( Time.now.utc - updated_at.utc ) < wait
534
- rescue ArgumentError
535
- false
536
- end
537
-
538
- # Report writing.
539
426
  def write_govern_report( report: )
540
427
  report_dir = report_dir_path
541
428
  FileUtils.mkdir_p( report_dir )
542
- json_path = File.join( report_dir, GOVERN_REPORT_JSON )
543
- md_path = File.join( report_dir, GOVERN_REPORT_MD )
544
- File.write( json_path, JSON.pretty_generate( report ) )
545
- File.write( md_path, render_govern_markdown( report: report ) )
546
- puts_verbose "report_json: #{json_path}"
547
- puts_verbose "report_markdown: #{md_path}"
429
+ File.write( File.join( report_dir, GOVERN_REPORT_JSON ), JSON.pretty_generate( report ) )
430
+ File.write( File.join( report_dir, GOVERN_REPORT_MD ), render_govern_markdown( report: report ) )
548
431
  end
549
432
 
550
433
  def render_govern_markdown( report: )
@@ -555,8 +438,8 @@ module Carson
555
438
  lines << "**Dry run**: #{report[ :dry_run ]}"
556
439
  lines << ""
557
440
 
558
- Array( report[ :repos ] ).each do |repo_report|
559
- lines << "## #{repo_report[ :repo ]}"
441
+ Array( report[ :repositories ] ).each do |repo_report|
442
+ lines << "## #{repo_report[ :path ]}"
560
443
  lines << ""
561
444
  if repo_report[ :error ]
562
445
  lines << "**Error**: #{repo_report[ :error ]}"
@@ -564,20 +447,20 @@ module Carson
564
447
  next
565
448
  end
566
449
 
567
- prs = Array( repo_report[ :prs ] )
568
- if prs.empty?
569
- lines << "No open PRs."
450
+ deliveries = Array( repo_report[ :deliveries ] )
451
+ if deliveries.empty?
452
+ lines << "No active deliveries."
570
453
  lines << ""
571
454
  next
572
455
  end
573
456
 
574
- prs.each do |pr|
575
- lines << "### PR ##{pr[ :number ]} — #{pr[ :title ]}"
457
+ deliveries.each do |delivery|
458
+ lines << "### #{delivery[ :branch ]}"
576
459
  lines << ""
577
- lines << "- **Branch**: #{pr[ :branch ]}"
578
- lines << "- **Classification**: #{pr[ :classification ]}"
579
- lines << "- **Action**: #{pr[ :action ]}"
580
- lines << "- **Detail**: #{pr[ :detail ]}" unless pr[ :detail ].to_s.empty?
460
+ lines << "- **Status**: #{delivery[ :status ]}"
461
+ lines << "- **Action**: #{delivery[ :action ]}"
462
+ lines << "- **Summary**: #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
463
+ lines << "- **Revision count**: #{delivery[ :revision_count ]}"
581
464
  lines << ""
582
465
  end
583
466
  end
@@ -586,28 +469,21 @@ module Carson
586
469
  end
587
470
 
588
471
  def print_govern_summary( report: )
589
- puts_line ""
590
- total_prs = 0
591
- ready_count = 0
592
- blocked_count = 0
593
-
594
- Array( report[ :repos ] ).each do |repo_report|
595
- Array( repo_report[ :prs ] ).each do |pr|
596
- total_prs += 1
597
- case pr[ :classification ]
598
- when TRIAGE_READY
599
- ready_count += 1
600
- else
601
- blocked_count += 1
602
- end
472
+ Array( report[ :repositories ] ).each do |repo_report|
473
+ if repo_report[ :error ]
474
+ puts_line "#{repo_report[ :repository ]}: #{repo_report[ :error ]}"
475
+ next
603
476
  end
604
- end
605
477
 
606
- repos_count = Array( report[ :repos ] ).length
607
- if verbose?
608
- puts_line "govern_summary: repos=#{repos_count} prs=#{total_prs} ready=#{ready_count} blocked=#{blocked_count}"
609
- else
610
- puts_line "Govern: #{repos_count} repo#{plural_suffix( count: repos_count )}, #{total_prs} PR#{plural_suffix( count: total_prs )} (#{ready_count} ready, #{blocked_count} blocked)"
478
+ if repo_report[ :deliveries ].empty?
479
+ puts_line "#{repo_report[ :repository ]}: no active deliveries"
480
+ next
481
+ end
482
+
483
+ repo_report[ :deliveries ].each do |delivery|
484
+ puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]}: #{delivery[ :status ]} -> #{delivery[ :action ]}"
485
+ puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
486
+ end
611
487
  end
612
488
  end
613
489
  end