carson 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +19 -20
  3. data/MANUAL.md +76 -65
  4. data/README.md +42 -50
  5. data/RELEASE.md +24 -1
  6. data/SKILL.md +1 -1
  7. data/VERSION +1 -1
  8. data/carson.gemspec +3 -4
  9. data/hooks/command-guard +1 -1
  10. data/hooks/pre-push +17 -20
  11. data/lib/carson/adapters/agent.rb +2 -2
  12. data/lib/carson/branch.rb +38 -0
  13. data/lib/carson/cli.rb +45 -30
  14. data/lib/carson/config.rb +80 -29
  15. data/lib/carson/delivery.rb +64 -0
  16. data/lib/carson/ledger.rb +305 -0
  17. data/lib/carson/repository.rb +47 -0
  18. data/lib/carson/revision.rb +30 -0
  19. data/lib/carson/runtime/audit.rb +43 -17
  20. data/lib/carson/runtime/deliver.rb +163 -149
  21. data/lib/carson/runtime/govern.rb +233 -357
  22. data/lib/carson/runtime/housekeep.rb +233 -27
  23. data/lib/carson/runtime/local/onboard.rb +29 -29
  24. data/lib/carson/runtime/local/prune.rb +120 -35
  25. data/lib/carson/runtime/local/sync.rb +29 -7
  26. data/lib/carson/runtime/local/template.rb +30 -12
  27. data/lib/carson/runtime/local/worktree.rb +37 -442
  28. data/lib/carson/runtime/review/gate_support.rb +144 -12
  29. data/lib/carson/runtime/review/sweep_support.rb +2 -2
  30. data/lib/carson/runtime/review/utility.rb +1 -1
  31. data/lib/carson/runtime/review.rb +21 -77
  32. data/lib/carson/runtime/setup.rb +25 -33
  33. data/lib/carson/runtime/status.rb +96 -212
  34. data/lib/carson/runtime.rb +39 -4
  35. data/lib/carson/worktree.rb +497 -0
  36. data/lib/carson.rb +6 -0
  37. metadata +37 -17
  38. data/.github/copilot-instructions.md +0 -1
  39. data/.github/pull_request_template.md +0 -12
  40. data/templates/.github/AGENTS.md +0 -1
  41. data/templates/.github/CLAUDE.md +0 -1
  42. data/templates/.github/carson.md +0 -47
  43. data/templates/.github/copilot-instructions.md +0 -1
  44. data/templates/.github/pull_request_template.md +0 -12
data/carson.gemspec CHANGED
@@ -7,8 +7,8 @@ Gem::Specification.new do |spec|
7
7
  spec.version = Carson::VERSION
8
8
  spec.authors = [ "Hailei Wang", "Codex", "Claude Code" ]
9
9
  spec.email = [ "wanghailei@users.noreply.github.com" ]
10
- spec.summary = "Autonomous repository governance — you write the code, Carson manages everything else."
11
- spec.description = "Carson is a governance runtime that lives outside the repositories it governs — no Carson-owned artefacts in your repo. On every commit, managed hooks enforce centralised lint policy and review gates. At portfolio level, carson govern triages every open PR across your registered repositories: merge what's ready, dispatch coding agents to fix what's failing, escalate what needs human judgement. One command, all your projects, unmanned."
10
+ spec.summary = "Autonomous git strategist and repositories governor — you write the code, Carson manages everything else."
11
+ spec.description = "Carson is an autonomous git strategist and repositories governor that lives outside the repositories it governs — no Carson-owned artefacts in your repo. As strategist, Carson knows when to branch, how to isolate concurrent work, and how to recover from failures. As governor, it enforces review gates, manages templates, and triages every open PR across your portfolio: merge what's ready, dispatch coding agents to fix what's failing, escalate what needs human judgement. One command, all your projects, unmanned."
12
12
  spec.homepage = "https://github.com/wanghailei/carson"
13
13
  spec.license = "PolyForm-Shield-1.0.0"
14
14
  spec.required_ruby_version = ">= 3.4"
@@ -28,9 +28,8 @@ Gem::Specification.new do |spec|
28
28
  spec.bindir = "exe"
29
29
  spec.executables = [ "carson" ]
30
30
  spec.require_paths = [ "lib" ]
