carson 3.30.1 → 3.30.3

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
@@ -1,26 +1,50 @@
1
1
  # Parses command-line arguments and dispatches to Runtime operations.
2
+ require "open3"
2
3
  require "optparse"
3
4
 
4
5
  module Carson
5
6
  class CLI
7
+ PORTFOLIO_COMMANDS = %w[onboard offboard list refresh version].freeze
8
+ REPO_COMMANDS = %w[deliver receive sync status audit prune housekeep worktree abandon recover review template setup].freeze
9
+ ALL_COMMANDS = ( PORTFOLIO_COMMANDS + REPO_COMMANDS ).freeze
10
+
6
11
  def self.start( arguments:, repo_root:, tool_root:, output:, error: )
7
12
  ensure_global_artefacts!( tool_root: tool_root )
8
13
 
9
14
  parsed = parse_args( arguments: arguments, output: output, error: error )
10
15
  command = parsed.fetch( :command )
11
16
  return Runtime::EXIT_OK if command == :help
17
+ return Runtime::EXIT_ERROR if command == :invalid
12
18
 
13
19
  if command == "version"
14
20
  output.puts "#{BADGE} #{Carson::VERSION}"
15
21
  return Runtime::EXIT_OK
16
22
  end
17
23
 
18
- if %w[repos refresh:all prune:all housekeep:all housekeep:target template:check:all audit:all sync:all status:all].include?( command )
19
- verbose = parsed.fetch( :verbose, false )
20
- runtime = Runtime.new( repo_root: repo_root, tool_root: tool_root, output: output, error: error, verbose: verbose )
24
+ verbose = parsed.fetch( :verbose, false )
25
+
26
+ # Portfolio commands no repo resolution needed.
27
+ # onboard/offboard carry a parsed repo_root from their <repo_path> argument;
28
+ # list and refresh:all use the invoking CWD.
29
+ if %w[list refresh:all onboard offboard].include?( command )
30
+ effective_root = parsed.key?( :repo_root ) ? parsed.fetch( :repo_root ) : repo_root
31
+ runtime = Runtime.new( repo_root: effective_root, tool_root: tool_root, output: output, error: error, verbose: verbose )
32
+ return dispatch( parsed: parsed, runtime: runtime )
33
+ end
34
+
35
+ # Repo commands with an explicit repo subject — resolve it.
36
+ if parsed.key?( :repo_subject )
37
+ config = Config.load( repo_root: repo_root )
38
+ resolved = resolve_repo_target( name: parsed.fetch( :repo_subject ), config: config )
39
+ if resolved.nil?
40
+ error.puts "#{BADGE} Not a governed repo: #{parsed.fetch( :repo_subject )}"
41
+ return Runtime::EXIT_ERROR
42
+ end
43
+ runtime = Runtime.new( repo_root: resolved, tool_root: tool_root, output: output, error: error, verbose: verbose )
21
44
  return dispatch( parsed: parsed, runtime: runtime )
22
45
  end
23
46
 
47
+ # Repo commands resolved from CWD.
24
48
  target_repo_root = parsed.fetch( :repo_root, nil )
25
49
  target_repo_root = repo_root if target_repo_root.to_s.strip.empty?
26
50
  unless Dir.exist?( target_repo_root )
@@ -28,8 +52,15 @@ module Carson
28
52
  return Runtime::EXIT_ERROR
29
53
  end
30
54
 
31
- verbose = parsed.fetch( :verbose, false )
32
- runtime = Runtime.new( repo_root: target_repo_root, tool_root: tool_root, output: output, error: error, verbose: verbose )
55
+ config = Config.load( repo_root: target_repo_root )
56
+ resolved = resolve_cwd_repo( repo_root: target_repo_root, config: config )
57
+ unless resolved
58
+ error.puts "#{BADGE} Not inside a governed repo. Use: carson <repo> #{command} or cd into a governed repo."
59
+ error.puts "#{BADGE} Run carson list to see governed repositories."
60
+ return Runtime::EXIT_ERROR
61
+ end
62
+
63
+ runtime = Runtime.new( repo_root: resolved, tool_root: tool_root, output: output, error: error, verbose: verbose, work_dir: target_repo_root )
33
64
  dispatch( parsed: parsed, runtime: runtime )
34
65
  rescue ConfigError => exception
35
66
  error.puts "#{BADGE} Configuration problem: #{exception.message}"
@@ -45,9 +76,51 @@ module Carson
45
76
  preset = parse_preset_command( arguments: arguments, output: output, parser: parser )
46
77
  return preset.merge( verbose: verbose ) unless preset.nil?
47
78
 
