carson 3.19.0 → 3.21.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -3
  3. data/RELEASE.md +25 -0
  4. data/VERSION +1 -1
  5. data/exe/carson +3 -3
  6. data/hooks/command-guard +56 -0
  7. data/hooks/pre-push +37 -1
  8. data/lib/carson/adapters/agent.rb +1 -0
  9. data/lib/carson/adapters/claude.rb +2 -0
  10. data/lib/carson/adapters/codex.rb +2 -0
  11. data/lib/carson/adapters/git.rb +2 -0
  12. data/lib/carson/adapters/github.rb +2 -0
  13. data/lib/carson/adapters/prompt.rb +2 -0
  14. data/lib/carson/cli.rb +415 -414
  15. data/lib/carson/config.rb +4 -3
  16. data/lib/carson/runtime/audit.rb +84 -84
  17. data/lib/carson/runtime/deliver.rb +27 -24
  18. data/lib/carson/runtime/govern.rb +29 -29
  19. data/lib/carson/runtime/housekeep.rb +15 -15
  20. data/lib/carson/runtime/local/hooks.rb +20 -0
  21. data/lib/carson/runtime/local/onboard.rb +17 -17
  22. data/lib/carson/runtime/local/prune.rb +13 -13
  23. data/lib/carson/runtime/local/sync.rb +6 -6
  24. data/lib/carson/runtime/local/template.rb +26 -25
  25. data/lib/carson/runtime/local/worktree.rb +76 -33
  26. data/lib/carson/runtime/local.rb +1 -0
  27. data/lib/carson/runtime/repos.rb +1 -1
  28. data/lib/carson/runtime/review/data_access.rb +1 -0
  29. data/lib/carson/runtime/review/gate_support.rb +15 -14
  30. data/lib/carson/runtime/review/query_text.rb +1 -0
  31. data/lib/carson/runtime/review/sweep_support.rb +5 -4
  32. data/lib/carson/runtime/review/utility.rb +2 -1
  33. data/lib/carson/runtime/review.rb +10 -8
  34. data/lib/carson/runtime/setup.rb +12 -10
  35. data/lib/carson/runtime/status.rb +20 -20
  36. data/lib/carson/runtime.rb +39 -25
  37. data/lib/carson/version.rb +1 -0
  38. data/lib/carson.rb +1 -0
  39. data/templates/.github/carson.md +7 -4
  40. metadata +2 -1
data/lib/carson/config.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # Loads and validates Carson configuration from global config and environment overrides.
1
2
  require "json"
2
3
 
3
4
  module Carson
@@ -85,8 +86,8 @@ module Carson
85
86
  parsed = JSON.parse( raw )
86
87
  raise ConfigError, "global config must be a JSON object at #{path}" unless parsed.is_a?( Hash )
87
88
  parsed
88
- rescue JSON::ParserError => e
89
- raise ConfigError, "invalid global config JSON at #{path} (#{e.message})"
89
+ rescue JSON::ParserError => exception
90
+ raise ConfigError, "invalid global config JSON at #{path} (#{exception.message})"
90
91
  end
91
92
 
92
93
  def self.global_config_path( repo_root: )
@@ -212,7 +213,7 @@ module Carson
212
213
  @audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
213
214
 
214
215
  govern_hash = fetch_hash( hash: data, key: "govern" )
215
- @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |p| safe_expand_path( p ) }
216
+ @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
216
217
  @govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
217
218
  @govern_merge_method = fetch_string( hash: govern_hash, key: "merge_method" ).downcase
218
219
  govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
@@ -11,7 +11,7 @@ module Carson
11
11
  return fingerprint_status unless fingerprint_status.nil?
12
12
  unless head_exists?
13
13
  if json_output
14
- out.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
14
+ output.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
15
15
  else
16
16
  puts_line "No commits yet — audit skipped for initial commit."
17
17
  end
@@ -69,24 +69,24 @@ module Carson
69
69
  audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( monitor_report.fetch( :status ) )
70
70
  if monitor_report.fetch( :status ) == "attention"
