carson 3.22.1 → 3.23.1

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,22 +1,12 @@
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
- require "fileutils"
6
5
 
7
6
  module Carson
8
7
  class Runtime
9
8
  module Govern
10
- GOVERN_REPORT_MD = "govern_latest.md".freeze
11
- GOVERN_REPORT_JSON = "govern_latest.json".freeze
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.
9
+ # Portfolio-level entry point. Scans governed repos (or the current repo) and advances deliveries.
20
10
  def govern!( dry_run: false, json_output: false, loop_seconds: nil )
21
11
  if loop_seconds
22
12
  govern_loop!( dry_run: dry_run, json_output: json_output, loop_seconds: loop_seconds )
@@ -27,31 +17,20 @@ module Carson
27
17
 
28
18
  def govern_cycle!( dry_run:, json_output: )
29
19
  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
20
+ repositories = governed_repo_paths
21
+ repositories = [ repo_root ] if repositories.empty?
22
+ puts_line "governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
37
23
 
38
- portfolio_report = {
24
+ report = {
39
25
  cycle_at: Time.now.utc.iso8601,
40
26
  dry_run: dry_run,
41
- repos: []
27
+ repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run ) }
42
28
  }
43
29
 
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
-
51
30
  if json_output
52
- puts_line JSON.pretty_generate( portfolio_report )
31
+ output.puts JSON.pretty_generate( report )
53
32
  else
54
- print_govern_summary( report: portfolio_report )
33
+ print_govern_summary( report: report )
55
34
  end
56
35
 
57
36
  EXIT_OK
@@ -61,312 +40,259 @@ module Carson
61
40
  end
62
41
 
63
42
  def govern_loop!( dry_run:, json_output:, loop_seconds: )
64
- print_header "⧓ Carson Govern — loop mode (every #{loop_seconds}s)"
65
43
  cycle_count = 0
66
44
  loop do
67
45
  cycle_count += 1
68
46
  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…"
47
+ puts_line "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
48
+ govern_cycle!( dry_run: dry_run, json_output: json_output )
76
49
  sleep loop_seconds
77
50
  end
78
51
  rescue Interrupt
79
- puts_line ""
80
- puts_line "⧓ govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}."
52
+ puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
81
53
  EXIT_OK
82
54
  end
83
55
 
84
56
  private
85
57
 
86
- # Resolves the list of repo paths to govern from config.
87
58
  def governed_repo_paths
88
59
  config.govern_repos.map do |path|
89
60
  expanded = File.expand_path( path )
90
- unless Dir.exist?( expanded )
91
- puts_line "Skipping #{expanded} — path not found"
92
- next nil
93
- end
61
+ next nil unless Dir.exist?( expanded )
94
62
  expanded
95
63
  end.compact
96
64
  end
97
65
 
98
- # Governs a single repository: list open PRs, triage each.
99
66
  def govern_repo!( repo_path:, dry_run: )
100
- puts_line ""
101
- puts_line "--- #{repo_path} ---"
67
+ scoped_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
68
+ repository = Repository.new( path: repo_path, authority: scoped_runtime.config.govern_authority, runtime: scoped_runtime )
69
+ deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
70
+
102
71
  repo_report = {
103
- repo: repo_path,
104
- prs: [],
72
+ repository: repository.name,
73
+ path: repo_path,
74
+ authority: repository.authority,
75
+ deliveries: [],
105
76
  error: nil
106
77
  }
107
78
 
108
- unless Dir.exist?( repo_path )
109
- repo_report[ :error ] = "path does not exist"
110
- puts_line "#{repo_path}: path not found, skipping"
79
+ if deliveries.empty?
80
+ puts_line "#{repository.name}: no active deliveries"
111
81
  return repo_report
112
82
  end
113
83
 
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
84
+ puts_line "#{repository.name}: #{deliveries.length} active deliver#{plural_suffix( count: deliveries.length )}"
120
85
 
121
- if prs.empty?
122
- puts_line "no open PRs"
123
- return repo_report
124
- end
86
+ reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
87
+ next_integration_id = reconciled.find( &:ready? )&.id
125
88
 
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
89
+ reconciled.each do |delivery|
90
+ delivery_report = scoped_runtime.send(
91
+ :decide_delivery_action,
92
+ delivery: delivery,
93
+ repo_path: repo_path,
94
+ dry_run: dry_run,
95
+ next_integration_id: next_integration_id
96
+ )
97
+ repo_report[ :deliveries ] << delivery_report
130
98
  end
131
99
 
132
100
  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