48
- command = arguments.shift
49
- result = parse_command( command: command, arguments: arguments, error: error )
50
- result.merge( verbose: verbose )
79
+ # Pre-scan for legacy grammar before OptionParser can reject tokens.
80
+ legacy = detect_legacy_grammar( arguments: arguments )
81
+ if legacy
82
+ error.puts "#{BADGE} #{legacy}"
83
+ return { command: :invalid, verbose: verbose }
84
+ end
85
+
86
+ first = arguments.first
87
+
88
+ # Portfolio command as first token.
89
+ if PORTFOLIO_COMMANDS.include?( first )
90
+ arguments.shift
91
+ result = parse_portfolio_command( command: first, arguments: arguments, error: error )
92
+ return result.merge( verbose: verbose )
93
+ end
94
+
95
+ # Repo command as first token — resolve from CWD.
96
+ if REPO_COMMANDS.include?( first )
97
+ arguments.shift
98
+ result = parse_repo_command( command: first, arguments: arguments, error: error )
99
+ return result.merge( verbose: verbose )
100
+ end
101
+
102
+ # Otherwise: first token is an explicit repo subject, second is the repo command.
103
+ repo_subject = arguments.shift
104
+ repo_command = arguments.shift
105
+
106
+ if repo_command.nil? || repo_command.strip.empty?
107
+ error.puts "#{BADGE} Unknown command: #{repo_subject}. Run carson --help for usage."
108
+ return { command: :invalid, verbose: verbose }
109
+ end
110
+
111
+ # Catch portfolio commands used with a repo subject.
112
+ if PORTFOLIO_COMMANDS.include?( repo_command ) && !REPO_COMMANDS.include?( repo_command )
113
+ error.puts "#{BADGE} #{repo_command} is a portfolio command. Use: carson #{repo_command}"
114
+ return { command: :invalid, verbose: verbose }
115
+ end
116
+
117
+ unless REPO_COMMANDS.include?( repo_command )
118
+ error.puts "#{BADGE} Unknown command: #{repo_command}. Run carson --help for usage."
119
+ return { command: :invalid, verbose: verbose }
120
+ end
121
+
122
+ result = parse_repo_command( command: repo_command, arguments: arguments, error: error )
123
+ result.merge( verbose: verbose, repo_subject: repo_subject )
51
124
  rescue OptionParser::ParseError => exception
52
125
  error.puts "#{BADGE} #{exception.message}"
53
126
  error.puts parser
@@ -56,11 +129,18 @@ module Carson
56
129
 
57
130
  def self.build_parser
58
131
  OptionParser.new do |parser|
59
- parser.banner = "Usage: carson <command> [options]"
132
+ parser.banner = "Usage: carson <command> [options]\n carson <repo> <command> [options]"
60
133
  parser.separator ""
61
134
  parser.separator "Repository governance and workflow automation for coding agents."
62
135
  parser.separator ""
63
- parser.separator "Commands:"
136
+ parser.separator "Portfolio commands:"
137
+ parser.separator " list List governed repositories"
138
+ parser.separator " onboard Register a repository for governance (requires repo path)"
139
+ parser.separator " offboard Remove a repository from governance (requires repo path)"
140
+ parser.separator " refresh Re-install hooks and configuration (all governed repos)"
141
+ parser.separator " version Show Carson version"
142
+ parser.separator ""
143
+ parser.separator "Repository commands (from CWD or with explicit repo):"
64
144
  parser.separator " status Show repository delivery state"
65
145
  parser.separator " setup Initialise Carson configuration"
66
146
  parser.separator " audit Run pre-commit health checks"
@@ -71,14 +151,9 @@ module Carson
71
151
  parser.separator " prune Remove stale local branches"
72
152
  parser.separator " worktree Manage isolated coding worktrees"
73
153
  parser.separator " housekeep Sync, reap worktrees, and prune branches"
74
- parser.separator " repos List governed repositories"
75
- parser.separator " onboard Register a repository for governance"
76
- parser.separator " offboard Remove a repository from governance"
77
- parser.separator " refresh Re-install hooks and configuration"
78
- parser.separator " template Manage canonical template files"
79
154
  parser.separator " review Manage PR review workflow"
80
- parser.separator " govern Portfolio-level PR triage loop"
81
- parser.separator " version Show Carson version"
155
+ parser.separator " template Manage canonical template files"
156
+ parser.separator " receive Triage and advance deliveries for one repo"
82
157
  parser.separator ""
83
158
  parser.separator "Run `carson <command> --help` for details on a specific command."
84
159
  end
@@ -96,46 +171,83 @@ module Carson
96
171
  nil
97
172
  end
98
173
 