71
71
  checks = monitor_report.fetch( :checks )
72
- fail_n = checks.fetch( :failing_count )
73
- pend_n = checks.fetch( :pending_count )
72
+ failing_count = checks.fetch( :failing_count )
73
+ pending_count = checks.fetch( :pending_count )
74
74
  total = checks.fetch( :required_total )
75
75
  fail_names = checks.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
76
- if fail_n.positive? && pend_n.positive?
77
- audit_concise_problems << "Checks: #{fail_n} failing (#{fail_names}), #{pend_n} pending of #{total} required."
78
- elsif fail_n.positive?
79
- audit_concise_problems << "Checks: #{fail_n} of #{total} failing (#{fail_names})."
80
- elsif pend_n.positive?
81
- audit_concise_problems << "Checks: pending (#{total - pend_n} of #{total} complete)."
76
+ if failing_count.positive? && pending_count.positive?
77
+ audit_concise_problems << "Checks: #{failing_count} failing (#{fail_names}), #{pending_count} pending of #{total} required."
78
+ elsif failing_count.positive?
79
+ audit_concise_problems << "Checks: #{failing_count} of #{total} failing (#{fail_names})."
80
+ elsif pending_count.positive?
81
+ audit_concise_problems << "Checks: pending (#{total - pending_count} of #{total} complete)."
82
82
  end
83
83
  end
84
84
  puts_verbose ""
85
85
  puts_verbose "[Default Branch CI Baseline (gh)]"
86
86
  default_branch_baseline = default_branch_ci_baseline_report
87
87
  audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( default_branch_baseline.fetch( :status ) )
88
- baseline_st = default_branch_baseline.fetch( :status )
89
- if baseline_st == "block"
88
+ baseline_status = default_branch_baseline.fetch( :status )
89
+ if baseline_status == "block"
90
90
  parts = []
91
91
  if default_branch_baseline.fetch( :failing_count ).positive?
92
92
  names = default_branch_baseline.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
@@ -98,7 +98,7 @@ module Carson
98
98
  end
99
99
  parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
100
100
  audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
101
- elsif baseline_st == "attention"
101
+ elsif baseline_status == "attention"
102
102
  parts = []
103
103
  if default_branch_baseline.fetch( :advisory_failing_count ).positive?
104
104
  names = default_branch_baseline.fetch( :advisory_failing ).map { it.fetch( :name ) }.join( ", " )
@@ -143,7 +143,7 @@ module Carson
143
143
  problems: audit_concise_problems,
144
144
  exit_code: exit_code
145
145
  }
146
- out.puts JSON.pretty_generate( result )
146
+ output.puts JSON.pretty_generate( result )
147
147
  else
148
148
  puts_verbose ""
149
149
  puts_verbose "[Audit Result]"
@@ -182,8 +182,8 @@ module Carson
182
182
  end
183
183
 
184
184
  begin
185
- rt = build_scoped_runtime( repo_path: repo_path )
186
- status = rt.audit!
185
+ scoped_runtime = build_scoped_runtime( repo_path: repo_path )
186
+ status = scoped_runtime.audit!
187
187
  case status
188
188
  when EXIT_OK
189
189
  puts_line "#{repo_name}: ok" unless verbose?
@@ -197,9 +197,9 @@ module Carson
197
197
  record_batch_skip( command: "audit", repo_path: repo_path, reason: "audit failed" )
198
198
  failed += 1
199
199
  end
200
- rescue StandardError => e
201
- puts_line "#{repo_name}: FAIL (#{e.message})"
202
- record_batch_skip( command: "audit", repo_path: repo_path, reason: e.message )
200
+ rescue StandardError => exception
201
+ puts_line "#{repo_name}: FAIL (#{exception.message})"
202
+ record_batch_skip( command: "audit", repo_path: repo_path, reason: exception.message )
203
203
  failed += 1
204
204
  end
205
205
  end
@@ -212,20 +212,20 @@ module Carson
212
212
  private
213
213
  def pr_and_check_report
