carson 3.22.1 → 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.
@@ -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,31 +21,21 @@ 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
@@ -61,312 +45,259 @@ module Carson
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 "Cycle #{cycle_count} did not complete: #{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 "Skipping #{expanded} — path not found"
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 "#{repo_path}: path not found, skipping"
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 "#{File.basename(repo_path)}: unable to list open PRs"
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" ]
198
- end
199
- if review_decision == "REVIEW_REQUIRED"
200
- return [ TRIAGE_REVIEW_BLOCKED, "review required" ]
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
+ )
201
134
  end
202
135
 
203
- review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
204
- return [ TRIAGE_NEEDS_ATTENTION, review_detail ] if review_status == :error
205
- return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
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
206
144
 
207
- [ TRIAGE_READY, "all gates pass" ]
145
+ assess_delivery!( delivery: delivery, branch_name: delivery.branch )
208
146
  end
209
147
 
210
- # Checks CI status from PR's statusCheckRollup.
211
- def check_ci_status( pr: )
212
- checks = Array( pr[ "statusCheckRollup" ] )
213
- return :green if checks.empty?
214
-
215
- has_failure = checks.any? { check_state_failing?( state: it[ "state" ].to_s ) || check_conclusion_failing?( conclusion: it[ "conclusion" ].to_s ) }
216
- return :red if has_failure
217
-
218
- has_pending = checks.any? { check_state_pending?( state: it[ "state" ].to_s ) }
219
- return :pending if has_pending
220
-
221
- :green
222
- 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
+ }
223
157
 
224
- def check_state_failing?( state: )
225
- [ "FAILURE", "ERROR" ].include?( state.upcase )
226
- end
158
+ if delivery.superseded? || delivery.integrated? || delivery.failed?
159
+ return report
160
+ end
227
161
 
228
- def check_conclusion_failing?( conclusion: )
229
- [ "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED" ].include?( conclusion.upcase )
230
- end
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
166
+ end
231
167
 
232
- def check_state_pending?( state: )
233
- [ "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED" ].include?( state.upcase )
234
- end
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
176
+ end
235
177
 
236
- # Checks review gate status. Returns [:pass/:fail, detail].
237
- def check_review_gate_status( pr:, repo_path: )
238
- repo_runtime = scoped_runtime( repo_path: repo_path )
239
- owner, repo = repo_runtime.send( :repository_coordinates )
240
- report = repo_runtime.send(
241
- :review_gate_report_for_pr,
242
- owner: owner,
243
- repo: repo,
244
- pr_number: pr.fetch( "number" ),
245
- branch_name: pr.fetch( "headRefName" ).to_s,
246
- pr_summary: {
247
- number: pr.fetch( "number" ),
248
- title: pr.fetch( "title" ).to_s,
249
- url: pr.fetch( "url" ).to_s,
250
- state: "OPEN"
251
- }
252
- )
253
- result = repo_runtime.send( :review_gate_result, report: report )
254
- [ result.fetch( :status ), result.fetch( :detail ) ]
255
- rescue StandardError => exception
256
- [ :error, "review gate check failed: #{exception.message}" ]
178
+ report
257
179
  end
258
180
 
259
- # Maps classification to action.
260
- def decide_action( classification:, dry_run: )
261
- case classification
262
- when TRIAGE_READY
263
- dry_run ? "would_merge" : "merge"
264
- when TRIAGE_CI_FAILING
265
- dry_run ? "would_dispatch_ci_fix" : "dispatch_ci_fix"
266
- when TRIAGE_REVIEW_BLOCKED
267
- dry_run ? "would_dispatch_review_fix" : "dispatch_review_fix"
268
- when TRIAGE_PENDING
269
- "skip"
270
- when TRIAGE_NEEDS_ATTENTION
271
- "escalate"
272
- else
273
- "skip"
274
- end
275
- end
181
+ def execute_delivery_action!( action:, delivery:, repo_path:, dry_run: )
182
+ return delivery if dry_run
276
183
 
277
- # Executes the decided action on a PR.
278
- def execute_action!( action:, pr:, repo_path:, dry_run: )
279
184
  case action