99
- def self.parse_command( command:, arguments:, error: )
174
+ # Detects legacy grammar patterns and returns a migration message, or nil.
175
+ def self.detect_legacy_grammar( arguments: )
176
+ tokens = arguments.dup
177
+
178
+ # Check for --all anywhere.
179
+ if tokens.include?( "--all" )
180
+ return "--all has been removed. Use carson list --json to script batch operations."
181
+ end
182
+
183
+ # Check first two tokens for legacy commands.
184
+ first = tokens[0].to_s
185
+ second = tokens[1].to_s
186
+
187
+ if first == "govern" || second == "govern"
188
+ return "carson govern has been replaced by carson <repo> receive"
189
+ end
190
+
191
+ if first == "repos" || second == "repos"
192
+ return "carson repos has been replaced by carson list"
193
+ end
194
+
195
+ nil
196
+ end
197
+
198
+ # --- portfolio command routing ---
199
+
200
+ def self.parse_portfolio_command( command:, arguments:, error: )
100
201
  case command
101
202
  when "version"
102
203
  { command: "version" }
103
- when "setup"
104
- parse_setup_command( arguments: arguments, error: error )
204
+ when "list"
205
+ parse_list_command( arguments: arguments, error: error )
105
206
  when "onboard"
106
207
  parse_onboard_command( arguments: arguments, error: error )
107
208
  when "offboard"
108
209
  parse_offboard_command( arguments: arguments, error: error )
109
210
  when "refresh"
110
211
  parse_refresh_command( arguments: arguments, error: error )
111
- when "template"
112
- parse_template_subcommand( arguments: arguments, error: error )
212
+ else
213
+ error.puts "#{BADGE} Unknown portfolio command: #{command}"
214
+ { command: :invalid }
215
+ end
216
+ end
217
+
218
+ # --- repo command routing ---
219
+
220
+ def self.parse_repo_command( command:, arguments:, error: )
221
+ case command
222
+ when "setup"
223
+ parse_setup_command( arguments: arguments, error: error )
224
+ when "deliver"
225
+ parse_deliver_command( arguments: arguments, error: error )
226
+ when "receive"
227
+ parse_receive_command( arguments: arguments, error: error )
228
+ when "sync"
229
+ parse_sync_command( arguments: arguments, error: error )
230
+ when "status"
231
+ parse_status_command( arguments: arguments, error: error )
232
+ when "audit"
233
+ parse_audit_command( arguments: arguments, error: error )
113
234
  when "prune"
114
235
  parse_prune_command( arguments: arguments, error: error )
115
- when "worktree"
116
- parse_worktree_subcommand( arguments: arguments, error: error )
117
- when "repos"
118
- parse_repos_command( arguments: arguments, error: error )
119
236
  when "housekeep"
120
237
  parse_housekeep_command( arguments: arguments, error: error )
121
- when "review"
122
- parse_review_subcommand( arguments: arguments, error: error )
123
- when "audit"
124
- parse_audit_command( arguments: arguments, error: error )
238
+ when "worktree"
239
+ parse_worktree_subcommand( arguments: arguments, error: error )
125
240
  when "abandon"
126
241
  parse_abandon_command( arguments: arguments, error: error )
127
- when "sync"
128
- parse_sync_command( arguments: arguments, error: error )
129
- when "status"
130
- parse_status_command( arguments: arguments, error: error )
131
- when "deliver"
132
- parse_deliver_command( arguments: arguments, error: error )
133
242
  when "recover"
134
243
  parse_recover_command( arguments: arguments, error: error )
135
- when "govern"
136
- parse_govern_subcommand( arguments: arguments, error: error )
244
+ when "review"
245
+ parse_review_subcommand( arguments: arguments, error: error )
246
+ when "template"
247
+ parse_template_subcommand( arguments: arguments, error: error )
137
248
  else
138
- { command: command }
249
+ error.puts "#{BADGE} Unknown repo command: #{command}"
250
+ { command: :invalid }
139
251
  end
140
252
  end
141
253
 
@@ -177,26 +289,28 @@ module Carson
177
289
 
178
290
  def self.parse_onboard_command( arguments:, error: )
179
291
  onboard_parser = OptionParser.new do |parser|
180
- parser.banner = "Usage: carson onboard [REPO_PATH]"
292
+ parser.banner = "Usage: carson onboard <REPO_PATH>"
181
293
  parser.separator ""
182
294
  parser.separator "Register a repository for Carson governance."
183
295
  parser.separator "Detects the remote, installs hooks, applies templates, and runs initial audit."
184
- parser.separator "Defaults to the current directory if no path is given."
185
296
  parser.separator ""
186
297
  parser.separator "Examples:"
187
- parser.separator " carson onboard Onboard the current repository"
188
298
  parser.separator " carson onboard ~/Dev/app Onboard a specific repository"
189
299
  end
190
300
  onboard_parser.parse!( arguments )
301
+ if arguments.empty?
302
+ error.puts "#{BADGE} Missing repo path. Use: carson onboard <repo_path>"
303
+ error.puts onboard_parser
304
+ return { command: :invalid }
305
+ end
191
306
  if arguments.length > 1
192
- error.puts "#{BADGE} Too many arguments for onboard. Use: carson onboard [repo_path]"
307
+ error.puts "#{BADGE} Too many arguments for onboard. Use: carson onboard <repo_path>"
193
308
  error.puts onboard_parser