31
+ spec.add_dependency "sqlite3", ">= 1.3", "< 3"
31
32
  spec.files = Dir.glob( "{lib,exe,templates,hooks}/**/*", File::FNM_DOTMATCH ).select { |path| File.file?( path ) } + [
32
- ".github/copilot-instructions.md",
33
- ".github/pull_request_template.md",
34
33
  ".github/workflows/carson_policy.yml",
35
34
  "README.md",
36
35
  "MANUAL.md",
data/hooks/command-guard CHANGED
@@ -53,6 +53,6 @@ if ! grep -qF "\"$normalised\"" "$config_file" 2>/dev/null; then
53
53
  fi
54
54
 
55
55
  # This is a raw gh pr command in a governed repo — block it.
56
- echo "BLOCKED: raw \`gh pr create/merge\` in a Carson-governed repository." >&2
56
+ echo "This repo is Carson-governed — use \`carson deliver\` instead of raw \`gh pr\`." >&2
57
57
  echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
58
58
  exit 2
data/hooks/pre-push CHANGED
@@ -4,9 +4,9 @@
4
4
  # Guards:
5
5
  # 1. Blocks direct push to main/master refs.
6
6
  # 2. Blocks raw git push in governed repos — agents must use `carson deliver`.
7
- # Carson sets CARSON_PUSH=1 internally so its own pushes pass through.
7
+ # Carson bypasses via --no-verify internally. No env-var signal to spoof.
8
8
  #
9
- # Bypass (emergency only): git push --no-verify
9
+ # Bypass: git push --no-verify
10
10
  set -euo pipefail
11
11
 
12
12
  hooks_dir="$(cd "$(dirname "$0")" && pwd)"
@@ -21,9 +21,9 @@ has_commit_push=false
21
21
  while read -r local_ref local_sha remote_ref remote_sha; do
22
22
  case "$remote_ref" in
23
23
  refs/heads/main|refs/heads/master)
24
- echo "BLOCKED: direct push to ${remote_ref#refs/heads/} is not allowed." >&2
24
+ echo "Pushes to ${remote_ref#refs/heads/} go through PRs, not direct push." >&2
25
25
  echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
26
- echo "Bypass (emergency only): git push --no-verify" >&2
26
+ echo "Bypass: git push --no-verify" >&2
27
27
  exit 1
28
28
  ;;
29
29
  esac
@@ -31,23 +31,20 @@ while read -r local_ref local_sha remote_ref remote_sha; do
31
31
  done
32
32
 
33
33
  # --- Guard 2: block raw git push in governed repos ---
34
- # Carson sets CARSON_PUSH=1 when pushing internally via `deliver`.
35
- # If the variable is absent, this is a raw git push from an agent or human.
34
+ # All pushes in governed repos are blocked unconditionally.
35
+ # Carson bypasses this hook via --no-verify when pushing internally.
36
+ # No env-var bypass — cannot be spoofed.
36
37
 
37
- if [[ -z "${CARSON_PUSH:-}" ]]; then
38
- config_file="${HOME}/.carson/config.json"
39
- if [[ -f "$config_file" ]]; then
40
- repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
41
- if [[ -n "$repo_root" ]]; then
42
- # Check if this repo appears in govern.repos.
43
- # Uses a simple grep against the JSON no jq dependency required.
44
- normalised="$(cd "$repo_root" && pwd -P)"
45
- if grep -qF "\"$normalised\"" "$config_file" 2>/dev/null; then
46
- echo "BLOCKED: raw \`git push\` in a Carson-governed repository." >&2
47
- echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
48
- echo "Bypass (emergency only): git push --no-verify" >&2
49
- exit 1
50
- fi
38
+ config_file="${HOME}/.carson/config.json"
39
+ if [[ -f "$config_file" ]]; then
40
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
41
+ if [[ -n "$repo_root" ]]; then
42
+ normalised="$(cd "$repo_root" && pwd -P)"
43
+ if grep -qF "\"$normalised\"" "$config_file" 2>/dev/null; then
44
+ echo "This repo is Carson-governed use \`carson deliver\` instead of raw \`git push\`." >&2
45
+ echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
46
+ echo "Bypass: git push --no-verify" >&2
47
+ exit 1
51
48
  fi
52
49
  fi
53
50
  fi
@@ -2,14 +2,14 @@
2
2
  module Carson
3
3
  module Adapters
4
4
  module Agent
