carson 3.24.0 → 3.27.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.
data/lib/carson/cli.rb CHANGED
@@ -64,8 +64,10 @@ module Carson
64
64
  parser.separator " status Show repository delivery state"
65
65
  parser.separator " setup Initialise Carson configuration"
66
66
  parser.separator " audit Run pre-commit health checks"
67
+ parser.separator " abandon Close and clean up abandoned delivery work"
67
68
  parser.separator " sync Sync local main with remote"
68
69
  parser.separator " deliver Start autonomous branch delivery"
70
+ parser.separator " recover Merge the repair PR for one baseline-red governance check"
69
71
  parser.separator " prune Remove stale local branches"
70
72
  parser.separator " worktree Manage isolated coding worktrees"
71
73
  parser.separator " housekeep Sync, reap worktrees, and prune branches"
@@ -120,12 +122,16 @@ module Carson
120
122
  parse_review_subcommand( arguments: arguments, error: error )
121
123
  when "audit"
122
124
  parse_audit_command( arguments: arguments, error: error )
125
+ when "abandon"
126
+ parse_abandon_command( arguments: arguments, error: error )
123
127
  when "sync"
124
128
  parse_sync_command( arguments: arguments, error: error )
125
129
  when "status"
126
130
  parse_status_command( arguments: arguments, error: error )
127
131
  when "deliver"
128
132
  parse_deliver_command( arguments: arguments, error: error )
133
+ when "recover"
134
+ parse_recover_command( arguments: arguments, error: error )
129
135
  when "govern"
130
136
  parse_govern_subcommand( arguments: arguments, error: error )
131
137
  else
@@ -303,7 +309,7 @@ module Carson
303
309
  def self.parse_worktree_subcommand( arguments:, error: )
304
310
  options = { json: false, force: false }
305
311
  worktree_parser = OptionParser.new do |parser|
306
- parser.banner = "Usage: carson worktree <create|remove> <name> [options]"
312
+ parser.banner = "Usage: carson worktree <create|list|remove> <name> [options]"
307
313
  parser.separator ""
308
314
  parser.separator "Manage isolated worktrees for coding agents."
309
315
  parser.separator "Create auto-syncs main before branching. Remove guards against"
@@ -311,6 +317,7 @@ module Carson
311
317
  parser.separator ""
312
318
  parser.separator "Subcommands:"
313
319
  parser.separator " create <name> Create a new worktree with a fresh branch"
320
+ parser.separator " list List registered worktrees with cleanup status"
314
321
  parser.separator " remove <name> [--force] Remove a worktree (--force skips safety checks)"
315
322
  parser.separator ""
316
323
  parser.separator "Options:"
@@ -319,6 +326,7 @@ module Carson
319
326
  parser.separator ""
320
327
  parser.separator "Examples:"
321
328
  parser.separator " carson worktree create feature-x Create an isolated worktree"
329
+ parser.separator " carson worktree list Show registered worktrees"
322
330
  parser.separator " carson worktree remove feature-x Remove after work is pushed"
323
331
  end
324
332
  worktree_parser.parse!( arguments )
@@ -338,6 +346,8 @@ module Carson
338
346
  return { command: :invalid }
339
347
  end
340
348
  { command: "worktree:create", worktree_name: name, json: options[ :json ] }
349
+ when "list"
350
+ { command: "worktree:list", json: options[ :json ] }
341
351
  when "remove"
342
352
  worktree_path = arguments.shift
343
353
  if worktree_path.to_s.strip.empty?
@@ -478,6 +488,38 @@ module Carson
478
488
  { command: :invalid }
479
489
  end
480
490
 