194
309
  return { command: :invalid }
195
310
  end
196
- repo_path = arguments.first
197
311
  {
198
312
  command: "onboard",
199
- repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
313
+ repo_root: File.expand_path( arguments.first )
200
314
  }
201
315
  rescue OptionParser::ParseError => exception
202
316
  error.puts "#{BADGE} #{exception.message}"
@@ -206,25 +320,28 @@ module Carson
206
320
 
207
321
  def self.parse_offboard_command( arguments:, error: )
208
322
  offboard_parser = OptionParser.new do |parser|
209
- parser.banner = "Usage: carson offboard [REPO_PATH]"
323
+ parser.banner = "Usage: carson offboard <REPO_PATH>"
210
324
  parser.separator ""
211
325
  parser.separator "Remove a repository from Carson governance."
212
326
  parser.separator "Unregisters the repo from Carson's portfolio and removes hooks."
213
- parser.separator "Defaults to the current directory if no path is given."
214
327
  parser.separator ""
215
328
  parser.separator "Examples:"
216
- parser.separator " carson offboard Offboard the current repository"
329
+ parser.separator " carson offboard ~/Dev/app Offboard a specific repository"
217
330
  end
218
331
  offboard_parser.parse!( arguments )
332
+ if arguments.empty?
333
+ error.puts "#{BADGE} Missing repo path. Use: carson offboard <repo_path>"
334
+ error.puts offboard_parser
335
+ return { command: :invalid }
336
+ end
219
337
  if arguments.length > 1
220
- error.puts "#{BADGE} Too many arguments for offboard. Use: carson offboard [repo_path]"
338
+ error.puts "#{BADGE} Too many arguments for offboard. Use: carson offboard <repo_path>"
221
339
  error.puts offboard_parser
222
340
  return { command: :invalid }
223
341
  end
224
- repo_path = arguments.first
225
342
  {
226
343
  command: "offboard",
227
- repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
344
+ repo_root: File.expand_path( arguments.first )
228
345
  }
229
346
  rescue OptionParser::ParseError => exception
230
347
  error.puts "#{BADGE} #{exception.message}"
@@ -232,71 +349,123 @@ module Carson
232
349
  { command: :invalid }
233
350
  end
234
351
 
352
+ # --- list ---
353
+
354
+ def self.parse_list_command( arguments:, error: )
355
+ options = { json: false }
356
+ list_parser = OptionParser.new do |parser|
357
+ parser.banner = "Usage: carson list [--json]"
358
+ parser.separator ""
359
+ parser.separator "List all repositories governed by Carson."
360
+ parser.separator "Shows the portfolio of repos registered via carson onboard."
361
+ parser.separator ""
362
+ parser.separator "Options:"
363
+ parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
364
+ parser.separator ""
365
+ parser.separator "Examples:"
366
+ parser.separator " carson list List governed repositories"
367
+ parser.separator " carson list --json Structured output for agent consumption"
368
+ end
369
+ list_parser.parse!( arguments )
370
+ unless arguments.empty?
371
+ error.puts "#{BADGE} Unexpected arguments for list: #{arguments.join( ' ' )}"
372
+ return { command: :invalid }
373
+ end
374
+ { command: "list", json: options[ :json ] }
375
+ rescue OptionParser::ParseError => exception
376
+ error.puts "#{BADGE} #{exception.message}"
377
+ error.puts list_parser
378
+ { command: :invalid }
379
+ end
380
+
235
381
  # --- refresh ---
236
382
 
237
383
  def self.parse_refresh_command( arguments:, error: )
238
- options = { all: false }
239
384
  refresh_parser = OptionParser.new do |parser|
240
- parser.banner = "Usage: carson refresh [--all] [REPO_PATH]"
241
- parser.separator ""
242
- parser.separator "Re-install Carson hooks and configuration for a repository."
243
- parser.separator "Defaults to the current directory. Use --all to refresh all governed repos."
385
+ parser.banner = "Usage: carson refresh"
244
386
  parser.separator ""
245
- parser.separator "Options:"
246
- parser.on( "--all", "Refresh all governed repositories" ) { options[ :all ] = true }
387
+ parser.separator "Re-install Carson hooks and configuration for all governed repositories."
247
388
  parser.separator ""
248
389
  parser.separator "Examples:"
249
- parser.separator " carson refresh Refresh the current repository"
250
- parser.separator " carson refresh --all Refresh all governed repos"
390
+ parser.separator " carson refresh Refresh all governed repos"
251
391
  end
252
392
  refresh_parser.parse!( arguments )
253
-
254
- if options[ :all ] && !arguments.empty?
255
- error.puts "#{BADGE} --all and repo_path are mutually exclusive. Use: carson refresh --all OR carson refresh [repo_path]"
393
+ unless arguments.empty?
394
+ error.puts "#{BADGE} Unexpected arguments for refresh: #{arguments.join( ' ' )}"
256
395
  error.puts refresh_parser