214
214
  report = {
215
- generated_at: Time.now.utc.iso8601,
216
- branch: current_branch,
217
- status: "ok",
218
- skip_reason: nil,
219
- pr: nil,
220
- checks: {
221
- status: "unknown",
222
- skip_reason: nil,
223
- required_total: 0,
224
- failing_count: 0,
225
- pending_count: 0,
226
- failing: [],
227
- pending: []
228
- }
215
+ generated_at: Time.now.utc.iso8601,
216
+ branch: current_branch,
217
+ status: "ok",
218
+ skip_reason: nil,
219
+ pr: nil,
220
+ checks: {
221
+ status: "unknown",
222
+ skip_reason: nil,
223
+ required_total: 0,
224
+ failing_count: 0,
225
+ pending_count: 0,
226
+ failing: [],
227
+ pending: []
228
+ }
229
229
  }
230
230
  unless gh_available?
231
231
  report[ :status ] = "skipped"
@@ -243,11 +243,11 @@ module Carson
243
243
  end
244
244
  pr_data = JSON.parse( pr_stdout )
245
245
  report[ :pr ] = {
246
- number: pr_data[ "number" ],
247
- title: pr_data[ "title" ].to_s,
248
- url: pr_data[ "url" ].to_s,
249
- state: pr_data[ "state" ].to_s,
250
- review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
246
+ number: pr_data[ "number" ],
247
+ title: pr_data[ "title" ].to_s,
248
+ url: pr_data[ "url" ].to_s,
249
+ state: pr_data[ "state" ].to_s,
250
+ review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
251
251
  }
252
252
  puts_verbose "pr: ##{report.dig( :pr, :number )} #{report.dig( :pr, :title )}"
253
253
  puts_verbose "url: #{report.dig( :pr, :url )}"