5
- WorkOrder = Data.define( :repo, :branch, :pr_number, :objective, :context, :acceptance_checks )
5
+ WorkOrder = Struct.new( :repo, :branch, :pr_number, :objective, :context, :acceptance_checks, keyword_init: true )
6
6
  # objective: "fix_ci" | "address_review" | "fix_audit"
7
7
  # context: String (legacy — PR title) or Hash with structured evidence:
8
8
  # fix_ci: { title:, ci_logs:, ci_run_url:, prior_attempt: { summary:, dispatched_at: } }
9
9
  # address_review: { title:, review_findings: [{ kind:, url:, body: }], prior_attempt: ... }
10
10
  # acceptance_checks: what must pass for the fix to be accepted
11
11
 
12
- Result = Data.define( :status, :summary, :evidence, :commit_sha )
12
+ Result = Struct.new( :status, :summary, :evidence, :commit_sha, keyword_init: true )
13
13
  # status: "done" | "failed" | "timeout"
14
14
  end
15
15
  end
@@ -0,0 +1,38 @@
1
+ # Passive branch record. Branch holds identity and current facts only.
2
+ module Carson
3
+ class Branch
4
+ attr_reader :repository, :name, :purpose, :head, :worktree, :delivery
5
+
6
+ def initialize( repository:, name:, runtime:, purpose: nil, head: nil, worktree: nil, delivery: nil )
7
+ @repository = repository
8
+ @name = name
9
+ @runtime = runtime
10
+ @purpose = purpose
11
+ @head = head
12
+ @worktree = worktree
13
+ @delivery = delivery
14
+ end
15
+
16
+ # Re-reads the branch facts from git and Carson's ledger.
17
+ def reload
18
+ refreshed_head = runtime.git_capture!( "rev-parse", name ).strip
19
+ refreshed_worktree = runtime.worktree_list.find { |entry| entry.branch == name }&.path || worktree
20
+ refreshed_delivery = runtime.ledger.active_delivery( repo_path: repository.path, branch_name: name )
21
+ self.class.new(
22
+ repository: repository,
23
+ name: name,
24
+ runtime: runtime,
25
+ purpose: purpose,
26
+ head: refreshed_head,
27
+ worktree: refreshed_worktree,
28
+ delivery: refreshed_delivery
29
+ )
30
+ rescue StandardError
31
+ self
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :runtime
37
+ end
38
+ end
data/lib/carson/cli.rb CHANGED
@@ -24,7 +24,7 @@ module Carson
24
24
  target_repo_root = parsed.fetch( :repo_root, nil )
25
25
  target_repo_root = repo_root if target_repo_root.to_s.strip.empty?
26
26
  unless Dir.exist?( target_repo_root )
27
- error.puts "#{BADGE} ERROR: repository path does not exist: #{target_repo_root}"
27
+ error.puts "#{BADGE} Repository path not found: #{target_repo_root}"
28
28
  return Runtime::EXIT_ERROR
29
29
  end
30
30
 
@@ -32,10 +32,10 @@ module Carson
32
32
  runtime = Runtime.new( repo_root: target_repo_root, tool_root: tool_root, output: output, error: error, verbose: verbose )
33
33
  dispatch( parsed: parsed, runtime: runtime )
34
34
  rescue ConfigError => exception
35
- error.puts "#{BADGE} CONFIG ERROR: #{exception.message}"
35
+ error.puts "#{BADGE} Configuration problem: #{exception.message}"
36
36
  Runtime::EXIT_ERROR
37
37
  rescue StandardError => exception
38
- error.puts "#{BADGE} ERROR: #{exception.message}"
38
+ error.puts "#{BADGE} #{exception.message}"
39
39
  Runtime::EXIT_ERROR
40
40
  end
41
41
 
@@ -61,11 +61,11 @@ module Carson
61
61
  parser.separator "Repository governance and workflow automation for coding agents."
62
62
  parser.separator ""
63
63
  parser.separator "Commands:"
64
- parser.separator " status Show repository state (branch, PRs, worktrees)"
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
67
  parser.separator " sync Sync local main with remote"
68
- parser.separator " deliver Push, create PR, and optionally merge"
68
+ parser.separator " deliver Start autonomous branch delivery"
69
69
  parser.separator " prune Remove stale local branches"
70
70
  parser.separator " worktree Manage isolated coding worktrees"
71
71
  parser.separator " housekeep Sync, reap worktrees, and prune branches"
@@ -138,7 +138,7 @@ module Carson
138
138
  def self.parse_setup_command( arguments:, error: )