491
+ # --- abandon ---
492
+
493
+ def self.parse_abandon_command( arguments:, error: )
494
+ options = { json: false }
495
+ abandon_parser = OptionParser.new do |parser|
496
+ parser.banner = "Usage: carson abandon <pr-number|pr-url|branch> [--json]"
497
+ parser.separator ""
498
+ parser.separator "Close an abandoned delivery and clean up its worktree and branch when safe."
499
+ parser.separator ""
500
+ parser.separator "Options:"
501
+ parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
502
+ parser.separator ""
503
+ parser.separator "Examples:"
504
+ parser.separator " carson abandon 301"
505
+ parser.separator " carson abandon https://github.com/acme/widgets/pull/301"
506
+ parser.separator " carson abandon codex/feature-branch"
507
+ end
508
+ abandon_parser.parse!( arguments )
509
+ target = arguments.shift.to_s.strip
510
+ if target.empty? || !arguments.empty?
511
+ error.puts "#{BADGE} Use: carson abandon <pr-number|pr-url|branch>"
512
+ error.puts abandon_parser
513
+ return { command: :invalid }
514
+ end
515
+
516
+ { command: "abandon", target: target, json: options.fetch( :json ) }
517
+ rescue OptionParser::ParseError => exception
518
+ error.puts "#{BADGE} #{exception.message}"
519
+ error.puts abandon_parser
520
+ { command: :invalid }
521
+ end
522
+
481
523
  # --- sync ---
482
524
 
483
525
  def self.parse_sync_command( arguments:, error: )
@@ -591,6 +633,47 @@ module Carson
591
633
  { command: :invalid }
592
634
  end
593
635
 
636
+ # --- recover ---
637
+
638
+ def self.parse_recover_command( arguments:, error: )
639
+ options = { json: false, check_name: nil }
640
+ recover_parser = OptionParser.new do |parser|
641
+ parser.banner = "Usage: carson recover --check NAME [--json]"
642
+ parser.separator ""
643
+ parser.separator "Merge the current repair PR when one governance-owned required check is already red on the default branch."
644
+ parser.separator "Recovery is narrow: Carson verifies the baseline failure, keeps every other gate intact, and records an audit event."
645
+ parser.separator ""
646
+ parser.separator "Options:"
647
+ parser.on( "--check NAME", "Name of the governance-owned required check to recover" ) { |value| options[ :check_name ] = value }
648
+ parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
649
+ parser.separator ""
650
+ parser.separator "Examples:"
651
+ parser.separator " carson recover --check \"Carson governance\""
652
+ parser.separator " carson recover --check \"Carson governance\" --json"
653
+ end
654
+ recover_parser.parse!( arguments )
655
+ if options.fetch( :check_name, nil ).to_s.strip.empty?
656
+ error.puts "#{BADGE} --check requires a non-empty governance check name"
657
+ error.puts recover_parser
658
+ return { command: :invalid }
659
+ end
660
+ unless arguments.empty?
661
+ error.puts "#{BADGE} Unexpected arguments for recover: #{arguments.join( ' ' )}"
662
+ error.puts recover_parser
663
+ return { command: :invalid }
664
+ end
665
+
666
+ {
667
+ command: "recover",
668
+ json: options.fetch( :json ),
669
+ check_name: options.fetch( :check_name )
670
+ }
671
+ rescue OptionParser::ParseError => exception
672
+ error.puts "#{BADGE} #{exception.message}"
673
+ error.puts recover_parser
674
+ { command: :invalid }
675
+ end
676
+
594
677
  # --- repos ---
595
678
 
596
679
  def self.parse_repos_command( arguments:, error: )
@@ -623,9 +706,9 @@ module Carson
623
706
  # --- housekeep ---
624
707
 
625
708
  def self.parse_housekeep_command( arguments:, error: )
626
- options = { all: false, json: false, dry_run: false }
709
+ options = { all: false, json: false, dry_run: false, loop_seconds: nil }
627
710
  housekeep_parser = OptionParser.new do |parser|
628
- parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json]"
711
+ parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json] [--loop SECONDS]"
629
712
  parser.separator ""
630
713
  parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
631
714
  parser.separator "Defaults to the current repository."
@@ -634,21 +717,31 @@ module Carson
634
717
  parser.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
635
718
  parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { options[ :dry_run ] = true }
636
719
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
720
+ parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles (requires --all)" ) do |seconds|
721
+ error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
722
+ options[ :loop_seconds ] = seconds
723
+ end
637
724
  parser.separator ""
638
725
  parser.separator "Examples:"