257
396
  return { command: :invalid }
258
397
  end
398
+ { command: "refresh:all" }
399
+ rescue OptionParser::ParseError => exception
400
+ error.puts "#{BADGE} #{exception.message}"
401
+ error.puts refresh_parser
402
+ { command: :invalid }
403
+ end
259
404
 
260
- return { command: "refresh:all" } if options[ :all ]
405
+ # --- receive ---
261
406
 
262
- if arguments.length > 1
263
- error.puts "#{BADGE} Too many arguments for refresh. Use: carson refresh [repo_path]"
264
- error.puts refresh_parser
407
+ def self.parse_receive_command( arguments:, error: )
408
+ options = {
409
+ dry_run: false,
410
+ json: false,
411
+ loop_seconds: nil
412
+ }
413
+ receive_parser = OptionParser.new do |parser|
414
+ parser.banner = "Usage: carson <repo> receive [--dry-run] [--json] [--loop SECONDS]"
415
+ parser.separator ""
416
+ parser.separator "Triage and advance deliveries for one repository."
417
+ parser.separator "Scans open PRs, classifies them, and takes action"
418
+ parser.separator "(merge, request review, or report). Runs once by default."
419
+ parser.separator ""
420
+ parser.separator "Options:"
421
+ parser.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { options[ :dry_run ] = true }
422
+ parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
423
+ parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |seconds|
424
+ error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
425
+ options[ :loop_seconds ] = seconds
426
+ end
427
+ parser.separator ""
428
+ parser.separator "Examples:"
429
+ parser.separator " carson nexus receive Triage deliveries for nexus"
430
+ parser.separator " carson nexus receive --dry-run Preview actions without applying them"
431
+ parser.separator " carson nexus receive --loop 300 Run continuously every 5 minutes"
432
+ end
433
+ receive_parser.parse!( arguments )
434
+ unless arguments.empty?
435
+ error.puts "#{BADGE} Unexpected arguments for receive: #{arguments.join( ' ' )}"
436
+ error.puts receive_parser
265
437
  return { command: :invalid }
266
438
  end
267
-
268
- repo_path = arguments.first
269
439
  {
270
- command: "refresh",
271
- repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
440
+ command: "receive",
441
+ dry_run: options.fetch( :dry_run ),
442
+ json: options.fetch( :json ),
443
+ loop_seconds: options[ :loop_seconds ]
272
444
  }
273
445
  rescue OptionParser::ParseError => exception
274
446
  error.puts "#{BADGE} #{exception.message}"
275
- error.puts refresh_parser
447
+ error.puts receive_parser
276
448
  { command: :invalid }
277
449
  end
278
450
 
279
451
  # --- prune ---
280
452
 
281
453
  def self.parse_prune_command( arguments:, error: )
282
- options = { all: false, json: false }
454
+ options = { json: false }
283
455
  prune_parser = OptionParser.new do |parser|
284
- parser.banner = "Usage: carson prune [--all] [--json]"
456
+ parser.banner = "Usage: carson prune [--json]"
285
457
  parser.separator ""
286
458
  parser.separator "Remove stale local branches."
287
459
  parser.separator "Cleans up branches gone from the remote, orphan branches with merged PRs,"
288
460
  parser.separator "and absorbed branches whose content is already on main."
289
461
  parser.separator ""
290
462
  parser.separator "Options:"
291
- parser.on( "--all", "Prune all governed repositories" ) { options[ :all ] = true }
292
463
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
293
464
  parser.separator ""
294
465
  parser.separator "Examples:"
295
466
  parser.separator " carson prune Clean up stale branches in this repo"
296
- parser.separator " carson prune --all Clean up across all governed repos"
297
467
  end
298
468
  prune_parser.parse!( arguments )
299
- return { command: "prune:all", json: options[ :json ] } if options[ :all ]
300
469
  { command: "prune", json: options[ :json ] }
301
470
  rescue OptionParser::ParseError => exception
302
471
  error.puts "#{BADGE} #{exception.message}"
@@ -427,7 +596,6 @@ module Carson
427
596
  end
428
597
 
429
598
  action = arguments.shift
430
- return { command: "template:check:all" } if action == "check" && arguments.include?( "--all" )
431
599
  return { command: "template:#{action}" } unless action == "apply"
432
600
 
433
601
  options = { push_prep: false }
@@ -458,29 +626,26 @@ module Carson
458
626
  # --- audit ---
459
627
 
460
628
  def self.parse_audit_command( arguments:, error: )
461
- options = { json: false, all: false }
629
+ options = { json: false }
462
630
  audit_parser = OptionParser.new do |parser|
463
- parser.banner = "Usage: carson audit [--all] [--json]"
631
+ parser.banner = "Usage: carson audit [--json]"
464
632
  parser.separator ""