@@ -277,9 +277,9 @@ module Carson
277
277
  report.dig( :checks, :pending ).each { |entry| puts_verbose "check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
278
278
  report[ :status ] = "attention" if report.dig( :checks, :failing_count ).positive? || report.dig( :checks, :pending_count ).positive?
279
279
  report
280
- rescue JSON::ParserError => e
280
+ rescue JSON::ParserError => exception
281
281
  report[ :status ] = "skipped"
282
- report[ :skip_reason ] = "invalid gh JSON response (#{e.message})"
282
+ report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
283
283
  puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
284
284
  report
285
285
  end
@@ -287,22 +287,22 @@ module Carson
287
287
  # Evaluates default-branch CI health so stale workflow drift blocks before merge.
288
288
  def default_branch_ci_baseline_report
289
289
  report = {
290
- status: "ok",
291
- skip_reason: nil,
292
- repository: nil,
293
- default_branch: nil,
294
- head_sha: nil,
295
- workflows_total: 0,
296
- check_runs_total: 0,
297
- failing_count: 0,
298
- pending_count: 0,
299
- advisory_failing_count: 0,
300
- advisory_pending_count: 0,
301
- no_check_evidence: false,
302
- failing: [],
303
- pending: [],
304
- advisory_failing: [],
305
- advisory_pending: []
290
+ status: "ok",
291
+ skip_reason: nil,
292
+ repository: nil,
293
+ default_branch: nil,
294
+ head_sha: nil,
295
+ workflows_total: 0,
296
+ check_runs_total: 0,
297
+ failing_count: 0,
298
+ pending_count: 0,
299
+ advisory_failing_count: 0,
300
+ advisory_pending_count: 0,
301
+ no_check_evidence: false,
302
+ failing: [],
303
+ pending: [],
304
+ advisory_failing: [],
305
+ advisory_pending: []
306
306
  }
307
307
  unless gh_available?
308
308
  report[ :status ] = "skipped"
@@ -313,30 +313,30 @@ module Carson
313
313
  owner, repo = repository_coordinates
314
314
  report[ :repository ] = "#{owner}/#{repo}"
315
315
  repository_data = gh_json_payload!(
316
- "api", "repos/#{owner}/#{repo}",
317
- "--method", "GET",
318
- fallback: "unable to read repository metadata for #{owner}/#{repo}"
316
+ "api", "repos/#{owner}/#{repo}",
317
+ "--method", "GET",
318
+ fallback: "unable to read repository metadata for #{owner}/#{repo}"
319
319
  )
320
320
  default_branch = blank_to( value: repository_data[ "default_branch" ], default: config.main_branch )
321
321
  report[ :default_branch ] = default_branch
322
322
  branch_data = gh_json_payload!(
323
- "api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
324
- "--method", "GET",
325
- fallback: "unable to read default branch #{default_branch}"
323
+ "api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
324
+ "--method", "GET",
325
+ fallback: "unable to read default branch #{default_branch}"
326
326
  )
327
327
  head_sha = branch_data.dig( "commit", "sha" ).to_s.strip
328
328
  raise "default branch #{default_branch} has no commit SHA" if head_sha.empty?
329
329
  report[ :head_sha ] = head_sha
330
330
  workflow_entries = default_branch_workflow_entries(
331
- owner: owner,
332
- repo: repo,
333
- default_branch: default_branch
331
+ owner: owner,
332
+ repo: repo,
333
+ default_branch: default_branch
334
334
  )
335
335
  report[ :workflows_total ] = workflow_entries.count
336
336
  check_runs_payload = gh_json_payload!(
337
- "api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
338
- "--method", "GET",
339
- fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
337
+ "api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
338
+ "--method", "GET",
339
+ fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
340
340
  )
341
341
  check_runs = Array( check_runs_payload[ "check_runs" ] )
342
342
  failing, pending = partition_default_branch_check_runs( check_runs: check_runs )
@@ -374,14 +374,14 @@ module Carson
374
374
  puts_verbose "ACTION: default branch has workflow files but no check-runs; align workflow triggers and branch protection check names."
375
375
  end
376
376
  report
377
- rescue JSON::ParserError => e
377
+ rescue JSON::ParserError => exception
378
378
  report[ :status ] = "skipped"
379
- report[ :skip_reason ] = "invalid gh JSON response (#{e.message})"
379
+ report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
380
380
  puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
381
381
  report
382
- rescue StandardError => e
382
+ rescue StandardError => exception
383
383
  report[ :status ] = "skipped"
384
- report[ :skip_reason ] = e.message
384
+ report[ :skip_reason ] = exception.message
385
385
  puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
386
386
  report
387
387
  end
@@ -399,15 +399,15 @@ module Carson
399
399
  # Reads workflow files from default branch; missing workflow directory is valid and returns none.
400
400
  def default_branch_workflow_entries( owner:, repo:, default_branch: )
401
401
  stdout_text, stderr_text, success, = gh_run(
402
- "api", "repos/#{owner}/#{repo}/contents/.github/workflows",
403
- "--method", "GET",
404
- "-f", "ref=#{default_branch}"
402
+ "api", "repos/#{owner}/#{repo}/contents/.github/workflows",
403
+ "--method", "GET",
404
+ "-f", "ref=#{default_branch}"
405
405
  )
406
406
  unless success
407
407
  error_text = gh_error_text(
408
- stdout_text: stdout_text,
409
- stderr_text: stderr_text,
410
- fallback: "unable to read workflow files for #{default_branch}"
408
+ stdout_text: stdout_text,
409
+ stderr_text: stderr_text,
410
+ fallback: "unable to read workflow files for #{default_branch}"
411
411
  )
412
412
  return [] if error_text.match?( /\b404\b/ )
413
413
  raise error_text
@@ -443,7 +443,7 @@ module Carson
443
443
  end
444
444
 
445
445
  # Returns true when a required-check entry is in a non-passing, non-pending state.
446
- # Cancelled, errored, timed-out, and any unknown bucket all count as failing.
446
+ # Cancelled, errored, timed-output, and any unknown bucket all count as failing.
447
447
  def check_entry_failing?( entry: )
448
448
  !%w[pass pending].include?( entry[ "bucket" ].to_s )
449
449
  end
@@ -474,10 +474,10 @@ module Carson
474
474
  blank_to( value: entry[ "status" ], default: "UNKNOWN" )
475
475
  end
476
476
  {
477
- workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
478
- name: blank_to( value: entry[ "name" ], default: "check" ),
479
- state: state.upcase,
480
- link: entry[ "html_url" ].to_s
477
+ workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
478
+ name: blank_to( value: entry[ "name" ], default: "check" ),
479
+ state: state.upcase,
480
+ link: entry[ "html_url" ].to_s
481
481
  }
482
482
  end
483
483
  end
@@ -487,8 +487,8 @@ module Carson
487
487
  markdown_path, json_path = write_pr_monitor_report( report: report )
488
488
  puts_verbose "report_markdown: #{markdown_path}"
489
489
  puts_verbose "report_json: #{json_path}"
490
- rescue StandardError => e
491
- puts_verbose "report_write: SKIP (#{e.message})"
490
+ rescue StandardError => exception
491
+ puts_verbose "report_write: SKIP (#{exception.message})"
492
492
  end
493
493
 
494
494
  # Persists report in both machine-readable JSON and human-readable Markdown.
@@ -90,7 +90,7 @@ module Carson
90
90
  result[ :exit_code ] = exit_code
91
91
 
92
92
  if json_output
93
- out.puts JSON.pretty_generate( result )
93
+ output.puts JSON.pretty_generate( result )
94
94
  else
95
95
  print_deliver_human( result: result )
96
96
  end
@@ -136,8 +136,11 @@ module Carson
136
136
  end
137
137
 
138
138
  # Pushes the branch to the remote with tracking.
139
+ # Sets CARSON_PUSH=1 so the pre-push hook knows this is a Carson-managed push.
139
140
  def push_branch!( branch:, remote:, result: )
140
- _, push_stderr, push_success, = git_run( "push", "-u", remote, branch )
141
+ _, push_stderr, push_success, = with_env_var( "CARSON_PUSH", "1" ) do
142
+ git_run( "push", "-u", remote, branch )
143
+ end
141
144
  unless push_success
142
145
  error_text = push_stderr.to_s.strip
143
146
  error_text = "push failed" if error_text.empty?
@@ -210,27 +213,27 @@ module Carson
210
213
 
211
214
  # Generates a default PR title from the branch name.
212
215
  def default_pr_title( branch: )
213
- branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { |c| c.upcase }
216
+ branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { it.upcase }
214
217
  end
215
218
 
216
219
  # Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
217
- # Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
218
- def check_pr_ci( number: )
219
- stdout, _, success, = gh_run(
220
- "pr", "checks", number.to_s,
221
- "--json", "name,bucket"
222
- )
223
- return :none unless success
224
-
225
- checks = JSON.parse( stdout ) rescue []
226
- return :none if checks.empty?
227
-
228
- buckets = checks.map { |c| c[ "bucket" ].to_s.downcase }
229
- return :fail if buckets.include?( "fail" )
230
- return :pending if buckets.include?( "pending" )
231
-
232
- :pass
233
- end
220
+ # Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
221
+ def check_pr_ci( number: )
222
+ stdout, _, success, = gh_run(
223
+ "pr", "checks", number.to_s,
224
+ "--json", "name,bucket"
225
+ )
226
+ return :none unless success
227
+
228
+ checks = JSON.parse( stdout ) rescue []
229
+ return :none if checks.empty?
230
+
231
+ buckets = checks.map { it[ "bucket" ].to_s.downcase }
232
+ return :fail if buckets.include?( "fail" )
233
+ return :pending if buckets.include?( "pending" )
234
+
235
+ :pass
236
+ end
234
237
 
235
238
  # Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
236
239
  def check_pr_review( number: )
@@ -253,7 +256,7 @@ end
253
256
  # Merges the PR using the configured merge method.
254
257
  # Deliberately omits --delete-branch: gh tries to switch the local
255
258
  # checkout to main afterwards, which fails inside a worktree where
256
- # main is already checked out. Branch cleanup deferred to `carson prune`.
259
+ # main is already checked output. Branch cleanup deferred to `carson prune`.
257
260
  def merge_pr!( number:, result: )
258
261
  method = config.govern_merge_method
259
262
  result[ :merge_method ] = method
@@ -277,7 +280,7 @@ end
277
280
  # Syncs main after a successful merge.
278
281
  # Pulls into the main worktree directly — does not attempt checkout,
279
282
  # because checkout would fail when running inside a feature worktree
280
- # (main is already checked out in the main tree).
283
+ # (main is already checked output in the main tree).
281
284
  def sync_after_merge!( remote:, main:, result: )
282
285
  main_root = main_worktree_root
283
286
  _, pull_stderr, pull_success, = Open3.capture3(
@@ -298,8 +301,8 @@ end
298
301
  def compute_post_merge_next_step!( result: )
299
302
  main_root = main_worktree_root
300
303
  cwd = realpath_safe( Dir.pwd )
301
- current_wt = worktree_list.select { |wt| wt.fetch( :path ) != realpath_safe( main_root ) }
302
- .find { |wt| cwd == wt.fetch( :path ) || cwd.start_with?( File.join( wt.fetch( :path ), "" ) ) }
304
+ current_wt = worktree_list.select { it.fetch( :path ) != realpath_safe( main_root ) }
305
+ .find { cwd == it.fetch( :path ) || cwd.start_with?( File.join( it.fetch( :path ), "" ) ) }
303
306
 
304
307
  if current_wt
305
308
  wt_name = File.basename( current_wt.fetch( :path ) )
@@ -55,8 +55,8 @@ module Carson
55
55
  end
56
56
 
57
57
  EXIT_OK
58
- rescue StandardError => e
59
- puts_line "ERROR: govern failed — #{e.message}"
58
+ rescue StandardError => exception
59
+ puts_line "ERROR: govern failed — #{exception.message}"
60
60
  EXIT_ERROR
61
61
  end
62
62
 
@@ -69,8 +69,8 @@ module Carson
69
69
  puts_line "── cycle #{cycle_count} at #{Time.now.utc.strftime( "%Y-%m-%d %H:%M:%S UTC" )} ──"
70
70
  begin
71
71
  govern_cycle!( dry_run: dry_run, json_output: json_output )
72
- rescue StandardError => e
73
- puts_line "ERROR: cycle #{cycle_count} failed — #{e.message}"
72
+ rescue StandardError => exception
73
+ puts_line "ERROR: cycle #{cycle_count} failed — #{exception.message}"
74
74
  end
75
75
  puts_line "sleeping #{loop_seconds}s until next cycle…"
76
76
  sleep loop_seconds
@@ -145,8 +145,8 @@ module Carson
145
145
  return nil
146
146
  end
147
147
  JSON.parse( stdout_text )
148
- rescue JSON::ParserError => e
149
- puts_line "gh pr list returned invalid JSON: #{e.message}"
148
+ rescue JSON::ParserError => exception
149
+ puts_line "gh pr list returned invalid JSON: #{exception.message}"
150
150
  nil
151
151
  end
152
152
 
@@ -208,10 +208,10 @@ module Carson
208
208
  checks = Array( pr[ "statusCheckRollup" ] )
209
209
  return :green if checks.empty?
210
210
 
211
- has_failure = checks.any? { |c| check_state_failing?( state: c[ "state" ].to_s ) || check_conclusion_failing?( conclusion: c[ "conclusion" ].to_s ) }
211
+ has_failure = checks.any? { check_state_failing?( state: it[ "state" ].to_s ) || check_conclusion_failing?( conclusion: it[ "conclusion" ].to_s ) }
212
212
  return :red if has_failure
213
213
 
214
- has_pending = checks.any? { |c| check_state_pending?( state: c[ "state" ].to_s ) }
214
+ has_pending = checks.any? { check_state_pending?( state: it[ "state" ].to_s ) }
215
215
  return :pending if has_pending
216
216
 
217
217
  :green
@@ -345,13 +345,13 @@ module Carson
345
345
 
346
346
  # Runs sync + prune in the given repo after a successful merge.
347
347
  def housekeep_repo!( repo_path: )
348
- rt = if repo_path == self.repo_root
348
+ scoped_runtime = if repo_path == self.repo_root
349
349
  self
350
350
  else
351
- Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
351
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
352
352
  end
353
- sync_status = rt.sync!
354
- rt.prune! if sync_status == EXIT_OK
353
+ sync_status = scoped_runtime.sync!
354
+ scoped_runtime.prune! if sync_status == EXIT_OK
355
355
  end
356
356
 
357
357
  # Selects which agent provider to use based on config and availability.
@@ -410,18 +410,18 @@ module Carson
410
410
 
411
411
  # Evidence gathering — builds structured context Hash for agent work orders.
412
412
  def evidence( pr:, repo_path:, objective: )
413
- ctx = { title: pr.fetch( "title", "" ) }
413
+ context = { title: pr.fetch( "title", "" ) }
414
414
  case objective
415
415
  when "fix_ci"
416
- ctx.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
416
+ context.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
417
417
  when "address_review"
418
- ctx.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
418
+ context.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
419
419
  end
420
420
  prior = prior_attempt( pr: pr, repo_path: repo_path )
421
- ctx[ :prior_attempt ] = prior if prior
422
- ctx
423
- rescue StandardError => e
424
- puts_line " evidence gathering failed: #{e.message}"
421
+ context[ :prior_attempt ] = prior if prior
422
+ context
423
+ rescue StandardError => exception
424
+ puts_line " evidence gathering failed: #{exception.message}"
425
425
  { title: pr.fetch( "title", "" ) }
426
426
  end
427
427
 
@@ -452,8 +452,8 @@ module Carson
452
452
  return { ci_run_url: run_url } unless log_status.success?
453
453
 
454
454
  { ci_logs: truncate_log( text: log_stdout ), ci_run_url: run_url }
455
- rescue StandardError => e
456
- puts_line " ci_evidence failed: #{e.message}"
455
+ rescue StandardError => exception
456
+ puts_line " ci_evidence failed: #{exception.message}"
457
457
  {}
458
458
  end
459
459
 
@@ -464,13 +464,13 @@ module Carson
464
464
  end
465
465
 
466
466
  def review_evidence( pr:, repo_path: )
467
- rt = scoped_runtime( repo_path: repo_path )
468
- owner, repo = rt.send( :repository_coordinates )
467
+ scoped_runtime = scoped_runtime( repo_path: repo_path )
468
+ owner, repo = scoped_runtime.send( :repository_coordinates )
469
469
  pr_number = pr[ "number" ]
470
- details = rt.send( :pull_request_details, owner: owner, repo: repo, pr_number: pr_number )
470
+ details = scoped_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: pr_number )
471
471
  pr_author = details.dig( :author, :login ).to_s
472
- threads = rt.send( :unresolved_thread_entries, details: details )
473
- top_level = rt.send( :actionable_top_level_items, details: details, pr_author: pr_author )
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 )
474
474
 