139
139
  options = {}
140
140
  setup_parser = OptionParser.new do |parser|
141
- parser.banner = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]"
141
+ parser.banner = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--canonical PATH]"
142
142
  parser.separator ""
143
143
  parser.separator "Initialise Carson configuration for the current repository."
144
144
  parser.separator "Detects git remote, main branch, and workflow style, then writes .carson.yml."
@@ -148,13 +148,11 @@ module Carson
148
148
  parser.on( "--remote NAME", "Git remote name" ) { |value| options[ "git.remote" ] = value }
149
149
  parser.on( "--main-branch NAME", "Main branch name" ) { |value| options[ "git.main_branch" ] = value }
150
150
  parser.on( "--workflow STYLE", "Workflow style (branch or trunk)" ) { |value| options[ "workflow.style" ] = value }
151
- parser.on( "--merge METHOD", "Merge method (squash, rebase, or merge)" ) { |value| options[ "govern.merge.method" ] = value }
152
- parser.on( "--canonical PATH", "Canonical template directory path" ) { |value| options[ "template.canonical" ] = value }
151
+ parser.on( "--canonical PATH", "Canonical lint policy directory path" ) { |value| options[ "lint.canonical" ] = value }
153
152
  parser.separator ""
154
153
  parser.separator "Examples:"
155
154
  parser.separator " carson setup Auto-detect and write config"
156
155
  parser.separator " carson setup --remote github Use 'github' as the git remote"
157
- parser.separator " carson setup --merge squash Set squash as the merge method"
158
156
  end
159
157
  setup_parser.parse!( arguments )
160
158
  unless arguments.empty?
@@ -165,6 +163,7 @@ module Carson
165
163
  { command: "setup", cli_choices: options }
166
164
  rescue OptionParser::ParseError => exception
167
165
  error.puts "#{BADGE} #{exception.message}"
166
+ error.puts setup_parser
168
167
  { command: :invalid }
169
168
  end
170
169
 
@@ -195,6 +194,7 @@ module Carson
195
194
  }
196
195
  rescue OptionParser::ParseError => exception
197
196
  error.puts "#{BADGE} #{exception.message}"
197
+ error.puts onboard_parser
198
198
  { command: :invalid }
199
199
  end
200
200
 
@@ -222,6 +222,7 @@ module Carson
222
222
  }
223
223
  rescue OptionParser::ParseError => exception
224
224
  error.puts "#{BADGE} #{exception.message}"
225
+ error.puts offboard_parser
225
226
  { command: :invalid }
226
227
  end
227
228
 
@@ -265,6 +266,7 @@ module Carson
265
266
  }
266
267
  rescue OptionParser::ParseError => exception
267
268
  error.puts "#{BADGE} #{exception.message}"
269
+ error.puts refresh_parser
268
270
  { command: :invalid }
269
271
  end
270
272
 
@@ -292,6 +294,7 @@ module Carson
292
294
  { command: "prune", json: options[ :json ] }
293
295
  rescue OptionParser::ParseError => exception
294
296
  error.puts "#{BADGE} #{exception.message}"
297
+ error.puts prune_parser
295
298
  { command: :invalid }
296
299
  end
297
300
 
@@ -348,6 +351,7 @@ module Carson
348
351
  end
349
352
  rescue OptionParser::ParseError => exception
350
353
  error.puts "#{BADGE} #{exception.message}"
354
+ error.puts worktree_parser
351
355
  { command: :invalid }
352
356
  end
353
357
 
@@ -378,6 +382,7 @@ module Carson
378
382
  { command: "review:#{action}" }
379
383
  rescue OptionParser::ParseError => exception
380
384
  error.puts "#{BADGE} #{exception.message}"
385
+ error.puts review_parser
381
386
  { command: :invalid }
382
387
  end
383
388
 
@@ -436,6 +441,7 @@ module Carson
436
441
  { command: "template:apply", push_prep: options.fetch( :push_prep ) }
437
442
  rescue OptionParser::ParseError => exception
438
443
  error.puts "#{BADGE} #{exception.message}"
444
+ error.puts( apply_parser || template_parser )
439
445
  { command: :invalid }
440
446
  end
441
447
 
@@ -468,6 +474,7 @@ module Carson
468
474
  { command: "audit", json: options[ :json ] }
469
475
  rescue OptionParser::ParseError => exception