465
633
  parser.separator "Run pre-commit health checks on the repository."
466
634
  parser.separator "Validates hooks, main-branch sync, PR status, and CI baseline."
467
635
  parser.separator "Exits with a non-zero status when policy violations are found."
468
636
  parser.separator ""
469
637
  parser.separator "Options:"
470
- parser.on( "--all", "Audit all governed repositories" ) { options[ :all ] = true }
471
638
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
472
639
  parser.separator ""
473
640
  parser.separator "Examples:"
474
641
  parser.separator " carson audit Check repository health (also the default command)"
475
642
  parser.separator " carson audit --json Structured output for agent consumption"
476
- parser.separator " carson audit --all Audit all governed repos"
477
643
  end
478
644
  audit_parser.parse!( arguments )
479
645
  unless arguments.empty?
480
646
  error.puts "#{BADGE} Unexpected arguments for audit: #{arguments.join( ' ' )}"
481
647
  return { command: :invalid }
482
648
  end
483
- return { command: "audit:all" } if options[ :all ]
484
649
  { command: "audit", json: options[ :json ] }
485
650
  rescue OptionParser::ParseError => exception
486
651
  error.puts "#{BADGE} #{exception.message}"
@@ -523,28 +688,25 @@ module Carson
523
688
  # --- sync ---
524
689
 
525
690
  def self.parse_sync_command( arguments:, error: )
526
- options = { json: false, all: false }
691
+ options = { json: false }
527
692
  sync_parser = OptionParser.new do |parser|
528
- parser.banner = "Usage: carson sync [--all] [--json]"
693
+ parser.banner = "Usage: carson sync [--json]"
529
694
  parser.separator ""
530
695
  parser.separator "Sync the local main branch with the remote."
531
696
  parser.separator "Fetches and fast-forwards main without switching branches."
532
697
  parser.separator ""
533
698
  parser.separator "Options:"
534
- parser.on( "--all", "Sync all governed repositories" ) { options[ :all ] = true }
535
699
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
536
700
  parser.separator ""
537
701
  parser.separator "Examples:"
538
702
  parser.separator " carson sync Pull latest changes from remote main"
539
703
  parser.separator " carson sync --json Structured output for agent consumption"
540
- parser.separator " carson sync --all Sync all governed repos"
541
704
  end
542
705
  sync_parser.parse!( arguments )
543
706
  unless arguments.empty?
544
707
  error.puts "#{BADGE} Unexpected arguments for sync: #{arguments.join( ' ' )}"
545
708
  return { command: :invalid }
546
709
  end
547
- return { command: "sync:all" } if options[ :all ]
548
710
  { command: "sync", json: options[ :json ] }
549
711
  rescue OptionParser::ParseError => exception
550
712
  error.puts "#{BADGE} #{exception.message}"
@@ -555,28 +717,25 @@ module Carson
555
717
  # --- status ---
556
718
 
557
719
  def self.parse_status_command( arguments:, error: )
558
- options = { json: false, all: false }
720
+ options = { json: false }
559
721
  status_parser = OptionParser.new do |parser|
560
- parser.banner = "Usage: carson status [--all] [--json]"
722
+ parser.banner = "Usage: carson status [--json]"
561
723
  parser.separator ""
562
724
  parser.separator "Show the current state of the repository."
563
725
  parser.separator "Reports branch, worktrees, open PRs, stale branches, and version."
564
726
  parser.separator ""
565
727
  parser.separator "Options:"
566
- parser.on( "--all", "Show status for all governed repositories" ) { options[ :all ] = true }
567
728
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
568
729
  parser.separator ""
569
730
  parser.separator "Examples:"
570
731
  parser.separator " carson status Quick overview of repository state"
571
732
  parser.separator " carson status --json Structured output for agent consumption"
572
- parser.separator " carson status --all Portfolio-wide status overview"
573
733
  end
574
734
  status_parser.parse!( arguments )
575
735
  unless arguments.empty?
576
736
  error.puts "#{BADGE} Unexpected arguments for status: #{arguments.join( ' ' )}"
577
737
  return { command: :invalid }
578
738
  end
579
- return { command: "status:all", json: options[ :json ] } if options[ :all ]
580
739
  { command: "status", json: options[ :json ] }
581
740
  rescue OptionParser::ParseError => exception
582
741
  error.puts "#{BADGE} #{exception.message}"
@@ -674,83 +833,30 @@ module Carson
674
833
  { command: :invalid }
675
834
  end
676
835
 