475
475
  findings = []
476
476
  threads.each do |entry|
@@ -483,14 +483,14 @@ module Carson
483
483
  end
484
484
 
485
485
  { review_findings: findings }
486
- rescue StandardError => e
487
- puts_line " review_evidence failed: #{e.message}"
486
+ rescue StandardError => exception
487
+ puts_line " review_evidence failed: #{exception.message}"
488
488
  {}
489
489
  end
490
490
 
491
491
  def scoped_runtime( repo_path: )
492
492
  return self if repo_path == self.repo_root
493
- Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
493
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
494
494
  end
495
495
 
496
496
  def prior_attempt( pr:, repo_path: )
@@ -64,9 +64,9 @@ module Carson
64
64
  return unless gh_available?
65
65
 
66
66
  main_root = main_worktree_root
67
- worktree_list.each do |wt|
68
- path = wt.fetch( :path )
69
- branch = wt.fetch( :branch, nil )
67
+ worktree_list.each do |worktree|
68
+ path = worktree.fetch( :path )
69
+ branch = worktree.fetch( :branch, nil )
70
70
  next if path == main_root
71
71
  next unless branch
72
72
  next if cwd_inside_worktree?( worktree_path: path )
@@ -121,26 +121,26 @@ module Carson
121
121
  return { name: repo_name, path: repo_path, status: "error", error: "path not found" }