101
+ rescue StandardError => exception
102
+ if defined?( repo_report ) && repo_report.is_a?( Hash )
103
+ repo_report[ :error ] = exception.message
104
+ repo_report
105
+ else
106
+ { repository: File.basename( repo_path ), path: repo_path, deliveries: [], error: exception.message }
146
107
  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
108
  end
152
109
 
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)" ]
110
+ def reconcile_delivery!( delivery: )
111
+ branch = Repository.new( path: repo_root, authority: config.govern_authority, runtime: self ).branch( delivery.branch ).reload
112
+ if branch.head && branch.head != delivery.head
113
+ return ledger.update_delivery(
114
+ delivery: delivery,
115
+ status: "superseded",
116
+ superseded_at: Time.now.utc.iso8601,
117
+ summary: "branch head advanced to #{branch.head}; run carson deliver again"
118
+ )
192
119
  end
193
- return [ TRIAGE_CI_FAILING, "CI checks failing or pending" ] unless ci_status == :green
194
120
 
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" ]
121
+ pr_state = pull_request_state( number: delivery.pull_request_number )
122
+ if pr_state && pr_state[ "state" ] == "MERGED"
123
+ return ledger.update_delivery(
124
+ delivery: delivery,
125
+ status: "integrated",
126
+ integrated_at: Time.now.utc.iso8601,
127
+ summary: "integrated into #{config.main_branch}"
128
+ )
201
129
  end
202
130
 
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
131
+ if pr_state && pr_state[ "state" ] == "CLOSED"
132
+ return ledger.update_delivery(
133
+ delivery: delivery,
134
+ status: "failed",
135
+ cause: "policy",
136
+ summary: "pull request closed without integration"
137
+ )
138
+ end
206
139
 
207
- [ TRIAGE_READY, "all gates pass" ]
140
+ assess_delivery!( delivery: delivery, branch_name: delivery.branch )
208
141
  end
209
142
 
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
143
+ def decide_delivery_action( delivery:, repo_path:, dry_run:, next_integration_id: )
144
+ report = {
145
+ id: delivery.id,
146
+ branch: delivery.branch,
147
+ status: delivery.status,
148
+ summary: delivery.summary,
149
+ revision_count: delivery.revision_count,
150
+ action: "none"
151
+ }
223
152
 
224
- def check_state_failing?( state: )
225
- [ "FAILURE", "ERROR" ].include?( state.upcase )
226
- end
153
+ if delivery.superseded? || delivery.integrated? || delivery.failed?
154
+ return report
155
+ end
227
156
 
228
- def check_conclusion_failing?( conclusion: )
229
- [ "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED" ].include?( conclusion.upcase )
230
- end
157
+ if delivery.ready? && delivery.id == next_integration_id
158
+ report[ :action ] = dry_run ? "would_integrate" : "integrate"
159
+ report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
160
+ return report
161
+ end
231
162
 
232
- def check_state_pending?( state: )
233
- [ "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED" ].include?( state.upcase )
234
- end
163
+ if delivery.blocked?
164
+ if delivery.revision_count >= 3
165
+ report[ :action ] = dry_run ? "would_escalate" : "escalate"
166
+ report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
167
+ else
168
+ report[ :action ] = dry_run ? "would_revise" : "revise"
169
+ report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
170
+ end
171
+ end
235
172
 
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}" ]
173
+ report
257
174
  end
258
175
 
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
176
+ def execute_delivery_action!( action:, delivery:, repo_path:, dry_run: )
177
+ return delivery if dry_run
276
178
 
277
- # Executes the decided action on a PR.
278
- def execute_action!( action:, pr:, repo_path:, dry_run: )
279
179
  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" )
180
+ when "integrate"
181
+ integrate_delivery!( delivery: delivery, repo_path: repo_path )
182
+ when "revise"
183
+ revise_delivery!( delivery: delivery, repo_path: repo_path )
286
184
  when "escalate"
287
- puts_line " ESCALATE: PR ##{pr[ 'number' ]} needs human attention"
185
+ escalate_delivery!( delivery: delivery, reason: "revision limit reached" )
186
+ else
187
+ delivery
288
188
  end
289
189
  end
290
190
 
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
191
+ def integrate_delivery!( delivery:, repo_path: )
192
+ result = {}
193
+ prepared = ledger.update_delivery(
194
+ delivery: delivery,
195
+ status: "integrating",
196
+ summary: "integrating into #{config.main_branch}"
305
197
  )