639
726
  parser.separator " carson housekeep Housekeep the current repository"
640
727
  parser.separator " carson housekeep --dry-run Preview what housekeep would do"
641
728
  parser.separator " carson housekeep nexus Housekeep a named governed repo"
642
729
  parser.separator " carson housekeep --all Housekeep all governed repos"
730
+ parser.separator " carson housekeep --all --loop 300 Housekeep every 5 minutes"
643
731
  end
644
732
  housekeep_parser.parse!( arguments )
645
733
 
734
+ if options[ :loop_seconds ] && !options[ :all ]
735
+ error.puts "#{BADGE} --loop requires --all"
736
+ return { command: :invalid }
737
+ end
738
+
646
739
  if options[ :all ] && !arguments.empty?
647
740
  error.puts "#{BADGE} --all and repo target are mutually exclusive. Use: carson housekeep --all OR carson housekeep [repo]"
648
741
  return { command: :invalid }
649
742
  end
650
743
 
651
- return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ] } if options[ :all ]
744
+ return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ], loop_seconds: options[ :loop_seconds ] } if options[ :all ]
652
745
 
653
746
  if arguments.length > 1
654
747
  error.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
@@ -745,6 +838,8 @@ module Carson
745
838
  runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) )
746
839
  when "audit"
747
840
  runtime.audit!( json_output: parsed.fetch( :json, false ) )
841
+ when "abandon"
842
+ runtime.abandon!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
748
843
  when "sync"
749
844
  runtime.sync!( json_output: parsed.fetch( :json, false ) )
750
845
  when "prune"
@@ -753,6 +848,8 @@ module Carson
753
848
  runtime.prune_all!
754
849
  when "worktree:create"
755
850
  runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
851
+ when "worktree:list"
852
+ runtime.worktree_list!( json_output: parsed.fetch( :json, false ) )
756
853
  when "worktree:remove"
757
854
  runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
758
855
  when "onboard"
@@ -774,6 +871,11 @@ module Carson
774
871
  commit_message: parsed.fetch( :commit_message, nil ),
775
872
  json_output: parsed.fetch( :json, false )
776
873
  )
874
+ when "recover"
875
+ runtime.recover!(
876
+ check_name: parsed.fetch( :check_name ),
877
+ json_output: parsed.fetch( :json, false )
878
+ )
777
879
  when "review:gate"
778
880
  runtime.review_gate!
779
881
  when "review:sweep"
@@ -785,7 +887,16 @@ module Carson
785
887
  when "housekeep:target"
786
888
  runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
787
889
  when "housekeep:all"
788
- runtime.housekeep_all!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
890
+ loop_seconds = parsed.fetch( :loop_seconds, nil )
891
+ if loop_seconds
892
+ runtime.housekeep_loop!(
893
+ json_output: parsed.fetch( :json, false ),
894
+ dry_run: parsed.fetch( :dry_run, false ),
895
+ loop_seconds: loop_seconds
896
+ )
897
+ else
898
+ runtime.housekeep_all!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
899
+ end
789
900
  when "govern"
790
901
  runtime.govern!(
791
902
  dry_run: parsed.fetch( :dry_run, false ),
data/lib/carson/config.rb CHANGED
@@ -30,7 +30,7 @@ module Carson
30
30
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
31
31
  :audit_advisory_check_names,
32
32
  :workflow_style,
33
- :govern_repos, :govern_authority, :govern_merge_method,
33
+ :govern_repos, :govern_merge_method,
34
34
  :govern_agent_provider, :govern_state_path,
35
35
  :govern_check_wait
36
36
 
@@ -83,7 +83,6 @@ module Carson
83
83
  },
84
84
  "govern" => {
85
85
  "repos" => [],
86
- "authority" => "remote",
87
86
  "merge" => {
88
87
  "method" => "squash"
89
88
  },
@@ -92,7 +91,7 @@ module Carson
92
91
  "codex" => {},
93
92
  "claude" => {}
94
93
  },
95
- "state_path" => "~/.carson/state.sqlite3",
94
+ "state_path" => "~/.carson/state.json",
96
95
  "check_wait" => 30
97
96
  }