677
- # --- repos ---
678
-
679
- def self.parse_repos_command( arguments:, error: )
680
- options = { json: false }
681
- repos_parser = OptionParser.new do |parser|
682
- parser.banner = "Usage: carson repos [--json]"
683
- parser.separator ""
684
- parser.separator "List all repositories governed by Carson."
685
- parser.separator "Shows the portfolio of repos registered via carson onboard."
686
- parser.separator ""
687
- parser.separator "Options:"
688
- parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
689
- parser.separator ""
690
- parser.separator "Examples:"
691
- parser.separator " carson repos List governed repositories"
692
- parser.separator " carson repos --json Structured output for agent consumption"
693
- end
694
- repos_parser.parse!( arguments )
695
- unless arguments.empty?
696
- error.puts "#{BADGE} Unexpected arguments for repos: #{arguments.join( ' ' )}"
697
- return { command: :invalid }
698
- end
699
- { command: "repos", json: options[ :json ] }
700
- rescue OptionParser::ParseError => exception
701
- error.puts "#{BADGE} #{exception.message}"
702
- error.puts repos_parser
703
- { command: :invalid }
704
- end
705
-
706
836
  # --- housekeep ---
707
837
 
708
838
  def self.parse_housekeep_command( arguments:, error: )
709
- options = { all: false, json: false, dry_run: false, loop_seconds: nil }
839
+ options = { json: false, dry_run: false }
710
840
  housekeep_parser = OptionParser.new do |parser|
711
- parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json] [--loop SECONDS]"
841
+ parser.banner = "Usage: carson housekeep [--dry-run] [--json]"
712
842
  parser.separator ""
713
843
  parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
714
- parser.separator "Defaults to the current repository."
844
+ parser.separator "Operates on the current repository (or explicit repo via carson <repo> housekeep)."
715
845
  parser.separator ""
716
846
  parser.separator "Options:"
717
- parser.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
718
847
  parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { options[ :dry_run ] = true }
719
848
  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
724
849
  parser.separator ""
725
850
  parser.separator "Examples:"
726
851
  parser.separator " carson housekeep Housekeep the current repository"
727
852
  parser.separator " carson housekeep --dry-run Preview what housekeep would do"
728
- parser.separator " carson housekeep nexus Housekeep a named governed repo"
729
- parser.separator " carson housekeep --all Housekeep all governed repos"
730
- parser.separator " carson housekeep --all --loop 300 Housekeep every 5 minutes"
731
853
  end
732
854
  housekeep_parser.parse!( arguments )
733
-
734
- if options[ :loop_seconds ] && !options[ :all ]
735
- error.puts "#{BADGE} --loop requires --all"
736
- return { command: :invalid }
737
- end
738
-
739
- if options[ :all ] && !arguments.empty?
740
- error.puts "#{BADGE} --all and repo target are mutually exclusive. Use: carson housekeep --all OR carson housekeep [repo]"
741
- return { command: :invalid }
742
- end
743
-
744
- return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ], loop_seconds: options[ :loop_seconds ] } if options[ :all ]
745
-
746
- if arguments.length > 1
747
- error.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
855
+ unless arguments.empty?
856
+ error.puts "#{BADGE} Unexpected arguments for housekeep: #{arguments.join( ' ' )}"
857
+ error.puts housekeep_parser
748
858
  return { command: :invalid }
749
859
  end
750
-
751
- target = arguments.shift
752
- return { command: "housekeep:target", target: target, json: options[ :json ], dry_run: options[ :dry_run ] } if target
753
-
754
860
  { command: "housekeep", json: options[ :json ], dry_run: options[ :dry_run ] }
755
861
  rescue OptionParser::ParseError => exception
756
862
  error.puts "#{BADGE} #{exception.message}"
@@ -758,50 +864,42 @@ module Carson
758
864
  { command: :invalid }
759
865
  end
760
866
 
761
- # --- govern ---
867
+ # --- repo resolution (CLI layer) ---
762
868
 
763
- def self.parse_govern_subcommand( arguments:, error: )
764
- options = {
765
- dry_run: false,
766
- json: false,
767
- loop_seconds: nil
768
- }
769
- govern_parser = OptionParser.new do |parser|
770
- parser.banner = "Usage: carson govern [--dry-run] [--json] [--loop SECONDS]"
771
- parser.separator ""
772
- parser.separator "Portfolio-level PR triage loop."
773
- parser.separator "Scans governed repositories, classifies open PRs, and takes action"
774
- parser.separator "(merge, request review, or report). Runs once by default."
775
- parser.separator ""
776
- parser.separator "Options:"
777
- parser.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { options[ :dry_run ] = true }
778
- parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
779
- parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |seconds|
780
- error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
781
- options[ :loop_seconds ] = seconds
782
- end
783
- parser.separator ""
784
- parser.separator "Examples:"
785
- parser.separator " carson govern Triage all governed repos once"
786
- parser.separator " carson govern --dry-run Preview actions without applying them"
787
- parser.separator " carson govern --loop 300 Run continuously every 5 minutes"
869
+ # Resolves an explicit repo name/path to a governed repository path.
870
+ # Tries exact configured path first, then basename match (case-insensitive).
871
+ def self.resolve_repo_target( name:, config: )
872
+ repos = config.govern_repos
873
+ expanded = File.expand_path( name )
874
+ return expanded if repos.include?( expanded )
875
+
876
+ downcased = File.basename( name ).downcase
877
+ repos.find { |repo_path| File.basename( repo_path ).downcase == downcased }
878
+ end
879
+
880
+ # Resolves the CWD repo_root to a governed repository path.
881
+ # Canonicalises worktree vs main-tree via git common-dir, then matches.
882
+ # Compares real paths to handle symlinks (e.g., /tmp → /private/tmp on macOS).
883
+ def self.resolve_cwd_repo( repo_root:, config: )
884
+ canonical = canonicalise_repo_root( repo_root: repo_root )
885
+ repos = config.govern_repos
886
+ repos.find do |repo_path|
887
+ expanded = File.expand_path( repo_path )
888
+ expanded == canonical || ( File.exist?( expanded ) && File.realpath( expanded ) == canonical )
788
889
  end