306
- if status.success?
307
- puts_line " merged PR ##{number} via #{method}"
198
+ merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
199
+ if merge_exit == EXIT_OK
200
+ integrated = ledger.update_delivery(
201
+ delivery: prepared,
202
+ status: "integrated",
203
+ integrated_at: Time.now.utc.iso8601,
204
+ summary: "integrated into #{config.main_branch}"
205
+ )
308
206
  housekeep_repo!( repo_path: repo_path )
207
+ integrated
309
208
  else
310
- error_text = stderr_text.to_s.strip
311
- puts_line " merge did not succeed: #{error_text}"
209
+ ledger.update_delivery(
210
+ delivery: prepared,
211
+ status: "gated",
212
+ cause: "policy",
213
+ summary: result.fetch( :error, "merge failed" )
214
+ )
312
215
  end
313
216
  end
314
217
 
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
-
218
+ def revise_delivery!( delivery:, repo_path: )
326
219
  provider = select_agent_provider
327
- unless provider
328
- puts_line " no agent provider available; escalating"
329
- return
330
- end
220
+ return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
221
+ return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
331
222
 
332
- context = evidence( pr: pr, repo_path: repo_path, objective: objective )
223
+ objective = revision_objective( cause: delivery.cause )
224
+ context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
333
225
  work_order = Adapters::Agent::WorkOrder.new(
334
226
  repo: repo_path,
335
- branch: pr[ "headRefName" ].to_s,
336
- pr_number: pr[ "number" ],
227
+ branch: delivery.branch,
228
+ pr_number: delivery.pull_request_number,
337
229
  objective: objective,
338
230
  context: context,
339
231
  acceptance_checks: nil
340
232
  )
341
233
 
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 )
234
+ result = build_agent_adapter( provider: provider, repo_path: delivery.worktree_path ).dispatch( work_order: work_order )
235
+ revision = ledger.record_revision(
236
+ delivery: delivery,
237
+ cause: delivery.cause || "policy",
238
+ provider: provider,
239
+ status: revision_status_for( result: result ),
240
+ summary: result.summary
241
+ )
345
242
 
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 )
243
+ if revision.completed?
244
+ updated = ledger.update_delivery(
245
+ delivery: delivery,
246
+ status: "gated",
247
+ summary: "revision #{revision.number} completed waiting for reassessment",
248
+ revision_count: revision.number
249
+ )
250
+ return reconcile_delivery!( delivery: updated )
251
+ end
354
252
 
355
- puts_line " agent result: #{result.status} #{result.summary.to_s[0, 120]}"
253
+ if revision.number >= 3
254
+ escalate_delivery!( delivery: delivery, reason: "revision #{revision.number} failed: #{result.summary}" )
255
+ else
256
+ ledger.update_delivery(
257
+ delivery: delivery,
258
+ status: "gated",
259
+ summary: "revision #{revision.number} failed: #{result.summary}",
260
+ revision_count: revision.number
261
+ )
262
+ end
356
263
  end
357
264
 
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 )
265
+ def escalate_delivery!( delivery:, reason: )
266
+ ledger.update_delivery(
267
+ delivery: delivery,
268
+ status: "escalated",
269
+ cause: delivery.cause || "policy",
270
+ summary: reason
271
+ )
272
+ end
273
+
274
+ def revision_objective( cause: )
275
+ case cause
276
+ when "ci" then "fix_ci"
277
+ when "review" then "address_review"
278
+ else "fix_audit"
364
279
  end
280
+ end
281
+
282
+ def revision_status_for( result: )
283
+ case result.status
284
+ when "done" then "completed"
285
+ when "timeout" then "stalled"
286
+ else "failed"
287
+ end
288
+ end
289
+
290
+ def housekeep_repo!( repo_path: )
291
+ scoped_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
365
292
  sync_status = scoped_runtime.sync!
366
293
  scoped_runtime.prune! if sync_status == EXIT_OK
367
294
  end
368
295
 
369
- # Selects which agent provider to use based on config and availability.
370
296
  def select_agent_provider
371
297
  provider = config.govern_agent_provider
372
298
  case provider
@@ -399,48 +325,26 @@ module Carson
399
325
  end
400
326
  end
401
327
 
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", "" ) }
328
+ def evidence( delivery:, repo_path:, objective: )
329
+ context = { title: delivery.summary.to_s }
426
330
  case objective
427
331
  when "fix_ci"
428
- context.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
332
+ context.merge!( ci_evidence( delivery: delivery, repo_path: repo_path ) )
429
333
  when "address_review"
430
- context.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
334
+ context.merge!( review_evidence( delivery: delivery, repo_path: repo_path ) )
431
335
  end