98
97
  }
@@ -171,8 +170,6 @@ module Carson
171
170
  govern = fetch_hash_section( data: copy, key: "govern" )
172
171
  govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
173
172
  govern[ "repos" ] = govern_repos unless govern_repos.empty?
174
- govern_authority = ENV.fetch( "CARSON_GOVERN_AUTHORITY", "" ).to_s.strip
175
- govern[ "authority" ] = govern_authority unless govern_authority.empty?
176
173
  govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
177
174
  unless govern_method.empty?
178
175
  govern[ "merge" ] ||= {}
@@ -243,14 +240,13 @@ module Carson
243
240
 
244
241
  govern_hash = fetch_hash( hash: data, key: "govern" )
245
242
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
246
- @govern_authority = fetch_string( hash: govern_hash, key: "authority" ).downcase
247
243
  govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
248
244
  @govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
249
245
  govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
250
246
  @govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
251
247
  @govern_state_path = resolve_runtime_path(
252
248
  path: govern_hash.fetch( "state_path" ).to_s,
253
- fallback_leaf: "state.sqlite3"
249
+ fallback_leaf: "state.json"
254
250
  )
255
251
  @govern_check_wait = fetch_non_negative_integer( hash: govern_hash, key: "check_wait" )
256
252
 
@@ -272,7 +268,6 @@ module Carson
272
268
  raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
273
269
  raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
274
270
  raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
275
- raise ConfigError, "govern.authority must be one of remote, local" unless [ "remote", "local" ].include?( govern_authority )
276
271
  raise ConfigError, "govern.merge.method must be squash" unless govern_merge_method == "squash"
277
272
  raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
278
273
  end
@@ -1,4 +1,4 @@
1
- # Passive ledger record for one branch-to-authority delivery attempt.
1
+ # Passive ledger record for one branch delivery attempt.
2
2
  module Carson
3
3
  class Delivery
4
4
  ACTIVE_STATES = %w[preparing gated queued integrating escalated].freeze
@@ -6,25 +6,25 @@ module Carson
6
6
  READY_STATES = %w[queued].freeze
7
7
  TERMINAL_STATES = %w[integrated failed superseded].freeze
8
8
 
9
- attr_reader :id, :repository, :branch, :head, :worktree_path, :authority, :status,
10
- :pull_request_number, :pull_request_url, :revision_count, :cause, :summary,
9
+ attr_reader :repo_path, :repository, :branch, :head, :worktree_path, :status,
10
+ :pull_request_number, :pull_request_url, :revisions, :cause, :summary,
11
11
  :created_at, :updated_at, :integrated_at, :superseded_at
12
12
 
13
13
  def initialize(
14
- id:, repository:, branch:, head:, worktree_path:, authority:, status:,
15
- pull_request_number:, pull_request_url:, revision_count:, cause:, summary:,
16
- created_at:, updated_at:, integrated_at:, superseded_at:
14
+ repo_path:, branch:, head:, worktree_path:, status:,
15
+ pull_request_number:, pull_request_url:, cause:, summary:,
16
+ created_at:, updated_at:, integrated_at:, superseded_at:,
17
+ revisions: [], repository: nil
17
18
  )
18
- @id = id
19
+ @repo_path = repo_path
19
20
  @repository = repository
20
21
  @branch = branch
21
22
  @head = head
22
23
  @worktree_path = worktree_path
23
- @authority = authority
24
24
  @status = status
25
25
  @pull_request_number = pull_request_number
26
26
  @pull_request_url = pull_request_url
27
- @revision_count = revision_count
27
+ @revisions = revisions
28
28
  @cause = cause
29
29
  @summary = summary
30
30
  @created_at = created_at
@@ -33,6 +33,14 @@ module Carson
33
33
  @superseded_at = superseded_at
34
34
  end
35
35
 
36
+ def key
37
+ "#{repo_path}:#{branch}:#{head}"
38
+ end
39
+
40
+ def revision_count
41
+ revisions.length
42
+ end
43
+
36
44
  def active?
37
45
  ACTIVE_STATES.include?( status )
38
46
  end