122
122
  end
123
123
 
124
- buf = verbose? ? out : StringIO.new
125
- err_buf = verbose? ? err : StringIO.new
126
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
124
+ buffer = verbose? ? output : StringIO.new
125
+ error_buffer = verbose? ? error : StringIO.new
126
+ scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
127
127
 
128
- sync_status = rt.sync!
128
+ sync_status = scoped_runtime.sync!
129
129
  if sync_status == EXIT_OK
130
- rt.reap_dead_worktrees!
131
- prune_status = rt.prune!
130
+ scoped_runtime.reap_dead_worktrees!
131
+ prune_status = scoped_runtime.prune!
132
132
  end
133
133
 
134
134
  ok = sync_status == EXIT_OK && prune_status == EXIT_OK
135
135
  unless verbose? || silent
136
- summary = strip_badge( buf.string.lines.last.to_s.strip )
136
+ summary = strip_badge( buffer.string.lines.last.to_s.strip )
137
137
  puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
138
138
  end
139
139
 
140
140
  { name: repo_name, path: repo_path, status: ok ? "ok" : "error" }
141
- rescue StandardError => e
142
- puts_line "#{repo_name}: FAIL (#{e.message})" unless silent
143
- { name: repo_name, path: repo_path, status: "error", error: e.message }
141
+ rescue StandardError => exception
142
+ puts_line "#{repo_name}: FAIL (#{exception.message})" unless silent
143
+ { name: repo_name, path: repo_path, status: "error", error: exception.message }
144
144
  end