432
- prior = prior_attempt( pr: pr, repo_path: repo_path )
336
+ prior = prior_attempt( delivery: delivery )
433
337
  context[ :prior_attempt ] = prior if prior
434
338
  context
435
339
  rescue StandardError => exception
436
- puts_line " evidence gathering failed: #{exception.message}"
437
- { title: pr.fetch( "title", "" ) }
340
+ puts_line "evidence gathering failed for #{delivery.branch}: #{exception.message}"
341
+ { title: delivery.summary.to_s }
438
342
  end
439
343
 
440
344
  CI_LOG_LIMIT = 8_000
441
345
 
442
- def ci_evidence( pr:, repo_path: )
443
- branch = pr[ "headRefName" ].to_s
346
+ def ci_evidence( delivery:, repo_path: )
347
+ branch = delivery.branch
444
348
  stdout_text, _, status = Open3.capture3(
445
349
  "gh", "run", "list",
446
350
  "--branch", branch,
@@ -456,17 +360,10 @@ module Carson
456
360
 
457
361
  run_id = runs.first[ "databaseId" ].to_s
458
362
  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
- )
363
+ log_stdout, _, log_status = Open3.capture3( "gh", "run", "view", run_id, "--log-failed", chdir: repo_path )
464
364
  return { ci_run_url: run_url } unless log_status.success?
465
365
 
466
366
  { 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
367
  end
471
368
 
472
369
  def truncate_log( text:, limit: CI_LOG_LIMIT )
@@ -475,14 +372,13 @@ module Carson
475
372
  text[ -limit.. ]
476
373
  end
477
374
 
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 )
375
+ def review_evidence( delivery:, repo_path: )
376
+ repo_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
377
+ owner, repo = repo_runtime.send( :repository_coordinates )
378
+ details = repo_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: delivery.pull_request_number )
483
379
  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 )
380
+ threads = repo_runtime.send( :unresolved_thread_entries, details: details )
381
+ top_level = repo_runtime.send( :actionable_top_level_items, details: details, pr_author: pr_author )
486
382
 
487
383
  findings = []
488
384
  threads.each do |entry|
@@ -495,23 +391,12 @@ module Carson
495
391
  end
496
392
 
497
393
  { 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
394
  end
507
395
 
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 }
396
+ def prior_attempt( delivery: )
397
+ revision = ledger.revisions_for_delivery( delivery_id: delivery.id ).last
398
+ return nil unless revision&.failed?
399
+ { summary: revision.summary.to_s, dispatched_at: revision.started_at.to_s }
515
400
  end
516
401
 
517
402
  def thread_body( details:, url: )
@@ -533,94 +418,23 @@ module Carson
533
418
  ""
534
419
  end
535
420
 
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
- def write_govern_report( report: )
552
- report_dir = report_dir_path
553
- 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}"
560
- end
561
-
562
- def render_govern_markdown( report: )
563
- lines = []
564
- lines << "# Carson Govern Report"
565
- lines << ""
566
- lines << "**Cycle**: #{report[ :cycle_at ]}"
567
- lines << "**Dry run**: #{report[ :dry_run ]}"
568
- lines << ""
569
-
570
- Array( report[ :repos ] ).each do |repo_report|
571
- lines << "## #{repo_report[ :repo ]}"
572
- lines << ""
421
+ def print_govern_summary( report: )
422
+ Array( report[ :repositories ] ).each do |repo_report|
573
423
  if repo_report[ :error ]
574
- lines << "**Error**: #{repo_report[ :error ]}"
575
- lines << ""
424
+ puts_line "#{repo_report[ :repository ]}: #{repo_report[ :error ]}"
576
425
  next
577
426
  end
578
427
 
579
- prs = Array( repo_report[ :prs ] )
580
- if prs.empty?
581
- lines << "No open PRs."
582
- lines << ""
428
+ if repo_report[ :deliveries ].empty?
429
+ puts_line "#{repo_report[ :repository ]}: no active deliveries"
583
430
  next
584
431
  end
585
432
 
586
- prs.each do |pr|
587
- lines << "### PR ##{pr[ :number ]} #{pr[ :title ]}"
588
- 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?
593
- lines << ""
433
+ repo_report[ :deliveries ].each do |delivery|
434
+ puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]}: #{delivery[ :status ]} -> #{delivery[ :action ]}"
435
+ puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
594
436
  end
595
437
  end
596
-
597
- lines.join( "\n" )
598
- end
599
-
600
- 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
615
- end
616
- end
617
-
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)"
623
- end
624
438
  end
625
439
  end
626
440