789
- govern_parser.parse!( arguments )
790
- unless arguments.empty?
791
- error.puts "#{BADGE} Unexpected arguments for govern: #{arguments.join( ' ' )}"
792
- error.puts govern_parser
793
- return { command: :invalid }
890
+ end
891
+
892
+ # Returns the canonical main worktree root for a repo_root.
893
+ # If inside a worktree, follows git-common-dir back to the main tree.
894
+ def self.canonicalise_repo_root( repo_root: )
895
+ stdout, _, status = Open3.capture3( "git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir" )
896
+ if status.success? && !stdout.strip.empty?
897
+ return File.dirname( stdout.strip )
794
898
  end
795
- {
796
- command: "govern",
797
- dry_run: options.fetch( :dry_run ),
798
- json: options.fetch( :json ),
799
- loop_seconds: options[ :loop_seconds ]
800
- }
801
- rescue OptionParser::ParseError => exception
802
- error.puts "#{BADGE} #{exception.message}"
803
- error.puts govern_parser
804
- { command: :invalid }
899
+
900
+ File.expand_path( repo_root )
901
+ rescue StandardError
902
+ File.expand_path( repo_root )
805
903
  end
806
904
 
807
905
  # --- global artefacts ---
@@ -811,7 +909,7 @@ module Carson
811
909
  # referenced by Claude Code's PreToolUse hook. It must exist regardless of
812
910
  # whether `carson refresh` has been run in any governed repo.
813
911
  def self.ensure_global_artefacts!( tool_root: )
814
- source = File.join( tool_root, "hooks", "command-guard" )
912
+ source = File.join( tool_root, "config", ".github", "hooks", "command-guard" )
815
913
  return unless File.file?( source )
816
914
 
817
915
  hooks_base = File.expand_path( "~/.carson/hooks" )
@@ -844,8 +942,6 @@ module Carson
844
942
  runtime.sync!( json_output: parsed.fetch( :json, false ) )
845
943
  when "prune"
846
944
  runtime.prune!( json_output: parsed.fetch( :json, false ) )
847
- when "prune:all"
848
- runtime.prune_all!
849
945
  when "worktree:create"
850
946
  runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
851
947
  when "worktree:list"
@@ -854,8 +950,6 @@ module Carson
854
950
  runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
855
951
  when "onboard"
856
952
  runtime.onboard!
857
- when "refresh"
858
- runtime.refresh!
859
953
  when "refresh:all"
860
954
  runtime.refresh_all!
861
955
  when "offboard"
@@ -880,37 +974,16 @@ module Carson
880
974
  runtime.review_gate!
881
975
  when "review:sweep"
882
976
  runtime.review_sweep!
883
- when "repos"
884
- runtime.repos!( json_output: parsed.fetch( :json, false ) )
885
- when "housekeep"
886
- runtime.housekeep!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
887
- when "housekeep:target"
888
- runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
889
- when "housekeep:all"
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
900
- when "govern"
901
- runtime.govern!(
977
+ when "list"
978
+ runtime.list!( json_output: parsed.fetch( :json, false ) )
979
+ when "receive"
980
+ runtime.receive!(
902
981
  dry_run: parsed.fetch( :dry_run, false ),
903
982
  json_output: parsed.fetch( :json, false ),
904
983
  loop_seconds: parsed.fetch( :loop_seconds, nil )
905
984
  )
906
- when "template:check:all"
907
- runtime.template_check_all!
908
- when "audit:all"
909
- runtime.audit_all!
910
- when "sync:all"
911
- runtime.sync_all!
912
- when "status:all"
913
- runtime.status_all!( json_output: parsed.fetch( :json, false ) )
985
+ when "housekeep"
986
+ runtime.housekeep!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
914
987
  else
915
988
  runtime.send( :puts_line, "Unknown command: #{command}" )
916
989
  Runtime::EXIT_ERROR