470
476
  error.puts "#{BADGE} #{exception.message}"
477
+ error.puts audit_parser
471
478
  { command: :invalid }
472
479
  end
473
480
 
@@ -499,6 +506,7 @@ module Carson
499
506
  { command: "sync", json: options[ :json ] }
500
507
  rescue OptionParser::ParseError => exception
501
508
  error.puts "#{BADGE} #{exception.message}"
509
+ error.puts sync_parser
502
510
  { command: :invalid }
503
511
  end
504
512
 
@@ -530,28 +538,32 @@ module Carson
530
538
  { command: "status", json: options[ :json ] }
531
539
  rescue OptionParser::ParseError => exception
532
540
  error.puts "#{BADGE} #{exception.message}"
541
+ error.puts status_parser
533
542
  { command: :invalid }
534
543
  end
535
544
 
536
545
  # --- deliver ---
537
546
 
538
547
  def self.parse_deliver_command( arguments:, error: )
539
- options = { merge: false, json: false, title: nil, body_file: nil }
548
+ if arguments.include?( "--merge" )
549
+ error.puts "#{BADGE} carson deliver --merge is no longer supported; use carson deliver"
550
+ return { command: :invalid }
551
+ end
552
+
553
+ options = { json: false, title: nil, body_file: nil }
540
554
  deliver_parser = OptionParser.new do |parser|
541
- parser.banner = "Usage: carson deliver [--merge] [--json] [--title TITLE] [--body-file PATH]"
555
+ parser.banner = "Usage: carson deliver [--json] [--title TITLE] [--body-file PATH]"
542
556
  parser.separator ""
543
- parser.separator "Push the current branch, create a pull request, and optionally merge."
544
- parser.separator "Collapses the manual push PR merge flow into a single command."
557
+ parser.separator "Push the current branch, create or refresh the pull request, and hand the branch to Carson."
558
+ parser.separator "Carson records delivery state and continues from there."
545
559
  parser.separator ""
546
560
  parser.separator "Options:"
547
- parser.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
548
561
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
549
562
  parser.on( "--title TITLE", "PR title (defaults to branch name)" ) { |value| options[ :title ] = value }
550
563
  parser.on( "--body-file PATH", "File containing PR body text" ) { |value| options[ :body_file ] = value }
551
564
  parser.separator ""
552
565
  parser.separator "Examples:"
553
- parser.separator " carson deliver Push and open a PR"
554
- parser.separator " carson deliver --merge Push, open a PR, and merge if CI passes"
566
+ parser.separator " carson deliver Push, open a PR, and register delivery state"
555
567
  end
556
568
  deliver_parser.parse!( arguments )
557
569
  unless arguments.empty?
@@ -561,13 +573,13 @@ module Carson
561
573
  end
562
574
  {
563
575
  command: "deliver",
564
- merge: options.fetch( :merge ),
565
576
  json: options.fetch( :json ),
566
577
  title: options[ :title ],
567
578
  body_file: options[ :body_file ]
568
579
  }
569
580
  rescue OptionParser::ParseError => exception
570
581
  error.puts "#{BADGE} #{exception.message}"
582
+ error.puts deliver_parser
571
583
  { command: :invalid }
572
584
  end
573
585
 
@@ -596,27 +608,30 @@ module Carson
596
608
  { command: "repos", json: options[ :json ] }
597
609
  rescue OptionParser::ParseError => exception
598
610
  error.puts "#{BADGE} #{exception.message}"
611
+ error.puts repos_parser
599
612
  { command: :invalid }
600
613
  end
601
614
 
602
615
  # --- housekeep ---
603
616
 
604
617
  def self.parse_housekeep_command( arguments:, error: )
605
- options = { all: false, json: false }
618
+ options = { all: false, json: false, dry_run: false }
606
619
  housekeep_parser = OptionParser.new do |parser|
607
- parser.banner = "Usage: carson housekeep [REPO] [--all] [--json]"
620
+ parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json]"
608
621
  parser.separator ""
609
622
  parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
610
623
  parser.separator "Defaults to the current repository."
611
624
  parser.separator ""
612
625
  parser.separator "Options:"
613
626
  parser.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
627
+ parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { options[ :dry_run ] = true }
614
628
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
615
629
  parser.separator ""
616
630
  parser.separator "Examples:"