145
145
 
146
146
  # Strips the Carson badge prefix from a message to avoid double-badging.
@@ -156,7 +156,7 @@ module Carson
156
156
  return expanded if repos.include?( expanded )
157
157
 
158
158
  downcased = File.basename( target ).downcase
159
- repos.find { |r| File.basename( r ).downcase == downcased }
159
+ repos.find { |repo_path| File.basename( repo_path ).downcase == downcased }
160
160
  end
161
161
 
162
162
  # Unified output — JSON or human-readable.
@@ -164,7 +164,7 @@ module Carson
164
164
  result[ :exit_code ] = exit_code
165
165
 
166
166
  if json_output
167
- out.puts JSON.pretty_generate( result )
167
+ output.puts JSON.pretty_generate( result )
168
168
  else
169
169
  if results && ( succeeded || failed )
170
170
  total = ( succeeded || 0 ) + ( failed || 0 )
@@ -1,3 +1,4 @@
1
+ # Installs, validates, and reports on managed git hooks for governed repositories.
1
2
  module Carson
2
3
  class Runtime
3
4
  module Local
@@ -30,11 +31,30 @@ module Carson
30
31
  end
31
32
  git_system!( "config", "core.hooksPath", hooks_dir )
32
33
  File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
34
+ install_command_guard!
33
35
  puts_verbose "configured_hooks_path: #{hooks_dir}"
34
36
  puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
35
37
  EXIT_OK
36
38
  end
37
39
 
40
+ # Installs the command guard hook to a stable (non-versioned) path.
41
+ # Claude Code's PreToolUse hook references this path — it must not change across upgrades.
42
+ def install_command_guard!
43
+ source = hook_template_path( hook_name: "command-guard" )
44
+ return unless File.file?( source )
45
+
46
+ target = command_guard_path
47
+ FileUtils.mkdir_p( File.dirname( target ) )
48
+ FileUtils.cp( source, target )
49
+ FileUtils.chmod( 0o755, target )
50
+ puts_verbose "command_guard: #{target}"
51
+ end
52
+
53
+ # Stable path for the command guard script — not versioned so external references survive upgrades.
54
+ def command_guard_path
55
+ File.expand_path( File.join( config.hooks_path, "command-guard" ) )
56
+ end
57
+
38
58
  # Canonical hook template location inside Carson repository.
39
59
  def hook_template_path( hook_name: )
40
60
  File.join( tool_root, "hooks", hook_name )