280
- when "merge"
281
- merge_if_ready!( pr: pr, repo_path: repo_path )
282
- when "dispatch_ci_fix"
283
- dispatch_agent!( pr: pr, repo_path: repo_path, objective: "fix_ci" )
284
- when "dispatch_review_fix"
285
- 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 )
286
189
  when "escalate"
287
- puts_line " ESCALATE: PR ##{pr[ 'number' ]} needs human attention"
190
+ escalate_delivery!( delivery: delivery, reason: "revision limit reached" )
191
+ else
192
+ delivery
288
193
  end
289
194
  end
290
195
 
291
- # Merges a PR that has passed all gates.
292
- # Omits --delete-branch (fails inside worktrees). Cleanup via `carson prune`.
293
- def merge_if_ready!( pr:, repo_path: )
294
- unless config.govern_auto_merge
295
- puts_line " merge authority disabled; skipping merge"
296
- return
297
- end
298
-
299
- method = config.govern_merge_method
300
- number = pr[ "number" ]
301
- stdout_text, stderr_text, status = Open3.capture3(
302
- "gh", "pr", "merge", number.to_s,
303
- "--#{method}",
304
- 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}"
305
202
  )
306
- if status.success?
307
- 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
+ )
308
211
  housekeep_repo!( repo_path: repo_path )
212
+ integrated
309
213
  else
310
- error_text = stderr_text.to_s.strip
311
- puts_line " merge did not succeed: #{error_text}"
214
+ ledger.update_delivery(
215
+ delivery: prepared,
216
+ status: "gated",
217
+ cause: "policy",
218
+ summary: result.fetch( :error, "merge failed" )
219
+ )
312
220
  end
313
221
  end
314
222
 
315
- # Dispatches an agent to fix an issue on a PR.
316
- def dispatch_agent!( pr:, repo_path:, objective: )
317
- state = load_dispatch_state
318
- state_key = dispatch_state_key( pr: pr, repo_path: repo_path )
319
-
320
- existing = state[ state_key ]
321
- if existing && existing[ "status" ] == "running"
322
- puts_line " agent already dispatched for #{objective}; skipping"
323
- return
324
- end
325
-
223
+ def revise_delivery!( delivery:, repo_path: )
326
224
  provider = select_agent_provider
327
- unless provider
328
- puts_line " no agent provider available; escalating"
329
- return
330
- 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 )
331
227
 
332
- 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 )
333
230
  work_order = Adapters::Agent::WorkOrder.new(
334
231
  repo: repo_path,
335
- branch: pr[ "headRefName" ].to_s,
336
- pr_number: pr[ "number" ],
232
+ branch: delivery.branch,
233
+ pr_number: delivery.pull_request_number,
337
234
  objective: objective,
338
235
  context: context,
339
236
  acceptance_checks: nil
340
237
  )
341
238
 
342
- puts_line " dispatching #{provider} agent for #{objective}"
343
- adapter = build_agent_adapter( provider: provider, repo_path: repo_path )
344
- 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
+ )
345
247
 
346
- state[ state_key ] = {
347
- "objective" => objective,
348
- "provider" => provider,
349
- "dispatched_at" => Time.now.utc.iso8601,
350
- "status" => result.status == "done" ? "done" : "failed",
351
- "summary" => result.summary
352
- }
353
- 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
354
257
 
355
- 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
356
268
  end
357
269
 
358
- # Runs sync + prune in the given repo after a successful merge.
359
- def housekeep_repo!( repo_path: )
360
- scoped_runtime = if repo_path == self.repo_root
361
- self
362
- else
363
- 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"
364
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 )
365
297
  sync_status = scoped_runtime.sync!
366
298
  scoped_runtime.prune! if sync_status == EXIT_OK
367
299
  end
368
300
 
369
- # Selects which agent provider to use based on config and availability.
370
301
  def select_agent_provider
371
302
  provider = config.govern_agent_provider
372
303
  case provider
@@ -399,48 +330,26 @@ module Carson
399
330
  end
400
331
  end
401
332
 