617
- parser.separator " carson housekeep Housekeep the current repository"
618
- parser.separator " carson housekeep nexus Housekeep a named governed repo"
619
- parser.separator " carson housekeep --all Housekeep all governed repos"
631
+ parser.separator " carson housekeep Housekeep the current repository"
632
+ parser.separator " carson housekeep --dry-run Preview what housekeep would do"
633
+ parser.separator " carson housekeep nexus Housekeep a named governed repo"
634
+ parser.separator " carson housekeep --all Housekeep all governed repos"
620
635
  end
621
636
  housekeep_parser.parse!( arguments )
622
637
 
@@ -625,7 +640,7 @@ module Carson
625
640
  return { command: :invalid }
626
641
  end
627
642
 
628
- return { command: "housekeep:all", json: options[ :json ] } if options[ :all ]
643
+ return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ] } if options[ :all ]
629
644
 
630
645
  if arguments.length > 1
631
646
  error.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
@@ -633,11 +648,12 @@ module Carson
633
648
  end
634
649
 
635
650
  target = arguments.shift
636
- return { command: "housekeep:target", target: target, json: options[ :json ] } if target
651
+ return { command: "housekeep:target", target: target, json: options[ :json ], dry_run: options[ :dry_run ] } if target
637
652
 
638
- { command: "housekeep", json: options[ :json ] }
653
+ { command: "housekeep", json: options[ :json ], dry_run: options[ :dry_run ] }
639
654
  rescue OptionParser::ParseError => exception
640
655
  error.puts "#{BADGE} #{exception.message}"
656
+ error.puts housekeep_parser
641
657
  { command: :invalid }
642
658
  end
643
659
 
@@ -660,7 +676,7 @@ module Carson
660
676
  parser.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { options[ :dry_run ] = true }
661
677
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
662
678
  parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |seconds|
663
- error.puts( "#{BADGE} Error: --loop must be a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
679
+ error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
664
680
  options[ :loop_seconds ] = seconds
665
681
  end
666
682
  parser.separator ""
@@ -745,7 +761,6 @@ module Carson
745
761
  runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) )
746
762
  when "deliver"
747
763
  runtime.deliver!(
748
- merge: parsed.fetch( :merge, false ),
749
764
  title: parsed.fetch( :title, nil ),
750
765
  body_file: parsed.fetch( :body_file, nil ),
751
766
  json_output: parsed.fetch( :json, false )
@@ -757,11 +772,11 @@ module Carson
757
772
  when "repos"
758
773
  runtime.repos!( json_output: parsed.fetch( :json, false ) )
759
774
  when "housekeep"
760
- runtime.housekeep!( json_output: parsed.fetch( :json, false ) )
775
+ runtime.housekeep!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
761
776
  when "housekeep:target"
762
- runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
777
+ runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
763
778
  when "housekeep:all"
764
- runtime.housekeep_all!( json_output: parsed.fetch( :json, false ) )
779
+ runtime.housekeep_all!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
765
780
  when "govern"
766
781
  runtime.govern!(
767
782
  dry_run: parsed.fetch( :dry_run, false ),
data/lib/carson/config.rb CHANGED
@@ -7,16 +7,31 @@ module Carson
7
7
 
8
8
  # Config is built-in only for outsider mode; host repositories do not carry Carson config files.
9
9
  class Config
10
+ CANONICAL_GITHUB_DIRECTORIES = %w[actions workflows ISSUE_TEMPLATE DISCUSSION_TEMPLATE linters].freeze
11
+ CANONICAL_GITHUB_ROOT_FILES = [
12
+ ".mega-linter.yml",
13
+ "AGENTS.md",
14
+ "CLAUDE.md",
15
+ "CODEOWNERS",
16
+ "FUNDING.yml",
17
+ "carson.md",
18
+ "copilot-instructions.md",
19
+ "dependabot.yml",
20
+ "funding.yml",
21
+ "labeler.yml",
22
+ "pull_request_template.md"
23
+ ].freeze
24
+
10
25
  attr_accessor :git_remote
11
26
  attr_reader :main_branch, :protected_branches, :hooks_path, :managed_hooks,
12
- :template_managed_files, :template_canonical,
27
+ :template_managed_files, :lint_canonical, :template_canonical,
13
28
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
14
29
  :review_sweep_states, :review_disposition, :review_risk_keywords,
15
30
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
16
31
  :audit_advisory_check_names,
17
32
  :workflow_style,
18
- :govern_repos, :govern_auto_merge, :govern_merge_method,
19
- :govern_agent_provider, :govern_dispatch_state_path,
33
+ :govern_repos, :govern_authority, :govern_merge_method,
34
+ :govern_agent_provider, :govern_state_path,
20
35
  :govern_check_wait
21
36
 
22
37
  def self.load( repo_root: )
@@ -38,7 +53,10 @@ module Carson
38
53
  "managed" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
39
54
  },
40
55
  "template" => {
41
- "managed_files" => [ ".github/carson.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md" ],
56
+ "managed_files" => [],
57
+ "canonical" => nil
58
+ },
59
+ "lint" => {
42
60
  "canonical" => nil
43
61
  },
44
62
  "workflow" => {
@@ -65,7 +83,7 @@ module Carson
65
83
  },
66
84
  "govern" => {
67
85
  "repos" => [],
68
- "auto_merge" => true,
86
+ "authority" => "remote",
69
87
  "merge" => {
70
88
  "method" => "squash"
71
89
  },
@@ -74,7 +92,7 @@ module Carson
74
92
  "codex" => {},
75
93
  "claude" => {}
76
94
  },
77
- "dispatch_state_path" => "~/.carson/govern/dispatch_state.json",
95
+ "state_path" => "~/.carson/state.sqlite3",
78
96
  "check_wait" => 30
79
97
  }