402
- # Dispatch state persistence.
403
- def load_dispatch_state
404
- path = config.govern_dispatch_state_path
405
- return {} unless File.file?( path )
406
-
407
- JSON.parse( File.read( path ) )
408
- rescue JSON::ParserError
409
- {}
410
- end
411
-
412
- def save_dispatch_state( state: )
413
- path = config.govern_dispatch_state_path
414
- FileUtils.mkdir_p( File.dirname( path ) )
415
- File.write( path, JSON.pretty_generate( state ) )
416
- end
417
-
418
- def dispatch_state_key( pr:, repo_path: )
419
- dir_name = File.basename( repo_path )
420
- "#{dir_name}##{pr[ 'number' ]}"
421
- end
422
-
423
- # Evidence gathering — builds structured context Hash for agent work orders.
424
- def evidence( pr:, repo_path:, objective: )
425
- context = { title: pr.fetch( "title", "" ) }
333
+ def evidence( delivery:, repo_path:, objective: )
334
+ context = { title: delivery.summary.to_s }
426
335
  case objective
427
336
  when "fix_ci"
428
- context.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
337
+ context.merge!( ci_evidence( delivery: delivery, repo_path: repo_path ) )
429
338
  when "address_review"
430
- context.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
339
+ context.merge!( review_evidence( delivery: delivery, repo_path: repo_path ) )
431
340
  end
432
- prior = prior_attempt( pr: pr, repo_path: repo_path )
341
+ prior = prior_attempt( delivery: delivery )
433
342
  context[ :prior_attempt ] = prior if prior
434
343
  context
435
344
  rescue StandardError => exception
436
- puts_line " evidence gathering failed: #{exception.message}"
437
- { title: pr.fetch( "title", "" ) }
345
+ puts_line "evidence gathering failed for #{delivery.branch}: #{exception.message}"
346
+ { title: delivery.summary.to_s }
438
347
  end
439
348
 
440
349
  CI_LOG_LIMIT = 8_000
441
350
 
442
- def ci_evidence( pr:, repo_path: )
443
- branch = pr[ "headRefName" ].to_s
351
+ def ci_evidence( delivery:, repo_path: )
352
+ branch = delivery.branch
444
353
  stdout_text, _, status = Open3.capture3(
445
354
  "gh", "run", "list",
446
355
  "--branch", branch,
@@ -456,17 +365,10 @@ module Carson
456
365
 
457
366
  run_id = runs.first[ "databaseId" ].to_s
458
367
  run_url = runs.first[ "url" ].to_s
459
-
460
- log_stdout, _, log_status = Open3.capture3(
461
- "gh", "run", "view", run_id, "--log-failed",
462
- chdir: repo_path
463
- )
368
+ log_stdout, _, log_status = Open3.capture3( "gh", "run", "view", run_id, "--log-failed", chdir: repo_path )
464
369
  return { ci_run_url: run_url } unless log_status.success?
465
370
 
466
371
  { ci_logs: truncate_log( text: log_stdout ), ci_run_url: run_url }
467
- rescue StandardError => exception
468
- puts_line " ci_evidence failed: #{exception.message}"
469
- {}
470
372
  end
471
373
 
472
374
  def truncate_log( text:, limit: CI_LOG_LIMIT )
@@ -475,14 +377,13 @@ module Carson
475
377
  text[ -limit.. ]
476
378
  end
477
379
 
478
- def review_evidence( pr:, repo_path: )
479
- scoped_runtime = scoped_runtime( repo_path: repo_path )
480
- owner, repo = scoped_runtime.send( :repository_coordinates )
481
- pr_number = pr[ "number" ]
482
- 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 )
483
384
  pr_author = details.dig( :author, :login ).to_s
484
- threads = scoped_runtime.send( :unresolved_thread_entries, details: details )
485
- 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 )
486
387
 
487
388
  findings = []
488
389
  threads.each do |entry|
@@ -495,23 +396,12 @@ module Carson
495
396
  end
496
397
 
497
398
  { review_findings: findings }
498
- rescue StandardError => exception
499
- puts_line " review_evidence failed: #{exception.message}"
500
- {}
501
- end
502
-
503
- def scoped_runtime( repo_path: )
504
- return self if repo_path == self.repo_root
505
- Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
506
399
  end
507
400
 
508
- def prior_attempt( pr:, repo_path: )
509
- state = load_dispatch_state
510
- key = dispatch_state_key( pr: pr, repo_path: repo_path )
511
- existing = state[ key ]
512
- return nil unless existing
513
- return nil unless existing[ "status" ] == "failed"
514
- { 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 }
515
405
  end
516
406
 
517
407
  def thread_body( details:, url: )
@@ -533,30 +423,11 @@ module Carson
533
423
  ""
534
424
  end
535
425
 
536
- # Check wait: returns true if the PR was updated within the configured wait window.
537
- def within_check_wait?( pr: )
538
- wait = config.govern_check_wait
539
- return false if wait <= 0
540
-
541
- updated_at_text = pr[ "updatedAt" ].to_s.strip
542
- return false if updated_at_text.empty?
543
-
544
- updated_at = Time.parse( updated_at_text )
545
- ( Time.now.utc - updated_at.utc ) < wait
546
- rescue ArgumentError
547
- false
548
- end
549
-
550
- # Report writing.
551
426
  def write_govern_report( report: )
552
427
  report_dir = report_dir_path
553
428
  FileUtils.mkdir_p( report_dir )
554
- json_path = File.join( report_dir, GOVERN_REPORT_JSON )
555
- md_path = File.join( report_dir, GOVERN_REPORT_MD )
556
- File.write( json_path, JSON.pretty_generate( report ) )
557
- File.write( md_path, render_govern_markdown( report: report ) )
558
- puts_verbose "report_json: #{json_path}"
559
- 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 ) )
560
431
  end
561
432
 
562
433
  def render_govern_markdown( report: )
@@ -567,8 +438,8 @@ module Carson
567
438
  lines << "**Dry run**: #{report[ :dry_run ]}"
568
439
  lines << ""
569
440
 
570
- Array( report[ :repos ] ).each do |repo_report|
571
- lines << "## #{repo_report[ :repo ]}"
441
+ Array( report[ :repositories ] ).each do |repo_report|
442
+ lines << "## #{repo_report[ :path ]}"
572
443
  lines << ""
573
444
  if repo_report[ :error ]
574
445
  lines << "**Error**: #{repo_report[ :error ]}"
@@ -576,20 +447,20 @@ module Carson
576
447
  next
577
448
  end
578
449
 
579
- prs = Array( repo_report[ :prs ] )
580
- if prs.empty?
581
- lines << "No open PRs."
450
+ deliveries = Array( repo_report[ :deliveries ] )
451
+ if deliveries.empty?
452
+ lines << "No active deliveries."
582
453
  lines << ""
583
454
  next
584
455
  end
585
456
 
586
- prs.each do |pr|
587
- lines << "### PR ##{pr[ :number ]} — #{pr[ :title ]}"
457
+ deliveries.each do |delivery|
458
+ lines << "### #{delivery[ :branch ]}"
588
459
  lines << ""
589
- lines << "- **Branch**: #{pr[ :branch ]}"
590
- lines << "- **Classification**: #{pr[ :classification ]}"
591
- lines << "- **Action**: #{pr[ :action ]}"
592
- 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 ]}"
593
464
  lines << ""
594
465
  end
595
466
  end
@@ -598,28 +469,21 @@ module Carson
598
469
  end
599
470
 
600
471
  def print_govern_summary( report: )
601
- puts_line ""
602
- total_prs = 0
603
- ready_count = 0
604
- blocked_count = 0
605
-
606
- Array( report[ :repos ] ).each do |repo_report|
607
- Array( repo_report[ :prs ] ).each do |pr|
608
- total_prs += 1
609
- case pr[ :classification ]
610
- when TRIAGE_READY
611
- ready_count += 1
612
- else
613
- blocked_count += 1
614
- 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
615
476
  end
616
- end
617
477
 
618
- repos_count = Array( report[ :repos ] ).length
619
- if verbose?
620
- puts_line "govern_summary: repos=#{repos_count} prs=#{total_prs} ready=#{ready_count} blocked=#{blocked_count}"
621
- else
622
- 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
623
487
  end
624
488
  end
625
489
  end