80
98
  }
@@ -153,8 +171,8 @@ module Carson
153
171
  govern = fetch_hash_section( data: copy, key: "govern" )
154
172
  govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
155
173
  govern[ "repos" ] = govern_repos unless govern_repos.empty?
156
- govern_auto_merge = ENV.fetch( "CARSON_GOVERN_AUTO_MERGE", "" ).to_s.strip
157
- govern[ "auto_merge" ] = ( govern_auto_merge == "true" ) unless govern_auto_merge.empty?
174
+ govern_authority = ENV.fetch( "CARSON_GOVERN_AUTHORITY", "" ).to_s.strip
175
+ govern[ "authority" ] = govern_authority unless govern_authority.empty?
158
176
  govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
159
177
  unless govern_method.empty?
160
178
  govern[ "merge" ] ||= {}
@@ -191,11 +209,17 @@ module Carson
191
209
  @main_branch = fetch_string( hash: fetch_hash( hash: data, key: "git" ), key: "main_branch" )
192
210
  @protected_branches = fetch_string_array( hash: fetch_hash( hash: data, key: "git" ), key: "protected_branches" )
193
211
 
194
- @hooks_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" )
212
+ @hooks_path = resolve_runtime_path(
213
+ path: fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" ),
214
+ fallback_leaf: "hooks"
215
+ )
195
216
  @managed_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "managed" )
196
217
 
197
- @template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
198
- @template_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "template" ), key: "canonical" )
218
+ template_hash = fetch_hash( hash: data, key: "template" )
219
+ @template_managed_files = fetch_optional_string_array( hash: template_hash, key: "managed_files" )
220
+ @lint_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "lint" ), key: "canonical" )
221
+ @lint_canonical ||= fetch_optional_path( hash: template_hash, key: "canonical" )
222
+ @template_canonical = @lint_canonical
199
223
  resolve_canonical_files!
200
224
 
201
225
  workflow_hash = fetch_hash( hash: data, key: "workflow" )
@@ -219,13 +243,15 @@ module Carson
219
243
 
220
244
  govern_hash = fetch_hash( hash: data, key: "govern" )
221
245
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
222
- @govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
246
+ @govern_authority = fetch_string( hash: govern_hash, key: "authority" ).downcase
223
247
  govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
224
248
  @govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
225
249
  govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
226
250
  @govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
227
- dispatch_path = govern_hash.fetch( "dispatch_state_path" ).to_s
228
- @govern_dispatch_state_path = safe_expand_path( dispatch_path )
251
+ @govern_state_path = resolve_runtime_path(
252
+ path: govern_hash.fetch( "state_path" ).to_s,
253
+ fallback_leaf: "state.sqlite3"
254
+ )
229
255
  @govern_check_wait = fetch_non_negative_integer( hash: govern_hash, key: "check_wait" )
230
256
 
231
257
  validate!
@@ -246,7 +272,8 @@ module Carson
246
272
  raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
247
273
  raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
248
274
  raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
249
- raise ConfigError, "govern.merge.method must be one of merge, squash, rebase" unless [ "merge", "squash", "rebase" ].include?( govern_merge_method )
275
+ raise ConfigError, "govern.authority must be one of remote, local" unless [ "remote", "local" ].include?( govern_authority )
276
+ raise ConfigError, "govern.merge.method must be squash" unless govern_merge_method == "squash"
250
277
  raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
251
278
  end
252
279
 
@@ -298,13 +325,6 @@ module Carson
298
325
  rescue ArgumentError, TypeError
299
326
  raise ConfigError, "config key #{key} must be an integer"
300
327
  end
301
- def fetch_optional_boolean( hash:, key:, default:, key_path: nil )
302
- value = hash.fetch( key, default )
303
- return true if value == true
304
- return false if value == false
305
-
306
- raise ConfigError, "config key #{key_path || key} must be boolean"
307
- end
308
328
 
309
329
  def safe_expand_path( path )
310
330
  return path unless path.start_with?( "~" )
@@ -314,6 +334,23 @@ module Carson
314
334
  path
315
335
  end
316
336
 
337
+ # Resolves Carson-owned runtime paths even when HOME is intentionally invalid.
338
+ # CI smoke uses that condition to verify TMPDIR and /tmp fallbacks.
339
+ def resolve_runtime_path( path:, fallback_leaf: )
340
+ expanded = safe_expand_path( path )
341
+ return expanded unless expanded.start_with?( "~" )
342
+
343
+ File.join( runtime_fallback_root, fallback_leaf )
344
+ end
345
+
346
+ # Shared fallback root for Carson-owned artefacts when HOME cannot be expanded.
347
+ def runtime_fallback_root
348
+ tmpdir = ENV.fetch( "TMPDIR", "" ).to_s.strip
349
+ return File.join( tmpdir, "carson" ) if tmpdir.start_with?( "/" )
350
+
351
+ "/tmp/carson"
352
+ end
353
+
317
354
  # Returns an expanded path string, or nil when the value is absent/blank.
318
355
  def fetch_optional_path( hash:, key: )
319
356
  value = hash[ key ]
@@ -323,18 +360,32 @@ module Carson
323
360
  safe_expand_path( text )
324
361
  end
325
362
 
326
- # Discovers files in the canonical directory and appends them to managed_files.
327
- # Canonical files mirror the .github/ structure and are synced alongside Carson's own governance files.
363
+ # Discovers files in the canonical lint-policy directory and appends them to managed_files.
364
+ # Explicit GitHub paths stay under .github/; flat policy files default to .github/linters/.
328
365
  def resolve_canonical_files!
329
- return if @template_canonical.nil? || @template_canonical.empty?
330
- return unless Dir.exist?( @template_canonical )
366
+ return if @lint_canonical.nil? || @lint_canonical.empty?
367
+ return unless Dir.exist?( @lint_canonical )
331
368
 
332
- Dir.glob( File.join( @template_canonical, "**", "*" ) ).sort.each do |absolute_path|
369
+ Dir.glob( File.join( @lint_canonical, "**", "*" ), File::FNM_DOTMATCH ).sort.each do |absolute_path|
370
+ basename = File.basename( absolute_path )
371
+ next if basename == "." || basename == ".."
333
372
  next unless File.file?( absolute_path )
334
- relative = absolute_path.delete_prefix( "#{@template_canonical}/" )
335
- managed_path = ".github/#{relative}"
373
+ relative = absolute_path.delete_prefix( "#{@lint_canonical}/" )
374
+ managed_path = canonical_managed_path( relative_path: relative )
336
375
  @template_managed_files << managed_path unless @template_managed_files.include?( managed_path )
337
376
  end
338
377
  end
378
+
379
+ def canonical_managed_path( relative_path: )
380
+ raw_relative = relative_path.to_s
381
+ return ".github/#{raw_relative.delete_prefix( '.github/' )}" if raw_relative.start_with?( ".github/" )
382
+
383
+ clean_relative = raw_relative
384
+ first_segment = clean_relative.split( "/", 2 ).first
385
+ return ".github/#{clean_relative}" if CANONICAL_GITHUB_DIRECTORIES.include?( first_segment )
386
+ return ".github/#{clean_relative}" if !clean_relative.include?( "/" ) && CANONICAL_GITHUB_ROOT_FILES.include?( clean_relative )
387
+
388
+ ".github/linters/#{clean_relative}"
389
+ end
339
390
  end
340
391
  end